Diff View

A file or a diff rendered by @pierre/diffs — virtualized, syntax highlighted, split or unified — bound to the app's own tokens.

Open in
lib/store/reducer.ts

Installation

npx shadcn@latest add miskibin/chat-components/diff-view

Also installs diff-worker-pool.

Usage

"use client"

import { DiffView } from "@/components/ui/diff-view"

export function Review({ file }: { file: ChangedFile }) {
  // The viewer is its own scroll container, so give it a height.
  return (
    <div className="h-full min-h-0">
      <DiffView
        path={file.path}
        oldText={file.before}
        newText={file.after}
        mode="split"
        wrap={false}
      />
    </div>
  )
}

Examples

Comment on the lines you picked

onLineComment is what turns a viewer into a review surface: the reader drags a range in the gutter, and the host gets the lines and their text. A range dragged across the two columns of a split diff belongs to the side it ended on, so the numbers and the quote always agree.

Drag down the gutter to pick lines — the affordance appears over the selection, and what it hands back carries the text of those lines, so the quote survives leaving the viewer.

Four thousand lines, sixteen changed

Only the rows on screen exist, so the size of the file stops mattering. DiffWorkerPoolProvider moves the highlighting off the main thread as well — mount it once, high in the app; the pool is shared, reference counted, and kept warm for 30s after the last panel closes. Without it a DiffView highlights inline instead, which is slower but never broken.

app/routes.generated.ts4,000 lines, 16 changed

A whole review, in one scroller

DiffView answers “show me this file”; DiffStack answers “show me what changed”, which is a different question with a different shape. A column of separate viewers would be a column of separate scrollers — the reader lands in one, scrolls it to its end, and stops, with the rest of the review below a boundary the wheel refuses to cross. So this is one viewer holding one item per file: @pierre/diffs virtualizes them together against a single scroll container, which is both the only way the wheel behaves and the reason a hundred-file review costs what one screen costs. File headers are on, because in a stack the filename is the only thing saying which file a hunk belongs to.

Install the pool separately

The provider ships as its own registry item so it can be mounted at the app root, far from any panel.

npx shadcn@latest add miskibin/chat-components/diff-worker-pool

Notes

API reference

DiffStack

files*DiffStackFile[]
One entry per file — the same inputs DiffView takes (patch, or oldText/newText, or content), keyed by path. A file with nothing to show drops out rather than rendering an empty frame.
focusPathstring
Scrolls that file to the top of the viewport. Pair it with focusNonce to ask for the same file twice — a file map's rows are exactly that.
focusNoncenumber
Makes a repeat request for the same path a new request.
mode"unified" | "split" = "unified"
Applies to every file in the stack.
wrapboolean = true
Soft-wrap long lines; off, each file scrolls sideways.
emptyLabelReactNode
Shown when there is nothing to draw — a clean worktree, or a change that is all binary.

DiffView

path*string
Names the file, and picks the language when none is given.
patchstring
Unified diff text. Outranks oldText / newText. A patch that will not parse is shown as its own text with a note above it, rather than as a blank panel — a reader would rather see the patch.
oldText / newTextstring
Before and after. The diff is computed for you, in the worker when there is a pool.
contentstring
No diff at all: the file itself, highlighted and virtualized the same way.
startLinenumber = 1
First line of content, for a partial read. The excerpt is rendered as its own hunk so the gutter keeps the real numbers, with an “N unmodified lines” marker above it.
mode"unified" | "split" = "unified"
Side by side or one column. Ignored without a diff.
wrapboolean = true
Soft-wrap long lines; off, the code scrolls sideways instead.
lineNumbersboolean = true
The gutter.
fileHeaderboolean = false
Pierre's own per-file header, with the name and the +/- counts. Off, because the host usually has a header of its own.
highlightLinesIterable<number>
Lines to tint, in the file's own numbering — the lines an agent touched, a search's hits. Emitted as one rule per line into the viewer's stylesheet, so it lands on a virtualized row the moment it mounts; past 400 lines the tint is dropped rather than costing more than it says.
focusLine / focusNoncenumber
Centre a line, once per request. Bump focusNonce to ask for the same line again — clicking one file.ts:42 chip twice is two requests, and only the host can tell them apart.
onLineComment(range: DiffLineCommentRange) => void
Turns on line selection. Drag the gutter and a Comment on lines button appears over the selection; clicking it hands back { path, startLine, endLine, side?, excerpt } — the numbers in the numbering of the side they were picked on, and the text of those lines, read back out of the patch so the quote survives leaving the viewer.
themeDiffsThemeNames | ThemesType = { light: "pierre-light", dark: "pierre-dark" }
Any Shiki theme, or a light/dark pair. The backgrounds, gutter and row tints are overridden with your own tokens either way — only the token colours come from here.
unsafeCSSstring
Appended inside the viewer's shadow root, after the token bindings. The escape hatch for chrome the viewer owns.
optionsDiffViewOptions
Everything DiffView does not own, forwarded to CodeView. The options it does own are removed from the type, so passing one fails to compile instead of being silently overwritten.

DiffWorkerPoolProvider

poolSizenumber = clamp(cores / 2, 2, 6)
Workers to spawn — enough to keep a scrolling file list highlighted without starving the app.
workerFactory() => Worker
Replaces how a worker is constructed, for a bundler that cannot follow the built-in new URL("@pierre/diffs/worker/worker.js", import.meta.url).
fallbackReact.ReactNode
Shown while the workers start. Defaults to a quiet status line.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

diff-viewdiff-view-surfacediff-view-line-commentdiff-view-notediff-view-emptydiff-stackdiff-stack-surfacediff-stack-emptydiff-worker-pool-fallback