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 components |
@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 |
Install the
diffs agent skill with
the Skills CLI for access to the entire API and
common recipes, regardless of your integration method.
1npx skills add pierrecomputer/pierre --skill diffsAlternatively, you can copy-paste this prompt to your agent to install the skill and point it to these docs as plain-text.
12345678Set up @pierre/diffs in this project.
Install its agent skill first so you have the full API reference:npx skills add pierrecomputer/pierre --skill diffs
Then follow that skill to add @pierre/diffs.Docs: https://diffs.com/docsFull reference for LLMs: https://diffs.com/llms-full.txtThe "For Agents" button on the home page copies the same prompt.
Our documentation is also available in condensed, Markdown-formatted plain text, available in two versions:
Copy and paste as needed, or provide the URLs to your agent.
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.
12345678910111213141516171819202122232425262728293031323334353637import 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 revision identity for rendering and Worker Pool // highlight caching 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 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.
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 components 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 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
components use edit, and optional editorOptions and editStateKey;
CodeView uses item edit flags, editorOptions, and optional
getEditStateKey. Forward the provider factory's document kind, options, and
key to new Editor(). Each active component or item receives an independent
editor. Two active editors of the same kind cannot share one key.
UnresolvedFile is not editable. See
Edit mode → Enable editing and
CodeView → Editing for lifecycle and callback details.
editStateKey retains state only in memory. See
Retain edit state in memory and
Persist a draft across reloads for
the distinction.
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).
Every editable component (File, FileDiff, MultiFileDiff, PatchDiff) also
takes onEditChange — the component's live change stream — and onEditComplete
— the accept/reject boundary for content-changing edit sessions. Change events
expose the attached editor; completion events expose the detached editor and
final state during the callback. A missing completion handler rejects. Edit mode
owns annotation positions during a session, so do not sync them back per
keystroke; accepting installs the final file or diff, but you should make sure
to hold onto the final versions provided to ensure you don't revert the changes.
See Handle changes and completion
and Line annotations.
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(). Pass an optional
editStateKey as the constructor's third argument for same-runtime retention.
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).
Editable components take onEditChange (the live change stream) and
onEditComplete (the accept/reject boundary for content-changing sessions) as
FileOptions / FileDiffOptions props. Change events expose the attached
editor; completion events expose the detached editor and final state during the
callback. A missing completion handler automatically rejects. Edit mode owns
annotation positions during a session, so do not sync them back per keystroke;
accepting installs the final collection (just don't forget to grab references to
the updated file/fileDiff and annotations if accepting). See
Handle changes and completion and
Line annotations.
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. Editable items use
createEditor(documentKind, options, editStateKey) and optional
getEditStateKey(item). See CodeView for the dedicated guide and
Retain edit state in memory for state
inspection and clearing.
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(event, item) reports each live change with
the owning item and its attached editor.
onItemEditComplete(event, item, nextItem) is the accept/reject boundary for
content-changing sessions. Return 'accept' to install nextItem; a controlled
component must also mirror it into its own state. Return 'reject' to restore
the latest external item. A missing handler will automatically reject.
Collapsing or scrolling an item out of view suspends its session rather than
completing it. Disabling editing, removing an item, reset(), cleanUp(), and
viewer unmount end changed sessions. Removal and teardown callbacks can capture
the result.
Each edited item receives an independent editor whose document, history,
selections, and horizontal code scroll survive virtualization. Per-item editors
never own or restore CodeView's shared vertical position. Changes to the
provider factory or editorOptions do not disturb active sessions; their latest
values apply the next time an item enters edit mode.
Use getEditStateKey(item) to retain a resumable session after editing ends.
Said another way, next time you edit the file in the same session, undo history,
selection state and caret position will be preserved.
React passes the resolved key through its EditProvider; vanilla CodeView
passes it to createEditor as the third argument. The resolver runs only when
the item editor is created, so changing it does not re-key an active editor.
CodeView will not derive this key for you.
Edit state retention is bounded and in memory only. Accepting or rejecting an
item does not clear its keyed state. See
Retain edit state in memory for
EditStateManager, clearing, and durable persistence guidance.
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({
getEditStateKey(item) {
return `review:${item.id}`;
},
createEditor(documentKind, options, editStateKey) {
return new Editor(
documentKind,
{
...options,
onAttach(editor) {
// This would force an initial line focus when edit is enabled on
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
},
editStateKey
);
},
});
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
Control the editor 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 lists 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.
While an item is editing, edit mode owns its annotation positions and re-renders
the annotation rows as the document changes — you should not sync them back
through onItemEditChange. event.lineAnnotations on that callback reports the
current collection if you want to observe it. A genuinely new item.annotations
collection, published with a version bump, replaces the editor's internal
remaps and treats its coordinates as positions in the current document. Defer
external annotation updates unless you have recomputed their positions against
that document.
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.
On completion, the nextItem CodeView builds already carries the final
annotation collection (event.lineAnnotations) alongside the accepted
file/fileDiff — returning 'accept' installs both. See
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 observers,
timers, and DOM state are released.scrollTo supports four target shapes:
123456789101112131415161718192021222324252627// Scroll directly to a file or diffviewer.scrollTo({ type: 'item', id: 'diff:src/app.ts', align: 'start' });
// Scroll directly to a line in a file or diffviewer.scrollTo({ type: 'line', id: 'diff:src/app.ts', lineNumber: 42, side: 'additions', align: 'center', behavior: 'smooth-auto',});
// Scroll directly to a range of lines in a file or diffviewer.scrollTo({ type: 'range', id: 'diff:src/app.ts', range: { start: 42, end: 48 }, align: 'center', behavior: 'smooth-auto',});
// Scroll directly to a pixel position in the CodeView scroll container. Generally// you want to avoid this for scrolling to a file or line because, due to layout// estimation: the target's actual position may change after it's rendered. It can// still be useful for scrolling to the top.viewer.scrollTo({ type: 'position', position: 0 });Line, range, and item targets resolve against live measured layout, so they continue to work even when wrapped lines or annotations change the rendered heights after initial paint.
If your scrollable region is only code, CodeView should usually be your
starting point. It is heavily optimized for that case: it owns the whole code
region, only renders what is visible, and is generally more performant and less
prone to blanking than the lower-level virtualization APIs.
Drop down to Virtualization when you need a more flexible,
mixed-content layout that CodeView cannot own directly. That flexibility comes
with trade-offs: the lower-level virtualizer always mounts every top-level file
or diff container, can blank more easily during aggressive scroll, and is
generally less performant than CodeView.
Edit mode is experimental and subject to change.
Edit mode adds text editing, multiple selections, undo and redo, search and replace, markers, and custom selection actions to Pierre file and diff components. The existing component continues to own syntax highlighting, diff layout, annotations, SSR markup, and virtualization.
Use it when a user needs to review and correct rendered code without replacing the surrounding file or diff UI with a separate editor.
For the complete lifecycle, see Handle changes and completion. The later sections cover history, focus, and view state, annotations and custom editing UI, and performance and loading. For component-specific integration details, see React API, Vanilla JS API, and CodeView editing.
An Editor attaches to one rendered File, VirtualizedFile, FileDiff, or
VirtualizedFileDiff. React components and CodeView create and attach editors
for you; standalone vanilla components use editor.edit(instance) directly.
Each editor is dedicated to either 'file' or 'file-diff' and attaches to one
matching component at a time.
Mount one stable EditProvider, forward all three factory arguments, and set
edit on a File, FileDiff, MultiFileDiff, or PatchDiff to begin
editing.
Pass onEditChange to monitor live changes while in edit mode. Use
onEditComplete to accept or reject content changes when the edit session ends.
See Handle changes and completion
for their lifecycle and event payloads.
12345678910111213141516171819202122232425262728293031const createEditor = useCallback<CreateEditor<undefined>>( (documentKind, editorOptions, editStateKey) => new Editor(documentKind, { ...defaultEditorOptions, ...editorOptions, }, editStateKey), []);
const editorOptions = useMemo<EditorOptions<undefined>>( () => ({ onAttach(editor) { editorRef.current = editor; }, }), []);
// Mount EditProvider near the root so its editors are available to every// editable File, diff, and CodeView.return ( <EditProvider createEditor={createEditor}> <File file={file} edit={editing} editStateKey="review:example.ts" editorOptions={editorOptions} onEditChange={handleChange} /> </EditProvider>);createEditor, editorOptions, editStateKey, and ownsVerticalViewport are
creation-time inputs for an editor. Changing them does not replace an active
editor; the latest values apply when a later edit session creates another
editor. Keep factories and object props stable across renders.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101import type { FileContents, FileEditCompleteHandler, FileOptions,} from '@pierre/diffs';import { Editor, type EditorOptions } from '@pierre/diffs/edit';import { EditProvider, File, Virtualizer } from '@pierre/diffs/react';import { useCallback, useRef, useState } from 'react';
const initialFile: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}
export { greet };`,};
const fileOptions: FileOptions<undefined> = { theme: { dark: 'pierre-dark', light: 'pierre-light' },};
const virtualizerStyle = { maxHeight: '16rem', overflow: 'auto', borderRadius: '0.5rem',} as const;
function createEditor( documentKind: 'file' | 'file-diff', options: EditorOptions<undefined>, editStateKey?: string) { return new Editor(documentKind, options, editStateKey);}
export function EditableFile() { const [file, setFile] = useState(initialFile); const [editing, setEditing] = useState(false); // Cancel marks the session so onEditComplete reverts instead of accepting. const cancelled = useRef(false); const version = useRef(0);
// Runs once when a changed session ends. Return 'accept' to install the // event's file, or 'reject' to revert. const handleEditComplete = useCallback<FileEditCompleteHandler<undefined>>( (event) => { if (cancelled.current) { cancelled.current = false; return 'reject'; } // Accepting: stamp the new contents with a fresh cacheKey, store them, // then accept; the component installs the event's file. version.current += 1; event.file.cacheKey = 'example:v' + version.current; setFile(event.file); return 'accept'; }, [] );
// This example is self-contained. Apps should usually mount EditProvider near // the root so its factory is available to every editable File, diff, and // CodeView. return ( <EditProvider createEditor={createEditor}> {editing ? ( <> <button type="button" onClick={() => { cancelled.current = true; setEditing(false); }} > Cancel </button> <button type="button" onClick={() => { cancelled.current = false; setEditing(false) }}> Save </button> </> ) : ( <button type="button" onClick={() => setEditing(true)}> Edit </button> )}
<Virtualizer style={virtualizerStyle}> <File file={file} options={fileOptions} edit={editing} onEditComplete={handleEditComplete} /> </Virtualizer> </EditProvider> );}Render the component first, then attach an editor of the matching kind. Keep the
function returned by edit() and call it for a normal, install-capable
completion.
Set onEditChange in the component's FileOptions or FileDiffOptions to
monitor live changes while in edit mode. Use onEditComplete to accept or
reject content changes when the edit session ends. See
Handle changes and completion for
their lifecycle and event payloads.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import { Virtualizer, VirtualizedFile, type FileContents,} from '@pierre/diffs';import { Editor } from '@pierre/diffs/edit';
const root = document.getElementById('file-scroll-root');const content = document.getElementById('file-scroll-content');if (root == null || content == null) { throw new Error('Expected virtualized file containers to exist');}
let file: FileContents = { name: 'example.ts', contents: 'export function greet(name: string) {\n return name;\n}',};
const virtualizer = new Virtualizer();virtualizer.setup(root, content);
const fileInstance = new VirtualizedFile( { theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Fired any time there's an edit to the document onEditChange(event) { console.log('change', event.file.name, event.lineAnnotations); }, // Runs once when a changed session ends. Return 'accept' to install the // event's file, or 'reject' to revert. onEditComplete(event) { // Store the edited file so later renders use it, and don't reset back to // the stale original. file = event.file; return 'accept'; }, }, virtualizer);fileInstance.render({ file, containerWrapper: content });
const editor = new Editor('file');// Start an edit sessionconst dispose = editor.edit(fileInstance);
// Later, complete the session and install an accepted result while the// component is still mounted.dispose();editor.cleanUp('discard') also runs completion for changed content, but never
installs the callback result. cleanUp('recycle') temporarily detaches
rendering without ending the session; virtualized hosts use it when a component
leaves the render window.
CodeView owns one editor per editable item. React gets the factory from the
nearest EditProvider; vanilla CodeView takes createEditor in its options.
Set edit: true on an item and increment its version whenever edit changes.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110import { parseDiffFromFile, type CodeViewItem } from '@pierre/diffs';import { Editor, type EditorOptions } from '@pierre/diffs/edit';import { CodeView, EditProvider, type CodeViewItemEditCompleteHandler,} from '@pierre/diffs/react';import { useCallback, useState } from 'react';
interface ThreadMetadata { id: string;}
const oldFile = { name: 'example.ts', contents: 'export const answer = 41;',};const newFile = { name: 'example.ts', contents: 'export const answer = 42;',};
const initialItems: CodeViewItem<ThreadMetadata>[] = [ { id: 'example.ts', type: 'diff', fileDiff: parseDiffFromFile(oldFile, newFile), annotations: [ { side: 'additions', lineNumber: 1, metadata: { id: 'answer-review' }, }, ], edit: true, version: 0, },];
const codeViewStyle = { height: '24rem', overflow: 'auto' } as const;
const editorOptions: EditorOptions<ThreadMetadata> = { onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); },};
function createEditor( documentKind: 'file' | 'file-diff', options: EditorOptions<ThreadMetadata>, editStateKey?: string) { return new Editor(documentKind, options, editStateKey);}
export function EditableCodeView() { const [items, setItems] = useState(initialItems);
const toggleEditing = useCallback(() => { setItems((current) => current.map((item) => ({ ...item, edit: item.edit !== true, version: (item.version ?? 0) + 1, })) ); }, []);
// Called once when an item's session ends. nextItem is the accepted // replacement CodeView built — the same item with the completed fileDiff and // annotations, edit: false, and a bumped version. Mirror it into state and // return 'accept' (edit mode already managed the annotations), or 'reject' to // revert. const commitEdit = useCallback<CodeViewItemEditCompleteHandler<ThreadMetadata>>( (event, item, nextItem) => { if (!('fileDiff' in event)) return 'reject';
event.fileDiff.cacheKey = item.id + ':v' + nextItem.version; setItems((current) => { // We must insert the new item into our controlled array. // If you're using the `initialItems` this is unnecessary as // the item will be imperatively added automatically for you return current.map((existing) => existing.id === item.id ? nextItem : existing) }); return 'accept'; }, [] );
// This example is self-contained. Apps should usually mount EditProvider near // the root so its factory is available to every editable File, diff, and // CodeView. return ( <EditProvider createEditor={createEditor}> <button type="button" onClick={toggleEditing}> {items[0]?.edit === true ? 'Disable editing' : 'Enable editing'} </button> <CodeView items={items} style={codeViewStyle} editorOptions={editorOptions} getEditStateKey={(item) => `review:${item.id}`} onItemEditComplete={commitEdit} renderAnnotation={(annotation) => ( <div>Thread {annotation.metadata.id}</div> )} /> </EditProvider> );}Use onItemEditChange(event, item) to monitor an item's live changes while in
edit mode. Use onItemEditComplete(event, item, nextItem) to accept or reject
content changes when its edit session ends. nextItem is the completed
replacement. In controlled React mode, write it into your items state before
returning 'accept'. A collapsed or virtualized item keeps its session active;
turning editing off, removing the item, resetting, cleaning up, or unmounting
ends it. See
Handle changes and completion for
the shared lifecycle and event payloads.
Use getEditStateKey(item) for per-item edit state retention. It is evaluated
only when that item's editor is created. Item editor state retains selections
and horizontal code scroll, but they never own or restore the shared CodeView
scroll position.
See CodeView editing for item ownership, removal, and controlled-state details.
Use the callbacks exposed by each component:
File, FileDiff, MultiFileDiff, and PatchDiff expose
onEditChange and onEditComplete as component props.File and FileDiff expose the same callbacks in FileOptions and
FileDiffOptions.CodeView use onItemEditChange and onItemEditComplete
so each callback also receives the owning item.onEditChange(event) is the live document change stream. Its
EditorChangeEvent contains:
changes contains normalized text edits with zero-based ranges and offsets.file is the current editable document, or the editable new side of a diff.lineAnnotations is the current remapped annotation collection when present.editor is the attached editor after the change. Pull state with
event.editor.getViewState() or event.editor.getEditState() only when
needed.Do not feed event.file or event.lineAnnotations back into the active
component on every change. Edit mode already owns the live document and
annotation positions. Use the event for autosave, validation, or application
side effects.
Most of the time you probably won't need this API.
1234567891011121314151617181920212223242526272829303132333435363738import type { FileContents, FileEditCompleteHandler,} from '@pierre/diffs';import { File } from '@pierre/diffs/react';import { useState } from 'react';
const initialFile: FileContents = { name: 'example.ts', contents: 'export const answer = 42;',};
// Render inside the stable EditProvider shown above.export function EditableFileHandlers() { const [file, setFile] = useState(initialFile);
const handleEditComplete: FileEditCompleteHandler<undefined> = (event) => { if (!window.confirm('Keep these changes?')) { return 'reject'; }
// Keep application state aligned with the value installed by the component. setFile(event.file); return 'accept'; };
return ( <File file={file} edit onEditChange={(event) => { console.log('Current contents:', event.file.contents); console.log('Normalized edits:', event.changes); }} onEditComplete={handleEditComplete} /> );}Completion runs only when the final contents differ from the latest external
input. Selection-only, scroll-only, annotation-only, and fully undone sessions
do not call onEditComplete. For React components and CodeView items,
completion is triggered when edit changes from true to false or the
component or item unmounts. Direct Editor usage ends the session through the
function returned by edit(). You must return 'accept' or 'reject' string
literals to instruct the component what to do next.
The frozen completion event contains the detached result and the latest external values a rejection restores:
file / fileDiff is the completed result.originalFile / originalFileDiff is the latest caller-provided value.oldFile and newFile values.lineAnnotations is the final collection; originalLineAnnotations is the
latest caller-provided collection.editor is detached, but final getViewState() and getEditState() remain
available during the callback.Return 'accept' to install the completed value while the component remains
mounted, or 'reject' to restore the latest external value. A missing handler
rejects automatically. Store an accepted value in application state so a later
render does not replace it with the original stale file or fileDiff.
For CodeView, onItemEditComplete(event, item, nextItem) receives the
completed replacement item. Returning 'accept' installs nextItem; a
controlled React owner must also write that item into its items state.
Returning 'reject' restores the latest external item.
Unmounting a changed component or item can still run completion, but does not install the result. The callback remains the place to capture or persist that final content.
Accept and reject control what the component displays. They do not clear a keyed editing session.
By default during any edit session, the Editor will manage editor state for
you, including undo/redo history. However, once the edit session has ended,
that session data will be thrown away unless you provide an editStateKey to
your Editor instantiation.
Future edit sessions with the same editStateKey can resume that undo history
and other editor state, including selections and cursor position.
For React components, simply pass the key as props:
<File file={file} edit={editing} editStateKey={`review:${file.name}`} />
For VanillaJS same key can be passed as the third argument to new Editor() or
returned by the CodeView getEditStateKey option or prop. A key retains:
scrollLeftscrollTop only for an explicitly owned viewportRetention is in memory, bounded by an LRU, and does not survive page reload. File and file-diff keys use independent namespaces. The same kind and key cannot be active in two editors at once. A retained FileDiff can resume only against a compatible old-side baseline.
Changing a key while an editor is active does not re-key that editor. The new key applies when a later editor is created.
EditStateManager clears inactive retained sessions. Clearing an active or
missing key returns false and does not alter the live editor.
import { EditStateManager } from '@pierre/diffs/edit';
const editStateKey = 'review:example.ts';
// Remove the complete retained session.
EditStateManager.clear('file', editStateKey);
// Keep the draft while selectively resetting retained state.
EditStateManager.clear('file', editStateKey, { history: true });
EditStateManager.clear('file', editStateKey, { selections: true });
EditStateManager.clear('file', editStateKey, { view: true });
EditStateManager.clear('file', editStateKey, { editor: true });
// Remove every inactive retained file and file-diff session.
EditStateManager.clearAll();
// Retain up to 50 inactive entries in each namespace.
EditStateManager.setCapacity(50);
{ document: true } removes the complete entry because history and editor state
depend on that document. { editor: true } clears both selections and view
state. setCapacity(capacity) changes the maximum number of inactive entries
retained by each namespace; the default is 100 per namespace.
Use initialState to provide application-owned state when constructing an
editor. documentKind is required and must match the editor kind. Every other
field is optional:
document supplies the live TextDocument, including its current contents
and any undo/redo history.fileInfo supplies the file name and optional language override.editor supplies selections and editor-owned scroll state.diffSession supplies the old-side baseline and hunk metadata for a
file-diff editor.The editor adopts initialState, it doesn't clone it. On its first attachment,
it fills omitted fields from the attached component and defaults. initialState
is constructor-only and is consumed by that first attachment.
For a file-diff editor, view-only initialization can omit the document and
diff-session fields. Transferring an edited document and its undo/redo history
requires the matching document, fileInfo, and diffSession from the same
complete EditState; diff-session metadata is not standalone state.
editStateKey retains an editing session only in memory. To continue editing in
a new page session, persist the latest FileContents and, optionally, its
EditorViewState. Rebuild those values as initialState when editing begins
again. See
Initialize state on first attach
and
View state and viewport ownership
for the corresponding APIs.
FileContents. Save event.file from onEditChange to recover an
in-progress draft, and save the accepted event.file from onEditComplete as
the application's final file state.EditorViewState returned by getViewState(). It contains
copied selections and scroll positions and is safe to encode as JSON.On the next page load, construct a fresh TextDocument from the persisted file
and supply it through initialState with the file identity and optional
EditorViewState. Pass the same file to the component. The new document
restores the latest contents with fresh undo/redo history; the editor builds any
omitted state from the attached component.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103import { getFiletypeFromFileName, type EditorViewState, type FileContents,} from '@pierre/diffs';import { TextDocument, type Editor, type EditorOptions,} from '@pierre/diffs/edit';import { File } from '@pierre/diffs/react';import { useRef, useState } from 'react';
const storageKey = 'draft:example.ts';const initialFile: FileContents = { name: 'example.ts', contents: 'export const answer = 42;',};
interface PersistedDraft { file: FileContents; editorState?: EditorViewState;}
function loadDraft(): PersistedDraft { const value = localStorage.getItem(storageKey); return value == null ? { file: initialFile } : (JSON.parse(value) as PersistedDraft);}
function persistDraft(file: FileContents, editor: Editor<undefined>) { const draft: PersistedDraft = { file, editorState: editor.getViewState(), }; localStorage.setItem(storageKey, JSON.stringify(draft));}
// Render inside the stable EditProvider shown above.export function PersistedEditableFile() { const [initialDraft] = useState(loadDraft); const [file, setFile] = useState(initialDraft.file); const [editing, setEditing] = useState(true); const editorRef = useRef<Editor<undefined> | null>(null); const [editorOptions] = useState<EditorOptions<undefined>>(() => ({ // Initialize the edit state from the latest version of the file, minus // undo history initialState: { documentKind: 'file', document: new TextDocument<undefined>( initialDraft.file.name, initialDraft.file.contents, initialDraft.file.lang ?? getFiletypeFromFileName(initialDraft.file.name) ), fileInfo: { name: initialDraft.file.name, lang: initialDraft.file.lang, }, editor: initialDraft.editorState, }, onAttach(editor) { editorRef.current = editor; }, }));
return ( <> <button type="button" onClick={() => { const editor = editorRef.current; const currentFile = editor?.getFile(); if (editor != null && currentFile != null) { // Captures the exact document, selections, and scroll position. persistDraft(currentFile, editor); } }} > Save draft </button> <button type="button" onClick={() => setEditing(false)}> Finish editing </button> <File file={file} edit={editing} editorOptions={editorOptions} onEditChange={(event) => { // Save the latest document and state without feeding them back into // the component during its active editing session. persistDraft(event.file, event.editor); }} onEditComplete={(event) => { // Persist the accepted final file as the next canonical input. setFile(event.file); persistDraft(event.file, event.editor); return 'accept'; }} /> </> );}onEditChange runs for document changes, not selection or scroll movement. The
example therefore also captures the current file and EditorViewState at an
explicit save point. Applications can do the same before navigation or at
another application-owned checkpoint.
EditorViewState is different from the complete EditState returned by
getEditState(). Complete state includes the live document and undo/redo
history, is borrowed by reference, and can contain non-JSON annotation values,
functions, DOM nodes, and cycles. It is not a serialization contract.
Pierre does not currently expose serializable undo/redo history. Restoring a
persisted file and EditorViewState starts a new history: edits made before the
reload cannot be undone in the new page session.
Use onAttach to capture an editor instance for toolbars and to opt into
initial focus. At this point the document and editable DOM are ready.
12345678910const editorOptions = useMemo<EditorOptions<undefined>>( () => ({ onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, }), []);
return <CodeView items={items} editorOptions={editorOptions} />;Vanilla CodeView can add the same behavior in its factory:
12345678910const viewer = new CodeView({ createEditor(documentKind, options, editStateKey) { return new Editor(documentKind, { ...options, onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, }, editStateKey); },});'first-visible' targets the first editable row whose top is inside the usable
viewport. offset adds a non-negative CSS-pixel inset and preventScroll: true
preserves vertical position. Numeric line numbers are one-based; character
offsets are zero-based.
1editor.focus({ lineNumber: 13, character: 4 });Typed input and applyEdits() share one timeline. undo() and redo() restore
the associated selections; canUndo and canRedo drive toolbar state.
historyMaxEntries defaults to 100.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859import type { FileContents } from '@pierre/diffs';import { Editor, type EditorOptions } from '@pierre/diffs/edit';import { EditProvider, File } from '@pierre/diffs/react';import { useMemo, useRef, useState } from 'react';
const file: FileContents = { name: 'example.ts', contents: 'export const x = 1;',};
function createEditor( documentKind: 'file' | 'file-diff', options: EditorOptions<undefined>, editStateKey?: string) { return new Editor(documentKind, options, editStateKey);}
export function EditableFileWithHistoryToolbar() { const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false);
const editorRef = useRef<Editor<undefined> | null>(null); // Creation-time options: capture the editor for the toolbar's imperative // calls. The change stream lives on the component's onEditChange prop. const editorOptions = useMemo<EditorOptions<undefined>>( () => ({ historyMaxEntries: 100, onAttach(editor) { editorRef.current = editor; }, }), [] );
return ( <EditProvider createEditor={createEditor}> <div className="toolbar"> <button type="button" disabled={!canUndo} onClick={() => editorRef.current?.undo()}> Undo </button> <button type="button" disabled={!canRedo} onClick={() => editorRef.current?.redo()}> Redo </button> </div> <File file={file} edit editorOptions={editorOptions} onEditChange={() => { // Undo and redo run through the same change path as edits, so // refresh toolbar state on every change, not only on button clicks. setCanUndo(editorRef.current?.canUndo ?? false); setCanRedo(editorRef.current?.canRedo ?? false); }} /> </EditProvider> );}getViewState() returns copied selections and restorable component-owned scroll
offsets. An attached component reports horizontal scrollLeft. It reports
vertical scrollTop only when the editor was constructed with
ownsVerticalViewport: true and the component exposes an exclusive element
viewport.
setViewState() requires an attached editor. It restores selections and
horizontal scroll; scrollTop is applied only to an owned viewport.
ownsVerticalViewport defaults to false and is captured by the constructor.
Enable it only when one editable component exclusively owns the vertical
scroller. Virtualization does not imply ownership, and later setOptions()
calls cannot change it. Never enable it for individual CodeView items because
they share one viewer viewport.
The retained view-state checkpoint updates after synchronization, edits,
explicit setViewState() calls, recycling, and completion. Pure selection or
scroll movement does not update that checkpoint; call getViewState() for the
exact live position.
Set enabledSelectionAction: true and return custom UI from
renderSelectionAction to show a popover beside a user-created ranged
selection. Programmatic setSelections() and setViewState() update the
selection without opening it.
1234567891011121314151617181920import { Editor } from '@pierre/diffs/edit';
const editor = new Editor('file', { enabledSelectionAction: true, // The popover appears after a user-created ranged selection. renderSelectionAction: (context) => { const container = document.createElement('div'); const button = document.createElement('button');
button.type = 'button'; button.textContent = 'Wrap selection in TODO()'; button.addEventListener('click', () => { context.replaceSelectionText(`TODO(${context.getSelectionText()})`); context.close(); });
container.appendChild(button); return container; },});The render context provides the active selection, live document, helpers to read
or replace selected text, applyEdits, and close:
1234567891011121314export interface SelectionActionContext<LAnnotation> { /** The current selection. */ selection: EditorSelection; /** The text document. */ textDocument: TextDocument<LAnnotation>; /** Applies the edits to the text document. */ applyEdits: (edits: TextEdit[]) => void; /** Gets the text of the current selection. */ getSelectionText: () => string; /** Replaces the text of the current selection. */ replaceSelectionText: (text: string) => void; /** Closes the selection action. */ close: () => void;}Markers add inline diagnostics with severity styling and hover messages. Call
setMarkers() after attachment; pass an empty array to clear them. Marker
positions re-anchor as the document changes.
12345678910111213141516type MarkerSeverity = 'error' | 'warning' | 'info' | 'hint';
interface Marker { /** Controls the marker color and popover styling. */ severity: MarkerSeverity; /** Popover content. Pass trusted HTML with `{ html }`. */ message: string | { html: string } | HTMLElement; /** Start position (zero-based line and character). */ start: { line: number; character: number }; /** End position (zero-based line and character). */ end: { line: number; character: number }; /** Optional origin label shown in the popover, e.g. "eslint". */ source?: string; /** Optional arbitrary data carried alongside the marker. */ metadata?: Record<string, unknown>;}123456789101112131415161718192021222324252627import { Editor } from '@pierre/diffs/edit';
const editor = new Editor('file');editor.edit(fileInstance);
// Apply diagnostics, e.g. from a linter or language server. Inlining the array// lets TypeScript check the severity literals against the Marker type without// importing it (the type is reached through editor.setMarkers).editor.setMarkers([ { severity: 'error', source: 'eslint', message: 'Expected === and instead saw ==.', start: { line: 9, character: 12 }, end: { line: 9, character: 14 }, }, { severity: 'warning', source: 'eslint', message: 'Unexpected var, use let or const instead.', start: { line: 1, character: 2 }, end: { line: 1, character: 5 }, },]);
// Pass an empty array to clear all markers.editor.setMarkers([]);Edit mode owns annotation positions during a session. Additions-side and file
annotations follow structural edits; deletion-side annotations and file-level
lineNumber: 0 annotations stay fixed. Undo and redo restore annotations with
their document state.
Do not mirror remapped event.lineAnnotations back on every change. A genuinely
new caller-provided annotation array replaces the edit owned collection and may
trigger unintended positional reverts. Reject restores the latest external
collection; accept installs the final collection, which should be committed
alongside the accepted file or diff.
For interactive annotation content, keep application state under a stable, position-independent metadata ID. React may recreate annotation content when rows move, disappear, or leave a virtualized window.
The edited file renders on the main thread while a worker pool continues to
render other components. Setting useTokenTransformer: true in the pool's
highlighter options can avoid a one-time re-render when entering edit mode, at
the cost of larger markup for every pool-rendered file.
1234567891011121314151617181920212223242526import { File } from '@pierre/diffs';import { Editor } from '@pierre/diffs/edit';import { getOrCreateWorkerPoolSingleton } from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Optional: pool markup is then already editor-compatible, so entering // edit mode skips a one-time re-render of the file. useTokenTransformer: true, },});
const fileInstance = new File( { theme: { dark: 'pierre-dark', light: 'pierre-light' } }, workerPool);fileInstance.render({ file: { name: 'example.ts', contents: 'export const x = 1;' }, containerWrapper: document.body,});
const editor = new Editor('file');editor.edit(fileInstance);See Worker Pool for setup details.
@pierre/diffs/edit is a separate entry point, so it can be loaded only when
the feature is used.
1234567891011121314import type { VirtualizedFile } from '@pierre/diffs';
const button = document.getElementById('edit-button');
async function edit(fileInstance: VirtualizedFile): Promise<() => void> { const { Editor } = await import('@pierre/diffs/edit'); const editor = new Editor('file'); return editor.edit(fileInstance);}
// Click to edit and lazy-load the editor bundle only when it is needed.button.addEventListener('click', () => { void edit(fileInstance);});123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136import { File, type EditorViewState, type FileContents,} from '@pierre/diffs';import { Editor, EditStateManager, type EditState, type EditorFocusOptions,} from '@pierre/diffs/edit';
// Editor// Most methods require an attached component via edit().
const fileInstance = new File();fileInstance.render({ file: { name: 'example.ts', contents: '...' }, containerWrapper: document.body,});
const editStateKey = 'review:example.ts';const editor = new Editor('file', {}, editStateKey);
// Merge partial options at runtime. Existing fields are preserved.editor.setOptions({ roundedSelection: false,});
// This file-kind editor attaches to a rendered File or VirtualizedFile. Create// an Editor('file-diff', ...) for FileDiff or VirtualizedFileDiff.// Normalizes conflicting fileInstance options and returns a dispose function.const dispose = editor.edit(fileInstance);
// Apply text edits to the attached document. Positions are zero-based.// Edits always join the undo stack, exactly like typed input. The optional// updateHistory argument defaults to true; false remaps live selections instead// of restoring snapshots but keeps the text edit undoable.editor.applyEdits([ { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, newText: 'Hello, world!', },]);
// Live FileContents for the current session or queued initialState. Undefined// when neither exists.const file: FileContents | undefined = editor.getFile();
// Full document text, or '' when no current session or initialState exists.const text: string = editor.getText();
// Snapshot selections and scroll positions for explicit restoration:const state: EditorViewState = editor.getViewState();// EditorViewState = {// selections?: EditorSelection[];// view?: { scrollLeft: number; scrollTop?: number };// }
// Restore selections and scroll positions after re-rendering.editor.setViewState(state);
// Borrow the complete live document, history, and editor-state checkpoint. This is// available only while a complete edit session exists.const editState: EditState<undefined> | undefined = editor.getEditState();
// Replace all cursors and ranges programmatically. Positions are zero-based;// direction controls which end the caret uses for keyboard extension.editor.setSelections([ { start: { line: 0, character: 2 }, end: { line: 0, character: 8 }, direction: 'forward', // 'forward' | 'backward' | 'none' },]);
// Show inline diagnostic markers. Pass [] to clear. Throws if not attached.editor.setMarkers([ { start: { line: 1, character: 2 }, end: { line: 1, character: 8 }, severity: 'error', // 'error' | 'warning' | 'info' | 'hint' message: { html: 'Some lint message' }, source: 'eslint', },]);editor.setMarkers([]);
// Focus the editable content. preventScroll skips scrolling the caret into view.// Blur removes focus from the content area.editor.focus();editor.focus({ preventScroll: true });
// Numeric line numbers are one-based; character offsets are zero-based.editor.focus({ lineNumber: 13, character: 4 });
// Target the first editable row whose top is visible. offset adds a// non-negative CSS-pixel inset below the viewport or sticky file header.const focusOptions: EditorFocusOptions = { lineNumber: 'first-visible', offset: 8, preventScroll: true,};editor.focus(focusOptions);editor.blur();
// Whether there is an edit to undo or redo.editor.canUndo;editor.canRedo;
// Undo the last edit or redo the last undone edit. No-ops when history is empty.editor.undo();editor.redo();
// End the session through the disposer returned by edit(). It detaches the// editor, then runs the component's onEditComplete accept/reject boundary.dispose();
// cleanUp('discard') also runs changed-session completion, but never installs// the result. Virtualized hosts use cleanUp('recycle') for temporary remounts.
// Inspect complete active or inactive state without changing LRU recency.EditStateManager.get('file', editStateKey);
// Clear a complete inactive session, or keep its draft while resetting selected// parts. Active sessions are never mutated by manager clearing.EditStateManager.clear('file', editStateKey);EditStateManager.clear('file', editStateKey, { history: true });EditStateManager.clear('file', editStateKey, { selections: true });EditStateManager.clear('file', editStateKey, { view: true });EditStateManager.clear('file', editStateKey, { editor: true });
// Clear all inactive state, or change each namespace's retained-state capacity.EditStateManager.clearAll();EditStateManager.setCapacity(50);
Pass options to new Editor(documentKind, options, editStateKey?).
initialState and ownsVerticalViewport are constructor-only. setOptions()
merges later values, but not every option is retroactive: for example,
matchBrackets is captured when the document synchronizes, and changing
historyMaxEntries does not resize history already created for a document.
Callbacks that are read when an event occurs use their latest values.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778import type { DiffLineAnnotation, DiffsEditableComponent, EditorChangeEvent, FileContents, LineAnnotation,} from '@pierre/diffs';import { Editor, type EditorInitialState, type EditorKeymap,} from '@pierre/diffs/edit';
interface EditorOptions<LAnnotation> { // Max undo stack entries historyMaxEntries?: number;
// Retain and restore vertical scroll only when this component exclusively owns // its scrollable HTMLElement viewport. Captured by the constructor; defaults to false. ownsVerticalViewport?: boolean;
// State to adopt on first attach. Only documentKind is required; omitted // fields are initialized from the attached component. initialState?: EditorInitialState<LAnnotation>;
// Custom keymap checked before the default map. keymap?: EditorKeymap;
// Render rounded corners on selection ranges (default: true) roundedSelection?: boolean;
// Highlight matching brackets near the caret (default: true) matchBrackets?: boolean;
// Auto-surround selected text when typing a quote or bracket. // Values: 'default' | 'never' | 'brackets' | 'quotes' | 'languageDefined' // (default: 'default' — both quotes and brackets) autoSurround?: 'default' | 'never' | 'brackets' | 'quotes' | 'languageDefined';
// Per-language comment tokens for the toggle-comment commands, merged over // the built-in defaults ('//' and '/* */'). A null lineComment disables // line comments for that language. languageCommentConfig?: Record< string, { lineComment?: string | null; blockComment?: readonly [string, string] } >;
// Show the floating Selection Action popover after a user selection. // Programmatic setSelections/setViewState calls do not open it. enabledSelectionAction?: boolean;
// Custom clipboard provider. Recommended in Electron apps — use the native // clipboard API: https://www.electronjs.org/docs/latest/api/clipboard clipboard?: { readText: (type?: string) => Promise<string> | string; };
// Custom Selection Action UI. See Selection Action docs for context shape. renderSelectionAction?: (context) => HTMLElement;
// Fires after attach when the text document is ready onAttach?: ( editor: Editor<LAnnotation>, fileInstance: DiffsEditableComponent<LAnnotation> ) => void;
// Editor-centric change stream. Fires after each edit with an // EditorChangeEvent carrying the editor, live file (the editable new side for // a diff), current lineAnnotations, and normalized text changes. Prefer a // component's onEditChange prop/option for per-component handling. onChange?: (event: EditorChangeEvent<LAnnotation, 'file' | 'diff'>) => void;
// Fires when the editable content area gains focus (tab, click, or editor.focus()). onFocus?: () => void;
// Fires when the editable content area loses focus. onBlur?: () => void;}EditorKeymap is an array of { platform?, bindings } groups. Platform values
are mac, windows, or linux; later groups win when bindings overlap. Custom
bindings take precedence and unmatched shortcuts fall back to the built-in
keymap.
Shortcuts use Cmd on macOS and Ctrl on Windows and Linux. Jumping to the document start or end uses that modifier with Home and
End; on macOS, the modifier with the up and down arrows works too.
| Shortcut | Command | Action |
|---|---|---|
| All platforms · 22 bindings | ||
| Tab | indent | Indent line or selection |
| ShiftTab | outdent | Outdent line or selection |
| Cmd/Ctrl[ | indentLess | Decrease indentation |
| Cmd/Ctrl] | indentMore | Increase indentation |
| Cmd/CtrlZ | undo | Undo |
| Cmd/CtrlShiftZ | redo | Redo |
| Cmd/CtrlA | selectAll | Select all |
| Cmd/CtrlD | findNextMatch | Find next match of the selection |
| Cmd/CtrlF | openSearchPanel | Open search |
| Cmd/CtrlAltF | openSearchReplacePanel | Open search and replace |
| Alt↑ | moveLineUp | Move selected line(s) up |
| Alt↓ | moveLineDown | Move selected line(s) down |
| ShiftAlt↑ | copyLineUp | Copy selected line(s) up |
| ShiftAlt↓ | copyLineDown | Copy selected line(s) down |
| Esc | simplifySelection | Collapse to a single cursor |
| Cmd/CtrlEnter | insertBlankLine | Insert a blank line |
| Cmd/Ctrl/ | toggleComment | Toggle line comment |
| ShiftAltA | toggleBlockComment | Toggle block comment |
| Cmd/CtrlHome | moveCursorToDocStart | Move cursor to document start |
| Cmd/CtrlEnd | moveCursorToDocEnd | Move cursor to document end |
| Cmd/CtrlShiftHome | expandSelectionDocStart | Extend selection to document start |
| Cmd/CtrlShiftEnd | expandSelectionDocEnd | Extend selection to document end |
| macOS · 7 bindings | ||
| CtrlK | deleteHardLineForward | Delete to the end of the line |
| CtrlAltP | moveLineUp | Move selected line(s) up |
| CtrlAltN | moveLineDown | Move selected line(s) down |
| Cmd↑ | moveCursorToDocStart | Move cursor to document start |
| Cmd↓ | moveCursorToDocEnd | Move cursor to document end |
| CmdShift↑ | expandSelectionDocStart | Extend selection to document start |
| CmdShift↓ | expandSelectionDocEnd | Extend selection to document end |
| Linux · 3 bindings | ||
| CtrlY | redo | Redo |
| CtrlAltP | moveLineUp | Move selected line(s) up |
| CtrlAltN | moveLineDown | Move selected line(s) down |
| Windows · 1 binding | ||
| CtrlY | redo | Redo |
Virtualization in Diffs uses estimated line and file heights to keep large renders fast. It renders placeholders and spacer buffers for off-screen content, then renders visible lines in hunk-sized batches as you scroll.
If your scrollable region is only code, start with CodeView instead. It is the
more optimized path: it owns the entire code region, only renders what you can
actually see, and is generally more performant and less prone to blanking.
Use the lower-level virtualization APIs when you need a more flexible,
mixed-content layout where the code has to live alongside other DOM that is
harder for CodeView to control. That flexibility comes with tradeoffs: every
top-level file or diff container stays mounted, and the experience is more
likely to blank during fast scroll.
Internally, the virtualizer listens to scroll and resize updates, computes a window with overscan, and reconciles measured DOM heights after render. This keeps scroll position stable even when line heights change because of wrapped lines or annotations.
For best results, you'll need to pass a metrics config object to your files or
diffs when your layout differs from the defaults. These metrics help the
Virtualizer estimate file and diff sizes more accurately before content is
measured. For large diffs, using virtualization with a
Worker Pool is strongly recommended.
To use virtualization, start with a scrollable container (an HTML element or the window). Directly inside that container, add a content wrapper that holds all diff/file instances and any other content you render. The virtualizer uses this wrapper to track content size changes.
Inside that scroll container, render the VirtualizedFile and
VirtualizedFileDiff components. In React, this is handled automatically by the
built-in Virtualizer context. In vanilla JS, you manage this explicitly by
creating a Virtualizer instance and wiring it to VirtualizedFile /
VirtualizedFileDiff instead of the traditional APIs.
In React, wrap your diff/file components in Virtualizer from
@pierre/diffs/react. The Virtualizer component is your scroll container.
Currently, the React wrapper does not support window scrolling unless you
orchestrate your own provider via VirtualizerContext.Provider (from
@pierre/diffs/react) and pass a manually created Virtualizer instance (from
@pierre/diffs).
123456789101112131415161718192021222324252627282930313233343536373839import { MultiFileDiff, Virtualizer, WorkerPoolContextProvider,} from '@pierre/diffs/react';import { workerFactory } from './utils/workerFactory';
function Example({ oldFile, newFile }) { return ( <WorkerPoolContextProvider poolOptions={{ workerFactory }} highlighterOptions={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, langs: ['typescript', 'javascript', 'css', 'html'], }} > <Virtualizer className="max-h-[70vh] overflow-auto" contentClassName="space-y-4" > <MultiFileDiff oldFile={oldFile} newFile={newFile} options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }} /> </Virtualizer> </WorkerPoolContextProvider> );}
// Any diff/file component inside <Virtualizer> automatically uses// virtualized rendering internally:// - <MultiFileDiff />// - <PatchDiff />// - <FileDiff />// - <File />You can tune virtualization behavior with the config prop.
1234567891011121314151617181920212223242526import { MultiFileDiff, Virtualizer } from '@pierre/diffs/react';
function Example({ oldFile, newFile }) { return ( <Virtualizer className="h-[80vh] overflow-auto" contentClassName="space-y-6" config={{ // Extra viewport size in pixels rendered above and below the viewport. // (default: 1000) overscrollSize: 1000,
// IntersectionObserver root margin in pixels for visibility tracking. // (default: 4000) intersectionObserverMargin: 4000,
// Logs size changes for debugging measurement jitter. // Keep disabled in production because it will hurt performance. // Useful to confirm that your metrics are accurate. resizeDebugging: false, }} > <MultiFileDiff oldFile={oldFile} newFile={newFile} /> </Virtualizer> );}Virtualizer props:
config: partial virtualizer config (overscrollSize,
intersectionObserverMargin, resizeDebugging)className / style: applied to the outer scroll rootcontentClassName / contentStyle: applied to the inner content wrapperIn vanilla JS, create a Virtualizer instance and pass it into
VirtualizedFileDiff or VirtualizedFile.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798import { Virtualizer, VirtualizedFile, VirtualizedFileDiff, type FileContents,} from '@pierre/diffs';import { getOrCreateWorkerPoolSingleton, terminateWorkerPoolSingleton,} from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
const root = document.getElementById('diff-scroll-root');const content = document.getElementById('diff-scroll-content');if (root == null || content == null) { throw new Error('Missing container elements');}
const oldFile: FileContents = { name: 'example.ts', contents: 'export const value = 1;\n',};
const newFile: FileContents = { name: 'example.ts', contents: 'export const value = 2;\n',};
const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, langs: ['typescript', 'javascript', 'css', 'html'], },});
const virtualizer = new Virtualizer({ // Extra viewport size in pixels rendered above and below the viewport. // (default: 1000) overscrollSize: 1000,
// IntersectionObserver root margin in pixels for visibility tracking. // (default: 4000) intersectionObserverMargin: 4000,
// Logs size changes for debugging measurement jitter. // Keep disabled in production because it will hurt performance. // Useful to confirm that your metrics are accurate. resizeDebugging: false,});virtualizer.setup(root, content);
// Optional partial metrics override.// Only include values that differ from defaults.const metrics = { lineHeight: 22, spacing: 10,};
const diff = new VirtualizedFileDiff( { theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }, virtualizer, metrics, workerPool);
diff.render({ oldFile, newFile, containerWrapper: content,});
const file = new VirtualizedFile( { theme: { dark: 'pierre-dark', light: 'pierre-light' }, overflow: 'scroll', }, virtualizer, metrics, workerPool);
file.render({ file: { name: 'another-example.ts', contents: 'export function hello() { return "world"; }\n', }, containerWrapper: content,});
// Later cleanupdiff.cleanUp();file.cleanUp();virtualizer.cleanUp();terminateWorkerPoolSingleton();metrics aligned with your layout if you customize heights.resizeDebugging with the Virtualizer temporarily when tuning metrics,
and to confirm everything is working properly. Don't forget to disable it in
production.The hunkSeparators option controls how collapsed unchanged regions are
displayed. For customization, we recommend starting with a built-in preset and
layering unsafeCSS on top.
Passing a render function is only documented for the Vanilla JS APIs. It is
being phased out, does not work well with the container-managed and
virtualization-oriented React APIs, and is not compatible with SSR. We strongly
recommend avoiding that path and customizing built-in separators with
unsafeCSS instead.
The Custom CSS example below keeps the built-in line-info-basic markup and
tweaks it with CSS.
Expand All button, which is normally hidden by default6 unmodified linesExpand all78910111213141519 unmodified linesExpand all35363738394041424315 unmodified linesExpand all59606162636465666713 unmodified linesExpand all6 unmodified linesExpand allsummary.push('checkpoint-02');summary.push('checkpoint-03');summary.push('checkpoint-04');summary.push('checkpoint-05');summary.push('phase:boot');summary.push('checkpoint-07');summary.push('checkpoint-08');summary.push('checkpoint-09');summary.push('checkpoint-10');19 unmodified linesExpand allsummary.push('checkpoint-30');summary.push('checkpoint-31');summary.push('checkpoint-32');summary.push('checkpoint-33');summary.push('phase:mid');summary.push('checkpoint-35');summary.push('checkpoint-36');summary.push('checkpoint-37');summary.push('checkpoint-38');15 unmodified linesExpand allsummary.push('checkpoint-54');summary.push('checkpoint-55');summary.push('checkpoint-56');summary.push('checkpoint-57');summary.push('phase:tail');summary.push('checkpoint-59');summary.push('checkpoint-60');summary.push('checkpoint-61');summary.push('checkpoint-62');13 unmodified linesExpand all6 unmodified linesExpand all78910111213141519 unmodified linesExpand all35363738394041424315 unmodified linesExpand all596061626364656667686913 unmodified linesExpand all6 unmodified linesExpand allsummary.push('checkpoint-02');summary.push('checkpoint-03');summary.push('checkpoint-04');summary.push('checkpoint-05');summary.push('phase:boot-ready');summary.push('checkpoint-07');summary.push('checkpoint-08');summary.push('checkpoint-09');summary.push('checkpoint-10');19 unmodified linesExpand allsummary.push('checkpoint-30');summary.push('checkpoint-31');summary.push('checkpoint-32');summary.push('checkpoint-33');summary.push(`phase:mid-${tasks.length}`);summary.push('checkpoint-35');summary.push('checkpoint-36');summary.push('checkpoint-37');summary.push('checkpoint-38');15 unmodified linesExpand allsummary.push('checkpoint-54');summary.push('checkpoint-55');summary.push('checkpoint-56');summary.push('checkpoint-57');if (tasks.length > 0) {summary.push(`phase:tail-${tasks[0].id}`);}summary.push('checkpoint-59');summary.push('checkpoint-60');summary.push('checkpoint-61');summary.push('checkpoint-62');13 unmodified linesExpand all
line-info: Rounded corner separator with collapsed line count and expansion
controls.line-info-basic: Compact, full-width variant of line-info with expansion
controls.metadata: Patch-style separator (@@ -x,y +a,b @@) with no expansion
controls.simple: Minimal separator bar.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133import type { FileContents } from '@pierre/diffs';import { MultiFileDiff } from '@pierre/diffs/react';
const customSeparatorCSS = `/* Fix bg colors to mux with background */[data-separator="line-info-basic"] { height: 24px; background: var(--diffs-bg); position: relative;}
/* Styles are made to target always the leftside gutter, however technically * these elements are rendered into every gutter and every content row giving you * lots of flexibility in how you may want to show or style them */[data-diff-type="single"] [data-gutter],[data-diff-type="split"] [data-deletions] [data-gutter] { [data-separator-wrapper] { position: absolute; left: 100%; display: flex; align-items: center; gap: unset; width: max-content; background: transparent; color: var(--diffs-fg-number); font-family: var(--diffs-header-font-family, var(--diffs-header-font-fallback)); font-size: 0.75rem; /* Ensure Arrows align with number column */ margin-left: calc(-2ch - 2px); }
[data-separator-wrapper][data-separator-multi-button] { margin-left: calc(-3ch - 2px); }
[data-expand-button], [data-separator-content] { display: block; align-self: unset; min-width: unset; min-height: unset; padding: 0; flex-shrink: 0; grid-column: unset; border: none; width: auto; height: auto; background-color: unset; color: inherit; font: inherit; }
[data-expand-button]:not([data-expand-all-button]) { &[data-expand-down]::before { content: '↑'; }
&[data-expand-up]::before { content: '↓'; }
&[data-expand-both]::before { content: '↕'; }
/* Hide built in icon */ svg { display: none; } }
[data-separator-content] { background: transparent; margin-left: calc(2px + 1ch); }
/* Expand all button will only appear if the collapsed region is larger than * an expand chunk */ [data-expand-all-button] { position: relative; margin-left: 14px; text-transform: lowercase;
&:hover { color: var(--diffs-fg); text-decoration: underline; } }
/* A little dot separator */ [data-expand-all-button]::before { content: ''; display: block; position: absolute; top: 50%; left: -8px; margin-top: -1px; width: 3px; height: 3px; border-radius: 2px; background-color: var(--diffs-fg-number); pointer-events: none; }
[data-separator-content]:hover, [data-expand-button]:hover, [data-expand-all-button]:hover { color: var(--diffs-fg); }}`;
interface CustomSeparatorExampleProps { oldFile: FileContents; newFile: FileContents;}
export function CustomSeparatorExample({ oldFile, newFile,}: CustomSeparatorExampleProps) { return ( <MultiFileDiff oldFile={oldFile} newFile={newFile} options={{ hunkSeparators: 'line-info-basic', expansionLineCount: 5, unsafeCSS: customSeparatorCSS, }} /> );}If CSS hooks are not enough, the low-level hunkSeparators(hunkData, instance) function still exists on the Vanilla JS FileDiff API. We only
recommend that escape hatch as a last resort. It is being phased out, and it
does not fit the container-managed and virtualization-oriented APIs that the
React components rely on.
Import utility functions from @pierre/diffs. These can be used with any
framework or rendering approach.
Use isDiffAnnotation and isFileAnnotation to tell diff annotations from file
annotations. Use isDiffAnnotationCollection and isFileAnnotationCollection
for arrays.
Both collection helpers return true for an empty array. Use the current file
or diff context if that distinction matters.
Programmatically accept or reject individual hunks (or specific change blocks inside a hunk) in a diff. This is useful for building interactive code review interfaces, AI-assisted coding tools, or any workflow where users need to selectively apply changes.
To resolve an entire hunk, pass 'accept', 'reject', or 'both'. To resolve
only one change block in a hunk, pass an object with type and changeIndex
(for example:
diffAcceptRejectHunk(diff, hunkIndex, { type: 'accept', changeIndex: 0 })).
changeIndex maps to the target entry in that hunk's hunkContent array.
When you accept a hunk, the new (additions) version is kept and the hunk is
converted to context lines. When you reject a hunk, the old (deletions)
version is restored. You can also use both as a lower-level way to mux the
two sides together, which keeps the old lines first and then appends the new
lines before collapsing the result back to context. The function returns a new
FileDiffMetadata object with all line numbers properly adjusted for subsequent
hunks.
1234567891011121314151617181920212223242526272829303132333435363738394041424344import { diffAcceptRejectHunk, FileDiff, parseDiffFromFile, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a diff from two existing file versionslet fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'file.ts', contents: 'const x = 1;\nconst y = 2;' }, { name: 'file.ts', contents: 'const x = 1;\nconst y = 3;\nconst z = 4;' });
// Create a FileDiff instanceconst instance = new FileDiff({ theme: 'pierre-dark' });
// Render the initial diff showing the changesinstance.render({ fileDiff, containerWrapper: document.getElementById('diff-container')!,});
// Accept a hunk - keeps the new (additions) version.// The hunk is converted to context lines (no longer shows as a change).// Note: If the diff has a cacheKey, it's automatically updated by// this function.fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'accept');
// Or reject a hunk - reverts to the old (deletions) version.// fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'reject');
// Or target a single change block inside the hunk by content index.// 'changeIndex' maps to that hunk's hunkContent entry.// fileDiff = diffAcceptRejectHunk(fileDiff, 0, {// type: 'accept',// changeIndex: 0,// });
// Re-render with the updated fileDiff - the accepted hunk// now appears as context lines instead of additions/deletionsinstance.render({ fileDiff, containerWrapper: document.getElementById('diff-container')!,});Apply a merge conflict action payload to a file string and return the next contents.
Experimental:
UnresolvedFile-related merge conflict APIs are currently beta/experimental and may change in future releases.
Default merge-conflict buttons work even without callbacks: UnresolvedFile
applies the resolution internally.
In vanilla, provide onMergeConflictAction for controlled state (for example,
to persist resolved contents, sync external stores, or trigger side effects).
Use onMergeConflictResolve when you want uncontrolled resolution plus a
notification with the resolved file. React UnresolvedFile is intentionally
uncontrolled.
123456789101112131415161718192021222324252627282930313233import { UnresolvedFile, type FileContents, resolveMergeConflict, type MergeConflictActionPayload,} from '@pierre/diffs';
const container = document.getElementById('diff-container');
let currentFile: FileContents = { name: 'App.tsx', contents: `export function handler() {<<<<<<< HEAD return 'current';======= return 'incoming';>>>>>>> feature/new-handler}`,};
const instance = new UnresolvedFile({ // Controlled mode: apply payloads yourself. onMergeConflictAction(payload: MergeConflictActionPayload) { currentFile = { ...currentFile, contents: resolveMergeConflict(currentFile.contents, payload), };
instance.render({ file: currentFile, containerWrapper: container }); },});
instance.render({ file: currentFile, containerWrapper: container });Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.
12345678910import { disposeHighlighter } from '@pierre/diffs';
// Dispose the shared highlighter instance to free memory.// This is useful when you're done rendering diffs and want// to clean up resources (e.g., in a single-page app when// navigating away from a diff view).//// Note: After calling this, all themes and languages will// need to be reloaded on the next render.disposeHighlighter();Get direct access to the shared Shiki highlighter instance used internally by all components. Useful for custom highlighting operations.
1234567891011121314import { getSharedHighlighter, DiffsHighlighter } from '@pierre/diffs';
// Get the shared Shiki highlighter instance.// This is the same instance used internally by all FileDiff// and File components. Useful if you need direct access to// Shiki for custom highlighting operations.//// The highlighter is initialized lazily - themes and languages// are loaded on demand as you render different files.const highlighter: DiffsHighlighter = await getSharedHighlighter();
// You can use it directly for custom highlighting, see the Shiki// docs at https://shiki.style/ for detailsconst tokens = highlighter.codeToTokens('const x = 1;'); Compare file contents and generate a FileDiffMetadata structure. Use this when
you have full file contents rather than a patch string. Pass both oldFile and
newFile for changed files, oldFile: null for added files, or newFile: null
for deleted files.
null means that file side intentionally does not exist. Empty files should use
contents: '', not null. Passing null for both sides throws because there
is no file to diff.
If both oldFile and newFile have a cacheKey, the resulting
FileDiffMetadata will automatically receive a combined cache key (format:
oldKey:newKey). See Render Cache for more
information.
An optional throwOnError parameter (default: false) controls error handling.
When true, parsing errors throw exceptions; when false, errors are logged to
the console and parsing continues on a best-effort basis.
123456789101112131415161718192021222324252627282930313233343536373839404142import { parseDiffFromFile, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a diff by comparing file contents.// This is useful when you have full file contents rather// than a patch/diff string.const oldFile = { name: 'example.ts', contents: `function greet(name: string) { console.log("Hello, " + name);}`,};
const newFile = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}
export { greet };`,};
const fileDiff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);
// For added or deleted files, pass null for the side that does not exist.// Empty files still use FileContents with contents: ''.const addedFileDiff: FileDiffMetadata = parseDiffFromFile(null, newFile);const deletedFileDiff: FileDiffMetadata = parseDiffFromFile(oldFile, null);
// Omitting one side is not the same as passing null, and passing// null for both sides throws because at least one side must exist.
// With strict error handling (throws instead of logging)// const fileDiff = parseDiffFromFile(oldFile, newFile, undefined, true);
// fileDiff contains:// - name: the filename// - hunks: array of diff hunks with line information// - oldLines/newLines: full file contents split by line// - Various line counts for renderingParse unified diff / patch file content into structured data. Handles both
single patches and multi-commit patch files (like those from GitHub pull request
.patch URLs). An optional second parameter cacheKeyPrefix can be provided to
generate cache keys for each file in the patch (format:
prefix-patchIndex-fileIndex), enabling
caching of rendered diff results in the worker
pool.
An optional throwOnError parameter (default: false) controls error handling.
When true, parsing errors throw exceptions; when false, errors are logged to
the console and parsing continues on a best-effort basis.
12345678910111213141516171819202122232425262728293031323334353637383940414243import { parsePatchFiles, type ParsedPatch,} from '@pierre/diffs';
// Parse unified diff / patch file content.// Handles both single patches and multi-commit patch files// (like those from GitHub PR .patch URLs).const patchContent = `diff --git a/example.ts b/example.tsindex abc123..def456 100644--- a/example.ts+++ b/example.ts@@ -1,3 +1,4 @@ function greet(name: string) {- console.log("Hello, " + name);+ console.log(\`Hello, \${name}!\`); }+export { greet };`;
// Basic usageconst patches: ParsedPatch[] = parsePatchFiles(patchContent);
// With cache key prefix for worker pool caching// Each file gets a key like 'my-pr-123-0-0', 'my-pr-123-0-1', etc.// IMPORTANT: The prefix must change when patchContent changes!// Use a stable identifier like a commit SHA or content hash.const cachedPatches = parsePatchFiles(patchContent, 'my-pr-123-abc456');
// With strict error handling (throws instead of logging)// const patches = parsePatchFiles(patchContent, undefined, true);
// Each ParsedPatch contains:// - message: commit message (if present)// - files: array of FileDiffMetadata for each file in the patch
for (const patch of patches) { console.log('Commit:', patch.message); for (const file of patch.files) { console.log(' File:', file.name); console.log(' Hunks:', file.hunks.length); }}Trim patches with large context windows down to a fixed context window while keeping valid diff headers.
1234567891011121314151617181920212223242526272829303132333435363738394041424344import { trimPatchContext } from '@pierre/diffs';
// Trim a patch's context lines down to a fixed window size.// Useful for reducing large diffs while preserving change hunks.const patchContent = `diff --git a/example.ts b/example.tsindex abc123..def456 100644--- a/example.ts+++ b/example.ts@@ -1,12 +1,13 @@ import { format } from "./format"; import { log } from "./log"; import { readConfig } from "./config"; import { parseEnv } from "./env"; import { setup } from "./setup";
function greet(name: string) {- log("Hello, " + name);+ log(format("Hello, " + name)); }
export { greet };`;
// Keep 3 lines of context around changes.const trimmedPatch = trimPatchContext(patchContent, 3);
/*trimmedPatch:
diff --git a/example.ts b/example.tsindex abc123..def456 100644--- a/example.ts+++ b/example.ts@@ -5,7 +5,7 @@ import { setup } from "./setup";
function greet(name: string) {- log("Hello, " + name);+ log(format("Hello, " + name)); }
export { greet };
*/Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.
123456789101112131415161718import { preloadHighlighter } from '@pierre/diffs';
// Preload specific themes and languages before rendering.// This ensures the highlighter is ready with the assets you// need, avoiding any flash of unstyled content on first render.//// By default, themes and languages are loaded on demand,// but preloading is useful when you know which languages// you'll be rendering ahead of time.await preloadHighlighter({ // Themes to preload themes: ['pierre-dark', 'pierre-light', 'github-dark'], // Languages to preload langs: ['typescript', 'javascript', 'python', 'rust', 'go'],});
// After preloading, rendering diffs in these languages// will be instant with no async loading delay.Register a custom Shiki theme for use with any component. The theme name you
register must match the name field inside your theme JSON file.
1234567891011121314151617181920212223242526272829import { registerCustomTheme } from '@pierre/diffs';
// Register a custom Shiki theme before using it.// The theme name you register must match the 'name' field// inside your theme JSON file.
// Option 1: Dynamic import (recommended for code splitting)registerCustomTheme('my-custom-theme', () => import('./my-theme.json'));
// Option 2: Inline theme objectregisterCustomTheme('inline-theme', async () => ({ name: 'inline-theme', type: 'dark', colors: { 'editor.background': '#1a1a2e', 'editor.foreground': '#eaeaea', // ... other VS Code theme colors }, tokenColors: [ { scope: ['comment'], settings: { foreground: '#6a6a8a' }, }, // ... other token rules ],}));
// Once registered, use the theme name in your components:// <FileDiff options={{ theme: 'my-custom-theme' }} ... />Register a custom Shiki language loader and optionally map it to file names or extensions. Use this when you're working with languages not bundled by Shiki or want custom highlighting grammars.
1234567891011121314151617import { registerCustomLanguage } from '@pierre/diffs';
// Register a custom Shiki language loader before rendering.// The language name you register becomes available to Shiki.
// Option 1: Dynamic import (recommended for code splitting)registerCustomLanguage('my-lang', () => import('./my-lang.tmLanguage.json'), [ // File names (exact match) 'MySpecialFile', // Extensions (without leading dot) 'mylang', // Compound extensions 'spec.mylang',]);
// Option 2: No extension mapping (use setLanguageOverride instead)registerCustomLanguage('my-lang', () => import('./my-lang.tmLanguage.json'));Override the syntax highlighting language for a FileContents or
FileDiffMetadata object. This is useful when the filename doesn't have an
extension or doesn't match the actual language.
123456789101112131415161718192021222324252627import { setLanguageOverride, parsePatchFiles, type FileContents, type FileDiffMetadata,} from '@pierre/diffs';
// setLanguageOverride creates a new FileContents or FileDiffMetadata// with the language explicitly set. This is useful when:// - The filename doesn't have an extension// - The extension doesn't match the actual language// - You're parsing patches and need to override the detected language
// Example 1: Override language on a FileContentsconst file: FileContents = { name: 'Dockerfile', // No extension, would default to 'text' contents: 'FROM node:20\nRUN npm install',};const dockerFile = setLanguageOverride(file, 'dockerfile');
// Example 2: Override language on a FileDiffMetadataconst patches = parsePatchFiles(patchString);const diff: FileDiffMetadata = patches[0].files[0];const typescriptDiff = setLanguageOverride(diff, 'typescript');
// The function returns a new object with the lang property set,// leaving the original unchanged (immutable operation).Diff and code components are rendered using shadow DOM APIs, allowing styles to
be well-isolated from your page's existing CSS. However, it also means you may
have to utilize some custom CSS variables to override default styles. These can
be done in your global CSS, as style props on parent components, or on the
FileDiff component directly.
12345678910111213141516171819202122232425262728293031323334353637383940414243:root { /* Available Custom CSS Variables. Most should be self explanatory */ /* Sets code font, very important */ --diffs-font-family: 'Berkeley Mono', monospace; --diffs-font-size: 14px; --diffs-line-height: 1.5; /* Controls tab character size */ --diffs-tab-size: 2; /* Font used in header and separator components, * typically not a monospace font, but it's your call */ --diffs-header-font-family: Helvetica; /* Override or customize any 'font-feature-settings' * for your code font */ --diffs-font-features: normal; /* Override the minimum width for the number column. By default * it should take into account the number of digits required * based on the lines in the file itself, but you can manually * override if desired. Generally we recommend using ch units * because they work well with monospaced fonts */ --diffs-min-number-column-width: 3ch;
/* By default we try to inherit the deletion/addition/modified * colors from the existing Shiki theme, however if you'd like * to override them, you can do so via these css variables: */ --diffs-deletion-color-override: orange; --diffs-addition-color-override: yellow; --diffs-modified-color-override: purple;
/* Line selection colors - customize the staged selection tint that gets * mixed into selected rows and their gutter/number cells. These support * light-dark() for automatic theme adaptation. */ --diffs-selection-color-override: rgb(37, 99, 235); --diffs-bg-selection-override: rgba(147, 197, 253, 0.28); --diffs-bg-selection-number-override: rgba(96, 165, 250, 0.55);
/* Edit cursor background color */ --diffs-bg-caret-override: rgba(128, 128, 128, 0.55);
/* Some basic variables for tweaking the layouts of some of the built in * components */ --diffs-gap-inline: 8px; --diffs-gap-block: 8px;}1234567<FileDiff style={{ '--diffs-font-family': 'JetBrains Mono, monospace', '--diffs-font-size': '13px' } as React.CSSProperties} // ... other props/>For advanced customization, you can inject arbitrary CSS into the shadow DOM
using the unsafeCSS option. This CSS will be wrapped in an @layer unsafe
block, giving it the highest priority in the cascade. Use this sparingly and
with caution, as it bypasses the normal style isolation.
We also recommend that any CSS you apply uses simple, direct selectors targeting
the existing data attributes. Avoid structural selectors like :first-child,
:last-child, :nth-child(), sibling combinators (+ or ~), deeply nested
descendant selectors, or bare tag selectors—these are susceptible to breaking in
future versions or in edge cases that may be difficult to anticipate.
We cannot currently guarantee backwards compatibility for this feature across any future changes to the library, even in patch versions. Please reach out so that we can discuss a more permanent solution for modifying styles.
1234567891011121314151617<FileDiff options={{ unsafeCSS: /* css */ `[data-line-index='0'] { border-top: 1px solid var(--diffs-bg-context);}
[data-line] { border-bottom: 1px solid var(--diffs-bg-context);}
[data-column-number] { border-right: 1px solid var(--diffs-bg-context);}` }} // ... other props/>Pierre Diffs ships with our custom open source themes, Pierre Light and Pierre Dark. We generate our themes with a custom build process that takes a shared color palette, assigns colors to specific roles for syntax highlighting, and builds JSON files and editor extensions. This makes our themes compatible with Shiki, Visual Studio Code, Cursor, and Zed.
| Editor / Platform | Source |
|---|---|
| Visual Studio Code | VS Code Marketplace |
| Cursor | Open VSX |
| Zed | Zed Extensions |
| Shiki | Theme repository |
While you can use any Shiki theme with Pierre Diffs by passing the theme name to
the theme option, you can also create and register custom themes compatible
with Shiki and Visual Studio Code. We recommend using our themes as a starting
point for your own custom themes—head to our themes documentation to
get started.
Themes documentation
Token hooks are experimental and subject to change.
Token hooks let you attach callbacks to syntax-highlighted tokens for custom
hover UI, and LSP textDocument/hover integrations.
The shared prop tables in the React API and Vanilla JS API sections list the exact option names. This section covers behavior, examples, and performance tradeoffs.
Available on:
MultiFileDiff, PatchDiff, FileDiff, and FileFileDiff and FileShared behavior:
onTokenEnter, onTokenLeave, and onTokenClick receive tokenText,
lineNumber, lineCharStart, lineCharEnd, and tokenElement. Diff
variants also receive side.lineCharStart is zero-based and lineCharEnd is end-exclusive.enableTokenInteractionsOnWhitespace is true.tokenElement is usually the simplest way to apply temporary hover styles.useTokenTransformer: true when you want token wrappers or experimental
selectors like data-char without token callbacks.useTokenTransformer: true on WorkerPoolManager — pool-rendered components
follow the pool's options, not component callbacks. Worker pools can move
highlighting work off the main thread, but they do not reduce the extra DOM
size created by token metadata.useTokenTransformer: true on your preload option configs only when you want
token markup without callbacks.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576import type { DiffTokenEventBaseProps } from '@pierre/diffs';import { MultiFileDiff } from '@pierre/diffs/react';
const oldFile = { name: 'hover.ts', contents: 'function greet(name: string) { return name;}',};
const newFile = { name: 'hover.ts', contents: 'function greet(userName: string) { return userName;}',};
export function TokenHoverExample() { return ( <MultiFileDiff oldFile={oldFile} newFile={newFile} options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' },
// Experimental API. Subject to change. onTokenEnter({ lineNumber, lineCharStart, lineCharEnd, side, tokenElement, tokenText, }: DiffTokenEventBaseProps) { // Designed to pair well with LSP APIs such as textDocument/hover. console.log('hover token', { lineNumber, lineCharStart, lineCharEnd, side, tokenText, });
// If you would like to apply hover styles to the token, // you could do so with the element reference tokenElement.style.backgroundColor = 'light-dark(black, white)'; tokenElement.style.color = 'light-dark(white, black)'; tokenElement.style.borderRadius = '2px'; },
onTokenLeave({ tokenElement }: DiffTokenEventBaseProps) { // Just don't forget to zero out the styles on leave tokenElement.style.backgroundColor = ''; tokenElement.style.color = ''; tokenElement.style.borderRadius = ''; },
onTokenClick({ tokenText, lineNumber, lineCharStart, lineCharEnd, side, }: DiffTokenEventBaseProps) { console.log('clicked token', { tokenText, lineNumber, lineCharStart, lineCharEnd, side, }); }, }} /> );}This feature is experimental and undergoing active development. There may be bugs and the API is subject to change.
Import worker utilities from @pierre/diffs/worker.
By default, syntax highlighting runs on the main thread using Shiki. If you're rendering large files or many diffs, this can cause a bottleneck on your JavaScript thread resulting in jank or unresponsiveness. To work around this, we've provided some APIs to run all syntax highlighting in worker threads. The main thread will still attempt to render plain text synchronously and then apply the syntax highlighting when we get a response from the worker threads.
Basic usage differs a bit depending on if you're using React or Vanilla JS APIs, so continue reading for more details.
One unfortunate side effect of using Web Workers is that different bundlers and environments require slightly different approaches to create a Web Worker. You'll need to create a function that spawns a worker that's appropriate for your environment and bundler and then pass that function to our provided APIs.
Lets begin with the workerFactory function. We've provided some examples for
common use cases below.
Only the Vite and NextJS examples have been tested by us. Additional examples were generated by AI. If any of them are incorrect, please let us know.
You may need to explicitly set the worker.format option in your
Vite Config to 'es'.
12345import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';
export function workerFactory(): Worker { return new Worker(WorkerUrl, { type: 'module' });}Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.
12345678910'use client';
export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ) );}VS Code webviews have special security restrictions that require a different approach. You'll need to configure both the extension side (to expose the worker file) and the webview side (to load it via blob URL).
Extension side: Add the worker directory to localResourceRoots in your
getWebviewOptions():
12345678910111213141516function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions { return { enableScripts: true, localResourceRoots: [ // ... your other roots vscode.Uri.joinPath( extensionUri, 'node_modules', '@pierre', 'diffs', 'dist', 'worker' ), ], };}Create the worker URI in _getHtmlForWebview(). Note: use worker-portable.js
instead of worker.js — the portable version is designed for environments where
ES modules aren't supported in web workers.
12345678910const workerScriptPath = vscode.Uri.joinPath( this._extensionUri, 'node_modules', '@pierre', 'diffs', 'dist', 'worker', 'worker-portable.js');const workerScriptUri = webview.asWebviewUri(workerScriptPath);Pass the URI to the webview via an inline script in your HTML:
1<script nonce="${nonce}">window.WORKER_URI = "${workerScriptUri}";</script>Your Content Security Policy must include worker-src and connect-src:
12worker-src ${webview.cspSource} blob:;connect-src ${webview.cspSource};Webview side: Declare the global type for the URI:
12345declare global { interface Window { WORKER_URI: string; }}Fetch the worker code and create a blob URL:
123456async function createWorkerBlobUrl(): Promise<string> { const response = await fetch(window.WORKER_URI); const workerCode = await response.text(); const blob = new Blob([workerCode], { type: 'application/javascript' }); return URL.createObjectURL(blob);}Create the workerFactory function:
12345const workerBlobUrl = await createWorkerBlobUrl();
function workerFactory() { return new Worker(workerBlobUrl);}123456789export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ), { type: 'module' } );}123456789export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ), { type: 'module' } );}If your bundler doesn't have special worker support, build and serve the worker file statically:
1234567// For Rollup or bundlers without special worker support:// 1. Copy worker.js to your static/public folder// 2. Reference it by URL
export function workerFactory(): Worker { return new Worker('/static/workers/worker.js', { type: 'module' });}For projects without a bundler, host the worker file on your server and reference it directly:
123456// No bundler / Vanilla JS// Host worker.js on your server and reference it by URL
export function workerFactory() { return new Worker('/path/to/worker.js', { type: 'module' });}With your workerFactory function created, you can integrate it with our
provided APIs. In React, you'll want to pass this workerFactory to a
<WorkerPoolContextProvider> so all components can inherit the pool
automatically. If you're using the Vanilla JS APIs, we provide a
getOrCreateWorkerPoolSingleton helper that ensures a single pool instance that
you can then manually pass to all your File/FileDiff instances.
When using the worker pool, the theme, lineDiffType,
tokenizeMaxLineLength, and useTokenTransformer render options are
controlled by WorkerPoolManager, not individual components. Passing these
options into component instances will be ignored.
To change render options after WorkerPoolManager instantiates, call
setRenderOptions(). Changing render options will force mounted components to
re-render and clear the render cache.
If you need token callbacks or experimental token selectors such as data-char,
enable useTokenTransformer: true on the worker pool itself. Worker pools can
move highlighting work off the main thread, but they do not reduce the extra DOM
size created by token metadata. For token callback behavior and performance
tradeoffs, see Token Hooks.
Edit mode needs no pool configuration: a file being edited renders
on the main thread with the token metadata the editor needs, while the pool
keeps rendering every other file. Enabling useTokenTransformer: true on the
pool is an optional optimization for edit-heavy apps — pool markup is then
already editor-compatible, so entering edit mode skips a one-time re-render of
that file.
If you need to control which Shiki engine is used, set preferredHighlighter
when initializing the pool ('shiki-js' by default, 'shiki-wasm' optional).
Wrap your component tree with WorkerPoolContextProvider from
@pierre/diffs/react. All FileDiff and File components nested within will
automatically use the worker pool for syntax highlighting.
The WorkerPoolContextProvider will automatically spin up or shut down the
worker pool based on its react lifecycle. If you have multiple context
providers, they will all share the same pool, and termination won't occur until
all contexts are unmounted.
Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.
To change themes or other render options dynamically, use the useWorkerPool()
hook to access the pool manager and call setRenderOptions().
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374// components/HighlightProvider.tsx'use client';
import { useWorkerPool, WorkerPoolContextProvider,} from '@pierre/diffs/react';import type { ReactNode } from 'react';import { workerFactory } from '@/utils/workerFactory';
// Create a client component that wraps children with the worker pool.// Import this in your layout to provide the worker pool to all pages.export function HighlightProvider({ children }: { children: ReactNode }) { return ( <WorkerPoolContextProvider poolOptions={{ workerFactory, // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. // poolSize: 8, }} highlighterOptions={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Optional: skip inline line diffs for very long changed lines // maxLineDiffLength: 1000, // Optional: pick the Shiki engine ('shiki-js' is default) // preferredHighlighter: 'shiki-wasm', // Optionally preload languages to avoid lazy-loading delays langs: ['typescript', 'javascript', 'css', 'html'], }} > {children} </WorkerPoolContextProvider> );}
// layout.tsx// import { HighlightProvider } from '@/components/HighlightProvider';//// export default function Layout({ children }) {// return (// <html>// <body>// <HighlightProvider>{children}</HighlightProvider>// </body>// </html>// );// }
// Any File, FileDiff, MultiFileDiff, or PatchDiff component nested within// the layout will automatically use the worker pool, no additional props required.
// ---
// To change render options dynamically, use the useWorkerPool hook:function ThemeSwitcher() { const workerPool = useWorkerPool();
const switchToGitHub = () => { // setRenderOptions accepts a Partial<WorkerRenderingOptions>. // Any omitted options will use defaults: // - theme: { dark: 'pierre-dark', light: 'pierre-light' } // - lineDiffType: 'word-alt' // - maxLineDiffLength: 1000 // - tokenizeMaxLineLength: 1000 void workerPool?.setRenderOptions({ theme: { dark: 'github-dark', light: 'github-light' }, }); };
return <button onClick={switchToGitHub}>Switch to GitHub theme</button>;}// WARNING: Changing render options will force all mounted components// to re-render and will clear the render cache.Use getOrCreateWorkerPoolSingleton to spin up a singleton worker pool. Then
pass that as the second argument to File and/or FileDiff. When you are done
with the worker pool, you can use terminateWorkerPoolSingleton to free up
resources.
To change themes or other render options dynamically, call
setRenderOptions(options) on the pool instance.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657import { FileDiff } from '@pierre/diffs';import { getOrCreateWorkerPoolSingleton, terminateWorkerPoolSingleton,} from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
// Create a singleton worker pool instance using your workerFactory.// This ensures the same pool is reused across your app.const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory, // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. // poolSize: 8, }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Optional: skip inline line diffs for very long changed lines // maxLineDiffLength: 1000, // Optional: pick the Shiki engine ('shiki-js' is default) // preferredHighlighter: 'shiki-wasm', // Optionally preload languages to avoid lazy-loading delays langs: ['typescript', 'javascript', 'css', 'html'], },});
// Pass the workerPool as the second argument to FileDiffconst instance = new FileDiff( { theme: { dark: 'pierre-dark', light: 'pierre-light' } }, workerPool);
// Note: Store file objects in variables rather than inlining them.// FileDiff uses reference equality to detect changes and skip// unnecessary re-renders.const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
instance.render({ oldFile, newFile, containerWrapper: document.body });
// To change render options dynamically, call setRenderOptions on the worker pool.// It accepts a Partial<WorkerRenderingOptions>. Any omitted options will use defaults:// - theme: { dark: 'pierre-dark', light: 'pierre-light' }// - lineDiffType: 'word-alt'// - maxLineDiffLength: 1000// - tokenizeMaxLineLength: 1000await workerPool.setRenderOptions({ theme: { dark: 'github-dark', light: 'github-light' },});// WARNING: Changing render options will force all mounted components// to re-render and will clear the render cache.
// Optional: terminate workers when no longer needed (e.g., SPA navigation)// Page unload automatically cleans up workers, but for SPAs you may want// to call this when unmounting to free resources sooner.// terminateWorkerPoolSingleton();This is an experimental feature being validated in production use cases. The API is subject to change.
The worker pool can cache rendered AST results to avoid redundant highlighting
work. When a file or diff has a cacheKey, subsequent requests with the same
key will return cached results immediately instead of reprocessing through a
worker. This works automatically for both React and Vanilla JS APIs.
Caching is enabled per-file/diff by setting a cacheKey property. Files and
diffs without a cacheKey will not be cached. The cache also validates
against render options — if options like theme or line diff type change, the
cached result is skipped and re-rendered.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263import { getOrCreateWorkerPoolSingleton,} from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory, // Optional: configure cache size per cache (default: 100) // Two separate LRU caches are maintained: one for files, // one for diffs, so combined cache size will be double totalASTLRUCacheSize: 200, }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, },});
// Caching is enabled automatically when files/diffs have a cacheKey.// Files and diffs without cacheKey will not be cached.
const fileWithCaching = { name: 'example.ts', contents: 'const x = 42;', cacheKey: 'file-abc123', // <-- Enables caching for this file};
const fileWithoutCaching = { name: 'other.ts', contents: 'const y = 1;', // No cacheKey - will not be cached};
// IMPORTANT: The cacheKey must change whenever the content changes!// If content changes but the key stays the same, stale cached results// will be returned. Use content hashes or version numbers in your keys.const fileV1 = { name: 'file.ts', contents: 'v1', cacheKey: 'file-v1' };const fileV2 = { name: 'file.ts', contents: 'v2', cacheKey: 'file-v2' };
// Cache key best practices:// - DON'T use file contents as the key - large strings potentially// waste memory// - DON'T rely solely on filenames - they may not be unique or stable// - DO use stable identifiers like commit SHAs, file IDs, or version numbers// - DO combine identifiers when needed: `${fileId}-${version}`
// How caching works:// - Files/diffs with cacheKey are stored in an LRU cache after rendering// - Subsequent renders with the same cacheKey return cached results instantly// - No worker processing required for cache hits// - Cache is validated against render options (e.g., theme, lineDiffType, maxLineDiffLength)// - If options changed, cached result is skipped and re-rendered// - Cache is cleared when the pool is terminated
// Inspect cache contents (for debugging)const { fileCache, diffCache } = workerPool.inspectCaches();console.log('Cached files:', fileCache.size);console.log('Cached diffs:', diffCache.size);
// Evict specific items from the cache when content is invalidated// (e.g., user edits a file, new commit is pushed)workerPool.evictFileFromCache('file-abc123');workerPool.evictDiffFromCache('diff-xyz789');These methods are exposed for advanced use cases. In most scenarios, you should
use the WorkerPoolContextProvider for React or pass the pool instance via the
workerPool option for Vanilla JS rather than calling these methods directly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566// WorkerPoolManager constructornew WorkerPoolManager(poolOptions, highlighterOptions)
// Parameters:// - poolOptions: WorkerPoolOptions// - workerFactory: () => Worker - Function that creates a Worker instance// - poolSize?: number (default: 8) - Number of workers// - totalASTLRUCacheSize?: number (default: 100) - Max items per cache// (Two separate LRU caches are maintained: one for files, one for diffs.// Each cache has this limit, so total cached items can be 2x this value.)// - highlighterOptions: WorkerInitializationRenderOptions// - theme?: DiffsThemeNames | ThemesType - Theme name or { dark, light } object// - lineDiffType?: 'word' | 'word-alt' | 'char' - How to diff lines (default: 'word-alt')// - maxLineDiffLength?: number - Max changed-line length for inline line diffs (default: 1000)// - tokenizeMaxLineLength?: number - Max line length to tokenize (default: 1000)// - preferredHighlighter?: 'shiki-js' | 'shiki-wasm' - Highlighter engine (default: 'shiki-js')// - langs?: SupportedLanguages[] - Array of languages to preload
// Methods:poolManager.initialize()// Returns: Promise<void> - Initializes workers (auto-called on first render)
poolManager.isInitialized()// Returns: boolean
poolManager.setRenderOptions(options)// Returns: Promise<void> - Changes render options dynamically// Accepts: Partial<WorkerRenderingOptions>// - theme?: DiffsThemeNames | ThemesType// - lineDiffType?: 'word' | 'word-alt' | 'char'// - maxLineDiffLength?: number// - tokenizeMaxLineLength?: number// Omitted options will use defaults. WARNING: This forces all mounted// components to re-render and clears the render cache.
poolManager.getRenderOptions()// Returns: WorkerRenderingOptions - Current render options (copy)
poolManager.highlightFileAST(fileInstance, file, options)// Queues highlighted file render, calls fileInstance.onHighlightSuccess when done
poolManager.getPlainFileAST(file, startingLineNumber?)// Returns: ThemedFileResult | undefined - Sync render with 'text' lang
poolManager.highlightDiffAST(fileDiffInstance, diff, options)// Queues highlighted diff render, calls fileDiffInstance.onHighlightSuccess when done
poolManager.getPlainDiffAST(diff, lineDiffType)// Returns: ThemedDiffResult | undefined - Sync render with 'text' lang
poolManager.terminate()// Terminates all workers and resets state
poolManager.getStats()// Returns: { totalWorkers, busyWorkers, queuedTasks, pendingTasks }
poolManager.inspectCaches()// Returns: { fileCache, diffCache } - LRU cache instances for debugging
poolManager.evictFileFromCache(cacheKey)// Returns: boolean - Evicts a file from the cache by its cacheKey// Returns true if the item was evicted, false if it wasn't in the cache
poolManager.evictDiffFromCache(cacheKey)// Returns: boolean - Evicts a diff from the cache by its cacheKey// Returns true if the item was evicted, false if it wasn't in the cacheThe worker pool manages a configurable number of worker threads that each initialize their own Shiki highlighter instance. Tasks are distributed across available workers, with queuing when all workers are busy.
1234567891011121314151617181920212223242526272829303132333435363738394041┌────────────── Main Thread ──────────────┐│ ┌ React (if used) ────────────────────┐ ││ │ <WorkerPoolContextProvider> │ ││ │ <FileDiff /> │ ││ │ <File /> │ ││ │ </WorkerPoolContextProvider> │ ││ │ │ ││ │ * Each component manages their own │ ││ │ instances of the Vanilla JS │ ││ │ Classes │ ││ └─┬───────────────────────────────────┘ ││ │ ││ ↓ ││ ┌ Vanilla JS Classes ─────────────────┐ ││ │ new FileDiff(opts, poolManager) │ ││ │ new File(opts, poolManager) │ ││ │ │ ││ │ * Renders plain text synchronously │ ││ │ * Queue requests to WorkerPool for │ ││ │ highlighted HAST │ ││ │ * Automatically render the │ ││ │ highlighted HAST response │ ││ └─┬─────────────────────────────────┬─┘ ││ │ HAST Request ↑ ││ ↓ HAST Response │ ││ ┌ WorkerPoolManager ────────────────┴─┐ ││ │ * Shared singleton │ ││ │ * Manages WorkerPool instance and │ ││ │ request queue │ ││ └─┬─────────────────────────────────┬─┘ │└───│─────────────────────────────────│───┘ │ postMessage ↑ ↓ HAST Response │┌───┴───────── Worker Threads ────────│───┐│ ┌ worker.js ────────────────────────│─┐ ││ │ * 8 threads by default │ │ ││ │ * Runs Shiki's codeToHast() ──────┘ │ ││ │ * Manages themes and language │ ││ │ loading automatically │ ││ └─────────────────────────────────────┘ │└─────────────────────────────────────────┘Import SSR utilities from @pierre/diffs/ssr.
The SSR API allows you to pre-render file diffs on the server with syntax highlighting, then hydrate them on the client for full interactivity.
If you pass prerenderedHTML, onPostRender still fires on the client after
hydration with phase: 'mount'. On later renders, it also fires after
DOM-committing updates with phase: 'update', whether those updates are a full
replacement or a partial update. Before a mounted container is removed,
replaced, or recycled, it fires with phase: 'unmount'. This is useful for
measuring, observing, cleaning up, or otherwise manipulating the mounted diff
container or content of the diffs itself.
Each preload function returns an object containing the original inputs plus a
prerenderedHTML string. This object can be spread directly into the
corresponding React component for automatic hydration.
Inputs used for pre-rendering must exactly match what's rendered in the client component. We recommend spreading the entire result object into your File or Diff component to ensure the client receives the same inputs that were used to generate the pre-rendered HTML.
123456789101112131415161718192021222324252627// app/diff/page.tsx (Server Component)import { preloadMultiFileDiff } from '@pierre/diffs/ssr';import { DiffViewer } from './DiffViewer';
const oldFile = { name: 'example.ts', contents: `function greet(name: string) { console.log("Hello, " + name);}`,};
const newFile = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}`,};
export default async function DiffPage() { const preloaded = await preloadMultiFileDiff({ oldFile, newFile, options: { theme: 'pierre-dark', diffStyle: 'split' }, });
return <DiffViewer preloaded={preloaded} />;}1234567891011121314// app/diff/DiffViewer.tsx (Client Component)'use client';
import { MultiFileDiff } from '@pierre/diffs/react';import type { PreloadMultiFileDiffResult } from '@pierre/diffs/ssr';
interface Props { preloaded: PreloadMultiFileDiffResult;}
export function DiffViewer({ preloaded }: Props) { // Spread the entire result to ensure inputs match what was pre-rendered return <MultiFileDiff {...preloaded} />;}We provide several preload functions to handle different input formats. Choose the one that matches your data source.
Preloads a single file with syntax highlighting (no diff). Use this when you
want to render a file without any diff context. Spread into the File
component.
12345678910111213import { preloadFile } from '@pierre/diffs/ssr';
const file = { name: 'example.ts', contents: 'export function hello() { return "world"; }',};
const result = await preloadFile({ file, options: { theme: 'pierre-dark' },});
// Spread result into <File {...result} />Preloads a merge-conflict file for UnresolvedFile hydration. Use this when the
file contains conflict markers (<<<<<<<, =======, >>>>>>>) and you want to
preserve the unresolved conflict UI from SSR to client.
Experimental:
UnresolvedFileandpreloadUnresolvedFileare currently beta/experimental and may change in future releases.
123456789101112131415161718import { preloadUnresolvedFile } from '@pierre/diffs/ssr';
const file = { name: 'example.ts', contents: `<<<<<<< HEADconst source = "server";=======const source = "web";>>>>>>> feature/web-source`,};
const result = await preloadUnresolvedFile({ file, options: { theme: 'pierre-dark' },});
// Spread result into <UnresolvedFile {...result} />Preloads a diff from a FileDiffMetadata object. Use this when you already have
parsed diff metadata (e.g., from parseDiffFromFile or parsePatchFiles).
Spread into the FileDiff component.
The lower-level preloadDiffHTML helper also accepts direct file contents. Use
oldFile: null for a new file, or newFile: null for a deleted file.
12345678910111213141516import { preloadFileDiff } from '@pierre/diffs/ssr';import { parseDiffFromFile } from '@pierre/diffs';
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
// First parse the diff to get FileDiffMetadataconst fileDiff = parseDiffFromFile(oldFile, newFile);
// Then preload for SSRconst result = await preloadFileDiff({ fileDiff, options: { theme: 'pierre-dark' },});
// Spread result into <FileDiff {...result} />Preloads a diff directly from file contents. This is the simplest option when
you have raw file contents and want to generate a diff. Pass both sides for a
changed file, oldFile: null for a new file, or newFile: null for a deleted
file. Spread into the MultiFileDiff component.
1234567891011121314import { preloadMultiFileDiff } from '@pierre/diffs/ssr';
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
const result = await preloadMultiFileDiff({ // Pass FileContents for existing sides. Use oldFile: null for // a new file or newFile: null for a deleted file. oldFile, newFile, options: { theme: 'pierre-dark', diffStyle: 'split' },});
// Spread result into <MultiFileDiff {...result} />Preloads a diff from a unified patch string for a single file. Use this when you
have a patch in unified diff format. Spread into the PatchDiff component.
1234567891011121314import { preloadPatchDiff } from '@pierre/diffs/ssr';
const patch = `--- a/example.ts+++ b/example.ts@@ -1 +1 @@-const x = 1;+const x = 2;`;
const result = await preloadPatchDiff({ patch, options: { theme: 'pierre-dark' },});
// Spread result into <PatchDiff {...result} />Preloads multiple diffs from a multi-file patch string. Returns an array of
results, one for each file in the patch. Each result can be spread into a
FileDiff component.
12345678910111213141516171819202122import { preloadPatchFile } from '@pierre/diffs/ssr';
// A patch containing multiple file changesconst patch = `diff --git a/foo.ts b/foo.ts--- a/foo.ts+++ b/foo.ts@@ -1 +1 @@-const a = 1;+const a = 2;diff --git a/bar.ts b/bar.ts--- a/bar.ts+++ b/bar.ts@@ -1 +1 @@-const b = 1;+const b = 2;`;
const results = await preloadPatchFile({ patch, options: { theme: 'pierre-dark' },});
// Spread each result into <FileDiff {...results[i]} />