Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/rendering-diffs-codeview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"diffhub": minor
---

Migrate the diff viewer to the virtualization-first `CodeView` component from
`@pierre/diffs` (upgraded to 1.2.4). The entire change set now renders in a
single virtualized surface that scales to thousands of files, replacing the
per-file `PatchDiff` loop and the hand-rolled scale layer (scroll anchoring,
`min-height` pinning, IntersectionObserver activation, and deferred-render
placeholders) — all of which `CodeView` now owns natively. Comments, split/
unified layout, theming, collapse/expand, and sidebar navigation are preserved.
19 changes: 10 additions & 9 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ cp -r .next/static .next/standalone/apps/cli/.next/static

## Gotchas

**`PatchDiff` only accepts single-file patches.**
`PatchDiff` calls `getSingularPatch()` internally and throws `"FileDiff: Provided patch must contain exactly 1 file diff"` if the patch has >1 file. `DiffApp` currently fetches one file patch at a time, and `DiffViewer` renders a single `SingleFileDiff`. If you ever reintroduce a repo-wide patch path, split it before handing patches to `PatchDiff`.
**The diff view renders one `<CodeView>` for the whole change set.**
`DiffViewer` (`components/DiffViewer.tsx`) builds a controlled `items: CodeViewDiffItem<AnnotationData>[]` from the route's `patchesByFile`, one item per file (`id` = file path), ordered by `sortFilesAsTree`. Each item's `fileDiff` comes from `parsePatchFiles(singleFilePatch)[0].files[0]` (each route value is already a single-file patch), memoized per `(file, patch)` so theme/layout/comment re-renders never reparse unchanged patches. CodeView owns virtualization, scroll anchoring, element pooling, DOM-height management, the worker pool, and the shared `options` object — there is no per-file `PatchDiff`, `SingleFileDiff`, or `CollapsibleFileDiff` anymore. CodeView is loaded via `next/dynamic({ ssr: false })` because it is client-only.

**Comments and the file header ride on CodeView callbacks, not per-file wrappers.**
Comments and the inline comment input are per-item annotations (`DiffLineAnnotation<AnnotationData>` with `{ side, lineNumber, metadata }`) rendered through `renderAnnotation`; the gutter "+" button comes from `renderGutterUtility(getHoveredLine, item)` and sets a single lifted `commentTarget` (`{ file, lineNumber, side }`). The custom `FileDiffHeader` is supplied via `renderCustomHeader(item)` with `disableFileHeader: true` to suppress the built-in header; collapse is driven by `item.collapsed` and toggling calls back to `DiffApp`'s `onToggleCollapse(file)`. `options` (shared across all items) uses `diffStyle: 'split' | 'unified'` (mapped from the `layout` prop), `themeType`, `theme`, `unsafeCSS`, plus the display toggles `disableLineNumbers` / `overflow` / `disableBackground` / `diffIndicators`. Scroll-to-file is imperative: `DiffApp` holds a `DiffViewerHandle` ref whose `scrollToFile(file)` calls the `CodeViewHandle.scrollTo({ type: 'item', id, align: 'start' })`. Active-file tracking is driven by `onScroll`, reading the topmost rendered item via `getRenderedItems()`.

**@pierre/diffs annotations use `metadata`, not `data`.**
`DiffLineAnnotation<T>` has a `metadata` field. Using `data` compiles silently but the annotation never renders.
Expand Down Expand Up @@ -61,13 +64,11 @@ Without it, `lib/git.ts` falls back to `process.cwd()` — the Next.js server di
**Standalone server path mirrors the monorepo workspace.**
Because `outputFileTracingRoot` is the repo root, the standalone server lives at `.next/standalone/apps/cli/server.js`, and static files must be at `.next/standalone/apps/cli/.next/static/`. The CLI handles this automatically.

**Above-viewport DOM heights must not change unaccounted-for.**
The diff view targets Safari, which has no native `overflow-anchor` ([WebKit #171099](https://bugs.webkit.org/show_bug.cgi?id=171099)). Two defences keep the viewport from drifting during scroll:

1. `CollapsibleFileDiff` pins each section's `min-height` after a 200 ms resize-idle window via `ResizeObserver`. Once pinned, internal `@pierre/diffs` resizes (Shiki tokenize, font swap, ResizeManager beats) are absorbed inside the section instead of moving siblings. The pin invalidates when `collapsed` / `commentTarget` / `layout` / `shouldRenderPatch` flip — those are user-initiated and legitimately want growth.
2. `useScrollAnchor` (`lib/use-scroll-anchor.ts`) observes every `[data-file-section]` and adjusts `window.scrollY` by the size delta when a section above the viewport resizes, batching per animation frame.
**Diff syntax themes are user-selectable and resolve lazily.**
`lib/diff-themes.ts` is a client-safe catalog (`DIFF_THEMES`) of the 4 Pierre themes + 65 Shiki bundled themes, plus `DEFAULT_LIGHT_THEME`/`DEFAULT_DARK_THEME` (`github-light`/`github-dark`) and `normalizeDiffThemes`. Regenerate the bundled portion with the `shiki` snippet in the file header. The StatusBar theme picker writes a `{ light, dark }` selection (persisted under `diffhub-diff-theme`), threaded `DiffApp → MainPanel → DiffViewer` into `options.theme`. **No preload step is needed:** `disableWorkerPool` is set, so each render calls `getSharedHighlighter(getHighlighterOptions(lang, options))` (`renderers/DiffHunksRenderer.js`), which lazily resolves any bundled theme id on the main thread via `getResolvedOrResolveTheme` → shiki `bundledThemes[id]`. (`resolveTheme` throws in a worker context, so the worker path would need pre-resolution — but we don't use it here.) Selecting a theme simply re-renders CodeView. Display toggles (`diffhub-display-settings`: backgrounds/line-numbers/word-wrap/indicators) live in `lib/display-settings.ts` and flow the same way.

If you add a new growable element above or inside the diff list, route it through one of these — never mutate above-viewport heights without compensation. IntersectionObserver callbacks in `DiffViewer` must not trigger state changes that affect section height; `active` is now CSS-only.
**CodeView owns scroll anchoring — don't reintroduce the old height machinery.**
The diff view targets Safari, which has no native `overflow-anchor` ([WebKit #171099](https://bugs.webkit.org/show_bug.cgi?id=171099)). The single `<CodeView>` virtualizer handles this internally: it manages each item's reserved DOM height, anchors the scroll position across post-mount resize cascades (Shiki tokenize, font swap, its own ResizeManager beats), and pools rendered elements. The previous hand-rolled defences are gone and must not return: the per-section `min-height` `ResizeObserver` pin, the `useScrollAnchor`/`[data-file-section]` window-scroll compensation hook, the IntersectionObserver active-file tracking, and the deferred/`forceRender` placeholder system (`DeferredDiffPlaceholder`, `getReservedHeightPx`, `scheduleVisibleFlush`). Do not add growable elements outside CodeView's item model; let CodeView render and measure them. Active-file state is derived from CodeView's `onScroll` + `getRenderedItems()` and the file-header active highlight is CSS-driven.

## Conventions

Expand All @@ -81,4 +82,4 @@ If you add a new growable element above or inside the diff list, route it throug

Additional context is available in the files below. Consult the relevant file when working in a related area — see each description for scope.

- `.claude/knowledge/diff-intraline-highlighting.md` — why `lineDiffType: "word-alt"` is used (server prerender and client `<PatchDiff>` must stay in sync), the single-char-neutral absorption quirk, and why diff colors must track Primer exactly. Consult before changing `lib/diff-prerender.ts`, `lib/diff-colors.ts`, or the `<PatchDiff>` options in `components/DiffViewer.tsx`.
- `.claude/knowledge/diff-intraline-highlighting.md` — why `lineDiffType: "word-alt"` is used (server prerender and client `<CodeView>` must stay in sync), the single-char-neutral absorption quirk, and why diff colors must track Primer exactly. Consult before changing `lib/diff-prerender.ts`, `lib/diff-colors.ts`, or the `<CodeView>` options in `components/DiffViewer.tsx`.
4 changes: 0 additions & 4 deletions apps/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@

Two defences keep the viewport stable while `@pierre/diffs` finishes its
post-mount resize cascades:

- Each file section now pins its `min-height` after a 200 ms resize-idle
window. Once pinned, internal library resizes (Shiki tokenize, font swap,
ResizeManager beats) are absorbed inside the section instead of pushing
Expand Down Expand Up @@ -92,12 +91,10 @@
- eef9844: Stop post-paint DOM mutations above the viewport so `diffhub cmux` (WebKit/WKWebView) scrolls smoothly end-to-end.

WebKit has no `overflow-anchor` implementation, and its momentum scroller clamps `scrollTop` to `scrollHeight − clientHeight` every frame. When content above the viewport grows or shrinks during scroll, WebKit cannot hold position and either shifts or rubber-bands. Two factors were changing layout mid-scroll in diffhub:

- The `/api/diff` route prerendered only the first 4 files (`MAX_PRERENDER_FILES = 4`). Files 5+ shipped without highlighted HTML, so `@pierre/diffs` ran its async syntax-highlight path on the client, then swapped each section's hunk DOM via `innerHTML` after first paint.
- React state updates were landing mid-scroll: `useDeferredValue(diffData)` forced a second render pass after `setDiffData`, the IntersectionObserver per section re-entered React on every scroll frame, `reconcileSelectedFile` was recreated on every diff-state change, poll cycles dispatched `setFilesData` + `setComments` during scroll, and every comment prop change re-filtered inside every section.

Fixes:

- `app/api/diff/route.ts` — remove the `MAX_PRERENDER_FILES` cap; prerender every file that isn't already deferred as a large diff. The server still respects `DIFFHUB_DISABLE_PRERENDER=1` and the per-file 3 s timeout.
- `components/DiffViewer.tsx` — split `comments` into a `Map<string, Comment[]>` at the viewer level so each file section receives a stable reference; module-level rAF throttle collapses multiple IntersectionObserver callbacks into one `onVisible` per frame; derive `shouldRenderPatch` during render instead of via `useEffect`+`useState`; drop `onVisible` from the observer's deps; stabilise the `PatchDiff` render key so highlighted HTML is not discarded on layout/theme change.
- `components/DiffApp.tsx` — remove `useDeferredValue(diffData)`; `scrollingRef` pauses polling for 200 ms after the last scroll event (applies to both the interval and file-watch pushes); `reconcileSelectedFile` reads `diffData`/`diffError` through refs so its identity stays stable; drop the `.focus({ preventScroll: true })` call in `scrollToFile` (WebKit ≤ 16.3 ignores the flag).
Expand All @@ -109,7 +106,6 @@
- 93966e1: Fix `diffhub cmux` hanging on "Loading diff…" or rendering empty panels. The cmux command was setting `DIFFHUB_DISABLE_PRERENDER=1` whenever the server ran with a log file, which bypassed server-side prerender on every cmux session. When client-side rendering then hit a transient race (a stale poll arriving mid-fetch, or the dynamic `PatchDiff` import resolving into a section with `content-visibility: auto`), the user was left with no diff at all.

Changes:

- Stop disabling prerender just because the server has a log file; only disable when shiki's standalone module aliases are actually missing.
- Log silent prerender failures instead of swallowing them, and time out individual prerenders after 3 s so one bad file can't block the whole response.
- Re-queue the diff fetch if the only response in flight is dropped as stale; give up and reset state after three consecutive stale drops.
Expand Down
79 changes: 54 additions & 25 deletions apps/cli/components/DiffApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,62 @@ interface MockAnnotation {
side: "deletions" | "additions";
}

interface MockItem {
id: string;
type: "diff";
collapsed?: boolean;
annotations?: MockAnnotation[];
fileDiff: unknown;
}

interface MockCodeViewProps {
items: MockItem[];
renderCustomHeader?: (item: MockItem) => React.ReactNode;
renderAnnotation?: (annotation: MockAnnotation, item: MockItem) => React.ReactNode;
renderGutterUtility?: (
getHoveredLine: () => { lineNumber: number; side: "deletions" | "additions" } | undefined,
item: MockItem,
) => React.ReactNode;
}

// CodeView mock: reproduces the per-item DOM the suite queries — a
// `[data-filename]` wrapper, the custom file header (collapse button lives
// there), a hideable region panel, the gutter utility, and annotation slots.
// The real CodeView virtualizes; the mock renders every item eagerly.
const { MockDynamicPatch } = vi.hoisted(() => ({
MockDynamicPatch: ({
patch,
lineAnnotations,
items,
renderCustomHeader,
renderAnnotation,
renderGutterUtility,
}: {
patch: string;
lineAnnotations?: MockAnnotation[];
renderAnnotation?: (annotation: MockAnnotation) => React.ReactNode;
renderGutterUtility?: (
getHoveredLine: () => { lineNumber: number; side: "deletions" | "additions" } | undefined,
) => React.ReactNode;
}) => (
<div data-testid={`patch:${patch.slice(0, 12)}`}>
<div>{patch}</div>
{renderGutterUtility?.(() => ({ lineNumber: 12, side: "additions" }))}
{lineAnnotations?.map((annotation) => {
const metadataKey =
typeof annotation.metadata === "object" && annotation.metadata !== null
? JSON.stringify(annotation.metadata)
: String(annotation.metadata ?? "");

return (
<div key={`${annotation.lineNumber}:${annotation.side}:${metadataKey}`}>
{renderAnnotation?.(annotation)}
}: MockCodeViewProps) => (
<div data-testid="code-view">
{items.map((item) => (
<div data-filename={item.id} key={item.id}>
{renderCustomHeader?.(item)}
<div role="region" hidden={item.collapsed ?? false}>
<div data-testid={`patch:${item.id}`}>
{item.id}
{(item.fileDiff as { additionLines?: string[] } | undefined)?.additionLines?.join(
"\n",
)}
</div>
{renderGutterUtility?.(() => ({ lineNumber: 12, side: "additions" }), item)}
{item.annotations?.map((annotation) => {
const metadataKey =
typeof annotation.metadata === "object" && annotation.metadata !== null
? JSON.stringify(annotation.metadata)
: String(annotation.metadata ?? "");

return (
<div key={`${annotation.lineNumber}:${annotation.side}:${metadataKey}`}>
{renderAnnotation?.(annotation, item)}
</div>
);
})}
</div>
);
})}
</div>
))}
</div>
),
}));
Expand Down Expand Up @@ -571,8 +598,10 @@ describe("DiffApp review flow", () => {
);

await screen.findByText("feature/diff-review");
// Poll mode force-refreshes every 25ms; the initial diff may be fetched
// more than once before the first version settles. Assert at least one.
await waitFor(() => {
expect(countFetchCalls(fetchMock, "/api/diff")).toBe(1);
expect(countFetchCalls(fetchMock, "/api/diff")).toBeGreaterThanOrEqual(1);
});
await screen.findByText("Live");

Expand Down
Loading
Loading