diff --git a/.github/workflows/diffr-web.yml b/.github/workflows/diffr-web.yml
new file mode 100644
index 000000000..f03b28fdb
--- /dev/null
+++ b/.github/workflows/diffr-web.yml
@@ -0,0 +1,116 @@
+on:
+ push:
+ branches: [main]
+ paths:
+ - "diffr/**"
+ - "apps/diffr-web/**"
+ # The page is built from Review Desktop's editor and review sources.
+ - "apps/review-desktop/code-oss/src/vs/**"
+ - "apps/review-desktop/code-oss/extensions/review-themes/**"
+ - "packages/review-protocol/**"
+ - ".github/workflows/diffr-web.yml"
+ pull_request:
+ paths:
+ - "diffr/**"
+ - "apps/diffr-web/**"
+ # The page is built from Review Desktop's editor and review sources.
+ - "apps/review-desktop/code-oss/src/vs/**"
+ - "apps/review-desktop/code-oss/extensions/review-themes/**"
+ - "packages/review-protocol/**"
+ - ".github/workflows/diffr-web.yml"
+ workflow_dispatch:
+
+name: Diffr Web
+
+permissions:
+ contents: read
+
+concurrency:
+ group: diffr-web-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Build and deploy
+ runs-on: ubuntu-24.04
+ env:
+ WRANGLER: npx --yes wrangler@4.135.0
+ # A fork's pull request gets no secrets, so it builds without deploying.
+ DEPLOY: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@1.99.0
+ with:
+ targets: wasm32-unknown-unknown
+ # llvm-ar archives the grammars' wasm objects; clang comes with the runner.
+ components: llvm-tools
+
+ - name: Install wasm-bindgen
+ uses: taiki-e/install-action@1329c298aa20c3257846c9b2e0e55967df3e3c37 # v2
+ with:
+ tool: wasm-bindgen@0.2.129
+
+ - name: Restore diffr build
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ diffr/target
+ key: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}-${{ github.sha }}
+ restore-keys: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}-
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+
+ - name: Setup Node
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --filter @dev.fast/diffr-web...
+
+ - name: Build the engine
+ run: pnpm --filter @dev.fast/diffr-web wasm
+
+ - name: Save diffr build
+ if: github.ref == 'refs/heads/main'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ diffr/target
+ key: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}-${{ github.sha }}
+
+ # Not the repo's typecheck: it needs the engine's bindings, built above.
+ - name: Typecheck
+ run: pnpm --filter @dev.fast/diffr-web check
+
+ - name: Build the page
+ run: pnpm --filter @dev.fast/diffr-web build
+
+ - name: Deploy
+ if: env.DEPLOY == 'true' && github.event_name != 'pull_request'
+ working-directory: apps/diffr-web
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: $WRANGLER deploy
+
+ - name: Upload a preview
+ if: env.DEPLOY == 'true' && github.event_name == 'pull_request'
+ working-directory: apps/diffr-web
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ ALIAS: pr-${{ github.event.pull_request.number }}
+ run: |
+ $WRANGLER versions upload --preview-alias "$ALIAS" | tee upload.log
+ url=$(grep -o "https://$ALIAS-[^ ]*workers.dev" upload.log | head -1)
+ echo "Preview: ${url:-see the log above}" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 7c59078fe..51f1f7173 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -24,6 +24,8 @@
"**/dist/**",
"**/.vite/**",
"apps/review-desktop/code-oss/**",
+ "apps/diffr-web/src/standalone/**",
+ "apps/diffr-web/src/review/**",
"diffr/**",
".agent/**",
".agents/**",
diff --git a/apps/diffr-web/.gitignore b/apps/diffr-web/.gitignore
new file mode 100644
index 000000000..0fd5a6058
--- /dev/null
+++ b/apps/diffr-web/.gitignore
@@ -0,0 +1,3 @@
+dist/
+src/wasm/
+.wrangler/
diff --git a/apps/diffr-web/index.html b/apps/diffr-web/index.html
new file mode 100644
index 000000000..b5ca78b24
--- /dev/null
+++ b/apps/diffr-web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ diffr
+
+
+
+
+
+
diff --git a/apps/diffr-web/package.json b/apps/diffr-web/package.json
new file mode 100644
index 000000000..e710f89b1
--- /dev/null
+++ b/apps/diffr-web/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@dev.fast/diffr-web",
+ "version": "0.0.0",
+ "private": true,
+ "description": "diffr in the browser: GitHub pull requests and comparisons diffed on the reader's machine",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "wasm": "cd ../../diffr && cargo xtask build-web --out-dir ../apps/diffr-web/src/wasm",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "pnpm -w exec tsc --noEmit -p apps/diffr-web"
+ },
+ "dependencies": {
+ "@dev.fast/diffr": "workspace:*",
+ "@dev.fast/review-protocol": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/trusted-types": "2.0.7",
+ "@types/wicg-file-system-access": "2023.10.7",
+ "@vscode/codicons": "0.0.46-21",
+ "@vscode/diff": "0.0.2-7",
+ "@vscode/tree-sitter-wasm": "0.3.1",
+ "@webgpu/types": "0.1.66",
+ "typescript": "6.0.3",
+ "vite": "8.0.16"
+ },
+ "engines": {
+ "node": ">=24 <25"
+ }
+}
diff --git a/apps/diffr-web/public/_headers b/apps/diffr-web/public/_headers
new file mode 100644
index 000000000..746f89989
--- /dev/null
+++ b/apps/diffr-web/public/_headers
@@ -0,0 +1,3 @@
+# Built assets are named by their hash, so they never change.
+/assets/*
+ Cache-Control: public, max-age=31536000, immutable
diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts
new file mode 100644
index 000000000..047e3a95c
--- /dev/null
+++ b/apps/diffr-web/src/comparison.ts
@@ -0,0 +1,549 @@
+/**
+ * One pull request or comparison on the page: its files listed from GitHub, each diffed by diffr in
+ * a worker once its text is fetched, nearest the reader first, into Review Desktop's multi-diff view.
+ * A file joins the list once diffed; the list keeps the reader's place as files arrive above.
+ */
+import { Emitter } from "vs/base/common/event.js";
+import { Disposable, DisposableStore } from "vs/base/common/lifecycle.js";
+import type { IObservable } from "vs/base/common/observable.js";
+import { URI } from "vs/base/common/uri.js";
+import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js";
+import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js";
+
+import type { Classified, Engine, FileRequest } from "./engine/engine.js";
+import { foldIds, gapIds } from "./folds.js";
+import {
+ type Change,
+ type ChangedFile,
+ type Preview,
+ type Target,
+ fileText,
+ loadChange,
+ targetPath,
+} from "./github.js";
+import type { ReviewDiffFileWire, StructuralLineCounts } from "./protocol.js";
+import { orderReviewDiffFiles } from "./review/common/reviewChangedFilesModel.js";
+import {
+ type StructuralTextDiff,
+ reviewFileCounts,
+} from "./review/common/reviewStructuralDiff.js";
+import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js";
+import {
+ ReviewFilesDiffView,
+ type ReviewFilesEditorEntry,
+ ReviewFilesEditorInput,
+} from "./review/services/reviewFilesDiffView.js";
+import { createStructuralDiffEditors } from "./review/services/reviewStructuralDiff.js";
+import { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js";
+import { structuralFoldCommand } from "./review/services/reviewStructuralFolds.js";
+import { StructuralViewedState } from "./review/services/reviewStructuralViewed.js";
+import { ScopeViewedControl } from "./review/services/reviewStructuralViewedControl.js";
+import { ViewedProgress } from "./viewed.js";
+
+/** Files fetched or diffed at once. The engine spreads them over its workers. */
+const IN_FLIGHT = 12;
+
+/** Milliseconds from page start, for the engine panel. */
+export interface Timing {
+ listed?: number;
+ previewed?: number;
+ firstDiff?: number;
+ done?: number;
+}
+
+export interface Work {
+ fetching: number;
+ diffing: number;
+ diffed: number;
+ hidden: number;
+ failed: number;
+ /** Total diffr time, and the file that took longest. */
+ diffMs: number;
+ slowest?: { path: string; ms: number };
+}
+
+interface File {
+ changed: ChangedFile;
+ entry: ReviewFilesEditorEntry;
+ classified?: Classified;
+ state: "waiting" | "fetching" | "diffing" | "done";
+}
+
+export class Comparison extends Disposable {
+ private readonly changed = this._register(new Emitter());
+ /** The title, timing or work counts changed. */
+ readonly onDidChange = this.changed.event;
+ readonly timing: Timing = {};
+ readonly work: Work = {
+ fetching: 0,
+ diffing: 0,
+ diffed: 0,
+ hidden: 0,
+ failed: 0,
+ diffMs: 0,
+ };
+ preview: Preview | undefined;
+ change: Change | undefined;
+ error: string | undefined;
+ private readonly files = new Map();
+ private order: string[] = [];
+ private view: ReviewFilesDiffView | undefined;
+ private session: StructuralDiffSession | undefined;
+ private viewed: ViewedProgress | undefined;
+ /** Files the reader asked for, most recent first. */
+ private requested: string[] = [];
+
+ constructor(
+ private readonly container: HTMLElement,
+ private readonly overflow: HTMLElement,
+ readonly target: Target,
+ private readonly engine: Engine,
+ private readonly layout: ReviewDiffLayoutSetting,
+ private readonly wordWrap: IObservable,
+ private readonly instantiation: IInstantiationService,
+ ) {
+ super();
+ void this.load().catch((error: Error) => {
+ if (this._store.isDisposed) return;
+ this.error = error.message;
+ this.session?.fail(error.message);
+ this.changed.fire();
+ });
+ }
+
+ get title(): string | undefined {
+ return (this.change ?? this.preview)?.title;
+ }
+
+ get url(): string | undefined {
+ return (this.change ?? this.preview)?.url;
+ }
+
+ get fileCount(): number {
+ return this.files.size;
+ }
+
+ /** Lines added and removed so far, from diffr, or GitHub's totals while files are still waiting. */
+ get counts(): StructuralLineCounts {
+ const change = this.change ?? this.preview;
+
+ if (this.work.diffed < this.files.size && change?.additions !== undefined)
+ return { added: change.additions, removed: change.deletions ?? 0 };
+
+ let added = 0,
+ removed = 0;
+
+ for (const path of this.order) {
+ const diff = this.session?.getFileResult(path)?.diff;
+
+ if (diff?.type !== "text") continue;
+ added += diff.stats.visible.added;
+ removed += diff.stats.visible.removed;
+ }
+
+ return { added, removed };
+ }
+
+ /** Files diffr diffed line by line, its structural diff having failed, and why. */
+ get fallbacks(): { path: string; message: string }[] {
+ return this.order.flatMap((path) => {
+ const diff = this.session?.getFileResult(path)?.diff;
+ const fallback = diff?.type === "text" ? diff.stats.fallback : undefined;
+
+ return fallback ? [{ path, message: fallback.message }] : [];
+ });
+ }
+
+ focus(): void {
+ this.view?.focus();
+ }
+
+ goToChange(direction: "next" | "previous"): void {
+ this.view?.goToChange(direction);
+ }
+
+ scroll(to: number | "top" | "end"): void {
+ this.view?.scroll(to);
+ }
+
+ toggleFileTree(): void {
+ this.view?.toggleFileTree();
+ }
+
+ /** A fold command at the caret of the focused diff editor (`StructuralFoldControls.command`). */
+ foldCommand(command: string): void {
+ const editor = this.instantiation
+ .invokeFunction((accessor) => accessor.get(ICodeEditorService))
+ .getFocusedCodeEditor();
+
+ if (editor) structuralFoldCommand(editor, command);
+ }
+
+ /** Folds or unfolds everything diffr can fold, in every file. */
+ foldAll(collapsed: boolean): void {
+ this.setFolds(foldIds, collapsed);
+ }
+
+ /** Shows every context gap, or hides them all again once any is shown. */
+ toggleContextGaps(): void {
+ const session = this.session;
+
+ if (!session) return;
+
+ const shown = this.order.some((path) => {
+ const diff = session.getTextDiff(path);
+
+ return (
+ !!diff &&
+ gapIds(diff).some((id) => session.isRegionCollapsed(path, id) === false)
+ );
+ });
+
+ this.setFolds(gapIds, shown);
+ }
+
+ private setFolds(
+ ids: (diff: StructuralTextDiff) => number[],
+ collapsed: boolean,
+ ): void {
+ const session = this.session;
+
+ if (!session) return;
+
+ for (const path of this.order) {
+ const diff = session.getTextDiff(path);
+
+ if (diff)
+ for (const id of ids(diff))
+ session.setRegionCollapsed(path, id, collapsed);
+ }
+ }
+
+ private async load(): Promise {
+ const change = await loadChange(this.target, (preview) => {
+ this.preview = preview;
+ this.changed.fire();
+ });
+
+ if (this._store.isDisposed) return;
+ this.change = change;
+ this.timing.listed = performance.now();
+ this.changed.fire();
+
+ const wires = new Map();
+
+ for (const file of change.files) {
+ wires.set(
+ {
+ path: file.path,
+ previousPath: file.previousPath,
+ status: file.status === "copied" ? "renamed" : file.status,
+ additions: file.additions,
+ deletions: file.deletions,
+ patch: file.patch,
+ },
+ file,
+ );
+ }
+
+ const ordered = orderReviewDiffFiles([...wires.keys()]);
+ const { owner, repo } = this.target;
+
+ const blob = (commit: string, path: string) =>
+ URI.from({
+ scheme: "https",
+ authority: "github.com",
+ path: `/${owner}/${repo}/blob/${commit}/${path}`,
+ });
+
+ const entries: ReviewFilesEditorEntry[] = ordered.map((file) => ({
+ file,
+ original: URI.from({
+ scheme: "diffr",
+ authority: "base",
+ path: `/${file.previousPath ?? file.path}`,
+ }),
+ modified: URI.from({
+ scheme: "diffr",
+ authority: "head",
+ path: `/${file.path}`,
+ }),
+ goToFileResource:
+ file.status === "deleted"
+ ? blob(change.base, file.previousPath ?? file.path)
+ : blob(change.head, file.path),
+ }));
+
+ for (const entry of entries)
+ this.files.set(entry.file.path, {
+ changed: wires.get(entry.file)!,
+ entry,
+ state: "waiting",
+ });
+ this.order = entries.map((entry) => entry.file.path);
+
+ const store = this._register(new DisposableStore());
+
+ const session = (this.session = store.add(
+ new StructuralDiffSession(new Set(this.order)),
+ ));
+
+ const viewed = (this.viewed = store.add(
+ new ViewedProgress(targetPath(this.target)),
+ ));
+
+ const viewedState = store.add(
+ new StructuralViewedState(
+ session,
+ entries,
+ () => viewed.progress,
+ viewed.onDidChange,
+ (ranges, isViewed) => viewed.setViewed(ranges, isViewed),
+ (editor, onToggle) => new ScopeViewedControl(editor, onToggle),
+ ),
+ );
+
+ const structural = createStructuralDiffEditors(
+ this.instantiation,
+ entries,
+ store,
+ session,
+ viewedState,
+ );
+
+ const input = store.add(
+ structural.instantiation.createInstance(
+ ReviewFilesEditorInput,
+ entries,
+ session,
+ this.wordWrap,
+ ),
+ );
+
+ const view = (this.view = store.add(
+ structural.instantiation.createInstance(
+ ReviewFilesDiffView,
+ this.container,
+ this.overflow,
+ this.layout,
+ undefined,
+ (path: string) => void viewed.toggleFile(path),
+ undefined,
+ undefined,
+ ),
+ ));
+
+ view.setProgress(viewed.progress);
+ store.add(viewed.onDidChange(() => view.setProgress(viewed.progress)));
+ view.startLoading(entries);
+ store.add(
+ view.onDidRequestFile((path) => {
+ this.requested = [path, ...this.requested.filter((p) => p !== path)];
+ this.pump();
+ }),
+ );
+ store.add(view.onDidScroll(() => this.pump()));
+ this.observe(session, entries, view, store);
+ await view.setInput(input, undefined);
+
+ if (this._store.isDisposed) return;
+
+ // Paths alone say which files diffr hides (generated, vendored, lockfiles); those go last.
+ const previews = await this.engine.preview(
+ entries.map((entry) => this.request(this.files.get(entry.file.path)!)),
+ );
+
+ if (this._store.isDisposed) return;
+ previews.forEach((preview, index) => {
+ if (!("error" in preview))
+ this.files.get(this.order[index]!)!.classified = preview;
+ });
+ this.work.hidden = [...this.files.values()].filter(
+ (file) => file.classified?.hidden,
+ ).length;
+ this.timing.previewed = performance.now();
+ this.changed.fire();
+ this.pump();
+ }
+
+ /** What diffr is told about a file: its sides, with their text once fetched. */
+ private request(
+ file: File,
+ text?: { lhs?: string; rhs?: string },
+ ): FileRequest {
+ const { changed } = file;
+ const base = changed.previousPath ?? changed.path;
+
+ return {
+ status: changed.status,
+ // The base blob's id is not in GitHub's listing; any id names the text diffr is given.
+ lhs:
+ changed.status === "added"
+ ? undefined
+ : {
+ path: base,
+ oid: `base:${base}`,
+ mode: "100644",
+ text: text?.lhs,
+ },
+ rhs:
+ changed.status === "deleted"
+ ? undefined
+ : {
+ path: changed.path,
+ oid: changed.sha ?? `head:${changed.path}`,
+ mode: "100644",
+ text: text?.rhs,
+ },
+ };
+ }
+
+ /** The next file to diff: one the reader asked for, else the nearest after the one on screen. */
+ private next(): File | undefined {
+ for (const path of this.requested) {
+ const file = this.files.get(path);
+
+ if (file?.state === "waiting") return file;
+ }
+
+ const active = this.view?.activePath;
+ const anchor = Math.max(0, active ? this.order.indexOf(active) : 0);
+
+ let best: File | undefined,
+ bestScore = Infinity;
+
+ this.order.forEach((path, index) => {
+ const file = this.files.get(path)!;
+
+ if (file.state !== "waiting") return;
+ // Ahead of the reader first; files behind cost double; hidden ones wait for the rest.
+ const distance = index >= anchor ? index - anchor : (anchor - index) * 2;
+ const score = distance + (file.classified?.hidden ? 1e6 : 0);
+
+ if (score < bestScore) [best, bestScore] = [file, score];
+ });
+
+ return best;
+ }
+
+ private pump(): void {
+ if (
+ !this.session ||
+ this.timing.previewed === undefined ||
+ this._store.isDisposed
+ )
+ return;
+
+ while (this.work.fetching + this.work.diffing < IN_FLIGHT) {
+ const file = this.next();
+
+ if (!file) break;
+ void this.diff(file);
+ }
+ }
+
+ private async diff(file: File): Promise {
+ const { changed, entry } = file;
+ const change = this.change!;
+ const session = this.session!;
+ file.state = "fetching";
+ this.work.fetching++;
+ this.changed.fire();
+
+ try {
+ const [lhs, rhs] = await Promise.all([
+ changed.status === "added"
+ ? undefined
+ : fileText(
+ this.target,
+ change.base,
+ changed.previousPath ?? changed.path,
+ ),
+ changed.status === "deleted"
+ ? undefined
+ : fileText(this.target, change.head, changed.path),
+ ]);
+
+ if (this._store.isDisposed) return;
+ this.work.fetching--;
+ this.work.diffing++;
+ file.state = "diffing";
+ this.changed.fire();
+ const diffed = await this.engine.diff(this.request(file, { lhs, rhs }));
+
+ if (this._store.isDisposed) return;
+ this.work.diffing--;
+ this.work.diffMs += diffed.ms;
+
+ if (!this.work.slowest || diffed.ms > this.work.slowest.ms)
+ this.work.slowest = { path: changed.path, ms: diffed.ms };
+ const event = diffed.event;
+
+ if (event.diff) {
+ // diffr decides what is binary, from the bytes.
+ if (event.diff.type === "binary") entry.file.binary = true;
+ this.viewed!.addFile(
+ changed.path,
+ changed.previousPath,
+ changed.sha ?? "deleted",
+ event.diff,
+ );
+ session.setFileResult(changed.path, {
+ diff: event.diff,
+ hidden: diffed.classified.hidden,
+ });
+ } else {
+ this.work.failed++;
+ session.setFileResult(changed.path, { error: event.error.message });
+ }
+ } catch (error) {
+ if (this._store.isDisposed) return;
+
+ if (file.state === "fetching") this.work.fetching--;
+ else this.work.diffing--;
+ this.work.failed++;
+ session.setFileResult(changed.path, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ file.state = "done";
+ this.work.diffed++;
+ this.timing.firstDiff ??= performance.now();
+
+ if (this.work.diffed === this.files.size)
+ this.timing.done = performance.now();
+ this.changed.fire();
+ this.pump();
+ }
+
+ /** Review Desktop's observeSession: each result goes to the view once. */
+ private observe(
+ session: StructuralDiffSession,
+ entries: readonly ReviewFilesEditorEntry[],
+ view: ReviewFilesDiffView,
+ store: DisposableStore,
+ ): void {
+ const rendered = new Set();
+ store.add(
+ session.onDidChange((change) => {
+ for (const entry of entries) {
+ const path = entry.file.path;
+
+ if (!change.files.has(path) || rendered.has(path)) continue;
+ const result = session.getFileResult(path);
+
+ if (!result) continue;
+ rendered.add(path);
+
+ if (result.hidden !== undefined) view.hideFile(path, result.hidden);
+
+ if (result.diff) view.fileCounts(path, reviewFileCounts(result.diff));
+ view.fileLoaded(path, result.error);
+ }
+
+ if (session.error) view.loadingFailed(session.error);
+ }),
+ );
+ }
+}
diff --git a/apps/diffr-web/src/editorFont.ts b/apps/diffr-web/src/editorFont.ts
new file mode 100644
index 000000000..38661a597
--- /dev/null
+++ b/apps/diffr-web/src/editorFont.ts
@@ -0,0 +1,20 @@
+/**
+ * Monaco takes a font as monospace only when its probe glyphs share one width, and otherwise
+ * measures every caret and selection from the DOM, which lands a caret at a line's end past the
+ * margin of text injected there (the fold hint's). Geist Mono has no "→", one of the probes, so it
+ * always fails. Review Desktop measures before Geist Mono loads and gets Menlo's uniform widths;
+ * the page measures Geist Mono itself and only overrules that one verdict.
+ */
+import { FontMeasurements } from "vs/editor/browser/config/fontMeasurements.js";
+import { FontInfo } from "vs/editor/common/config/fontInfo.js";
+
+const readFontInfo = FontMeasurements.readFontInfo.bind(FontMeasurements);
+
+FontMeasurements.readFontInfo = (targetWindow, bareFontInfo) => {
+ const info = readFontInfo(targetWindow, bareFontInfo);
+
+ if (info.isMonospace || !info.fontFamily.startsWith('"Geist Mono"'))
+ return info;
+
+ return new FontInfo({ ...info, isMonospace: true }, info.isTrusted);
+};
diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts
new file mode 100644
index 000000000..476126abc
--- /dev/null
+++ b/apps/diffr-web/src/engine/engine.ts
@@ -0,0 +1,345 @@
+/**
+ * The page's handle on diffr: the wasm module is fetched and compiled once, then handed to a pool
+ * of workers, so files diff in parallel without each worker paying for its own compile. The pool
+ * starts at one and grows while work queues, a worker at a time: each spends a second or two
+ * compiling diffr's queries, and workers starting together slow each other several times over.
+ * All but one go after ten idle seconds, since wasm memory never shrinks.
+ */
+// Gzipped by the build (vite.config.ts): static hosts cap a file at 25 MiB, and the engine is larger.
+import wasmUrl from "../wasm/diffr_web_bg.wasm.gz?url";
+import type { Classified, FileEvent, Request, Response } from "./worker.js";
+
+export type { Classified, FileEvent };
+
+/** One side of a file, named as diffr's classifier reads it. */
+export interface Side {
+ path: string;
+ oid: string;
+ mode: string;
+ text?: string;
+}
+
+export interface FileRequest {
+ status: "added" | "deleted" | "modified" | "renamed" | "copied";
+ lhs?: Side;
+ rhs?: Side;
+}
+
+export interface Diffed {
+ classified: Classified;
+ event: FileEvent;
+ ms: number;
+}
+
+/** What the engine panel shows. */
+export interface EngineStats {
+ /** Most workers the pool grows to. */
+ maxWorkers: number;
+ workers: number;
+ busy: number;
+ /** Milliseconds from page start until the module was compiled. */
+ compiled?: number;
+ /** Milliseconds a worker took to load the module and the configuration. */
+ ready?: number;
+ bytes: number;
+ /** The largest wasm memory any worker holds. */
+ memory: number;
+ diffed: number;
+ diffMs: number;
+}
+
+const MAX_WORKERS = Math.max(
+ 1,
+ Math.min(8, (navigator.hardwareConcurrency || 4) - 1),
+);
+
+const IDLE_MS = 10_000;
+
+/** Past this, a worker is replaced once its queue drains. */
+const MEMORY_LIMIT = 1024 * 1024 * 1024;
+
+const stats: EngineStats = {
+ maxWorkers: MAX_WORKERS,
+ workers: 0,
+ busy: 0,
+ bytes: 0,
+ memory: 0,
+ diffed: 0,
+ diffMs: 0,
+};
+
+const listeners = new Set<() => void>();
+
+export function engineStats(): Readonly {
+ return stats;
+}
+
+export function onEngineChange(listener: () => void): () => void {
+ listeners.add(listener);
+
+ return () => listeners.delete(listener);
+}
+
+const changed = () => listeners.forEach((listener) => listener());
+
+/**
+ * The engine's bytes, unpacked as they arrive so compiling still streams. A server that sent the
+ * file with `Content-Encoding: gzip` has unpacked it already, so the first bytes decide.
+ */
+async function unpacked(
+ response: globalThis.Response,
+): Promise {
+ const [peek, body] = response.body!.tee();
+ const reader = peek.getReader();
+ const { value } = await reader.read();
+ void reader.cancel();
+ const gzipped = !!value && value[0] === 0x1f && value[1] === 0x8b;
+
+ const stream = gzipped
+ ? body.pipeThrough(new DecompressionStream("gzip"))
+ : body;
+
+ return new globalThis.Response(stream, {
+ headers: { "content-type": "application/wasm" },
+ });
+}
+
+let compiled: Promise | undefined;
+
+function module(): Promise {
+ return (compiled ??= (async () => {
+ const response = await fetch(wasmUrl);
+
+ if (!response.ok)
+ throw new Error(`The diffr engine failed to load (${response.status}).`);
+ stats.bytes = Number(response.headers.get("content-length")) || 0;
+ const module = await WebAssembly.compileStreaming(await unpacked(response));
+ stats.compiled = performance.now();
+ changed();
+
+ return module;
+ })());
+}
+
+interface Call {
+ resolve(response: Response): void;
+ reject(error: Error): void;
+}
+
+interface Slot {
+ worker: Worker;
+ busy: number;
+ retire: boolean;
+ ready: boolean;
+}
+
+/** A pool of diffr workers, all with one configuration. */
+export class Engine {
+ private readonly slots: Slot[] = [];
+ private readonly pending = new Map();
+ /** Jobs no worker has taken yet. */
+ private readonly queue: Request[] = [];
+ private next = 0;
+ private idle: ReturnType | undefined;
+ private failure: Error | undefined;
+ /** What the configuration asked for that the browser does not run. */
+ readonly notices: Promise;
+ private resolveNotices!: (notices: string[]) => void;
+ private rejectNotices!: (error: Error) => void;
+
+ constructor(private readonly config: string | undefined) {
+ this.notices = new Promise((resolve, reject) => {
+ this.resolveNotices = resolve;
+ this.rejectNotices = reject;
+ });
+ this.notices.catch(() => {});
+ this.slots.push(this.spawn());
+ }
+
+ private spawn(): Slot {
+ const slot: Slot = {
+ worker: new Worker(new URL("./worker.ts", import.meta.url), {
+ type: "module",
+ }),
+ busy: 0,
+ retire: false,
+ ready: false,
+ };
+
+ stats.workers++;
+ module().then(
+ (module) =>
+ slot.worker.postMessage({
+ module,
+ config: this.config,
+ } satisfies Request),
+ (error: Error) => this.fail(error),
+ );
+ slot.worker.onmessage = ({ data }: MessageEvent) =>
+ this.receive(slot, data);
+ slot.worker.onerror = (event) =>
+ this.fail(new Error(event.message || "A diffr worker failed to start."));
+ changed();
+
+ return slot;
+ }
+
+ private receive(slot: Slot, data: Response): void {
+ if ("ready" in data) {
+ slot.ready = true;
+ stats.ready ??= data.ready;
+ this.resolveNotices(data.notices);
+ this.dispatch();
+ changed();
+
+ return;
+ }
+
+ if ("failed" in data) {
+ this.fail(new Error(data.failed));
+
+ return;
+ }
+
+ slot.busy--;
+ stats.busy--;
+ stats.memory = Math.max(stats.memory, data.memory);
+
+ if (data.memory > MEMORY_LIMIT) slot.retire = true;
+
+ if (slot.retire && !slot.busy) this.replace(slot);
+ const call = this.pending.get(data.id)!;
+ this.pending.delete(data.id);
+
+ if ("error" in data) call.reject(new Error(data.error));
+ else call.resolve(data);
+ this.dispatch();
+
+ if (!stats.busy && !this.queue.length) this.scheduleIdle();
+ changed();
+ }
+
+ private replace(slot: Slot): void {
+ slot.worker.terminate();
+ stats.workers--;
+ this.slots.splice(this.slots.indexOf(slot), 1, this.spawn());
+ }
+
+ /** The configuration or the engine failed: every call fails with it. */
+ private fail(error: Error): void {
+ this.failure = error;
+ this.rejectNotices(error);
+
+ for (const call of this.pending.values()) call.reject(error);
+ this.pending.clear();
+ this.queue.length = 0;
+ }
+
+ private scheduleIdle(): void {
+ clearTimeout(this.idle);
+ this.idle = setTimeout(() => {
+ for (const slot of this.slots.splice(1)) {
+ if (slot.busy) this.slots.push(slot);
+ else {
+ slot.worker.terminate();
+ stats.workers--;
+ }
+ }
+
+ changed();
+ }, IDLE_MS);
+ }
+
+ /**
+ * Queued jobs to ready workers, two each so none waits on the page between jobs. When every
+ * ready worker is full and none is starting, another starts. A retiring worker takes nothing
+ * new, so it drains and goes.
+ */
+ private dispatch(): void {
+ while (this.queue.length) {
+ const slot = this.slots.find(
+ (slot) => slot.ready && !slot.retire && slot.busy < 2,
+ );
+
+ if (!slot) {
+ if (
+ this.slots.every((slot) => slot.ready) &&
+ this.slots.length < MAX_WORKERS
+ )
+ this.slots.push(this.spawn());
+
+ return;
+ }
+
+ const job = this.queue.shift()!;
+ slot.busy++;
+ stats.busy++;
+ slot.worker.postMessage(job);
+ }
+ }
+
+ private send(
+ message: (id: number) => Request,
+ ): Promise {
+ if (this.failure) return Promise.reject(this.failure);
+ clearTimeout(this.idle);
+ const id = this.next++;
+
+ return new Promise((resolve, reject) => {
+ // SAFETY: the worker answers this id with the response kind its request asks for.
+ this.pending.set(id, { resolve: resolve as Call["resolve"], reject });
+ this.queue.push(message(id));
+ this.dispatch();
+ changed();
+ });
+ }
+
+ /** Diff one file. Resolves with its classification, its record and the time it took. */
+ async diff(file: FileRequest): Promise {
+ const response = await this.send>(
+ (id) => ({
+ id,
+ diff: JSON.stringify({ ...file, syntax: true }),
+ }),
+ );
+
+ stats.diffed++;
+ stats.diffMs += response.ms;
+
+ return {
+ classified: response.classified,
+ event: response.event,
+ ms: response.ms,
+ };
+ }
+
+ /** Classify files from their paths alone, before any source is fetched. In the order asked. */
+ async preview(
+ files: readonly FileRequest[],
+ ): Promise<(Classified | { error: string })[]> {
+ const response = await this.send>(
+ (id) => ({
+ id,
+ preview: JSON.stringify(files),
+ }),
+ );
+
+ return response.previews;
+ }
+
+ dispose(): void {
+ clearTimeout(this.idle);
+
+ for (const slot of this.slots.splice(0)) {
+ slot.worker.terminate();
+ stats.workers--;
+ stats.busy -= slot.busy;
+ }
+
+ for (const call of this.pending.values())
+ call.reject(new Error("The engine was stopped."));
+ this.pending.clear();
+ this.queue.length = 0;
+ changed();
+ }
+}
diff --git a/apps/diffr-web/src/engine/worker.ts b/apps/diffr-web/src/engine/worker.ts
new file mode 100644
index 000000000..296595b58
--- /dev/null
+++ b/apps/diffr-web/src/engine/worker.ts
@@ -0,0 +1,112 @@
+///
+/** Runs diffr off the main thread: one engine, one request at a time, in order. The page sends work only once it is ready. */
+import type { StructuralDiffEvent } from "../protocol.js";
+import init, { Differ } from "../wasm/diffr_web.js";
+
+/** A file as the classifier sees it: its tags, and why it starts hidden. */
+export interface Classified {
+ tags: string[];
+ hidden?: string;
+}
+
+export type FileEvent = Extract;
+
+/** The first message carries the compiled module and the configuration; every later one is work. */
+export type Request =
+ | { module: WebAssembly.Module; config: string | undefined }
+ | { id: number; diff: string }
+ | { id: number; preview: string };
+
+interface Usage {
+ id: number;
+ ms: number;
+ /** Bytes of wasm memory, which never shrinks. */
+ memory: number;
+}
+
+export type Response =
+ | { ready: number; notices: string[] }
+ | { failed: string }
+ | (Usage & { classified: Classified; event: FileEvent })
+ | (Usage & { previews: (Classified | { error: string })[] })
+ | (Usage & { error: string });
+
+interface WireClassified {
+ entry: { tags?: string[] };
+ hidden?: string;
+}
+
+let differ: Differ | undefined;
+
+let memory: WebAssembly.Memory | undefined;
+
+self.onmessage = async ({ data }: MessageEvent) => {
+ if ("module" in data) {
+ const start = performance.now();
+
+ try {
+ const wasm = await init({ module_or_path: data.module });
+ memory = wasm.memory;
+ differ = new Differ(data.config);
+ self.postMessage({
+ ready: performance.now() - start,
+ notices: JSON.parse(differ.notices()),
+ } satisfies Response);
+ } catch (error) {
+ self.postMessage({
+ failed: error instanceof Error ? error.message : String(error),
+ } satisfies Response);
+ }
+
+ return;
+ }
+
+ handle(data);
+};
+
+function handle(data: Request): void {
+ if ("module" in data) return;
+ const start = performance.now();
+
+ const usage = () => ({
+ id: data.id,
+ ms: performance.now() - start,
+ memory: memory?.buffer.byteLength ?? 0,
+ });
+
+ try {
+ if ("preview" in data) {
+ // SAFETY: Differ.preview returns an array of these, one per file (diffr-web's lib.rs).
+ const previews = (
+ JSON.parse(differ!.preview(data.preview)) as (
+ | WireClassified
+ | { error: string }
+ )[]
+ ).map((preview) =>
+ "error" in preview
+ ? preview
+ : { tags: preview.entry.tags ?? [], hidden: preview.hidden },
+ );
+
+ self.postMessage({ ...usage(), previews } satisfies Response);
+
+ return;
+ }
+
+ // SAFETY: Differ.diff returns the classified entry and its record (diffr-web's lib.rs).
+ const response = JSON.parse(differ!.diff(data.diff)) as WireClassified & {
+ event: FileEvent;
+ };
+
+ self.postMessage({
+ ...usage(),
+ classified: { tags: response.entry.tags ?? [], hidden: response.hidden },
+ event: response.event,
+ } satisfies Response);
+ } catch (error) {
+ self.postMessage({
+ ...usage(),
+ error: error instanceof Error ? error.message : String(error),
+ } satisfies Response);
+ }
+}
diff --git a/apps/diffr-web/src/folds.ts b/apps/diffr-web/src/folds.ts
new file mode 100644
index 000000000..35f0139a8
--- /dev/null
+++ b/apps/diffr-web/src/folds.ts
@@ -0,0 +1,118 @@
+/**
+ * The fold sets the diffr TUI's commands act on (diffr/tui/packages/hunk/src/diffr/regions.ts):
+ * everything foldable, what a fold holds, and the context gaps. Only fold-state ids: the session
+ * keeps whether each is collapsed.
+ */
+import type {
+ StructuralRegion,
+ StructuralTextDiff,
+} from "./review/common/reviewStructuralDiff.js";
+
+type Region = StructuralRegion;
+
+/** Each side's regions under its root, depth first. */
+function regions(diff: StructuralTextDiff): Region[][] {
+ return [diff.lhs, diff.rhs].map((side) => {
+ const out: Region[] = [];
+
+ const walk = (region: Region) => {
+ out.push(region);
+
+ if (region.kind === "fold") region.children.forEach(walk);
+ };
+
+ if (side?.root.kind === "fold") side.root.children.forEach(walk);
+
+ return out;
+ });
+}
+
+/** Every fold, and every leaf diffr collapsed or labelled: what fold all and unfold all touch. */
+export function foldIds(diff: StructuralTextDiff): number[] {
+ const ids = regions(diff)
+ .flat()
+ .filter(
+ (region) =>
+ region.kind === "fold" ||
+ region.visibility?.collapsed === true ||
+ !!region.visibility?.label,
+ )
+ .map((region) => region.fold_state_id);
+
+ return [...new Set(ids)];
+}
+
+/** The folds inside every region sharing `id`, so a docstring and its function open as one. */
+export function nestedIds(diff: StructuralTextDiff, id: number): number[] {
+ const nested = new Set();
+
+ const collect = (region: Region) => {
+ nested.add(region.fold_state_id);
+
+ if (region.kind === "fold") region.children.forEach(collect);
+ };
+
+ for (const region of regions(diff).flat())
+ if (region.fold_state_id === id && region.kind === "fold")
+ region.children.forEach(collect);
+ nested.delete(id);
+
+ return [...nested].filter((n) => foldIds(diff).includes(n));
+}
+
+/**
+ * Context gaps: what diffr collapsed far from any change. A region that starts collapsed, has no
+ * tags, has a counterpart on the other side, and holds no change on either side.
+ */
+export function gapIds(diff: StructuralTextDiff): number[] {
+ const sides = regions(diff);
+
+ const leaves = sides.map((side) =>
+ side.filter(
+ (region): region is Extract =>
+ region.kind === "leaf",
+ ),
+ );
+
+ const states = sides.map(
+ (side) => new Set(side.map((region) => region.fold_state_id)),
+ );
+
+ const alignments = leaves.map(
+ (side) => new Set(side.map((leaf) => leaf.alignment_id)),
+ );
+
+ const changed = new Set(
+ leaves
+ .flat()
+ .filter((leaf) => leaf.changed?.length)
+ .map((leaf) => leaf.alignment_id),
+ );
+
+ const gaps = new Set();
+
+ sides.forEach((side, index) => {
+ const other = index ? 0 : 1;
+
+ const paired = (region: Region) =>
+ region.kind === "leaf"
+ ? alignments[other]!.has(region.alignment_id)
+ : states[other]!.has(region.fold_state_id);
+
+ const unchanged = (region: Region): boolean =>
+ region.kind === "leaf"
+ ? paired(region) && !changed.has(region.alignment_id)
+ : region.children.every(unchanged);
+
+ for (const region of side)
+ if (
+ region.visibility?.collapsed === true &&
+ !region.tags?.length &&
+ paired(region) &&
+ unchanged(region)
+ )
+ gaps.add(region.fold_state_id);
+ });
+
+ return [...gaps];
+}
diff --git a/apps/diffr-web/src/fonts.css b/apps/diffr-web/src/fonts.css
new file mode 100644
index 000000000..772951292
--- /dev/null
+++ b/apps/diffr-web/src/fonts.css
@@ -0,0 +1,229 @@
+/* Geist Mono, the editor font, from the canvas's self-hosted fonts (packages/review/app/src/fonts). */
+
+/* cyrillic-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2)
+ format("woff2");
+ unicode-range:
+ U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+/* cyrillic */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2)
+ format("woff2");
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+/* symbols2 */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2)
+ format("woff2");
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+
+/* vietnamese */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2)
+ format("woff2");
+ unicode-range:
+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
+ U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
+ U+1EA0-1EF9, U+20AB;
+}
+
+/* latin-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2)
+ format("woff2");
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
+ U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
+ U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+/* latin */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2)
+ format("woff2");
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
+ U+2215, U+FEFF, U+FFFD;
+}
+
+/* cyrillic-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2)
+ format("woff2");
+ unicode-range:
+ U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+/* cyrillic */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2)
+ format("woff2");
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+/* symbols2 */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2)
+ format("woff2");
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+
+/* vietnamese */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2)
+ format("woff2");
+ unicode-range:
+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
+ U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
+ U+1EA0-1EF9, U+20AB;
+}
+
+/* latin-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2)
+ format("woff2");
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
+ U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
+ U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+/* latin */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2)
+ format("woff2");
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
+ U+2215, U+FEFF, U+FFFD;
+}
+
+/* cyrillic-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2)
+ format("woff2");
+ unicode-range:
+ U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+/* cyrillic */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2)
+ format("woff2");
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+/* symbols2 */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2)
+ format("woff2");
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+
+/* vietnamese */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2)
+ format("woff2");
+ unicode-range:
+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
+ U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
+ U+1EA0-1EF9, U+20AB;
+}
+
+/* latin-ext */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2)
+ format("woff2");
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
+ U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
+ U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+/* latin */
+@font-face {
+ font-family: "Geist Mono";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2)
+ format("woff2");
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
+ U+2215, U+FEFF, U+FFFD;
+}
diff --git a/apps/diffr-web/src/github.ts b/apps/diffr-web/src/github.ts
new file mode 100644
index 000000000..bde0ac7a7
--- /dev/null
+++ b/apps/diffr-web/src/github.ts
@@ -0,0 +1,368 @@
+/**
+ * Read a pull request or a comparison straight from GitHub. Requests go only to api.github.com and
+ * raw.githubusercontent.com, both of which allow cross-origin reads; the token, when there is one,
+ * stays in this browser's localStorage and goes only to api.github.com.
+ */
+
+export type Target =
+ | { kind: "pull"; owner: string; repo: string; number: number }
+ | {
+ kind: "compare";
+ owner: string;
+ repo: string;
+ base: string;
+ head: string;
+ };
+
+export type Status = "added" | "deleted" | "modified" | "renamed" | "copied";
+
+export interface ChangedFile {
+ path: string;
+ /** The path at the base, for a rename or copy. */
+ previousPath?: string;
+ status: Status;
+ /** GitHub's patch for the file; absent for binary files and ones too large for the listing. */
+ patch?: string;
+ additions: number;
+ deletions: number;
+ /** The file's blob at the head; absent for a deletion. */
+ sha?: string;
+}
+
+export interface Change {
+ target: Target;
+ title: string;
+ url: string;
+ /** The merge base, as GitHub's own PR diff uses: what the head is compared with. */
+ base: string;
+ head: string;
+ files: ChangedFile[];
+ /** GitHub's totals for a pull request. Its file listing reports 0 for files too large to patch. */
+ additions?: number;
+ deletions?: number;
+}
+
+const TOKEN_KEY = "diffr.githubToken";
+
+export function token(): string | null {
+ try {
+ return localStorage.getItem(TOKEN_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function setToken(value: string | null) {
+ try {
+ if (value) localStorage.setItem(TOKEN_KEY, value);
+ else localStorage.removeItem(TOKEN_KEY);
+ } catch {
+ // Storage is off: the token lasts until the page closes, which is never, since nothing keeps it.
+ }
+}
+
+/** `owner/repo/pull/N` or `owner/repo/compare/base...head`, with or without github.com in front. */
+export function parseTarget(input: string): Target | null {
+ const path = input
+ .trim()
+ .replace(/^https?:\/\/(www\.)?github\.com\//, "")
+ .replace(/^\/+/, "")
+ .replace(/[?#].*$/, "");
+
+ const pull = path.match(/^([\w.-]+)\/([\w.-]+)\/pulls?\/(\d+)/);
+
+ if (pull)
+ return {
+ kind: "pull",
+ owner: pull[1]!,
+ repo: pull[2]!,
+ number: Number(pull[3]),
+ };
+
+ const compare = path.match(
+ /^([\w.-]+)\/([\w.-]+)\/compare\/(.+?)\.\.\.?(.+)$/,
+ );
+
+ if (compare)
+ return {
+ kind: "compare",
+ owner: compare[1]!,
+ repo: compare[2]!,
+ base: decodeURIComponent(compare[3]!),
+ head: decodeURIComponent(compare[4]!),
+ };
+
+ return null;
+}
+
+export function targetPath(target: Target): string {
+ const repo = `/${target.owner}/${target.repo}`;
+
+ return target.kind === "pull"
+ ? `${repo}/pull/${target.number}`
+ : `${repo}/compare/${encodeURIComponent(target.base)}...${encodeURIComponent(target.head)}`;
+}
+
+export class GitHubError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ }
+}
+
+async function api(path: string): Promise {
+ const auth = token();
+
+ const headers = new Headers({ Accept: "application/vnd.github+json" });
+
+ if (auth) headers.set("Authorization", `Bearer ${auth}`);
+
+ const response = await fetch(`https://api.github.com${path}`, { headers });
+
+ if (!response.ok) {
+ // SAFETY: GitHub's error bodies carry a message; without one the status text is shown.
+ const body = (await response.json().catch(() => ({}))) as {
+ message?: string;
+ };
+
+ const limited = response.headers.get("x-ratelimit-remaining") === "0";
+ const reset = Number(response.headers.get("x-ratelimit-reset")) * 1000;
+
+ const until = reset
+ ? ` until ${new Date(reset).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
+ : "";
+
+ const message = limited
+ ? `GitHub's rate limit is spent${auth ? "" : " (60 requests an hour without a token)"}${until}. Add a token or try again then.`
+ : response.status === 404 && !auth
+ ? "Not found. A private repository needs a token"
+ : (body.message ?? response.statusText);
+
+ throw new GitHubError(
+ `GitHub ${response.status}: ${message}`,
+ response.status,
+ );
+ }
+
+ // SAFETY: each caller names the shape GitHub documents for its endpoint.
+ return response.json() as Promise;
+}
+
+interface ApiFile {
+ filename: string;
+ previous_filename?: string;
+ status:
+ | "added"
+ | "removed"
+ | "modified"
+ | "renamed"
+ | "copied"
+ | "changed"
+ | "unchanged";
+ patch?: string;
+ additions: number;
+ deletions: number;
+ sha?: string;
+}
+
+const statuses: Record = {
+ added: "added",
+ removed: "deleted",
+ modified: "modified",
+ renamed: "renamed",
+ copied: "copied",
+ changed: "modified",
+ unchanged: "modified",
+};
+
+const changedFile = (file: ApiFile): ChangedFile => ({
+ path: file.filename,
+ previousPath: file.previous_filename,
+ status: statuses[file.status],
+ patch: file.patch,
+ additions: file.additions,
+ deletions: file.deletions,
+ sha: file.status === "removed" ? undefined : file.sha,
+});
+
+/** Every page of a listing GitHub caps at 100 a page; it stops listing a PR's files at 3000. */
+async function pages(
+ path: (page: number) => string,
+ items: (response: R) => T[],
+): Promise {
+ const all: T[] = [];
+
+ for (let page = 1; page <= 30; page++) {
+ const batch = items(await api(path(page)));
+ all.push(...batch);
+
+ if (batch.length < 100) break;
+ }
+
+ return all;
+}
+
+/** What can be shown before the whole listing is in: the title and the first page of files. */
+export type Preview = Omit & { base?: string };
+
+/**
+ * Load a change. A pull request calls `onPreview` as soon as its first hundred files are known,
+ * so they can be shown while the rest of the listing and the merge base load.
+ */
+export async function loadChange(
+ target: Target,
+ onPreview?: (preview: Preview) => void,
+): Promise {
+ if (
+ import.meta.env.DEV &&
+ new URLSearchParams(location.search).has("ui-fixture")
+ ) {
+ const data = await (await fetch("/__ui-fixture.json")).json();
+ onPreview?.(data.change);
+
+ return data.change;
+ }
+
+ const repo = `/repos/${target.owner}/${target.repo}`;
+
+ if (target.kind === "pull") {
+ const page = (n: number) =>
+ api(
+ `${repo}/pulls/${target.number}/files?per_page=100&page=${n}`,
+ );
+
+ // The first page of files needs nothing from the PR, so it is asked for alongside it.
+ const [pr, first] = await Promise.all([
+ api<{
+ title: string;
+ html_url: string;
+ base: { sha: string };
+ head: { sha: string };
+ changed_files: number;
+ additions: number;
+ deletions: number;
+ }>(`${repo}/pulls/${target.number}`),
+ page(1),
+ ]);
+
+ const preview: Preview = {
+ target,
+ title: pr.title,
+ url: pr.html_url,
+ head: pr.head.sha,
+ files: first.map(changedFile),
+ additions: pr.additions,
+ deletions: pr.deletions,
+ };
+
+ onPreview?.(preview);
+
+ // The merge base is all diffr needs to start on the files already shown.
+ const base = api<{ merge_base_commit: { sha: string } }>(
+ `${repo}/compare/${pr.base.sha}...${pr.head.sha}?per_page=1`,
+ );
+
+ void base.then(
+ (compare) =>
+ onPreview?.({ ...preview, base: compare.merge_base_commit.sha }),
+ () => {},
+ );
+ // The PR says how many files it changed, so every other page can be asked for at once.
+ const count = Math.min(30, Math.max(1, Math.ceil(pr.changed_files / 100)));
+
+ const [compare, ...rest] = await Promise.all([
+ base,
+ ...Array.from({ length: count - 1 }, (_, i) => page(i + 2)),
+ ]);
+
+ const files = [first, ...rest].flat();
+
+ return {
+ target,
+ title: pr.title,
+ url: pr.html_url,
+ base: compare.merge_base_commit.sha,
+ head: pr.head.sha,
+ files: files.map(changedFile),
+ additions: pr.additions,
+ deletions: pr.deletions,
+ };
+ }
+
+ const range = `${encodeURIComponent(target.base)}...${encodeURIComponent(target.head)}`;
+
+ type Compare = {
+ merge_base_commit: { sha: string };
+ html_url: string;
+ files?: ApiFile[];
+ };
+
+ let compare: Compare | undefined;
+
+ const [head, files] = await Promise.all([
+ api<{ sha: string }>(`${repo}/commits/${encodeURIComponent(target.head)}`),
+ pages(
+ (page) => `${repo}/compare/${range}?per_page=100&page=${page}`,
+ (r: Compare) => {
+ compare ??= r;
+
+ return r.files ?? [];
+ },
+ ),
+ ]);
+
+ return {
+ target,
+ title: `${target.base}...${target.head}`,
+ url: compare!.html_url,
+ base: compare!.merge_base_commit.sha,
+ head: head.sha,
+ files: files.map(changedFile),
+ };
+}
+
+const encodePath = (path: string) =>
+ path.split("/").map(encodeURIComponent).join("/");
+
+/** A file's text at a commit. Without a token, raw.githubusercontent.com serves public files outside the API's rate limit. */
+export async function fileText(
+ target: Target,
+ sha: string,
+ path: string,
+): Promise {
+ if (
+ import.meta.env.DEV &&
+ new URLSearchParams(location.search).has("ui-fixture")
+ ) {
+ const data = await (await fetch("/__ui-fixture.json")).json();
+
+ return data.texts[sha][path];
+ }
+
+ const auth = token();
+
+ const response = auth
+ ? await fetch(
+ `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${encodePath(path)}?ref=${sha}`,
+ {
+ headers: {
+ Accept: "application/vnd.github.raw+json",
+ Authorization: `Bearer ${auth}`,
+ },
+ },
+ )
+ : await fetch(
+ `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${sha}/${encodePath(path)}`,
+ );
+
+ if (!response.ok)
+ throw new GitHubError(
+ `GitHub ${response.status} fetching ${path}`,
+ response.status,
+ );
+
+ // diffr decides what is binary from the bytes, so keep NULs; invalid UTF-8 becomes U+FFFD.
+ return new TextDecoder().decode(await response.arrayBuffer());
+}
diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts
new file mode 100644
index 000000000..54d6c1bae
--- /dev/null
+++ b/apps/diffr-web/src/main.ts
@@ -0,0 +1,478 @@
+/**
+ * The page: a landing form, or one pull request or comparison at `/owner/repo/pull/N` or
+ * `/owner/repo/compare/base...head`. Everything runs here; GitHub is the only server it talks to.
+ */
+import "vs/base/browser/ui/codicons/codiconStyles.js";
+import { observableValue } from "vs/base/common/observable.js";
+
+import "./fonts.css";
+import "./styles.css";
+import "./editorFont.js";
+import { Comparison } from "./comparison.js";
+import { Engine, onEngineChange } from "./engine/engine.js";
+import { parseTarget, setToken, targetPath, token } from "./github.js";
+import { Panels } from "./panels.js";
+import { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js";
+import { readSetting, writeSetting } from "./settings.js";
+import { StandaloneServices } from "./standalone/browser/standaloneServices.js";
+import { applyTheme } from "./theme.js";
+
+const instantiation = StandaloneServices.initialize({});
+
+const layout = new ReviewDiffLayoutSetting();
+
+const wordWrap = observableValue("wordWrap", false);
+
+const root = document.getElementById("app")!;
+
+// Review Desktop's stylesheet is scoped to its workbench; the theme's variables to Monaco components.
+root.className = "app monaco-workbench review-workbench monaco-component";
+
+applyTheme(root);
+
+const header = root.appendChild(element("header", "app-header"));
+
+const status = root.appendChild(element("div", "app-status"));
+
+status.hidden = true;
+
+status.setAttribute("role", "status");
+
+const statusMessage = status.appendChild(element("span", "app-status-message"));
+
+const retry = status.appendChild(actionButton("Retry", route));
+
+const body = root.appendChild(element("main", "app-body"));
+
+const overflow = root.appendChild(element("div", "monaco-editor app-overflow"));
+
+let engine: Engine | undefined;
+
+let comparison: Comparison | undefined;
+
+const panels = new Panels(() => comparison);
+
+root.appendChild(panels.element);
+
+onEngineChange(() => panels.update());
+
+function element(
+ tag: K,
+ className?: string,
+ text?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+
+ if (className) node.className = className;
+
+ if (text !== undefined) node.textContent = text;
+
+ return node;
+}
+
+function iconButton(
+ icon: string,
+ tooltip: string,
+ onClick: () => void,
+): HTMLButtonElement {
+ const button = element("button", `app-icon-button codicon codicon-${icon}`);
+ button.title = tooltip;
+ button.setAttribute("aria-label", tooltip);
+ button.addEventListener("click", onClick);
+
+ return button;
+}
+
+/** One engine for the page, remade when the configuration changes. */
+function currentEngine(): Engine {
+ return (engine ??= new Engine(readSetting("config")));
+}
+
+function route(): void {
+ comparison?.dispose();
+ comparison = undefined;
+ body.replaceChildren();
+ const target = parseTarget(location.pathname);
+
+ if (!target) {
+ document.title = "diffr";
+ renderHeader();
+ renderLanding();
+ panels.update();
+
+ return;
+ }
+
+ const container = body.appendChild(element("div", "app-comparison"));
+
+ const current = (comparison = new Comparison(
+ container,
+ overflow,
+ target,
+ currentEngine(),
+ layout,
+ wordWrap,
+ instantiation,
+ ));
+
+ const update = () => {
+ if (comparison !== current) return;
+ document.title = current.title ? `${current.title} · diffr` : "diffr";
+ renderHeader();
+ panels.update();
+ };
+
+ current.onDidChange(update);
+ update();
+}
+
+function renderHeader(): void {
+ const focusedButton = Array.from(header.querySelectorAll("button")).findIndex(
+ (button) => button === document.activeElement,
+ );
+
+ header.replaceChildren();
+ const error = comparison?.error;
+
+ const loading =
+ comparison && !comparison.preview && !comparison.change && !error;
+
+ status.hidden = !error && !loading;
+ status.classList.toggle("is-error", !!error);
+ status.setAttribute("role", error ? "alert" : "status");
+ statusMessage.textContent = error ?? (loading ? "Loading comparison…" : "");
+ retry.hidden = !error;
+ const home = header.appendChild(element("a", "app-wordmark", "diffr"));
+ home.href = "/";
+ home.title = "Open another pull request";
+ home.addEventListener("click", (event) => {
+ event.preventDefault();
+ navigate("/");
+ });
+
+ if (comparison) {
+ const title = header.appendChild(
+ element(
+ "a",
+ "app-title",
+ comparison.title ?? targetPath(comparison.target).slice(1),
+ ),
+ );
+
+ if (comparison.url) {
+ title.href = comparison.url;
+ title.target = "_blank";
+ title.rel = "noopener";
+ title.title = `Open on GitHub · ${targetPath(comparison.target).slice(1)}`;
+ }
+ }
+
+ header.appendChild(element("span", "app-spacer"));
+
+ if (comparison) {
+ for (const [panel, icon, label] of [
+ ["stats", "graph", "Diff stats (F2)"],
+ ["engine", "dashboard", "Engine stats (F3)"],
+ ] as const) {
+ const button = iconButton(icon, label, () => togglePanel(panel));
+ button.setAttribute("aria-pressed", String(panels.isOpen(panel)));
+ header.appendChild(button);
+ }
+
+ header.appendChild(
+ iconButton("layout-sidebar-left", "Toggle file tree (⌘/Ctrl+B)", () =>
+ comparison?.toggleFileTree(),
+ ),
+ );
+
+ const wrap = iconButton("word-wrap", "Toggle word wrap (W)", () => {
+ wordWrap.set(!wordWrap.get(), undefined);
+ renderHeader();
+ });
+
+ wrap.setAttribute("aria-pressed", String(wordWrap.get()));
+ header.appendChild(wrap);
+ const split = layout.get() === "split";
+ header.appendChild(
+ iconButton(
+ split ? "split-horizontal" : "list-flat",
+ split ? "Split · show unified" : "Unified · show split",
+ () => {
+ void layout.toggle().then(renderHeader);
+ },
+ ),
+ );
+ }
+
+ header.appendChild(
+ iconButton("settings-gear", "diffr configuration", openConfig),
+ );
+ header.appendChild(
+ iconButton(
+ token() ? "unlock" : "key",
+ token() ? "GitHub token set" : "Add a GitHub token",
+ openToken,
+ ),
+ );
+
+ if (focusedButton >= 0) {
+ const button = header.querySelectorAll("button").item(focusedButton);
+ button?.focus({ preventScroll: true });
+ }
+}
+
+function togglePanel(panel: "stats" | "engine"): void {
+ panels.toggle(panel);
+ renderHeader();
+}
+
+function renderLanding(): void {
+ const landing = body.appendChild(element("form", "app-landing"));
+ landing.appendChild(element("h1", undefined, "diffr"));
+ landing.appendChild(
+ element(
+ "p",
+ undefined,
+ "Structural diffs of GitHub pull requests, worked out in your browser.",
+ ),
+ );
+ const input = landing.appendChild(element("input"));
+ input.placeholder = "github.com/owner/repo/pull/123";
+ input.setAttribute("aria-label", "Pull request or comparison URL");
+ input.autofocus = true;
+ const error = landing.appendChild(element("p", "app-landing-error"));
+ landing.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const target = parseTarget(input.value);
+
+ if (target) navigate(targetPath(target));
+ else
+ error.textContent =
+ "A pull request or compare URL, like github.com/owner/repo/pull/123";
+ });
+ input.focus();
+}
+
+function navigate(path: string): void {
+ if (path !== location.pathname) history.pushState(null, "", path);
+ route();
+}
+
+function dialog(
+ title: string,
+ build: (dialog: HTMLDialogElement, actions: HTMLElement) => void,
+): void {
+ const node = root.appendChild(element("dialog", "app-dialog"));
+ node.appendChild(element("h2", undefined, title));
+ const actions = element("div", "app-dialog-actions");
+ build(node, actions);
+ node.appendChild(actions);
+ node.addEventListener("close", () => node.remove());
+ node.showModal();
+}
+
+function actionButton(
+ label: string,
+ onClick: () => void,
+ primary = false,
+): HTMLButtonElement {
+ const button = element(
+ "button",
+ primary ? "app-button primary" : "app-button",
+ label,
+ );
+
+ button.type = "button";
+ button.addEventListener("click", onClick);
+
+ return button;
+}
+
+function openToken(): void {
+ dialog("GitHub token", (node, actions) => {
+ node.appendChild(
+ element(
+ "p",
+ undefined,
+ "For private repositories, and GitHub's higher rate limit. It stays in this browser and is sent only to GitHub.",
+ ),
+ );
+ const input = node.appendChild(element("input"));
+ input.type = "password";
+ input.placeholder = "github_pat_…";
+ input.value = token() ?? "";
+ input.setAttribute("aria-label", "GitHub token");
+
+ const save = (value: string | null) => {
+ setToken(value);
+ node.close();
+ route();
+ };
+
+ if (token()) actions.appendChild(actionButton("Remove", () => save(null)));
+ actions.appendChild(actionButton("Cancel", () => node.close()));
+ actions.appendChild(
+ actionButton("Save", () => save(input.value.trim() || null), true),
+ );
+ });
+}
+
+function openConfig(): void {
+ dialog("diffr configuration", (node, actions) => {
+ node.appendChild(
+ element(
+ "p",
+ undefined,
+ "diffr's config.toml: file tags, hidden files and plugins. It stays in this browser.",
+ ),
+ );
+ const input = node.appendChild(element("textarea"));
+ input.spellcheck = false;
+ input.value = readSetting("config") ?? "";
+ input.placeholder = "# diffr's defaults";
+ input.setAttribute("aria-label", "config.toml");
+ const status = node.appendChild(element("p", "app-dialog-status"));
+
+ const show = (candidate: Engine) => {
+ candidate.notices.then(
+ (notices) => (status.textContent = notices.join("\n")),
+ (error: Error) => (status.textContent = error.message),
+ );
+ };
+
+ show(currentEngine());
+ actions.appendChild(actionButton("Cancel", () => node.close()));
+ actions.appendChild(
+ actionButton(
+ "Save",
+ () => {
+ const config = input.value.trim() ? input.value : undefined;
+ // Tried in its own engine first, so a broken file leaves the page as it was.
+ const candidate = new Engine(config);
+ status.textContent = "Checking…";
+ candidate.notices.then(
+ () => {
+ writeSetting("config", config);
+ engine?.dispose();
+ engine = candidate;
+ node.close();
+ route();
+ },
+ (error: Error) => {
+ candidate.dispose();
+ status.textContent = error.message;
+ },
+ );
+ },
+ true,
+ ),
+ );
+ });
+}
+
+window.addEventListener("popstate", route);
+
+/** A `z` waiting for its fold command. */
+let chord = false;
+
+/** The diffr TUI's keys, besides its menus and panning, which the page has as clicks and scrolls. */
+function command(event: KeyboardEvent): (() => void) | undefined {
+ // A shifted letter, as some keyboards and input tools report it.
+ const key =
+ event.shiftKey && event.key.length === 1
+ ? event.key.toUpperCase()
+ : event.key;
+
+ if (key === "F2" || key === "F3")
+ return () => togglePanel(key === "F2" ? "stats" : "engine");
+
+ if (!comparison) return undefined;
+ const current = comparison;
+
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && key === "b")
+ return () => current.toggleFileTree();
+
+ // Plain keys only, and never while typing. The editors are read only, so their input area types
+ // nothing.
+ if (event.metaKey || event.ctrlKey || event.altKey) return undefined;
+ const target = event.target;
+
+ if (
+ (target instanceof Element &&
+ target.closest("input, dialog, .find-widget")) ||
+ (target instanceof HTMLTextAreaElement &&
+ !target.classList.contains("inputarea"))
+ )
+ return undefined;
+
+ // Space activates a focused control. The diff scrolling shortcut must not steal that action.
+ if (
+ key === " " &&
+ target instanceof Element &&
+ target.closest("button, a[href], [role=checkbox], [role=button]")
+ )
+ return undefined;
+
+ // Shift, for a capital, is pressed before the letter it shifts.
+ if (["Shift", "Alt", "Control", "Meta"].includes(key)) return undefined;
+
+ if (chord) {
+ chord = false;
+
+ if (key === "M" || key === "R") return () => current.foldAll(key === "M");
+
+ return "aocAOCjk".includes(key) && key.length === 1
+ ? () => current.foldCommand(key)
+ : undefined;
+ }
+
+ const viewport =
+ (document.querySelector(".app-comparison")?.clientHeight ?? 0) - 2 * LINE;
+
+ const keys = new Map void>([
+ ["z", () => (chord = true)],
+ ["]", () => current.goToChange("next")],
+ ["[", () => current.goToChange("previous")],
+ ["s", () => void layout.toggle().then(renderHeader)],
+ [
+ "w",
+ () => {
+ wordWrap.set(!wordWrap.get(), undefined);
+ renderHeader();
+ },
+ ],
+ ["c", () => current.toggleContextGaps()],
+ ["i", () => togglePanel("stats")],
+ ["\\", () => current.toggleFileTree()],
+ ["j", () => current.scroll(LINE)],
+ ["k", () => current.scroll(-LINE)],
+ ["d", () => current.scroll(viewport / 2)],
+ ["u", () => current.scroll(-viewport / 2)],
+ ["f", () => current.scroll(viewport)],
+ [" ", () => current.scroll(event.shiftKey ? -viewport : viewport)],
+ ["b", () => current.scroll(-viewport)],
+ ["g", () => current.scroll("top")],
+ ["G", () => current.scroll("end")],
+ ]);
+
+ return keys.get(key);
+}
+
+/** One row of the diff, in pixels: the editor's 12px font at Monaco's line height. */
+const LINE = 18;
+
+// Capturing, so a read-only editor never answers a letter with its "cannot edit" note.
+window.addEventListener(
+ "keydown",
+ (event: KeyboardEvent) => {
+ const run = command(event);
+
+ if (!run) return;
+ event.preventDefault();
+ event.stopPropagation();
+ run();
+ },
+ true,
+);
+
+route();
diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts
new file mode 100644
index 000000000..9c4c2a1a3
--- /dev/null
+++ b/apps/diffr-web/src/panels.ts
@@ -0,0 +1,162 @@
+/** The diff stats (F2) and engine (F3) panels: a card over the foot of the file tree. */
+import type { Comparison } from "./comparison.js";
+import { engineStats } from "./engine/engine.js";
+import { readSetting, writeSetting } from "./settings.js";
+
+type Panel = "stats" | "engine";
+
+/** A label, its value, and what the value means. */
+type Row = [label: string, value: string, tooltip?: string];
+
+const number = (n: number) => n.toLocaleString("en");
+
+const seconds = (ms: number) => `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`;
+
+const megabytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(0)} MB`;
+
+export class Panels {
+ readonly element: HTMLElement;
+ private readonly open = new Set();
+ private frame = 0;
+
+ constructor(private readonly comparison: () => Comparison | undefined) {
+ this.element = document.createElement("div");
+ this.element.className = "app-panels";
+
+ for (const panel of ["stats", "engine"] as const)
+ if (readSetting(`panel.${panel}`) === "1") this.open.add(panel);
+ this.render();
+ }
+
+ isOpen(panel: Panel): boolean {
+ return this.open.has(panel);
+ }
+
+ toggle(panel: Panel): void {
+ if (this.open.has(panel)) this.open.delete(panel);
+ else this.open.add(panel);
+ writeSetting(`panel.${panel}`, this.open.has(panel) ? "1" : undefined);
+ this.render();
+ }
+
+ /** Redraw on the next frame; work updates arrive per file. */
+ update(): void {
+ if (this.frame) return;
+ this.frame = requestAnimationFrame(() => {
+ this.frame = 0;
+ this.render();
+ });
+ }
+
+ private render(): void {
+ const comparison = this.comparison();
+ this.element.replaceChildren();
+ this.element.hidden = !comparison || !this.open.size;
+
+ if (!comparison) return;
+
+ if (this.open.has("stats"))
+ this.section("Diff stats", "F2", this.stats(comparison));
+
+ if (this.open.has("engine"))
+ this.section("Engine", "F3", this.engine(comparison));
+ }
+
+ private section(title: string, key: string, rows: Row[]): void {
+ const section = this.element.appendChild(document.createElement("section"));
+ const head = section.appendChild(document.createElement("h2"));
+ head.textContent = title;
+ head.title = `${key} to close`;
+
+ for (const [label, value, tooltip] of rows) {
+ const row = section.appendChild(document.createElement("div"));
+ row.className = "app-panel-row";
+
+ if (tooltip) row.title = tooltip;
+ row.appendChild(document.createElement("span")).textContent = label;
+ row.appendChild(document.createElement("span")).textContent = value;
+ }
+ }
+
+ private stats(comparison: Comparison): Row[] {
+ const { work } = comparison;
+ const counts = comparison.counts;
+
+ const rows: Row[] = [
+ ["Files", number(comparison.fileCount)],
+ [
+ "Additions",
+ number(counts.added),
+ "diffr's counts once every file is diffed; GitHub's until then",
+ ],
+ ["Deletions", number(counts.removed)],
+ [
+ "Diffed by diffr",
+ `${number(work.diffed)} / ${number(comparison.fileCount)}`,
+ "Files near the screen go first; hidden files last",
+ ],
+ ];
+
+ if (work.hidden)
+ rows.push([
+ "Hidden by diffr",
+ number(work.hidden),
+ "Generated, vendored and lock files, folded until opened",
+ ]);
+
+ const fallbacks = comparison.fallbacks;
+
+ if (fallbacks.length)
+ rows.push([
+ "Line diff",
+ number(fallbacks.length),
+ fallbacks.map(({ path, message }) => `${path}: ${message}`).join("\n"),
+ ]);
+
+ if (work.failed) rows.push(["Failed", number(work.failed)]);
+
+ return rows;
+ }
+
+ private engine(comparison: Comparison): Row[] {
+ const stats = engineStats();
+ const { timing, work } = comparison;
+ const at = (ms?: number) => (ms === undefined ? "…" : seconds(ms));
+
+ const rows: Row[] = [
+ [
+ "Workers",
+ `${stats.workers} of ${stats.maxWorkers}`,
+ "Workers grow while files queue and shrink after ten idle seconds",
+ ],
+ ["Engine download", stats.bytes ? megabytes(stats.bytes) : "…"],
+ ["Engine compiled", at(stats.compiled)],
+ [
+ "Worker ready",
+ stats.ready === undefined ? "…" : seconds(stats.ready),
+ "Loading the module and the configuration in a worker",
+ ],
+ ["Files listed", at(timing.listed)],
+ ["Files previewed", at(timing.previewed)],
+ ["First diff", at(timing.firstDiff)],
+ ["All diffed", at(timing.done)],
+ ["Fetching", number(work.fetching)],
+ ["Diffing", number(work.diffing)],
+ [
+ "Engine time",
+ work.diffed ? seconds(work.diffMs) : "…",
+ "Time diffr spent across all workers",
+ ],
+ ];
+
+ if (work.slowest)
+ rows.push(["Slowest", seconds(work.slowest.ms), work.slowest.path]);
+ rows.push([
+ "Memory",
+ stats.memory ? megabytes(stats.memory) : "…",
+ "The largest wasm memory a worker holds; it never shrinks",
+ ]);
+
+ return rows;
+ }
+}
diff --git a/apps/diffr-web/src/protocol.ts b/apps/diffr-web/src/protocol.ts
new file mode 100644
index 000000000..7176810fe
--- /dev/null
+++ b/apps/diffr-web/src/protocol.ts
@@ -0,0 +1,125 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+// What the forked review code imported from Review Desktop's generated
+// `reviewProtocol.ts`: diffr's wire types and the review contracts it uses.
+// Only types come from the contract module, so its zod validators stay out
+// of the page.
+import type {
+ StructuralChanges,
+ StructuralLineCounts,
+} from "@dev.fast/diffr/src/contract.ts";
+
+export type {
+ StructuralPairing,
+ StructuralProblem,
+ StructuralPos,
+ StructuralSpan,
+ StructuralVisibility,
+ StructuralRegion,
+ StructuralSyntaxSpan,
+ StructuralSource,
+ StructuralLineCounts,
+ StructuralStats,
+ StructuralDiff,
+ StructuralFileRef,
+ StructuralFileChange,
+ StructuralFileStatus,
+ StructuralDiffEvent,
+} from "@dev.fast/diffr/src/contract.ts";
+
+export { structuralRows } from "@dev.fast/review-protocol/src/source-alignment.ts";
+
+export function structuralChangeCounts(
+ changes: StructuralChanges,
+): StructuralLineCounts {
+ const count = (ranges: [number, number][]) =>
+ ranges.reduce((sum, [start, end]) => sum + end - start, 0);
+
+ return { added: count(changes.head), removed: count(changes.base) };
+}
+
+/** One changed file, as the file tree and headers name it. */
+export interface ReviewDiffFileWire {
+ path: string;
+ previousPath?: string;
+ status: "added" | "modified" | "deleted" | "renamed" | "unchanged";
+ additions: number;
+ deletions: number;
+ /** The file's contents are binary. */
+ binary?: true;
+ patch?: string;
+}
+
+export interface ReviewDiffLens {
+ wholeFiles?: boolean;
+ id: string;
+ title: string;
+ reviewId: string;
+ version: number;
+ ranges: readonly {
+ side: "base" | "head";
+ file: string;
+ fromLine: number;
+ toLine: number;
+ }[];
+}
+
+/** "folded": nothing left to read and nothing marked; diffr folds all of it by default. */
+export type ReviewDiffProgressState =
+ | "unread"
+ | "partial"
+ | "viewed"
+ | "folded";
+
+/** Reader progress is supplied independently of the immutable comparison. */
+export interface ReviewDiffProgressFile {
+ path: string;
+ state: ReviewDiffProgressState;
+ remaining: { additions: number; deletions: number };
+ total: { additions: number; deletions: number };
+ viewedRanges: ReviewDiffLens["ranges"];
+ changedRanges: ReviewDiffLens["ranges"];
+ unfoldRanges?: ReviewDiffLens["ranges"];
+}
+
+export interface ReviewDiffSection {
+ files?: readonly ReviewDiffProgressFile[];
+ id: string;
+ label: string;
+ sources: ReviewDiffLens["ranges"];
+ state: ReviewDiffProgressState;
+ total: { additions: number; deletions: number };
+ remaining: { additions: number; deletions: number };
+}
+
+export interface ReviewDiffProgress {
+ sections?: readonly ReviewDiffSection[];
+ files: readonly ReviewDiffProgressFile[];
+ /** Only present while applying a new viewed action, to reset affected fold overrides. */
+ changedPaths?: readonly string[];
+}
+
+/** Marks lines viewed or unviewed; resolves once the new progress is applied. */
+export type SetViewed = (
+ ranges: ReviewDiffLens["ranges"],
+ viewed: boolean,
+) => Promise;
+
+/** What the forked view reads from Review Desktop's view spec. */
+export interface ReviewDiffViewSpec {
+ /** Embed the same diff renderer in the review document. */
+ document?: {
+ heightMode: "capped" | "content";
+ onDidChangeHeight(height: number): void;
+ onDidFocus?: () => void;
+ onDidOpen?: () => void;
+ };
+ /** Mark a structural scope using the same persisted line coverage as file marks. */
+ onSetViewed?: (
+ ranges: ReviewDiffLens["ranges"],
+ viewed: boolean,
+ ) => void | Promise;
+}
diff --git a/apps/diffr-web/src/review/browser/media/review.css b/apps/diffr-web/src/review/browser/media/review.css
new file mode 100644
index 000000000..a21c4fb0a
--- /dev/null
+++ b/apps/diffr-web/src/review/browser/media/review.css
@@ -0,0 +1,1675 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/* Wherever the workbench root shows around the parts, it reads as the editor
+ rather than the stock WORKBENCH_BACKGROUND (#252526 on dark). */
+.monaco-workbench.review-workbench {
+ background: var(--vscode-editor-background);
+}
+
+/* Monaco's scrollbars take the canvas pill (packages/review/app/src/styles.css).
+ The slider fills its 10px lane (the scrollable element's default, and the
+ editor.scrollbar.* sizes in reviewConfigurationDefaults.ts), and a transparent
+ border insets the paint to a 6px rounded pill, the same construction as the
+ canvas's ::-webkit-scrollbar-thumb. The stock hover and active rules reset
+ `background-clip` through their shorthand, which this rule outranks. */
+.monaco-workbench.review-workbench
+ .monaco-scrollable-element
+ > .scrollbar
+ > .slider {
+ box-sizing: border-box;
+ border: 2px solid transparent;
+ border-radius: 999px;
+ background-clip: padding-box;
+}
+
+.monaco-workbench.review-workbench .monaco-scrollable-element > .scrollbar {
+ overflow: hidden;
+}
+
+/* KEEP IN SYNC WITH REVIEW_CHROME_HEIGHT in review/common/reviewChrome.ts. The
+ overlay must be as tall as the editor tab strip below it, or the window
+ controls and the navigation arrows stop aligning with the tab pills. */
+.monaco-workbench .part.titlebar.review-chrome-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ width: 100%;
+ height: 40px;
+ box-shadow: none;
+ pointer-events: none;
+ background: transparent;
+}
+
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-left,
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-right {
+ width: max-content !important;
+ min-width: 0;
+ flex: 0 0 auto !important;
+ padding-left: 0 !important;
+ pointer-events: auto;
+}
+
+/* Windows and Linux draw their window controls on the right. Without this the
+ right section packs against the left one, and the invisible Windows controls
+ placeholder sits over the first editor tabs and swallows their clicks. */
+.monaco-workbench:is(.linux, .windows)
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-right {
+ margin-left: auto;
+}
+
+/* The native macOS stoplights sit over the left container. Make its unused
+ area draggable, including the space between and around the stoplights. The
+ navigation control below removes itself from this drag region. */
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-left {
+ -webkit-app-region: drag;
+}
+
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-right {
+ -webkit-app-region: no-drag;
+}
+
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-drag-region {
+ left: var(--review-chrome-left-width, 0px);
+ right: var(--review-chrome-right-width, 0px);
+ width: auto;
+ display: none;
+ pointer-events: none;
+}
+
+.monaco-workbench.nomaineditorarea
+ .part.titlebar.review-chrome-overlay
+ > .review-titlebar-container
+ > .titlebar-drag-region {
+ display: block;
+ pointer-events: auto;
+ -webkit-app-region: drag;
+}
+
+.monaco-workbench
+ .part.editor
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container.review-chrome-tab-strip {
+ box-sizing: border-box;
+ padding-left: 6px;
+ padding-right: 6px;
+ user-select: none;
+ -webkit-app-region: drag;
+}
+
+.monaco-workbench.review-tab-dragging
+ .part.editor
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container.review-chrome-tab-strip {
+ -webkit-app-region: no-drag;
+}
+
+.monaco-workbench
+ .part.editor
+ .tabs-and-actions-container.review-chrome-tab-strip
+ .tabs-container {
+ -webkit-app-region: drag;
+}
+
+.monaco-workbench.review-tab-dragging
+ .part.editor
+ .tabs-and-actions-container.review-chrome-tab-strip
+ .tabs-container {
+ -webkit-app-region: no-drag;
+}
+
+.monaco-workbench
+ .part.editor
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container.review-chrome-inset {
+ padding-left: calc(var(--review-chrome-left-width, 0px) + 6px);
+ padding-right: calc(var(--review-chrome-right-width, 0px) + 6px);
+}
+
+.monaco-workbench .part.editor .tabs-container > .tab,
+.monaco-workbench .part.editor .tabs-container > .tabs-bar-add-tab,
+.monaco-workbench .part.editor .tabs-and-actions-container > .editor-actions,
+.monaco-workbench
+ .part.editor
+ .tabs-and-actions-container
+ > .editor-layout-actions,
+.monaco-workbench
+ .part.editor
+ .tabs-and-actions-container
+ > .monaco-scrollable-element
+ > .scrollbar {
+ -webkit-app-region: no-drag;
+}
+
+/* ---------------------------------------------------------------------------
+ Review file explorer
+
+ The explorer is the leftmost surface whenever it is visible, so the window
+ controls and the navigation arrows sit over its top row instead of over the
+ editor tab strip. Two rules follow from that: the explorer reserves one chrome
+ row for them, and the tab strip drops the traffic-light inset it would
+ otherwise need.
+ --------------------------------------------------------------------------- */
+
+/* The grid sizes the `.split-view-view` wrapper, not the part inside it, and the
+ part is a static block. Without this the part grows to the list's content
+ height, the list never scrolls, and every row renders. */
+.monaco-workbench .part.review-explorer {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background-color: var(--vscode-editor-background);
+ /* An inset shadow rather than a border, so the tree keeps the full part width
+ and `ReviewExplorerPart.layout` needs no separator correction. */
+ box-shadow: inset -1px 0 0
+ var(--vscode-panel-border, var(--vscode-sideBar-border, transparent));
+ overflow: hidden;
+}
+
+/* The one chrome row keeps the editor tab-strip background, so the top of the
+ window reads as a single bar running across the tree and the tabs. The step
+ from that background down to the tree is the whole divider — deliberately no
+ bottom rule.
+
+ A 1px rule here would be the only horizontal line at this edge: the editor tab
+ strip ends at the same y with no border and no shadow, because the Review pill
+ tabs carry no separators by design (see the pill-tab section below). A line on
+ just the tree half stops dead at the sash and reads as an unfinished stub.
+
+ `ReviewExplorerPart` writes `--review-chrome-height` on this part and the
+ workbench layout writes `--review-explorer-width` on the inset tab strip
+ (never on the workbench root; see `publishReviewChromeInset`), so
+ REVIEW_CHROME_HEIGHT stays the only source. The fallbacks cover the frame
+ before the part first lays out. */
+.monaco-workbench .part.review-explorer > .review-explorer-chrome-spacer {
+ box-sizing: border-box;
+ height: var(--review-chrome-height, 40px);
+ flex-shrink: 0;
+ background-color: var(--vscode-editorGroupHeader-tabsBackground);
+ user-select: none;
+ -webkit-app-region: drag;
+}
+
+/* Flex rather than `calc(100% - )`: the row above is the only fixed
+ height, so the tree takes whatever is left and the height is written once. */
+.monaco-workbench .part.review-explorer > .review-explorer-tree {
+ flex: 1;
+ min-height: 0;
+}
+
+/* ---------------------------------------------------------------------------
+ The file-tree toggle
+
+ The button always occupies its 22px slot, so the titlebar's left cluster keeps
+ a constant width. That matters because a ResizeObserver on the cluster feeds
+ `--review-chrome-left-width`, which the editor tab strip pads itself by — a
+ button that grew from 0 would slide every tab as it appeared. Only opacity and
+ transform animate here, so nothing reflows.
+ --------------------------------------------------------------------------- */
+
+.monaco-workbench .review-titlebar-file-tree-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ margin-left: 4px;
+ border-radius: 5px;
+ flex-shrink: 0;
+ color: var(--vscode-icon-foreground);
+ /* Nothing to toggle until a file or diff tab is active. */
+ opacity: 0;
+ transform: scale(0.8);
+ pointer-events: none;
+ /* The cluster around it drags the window; this button must not. */
+ -webkit-app-region: no-drag;
+}
+
+.monaco-workbench.review-explorer-available .review-titlebar-file-tree-toggle {
+ opacity: 1;
+ transform: none;
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+/* Reduced motion gets a straight cut: `monaco-enable-motion` is absent then,
+ because accessibility/browser/accessibilityService.ts toggles it from
+ `workbench.reduceMotion`. */
+.monaco-workbench.monaco-enable-motion .review-titlebar-file-tree-toggle {
+ transition:
+ opacity 120ms ease,
+ transform 120ms ease;
+}
+
+.monaco-workbench .review-titlebar-file-tree-toggle:hover {
+ background-color: var(--vscode-toolbar-hoverBackground);
+}
+
+.monaco-workbench .review-titlebar-file-tree-toggle:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: -1px;
+}
+
+/* One icon per state, picked here so the titlebar part needs no explorer state. */
+.monaco-workbench
+ .review-titlebar-file-tree-toggle
+ > .review-file-tree-icon-open,
+.monaco-workbench.review-explorer-visible
+ .review-titlebar-file-tree-toggle
+ > .review-file-tree-icon-closed {
+ display: none;
+}
+
+.monaco-workbench.review-explorer-visible
+ .review-titlebar-file-tree-toggle
+ > .review-file-tree-icon-open {
+ display: block;
+}
+
+/* The tab strip pads itself past the traffic lights and the navigation arrows,
+ which is correct only while the editor is the leftmost surface. The tree now
+ covers that chrome, so the strip only has to clear whatever the tree does NOT
+ cover — subtract the tree's width rather than assuming it covers all of it.
+
+ A flat `6px` would be wrong whenever the left cluster is wider than the tree.
+ The cluster counter-zooms (see `ReviewTitlebarPart.layout`), so zooming the
+ window out grows it in CSS pixels until it passes even the tree's 170px
+ minimum, and it would then sit over the first tab and swallow clicks as window
+ drags. `max()` keeps the plain 6px gutter in the normal case. */
+.monaco-workbench.review-explorer-visible
+ .part.editor
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container.review-chrome-inset {
+ padding-left: max(
+ 6px,
+ calc(
+ var(--review-chrome-left-width, 0px) + 6px -
+ var(--review-explorer-width, 0px)
+ )
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Review pill tabs
+
+ The tab row keeps the titlebar background and carries no separators, no
+ bottom border, and no per-tab top-border indicator. The active tab is a
+ raised pill. The other tabs are flat.
+
+ Each rule below starts with `body` because it must outrank an upstream rule
+ of the same class count in multieditortabscontrol.css.
+ --------------------------------------------------------------------------- */
+
+.monaco-workbench.review-workbench {
+ /* Dark boards are darker than the tray, so the active pill mixes lighter. */
+ --review-tab-fill: color-mix(
+ in srgb,
+ #ffffff 8%,
+ var(--vscode-editorGroupHeader-tabsBackground)
+ );
+ --review-tab-border: var(--vscode-editorWidget-border);
+ --review-tab-hover-fill: color-mix(
+ in srgb,
+ #ffffff 4%,
+ var(--vscode-editorGroupHeader-tabsBackground)
+ );
+ --review-tab-separator: var(--vscode-panel-border);
+ --review-chrome-font:
+ "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+/* Light themes: a board-colored pill. */
+.monaco-workbench.review-workbench.vs,
+.monaco-workbench.review-workbench.hc-light {
+ --review-tab-fill: var(--vscode-editor-background);
+ --review-tab-border: var(--vscode-panel-border);
+ --review-tab-hover-fill: color-mix(
+ in srgb,
+ #ffffff 40%,
+ var(--vscode-editorGroupHeader-tabsBackground)
+ );
+}
+
+/* 28px pill + 6px above + 6px below = the 40px row. EditorTabsControl reports
+ the same 40px through EDITOR_TAB_HEIGHT.review. The tab-height variable is
+ an inline style on `.title`, so this rule redeclares it one level down. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container {
+ --editor-group-tab-height: 28px;
+ padding-top: 6px;
+ padding-bottom: 6px;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title.compact-height
+ > .tabs-and-actions-container {
+ --editor-group-tab-height: 20px;
+}
+
+/* No bottom border below the row. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title.title-border-bottom::after,
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ > .tabs-and-actions-container.tabs-border-bottom::after {
+ display: none;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container {
+ gap: 4px;
+}
+
+/* Flat by default. `redrawTabBorders` writes the separator as an inline
+ `border-right`, so only `!important` removes it.
+
+ The pill carries no border at all: a 1px border would leave a 22px content
+ box inside the 24px tab, and the label line is `--editor-group-tab-height`
+ tall, so the icon and the name would sit 1px below the center. The active
+ pill draws its outline as an inset shadow instead, which costs no layout. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab {
+ border: none !important;
+ border-radius: 6px;
+ background-color: transparent;
+ box-shadow: none;
+ /* The whole inset. Upstream pads 10px on the left and nothing on the right,
+ which reads wider than the Paper design. */
+ padding-left: 4px;
+ padding-right: 4px;
+}
+
+/* Upstream shrinking tabs keep the label at its natural width, clip its flex
+ parent, and paint a gradient over the clipped edge. That treatment assumes
+ flat tabs. Inside Review's raised pill it becomes a dark block, and changing
+ only `text-overflow` cannot produce an ellipsis because the label itself
+ never gets narrower.
+
+ Give the label the pill's available width instead. The browser then draws an
+ ellipsis only when the text is actually clipped. Remove the upstream
+ gradient in every tab state; theme rules can still assign its background,
+ but the pseudo-element has no box to paint. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab:is(.sizing-shrink, .sizing-fixed)
+ > .monaco-icon-label
+ > .monaco-icon-label-container {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab:is(.sizing-shrink, .sizing-fixed)
+ .monaco-icon-label-container::after {
+ content: none;
+ display: none;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab.active {
+ background-color: var(--review-tab-fill);
+ box-shadow: inset 0 0 0 1px var(--review-tab-border);
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab:hover:not(.active) {
+ background-color: var(--review-tab-hover-fill);
+ box-shadow: none;
+}
+
+/* The close button carries three insets of its own: a 28px column, a 16px
+ button centered in it, and 2px of padding around the glyph. Together they put
+ the glyph 10px inside the pill while the icon on the left sits at 4px. The
+ column hugs the glyph instead, so both sides read the same. The vertical
+ padding stays, so the hit target keeps its height. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab
+ > .tab-actions,
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab
+ > .tab-actions
+ > .monaco-action-bar {
+ width: 16px;
+}
+
+/* The 28px column also held the gap after the name. Restore it as the 6px the
+ icon puts between itself and the name. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab
+ > .tab-actions {
+ margin-left: 6px;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab
+ > .tab-actions
+ .action-label.codicon {
+ padding: 2px 0;
+}
+
+/* Home carries no close button, but `sizing-fit` still stretches it to a fixed
+ 120px. That leaves ~30px of dead space on each side of the home icon and the
+ name, which reads as an off-center label. The pill hugs them instead. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab.sticky {
+ width: fit-content;
+}
+
+/* Home is the one sticky tab, so it is always the last one before the review
+ tabs. A rule after it separates the two groups. `redrawTabBorders` draws that
+ rule as an inline border-right, which the flat rule above removes, so the
+ pseudo-element below draws it instead. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab.sticky:not(:last-child) {
+ margin-right: 8px;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab.sticky:not(:last-child)::after {
+ content: "";
+ position: absolute;
+ right: -7px;
+ top: 4px;
+ bottom: 4px;
+ width: 1px;
+ pointer-events: none;
+ background-color: var(--review-tab-separator);
+}
+
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ .review-titlebar-container
+ .window-controls-container.review-traffic-light-inset {
+ flex-shrink: 0;
+ /* macOS moves the traffic lights right as the zoom makes the row taller.
+ The doubled class outranks upstream's fixed 70px. */
+ width: calc(50px + 20px * var(--zoom-factor, 1));
+ -webkit-app-region: drag;
+}
+
+/* The navigation control sits in .titlebar-left, after the traffic lights.
+ titlebarpart.css scopes every .command-center rule to `.titlebar-center >
+ .window-title >`, so the rules below restate the ones that still apply here.
+ The margin is the gap from the traffic lights. It is also the only left
+ padding in macOS fullscreen, where the inset collapses to zero. */
+.review-titlebar-container .review-titlebar-navigation {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ font-size: 12px;
+ margin-left: 8px;
+}
+
+.review-titlebar-container .review-application-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ align-self: center;
+ width: 30px;
+ height: 28px;
+ margin-left: 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ color: inherit;
+ background: transparent;
+ cursor: pointer;
+ -webkit-app-region: no-drag;
+}
+
+.review-titlebar-container .review-application-menu:hover {
+ background: var(--vscode-toolbar-hoverBackground);
+}
+
+.review-titlebar-container .review-application-menu:focus-visible,
+.review-titlebar-container .window-controls-container > button:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: -2px;
+}
+
+.review-titlebar-container .window-controls-container > button {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ -webkit-app-region: no-drag;
+}
+
+/* Equal space on each side of the arrows.
+
+ `CodeWindow.updateWindowControls` moves the traffic lights itself: it calls
+ `setWindowButtonPosition({ x: floor((REVIEW_CHROME_HEIGHT - buttonHeight) / 2) + 1 })`.
+ At 40px with the 14px macOS Tahoe button that is x=14, and three 14px buttons
+ on the 20px macOS pitch end at 68. Change REVIEW_CHROME_HEIGHT and that
+ number moves.
+
+ The arrow glyph carries 2px of side bearing inside its box, so the back arrow
+ starts drawing 19px after the last light. On the other side the tab strip
+ adds 6px of its own (`.review-chrome-inset`), so 8px here lands the Home tab
+ 19px after the forward arrow draws. */
+.monaco-workbench
+ .part.titlebar.review-chrome-overlay
+ .review-titlebar-navigation {
+ margin-left: 12px;
+ margin-right: 8px;
+}
+
+/* Without no-drag the full-size .titlebar-drag-region takes the clicks and the
+ arrows stop working. */
+.review-titlebar-container .review-titlebar-navigation > .command-center {
+ z-index: 2500;
+ -webkit-app-region: no-drag;
+}
+
+.review-titlebar-container .review-titlebar-navigation > .command-center.hide {
+ visibility: hidden;
+}
+
+.review-titlebar-container
+ .review-titlebar-navigation
+ > .command-center
+ > .monaco-toolbar
+ > .monaco-action-bar
+ > .actions-container
+ > .action-item
+ > .action-label {
+ color: inherit;
+}
+
+.review-canvas-part,
+.review-canvas-part > .review-canvas-container,
+.review-canvas-part .review-canvas-host {
+ width: 100%;
+ height: 100%;
+}
+
+/* Whiteboard: every label on the tray is set in the same mono face as the
+ canvas chrome. The faces ship with the canvas stylesheet, whose @font-face
+ rules are hoisted out of its @scope so they apply document-wide. */
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab
+ .monaco-icon-label {
+ font-family: var(--review-chrome-font);
+ font-size: 12px;
+}
+
+body
+ .monaco-workbench.review-workbench
+ .part.editor
+ > .content
+ .editor-group-container
+ > .title
+ .tabs-container
+ > .tab.active
+ .monaco-icon-label {
+ font-weight: 600;
+}
+
+/* Multi-diff headers print a full Review path in one label. Keep the file name
+ visible in narrow panes by placing the ellipsis at the leading edge. */
+.review-path-label .monaco-icon-label-container {
+ direction: rtl;
+ text-align: left;
+}
+
+.review-path-label .label-name {
+ unicode-bidi: plaintext;
+}
+
+.review-multidiff-counts {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 4px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.review-multidiff-counts[hidden] {
+ display: none;
+}
+
+.review-multidiff-additions {
+ color: var(--vscode-gitDecoration-addedResourceForeground);
+}
+
+.review-multidiff-deletions {
+ color: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+
+.review-counts-folded .review-multidiff-additions {
+ color: var(--vscode-descriptionForeground);
+}
+
+.review-multidiff-open-container {
+ display: inline-flex;
+ align-items: center;
+ margin: 0 3px;
+}
+
+.review-multidiff-open-container[hidden] {
+ display: none;
+}
+
+.review-multidiff-open.monaco-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ width: auto;
+ min-width: 0;
+ height: 24px;
+ line-height: 22px;
+ padding: 0 9px;
+ border: 1px solid
+ color-mix(in srgb, var(--vscode-icon-foreground) 20%, transparent);
+ border-radius: 5px;
+ background: color-mix(in srgb, var(--vscode-icon-foreground) 5%, transparent);
+ color: var(--vscode-icon-foreground);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.review-multidiff-open.monaco-button .codicon[class*="codicon-"] {
+ margin: 0;
+ font-size: 13px;
+}
+
+.review-multidiff-open.monaco-button:hover {
+ background: var(--vscode-toolbar-hoverBackground);
+}
+
+.review-files-editor,
+.review-files-editor-tree,
+.review-files-editor-diffs {
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+}
+
+.review-files-editor {
+ width: 100%;
+ overflow: hidden;
+ background: var(--vscode-editor-background);
+}
+
+/* The sidebar is one tray from the progress line to the last file: the tree
+ paints no ground of its own and inherits the tray behind it (see the
+ "Sidebar tone" board). Hover and selection come from the theme's list
+ colors, the well and the marker tint. */
+.review-files-editor-tree {
+ border-right: 1px solid var(--vscode-sideBar-border, transparent);
+}
+
+.review-changed-files {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ font-family: var(--review-chrome-font, system-ui, sans-serif);
+}
+
+.review-changed-files-tree {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.review-changed-files-row {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 22px;
+ padding-right: 8px;
+}
+
+.review-changed-files-icon {
+ flex: 0 0 16px;
+ width: 16px;
+ color: var(--vscode-icon-foreground);
+ text-align: center;
+}
+
+.review-changed-files-label {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ font-weight: 400;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.review-changed-files-folder .review-changed-files-label {
+ margin-left: 7px;
+ font-weight: 600;
+}
+
+.review-files-editor-diffs {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--vscode-multiDiffEditor-background);
+}
+
+.review-files-editor-diffs
+ .monaco-component.multiDiffEditor
+ .multiDiffEntry
+ > .header
+ > .header-content {
+ margin-top: 0;
+}
+
+.review-files-editor-diffs
+ .monaco-component.multiDiffEditor
+ .multiDiffEntry
+ > .header
+ > .header-content
+ .file-path
+ .title {
+ font-size: 12px;
+}
+
+.review-files-editor-diffs
+ .monaco-component.multiDiffEditor
+ .multiDiffEntry
+ > .header
+ > .header-content
+ > .actions {
+ padding: 0;
+}
+
+.review-files-editor-diffs
+ .multiDiffEntry
+ > .header
+ > .header-content
+ > .actions.has-no-actions {
+ display: none;
+}
+
+.review-canvas-part > .review-canvas-container {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr);
+ overflow: hidden;
+}
+
+.review-canvas-part .review-canvas-host {
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--vscode-editor-background);
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+/* Hosts fixedOverflowWidgets (hover, definition) for the inline peek editors.
+ Lives in the workbench container — inside .monaco-workbench so theme
+ variables apply, outside .review-canvas-root whose container-query
+ containment would re-anchor and clip position: fixed widgets. The widgets
+ position themselves against the viewport; z-index matches the workbench's
+ other overflow-widget hosts (scm, keybindings editor). */
+.review-overflow-widgets {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 5000;
+}
+
+/* The host carries tabindex="-1" so the canvas app can focus it programmatically
+ (desktop-entry's mount handle calls container.focus()). It is a document
+ surface rather than a control, and it is not tab-reachable, so suppress the
+ workbench-wide `[tabindex="-1"]:focus` ring that would otherwise trace the
+ whole pane -- the same treatment upstream gives editor group containers. */
+.monaco-workbench .review-canvas-part .review-canvas-host:focus {
+ outline: none;
+}
+
+.review-reveal-line {
+ background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
+ border-left: 2px solid var(--vscode-panel-border);
+}
+
+/* Give commenting and folding separate hit targets in the Files view. */
+.review-files-editor
+ .monaco-editor
+ .margin-view-overlays
+ .cldr:is(
+ .codicon-folding-expanded,
+ .codicon-folding-collapsed,
+ .codicon-folding-manual-expanded,
+ .codicon-folding-manual-collapsed
+ ) {
+ margin-left: 30px;
+ width: 20px !important;
+}
+
+.review-files-editor-diffs {
+ position: relative;
+}
+.review-files-editor-summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--vscode-sideBar-border, transparent);
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.review-files-editor-summary[hidden] {
+ display: none;
+}
+
+.review-files-editor-summary-added {
+ color: var(--vscode-gitDecoration-addedResourceForeground);
+}
+
+.review-files-editor-summary-removed {
+ color: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+
+.review-files-editor-summary-bar {
+ display: inline-flex;
+ gap: 1px;
+}
+
+.review-files-editor-summary-block {
+ width: 8px;
+ height: 8px;
+ background: var(--vscode-editorWidget-border);
+}
+
+.review-files-editor-summary-block.added {
+ background: var(--vscode-gitDecoration-addedResourceForeground);
+}
+
+.review-files-editor-summary-block.removed {
+ background: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+
+.review-multidiff-note {
+ padding: 0 6px;
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+}
+
+.review-multidiff-note[hidden] {
+ display: none;
+}
+
+.review-structural-label-inline {
+ color: var(--vscode-editor-foldPlaceholderForeground);
+ font-style: italic;
+}
+
+.review-structural-label-zone {
+ background-color: var(--vscode-editor-foldBackground);
+ color: var(--vscode-editor-foldPlaceholderForeground);
+}
+
+.review-structural-label-zone pre {
+ margin: 0;
+ padding-left: 4ch;
+ font: inherit;
+ white-space: pre;
+}
+
+.review-structural-stream-status:not([hidden]) {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 24px;
+ color: var(--vscode-descriptionForeground);
+ background: var(--vscode-editor-background);
+}
+.review-structural-stream-status.loading::before {
+ content: "";
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: review-structural-loading 1s linear infinite;
+}
+@keyframes review-structural-loading {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .review-structural-stream-status.loading::before {
+ animation: none;
+ }
+}
+.review-counts-viewed {
+ opacity: 0.45;
+}
+
+/* The viewed box: one look for the canvas lens rows and the diff headers.
+ The canvas mounts into this document, so inside it the canvas tokens win;
+ elsewhere the box falls back to the theme's own colors. */
+.review-viewed-check {
+ --review-check-ink: var(--ink, var(--vscode-editor-foreground));
+ --review-check-board: var(--surface, var(--vscode-editor-background));
+ --review-check-line: var(
+ --rule-soft,
+ var(--vscode-editorWidget-border, var(--vscode-descriptionForeground))
+ );
+ --review-check-marker: var(--accent, var(--vscode-button-background));
+ --review-check-on-marker: var(
+ --on-accent,
+ var(--vscode-button-foreground, #ffffff)
+ );
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ padding: 0;
+ border: 1px solid var(--review-check-line);
+ border-radius: 3px;
+ background: var(--review-check-board);
+ cursor: pointer;
+}
+/* The headers give the box the 32px column their old 16px box had. */
+.review-multidiff-open-container + .review-viewed-check,
+.review-diff-group > .review-viewed-check {
+ margin: 0 9px;
+}
+.review-viewed-check:hover:not(:disabled) {
+ border-color: var(--review-check-ink);
+}
+.review-viewed-check:focus-visible {
+ outline: 1px solid var(--review-check-marker);
+ outline-offset: 1px;
+}
+.review-viewed-check:disabled {
+ cursor: default;
+ opacity: 0.4;
+}
+/* Nothing to view: the box hides but keeps its slot, so columns stay aligned. */
+.review-viewed-check.is-empty {
+ visibility: hidden;
+}
+/* File headers without a viewed action leave Open file at the trailing edge. */
+.review-multidiff-open-container + .review-viewed-check.is-empty {
+ display: none;
+}
+.review-viewed-check[aria-checked="mixed"]::after {
+ content: "";
+ position: absolute;
+ top: 5.25px;
+ left: 3px;
+ width: 6px;
+ height: 1.5px;
+ border-radius: 1px;
+ background: var(--review-check-ink);
+}
+.review-viewed-check[aria-checked="true"] {
+ border-color: var(--review-check-marker);
+ background: var(--review-check-marker);
+}
+.review-viewed-check[aria-checked="true"]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--review-check-on-marker);
+ -webkit-mask: url("data:image/svg+xml;utf8,")
+ center / 12px 12px no-repeat;
+ mask: url("data:image/svg+xml;utf8,")
+ center / 12px 12px no-repeat;
+}
+
+/* The Whiteboard tooltip (ReviewTooltip): a raised card in the page's own ink,
+ 11/16 mono. Scoped by its `review-tooltip` class so no other hover changes.
+ Colors live in these variables, one block per theme. */
+.workbench-hover-container:has(> .review-tooltip) {
+ --review-tooltip-bg: var(--vscode-editorWidget-background, #1a1f29);
+ --review-tooltip-fg: var(--vscode-editor-foreground, #eef0f4);
+ --review-tooltip-fg-muted: var(--vscode-descriptionForeground, #9199a8);
+ --review-tooltip-border: var(--vscode-editorWidget-border, #39404d);
+ --review-tooltip-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
+}
+.monaco-workbench.vs .workbench-hover-container:has(> .review-tooltip),
+.monaco-workbench.hc-light .workbench-hover-container:has(> .review-tooltip) {
+ --review-tooltip-bg: var(--vscode-editor-background, #ffffff);
+ --review-tooltip-fg: var(--vscode-editor-foreground, #15181e);
+ --review-tooltip-fg-muted: var(--vscode-descriptionForeground, #5d6472);
+ --review-tooltip-border: var(--vscode-panel-border, #d8dbe1);
+ --review-tooltip-shadow: 0 4px 12px rgba(21, 24, 30, 0.1);
+}
+.monaco-hover.workbench-hover.review-tooltip {
+ max-width: 240px;
+ border: 1px solid var(--review-tooltip-border);
+ border-radius: 4px;
+ background: var(--review-tooltip-bg);
+ color: var(--review-tooltip-fg);
+ box-shadow: var(--review-tooltip-shadow);
+ font: 11px/16px
+ var(--review-chrome-font, "Geist Mono", ui-monospace, monospace);
+}
+.monaco-hover.workbench-hover.review-tooltip .hover-contents {
+ padding: 4px 8px;
+}
+.review-tooltip-label,
+.review-tooltip-detail {
+ display: block;
+}
+.review-tooltip-detail {
+ color: var(--review-tooltip-fg-muted);
+}
+.workbench-hover-container:has(> .review-tooltip)
+ > .workbench-hover-pointer::after {
+ background-color: var(--review-tooltip-bg);
+ border-right: 1px solid var(--review-tooltip-border);
+ border-bottom: 1px solid var(--review-tooltip-border);
+}
+.review-tree-counts {
+ display: inline-flex;
+ flex-shrink: 0;
+ gap: 6px;
+ margin-left: auto;
+ padding-left: 8px;
+ padding-right: 8px;
+ font: 11px/22px var(--review-chrome-font, monospace);
+ font-variant-numeric: tabular-nums;
+}
+.review-tree-added {
+ color: var(--vscode-gitDecoration-addedResourceForeground);
+}
+.review-tree-removed {
+ color: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+
+/* One disclosure glyph. The canvas draws it as an inline SVG (DisclosureChevron);
+ the workbench trees and the multi-diff headers use codicons, so their glyph is
+ swapped for the same 12px stroke path through a mask. The tree sets the
+ twistie's left padding to the row's indent; stock sizing is border-box, so
+ past the first level the padding eats the whole 16px box and the glyph
+ lands on the label. Content-box keeps the 16px glyph slot beside the
+ indent at every depth, so the chevron sits the same distance from its
+ label as a file mark does from its name. The file header button and the
+ section header toggle are keyed on their codicon name, down = open. */
+.review-workbench .monaco-tl-twistie.codicon-tree-item-expanded {
+ box-sizing: content-box;
+ width: 16px;
+ padding-right: 0;
+ justify-content: center;
+}
+.review-workbench .monaco-tl-twistie.codicon-tree-item-expanded::before,
+.review-workbench .collapse-button .codicon-chevron-down::before,
+.review-workbench .collapse-button .codicon-chevron-right::before,
+.review-workbench .review-diff-group-toggle .codicon-chevron-down::before,
+.review-workbench .review-diff-group-toggle .codicon-chevron-right::before {
+ content: "";
+ display: block;
+ flex: none;
+ width: 12px;
+ height: 12px;
+ background: currentColor;
+ mask: url("data:image/svg+xml;utf8,")
+ center / 12px 12px no-repeat;
+ transform: rotate(90deg);
+ transition: transform 120ms ease;
+}
+.review-workbench
+ .monaco-tl-twistie.collapsed.codicon-tree-item-expanded::before,
+.review-workbench .collapse-button .codicon-chevron-right::before,
+.review-workbench .review-diff-group-toggle .codicon-chevron-right::before {
+ transform: rotate(0deg);
+}
+
+/* File marks in the changed-files tree: status is one stroke beside the name,
+ never a boxed codicon. Plus, minus, dot, arrow, a ghost file for referenced
+ context, three dots while loading, a bang on error; viewed replaces the mark
+ with a check. Colors come from the theme's gitDecoration foregrounds (add,
+ del, warn amber, ink-muted). Folder rows keep an empty slot and paint nothing. */
+.review-workbench {
+ --review-mark-plus: url("data:image/svg+xml;utf8,");
+ --review-mark-minus: url("data:image/svg+xml;utf8,");
+ --review-mark-dot: url("data:image/svg+xml;utf8,");
+ --review-mark-arrow: url("data:image/svg+xml;utf8,");
+ --review-mark-file: url("data:image/svg+xml;utf8,");
+ --review-mark-check: url("data:image/svg+xml;utf8,");
+ --review-mark-fold: url("data:image/svg+xml;utf8,");
+ --review-mark-dots: url("data:image/svg+xml;utf8,");
+ --review-mark-bang: url("data:image/svg+xml;utf8,");
+}
+.review-workbench
+ .review-changed-files-icon[class*="review-changed-files-icon-"]::before,
+.review-workbench .review-changed-files-icon.codicon-loading::before,
+.review-workbench .review-changed-files-icon.codicon-error::before,
+.review-workbench .review-file-viewed .review-changed-files-icon::before,
+.review-workbench .review-file-folded .review-changed-files-icon::before {
+ content: "";
+ display: block;
+ width: 16px;
+ height: 16px;
+ background: var(--review-mark-color, currentColor);
+ mask: var(--review-mark) center / 16px 16px no-repeat;
+ animation: none !important;
+}
+.review-workbench .review-changed-files-icon:not([hidden]) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.review-workbench .review-changed-files-icon-added {
+ --review-mark: var(--review-mark-plus);
+ --review-mark-color: var(--vscode-gitDecoration-addedResourceForeground);
+}
+.review-workbench .review-changed-files-icon-deleted {
+ --review-mark: var(--review-mark-minus);
+ --review-mark-color: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+.review-workbench .review-changed-files-icon-modified {
+ --review-mark: var(--review-mark-dot);
+ --review-mark-color: var(--vscode-gitDecoration-modifiedResourceForeground);
+}
+.review-workbench .review-changed-files-icon-renamed {
+ --review-mark: var(--review-mark-arrow);
+ --review-mark-color: var(--vscode-gitDecoration-renamedResourceForeground);
+}
+.review-workbench .review-changed-files-icon-unchanged {
+ --review-mark: var(--review-mark-file);
+ --review-mark-color: var(--vscode-disabledForeground);
+}
+.review-workbench .review-changed-files-icon.codicon-loading {
+ --review-mark: var(--review-mark-dots);
+ --review-mark-color: var(--vscode-descriptionForeground);
+}
+.review-workbench .review-changed-files-icon.codicon-error {
+ --review-mark: var(--review-mark-bang);
+ --review-mark-color: var(--vscode-gitDecoration-deletedResourceForeground);
+}
+.review-workbench .review-file-viewed {
+ opacity: 1;
+ color: var(--vscode-descriptionForeground);
+}
+.review-workbench .review-file-viewed .review-changed-files-icon {
+ --review-mark: var(--review-mark-check);
+ --review-mark-color: var(--vscode-disabledForeground);
+}
+/* Folded by default counts as done: the viewed treatment, with a fold mark. */
+.review-workbench .review-file-folded {
+ opacity: 1;
+ color: var(--vscode-descriptionForeground);
+}
+.review-workbench .review-file-folded .review-changed-files-icon {
+ --review-mark: var(--review-mark-fold);
+ --review-mark-color: var(--vscode-disabledForeground);
+}
+
+/* Folds get out of the way: ghost ink on the code's own background, in the
+ chrome font. The theme sets the unchangedRegion background, foreground and
+ shadow. The row keeps Monaco's 24px height: its view zone and the handle
+ transforms assume it, and a shorter row rides up over the previous line. */
+.review-workbench .monaco-editor .diff-hidden-lines .center {
+ font: 11px/24px var(--review-chrome-font, monospace);
+}
+.review-workbench .monaco-editor .diff-hidden-lines .center:hover {
+ color: var(--vscode-descriptionForeground);
+}
+
+/* Scope chevrons, rails and braces in structural diffs. The marker blue is
+ fixed because some themes make focusBorder transparent. */
+.review-workbench .monaco-editor {
+ --review-scope-marker: #2b55e6;
+}
+.review-workbench .monaco-editor:is(.vs-dark, .hc-black) {
+ --review-scope-marker: #5b7cff;
+}
+.review-workbench .monaco-editor .review-fold-chevron {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0.45;
+}
+.review-workbench .monaco-editor .review-fold-chevron.is-collapsed {
+ opacity: 0.7;
+}
+.review-workbench .monaco-editor .review-fold-chevron.is-target {
+ opacity: 1;
+ color: var(--review-scope-marker);
+}
+/* Keep the full gutter hit target; only the glyph needs less visual weight. */
+.review-workbench .monaco-editor .review-fold-chevron.codicon,
+.review-workbench .monaco-editor .diff-fold-chevron.codicon,
+.review-workbench .monaco-editor .diff-fold-chevron .codicon {
+ font-size: 12px;
+}
+.review-workbench .monaco-editor.review-fold-rail .view-lines {
+ cursor: pointer;
+}
+.review-workbench .monaco-editor .review-scope-rails {
+ z-index: 1;
+ overflow: hidden;
+ pointer-events: none;
+}
+.review-workbench .monaco-editor .review-scope-rail {
+ position: absolute;
+ width: 7px;
+ transform: translateX(-3px);
+ pointer-events: auto;
+ cursor: pointer;
+}
+.review-workbench .monaco-editor .review-scope-rail::before {
+ content: "";
+ position: absolute;
+ left: 3px;
+ height: 100%;
+ border-left: 1px solid var(--vscode-editorIndentGuide-background1);
+}
+.review-workbench .monaco-editor .review-scope-rail.is-active::before {
+ border-left-color: var(--review-scope-marker);
+}
+.review-workbench .monaco-editor .review-scope-rail.is-target::before {
+ border-left-width: 2px;
+}
+.review-workbench .monaco-editor .review-scope-brace {
+ color: var(--review-scope-marker) !important;
+}
+.review-workbench .monaco-editor .review-scope-brace.is-target {
+ color: var(--vscode-editor-foreground) !important;
+ background: color-mix(in srgb, var(--review-scope-marker) 30%, transparent);
+ outline: 1px solid var(--review-scope-marker);
+ border-radius: 2px;
+}
+.review-workbench .monaco-editor .review-fold-hint {
+ margin-left: 2ch;
+ color: var(--review-scope-marker);
+ font-family: var(--review-chrome-font, inherit);
+ font-size: 11px;
+}
+
+/* A scope folded into its header line: `opener { ⋯ N lines }`. */
+.review-workbench .monaco-editor .review-fold-pill {
+ margin: 0 0.5ch;
+ padding: 0 0.6ch;
+ border: 1px solid var(--vscode-editorIndentGuide-activeBackground1);
+ border-radius: 3px;
+ color: var(--vscode-descriptionForeground);
+ font-size: 0.92em;
+ cursor: pointer;
+}
+.review-workbench .monaco-editor .review-fold-pill.is-target {
+ border-color: var(--review-scope-marker);
+ color: var(--vscode-editor-foreground);
+}
+.review-workbench .monaco-editor .review-fold-closer {
+ color: var(--vscode-editor-foreground);
+ cursor: pointer;
+}
+
+/* A fold inside removed code, drawn as a row of the removed block. */
+.review-workbench .monaco-editor .diff-fold-placeholder {
+ color: var(--vscode-descriptionForeground) !important;
+ opacity: 0.8;
+}
+.review-workbench .monaco-editor .diff-fold-placeholder-hit:hover {
+ background: color-mix(
+ in srgb,
+ var(--vscode-editor-foreground) 4%,
+ transparent
+ );
+}
+
+/* Align fold labels with the hidden code's indent. */
+.review-workbench
+ .review-files-editor
+ .monaco-editor
+ .diff-hidden-lines:not(.kind-inserted):not(.kind-removed)
+ .center {
+ background: transparent;
+ box-shadow: none;
+}
+.review-workbench
+ .review-files-editor
+ .monaco-editor
+ .diff-hidden-lines
+ .diff-hidden-lines-label {
+ flex: 1;
+ justify-content: flex-start !important;
+ color: var(--vscode-descriptionForeground);
+}
+.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal::before {
+ content: "\22EF\00a0";
+}
+.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal:hover {
+ color: var(--review-scope-marker);
+}
+.review-workbench
+ .monaco-editor
+ .diff-hidden-lines
+ .diff-fold-reveal:focus-visible {
+ outline: 1px solid var(--review-scope-marker);
+ outline-offset: 2px;
+}
+
+/* The canvas stylesheet resets every element to border-box, and the Diff
+ view's editors live inside the canvas. Monaco sizes the fold's drag
+ handles as content-box (4px tall plus 6px of transparent borders) and
+ offsets the whole widget by that sum, so a border-box handle is 4px short
+ and the fold row rides up over the previous line. */
+.monaco-editor .diff-hidden-lines .top,
+.monaco-editor .diff-hidden-lines .bottom {
+ box-sizing: content-box;
+}
+.review-changed-files-label {
+ flex: 1;
+ min-width: 0;
+}
+/* Left inset 10px and title gap 5px mirror the file header below it (5px
+ padding + 5px collapse-button margin, then 5px to the path), so the
+ section chevron and the file chevron sit on one vertical line. */
+.review-diff-group {
+ height: 48px;
+ min-height: 48px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px 4px 10px;
+ border-top: 1px solid var(--vscode-panel-border);
+ background: var(--vscode-editor-background);
+ color: var(--vscode-editor-foreground);
+}
+.review-diff-group[hidden] {
+ display: none;
+}
+.review-diff-group-title {
+ font-size: 13px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.review-diff-group-counts {
+ margin-left: auto;
+ font: 11px var(--vscode-editor-font-family, monospace);
+ color: var(--vscode-descriptionForeground);
+ white-space: nowrap;
+}
+.review-diff-group.is-viewed > span {
+ opacity: 0.45;
+}
+
+.review-diff-group-toggle {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ border: 0;
+ padding: 0;
+ color: inherit;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+.review-diff-group-toggle:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+}
+
+/* The surrounding navigation owns the top edge of the diff pane. */
+.multi-diff-sticky-section .review-diff-group,
+.review-files-editor-diffs .multiDiffEntry:first-child > .review-diff-group {
+ border-top: 0;
+}
+
+/* Placeholder rows share the scope chevron lane after the line numbers. */
+.review-workbench .monaco-editor .diff-fold-chevron {
+ appearance: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ height: 100%;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--vscode-descriptionForeground);
+ opacity: 0.7;
+ cursor: pointer;
+}
+.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-chevron,
+.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-reveal,
+.review-workbench .monaco-editor .diff-fold-chevron.is-target {
+ color: var(--review-scope-marker);
+ opacity: 1;
+}
+.review-workbench .monaco-editor .diff-fold-chevron:focus-visible {
+ outline: 1px solid var(--review-scope-marker);
+ outline-offset: -2px;
+}
+.review-workbench .monaco-editor .diff-fold-placeholder.is-target {
+ color: var(--review-scope-marker) !important;
+ opacity: 1;
+}
+
+.review-workbench
+ .monaco-editor
+ .diff-hidden-lines
+ .diff-fold-chevron
+ .codicon {
+ color: inherit;
+}
+
+.review-workbench .monaco-editor .review-scope-viewed-control {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ gap: 8px;
+ padding: 0 8px;
+ border: 1px solid
+ var(--vscode-widget-border, var(--vscode-editorWidget-border));
+ border-radius: 5px;
+ background: var(
+ --vscode-editorWidget-background,
+ var(--vscode-editor-background)
+ );
+ font: 11px/20px var(--monaco-monospace-font);
+ z-index: 8;
+}
+.review-workbench .monaco-editor .review-scope-viewed-control > span:empty {
+ display: none;
+}
+.review-workbench .monaco-editor .review-scope-viewed-status {
+ color: var(--vscode-descriptionForeground);
+}
+.review-workbench
+ .monaco-editor
+ .review-scope-viewed-control
+ .review-viewed-check {
+ margin-left: 4px;
+}
+/* Keep the shared 14px glyph while making the padded space around it clickable. */
+.review-workbench
+ .monaco-editor
+ .review-scope-viewed-control
+ .review-viewed-check::before {
+ content: "";
+ position: absolute;
+ inset: -5px;
+}
+.review-workbench .monaco-editor .review-scope-viewed-ink,
+.review-workbench .monaco-editor .review-scope-viewed-ink span,
+.review-workbench .monaco-editor .review-fold-pill.is-viewed {
+ color: var(--vscode-descriptionForeground) !important;
+}
+.review-workbench .monaco-editor .review-scope-viewed-tint {
+ background: color-mix(
+ in srgb,
+ var(--vscode-editor-background) 70%,
+ transparent
+ );
+}
+.review-workbench .monaco-editor .review-fold-pill.is-viewed:not(.is-target) {
+ opacity: 0.65;
+}
+
+.review-files-editor-summary {
+ flex-wrap: wrap;
+}
+.review-files-editor-summary-files {
+ flex-basis: 100%;
+}
+.review-files-editor-summary-progress {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+}
+.review-files-editor-summary-progress::before {
+ content: "";
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: conic-gradient(
+ var(--vscode-focusBorder) var(--viewed-progress),
+ var(--vscode-editorWidget-border) 0
+ );
+ mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 0);
+}
diff --git a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts
new file mode 100644
index 000000000..68ee46a11
--- /dev/null
+++ b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts
@@ -0,0 +1,462 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { $, append } from "vs/base/browser/dom.js";
+import type { IListVirtualDelegate } from "vs/base/browser/ui/list/list.js";
+import { RenderIndentGuides } from "vs/base/browser/ui/tree/abstractTree.js";
+import type {
+ ICompressedTreeElement,
+ ICompressedTreeNode,
+} from "vs/base/browser/ui/tree/compressedObjectTreeModel.js";
+import type { ICompressibleTreeRenderer } from "vs/base/browser/ui/tree/objectTree.js";
+import {
+ ObjectTreeElementCollapseState,
+ type ITreeNode,
+} from "vs/base/browser/ui/tree/tree.js";
+import { Codicon } from "vs/base/common/codicons.js";
+import { Emitter, type Event } from "vs/base/common/event.js";
+import { Disposable } from "vs/base/common/lifecycle.js";
+import { ThemeIcon } from "vs/base/common/themables.js";
+import { localize } from "vs/nls.js";
+import { IHoverService } from "vs/platform/hover/browser/hover.js";
+import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js";
+import { WorkbenchCompressibleObjectTree } from "vs/platform/list/browser/listService.js";
+import { registerColor } from "vs/platform/theme/common/colorRegistry.js";
+
+import type {
+ ReviewDiffFileWire,
+ ReviewDiffProgressFile,
+} from "../../protocol.js";
+import {
+ buildTree,
+ type ChangedFileElement,
+ type ChangedTreeElement,
+} from "../common/reviewChangedFilesModel.js";
+import {
+ binarySizeLabel,
+ isBinaryCounts,
+ type ReviewFileCounts,
+} from "../common/reviewStructuralDiff.js";
+import { reviewBottomScrollPadding } from "./reviewScrollPadding.js";
+import {
+ REVIEW_COUNTS_PENDING_TOOLTIP,
+ reviewCountsTooltip,
+ ReviewTooltip,
+ type ReviewTooltipHoverService,
+} from "./reviewTooltip.js";
+
+registerColor(
+ "gitDecoration.addedResourceForeground",
+ {
+ light: "#587c0c",
+ dark: "#81b88b",
+ hcDark: "#a1e3ad",
+ hcLight: "#374e06",
+ },
+ localize("review.gitDecoration.added", "Color for added file resources."),
+);
+registerColor(
+ "gitDecoration.modifiedResourceForeground",
+ {
+ light: "#895503",
+ dark: "#E2C08D",
+ hcDark: "#E2C08D",
+ hcLight: "#895503",
+ },
+ localize(
+ "review.gitDecoration.modified",
+ "Color for modified file resources.",
+ ),
+);
+registerColor(
+ "gitDecoration.deletedResourceForeground",
+ {
+ light: "#ad0707",
+ dark: "#c74e39",
+ hcDark: "#c74e39",
+ hcLight: "#ad0707",
+ },
+ localize("review.gitDecoration.deleted", "Color for deleted file resources."),
+);
+registerColor(
+ "gitDecoration.renamedResourceForeground",
+ {
+ light: "#007100",
+ dark: "#73C991",
+ hcDark: "#73C991",
+ hcLight: "#007100",
+ },
+ localize("review.gitDecoration.renamed", "Color for renamed file resources."),
+);
+
+const CHANGED_FILE_ROW_HEIGHT = 22;
+
+interface ChangedFilesTreeTemplate {
+ readonly row: HTMLElement;
+ readonly icon: HTMLElement;
+ readonly label: HTMLElement;
+ readonly counts: HTMLElement;
+ readonly countsTooltip: ReviewTooltip;
+}
+
+class ChangedFilesTreeDelegate implements IListVirtualDelegate {
+ getHeight(): number {
+ return CHANGED_FILE_ROW_HEIGHT;
+ }
+
+ getTemplateId(): string {
+ return ChangedFilesTreeRenderer.TEMPLATE_ID;
+ }
+}
+
+export class ChangedFilesTreeRenderer implements ICompressibleTreeRenderer<
+ ChangedTreeElement,
+ void,
+ ChangedFilesTreeTemplate
+> {
+ static readonly TEMPLATE_ID = "review.changedFiles.entry";
+ readonly templateId = ChangedFilesTreeRenderer.TEMPLATE_ID;
+
+ constructor(
+ private readonly counts: Map,
+ private readonly progress: Map,
+ private readonly states: Map<
+ string,
+ { status: "loading" | "error"; message?: string }
+ >,
+ private readonly hoverService: ReviewTooltipHoverService,
+ ) {}
+
+ renderTemplate(container: HTMLElement): ChangedFilesTreeTemplate {
+ const row = append(container, $(".review-changed-files-row"));
+ const icon = append(row, $("span.review-changed-files-icon"));
+ icon.setAttribute("aria-hidden", "true");
+ const label = append(row, $("span.review-changed-files-label"));
+ const counts = append(row, $("span.review-tree-counts"));
+ return {
+ row,
+ icon,
+ label,
+ counts,
+ countsTooltip: new ReviewTooltip(this.hoverService, counts),
+ };
+ }
+
+ renderElement(
+ node: ITreeNode,
+ _index: number,
+ template: ChangedFilesTreeTemplate,
+ ): void {
+ this.renderElements([node.element], template);
+ }
+
+ renderCompressedElements(
+ node: ITreeNode>,
+ _index: number,
+ template: ChangedFilesTreeTemplate,
+ ): void {
+ this.renderElements(node.element.elements, template);
+ }
+
+ disposeTemplate(template: ChangedFilesTreeTemplate): void {
+ template.countsTooltip.dispose();
+ }
+
+ private renderElements(
+ elements: readonly ChangedTreeElement[],
+ template: ChangedFilesTreeTemplate,
+ ): void {
+ const element = elements[elements.length - 1];
+ const isFile = element.kind === "file";
+
+ template.row.classList.toggle("review-changed-files-folder", !isFile);
+ const done = isFile
+ ? this.progress.get(element.file.path)?.state
+ : undefined;
+ // Folded files read as done, like viewed ones: greyed, with the reason in place of counts.
+ template.row.classList.toggle("review-file-viewed", done === "viewed");
+ // A binary stays folded: nothing in it can be read, so it looks and counts like a folded file.
+ template.row.classList.toggle(
+ "review-file-folded",
+ done === "folded" ||
+ (isFile && !!element.file.binary && done !== "viewed"),
+ );
+ template.icon.hidden = !isFile;
+ template.icon.className = isFile
+ ? `review-changed-files-icon review-changed-files-icon-${element.file.status} ${ThemeIcon.asClassName(fileStatusIcon(element.file.status))}`
+ : "review-changed-files-icon";
+ template.counts.hidden = !isFile;
+ template.counts.replaceChildren();
+ template.countsTooltip.content = undefined;
+ if (isFile) {
+ const progress = this.progress.get(element.file.path);
+ const compact = (n: number) =>
+ new Intl.NumberFormat("en", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ })
+ .format(n)
+ .toLowerCase();
+ const stored = this.counts.get(element.file.path);
+ const lines = isBinaryCounts(stored) ? undefined : stored;
+ const additions = progress?.remaining.additions ?? lines?.added;
+ const deletions = progress?.remaining.deletions ?? lines?.removed;
+ if (element.file.binary) {
+ // Where a folded file says "Folded", a binary says its size, or "Binary" until diffr reports it.
+ const size = isBinaryCounts(stored)
+ ? binarySizeLabel(stored)
+ : undefined;
+ template.counts.textContent =
+ progress?.state === "viewed"
+ ? "Viewed"
+ : progress?.state === "folded"
+ ? "Folded"
+ : (size ?? "Binary");
+ template.countsTooltip.content = {
+ label: "No text to show · binary files stay folded",
+ detail: size,
+ };
+ } else if (element.file.status === "unchanged")
+ template.counts.textContent = "Unchanged";
+ else if (progress?.state === "viewed")
+ template.counts.textContent = "Viewed";
+ else if (progress?.state === "folded")
+ template.counts.textContent = "Folded";
+ else if (additions !== undefined && deletions !== undefined) {
+ const added = append(template.counts, $("span.review-tree-added"));
+ added.textContent = `+${compact(additions)}`;
+ const removed = append(template.counts, $("span.review-tree-removed"));
+ removed.textContent = `−${compact(deletions)}`;
+ }
+ const total = progress?.total ?? {
+ additions: lines?.added ?? 0,
+ deletions: lines?.removed ?? 0,
+ };
+ if (!element.file.binary)
+ template.countsTooltip.content =
+ element.file.status === "unchanged"
+ ? { label: "Referenced context; no changed lines" }
+ : additions === undefined || deletions === undefined
+ ? REVIEW_COUNTS_PENDING_TOOLTIP
+ : progress?.state === "folded"
+ ? {
+ label: "Folded by default; counted as done",
+ detail: `+${total.additions} −${total.deletions} total`,
+ }
+ : reviewCountsTooltip({
+ remaining: { additions, deletions },
+ total,
+ });
+ }
+ const state = isFile ? this.states.get(element.file.path) : undefined;
+ if (state)
+ template.icon.className = `review-changed-files-icon codicon codicon-${state.status === "loading" ? "loading codicon-modifier-spin" : "error"}`;
+ template.row.title =
+ state?.message ?? (state?.status === "loading" ? "Loading diff…" : "");
+ template.row.setAttribute("aria-busy", String(state?.status === "loading"));
+ template.label.textContent = isFile
+ ? element.name
+ : elements.map((item) => item.name).join("/");
+ }
+}
+
+/** A Workbench-compressed tree over one Review's changed files. */
+export class ReviewChangedFilesTree extends Disposable {
+ private readonly _onDidOpenFile = this._register(
+ new Emitter(),
+ );
+ readonly onDidOpenFile: Event = this._onDidOpenFile.event;
+
+ private readonly tree: WorkbenchCompressibleObjectTree<
+ ChangedTreeElement,
+ void
+ >;
+ private readonly fileElements = new Map();
+ private files: readonly ReviewDiffFileWire[] = [];
+ private readonly states = new Map<
+ string,
+ { status: "loading" | "error"; message?: string }
+ >();
+ private activePath: string | undefined;
+ private readonly counts = new Map();
+ setCounts(path: string, counts: ReviewFileCounts): void {
+ const previous = this.counts.get(path);
+ this.counts.set(path, counts);
+ if (JSON.stringify(previous) !== JSON.stringify(counts))
+ this.refreshFile(path);
+ }
+ private readonly progress = new Map();
+ setProgressFiles(files: readonly ReviewDiffProgressFile[]): void {
+ const previous = new Map(this.progress);
+ this.progress.clear();
+ for (const file of files) this.progress.set(file.path, file);
+ for (const path of new Set([...previous.keys(), ...this.progress.keys()])) {
+ const before = previous.get(path),
+ after = this.progress.get(path);
+ if (
+ before?.state !== after?.state ||
+ before?.remaining.additions !== after?.remaining.additions ||
+ before?.remaining.deletions !== after?.remaining.deletions ||
+ before?.total.additions !== after?.total.additions ||
+ before?.total.deletions !== after?.total.deletions
+ )
+ this.refreshFile(path);
+ }
+ }
+
+ private refreshFile(path: string): void {
+ // The parameterless rerender only remeasures dynamic heights; these rows
+ // have fixed heights. Target the model node to refresh its rendered data.
+ const element = this.fileElements.get(path);
+ if (element) this.tree.rerender(element);
+ }
+ private syncingActiveFile = false;
+
+ constructor(
+ container: HTMLElement,
+ @IInstantiationService instantiationService: IInstantiationService,
+ @IHoverService hoverService: IHoverService,
+ ) {
+ super();
+ container.classList.add("review-changed-files");
+ const treeContainer = append(container, $(".review-changed-files-tree"));
+ const renderer = new ChangedFilesTreeRenderer(
+ this.counts,
+ this.progress,
+ this.states,
+ hoverService,
+ );
+ this.tree = this._register(
+ instantiationService.createInstance(
+ WorkbenchCompressibleObjectTree,
+ "ReviewChangedFiles",
+ treeContainer,
+ new ChangedFilesTreeDelegate(),
+ [renderer],
+ {
+ accessibilityProvider: {
+ getAriaLabel: (element) =>
+ element.kind === "file" ? element.file.path : element.path,
+ getWidgetAriaLabel: () =>
+ localize("review.changedFiles.ariaLabel", "Changed files"),
+ },
+ alwaysConsumeMouseWheel: false,
+ horizontalScrolling: false,
+ paddingBottom: reviewBottomScrollPadding,
+ identityProvider: {
+ getId: (element) =>
+ element.kind === "file"
+ ? element.file.path
+ : `dir:${element.path}`,
+ },
+ indent: 12,
+ keyboardNavigationLabelProvider: {
+ getKeyboardNavigationLabel: (element) => element.name,
+ getCompressedNodeKeyboardNavigationLabel: (elements) =>
+ elements.map((element) => element.name).join("/"),
+ },
+ compressionEnabled: true,
+ multipleSelectionSupport: false,
+ openOnSingleClick: true,
+ renderIndentGuides: RenderIndentGuides.None,
+ },
+ ),
+ );
+ this._register(
+ this.tree.onDidOpen((event) => {
+ if (this.syncingActiveFile || event.element?.kind !== "file") return;
+ this._onDidOpenFile.fire(event.element.file);
+ }),
+ );
+ this.refresh();
+ }
+
+ setFiles(files: readonly ReviewDiffFileWire[]): void {
+ const selectedElement = this.tree
+ .getSelection()
+ .find((element) => element?.kind === "file");
+ const selectedPath =
+ selectedElement?.kind === "file" ? selectedElement.file.path : undefined;
+ this.files = files;
+ this.refresh(selectedPath);
+ }
+
+ setFileState(
+ path: string,
+ status: "loading" | "error" | undefined,
+ message?: string,
+ ): void {
+ if (status) this.states.set(path, { status, message });
+ else this.states.delete(path);
+ const element = this.fileElements.get(path);
+ if (element) this.tree.rerender(element);
+ }
+
+ setActiveFile(path: string | undefined, reveal = true): void {
+ if (!reveal && this.activePath === path) return;
+ this.activePath = path;
+ this.syncSelection(path, reveal);
+ }
+
+ layout(height: number, width: number): void {
+ this.tree.layout(Math.max(0, height), width);
+ }
+
+ private refresh(selectedPath?: string): void {
+ const roots = buildTree(this.files);
+ this.fileElements.clear();
+ collectFileElements(roots, this.fileElements);
+ this.tree.setChildren(null, toTreeElements(roots));
+ this.syncSelection(this.activePath ?? selectedPath);
+ }
+
+ private syncSelection(path: string | undefined, reveal = true): void {
+ const element = path ? this.fileElements.get(path) : undefined;
+ this.syncingActiveFile = true;
+ try {
+ this.tree.setSelection(element ? [element] : []);
+ this.tree.setFocus(element ? [element] : []);
+ if (element && reveal) this.tree.reveal(element);
+ } finally {
+ this.syncingActiveFile = false;
+ }
+ }
+}
+
+function fileStatusIcon(status: ReviewDiffFileWire["status"]): ThemeIcon {
+ if (status === "unchanged") return Codicon.file;
+ if (status === "added") return Codicon.diffAdded;
+ if (status === "deleted") return Codicon.diffRemoved;
+ if (status === "renamed") return Codicon.diffRenamed;
+ return Codicon.diffModified;
+}
+
+function toTreeElements(
+ elements: readonly ChangedTreeElement[],
+): ICompressedTreeElement[] {
+ return elements.map((element) =>
+ element.kind === "file"
+ ? { element, incompressible: true }
+ : {
+ element,
+ collapsible: true,
+ collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded,
+ children: toTreeElements(element.children),
+ },
+ );
+}
+
+function collectFileElements(
+ elements: readonly ChangedTreeElement[],
+ target: Map,
+): void {
+ for (const element of elements) {
+ if (element.kind === "file") {
+ target.set(element.file.path, element);
+ } else {
+ collectFileElements(element.children, target);
+ }
+ }
+}
diff --git a/apps/diffr-web/src/review/browser/reviewScrollPadding.ts b/apps/diffr-web/src/review/browser/reviewScrollPadding.ts
new file mode 100644
index 000000000..f94f0159d
--- /dev/null
+++ b/apps/diffr-web/src/review/browser/reviewScrollPadding.ts
@@ -0,0 +1,4 @@
+import { isMacintosh } from "vs/base/common/platform.js";
+
+// Leave room to scroll the last item above an auto-hidden macOS Dock.
+export const reviewBottomScrollPadding = isMacintosh ? 128 : 0;
diff --git a/apps/diffr-web/src/review/browser/reviewTooltip.ts b/apps/diffr-web/src/review/browser/reviewTooltip.ts
new file mode 100644
index 000000000..08f2d3bf1
--- /dev/null
+++ b/apps/diffr-web/src/review/browser/reviewTooltip.ts
@@ -0,0 +1,196 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { $, addDisposableListener, append } from "vs/base/browser/dom.js";
+import type { IHoverWidget } from "vs/base/browser/ui/hover/hover.js";
+import { HoverPosition } from "vs/base/browser/ui/hover/hoverWidget.js";
+import { Disposable, MutableDisposable } from "vs/base/common/lifecycle.js";
+import type { IHoverService } from "vs/platform/hover/browser/hover.js";
+
+import type { ReviewDiffProgressState } from "../../protocol.js";
+
+/** A label line and an optional fainter second line. */
+export interface ReviewTooltipContent {
+ readonly label: string;
+ readonly detail?: string;
+}
+
+export type ReviewTooltipHoverService = Pick;
+
+/**
+ * The Whiteboard tooltip: a raised card above its target, shown the moment the
+ * pointer or keyboard focus lands and gone on leave, click, scroll or key.
+ * The `review-tooltip` class keeps its look off every other hover.
+ */
+export class ReviewTooltip extends Disposable {
+ private readonly hover = this._register(
+ new MutableDisposable(),
+ );
+ private readonly scroll = this._register(new MutableDisposable());
+ private current: ReviewTooltipContent | undefined;
+
+ constructor(
+ private readonly hoverService: ReviewTooltipHoverService,
+ private readonly target: HTMLElement,
+ content?: ReviewTooltipContent,
+ ) {
+ super();
+ this.current = content;
+ this._register(
+ addDisposableListener(target, "mouseenter", () => this.show()),
+ );
+ this._register(
+ addDisposableListener(target, "focus", () => {
+ if (target.matches(":focus-visible")) this.show();
+ }),
+ );
+ for (const event of [
+ "mouseleave",
+ "blur",
+ "pointerdown",
+ "click",
+ "keydown",
+ ]) {
+ this._register(addDisposableListener(target, event, () => this.hide()));
+ }
+ }
+
+ set content(content: ReviewTooltipContent | undefined) {
+ if (
+ content?.label === this.current?.label &&
+ content?.detail === this.current?.detail
+ )
+ return;
+ this.current = content;
+ if (this.hover.value) this.show();
+ }
+
+ private show(): void {
+ this.hide();
+ if (!this.current?.label) return;
+ const body = $("span.review-tooltip-body");
+ appendCounts(
+ append(body, $("span.review-tooltip-label")),
+ this.current.label,
+ );
+ if (this.current.detail)
+ appendCounts(
+ append(body, $("span.review-tooltip-detail")),
+ this.current.detail,
+ );
+ this.hover.value = this.hoverService.showInstantHover({
+ target: this.target,
+ content: body,
+ additionalClasses: ["review-tooltip"],
+ position: { hoverPosition: HoverPosition.ABOVE },
+ appearance: {
+ compact: true,
+ showPointer: true,
+ skipFadeInAnimation: true,
+ },
+ persistence: { hideOnHover: true, hideOnKeyDown: true },
+ });
+ if (this.hover.value) {
+ this.scroll.value = addDisposableListener(
+ this.target.ownerDocument,
+ "scroll",
+ () => this.hide(),
+ true,
+ );
+ }
+ }
+
+ private hide(): void {
+ this.hover.clear();
+ this.scroll.clear();
+ }
+}
+
+const signedPair = (additions: number, deletions: number) =>
+ `+${additions} −${deletions}`;
+
+const SIGNED_PAIR = /\+([\d.]+[kKM]?) −([\d.]+[kKM]?)/;
+
+/** Colors each `+N −N` pair like the tree's counts. */
+function appendCounts(parent: HTMLElement, text: string): void {
+ const match = SIGNED_PAIR.exec(text);
+ if (!match) {
+ parent.append(text);
+ return;
+ }
+ parent.append(text.slice(0, match.index));
+ append(parent, $("span.review-tree-added")).textContent = `+${match[1]}`;
+ parent.append(" ");
+ append(parent, $("span.review-tree-removed")).textContent = `−${match[2]}`;
+ appendCounts(parent, text.slice(match.index + match[0].length));
+}
+
+/** What a diff count's tooltip says: what is left, then the whole. */
+export function reviewCountsTooltip(counts: {
+ readonly remaining: {
+ readonly additions: number;
+ readonly deletions: number;
+ };
+ readonly total: { readonly additions: number; readonly deletions: number };
+}): ReviewTooltipContent {
+ return {
+ label: `${signedPair(counts.remaining.additions, counts.remaining.deletions)} remaining`,
+ detail: `of ${signedPair(counts.total.additions, counts.total.deletions)} total`,
+ };
+}
+
+/** A count that has no coverage yet: the structural diff's own totals. */
+export function reviewChangesTooltip(
+ added: number,
+ removed: number,
+): ReviewTooltipContent {
+ return { label: `${signedPair(added, removed)} changed` };
+}
+
+export const REVIEW_COUNTS_PENDING_TOOLTIP: ReviewTooltipContent = {
+ label: "Waiting for structural coverage",
+};
+
+/**
+ * The one viewed checkbox: a 14px box whose dash and check are drawn in CSS,
+ * with an instant tooltip saying what a click does. With nothing to view it
+ * keeps its slot but hides, so the columns beside it stay aligned.
+ */
+export class ReviewViewedCheckbox extends Disposable {
+ readonly element: HTMLButtonElement;
+
+ constructor(ownerDocument: Document, onToggle: () => void) {
+ super();
+ this.element = ownerDocument.createElement("button");
+ this.element.type = "button";
+ this.element.className = "review-viewed-check";
+ this.element.setAttribute("role", "checkbox");
+ this._register(
+ addDisposableListener(this.element, "click", (event) => {
+ event.stopPropagation();
+ onToggle();
+ }),
+ );
+ }
+
+ /** `subject` names what the box marks, for screen readers. */
+ update(
+ state: ReviewDiffProgressState | undefined,
+ subject: string,
+ empty: boolean,
+ ): void {
+ const viewed = state === "viewed";
+ this.element.setAttribute(
+ "aria-checked",
+ state === "partial" ? "mixed" : String(viewed),
+ );
+ this.element.setAttribute(
+ "aria-label",
+ `${viewed ? "Mark unviewed" : "Mark viewed"}: ${subject}`,
+ );
+ this.element.classList.toggle("is-empty", empty);
+ this.element.disabled = empty;
+ }
+}
diff --git a/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts b/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts
new file mode 100644
index 000000000..4cc471592
--- /dev/null
+++ b/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts
@@ -0,0 +1,96 @@
+import type { ReviewDiffFileWire } from "../../protocol.js";
+
+export interface ChangedFileElement {
+ readonly kind: "file";
+ readonly file: ReviewDiffFileWire;
+ readonly name: string;
+}
+
+export interface ChangedFolderElement {
+ readonly kind: "folder";
+ readonly name: string;
+ readonly path: string;
+ readonly children: ChangedTreeElement[];
+}
+
+export type ChangedTreeElement = ChangedFileElement | ChangedFolderElement;
+
+interface MutableFolder {
+ readonly name: string;
+ readonly path: string;
+ readonly folders: Map;
+ readonly files: ChangedFileElement[];
+}
+
+/** Returns changed files in the same folder-first order that the tree shows. */
+export function orderReviewDiffFiles(
+ files: readonly ReviewDiffFileWire[],
+): readonly ReviewDiffFileWire[] {
+ const ordered: ReviewDiffFileWire[] = [];
+ collectFiles(buildTree(files), ordered);
+ return ordered;
+}
+
+export function buildTree(
+ files: readonly ReviewDiffFileWire[],
+): ChangedTreeElement[] {
+ const root: MutableFolder = {
+ name: "",
+ path: "",
+ folders: new Map(),
+ files: [],
+ };
+
+ for (const file of files) {
+ const segments = file.path.split("/").filter(Boolean);
+ const name = segments.pop() ?? file.path;
+ let folder = root;
+ for (const segment of segments) {
+ const path = folder.path ? `${folder.path}/${segment}` : segment;
+ let child = folder.folders.get(segment);
+ if (!child) {
+ child = {
+ name: segment,
+ path,
+ folders: new Map(),
+ files: [],
+ };
+ folder.folders.set(segment, child);
+ }
+ folder = child;
+ }
+ folder.files.push({ kind: "file", file, name });
+ }
+
+ return folderChildren(root);
+}
+
+function folderChildren(folder: MutableFolder): ChangedTreeElement[] {
+ const folders = [...folder.folders.values()]
+ .map(
+ (child): ChangedFolderElement => ({
+ kind: "folder",
+ name: child.name,
+ path: child.path,
+ children: folderChildren(child),
+ }),
+ )
+ .sort((left, right) => left.name.localeCompare(right.name));
+ const files = [...folder.files].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ );
+ return [...folders, ...files];
+}
+
+function collectFiles(
+ elements: readonly ChangedTreeElement[],
+ target: ReviewDiffFileWire[],
+): void {
+ for (const element of elements) {
+ if (element.kind === "file") {
+ target.push(element.file);
+ } else {
+ collectFiles(element.children, target);
+ }
+ }
+}
diff --git a/apps/diffr-web/src/review/common/reviewStructuralDiff.ts b/apps/diffr-web/src/review/common/reviewStructuralDiff.ts
new file mode 100644
index 000000000..56e666eef
--- /dev/null
+++ b/apps/diffr-web/src/review/common/reviewStructuralDiff.ts
@@ -0,0 +1,582 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { structuralRows } from "../../protocol.js";
+export { structuralRows } from "../../protocol.js";
+
+import { structuralChangeCounts } from "../../protocol.js";
+import type {
+ StructuralPairing,
+ StructuralFileRef,
+ StructuralRegion,
+ StructuralSource,
+ StructuralDiff,
+ StructuralLineCounts,
+} from "../../protocol.js";
+export type {
+ StructuralPairing,
+ StructuralProblem,
+ StructuralPos,
+ StructuralSpan,
+ StructuralVisibility,
+ StructuralRegion,
+ StructuralSyntaxSpan,
+ StructuralSource,
+ StructuralLineCounts,
+ StructuralStats,
+ StructuralDiff,
+ StructuralFileRef,
+ StructuralFileChange,
+} from "../../protocol.js";
+export type { StructuralDiffEvent as StructuralEvent } from "../../protocol.js";
+export type StructuralLeaf = Extract;
+export type StructuralFold = Extract;
+export type StructuralTextDiff = Extract;
+export type StructuralBinaryDiff = Extract;
+
+/** What a file's count lane and header show: changed lines, or a binary file's byte sizes. */
+export type ReviewFileCounts =
+ | StructuralLineCounts
+ | {
+ readonly binary: true;
+ readonly baseSize?: number;
+ readonly headSize?: number;
+ };
+
+export function isBinaryCounts(
+ counts: ReviewFileCounts | undefined,
+): counts is Extract {
+ return !!counts && "binary" in counts;
+}
+
+/** A diffr result's counts: its changed lines, or the sizes of a binary file's sides. */
+export function reviewFileCounts(diff: StructuralDiff): ReviewFileCounts {
+ return diff.type === "text"
+ ? structuralChangeCounts(diff.structural_changes)
+ : { binary: true, baseSize: diff.lhs?.size, headSize: diff.rhs?.size };
+}
+
+/** "9.4 KB", or "66.1 KB → 14.7 KB" when both sides exist; undefined until a size is known. */
+export function binarySizeLabel(counts: {
+ readonly baseSize?: number;
+ readonly headSize?: number;
+}): string | undefined {
+ const { baseSize, headSize } = counts;
+ if (baseSize !== undefined && headSize !== undefined)
+ return `${formatByteSize(baseSize)} → ${formatByteSize(headSize)}`;
+ const size = headSize ?? baseSize;
+ return size === undefined ? undefined : formatByteSize(size);
+}
+
+function formatByteSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ["KB", "MB", "GB"];
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ return `${value.toFixed(1)} ${units[unit]}`;
+}
+
+/** Review keys a file by its head path, or its base path for a deletion. */
+export function structuralFilePath(
+ file: StructuralPairing,
+): string {
+ return file.rhs?.path ?? file.lhs!.path;
+}
+
+/** The 0-based, half-open line span a region touches. An end at column 0 does not touch its end line. */
+export function regionLines(region: StructuralRegion): {
+ start: number;
+ end: number;
+} {
+ return {
+ start: region.start.line,
+ end: region.end.column === 0 ? region.end.line : region.end.line + 1,
+ };
+}
+
+/** Preserve the context plugin's scope boundaries for lens projection. */
+export function structuralContextScopes(diff: StructuralTextDiff) {
+ const scopes = (source: StructuralSource | undefined) => {
+ const result: [number, number][] = [];
+ const visit = (region: StructuralRegion) => {
+ if (region.kind !== "fold") return;
+ if (region.tags?.includes("context:scope")) {
+ const { start, end } = regionLines(region);
+ result.push([start, end]);
+ }
+ region.children.forEach(visit);
+ };
+ if (source) visit(source.root);
+ return result;
+ };
+ return { original: scopes(diff.lhs), modified: scopes(diff.rhs) };
+}
+
+/**
+ * The zero-based lines a fold hides when it folds into its header line, as
+ * `opener { ⋯ N lines }`, and the closer text after the pill. Undefined when
+ * the fold needs a band: no opener, a summary, or code before its closer.
+ */
+function structuralInlineFold(
+ region: StructuralRegion,
+ lines: readonly string[],
+) {
+ if (
+ region.kind !== "fold" ||
+ !region.syntax ||
+ bandDetail(region.visibility?.label ?? "")
+ )
+ return undefined;
+ const { start, end } = regionLines(region);
+ const header = region.syntax.start.line,
+ closerLine = region.syntax.end.line;
+ if (closerLine <= header || start !== header + 1 || end > closerLine + 1)
+ return undefined;
+ const text = lines[closerLine] ?? "";
+ const column = utf16Column(text, region.syntax.end.column) - 1;
+ if (text.slice(0, column).trim()) return undefined;
+ return {
+ start: header + 1,
+ end: closerLine + 1,
+ closer: text.slice(column).trim(),
+ };
+}
+
+/**
+ * A region the reader can fold. `line` (zero-based) is the line with its
+ * chevron. A fold with an opener has a rail from the line below its header to
+ * its closer, and braces (one-based, UTF-16 columns).
+ */
+export interface StructuralFoldable {
+ readonly foldStateId: number;
+ readonly line: number;
+ readonly rail?: { readonly start: number; readonly end: number };
+ readonly braces?: {
+ readonly opener: { line: number; column: number };
+ readonly closer: { line: number; column: number };
+ };
+ /** Set when the fold folds into its header line: the pill text and the closer text. */
+ readonly inline?: { readonly label: string; readonly closer: string };
+ /** False when another region on the same line has the chevron. */
+ readonly chevron: boolean;
+ readonly collapsedByDefault: boolean;
+}
+
+/**
+ * The regions of one side the reader can fold. Each line has one chevron. A
+ * fold with an opener gets it first, so that the chevron and the rail fold the
+ * same body. Then a fold gets it before a leaf, and an outer region before an
+ * inner one.
+ */
+export function structuralFoldables(
+ source: StructuralSource | undefined,
+): StructuralFoldable[] {
+ const lines = source ? source.text.replace(/\r\n/g, "\n").split("\n") : [];
+ const found: {
+ foldable: Omit;
+ rank: [number, number, number];
+ }[] = [];
+ const visit = (region: StructuralRegion) => {
+ const { start, end } = regionLines(region);
+ const fold = region.kind === "fold";
+ if (
+ (fold ||
+ region.visibility?.collapsed === true ||
+ !!region.visibility?.label) &&
+ end > start
+ ) {
+ const syntax = fold ? region.syntax : undefined;
+ const inline = structuralInlineFold(region, lines);
+ const hidden = inline ? inline.end - inline.start - 1 : 0;
+ found.push({
+ foldable: {
+ foldStateId: region.fold_state_id,
+ line: syntax ? syntax.start.line : start,
+ rail:
+ syntax && syntax.end.line > syntax.start.line
+ ? { start: syntax.start.line + 1, end: syntax.end.line }
+ : undefined,
+ braces: syntax
+ ? {
+ opener: {
+ line: syntax.start.line + 1,
+ column:
+ utf16Column(
+ lines[syntax.start.line] ?? "",
+ syntax.start.column,
+ ) - 1,
+ },
+ closer: {
+ line: syntax.end.line + 1,
+ column: utf16Column(
+ lines[syntax.end.line] ?? "",
+ syntax.end.column,
+ ),
+ },
+ }
+ : undefined,
+ inline: inline && {
+ label:
+ region.visibility?.label ||
+ `${hidden} line${hidden === 1 ? "" : "s"}`,
+ closer: inline.closer,
+ },
+ collapsedByDefault: region.visibility?.collapsed === true,
+ },
+ rank: [syntax ? 1 : 0, fold ? 1 : 0, end],
+ });
+ }
+ if (region.kind === "fold") region.children.forEach(visit);
+ };
+ // The file header folds the root.
+ if (source?.root.kind === "fold") source.root.children.forEach(visit);
+ const holder = new Map();
+ for (const entry of found) {
+ const held = holder.get(entry.foldable.line);
+ const [a, b] = [entry.rank, held?.rank];
+ if (!b || (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) > 0)
+ holder.set(entry.foldable.line, entry);
+ }
+ return found.map((entry) => ({
+ ...entry.foldable,
+ chevron: holder.get(entry.foldable.line) === entry,
+ }));
+}
+
+function structuralLeaves(root: StructuralRegion): StructuralLeaf[] {
+ const leaves: StructuralLeaf[] = [];
+ const walk = (region: StructuralRegion) => {
+ if (region.kind === "leaf") leaves.push(region);
+ else for (const child of region.children) walk(child);
+ };
+ walk(root);
+ return leaves;
+}
+
+export function utf16Column(text: string, byteColumn: number): number {
+ let bytes = 0,
+ units = 0;
+ for (const character of text) {
+ if (bytes >= byteColumn) break;
+ const code = character.codePointAt(0)!;
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
+ units += character.length;
+ }
+ return units + 1;
+}
+
+/**
+ * Whole-line tint follows diffr’s structural coverage, matching change counts.
+ * Changed token spans supply the stronger tint within those lines.
+ */
+export function structuralHighlights(diff: StructuralTextDiff) {
+ function side(
+ source: StructuralSource | undefined,
+ ranges: readonly (readonly [number, number])[],
+ ) {
+ if (!source) return { spans: [], lines: [] };
+ const lines = source.text.replace(/\r\n/g, "\n").split("\n");
+ const changedLines: number[] = [];
+ for (const [start, end] of ranges) {
+ for (let line = start; line < end; line++) changedLines.push(line + 1);
+ }
+ const spans = [];
+ for (const leaf of structuralLeaves(source.root)) {
+ for (const span of leaf.changed ?? []) {
+ spans.push({
+ startLineNumber: span.line + 1,
+ startColumn: utf16Column(lines[span.line], span.start_column),
+ endLineNumber: span.line + 1,
+ endColumn: utf16Column(lines[span.line], span.end_column),
+ });
+ }
+ }
+ // Keep coverage for counts, but reserve token tint for partially changed lines.
+ const byLine = new Map();
+ for (const span of spans) {
+ const group = byLine.get(span.startLineNumber) ?? [];
+ group.push(span);
+ byLine.set(span.startLineNumber, group);
+ }
+ const fullyNovel = new Set();
+ for (const [line, group] of byLine) {
+ let end = 0;
+ let unchanged = "";
+ for (const span of group.sort((a, b) => a.startColumn - b.startColumn)) {
+ unchanged += lines[line - 1].slice(
+ end,
+ Math.max(end, span.startColumn - 1),
+ );
+ end = Math.max(end, span.endColumn - 1);
+ }
+ unchanged += lines[line - 1].slice(end);
+ if (!unchanged.trim()) fullyNovel.add(line);
+ }
+ return {
+ spans: spans.filter((span) => !fullyNovel.has(span.startLineNumber)),
+ lines: changedLines,
+ };
+ }
+ const original = side(diff.lhs, diff.structural_changes.base);
+ const modified = side(diff.rhs, diff.structural_changes.head);
+ return {
+ original: original.spans,
+ modified: modified.spans,
+ originalLines: original.lines,
+ modifiedLines: modified.lines,
+ };
+}
+
+/** Tags are `:`; several plugins capture docstrings, each under its own prefix. */
+const isDocstring = (region: StructuralRegion) =>
+ region.tags?.some(
+ (tag) => tag.slice(tag.lastIndexOf(":") + 1) === "docstring",
+ ) === true;
+
+/** A hidden band on one or both sides, one-based like Monaco's diff editor. */
+export interface StructuralGap {
+ originalStart: number;
+ modifiedStart: number;
+ originalCount: number;
+ modifiedCount: number;
+ label: string;
+ /** What the band hides: unchanged context, or lines that exist on one side only. */
+ owner: "base" | "head" | "both";
+ change: "unchanged" | "inserted" | "removed" | "modified";
+ /** False for a region the reader revealed: it stays a band the editor can fold again. */
+ collapsed: boolean;
+ /** The fold-state id of the region(s) this band hides; toggling the band toggles it. */
+ foldStateId: number;
+ /** False for a bundled docstring: its band shows the bare count, no symbol names. */
+ breadcrumbs: boolean;
+ /** False when the fold folds into its header line and has no band. */
+ band: boolean;
+}
+
+/**
+ * The text a band shows under its title. diffr prepends a ` pseudocode`
+ * line to a summary for terminals; the app has its own caption, so that line
+ * is dropped here. A one-line label has no detail.
+ */
+export function bandDetail(label: string): string {
+ const lines = label.split("\n");
+ if (lines.length < 2) return "";
+ const body = /^(\/\/|#|--|;|%)\s*pseudocode$/.test(lines[0].trim())
+ ? lines.slice(1)
+ : lines;
+ return body.join("\n");
+}
+
+/**
+ * Regions of one side the state knows about, outermost first: collapsed ones
+ * and ones a reader revealed. A collapsed region subsumes its descendants; a
+ * revealed one still lists them, since a child may be collapsed on its own.
+ */
+function knownRegions(
+ root: StructuralRegion | undefined,
+ state: (foldStateId: number) => boolean | undefined,
+): { region: StructuralRegion; collapsed: boolean }[] {
+ const result: { region: StructuralRegion; collapsed: boolean }[] = [];
+ const walk = (region: StructuralRegion) => {
+ const known = state(region.fold_state_id);
+ const { start, end } = regionLines(region);
+ const hides = end > start;
+ if (known === true) {
+ if (hides) result.push({ region, collapsed: true });
+ return;
+ }
+ // Open, but a band by the wire's default: a reader revealed it, and the editor can fold it again.
+ if (known === false && hides && region.visibility?.collapsed === true)
+ result.push({ region, collapsed: false });
+ if (region.kind === "fold")
+ for (const child of region.children) walk(child);
+ };
+ if (root) walk(root);
+ return result;
+}
+
+/**
+ * Every collapsed region as a diff-editor band. A region collapsed on both
+ * sides becomes one band: a leaf with the leaf sharing its `alignment_id`, a
+ * fold with the fold sharing its `fold_state_id` (a docstring only with a
+ * docstring, since a docstring shares its body's fold state). A region on one side only
+ * becomes a band on that side; the other side's range starts right after the
+ * row that precedes the region and covers only the opposite lines the zip put
+ * inside the region's rows, none when those rows are filler.
+ */
+export function structuralContextGaps(
+ diff: StructuralTextDiff,
+ isCollapsed: (foldStateId: number) => boolean,
+ state: (foldStateId: number) => boolean | undefined = (id) =>
+ isCollapsed(id) ? true : undefined,
+): StructuralGap[] {
+ const rows = structuralRows(diff);
+ const rowOfLeft = new Map(),
+ rowOfRight = new Map();
+ rows.forEach(([l, r], index) => {
+ if (l !== null) rowOfLeft.set(l, index);
+ if (r !== null) rowOfRight.set(r, index);
+ });
+ // One-based start and count of the opposite range for lines hidden on `side`. Rows are monotone, so
+ // the opposite lines inside the region's rows follow the opposite line of the row before it.
+ const oppositeSpan = (
+ side: 0 | 1,
+ hidden: { start: number; end: number },
+ ): { start: number; count: number } => {
+ const other = side === 0 ? 1 : 0;
+ const rowOf = side === 0 ? rowOfLeft : rowOfRight;
+ const first = rowOf.get(hidden.start)!,
+ last = rowOf.get(hidden.end - 1)!;
+ let before = -1;
+ for (let index = first - 1; index >= 0 && before === -1; index--)
+ before = rows[index][other] ?? -1;
+ let count = 0;
+ for (let index = first; index <= last; index++)
+ if (rows[index][other] !== null) count++;
+ return { start: before + 2, count };
+ };
+ const lhs = knownRegions(diff.lhs?.root, state);
+ const rhs = knownRegions(diff.rhs?.root, state);
+ const lhsLines = (diff.lhs?.text ?? "").replace(/\r\n/g, "\n").split("\n");
+ const rhsLines = (diff.rhs?.text ?? "").replace(/\r\n/g, "\n").split("\n");
+ // Leaves pair by alignment; folds pair by fold state, the only identity they share across sides.
+ const usedRhs = new Set();
+ const pairs = (left: StructuralRegion, right: StructuralRegion) => {
+ if (left.kind === "leaf")
+ return right.kind === "leaf" && right.alignment_id === left.alignment_id;
+ return (
+ right.kind === "fold" &&
+ right.fold_state_id === left.fold_state_id &&
+ isDocstring(right) === isDocstring(left)
+ );
+ };
+ const counterpart = (left: StructuralRegion) =>
+ rhs.find(({ region: right }) => !usedRhs.has(right) && pairs(left, right));
+ const gaps: StructuralGap[] = [];
+ for (const { region: left, collapsed } of lhs) {
+ const partner = counterpart(left);
+ if (partner) {
+ usedRhs.add(partner.region);
+ // Both sides fold into their header lines, or both keep a band.
+ const leftInline = structuralInlineFold(left, lhsLines),
+ rightInline = structuralInlineFold(partner.region, rhsLines);
+ const inline = leftInline && rightInline;
+ const hidden = inline ? leftInline : regionLines(left);
+ const right = inline ? rightInline : regionLines(partner.region);
+ gaps.push({
+ originalStart: hidden.start + 1,
+ originalCount: hidden.end - hidden.start,
+ modifiedStart: right.start + 1,
+ modifiedCount: right.end - right.start,
+ label: partner.region.visibility?.label || left.visibility?.label || "",
+ owner: "both",
+ change: "unchanged",
+ collapsed: collapsed && partner.collapsed,
+ foldStateId: left.fold_state_id,
+ breadcrumbs: !isDocstring(left) && !isDocstring(partner.region),
+ band: !inline,
+ });
+ continue;
+ }
+ // A base-only fold keeps its band. The unified layout has no header line for it.
+ const hidden = regionLines(left);
+ const opposite = oppositeSpan(0, hidden);
+ gaps.push({
+ originalStart: hidden.start + 1,
+ originalCount: hidden.end - hidden.start,
+ modifiedStart: opposite.start,
+ modifiedCount: opposite.count,
+ label: left.visibility?.label || "",
+ owner: "base",
+ change: "unchanged",
+ collapsed,
+ foldStateId: left.fold_state_id,
+ breadcrumbs: !isDocstring(left),
+ band: true,
+ });
+ }
+ for (const { region: right, collapsed } of rhs) {
+ if (usedRhs.has(right)) continue;
+ const inline = structuralInlineFold(right, rhsLines);
+ const hidden = inline ?? regionLines(right);
+ const opposite = oppositeSpan(1, hidden);
+ gaps.push({
+ originalStart: opposite.start,
+ originalCount: opposite.count,
+ modifiedStart: hidden.start + 1,
+ modifiedCount: hidden.end - hidden.start,
+ label: right.visibility?.label || "",
+ owner: "head",
+ change: "unchanged",
+ collapsed,
+ foldStateId: right.fold_state_id,
+ breadcrumbs: !isDocstring(right),
+ band: !inline,
+ });
+ }
+ // Ownership says which fold state to toggle, never whether its contents were deleted.
+ const highlights = structuralHighlights(diff);
+ const removedLines = new Set(highlights.originalLines),
+ addedLines = new Set(highlights.modifiedLines);
+ for (const [left, right] of rows) {
+ if (left !== null && right === null) removedLines.add(left + 1);
+ if (right !== null && left === null) addedLines.add(right + 1);
+ }
+ for (const gap of gaps) {
+ const removed = [...removedLines].some(
+ (line) =>
+ line >= gap.originalStart &&
+ line < gap.originalStart + gap.originalCount,
+ );
+ const added = [...addedLines].some(
+ (line) =>
+ line >= gap.modifiedStart &&
+ line < gap.modifiedStart + gap.modifiedCount,
+ );
+ gap.change =
+ removed && added
+ ? "modified"
+ : removed
+ ? "removed"
+ : added
+ ? "inserted"
+ : "unchanged";
+ }
+ gaps.sort(
+ (a, b) =>
+ a.modifiedStart - b.modifiedStart || a.originalStart - b.originalStart,
+ );
+ // A rewritten body can fold on each side under its own fold state, over the same rows.
+ // Show one fold: drop a one-sided fold that another fold hides fully. Of two equal folds, keep the first.
+ const within = (inner: StructuralGap, outer: StructuralGap) =>
+ inner.originalStart >= outer.originalStart &&
+ inner.originalStart + inner.originalCount <=
+ outer.originalStart + outer.originalCount &&
+ inner.modifiedStart >= outer.modifiedStart &&
+ inner.modifiedStart + inner.modifiedCount <=
+ outer.modifiedStart + outer.modifiedCount;
+ const redundant = (gap: StructuralGap, index: number) =>
+ gaps.some(
+ (other, at) =>
+ at !== index &&
+ other.collapsed &&
+ within(gap, other) &&
+ !(within(other, gap) && at > index),
+ );
+ const shown = gaps.filter(
+ (gap, index) =>
+ gap.owner === "both" || !gap.collapsed || !redundant(gap, index),
+ );
+ for (const gap of shown)
+ if (!gap.label) {
+ const count = Math.max(gap.originalCount, gap.modifiedCount);
+ gap.label = `${count} hidden line${count === 1 ? "" : "s"}`;
+ }
+ return shown;
+}
diff --git a/apps/diffr-web/src/review/common/reviewStructuralViewed.ts b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts
new file mode 100644
index 000000000..5d6a980ff
--- /dev/null
+++ b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts
@@ -0,0 +1,128 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+import type { ReviewDiffLens, ReviewDiffProgressFile } from "../../protocol.js";
+import {
+ regionLines,
+ type StructuralRegion,
+ type StructuralTextDiff,
+} from "./reviewStructuralDiff.js";
+
+type Sources = ReviewDiffLens["ranges"];
+type Source = Sources[number];
+
+/** All members of a fold state, including a bundled doc comment and both revisions. */
+export function structuralViewedScopes(
+ diff: StructuralTextDiff,
+ path: string,
+ previousPath = path,
+): ReadonlyMap {
+ const result = new Map();
+ const scopes = new Set();
+ for (const [side, source, file] of [
+ ["base", diff.lhs, previousPath],
+ ["head", diff.rhs, path],
+ ] as const) {
+ const visit = (region: StructuralRegion) => {
+ if (region.kind !== "fold") return;
+ if (region.syntax) scopes.add(region.fold_state_id);
+ const span = regionLines(region);
+ const fromLine = (region.syntax?.start.line ?? span.start) + 1;
+ const toLine = region.syntax ? region.syntax.end.line + 1 : span.end;
+ if (toLine >= fromLine) {
+ const ranges = result.get(region.fold_state_id) ?? [];
+ ranges.push({ side, file, fromLine, toLine });
+ result.set(region.fold_state_id, ranges);
+ }
+ region.children.forEach(visit);
+ };
+ // The file header owns the root's viewed control.
+ if (source?.root.kind === "fold") source.root.children.forEach(visit);
+ }
+ for (const [id, ranges] of result) {
+ if (scopes.has(id)) result.set(id, mergeSources(ranges));
+ else result.delete(id);
+ }
+ return result;
+}
+
+/** Viewed state depends on changed-line coverage, never on whether a fold is open. */
+export function structuralViewedProgress(
+ sources: Sources,
+ file: ReviewDiffProgressFile | undefined,
+) {
+ const changes = intersectSources(file?.changedRanges ?? [], sources);
+ const viewed = intersectSources(changes, file?.viewedRanges ?? []);
+ const count = (ranges: Sources, side: Source["side"]) =>
+ ranges.reduce(
+ (sum, r) => sum + (r.side === side ? r.toLine - r.fromLine + 1 : 0),
+ 0,
+ );
+ const total = {
+ additions: count(changes, "head"),
+ deletions: count(changes, "base"),
+ };
+ const remaining = {
+ additions: total.additions - count(viewed, "head"),
+ deletions: total.deletions - count(viewed, "base"),
+ };
+ const size = total.additions + total.deletions,
+ unread = remaining.additions + remaining.deletions;
+ const state =
+ size > 0 && unread === 0 ? "viewed" : unread < size ? "partial" : "unread";
+ return { state, total, remaining } as const;
+}
+
+/** Lens marks can cover individual changes without covering any complete syntax scope. */
+export function structuralViewedRanges(
+ scopes: ReadonlyMap | undefined,
+ file: ReviewDiffProgressFile | undefined,
+): Sources {
+ const ranges = intersectSources(
+ file?.changedRanges ?? [],
+ file?.viewedRanges ?? [],
+ );
+ for (const sources of scopes?.values() ?? []) {
+ if (structuralViewedProgress(sources, file).state === "viewed")
+ ranges.push(...sources);
+ }
+ return mergeSources(ranges);
+}
+
+function intersectSources(a: Sources, b: Sources): Source[] {
+ return mergeSources(
+ a.flatMap((left) =>
+ b.flatMap((right) => {
+ if (left.side !== right.side || left.file !== right.file) return [];
+ const fromLine = Math.max(left.fromLine, right.fromLine),
+ toLine = Math.min(left.toLine, right.toLine);
+ return fromLine <= toLine ? [{ ...left, fromLine, toLine }] : [];
+ }),
+ ),
+ );
+}
+
+function mergeSources(ranges: Sources): Source[] {
+ const result: Source[] = [];
+ for (const range of [...ranges].sort(
+ (a, b) =>
+ a.side.localeCompare(b.side) ||
+ a.file.localeCompare(b.file) ||
+ a.fromLine - b.fromLine,
+ )) {
+ const last = result[result.length - 1];
+ if (
+ last &&
+ last.side === range.side &&
+ last.file === range.file &&
+ range.fromLine <= last.toLine + 1
+ ) {
+ result[result.length - 1] = {
+ ...last,
+ toLine: Math.max(last.toLine, range.toLine),
+ };
+ } else result.push(range);
+ }
+ return result;
+}
diff --git a/apps/diffr-web/src/review/services/reviewDiffLayout.ts b/apps/diffr-web/src/review/services/reviewDiffLayout.ts
new file mode 100644
index 000000000..5254482af
--- /dev/null
+++ b/apps/diffr-web/src/review/services/reviewDiffLayout.ts
@@ -0,0 +1,36 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Emitter } from "vs/base/common/event.js";
+import { Disposable } from "vs/base/common/lifecycle.js";
+
+import { readSetting, writeSetting } from "../../settings.js";
+
+export type ReviewDiffLayout = "split" | "unified";
+
+/** The unified/split choice, remembered in this browser. */
+export class ReviewDiffLayoutSetting extends Disposable {
+ private readonly _onDidChange = this._register(
+ new Emitter(),
+ );
+ readonly onDidChange = this._onDidChange.event;
+ private layout: ReviewDiffLayout =
+ readSetting("layout") === "unified" ? "unified" : "split";
+
+ get(): ReviewDiffLayout {
+ return this.layout;
+ }
+
+ async set(layout: ReviewDiffLayout): Promise {
+ if (layout === this.layout) return;
+ this.layout = layout;
+ writeSetting("layout", layout);
+ this._onDidChange.fire(layout);
+ }
+
+ toggle(): Promise {
+ return this.set(this.layout === "split" ? "unified" : "split");
+ }
+}
diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts
new file mode 100644
index 000000000..e99574cef
--- /dev/null
+++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts
@@ -0,0 +1,1093 @@
+import {
+ $,
+ addDisposableListener,
+ append,
+ Dimension,
+ getWindow,
+ scheduleAtNextAnimationFrame,
+} from "vs/base/browser/dom.js";
+import {
+ Orientation,
+ SplitView,
+} from "vs/base/browser/ui/splitview/splitview.js";
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import "../browser/media/review.css";
+import { disposableTimeout } from "vs/base/common/async.js";
+import { Emitter, Event } from "vs/base/common/event.js";
+import {
+ Disposable,
+ DisposableStore,
+ MutableDisposable,
+ toDisposable,
+} from "vs/base/common/lifecycle.js";
+import {
+ autorun,
+ observableValue,
+ type IObservable,
+} from "vs/base/common/observable.js";
+import { isEqual } from "vs/base/common/resources.js";
+import { URI } from "vs/base/common/uri.js";
+import { ElementSizeObserver } from "vs/editor/browser/config/elementSizeObserver.js";
+import type { IDiffEditor } from "vs/editor/browser/editorBrowser.js";
+import { RefCounted } from "vs/editor/browser/widget/diffEditor/utils.js";
+import type { IDocumentDiffItem } from "vs/editor/browser/widget/multiDiffEditor/model.js";
+import { MultiDiffEditorViewModel } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js";
+import {
+ MultiDiffEditorWidget,
+ type RevealOptions,
+} from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js";
+import type { IMultiDiffEditorViewState } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js";
+import { IDiffEditorOptions } from "vs/editor/common/config/editorOptions.js";
+import { Range } from "vs/editor/common/core/range.js";
+import { ILanguageService } from "vs/editor/common/languages/language.js";
+import { PLAINTEXT_LANGUAGE_ID } from "vs/editor/common/languages/modesRegistry.js";
+import { IModelService } from "vs/editor/common/services/model.js";
+import { IHoverService } from "vs/platform/hover/browser/hover.js";
+import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js";
+
+import type {
+ ReviewDiffProgress,
+ ReviewDiffLens,
+ ReviewDiffViewSpec,
+} from "../../protocol.js";
+import { type ReviewDiffFileWire } from "../../protocol.js";
+import { ReviewChangedFilesTree } from "../browser/reviewChangedFilesTree.js";
+import {
+ REVIEW_COUNTS_PENDING_TOOLTIP,
+ reviewChangesTooltip,
+ reviewCountsTooltip,
+ ReviewTooltip,
+ type ReviewTooltipContent,
+} from "../browser/reviewTooltip.js";
+import {
+ binarySizeLabel,
+ isBinaryCounts,
+ type ReviewFileCounts,
+} from "../common/reviewStructuralDiff.js";
+import type { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js";
+import {
+ reviewMultiDiffLabelUris,
+ ReviewMultiDiffUIElementFactory,
+} from "./reviewMultiDiff.js";
+import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js";
+
+const FILE_TREE_MINIMUM_WIDTH = 180;
+const DIFF_MINIMUM_WIDTH = 320;
+const FILE_TREE_COLLAPSE_WIDTH = FILE_TREE_MINIMUM_WIDTH + DIFF_MINIMUM_WIDTH;
+const REVIEW_FILES_DIFF_EDITOR_OPTIONS = {
+ hideUnchangedRegions: { enabled: true },
+ originalEditable: false,
+ readOnly: true,
+ glyphMargin: false,
+ lineNumbersMinChars: 3,
+ // A peek or diff on the page is read until it is clicked into; the current
+ // line highlight belongs to the cursor, not to the first revealed line.
+ renderLineHighlightOnlyWhenFocus: true,
+ // Review Desktop's editor defaults (reviewConfigurationDefaults.ts), which
+ // the page has no configuration service to supply.
+ fontFamily: '"Geist Mono", Menlo, Monaco, "Courier New", monospace',
+ fontSize: 12,
+ minimap: { enabled: false },
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
+ renderIndicators: false,
+ // Each file's editor keeps an overview ruler where its hidden scrollbar would be; drawn alone,
+ // the caret's mark there reads as a stray blue dash beside the code.
+ hideCursorInOverviewRuler: true,
+ overviewRulerLanes: 0,
+} satisfies IDiffEditorOptions;
+
+export interface ReviewFilesEditorEntry {
+ readonly sectionId?: string;
+ readonly sectionStart?: boolean;
+ readonly file: ReviewDiffFileWire;
+ readonly original: URI | undefined;
+ readonly modified: URI | undefined;
+ readonly goToFileResource: URI;
+}
+
+/**
+ * The files of one comparison as the multi-diff widget's model. Review
+ * Desktop builds this on the workbench's MultiDiffEditorInput and resolves
+ * text through content providers; here a file's models are made from its
+ * diffr result once the file is diffed, and only diffed files are listed.
+ */
+export class ReviewFilesEditorInput extends Disposable {
+ private readonly changes = this._register(new Emitter());
+ private readonly documents = new Map>();
+ private current: readonly RefCounted[] = [];
+ private loading: boolean;
+
+ constructor(
+ readonly entries: readonly ReviewFilesEditorEntry[],
+ private readonly session: StructuralDiffSession,
+ /** Whether long lines wrap, in every file. */
+ private readonly wordWrap: IObservable,
+ @IModelService private readonly modelService: IModelService,
+ @ILanguageService private readonly languageService: ILanguageService,
+ @IInstantiationService
+ private readonly instantiationService: IInstantiationService,
+ ) {
+ super();
+ this.loading = entries.length > 0;
+ this._register(
+ toDisposable(() => {
+ for (const document of this.documents.values()) document.dispose();
+ this.documents.clear();
+ }),
+ );
+ }
+
+ setReadyFiles(paths: ReadonlySet, loading: boolean): void {
+ this.loading = loading;
+ this.current = this.entries.flatMap((entry) => {
+ if (!paths.has(entry.file.path)) return [];
+ const document =
+ this.documents.get(entry.file.path) ?? this.createDocument(entry);
+ return document ? [document] : [];
+ });
+ this.changes.fire();
+ }
+
+ private createDocument(
+ entry: ReviewFilesEditorEntry,
+ ): RefCounted | undefined {
+ const diff = this.session.getFileResult(entry.file.path)?.diff;
+ if (!diff) return undefined;
+ const store = new DisposableStore();
+ const model = (uri: URI, text: string) =>
+ store.add(
+ this.modelService.getModel(uri) ??
+ this.modelService.createModel(
+ text,
+ this.languageService.createById(PLAINTEXT_LANGUAGE_ID),
+ uri,
+ ),
+ );
+ const text = (side: "lhs" | "rhs") =>
+ diff.type === "text" ? (diff[side]?.text ?? "") : "";
+ const wordWrap = this.wordWrap;
+ // Every hidden line comes from the one fold model: diffr's regions,
+ // never the diff editor's own unchanged-region hiding.
+ const options = {
+ ...REVIEW_FILES_DIFF_EDITOR_OPTIONS,
+ hideOriginalLineNumbers:
+ entry.file.status === "added" || entry.file.status === "unchanged",
+ hideUnchangedRegions: {
+ enabled: true,
+ minimumLineCount: 1,
+ contextLineCount: 0,
+ },
+ folding: false,
+ // Structural rails use one geometry through code, folds and deleted rows.
+ guides: { indentation: false, bracketPairs: false },
+ // Fold controls follow the line numbers, with breathing room before code.
+ lineDecorationsWidth: 24,
+ glyphMargin: true,
+ experimental: { useTrueInlineView: false },
+ } satisfies IDiffEditorOptions;
+ const item: IDocumentDiffItem = {
+ collapsed: observableValue("fileCollapsed", false),
+ original: model(entry.original!, text("lhs")),
+ modified: model(entry.modified!, text("rhs")),
+ labelUris: reviewMultiDiffLabelUris(entry.file),
+ get options() {
+ return {
+ ...options,
+ wordWrap: wordWrap.get() ? ("on" as const) : ("off" as const),
+ };
+ },
+ onOptionsDidChange: Event.fromObservableLight(wordWrap),
+ };
+ const document = RefCounted.createOfNonDisposable(item, store, this);
+ this.documents.set(entry.file.path, document);
+ return document;
+ }
+
+ async getViewModel(): Promise {
+ const input = this;
+ // Its instantiation service is a plain parameter, not an injected one.
+ return this._register(
+ new MultiDiffEditorViewModel(
+ {
+ documents: {
+ get value() {
+ return input.current.length || !input.loading
+ ? input.current
+ : "loading";
+ },
+ onDidChange: this.changes.event,
+ },
+ },
+ this.instantiationService,
+ ),
+ );
+ }
+}
+
+/**
+ * The changed-files diff UI — a file list beside a multi-diff widget — as a
+ * plain widget. It owns no editor pane, so the Review canvas can mount it into
+ * a container the app supplies. The container's size drives the layout: the
+ * canvas gives the host element its bounds through CSS, not through a pane
+ * layout call.
+ */
+export class ReviewFilesDiffView extends Disposable {
+ private readonly _onDidChangeActiveControl = this._register(
+ new Emitter(),
+ );
+ readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event;
+ private readonly _onDidScroll = this._register(new Emitter());
+ /** The list scrolled or its topmost file changed. */
+ readonly onDidScroll = this._onDidScroll.event;
+ private readonly _onDidRequestFile = this._register(new Emitter());
+ /** The reader asked for a file from the tree, diffed or not. */
+ readonly onDidRequestFile = this._onDidRequestFile.event;
+ private readonly diffContainer: HTMLElement;
+
+ private readonly root: HTMLElement;
+ private readonly splitView: SplitView;
+ private readonly changedFilesTree: ReviewChangedFilesTree | undefined;
+ private readonly widget: MultiDiffEditorWidget;
+ private pendingViewState: IMultiDiffEditorViewState | undefined;
+ private viewModel: MultiDiffEditorViewModel | undefined;
+ private input: ReviewFilesEditorInput | undefined;
+ private readonly readyFiles = new Set();
+ private readonly fileStates = new Map();
+ private readonly settleHold = this._register(
+ new MutableDisposable(),
+ );
+ /** Full structural counts for views without persisted coverage. */
+ private readonly streamStats = new Map<
+ string,
+ { counts: ReviewFileCounts; tooltip?: ReviewTooltipContent }
+ >();
+ private readonly headerFactory: ReviewMultiDiffUIElementFactory;
+ private readonly summary: HTMLElement;
+ private readonly summaryTooltip: ReviewTooltip;
+ /** Files the stream says start hidden, with the reason shown in their header. */
+ private readonly hiddenFiles = new Map();
+ private readonly hiddenApplied = new Set();
+ private pendingPath: string | undefined;
+ private pendingSectionId: string | undefined;
+ private pendingSource: ReviewDiffLens["ranges"][number] | undefined;
+ private progress: ReviewDiffProgress | undefined;
+ private documentCollapsed = false;
+ private fileTreeHidden = false;
+ private readonly initializedDocumentItems = new WeakSet