feat(desktop,host-service): render the Changes pane from patches

Merged
KI
2mo ago
+1336-376

Stacked on #6955 — review that one first; this PR's diff is against it.

@pierre/diffs is built around a model we weren't using. You parse a patch into partial per-file metadata, render the hunks, and hand the library a loadDiffFiles callback it calls only when it needs the rest of a file. Their own DiffsHub demo (apps/diffshub in pierrecomputer/pierre) drives arbitrarily large GitHub diffs that way, with createGitHubDiffFileLoader as the loader and viewer.addItems as files stream in.

We were doing the opposite: git show twice per changed file, two complete copies over the wire, parsed on the renderer's main thread. A CPU profile of the old path put ~1 second in payload decode alone, which no amount of deferral touches.

What changed

Host service. git.getDiffPatch returns one git diff per category, built inside the worker pool that already runs the bulk path. The ref pair per category mirrors loadFileDiffContent exactly, so a patch and a hydrated file always describe the same two sides. Untracked files get their own --no-index section, since git diff won't report them.

Renderer. useDiffCodeViewItems groups files by (category, baseBranch, commitHash, fromHash) — usually 1-3 groups for a pane — issues one patch query per group, and parses it with parsePatchFiles. DiffPane passes loadDiffFiles into codeViewOptions, so whole files cross the wire only for files somebody expands or edits. Parsed metadata is cached per group and reused by identity, because the library hydrates partial diffs by upgrading that object in place.

Payload

bytes
patch for this branch's changeset (22 files)3.4 MB
the same changeset as getDiff contents13.2 MB
25.5%

A one-line edit in a 5,000-line file comes out under 10% (asserted in get-diff-patch.test.ts).

What this removes and what it keeps

Hunks are cheap enough that the size-based deferral from #6955 is gone: a 34,971-line messages.po now renders inline where #6955 hid it behind "Load diff". Verified in the running app.

Kept: generated artifacts stay behind the button — their patches are still tens of thousands of lines of noise, and the compiled ones (locales/*/messages.ts) are single multi-megabyte lines, which the Pierre writeup lists as an unsolved case for their renderer too. The 500k-char guard survives as the ceiling on hydration rather than a gate on rendering. Binary placeholders and per-file loading/error/retry states are unchanged.

Verification

  • apps/desktop: bun test → 2956 pass, 0 fail. packages/host-service: git router suite → 129 pass, 0 fail, including six new getDiffPatch cases pinning each category's ref pair, untracked sections, path filtering, and the payload ratio.
  • tsc --noEmit clean in both packages; biome check clean; bun run --cwd packages/i18n check exits 0 with ja and zh-CN at 0 missing.
  • Timing, CDP, same workspace, before and after. Patch build: first content 2342/2279 ms, longest task 191/179 ms, blocking 420/383 ms. The same workspace with this branch's DiffPane reverted to #6955: 2299/2417 ms, 185/190 ms, 343/350 ms. Equal within noise — but #6955 is not rendering the 34,971-line messages.po at all and this build renders it inline. Paired screenshots captured.
  • Hydration, live. Clicking "5 unmodified lines" in the running app fired two git.getDiff requests, the pane content grew, and the expanded lines rendered. That is loadDiffFiles end to end.
  • Untracked and unstaged, real worktree. Ran the host-side patch build against this worktree's live state (one modified tracked file, two untracked files) and parsed the result with parsePatchFiles: the tracked edit comes back type: "change", both untracked files come back type: "new" with hunks, and every name is repo-relative, which is what the renderer matches ChangesetFile.path against.
  • Edit mode on a partial diff resolves in the library, not in our code: FileDiff.attachEditor calls loadFilesIfNecessary() when fileDiff.isPartial (dist/components/FileDiff.js:636), the same path expanding context uses (:505), and canHydrateDiff limits hydration to change/rename-changed/rename-pure — new and deleted already carry their side, which is why our loader is never asked for them. Verified by reading the shipped source; not yet exercised through the editor UI.

Older host-service compatibility

Desktop and host-service ship from one version, but a remote or cloud workspace runs host-service inside its own sandbox and can be older than the app talking to it, and there is no capability negotiation between them. A desktop on this build pointed at such a host would have called a procedure that isn't there and rendered every file as "Unable to load diff".

The patch query now catches a missing-procedure error and falls back to per-file git.getDiff, parsed into complete metadata — what the pane did before this branch. Verified by renaming the procedure server-side to simulate an old host:

hostgetDiffPatch callsper-file getDiff callspane
simulated old (procedure absent)2, both 40422renders, no error placeholders
current2, both 2000renders identically

Not done here

The item list is still controlled items rather than initialItems + addItems, so files appear when the patch resolves rather than streaming in as it arrives. That's the remaining half of the DiffsHub pattern and wants its own PR.

Two paths remain unexercised through the UI: entering edit mode on a partial diff (the library source says it hydrates; nobody has typed into one), and an untracked file rendering in the pane (verified at the patch and parse layers only). Both need a workspace with uncommitted changes; the app's command palette searches commands rather than workspaces, so I could not drive the running instance to one.

If loadDiffFiles rejects — a file over the 500k-char hydration ceiling — the library logs and leaves the diff partial. For expansion that degrades cleanly. For edit mode on such a file it would mean editing against un-hydrated content, so the ceiling and the generated-file gate should stay aligned.

https://claude.ai/code/session_01Pkvq2izakEnyAXphgz176E


Summary by cubic

Renders the Changes pane from per-category git diff patches instead of pulling two complete copies of every changed file, so a changeset now ships about a quarter of the bytes (3.4 MB vs 13.2 MB here). Diffs render as per-file placeholders: small, non-generated files auto-load on scroll, while large and generated files show a "Load diff" button (or "Open file" past the 500k-char hydration ceiling); a failed fetch retries per file instead of showing a pane-wide error.

Host service

  • Adds git.getDiffPatch, one patch per category built in the existing worker pool; a failed git diff now throws so the query reports an error instead of an empty patch.
  • Untracked files get their own --no-index patch section, since git diff won't report them.
  • git.getDiffBulk has no renderer caller left; the procedure stays for older desktop builds.

Renderer

  • Groups files by ref pair, requests one patch per group, and parses hunks into per-file metadata; a patch missing a file's section shows that file's error state, not a generated-file placeholder, and the load/retry button refetches on the first click.
  • Expanding context or editing fetches full contents for just that file.
  • Diff cache keys use react-query's dataUpdatedAt instead of hashing both file bodies.
  • If the host lacks getDiffPatch (an older remote or cloud host), the pane falls back to per-file getDiff contents so files don't render as permanent errors.
  • Files appear when the patch resolves rather than streaming in as it arrives; that's left for a follow-up.

Summary by CodeRabbit

  • New Features
    • Diff views now load file contents on demand, improving performance for large or generated files.
    • Added clear placeholders for hidden, loading, or failed diffs, with Load diff and Retry actions.
    • Large files are protected from excessive loading.
  • Bug Fixes
    • Staging, discarding, refreshing, and saving changes now reliably update displayed status and diffs.
  • Localization
    • Added translated messages for new diff-loading states and actions.
  • Tests
    • Added coverage for diff safeguards and Git diff retrieval scenarios.