# @pierre/diffs > An open source diff and code rendering library for the web. Built on Shiki for syntax highlighting, with React and vanilla JS APIs, virtualization, SSR support, and extensive theming. - Package: `@pierre/diffs` on [npm](https://www.npmjs.com/package/@pierre/diffs) - GitHub: https://github.com/pierrecomputer/pierre - Docs: https://diffs.com/docs ## Overview **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](https://shiki.style/) which provides a lot of great theme and language support. 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. ## Rendering Diffs 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`: 1. Provide two versions of a file or code snippet to compare 2. Consume a patch file You can see examples of these approaches below, in both JavaScript and React. **React Patch File** (`react_patch_file.tsx`): ```tsx import { type ParsedPatch, FileDiff, parsePatchFiles, } from '@pierre/diffs/react'; // If you consume a patch file, then you'll need to spawn multiple // renderers for each file in the patches function Patches() { const [parsedPatches, setParsedPatches] = useState([]); useEffect(() => { // This is a fake function to fetch a github pr patch file, not an // actual api fetchGithubPatch('https://github.com/twbs/bootstrap/pull/41766.patch') .then( (data: string) => { setParsedPatches( // Github can return multiple patches in 1 file, we handle all // of this automatically for you. Just give us a single patch // or any number parsePatchFiles(data) ); } ); }, []); return ( <> {parsedPatches.map((patch, index) => ( {patch.files.map((fileDiff, index) => ( // Under the hood, all instances of FileDiff will use a // shared Shiki highlighter and manage loading languages // and themes for you ))} ))} ); } ``` **React Single File** (`react_single_file.tsx`): ```tsx import { type FileContents, MultiFileDiff, } from '@pierre/diffs/react'; // Store file objects in variables rather than inlining them. // The React components use reference equality to detect changes // and skip unnecessary re-renders, so keep these references stable // (e.g., with useState or useMemo). 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"}); } `, }; function SingleDiff() { return ( ); } ``` **Vanilla Patch File** (`vanilla_patch_file.ts`): ```typescript import { FileDiff, ParsedPatch, parsePatchFiles, } from '@pierre/diffs'; // This is a fake function to fetch a GitHub PR patch file, // not an actual api const patchFileContent: string = await fetchGithubPatch( 'https://github.com/twbs/bootstrap/pull/41766.patch' ); // Github can return multiple patches in 1 file, we handle all of this // automatically for you. Just give us a single patch or any number const parsedPatches: ParsedPatch[] = parsePatchFiles(patchFileContent); for (const patch of parsedPatches) { for (const fileDiff of patch.files) { // 'fileDiff' is a data structure that includes all hunks for a // specific file from a patch const instance = new FileDiff({ // Automatically theme based on users os settings theme: { dark: 'pierre-dark', light: 'pierre-light' }, }); // Under the hood, all instances of FileDiff will use a shared // Shiki highlighter and manage loading languages and themes for // you automatically instance.render({ fileDiff, containerWrapper: document.body, }); } } ``` **Vanilla Single File** (`vanilla_single_file.ts`): ```typescript import { 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, }); ``` ## Installation Diffs is [published as an npm package](https://www.npmjs.com/package/@pierre/diffs). Install Diffs with the package manager of your choice: ### Package Exports The package provides several entry points for different use cases: | Package | Description | | ---------------------- | --------------------------------------------------------------------------------------------------- | | `@pierre/diffs` | [Vanilla JS components](#vanilla-js-api), plus [utility functions](#utilities) | | `@pierre/diffs/react` | [React components](#react-api) for rendering diffs and files | | `@pierre/diffs/edit` | Low-level [edit mode](#edit-mode) `Editor` for attaching editing to rendered file and diff surfaces | | `@pierre/diffs/ssr` | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting | | `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) for offloading syntax highlighting to background threads | ## Build with agents ### Agent skill Install the [`diffs` agent skill](https://www.skills.sh/pierrecomputer/pierre/diffs) with the [Skills CLI](https://skills.sh/docs/cli) for access to the entire API and common recipes, regardless of your integration method. ### Prompt an agent Alternatively, you can copy-paste this prompt to your agent to install the skill and point it to these docs as plain-text. The "For Agents" button on the [home page](/) copies the same prompt. ### Plain-text docs Our documentation is also available in condensed, Markdown-formatted plain text, available in two versions: - [llms.txt](/llms.txt) is a short index of the sections - [llms-full.txt](/llms-full.txt) is the everything in the docs — prose, API tables, and code examples Copy and paste as needed, or provide the URLs to your agent. **Agent Prompt** (`prompt.md`): ```text Set 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/docs Full reference for LLMs: https://diffs.com/llms-full.txt ``` **Agent Skill Install** (`agent-skill.sh`): ```bash npx skills add pierrecomputer/pierre --skill diffs ``` ## Core Types Before diving into the components, it's helpful to understand the core file, diff, and annotation data structures used throughout the library. ### FileContents `FileContents` represents one existing file version. Use it when rendering a file with the `` 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`. For read-only rendering and Worker Pool caching, `cacheKey` is optional; when provided, treat it as a revision identity and change it with the contents, filename, language, or revision. When [`Editor.persistState`](#edit-mode-editor-options-persisting-file-state) is enabled, every editable file requires an explicit, non-empty `cacheKey`. Use unique, stable keys for editing sessions, and change the key when incoming contents should replace the cached document. ### FileDiffMetadata `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`](#utilities-parsedifffromfile) (from file contents) or [`parsePatchFiles`](#utilities-parsepatchfiles) (from a patch string). ### LineAnnotation and DiffLineAnnotation `LineAnnotation` 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` adds `side: 'additions' | 'deletions'` to select a file side. Line coordinates are one-based on the selected file side, not row positions in the rendered diff. Use `lineNumber: 0` for a file-level annotation above the first file line or, in a diff, above the first hunk or row on that side. Callbacks shared by file and diff surfaces use `LineAnnotation[] | DiffLineAnnotation[]`. Use `isFileAnnotationCollection` or `isDiffAnnotationCollection` to narrow the collection before reading shape-specific fields. For individual annotation unions, use `isFileAnnotation` or `isDiffAnnotation`. Store a stable, position-independent application ID in metadata when an annotation owns drafts or other interactive state. For annotations that survive an edit, edit mode preserves metadata while remapping line coordinates. For the controlled update pattern and exact remapping rules, see [Editing with line annotations](#edit-mode-editing-with-line-annotations). ### Creating Diffs There are two ways to create a `FileDiffMetadata`. #### From File Contents 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. #### From a Patch String 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`. **Tip:** If you need to change the language after creating a `FileContents` or `FileDiffMetadata`, use the [`setLanguageOverride`](#utilities-setlanguageoverride) utility function. **File Contents Type** (`FileContents.ts`): ```typescript import type { FileContents } from '@pierre/diffs'; // FileContents represents one existing file side. // Use null, not FileContents with an empty string, for an intentionally // missing side. interface FileContents { // The filename (used for display and language detection) name: string; // The file's text content contents: string; // Optional: Override the detected language for syntax highlighting // See: https://shiki.style/languages lang?: SupportedLanguages; // Optional identity for Worker Pool caching. Required when // Editor.persistState is enabled; use a unique, stable key for that editing // session and reuse it only when the cached document should resume. cacheKey?: string; } // Example usage const file: FileContents = { // We'll attempt to detect the language based on file extension name: 'example.tsx', contents: 'export function Hello() { return
Hello
; }', cacheKey: 'example-file-v1', }; // With explicit language override const jsonFile: FileContents = { // No extension, so we specify lang name: 'config', contents: '{ "key": "value" }', lang: 'json', cacheKey: 'config-file', }; ``` **File Diff Metadata Type** (`FileDiffMetadata.ts`): ```typescript import 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 patches interface 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 changes interface 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 deletions interface ChangeContent { type: 'change'; deletions: string[]; additions: string[]; // 'true' if the file does not have a blank newline at the end noEOFCRDeletions: boolean; noEOFCRAdditions: boolean; } ``` **Line Annotation Types** (`line_annotations.ts`): ```typescript import type { DiffLineAnnotation, LineAnnotation, } from '@pierre/diffs'; interface ThreadMetadata { // Position-independent identity for application-owned state. id: string; } const fileAnnotations: LineAnnotation[] = [ { lineNumber: 0, metadata: { id: 'file-summary' } }, { lineNumber: 5, metadata: { id: 'line-five-review' } }, ]; const diffAnnotations: DiffLineAnnotation[] = [ { side: 'additions', lineNumber: 12, metadata: { id: 'new-line-review' }, }, { side: 'deletions', lineNumber: 9, metadata: { id: 'old-line-review' }, }, ]; ``` **Parse Diff From File Example** (`parseDiffFromFile.ts`): ```typescript import { parseDiffFromFile, type FileContents, type FileDiffMetadata, } from '@pierre/diffs'; // Define the existing file versions const 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 versions const 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. ``` **Parse Patch Files Example** (`parsePatchFiles.ts`): ```typescript import { parsePatchFiles, type ParsedPatch, type FileDiffMetadata, } from '@pierre/diffs'; // Parse a unified diff / patch string const 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 Pool const patches: ParsedPatch[] = parsePatchFiles(patchString, 'my-patch-key'); // Each ParsedPatch contains an array of FileDiffMetadata const 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. ``` ## React API > 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](#react-api-shared-props). ### Components The React API exposes six main components: - `CodeView` renders a mixed, virtualized list of files and diffs inside one scroll container - `MultiFileDiff` compares file contents directly - `PatchDiff` renders from a patch string - `FileDiff` renders a pre-parsed `FileDiffMetadata` - `File` renders a single code file without a diff - `UnresolvedFile` renders merge conflict markers with built-in resolution UI - _Currently in beta/experimental and may change in future releases._ For editing, mount one stable `EditProvider` high in the tree. Standalone surfaces use `edit` and `editorOptions`; `CodeView` uses item `edit` flags and its own `editorOptions`. Each active surface or item receives an independent editor. `UnresolvedFile` is not editable. See [Edit mode → React](#edit-mode-react) and [CodeView → Editing](#codeview-editing) for lifecycle and callback details. Keep non-primitive props stable across renders. Define static files, diffs, options, styles, and factories at module scope; when they depend on component state or props, use `useMemo` for objects and arrays and `useCallback` for functions. This applies to `options`, `editorOptions`, annotations, and render callbacks as well as `file` and `fileDiff`. `UnresolvedFile` is intentionally uncontrolled in React. Treat `file` as initial input and remount (for example, with a changing `key`) when you want to reset. `MultiFileDiff` accepts `FileContents` for each existing side. Pass `oldFile={null}` for a new file, or `newFile={null}` for a deleted file. The `CodeView` tab above is the quick-start version. For the full guide on controlled `items`, imperative `initialItems`, ids, `version`, selection, and `scrollTo`, see [CodeView](#codeview). ### Partial Diff Hydration 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. ### Shared Props The three diff components (`MultiFileDiff`, `PatchDiff`, and `FileDiff`) share a common set of props for configuration, annotations, and styling. The `File` component has similar props, but uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). When one of these components is attached to an `Editor`, keep its annotations in application-owned state and replace them with the current collection emitted by `Editor.onChange`. See [Editing with line annotations](#edit-mode-editing-with-line-annotations) for the React `flushSync` pattern and annotation-content lifetime guidance. `CodeView` reuses many of the same option names internally, but it has its own controlled `items` mode, imperative mode with optional `initialItems`, viewer ref, and mixed-item render props. See [CodeView](#codeview) for the dedicated guide. Header customization and collapsing behavior: - Use `renderHeaderPrefix` to render custom UI at the beginning of the built-in header, before the filename and icons, while keeping the default header layout. - Use `renderHeaderFilenameSuffix` for compact UI immediately after the displayed filename, such as badges, review state, or generated-file labels. - Use `renderHeaderMetadata` to render custom UI at the end of the built-in header, after the diff stats, while keeping the default header layout. - Use `renderCustomHeader` when you want to replace the built-in header content with your own custom designed one. - For diff components, these header callbacks receive `fileDiff: FileDiffMetadata`. - For `File`, the corresponding header callbacks receive `file: FileContents`. - Use `options.collapsed` to hide file body content while keeping the file header visible. #### Post Render Lifecycle `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`. Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and `useTokenTransformer` are documented in [Token Hooks](#token-hooks), including examples, payload details, performance notes, and Worker Pool caveats. **Code View** (`code_view.tsx`): ```tsx import { 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>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, }), [] ); return ( ); } ``` **File** (`file.tsx`): ```tsx import type { FileContents, FileOptions, } from '@pierre/diffs'; import { File } from '@pierre/diffs/react'; import { useMemo } from 'react'; // The File component renders a single code file with syntax highlighting. // Unlike the diff components, it doesn't show any changes - just the file // contents with optional line annotations. // Keep file objects stable: define static inputs at module scope, or use // useState/useMemo when they depend on component values. const file: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`); } export { greet };`, }; export function CodeFile() { const fileOptions = useMemo>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, }), [] ); return ( ); } ``` **File Diff** (`file_diff.tsx`): ```tsx import { parseDiffFromFile, type FileDiffMetadata, type FileDiffOptions, } from '@pierre/diffs'; import { FileDiff } from '@pierre/diffs/react'; import { useMemo } from 'react'; // FileDiff takes a pre-parsed FileDiffMetadata object. // Use this when: // - You've already parsed the diff (e.g., from parsePatchFiles) // - You want to manipulate the diff before rendering // - You're using diffAcceptRejectHunk for interactive accept/reject // Parse the diff yourself const fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'example.ts', contents: 'console.log("Hello world")' }, { name: 'example.ts', contents: 'console.warn("Updated message")' } ); export function MyFileDiff() { const fileDiffOptions = useMemo>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }), [] ); return ( ); } ``` **Load Diff Files** (`load_diff_files.tsx`): ```tsx import { 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>( () => ({ async loadDiffFiles(fileDiff): Promise { 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 ; } ``` **Multi File Diff** (`multi_file_diff.tsx`): ```tsx import type { FileContents, FileDiffOptions, } from '@pierre/diffs'; import { MultiFileDiff } from '@pierre/diffs/react'; import { useMemo } from 'react'; // MultiFileDiff compares file contents directly. // Use this when you have the old and/or new file contents. // Keep file objects stable: define static inputs at module scope, or use // useState/useMemo when they depend on component values. const oldFile: FileContents = { name: 'example.ts', contents: 'console.log("Hello world")', }; const newFile: FileContents = { name: 'example.ts', contents: 'console.warn("Updated message")', }; export function MyDiff() { const fileDiffOptions = useMemo>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }), [] ); return ( ); } ``` **Patch Diff** (`patch_diff.tsx`): ```tsx import type { FileDiffOptions } from '@pierre/diffs'; import { PatchDiff } from '@pierre/diffs/react'; import { useMemo } from 'react'; // PatchDiff renders from a unified diff/patch string. // Use this when you have patch content (e.g., from git or GitHub). const patch = `diff --git a/example.ts b/example.ts --- a/example.ts +++ b/example.ts @@ -1,3 +1,3 @@ -console.log("Hello world"); +console.warn("Updated message"); `; export function MyPatchDiff() { const fileDiffOptions = useMemo>( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'unified', // patches often look better unified }), [] ); return ( ); } ``` **Post Render Lifecycle** (`post_render_lifecycle.tsx`): ```tsx import type { FileDiffMetadata, FileDiffOptions, } from '@pierre/diffs'; import { FileDiff } from '@pierre/diffs/react'; import { useMemo } from 'react'; const cleanupByNode = new WeakMap void>(); export function DiffWithRenderLifecycle({ fileDiff, }: { fileDiff: FileDiffMetadata; }) { const fileDiffOptions = useMemo>( () => ({ 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 ( ); } function containsSelectionNode(root: Node, node: Node | null) { return node != null && root.contains(node); } ``` **Shared Diff Options** (`shared_diff_options.tsx`): ```tsx // ============================================================ // 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'; 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); }, } ``` **Shared Diff Render Props** (`shared_diff_render_props.tsx`): ```tsx // ============================================================ // SHARED RENDER PROPS FOR DIFF COMPONENTS // ============================================================ // These props are shared by MultiFileDiff, PatchDiff, and FileDiff. import { type DiffLineAnnotation, MultiFileDiff, } from '@pierre/diffs/react'; interface ThreadMetadata { threadId: string; } // This is static read-only data. In edit mode, initialize state with this array // and replace that state when Editor.onChange emits a different collection. const lineAnnotations: DiffLineAnnotation[] = [ { side: 'additions', lineNumber: 0, metadata: { threadId: 'file-summary' }, }, { side: 'additions', // One-based line number on the selected file side. lineNumber: 16, metadata: { threadId: 'abc123' }, }, ]; {...} // ───────────────────────────────────────────────────────────── // LINE ANNOTATIONS // ───────────────────────────────────────────────────────────── // Array of annotations to display on specific lines. // Keep the same array reference until the annotations change. // Annotation metadata can be typed any way you'd like. // Multiple annotations can target the same side/line. // Use lineNumber: 0 for a file-level annotation rendered above the first // hunk separator or diff row. lineAnnotations={lineAnnotations} // Render function for each annotation. Despite the diff being // rendered in shadow DOM, annotations use slots so you can use // normal CSS and styling. renderAnnotation={(annotation) => ( )} // ───────────────────────────────────────────────────────────── // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── // All 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. // 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. // // Callback arg: FileDiffMetadata // Render custom content at the beginning of the built-in header. renderHeaderPrefix={(fileDiff) => ( {fileDiff.type} )} // Render custom content on the right side of the built-in header. renderHeaderMetadata={(fileDiff) => ( {fileDiff.name} )} // ───────────────────────────────────────────────────────────── // GUTTER UTILITY // ───────────────────────────────────────────────────────────── // Preferred: built-in + button (no custom render function). // Callback receives a SelectedLineRange. // Visibility is still controlled by options.enableGutterUtility. // 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); }} // Advanced: render your own UI in the line number column on hover. // Prefer onGutterUtilityClick unless you need fully custom content. // Requires options.enableGutterUtility = true // Do not combine with onGutterUtilityClick. // WebKit/Safari bug 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: Just enable 'enableGutterUtility' for the default button, // or switch hunk separators to 'line-info-basic', 'metadata', or 'simple'. // For a status of this bug, visit: // https://bugs.webkit.org/show_bug.cgi?id=308027 // // Note: This is NOT reactive - render is not called on every // mouse move. Use getHoveredLine() in click handlers. renderGutterUtility={(getHoveredLine) => ( )} // ───────────────────────────────────────────────────────────── // LINE SELECTION (controlled) // ───────────────────────────────────────────────────────────── // Programmatically control which lines are selected. // Works with both 'split' and 'unified' diff styles. selectedLines={{ start: 3, end: 5, side: 'additions', // optional, defaults to 'additions' endSide: 'additions', // optional, defaults to 'side' }} // ───────────────────────────────────────────────────────────── // STYLING // ───────────────────────────────────────────────────────────── className="my-diff" style={{ maxHeight: 500 }} // ───────────────────────────────────────────────────────────── // SSR (advanced) // ───────────────────────────────────────────────────────────── // Pre-rendered HTML from server for hydration // See the SSR section for details prerenderedHTML={htmlFromServer} /> ``` **Shared File Options** (`shared_file_options.tsx`): ```tsx // ============================================================ // OPTIONS FOR THE FILE COMPONENT // ============================================================ // Pass these via the `options` prop on the File component. import type { File as FileClass, PostRenderPhase, TokenEventBase, } from '@pierre/diffs'; import { File } from '@pierre/diffs/react'; interface FileOptions { // ───────────────────────────────────────────────────────────── // 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', // ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ───────────────────────────────────────────────────────────── // Show line numbers (default: true) disableLineNumbers: false, // Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll', // Hide the file header with filename 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: FileClass, 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 file lines onLineClick({ lineNumber, event }) { // Fires when clicking anywhere on a line }, onLineNumberClick({ lineNumber, event }) { // Fires when clicking anywhere in the line number column }, onLineEnter({ lineNumber }) { // Fires when mouse enters a line }, onLineLeave({ lineNumber }) { // 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, }: TokenEventBase) { // Fires when clicking a token in the code column }, onTokenEnter({ tokenText, lineNumber, lineCharStart, lineCharEnd, tokenElement, }: TokenEventBase) { // Use tokenElement for hover styling or tooltips }, onTokenLeave({ tokenText, tokenElement }: TokenEventBase) { // 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); }, } ``` **Shared File Render Props** (`shared_file_render_props.tsx`): ```tsx // ============================================================ // RENDER PROPS FOR THE FILE COMPONENT // ============================================================ // These props are available on the File component. import { File, type LineAnnotation } from '@pierre/diffs/react'; interface CommentMetadata { commentId: string; } // This is static read-only data. In edit mode, initialize state with this array // and replace that state when Editor.onChange emits a different collection. const lineAnnotations: LineAnnotation[] = [ { lineNumber: 0, metadata: { commentId: 'file-summary' }, }, { // One-based line number in the file. lineNumber: 5, metadata: { commentId: 'comment-123' }, }, ]; {...} // ───────────────────────────────────────────────────────────── // LINE ANNOTATIONS // ───────────────────────────────────────────────────────────── // Array of annotations to display on specific lines. // Keep the same array reference until the annotations change. // Annotation metadata can be typed any way you'd like. // Multiple annotations can target the same line. // // Note: Unlike diff components, File uses LineAnnotation which // has no 'side' property since there's only one column. // Use lineNumber: 0 for a file-level annotation rendered above the // first file line. lineAnnotations={lineAnnotations} // Render function for each annotation. Despite the file being // rendered in shadow DOM, annotations use slots so you can use // normal CSS and styling. renderAnnotation={(annotation) => ( )} // ───────────────────────────────────────────────────────────── // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── // File header callbacks receive FileContents directly. // renderHeaderPrefix renders at the beginning of the built-in header, // before the filename. // renderHeaderFilenameSuffix renders immediately after the displayed filename. // renderHeaderMetadata renders at the end of the built-in header. // renderCustomHeader replaces the built-in header content entirely. // Callback arg: FileContents // // Render custom content at the beginning of the built-in header. renderHeaderPrefix={(file) => ( {file.name.endsWith('.generated.ts') ? 'Generated' : 'Source'} )} // Render custom content on the right side of the built-in header. renderHeaderMetadata={(file) => ( {file.name} )} // ───────────────────────────────────────────────────────────── // GUTTER UTILITY // ───────────────────────────────────────────────────────────── // Preferred: built-in + button (no custom render function). // Callback receives a SelectedLineRange. // Visibility is still controlled by options.enableGutterUtility. // 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); }} // Advanced: render your own UI in the line number column on hover. // Prefer onGutterUtilityClick unless you need fully custom content. // Requires options.enableGutterUtility = true // Do not combine with onGutterUtilityClick. // WebKit/Safari note: there is a specific scroll-jump issue is tied to // diff views using custom renderGutterUtility + hunkSeparators: 'line-info'. // File views do not use hunk separators, so this case does not apply here. // // Note: This is NOT reactive - render is not called on every // mouse move. Use getHoveredLine() in click handlers. renderGutterUtility={(getHoveredLine) => ( )} // ───────────────────────────────────────────────────────────── // LINE SELECTION (controlled) // ───────────────────────────────────────────────────────────── // Programmatically control which lines are selected. selectedLines={{ start: 3, end: 5, }} // ───────────────────────────────────────────────────────────── // STYLING // ───────────────────────────────────────────────────────────── className="my-file" style={{ maxHeight: 500 }} // ───────────────────────────────────────────────────────────── // SSR (advanced) // ───────────────────────────────────────────────────────────── // Pre-rendered HTML from server for hydration // See the SSR section for details prerenderedHTML={htmlFromServer} /> ``` **Unresolved File** (`unresolved_file.tsx`): ```tsx import { UnresolvedFile, type FileContents, type UnresolvedFileReactOptions, } from '@pierre/diffs/react'; import { useMemo, useState } from 'react'; // UnresolvedFile renders Git-style merge conflict markers. // React UnresolvedFile is intentionally uncontrolled: // - The `file` prop is treated as the initial source // - Conflict buttons apply changes internally // - To reset, remount the component (shown with the key below) const initialFile: FileContents = { name: 'auth.ts', contents: `export function createSession() { <<<<<<< HEAD return { source: 'server', ttl: 12 }; ======= return { source: 'web', ttl: 24 }; >>>>>>> feature/web-session }`, }; export function MergeConflictPreview() { const [instanceKey, setInstanceKey] = useState(0); const unresolvedFileOptions = useMemo< UnresolvedFileReactOptions >( () => ({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffIndicators: 'none', onPostRender(node, _instance, phase) { if (phase === 'unmount') { return; } const codeLines = node.shadowRoot?.querySelectorAll('[data-line]'); console.log('rendered line count', codeLines?.length ?? 0); }, }), [] ); return ( <> ); } ``` ## Vanilla JS API > Import vanilla JavaScript classes, components, and methods from > `@pierre/diffs`. ### Components 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. > `UnresolvedFile` is currently beta/experimental and may change in future > releases. See [Edit mode → Vanilla JS](#edit-mode-vanilla-js) for attaching `Editor` to a rendered `File` or `FileDiff` with `edit()`. `UnresolvedFile` in vanilla supports both uncontrolled and controlled callbacks (`onMergeConflictResolve` / `onMergeConflictAction`). The `CodeView` tab above is the quick-start version. For the deeper guide on `setup`, `setItems`, `addItems`, `getItem`, `removeItem`, `updateItem`, selection, and `scrollTo`, see [CodeView](#codeview). ### Props Both `FileDiff` and `File` accept an options object in their constructor. The `File` component has similar options, but excludes diff-specific settings and uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). When one of these components is attached to an `Editor`, keep its annotations in an external variable or store and replace them with the current collection emitted by `Editor.onChange`. See [Editing with line annotations](#edit-mode-editing-with-line-annotations) for the synchronization pattern and remapping rules. When rendering direct file contents with `FileDiff.render`, pass `FileContents` for each existing side. Use `oldFile: null` for a new file, or `newFile: null` for a deleted file. For partial diffs parsed from patches, pass `loadDiffFiles` to `FileDiff` constructor options when you want collapsed unchanged context to expand from full file contents. The loader receives the partial `FileDiffMetadata` and returns `{ oldFile, newFile }`: changed and rename-changed diffs return both sides, while pure renames return `{ oldFile: null, newFile }`. Added and deleted patch diffs do not need loader hydration. Components catch loader errors by default; set `disableErrorHandling: true` when you want errors to rethrow. `CodeView` forwards many of those same options to each rendered item, while adding CodeView-specific controls like `layout`, `itemMetrics`, `stickyHeaders`, `pointerEventsOnScroll`, and `smoothScrollSettings`. Its class instance also exposes item-level methods such as `addItems`, `getItem`, `removeItem`, and `updateItem`. See [CodeView](#codeview) for the dedicated guide. Header customization and collapsing behavior: - Use `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. - Use `renderHeaderFilenameSuffix` for compact UI immediately after the displayed filename, such as badges, review state, or generated-file labels. - Use `renderHeaderMetadata` to render custom UI at the end of the built-in `FileDiff` header, after the diff stats, while keeping the default header layout. - Use `renderCustomHeader` when you want to replace the built-in header content entirely. - In `File`, header callbacks receive `file: FileContents`. - Use `collapsed` in constructor options to hide file body content while keeping the file header visible. #### Post Render Lifecycle `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`. Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and `useTokenTransformer` are documented in [Token Hooks](#token-hooks), including examples, payload details, performance notes, and Worker Pool caveats. #### Custom Hunk Separators Start with the [Hunk Separators](#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: ### Renderers > 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](https://github.com/syntax-tree/hast) nodes or HTML strings for custom rendering pipelines. #### DiffHunksRenderer 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. #### FileRenderer 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. **Code View Example** (`code_view_example.ts`): ```typescript import { 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(); }); ``` **Custom Hunk File** (`hunks_example.ts`): ```typescript import { 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 code const 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; }, }) ``` **File Diff Example** (`file_diff_example.ts`): ```typescript import { FileDiff, type FileContents } from '@pierre/diffs'; // Create the instance with options const instance = new FileDiff({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }); // Define your files (keep references stable to avoid re-renders) const oldFile: FileContents = { name: 'example.ts', contents: 'console.log("Hello world")', }; const newFile: FileContents = { name: 'example.ts', contents: 'console.warn("Updated message")', }; // Render the diff into a container instance.render({ // Pass FileContents for existing sides. For added or deleted files, // pass null for the side that does not exist. oldFile, newFile, containerWrapper: document.getElementById('diff-container'), }); // Update options later if needed (full replacement, not merge) instance.setOptions({ ...instance.options, diffStyle: 'unified' }); instance.rerender(); // Must call rerender() after updating options // Clean up when done instance.cleanUp(); ``` **File Diff Props** (`file_diff_props.ts`): ```typescript import { 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[] = [ { 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 class const instance = new FileDiff({ // ───────────────────────────────────────────────────────────── // 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 diff instance.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 render lineAnnotations = [ { side: 'additions', lineNumber: 0, metadata: { threadId: 'file-summary' } }, { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } } ]; instance.setLineAnnotations(lineAnnotations); instance.rerender(); // Programmatically control selected lines instance.setSelectedLines({ start: 12, end: 22, side: 'additions', endSide: 'deletions', }); // Programmatically expand a collapsed hunk instance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'both' // Expand an entire collapsed hunk instance.expandHunk(0, 'both', Number.POSITIVE_INFINITY); // Change the active theme type instance.setThemeType('dark'); // 'dark' | 'light' | 'system' // Clean up (removes DOM, event listeners, clears state) instance.cleanUp(); ``` **File Example** (`file_example.ts`): ```typescript import { File, type FileContents } from '@pierre/diffs'; // Create the instance with options const instance = new File({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, overflow: 'scroll', }); // Define your file (keep reference stable to avoid re-renders) const file: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`); } export { greet };`, }; // Render the file into a container instance.render({ file, containerWrapper: document.getElementById('file-container'), }); // Update options later if needed (full replacement, not merge) instance.setOptions({ ...instance.options, overflow: 'wrap' }); instance.rerender(); // Must call rerender() after updating options // Clean up when done instance.cleanUp(); ``` **File Props** (`file_props.ts`): ```typescript import { File, type LineAnnotation, type TokenEventBase, } from '@pierre/diffs'; interface CommentMetadata { commentId: string; } // Keep this array in application-owned storage when annotations can change. let lineAnnotations: LineAnnotation[] = [ { lineNumber: 0, metadata: { commentId: 'file-summary' } }, { // One-based line number in the file. lineNumber: 5, metadata: { commentId: 'abc' }, }, ]; // All available options for the File class const instance = new File({ // ───────────────────────────────────────────────────────────── // 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', // ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ───────────────────────────────────────────────────────────── // Show line numbers (default: true) disableLineNumbers: false, // Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll', // Hide the file header with filename 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, fileInstance, 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, event }) {}, // Fires when clicking anywhere in the line number column onLineNumberClick({ lineNumber, event }) {}, // Fires when mouse enters a line onLineEnter({ lineNumber }) {}, // Fires when mouse leaves a line onLineLeave({ lineNumber }) {}, // 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, }: TokenEventBase) {}, onTokenEnter({ tokenText, lineNumber, lineCharStart, lineCharEnd, tokenElement, }: TokenEventBase) {}, onTokenLeave({ tokenText, tokenElement }: TokenEventBase) {}, // 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); }, // ───────────────────────────────────────────────────────────── // RENDER CALLBACKS // ───────────────────────────────────────────────────────────── // File header callbacks receive FileContents directly. // renderHeaderPrefix renders at the beginning of the built-in header, // before the filename. // renderHeaderFilenameSuffix renders immediately after the displayed filename. // renderHeaderMetadata renders at the end of the built-in header. // renderCustomHeader replaces the built-in header content entirely. // Render custom content at the beginning of the built-in header. renderHeaderPrefix(file) { const span = document.createElement('span'); span.textContent = file.name.endsWith('.generated.ts') ? 'Generated' : 'Source'; return span; }, // Render custom content in the file header. renderHeaderMetadata(file) { const span = document.createElement('span'); span.textContent = file.name; return span; }, // Render annotations on specific lines. Use lineNumber: 0 for a file-level // annotation above the first file line. // Note: File uses LineAnnotation (no 'side' property) renderAnnotation(annotation) { const element = document.createElement('div'); element.textContent = annotation.metadata.commentId; 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 note: there is a specific scroll-jump issue is tied to // diff views using custom renderGutterUtility + hunkSeparators: // 'line-info'. File views do not use hunk separators, so this case // does not apply here but you should be aware of it. renderGutterUtility(getHoveredLine) { const button = document.createElement('button'); button.textContent = '+'; button.addEventListener('click', () => { const { lineNumber } = getHoveredLine(); console.log('Clicked line', lineNumber); }); return button; }, }); // ───────────────────────────────────────────────────────────── // INSTANCE METHODS // ───────────────────────────────────────────────────────────── // Render the file instance.render({ file: { name: 'example.ts', contents: '...' }, lineAnnotations, containerWrapper: document.body, }); // Update options (full replacement, not merge) instance.setOptions({ ...instance.options, overflow: 'wrap' }); instance.rerender(); // Update line annotations after initial render lineAnnotations = [ { lineNumber: 0, metadata: { commentId: 'file-summary' } }, { lineNumber: 5, metadata: { commentId: 'abc' } } ]; instance.setLineAnnotations(lineAnnotations); instance.rerender(); // Programmatically control selected lines instance.setSelectedLines({ start: 3, end: 8 }); // Change the active theme type instance.setThemeType('dark'); // 'dark' | 'light' | 'system' // Clean up (removes DOM, event listeners, clears state) instance.cleanUp(); ``` **File Renderer** (`file_renderer.ts`): ```typescript import { 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
 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 
 wrapper)
const fullHTML: string = instance.renderFullHTML(result);

// Or render just the code lines to HTML
const partialHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST(result)
);

// Or get the full AST for further transformation
const fullAST = instance.renderFullAST(result);
```

**Hunks Renderer File** (`hunks_renderer_file.ts`):

```typescript
import {
  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 file
const 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 
 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 
 and  wrappers)
const fullHTML: string = instance.renderFullHTML(result);

// Or render just a specific column to HTML
const additionsHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST('additions', result),
  'additions' // wraps in 
);

// Or render without the  wrapper
const rawHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST('additions', result)
);

// Or get the full AST for further transformation
const fullAST = instance.renderFullAST(result);
```

**Hunks Renderer Patch File** (`hunks_renderer_patch.ts`):

```typescript
import {
  DiffHunksRenderer,
  type FileDiffMetadata,
  type HunksRenderResult,
  parsePatchFiles,
} from '@pierre/diffs';

// If you have the string data for any github or git/unified
// patch file, you can alternatively load that into parsePatchContent
const patches =
  parsePatchFiles(`commit e4c066d37a38889612d8e3d18089729e4109fd09
Merge: 2103046 7210630
Author: James Dean 
Date:   Mon Sep 15 11:25:22 2025 -0700

    Merge branch 'react-tests'

diff --git a/eslint.config.js b/eslint.config.js
index c52c9ca..f3b592b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -2,6 +2,7 @@ import js from '@eslint/js';
 import tseslint from 'typescript-eslint';

 export default tseslint.config(
+  { ignores: ['dist/**'] },
   js.configs.recommended,
   ...tseslint.configs.recommended,
   {
@@ -10,7 +11,6 @@ export default tseslint.config(
       'error',
       { argsIgnorePattern: '^_' },
     ],
-      '@typescript-eslint/no-explicit-any': 'warn',
   },
 }
);
`);

for (const patch of patches) {
  for (const fileDiff of patch.files) {
    // Create a new renderer for each file
    const instance = new DiffHunksRenderer({
      diffStyle: 'unified',
      theme: 'pierre-dark',
    });

    // Render hunks (async - waits for highlighter initialization)
    const result: HunksRenderResult = await instance.asyncRender(fileDiff);

    // result contains hast nodes based on diffStyle:
    // - 'unified' mode: unifiedGutterAST/unifiedContentAST
    // - 'split' mode: additionsGutterAST/additionsContentAST and deletionsGutterAST/deletionsContentAST

    // Render to complete HTML (includes 
 and  wrappers)
    const fullHTML: string = instance.renderFullHTML(result);

    // Or render just the unified column with  wrapper
    const unifiedHTML: string = instance.renderPartialHTML(
      instance.renderCodeAST('unified', result),
      'unified'
    );

    // Or render without any wrapper
    const rawHTML: string = instance.renderPartialHTML(
      instance.renderCodeAST('unified', result)
    );

    // Or get the full AST for custom transformation
    const fullAST = instance.renderFullAST(result);
  }
}
```

**Load Diff Files** (`load_diff_files.ts`):

```typescript
import {
  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 {
    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'),
});
```

**Post Render Lifecycle** (`post_render_lifecycle.ts`):

```typescript
import { FileDiff } from '@pierre/diffs';

const cleanupByNode = new WeakMap 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);
}
```

**Unresolved File Example** (`unresolved_file_example.ts`):

```typescript
import {
  UnresolvedFile,
  resolveMergeConflict,
  type FileContents,
  type MergeConflictActionPayload,
} from '@pierre/diffs';

const container = document.getElementById('diff-container');
if (container == null) {
  throw new Error('Expected #diff-container to exist');
}

let file: FileContents = {
  name: 'auth.ts',
  contents: `export function createSession() {
<<<<<<< HEAD
  return { source: 'server', ttl: 12 };
=======
  return { source: 'web', ttl: 24 };
>>>>>>> feature/web-session
}`,
};

const instance = new UnresolvedFile({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // Controlled mode (optional): apply payloads yourself.
  onMergeConflictAction(payload: MergeConflictActionPayload) {
    file = {
      ...file,
      contents: resolveMergeConflict(file.contents, payload),
    };

    instance.render({ file, containerWrapper: container });
  },
});

instance.render({ file, containerWrapper: container });
```

## CodeView

> `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](https://diffshub.com)

If you need to render one or more files or diffs in a scrollable container, use
CodeView to avoid handling scaling yourself.

### What It Gives You

- One scroll container for a mixed list of `file` and `diff` items.
- Built-in per-line virtualization that should scale to nearly any file or diff
  that can fit in memory.
- `scrollTo` APIs for items, line targets, and raw scroll positions.
- Unified selection API, support for custom annotations, custom headers, and
  gutter utilities across the entire viewer.
- Optional per-item [edit mode](#edit-mode-codeview) for files and diffs.
- Optional non-virtualized [header and footer regions](#codeview-header-footer)
  rendered inside the scroll container — ideal for PR summary cards and approval
  bars.

### Core Model

`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.

- Every item needs a stable unique `id`. That id is how `scrollTo`, line
  selection, `getItem`, `removeItem`, `updateItem`, and reconciliation find the
  correct records.
- Items are either `{ type: 'file', file }` or `{ type: 'diff', fileDiff }`.
- If you keep the same item id but change its content or annotations, you must
  increment the `version` so `CodeView` can make an efficient targeted updates
  based only on what changed without recomputing everything.
- Selection is viewer-wide, meaning a selection in one file will remove the
  selection in another file in the same scroll view. The payload shape is
  `{ id, range }` instead of only a line range.
- The `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.
- The `edit` property enables [edit mode](#edit-mode-codeview) for an item when
  React `CodeView` has an `EditProvider`, or vanilla `CodeView` has a
  `createEditor` option. Update `version` when toggling it.
- CodeView-level options such as `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.

### Editing

React `CodeView` gets its editor factory from the nearest `EditProvider`; unlike
vanilla `CodeView`, it does not accept `createEditor` directly or inside
`options`. Keep the provider mounted, set `edit: true` on the items that should
be editable, and pass creation-time item-editor behavior through
`editorOptions`. `onItemEditChange` reports live contents with the owning item.
If a session produces a change, `onItemEditComplete` reports its latest contents
when editing is disabled or the item is collapsed or removed. Direct reset,
cleanup, and viewer unmount are silent.

Each edited item receives an independent editor whose history survives
virtualization. Changes to the provider factory or `editorOptions` do not
disturb active sessions; their latest values apply the next time an item enters
edit mode.

For vanilla `CodeView`, keep using `CodeViewOptions.createEditor`. It exposes
the same item-aware callbacks, and CodeView owns each returned editor's
lifecycle.

#### Autofocus on Attach

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:

```tsx
const editorOptions: EditorOptions = {
  onAttach(editor) {
    editor.focus({ lineNumber: 'first-visible', preventScroll: true });
  },
};

### File & Diff Size Estimation

`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.

### Examples

### React Item Ownership

React `CodeView` supports two item ownership models. Use one per mounted viewer;
do not switch between them without remounting with a new `key`.

| Mode       | Use                                                | Item prop               | Item updates                                                                                      |
| ---------- | -------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- |
| Controlled | React state owns the complete item list            | `items`                 | Publish a new `items` array. Append-only changes are optimized; other changes reconcile the list. |
| Imperative | The viewer instance owns the item list after mount | optional `initialItems` | Use the ref APIs: `addItems`, `getItem`, `removeItem`, and `updateItem`.                          |

Use controlled mode when item data already lives naturally in React state and
the list is small enough that mutating arrays or items is cheap. Use imperative
mode for very large or streaming surfaces where routing every item update
through React would be expensive. In imperative mode, omit `items`, optionally
seed the viewer with `initialItems`, and use the `CodeViewHandle` to add new
items, remove items, or update existing ones.

### Editing Item Annotations

When an editable item has annotations, `onItemEditChange` receives the owning
item, its edited `FileContents`, and the complete current annotation collection.
For a diff item, those contents represent the editable new-file side. When the
emitted annotation array is a different object, replace `item.annotations` and
increment the item's `version`. `CodeView` does not write either value for you.

The callback annotation type is `LineAnnotation[] | DiffLineAnnotation[]`: file
items emit the side-less shape and diff items emit annotations with a `side`.
Use `isFileAnnotationCollection` or `isDiffAnnotationCollection` to narrow it
before reading shape-specific fields.

When React owns the `CodeView` item list through `items`, publish a new `items`
array containing the updated item inside `flushSync` so annotation placement
updates with the edited content before paint. When `CodeView` owns the list in
React's imperative mode, call `updateItem` through the component ref; in vanilla
JS, call `updateItem` on the `CodeView` instance. Skip the update when the
emitted array is the same object as the item's current annotations, since
ordinary same-line typing reuses that array.

Keep `onItemEditComplete` focused on committing the final `file` contents or
rebuilding `fileDiff` with a fresh `cacheKey`. Live annotations should already
be synchronized through `onItemEditChange`. See
[Editing with line annotations](#edit-mode-editing-with-line-annotations) for
remapping rules, stable metadata IDs, and annotation-content lifetime guidance.

### Usage Notes

- In React, pass `items` for controlled item ownership.
- In React, pass `initialItems` instead of `items` for imperative item
  ownership. `initialItems` seeds the viewer once; later item changes should go
  through the ref.
- In React, `addItems`, `removeItem`, and `updateItem` require imperative item
  ownership and throw if the viewer is controlled with `items`.
- In React, use `selectedLines` and `onSelectedLinesChange` when selection needs
  to live in component state.
- In React, use the ref for `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`.
- In Vanilla JS, `CodeView` owns a scrollable root that you set up once and
  update over time.
- In Vanilla JS, call `setup(root)` once with the scrollable container.
- In Vanilla JS, use `setItems`, `addItem`, or `addItems` to populate the
  viewer, and `getItem`, `removeItem`, or `updateItem` for item-level imperative
  changes.
- Shared callbacks receive the normal file/diff payload plus a `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.
- By default, `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.
- In Vanilla JS, call `cleanUp()` when the viewer is removed so observers,
  timers, and DOM state are released.

### Scroll Targets

`scrollTo` supports four target shapes:

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.

### Relationship To Virtualization

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
surface, 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](#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`.

**Code View Header Footer React Example** (`code_view_header_footer.tsx`):

```tsx
import { 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(
    () => [
      {
        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 (
      

Add personalized greetings

Threads a name through greet() so callers control the message.

1 file changed
); }, []); // 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 (
{approved ? 'Approved' : 'Reviewed 1 of 1 files'}
); }, [approved]); return ( ); } ``` **Code View Header Footer Vanilla Example** (`code_view_header_footer.ts`): ```typescript import { CodeView, parseDiffFromFile } 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'; // Create the header element once, outside the callback. To update it later, // mutate it in place: the viewer observes the host and re-measures height // changes automatically. const summaryCard = document.createElement('section'); summaryCard.className = 'pr-summary'; const summaryTitle = document.createElement('h2'); summaryTitle.textContent = 'Add personalized greetings'; const summaryBody = document.createElement('p'); summaryBody.textContent = 'Threads a name through greet() so callers control the message.'; summaryCard.append(summaryTitle, summaryBody); const approveBar = document.createElement('div'); approveBar.className = 'review-actions'; const approveButton = document.createElement('button'); approveButton.type = 'button'; approveButton.textContent = 'Approve changes'; approveButton.addEventListener('click', () => { // Mutating the existing element is the whole update model; no re-render // call is needed, and the new height is measured automatically. approveBar.textContent = 'Approved'; }); approveBar.append('Reviewed 1 of 1 files — ', approveButton); const viewer = new CodeView({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, stickyHeaders: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, // Return the same element across calls. Returning undefined instead would // empty the host; removing the callback removes the host entirely. renderCodeViewHeader() { return summaryCard; }, renderCodeViewFooter() { return approveBar; }, }); viewer.setup(root); viewer.setItems([ { 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}', } ), }, ]); window.addEventListener('beforeunload', () => { viewer.cleanUp(); }); ``` **Code View Item Metrics Options Example** (`code_view_item_metrics.ts`): ```typescript const 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; } } ``` **Code View Item Type Example** (`code_view_items.ts`): ```typescript type CodeViewFileItem = { type: 'file'; id: string; file: FileContents; annotations?: LineAnnotation[]; 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 = { type: 'diff'; id: string; fileDiff: FileDiffMetadata; annotations?: DiffLineAnnotation[]; 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 = CodeViewFileItem | CodeViewDiffItem; ``` **Code View Layout Options Example** (`code_view_layout.ts`): ```typescript options: { 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, } } ``` **Code View React Example** (`code_view_react.tsx`): ```tsx import { 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(null); const [selectedLines, setSelectedLines] = useState(null); const initialItems = useMemo( () => [ { 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 ( <> ( {item.type === 'diff' ? 'Diff' : 'File'} )} renderHeaderMetadata={(item) => item.type === 'diff' ? {item.fileDiff.type} : file } renderAnnotation={(annotation, item) => (
Note for {item.id} on line {annotation.lineNumber}
)} /> ); } ``` **Code View Scroll Targets Example** (`code_view_scroll_targets.ts`): ```typescript // Scroll directly to a file or diff viewer.scrollTo({ type: 'item', id: 'diff:src/app.ts', align: 'start' }); // Scroll directly to a line in a file or diff viewer.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 diff viewer.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 }); ``` **Code View Vanilla Example** (`code_view_vanilla.ts`): ```typescript import { 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, enableLineSelection: true, enableGutterUtility: true, layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, onSelectedLinesChange(selection) { console.log('selected lines', selection); }, renderHeaderPrefix(_headerData, context) { const span = document.createElement('span'); span.textContent = context.item.type === 'diff' ? 'Diff' : 'File'; return span; }, renderHeaderMetadata(_headerData, context) { return context.item.type === 'diff' ? context.item.fileDiff.type : 'file'; }, renderGutterUtility(getHoveredLine, context) { const hoveredLine = getHoveredLine(); if (hoveredLine == null || context.item.type !== 'diff') { return undefined; } const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Comment on line ' + hoveredLine.lineNumber; return button; }, }); 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); viewer.scrollTo({ type: 'line', id: 'diff:src/app.ts', lineNumber: 2, side: 'additions', behavior: 'smooth-auto', }); 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(); }); ``` ## Edit mode > **Warning:** Edit mode is experimental and subject to change. Edit mode is a pluggable editing layer for `File`-style code surfaces. It adds keyboard-driven editing to an already-rendered `File` or `FileDiff` while the existing renderer continues to own syntax highlighting, layout, annotations, and virtualization. Edit mode features include: - Text editing - Multiple cursors - Automatic indentation - Brace matching - History (undo and redo) - Find-in-file search and replace - [Selection Action](#edit-mode-selection-action) (opt-in, custom UI) - Markers (inline diagnostics) - SSR support - Mobile-friendly - Lightweight Use the `Editor` API to add editing to `File`, `FileDiff`, `MultiFileDiff`, and `PatchDiff`, or to individual `CodeView` items. In React, wrap editable surfaces in `EditProvider`, then set a standalone surface's `edit` prop or a `CodeView` item's `edit` flag. In vanilla JS, call `editor.edit(fileInstance)` after rendering a standalone surface, or give `CodeView` its own `createEditor` option. Edit mode is not a full-fledged IDE, though you can build IDE-like experiences on top of it. It is purpose-built for code rendered by this library: the existing file and diff surfaces keep their diff layout, annotations, syntax highlighting, SSR, and virtualization, and edit mode adds editing, multiple selections, history, search and replace, and markers. That focus makes it a natural fit for “review and correct” flows with generative code changes. ### How It Works Edit mode does not replace `File`, `FileDiff`, or their virtualized variants. You render the surface first, then attach editing: 1. **Attach** — Call `editor.edit(fileInstance)` in vanilla JS, or pass `edit` on a React `File`, `FileDiff`, `MultiFileDiff`, or `PatchDiff` wrapped in `EditProvider`. For React `CodeView`, use the same provider and set `edit: true` on an item. Vanilla `CodeView` instead takes `CodeViewOptions.createEditor`. The hookup returns a dispose function for a standalone vanilla surface; React surfaces and `CodeView` manage it automatically when editing toggles. 2. **Edit surface** — Edit mode sets `contentEditable` on the code content element so mobile keyboards, paste, and native selection UI work as users expect. Clipboard shortcuts are handled by the browser; edit mode commands handle structure-aware actions like indent and undo. 3. **Selections** — Carets and ranges are tracked with the native `Selection` API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds another cursor without clearing existing ones. 4. **Updates** — Keystrokes update an internal `TextDocument`, then changed lines are re-highlighted through the same tokenizer pipeline as read-only mode. Your `onChange` handler receives updated `FileContents`, optional `lineAnnotations`, and an `EditorChangeEvent` as its third argument. The event includes both values alongside normalized text `changes`. When attaching, edit mode turns on the token transformer and triggers a re-render if necessary. Collapsed unchanged regions stay collapsed: arrow keys skip over them like code folds, jumps that must land inside one (search matches, undo, Cmd/Ctrl+End) expand it just enough to show the target, and the separator expand buttons keep working. The diff layout is preserved, so a `FileDiff` or `MultiFileDiff` stays in whatever view it was rendered in; in unified diffs, deleted lines and annotation lines remain read-only. ### React Wrap editable surfaces in a permanently mounted `EditProvider`, then toggle the standalone surface's `edit` prop or a `CodeView` item's `edit` flag. Standalone surface DOM and imperative file/diff instances stay mounted across the toggle. The provider's `createEditor` factory gives each editable surface or `CodeView` item its own `Editor`, cached by `editorOptions` object identity: an edit session that restarts with the same options object reuses its editor, and surfaces editable at the same time need distinct options objects. Pass creation-time callbacks and behavior through the surface's `editorOptions`; the factory can spread shared defaults before the surface options so the surface wins. Keep factories and object props stable: define static values at module scope, or use `useCallback` and `useMemo` when they depend on component values. Because editors are cached by options identity, passing one stable `editorOptions` object to every surface shares one `Editor` across them. This is the pattern for IDE-like UIs that edit one file at a time across many files, normally combined with `persistState` so each file's contents, history, selections, and scroll position survive switches — see [Persisting Editor State](#edit-mode-persisting-editor-state): Changes to `createEditor` or `editorOptions` do not disturb an active edit session; their latest values apply the next time `edit` transitions from false to true. Use `onAttach` with your own ref when controls need imperative APIs such as history, selections, markers, save, or search. Use `Virtualizer` for large editable files. The `FileDiff` tab below shows the controlled annotation feedback loop. ### Vanilla JS Render with `File` or `FileDiff` first, then attach the editor with `editor.edit(component)`. Use `VirtualizedFile` and `VirtualizedFileDiff` for large editable files. Keep any emitted annotation collection in your external source of truth before a later render. Call `cleanUp()` when the editor is removed. The `FileDiff` tab below includes this synchronization. ### CodeView `CodeView` manages one `Editor` per editable item. In React, wrap it in the same app-level `EditProvider` used by other editable surfaces, set `edit: true` on an item, and pass creation-time item-editor behavior through the `CodeView` `editorOptions` prop. In vanilla JS, pass `createEditor` in `CodeViewOptions` instead. Increment the item's `version` whenever `edit` changes. `CodeView` uses item-aware callbacks instead of `editorOptions.onChange`. `onItemEditChange` receives the owning item with each live change: replace that item's annotations whenever the emitted array changes, and increment its `version`. This live update keeps annotation coordinates synchronized throughout the edit session. `onItemEditComplete` receives the item and latest contents when a changed session ends because editing is disabled or the item is collapsed or removed. Sessions with no changes do not emit it. Resetting, cleaning up, or unmounting the viewer is silent teardown. Persist edited contents to the item's `file`, or rebuild its `fileDiff`. Assign a fresh `cacheKey` and increment `version`; `CodeView` does not update item data for you. Editors retain their document and history while items move in and out of the virtualized window. `editorOptions` and provider-factory changes are creation-time inputs: active item sessions keep their current editor, while the latest values apply to the next item that enters edit mode. Simultaneously edited items always receive independent editor instances. ### Editing with Line Annotations Applications own the annotation collection passed to a `File`, `FileDiff`, or other editable surface. When an edit affects annotation coordinates, the editor remaps them and passes the complete current collection as `onChange`'s second argument alongside the edited file. This collection is authoritative when present; it is not a delta. Replace your application-owned collection with it; otherwise a later render reapplies stale coordinates. The editor preserves the existing array reference for ordinary same-line edits and other edits that do not affect any annotation. When a structural edit touches, moves, or removes an annotation, it returns a new array. Check that identity before publishing state so ordinary typing does not cause unnecessary annotation renders. Remapping follows these rules: - A `LineAnnotation`, or a `DiffLineAnnotation` on the `additions` side, follows structural edits in the editable file. Inserting lines above it moves it down; deleting earlier lines moves it up. - A `DiffLineAnnotation` on the `deletions` side stays attached to the read-only old-file side and is not remapped. - `lineNumber: 0` remains a file-level annotation and is not remapped. - Deleting only an annotated line's text leaves an annotated blank line. To remove a non-final line and its annotation, delete from its start through the start of the next line, consuming its trailing line break. To remove a final line that has a preceding line, delete from the end of the preceding line through EOF, consuming the preceding line break. A one-line document always retains one blank line. - Deleting only the line break before an annotated line while retaining its text merges that text into the previous line and moves the annotation to the merged line. - Undo and redo use the same `onChange` path, restoring or removing annotations with the corresponding document state. For annotations that survive, remapping changes only their coordinates. Metadata is preserved, so give each interactive annotation a stable, position-independent ID in `metadata` and key application-owned drafts or UI state by that ID. The shared `onChange` callback accepts either annotation shape: standalone `File` surfaces emit `LineAnnotation[]`, while diff surfaces emit `DiffLineAnnotation[]`. Narrow `lineAnnotations` before reading the diff-only `side` property. Use `isFileAnnotationCollection` or `isDiffAnnotationCollection` when the surface type is not already known. In React, synchronously publish a changed annotation array with `flushSync` so the annotation placement updates with the edited content before paint. Do not wrap every `onChange` update in `flushSync`: bail out when the emitted array is the same object. > **Warning:** React may recreate annotation content when annotations are removed, restored, > or reordered, or when a `CodeView` item leaves the virtualized window. > `flushSync` keeps the placement update visually atomic, but it does not > preserve component-local state. Keep drafts and other interactive state in > application-owned state keyed by a stable annotation metadata ID. We intend to > preserve annotation node identity in a future release, so current remounting > behavior is not a long-term API contract. In vanilla JS, replace the annotation collection in your external variable or store before updating the surface. When the emitted array changes, schedule the same render function used for the initial surface after `onChange` returns, passing the edited file and replacement annotations. For `CodeView`, pass the collection to `updateItem` with a `version` increment. In every case, updating the source of truth prevents a later render from restoring stale line numbers. Use one `Editor` per concurrently editable standalone surface. `CodeView` instead creates and manages one editor per editable item. See the [playground](/playground) for the complete interaction: submit a line annotation, enable editing, insert a line above it, remove its logical line, then use undo and redo. The playground controls demonstrate the integration; they are not additional public editor options. ### Selection Action Selection Action is an opt-in edit mode feature for showing custom UI alongside the current selection — useful for quick transforms, refactor prompts, or other selection-scoped tools. Set `enabledSelectionAction: true` and return your UI from `renderSelectionAction`; a floating popover holding your UI appears after the user creates a ranged selection. Programmatic `setSelections` and `setState` calls update the selection without opening the popover. The popover can hold any number of actions. `renderSelectionAction` runs when the popover opens. Its context includes the active `selection`, the editable `textDocument`, helpers to read or modify the selection (`getSelectionText`, `replaceSelectionText`, `applyEdits`), and `close` to dismiss the popover: ### Persisting Editor State By default, each edit session starts fresh: switching files (or re-entering edit mode) discards the previous file's edits, history, and selections. Set `persistState: true` to keep per-file state alive across file switches on the same `Editor` instance. State is keyed by the file's `cacheKey`, so every editable file must provide one — unique and stable (the editor throws otherwise). Diffs parsed with `parseDiffFromFile` derive a cache key from the file pair automatically. Two layers persist: - **File contents and undo history.** The editor caches each file's text document on the instance. When a file whose `cacheKey` it has already seen attaches again, the editor swaps the cached document back in — the host keeps passing the original `contents`, and the edited text with its full undo timeline returns. - **Editor state.** Selections and scroll positions — the code scroller's `scrollLeft` and the surrounding viewport's `scrollTop` — are written to `persistStateStorage` when the editor detaches or switches files, and restored on the next attach with the same `cacheKey`. If the user scrolls before an async restore lands, the restore leaves the viewport alone. A file with no stored record yet — its first open — starts scrolled to the top instead of inheriting whatever offset the previous file left behind in a shared scroll container. `persistStateStorage` selects where the serializable state lives: `'inMemory'` (the default) lasts as long as the `Editor` instance, `'indexedDB'` survives page reloads, and a custom `IStateStorage` — an object with `get(cacheKey)` and `set(cacheKey, state)`, synchronous or async — bridges to your own store. Cached text documents always stay on the editor instance regardless of the storage choice, so persisting content across file switches means reusing one editor rather than creating one per file. In React, reuse one editor by passing the same `editorOptions` object to every surface — `EditProvider` caches editors by options identity, so a stable object yields one instance across file switches: ### Markers Markers add inline diagnostics — errors, warnings, and other annotations — to the editable surface, with a hover popover that shows each marker's message. They are useful for surfacing linter or language-server output alongside live edits. Each marker is positioned with zero-based `start` and `end` positions and a `severity` that drives its color and popover styling: Call `editor.setMarkers(markers)` after the editor has attached to a surface (calling it before attaching throws). Inlining the array lets TypeScript check the `severity` literals against the `Marker` type without importing it. Markers re-anchor as the document changes, so they stay attached to their text while you edit. Pass an empty array to clear them. ### History Each editor keeps a single undo stack per file. Typed input and programmatic `applyEdits` calls join the same timeline, so a mixed sequence undoes exactly like a hand-typed one, and each undo or redo restores the selections that went with the edit. Drive it with `undo()` and `redo()` — the same commands behind `Cmd/Ctrl+Z` — and read `canUndo` and `canRedo` for toolbar state. History lives in the file's text document, so with [`persistState`](#edit-mode-persisting-editor-state) it survives file switches. You can limit the stack size with `historyMaxEntries` in [Editor Options](#edit-mode-editor-options) (default is `100`). ### Using Worker Pool Edit mode works with a worker pool out of the box: a file being edited renders on the main thread with the token metadata the editor needs, while the pool keeps rendering every other file on the page. Keystroke re-highlighting always runs on the main thread, with or without a pool. Setting `useTokenTransformer: true` on the pool's `highlighterOptions` is an optional optimization for edit-heavy apps: pool-rendered markup is then already editor-compatible, so entering edit mode skips a one-time re-render of that file. It costs larger DOM for every rendered file, which is why it is off by default. See [Worker Pool](#worker-pool) for worker factory setup and pool options. In vanilla JS, pass the pool as the second argument to `File` or `FileDiff`. In React, wrap your tree in `WorkerPoolContextProvider`. Then attach the editor as usual. ### Lazy Importing Because `@pierre/diffs/edit` is a standalone entry point, you can dynamic-import it only when the user enters edit mode. That keeps the initial page bundle smaller and can improve LCP (Largest Contentful Paint) on pages where editing is rare. ### API Reference These methods are available on an `Editor` instance. Attach it to a rendered `File` or `FileDiff` with `edit()` first. #### Editor Options Pass these when constructing `new Editor({ ... })`, or update them later with [`setOptions`](#edit-mode-api-reference). `EditorKeymap` is an array of `{ platform?, bindings }` groups. `platform` can be `mac`, `windows`, or `linux`; omit it to enable the group's bindings everywhere. Later groups take precedence when bindings overlap. When set, custom bindings take precedence, while unmatched shortcuts fall back to the built-in keymap. Keymaps are immutable; pass a new array to `setOptions` to change one. #### `onChange` The third argument is an `EditorChangeEvent`. Its `changes` array contains every normalized edit in the change: Ranges use zero-based line and character positions. #### `onAttach` Use `onAttach` to opt into initial caret placement. At this point the text document and editable DOM are ready. In React, pass a stable `editorOptions` object to any editable surface, including `CodeView`: Vanilla `CodeView` can add the same behavior in its editor factory: `'first-visible'` selects the first editable row whose top falls inside the usable viewport and places the caret at character zero. If no editable row top is visible, the call does nothing. Set `offset` to a non-negative number of CSS pixels to move the usable top below the viewport if needed. With `preventScroll: true`, the focus request does not change the vertical scroll position. You can also target a specific document position: Numeric `lineNumber` values are one-based, while `character` values are zero-based. Numeric targeting works on every attached editor. Any targeted focus replaces the current selection, so use either this initial placement or restored selection/view state as the owner of the edit session's starting position, not both. `CodeView` retains an editor while its item is recycled, so `onAttach` is not repeated for virtualized remounts in the same edit session. ### Keyboard Shortcuts Shortcuts use Cmd on macOS and Ctrl on Windows and Linux. Jumping to the document start or end uses the modifier with the Home and End keys; on macOS, the modifier with ↑ and ↓ arrows works too. **Editor Options Type** (`editor_options_type.ts`): ```typescript import type { DiffLineAnnotation, DiffsEditableComponent, EditorChangeEvent, FileContents, LineAnnotation, } from '@pierre/diffs'; import { Editor, type EditorKeymap, type IStateStorage, } from '@pierre/diffs/edit'; interface EditorOptions { // Max undo stack entries historyMaxEntries?: number; // Custom keymap checked before the default map. keymap?: EditorKeymap; // Preserve each File's document and item-local editor state between renders. // Requires every editable file to provide a unique, stable cacheKey. // Default: false. persistState?: boolean; // Where serializable editor state is stored. Text documents and undo // history remain in this Editor instance's in-memory cache. // Defaults to 'inMemory' when persistState is enabled. persistStateStorage?: 'inMemory' | 'indexedDB' | IStateStorage; // 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/setState calls do not open it (default: false). 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; }; // 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, fileInstance: DiffsEditableComponent ) => void; // Fires after each edit. file.contents reflects the live document. When // present, lineAnnotations is the complete current collection, not a delta; // replace the application-owned source with it. Unaffected edits reuse the // existing array reference. onChange?: ( file: FileContents, lineAnnotations: | LineAnnotation[] | DiffLineAnnotation[] | undefined, event: EditorChangeEvent ) => 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; } ``` **Editor Public API** (`editor_public_api.ts`): ```typescript import { File, type EditorState, type FileContents, } from '@pierre/diffs'; import { Editor, type EditorFocusOptions } from '@pierre/diffs/edit'; // Editor // Most methods require an attached surface via edit(). const fileInstance = new File(); fileInstance.render({ file: { name: 'example.ts', contents: '...' }, containerWrapper: document.body, }); const editor = new Editor(); // Merge partial options at runtime. Existing fields are preserved. // onChange and similar handlers read from the latest options on each call; // pass onFocus/onBlur before edit() attaches, or set them in the constructor. editor.setOptions({ onChange(file, lineAnnotations) { // Save file in application state. if (lineAnnotations != null) { // Replace the application-owned annotation collection. } }, }); // Attach to a rendered File, FileDiff, or virtualized variant. // Normalizes conflicting fileInstance options and returns a dispose function. const dispose = editor.edit(fileInstance); // Detach, remove listeners, and clean up injected editor DOM. // Pass recycle=true when a virtualized host is temporarily unmounting. editor.cleanUp(); editor.cleanUp(true); // 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 attached document. Undefined when nothing is // attached. const file: FileContents | undefined = editor.getFile(); // Full document text, or '' when nothing is attached. const text: string = editor.getText(); // Snapshot selections and scroll positions for explicit restoration: const state: EditorState = editor.getState(); // EditorState = { // selections?: EditorSelection[]; // view?: { scrollLeft: number; scrollTop?: number }; // } // Restore selections and scroll positions after re-rendering. editor.setState(state); // 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(); ``` **Edit Focus Position Example** (`editor_focus_position.ts`): ```typescript editor.focus({ lineNumber: 13, character: 4 }); ``` **Edit Lazy File Example** (`editor_lazy_file.ts`): ```typescript import 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({ onChange(file, lineAnnotations) { console.log('change', file.name, lineAnnotations); }, }); return editor.edit(fileInstance); } // Click to edit and lazy-load the editor bundle only when it is needed. button.addEventListener('click', () => { void edit(fileInstance); }); ``` **Edit Marker Example** (`editor_markers.ts`): ```typescript import { Editor } from '@pierre/diffs/edit'; const editor = new Editor(); 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 Marker Type** (`marker.ts`): ```typescript type 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; } ``` **Edit On Attach React Example** (`editor_on_attach_react.tsx`): ```tsx const editorOptions = useMemo>( () => ({ onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, }), [] ); return ; ``` **Edit On Attach Vanilla Example** (`editor_on_attach_vanilla.ts`): ```typescript const viewer = new CodeView({ createEditor(options) { return new Editor({ ...options, onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, }); }, }); ``` **Edit On Change Example** (`editor_on_change.ts`): ```typescript import { Editor } from '@pierre/diffs/edit'; new Editor({ onChange: (file, lineAnnotations, event) => { // `event.changes` is an array containing all edits. const changes = event.changes; changes.forEach((change) => { console.log('Text inserted/replaced:', change.text); console.log('Range of the edit:', change.range); // { start: { line, character }, end: { line, character } } console.log('Offset of the change:', change.start, change.end); }); }, }); ``` **Edit Persist State Example** (`editor_persist_state.ts`): ```typescript import type { FileContents } from '@pierre/diffs'; import { Editor } from '@pierre/diffs/edit'; // Unique, stable cacheKeys identify each file's cached document and its // stored editor state. const fileA: FileContents = { name: 'a.ts', contents: 'export const a = 1;', cacheKey: 'a.ts', }; const fileB: FileContents = { name: 'b.ts', contents: 'export const b = 2;', cacheKey: 'b.ts', }; // `fileInstance` is a rendered File — see the Vanilla JS section above. const editor = new Editor({ persistState: true }); editor.edit(fileInstance); fileInstance.render({ file: fileA }); // ...the user edits, selects, and scrolls fileA... // Switching files caches fileA's document (contents + undo history) on the // editor and writes its selections and scroll offsets to the state storage. // fileB has no record yet, so its surface starts scrolled to the top. fileInstance.render({ file: fileB }); // Switching back renders fileA's edited contents — even though the original // `contents` string is passed again — and restores its selections, scroll // position, and undo history. fileInstance.render({ file: fileA }); ``` **Edit Persist State React Example** (`editor_persist_state_react.tsx`): ```tsx import type { FileContents } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; import { type CreateEditor, EditProvider, File } from '@pierre/diffs/react'; import { useCallback, useMemo } from 'react'; // Editors are cached by `editorOptions` object identity, so the stable // options object below hands every file rendered here the same editor. Its // cached documents and default 'inMemory' state store live on that instance, // which is what lets per-file contents, selections, and scroll survive // surface remounts. export function PersistedEditor({ file }: { file: FileContents }) { const createEditor = useCallback>( (options) => new Editor(options), [] ); const editorOptions = useMemo>( () => ({ persistState: true }), [] ); return ( ); } ``` **Edit React Code View Example** (`editor_react_code_view.tsx`): ```tsx import { isDiffAnnotationCollection, parseDiffFromFile, type CodeViewItem, type DiffLineAnnotation, type FileContents, type LineAnnotation, } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; import { CodeView, EditProvider } from '@pierre/diffs/react'; import { useCallback, useState } from 'react'; import { flushSync } from 'react-dom'; 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[] = [ { 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 = { onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, }; function createEditor(options: EditorOptions) { return new Editor(options); } 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, })) ); }, []); const syncAnnotations = useCallback( ( item: CodeViewItem, _file: FileContents, nextAnnotations?: | LineAnnotation[] | DiffLineAnnotation[] ) => { if ( item.type !== 'diff' || nextAnnotations == null || !isDiffAnnotationCollection(nextAnnotations) || item.annotations === nextAnnotations ) { return; } flushSync(() => { setItems((current) => current.map((existing) => existing.id === item.id && existing.type === 'diff' ? { ...existing, annotations: nextAnnotations, version: (existing.version ?? 0) + 1, } : existing ) ); }); }, [] ); const commitEdit = useCallback( (item: CodeViewItem, file: FileContents) => { setItems((current) => current.map((existing) => { if (existing.id !== item.id || existing.type !== 'diff') { return existing; } const version = (existing.version ?? 0) + 1; const cacheKey = existing.id + ':v' + version; return { ...existing, edit: false, version, fileDiff: { ...parseDiffFromFile(oldFile, { ...file, cacheKey }), cacheKey, }, }; }) ); }, [] ); // 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 ( (
Thread {annotation.metadata.id}
)} />
); } ``` **Edit React Create Editor Example** (`editor_react_create_editor.tsx`): ```tsx const createEditor = useCallback>( (surfaceOptions) => new Editor({ ...defaultEditorOptions, ...surfaceOptions, }), [] ); const editorOptions = useMemo>( () => ({ onChange: handleChange, onAttach(editor) { editorRef.current = editor; }, }), [handleChange] ); // Mount EditProvider near the root so its editors are available to every // editable File, diff, and CodeView. return ( ); ``` **Edit React Example** (`editor_react.tsx`): ```tsx import type { FileContents, FileOptions } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; import { EditProvider, File, Virtualizer } from '@pierre/diffs/react'; import { useMemo, useState } from 'react'; const file: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`); } export { greet };`, }; const fileOptions: FileOptions = { theme: { dark: 'pierre-dark', light: 'pierre-light' }, }; const virtualizerStyle = { maxHeight: '16rem', overflow: 'auto', borderRadius: '0.5rem', } as const; function createEditor(options: EditorOptions) { return new Editor(options); } export function EditableFile() { const [editable, setEditable] = useState(true); const editorOptions = useMemo>( () => ({ onChange(file, lineAnnotations) { console.log('change', file.name, lineAnnotations); }, }), [] ); // 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 ( ); } ``` **Edit React File Diff Example** (`editor_react_file_diff.tsx`): ```tsx import { isDiffAnnotationCollection, parseDiffFromFile, type DiffLineAnnotation, type FileDiffMetadata, type FileDiffOptions, } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; import { EditProvider, FileDiff, Virtualizer, } from '@pierre/diffs/react'; import { useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; interface ThreadMetadata { id: string; } const initialAnnotations: DiffLineAnnotation[] = [ { side: 'additions', lineNumber: 1, metadata: { id: 'updated-message-review' }, }, ]; // FileDiff takes a pre-parsed FileDiffMetadata object. const fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'example.ts', contents: 'console.log("Hello world")' }, { name: 'example.ts', contents: 'console.warn("Updated message")' } ); const fileDiffOptions: FileDiffOptions = { theme: { dark: 'pierre-dark', light: 'pierre-light' }, }; const virtualizerStyle = { maxHeight: '16rem', overflow: 'auto', borderRadius: '0.5rem', } as const; function createEditor(options: EditorOptions) { return new Editor(options); } export function EditableFileDiff() { const [editable, setEditable] = useState(true); const [annotations, setAnnotations] = useState(initialAnnotations); const annotationsRef = useRef(initialAnnotations); // Key interaction state by stable metadata rather than line coordinates. const [drafts, setDrafts] = useState>({}); const editorOptions = useMemo>( () => ({ onChange(_file, nextAnnotations) { if ( nextAnnotations == null || !isDiffAnnotationCollection(nextAnnotations) || nextAnnotations === annotationsRef.current ) { return; } annotationsRef.current = nextAnnotations; // Publish remapped annotations before the browser paints the edit. flushSync(() => setAnnotations(nextAnnotations)); }, }), [] ); // 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 ( fileDiff={fileDiff} lineAnnotations={annotations} options={fileDiffOptions} edit={editable} editorOptions={editorOptions} renderAnnotation={(annotation) => { const id = annotation.metadata.id; return (