Diffs is a library for rendering code and diffs on the web. This includes both high-level, easy-to-use components, as well as exposing many of the internals if you want to selectively use specific pieces. We've built syntax highlighting on top of Shiki which provides a lot of great theme and language support.
123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hi you, {s}!\n", .{"world"});}123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hello there, {s}!\n", .{"zig"});}
We have an opinionated stance in our architecture: browsers are rather efficient at rendering raw HTML. We lean into this by having all the lower level APIs purely rendering strings (the raw HTML) that are then consumed by higher-order components and utilities. This gives us great performance and flexibility to support popular libraries like React as well as provide great tools if you want to stick to vanilla JavaScript and HTML. The higher-order components render all this out into Shadow DOM and CSS grid layout.
Generally speaking, you're probably going to want to use the higher level components since they provide an easy-to-use API that you can get started with rather quickly. We currently only have components for vanilla JavaScript and React, but will add more if there's demand.
For this overview, we'll talk about the vanilla JavaScript components for now but there are React equivalents for all of these.
Our goal with visualizing diffs was to provide some flexible and approachable
APIs for how you may want to render diffs. For this, we provide a component
called FileDiff.
There are two ways to render diffs with FileDiff:
You can see examples of these approaches below, in both JavaScript and React.
123456789101112131415161718192021222324252627282930313233343536373839404142import { type FileContents, FileDiff,} from '@pierre/diffs';
// Store file objects in variables rather than inlining them.// FileDiff uses reference equality to detect changes and skip// unnecessary re-renders, so keep these references stable.const oldFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hi you, {s}!\\\\n", .{"world"});}`,};
const newFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hello there, {s}!\\\\n", .{"zig"});}`,};
// We automatically detect the language based on the filename// You can also provide a lang property when instantiating FileDiff.const fileDiffInstance = new FileDiff({ theme: 'pierre-dark' });
// render() is synchronous. Syntax highlighting happens async in the// background and the diff updates automatically when complete.fileDiffInstance.render({ oldFile, newFile, // where to render the diff into containerWrapper: document.body,});Render conflicts through a dedicated diff primitive that treats current and incoming sections as structured additions/deletions without running text diffing. Resolve by choosing current, incoming, or both changes and preview the updated file instantly.
19 unmodified lines20212223232425262714 unmodified lines42434444454647454647484950515253545521 unmodified lines19 unmodified linesexport async function createSession(userId: string) { await cleanupExpiredSessions(userId);
|| <<<<<<< HEAD const data = {======= const sessionData = { source: 'web',>>>>>>> feature/oauth-session-source provider: 'password', userId, expiresAt: Date.now() + SESSION_TTL,14 unmodified lines if (oldest) await invalidateSession(oldest.id); }
|| <<<<<<< HEAD await db.auditLog.create({ event: 'session.created', userId, });======= await db.sessionEvent.create({ type: 'audit-log', data: { sessionId: session.id, type: 'created', source: sessionData.source ?? 'credentials', }, });>>>>>>> feature/oauth-session-source
return { session, token };}21 unmodified linesDiffs is published as an npm package. Install Diffs with the package manager of your choice:
1pnpm add @pierre/diffsThe package provides several entry points for different use cases:
| Package | Description |
|---|---|
@pierre/diffs | Vanilla JS components, plus utility functions |
@pierre/diffs/react | React components for rendering diffs and files |
@pierre/diffs/edit | Low-level edit mode Editor for attaching editing to rendered file and diff surfaces |
@pierre/diffs/ssr | Server-side rendering utilities for pre-rendering diffs with syntax highlighting |
@pierre/diffs/worker | Worker pool utilities for offloading syntax highlighting to background threads |
Before diving into the components, it's helpful to understand the core file, diff, and annotation data structures used throughout the library.
FileContents represents one existing file version. Use it when rendering a
file with the <File> component, or pass it as oldFile and/or newFile to
diff components. For added or deleted files, pass null for the intentionally
missing side.
An omitted side is not the same as null. If you provide either oldFile or
newFile, provide the other side too, using null only when that file side
does not exist. Empty file contents are still a real file; represent them with
contents: '', not null.
1234567891011121314151617181920212223242526272829303132333435363738import type { FileContents } from '@pierre/diffs';
// FileContents represents one existing file side.// Use null, not FileContents with an empty string, for an intentionally// missing side.interface FileContents { // The filename (used for display and language detection) name: string;
// The file's text content contents: string;
// Optional: Override the detected language for syntax highlighting // See: https://shiki.style/languages lang?: SupportedLanguages;
// Optional identity for Worker Pool caching. Required when // Editor.persistState is enabled; use a unique, stable key for that editing // session and reuse it only when the cached document should resume. cacheKey?: string;}
// Example usageconst file: FileContents = { // We'll attempt to detect the language based on file extension name: 'example.tsx', contents: 'export function Hello() { return <div>Hello</div>; }', cacheKey: 'example-file-v1',};
// With explicit language overrideconst jsonFile: FileContents = { // No extension, so we specify lang name: 'config', contents: '{ "key": "value" }', lang: 'json', cacheKey: 'config-file',};For read-only rendering and Worker Pool caching, cacheKey is optional; when
provided, treat it as a revision identity and change it with the contents,
filename, language, or revision. When
Editor.persistState is
enabled, every editable file requires an explicit, non-empty cacheKey. Use
unique, stable keys for editing sessions, and change the key when incoming
contents should replace the cached document.
FileDiffMetadata represents the differences between file versions. It contains
the hunks (changed regions), line counts, and optionally the full file contents
for expansion (if possible).
When a component uses loadDiffFiles, treat FileDiffMetadata as mutable
render metadata. A partial metadata object parsed from a patch can be upgraded
in place: isPartial flips to false, hunks and line arrays are replaced with
hydrated values, and the object identity is preserved.
If you reparse a patch or create a new partial FileDiffMetadata, the renderer
treats it as a fresh partial model. Keep the same metadata object stable when
you want hydration to persist.
When loaded files provide cache keys, hydration uses those keys so full-file
highlights can be reused across diffs. Change each FileContents.cacheKey
whenever the loaded contents, filename, language, or revision changes. If loaded
files are unkeyed but the partial metadata has a cacheKey, hydration appends a
hydrated segment as a fallback.
Tip: You can generate FileDiffMetadata using
parseDiffFromFile (from file contents) or
parsePatchFiles (from a patch string).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879import type { FileDiffMetadata, Hunk } from '@pierre/diffs';
// FileDiffMetadata represents a parsed file change.interface FileDiffMetadata { // Current filename name: string;
// Previous filename (for renames) prevName: string | undefined;
// Optional: Override language for syntax highlighting. Normally // language is detected automatically base on file extension and you do not // need to set this. If you need to set a custom lang on a FileDiffMetadata // instance, use the `setLanguageOverride(diff, 'ruby')` method. lang?: SupportedLanguages;
// Type of change: 'change' | 'rename-pure' | 'rename-changed' | 'new' | 'deleted' type: ChangeTypes;
// Array of diff hunks containing the actual changes hunks: Hunk[];
// Line counts for split and unified views splitLineCount: number; unifiedLineCount: number;
// Full file contents (when generated using parseDiffFromFile, // enables expansion around hunks) oldLines?: string[]; newLines?: string[];
// Optional: Cache key for AST caching in Worker Pool. // When provided, rendered diff AST results are cached and reused. // IMPORTANT: The key must change whenever the diff changes! cacheKey?: string;}
// Hunk represents a single changed region in the diff// Think of it like the sections defined by the '@@' lines in patchesinterface Hunk { // Addition/deletion counts, parsed out from patch data additionCount: number; additionStart: number; additionLines: number; deletionCount: number; deletionStart: number; deletionLines: number;
// The actual content of the hunk (context and changes) hunkContent: (ContextContent | ChangeContent)[];
// Optional context shown in hunk headers (e.g., function name) hunkContext: string | undefined;
// Line position information, mostly used internally for // rendering optimizations splitLineStart: number; splitLineCount: number; unifiedLineStart: number; unifiedLineCount: number;}
// ContextContent represents unchanged lines surrounding changesinterface ContextContent { type: 'context'; lines: string[]; // 'true' if the file does not have a blank newline at the end noEOFCR: boolean;}
// ChangeContent represents a group of additions and deletionsinterface ChangeContent { type: 'change'; deletions: string[]; additions: string[]; // 'true' if the file does not have a blank newline at the end noEOFCRDeletions: boolean; noEOFCRAdditions: boolean;}LineAnnotation<T> places content on a file line and contains lineNumber plus
typed metadata. Metadata is required when T is a concrete type and omitted
when T is undefined. DiffLineAnnotation<T> adds
side: 'additions' | 'deletions' to select a file side. Line coordinates are
one-based on the selected file side, not row positions in the rendered diff. Use
lineNumber: 0 for a file-level annotation above the first file line or, in a
diff, above the first hunk or row on that side.
Callbacks shared by file and diff surfaces use
LineAnnotation[] | DiffLineAnnotation[]. Use isFileAnnotationCollection or
isDiffAnnotationCollection to narrow the collection before reading
shape-specific fields. For individual annotation unions, use isFileAnnotation
or isDiffAnnotation.
Store a stable, position-independent application ID in metadata when an annotation owns drafts or other interactive state. For annotations that survive an edit, edit mode preserves metadata while remapping line coordinates. For the controlled update pattern and exact remapping rules, see Editing with line annotations.
123456789101112131415161718192021222324252627import type { DiffLineAnnotation, LineAnnotation,} from '@pierre/diffs';
interface ThreadMetadata { // Position-independent identity for application-owned state. id: string;}
const fileAnnotations: LineAnnotation<ThreadMetadata>[] = [ { lineNumber: 0, metadata: { id: 'file-summary' } }, { lineNumber: 5, metadata: { id: 'line-five-review' } },];
const diffAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [ { side: 'additions', lineNumber: 12, metadata: { id: 'new-line-review' }, }, { side: 'deletions', lineNumber: 9, metadata: { id: 'old-line-review' }, },];There are two ways to create a FileDiffMetadata.
Use parseDiffFromFile when you have the full file contents. Pass both sides
for a changed file, oldFile: null for a new file, or newFile: null for a
deleted file. This approach allows collapsed regions to be expanded.
123456789101112131415161718192021222324252627282930313233import { parseDiffFromFile, type FileContents, type FileDiffMetadata,} from '@pierre/diffs';
// Define the existing file versionsconst oldFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello";', cacheKey: 'greeting-old', // Optional: enables AST caching};
const newFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello, World!";', cacheKey: 'greeting-new',};
// Generate diff metadata from two existing versionsconst diff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);
// For added or deleted files, pass null for the side that does not exist.// Omitting the side is not the same as passing null.const addedFileDiff = parseDiffFromFile(null, newFile);const deletedFileDiff = parseDiffFromFile(oldFile, null);
// parseDiffFromFile(null, null) throws because at least one side must exist.
// The resulting diff includes oldLines and newLines,// which enables "expand unchanged" functionality in the UI.// If both existing versions have cacheKey, the diff will have a combined// cacheKey of "greeting-old:greeting-new" for AST caching.Use parsePatchFiles when you have a unified diff or patch file. This is useful
when working with git output or patch files from APIs. Patch-derived metadata is
partial until a renderer hydrates it with full files from loadDiffFiles.
1234567891011121314151617181920212223242526272829import { parsePatchFiles, type ParsedPatch, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a unified diff / patch stringconst patchString = `--- a/file.ts+++ b/file.ts@@ -1,3 +1,3 @@ const x = 1;-const y = 2;+const y = 3; const z = 4;`;
// Returns an array of ParsedPatch objects (one per commit in the patch)// Pass an optional cacheKeyPrefix to enable AST caching with Worker Poolconst patches: ParsedPatch[] = parsePatchFiles(patchString, 'my-patch-key');
// Each ParsedPatch contains an array of FileDiffMetadataconst files: FileDiffMetadata[] = patches[0].files;
// With cacheKeyPrefix, each diff gets a cacheKey like "my-patch-0",// "my-patch-1", etc.// This enables AST caching in Worker Pool for parsed patches.
// Note: Diffs from patch files don't include oldLines/newLines.// Renderers can hydrate them with loadDiffFiles when full file// contents are needed for expanding unchanged context.Tip: If you need to change the language after creating a FileContents or
FileDiffMetadata, use the
setLanguageOverride utility function.
Import React components from @pierre/diffs/react.
We offer a variety of components to render diffs and files. Many of them share similar types of props, which you can find documented in Shared Props.
The React API exposes six main components:
CodeView renders a mixed, virtualized list of files and diffs inside one
scroll containerMultiFileDiff compares file contents directlyPatchDiff renders from a patch stringFileDiff renders a pre-parsed FileDiffMetadataFile renders a single code file without a diffUnresolvedFile renders merge conflict markers with built-in resolution UI
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263import { parseDiffFromFile, type CodeViewItem,} from '@pierre/diffs';import { CodeView, type CodeViewReactOptions,} from '@pierre/diffs/react';import { useMemo } from 'react';
const oldAppFile = { name: 'src/app.ts', contents: 'export function greet() {\n return "hello";\n}',};
const newAppFile = { name: 'src/app.ts', contents: 'export function greet(name: string) {\n return "hello " + name;\n}',};
const readmeFile = { name: 'README.md', contents: '# Docs\n\nThis file is rendered inline with the diff list.',};
// Pass `items` when React owns the full item list. Use `initialItems` plus a// ref instead when item updates should be imperative; omit both item props to// start empty and append later.const items: CodeViewItem[] = [ { id: 'diff:src/app.ts', type: 'diff', fileDiff: parseDiffFromFile(oldAppFile, newAppFile), annotations: [{ side: 'additions', lineNumber: 2 }], }, { id: 'file:README.md', type: 'file', file: readmeFile, },];
const codeViewStyle = { height: 600, overflow: 'auto' } as const;
export function ReviewSurface() { const codeViewOptions = useMemo<CodeViewReactOptions<undefined>>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, }), [] );
return ( <CodeView items={items} style={codeViewStyle} options={codeViewOptions} /> );}For editing, mount one stable EditProvider high in the tree. Standalone
surfaces use edit and editorOptions; CodeView uses item edit flags and
its own editorOptions. Each active surface or item receives an independent
editor. UnresolvedFile is not editable. See
Edit mode → React and
CodeView → Editing for lifecycle and callback details.
Keep non-primitive props stable across renders. Define static files, diffs,
options, styles, and factories at module scope; when they depend on component
state or props, use useMemo for objects and arrays and useCallback for
functions. This applies to options, editorOptions, annotations, and render
callbacks as well as file and fileDiff.
UnresolvedFile is intentionally uncontrolled in React. Treat file as initial
input and remount (for example, with a changing key) when you want to reset.
MultiFileDiff accepts FileContents for each existing side. Pass
oldFile={null} for a new file, or newFile={null} for a deleted file.
The CodeView tab above is the quick-start version. For the full guide on
controlled items, imperative initialItems, ids, version, selection, and
scrollTo, see CodeView.
When loadDiffFiles is configured, partial FileDiffMetadata passed to
FileDiff may be hydrated in place. Keep the same fileDiff object identity
stable across parent rerenders when you want the hydrated full metadata to
persist.
Return both sides for changed diffs and { oldFile: null, newFile } for pure
renames. Added and deleted diffs do not need to be hydrated.
Passing a freshly parsed partial object resets hydration for that render. Avoid
calling parsePatchFiles during every render before passing the result to
FileDiff; store or memoize the parsed metadata instead.
123456789101112131415161718192021222324252627282930313233import { parsePatchFiles, type FileDiffLoadedFiles, type FileDiffOptions,} from '@pierre/diffs';import { FileDiff } from '@pierre/diffs/react';import { useMemo } from 'react';
declare const patchText: string;
const fileDiff = parsePatchFiles(patchText, 'pull-42')[0]?.files[0];if (fileDiff == null) { throw new Error('The patch does not contain a file diff');}
export function ReviewDiff() { const fileDiffOptions = useMemo<FileDiffOptions<undefined>>( () => ({ async loadDiffFiles(fileDiff): Promise<FileDiffLoadedFiles> { const response = await fetch( '/api/files?path=' + encodeURIComponent(fileDiff.name) ); // Return { oldFile, newFile }, or { oldFile: null, newFile } // for pure renames. // Include cacheKey values that change with revision or content. return response.json(); }, }), [] );
return <FileDiff fileDiff={fileDiff} options={fileDiffOptions} />;}The three diff components (MultiFileDiff, PatchDiff, and FileDiff) share a
common set of props for configuration, annotations, and styling. The File
component has similar props, but uses LineAnnotation instead of
DiffLineAnnotation (no side property).
When one of these components is attached to an Editor, keep its annotations in
application-owned state and replace them with the current collection emitted by
Editor.onChange. See
Editing with line annotations for
the React flushSync pattern and annotation-content lifetime guidance.
CodeView reuses many of the same option names internally, but it has its own
controlled items mode, imperative mode with optional initialItems, viewer
ref, and mixed-item render props. See CodeView for the dedicated
guide.
Header customization and collapsing behavior:
renderHeaderPrefix to render custom UI at the beginning of the built-in
header, before the filename and icons, while keeping the default header
layout.renderHeaderFilenameSuffix for compact UI immediately after the
displayed filename, such as badges, review state, or generated-file labels.renderHeaderMetadata to render custom UI at the end of the built-in
header, after the diff stats, while keeping the default header layout.renderCustomHeader when you want to replace the built-in header content
with your own custom designed one.fileDiff: FileDiffMetadata.File, the corresponding header callbacks receive file: FileContents.options.collapsed to hide file body content while keeping the file
header visible.options.onPostRender(node, instance, phase) is a DOM-node lifecycle callback.
It fires with phase: 'mount' after the first committed render or hydration for
a container node, phase: 'update' after later DOM-committing renders, and
phase: 'unmount' before a mounted container node is removed, replaced, cleaned
up, or recycled.
Use this callback when native DOM selection listeners need access to the
rendered diff node or its shadow DOM. Attach listeners such as selectstart and
selectionchange during mount, and remove them during unmount with teardown
state captured by node.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import type { FileDiffMetadata, FileDiffOptions,} from '@pierre/diffs';import { FileDiff } from '@pierre/diffs/react';import { useMemo } from 'react';
const cleanupByNode = new WeakMap<HTMLElement, () => void>();
export function DiffWithRenderLifecycle({ fileDiff,}: { fileDiff: FileDiffMetadata;}) { const fileDiffOptions = useMemo<FileDiffOptions<undefined>>( () => ({ onPostRender(node, _instance, phase) { if (phase === 'mount') { const selectionRoot = node.shadowRoot ?? node;
const handleSelectStart = () => { console.log('selection started in diff'); };
const handleSelectionChange = () => { const selection = document.getSelection(); if (selection == null || selection.isCollapsed) { return; }
if ( !containsSelectionNode(selectionRoot, selection.anchorNode) && !containsSelectionNode(selectionRoot, selection.focusNode) ) { return; }
console.log('selected text', selection.toString()); };
selectionRoot.addEventListener('selectstart', handleSelectStart); document.addEventListener('selectionchange', handleSelectionChange); cleanupByNode.set(node, () => { selectionRoot.removeEventListener('selectstart', handleSelectStart); document.removeEventListener( 'selectionchange', handleSelectionChange ); }); return; }
if (phase === 'unmount') { cleanupByNode.get(node)?.(); cleanupByNode.delete(node); } }, }), [] );
return ( <FileDiff fileDiff={fileDiff} options={fileDiffOptions} /> );}
function containsSelectionNode(root: Node, node: Node | null) { return node != null && root.contains(node);}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258// ============================================================// SHARED OPTIONS FOR DIFF COMPONENTS// ============================================================// These options are shared by MultiFileDiff, PatchDiff, and FileDiff.// Pass them via the `options` prop.
import type { DiffTokenEventBaseProps, FileDiff as FileDiffClass, FileDiffContentsLoader, PostRenderPhase,} from '@pierre/diffs';import { MultiFileDiff } from '@pierre/diffs/react';
<MultiFileDiff {...} // You should generally memoize options inside your component with useMemo. options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', // ... see below for all available options }}/>
interface DiffOptions { // ───────────────────────────────────────────────────────────── // THEMING // ─────────────────────────────────────────────────────────────
// Theme for syntax highlighting. Can be a single theme name or an // object with 'dark' and 'light' keys for automatic switching. // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme. // See: https://shiki.style/themes theme: { dark: 'pierre-dark', light: 'pierre-light' },
// When using dark/light theme object, this controls which is used: // 'system' (default) - follows OS preference // 'dark' or 'light' - forces specific theme themeType: 'system',
// Choose the Shiki engine: // 'shiki-js' (default) - JavaScript regex engine // 'shiki-wasm' - WASM Oniguruma engine preferredHighlighter: 'shiki-js',
// ───────────────────────────────────────────────────────────── // DIFF DISPLAY // ─────────────────────────────────────────────────────────────
// 'split' (default) - side-by-side view // 'unified' - single column view diffStyle: 'split',
// Line change indicators: // 'bars' (default) - colored bars on left edge // 'classic' - '+' and '-' characters // 'none' - no indicators diffIndicators: 'bars',
// Show colored backgrounds on changed lines (default: false) disableBackground: false,
// ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ─────────────────────────────────────────────────────────────
// What to show between diff hunks: // 'line-info' (default) - shows collapsed line count, clickable to expand // WebKit/Safari bug in version 26 as of this writing: if you use // custom renderGutterUtility with hunkSeparators: 'line-info', you may // experience scroll jumping while moving the mouse. // Recommended: avoid this API by just using enableGutterUtility to render // the default button, or switch to another hunk separator type // (e.g. 'line-info-basic'). // For a status of this bug, visit: // https://bugs.webkit.org/show_bug.cgi?id=308027 // 'line-info-basic' - slightly more compact full width line-info variant // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@' // 'simple' - subtle bar separator // We recommend sticking to these built-in string presets in React. // The low-level functional separator API is only documented for vanilla JS, // is being phased out, and is a poor fit for the container-managed and // virtualization-oriented React APIs. hunkSeparators: 'line-info',
// Force unchanged context to always render (default: false) // Requires oldFile/newFile API or FileDiffMetadata with newLines expandUnchanged: false,
// Lines revealed per click when expanding collapsed regions expansionLineCount: 100,
// Load full contents for partial changed/renamed diffs parsed from patches. // Return both sides for changed diffs and oldFile: null for pure renames. // Added/deleted diffs do not need to be hydrated. loadDiffFiles?: FileDiffContentsLoader,
// Auto-expand collapsed context regions at or below this size // (default: 1) collapsedContextThreshold: 1,
// ───────────────────────────────────────────────────────────── // INLINE CHANGE HIGHLIGHTING // ─────────────────────────────────────────────────────────────
// Highlight changed portions within modified lines: // 'word-alt' (default) - word boundaries, minimizes single-char gaps // 'word' - word boundaries // 'char' - character-level granularity // 'none' - disable inline highlighting lineDiffType: 'word-alt',
// Skip inline diff for lines exceeding this length maxLineDiffLength: 1000,
// ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ─────────────────────────────────────────────────────────────
// Show line numbers (default: true) disableLineNumbers: false,
// Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll',
// Hide the file header with filename and stats disableFileHeader: false,
// Rethrow rendering errors instead of catching and displaying them // in the DOM. Useful for testing or custom error handling. // (default: false) disableErrorHandling: false,
// Skip syntax highlighting for lines exceeding this length tokenizeMaxLineLength: 1000,
// Fires after hydration, after DOM-committing render updates, and before // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'. // Receives the outer diffs container element. // Useful when you want to measure, observe, or clean up DOM-node state. // You can access the shadow DOM from here if you need to inspect lines. onPostRender( node: HTMLElement, instance: FileDiffClass, phase: PostRenderPhase ) { if (phase === 'unmount') { return; }
const codeLines = node.shadowRoot?.querySelectorAll('[data-line]'); console.log('rendered line count', codeLines?.length ?? 0); },
// ───────────────────────────────────────────────────────────── // LINE SELECTION // ─────────────────────────────────────────────────────────────
// Enable click-to-select on line numbers enableLineSelection: false,
// Callbacks for selection events onLineSelectionStart(range: SelectedLineRange | null) { // Fires on pointer down }, onLineSelectionChange(range: SelectedLineRange | null) { // Fires while dragging when range grows/shrinks (not initial down) }, onLineSelectionEnd(range: SelectedLineRange | null) { // Fires on pointer up }, onLineSelected(range: SelectedLineRange | null) { // Fires on pointer up with final range (or null) },
// ───────────────────────────────────────────────────────────── // MOUSE EVENTS // ─────────────────────────────────────────────────────────────
// Line hover effect. Sets a data-hovered attribute on the // hovered element(s), which you can style via the Styling API. // 'disabled' (default) - no hover effect // 'both' - highlights both line number and line content // 'number' - highlights only the line number // 'line' - highlights only the line content lineHoverHighlight: 'disabled',
// Must be true to enable renderGutterUtility prop enableGutterUtility: false,
// Callbacks for mouse events on diff lines onLineClick({ lineNumber, side, event }) { // Fires when clicking anywhere on a line }, onLineNumberClick({ lineNumber, side, event }) { // Fires when clicking anywhere in the line number column }, onLineEnter({ lineNumber, side }) { // Fires when mouse enters a line }, onLineLeave({ lineNumber, side }) { // Fires when mouse leaves a line },
// See the Token Hooks section for examples, performance notes, // and Worker Pool caveats. // These APIs preserve more token-level DOM metadata, which increases DOM // size and may have a performance impact on larger files. // Experimental token callbacks. Useful for token-aware UIs such as // LSP textDocument/hover tooltips or temporary token styling. // lineCharStart is zero-based and lineCharEnd is end-exclusive. // If both token and line click handlers are provided, both will fire. onTokenClick({ tokenText, lineNumber, lineCharStart, lineCharEnd, side, }: DiffTokenEventBaseProps) { // Fires when clicking a token in the code column }, onTokenEnter({ tokenText, lineNumber, lineCharStart, lineCharEnd, side, tokenElement, }: DiffTokenEventBaseProps) { // Use tokenElement for hover styling or tooltips }, onTokenLeave({ tokenText, side, tokenElement }: DiffTokenEventBaseProps) { // Clean up token-specific hover UI },
// Include whitespace-only tokens in token callbacks (default: false) enableTokenInteractionsOnWhitespace: false,
// Experimental: force token wrappers/data-char output even when no token // callbacks are attached. Usually unnecessary unless you want custom styling. // This also increases DOM size and may have a performance impact on // larger files. useTokenTransformer: false,
// Preferred: built-in gutter utility button (+) // No render callback needed; callback receives a SelectedLineRange. // Callback does not control visibility; options.enableGutterUtility does. // Fires on pointer up only: // - click => single-line range // - drag => final range at release // Selection lifecycle callbacks also fire for a gutter utility gesture, // even when line selection is disabled. // Can click a single line or apply to a drag interaction started pointer // down on the button onGutterUtilityClick(range: SelectedLineRange) { console.log(range.start, range.end, range.side, range.endSide); },}Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and
useTokenTransformer are documented in Token Hooks, including
examples, payload details, performance notes, and Worker Pool caveats.
Import vanilla JavaScript classes, components, and methods from
@pierre/diffs.
The Vanilla JS API exposes four core components: CodeView (render a mixed,
virtualized list of files and diffs in one scroll container), FileDiff
(compare file contents directly or render a pre-parsed FileDiffMetadata),
File (render a single code file without a diff), and UnresolvedFile (render
merge conflicts with built-in resolution controls). Start with these components
for syntax highlighting, theming, layout, and interactivity.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374import { CodeView, parseDiffFromFile, type CodeViewItem,} from '@pierre/diffs';
const root = document.getElementById('review-root');if (root == null) { throw new Error('Expected #review-root to exist');}
root.style.height = '600px';root.style.overflow = 'auto';
const viewer = new CodeView({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 },});
viewer.setup(root);
const items: CodeViewItem[] = [ { id: 'diff:src/app.ts', type: 'diff', fileDiff: parseDiffFromFile( { name: 'src/app.ts', contents: 'export function greet() {\n return "hello";\n}', }, { name: 'src/app.ts', contents: 'export function greet(name: string) {\n return "hello " + name;\n}', } ), annotations: [{ side: 'additions', lineNumber: 2 }], }, { id: 'file:README.md', type: 'file', file: { name: 'README.md', contents: '# Docs\n\nThis file is rendered inline with the diff list.', }, },];
viewer.setItems(items);
const appItem = viewer.getItem('diff:src/app.ts');if (appItem?.type === 'diff') { viewer.updateItem({ ...appItem, version: 2, annotations: [{ side: 'additions', lineNumber: 2 }], });}
viewer.addItems([ { id: 'file:CHANGELOG.md', type: 'file', file: { name: 'CHANGELOG.md', contents: '# Changelog\n\n- Added personalized greetings.', }, },]);
window.addEventListener('beforeunload', () => { viewer.cleanUp();});
UnresolvedFileis currently beta/experimental and may change in future releases.
See Edit mode → Vanilla JS for attaching Editor to a
rendered File or FileDiff with edit().
UnresolvedFile in vanilla supports both uncontrolled and controlled callbacks
(onMergeConflictResolve / onMergeConflictAction).
The CodeView tab above is the quick-start version. For the deeper guide on
setup, setItems, addItems, getItem, removeItem, updateItem,
selection, and scrollTo, see CodeView.
Both FileDiff and File accept an options object in their constructor. The
File component has similar options, but excludes diff-specific settings and
uses LineAnnotation instead of DiffLineAnnotation (no side property).
When one of these components is attached to an Editor, keep its annotations in
an external variable or store and replace them with the current collection
emitted by Editor.onChange. See
Editing with line annotations for
the synchronization pattern and remapping rules.
When rendering direct file contents with FileDiff.render, pass FileContents
for each existing side. Use oldFile: null for a new file, or newFile: null
for a deleted file.
For partial diffs parsed from patches, pass loadDiffFiles to FileDiff
constructor options when you want collapsed unchanged context to expand from
full file contents. The loader receives the partial FileDiffMetadata and
returns { oldFile, newFile }: changed and rename-changed diffs return both
sides, while pure renames return { oldFile: null, newFile }. Added and deleted
patch diffs do not need loader hydration. Components catch loader errors by
default; set disableErrorHandling: true when you want errors to rethrow.
12345678910111213141516171819202122232425import { FileDiff, type FileDiffLoadedFiles, parsePatchFiles,} from '@pierre/diffs';
const [patch] = parsePatchFiles(patchText, 'pull-42');const fileDiff = patch.files[0];
const instance = new FileDiff({ async loadDiffFiles(fileDiff): Promise<FileDiffLoadedFiles> { const response = await fetch( '/api/files?path=' + encodeURIComponent(fileDiff.name) ); // Return { oldFile, newFile }, or { oldFile: null, newFile } // for pure renames. // Include cacheKey values that change with revision or content. return response.json(); },});
instance.render({ fileDiff, containerWrapper: document.getElementById('diff-container'),});CodeView forwards many of those same options to each rendered item, while
adding CodeView-specific controls like layout, itemMetrics, stickyHeaders,
pointerEventsOnScroll, and smoothScrollSettings. Its class instance also
exposes item-level methods such as addItems, getItem, removeItem, and
updateItem. See CodeView for the dedicated guide.
Header customization and collapsing behavior:
renderHeaderPrefix to render custom UI at the beginning of the built-in
FileDiff header, before the filename and icon, while keeping the default
header layout.renderHeaderFilenameSuffix for compact UI immediately after the
displayed filename, such as badges, review state, or generated-file labels.renderHeaderMetadata to render custom UI at the end of the built-in
FileDiff header, after the diff stats, while keeping the default header
layout.renderCustomHeader when you want to replace the built-in header content
entirely.File, header callbacks receive file: FileContents.collapsed in constructor options to hide file body content while keeping
the file header visible.onPostRender(node, instance, phase) is a DOM-node lifecycle callback. It fires
with phase: 'mount' after the first committed render or hydration for a
container node, phase: 'update' after later DOM-committing renders, and
phase: 'unmount' before a mounted container node is removed, replaced, cleaned
up, or recycled.
Use this callback when native DOM selection listeners need access to the
rendered diff node or its shadow DOM. Attach listeners such as selectstart and
selectionchange during mount, and remove them during unmount with teardown
state captured by node.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import { FileDiff } from '@pierre/diffs';
const cleanupByNode = new WeakMap<HTMLElement, () => void>();
const instance = new FileDiff({ onPostRender(node, _instance, phase) { if (phase === 'mount') { const selectionRoot = node.shadowRoot ?? node;
const handleSelectStart = () => { console.log('selection started in diff'); };
const handleSelectionChange = () => { const selection = document.getSelection(); if (selection == null || selection.isCollapsed) { return; }
if ( !containsSelectionNode(selectionRoot, selection.anchorNode) && !containsSelectionNode(selectionRoot, selection.focusNode) ) { return; }
console.log('selected text', selection.toString()); };
selectionRoot.addEventListener('selectstart', handleSelectStart); document.addEventListener('selectionchange', handleSelectionChange); cleanupByNode.set(node, () => { selectionRoot.removeEventListener('selectstart', handleSelectStart); document.removeEventListener('selectionchange', handleSelectionChange); }); return; }
if (phase === 'unmount') { cleanupByNode.get(node)?.(); cleanupByNode.delete(node); } },});
function containsSelectionNode(root: Node, node: Node | null) { return node != null && root.contains(node);}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350import { FileDiff, type DiffLineAnnotation, type DiffTokenEventBaseProps, type FileDiffContentsLoader,} from '@pierre/diffs';
interface ThreadMetadata { threadId: string;}
// Keep this array in application-owned storage when annotations can change.let lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [ { side: 'additions', lineNumber: 0, metadata: { threadId: 'file-summary' }, }, { side: 'additions', // One-based line number on the selected file side. lineNumber: 5, metadata: { threadId: 'abc' }, },];
// All available options for the FileDiff classconst instance = new FileDiff<ThreadMetadata>({
// ───────────────────────────────────────────────────────────── // THEMING // ─────────────────────────────────────────────────────────────
// Theme for syntax highlighting. Can be a single theme name or an // object with 'dark' and 'light' keys for automatic switching. // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme. // See: https://shiki.style/themes theme: { dark: 'pierre-dark', light: 'pierre-light' },
// When using dark/light theme object, this controls which is used: // 'system' (default) - follows OS preference // 'dark' or 'light' - forces specific theme themeType: 'system',
// Choose the Shiki engine: // 'shiki-js' (default) - JavaScript regex engine // 'shiki-wasm' - WASM Oniguruma engine preferredHighlighter: 'shiki-js',
// ───────────────────────────────────────────────────────────── // DIFF DISPLAY // ─────────────────────────────────────────────────────────────
// 'split' (default) - side-by-side view // 'unified' - single column view diffStyle: 'split',
// Line change indicators: // 'bars' (default) - colored bars on left edge // 'classic' - '+' and '-' characters // 'none' - no indicators diffIndicators: 'bars',
// Show colored backgrounds on changed lines (default: false) disableBackground: false,
// ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ─────────────────────────────────────────────────────────────
// What to show between diff hunks: // 'line-info' (default) - shows collapsed line count, clickable to expand // WebKit/Safari bug in version 26 as of this writing: if you use // 'renderGutterUtility' with hunkSeparators: 'line-info', you may see // scroll jumping while moving the mouse. // Recommended: use the built-in gutter utility button by not using this API, // or switch to another hunk separator type (for example 'line-info-basic'). // For a status of this bug, visit: // https://bugs.webkit.org/show_bug.cgi?id=308027 // 'line-info-basic' - slightly more compact full width line-info variant // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@' // 'simple' - subtle bar separator // Prefer the built-in presets plus CSS first (see the Hunk Separators // section). The low-level functional API is documented only for vanilla JS, // is being phased out, and should be treated as a last-resort escape hatch. hunkSeparators: 'line-info',
// Force unchanged context to always render (default: false) // Requires oldFile/newFile API or FileDiffMetadata with newLines expandUnchanged: false,
// Lines revealed per click when expanding collapsed regions expansionLineCount: 100,
// Load full contents for partial changed/renamed diffs parsed from patches. // Return both sides for changed diffs and oldFile: null for pure renames. // Added/deleted diffs do not need to be hydrated. loadDiffFiles: undefined as FileDiffContentsLoader | undefined,
// Auto-expand collapsed context regions at or below this size // (default: 1) collapsedContextThreshold: 1,
// ───────────────────────────────────────────────────────────── // INLINE CHANGE HIGHLIGHTING // ─────────────────────────────────────────────────────────────
// Highlight changed portions within modified lines: // 'word-alt' (default) - word boundaries, minimizes single-char gaps // 'word' - word boundaries // 'char' - character-level granularity // 'none' - disable inline highlighting lineDiffType: 'word-alt',
// Skip inline diff for lines exceeding this length maxLineDiffLength: 1000,
// ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ─────────────────────────────────────────────────────────────
// Show line numbers (default: true) disableLineNumbers: false,
// Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll',
// Hide the file header with filename and stats disableFileHeader: false,
// Rethrow rendering errors instead of catching and displaying them // in the DOM. Useful for testing or custom error handling. // (default: false) disableErrorHandling: false,
// Skip syntax highlighting for lines exceeding this length tokenizeMaxLineLength: 1000,
// Fires after hydration, after DOM-committing render updates, and before // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'. // Receives the outer diffs container element. // Useful when you want to measure, observe, or clean up DOM-node state. // You can access the shadow DOM from here if you need to inspect lines. onPostRender(node, fileDiffInstance, phase) { if (phase === 'unmount') { return; }
const codeLines = node.shadowRoot?.querySelectorAll('[data-line]'); console.log('rendered line count', codeLines?.length ?? 0); },
// ───────────────────────────────────────────────────────────── // LINE SELECTION // ─────────────────────────────────────────────────────────────
// Enable click-to-select on line numbers enableLineSelection: false,
// Callbacks for selection events onLineSelectionStart(range) { // Fires on pointer down }, onLineSelectionChange(range) { // Fires while dragging when range grows/shrinks (not initial down) }, onLineSelectionEnd(range) { // Fires on pointer up }, onLineSelected(range) { // Fires on pointer up with final range (or null) },
// ───────────────────────────────────────────────────────────── // MOUSE EVENTS // ─────────────────────────────────────────────────────────────
// Line hover effect. Sets a data-hovered attribute on the // hovered element(s), which you can style via the Styling API. // 'disabled' (default) - no hover effect // 'both' - highlights both line number and line content // 'number' - highlights only the line number // 'line' - highlights only the line content lineHoverHighlight: 'disabled',
// Must be true to enable renderGutterUtility enableGutterUtility: false,
// Fires when clicking anywhere on a line onLineClick({ lineNumber, side, event }) {},
// Fires when clicking anywhere in the line number column onLineNumberClick({ lineNumber, side, event }) {},
// Fires when mouse enters a line onLineEnter({ lineNumber, side }) {},
// Fires when mouse leaves a line onLineLeave({ lineNumber, side }) {},
// See the Token Hooks section for examples, performance notes, // and Worker Pool caveats. // These APIs preserve more token-level DOM metadata, which increases DOM // size and can have a noticeable cost on larger files. // Experimental token callbacks. Useful for token-aware UIs such as // LSP textDocument/hover tooltips or temporary token styling. // lineCharStart is zero-based and lineCharEnd is end-exclusive. // If both token and line click handlers are provided, both will fire. onTokenClick({ tokenText, lineNumber, lineCharStart, lineCharEnd, side, }: DiffTokenEventBaseProps) {}, onTokenEnter({ tokenText, lineNumber, lineCharStart, lineCharEnd, side, tokenElement, }: DiffTokenEventBaseProps) {}, onTokenLeave({ tokenText, side, tokenElement }: DiffTokenEventBaseProps) {},
// Include whitespace-only tokens in token callbacks (default: false) enableTokenInteractionsOnWhitespace: false,
// Experimental: force token wrappers/data-char output even when no token // callbacks are attached. Usually unnecessary unless you want custom styling. // This also increases DOM size and may impact larger files. useTokenTransformer: false,
// Preferred: built-in gutter utility button (+) // No render callback needed; callback receives a SelectedLineRange. // Callback does not control visibility; enableGutterUtility does. // Fires on pointer up only: // - click => single-line range // - drag => final range at release // Selection lifecycle callbacks also fire for a gutter utility gesture, // even when line selection is disabled. // Can click a single line or apply to a drag interaction started pointer // down on the button onGutterUtilityClick(range) { console.log(range.start, range.end, range.side, range.endSide); },
// ───────────────────────────────────────────────────────────── // RENDER CALLBACKS // ─────────────────────────────────────────────────────────────
// Diff header render callbacks receive FileDiffMetadata directly. // This includes renderCustomHeader, renderHeaderPrefix, // renderHeaderFilenameSuffix, and renderHeaderMetadata. // renderHeaderPrefix renders at the beginning of the built-in header, // before the filename and icon. // renderHeaderFilenameSuffix renders immediately after the displayed filename. // renderHeaderMetadata renders at the end of the built-in header, // after the +/- line metrics. // renderCustomHeader replaces the built-in header content entirely. // // Render custom content at the beginning of the built-in header. renderHeaderPrefix(fileDiff) { const span = document.createElement('span'); span.textContent = fileDiff.type; return span; },
// Render custom content at the end of the built-in header. renderHeaderMetadata(fileDiff) { const span = document.createElement('span'); span.textContent = fileDiff.name; return span; },
// Render annotations on specific lines. Use lineNumber: 0 for a file-level // annotation above the first hunk separator or diff row. renderAnnotation(annotation) { const element = document.createElement('div'); element.textContent = annotation.metadata.threadId; return element; },
// Advanced: render your own custom gutter utility UI on hover. // Prefer onGutterUtilityClick unless you need fully custom content. // Requires enableGutterUtility: true // Do not combine with onGutterUtilityClick. // WebKit/Safari bug in version 26 as of this writing: if you use this custom // API with hunkSeparators: 'line-info', you may see scroll jumping while // moving the mouse. // Recommended: use the built-in gutter utility API, or switch hunk // separators to 'line-info-basic', 'metadata', or 'simple'. See: // https://bugs.webkit.org/show_bug.cgi?id=308027 renderGutterUtility(getHoveredLine) { const button = document.createElement('button'); button.textContent = '+'; button.addEventListener('click', () => { const { lineNumber, side } = getHoveredLine(); console.log('Clicked line', lineNumber, 'on', side); }); return button; },
});
// ─────────────────────────────────────────────────────────────// INSTANCE METHODS// ─────────────────────────────────────────────────────────────
// Render the diffinstance.render({ // Use oldFile: null for a new file or newFile: null for a deleted file. Do // not omit only one side. oldFile: { name: 'file.ts', contents: '...' }, newFile: { name: 'file.ts', contents: '...' }, lineAnnotations, containerWrapper: document.body,});
// Update options (full replacement, not merge)instance.setOptions({ ...instance.options, diffStyle: 'unified' });instance.rerender();
// Update line annotations after initial renderlineAnnotations = [ { side: 'additions', lineNumber: 0, metadata: { threadId: 'file-summary' } }, { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } }];instance.setLineAnnotations(lineAnnotations);instance.rerender();
// Programmatically control selected linesinstance.setSelectedLines({ start: 12, end: 22, side: 'additions', endSide: 'deletions',});
// Programmatically expand a collapsed hunkinstance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'both'
// Expand an entire collapsed hunkinstance.expandHunk(0, 'both', Number.POSITIVE_INFINITY);
// Change the active theme typeinstance.setThemeType('dark'); // 'dark' | 'light' | 'system'
// Clean up (removes DOM, event listeners, clears state)instance.cleanUp();Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and
useTokenTransformer are documented in Token Hooks, including
examples, payload details, performance notes, and Worker Pool caveats.
Start with the Hunk Separators section first. In most cases,
styling the built-in separator markup with unsafeCSS is the better approach.
If that is still not enough, the low-level hunkSeparators(hunkData, instance)
function remains available in Vanilla JS as a last-resort escape hatch. It is
being phased out and is not the recommended path for new integrations, but the
example below shows how it works when you truly need to render your own
elements:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960import { FileDiff } from '@pierre/diffs';
// This is a low-level vanilla-only escape hatch.// Prefer built-in hunk separators plus CSS customization when possible.// This function-based API is being phased out and does not fit the// container-managed and virtualization-oriented APIs.
// A hunk separator that utilizes the existing grid to have// a number column and a content column where neither will// scroll with the codeconst instance = new FileDiff({ hunkSeparators(hunkData: HunkData) { const fragment = document.createDocumentFragment(); const numCol = document.createElement('div'); numCol.textContent = `${hunkData.lines}`; numCol.style.position = 'sticky'; numCol.style.left = '0'; numCol.style.backgroundColor = 'var(--diffs-bg)'; numCol.style.zIndex = '2'; fragment.appendChild(numCol); const contentCol = document.createElement('div'); contentCol.textContent = 'unmodified lines'; contentCol.style.position = 'sticky'; contentCol.style.width = 'var(--diffs-column-content-width)'; contentCol.style.left = 'var(--diffs-column-number-width)'; fragment.appendChild(contentCol); return fragment; },})
// If you want to create a single column that spans both colums// and doesn't scroll, you can do something like this:const instance2 = new FileDiff({ hunkSeparators(hunkData: HunkData) { const wrapper = document.createElement('div'); wrapper.style.gridColumn = 'span 2'; const contentCol = document.createElement('div'); contentCol.textContent = `${hunkData.lines} unmodified lines`; contentCol.style.position = 'sticky'; contentCol.style.width = 'var(--diffs-column-width)'; contentCol.style.left = '0'; wrapper.appendChild(contentCol); return wrapper; },})
// If you want to create a single column that's aligned with the content// column and doesn't scroll, you can do something like this:const instance3 = new FileDiff({ hunkSeparators(hunkData: HunkData) { const wrapper = document.createElement('div'); wrapper.style.gridColumn = '2 / 3'; wrapper.textContent = `${hunkData.lines} unmodified lines`; wrapper.style.position = 'sticky'; wrapper.style.width = 'var(--diffs-column-content-width)'; wrapper.style.left = 'var(--diffs-column-number-width)'; return wrapper; },})
For most use cases, you should use the higher-level components like FileDiff
and File (vanilla JS) or the React components (MultiFileDiff, FileDiff,
PatchDiff, File). These renderers are low-level building blocks intended
for advanced use cases.
These renderer classes handle the low-level work of parsing and rendering code with syntax highlighting. Useful when you need direct access to the rendered output as HAST nodes or HTML strings for custom rendering pipelines.
Takes a FileDiffMetadata data structure and renders out the raw HAST
(Hypertext Abstract Syntax Tree) elements for diff hunks. You can generate
FileDiffMetadata via parseDiffFromFile or parsePatchFiles utility
functions.
1234567891011121314151617181920212223242526272829303132333435363738394041424344import { DiffHunksRenderer, type FileDiffMetadata, type HunksRenderResult, parseDiffFromFile,} from '@pierre/diffs';
const instance = new DiffHunksRenderer();
// Set options (this is a full replacement, not a merge)instance.setOptions({ theme: 'github-dark', diffStyle: 'split' });
// Parse diff content from 2 versions of a fileconst fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'file.ts', contents: 'const greeting = "Hello";' }, { name: 'file.ts', contents: 'const greeting = "Hello, World!";' });
// Render hunks (async - waits for highlighter initialization)const result: HunksRenderResult = await instance.asyncRender(fileDiff);
// result contains hast nodes for each column based on diffStyle:// - 'split' mode: additionsAST and deletionsAST (side-by-side)// - 'unified' mode: unifiedAST only (single column)// - preNode: the wrapper <pre> element as a hast node// - headerNode: the file header element// - hunkData: metadata about each hunk (for custom separators)
// Render to a complete HTML string (includes <pre> and <code> wrappers)const fullHTML: string = instance.renderFullHTML(result);
// Or render just a specific column to HTMLconst additionsHTML: string = instance.renderPartialHTML( instance.renderCodeAST('additions', result), 'additions' // wraps in <code data-additions>);
// Or render without the <code> wrapperconst rawHTML: string = instance.renderPartialHTML( instance.renderCodeAST('additions', result));
// Or get the full AST for further transformationconst fullAST = instance.renderFullAST(result);Takes a FileContents object (just a filename and contents string) and renders
syntax-highlighted code as HAST elements. Useful for rendering single files
without any diff context.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849import { FileRenderer, type FileContents, type FileRenderResult,} from '@pierre/diffs';
const instance = new FileRenderer();
// Set options (this is a full replacement, not a merge)instance.setOptions({ theme: 'pierre-dark', overflow: 'scroll', disableLineNumbers: false, disableFileHeader: false, // Starting line number (useful for showing snippets) startingLineNumber: 1, // Skip syntax highlighting for very long lines tokenizeMaxLineLength: 1000,});
const file: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}
export { greet };`,};
// Render file (async - waits for highlighter initialization)const result: FileRenderResult = await instance.asyncRender(file);
// result contains:// - gutterAST/contentAST: arrays of hast ElementContent nodes for each line// - preAST: the wrapper <pre> element as a hast node// - headerAST: the file header element (if not disabled)// - totalLines: number of lines in the file// - themeStyles: CSS custom properties for theming
// Render to a complete HTML string (includes <pre> wrapper)const fullHTML: string = instance.renderFullHTML(result);
// Or render just the code lines to HTMLconst partialHTML: string = instance.renderPartialHTML( instance.renderCodeAST(result));
// Or get the full AST for further transformationconst fullAST = instance.renderFullAST(result);CodeView is the high-level API for rendering one large scroll region that
can contain files, diffs, or both.
CodeView renders a list of CodeViewItem[] and manages the hard parts for
you: virtualization, measured layout reconciliation, sticky headers, selection
across items, and scrollTo targeting by item, line, or absolute position.
You can check out a live demo at diffshub.com
1234567891011121314151617181920212223242526272829type CodeViewFileItem<T = undefined> = { type: 'file'; id: string; file: FileContents; annotations?: LineAnnotation<T>[]; collapsed?: boolean; // Enables per-item edit mode when editing is configured. edit?: boolean; // Any time a value changes on an item, you must increment the version. This // is an intentional escape hatch to avoid potentially expensive deep object // equality checks version?: number;};
type CodeViewDiffItem<T = undefined> = { type: 'diff'; id: string; fileDiff: FileDiffMetadata; annotations?: DiffLineAnnotation<T>[]; collapsed?: boolean; // Enables per-item edit mode when editing is configured. edit?: boolean; // Any time a value changes on an item, you must increment the version. This // is an intentional escape hatch to avoid potentially expensive deep object // equality checks version?: number;};
type CodeViewItem<T = undefined> = CodeViewFileItem<T> | CodeViewDiffItem<T>;If you need to render one or more files or diffs in a scrollable container, use CodeView to avoid handling scaling yourself.
file and diff items.scrollTo APIs for items, line targets, and raw scroll positions.CodeView is designed to enable easy rendering of any files or diffs,
regardless of scale, so its data model does not depend on traditional
immutability or deep equality checks, which can quickly become expensive.
id. That id is how scrollTo, line
selection, getItem, removeItem, updateItem, and reconciliation find the
correct records.{ type: 'file', file } or { type: 'diff', fileDiff }.version so CodeView can make an efficient targeted updates
based only on what changed without recomputing everything.{ id, range } instead of only a line range.collapsed property on an item controls whether file or diff content is
shown. You'll have to wire up your own custom header or utilities if you want
to control it interactively. Remember to update version when this value
changes.edit property enables edit mode for an item when
React CodeView has an EditProvider, or vanilla CodeView has a
createEditor option. Update version when toggling it.layout, itemMetrics, stickyHeaders,
pointerEventsOnScroll, and smoothScrollSettings allow you to configure the
scroll view. All other options are shared between all files and diffs.loadDiffFiles is one of those shared diff options. It applies to diff items
rendered inside CodeView, which is useful for large patch-driven review UIs
where full file contents should be fetched only when users expand unchanged
context. Hydration updates the existing fileDiff object in place, so keep
its identity stable when the hydrated metadata should persist across later
renders.React CodeView gets its editor factory from the nearest EditProvider; unlike
vanilla CodeView, it does not accept createEditor directly or inside
options. Keep the provider mounted, set edit: true on the items that should
be editable, and pass creation-time item-editor behavior through
editorOptions. onItemEditChange reports live contents with the owning item.
If a session produces a change, onItemEditComplete reports its latest contents
when editing is disabled or the item is collapsed or removed. Direct reset,
cleanup, and viewer unmount are silent.
Each edited item receives an independent editor whose history survives
virtualization. Changes to the provider factory or editorOptions do not
disturb active sessions; their latest values apply the next time an item enters
edit mode.
For vanilla CodeView, keep using CodeViewOptions.createEditor. It exposes
the same item-aware callbacks, and CodeView owns each returned editor's
lifecycle.
Autofocus is opt-in per edit session. In React, pass a stable editorOptions
object whose onAttach callback targets the first editable row with a visible
top edge:
const editorOptions: EditorOptions<ThreadMetadata> = {
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
};
<CodeView items={items} editorOptions={editorOptions} />;
For vanilla CodeView, add the callback while constructing each item editor:
const viewer = new CodeView({
createEditor(options) {
return new Editor({
...options,
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
});
},
});
preventScroll: true preserves the viewer's scroll position. CodeView keeps
the editor alive while an item is recycled, so the callback does not steal focus
again when that item re-enters the virtualized window. If several items start an
autofocusing edit session together, the last callback to run owns focus. See
Autofocus on Attach for explicit line targets,
viewport fallback, offsets, and selection-state behavior.
For controlling layout inside and between items in CodeView, you can use the
layout prop. Unlike itemMetrics, these values actually set internal values
and adjust the layout. You should not apply these values with CSS yourself.
12345678910options: { layout: { // Controls how much spacing before files/diffs paddingTop: 16, // Controls how much spacing after files/diffs paddingBottom: 16, // Controls how much spacing between files/diffs gap: 12, }}Use the renderCodeViewHeader and renderCodeViewFooter options to render your
own element at the start and end of the scroll content; before the first item
and after the last one.
CodeView doesn't apply any positioning of its own, and they don't
affect stickyHeaders behavior for item headers.CodeView measures the element on mount and
tracks later size changes with a ResizeObserver, so async content, font
loads, and late-arriving React portals stay coherent, and scroll position is
re-anchored when a header's height changes.renderCodeViewHeader /
renderCodeViewFooter props. The node is portaled into a host element the
viewer manages, so state-driven updates just work. Memoize the callbacks with
useCallback (listing any state they read as deps) so the header and footer
don't re-render on every parent render (don't trust React Compiler).undefined empties the host. The callback's presence
controls whether the host element exists at all.data-diffs-code-view-header and
data-diffs-code-view-footer attributes for styling, and are exposed via
getHeaderElement() / getFooterElement() on the instance.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576import { parseDiffFromFile, type CodeViewItem } from '@pierre/diffs';import { CodeView } from '@pierre/diffs/react';import { useCallback, useMemo, useState } from 'react';
const oldAppFile = { name: 'src/app.ts', contents: `export function greet() { return "hello";}`,};
const newAppFile = { name: 'src/app.ts', contents: `export function greet(name: string) { return "hello " + name;}`,};
export function ReviewSurface() { const [approved, setApproved] = useState(false);
const items = useMemo<CodeViewItem[]>( () => [ { id: 'diff:src/app.ts', type: 'diff', fileDiff: parseDiffFromFile(oldAppFile, newAppFile), }, ], [] );
// Rendered before the first item and portaled into a host element the // viewer manages inside the scroll container. Not virtualized: always in // the DOM. Memoize render callbacks so the viewer doesn't re-render the // header on every parent render. (don't trust react compiler). const renderHeader = useCallback(() => { return ( <section className="pr-summary"> <h2>Add personalized greetings</h2> <p>Threads a name through greet() so callers control the message.</p> <span>1 file changed</span> </section> ); }, []);
// Rendered after the last item. Plain state-driven JSX: when it re-renders // at a different height, the viewer re-measures automatically. List the // state the callback reads in the deps so updates flow through. (don't trust // react compiler). const renderFooter = useCallback(() => { return ( <div className="review-actions"> <span>{approved ? 'Approved' : 'Reviewed 1 of 1 files'}</span> <button type="button" onClick={() => setApproved(true)}> Approve changes </button> </div> ); }, [approved]);
return ( <CodeView items={items} style={{ height: 600, overflow: 'auto' }} options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, }} renderCodeViewHeader={renderHeader} renderCodeViewFooter={renderFooter} /> );}CodeView uses a line-based virtualization system that renders a minimal
snapshot to keep browser performance top of mind. Under the hood, it estimates
the mathematical size of all code, then corrects and caches those estimates as
you scroll and more content renders. These estimates are based on itemMetrics,
and can be verified with the __devOnlyValidateItemHeights property.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354const options: CodeViewOptions = { // As a general rule if you are using any `unsafeCSS` or custom line-height, // you should test with `__devOnlyValidateItemHeights` enabled to ensure // that estimations are working correctly. Otherwise CodeView's layout and // scrolling can become inaccurate. Don't leave this property on because it // incurs a significant performance penalty. With this property enabled, open // the console and scroll around your CodeView. If you don't see any console // errors you should be good. __devOnlyValidateItemHeights: true,
// Use `itemMetrics` to correct any issues identified by // `__devOnlyValidateItemHeights`. If you are only using default settings then // you shouldn't need to use `itemMetrics` at all. All fields are optional. itemMetrics: { // This should match your defined line-height for code. No need to define if // you're using the default line-height. lineHeight: number | undefined;
// If you've customized the header for files or diffs via unsafeCSS in a way // that changes how tall they are, you'll need to set that new height here. diffHeaderHeight: number | undefined;
// -------------------
// Advanced Measurement Values - you probably should NEVER set these next // values unless you absolutely know what you're doing and fully understand the // different rendering scenarios for files and diffs
// If you've customized hunk separators at all with unsafeCSS that changes // their height, you need to define that new height here. If you've just set // a different type, their sizes will be handled automatically for you hunkSeparatorHeight: number | undefined;
// Vertical spacing used around hunks, also gets used in calculations for // padding if paddingTop/Bottom are not defined. The rules for this are // dependent on the type of hunk separators that are used. Normally you should // never need to edit this unless applying custom CSS to hunk separators that // changes the spacing around them. DO NOT EDIT THIS UNLESS you fully // understand how the CSS and HTML work. spacing: number | undefined;
// Top padding applied after the file header, or before content when // the header is disabled. This should match the effects of your unsafeCSS, it // does not actually change paddingTop. Like the spacing prop, this is for // advanced use cases that fully understand how the HTML and CSS work. paddingTop: number | undefined;
// Bottom padding applied after the file content and only if there is // code to render. This should match the effects of your unsafeCSS, it does not // actually change paddingBottom. Like the spacing prop, this is for advanced // use cases that fully understand how the HTML and CSS work. paddingBottom: number | undefined; }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143import { parseDiffFromFile, type CodeViewItem, type CodeViewLineSelection,} from '@pierre/diffs';import { CodeView, type CodeViewHandle } from '@pierre/diffs/react';import { useMemo, useRef, useState } from 'react';
const oldAppFile = { name: 'src/app.ts', contents: `export function greet() { return "hello";}`,};
const newAppFile = { name: 'src/app.ts', contents: `export function greet(name: string) { return "hello " + name;}`,};
const readmeFile = { name: 'README.md', contents: `# Docs
This file is rendered inline with the diff list.`,};
const changelogFile = { name: 'CHANGELOG.md', contents: `# Changelog
- Added personalized greetings.`,};
export function ReviewSurface() { const viewerRef = useRef<CodeViewHandle | null>(null); const [selectedLines, setSelectedLines] = useState<CodeViewLineSelection | null>(null);
const initialItems = useMemo<CodeViewItem[]>( () => [ { id: 'diff:src/app.ts', type: 'diff', fileDiff: parseDiffFromFile(oldAppFile, newAppFile), annotations: [{ side: 'additions', lineNumber: 2 }], }, { id: 'file:README.md', type: 'file', file: readmeFile, }, ], [] );
return ( <> <button type="button" onClick={() => viewerRef.current?.scrollTo({ type: 'line', id: 'diff:src/app.ts', lineNumber: 2, side: 'additions', behavior: 'smooth-auto', }) } > Jump to change </button>
<button type="button" onClick={() => { const viewer = viewerRef.current; const item = viewer?.getItem('diff:src/app.ts'); if (item?.type !== 'diff') { return; }
viewer.updateItem({ ...item, version: item.version != null ? item.version + 1 : 1, collapsed: !item.collapsed, }); }} > Toggle app diff </button>
<button type="button" onClick={() => { const viewer = viewerRef.current; if (viewer?.getItem('file:CHANGELOG.md') != null) { return; }
viewer?.addItems([ { id: 'file:CHANGELOG.md', type: 'file', file: changelogFile, }, ]); }} > Append changelog </button>
<CodeView ref={viewerRef} initialItems={initialItems} style={{ height: 600, overflow: 'auto' }} options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, enableLineSelection: true, enableGutterUtility: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, }} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} renderHeaderPrefix={(item) => ( <span>{item.type === 'diff' ? 'Diff' : 'File'}</span> )} renderHeaderMetadata={(item) => item.type === 'diff' ? <span>{item.fileDiff.type}</span> : <span>file</span> } renderAnnotation={(annotation, item) => ( <div> Note for {item.id} on line {annotation.lineNumber} </div> )} /> </> );}React CodeView supports two item ownership models. Use one per mounted viewer;
do not switch between them without remounting with a new key.
| Mode | Use | Item prop | Item updates |
|---|---|---|---|
| Controlled | React state owns the complete item list | items | Publish a new items array. Append-only changes are optimized; other changes reconcile the list. |
| Imperative | The viewer instance owns the item list after mount | optional initialItems | Use the ref APIs: addItems, getItem, removeItem, and updateItem. |
Use controlled mode when item data already lives naturally in React state and
the list is small enough that mutating arrays or items is cheap. Use imperative
mode for very large or streaming surfaces where routing every item update
through React would be expensive. In imperative mode, omit items, optionally
seed the viewer with initialItems, and use the CodeViewHandle to add new
items, remove items, or update existing ones.
When an editable item has annotations, onItemEditChange receives the owning
item, its edited FileContents, and the complete current annotation collection.
For a diff item, those contents represent the editable new-file side. When the
emitted annotation array is a different object, replace item.annotations and
increment the item's version. CodeView does not write either value for you.
The callback annotation type is LineAnnotation[] | DiffLineAnnotation[]: file
items emit the side-less shape and diff items emit annotations with a side.
Use isFileAnnotationCollection or isDiffAnnotationCollection to narrow it
before reading shape-specific fields.
When React owns the CodeView item list through items, publish a new items
array containing the updated item inside flushSync so annotation placement
updates with the edited content before paint. When CodeView owns the list in
React's imperative mode, call updateItem through the component ref; in vanilla
JS, call updateItem on the CodeView instance. Skip the update when the
emitted array is the same object as the item's current annotations, since
ordinary same-line typing reuses that array.
Keep onItemEditComplete focused on committing the final file contents or
rebuilding fileDiff with a fresh cacheKey. Live annotations should already
be synchronized through onItemEditChange. See
Editing with line annotations for
remapping rules, stable metadata IDs, and annotation-content lifetime guidance.
items for controlled item ownership.initialItems instead of items for imperative item
ownership. initialItems seeds the viewer once; later item changes should go
through the ref.addItems, removeItem, and updateItem require imperative item
ownership and throw if the viewer is controlled with items.selectedLines and onSelectedLinesChange when selection needs
to live in component state.scrollTo, setSelectedLines, getSelectedLines,
clearSelectedLines, getItem, updateItem, addItems, removeItem, and
getInstance.renderCustomHeader, renderHeaderPrefix, renderHeaderFilenameSuffix,
renderHeaderMetadata, renderAnnotation, and renderGutterUtility receive
the whole CodeViewItem, which makes it easy to branch on item.type.CodeView owns a scrollable root that you set up once and
update over time.setup(root) once with the scrollable container.setItems, addItem, or addItems to populate the
viewer, and getItem, removeItem, or updateItem for item-level imperative
changes.context
argument containing the current viewer item and instance.onPostRender receives (node, instance, phase, context). Its unmount
phase will fire when an item scrolls out of the rendered window and CodeView
recycles that item's DOM shell.CodeView temporarily disables pointer events on rendered content
while scrolling for smoother scroll performance. Set
pointerEventsOnScroll: true only when pointer interactions must remain
active during scroll.cleanUp() when the viewer is removed so obs