Files
opencode/specs/file-component-unification-plan.md
2026-02-26 18:23:04 -06:00

427 lines
19 KiB
Markdown

# File Component Unification Plan
Single path for text, diff, and media
---
## Define goal
Introduce one public UI component API that renders plain text files or diffs from the same entry point, so selection, comments, search, theming, and media behavior are maintained once.
### Goal
- Add a unified `File` component in `packages/ui/src/components/file.tsx` that chooses plain or diff rendering from props.
- Centralize shared behavior now split between `packages/ui/src/components/code.tsx` and `packages/ui/src/components/diff.tsx`.
- Bring the existing find/search UX to diff rendering through a shared engine.
- Consolidate media rendering logic currently split across `packages/ui/src/components/session-review.tsx` and `packages/app/src/pages/session/file-tabs.tsx`.
- Provide a clear SSR path for preloaded diffs without keeping a third independent implementation.
### Non-goal
- Do not change `@pierre/diffs` behavior or fork its internals.
- Do not redesign line comment UX, diff visuals, or keyboard shortcuts.
- Do not remove legacy `Code`/`Diff` APIs in the first pass.
- Do not add new media types beyond parity unless explicitly approved.
- Do not refactor unrelated session review or file tab layout code outside integration points.
---
## Audit duplication
The current split duplicates runtime logic and makes feature parity drift likely.
### Duplicate categories
- Rendering lifecycle is duplicated in `code.tsx` and `diff.tsx`, including instance creation, cleanup, `onRendered` readiness, and shadow root lookup.
- Theme sync is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` through similar `applyScheme` and `MutationObserver` code.
- Line selection wiring is duplicated in `code.tsx` and `diff.tsx`, including drag state, shadow selection reads, and line-number bridge integration.
- Comment annotation rerender flow is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx`.
- Commented line marking is split across `markCommentedFileLines` and `markCommentedDiffLines`, with similar timing and effect wiring.
- Diff selection normalization (`fixSelection`) exists twice in `diff.tsx` and `diff-ssr.tsx`.
- Search exists only in `code.tsx`, so diff lacks find and the feature cannot be maintained in one place.
- Contexts are split (`context/code.tsx`, `context/diff.tsx`), which forces consumers to choose paths early.
- Media rendering is duplicated outside the core viewers in `session-review.tsx` and `file-tabs.tsx`.
### Drift pain points
- Any change to comments, theming, or selection requires touching multiple files.
- Diff SSR and client diff can drift because they carry separate normalization and marking code.
- Search cannot be added to diff cleanly without more duplication unless the viewer runtime is unified.
---
## Design architecture
Use one public component with a discriminated prop shape and split shared behavior into small runtime modules.
### Public API proposal
- Add `packages/ui/src/components/file.tsx` as the primary client entry point.
- Export a single `File` component that accepts a discriminated union with two primary modes.
- Use an explicit `mode` prop (`"text"` or `"diff"`) to avoid ambiguous prop inference and keep type errors clear.
### Proposed prop shape
- Shared props:
- `annotations`
- `selectedLines`
- `commentedLines`
- `onLineSelected`
- `onLineSelectionEnd`
- `onLineNumberSelectionEnd`
- `onRendered`
- `class`
- `classList`
- selection and hover flags already supported by current viewers
- Text mode props:
- `mode: "text"`
- `file` (`FileContents`)
- text renderer options from `@pierre/diffs` `FileOptions`
- Diff mode props:
- `mode: "diff"`
- `before`
- `after`
- `diffStyle`
- diff renderer options from `FileDiffOptions`
- optional `preloadedDiff` only for SSR-aware entry or hydration adapter
- Media props (shared, optional):
- `media` config for `"auto" | "off"` behavior
- path/name metadata
- optional lazy loader (`readFile`) for session review use
- optional custom placeholders for binary or removed content
### Internal module split
- `packages/ui/src/components/file.tsx`
Public unified component and mode routing.
- `packages/ui/src/components/file-ssr.tsx`
Unified SSR entry for preloaded diff hydration.
- `packages/ui/src/components/file-search.tsx`
Shared find bar UI and host registration.
- `packages/ui/src/components/file-media.tsx`
Shared image/audio/svg/binary rendering shell.
- `packages/ui/src/pierre/file-runtime.ts`
Common render lifecycle, instance setup, cleanup, scheme sync, and readiness notification.
- `packages/ui/src/pierre/file-selection.ts`
Shared selection/drag/line-number bridge controller with mode adapters.
- `packages/ui/src/pierre/diff-selection.ts`
Diff-specific `fixSelection` and row/side normalization reused by client and SSR.
- `packages/ui/src/pierre/file-find.ts`
Shared find engine (scan, highlight API, overlay fallback, match navigation).
- `packages/ui/src/pierre/media.ts`
MIME normalization, data URL helpers, and media type detection.
### Wrapper strategy
- Keep `packages/ui/src/components/code.tsx` as a thin compatibility wrapper over unified `File` in text mode.
- Keep `packages/ui/src/components/diff.tsx` as a thin compatibility wrapper over unified `File` in diff mode.
- Keep `packages/ui/src/components/diff-ssr.tsx` as a thin compatibility wrapper over unified SSR entry.
---
## Phase delivery
Ship this in small phases so each step is reviewable and reversible.
### Phase 0: Align interfaces
- Document the final prop contract and adapter behavior before moving logic.
- Add a short migration note in the plan PR description so reviewers know wrappers stay in place.
#### Acceptance
- Final prop names and mode shape are agreed up front.
- No runtime code changes land yet.
### Phase 1: Extract shared runtime pieces
- Move duplicated theme sync and render readiness logic from `code.tsx` and `diff.tsx` into shared runtime helpers.
- Move diff selection normalization (`fixSelection` and helpers) out of both `diff.tsx` and `diff-ssr.tsx` into `packages/ui/src/pierre/diff-selection.ts`.
- Extract shared selection controller flow into `packages/ui/src/pierre/file-selection.ts` with mode callbacks for line parsing and normalization.
- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` behavior unchanged from the outside.
#### Acceptance
- `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` are smaller and call shared helpers.
- Line selection, comments, and theme sync still work in current consumers.
- No consumer imports change yet.
### Phase 2: Introduce unified client entry
- Create `packages/ui/src/components/file.tsx` and wire it to shared runtime pieces.
- Route text mode to `@pierre/diffs` `File` or `VirtualizedFile` and diff mode to `FileDiff` or `VirtualizedFileDiff`.
- Preserve current performance rules, including virtualization thresholds and large-diff options.
- Keep search out of this phase if it risks scope creep, but leave extension points in place.
#### Acceptance
- New unified component renders text and diff with parity to existing components.
- `code.tsx` and `diff.tsx` can be rewritten as thin adapters without behavior changes.
- Existing consumers still work through old `Code` and `Diff` exports.
### Phase 3: Add unified context path
- Add `packages/ui/src/context/file.tsx` with `FileComponentProvider` and `useFileComponent`.
- Update `packages/ui/src/context/index.ts` to export the new context.
- Keep `context/code.tsx` and `context/diff.tsx` as compatibility shims that adapt to `useFileComponent`.
- Migrate `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` to provide the unified component once wrappers are stable.
#### Acceptance
- New consumers can use one context path.
- Existing `useCodeComponent` and `useDiffComponent` hooks still resolve and render correctly.
- Provider wiring in app and enterprise stays compatible during transition.
### Phase 4: Share find and enable diff search
- Extract the find engine and find bar UI from `code.tsx` into shared modules.
- Hook the shared find host into unified `File` for both text and diff modes.
- Keep current shortcuts (`Ctrl/Cmd+F`, `Ctrl/Cmd+G`, `Shift+Ctrl/Cmd+G`) and active-host behavior.
- Preserve CSS Highlight API support with overlay fallback.
#### Acceptance
- Text mode search behaves the same as today.
- Diff mode now supports the same find UI and shortcuts.
- Multiple viewer instances still route shortcuts to the focused/active host correctly.
### Phase 5: Consolidate media rendering
- Extract media type detection and data URL helpers from `session-review.tsx` and `file-tabs.tsx` into shared UI helpers.
- Add `file-media.tsx` and let unified `File` optionally render media or binary placeholders before falling back to text/diff.
- Migrate `session-review.tsx` and `file-tabs.tsx` to pass media props instead of owning media-specific branches.
- Keep session-specific layout and i18n strings in the consumer where they are not generic.
#### Acceptance
- Image/audio/svg/binary handling no longer duplicates core detection and load state logic.
- Session review and file tabs still render the same media states and placeholders.
- Text/diff comment and selection behavior is unchanged when media is not shown.
### Phase 6: Align SSR and preloaded diffs
- Create `packages/ui/src/components/file-ssr.tsx` with the same unified prop shape plus `preloadedDiff`.
- Reuse shared diff normalization, theme sync, and commented-line marking helpers.
- Convert `packages/ui/src/components/diff-ssr.tsx` into a thin adapter that forwards to the unified SSR entry in diff mode.
- Migrate enterprise share page imports to `@opencode-ai/ui/file-ssr` when convenient, but keep `diff-ssr` export working.
#### Acceptance
- Preloaded diff hydration still works in `packages/enterprise/src/routes/share/[shareID].tsx`.
- SSR diff and client diff now share normalization and comment marking helpers.
- No duplicate `fixSelection` implementation remains.
### Phase 7: Clean up and document
- Remove dead internal helpers left behind in `code.tsx` and `diff.tsx`.
- Add a short migration doc for downstream consumers that want to switch from `Code`/`Diff` to unified `File`.
- Mark `Code`/`Diff` contexts and components as compatibility APIs in comments or docs.
#### Acceptance
- No stale duplicate helpers remain in legacy wrappers.
- Unified path is the default recommendation for new UI work.
---
## Preserve compatibility
Keep old APIs working while moving internals under them.
### Context migration strategy
- Introduce `FileComponentProvider` without deleting `CodeComponentProvider` or `DiffComponentProvider`.
- Implement `useCodeComponent` and `useDiffComponent` as adapters around the unified context where possible.
- If full adapter reuse is messy at first, keep old contexts and providers as thin wrappers that internally provide mapped unified props.
### Consumer migration targets
- `packages/app/src/pages/session/file-tabs.tsx` should move from `useCodeComponent` to `useFileComponent`.
- `packages/ui/src/components/session-review.tsx`, `session-turn.tsx`, and `message-part.tsx` should move from `useDiffComponent` to `useFileComponent`.
- `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` should eventually provide only the unified provider.
- Keep legacy hooks available until all call sites are migrated and reviewed.
### Compatibility checkpoints
- `@opencode-ai/ui/code`, `@opencode-ai/ui/diff`, and `@opencode-ai/ui/diff-ssr` imports must keep working during migration.
- Existing prop names on `Code` and `Diff` wrappers should remain stable to avoid broad app changes in one PR.
---
## Unify search
Port the current find feature into a shared engine and attach it to both modes.
### Shared engine plan
- Move keyboard host registry and active-target logic out of `code.tsx` into `packages/ui/src/pierre/file-find.ts`.
- Move the find bar UI into `packages/ui/src/components/file-search.tsx`.
- Keep DOM-based scanning and highlight/overlay rendering shared, since both text and diff render into the same shadow-root patterns.
### Diff-specific handling
- Search should scan both unified and split diff columns through the same selectors used in the current code find feature.
- Match navigation should scroll the active range into view without interfering with line selection state.
- Search refresh should run after `onRendered`, diff style changes, annotation rerenders, and query changes.
### Scope guard
- Preserve the current DOM-scan behavior first, even if virtualized search is limited to mounted rows.
- If full-document virtualized search is required, treat it as a follow-up with a text-index layer rather than blocking the core refactor.
---
## Consolidate media
Move media rendering logic into shared UI so text, diff, and media routing live behind one entry.
### Ownership plan
- Put media detection and normalization helpers in `packages/ui/src/pierre/media.ts`.
- Put shared rendering UI in `packages/ui/src/components/file-media.tsx`.
- Keep layout-specific wrappers in `session-review.tsx` and `file-tabs.tsx`, but remove duplicated media branching and load-state code from them.
### Proposed media props
- `media.mode`: `"auto"` or `"off"` for default behavior.
- `media.path`: file path for extension checks and labels.
- `media.current`: loaded file content for plain-file views.
- `media.before` and `media.after`: diff-side values for image/audio previews.
- `media.readFile`: optional lazy loader for session review expansion.
- `media.renderBinaryPlaceholder`: optional consumer override for binary states.
- `media.renderLoading` and `media.renderError`: optional consumer overrides when generic text is not enough.
### Parity targets
- Keep current image and audio support from session review.
- Keep current SVG and binary handling from file tabs.
- Defer video or PDF support unless explicitly requested.
---
## Align SSR
Make SSR diff hydration a mode of the unified viewer instead of a parallel implementation.
### SSR plan
- Add `packages/ui/src/components/file-ssr.tsx` as the unified SSR entry with a diff-only path in phase one.
- Reuse shared diff helpers for `fixSelection`, theme sync, and commented-line marking.
- Keep the private `fileContainer` hydration workaround isolated in the SSR module so client code stays clean.
### Integration plan
- Keep `packages/ui/src/components/diff-ssr.tsx` as a forwarding adapter for compatibility.
- Update enterprise share route to the unified SSR import after client and context migrations are stable.
- Align prop names with the client `File` component so `SessionReview` can swap client/SSR providers without branching logic.
### Defer item
- Plain-file SSR hydration is not needed for this refactor and can stay out of scope.
---
## Verify behavior
Use typechecks and targeted UI checks after each phase, and avoid repo-root runs.
### Typecheck plan
- Run `bun run typecheck` from `packages/ui` after phases 1-7 changes there.
- Run `bun run typecheck` from `packages/app` after migrating file tabs or app provider wiring.
- Run `bun run typecheck` from `packages/enterprise` after SSR/provider changes on the share route.
### Targeted UI checks
- Text mode:
- small file render
- virtualized large file render
- drag selection and line-number selection
- comment annotations and commented-line marks
- find shortcuts and match navigation
- Diff mode:
- unified and split styles
- large diff fallback options
- diff selection normalization across sides
- comments and commented-line marks
- new find UX parity
- Media:
- image, audio, SVG, and binary states in file tabs
- image and audio diff previews in session review
- lazy load and error placeholders
- SSR:
- enterprise share page preloaded diffs hydrate correctly
- theme switching still updates hydrated diffs
### Regression focus
- Watch scroll restore behavior in `packages/app/src/pages/session/file-tabs.tsx`.
- Watch multi-instance find shortcut routing in screens with many viewers.
- Watch cleanup paths for listeners and virtualizers to avoid leaks.
---
## Manage risk
Keep wrappers and adapters in place until the unified path is proven.
### Key risks
- Selection regressions are the highest risk because text and diff have similar but not identical line semantics.
- SSR hydration can break subtly if client and SSR prop shapes drift.
- Shared find host state can misroute shortcuts when many viewers are mounted.
- Media consolidation can accidentally change placeholder timing or load behavior.
### Rollback strategy
- Land each phase in separate PRs or clearly separated commits on `dev`.
- If a phase regresses behavior, revert only that phase and keep earlier extractions.
- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` wrappers intact until final verification, so a rollback only changes internals.
- If diff search is unstable, disable it behind the unified component while keeping the rest of the refactor.
---
## Order implementation
Follow this sequence to keep reviews small and reduce merge risk.
1. Finalize prop shape and file names for the unified component and context.
2. Extract shared diff normalization, theme sync, and render-ready helpers with no public API changes.
3. Extract shared selection controller and migrate `code.tsx` and `diff.tsx` to it.
4. Add the unified client `File` component and convert `code.tsx`/`diff.tsx` into wrappers.
5. Add `FileComponentProvider` and migrate provider wiring in `app.tsx` and enterprise share route.
6. Migrate consumer hooks (`file-tabs`, `session-review`, `message-part`, `session-turn`) to the unified context.
7. Extract and share find engine/UI, then enable search in diff mode.
8. Extract media helpers/UI and migrate `session-review.tsx` and `file-tabs.tsx`.
9. Add unified `file-ssr.tsx`, convert `diff-ssr.tsx` to a wrapper, and migrate enterprise imports.
10. Remove dead duplication and write a short migration note for future consumers.
---
## Decide open items
Resolve these before coding to avoid rework mid-refactor.
### API decisions
- Should the unified component require `mode`, or should it infer mode from props for convenience.
- Should the public export be named `File` only, or also ship a temporary alias like `UnifiedFile` for migration clarity.
- Should `preloadedDiff` live on the main `File` props or only on `file-ssr.tsx`.
### Search decisions
- Is DOM-only search acceptable for virtualized content in the first pass.
- Should find state reset on every rerender, or preserve query and index across diff style toggles.
### Media decisions
- Which placeholders and strings should stay consumer-owned versus shared in UI.
- Whether SVG should be treated as media-only, text-only, or a mixed mode with both preview and source.
- Whether video support should be included now or explicitly deferred.
### Migration decisions
- How long `CodeComponentProvider` and `DiffComponentProvider` should remain supported.
- Whether to migrate all consumers in one PR after wrappers land, or in follow-up PRs by surface area.
- Whether `diff-ssr` should remain as a permanent alias for compatibility.