- setDiffStyle(value as 'bars' | 'classic' | 'none')
- }
+ onValueChange={(value) => setDiffStyle(value as DiffIndicators)}
className="col-span-full"
>
{['bars', 'classic', 'none'].map((value) => (
diff --git a/apps/docs/app/(diffs)/_examples/ShikiThemes/ShikiThemes.tsx b/apps/docs/app/(diffs)/_examples/ShikiThemes/ShikiThemes.tsx
index bdb0e8b91..bf8e76231 100644
--- a/apps/docs/app/(diffs)/_examples/ShikiThemes/ShikiThemes.tsx
+++ b/apps/docs/app/(diffs)/_examples/ShikiThemes/ShikiThemes.tsx
@@ -25,6 +25,7 @@ import {
const LIGHT_THEMES = [
'pierre-light',
+ 'pierre-light-soft',
'catppuccin-latte',
'everforest-light',
'github-light',
@@ -47,6 +48,7 @@ const LIGHT_THEMES = [
const DARK_THEMES = [
'pierre-dark',
+ 'pierre-dark-soft',
'andromeeda',
'aurora-x',
'ayu-dark',
diff --git a/apps/docs/app/(diffs)/docs/CodeView/ExampleTabs.tsx b/apps/docs/app/(diffs)/docs/CodeView/ExampleTabs.tsx
new file mode 100644
index 000000000..5d8bfe94d
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/CodeView/ExampleTabs.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import type { PreloadedFileResult } from '@pierre/diffs/ssr';
+import { useState } from 'react';
+
+import { DocsCodeExample } from '@/components/docs/DocsCodeExample';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
+
+type CodeViewExampleMode = 'react' | 'vanilla';
+
+interface CodeViewExampleTabsProps {
+ reactExample: PreloadedFileResult;
+ vanillaExample: PreloadedFileResult;
+}
+
+export function CodeViewExampleTabs({
+ reactExample,
+ vanillaExample,
+}: CodeViewExampleTabsProps) {
+ const [mode, setMode] = useState('react');
+
+ return (
+ <>
+ setMode(value as CodeViewExampleMode)}
+ >
+ React
+ Vanilla JS
+
+ {mode === 'react' ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/docs/app/(diffs)/docs/CodeView/constants.ts b/apps/docs/app/(diffs)/docs/CodeView/constants.ts
new file mode 100644
index 000000000..9f7b98202
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/CodeView/constants.ts
@@ -0,0 +1,401 @@
+import type { PreloadFileOptions } from '@pierre/diffs/ssr';
+
+import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS';
+
+const options = {
+ theme: { dark: 'pierre-dark', light: 'pierre-light' },
+ disableFileHeader: true,
+ unsafeCSS: CustomScrollbarCSS,
+} as const;
+
+export const CODE_VIEW_ITEM_TYPE_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'code_view_items.ts',
+ contents: `type CodeViewFileItem = {
+ type: 'file';
+ id: string;
+ file: FileContents;
+ annotations?: LineAnnotation[];
+ collapsed?: 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;
+ // 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;`,
+ },
+ options,
+};
+
+export const CODE_VIEW_LAYOUT_OPTIONS_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'code_view_layout.ts',
+ contents: `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,
+ }
+}`,
+ },
+ options,
+};
+
+export const CODE_VIEW_ITEM_METRICS_OPTIONS_EXAMPLE: PreloadFileOptions =
+ {
+ file: {
+ name: 'code_view_item_metrics.ts',
+ contents: `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;
+ }
+}`,
+ },
+ options,
+ };
+
+export const CODE_VIEW_SCROLL_TARGETS_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'code_view_scroll_targets.ts',
+ contents: `// 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 });`,
+ },
+ options,
+};
+
+export const CODE_VIEW_REACT_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'code_view_react.tsx',
+ contents: `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() {\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.\`,
+};
+
+const changelogFile = {
+ name: 'CHANGELOG.md',
+ contents: \`# Changelog\n\n- 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 (
+ <>
+
+ viewerRef.current?.scrollTo({
+ type: 'line',
+ id: 'diff:src/app.ts',
+ lineNumber: 2,
+ side: 'additions',
+ behavior: 'smooth-auto',
+ })
+ }
+ >
+ Jump to change
+
+
+ {
+ const viewer = viewerRef.current;
+ const item = viewer?.getItem('diff:src/app.ts');
+ if (item?.type !== 'diff') {
+ return;
+ }
+
+ viewer.updateItem({
+ ...item,
+ version: item.version != null ? item.version + 1 : 1,
+ collapsed: !item.collapsed,
+ });
+ }}
+ >
+ Toggle app diff
+
+
+ {
+ const viewer = viewerRef.current;
+ if (viewer?.getItem('file:CHANGELOG.md') != null) {
+ return;
+ }
+
+ viewer?.addItems([
+ {
+ id: 'file:CHANGELOG.md',
+ type: 'file',
+ file: changelogFile,
+ },
+ ]);
+ }}
+ >
+ Append changelog
+
+
+
+ item.type === 'diff' ? {item.fileDiff.type} : file
+ }
+ renderAnnotation={(annotation, item) => (
+
+ Note for {item.id} on line {annotation.lineNumber}
+
+ )}
+ />
+ >
+ );
+}`,
+ },
+ options,
+};
+
+export const CODE_VIEW_VANILLA_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'code_view_vanilla.ts',
+ contents: `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);
+ },
+ 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();
+});`,
+ },
+ options,
+};
diff --git a/apps/docs/app/(diffs)/docs/CodeView/content.mdx b/apps/docs/app/(diffs)/docs/CodeView/content.mdx
new file mode 100644
index 000000000..c87a5a4f4
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/CodeView/content.mdx
@@ -0,0 +1,144 @@
+## 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.
+
+### 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`, `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.
+- 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.
+
+### Padding & Gap
+
+For controlling layout inside and between items in `CodeView`, you can use the
+`layout` prop. Unlike `itemMetrics`, these values actually set internal values
+and adjust the layout. You should not apply these values with CSS yourself.
+
+
+
+### 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`, 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 or update existing ones.
+
+### 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` 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`, and `getInstance`.
+- `renderCustomHeader`, `renderHeaderPrefix`, `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` / `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.
+- 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`.
diff --git a/apps/docs/app/(diffs)/docs/Overview/content.mdx b/apps/docs/app/(diffs)/docs/Overview/content.mdx
index 1ca3402a5..aace29dc6 100644
--- a/apps/docs/app/(diffs)/docs/Overview/content.mdx
+++ b/apps/docs/app/(diffs)/docs/Overview/content.mdx
@@ -1,9 +1,5 @@
## Overview
- }>
- Diffs is in early active developmentāAPIs are subject to change.
-
-
**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
diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/ComponentTabs.tsx b/apps/docs/app/(diffs)/docs/ReactAPI/ComponentTabs.tsx
index 1544fd1fd..15668b789 100644
--- a/apps/docs/app/(diffs)/docs/ReactAPI/ComponentTabs.tsx
+++ b/apps/docs/app/(diffs)/docs/ReactAPI/ComponentTabs.tsx
@@ -11,6 +11,7 @@ const NumberColumnWidthOverride = {
} as CSSProperties;
type ExampleTypes =
+ | 'code-view'
| 'multi-file-diff'
| 'patch-diff'
| 'file-diff'
@@ -23,6 +24,7 @@ type SharedPropsTypes =
| 'file-render-props';
interface ComponentTabsProps {
+ reactAPICodeView: PreloadedFileResult;
reactAPIMultiFileDiff: PreloadedFileResult;
reactAPIFileDiff: PreloadedFileResult;
reactAPIPatch: PreloadedFileResult;
@@ -31,13 +33,14 @@ interface ComponentTabsProps {
}
export function ComponentTabs({
+ reactAPICodeView,
reactAPIMultiFileDiff,
reactAPIFileDiff,
reactAPIPatch,
reactAPIFile,
reactAPIUnresolvedFile,
}: ComponentTabsProps) {
- const [example, setExample] = useState('multi-file-diff');
+ const [example, setExample] = useState('code-view');
return (
<>
@@ -45,6 +48,7 @@ export function ComponentTabs({
value={example}
onValueChange={(value) => setExample(value as ExampleTypes)}
>
+ CodeView
MultiFileDiff
PatchDiff
FileDiff
@@ -55,6 +59,8 @@ export function ComponentTabs({
{(() => {
switch (example) {
+ case 'code-view':
+ return
;
case 'multi-file-diff':
return
;
case 'file-diff':
diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
index b0b8552fc..d5b80f3fe 100644
--- a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
+++ b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
@@ -184,8 +184,6 @@ interface DiffOptions {
// Must be true to enable renderGutterUtility prop
enableGutterUtility: false,
- // Deprecated alias: enableHoverUtility
- // This boolean controls visibility for both built-in and custom gutter utility UI.
// Callbacks for mouse events on diff lines
onLineClick({ lineNumber, side, event }) {
@@ -618,6 +616,69 @@ export function MergeConflictPreview() {
options,
};
+export const REACT_API_CODE_VIEW: PreloadFileOptions
= {
+ file: {
+ name: 'code_view.tsx',
+ contents: `import {
+ parseDiffFromFile,
+ type CodeViewItem,
+} from '@pierre/diffs';
+import { CodeView } 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.',
+};
+
+export function ReviewSurface() {
+ // 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 = 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 (
+
+ );
+}`,
+ },
+ options,
+};
+
export const REACT_API_SHARED_FILE_OPTIONS: PreloadFileOptions = {
file: {
name: 'shared_file_options.tsx',
@@ -724,9 +785,6 @@ interface FileOptions {
// Must be true to enable renderGutterUtility prop
enableGutterUtility: false,
- // Deprecated alias: enableHoverUtility
- // This boolean controls visibility for both built-in and custom gutter
- // utility UI.
// Callbacks for mouse events on file lines
onLineClick({ lineNumber, event }) {
diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
index a74549ac3..2eed9d9aa 100644
--- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
+++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
@@ -10,8 +10,10 @@ similar types of props, which you can find documented in
### Components
-The React API exposes five main components:
+The React API exposes six main components:
+- `CodeView` renders a mixed, virtualized list of files and diffs inside one
+ scroll container
- `MultiFileDiff` compares two file versions
- `PatchDiff` renders from a patch string
- `FileDiff` renders a pre-parsed `FileDiffMetadata`
@@ -20,6 +22,7 @@ The React API exposes five main components:
- _Currently in beta/experimental and may change in future releases._
;
fileDiffExample: PreloadedFileResult;
fileExample: PreloadedFileResult;
unresolvedFileExample: PreloadedFileResult;
}
export function VanillaComponentTabs({
+ codeViewExample,
fileDiffExample,
fileExample,
unresolvedFileExample,
}: VanillaComponentTabsProps) {
const [componentType, setComponentType] =
- useState('file-diff');
+ useState('code-view');
return (
<>
@@ -30,6 +32,7 @@ export function VanillaComponentTabs({
value={componentType}
onValueChange={(value) => setComponentType(value as ComponentType)}
>
+ CodeView
FileDiff
File
@@ -38,6 +41,13 @@ export function VanillaComponentTabs({
{(() => {
switch (componentType) {
+ case 'code-view':
+ return (
+
+ );
case 'file-diff':
return (
= {
+ file: {
+ name: 'code_view_example.ts',
+ contents: `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();
+});`,
+ },
+ options,
+};
+
// =============================================================================
// FILEDIFF PROPS
// =============================================================================
@@ -295,9 +376,6 @@ const instance = new FileDiff({
// Must be true to enable renderGutterUtility
enableGutterUtility: false,
- // Deprecated alias: enableHoverUtility
- // This boolean controls visibility for both built-in and
- // custom gutter utility UI.
// Fires when clicking anywhere on a line
onLineClick({ lineNumber, side, event }) {},
@@ -550,9 +628,6 @@ const instance = new File({
// Must be true to enable renderGutterUtility
enableGutterUtility: false,
- // Deprecated alias: enableHoverUtility
- // This boolean controls visibility for both built-in and
- // custom gutter utility UI.
// Fires when clicking anywhere on a line
onLineClick({ lineNumber, event }) {},
@@ -615,7 +690,7 @@ const instance = new File({
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// Render custom content in the file header
- renderCustomMetadata(file) {
+ renderHeaderMetadata(file) {
const span = document.createElement('span');
span.textContent = file.name;
return span;
diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
index b894dd0c0..3c64fae46 100644
--- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
+++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
@@ -7,14 +7,16 @@
### Components
-The Vanilla JS API exposes three core components: `FileDiff` (compare two file
-versions or render a pre-parsed `FileDiffMetadata`), `File` (render a single
-code file without diff), and `UnresolvedFile` (render merge conflicts with
-built-in resolution controls). Typically you'll want to interface with these as
-they'll handle all the complicated aspects of syntax highlighting, theming, and
-full interactivity for you.
+The Vanilla JS API exposes four core components: `CodeView` (render a mixed,
+virtualized list of files and diffs in one scroll container), `FileDiff`
+(compare two file versions or render a pre-parsed `FileDiffMetadata`), `File`
+(render a single code file without diff), and `UnresolvedFile` (render merge
+conflicts with built-in resolution controls). Typically you'll want to interface
+with these as they'll handle the complicated aspects of syntax highlighting,
+theming, and interactivity for you.
= {
file: {
- name: 'CodeViewer.tsx',
+ name: 'CodeView.tsx',
contents: `'use client';
import { createWorkerAPI } from '@/utils/createWorkerAPI';
import { useEffect, useState } from 'react';
-export function CodeViewer() {
+export function CodeView() {
const [workerAPI] = useState(() =>
createWorkerAPI({
poolSize: 8,
diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
index cea0c49a5..5c6051ad8 100644
--- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
+++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
@@ -2,6 +2,7 @@
import type {
AnnotationSide,
+ DiffIndicators,
DiffLineAnnotation,
SelectedLineRange,
} from '@pierre/diffs';
@@ -46,6 +47,7 @@ import { Switch } from '@/components/ui/switch';
const LIGHT_THEMES = [
'pierre-light',
+ 'pierre-light-soft',
'catppuccin-latte',
'github-light',
'one-light',
@@ -54,6 +56,7 @@ const LIGHT_THEMES = [
const DARK_THEMES = [
'pierre-dark',
+ 'pierre-dark-soft',
'catppuccin-mocha',
'dracula',
'github-dark',
@@ -91,7 +94,7 @@ const DEFAULTS = {
lineNumbers: true,
wrap: true,
lineSelection: true,
- hoverButton: true,
+ gutterButton: true,
interactionMode: 'comment' as const,
annotations: true,
} as const;
@@ -109,8 +112,8 @@ interface PlaygroundControlsContentProps {
setSelectedLightTheme: (v: (typeof LIGHT_THEMES)[number]) => void;
selectedDarkTheme: (typeof DARK_THEMES)[number];
setSelectedDarkTheme: (v: (typeof DARK_THEMES)[number]) => void;
- diffIndicators: 'bars' | 'classic' | 'none';
- setDiffIndicators: (v: 'bars' | 'classic' | 'none') => void;
+ diffIndicators: DiffIndicators;
+ setDiffIndicators: (v: DiffIndicators) => void;
lineDiffType: 'word-alt' | 'word' | 'char' | 'none';
setLineDiffType: (v: 'word-alt' | 'word' | 'char' | 'none') => void;
hunkSeparators: HunkSeparatorValue;
@@ -123,8 +126,8 @@ interface PlaygroundControlsContentProps {
setOverflow: (v: 'wrap' | 'scroll') => void;
enableLineSelection: boolean;
setEnableLineSelection: (v: boolean) => void;
- enableHoverUtility: boolean;
- setEnableHoverUtility: (v: boolean) => void;
+ enableGutterUtility: boolean;
+ setEnableGutterUtility: (v: boolean) => void;
showAnnotations: boolean;
setShowAnnotations: (v: boolean) => void;
selectedRange: SelectedLineRange | null;
@@ -156,8 +159,8 @@ function PlaygroundControlsContent({
setOverflow,
enableLineSelection,
setEnableLineSelection,
- enableHoverUtility,
- setEnableHoverUtility,
+ enableGutterUtility,
+ setEnableGutterUtility,
showAnnotations,
setShowAnnotations,
selectedRange,
@@ -165,7 +168,7 @@ function PlaygroundControlsContent({
handleCopyLink,
hideShare = false,
}: PlaygroundControlsContentProps) {
- const interactionMode: 'select' | 'comment' | 'none' = enableHoverUtility
+ const interactionMode: 'select' | 'comment' | 'none' = enableGutterUtility
? 'comment'
: enableLineSelection
? 'select'
@@ -178,17 +181,17 @@ function PlaygroundControlsContent({
const setInteractionMode = (mode: 'select' | 'comment' | 'none') => {
if (mode === 'comment') {
- setEnableHoverUtility(true);
+ setEnableGutterUtility(true);
setEnableLineSelection(false);
return;
}
if (mode === 'select') {
setEnableLineSelection(true);
- setEnableHoverUtility(false);
+ setEnableGutterUtility(false);
return;
}
setEnableLineSelection(false);
- setEnableHoverUtility(false);
+ setEnableGutterUtility(false);
};
return (
@@ -283,9 +286,7 @@ function PlaygroundControlsContent({
- setDiffIndicators(value as 'bars' | 'classic' | 'none')
- }
+ onValueChange={(value) => setDiffIndicators(value as DiffIndicators)}
>
@@ -495,13 +496,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
(typeof DARK_THEMES)[number]
>(getParam('dark', DEFAULTS.darkTheme) as (typeof DARK_THEMES)[number]);
- const [diffIndicators, setDiffIndicators] = useState<
- 'bars' | 'classic' | 'none'
- >(
- getParam('indicators', DEFAULTS.diffIndicators) as
- | 'bars'
- | 'classic'
- | 'none'
+ const [diffIndicators, setDiffIndicators] = useState(
+ getParam('indicators', DEFAULTS.diffIndicators) as DiffIndicators
);
const [lineDiffType, setLineDiffType] = useState<
@@ -538,14 +534,14 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
? false
: getBoolParam('select', DEFAULTS.lineSelection)
);
- const [enableHoverUtility, setEnableHoverUtility] = useState(
+ const [enableGutterUtility, setEnableGutterUtility] = useState(
initialLineMode === 'comment'
? true
: initialLineMode === 'select'
? false
: initialLineMode === 'none'
? false
- : getBoolParam('hover', DEFAULTS.hoverButton)
+ : getBoolParam('gutter', DEFAULTS.gutterButton)
);
const [showAnnotations, setShowAnnotations] = useState(
getBoolParam('annot', DEFAULTS.annotations)
@@ -575,7 +571,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
DiffLineAnnotation[]
>(prerenderedDiff.annotations ?? []);
- const interactionMode: 'select' | 'comment' | 'none' = enableHoverUtility
+ const interactionMode: 'select' | 'comment' | 'none' = enableGutterUtility
? 'comment'
: enableLineSelection
? 'select'
@@ -608,8 +604,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
params.set('lineMode', interactionMode);
if (enableLineSelection !== DEFAULTS.lineSelection)
params.set('select', enableLineSelection ? '1' : '0');
- if (enableHoverUtility !== DEFAULTS.hoverButton)
- params.set('hover', enableHoverUtility ? '1' : '0');
+ if (enableGutterUtility !== DEFAULTS.gutterButton)
+ params.set('gutter', enableGutterUtility ? '1' : '0');
if (showAnnotations !== DEFAULTS.annotations)
params.set('annot', showAnnotations ? '1' : '0');
@@ -639,7 +635,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
overflow,
interactionMode,
enableLineSelection,
- enableHoverUtility,
+ enableGutterUtility,
showAnnotations,
selectedRange,
]);
@@ -703,11 +699,11 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
(ann) => ann.metadata.isThread !== true
);
- // Hover comments and line selection conflict on click targets.
- // Give hover comments precedence when both toggles are on.
- const canUseHoverComments = enableHoverUtility && !hasOpenCommentForm;
+ // Gutter comments and line selection conflict on click targets.
+ // Give gutter comments precedence when both toggles are on.
+ const canUseGutterComments = enableGutterUtility && !hasOpenCommentForm;
const canSelectLines =
- enableLineSelection && !enableHoverUtility && !hasOpenCommentForm;
+ enableLineSelection && !enableGutterUtility && !hasOpenCommentForm;
const [isControlsOpen, setIsControlsOpen] = useState(false);
const closeControls = useCallback(() => setIsControlsOpen(false), []);
@@ -744,8 +740,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
setOverflow,
enableLineSelection,
setEnableLineSelection,
- enableHoverUtility,
- setEnableHoverUtility,
+ enableGutterUtility,
+ setEnableGutterUtility,
showAnnotations,
setShowAnnotations,
selectedRange,
@@ -822,9 +818,9 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
themeType,
theme: { dark: selectedDarkTheme, light: selectedLightTheme },
enableLineSelection: canSelectLines,
- enableGutterUtility: canUseHoverComments,
+ enableGutterUtility: canUseGutterComments,
onLineSelectionEnd: handleLineSelectionEnd,
- onGutterUtilityClick: canUseHoverComments
+ onGutterUtilityClick: canUseGutterComments
? (range) => {
if (range.side != null) {
addCommentAtLine(range.side, range.start);
diff --git a/apps/docs/app/(diffs)/theme/ThemeDemo.tsx b/apps/docs/app/(diffs)/theme/ThemeDemo.tsx
index 8d71693cb..01dde55d5 100644
--- a/apps/docs/app/(diffs)/theme/ThemeDemo.tsx
+++ b/apps/docs/app/(diffs)/theme/ThemeDemo.tsx
@@ -17,7 +17,12 @@ import { cn } from '@/lib/utils';
// Preload themes at module level for earliest possible start
void preloadHighlighter({
- themes: ['pierre-dark', 'pierre-light'],
+ themes: [
+ 'pierre-dark',
+ 'pierre-dark-soft',
+ 'pierre-light',
+ 'pierre-light-soft',
+ ],
langs: ['tsx', 'html', 'css'],
});
diff --git a/apps/docs/app/(diffs)/theme/gallery/page.tsx b/apps/docs/app/(diffs)/theme/gallery/page.tsx
index 3b0068b7b..b28f9e997 100644
--- a/apps/docs/app/(diffs)/theme/gallery/page.tsx
+++ b/apps/docs/app/(diffs)/theme/gallery/page.tsx
@@ -65,7 +65,9 @@ const SAMPLE_NEW_FILE = {
const THEMES = [
// Pierre
'pierre-light',
+ 'pierre-light-soft',
'pierre-dark',
+ 'pierre-dark-soft',
// GitHub
'github-light',
diff --git a/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx b/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx
new file mode 100644
index 000000000..5dff5ee5a
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx
@@ -0,0 +1,32 @@
+import { redirect } from 'next/navigation';
+
+import { ReviewUI } from '../_components/ReviewUI';
+import { resolveDiffshubViewerRoute } from '../_components/utils';
+
+// Viewer route that mirrors the upstream path. GitHub is the public default,
+// while hidden alternate domains can opt in through the `domain` query param.
+export default async function DiffshubViewByPathPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ path: string[] }>;
+ searchParams: Promise<{ domain?: string | string[] }>;
+}) {
+ const { path } = await params;
+ const { domain } = await searchParams;
+ const requestedDomain = Array.isArray(domain) ? domain[0] : domain;
+ const route = resolveDiffshubViewerRoute(path, requestedDomain);
+ if (route.kind === 'redirect') {
+ redirect(route.target);
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewCommentsList.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewCommentsList.tsx
new file mode 100644
index 000000000..32d220c38
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewCommentsList.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import type { AnnotationSide } from '@pierre/diffs';
+import { IconConvoFill, IconPlus } from '@pierre/icons';
+import { memo, type MouseEvent } from 'react';
+
+import { CommentAuthorAvatar } from './annotation-shared';
+import type {
+ CodeViewSavedCommentEntry,
+ CodeViewSavedCommentItem,
+ CommentLineType,
+} from './types';
+import { cn } from '@/lib/utils';
+
+interface CodeViewCommentsListProps {
+ commentSections: readonly CodeViewSavedCommentItem[];
+ onSelectComment?(comment: CodeViewSavedCommentEntry): void;
+ onSelectItem?(itemId: string): void;
+}
+
+function getCommentLineLabel(
+ side: AnnotationSide,
+ lineNumber: number,
+ lineType: CommentLineType
+): string {
+ if (lineType === 'context') {
+ return `Line ${lineNumber}`;
+ }
+ const sigil = side === 'additions' ? '+' : '-';
+ return `Line ${sigil}${lineNumber}`;
+}
+
+function getCommentLineClassName(
+ side: AnnotationSide,
+ lineType: CommentLineType
+): string {
+ if (lineType === 'context') {
+ return 'text-muted-foreground';
+ }
+ return side === 'additions'
+ ? 'text-emerald-700 dark:text-emerald-400'
+ : 'text-rose-700 dark:text-rose-400';
+}
+
+// Wraps a click handler so users can drag-select text inside the row without
+// also triggering navigation. mouseup after a selection fires click on the
+// button; bail out only when the resulting selection is anchored inside this
+// row, so a pre-existing selection elsewhere on the page (e.g. in the diff
+// viewer) does not block keyboard/mouse activation of the row.
+function handleRowClick(
+ event: MouseEvent,
+ run: () => void
+): void {
+ if (event.button !== 0) {
+ return;
+ }
+ const selection =
+ typeof window !== 'undefined' ? window.getSelection() : null;
+ if (selection != null && selection.toString().length > 0) {
+ const row = event.currentTarget;
+ const anchorInRow =
+ selection.anchorNode != null && row.contains(selection.anchorNode);
+ const focusInRow =
+ selection.focusNode != null && row.contains(selection.focusNode);
+ if (anchorInRow || focusInRow) {
+ event.preventDefault();
+ return;
+ }
+ }
+ run();
+}
+
+export const CodeViewCommentsList = memo(function CodeViewCommentsList({
+ commentSections,
+ onSelectComment,
+ onSelectItem,
+}: CodeViewCommentsListProps) {
+ if (commentSections.length === 0) {
+ return (
+
+
+
+
No comments yet
+
+ Hover over a line and click the{' '}
+
+
+ {' '}
+ button to add fake code comments.
+
+
+
+ );
+ }
+
+ return (
+
+ {commentSections.map((section) => (
+
+ {onSelectItem != null ? (
+
+ handleRowClick(event, () => onSelectItem(section.itemId))
+ }
+ >
+ {section.path}
+
+ ) : (
+
+ {section.path}
+
+ )}
+
+ {section.comments.map((comment) => (
+
+ handleRowClick(event, () => onSelectComment?.(comment))
+ }
+ >
+
+
+
+ {comment.author} commented on{' '}
+
+ {getCommentLineLabel(
+ comment.side,
+ comment.lineNumber,
+ comment.lineType
+ )}
+
+
+
+ {comment.message}
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+});
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx
new file mode 100644
index 000000000..11b4f84a7
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { IconSymbolDiffstatFill } from '@pierre/icons';
+import { memo, useEffect } from 'react';
+
+import type { CodeViewDiffStats as CodeViewDiffStatsData } from './types';
+import { StatItem, StatusRow } from './WorkerPoolStatus';
+
+interface CodeViewDiffStatsProps {
+ expanded: boolean;
+ onToggle(): void;
+ stats: CodeViewDiffStatsData | null;
+ streaming: boolean;
+}
+
+export const CodeViewDiffStats = memo(function CodeViewDiffStats({
+ expanded,
+ onToggle,
+ stats,
+ streaming,
+}: CodeViewDiffStatsProps) {
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'F2') {
+ event.preventDefault();
+ onToggle();
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [onToggle]);
+
+ if (stats == null) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ Diff Stats
+
+ (F2)
+
+ {streaming && }
+
+
+ {expanded && (
+
+
+
+
+
+
+ )}
+ >
+ );
+});
+
+function StreamingIndicator() {
+ return (
+
+ streaming
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx
new file mode 100644
index 000000000..a653622f3
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx
@@ -0,0 +1,159 @@
+'use client';
+
+import { useStableCallback } from '@pierre/diffs/react';
+import darkSoftTheme from '@pierre/theme/pierre-dark-soft';
+import lightSoftTheme from '@pierre/theme/pierre-light-soft';
+import type {
+ FileTreeBatchOperation,
+ FileTree as FileTreeModel,
+} from '@pierre/trees';
+import { themeToTreeStyles } from '@pierre/trees';
+import { FileTree, useFileTree } from '@pierre/trees/react';
+import {
+ type CSSProperties,
+ memo,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import type { FileTreePublicId } from '../../../../../../packages/trees/dist/model/publicTypes';
+import {
+ BASE_FILE_TREE_OPTIONS,
+ CODE_VIEW_FILE_TREE_ITEM_HEIGHT,
+ getInitialBatchSize,
+} from './constants';
+import type { CodeViewFileTreeSource } from './types';
+import { useTheme } from '@/components/theme-provider';
+
+// Computed once at module level so they're never re-derived on every render.
+const LIGHT_SOFT_TREE_STYLES = themeToTreeStyles(lightSoftTheme);
+const DARK_SOFT_TREE_STYLES = themeToTreeStyles(darkSoftTheme);
+
+// These override vars take precedence over the --trees-theme-* vars set by
+// themeToTreeStyles, so diffshub-specific layout tweaks are always preserved.
+const DENSITY_OVERRIDE_STYLES = {
+ '--trees-bg-override': 'var(--diffshub-sidebar-bg)',
+ '--trees-density-override': 0.8,
+ '--trees-selected-fg-override': 'light-dark(#1c1c1e, #f0f0f2)',
+ '--trees-padding-inline-override': 8,
+ '--trees-bg-muted': 'light-dark(#f5f5f5, #262626)',
+ '--trees-search-bg-override': 'light-dark(#fff, #262626)',
+ '--trees-git-renamed-color-override': 'light-dark(#007aff, #007aff)',
+} as CSSProperties;
+
+interface CodeViewFileTreeProps {
+ // Callback invoked with the underlying tree model once it's mounted, and
+ // again with `null` on unmount. Lets parents drive imperative APIs like
+ // search open/close without owning the model creation.
+ onModelReady(model: FileTreeModel | null): void;
+ onSelectItem(itemId: string): void;
+ source: CodeViewFileTreeSource;
+}
+
+export const CodeViewFileTree = memo(function CodeViewFileTree({
+ onModelReady,
+ onSelectItem,
+ source,
+}: CodeViewFileTreeProps) {
+ const { resolvedTheme } = useTheme();
+ const themeStyles = useMemo(
+ () => ({
+ ...(resolvedTheme === 'dark'
+ ? DARK_SOFT_TREE_STYLES
+ : LIGHT_SOFT_TREE_STYLES),
+ ...DENSITY_OVERRIDE_STYLES,
+ }),
+ [resolvedTheme]
+ );
+ const sourceRef = useRef(source);
+ const previousSourceRef = useRef(source);
+ const [initialVisibleRowCount] = useState(getInitialBatchSize);
+ sourceRef.current = source;
+ // `source.paths` aliases the streaming accumulator's live array, so it keeps
+ // growing on later publishes. The FileTree model consumes its path list
+ // exactly once via useFileTree's useState initializer; capture a bounded
+ // snapshot here so the first model build uses only what `pathCount`
+ // describes and so subsequent streaming re-renders don't re-slice the
+ // ever-growing live array.
+ const initialPathsRef = useRef(null);
+ initialPathsRef.current ??= source.paths.slice(0, source.pathCount);
+ const sort = useStableCallback(
+ (left, right) => sourceRef.current.sort(left, right)
+ );
+ const onSelectionChange = useStableCallback(
+ (selectedPaths: readonly FileTreePublicId[]) => {
+ if (selectedPaths.length !== 1 || onSelectItem == null) {
+ return;
+ }
+ const [path] = selectedPaths;
+ const itemId = sourceRef.current.pathToItemId.get(path);
+ if (itemId != null) {
+ onSelectItem(itemId);
+ }
+ }
+ );
+
+ const { model } = useFileTree({
+ ...BASE_FILE_TREE_OPTIONS,
+ gitStatus: source.gitStatus,
+ paths: initialPathsRef.current,
+ sort,
+ onSelectionChange,
+ itemHeight: CODE_VIEW_FILE_TREE_ITEM_HEIGHT,
+ initialVisibleRowCount,
+ });
+
+ useEffect(() => {
+ const previousSource = previousSourceRef.current;
+ if (previousSource === source) {
+ return;
+ }
+
+ previousSourceRef.current = source;
+ // The streaming patch loader links each tree-source snapshot to the prior
+ // one through `previousSource`. When the link matches what this component
+ // last applied, the new paths array is guaranteed to extend the previous
+ // one, so we apply the delta as add() operations instead of asking the
+ // model to throw itself away and rebuild against the full path list. This
+ // turns tree publishes from O(N) each (where N is the total accumulated
+ // path count) into O(delta), which keeps the Diff Stats counter fast as
+ // more files stream in.
+ //
+ // Both snapshots alias the live accumulator's paths array, so we read the
+ // delta bounds from each snapshot's captured `pathCount` instead of the
+ // shared array's current length.
+ if (
+ source.previousSource != null &&
+ source.previousSource === previousSource
+ ) {
+ const previousPathCount = previousSource.pathCount;
+ if (source.pathCount > previousPathCount) {
+ const operations: FileTreeBatchOperation[] = [];
+ for (let index = previousPathCount; index < source.pathCount; index++) {
+ operations.push({ type: 'add', path: source.paths[index] });
+ }
+ if (operations.length > 0) {
+ model.batch(operations);
+ }
+ }
+ } else {
+ model.resetPaths(source.paths.slice(0, source.pathCount));
+ }
+ model.setGitStatus(source.gitStatus);
+ }, [model, source]);
+
+ useEffect(() => {
+ onModelReady(model);
+ return () => onModelReady(null);
+ }, [model, onModelReady]);
+
+ return (
+
+ );
+});
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewHeader.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewHeader.tsx
new file mode 100644
index 000000000..dc7f47af3
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewHeader.tsx
@@ -0,0 +1,222 @@
+import type { DiffIndicators } from '@pierre/diffs';
+import {
+ IconCodeStyleBars,
+ IconDiffSplit,
+ IconDiffUnified,
+ IconEyeSlash,
+ IconFileTreeFill,
+ IconGearFill,
+ IconShare,
+ IconSymbolDiffstat,
+} from '@pierre/icons';
+import Link from 'next/link';
+import { type Dispatch, memo, type SetStateAction, useState } from 'react';
+
+import { DiffUrlForm } from '../../_components/DiffUrlForm';
+import { DiffsHubLogo } from './DiffsHubLogo';
+import { Button } from '@/components/ui/button';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Switch } from '@/components/ui/switch';
+import { cn } from '@/lib/utils';
+
+const SETTING_ROW_CLASS =
+ 'w-full flex cursor-pointer items-center justify-between gap-4 px-2 py-1.5 text-sm';
+
+interface HeaderProps {
+ className?: string;
+ diffIndicators: DiffIndicators;
+ diffStyle: 'split' | 'unified';
+ fileTreeAvailable: boolean;
+ fileTreeOverlayOpen: boolean;
+ initialUrl: string;
+ lineNumbers: boolean;
+ overflow: 'wrap' | 'scroll';
+ onToggleFileTreeOverlay(): void;
+ setDiffIndicators: Dispatch>;
+ setDiffStyle: Dispatch>;
+ setLineNumbers: Dispatch>;
+ setOverflow: Dispatch>;
+ setShowBackgrounds: Dispatch>;
+ showBackgrounds: boolean;
+}
+
+export const CodeViewHeader = memo(function CodeViewHeader({
+ className,
+ diffIndicators,
+ diffStyle,
+ fileTreeAvailable,
+ fileTreeOverlayOpen,
+ initialUrl,
+ lineNumbers,
+ overflow,
+ onToggleFileTreeOverlay,
+ setDiffIndicators,
+ setDiffStyle,
+ setLineNumbers,
+ setOverflow,
+ setShowBackgrounds,
+ showBackgrounds,
+}: HeaderProps) {
+ const [currentUrl, setCurrentUrl] = useState(initialUrl);
+ // Only show the external-link button when the input still reflects the
+ // committed URL ā otherwise we'd be pointing at a draft the user is editing.
+ const showExternalLink = currentUrl === initialUrl;
+ return (
+
+
+
+
+
+
+
+
+
+
+ {showExternalLink && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ setDiffStyle(diffStyle === 'split' ? 'unified' : 'split')
+ }
+ >
+ {diffStyle === 'split' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ e.preventDefault()}
+ >
+
+ Backgrounds
+
+
+
+ e.preventDefault()}
+ >
+
+ Line numbers
+
+
+
+ e.preventDefault()}
+ >
+
+ Word wrap
+
+ setOverflow(checked ? 'wrap' : 'scroll')
+ }
+ />
+
+
+ e.preventDefault()}
+ >
+ Indicator style
+
+ setDiffIndicators(value as DiffIndicators)
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx
new file mode 100644
index 000000000..eeb45481d
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx
@@ -0,0 +1,472 @@
+'use client';
+
+import {
+ IconComment,
+ IconFileTree,
+ IconFilter,
+ IconSearch,
+ IconXSquircle,
+} from '@pierre/icons';
+import { FileTree } from '@pierre/trees';
+import type { GitStatus } from '@pierre/trees';
+import { useFileTreeSearch } from '@pierre/trees/react';
+import {
+ memo,
+ type ReactNode,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { CodeViewCommentsList } from './CodeViewCommentsList';
+import { CodeViewDiffStats } from './CodeViewDiffStats';
+import { CodeViewFileTree } from './CodeViewFileTree';
+import type {
+ CodeViewDiffStats as CodeViewDiffStatsData,
+ CodeViewFileTreeSource,
+ CodeViewSavedCommentEntry,
+ CodeViewSavedCommentItem,
+} from './types';
+import {
+ filterCodeViewFileTreeSource,
+ getCodeViewFileTreeAvailableStatuses,
+} from './utils';
+import { WorkerPoolStatus } from './WorkerPoolStatus';
+import { Button } from '@/components/ui/button';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+
+type SidebarTab = 'files' | 'comments';
+type SidebarStatusPanel = 'diffStats' | 'systemMonitor';
+
+const MOBILE_MEDIA_QUERY = '(max-width: 767px)';
+
+interface CodeViewSidebarProps {
+ className?: string;
+ commentSections: readonly CodeViewSavedCommentItem[];
+ diffStats: CodeViewDiffStatsData | null;
+ mobileOverlayOpen?: boolean;
+ onMobileClose(): void;
+ onSelectComment(comment: CodeViewSavedCommentEntry): void;
+ onSelectItem(itemId: string): void;
+ scrollRef: RefObject;
+ source: CodeViewFileTreeSource;
+ streaming: boolean;
+}
+
+export const CodeViewSidebar = memo(function CodeViewSidebar({
+ className,
+ commentSections,
+ diffStats,
+ mobileOverlayOpen = false,
+ onMobileClose,
+ onSelectComment,
+ onSelectItem,
+ scrollRef,
+ source,
+ streaming,
+}: CodeViewSidebarProps) {
+ const [activeTab, setActiveTab] = useState('files');
+ let totalCommentCount = 0;
+ for (const section of commentSections) {
+ totalCommentCount += section.comments.length;
+ }
+ const [activeStatusPanel, setActiveStatusPanel] =
+ useState('diffStats');
+ const [fileTreeModel, setFileTreeModel] = useState(null);
+ const [excludedStatuses, setExcludedStatuses] = useState<
+ ReadonlySet
+ >(() => new Set());
+ const availableStatuses = useMemo(
+ () => getCodeViewFileTreeAvailableStatuses(source),
+ [source]
+ );
+ const filteredSource = useMemo(
+ () => filterCodeViewFileTreeSource(source, excludedStatuses),
+ [source, excludedStatuses]
+ );
+ const handleModelReady = useCallback((model: FileTree | null) => {
+ setFileTreeModel(model);
+ }, []);
+ const toggleStatusPanel = useCallback((panel: SidebarStatusPanel) => {
+ setActiveStatusPanel((current) => (current === panel ? null : panel));
+ }, []);
+
+ const clearStatusFilter = useCallback(() => {
+ setExcludedStatuses(new Set());
+ }, []);
+
+ const toggleExcludedStatus = useCallback((status: GitStatus) => {
+ setExcludedStatuses((prev) => {
+ const next = new Set(prev);
+ if (next.has(status)) {
+ next.delete(status);
+ } else {
+ next.add(status);
+ }
+ return next;
+ });
+ }, []);
+
+ // Alt+click "isolate": exclude everything except the clicked status.
+ // If that status is already the only visible one, clear the filter instead.
+ const isolateStatus = useCallback(
+ (status: GitStatus) => {
+ setExcludedStatuses((prev) => {
+ const visible = [...availableStatuses].filter((s) => !prev.has(s));
+ if (visible.length === 1 && visible[0] === status) {
+ return new Set();
+ }
+ return new Set([...availableStatuses].filter((s) => s !== status));
+ });
+ },
+ [availableStatuses]
+ );
+
+ useEffect(() => {
+ if (mobileOverlayOpen && window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
+ setActiveStatusPanel(null);
+ }
+ }, [mobileOverlayOpen]);
+
+ useEffect(() => {
+ if (!mobileOverlayOpen || !window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
+ return undefined;
+ }
+
+ const { body, documentElement } = document;
+ const codeViewScroll = scrollRef.current;
+ const previousBodyOverflow = body.style.overflow;
+ const previousRootOverscrollBehavior =
+ documentElement.style.overscrollBehavior;
+ const previousCodeViewOverflow = codeViewScroll?.style.overflow;
+
+ body.style.overflow = 'hidden';
+ documentElement.style.overscrollBehavior = 'none';
+ if (codeViewScroll != null) {
+ codeViewScroll.style.overflow = 'hidden';
+ }
+
+ return () => {
+ body.style.overflow = previousBodyOverflow;
+ documentElement.style.overscrollBehavior = previousRootOverscrollBehavior;
+ if (codeViewScroll != null) {
+ codeViewScroll.style.overflow = previousCodeViewOverflow ?? '';
+ }
+ };
+ }, [mobileOverlayOpen, scrollRef]);
+
+ return (
+ <>
+
+
+
+ setActiveTab(value as SidebarTab)}
+ >
+
+
+ Files
+
+ 0 && 'w-auto gap-1 pr-1'
+ )}
+ >
+
+ Comments
+ {totalCommentCount > 0 && (
+
+ {totalCommentCount}
+
+ )}
+
+
+ {activeTab === 'files' && fileTreeModel != null && (
+
+ )}
+ {activeTab === 'files' && availableStatuses.size > 1 && (
+
+ )}
+ {onMobileClose != null && (
+
+
+
+ )}
+
+
+ toggleStatusPanel('diffStats')}
+ stats={diffStats}
+ streaming={streaming}
+ />
+ toggleStatusPanel('systemMonitor')}
+ scrollRef={scrollRef}
+ />
+
+ >
+ );
+});
+
+interface SidebarWrapperProps {
+ children: ReactNode;
+ className?: string;
+ mobileOverlayOpen: boolean;
+}
+
+function SidebarWrapper({
+ children,
+ className,
+ mobileOverlayOpen,
+}: SidebarWrapperProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Statuses that can appear in a diff, in the order they should appear in the
+// filter dropdown. Colors mirror the exact light-dark() values from the tree's
+// style.css so the badges match what the tree rows show.
+const DIFF_STATUS_ITEMS: {
+ status: GitStatus;
+ label: string;
+ short: string;
+ color: string;
+}[] = [
+ {
+ status: 'added',
+ label: 'Added',
+ short: 'A',
+ color: 'light-dark(#16a994, #00cab1)',
+ },
+ {
+ status: 'modified',
+ label: 'Modified',
+ short: 'M',
+ color: 'light-dark(#1ca1c7, #08c0ef)',
+ },
+ {
+ status: 'renamed',
+ label: 'Renamed',
+ short: 'R',
+ color: 'light-dark(#d5a910, #ffd452)',
+ },
+ {
+ status: 'deleted',
+ label: 'Deleted',
+ short: 'D',
+ color: 'light-dark(#ff2e3f, #ff6762)',
+ },
+];
+
+interface FileTreeFilterButtonProps {
+ availableStatuses: ReadonlySet;
+ excludedStatuses: ReadonlySet;
+ onClear(): void;
+ onIsolate(status: GitStatus): void;
+ onToggle(status: GitStatus): void;
+}
+
+function FileTreeFilterButton({
+ availableStatuses,
+ excludedStatuses,
+ onClear,
+ onIsolate,
+ onToggle,
+}: FileTreeFilterButtonProps) {
+ const isFiltered = excludedStatuses.size > 0;
+ const visibleItems = DIFF_STATUS_ITEMS.filter(({ status }) =>
+ availableStatuses.has(status)
+ );
+ const [isMac] = useState(
+ () => typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)
+ );
+ // Track whether Alt was held on the most recent pointer-down so the
+ // onCheckedChange handler (which receives no event) can branch on it.
+ const altKeyRef = useRef(false);
+ return (
+
+
+
+
+ {isFiltered && (
+
+ )}
+
+
+
+
+ Filter by Git status
+
+ {isMac ? 'Option' : 'Alt'}-click to filter a single status
+
+
+
+ {visibleItems.map(({ status, label, short, color }) => (
+ {
+ altKeyRef.current = e.altKey;
+ }}
+ onSelect={(e) => e.preventDefault()}
+ onCheckedChange={() => {
+ if (altKeyRef.current) {
+ onIsolate(status);
+ } else {
+ onToggle(status);
+ }
+ }}
+ className={`${!excludedStatuses.has(status) ? '' : 'text-muted-foreground'} px-2`}
+ >
+
+ {short}
+
+ {label}
+
+ ))}
+
+
+
+ Clear filter
+
+
+
+ );
+}
+
+// Lives in its own component so we can call useFileTreeSearch only once we
+// actually have a model; conditional hook calls aren't allowed in the parent.
+function FileTreeSearchToggle({ model }: { model: FileTree }) {
+ const search = useFileTreeSearch(model);
+ return (
+ event.preventDefault()}
+ onClick={() => {
+ if (search.isOpen) {
+ search.close();
+ } else {
+ search.open();
+ }
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx
new file mode 100644
index 000000000..0a51cf02d
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx
@@ -0,0 +1,60 @@
+import { IconCiWarningFill, IconRefresh } from '@pierre/icons';
+
+import type { ViewerLoadState } from './types';
+import { Button } from '@/components/ui/button';
+
+interface CodeViewStatusPanelProps {
+ errorMessage: string | null;
+ onRetry(): void;
+ state: ViewerLoadState;
+}
+
+export function CodeViewStatusPanel({
+ errorMessage,
+ onRetry,
+ state,
+}: CodeViewStatusPanelProps) {
+ const isError = state === 'error';
+ const title = isError
+ ? 'Couldnāt load diff'
+ : state === 'parsing'
+ ? 'Preparing diff'
+ : state === 'fetching'
+ ? 'Fetching diff'
+ : 'Streaming diff';
+
+ const message = isError
+ ? (errorMessage ?? 'Failed to fetch the diff, please try again.')
+ : state === 'parsing'
+ ? 'Parsing the patch and building the file treeā¦'
+ : state === 'fetching'
+ ? 'Fetching the patch from GitHubā¦'
+ : 'Reading the patch and showing files as they arriveā¦';
+
+ return (
+
+
+ {!isError ? (
+
+ ) : (
+
+ )}
+ {title}
+ {message}
+ {isError && (
+
+ Try again
+
+ )}
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx
new file mode 100644
index 000000000..acdb173d7
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx
@@ -0,0 +1,507 @@
+import {
+ areSelectionsEqual,
+ type CodeViewDiffItem,
+ type CodeViewItem,
+ type CodeViewLineSelection,
+ type CodeViewOptions,
+ type DiffIndicators,
+ type DiffLineAnnotation,
+ type LineAnnotation,
+ type SelectedLineRange,
+} from '@pierre/diffs';
+import {
+ CodeView,
+ type CodeViewHandle,
+ useStableCallback,
+} from '@pierre/diffs/react';
+import { IconChevronSm } from '@pierre/icons';
+import { memo, type RefObject, useMemo, useRef, useState } from 'react';
+
+import type { AvatarName } from './annotation-shared';
+import { CODE_VIEW_CUSTOM_CSS, CODE_VIEW_LAYOUT } from './constants';
+import { DraftAnnotation } from './DraftAnnotation';
+import { ExampleAnnotation } from './ExampleAnnotation';
+import type {
+ CodeViewDeletedCommentEvent,
+ CodeViewSavedCommentEvent,
+ CommentMetadata,
+} from './types';
+import {
+ classifyCommentLineType,
+ isDiffItem,
+ isDraftAnnotation,
+ isDraftMetadata,
+ isSavedAnnotation,
+} from './utils';
+import { cn } from '@/lib/utils';
+
+function getNextItemVersion(item: CodeViewItem): number {
+ return typeof item.version === 'number' ? item.version + 1 : 1;
+}
+
+function updateViewerDiffItem(
+ viewer: CodeViewHandle,
+ itemId: string,
+ updateItem: (item: CodeViewDiffItem) => boolean
+): CodeViewDiffItem | undefined {
+ const item = viewer.getItem(itemId);
+ if (item == null || !isDiffItem(item)) {
+ return undefined;
+ }
+
+ if (!updateItem(item)) {
+ return undefined;
+ }
+
+ item.version = getNextItemVersion(item);
+ return viewer.updateItem(item) ? item : undefined;
+}
+
+interface ActiveDraftComment {
+ itemId: string;
+ key: string;
+}
+
+interface CodeViewWrapperProps {
+ className?: string;
+ diffStyle: 'split' | 'unified';
+ onCommentDeleted(comment: CodeViewDeletedCommentEvent): void;
+ onCommentSaved(comment: CodeViewSavedCommentEvent): void;
+ overflow: 'wrap' | 'scroll';
+ showBackgrounds: boolean;
+ diffIndicators: DiffIndicators;
+ lineNumbers: boolean;
+ scrollRef: RefObject;
+ viewerRef: RefObject | null>;
+ initialItems: CodeViewItem[];
+ onLineLinkChange(selection: CodeViewLineSelection | null): void;
+ onViewerReady(): void;
+}
+
+export const CodeViewWrapper = memo(function CodeViewWrapper({
+ className,
+ diffStyle,
+ onCommentDeleted,
+ onCommentSaved,
+ overflow,
+ showBackgrounds,
+ diffIndicators,
+ lineNumbers,
+ scrollRef,
+ viewerRef,
+ initialItems,
+ onLineLinkChange,
+ onViewerReady,
+}: CodeViewWrapperProps) {
+ const nextCommentKeyRef = useRef(0);
+ const activeDraftRef = useRef(null);
+ const [selectedLines, setSelectedLines] =
+ useState(null);
+
+ const handleSetSelection = useStableCallback(
+ (selection: CodeViewLineSelection | null) => {
+ setSelectedLines(selection);
+ }
+ );
+
+ const handleToggleCommentSelection = useStableCallback(
+ (selection: CodeViewLineSelection) => {
+ setSelectedLines((prev) =>
+ prev?.id === selection.id &&
+ areSelectionsEqual(prev.range, selection.range)
+ ? null
+ : selection
+ );
+ }
+ );
+
+ const handleLineSelectionEnd = useStableCallback(
+ (range: SelectedLineRange | null, item: CodeViewItem) => {
+ if (range == null || item.type !== 'diff') {
+ onLineLinkChange(null);
+ } else {
+ onLineLinkChange({ id: item.id, range });
+ }
+ }
+ );
+
+ const handleViewerRef = useStableCallback(
+ (viewer: CodeViewHandle | null) => {
+ viewerRef.current = viewer;
+ if (viewer != null) {
+ onViewerReady();
+ }
+ }
+ );
+
+ const handleCreateDraftComment = useStableCallback(
+ (range: SelectedLineRange, itemId: string) => {
+ const side = range.endSide ?? range.side;
+ if (side == null) {
+ return;
+ }
+
+ const lineNumber = range.end;
+ const commentKey = `draft-${nextCommentKeyRef.current++}`;
+ const { current: viewer } = viewerRef;
+ if (viewer == null) {
+ return;
+ }
+
+ const draftAnnotation: DiffLineAnnotation = {
+ side,
+ lineNumber,
+ metadata: {
+ kind: 'draft',
+ key: commentKey,
+ message: '',
+ range,
+ },
+ };
+
+ const { current: activeDraft } = activeDraftRef;
+ if (activeDraft != null && activeDraft.itemId !== itemId) {
+ updateViewerDiffItem(viewer, activeDraft.itemId, (item) => {
+ if (item.annotations == null) {
+ return false;
+ }
+
+ const nextAnnotations = item.annotations.filter(
+ (annotation) => annotation.metadata.key !== activeDraft.key
+ );
+ if (nextAnnotations.length === item.annotations.length) {
+ return false;
+ }
+
+ item.annotations = nextAnnotations;
+ return true;
+ });
+ }
+
+ const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => {
+ const nonDraftAnnotations = (item.annotations ?? []).filter(
+ (annotation) => !isDraftMetadata(annotation.metadata)
+ );
+ item.annotations = [...nonDraftAnnotations, draftAnnotation];
+ return true;
+ });
+
+ if (updatedItem != null) {
+ activeDraftRef.current = { itemId, key: commentKey };
+ }
+ }
+ );
+
+ const handleRemoveComment = useStableCallback(
+ (itemId: string, key: string) => {
+ const { current: viewer } = viewerRef;
+ if (viewer == null) {
+ return;
+ }
+ const item = viewer.getItem(itemId);
+ const removedAnnotation =
+ item != null && isDiffItem(item)
+ ? item.annotations?.find(
+ (annotation) => annotation.metadata.key === key
+ )
+ : undefined;
+
+ updateViewerDiffItem(viewer, itemId, (item) => {
+ if (item.annotations == null) {
+ return false;
+ }
+
+ const nextAnnotations = item.annotations.filter(
+ (annotation) => annotation.metadata.key !== key
+ );
+
+ if (nextAnnotations.length === item.annotations.length) {
+ return false;
+ }
+
+ item.annotations = nextAnnotations;
+ return true;
+ });
+
+ const { current: activeDraft } = activeDraftRef;
+ if (activeDraft?.itemId === itemId && activeDraft.key === key) {
+ activeDraftRef.current = null;
+ }
+
+ setSelectedLines(null);
+ onLineLinkChange(null);
+ if (removedAnnotation != null && isSavedAnnotation(removedAnnotation)) {
+ onCommentDeleted({ itemId, key });
+ }
+ }
+ );
+
+ const handleSaveDraftComment = useStableCallback(
+ (itemId: string, key: string, message: string, author: AvatarName) => {
+ const trimmedMessage = message.trim();
+ const { current: viewer } = viewerRef;
+ if (trimmedMessage.length === 0 || viewer == null) {
+ return;
+ }
+
+ const item = viewer.getItem(itemId);
+ if (item == null || !isDiffItem(item)) {
+ return;
+ }
+
+ const draftAnnotation = item?.annotations?.find(
+ (annotation) => annotation.metadata.key === key
+ );
+ if (draftAnnotation == null || !isDraftAnnotation(draftAnnotation)) {
+ return;
+ }
+
+ const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => {
+ if (item.annotations == null) {
+ return false;
+ }
+
+ const nextAnnotations: DiffLineAnnotation[] =
+ item.annotations.map((annotation) => {
+ if (
+ annotation.metadata.key !== key ||
+ !isDraftAnnotation(annotation)
+ ) {
+ return annotation;
+ }
+
+ return {
+ ...annotation,
+ metadata: {
+ kind: 'saved',
+ key,
+ author,
+ message: trimmedMessage,
+ range: annotation.metadata.range,
+ },
+ };
+ });
+
+ let didChange = false;
+ for (let index = 0; index < nextAnnotations.length; index++) {
+ if (nextAnnotations[index] !== item.annotations[index]) {
+ didChange = true;
+ break;
+ }
+ }
+
+ if (!didChange) {
+ return false;
+ }
+
+ item.annotations = nextAnnotations;
+ return true;
+ });
+
+ if (updatedItem == null) {
+ return;
+ }
+
+ const { current: activeDraft } = activeDraftRef;
+ if (activeDraft?.itemId === itemId && activeDraft.key === key) {
+ activeDraftRef.current = null;
+ }
+
+ setSelectedLines(null);
+ onLineLinkChange(null);
+ onCommentSaved({
+ author,
+ itemId,
+ key,
+ lineNumber: draftAnnotation.lineNumber,
+ lineType: classifyCommentLineType(
+ item.fileDiff,
+ draftAnnotation.side,
+ draftAnnotation.lineNumber
+ ),
+ message: trimmedMessage,
+ range: draftAnnotation.metadata.range,
+ side: draftAnnotation.side,
+ });
+ }
+ );
+
+ const handleToggleItemCollapsed = useStableCallback((itemId: string) => {
+ const { current: viewerHandle } = viewerRef;
+ const viewer = viewerHandle?.getInstance();
+ const item = viewerHandle?.getItem(itemId);
+ if (viewerHandle == null || viewer == null || item == null) {
+ return;
+ }
+
+ // NOTE(amadeus): If the top of the item is before the scrollTop, then
+ // we'll want to apply a scroll fix on the next render to ensure we
+ // keep the collapsed file in view and anchored.
+ const itemTop = viewer.getTopForItem(itemId);
+ item.collapsed = item.collapsed !== true;
+ item.version = getNextItemVersion(item);
+ if (!viewerHandle.updateItem(item)) {
+ return;
+ }
+
+ if (itemTop != null && itemTop < viewer.getScrollTop()) {
+ viewer.scrollTo({
+ type: 'item',
+ id: item.id,
+ align: 'start',
+ });
+ }
+ });
+
+ const renderCommentAnnotation = useStableCallback(
+ (
+ annotation:
+ | DiffLineAnnotation
+ | LineAnnotation,
+ item: CodeViewItem
+ ) => {
+ if (!('side' in annotation) || item.type !== 'diff') {
+ return null;
+ }
+
+ if (isDraftAnnotation(annotation)) {
+ return (
+
+ );
+ }
+
+ if (!isSavedAnnotation(annotation)) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+ );
+
+ const renderHeaderPrefix = useStableCallback(
+ (item: CodeViewItem) => {
+ if (item.type !== 'diff') {
+ return null;
+ }
+
+ return (
+ handleToggleItemCollapsed(item.id)}
+ />
+ );
+ }
+ );
+
+ // NOTE(amadeus): For some insane reason, the react compiler did not know how
+ // to properly memoize this, so we pulled it into a `useMemo` for safety...
+ const options: CodeViewOptions = useMemo(
+ () =>
+ ({
+ // Use this to validate itemMetrics when changing layout with unsafeCSS.
+ // __devOnlyValidateItemHeights: true,
+ layout: CODE_VIEW_LAYOUT,
+ theme: { dark: 'pierre-dark-soft', light: 'pierre-light-soft' },
+ diffStyle,
+ diffIndicators,
+ overflow,
+ disableBackground: !showBackgrounds,
+ disableLineNumbers: !lineNumbers,
+ lineHoverHighlight: 'number',
+ // hunkSeparators: 'line-info-basic',
+ enableLineSelection: true,
+ enableGutterUtility: true,
+ stickyHeaders: true,
+ unsafeCSS: CODE_VIEW_CUSTOM_CSS,
+ // FIXME(amadeus): Move all `onX` methods onto the react component maybe?
+ onGutterUtilityClick(range, context) {
+ if (context.item.type !== 'diff') {
+ return;
+ }
+ handleCreateDraftComment(range, context.item.id);
+ },
+ onLineSelectionEnd(range, context) {
+ handleLineSelectionEnd(range, context.item);
+ },
+ }) satisfies CodeViewOptions,
+ [
+ diffIndicators,
+ diffStyle,
+ handleCreateDraftComment,
+ handleLineSelectionEnd,
+ lineNumbers,
+ overflow,
+ showBackgrounds,
+ ]
+ );
+ return (
+
+ ref={handleViewerRef}
+ containerRef={scrollRef}
+ initialItems={initialItems}
+ className={cn(
+ className,
+ 'cv-scrollbar relative h-full min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-clip overscroll-contain border-b border-border w-full [contain:strict] [overflow-anchor:none] [will-change:scroll-position] md:border-b-0 [&_diffs-container]:overflow-clip [&_diffs-container]:[contain:layout_paint_style] [&_diffs-container]:shadow-[0_-1px_0_var(--color-border-opaque),0_1px_0_var(--color-border-opaque)]'
+ )}
+ options={options}
+ selectedLines={selectedLines}
+ onSelectedLinesChange={handleSetSelection}
+ renderAnnotation={renderCommentAnnotation}
+ renderHeaderPrefix={renderHeaderPrefix}
+ />
+ );
+});
+
+interface CollapseDiffButtonProps {
+ disabled?: boolean;
+ collapsed?: boolean;
+ onToggle(): void;
+}
+
+function CollapseDiffButton({
+ disabled = false,
+ collapsed = false,
+ onToggle,
+}: CollapseDiffButtonProps) {
+ return (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ onToggle();
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/DiffsHubLogo.tsx b/apps/docs/app/(diffshub)/(view)/_components/DiffsHubLogo.tsx
new file mode 100644
index 000000000..539da9cc0
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/DiffsHubLogo.tsx
@@ -0,0 +1,19 @@
+import { cn } from '@/lib/utils';
+
+export function DiffsHubLogo({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/DraftAnnotation.tsx b/apps/docs/app/(diffshub)/(view)/_components/DraftAnnotation.tsx
new file mode 100644
index 000000000..396ba80cf
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/DraftAnnotation.tsx
@@ -0,0 +1,126 @@
+import type { DiffLineAnnotation } from '@pierre/diffs';
+import { IconArrowRight } from '@pierre/icons';
+import { useEffect, useRef, useState } from 'react';
+
+import {
+ annotationCardBase,
+ type AvatarName,
+ CommentAuthorAvatar,
+ getRandomPersona,
+} from './annotation-shared';
+import type { DraftCommentMetadata } from './types';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface DraftAnnotationProps {
+ annotation: DiffLineAnnotation;
+ itemId: string;
+ onCancel(itemId: string, key: string): void;
+ onSave(
+ itemId: string,
+ key: string,
+ message: string,
+ author: AvatarName
+ ): void;
+}
+
+export function DraftAnnotation({
+ annotation,
+ itemId,
+ onCancel,
+ onSave,
+}: DraftAnnotationProps) {
+ const [message, setMessage] = useState(annotation.metadata.message);
+ const [persona] = useState(getRandomPersona);
+ const textareaRef = useRef(null);
+ const trimmedMessage = message.trim();
+
+ function handleSave() {
+ if (trimmedMessage.length === 0) {
+ return;
+ }
+ onSave(itemId, annotation.metadata.key, trimmedMessage, persona.name);
+ }
+
+ function tryCancel() {
+ if (trimmedMessage.length > 0 && !window.confirm('Discard this comment?')) {
+ return;
+ }
+ onCancel(itemId, annotation.metadata.key);
+ }
+
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (textarea == null) {
+ return;
+ }
+
+ textarea.focus({ preventScroll: true });
+ const cursorIndex = textarea.value.length;
+ textarea.setSelectionRange(cursorIndex, cursorIndex);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/ExampleAnnotation.tsx b/apps/docs/app/(diffshub)/(view)/_components/ExampleAnnotation.tsx
new file mode 100644
index 000000000..91f4f5d73
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/ExampleAnnotation.tsx
@@ -0,0 +1,64 @@
+import type { CodeViewLineSelection, DiffLineAnnotation } from '@pierre/diffs';
+import { IconX } from '@pierre/icons';
+import { memo } from 'react';
+
+import { annotationCardBase, CommentAuthorAvatar } from './annotation-shared';
+import type { SavedCommentMetadata } from './types';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface ExampleAnnotationProps {
+ annotation: DiffLineAnnotation;
+ itemId: string;
+ onDelete(itemId: string, key: string): void;
+ onToggleSelection(selection: CodeViewLineSelection): void;
+}
+
+export const ExampleAnnotation = memo(function ExampleAnnotation({
+ annotation,
+ itemId,
+ onDelete,
+ onToggleSelection,
+}: ExampleAnnotationProps) {
+ const selection = { id: itemId, range: annotation.metadata.range };
+ return (
+ onToggleSelection(selection)}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return;
+ }
+ event.preventDefault();
+ onToggleSelection(selection);
+ }}
+ >
+
+
{
+ event.stopPropagation();
+ onDelete(itemId, annotation.metadata.key);
+ }}
+ className="pointer-events-none absolute top-0 right-0 z-1 inline-flex translate-x-[35%] -translate-y-[35%] cursor-pointer items-center justify-center rounded-full bg-neutral-500 opacity-0 shadow-[inherit] transition-opacity duration-100 group-hover:pointer-events-auto group-hover:opacity-100"
+ >
+
+
+
+
+ {annotation.metadata.author}
+
+
+ {annotation.metadata.message}
+
+
+
+ );
+});
diff --git a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx
new file mode 100644
index 000000000..51041eeed
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx
@@ -0,0 +1,230 @@
+'use client';
+
+import { type DiffIndicators } from '@pierre/diffs';
+import { type CodeViewHandle, useWorkerPool } from '@pierre/diffs/react';
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import { preloadAvatars } from './annotation-shared';
+import { CodeViewHeader } from './CodeViewHeader';
+import { CodeViewSidebar } from './CodeViewSidebar';
+import { CodeViewStatusPanel } from './CodeViewStatusPanel';
+import { CodeViewWrapper } from './CodeViewWrapper';
+import type {
+ CodeViewDeletedCommentEvent,
+ CodeViewSavedCommentEntry,
+ CodeViewSavedCommentEvent,
+ CommentMetadata,
+} from './types';
+import { usePatchLoader } from './usePatchLoader';
+import {
+ removeSavedCommentSidebarEntry,
+ upsertSavedCommentSidebarEntry,
+} from './utils';
+
+interface ReviewUIProps {
+ domain?: string;
+ initialUrl: string;
+ path: string;
+}
+
+export function ReviewUI({ domain, initialUrl, path }: ReviewUIProps) {
+ useEffect(preloadAvatars, []);
+
+ const isWorkerPoolReadyOrDisable = useIsWorkerPoolReadyOrDisabled();
+ const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split');
+ const [fileTreeOverlayOpen, setFileTreeOverlayOpen] = useState(false);
+ const [overflow, setOverflow] = useState<'wrap' | 'scroll'>('scroll');
+ const [showBackgrounds, setShowBackgrounds] = useState(true);
+ const [diffIndicators, setDiffIndicators] = useState('bars');
+ const [lineNumbers, setLineNumbers] = useState(true);
+ const scrollRef = useRef(null);
+ const viewerRef = useRef | null>(null);
+ const handlePatchLoadStart = useCallback(() => {
+ setFileTreeOverlayOpen(false);
+ }, []);
+ const {
+ commentFileByItemId,
+ commentSections,
+ diffStats,
+ errorMessage,
+ initialItems,
+ loadState,
+ onLineLinkChange,
+ onViewerReady,
+ retryLoad,
+ setCommentSections,
+ treeSource,
+ viewerKey,
+ } = usePatchLoader({
+ domain,
+ onLoadStart: handlePatchLoadStart,
+ path,
+ viewerRef,
+ });
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(max-width: 767px)');
+ const updateMobileState = (matches: boolean) => {
+ setDiffStyle(matches ? 'unified' : 'split');
+ if (!matches) setFileTreeOverlayOpen(false);
+ };
+ const handleChange = (event: MediaQueryListEvent) => {
+ updateMobileState(event.matches);
+ };
+
+ updateMobileState(mediaQuery.matches);
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, []);
+ const handleSelectTreeItem = useCallback((itemId: string) => {
+ setFileTreeOverlayOpen(false);
+ viewerRef.current?.scrollTo({
+ type: 'item',
+ id: itemId,
+ align: 'start',
+ behavior: 'smooth',
+ });
+ }, []);
+ const handleCommentSaved = useCallback(
+ (comment: CodeViewSavedCommentEvent) => {
+ setCommentSections((prev) =>
+ upsertSavedCommentSidebarEntry(prev, commentFileByItemId, comment)
+ );
+ },
+ [commentFileByItemId, setCommentSections]
+ );
+ const handleCommentDeleted = useCallback(
+ (comment: CodeViewDeletedCommentEvent) => {
+ setCommentSections((prev) =>
+ removeSavedCommentSidebarEntry(prev, comment)
+ );
+ },
+ [setCommentSections]
+ );
+ const handleToggleFileTreeOverlay = useCallback(() => {
+ setFileTreeOverlayOpen((open) => !open);
+ }, []);
+ const handleCloseFileTreeOverlay = useCallback(() => {
+ setFileTreeOverlayOpen(false);
+ }, []);
+ const handleSelectComment = useCallback(
+ (comment: CodeViewSavedCommentEntry) => {
+ setFileTreeOverlayOpen(false);
+ viewerRef.current?.setSelectedLines({
+ id: comment.itemId,
+ range: comment.range,
+ });
+ viewerRef.current?.scrollTo({
+ type: 'line',
+ id: comment.itemId,
+ lineNumber: comment.range.end,
+ side: comment.range.endSide ?? comment.range.side,
+ align: 'center',
+ behavior: 'smooth-auto',
+ });
+ },
+ []
+ );
+ const viewerAvailable =
+ isWorkerPoolReadyOrDisable &&
+ (loadState === 'ready' ||
+ (loadState === 'streaming' && initialItems.length > 0));
+
+ return (
+
+
+ {viewerAvailable && treeSource != null ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+function useIsWorkerPoolReadyOrDisabled() {
+ const workerPool = useWorkerPool();
+ const [isReady, setIsReady] = useState(
+ () => workerPool?.isInitialized() ?? true
+ );
+ const isReadyRef = useRef(isReady);
+ useEffect(() => {
+ // The callback will always be fired immediately with the new state, so we
+ // don't need to check for it in the effect
+ return workerPool?.subscribeToStatChanges((stats) => {
+ const isReady = stats.managerState === 'initialized';
+ if (isReady !== isReadyRef.current) {
+ setIsReady(isReady);
+ isReadyRef.current = isReady;
+ }
+ });
+ }, [workerPool]);
+ return isReady;
+}
+
+interface ReviewGridProps {
+ children: ReactNode;
+}
+
+function ReviewGrid({ children }: ReviewGridProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/WorkerPoolStatus.tsx b/apps/docs/app/(diffshub)/(view)/_components/WorkerPoolStatus.tsx
new file mode 100644
index 000000000..05fcea7af
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/WorkerPoolStatus.tsx
@@ -0,0 +1,313 @@
+'use client';
+
+import {
+ areWorkerStatsEqual,
+ DEFAULT_CODE_VIEW_FILE_METRICS,
+ queueRender,
+} from '@pierre/diffs';
+import { useWorkerPool } from '@pierre/diffs/react';
+import type { WorkerStats } from '@pierre/diffs/worker';
+import {
+ IconCircleFill,
+ IconEye,
+ IconEyeSlash,
+ IconInfoFill,
+ IconSquircleLgFill,
+ IconTriangleFill,
+} from '@pierre/icons';
+import Link from 'next/link';
+import {
+ type ComponentType,
+ memo,
+ type ReactNode,
+ type RefObject,
+ useEffect,
+ useState,
+} from 'react';
+
+import { cn } from '@/lib/utils';
+
+const NUMBER_FORMATTER = new Intl.NumberFormat('en-US');
+
+class AutoScrollTester {
+ private running: 0 | 1 | 2 = 0;
+ private direction = 1;
+
+ constructor(
+ private scrollRef: RefObject,
+ private onStateChange?: (running: boolean) => unknown
+ ) {}
+
+ start() {
+ if (this.running > 0) return;
+ this.running = 1;
+ this.onStateChange?.(true);
+ this.render();
+ }
+
+ render = () => {
+ if (this.running === 0 || this.scrollRef.current == null) {
+ return;
+ }
+ const { scrollHeight, scrollTop, clientHeight } = this.scrollRef.current;
+
+ // The first scroll tick should always attempt to scroll
+ if (this.running === 1) {
+ this.running = 2;
+ }
+ // If we're scrolling and we hit a boundary, lets stop, and invert the
+ // direction, so next click will scroll us the other direction
+ else if (
+ this.running === 2 &&
+ (scrollTop <= 0 || scrollTop >= scrollHeight - clientHeight)
+ ) {
+ this.direction *= -1;
+ this.stop();
+ return;
+ }
+ this.scrollRef.current.scrollTo({
+ top:
+ scrollTop +
+ clientHeight * 2 * this.direction +
+ Math.random() * DEFAULT_CODE_VIEW_FILE_METRICS.lineHeight,
+ });
+ queueRender(this.render);
+ };
+
+ stop() {
+ this.running = 0;
+ this.onStateChange?.(false);
+ }
+
+ toggleState = () => {
+ if (this.running > 0) {
+ this.stop();
+ } else {
+ this.start();
+ }
+ };
+}
+
+interface WorkerPoolStatusProps {
+ expanded: boolean;
+ onToggle(): void;
+ scrollRef: RefObject;
+}
+
+export const WorkerPoolStatus = memo(function WorkerPoolStatus({
+ expanded,
+ onToggle,
+ scrollRef,
+}: WorkerPoolStatusProps) {
+ const pool = useWorkerPool();
+ const [stats, setStats] = useState(undefined);
+ useEffect(() => {
+ if (pool == null) {
+ setStats(undefined);
+ return undefined;
+ } else {
+ return pool.subscribeToStatChanges((newStats) => {
+ setStats((prevStats): WorkerStats | undefined => {
+ if (areWorkerStatsEqual(prevStats, newStats)) {
+ return prevStats;
+ }
+ return newStats;
+ });
+ });
+ }
+ }, [pool]);
+ return (
+ stats != null && (
+
+ )
+ );
+});
+
+export interface StatItemProps {
+ label: string;
+ value: string | number;
+ valueClassName?: string;
+}
+
+export function StatItem({ label, value, valueClassName }: StatItemProps) {
+ const isZero = value === 0 || value === '0';
+ const formatted =
+ typeof value === 'number' ? NUMBER_FORMATTER.format(value) : value;
+ return (
+
+
{label}
+
+ {formatted}
+
+
+ );
+}
+
+interface StatsDisplayProps {
+ expanded: boolean;
+ onToggle(): void;
+ stats: WorkerStats;
+ scrollRef: RefObject;
+}
+
+// Map worker pool status to a single icon component + color so the legend row
+// and the status indicator share one source of truth.
+function getStatusIcon(stats: WorkerStats) {
+ if (stats.workersFailed) {
+ return { Icon: IconSquircleLgFill, className: 'text-red-400' };
+ }
+ if (stats.managerState === 'initializing') {
+ return { Icon: IconTriangleFill, className: 'text-amber-400' };
+ }
+ if (stats.managerState === 'initialized') {
+ return { Icon: IconCircleFill, className: 'text-green-400' };
+ }
+ return { Icon: IconCircleFill, className: 'text-muted-foreground' };
+}
+
+export interface StatusRowProps {
+ icon: ComponentType<{ className?: string }>;
+ children: ReactNode;
+ className?: string;
+}
+
+export function StatusRow({ icon: Icon, children, className }: StatusRowProps) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function StatsDisplay({
+ expanded,
+ onToggle,
+ stats,
+ scrollRef,
+}: StatsDisplayProps) {
+ const [isBrrt, setIsBrrt] = useState(false);
+ const [scrollTester] = useState(
+ () => new AutoScrollTester(scrollRef, setIsBrrt)
+ );
+
+ // Mirror the inline (F3) hint with an actual keybinding so the label
+ // doesn't lie about how to toggle the panel.
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'F3') {
+ event.preventDefault();
+ onToggle();
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [onToggle]);
+
+ const { Icon: StatusIcon, className: statusIconClass } = getStatusIcon(stats);
+
+ return (
+
+
+
+ System Monitor
+
+ (F3)
+
+
+
+
+ {expanded && (
+
+
+
+
+
+
+ )}
+
+
+ Powered by{' '}
+
+ Diffs
+ {' '}
+ and{' '}
+
+ Trees
+
+
+
+
+ );
+}
+
+interface AutoScrollToggleIconProps {
+ running: boolean;
+}
+
+function AutoScrollToggleIcon({ running }: AutoScrollToggleIconProps) {
+ if (running) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/annotation-shared.tsx b/apps/docs/app/(diffshub)/(view)/_components/annotation-shared.tsx
new file mode 100644
index 000000000..d3ba10630
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/annotation-shared.tsx
@@ -0,0 +1,95 @@
+// Shared pieces used by both DraftAnnotation and ExampleAnnotation.
+
+import { cn } from '@/lib/utils';
+
+export const annotationCardBase =
+ 'bg-card m-2 flex max-w-[600px] gap-2.5 rounded-xl border border-[rgb(0_0_0_/_0.1)] bg-clip-padding p-3 font-sans shadow-[0_2px_4px_rgb(0_0_0_/_0.025),0_4px_8px_rgb(0_0_0_/_0.025)] dark:border-[rgb(255_255_255_/_0.1)] dark:shadow-[0_2px_4px_rgb(0_0_0_/_0.25),0_4px_8px_rgb(0_0_0_/_0.25)] dark:bg-neutral-900/80';
+
+// All available reviewer personas, derived from /public/diffshub-avatars/ filenames.
+const AVATAR_NAMES = [
+ 'alex',
+ 'amacateus',
+ 'amadeus',
+ 'aussie',
+ 'cedric',
+ 'chugs',
+ 'dwayn',
+ 'ed',
+ 'fat',
+ 'ian',
+ 'jacob2',
+ 'joe',
+ 'kris',
+ 'mdo',
+ 'murphy',
+ 'nicolas',
+ 'pia',
+ 'toshi',
+ 'zac',
+] as const;
+
+export type AvatarName = (typeof AVATAR_NAMES)[number];
+
+export interface Persona {
+ name: AvatarName;
+ avatarSrc: string;
+}
+
+// Triggers browser fetches for all avatar images so they are in the cache
+// before the comment form opens. Call once on mount of the top-level UI component.
+export function preloadAvatars(): void {
+ for (const name of AVATAR_NAMES) {
+ const img = new Image();
+ img.src = `/diffshub-avatars/${name}.png`;
+ }
+}
+
+function buildPersona(name: AvatarName): Persona {
+ return { name, avatarSrc: `/diffshub-avatars/${name}.png` };
+}
+
+// Picks a random persona from the avatar list. Intended for use as a useState
+// lazy initializer so each new draft form gets a fresh identity on mount.
+export function getRandomPersona(): Persona {
+ const name = AVATAR_NAMES[Math.floor(Math.random() * AVATAR_NAMES.length)];
+ return buildPersona(name);
+}
+
+// Returns a persona for the given name or seed. If the seed is an exact avatar
+// name (i.e. it was stored directly from getRandomPersona), returns that persona
+// directly so draft and saved annotations stay in sync. Otherwise falls back to
+// a djb2 hash to spread arbitrary comment keys across the avatar list.
+export function getCommentPersona(seed: string): Persona {
+ if (AVATAR_NAMES.includes(seed as AvatarName)) {
+ return buildPersona(seed as AvatarName);
+ }
+ let hash = 5381;
+ for (let i = 0; i < seed.length; i++) {
+ hash = ((hash << 5) + hash + seed.charCodeAt(i)) >>> 0;
+ }
+ return buildPersona(AVATAR_NAMES[hash % AVATAR_NAMES.length]);
+}
+
+interface CommentAuthorAvatarProps {
+ // A stable seed (e.g. comment key or a fixed name) used to pick the avatar.
+ seed: string;
+ className?: string;
+}
+
+// Renders a circular avatar image for a comment author.
+// Defaults to 32px (size-8); pass className to override for other sizes.
+export function CommentAuthorAvatar({
+ seed,
+ className,
+}: CommentAuthorAvatarProps) {
+ const { name, avatarSrc } = getCommentPersona(seed);
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts b/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts
new file mode 100644
index 000000000..51346acf3
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts
@@ -0,0 +1,329 @@
+import {
+ type ChangeTypes,
+ type CodeViewItem,
+ type FileDiffMetadata,
+ parsePatchFiles,
+} from '@pierre/diffs';
+import type { GitStatusEntry } from '@pierre/trees';
+
+import { getPatchTreePathPrefix } from './gitPatchMetadata';
+import type {
+ CodeViewCommentFileByItemId,
+ CodeViewCommentSidebarFile,
+ CodeViewDiffStats,
+ CodeViewFileTreeSort,
+ CodeViewFileTreeSource,
+ CommentMetadata,
+} from './types';
+import {
+ createPatchOrderSortFromRankMap,
+ extendPatchOrderRanks,
+ mapChangeTypeToGitStatus,
+} from './utils';
+
+export interface CodeViewDataAccumulator {
+ diffStats: CodeViewDiffStats;
+ fileIndex: number;
+ gitStatusByPath: Map;
+ itemIdToFile: Map;
+ items: CodeViewItem[];
+ // The last tree source emitted by snapshotCodeViewTreeSource for this
+ // accumulator. Each new snapshot links back to this so the consumer can
+ // recognize append-only growth and skip the full PathStore rebuild.
+ lastTreeSource: CodeViewFileTreeSource | undefined;
+ nextCollisionSuffixByBase: Map;
+ pendingItems: CodeViewItem[];
+ pendingItemById: Map>;
+ pathToItemId: Map;
+ pathStateByTreePath: Map;
+ paths: string[];
+ // Patch-order ranks for every path and directory ancestor we have ever
+ // appended. Extended incrementally so the sort comparator below stays valid
+ // across publishes without rebuilding the map.
+ rankByPath: Map;
+ // Stable comparator that reads from rankByPath. Reused across snapshots so
+ // each publish does not allocate a fresh closure or repopulate a rank map.
+ sort: CodeViewFileTreeSort;
+}
+
+export interface CodeViewItemIdRename {
+ oldId: string;
+ newId: string;
+}
+
+interface CodeViewPathState {
+ currentItem: CodeViewItem;
+ currentItemId: string;
+ currentType: ChangeTypes;
+ sawDeleted: boolean;
+}
+
+export interface LoadedCodeViewData {
+ itemIdToFile: CodeViewCommentFileByItemId;
+ diffStats: CodeViewDiffStats;
+ items: CodeViewItem[];
+ treeSource: CodeViewFileTreeSource;
+}
+
+export function createCodeViewDataAccumulator(): CodeViewDataAccumulator {
+ const rankByPath = new Map();
+ return {
+ diffStats: {
+ addedLines: 0,
+ deletedLines: 0,
+ fileCount: 0,
+ totalLinesOfCode: 0,
+ },
+ fileIndex: 0,
+ gitStatusByPath: new Map(),
+ itemIdToFile: new Map(),
+ items: [],
+ lastTreeSource: undefined,
+ nextCollisionSuffixByBase: new Map(),
+ pendingItems: [],
+ pendingItemById: new Map(),
+ pathToItemId: new Map(),
+ pathStateByTreePath: new Map(),
+ paths: [],
+ rankByPath,
+ sort: createPatchOrderSortFromRankMap(rankByPath),
+ };
+}
+
+export function appendFileDiffToCodeViewData(
+ accumulator: CodeViewDataAccumulator,
+ fileDiff: FileDiffMetadata,
+ treePathPrefix: string | undefined
+): CodeViewItemIdRename | undefined {
+ const { diffStats } = accumulator;
+ diffStats.fileCount++;
+ diffStats.totalLinesOfCode += fileDiff.unifiedLineCount;
+ for (const hunk of fileDiff.hunks) {
+ diffStats.addedLines += hunk.additionLines;
+ diffStats.deletedLines += hunk.deletionLines;
+ }
+
+ const path = fileDiff.name;
+ const treePath = treePathPrefix == null ? path : `${treePathPrefix}/${path}`;
+ const previousPathState =
+ path.length === 0
+ ? undefined
+ : accumulator.pathStateByTreePath.get(treePath);
+ const itemIdRename =
+ previousPathState == null
+ ? undefined
+ : renameCurrentPathItem(accumulator, treePath, previousPathState);
+ const id = accumulator.itemIdToFile.has(treePath)
+ ? createFallbackItemId(accumulator, treePath)
+ : treePath;
+ // Streaming cache keys read fileIndex before this append, so keep advancing
+ // it even though item ids are now path-based.
+ accumulator.fileIndex++;
+ const fileOrder = accumulator.items.length;
+
+ const item: CodeViewItem = {
+ id,
+ type: 'diff',
+ collapsed: fileDiff.type === 'deleted',
+ fileDiff,
+ version: 0,
+ };
+ accumulator.items.push(item);
+ accumulator.pendingItems.push(item);
+ accumulator.pendingItemById.set(id, item);
+
+ accumulator.itemIdToFile.set(id, { fileOrder, path });
+ if (path.length === 0) {
+ return itemIdRename;
+ }
+
+ if (previousPathState == null) {
+ accumulator.paths.push(treePath);
+ extendPatchOrderRanks(
+ accumulator.rankByPath,
+ treePath,
+ accumulator.paths.length - 1
+ );
+ }
+ accumulator.pathToItemId.set(treePath, id);
+ updateGitStatusByPath(
+ accumulator,
+ treePath,
+ fileDiff.type,
+ previousPathState?.sawDeleted === true
+ );
+ accumulator.pathStateByTreePath.set(treePath, {
+ currentItem: item,
+ currentItemId: id,
+ currentType: fileDiff.type,
+ sawDeleted:
+ previousPathState?.sawDeleted === true || fileDiff.type === 'deleted',
+ });
+
+ return itemIdRename;
+}
+
+export function takePendingCodeViewItems(
+ accumulator: CodeViewDataAccumulator
+): CodeViewItem[] {
+ const { pendingItems } = accumulator;
+ accumulator.pendingItems = [];
+ accumulator.pendingItemById.clear();
+ return pendingItems;
+}
+
+// Produces a tree source snapshot, linking it to the previous snapshot from
+// the same accumulator. The consumer treats that link as a hint that the new
+// paths array is an append-only extension of the prior one and applies the
+// delta with model.batch instead of rebuilding the whole PathStore. Consumers
+// that recreate the accumulator (e.g. a new request) discard the prior link
+// implicitly because lastTreeSource is undefined on a fresh accumulator.
+export function snapshotCodeViewTreeSource(
+ accumulator: CodeViewDataAccumulator
+): CodeViewFileTreeSource {
+ const snapshot: CodeViewFileTreeSource = {
+ gitStatus: Array.from(accumulator.gitStatusByPath.values()),
+ pathCount: accumulator.paths.length,
+ paths: accumulator.paths,
+ pathToItemId: accumulator.pathToItemId,
+ previousSource: accumulator.lastTreeSource,
+ sort: accumulator.sort,
+ };
+ accumulator.lastTreeSource = snapshot;
+ return snapshot;
+}
+
+// Moves the current CodeView item for a path off the canonical tree id so the
+// next diff entry for that same path can own tree navigation without rebuilding.
+function renameCurrentPathItem(
+ accumulator: CodeViewDataAccumulator,
+ treePath: string,
+ pathState: CodeViewPathState
+): CodeViewItemIdRename | undefined {
+ const oldId = pathState.currentItemId;
+ const newId = createSupersededItemId(
+ accumulator,
+ treePath,
+ pathState.currentType
+ );
+ pathState.currentItem.id = newId;
+ pathState.currentItemId = newId;
+
+ const file = accumulator.itemIdToFile.get(oldId);
+ if (file != null) {
+ accumulator.itemIdToFile.delete(oldId);
+ accumulator.itemIdToFile.set(newId, file);
+ }
+
+ const pendingItem = accumulator.pendingItemById.get(oldId);
+ if (pendingItem != null) {
+ accumulator.pendingItemById.delete(oldId);
+ accumulator.pendingItemById.set(newId, pendingItem);
+ return undefined;
+ }
+
+ return { oldId, newId };
+}
+
+function createSupersededItemId(
+ accumulator: CodeViewDataAccumulator,
+ treePath: string,
+ changeType: ChangeTypes
+): string {
+ const semanticSuffix = changeType === 'deleted' ? '?deleted' : '?previous';
+ return createUniqueItemId(accumulator, `${treePath}${semanticSuffix}`);
+}
+
+function createFallbackItemId(
+ accumulator: CodeViewDataAccumulator,
+ treePath: string
+): string {
+ return createUniqueItemId(accumulator, `${treePath}?2`);
+}
+
+// Resolves rare id collisions by advancing a per-base suffix instead of scanning
+// accumulated items.
+function createUniqueItemId(
+ accumulator: CodeViewDataAccumulator,
+ baseId: string
+): string {
+ if (!accumulator.itemIdToFile.has(baseId)) {
+ return baseId;
+ }
+
+ let suffix = accumulator.nextCollisionSuffixByBase.get(baseId) ?? 2;
+ let itemId = `${baseId}-${suffix}`;
+ while (accumulator.itemIdToFile.has(itemId)) {
+ suffix++;
+ itemId = `${baseId}-${suffix}`;
+ }
+ accumulator.nextCollisionSuffixByBase.set(baseId, suffix + 1);
+ return itemId;
+}
+
+// Maintains the file tree status for a real path while repeated patch entries
+// replace the path's final CodeView item.
+function updateGitStatusByPath(
+ accumulator: CodeViewDataAccumulator,
+ treePath: string,
+ changeType: ChangeTypes,
+ hadDeletedEntry: boolean
+): void {
+ if (hadDeletedEntry && changeType !== 'deleted') {
+ accumulator.gitStatusByPath.delete(treePath);
+ return;
+ }
+
+ // Modified files are excluded so they render as the visual default. Only
+ // added, deleted, and renamed files retain status indicators.
+ const gitStatusEntry = mapChangeTypeToGitStatus(changeType);
+ if (gitStatusEntry === 'modified') {
+ accumulator.gitStatusByPath.delete(treePath);
+ } else {
+ accumulator.gitStatusByPath.set(treePath, {
+ path: treePath,
+ status: gitStatusEntry,
+ });
+ }
+}
+
+export function snapshotCodeViewData(
+ accumulator: CodeViewDataAccumulator
+): LoadedCodeViewData {
+ return {
+ itemIdToFile: new Map(accumulator.itemIdToFile),
+ diffStats: { ...accumulator.diffStats },
+ items: accumulator.items.slice(),
+ treeSource: snapshotCodeViewTreeSource(accumulator),
+ };
+}
+
+// Converts raw patch text into the exact state slices consumed by the diff
+// viewer, sidebar tree, stats panel, and comment index in one linear pass.
+export function buildCodeViewData(
+ patchContent: string,
+ githubPath: string
+): LoadedCodeViewData {
+ console.time('-- parsing patches');
+ const parsedPatches = parsePatchFiles(
+ patchContent,
+ // Use the url as a cache key
+ encodeURIComponent(githubPath)
+ );
+ console.timeEnd('-- parsing patches');
+
+ console.time('-- computing layout');
+ const accumulator = createCodeViewDataAccumulator();
+ const shouldPrefixTreePaths = parsedPatches.length > 1;
+ for (const [patchIndex, patch] of parsedPatches.entries()) {
+ const treePathPrefix = shouldPrefixTreePaths
+ ? getPatchTreePathPrefix(patch.patchMetadata, patchIndex)
+ : undefined;
+ for (const fileDiff of patch.files) {
+ appendFileDiffToCodeViewData(accumulator, fileDiff, treePathPrefix);
+ }
+ }
+ console.timeEnd('-- computing layout');
+
+ return snapshotCodeViewData(accumulator);
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/constants.ts b/apps/docs/app/(diffshub)/(view)/_components/constants.ts
new file mode 100644
index 000000000..02d245571
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/constants.ts
@@ -0,0 +1,156 @@
+import type { CodeViewLayout } from '@pierre/diffs';
+import type { FileTreeOptions } from '@pierre/trees';
+
+export const CODE_VIEW_LAYOUT: CodeViewLayout = {
+ paddingTop: 0,
+ gap: 1,
+ paddingBottom: 0,
+};
+
+export const CODE_VIEW_CUSTOM_CSS = `
+[data-diffs-header] {
+ container-type: scroll-state;
+ container-name: sticky-header;
+}
+
+@container sticky-header scroll-state(stuck: top) {
+ [data-diffs-header]::after {
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ width: 100%;
+ height: 1px;
+ content: '';
+ background-color: var(--color-border-opaque);
+ }
+}
+`;
+
+export const CODE_VIEW_FILE_TREE_ITEM_HEIGHT = 24;
+export const CODE_VIEW_BATCH_COUNT = 25;
+export const CODE_VIEW_BATCH_COUNT_MAX = 96;
+
+export function getInitialBatchSize(): number {
+ const viewportHeight = getViewportHeight();
+ if (viewportHeight == null) {
+ return CODE_VIEW_BATCH_COUNT;
+ }
+
+ return Math.min(
+ CODE_VIEW_BATCH_COUNT_MAX,
+ Math.max(
+ CODE_VIEW_BATCH_COUNT,
+ Math.ceil(viewportHeight / CODE_VIEW_FILE_TREE_ITEM_HEIGHT)
+ )
+ );
+}
+
+function getViewportHeight(): number | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
+ return Number.isFinite(viewportHeight) && viewportHeight > 0
+ ? viewportHeight
+ : null;
+}
+
+// Hide the built-in search input until the user opts into search via the
+// sidebar toggle. The trees library always mounts the input when
+// `search: true`, but reflects open/closed state on the container's
+// `data-open` attribute -- we collapse it when closed so it doesn't take up
+// vertical space above the tree.
+const HIDDEN_SEARCH_UNSAFE_CSS = `
+ [data-file-tree-search-container][data-open='false'] {
+ display: none;
+ }
+ [data-file-tree-search-container] {
+ padding-bottom: 12px;
+ margin-bottom: 12px;
+ margin-right: 4px;
+ border-bottom: 1px solid var(--color-border);
+ padding-inline-start: 1px;
+ padding-inline-end: 5px;
+ }
+
+ [data-file-tree-sticky-overlay-content] {
+ box-shadow: 0 2px 3px -4px rgb(0 0 0 / 1);
+
+ [data-item-section="spacing"] {
+ opacity: 0.5;
+ }
+
+ > [data-file-tree-sticky-path]:last-of-type {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+
+ [data-item-section="spacing"] {
+ margin-bottom: 4px;
+ }
+ }
+ }
+
+ @media (prefers-color-scheme: dark) {
+ [data-file-tree-sticky-overlay-content] {
+ box-shadow: 0 3px 3px -3px rgb(0 0 0 / 80%);
+
+ [data-item-section="spacing"] {
+ opacity: 0.6;
+ }
+ }
+ }
+`;
+
+/** In `@layer unsafe` so it overrides core tree `padding-inline` without host vars. */
+const SIDEBAR_VIRTUALIZED_SCROLL_UNSAFE_CSS = `
+ [data-file-tree-virtualized-scroll="true"] {
+ padding-inline-start: 0;
+ padding-inline-end: 2px;
+ margin-inline-end: 2px;
+ }
+
+ @media (width <= 767px) {
+ [data-file-tree-search-container="true"],
+ [data-file-tree-virtualized-scroll="true"] {
+ padding-inline-start: 14px;
+ }
+
+ [data-file-tree-search-container="true"] {
+ margin-right: 0;
+ padding-inline-end: 14px;
+ }
+
+ [data-file-tree-virtualized-scroll="true"] {
+ padding-inline-end: max(0px, calc(14px - var(--trees-scrollbar-gutter)));
+ }
+ }
+`;
+
+// In this view everything is assumed to be changing, so the folder dot that
+// signals "contains a git change" is superfluous and is hidden globally.
+const SUPPRESS_FOLDER_DOT_UNSAFE_CSS = `
+ [data-item-contains-git-change='true'] > [data-item-section='git'] {
+ display: none;
+ }
+`;
+
+// Folders get higher contrast and medium weight to stand out from regular file
+// entries, which use the default muted tree fg color.
+const FOLDER_LABEL_UNSAFE_CSS = `
+ [data-item-type='folder'] {
+ color: color-mix(in lab, light-dark(#000, #fff) 25%, var(--trees-fg));
+ font-weight: 500;
+ }
+`;
+
+// Options shared across all mounts of this tree. Lives at module scope so the
+// reference stays stable and useFileTree() never churns its initial snapshot.
+export const BASE_FILE_TREE_OPTIONS = {
+ flattenEmptyDirectories: true,
+ id: 'gh-code-view-tree',
+ initialExpansion: 'open',
+ search: true,
+ stickyFolders: true,
+ unsafeCSS: `${HIDDEN_SEARCH_UNSAFE_CSS}\n${SIDEBAR_VIRTUALIZED_SCROLL_UNSAFE_CSS}\n${SUPPRESS_FOLDER_DOT_UNSAFE_CSS}\n${FOLDER_LABEL_UNSAFE_CSS}`,
+} as const satisfies Omit;
diff --git a/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts b/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts
new file mode 100644
index 000000000..e1190b1bf
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts
@@ -0,0 +1,18 @@
+export const COMMIT_HASH_METADATA_PATTERN = /^From\s+([a-f0-9]+)\s/im;
+
+const commitPrefixEncoder = new TextEncoder();
+const commitPrefixDecoder = new TextDecoder();
+
+export function getPatchTreePathPrefix(
+ patchMetadata: string | undefined,
+ patchIndex: number
+): string {
+ const commitHash = patchMetadata?.match(COMMIT_HASH_METADATA_PATTERN)?.[1];
+ return commitHash != null
+ ? detachCommitPrefix(commitHash.slice(0, 5))
+ : `Commit ${patchIndex + 1}`;
+}
+
+function detachCommitPrefix(value: string): string {
+ return commitPrefixDecoder.decode(commitPrefixEncoder.encode(value));
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/lineHash.ts b/apps/docs/app/(diffshub)/(view)/_components/lineHash.ts
new file mode 100644
index 000000000..eca99cf34
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/lineHash.ts
@@ -0,0 +1,144 @@
+import type {
+ CodeViewLineSelection,
+ SelectedLineRange,
+ SelectionSide,
+} from '@pierre/diffs';
+
+interface LineHashPoint {
+ lineNumber: number;
+ side: SelectionSide;
+}
+
+export interface CodeViewLineHashTarget {
+ itemId: string;
+ range: SelectedLineRange;
+}
+
+const LINE_POINT_PATTERN = /^([AD])(\d+)$/;
+
+export function parseCodeViewLineHash(
+ hash: string
+): CodeViewLineHashTarget | null {
+ const text = hash.startsWith('#') ? hash.slice(1) : hash;
+ if (text.length === 0) {
+ return null;
+ }
+
+ const params = new URLSearchParams(text);
+ const itemId = params.get('target');
+ const startPoint = parseLineHashPoint(params.get('start'));
+ if (itemId == null || itemId.length === 0 || startPoint == null) {
+ return null;
+ }
+
+ const endParam = params.get('end');
+ const endPoint = endParam == null ? startPoint : parseLineHashPoint(endParam);
+ if (endPoint == null) {
+ return null;
+ }
+
+ return {
+ itemId,
+ range: createSelectedLineRange(startPoint, endPoint),
+ };
+}
+
+export function formatCodeViewLineHash(
+ selection: CodeViewLineSelection
+): string | null {
+ if (selection.id.length === 0) {
+ return null;
+ }
+
+ const startPoint = createLineHashPoint(
+ selection.range.start,
+ selection.range.side
+ );
+ const endPoint = createLineHashPoint(
+ selection.range.end,
+ selection.range.endSide ?? selection.range.side
+ );
+ if (startPoint == null || endPoint == null) {
+ return null;
+ }
+
+ const params = [
+ `target=${encodeHashValue(selection.id)}`,
+ `start=${formatLineHashPoint(startPoint)}`,
+ ];
+ if (!areLineHashPointsEqual(startPoint, endPoint)) {
+ params.push(`end=${formatLineHashPoint(endPoint)}`);
+ }
+
+ return `#${params.join('&')}`;
+}
+
+function parseLineHashPoint(value: string | null): LineHashPoint | null {
+ if (value == null) {
+ return null;
+ }
+
+ const match = LINE_POINT_PATTERN.exec(value);
+ if (match == null) {
+ return null;
+ }
+
+ const side = parseLineHashSide(match[1]);
+ const lineNumber = Number.parseInt(match[2] ?? '', 10);
+ if (side == null || !Number.isSafeInteger(lineNumber) || lineNumber < 1) {
+ return null;
+ }
+
+ return { lineNumber, side };
+}
+
+function parseLineHashSide(value: string | undefined): SelectionSide | null {
+ switch (value) {
+ case 'A':
+ return 'additions';
+ case 'D':
+ return 'deletions';
+ default:
+ return null;
+ }
+}
+
+function createSelectedLineRange(
+ startPoint: LineHashPoint,
+ endPoint: LineHashPoint
+): SelectedLineRange {
+ return {
+ start: startPoint.lineNumber,
+ side: startPoint.side,
+ end: endPoint.lineNumber,
+ ...(startPoint.side !== endPoint.side ? { endSide: endPoint.side } : {}),
+ };
+}
+
+function createLineHashPoint(
+ lineNumber: number,
+ side: SelectionSide | undefined
+): LineHashPoint | null {
+ if (!Number.isSafeInteger(lineNumber) || lineNumber < 1 || side == null) {
+ return null;
+ }
+
+ return { lineNumber, side };
+}
+
+function formatLineHashPoint(point: LineHashPoint): string {
+ return `${point.side === 'deletions' ? 'D' : 'A'}${point.lineNumber}`;
+}
+
+function encodeHashValue(value: string): string {
+ return encodeURIComponent(value)
+ .replaceAll('%2F', '/')
+ .replaceAll('%3F', '?');
+}
+
+function areLineHashPointsEqual(
+ left: LineHashPoint,
+ right: LineHashPoint
+): boolean {
+ return left.lineNumber === right.lineNumber && left.side === right.side;
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/patchCache.ts b/apps/docs/app/(diffshub)/(view)/_components/patchCache.ts
new file mode 100644
index 000000000..d5f867584
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/patchCache.ts
@@ -0,0 +1,20 @@
+// Tiny in-memory cache of fetched patch text, keyed by GitHub path (e.g.
+// "/nodejs/bootstrap/pull/42369"). Lives at module scope so it survives
+// client-side navigations and back/forward visits but resets on a full reload.
+//
+// This is intentionally not wired into the viewer at the moment. Keep it around
+// as a small client-session cache option in case we want to re-enable raw patch
+// reuse for repeated visits without changing the viewer flow again.
+
+const patchTextByGitHubPath = new Map();
+
+export function getCachedPatchText(githubPath: string): string | undefined {
+ return patchTextByGitHubPath.get(githubPath);
+}
+
+export function setCachedPatchText(
+ githubPath: string,
+ patchText: string
+): void {
+ patchTextByGitHubPath.set(githubPath, patchText);
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts
new file mode 100644
index 000000000..c5872d46d
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts
@@ -0,0 +1,243 @@
+import { COMMIT_HASH_METADATA_PATTERN } from './gitPatchMetadata';
+
+const GIT_FILE_BOUNDARY = 'diff --git ';
+const GIT_FILE_BOUNDARY_WITH_NEWLINE = `\n${GIT_FILE_BOUNDARY}`;
+const GIT_FILE_BOUNDARY_SCAN_OVERLAP =
+ GIT_FILE_BOUNDARY_WITH_NEWLINE.length - 1;
+const NON_WHITESPACE_PATTERN = /\S/;
+
+export async function streamGitPatchFiles(
+ body: ReadableStream,
+ onFileText: (fileText: string) => Promise
+): Promise {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ const parser = createGitPatchFileStreamParser();
+
+ try {
+ for (;;) {
+ const result = await reader.read();
+ if (result.done) {
+ break;
+ }
+ if (result.value.byteLength > 0) {
+ parser.push(decoder.decode(result.value, { stream: true }));
+ await consumeAvailableStreamedFiles(parser, onFileText);
+ }
+ }
+
+ const finalText = decoder.decode();
+ if (finalText.length > 0) {
+ parser.push(finalText);
+ await consumeAvailableStreamedFiles(parser, onFileText);
+ }
+ const result = parser.finish();
+ if (result.fileText != null) {
+ await onFileText(result.fileText);
+ }
+ let fileText: string | undefined;
+ while ((fileText = parser.takeAvailableFile()) != null) {
+ await onFileText(fileText);
+ }
+ return result.fallbackPatchContent;
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+export function getStreamedPatchMetadata(fileText: string): string | undefined {
+ const diffBoundaryIndex = findNextGitFileBoundary(fileText, 0);
+ if (diffBoundaryIndex == null || diffBoundaryIndex <= 0) {
+ return undefined;
+ }
+
+ const metadata = fileText.slice(0, diffBoundaryIndex);
+ return COMMIT_HASH_METADATA_PATTERN.test(metadata) ? metadata : undefined;
+}
+
+interface GitPatchFileStreamFinishResult {
+ fallbackPatchContent?: string;
+ fileText?: string;
+}
+
+interface GitPatchFileStreamParser {
+ finish(): GitPatchFileStreamFinishResult;
+ push(chunk: string): void;
+ takeAvailableFile(): string | undefined;
+}
+
+async function consumeAvailableStreamedFiles(
+ parser: GitPatchFileStreamParser,
+ onFileText: (fileText: string) => Promise
+): Promise {
+ let fileText: string | undefined;
+ while ((fileText = parser.takeAvailableFile()) != null) {
+ await onFileText(fileText);
+ }
+}
+
+// Buffers the current file until the following `diff --git` header arrives so
+// each parsed file is complete before it is appended to the viewer.
+function createGitPatchFileStreamParser(): GitPatchFileStreamParser {
+ let buffer = '';
+ let currentFileBoundaryIndex: number | undefined;
+ let nextBoundarySearchIndex = 0;
+ let sawFileBoundary = false;
+
+ function takeAvailableFile(): string | undefined {
+ if (currentFileBoundaryIndex == null) {
+ currentFileBoundaryIndex = findNextGitFileBoundary(
+ buffer,
+ nextBoundarySearchIndex
+ );
+ if (currentFileBoundaryIndex == null) {
+ nextBoundarySearchIndex = getNextBoundarySearchIndex(buffer, 0);
+ return undefined;
+ }
+
+ sawFileBoundary = true;
+ nextBoundarySearchIndex = currentFileBoundaryIndex + 1;
+ }
+
+ for (;;) {
+ const fileBoundaryIndex = currentFileBoundaryIndex;
+ if (fileBoundaryIndex == null) {
+ return undefined;
+ }
+
+ const nextBoundaryIndex = findNextGitFileBoundary(
+ buffer,
+ nextBoundarySearchIndex
+ );
+ if (nextBoundaryIndex == null) {
+ nextBoundarySearchIndex = getNextBoundarySearchIndex(
+ buffer,
+ fileBoundaryIndex + 1
+ );
+ return undefined;
+ }
+
+ const splitIndex = getStreamedFileSplitIndex(
+ buffer,
+ fileBoundaryIndex,
+ nextBoundaryIndex
+ );
+ const fileText = buffer.slice(0, splitIndex);
+
+ buffer = buffer.slice(splitIndex);
+ currentFileBoundaryIndex = findNextGitFileBoundary(buffer, 0);
+ nextBoundarySearchIndex =
+ currentFileBoundaryIndex == null ? 0 : currentFileBoundaryIndex + 1;
+ if (NON_WHITESPACE_PATTERN.test(fileText)) {
+ return fileText;
+ }
+ }
+ }
+
+ return {
+ push(chunk: string) {
+ if (chunk.length === 0) {
+ return;
+ }
+ buffer += chunk;
+ },
+ takeAvailableFile,
+ finish() {
+ const fileText = takeAvailableFile();
+ if (fileText != null) {
+ return { fileText };
+ }
+
+ if (!NON_WHITESPACE_PATTERN.test(buffer)) {
+ buffer = '';
+ return {};
+ }
+ if (!sawFileBoundary) {
+ const fullPatchText = buffer;
+ buffer = '';
+ return { fallbackPatchContent: fullPatchText };
+ }
+
+ const finalFileText = buffer;
+ buffer = '';
+ return { fileText: finalFileText };
+ },
+ };
+}
+
+function getNextBoundarySearchIndex(
+ text: string,
+ minimumIndex: number
+): number {
+ return Math.max(minimumIndex, text.length - GIT_FILE_BOUNDARY_SCAN_OVERLAP);
+}
+
+function findNextGitFileBoundary(
+ text: string,
+ fromIndex: number
+): number | undefined {
+ const startIndex = Math.max(fromIndex, 0);
+ if (startIndex === 0 && text.startsWith(GIT_FILE_BOUNDARY)) {
+ return 0;
+ }
+
+ const boundaryIndex = text.indexOf(
+ GIT_FILE_BOUNDARY_WITH_NEWLINE,
+ startIndex
+ );
+ return boundaryIndex === -1 ? undefined : boundaryIndex + 1;
+}
+
+function getStreamedFileSplitIndex(
+ text: string,
+ firstBoundaryIndex: number,
+ nextBoundaryIndex: number
+): number {
+ return (
+ findLastCommitMetadataBoundary(
+ text,
+ firstBoundaryIndex + 1,
+ nextBoundaryIndex
+ ) ?? nextBoundaryIndex
+ );
+}
+
+function findLastCommitMetadataBoundary(
+ text: string,
+ startIndex: number,
+ endIndex: number
+): number | undefined {
+ const minimumBoundaryIndex = Math.max(startIndex, 0);
+ const maximumBoundaryIndex = Math.min(endIndex, text.length);
+ if (minimumBoundaryIndex >= maximumBoundaryIndex) {
+ return undefined;
+ }
+
+ let newlineIndex = text.lastIndexOf('\nFrom ', maximumBoundaryIndex - 1);
+ for (;;) {
+ if (newlineIndex === -1) {
+ return undefined;
+ }
+
+ const boundaryIndex = newlineIndex + 1;
+ if (boundaryIndex < minimumBoundaryIndex) {
+ return undefined;
+ }
+ if (boundaryIndex >= maximumBoundaryIndex) {
+ newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1);
+ continue;
+ }
+
+ const lineEndIndex = text.indexOf('\n', boundaryIndex + 1);
+ const line = text.slice(
+ boundaryIndex,
+ lineEndIndex === -1 || lineEndIndex > maximumBoundaryIndex
+ ? maximumBoundaryIndex
+ : lineEndIndex
+ );
+ if (COMMIT_HASH_METADATA_PATTERN.test(line)) {
+ return boundaryIndex;
+ }
+ newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1);
+ }
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/types.ts b/apps/docs/app/(diffshub)/(view)/_components/types.ts
new file mode 100644
index 000000000..bdb638152
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/types.ts
@@ -0,0 +1,113 @@
+import type { AnnotationSide, SelectedLineRange } from '@pierre/diffs';
+import type { FileTreeOptions, GitStatusEntry } from '@pierre/trees';
+
+type FileTreeInputSort = NonNullable;
+
+export type ViewerLoadState =
+ | 'fetching'
+ | 'streaming'
+ | 'parsing'
+ | 'ready'
+ | 'error';
+
+export type CodeViewFileTreeSort = Exclude;
+
+export interface SavedCommentMetadata {
+ kind: 'saved';
+ key: string;
+ author: string;
+ message: string;
+ range: SelectedLineRange;
+}
+
+export interface DraftCommentMetadata {
+ kind: 'draft';
+ key: string;
+ message: string;
+ range: SelectedLineRange;
+}
+
+export type CommentMetadata = SavedCommentMetadata | DraftCommentMetadata;
+
+export interface CodeViewCommentSidebarFile {
+ fileOrder: number;
+ path: string;
+}
+
+export type CodeViewCommentFileByItemId = ReadonlyMap<
+ string,
+ CodeViewCommentSidebarFile
+>;
+
+// Whether the line the comment is anchored to is a real addition/deletion or
+// an unchanged context line shown in the diff. Tracked so the sidebar can
+// render "Line N" without a misleading + / - sigil for context lines.
+export type CommentLineType = 'change' | 'context';
+
+export interface CodeViewSavedCommentEvent {
+ author: string;
+ itemId: string;
+ key: string;
+ lineNumber: number;
+ lineType: CommentLineType;
+ message: string;
+ range: SelectedLineRange;
+ side: AnnotationSide;
+}
+
+export interface CodeViewDeletedCommentEvent {
+ itemId: string;
+ key: string;
+}
+
+export interface CodeViewSavedCommentEntry {
+ author: string;
+ itemId: string;
+ key: string;
+ lineNumber: number;
+ lineType: CommentLineType;
+ message: string;
+ range: SelectedLineRange;
+ side: AnnotationSide;
+}
+
+export interface CodeViewSavedCommentItem {
+ comments: CodeViewSavedCommentEntry[];
+ fileOrder: number;
+ itemId: string;
+ path: string;
+}
+
+// The fully pre-computed input this tree needs for a given fetch. It is built
+// once at fetch time by snapshotCodeViewTreeSource and stored alongside the
+// viewer items, so later per-item annotation updates do not feed into the
+// tree and do not cause it to rebuild.
+//
+// Streamed publishes link successive snapshots through `previousSource` so the
+// tree consumer can recognize append-only growth and apply the delta as
+// `model.batch` adds instead of rebuilding the entire path store. The link is
+// present only on snapshots that share the same underlying accumulator; the
+// initial publish and any non-streamed source leave it undefined and force a
+// full reset.
+//
+// `paths` and `pathToItemId` may alias the live accumulator state for
+// streamed sources, so consumers must treat them as read-only and must use
+// `pathCount` (captured at snapshot time) as the exclusive upper bound when
+// iterating `paths`. The `readonly` markers and ReadonlyMap type enforce the
+// read-only side; pathCount is what keeps later in-place growth invisible to
+// this snapshot.
+export interface CodeViewFileTreeSource {
+ gitStatus: readonly GitStatusEntry[];
+ pathCount: number;
+ paths: readonly string[];
+ pathToItemId: ReadonlyMap;
+ previousSource?: CodeViewFileTreeSource;
+ sort: CodeViewFileTreeSort;
+}
+
+export interface CodeViewDiffStats {
+ addedLines: number;
+ deletedLines: number;
+ fileCount: number;
+ totalLinesOfCode: number;
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts
new file mode 100644
index 000000000..f1b90ed08
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts
@@ -0,0 +1,528 @@
+'use client';
+
+import {
+ areSelectionsEqual,
+ type CodeViewItem,
+ type CodeViewLineSelection,
+ processFile,
+} from '@pierre/diffs';
+import { type CodeViewHandle, useStableCallback } from '@pierre/diffs/react';
+import {
+ type Dispatch,
+ type RefObject,
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import {
+ appendFileDiffToCodeViewData,
+ buildCodeViewData,
+ type CodeViewItemIdRename,
+ createCodeViewDataAccumulator,
+ snapshotCodeViewTreeSource,
+ takePendingCodeViewItems,
+} from './codeViewDataAccumulator';
+import { CODE_VIEW_BATCH_COUNT, getInitialBatchSize } from './constants';
+import { getPatchTreePathPrefix } from './gitPatchMetadata';
+import {
+ type CodeViewLineHashTarget,
+ formatCodeViewLineHash,
+ parseCodeViewLineHash,
+} from './lineHash';
+import {
+ getStreamedPatchMetadata,
+ streamGitPatchFiles,
+} from './streamGitPatchFiles';
+import type {
+ CodeViewCommentFileByItemId,
+ CodeViewDiffStats,
+ CodeViewFileTreeSource,
+ CodeViewSavedCommentItem,
+ CommentMetadata,
+ ViewerLoadState,
+} from './types';
+
+const STREAM_PUBLISH_INTERVAL_MS = 100;
+const STREAM_INITIAL_PUBLISH_INTERVAL_MS = 500;
+const STREAM_WORK_BUDGET_MS = 8;
+const STREAM_TREE_PUBLISH_FILE_BATCH_SIZE = 1_000;
+const STREAM_TREE_PUBLISH_INTERVAL_MS = 1_000;
+const GENERIC_PATCH_LOAD_ERROR_MESSAGE =
+ 'We couldnāt load that diff. Check the URL and try again.';
+
+interface UsePatchLoaderOptions {
+ domain?: string;
+ onLoadStart(): void;
+ path: string;
+ viewerRef: RefObject | null>;
+}
+
+interface UsePatchLoaderResult {
+ commentFileByItemId: CodeViewCommentFileByItemId | null;
+ commentSections: CodeViewSavedCommentItem[];
+ diffStats: CodeViewDiffStats | null;
+ errorMessage: string | null;
+ initialItems: CodeViewItem[];
+ loadState: ViewerLoadState;
+ onLineLinkChange(selection: CodeViewLineSelection | null): void;
+ onViewerReady(): void;
+ retryLoad(): void;
+ setCommentSections: Dispatch>;
+ treeSource: CodeViewFileTreeSource | null;
+ viewerKey: number;
+}
+
+export function usePatchLoader({
+ domain,
+ onLoadStart,
+ path,
+ viewerRef,
+}: UsePatchLoaderOptions): UsePatchLoaderResult {
+ const [initialItems, setInitialItems] = useState<
+ CodeViewItem[]
+ >([]);
+ // Tree data is intentionally stored separately from items so annotation
+ // updates do not cascade into the file tree and trigger needless rebuilds.
+ // It is updated by fetch/stream batches in this viewer route.
+ const [treeSource, setTreeSource] = useState(
+ null
+ );
+ const [diffStats, setDiffStats] = useState(null);
+ const [commentFileByItemId, setCommentFileByItemId] =
+ useState(null);
+ const [commentSections, setCommentSections] = useState<
+ CodeViewSavedCommentItem[]
+ >([]);
+ const [loadState, setLoadState] = useState('fetching');
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [loadAttempt, setLoadAttempt] = useState(0);
+ const [viewerKey, setViewerKey] = useState(0);
+ const requestIdRef = useRef(0);
+ const appliedLineHashKeyRef = useRef(null);
+ const viewerKeyRef = useRef(0);
+
+ const tryApplyLineHashTarget = useStableCallback(() => {
+ const { hash } = window.location;
+ const target = parseCodeViewLineHash(hash);
+ if (target == null) {
+ return;
+ }
+
+ const applyKey = getLineHashApplyKey(viewerKeyRef.current, hash);
+ if (appliedLineHashKeyRef.current === applyKey) {
+ return;
+ }
+
+ const viewer = viewerRef.current;
+ if (viewer == null) {
+ return;
+ }
+
+ if (applyCodeViewLineHashTarget(viewer, target)) {
+ appliedLineHashKeyRef.current = applyKey;
+ }
+ });
+
+ const handleLineLinkChange = useStableCallback(
+ (selection: CodeViewLineSelection | null) => {
+ const nextHash =
+ selection == null ? null : formatCodeViewLineHash(selection);
+ appliedLineHashKeyRef.current =
+ nextHash == null
+ ? null
+ : getLineHashApplyKey(viewerKeyRef.current, nextHash);
+ replaceLocationHash(nextHash);
+ }
+ );
+
+ useEffect(() => {
+ const patchRequestKey =
+ domain == null || domain === '' ? path : `${domain}${path}`;
+ const patchSearchParams = new URLSearchParams({ path });
+ if (domain != null && domain !== '') {
+ patchSearchParams.set('domain', domain);
+ }
+
+ const controller = new AbortController();
+ const requestId = ++requestIdRef.current;
+ const isCurrentRequest = () =>
+ requestIdRef.current === requestId && !controller.signal.aborted;
+
+ viewerKeyRef.current = requestId;
+ appliedLineHashKeyRef.current = null;
+ setViewerKey(requestId);
+ setInitialItems([]);
+ setTreeSource(null);
+ setDiffStats(null);
+ setCommentFileByItemId(null);
+ setCommentSections([]);
+ onLoadStart();
+ setErrorMessage(null);
+ setLoadState('fetching');
+
+ async function loadPatch() {
+ try {
+ const cacheKeyPrefix = encodeURIComponent(patchRequestKey);
+ async function commitFullPatch(patchContent: string) {
+ if (!isCurrentRequest()) {
+ return;
+ }
+ setLoadState('parsing');
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+
+ if (!isCurrentRequest()) {
+ return;
+ }
+ const loadedData = buildCodeViewData(patchContent, patchRequestKey);
+ if (!isCurrentRequest()) {
+ return;
+ }
+
+ setTreeSource(loadedData.treeSource);
+ setCommentFileByItemId(loadedData.itemIdToFile);
+ setCommentSections([]);
+ setDiffStats(loadedData.diffStats);
+ setInitialItems(loadedData.items);
+ setLoadState('ready');
+ await yieldToBrowser();
+ if (isCurrentRequest()) {
+ tryApplyLineHashTarget();
+ }
+ }
+
+ console.time('-- request time');
+ const response = await fetch(`/api/diff?${patchSearchParams}`, {
+ cache: 'no-store',
+ signal: controller.signal,
+ });
+ console.timeEnd('-- request time');
+
+ // This only catches route setup errors. GitHub fetch failures are
+ // delivered while consuming the stream so the UI can enter the
+ // streaming state as soon as the local transport opens.
+ if (!response.ok) {
+ const detail = (await response.text()).trim();
+ throw new Error(
+ detail.length > 0 ? detail : `Request failed (${response.status}).`
+ );
+ }
+
+ if (response.body == null) {
+ console.time('-- reading patch');
+ const patchContent = await response.text();
+ console.timeEnd('-- reading patch');
+ await commitFullPatch(patchContent);
+ return;
+ }
+
+ setLoadState('streaming');
+ await yieldToBrowser();
+ if (!isCurrentRequest()) {
+ return;
+ }
+
+ const accumulator = createCodeViewDataAccumulator();
+ let streamPatchIndex = 0;
+ let streamTreePathPrefix: string | undefined;
+ let pendingPublishFileCount = 0;
+ let pendingTreePublishFileCount = 0;
+ let hasPublishedTree = false;
+ let hasPublishedInitialItems = false;
+ let hasReceivedFirstStreamedFile = false;
+ let lastPublishTime = performance.now();
+ let lastWorkYieldTime = lastPublishTime;
+ let lastTreePublishTime = lastPublishTime;
+ const initialPublishFileBatchSize = getInitialBatchSize();
+
+ const publishTreeSource = () => {
+ if (pendingTreePublishFileCount === 0 || !isCurrentRequest()) {
+ return;
+ }
+
+ pendingTreePublishFileCount = 0;
+ hasPublishedTree = true;
+ lastTreePublishTime = performance.now();
+ setCommentFileByItemId(accumulator.itemIdToFile);
+ setDiffStats({ ...accumulator.diffStats });
+ setTreeSource(snapshotCodeViewTreeSource(accumulator));
+ };
+
+ const publishPendingData = async () => {
+ if (pendingPublishFileCount === 0 || !isCurrentRequest()) {
+ return;
+ }
+
+ pendingPublishFileCount = 0;
+ lastPublishTime = performance.now();
+ const pendingItems = takePendingCodeViewItems(accumulator);
+ if (!hasPublishedInitialItems) {
+ hasPublishedInitialItems = true;
+ publishTreeSource();
+ setInitialItems(pendingItems);
+ } else {
+ const viewer = viewerRef.current;
+ if (viewer != null) {
+ viewer.addItems(pendingItems);
+ } else {
+ setInitialItems((prev) => [...prev, ...pendingItems]);
+ }
+ }
+ await yieldToBrowser();
+ if (isCurrentRequest()) {
+ tryApplyLineHashTarget();
+ }
+ lastWorkYieldTime = performance.now();
+ };
+
+ const publishPendingDataIfNeeded = async () => {
+ if (pendingPublishFileCount === 0) {
+ return;
+ }
+
+ const elapsed = performance.now() - lastPublishTime;
+ const publishFileBatchSize = hasPublishedInitialItems
+ ? CODE_VIEW_BATCH_COUNT
+ : initialPublishFileBatchSize;
+ const publishInterval = hasPublishedInitialItems
+ ? STREAM_PUBLISH_INTERVAL_MS
+ : STREAM_INITIAL_PUBLISH_INTERVAL_MS;
+ if (
+ pendingPublishFileCount < publishFileBatchSize &&
+ elapsed < publishInterval
+ ) {
+ return;
+ }
+
+ await publishPendingData();
+ };
+ const shouldDeferInitialPublishForBatchTarget = () => {
+ if (hasPublishedInitialItems) {
+ return false;
+ }
+
+ const elapsed = performance.now() - lastPublishTime;
+ return (
+ pendingPublishFileCount < initialPublishFileBatchSize &&
+ elapsed < STREAM_INITIAL_PUBLISH_INTERVAL_MS
+ );
+ };
+ const publishTreeSourceIfNeeded = () => {
+ if (pendingTreePublishFileCount === 0) {
+ return;
+ }
+
+ const elapsed = performance.now() - lastTreePublishTime;
+ if (
+ hasPublishedTree &&
+ pendingTreePublishFileCount < STREAM_TREE_PUBLISH_FILE_BATCH_SIZE &&
+ elapsed < STREAM_TREE_PUBLISH_INTERVAL_MS
+ ) {
+ return;
+ }
+
+ publishTreeSource();
+ };
+ const appendStreamedFile = async (fileText: string) => {
+ if (!hasReceivedFirstStreamedFile) {
+ hasReceivedFirstStreamedFile = true;
+ console.timeEnd('-- first streamed file');
+ }
+
+ const patchMetadata = getStreamedPatchMetadata(fileText);
+ if (patchMetadata != null) {
+ streamTreePathPrefix = getPatchTreePathPrefix(
+ patchMetadata,
+ streamPatchIndex++
+ );
+ }
+
+ const fileDiff = processFile(fileText, {
+ cacheKey: `${cacheKeyPrefix}-0-${accumulator.fileIndex}`,
+ isGitDiff: true,
+ });
+ if (fileDiff == null) {
+ return;
+ }
+
+ const itemIdRename = appendFileDiffToCodeViewData(
+ accumulator,
+ fileDiff,
+ streamTreePathPrefix
+ );
+ if (itemIdRename != null) {
+ applyCodeViewItemIdRename(viewerRef.current, itemIdRename);
+ }
+ pendingPublishFileCount++;
+ pendingTreePublishFileCount++;
+ const elapsedWork = performance.now() - lastWorkYieldTime;
+ if (elapsedWork >= STREAM_WORK_BUDGET_MS) {
+ if (shouldDeferInitialPublishForBatchTarget()) {
+ await yieldToBrowser();
+ lastWorkYieldTime = performance.now();
+ } else {
+ await publishPendingData();
+ }
+ } else {
+ await publishPendingDataIfNeeded();
+ }
+ publishTreeSourceIfNeeded();
+ };
+
+ console.time('-- first streamed file');
+ console.time('-- reading patch stream');
+ const fallbackPatchContent = await streamGitPatchFiles(
+ response.body,
+ appendStreamedFile
+ );
+ console.timeEnd('-- reading patch stream');
+ if (!isCurrentRequest()) {
+ return;
+ }
+
+ await publishPendingData();
+ publishTreeSource();
+ if (fallbackPatchContent != null) {
+ await commitFullPatch(fallbackPatchContent);
+ return;
+ }
+
+ setCommentFileByItemId(new Map(accumulator.itemIdToFile));
+ setDiffStats({ ...accumulator.diffStats });
+ setLoadState('ready');
+ } catch (error) {
+ if (!isCurrentRequest()) {
+ return;
+ }
+ console.warn('Failed to load diff', error);
+ setErrorMessage(GENERIC_PATCH_LOAD_ERROR_MESSAGE);
+ setLoadState('error');
+ }
+ }
+
+ void loadPatch();
+
+ return () => {
+ controller.abort();
+ };
+ }, [
+ domain,
+ loadAttempt,
+ onLoadStart,
+ path,
+ tryApplyLineHashTarget,
+ viewerRef,
+ ]);
+
+ useEffect(() => {
+ window.addEventListener('hashchange', tryApplyLineHashTarget);
+ tryApplyLineHashTarget();
+ return () => {
+ window.removeEventListener('hashchange', tryApplyLineHashTarget);
+ };
+ }, [tryApplyLineHashTarget]);
+
+ const retryLoad = useCallback(() => {
+ setLoadAttempt((attempt) => attempt + 1);
+ }, []);
+
+ return {
+ commentFileByItemId,
+ commentSections,
+ diffStats,
+ errorMessage,
+ initialItems,
+ loadState,
+ onLineLinkChange: handleLineLinkChange,
+ onViewerReady: tryApplyLineHashTarget,
+ retryLoad,
+ setCommentSections,
+ treeSource,
+ viewerKey,
+ };
+}
+
+function getLineHashApplyKey(viewerKey: number, hash: string): string {
+ return `${viewerKey}:${hash}`;
+}
+
+function applyCodeViewLineHashTarget(
+ viewer: CodeViewHandle,
+ target: CodeViewLineHashTarget
+): boolean {
+ const item = viewer.getItem(target.itemId);
+ if (item == null) {
+ return false;
+ }
+
+ const selectedLines = viewer.getSelectedLines();
+ if (
+ selectedLines?.id === target.itemId &&
+ areSelectionsEqual(selectedLines.range, target.range)
+ ) {
+ return true;
+ }
+
+ if (item.collapsed === true) {
+ item.collapsed = false;
+ item.version = getNextItemVersion(item);
+ if (!viewer.updateItem(item)) {
+ return false;
+ }
+ viewer.getInstance()?.render(true);
+ }
+
+ viewer.setSelectedLines({ id: target.itemId, range: target.range });
+ viewer.scrollTo({
+ type: 'range',
+ id: target.itemId,
+ range: target.range,
+ align: 'center',
+ behavior: 'instant',
+ });
+ return true;
+}
+
+function applyCodeViewItemIdRename(
+ viewer: CodeViewHandle | null,
+ rename: CodeViewItemIdRename
+): void {
+ viewer?.updateItemId(rename.oldId, rename.newId);
+}
+
+function getNextItemVersion(item: { version?: string | number }): number {
+ return typeof item.version === 'number' ? item.version + 1 : 1;
+}
+
+function replaceLocationHash(hash: string | null): void {
+ const { pathname, search } = window.location;
+ const nextHash = hash ?? '';
+ if (window.location.hash === nextHash) {
+ return;
+ }
+
+ window.history.replaceState(
+ window.history.state,
+ '',
+ `${pathname}${search}${nextHash}`
+ );
+}
+
+function yieldToBrowser(): Promise {
+ return new Promise((resolve) => {
+ let didResolve = false;
+ const resolveOnce = () => {
+ if (didResolve) {
+ return;
+ }
+
+ didResolve = true;
+ window.clearTimeout(timeout);
+ resolve();
+ };
+ const timeout = window.setTimeout(resolveOnce, 50);
+ window.requestAnimationFrame(resolveOnce);
+ });
+}
diff --git a/apps/docs/app/(diffshub)/(view)/_components/utils.ts b/apps/docs/app/(diffshub)/(view)/_components/utils.ts
new file mode 100644
index 000000000..22a7708ed
--- /dev/null
+++ b/apps/docs/app/(diffshub)/(view)/_components/utils.ts
@@ -0,0 +1,529 @@
+import type {
+ AnnotationSide,
+ ChangeTypes,
+ CodeViewDiffItem,
+ CodeViewItem,
+ DiffLineAnnotation,
+ FileDiffMetadata,
+} from '@pierre/diffs';
+import type { GitStatus } from '@pierre/trees';
+
+import type {
+ CodeViewCommentFileByItemId,
+ CodeViewDeletedCommentEvent,
+ CodeViewFileTreeSort,
+ CodeViewFileTreeSource,
+ CodeViewSavedCommentEntry,
+ CodeViewSavedCommentEvent,
+ CodeViewSavedCommentItem,
+ CommentLineType,
+ CommentMetadata,
+ DraftCommentMetadata,
+ SavedCommentMetadata,
+} from './types';
+
+const PATCH_ORDER_FALLBACK_RANK = Number.MAX_SAFE_INTEGER;
+const GITHUB_HOST = 'github.com';
+const GITHUB_RAW_DIFF_HOST = 'patch-diff.githubusercontent.com';
+const RAW_GITHUB_DIFF_PATH_PATTERN =
+ /^\/raw\/([^/]+)\/([^/]+)\/pull\/([^/]+\.(?:diff|patch))$/;
+const GITHUB_PULL_TAB_PATH_PATTERN =
+ /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/(?:changes|files)$/;
+const GITHUB_PULL_COMMIT_PATH_PATTERN =
+ /^\/([^/]+)\/([^/]+)\/pull\/\d+\/(?:changes|files)\/([0-9a-f]{4,40})$/i;
+
+// Matches GitHub shorthand "owner/repo#123" ā /owner/repo/pull/123.
+const GITHUB_SHORTHAND_PATTERN = /^([^/\s]+)\/([^/\s#]+)#(\d+)$/;
+
+// Matches bare paths like "owner/repo/pull/123" where neither of the first two
+// segments contains a dot ā a dot would indicate a domain like "github.com".
+const BARE_GITHUB_PATH_PATTERN = /^([^/\s.]+)\/([^/\s.]+)(\/[^\s]*)?$/;
+
+export function incrementItemVersion(item: CodeViewItem) {
+ item.version = typeof item.version === 'number' ? item.version + 1 : 1;
+}
+
+export function isDiffItem(
+ item: CodeViewItem
+): item is CodeViewDiffItem {
+ return item.type === 'diff';
+}
+
+export function isDraftMetadata(
+ metadata: CommentMetadata
+): metadata is DraftCommentMetadata {
+ return metadata.kind === 'draft';
+}
+
+export function isDraftAnnotation(
+ annotation: DiffLineAnnotation
+): annotation is DiffLineAnnotation {
+ return isDraftMetadata(annotation.metadata);
+}
+
+export function isSavedAnnotation(
+ annotation: DiffLineAnnotation
+): annotation is DiffLineAnnotation {
+ return annotation.metadata.kind === 'saved';
+}
+
+export function getGitHubPath(input: string): string | undefined {
+ try {
+ const parsedURL = new URL(input);
+ return getGitHubPathFromURL(parsedURL);
+ } catch {
+ return undefined;
+ }
+}
+
+// Resolves a user-supplied string into a viewer href, or undefined if the
+// input can't be mapped to a supported diff URL. Accepts full URLs, URLs
+// without a protocol (e.g. "github.com/ā¦"), bare "owner/repo/ā¦" paths, and
+// GitHub shorthand ("owner/repo#123").
+export function getPatchViewerHref(input: string): string | undefined {
+ const trimmed = input.trim();
+ if (trimmed === '') return undefined;
+
+ // GitHub shorthand: "owner/repo#123" ā "/owner/repo/pull/123"
+ const shorthandMatch = GITHUB_SHORTHAND_PATTERN.exec(trimmed);
+ if (shorthandMatch != null) {
+ return `/${shorthandMatch[1]}/${shorthandMatch[2]}/pull/${shorthandMatch[3]}`;
+ }
+
+ // Full URL with protocol (most common case).
+ try {
+ const parsedURL = new URL(trimmed);
+ const githubPath = getGitHubPathFromURL(parsedURL);
+ if (githubPath != null) return githubPath;
+ if (parsedURL.pathname !== '/') {
+ return `${parsedURL.pathname}?domain=${encodeURIComponent(parsedURL.hostname)}`;
+ }
+ return undefined;
+ } catch {
+ // Not a fully-qualified URL; try other interpretations.
+ }
+
+ // Domain-relative URL like "github.com/owner/repo/pull/123" ā only attempt
+ // when the first path segment contains a dot, indicating it's a hostname
+ // rather than an owner name. Checking only the first segment avoids false
+ // positives from dots in later segments (e.g. "v6.0...v7.0" in a compare URL).
+ const firstSegment = trimmed.split('/')[0] ?? '';
+ if (firstSegment.includes('.')) {
+ try {
+ const parsedURL = new URL(`https://${trimmed}`);
+ const githubPath = getGitHubPathFromURL(parsedURL);
+ if (githubPath != null) return githubPath;
+ if (parsedURL.pathname !== '/') {
+ return `${parsedURL.pathname}?domain=${encodeURIComponent(parsedURL.hostname)}`;
+ }
+ } catch {
+ // Not parseable even with https:// prefix.
+ }
+ }
+
+ // Bare GitHub path: "owner/repo/pull/123" or "owner/repo/compare/a...b".
+ // The dot-free first segment check above ensures we don't land here for
+ // domain-style inputs.
+ const bareMatch = BARE_GITHUB_PATH_PATTERN.exec(trimmed);
+ if (bareMatch != null) {
+ const [, owner, repo, rest = ''] = bareMatch;
+ return normalizeGitHubPath(`/${owner}/${repo}${rest}`);
+ }
+
+ return undefined;
+}
+
+export type DiffshubViewerRoute =
+ | { kind: 'redirect'; target: string }
+ | {
+ kind: 'render';
+ upstreamPath: string;
+ url: string;
+ domain: string | undefined;
+ };
+
+// Resolves the catch-all viewer route into either a redirect or the props the
+// viewer needs to render. Extracted from the route page so it can be unit
+// tested without spinning up Next.js. Empty paths redirect to the home page;
+// GitHub paths are canonicalized via normalizeGitHubPath so direct navigation
+// matches the hrefs getPatchViewerHref produces from form input. Non-GitHub
+// hosts are passed through unchanged because their canonical form is unknown.
+export function resolveDiffshubViewerRoute(
+ pathSegments: readonly string[],
+ requestedDomainInput: string | undefined
+): DiffshubViewerRoute {
+ if (pathSegments.length === 0) {
+ return { kind: 'redirect', target: '/' };
+ }
+
+ const domain =
+ requestedDomainInput == null || requestedDomainInput === ''
+ ? undefined
+ : requestedDomainInput;
+ const joinedPath = `/${pathSegments.join('/')}`;
+ const upstreamPath =
+ domain == null ? normalizeGitHubPath(joinedPath) : joinedPath;
+
+ if (upstreamPath !== joinedPath) {
+ const query = domain == null ? '' : `?domain=${encodeURIComponent(domain)}`;
+ return { kind: 'redirect', target: `${upstreamPath}${query}` };
+ }
+
+ const host = domain ?? GITHUB_HOST;
+ return {
+ domain,
+ kind: 'render',
+ upstreamPath,
+ url: `https://${host}${upstreamPath}`,
+ };
+}
+
+function getGitHubPathFromURL(parsedURL: URL): string | undefined {
+ if (parsedURL.hostname === GITHUB_HOST) {
+ if (parsedURL.pathname === '/') {
+ return undefined;
+ }
+ return normalizeGitHubPath(parsedURL.pathname);
+ }
+
+ if (parsedURL.hostname !== GITHUB_RAW_DIFF_HOST) {
+ return undefined;
+ }
+
+ const rawDiffMatch = RAW_GITHUB_DIFF_PATH_PATTERN.exec(parsedURL.pathname);
+ if (rawDiffMatch == null) {
+ return undefined;
+ }
+
+ const owner = rawDiffMatch[1];
+ const repo = rawDiffMatch[2];
+ const pullFile = rawDiffMatch[3];
+ if (owner == null || repo == null || pullFile == null) {
+ return undefined;
+ }
+
+ return `/${owner}/${repo}/pull/${pullFile}`;
+}
+
+export function normalizeGitHubPath(path: string): string {
+ const pathWithoutTrailingSlash = path.replace(/\/+$/, '');
+ const trimmedPath =
+ pathWithoutTrailingSlash === '' ? '/' : pathWithoutTrailingSlash;
+ const pullCommitMatch = GITHUB_PULL_COMMIT_PATH_PATTERN.exec(trimmedPath);
+ if (pullCommitMatch != null) {
+ return `/${pullCommitMatch[1]}/${pullCommitMatch[2]}/commit/${pullCommitMatch[3]}`;
+ }
+
+ const pullTabMatch = GITHUB_PULL_TAB_PATH_PATTERN.exec(trimmedPath);
+ if (pullTabMatch == null) {
+ return trimmedPath;
+ }
+
+ return `/${pullTabMatch[1]}/${pullTabMatch[2]}/pull/${pullTabMatch[3]}`;
+}
+
+// Translates the diff-level change type surfaced by @pierre/diffs into the
+// git-status vocabulary the file tree understands. Both rename variants fold
+// into 'renamed' so the tree shows a consistent rename badge regardless of
+// whether content also changed.
+export function mapChangeTypeToGitStatus(type: ChangeTypes): GitStatus {
+ switch (type) {
+ case 'new':
+ return 'added';
+ case 'deleted':
+ return 'deleted';
+ case 'rename-pure':
+ case 'rename-changed':
+ return 'renamed';
+ case 'change':
+ return 'modified';
+ }
+}
+
+// Records the patch-order rank for a path and every directory ancestor inside
+// `rankByPath`. Existing ranks are preserved so callers can fold new paths into
+// the same map without disturbing the ordering established for earlier paths.
+export function extendPatchOrderRanks(
+ rankByPath: Map,
+ path: string,
+ index: number
+): void {
+ if (path.length === 0) {
+ return;
+ }
+
+ if (!rankByPath.has(path)) {
+ rankByPath.set(path, index);
+ }
+
+ let slashIndex = path.lastIndexOf('/');
+ while (slashIndex > 0) {
+ const directory = path.slice(0, slashIndex);
+ if (!rankByPath.has(directory)) {
+ rankByPath.set(directory, index);
+ }
+ slashIndex = directory.lastIndexOf('/');
+ }
+}
+
+// Builds a sort comparator that reads from a `rankByPath` map populated by
+// extendPatchOrderRanks. The comparator captures the map by reference so the
+// same comparator instance can be reused across incremental publishes while
+// the map continues to grow.
+export function createPatchOrderSortFromRankMap(
+ rankByPath: ReadonlyMap
+): CodeViewFileTreeSort {
+ return (left, right) => {
+ const leftRank = rankByPath.get(left.path) ?? PATCH_ORDER_FALLBACK_RANK;
+ const rightRank = rankByPath.get(right.path) ?? PATCH_ORDER_FALLBACK_RANK;
+ if (leftRank !== rightRank) {
+ return leftRank - rightRank;
+ }
+
+ if (left.depth !== right.depth) {
+ return left.depth - right.depth;
+ }
+
+ if (left.isDirectory !== right.isDirectory) {
+ return left.isDirectory ? -1 : 1;
+ }
+
+ if (left.path === right.path) {
+ return 0;
+ }
+
+ return left.path < right.path ? -1 : 1;
+ };
+}
+
+function insertCommentInLineOrder(
+ comments: readonly CodeViewSavedCommentEntry[],
+ entry: CodeViewSavedCommentEntry
+): CodeViewSavedCommentEntry[] {
+ let existingIndex = -1;
+ for (let index = 0; index < comments.length; index++) {
+ if (comments[index]?.key === entry.key) {
+ existingIndex = index;
+ break;
+ }
+ }
+
+ const nextComments =
+ existingIndex === -1
+ ? [...comments]
+ : comments.filter((_, index) => index !== existingIndex);
+
+ let insertIndex = nextComments.length;
+ for (let index = 0; index < nextComments.length; index++) {
+ const comment = nextComments[index];
+ if (comment != null && entry.lineNumber < comment.lineNumber) {
+ insertIndex = index;
+ break;
+ }
+ }
+
+ nextComments.splice(insertIndex, 0, entry);
+ return nextComments;
+}
+
+export function upsertSavedCommentSidebarEntry(
+ sections: readonly CodeViewSavedCommentItem[],
+ commentFileByItemId: CodeViewCommentFileByItemId | null,
+ entry: CodeViewSavedCommentEvent
+): CodeViewSavedCommentItem[] {
+ const file = commentFileByItemId?.get(entry.itemId);
+ if (file == null) {
+ return [...sections];
+ }
+
+ const nextEntry: CodeViewSavedCommentEntry = {
+ author: entry.author,
+ itemId: entry.itemId,
+ key: entry.key,
+ lineNumber: entry.lineNumber,
+ lineType: entry.lineType,
+ message: entry.message,
+ range: entry.range,
+ side: entry.side,
+ };
+
+ const nextSections = [...sections];
+ let sectionIndex = -1;
+ for (let index = 0; index < nextSections.length; index++) {
+ if (nextSections[index]?.itemId === entry.itemId) {
+ sectionIndex = index;
+ break;
+ }
+ }
+
+ if (sectionIndex === -1) {
+ const nextSection: CodeViewSavedCommentItem = {
+ comments: [nextEntry],
+ fileOrder: file.fileOrder,
+ itemId: entry.itemId,
+ path: file.path,
+ };
+
+ let insertIndex = nextSections.length;
+ for (let index = 0; index < nextSections.length; index++) {
+ const section = nextSections[index];
+ if (section != null && file.fileOrder < section.fileOrder) {
+ insertIndex = index;
+ break;
+ }
+ }
+
+ nextSections.splice(insertIndex, 0, nextSection);
+ return nextSections;
+ }
+
+ const section = nextSections[sectionIndex];
+ if (section == null) {
+ return sections.slice();
+ }
+
+ nextSections[sectionIndex] = {
+ ...section,
+ comments: insertCommentInLineOrder(section.comments, nextEntry),
+ };
+ return nextSections;
+}
+
+// Returns a filtered copy of the source keeping only paths whose effective git
+// status is not in `excludedStatuses`. Paths absent from gitStatus are treated
+// as 'modified' (the accumulator intentionally omits them so the tree renders
+// them as the visual default). The original sort function is preserved so patch
+// order is maintained for the visible subset without rebuilding rank tables.
+export function filterCodeViewFileTreeSource(
+ source: CodeViewFileTreeSource,
+ excludedStatuses: ReadonlySet
+): CodeViewFileTreeSource {
+ if (excludedStatuses.size === 0) return source;
+
+ const pathStatusMap = new Map(
+ source.gitStatus.map((e) => [e.path, e.status])
+ );
+
+ const filteredPaths = source.paths.filter((path) => {
+ const status = pathStatusMap.get(path) ?? 'modified';
+ return !excludedStatuses.has(status);
+ });
+
+ const filteredGitStatus = source.gitStatus.filter(
+ (e) => !excludedStatuses.has(e.status)
+ );
+
+ const filteredPathToItemId = new Map();
+ for (const path of filteredPaths) {
+ const id = source.pathToItemId.get(path);
+ if (id != null) {
+ filteredPathToItemId.set(path, id);
+ }
+ }
+
+ return {
+ gitStatus: filteredGitStatus,
+ pathCount: filteredPaths.length,
+ paths: filteredPaths,
+ pathToItemId: filteredPathToItemId,
+ sort: source.sort,
+ };
+}
+
+// Returns the set of GitStatus values that are actually present in the source.
+// Paths not listed in gitStatus are treated as 'modified'.
+export function getCodeViewFileTreeAvailableStatuses(
+ source: CodeViewFileTreeSource
+): Set {
+ const statuses = new Set(source.gitStatus.map((e) => e.status));
+ const pathsWithExplicitStatus = new Set(source.gitStatus.map((e) => e.path));
+ if (source.paths.some((p) => !pathsWithExplicitStatus.has(p))) {
+ statuses.add('modified');
+ }
+ return statuses;
+}
+
+export function removeSavedCommentSidebarEntry(
+ sections: readonly CodeViewSavedCommentItem[],
+ entry: CodeViewDeletedCommentEvent
+): CodeViewSavedCommentItem[] {
+ let sectionIndex = -1;
+ for (let index = 0; index < sections.length; index++) {
+ if (sections[index]?.itemId === entry.itemId) {
+ sectionIndex = index;
+ break;
+ }
+ }
+
+ if (sectionIndex === -1) {
+ return sections.slice();
+ }
+
+ const section = sections[sectionIndex];
+ if (section == null) {
+ return sections.slice();
+ }
+
+ const nextComments = section.comments.filter(
+ (comment) => comment.key !== entry.key
+ );
+ if (nextComments.length === section.comments.length) {
+ return sections.slice();
+ }
+
+ if (nextComments.length === 0) {
+ return sections.filter((_, index) => index !== sectionIndex);
+ }
+
+ const nextSections = [...sections];
+ nextSections[sectionIndex] = {
+ ...section,
+ comments: nextComments,
+ };
+ return nextSections;
+}
+
+// Classifies a 1-based line number on a given diff side as either an actual
+// addition/deletion or an unchanged context line. The sidebar uses this to
+// avoid rendering "+13" / "-13" for comments anchored to lines that are
+// rendered as context (and therefore weren't actually added or removed).
+//
+// Walks each hunk's ordered `hunkContent` while tracking the running line
+// number on the requested side. A context block of N lines advances by N on
+// both sides; a change block advances by `additions` on the addition side and
+// `deletions` on the deletion side. Mirrors the walk pattern used by
+// FileDiff.getLineIndex inside `@pierre/diffs`.
+export function classifyCommentLineType(
+ fileDiff: FileDiffMetadata,
+ side: AnnotationSide,
+ lineNumber: number
+): CommentLineType {
+ for (const hunk of fileDiff.hunks) {
+ let currentLineNumber =
+ side === 'additions' ? hunk.additionStart : hunk.deletionStart;
+ const hunkCount =
+ side === 'additions' ? hunk.additionCount : hunk.deletionCount;
+ if (
+ lineNumber < currentLineNumber ||
+ lineNumber >= currentLineNumber + hunkCount
+ ) {
+ continue;
+ }
+ for (const content of hunk.hunkContent) {
+ const blockLength =
+ content.type === 'context'
+ ? content.lines
+ : side === 'additions'
+ ? content.additions
+ : content.deletions;
+ if (blockLength === 0) {
+ continue;
+ }
+ if (lineNumber < currentLineNumber + blockLength) {
+ return content.type === 'context' ? 'context' : 'change';
+ }
+ currentLineNumber += blockLength;
+ }
+ }
+ return 'change';
+}
diff --git a/apps/docs/app/(diffshub)/_components/DiffUrlForm.tsx b/apps/docs/app/(diffshub)/_components/DiffUrlForm.tsx
new file mode 100644
index 000000000..8f900253e
--- /dev/null
+++ b/apps/docs/app/(diffshub)/_components/DiffUrlForm.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useStableCallback } from '@pierre/diffs/react';
+import { IconX } from '@pierre/icons';
+import { useRouter } from 'next/navigation';
+import {
+ type FormEvent,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+ useTransition,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+import { getPatchViewerHref } from '../(view)/_components/utils';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface DiffUrlFormProps {
+ className?: string;
+ // When provided, the input restores to this value on blur or Escape. Also
+ // controls the clear-button visibility: with an initialUrl set, the clear
+ // button only shows when the input matches the committed URL or has an error
+ // (i.e. not while the user is typing). Without an initialUrl the clear
+ // button shows whenever the input has content.
+ initialUrl?: string;
+ inputClassName?: string;
+ // Called whenever the controlled URL value changes, so parent components
+ // can react to edits (e.g. to conditionally show/hide related controls).
+ onUrlChange?: (url: string) => void;
+ placeholder?: string;
+ // Render prop for the submit button area. Receives the transition pending
+ // state and current URL value so callers can conditionally render controls.
+ children?: (isPending: boolean, url: string) => ReactNode;
+}
+
+// Shared URL input form used in both the viewer header and the home page.
+// Handles URL state, validation via getPatchViewerHref, router navigation,
+// the validation error popover (portal-based to escape contain-paint), and
+// escape/blur restore behavior.
+export function DiffUrlForm({
+ className,
+ initialUrl = '',
+ inputClassName,
+ onUrlChange,
+ placeholder,
+ children,
+}: DiffUrlFormProps) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [url, setURL] = useState(initialUrl);
+ const [validationError, setValidationError] = useState(null);
+ // Tracks the input's viewport position when an error is shown so the portal
+ // can be fixed-positioned outside any contain-paint boundary.
+ const [errorAnchor, setErrorAnchor] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ // Preserves the last message so the popover still has content while fading out.
+ const lastErrorText = useRef(null);
+ // Prevents the onBlur restore from firing when blur is caused by Enter.
+ const isSubmittingRef = useRef(false);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setURL(initialUrl);
+ }, [initialUrl]);
+
+ useEffect(() => {
+ onUrlChange?.(url);
+ }, [onUrlChange, url]);
+
+ // Keep the portal position in sync with the input whenever it's visible.
+ // Resize (including DevTools opening) and scroll both change the input's
+ // viewport position, so we re-measure on those events.
+ useEffect(() => {
+ if (errorAnchor === null) return;
+
+ const updatePosition = () => {
+ const rect = inputRef.current?.getBoundingClientRect();
+ if (rect != null) setErrorAnchor({ top: rect.bottom, left: rect.left });
+ };
+
+ window.addEventListener('resize', updatePosition);
+ window.addEventListener('scroll', updatePosition, true);
+ return () => {
+ window.removeEventListener('resize', updatePosition);
+ window.removeEventListener('scroll', updatePosition, true);
+ };
+ }, [errorAnchor]);
+
+ const handleSubmit = useStableCallback(
+ (event: FormEvent) => {
+ event.preventDefault();
+ isSubmittingRef.current = false;
+ const normalizedURL = url.trim();
+ const viewerHref = getPatchViewerHref(normalizedURL);
+ if (viewerHref == null) {
+ const rect = inputRef.current?.getBoundingClientRect();
+ if (rect != null) setErrorAnchor({ top: rect.bottom, left: rect.left });
+ lastErrorText.current = 'Please enter a valid URL';
+ setValidationError('Please enter a valid URL');
+ return;
+ }
+ setValidationError(null);
+ setURL(normalizedURL);
+ startTransition(() => {
+ router.push(viewerHref);
+ });
+ }
+ );
+
+ // Show the clear button when the input has content. When an initialUrl is
+ // set (viewer header), hide it while the user is actively editing so it
+ // doesn't distract ā restore it once committed or on error.
+ const showClear =
+ url.length > 0 &&
+ (initialUrl === '' || url === initialUrl || validationError !== null);
+
+ return (
+
+ {
+ setURL(currentTarget.value);
+ if (validationError) setValidationError(null);
+ }}
+ onBlur={() => {
+ if (isSubmittingRef.current) return;
+ // Only restore the committed URL when the field is empty ā if the
+ // user typed something and clicked away, keep their draft.
+ if (url.trim() === '') {
+ setURL(initialUrl);
+ setValidationError(null);
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ setURL(initialUrl);
+ setValidationError(null);
+ inputRef.current?.blur();
+ } else if (e.key === 'Enter') {
+ isSubmittingRef.current = true;
+ }
+ }}
+ placeholder={placeholder}
+ />
+ {showClear && (
+ {
+ setURL('');
+ setValidationError(null);
+ inputRef.current?.focus();
+ }}
+ >
+
+
+ )}
+ {children?.(isPending, url)}
+ {/* Hidden submit ensures Enter triggers form submission in all browsers */}
+
+ {errorAnchor !== null &&
+ createPortal(
+ {
+ if (validationError === null) setErrorAnchor(null);
+ }}
+ >
+
+ {lastErrorText.current}
+
,
+ document.body
+ )}
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/_home/Home.tsx b/apps/docs/app/(diffshub)/_home/Home.tsx
new file mode 100644
index 000000000..996931b0e
--- /dev/null
+++ b/apps/docs/app/(diffshub)/_home/Home.tsx
@@ -0,0 +1,162 @@
+import {
+ IconArrowRightShort,
+ IconBrandDiscord,
+ IconBrandGithub,
+ IconBrandTwitterX,
+} from '@pierre/icons';
+import Link from 'next/link';
+
+import { DiffsHubLogo } from '../(view)/_components/DiffsHubLogo';
+import { getGitHubPath } from '../(view)/_components/utils';
+
+const DIFF_LINE_BADGE = 'inline-flex rounded-r py-0.25 pr-1.5 pl-1.5';
+const DIFF_LINE_DELETED_BADGE = `${DIFF_LINE_BADGE} bg-[#ff6762]/15 text-[#ff2e3f] dark:bg-[#ff6762]/10 dark:text-[#ff6762]`;
+const DIFF_LINE_ADDED_BADGE = `${DIFF_LINE_BADGE} bg-[#07c480]/15 text-[#18a46c] dark:bg-[#07c480]/10 dark:text-[#07c480]`;
+import { HomeFetchForm } from './HomeFetchForm';
+import { ScrollDownButton } from './ScrollDownButton';
+
+function Divider() {
+ return ;
+}
+
+const EXAMPLE_URLS = [
+ 'oven-sh/bun/pull/30412',
+ 'nodejs/node/pull/59805',
+ 'ghostty-org/ghostty/pull/12291',
+] as const;
+
+const SOCIAL_LINKS = [
+ {
+ label: 'X',
+ href: 'https://x.com/pierrecomputer',
+ Icon: IconBrandTwitterX,
+ },
+ {
+ label: 'Discord',
+ href: 'https://discord.gg/pierre',
+ Icon: IconBrandDiscord,
+ },
+ {
+ label: 'GitHub',
+ href: 'https://github.com/pierrecomputer/pierre',
+ Icon: IconBrandGithub,
+ },
+];
+
+export default function DiffshubHome() {
+ return (
+
+
+
+
+ DiffsHub
+
+
+ View code changes from any public GitHub diffāPRs, comparisons,
+ commits, diffs, and patchesāwith a super-freaking-fast, beautiful, and
+ virtualized interface by replacing github.com with{' '}
+ diffshub.com.
+
+
+
+ - github
+ .com/org/repo/pull/number
+
+
+ + diffshub
+ .com/org/repo/pull/number
+
+
+
+
+
+ Enter a URL above, or use one of these:
+
+
+
+ You can also compare millions of lines with ease, like{' '}
+
+ v6...v7 of Linux
+
+ . This sometimes crashes mobile browsers, and GitHub unreliably
+ serves diffs over 100k lines with a delayed first byte.
+
+
+
+
+
+
+
+ Built by{' '}
+
+ The Pierre Computer Company
+ {' '}
+ with{' '}
+
+ FileTree
+ {' '}
+ and the new{' '}
+
+ CodeView
+ {' '}
+ component.
+
+
+ {SOCIAL_LINKS.map(({ label, href, Icon }) => (
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffshub)/_home/HomeFetchForm.tsx b/apps/docs/app/(diffshub)/_home/HomeFetchForm.tsx
new file mode 100644
index 000000000..60d2ba529
--- /dev/null
+++ b/apps/docs/app/(diffshub)/_home/HomeFetchForm.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { IconArrow } from '@pierre/icons';
+import { memo } from 'react';
+
+import { DiffUrlForm } from '../_components/DiffUrlForm';
+import { Button } from '@/components/ui/button';
+
+// Submitting the home form should move to the shareable viewer URL first. The
+// viewer route owns fetching and renders its own loading state there.
+export const HomeFetchForm = memo(function HomeFetchForm() {
+ return (
+
+
+ {(isPending, url) => (
+
+
+
+ )}
+
+
+ );
+});
diff --git a/apps/docs/app/(diffshub)/_home/ScrollDownButton.tsx b/apps/docs/app/(diffshub)/_home/ScrollDownButton.tsx
new file mode 100644
index 000000000..61c7d83ca
--- /dev/null
+++ b/apps/docs/app/(diffshub)/_home/ScrollDownButton.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { IconChevronFlat } from '@pierre/icons';
+
+export function ScrollDownButton() {
+ return (
+
+ document
+ .getElementById('home-more')
+ ?.scrollIntoView({ behavior: 'smooth' })
+ }
+ >
+
+
+ );
+}
diff --git a/apps/docs/app/(trees)/_components/DemoThemingClient.tsx b/apps/docs/app/(trees)/_components/DemoThemingClient.tsx
index b84df4560..7e95aa1bf 100644
--- a/apps/docs/app/(trees)/_components/DemoThemingClient.tsx
+++ b/apps/docs/app/(trees)/_components/DemoThemingClient.tsx
@@ -39,6 +39,7 @@ import { PRODUCTS } from '@/lib/product-config';
const LIGHT_THEMES = [
'pierre-light',
+ 'pierre-light-soft',
'catppuccin-latte',
'everforest-light',
'github-light',
@@ -61,6 +62,7 @@ const LIGHT_THEMES = [
const DARK_THEMES = [
'pierre-dark',
+ 'pierre-dark-soft',
'andromeeda',
'aurora-x',
'ayu-dark',
diff --git a/apps/docs/app/api/diff/route.ts b/apps/docs/app/api/diff/route.ts
new file mode 100644
index 000000000..93d606b83
--- /dev/null
+++ b/apps/docs/app/api/diff/route.ts
@@ -0,0 +1,388 @@
+import { type NextRequest } from 'next/server';
+
+const CACHE_CONTROL = 'no-store';
+const EMPTY_PATCH_MESSAGE = 'GitHub returned an empty diff.';
+const GITHUB_HOST = 'github.com';
+const GITHUB_RAW_DIFF_HOST = 'patch-diff.githubusercontent.com';
+const NON_DIFF_RESPONSE_MESSAGE = 'GitHub did not return a diff for this URL.';
+const NON_WHITESPACE_PATTERN = /\S/;
+const RAW_GITHUB_DIFF_PATH_PATTERN =
+ /^\/raw\/[^/]+\/[^/]+\/pull\/[^/]+\.(?:diff|patch)$/;
+const GITHUB_PULL_TAB_PATH_PATTERN =
+ /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/(?:changes|files)$/;
+
+const CACHED_BLOBS = new Map([
+ [
+ '/nodejs/oven-sh/bun/pull/30412',
+ 'https://diffshub.pierrecdn.com/patches/30412.diff',
+ ],
+ [
+ '/nodejs/node/pull/59805',
+ 'https://diffshub.pierrecdn.com/patches/59805.diff',
+ ],
+ [
+ '/ghostty-org/ghostty/pull/12291',
+ 'https://diffshub.pierrecdn.com/patches/12291.diff',
+ ],
+ [
+ '/pierrecomputer/pierre/commit/0800fb',
+ 'https://diffshub.pierrecdn.com/patches/0800fb.diff',
+ ],
+ [
+ '/torvalds/linux/compare/v6.0...v7.0',
+ 'https://diffshub.pierrecdn.com/patches/v6.0-v7.0.diff',
+ ],
+]);
+
+const HIDDEN_PATCH_DOMAIN_RULES = [
+ { domainRoot: 'tangled.org', defaultExtension: '.patch' },
+] as const;
+
+interface ResolvedPatchRequest {
+ patchURL: string;
+ sourceURL?: string;
+}
+
+// Validates the accepted path or URL, normalizes it to a raw diff URL, and
+// returns a streaming proxy response so the client can render files as they
+// arrive instead of waiting for the full patch text.
+export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const path = searchParams.get('path');
+ const domain = searchParams.get('domain');
+ const url = searchParams.get('url');
+
+ if (path == null && url == null) {
+ return createTextResponse('Path or URL parameter is required', {
+ status: 400,
+ });
+ }
+
+ try {
+ // The client normally sends only the GitHub-relative path, but GitHub also
+ // exposes raw PR diffs through patch-diff.githubusercontent.com. Tangled
+ // paths use an explicit domain query parameter and are normalized to their
+ // patch endpoint.
+ const patchRequest = resolvePatchRequest(path, domain, url);
+ if (patchRequest == null) {
+ return createTextResponse('Invalid GitHub patch URL format', {
+ status: 400,
+ });
+ }
+
+ return await createPatchStreamResponse(
+ patchRequest.patchURL,
+ request.signal,
+ {
+ sourceURL: patchRequest.sourceURL ?? patchRequest.patchURL,
+ }
+ );
+ } catch (error) {
+ return createTextResponse(
+ error instanceof Error ? error.message : 'Unknown error',
+ { status: 500 }
+ );
+ }
+}
+
+// Resolves the accepted URL shapes to the exact upstream URL to fetch. Most
+// callers send a GitHub-relative path, but this also permits GitHub's raw PR
+// diff host and Tangled patch URLs without becoming a general URL fetcher.
+function resolvePatchRequest(
+ path: string | null,
+ domain: string | null,
+ url: string | null
+): ResolvedPatchRequest | undefined {
+ if (url != null) {
+ return resolvePatchURLInput(url);
+ }
+
+ if (path == null) {
+ return undefined;
+ }
+
+ if (domain != null) {
+ const patchURL = resolveDomainPatchURL(domain, path);
+ return patchURL == null ? undefined : { patchURL };
+ }
+
+ return resolvePatchURLInput(path);
+}
+
+function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined {
+ if (input.startsWith('/')) {
+ return resolveGitHubPatchRequest(input);
+ }
+
+ let parsedURL: URL;
+ try {
+ parsedURL = new URL(input);
+ } catch {
+ return undefined;
+ }
+
+ if (!isAllowedHTTPSURL(parsedURL)) {
+ return undefined;
+ }
+
+ if (parsedURL.hostname === GITHUB_HOST) {
+ return resolveGitHubPatchRequest(parsedURL.pathname);
+ }
+
+ if (
+ parsedURL.hostname === GITHUB_RAW_DIFF_HOST &&
+ RAW_GITHUB_DIFF_PATH_PATTERN.test(parsedURL.pathname)
+ ) {
+ return { patchURL: parsedURL.href };
+ }
+
+ const domainPatchURL = resolveDomainPatchURL(
+ parsedURL.hostname,
+ parsedURL.pathname
+ );
+ return domainPatchURL == null ? undefined : { patchURL: domainPatchURL };
+}
+
+function resolveGitHubPatchRequest(
+ path: string
+): ResolvedPatchRequest | undefined {
+ const patchURL = resolveGitHubPath(path);
+ return patchURL == null ? undefined : { patchURL };
+}
+
+function resolveDomainPatchURL(
+ domain: string,
+ path: string
+): string | undefined {
+ const domainRule = getHiddenPatchDomainRule(domain);
+ if (domainRule == null) {
+ return undefined;
+ }
+
+ const pathWithLeadingSlash = path.startsWith('/') ? path : `/${path}`;
+ const url = new URL(`https://${domainRule.hostname}`);
+ const normalizedPath = pathWithLeadingSlash.replace(/\/+$/, '');
+ url.pathname = normalizedPath === '' ? '/' : normalizedPath;
+ if (!url.pathname.endsWith(domainRule.defaultExtension)) {
+ url.pathname += domainRule.defaultExtension;
+ }
+
+ return url.href;
+}
+
+function getHiddenPatchDomainRule(
+ domain: string
+): { defaultExtension: string; hostname: string } | undefined {
+ let hostname: string;
+ try {
+ hostname = new URL(`https://${domain}`).hostname;
+ } catch {
+ return undefined;
+ }
+
+ for (const domainRule of HIDDEN_PATCH_DOMAIN_RULES) {
+ if (
+ hostname === domainRule.domainRoot ||
+ hostname.endsWith(`.${domainRule.domainRoot}`)
+ ) {
+ return { defaultExtension: domainRule.defaultExtension, hostname };
+ }
+ }
+
+ return undefined;
+}
+
+function resolveGitHubPath(path: string): string | undefined {
+ if (path === '/') {
+ return undefined;
+ }
+
+ let patchPath = normalizeGitHubPath(path);
+ if (patchPath === '') {
+ return undefined;
+ }
+
+ const blobPatchURL = CACHED_BLOBS.get(removeDiffExtension(patchPath));
+ if (blobPatchURL != null) {
+ return blobPatchURL;
+ }
+
+ if (!patchPath.endsWith('.patch') && !patchPath.endsWith('.diff')) {
+ patchPath += '.diff';
+ }
+
+ return `https://${GITHUB_HOST}${patchPath}`;
+}
+
+function removeDiffExtension(path: string): string {
+ if (path.endsWith('.patch')) {
+ return path.slice(0, -'.patch'.length);
+ }
+
+ if (path.endsWith('.diff')) {
+ return path.slice(0, -'.diff'.length);
+ }
+
+ return path;
+}
+
+function normalizeGitHubPath(path: string): string {
+ const trimmedPath = path.replace(/\/+$/, '');
+ const pullTabMatch = GITHUB_PULL_TAB_PATH_PATTERN.exec(trimmedPath);
+ if (pullTabMatch == null) {
+ return trimmedPath;
+ }
+
+ return `/${pullTabMatch[1]}/${pullTabMatch[2]}/pull/${pullTabMatch[3]}`;
+}
+
+function isAllowedHTTPSURL(url: URL): boolean {
+ return (
+ url.protocol === 'https:' &&
+ url.port === '' &&
+ url.username === '' &&
+ url.password === ''
+ );
+}
+
+interface TextResponseOptions {
+ status?: number;
+ sourceURL?: string;
+}
+
+// Serves local patch fixtures through the same response path as GitHub data,
+// while rejecting empty files so the viewer does not enter a silent no-op
+// state.
+function createPatchTextResponse(
+ patchText: string,
+ options: Omit
+): Response {
+ if (!NON_WHITESPACE_PATTERN.test(patchText)) {
+ return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 });
+ }
+
+ return createTextResponse(patchText, options);
+}
+
+// Validates the upstream response before opening the client-facing stream so
+// GitHub HTML pages and redirects become small text errors instead of Next.js
+// error documents.
+async function createPatchStreamResponse(
+ patchURL: string,
+ requestSignal: AbortSignal,
+ options: Omit
+): Promise {
+ const upstreamController = new AbortController();
+ const abortUpstream = () => upstreamController.abort();
+ requestSignal.addEventListener('abort', abortUpstream, { once: true });
+
+ let response: Response;
+ try {
+ response = await fetch(patchURL, {
+ cache: 'no-store',
+ headers: { 'User-Agent': 'pierre-diffshub' },
+ signal: upstreamController.signal,
+ });
+ } catch {
+ requestSignal.removeEventListener('abort', abortUpstream);
+ return createTextResponse('Failed to fetch patch.', { status: 502 });
+ }
+
+ if (!response.ok) {
+ const status = response.status >= 400 ? response.status : 502;
+ requestSignal.removeEventListener('abort', abortUpstream);
+ return createTextResponse(
+ `Failed to fetch patch: ${response.status} ${response.statusText}`,
+ { status }
+ );
+ }
+
+ const contentType = response.headers.get('Content-Type');
+ if (contentType == null || !contentType.startsWith('text/plain')) {
+ requestSignal.removeEventListener('abort', abortUpstream);
+ return createTextResponse(NON_DIFF_RESPONSE_MESSAGE, { status: 415 });
+ }
+
+ if (response.headers.get('Content-Length') === '0') {
+ requestSignal.removeEventListener('abort', abortUpstream);
+ return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 });
+ }
+
+ const responseBody = response.body;
+ if (responseBody == null) {
+ try {
+ const patchText = await response.text();
+ return createPatchTextResponse(patchText, options);
+ } finally {
+ requestSignal.removeEventListener('abort', abortUpstream);
+ }
+ }
+
+ const stream = new ReadableStream({
+ start(controller) {
+ void pumpPatchBody(responseBody, controller).finally(() => {
+ requestSignal.removeEventListener('abort', abortUpstream);
+ });
+ },
+ cancel() {
+ abortUpstream();
+ requestSignal.removeEventListener('abort', abortUpstream);
+ },
+ });
+
+ return createTextResponse(stream, options);
+}
+
+// Forwards each validated upstream diff chunk into the client stream.
+async function pumpPatchBody(
+ body: ReadableStream,
+ controller: ReadableStreamDefaultController
+): Promise {
+ try {
+ const reader = body.getReader();
+ let sawContent = false;
+ try {
+ for (;;) {
+ const result = await reader.read();
+ if (result.done) {
+ break;
+ }
+
+ if (result.value.byteLength > 0) {
+ sawContent = true;
+ controller.enqueue(result.value);
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ if (!sawContent) {
+ throw new Error(EMPTY_PATCH_MESSAGE);
+ }
+
+ controller.close();
+ } catch (error) {
+ controller.error(error);
+ }
+}
+
+// Centralizes text response headers for both stream and error bodies. Diff
+// responses are intentionally not cached in the browser because cached 100MB+
+// responses can replay poorly and delay the first useful diff bytes.
+function createTextResponse(
+ body: string | ReadableStream,
+ { status = 200, sourceURL }: TextResponseOptions = {}
+): Response {
+ const headers = new Headers({
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Cache-Control': CACHE_CONTROL,
+ });
+ if (sourceURL != null) {
+ headers.set('X-Patch-Source', sourceURL);
+ }
+ return new Response(body, {
+ status,
+ headers,
+ });
+}
diff --git a/apps/docs/app/docs/page.tsx b/apps/docs/app/docs/page.tsx
index 28ec567fa..b317091df 100644
--- a/apps/docs/app/docs/page.tsx
+++ b/apps/docs/app/docs/page.tsx
@@ -1,4 +1,8 @@
// Build-time dispatcher for `/docs`: see app/page.tsx for rationale.
+// Diffshub has no docs (yet), so on that site `/docs` permanently redirects
+// to the home page rather than rendering a separate doc shell.
+import { redirect } from 'next/navigation';
+
import DiffsDocsPage, {
metadata as diffsDocsMetadata,
} from '../(diffs)/_docs/DocsPage';
@@ -6,9 +10,19 @@ import TreesDocsPage, {
metadata as treesDocsMetadata,
} from '../(trees)/_docs/DocsPage';
-const isTrees = process.env.NEXT_PUBLIC_SITE === 'trees';
+const SITE = process.env.NEXT_PUBLIC_SITE;
+const isTrees = SITE === 'trees';
+const isDiffshub = SITE === 'diffshub';
export const metadata = isTrees ? treesDocsMetadata : diffsDocsMetadata;
-const Page = isTrees ? TreesDocsPage : DiffsDocsPage;
+function DiffshubDocsPage(): never {
+ redirect('/');
+}
+
+const Page = isDiffshub
+ ? DiffshubDocsPage
+ : isTrees
+ ? TreesDocsPage
+ : DiffsDocsPage;
export default Page;
diff --git a/apps/docs/app/gh/page.tsx b/apps/docs/app/gh/page.tsx
new file mode 100644
index 000000000..fc6f82edb
--- /dev/null
+++ b/apps/docs/app/gh/page.tsx
@@ -0,0 +1,5 @@
+import { permanentRedirect } from 'next/navigation';
+
+export default function GitHubRedirectPage() {
+ permanentRedirect('https://diffshub.com');
+}
diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css
index c58dcc9e1..2a917251f 100644
--- a/apps/docs/app/globals.css
+++ b/apps/docs/app/globals.css
@@ -26,6 +26,7 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
+ --color-border-opaque: var(--border-opaque);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
@@ -62,6 +63,11 @@
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
+ --border-opaque: color-mix(
+ in oklch,
+ var(--border) 100%,
+ var(--diffshub-sidebar-bg) 0%
+ );
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
@@ -77,6 +83,9 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
+
+ --diffshub-sidebar-bg: #f7f7f7;
+
color-scheme: light;
}
@@ -97,6 +106,11 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
+ --border-opaque: color-mix(
+ in oklch,
+ var(--diffshub-sidebar-bg) 90%,
+ oklch(1 0 0)
+ );
--color-border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@@ -113,6 +127,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
+ --diffshub-sidebar-bg: #101010;
color-scheme: dark;
}
@@ -134,6 +149,11 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
+ --border-opaque: color-mix(
+ in oklch,
+ var(--diffshub-sidebar-bg) 90%,
+ oklch(1 0 0)
+ );
--color-border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@@ -156,6 +176,10 @@
:root {
--diffs-font-family: var(--font-berkeley-mono);
+ --cv-gutter-vertical: 0px;
+ --cv-gutter-horizontal: 0px;
+ --cv-mini-gutter-vertical: 0px;
+ --cv-mini-gutter-horizontal: 0px;
color-scheme: light;
-webkit-font-smoothing: antialiased;
}
@@ -165,7 +189,7 @@
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground font-geist;
+ @apply bg-[var(--diffshub-sidebar-bg,_var(--color-background))] text-foreground font-geist;
}
textarea {
@apply outline-offset-1;
@@ -175,7 +199,7 @@
font-size: 95%;
font-weight: 500;
color: var(--color-primary);
- letter-spacing: -0.02em;
+ letter-spacing: -0.025em;
}
h2,
@@ -216,7 +240,7 @@
}
.inline-link {
- @apply text-foreground hover:text-foreground decoration-muted-foreground hover:decoration-foreground underline decoration-[1px] underline-offset-3 transition-colors;
+ @apply text-foreground hover:text-foreground decoration-muted-foreground hover:decoration-foreground underline decoration-[1px] underline-offset-3 transition-[color,text-decoration-color,text-underline-offset];
}
.no-scrollbar::-webkit-scrollbar {
@@ -243,6 +267,70 @@
@apply overflow-hidden rounded-lg border border-transparent dark:border-neutral-800;
}
+.cv-scrollbar {
+ --cv-scrollbar-thumb-bg: light-dark(
+ color-mix(in lab, var(--background) 85%, black),
+ color-mix(in lab, var(--background) 80%, white)
+ );
+ scrollbar-gutter: stable;
+}
+
+.cv-scrollbar::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+.cv-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ border-left: 1px solid var(--color-border-opaque);
+ margin-top: -1px;
+}
+
+.cv-scrollbar::-webkit-scrollbar-thumb {
+ background-color: var(--cv-scrollbar-thumb-bg);
+ background-clip: content-box;
+ border: 2px solid transparent;
+ border-radius: 6px;
+}
+
+.cv-mini-scrollbar {
+ --cv-mini-scrollbar-thumb-bg: transparent;
+ scrollbar-gutter: stable;
+}
+
+.cv-mini-scrollbar::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+.cv-mini-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.cv-mini-scrollbar:hover {
+ --cv-mini-scrollbar-thumb-bg: light-dark(
+ color-mix(in lab, var(--background) 85%, black),
+ color-mix(in lab, var(--background) 80%, white)
+ );
+}
+
+.cv-mini-scrollbar::-webkit-scrollbar-thumb {
+ background-color: var(--cv-mini-scrollbar-thumb-bg);
+ background-clip: content-box;
+ border: 3px solid transparent;
+ border-radius: 5px;
+}
+
+@supports (-moz-appearance: none) {
+ .cv-scrollbar {
+ scrollbar-color: var(--cv-scrollbar-thumb-bg) transparent;
+ }
+ .cv-mini-scrollbar {
+ scrollbar-color: var(--cv-mini-scrollbar-thumb-bg) transparent;
+ scrollbar-width: thin;
+ }
+}
+
[data-slot='header'] {
@apply border-b border-transparent transition-[border-color] duration-200;
}
@@ -255,7 +343,7 @@
@apply bg-background fixed top-16 right-4 left-4 z-[60];
@apply max-h-[80dvh] overflow-y-auto;
@apply -translate-y-2;
- @apply rounded-xl border border-[rgb(0_0_0_/_0.15)] dark:border-[rgb(255_255_255_/_0.1)] bg-clip-padding p-4;
+ @apply rounded-xl border border-[rgb(0_0_0_/_0.15)] bg-clip-padding p-4 dark:border-[rgb(255_255_255_/_0.1)];
@apply opacity-0 shadow-2xl;
@apply pointer-events-none transition-all duration-200 ease-out;
@apply [scrollbar-color:transparent_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin];
@@ -266,6 +354,48 @@
}
}
+/* FAQ accordion: animate expand/collapse using the ::details-content pseudo-element
+ (Chrome 131+, Firefox 143+, Safari 18.4+). In older browsers the items still
+ work ā they just open instantly. height animates to `auto` via interpolate-size
+ when supported; falls back to a fixed cap with scroll otherwise. */
+@supports selector(::details-content) {
+ .faq-item {
+ interpolate-size: allow-keywords;
+ }
+
+ .faq-item::details-content {
+ block-size: 0;
+ overflow-y: clip;
+ transition:
+ content-visibility 0.2s ease allow-discrete,
+ block-size 0.2s;
+ }
+
+ .faq-item[open]::details-content {
+ block-size: auto;
+ }
+}
+
+.diffshub-border-deleted {
+ display: inline-flex;
+ align-items: stretch;
+}
+
+.diffshub-border-deleted::before {
+ display: block;
+ content: '';
+ width: 4px;
+ height: 24px;
+ background-image: linear-gradient(
+ 0deg,
+ light-dark(#ff2e3f, #ff6762) 50%,
+ light-dark(#ffdbd6, #3e1715) 50%
+ );
+ background-repeat: repeat;
+ background-size: 2px 2px;
+ background-size: calc(1lh / round(1lh / 2px)) calc(1lh / round(1lh / 2px));
+}
+
/* Docs sidebar: shares mobile popover styles, adds desktop sticky overrides */
.docs-sidebar {
/* Desktop sticky sidebar */
diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx
index d4dd1f8e2..7ed5fb5a7 100644
--- a/apps/docs/app/layout.tsx
+++ b/apps/docs/app/layout.tsx
@@ -11,7 +11,11 @@ import {
import localFont from 'next/font/local';
import './globals.css';
+import { Fragment } from 'react';
+
+import { WorkerPoolContext } from './(diffs)/_components/WorkerPoolContext';
import { PreloadHighlighter } from '@/components/PreloadHighlighter';
+import { ScrollbarGutterVariables } from '@/components/ScrollbarGutterVariables';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import { type ProductId, PRODUCTS } from '@/lib/product-config';
@@ -57,6 +61,11 @@ const geistMono = Geist_Mono({
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
+ userScalable: false,
+ ...(process.env.NEXT_PUBLIC_SITE === 'diffshub' && {
+ maximumScale: 1,
+ viewportFit: 'cover',
+ }),
};
// When running in a worktree, prefix the title with a stable emoji + slug so
@@ -89,40 +98,64 @@ function worktreeTitlePrefix(): string {
const WORKTREE_PREFIX = worktreeTitlePrefix();
// Per-site branding (icons, OG/twitter) is set here explicitly so the
-// dispatcher route at `app/page.tsx` (outside both route groups) inherits it.
+// dispatcher route at `app/page.tsx` (outside the route groups) inherits it.
const SITE = (process.env.NEXT_PUBLIC_SITE ?? 'diffs') as ProductId;
-const isTrees = SITE === 'trees';
const SITE_PRODUCT = PRODUCTS[SITE];
-const PROD_ORIGIN = isTrees ? 'https://trees.software' : 'https://diffs.com';
+const PROD_ORIGIN_BY_SITE: Record = {
+ diffs: 'https://diffs.com',
+ trees: 'https://trees.software',
+ diffshub: 'https://diffshub.com',
+};
+const DEV_PORT_BY_SITE: Record = {
+ diffs: '3690',
+ trees: '3691',
+ diffshub: '3692',
+};
+const PROD_ORIGIN = PROD_ORIGIN_BY_SITE[SITE];
// In dev, point `metadataBase` at localhost so OG previewers fetch
// in-progress assets instead of whatever's deployed.
const isDev = process.env.NODE_ENV !== 'production';
-const DEV_PORT = process.env.PORT ?? (isTrees ? '3691' : '3690');
+const DEV_PORT = process.env.PORT ?? DEV_PORT_BY_SITE[SITE];
const SITE_ORIGIN = isDev ? `http://localhost:${DEV_PORT}` : PROD_ORIGIN;
const baseTitle = `${SITE_PRODUCT.name}, from Pierre`;
const taggedTitle = `${WORKTREE_PREFIX}${baseTitle}`;
const description = SITE_PRODUCT.description;
-const SITE_ICONS: Metadata['icons'] = isTrees
- ? {
- icon: [
- { url: '/trees-brand/icon.svg', type: 'image/svg+xml' },
- { url: '/trees-brand/icon.ico', sizes: 'any' },
- ],
- apple: '/trees-brand/apple-icon.png',
- }
- : {
- icon: [
- { url: '/favicon.svg', type: 'image/svg+xml' },
- { url: '/favicon.png', type: 'image/png' },
- ],
- apple: '/apple-touch-icon.png',
- };
-const SITE_OG_IMAGE = isTrees
- ? '/trees-brand/opengraph-image.png'
- : '/diffs-brand/opengraph-image.png';
-const SITE_TWITTER_IMAGE = isTrees
- ? '/trees-brand/twitter-image.png'
- : '/diffs-brand/twitter-image.png';
+const SITE_ICONS_BY_SITE: Record = {
+ diffs: {
+ icon: [
+ { url: '/favicon.svg', type: 'image/svg+xml' },
+ { url: '/favicon.png', type: 'image/png' },
+ ],
+ apple: '/apple-touch-icon.png',
+ },
+ trees: {
+ icon: [
+ { url: '/trees-brand/icon.svg', type: 'image/svg+xml' },
+ { url: '/trees-brand/icon.ico', sizes: 'any' },
+ ],
+ apple: '/trees-brand/apple-icon.png',
+ },
+ diffshub: {
+ icon: [
+ { url: '/diffshub-brand/icon.svg', type: 'image/svg+xml' },
+ { url: '/diffshub-brand/icon.ico', sizes: 'any' },
+ ],
+ apple: '/diffshub-brand/apple-icon.png',
+ },
+};
+const SITE_OG_IMAGE_BY_SITE: Record = {
+ diffs: '/diffs-brand/opengraph-image.png',
+ trees: '/trees-brand/opengraph-image.png',
+ diffshub: '/diffshub-brand/opengraph-image.png',
+};
+const SITE_TWITTER_IMAGE_BY_SITE: Record = {
+ diffs: '/diffs-brand/twitter-image.png',
+ trees: '/trees-brand/twitter-image.png',
+ diffshub: '/diffshub-brand/twitter-image.png',
+};
+const SITE_ICONS = SITE_ICONS_BY_SITE[SITE];
+const SITE_OG_IMAGE = SITE_OG_IMAGE_BY_SITE[SITE];
+const SITE_TWITTER_IMAGE = SITE_TWITTER_IMAGE_BY_SITE[SITE];
const themeBootstrapScript = `(${String(function applyInitialTheme() {
try {
const storedTheme = window.localStorage.getItem('theme');
@@ -173,6 +206,8 @@ export const metadata: Metadata = {
},
};
+const WrapperContext = SITE === 'diffshub' ? WorkerPoolContext : Fragment;
+
export default function RootLayout({
children,
}: Readonly<{
@@ -191,20 +226,23 @@ export default function RootLayout({
/>
-
- {children}
-
-
-
-
+
+
+
+ {children}
+
+
+
+
+