diff --git a/apps/diffr-web/package.json b/apps/diffr-web/package.json
index e710f89b1..727088f25 100644
--- a/apps/diffr-web/package.json
+++ b/apps/diffr-web/package.json
@@ -14,7 +14,8 @@
},
"dependencies": {
"@dev.fast/diffr": "workspace:*",
- "@dev.fast/review-protocol": "workspace:*"
+ "@dev.fast/review-protocol": "workspace:*",
+ "zod": "catalog:"
},
"devDependencies": {
"@types/trusted-types": "2.0.7",
@@ -23,6 +24,7 @@
"@vscode/diff": "0.0.2-7",
"@vscode/tree-sitter-wasm": "0.3.1",
"@webgpu/types": "0.1.66",
+ "playwright": "catalog:",
"typescript": "6.0.3",
"vite": "8.0.16"
},
diff --git a/apps/diffr-web/scripts/fold-scroll-anchor.mjs b/apps/diffr-web/scripts/fold-scroll-anchor.mjs
new file mode 100644
index 000000000..766e1fbb7
--- /dev/null
+++ b/apps/diffr-web/scripts/fold-scroll-anchor.mjs
@@ -0,0 +1,120 @@
+/** Against Vite: node apps/diffr-web/scripts/fold-scroll-anchor.mjs [chromium|webkit]. */
+import assert from "node:assert/strict";
+
+import { chromium, devices, webkit } from "playwright";
+
+import { fixture } from "./mobile-fixture.mjs";
+
+const engine = process.argv[2] ?? "chromium";
+
+const browser = await { chromium, webkit }[engine].launch();
+
+try {
+ const page = await browser.newPage({
+ ...devices["iPhone 13"],
+ deviceScaleFactor: 1,
+ });
+
+ await page.route("**/__ui-fixture.json", (r) =>
+ r.fulfill({ json: fixture() }),
+ );
+ await page.goto("http://127.0.0.1:4181/fixture/mobile/pull/1?ui-fixture");
+ await page
+ .getByRole("button", { name: "Continue without token", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "Start reviewing", exact: true })
+ .click();
+ await page
+ .locator(".modified .view-line")
+ .first()
+ .waitFor({ timeout: 60000 });
+ await page
+ .getByRole("button", { name: "Open file tree", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "src/group-0/file-1.ts", exact: true })
+ .click();
+ await page.waitForTimeout(800);
+ await page.locator(".app-native-scroll").evaluate((n) => {
+ n.dispatchEvent(new TouchEvent("touchstart", { bubbles: true }));
+ n.scrollTop -= 650;
+ });
+ await page.waitForTimeout(600);
+
+ const scope = page.locator(".multiDiffEntry").filter({
+ has: page.locator('[aria-label="Mark viewed: src/group-0/file-0.ts"]'),
+ });
+
+ const fold = scope.locator(
+ '.modified .app-touch-fold[aria-label="Fold scope at line 155"]',
+ );
+
+ const before = await fold.boundingBox();
+
+ const top = await page
+ .locator(".app-native-scroll")
+ .evaluate((n) => n.scrollTop);
+
+ console.log("before", before, top);
+
+ await fold.tap();
+ await page.waitForTimeout(500);
+
+ const after = await scope
+ .locator('.modified .app-touch-fold[aria-label="Expand scope at line 155"]')
+ .boundingBox();
+
+ console.log(
+ "after",
+ after,
+ await page.locator(".app-native-scroll").evaluate((n) => n.scrollTop),
+ );
+
+ assert.ok(
+ Math.abs(after.y - before.y) < 2,
+ `fold moved ${after.y - before.y}px`,
+ );
+
+ const next = page.locator(
+ '[aria-label="Mark viewed: src/group-0/file-1.ts"]',
+ );
+
+ assert.ok(
+ (await next.boundingBox()).y < 844,
+ "Collapsing should bring the following file into view",
+ );
+
+ for (let i = 0; i < 12; i++) {
+ await scope
+ .locator(
+ '.modified .app-touch-fold[aria-label="Expand scope at line 155"]',
+ )
+ .tap();
+ await page.waitForTimeout(100);
+ assert.ok(
+ Math.abs((await fold.boundingBox()).y - before.y) < 2,
+ "Expanding moved the fold header",
+ );
+ await fold.tap();
+ await page.waitForTimeout(100);
+ assert.ok(
+ Math.abs(
+ (
+ await scope
+ .locator(
+ '.modified .app-touch-fold[aria-label="Expand scope at line 155"]',
+ )
+ .boundingBox()
+ ).y - before.y,
+ ) < 2,
+ "Repeated folding moved the header",
+ );
+ }
+
+ console.log(
+ `${engine}: collapse reveals next file; 12 expand/collapse cycles preserve position`,
+ );
+} finally {
+ await browser.close();
+}
diff --git a/apps/diffr-web/scripts/menu-close.mjs b/apps/diffr-web/scripts/menu-close.mjs
new file mode 100644
index 000000000..cd85cea2a
--- /dev/null
+++ b/apps/diffr-web/scripts/menu-close.mjs
@@ -0,0 +1,116 @@
+/** Verify hovered menu triggers do not flash during dismissal. Run against Vite on port 4181. */
+import assert from "node:assert/strict";
+
+import { chromium, devices, webkit } from "playwright";
+
+import { fixture } from "./mobile-fixture.mjs";
+
+const base = process.env.DIFFR_TEST_URL ?? "http://127.0.0.1:4181";
+
+for (const [name, engine] of [
+ ["chromium", chromium],
+ ["webkit", webkit],
+]) {
+ const b = await engine.launch();
+
+ try {
+ const p = await b.newPage({
+ ...devices["iPhone 13"],
+ deviceScaleFactor: 1,
+ });
+
+ const data = fixture();
+ data.change.files = data.change.files.slice(0, 1);
+ await p.route("**/__ui-fixture.json", (r) => r.fulfill({ json: data }));
+ await p.goto(`${base}/fixture/mobile/pull/1?ui-fixture`);
+ await p
+ .getByRole("button", { name: "Continue without token", exact: true })
+ .click();
+ await p
+ .getByRole("button", { name: "Start reviewing", exact: true })
+ .click();
+ await p.locator(".modified .view-line").first().waitFor({ timeout: 60000 });
+ await p.waitForTimeout(1500);
+
+ for (const label of ["Display settings", "Theme settings"]) {
+ const button = p.getByRole("button", { name: label, exact: true });
+ await button.click();
+ assert.equal(await p.locator(":popover-open").count(), 1);
+ await button.hover();
+ await p.waitForTimeout(250);
+
+ const color = await button.evaluate(
+ (n) => getComputedStyle(n).backgroundColor,
+ );
+
+ await p.evaluate((label) => {
+ window.colors = [];
+ const start = performance.now();
+
+ function sample() {
+ const n = [...document.querySelectorAll(".app-header button")].find(
+ (n) => n.getAttribute("aria-label") === label,
+ );
+
+ window.colors.push(getComputedStyle(n).backgroundColor);
+
+ if (performance.now() - start < 550) requestAnimationFrame(sample);
+ }
+
+ requestAnimationFrame(sample);
+ }, label);
+ await button.click();
+ await p.waitForTimeout(600);
+ assert.equal(await p.locator(":popover-open").count(), 0);
+ const colors = await p.evaluate(() => [...new Set(window.colors)]);
+ console.log(name, label, { color, colors });
+ assert.deepEqual(
+ colors,
+ [color],
+ "hovered trigger must not flash while closing",
+ );
+ }
+
+ await p.close();
+ const loading = await b.newPage({ ...devices["iPhone 13"] });
+ let release;
+
+ const ready = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ await loading.route("**/__ui-fixture.json", async (route) => {
+ await ready;
+ await route.fulfill({ json: data });
+ });
+ await loading.goto(`${base}/fixture/mobile/pull/1?ui-fixture`);
+ await loading
+ .getByRole("button", { name: "Continue without token", exact: true })
+ .click();
+ await loading
+ .getByRole("button", { name: "Start reviewing", exact: true })
+ .click();
+
+ const trigger = loading.getByRole("button", {
+ name: "Display settings",
+ exact: true,
+ });
+
+ await trigger.click();
+ release();
+ await loading
+ .locator(".modified .view-line")
+ .first()
+ .waitFor({ timeout: 60000 });
+ assert.equal(await loading.locator(":popover-open").count(), 1);
+ await trigger.click();
+ await loading.waitForFunction(
+ (title) => document.querySelector(".app-title").textContent === title,
+ data.change.title,
+ );
+ assert.equal(await loading.locator(":popover-open").count(), 0);
+ console.log(name, "deferred header update: PASS");
+ } finally {
+ await b.close();
+ }
+}
diff --git a/apps/diffr-web/scripts/mobile-fixture.mjs b/apps/diffr-web/scripts/mobile-fixture.mjs
new file mode 100644
index 000000000..0c9df810f
--- /dev/null
+++ b/apps/diffr-web/scripts/mobile-fixture.mjs
@@ -0,0 +1,47 @@
+export const summary =
+ "// pseudocode\nCollect every matching entry, normalize its path and metadata, then render the complete result while preserving the original ordering and error details for the caller. Keep source locations available so each result can be traced back to its input.\nReturn the processed entries.";
+
+export const plugin = {
+ name: "mobile-stress-summary",
+ description: "Deterministic summary for UI tests",
+ enabled: true,
+ code: `(file)=>{for(const side of [file.lhs,file.rhs]){if(!side)continue;const visit=(r,depth)=>{if(r.kind!=='fold')return;if(depth===1)r.visibility={collapsed:true,label:${JSON.stringify(summary)}};else r.children.forEach(c=>visit(c,depth+1));};visit(side.root,0);}}`,
+};
+
+export function fixture() {
+ const files = [],
+ texts = { base: {}, head: {} };
+
+ for (let f = 0; f < 24; f++) {
+ const path = `src/group-${Math.floor(f / 6)}/file-${f}.ts`;
+
+ const source = (version) =>
+ Array.from(
+ { length: 12 },
+ (_, i) =>
+ `export function process${i}(value: number) {\n${Array.from({ length: 10 }, (_, j) => ` const result${j} = value + ${j + version}; // ${"long code content ".repeat(12)}`).join("\n")}\n return result9;\n}`,
+ ).join("\n\n");
+
+ files.push({
+ path,
+ status: "modified",
+ additions: 120,
+ deletions: 120,
+ sha: `blob-${f}`,
+ });
+ texts.base[path] = source(0);
+ texts.head[path] = source(1);
+ }
+
+ return {
+ change: {
+ target: { kind: "pull", owner: "fixture", repo: "mobile", number: 1 },
+ title: "Mobile interaction stress fixture",
+ url: "https://github.com/fixture/mobile/pull/1",
+ base: "base",
+ head: "head",
+ files,
+ },
+ texts,
+ };
+}
diff --git a/apps/diffr-web/scripts/mobile-scroll-boundary.mjs b/apps/diffr-web/scripts/mobile-scroll-boundary.mjs
new file mode 100644
index 000000000..2e7cc8d20
--- /dev/null
+++ b/apps/diffr-web/scripts/mobile-scroll-boundary.mjs
@@ -0,0 +1,163 @@
+/** Against Vite: node apps/diffr-web/scripts/mobile-scroll-boundary.mjs [chromium|webkit] [seconds=0]. */
+import assert from "node:assert/strict";
+
+import { chromium, devices, webkit } from "playwright";
+
+import { fixture } from "./mobile-fixture.mjs";
+
+const engine = process.argv[2] ?? "chromium";
+
+const seconds = Number(process.argv[3] ?? 0);
+
+const browser = await { chromium, webkit }[engine].launch();
+
+const started = Date.now();
+
+const sizes = [
+ { width: 390, height: 844 },
+ { width: 320, height: 640 },
+ { width: 430, height: 932 },
+ { width: 760, height: 844 },
+ { width: 844, height: 390 },
+];
+
+const data = fixture();
+
+const previous = "src/group-1/file-9.ts";
+
+const target = "src/group-2/file-12.ts";
+
+// A long, unvisited preceding file makes an incorrect top anchor unmistakable.
+for (const side of ["base", "head"])
+ data.texts[side][previous] = Array.from({ length: 10 }, (_, i) =>
+ data.texts[side][previous].replace(
+ /process(\d+)/g,
+ (_, n) => `process${i}_${n}`,
+ ),
+ ).join("\n\n");
+
+let cycles = 0;
+
+try {
+ do {
+ const context = await browser.newContext({
+ ...devices["iPhone 13"],
+ viewport: sizes[cycles % sizes.length],
+ deviceScaleFactor: 1,
+ });
+
+ const page = await context.newPage();
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.route("**/__ui-fixture.json", (route) =>
+ route.fulfill({ json: data }),
+ );
+ await page.goto(
+ `${process.env.DIFFR_TEST_URL ?? "http://127.0.0.1:4181"}/fixture/mobile/pull/1?ui-fixture`,
+ );
+ await page
+ .getByRole("button", { name: "Continue without token", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "Start reviewing", exact: true })
+ .click();
+ await page
+ .locator(".modified .view-line")
+ .first()
+ .waitFor({ timeout: 60000 });
+ await page
+ .getByRole("button", { name: "Open file tree", exact: true })
+ .tap();
+ await page.waitForFunction(
+ (path) =>
+ [...document.querySelectorAll(".app-mobile-file-list button")].some(
+ (button) =>
+ button.getAttribute("aria-label") === path &&
+ button
+ .getAttribute("aria-description")
+ ?.includes("1200 lines added"),
+ ),
+ previous,
+ );
+ await page.getByRole("button", { name: target, exact: true }).tap();
+ await page.waitForTimeout(500);
+
+ const nearEnd = async () => {
+ const lines = await page.locator(".line-numbers").evaluateAll((nodes) =>
+ nodes
+ .filter((node) => {
+ const rect = node.getBoundingClientRect();
+
+ return (
+ rect.height > 0 && rect.top > 110 && rect.top < innerHeight - 20
+ );
+ })
+ .map((node) => Number(node.textContent)),
+ );
+
+ assert(
+ lines.some((line) => line > 1600),
+ `Upward scroll should enter the preceding file at its end; visible lines: ${lines}`,
+ );
+ };
+
+ await page.locator(".app-native-scroll").evaluate((node) => {
+ // Cancel the file-jump hold just as the start of a touch gesture does.
+ node.dispatchEvent(new TouchEvent("touchstart", { bubbles: true }));
+ node.scrollTop -= 900;
+ });
+ await page.waitForTimeout(200);
+ await nearEnd();
+
+ if (engine === "chromium") {
+ const cdp = await context.newCDPSession(page);
+ const start = 140;
+ const end = Math.min(700, sizes[cycles % sizes.length].height - 40);
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchStart",
+ touchPoints: [{ x: 240, y: start }],
+ });
+
+ for (let y = start + 30; y <= end; y += 40) {
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchMove",
+ touchPoints: [{ x: 240, y }],
+ });
+ await page.waitForTimeout(8);
+ }
+
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchEnd",
+ touchPoints: [],
+ });
+ } else {
+ // Playwright WebKit has no native touch-drag API; exercise the scroll bridge.
+ for (let i = 0; i < 12; i++) {
+ await page.locator(".app-native-scroll").evaluate((node) => {
+ node.scrollTop -= 40;
+ });
+ await page.waitForTimeout(8);
+ }
+ }
+
+ await page.waitForTimeout(1200);
+ await nearEnd();
+ assert.deepEqual(errors, []);
+ console.log(
+ `${engine}: boundary and continued upward scroll passed at ${sizes[cycles % sizes.length].width}px`,
+ );
+ cycles++;
+ await context.close();
+ } while (Date.now() - started < seconds * 1000);
+
+ console.log(
+ JSON.stringify({
+ engine,
+ cycles,
+ seconds: Math.round((Date.now() - started) / 1000),
+ errors: 0,
+ }),
+ );
+} finally {
+ await browser.close();
+}
diff --git a/apps/diffr-web/scripts/mobile-stress.mjs b/apps/diffr-web/scripts/mobile-stress.mjs
new file mode 100644
index 000000000..41e354cce
--- /dev/null
+++ b/apps/diffr-web/scripts/mobile-stress.mjs
@@ -0,0 +1,480 @@
+/** Run against Vite: node apps/diffr-web/scripts/mobile-stress.mjs [chromium|webkit] [seconds=240].
+ * Uses real WASM diffing with deterministic source/summary inputs; no accounts or API keys.
+ */
+import assert from "node:assert/strict";
+
+import { chromium, devices, webkit } from "playwright";
+
+import { fixture, plugin } from "./mobile-fixture.mjs";
+
+const engineName = process.argv[2] ?? "chromium";
+
+const seconds = Number(process.argv[3] ?? 240);
+
+const browser = await { chromium, webkit }[engineName].launch({
+ headless: true,
+});
+
+const page = await browser.newPage({
+ ...devices["iPhone 13"],
+ deviceScaleFactor: 1,
+});
+
+const errors = [];
+
+page.on("pageerror", (error) => errors.push(error.message));
+
+const checks = {
+ menus: 0,
+ sheets: 0,
+ folds: 0,
+ swipes: 0,
+ copies: 0,
+ resizes: 0,
+};
+
+const sizes = [
+ { width: 390, height: 844 },
+ { width: 320, height: 640 },
+ { width: 430, height: 844 },
+ { width: 375, height: 844 },
+ { width: 760, height: 844 },
+ { width: 600, height: 844 },
+ { width: 844, height: 390 },
+ { width: 932, height: 430 },
+];
+
+const base = process.env.DIFFR_TEST_URL ?? "http://127.0.0.1:4181";
+
+try {
+ await page.addInitScript(
+ (plugin) => localStorage.setItem("diffr.plugins", JSON.stringify([plugin])),
+ plugin,
+ );
+ await page.route("**/__ui-fixture.json", (route) =>
+ route.fulfill({ json: fixture() }),
+ );
+ await page.goto(`${base}/fixture/mobile/pull/1?ui-fixture`);
+ await page
+ .getByRole("button", { name: "Continue without token", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "Start reviewing", exact: true })
+ .click();
+ await page
+ .locator(".summary-fold pre:visible")
+ .first()
+ .waitFor({ timeout: 60000 });
+
+ const cdp =
+ engineName === "chromium"
+ ? await page.context().newCDPSession(page)
+ : undefined;
+
+ if (cdp && process.env.DIFFR_CPU_RATE)
+ await cdp.send("Emulation.setCPUThrottlingRate", {
+ rate: Number(process.env.DIFFR_CPU_RATE),
+ });
+ await page.evaluate(() => {
+ window.stressFrames = [];
+ window.stressSampling = true;
+ let last = performance.now();
+
+ const sample = (now) => {
+ window.stressFrames.push(now - last);
+ last = now;
+
+ if (window.stressSampling) requestAnimationFrame(sample);
+ };
+
+ requestAnimationFrame(sample);
+ });
+ const tap = (name) => page.getByRole("button", { name, exact: true }).tap();
+
+ const swipe = async (x, y, dx, dy) => {
+ if (cdp) {
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchStart",
+ touchPoints: [{ x, y }],
+ });
+
+ for (let step = 1; step <= 12; step++) {
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchMove",
+ touchPoints: [{ x: x + (dx * step) / 12, y: y + (dy * step) / 12 }],
+ });
+ await page.waitForTimeout(16);
+ }
+
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchEnd",
+ touchPoints: [],
+ });
+ } else {
+ // WebKit Playwright has no native drag-touch API. Exercise the application's touch handling;
+ // use the iOS simulator separately to verify native Safari scrolling.
+ await page.evaluate(
+ async ({ x, y, dx, dy }) => {
+ const node = document.elementFromPoint(x, y);
+
+ const fire = (type, x, y) => {
+ const touches =
+ type === "touchend"
+ ? []
+ : [
+ {
+ identifier: 1,
+ target: node,
+ clientX: x,
+ clientY: y,
+ },
+ ];
+
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperties(event, {
+ touches: { value: touches },
+ targetTouches: { value: touches },
+ changedTouches: { value: touches },
+ });
+
+ return node.dispatchEvent(event);
+ };
+
+ fire("touchstart", x, y);
+
+ for (let step = 1; step <= 12; step++) {
+ fire("touchmove", x + (dx * step) / 12, y + (dy * step) / 12);
+ await new Promise(requestAnimationFrame);
+ }
+
+ fire("touchend", x + dx, y + dy);
+
+ if (dy) document.querySelector(".app-native-scroll").scrollTop -= dy;
+ },
+ { x, y, dx, dy },
+ );
+ }
+
+ checks.swipes++;
+ await page.waitForTimeout(160);
+ };
+
+ async function inViewport(locator) {
+ for (const candidate of await locator.all()) {
+ const box = await candidate.boundingBox();
+
+ if (
+ box &&
+ box.x < page.viewportSize().width &&
+ box.y >= 110 &&
+ box.y + box.height <= page.viewportSize().height - 20 &&
+ (await candidate.evaluate((n) => {
+ const r = n.getBoundingClientRect();
+
+ const x =
+ (Math.max(0, r.left) + Math.min(innerWidth - 5, r.right)) / 2;
+
+ return n.contains(
+ document.elementFromPoint(x, r.top + Math.min(12, r.height / 2)),
+ );
+ }))
+ )
+ return candidate;
+ }
+
+ throw new Error("No target inside the visible virtualized viewport");
+ }
+
+ const start = Date.now();
+ let iteration = 0;
+
+ while (Date.now() - start < seconds * 1000) {
+ const { width, height } = sizes[iteration % sizes.length];
+ await page.setViewportSize({ width, height });
+ checks.resizes++;
+ await page.waitForTimeout(100);
+ assert.equal(
+ await page.evaluate(() => document.body.scrollWidth),
+ width,
+ "page must not overflow horizontally",
+ );
+
+ for (const name of ["Display settings", "Theme settings"]) {
+ await tap(name);
+ await page.waitForTimeout(iteration % 2 ? 30 : 200);
+ assert.equal(await page.locator(":popover-open").count(), 1);
+
+ if (name === "Display settings")
+ await page
+ .getByLabel("Line numbers", { exact: true })
+ .setChecked(iteration % 2 === 1);
+
+ if (name === "Display settings" && iteration % 3 === 0) {
+ await page.getByLabel("Word wrap", { exact: true }).check();
+ await page.waitForTimeout(50);
+ await page.getByLabel("Word wrap", { exact: true }).uncheck();
+ }
+
+ await tap(name);
+ await page.waitForTimeout(230);
+ assert.equal(
+ await page.locator(":popover-open").count(),
+ 0,
+ `${name}: second tap must close`,
+ );
+ checks.menus++;
+ }
+
+ await tap("Open file tree");
+ await page.waitForTimeout(270);
+ const rows = page.locator(".app-mobile-file-row");
+ assert.equal(await rows.count(), 24);
+ await tap("Show file search");
+ const path = `src/group-${Math.floor((iteration % 24) / 6)}/file-${iteration % 24}.ts`;
+ await page.getByRole("searchbox").fill(path);
+ await page.evaluate(() => {
+ const sheet = document.querySelector(".app-mobile-files");
+ window.exitTransitions = [];
+
+ const record = (event) => {
+ if (event.target === sheet && event.propertyName === "transform")
+ window.exitTransitions.push(event.type);
+ };
+
+ sheet.addEventListener("transitionrun", record);
+ sheet.addEventListener("transitionend", record);
+ sheet.addEventListener(
+ "close",
+ () => {
+ sheet.removeEventListener("transitionrun", record);
+ sheet.removeEventListener("transitionend", record);
+ },
+ { once: true },
+ );
+ });
+ await page.getByRole("button", { name: path, exact: true }).tap();
+ await page.waitForTimeout(300);
+ assert.ok(
+ await page.evaluate(() =>
+ window.exitTransitions.includes("transitionrun"),
+ ),
+ "sheet must animate while dismissing",
+ );
+ assert.equal(await page.locator(".app-mobile-files").isVisible(), false);
+ checks.sheets++;
+ await page
+ .locator(".summary-fold pre:visible")
+ .first()
+ .waitFor({ timeout: 15000 });
+
+ const summary = await inViewport(
+ page
+ .locator(".summary-fold pre")
+ .filter({ hasText: "Collect every matching entry" }),
+ );
+
+ const summaryBox = await summary.boundingBox();
+ const summaryText = await summary.textContent();
+ assert.notEqual(
+ await summary.evaluate((n) => getComputedStyle(n).textOverflow),
+ "ellipsis",
+ );
+ await swipe(
+ Math.min(width - 25, summaryBox.x + summaryBox.width - 20),
+ summaryBox.y + 12,
+ -Math.min(160, width / 2),
+ 0,
+ );
+ assert.ok(
+ await summary.evaluate(
+ (n) =>
+ n.scrollWidth > n.clientWidth &&
+ n.scrollLeft >= Math.min(20, n.scrollWidth - n.clientWidth),
+ ),
+ "long summary must pan without opening the fold",
+ );
+ assert.equal(await summary.textContent(), summaryText);
+ // A deliberate tap after panning still expands the code.
+ await summary.tap();
+ checks.folds++;
+ await page.waitForTimeout(120);
+
+ const longLine = await inViewport(
+ page
+ .locator(".modified .view-line")
+ .filter({ hasText: "long code content" }),
+ );
+
+ const box = await longLine.boundingBox();
+ const before = box.x;
+ await swipe(width - 35, box.y + 12, -Math.min(190, width / 2), 0);
+ assert.ok(
+ (await longLine.boundingBox()).x < before - 20,
+ "unwrapped code must pan",
+ );
+
+ // The shared horizontal thumb remains above the editor at the bottom of the viewport.
+ const scrollbar = page.locator(
+ ".multiDiffEditor > div > .monaco-scrollable-element > .scrollbar.horizontal",
+ );
+
+ assert.ok(
+ await scrollbar.evaluate((n) => {
+ const r = n.getBoundingClientRect();
+
+ const x = r.x + Math.min(20, r.width / 2),
+ y = r.y + r.height / 2;
+
+ return (
+ r.width > 0 &&
+ r.height > 0 &&
+ n.contains(document.elementFromPoint(x, y))
+ );
+ }),
+ "diff must not cover the scrollbar",
+ );
+
+ const copy = await longLine.evaluate((line) => {
+ const range = document.createRange();
+ range.selectNodeContents(line);
+ getSelection().removeAllRanges();
+ getSelection().addRange(range);
+ const data = new DataTransfer();
+ line.dispatchEvent(
+ new ClipboardEvent("copy", {
+ bubbles: true,
+ cancelable: true,
+ clipboardData: data,
+ }),
+ );
+
+ const result = {
+ actual: data.getData("text/plain"),
+ expected: line.textContent.replace(/\u00a0/g, " "),
+ };
+
+ getSelection().removeAllRanges();
+
+ return result;
+ });
+
+ assert.equal(copy.actual, copy.expected);
+ checks.copies++;
+ assert.ok(
+ await page
+ .locator(".multiDiffEditor .scrollContent > div:first-child")
+ .evaluate((n) => n.scrollLeft === 0 && n.scrollTop === 0),
+ "focus must not scroll the virtual-content wrapper",
+ );
+
+ const collapse = await inViewport(
+ page.locator('.modified .app-touch-fold[aria-expanded="true"]'),
+ );
+
+ assert.ok(
+ await collapse.evaluate((button) => {
+ const code = button
+ .closest(".monaco-editor")
+ .querySelector(".monaco-scrollable-element");
+
+ return (
+ button.getBoundingClientRect().right <=
+ code.getBoundingClientRect().left
+ );
+ }),
+ "fold touch targets must stay outside code with line numbers on or off",
+ );
+
+ await collapse.tap();
+ await page.waitForTimeout(120);
+ await inViewport(
+ page
+ .locator(".summary-fold pre")
+ .filter({ hasText: "Collect every matching entry" }),
+ );
+ checks.folds++;
+
+ const scrollBefore = await page
+ .locator(".app-native-scroll")
+ .evaluate((n) => n.scrollTop);
+
+ await swipe(
+ width - 60,
+ Math.min(540, height - 60),
+ 0,
+ -Math.min(260, height - 200),
+ );
+ assert.ok(
+ (await page.locator(".app-native-scroll").evaluate((n) => n.scrollTop)) >
+ scrollBefore + 80,
+ "vertical scroll must continue after horizontal pan",
+ );
+
+ await tap("Open file tree");
+ await page.waitForTimeout(260);
+ await tap("Show file search"); // reset search for the next iteration
+
+ if (iteration % 3 === 0) await tap("Close file tree");
+ else if (iteration % 3 === 1) await page.keyboard.press("Escape");
+ else await page.touchscreen.tap(10, 2);
+ await page.waitForTimeout(300);
+ assert.equal(await page.locator(".app-mobile-files").isVisible(), false);
+ checks.sheets++;
+ assert.deepEqual(errors, []);
+ iteration++;
+
+ if (iteration % 5 === 0)
+ console.log(
+ JSON.stringify({
+ engine: engineName,
+ elapsed: Math.round((Date.now() - start) / 1000),
+ iteration,
+ checks,
+ }),
+ );
+ }
+
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await tap("Open file tree");
+ assert.equal(
+ await page
+ .locator(".app-mobile-files")
+ .evaluate((n) => n.getAnimations().length),
+ 0,
+ );
+ await tap("Close file tree");
+ assert.equal(await page.locator(".app-mobile-files").isVisible(), false);
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.waitForTimeout(200);
+ assert.equal(await page.locator(".app-native-scroll.is-native").count(), 0);
+
+ const timing = await page.evaluate(() => {
+ window.stressSampling = false;
+ const f = window.stressFrames.sort((a, b) => a - b);
+
+ return {
+ frames: f.length,
+ p95: f[Math.floor(f.length * 0.95)],
+ over50ms: f.filter((n) => n > 50).length,
+ max: f.at(-1),
+ };
+ });
+
+ console.log(
+ JSON.stringify({
+ result: "PASS",
+ engine: engineName,
+ elapsed: Math.round((Date.now() - start) / 1000),
+ checks,
+ timing,
+ errors,
+ }),
+ );
+} catch (error) {
+ await page.screenshot({
+ path: `/private/tmp/diffr-stress-failure-${engineName}.png`,
+ });
+ throw error;
+} finally {
+ await browser.close();
+}
diff --git a/apps/diffr-web/scripts/onboarding.mjs b/apps/diffr-web/scripts/onboarding.mjs
new file mode 100644
index 000000000..f11b74fd4
--- /dev/null
+++ b/apps/diffr-web/scripts/onboarding.mjs
@@ -0,0 +1,111 @@
+/** Against Vite: node apps/diffr-web/scripts/onboarding.mjs */
+import assert from "node:assert/strict";
+
+import { chromium, webkit } from "playwright";
+
+const origin = process.env.DIFFR_TEST_URL ?? "http://127.0.0.1:4181";
+
+const harness = `
`;
+
+for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch();
+
+ try {
+ for (const viewport of [
+ { width: 320, height: 640 },
+ { width: 390, height: 844 },
+ { width: 1280, height: 900 },
+ ]) {
+ const page = await browser.newPage({ viewport });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.route("**/__onboarding-test", (route) =>
+ route.fulfill({ contentType: "text/html", body: harness }),
+ );
+ await page.goto(`${origin}/__onboarding-test`);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Continue without token").waitFor();
+ assert.notEqual(
+ await page.evaluate(() => document.activeElement.tagName),
+ "INPUT",
+ );
+ await button("Continue without token").click();
+ assert.equal(await button("Not now").isVisible(), false);
+ await page.getByLabel(/^API key/).fill("test-only-key");
+ await page.getByLabel(/^Model/).fill("test-model");
+ await button("Back").click();
+ await button("Continue without token").click();
+ assert.equal(
+ await page.getByLabel(/^API key/).inputValue(),
+ "test-only-key",
+ );
+ await page.getByLabel(/^Provider/).selectOption("openai");
+ assert.equal(await page.getByLabel(/^API key/).inputValue(), "");
+ assert.equal(await page.getByLabel(/^Model/).inputValue(), "");
+ await button("Start reviewing").click();
+ await page.waitForFunction(() => !document.querySelector("dialog"));
+ assert.equal(
+ await page.evaluate(() => localStorage.getItem("diffr.onboarded")),
+ "1",
+ );
+ assert.equal(
+ await page.evaluate(() => localStorage.getItem("diffr.summaries")),
+ null,
+ );
+ await page.reload();
+ await page.getByLabel(/^GitHub token/).fill("test-only-token");
+ await button("Save token and continue").click();
+ assert.equal(
+ await page.evaluate(() => localStorage.getItem("diffr.githubToken")),
+ "test-only-token",
+ );
+ assert.equal(await page.evaluate(() => calls.reload), 1);
+ await page.getByLabel(/^API key/).fill("test-only-key");
+ await button("Not now").click();
+ await page.waitForFunction(() => !document.querySelector("dialog"));
+ assert.equal(
+ await page.evaluate(() => localStorage.getItem("diffr.summaries")),
+ null,
+ );
+ // A rejected configuration must leave credentials unsaved and permit recovery.
+ await page.evaluate(() =>
+ localStorage.setItem("diffr.config", "[broken"),
+ );
+ await page.reload();
+ await button("Save token and continue").click();
+ await page.getByLabel(/^API key/).fill("test-only-key");
+ await button("Enable summaries").click();
+ await page.waitForFunction(
+ () =>
+ document.querySelector('[role="status"]').textContent !== "Checking…",
+ );
+ assert.equal(
+ await page.evaluate(() => localStorage.getItem("diffr.summaries")),
+ null,
+ );
+ assert.equal(await button("Back").isEnabled(), true);
+ await page.evaluate(() => localStorage.removeItem("diffr.config"));
+ await button("Enable summaries").click();
+ await page.waitForFunction(() => !document.querySelector("dialog"));
+ assert.equal(
+ await page.evaluate(
+ () => JSON.parse(localStorage.getItem("diffr.summaries")).apiKey,
+ ),
+ "test-only-key",
+ );
+ assert.equal(await page.evaluate(() => calls.replace), 1);
+ assert.deepEqual(errors, []);
+ console.log(
+ `${name} ${viewport.width}px: skip, back, provider change, save, failure recovery passed`,
+ );
+ await page.close();
+ }
+ } finally {
+ await browser.close();
+ }
+}
diff --git a/apps/diffr-web/src/agent.ts b/apps/diffr-web/src/agent.ts
new file mode 100644
index 000000000..8f845d160
--- /dev/null
+++ b/apps/diffr-web/src/agent.ts
@@ -0,0 +1,577 @@
+/**
+ * The page's tools for an agent in the reader's browser, through WebMCP (`document.modelContext`):
+ * read the comparison, move around it, group it into lenses, and write plugins that fold what
+ * diffr's own leave open. main.ts loads this only where the browser has WebMCP.
+ */
+import * as z from "zod/mini";
+
+import type { Comparison } from "./comparison.js";
+import { Engine } from "./engine/engine.js";
+import { type Lens, lenses, setLenses } from "./lenses.js";
+import {
+ type StructuralRegion,
+ regionLines,
+} from "./review/common/reviewStructuralDiff.js";
+import {
+ type AgentPlugin,
+ agentPlugins,
+ configOverrides,
+ readSetting,
+ setAgentPlugins,
+} from "./settings.js";
+
+interface ToolResult {
+ content: { type: "text"; text: string }[];
+ isError?: boolean;
+}
+
+/** What an agent sends a tool: JSON. */
+type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
+
+/** A tool as WebMCP takes it. */
+interface RegisteredTool {
+ name: string;
+ description: string;
+ inputSchema: object;
+ annotations: { readOnlyHint: boolean };
+ execute(input: Json | undefined): Promise;
+}
+
+interface ModelContext {
+ /** Chrome's, and the current draft's. */
+ registerTool?(tool: RegisteredTool): Promise | void;
+ /** Earlier drafts', which the current one dropped. */
+ provideContext?(context: { tools: RegisteredTool[] }): void;
+}
+
+declare global {
+ interface Document {
+ modelContext?: ModelContext;
+ }
+
+ interface Navigator {
+ /** Where Chrome first put it, kept for a while as an alias. */
+ modelContext?: ModelContext;
+ }
+}
+
+export interface AgentHost {
+ comparison(): Comparison | undefined;
+ /** Diff with `engine` from now on, and load the page again. */
+ replaceEngine(engine: Engine): void;
+ /** The comparison's lenses or the plugins changed. */
+ changed(): void;
+}
+
+interface Tool {
+ name: string;
+ description: string;
+ input: Input;
+ readOnly?: boolean;
+ /** A string answers as is; anything else as JSON. */
+ run(input: z.infer): Promise;
+}
+
+const tool = (definition: Tool) =>
+ definition;
+
+/** Text past this is cut, so one call cannot fill an agent's context. */
+const MAX_TEXT = 200_000;
+
+const MAX_MATCHES = 500;
+
+const described = (schema: T, description: string) =>
+ schema.check(z.describe(description));
+
+const PATH = described(
+ z.string(),
+ "The file's path, as diffr_get_comparison lists it.",
+);
+
+const SIDE = z.enum(["base", "head"]);
+
+const LINE = z.int().check(z.minimum(1));
+
+const RANGES = described(
+ z.array(z.object({ side: SIDE, from: LINE, to: LINE })),
+ "Lines to show, from 1, both ends included; `base` is the old side, `head` the new. Leave it out to show the whole file.",
+);
+
+const PLUGIN_CONTRACT = `A plugin is the source of one JavaScript function, \`(file) => void\`, run in a Web Worker on every diffed text file that diffr does not hide, after diffr's own plugins. \`file\` is { path, status: "added" | "modified" | "deleted", tags: string[], lhs?: Side, rhs?: Side }: lhs is the base (old) side, rhs the head (new) side. Side is { text, root: Region }. Region is { kind: "fold" | "leaf", id, fold_state_id: number, start: { line, column }, end: { line, column } (from 0; an end at column 0 stops before its line), tags?: string[], visibility?: { collapsed: boolean, label: string }, children: Region[] (folds only), changed?: spans (leaves only, the edited parts) }. The plugin folds by setting a region's \`visibility\`, e.g. region.visibility = { collapsed: true, label: "Logging" }; nothing else it changes is kept, and a fold state changed on either side changes on both. Call diffr_get_file to see real regions first. A plugin that throws is reported and skipped for that file.`;
+
+/** diffr's folds in one side of a file, as a flat list. */
+function folds(
+ path: string,
+ comparison: Comparison,
+ root: StructuralRegion | undefined,
+ side: "base" | "head",
+) {
+ const out: object[] = [];
+
+ const walk = (region: StructuralRegion, depth: number) => {
+ const { start, end } = regionLines(region);
+
+ if (
+ region.kind === "fold" ||
+ region.visibility?.label ||
+ region.visibility?.collapsed
+ )
+ out.push({
+ id: region.fold_state_id,
+ side,
+ kind: region.kind,
+ lines: [start + 1, Math.max(end, start + 1)],
+ depth,
+ tags: region.tags,
+ label: region.visibility?.label || undefined,
+ collapsed:
+ comparison.isRegionCollapsed(path, region.fold_state_id) ??
+ !!region.visibility?.collapsed,
+ changed:
+ region.kind === "leaf" && region.changed?.length ? true : undefined,
+ });
+
+ if (region.kind === "fold")
+ region.children.forEach((child) => walk(child, depth + 1));
+ };
+
+ if (root?.kind === "fold") root.children.forEach((child) => walk(child, 0));
+
+ return out;
+}
+
+/** Line ranges, from 1 with both ends included, of what changed on one side. */
+const changedLines = (ranges: readonly (readonly [number, number])[]) =>
+ ranges.map(([start, end]) => [start + 1, Math.max(end, start + 1)]);
+
+const cut = (value: string | undefined) =>
+ value === undefined || value.length <= MAX_TEXT
+ ? value
+ : `${value.slice(0, MAX_TEXT)}\n[cut: ${value.length - MAX_TEXT} more characters]`;
+
+const slug = (title: string) =>
+ title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "") || `lens-${Date.now()}`;
+
+export function tools(host: AgentHost) {
+ const comparison = () => {
+ const current = host.comparison();
+
+ if (!current?.change)
+ throw new Error("No comparison is open, or it is still loading");
+
+ return current;
+ };
+
+ /** Save the plugins, try them in a fresh engine, and report what it says of them. */
+ const usePlugins = async (next: AgentPlugin[]) => {
+ const previous = agentPlugins();
+ setAgentPlugins(next);
+ const engine = new Engine(readSetting("config"), configOverrides());
+
+ try {
+ const notices = await engine.notices;
+ host.replaceEngine(engine);
+ host.changed();
+
+ return notices;
+ } catch (error) {
+ engine.dispose();
+ setAgentPlugins(previous);
+ throw error;
+ }
+ };
+
+ return [
+ tool({
+ name: "diffr_get_comparison",
+ description:
+ "The pull request or comparison open in diffr: its title, URL, base and head commits, and every changed file in the order the page lists them, with whether diffr hides it (generated, vendored, lockfiles) and whether it is diffed yet. Also the comparison's lenses and the one shown.",
+ input: z.object({}),
+ readOnly: true,
+ async run() {
+ const current = comparison();
+ const change = current.change!;
+ const stats = new Map(change.files.map((file) => [file.path, file]));
+
+ return {
+ title: current.title,
+ url: current.url,
+ base: change.base,
+ head: change.head,
+ files: current.fileList.map((file) => ({
+ ...file,
+ additions: stats.get(file.path)?.additions,
+ deletions: stats.get(file.path)?.deletions,
+ })),
+ lenses: lenses(current).map(({ id, title, description, files }) => ({
+ id,
+ title,
+ description,
+ files: files.length,
+ })),
+ shownLens: current.activeLens?.id,
+ };
+ },
+ }),
+ tool({
+ name: "diffr_get_file",
+ description:
+ "One changed file, diffed first if it is still waiting: the lines that changed on each side, diffr's folds (ids, line ranges, labels, whether collapsed), which diffr_set_folds and plugins act on, and optionally each side's text. Line numbers count from 1.",
+ input: z.object({
+ path: PATH,
+ text: z.optional(
+ described(z.boolean(), "Include each side's whole text."),
+ ),
+ folds: z.optional(
+ described(z.boolean(), "Include diffr's folds. Default true."),
+ ),
+ }),
+ readOnly: true,
+ async run(input) {
+ const current = comparison();
+ await current.whenDiffed(input.path);
+ const result = current.fileResult(input.path);
+
+ const listed = current.fileList.find(
+ (file) => file.path === input.path,
+ );
+
+ if (!result?.diff)
+ return { ...listed, error: result?.error ?? "Not diffed" };
+
+ if (result.diff.type !== "text") return { ...listed, binary: true };
+ const diff = result.diff;
+
+ return {
+ ...listed,
+ changed: {
+ base: changedLines(diff.structural_changes.base),
+ head: changedLines(diff.structural_changes.head),
+ },
+ fallback: diff.stats.fallback?.message,
+ folds:
+ input.folds === false
+ ? undefined
+ : [
+ ...folds(input.path, current, diff.lhs?.root, "base"),
+ ...folds(input.path, current, diff.rhs?.root, "head"),
+ ],
+ base: input.text ? cut(diff.lhs?.text) : undefined,
+ head: input.text ? cut(diff.rhs?.text) : undefined,
+ };
+ },
+ }),
+ tool({
+ name: "diffr_search",
+ description:
+ "Find text in every diffed file: the whole head side, and the base side's removed lines. Files still waiting are not searched; diffr_get_comparison says which.",
+ input: z.object({
+ query: described(
+ z.string(),
+ "Text, or a JavaScript regular expression when regex is true.",
+ ),
+ regex: z.optional(z.boolean()),
+ matchCase: z.optional(z.boolean()),
+ }),
+ readOnly: true,
+ async run(input) {
+ const current = comparison();
+
+ const pattern = new RegExp(
+ input.regex
+ ? input.query
+ : input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ input.matchCase ? "u" : "iu",
+ );
+
+ const matches: object[] = [];
+
+ outer: for (const file of current.texts()) {
+ const removed = current.removedLines(file.path);
+
+ for (const [side, source] of [
+ ["head", file.modified],
+ ["base", file.original],
+ ] as const)
+ for (const [index, line] of (source ?? "").split("\n").entries()) {
+ if ((side === "base" && !removed(index)) || !pattern.test(line))
+ continue;
+ matches.push({
+ path: file.path,
+ side,
+ line: index + 1,
+ text: line.slice(0, 300),
+ });
+
+ if (matches.length >= MAX_MATCHES) break outer;
+ }
+ }
+
+ return { matches, truncated: matches.length >= MAX_MATCHES };
+ },
+ }),
+ tool({
+ name: "diffr_reveal",
+ description:
+ "Scroll the reader to a file, or to a line of one side of it, opening the folds around it.",
+ input: z.object({
+ path: PATH,
+ side: z.optional(described(SIDE, "Default head.")),
+ line: z.optional(LINE),
+ }),
+ async run(input) {
+ const current = comparison();
+
+ if (input.line === undefined) {
+ current.openFile(input.path);
+
+ return `Showing ${input.path}`;
+ }
+
+ await current.whenDiffed(input.path);
+ current.revealLine(
+ input.path,
+ input.side === "base" ? "original" : "modified",
+ input.line,
+ );
+
+ return `Showing ${input.path}:${input.line}`;
+ },
+ }),
+ tool({
+ name: "diffr_set_folds",
+ description:
+ "Fold or unfold diffr's regions in a file, by the ids diffr_get_file lists. For the reader's view only, until the page reloads; a plugin makes it last.",
+ input: z.object({
+ path: PATH,
+ ids: z.array(z.int()),
+ collapsed: z.boolean(),
+ }),
+ async run(input) {
+ const current = comparison();
+ await current.whenDiffed(input.path);
+
+ for (const id of input.ids)
+ current.setRegionCollapsed(input.path, id, input.collapsed);
+
+ return `${input.collapsed ? "Folded" : "Unfolded"} ${input.ids.length} region${input.ids.length === 1 ? "" : "s"}`;
+ },
+ }),
+ tool({
+ name: "diffr_create_lens",
+ description:
+ "Save a lens: a named part of this comparison for the reader to read on its own, such as one layer of the design, the tests, or a refactor's mechanical edits. It is listed over the diff, kept in this browser for this comparison, and shown at once unless show is false. Showing it folds every other file, and in each of its files that gives ranges, every fold that holds none of them. Saving with an existing id replaces that lens.",
+ input: z.object({
+ id: z.optional(
+ described(
+ z.string(),
+ "A short slug; made from the title if left out.",
+ ),
+ ),
+ title: described(z.string(), "A few words, shown on its button."),
+ description: z.optional(
+ described(
+ z.string(),
+ "One sentence on what the reader will find in it.",
+ ),
+ ),
+ files: z.array(z.object({ path: PATH, ranges: z.optional(RANGES) })),
+ show: z.optional(z.boolean()),
+ }),
+ async run(input) {
+ const current = comparison();
+
+ const paths = new Set(
+ current.fileList.flatMap((file) => [
+ file.path,
+ file.previousPath ?? file.path,
+ ]),
+ );
+
+ const unknown = input.files
+ .filter((file) => !paths.has(file.path))
+ .map((file) => file.path);
+
+ if (unknown.length)
+ throw new Error(
+ `Not changed in this comparison: ${unknown.join(", ")}`,
+ );
+ const id = input.id || slug(input.title);
+
+ const lens: Lens = {
+ id,
+ title: input.title,
+ description: input.description,
+ files: input.files.map(({ path, ranges }) => ({
+ path,
+ ranges: ranges?.length ? ranges : undefined,
+ })),
+ };
+
+ const others = lenses(current).filter((other) => other.id !== id);
+ setLenses(current, [...others, lens]);
+ const show = input.show !== false || current.activeLens?.id === id;
+
+ if (show) current.showLens(lens);
+ host.changed();
+
+ return `Saved the lens ${id}${show ? " and showed it" : ""}`;
+ },
+ }),
+ tool({
+ name: "diffr_show_lens",
+ description:
+ "Show one of the comparison's lenses by id, or every file again when id is left out.",
+ input: z.object({ id: z.optional(z.string()) }),
+ async run(input) {
+ const current = comparison();
+ const lens = lenses(current).find((lens) => lens.id === input.id);
+
+ if (input.id !== undefined && !lens)
+ throw new Error(`No lens ${input.id}`);
+ current.showLens(lens);
+ host.changed();
+
+ return lens ? `Showing ${lens.title}` : "Showing every file";
+ },
+ }),
+ tool({
+ name: "diffr_delete_lens",
+ description: "Delete one of the comparison's lenses by id.",
+ input: z.object({ id: z.string() }),
+ async run(input) {
+ const current = comparison();
+ const all = lenses(current);
+ const rest = all.filter((lens) => lens.id !== input.id);
+
+ if (rest.length === all.length) throw new Error(`No lens ${input.id}`);
+ setLenses(current, rest);
+
+ if (current.activeLens?.id === input.id) current.showLens(undefined);
+ host.changed();
+
+ return `Deleted the lens ${input.id}`;
+ },
+ }),
+ tool({
+ name: "diffr_list_plugins",
+ description: `The plugins agents have installed on this page, with their code. ${PLUGIN_CONTRACT}`,
+ input: z.object({}),
+ readOnly: true,
+ run: async () => agentPlugins(),
+ }),
+ tool({
+ name: "diffr_install_plugin",
+ description: `Install a plugin, or replace the one with the same name. It is kept in this browser and runs on every comparison from now on; the open one is diffed again with it. ${PLUGIN_CONTRACT} Returns diffr's notices: one says if the code did not compile.`,
+ input: z.object({
+ name: described(z.string(), "A short name."),
+ description: described(
+ z.string(),
+ "One sentence on what it folds, shown in the page's settings.",
+ ),
+ code: described(
+ z.string(),
+ "The function's source, such as `(file) => { … }`.",
+ ),
+ }),
+ async run(input) {
+ const notices = await usePlugins([
+ ...agentPlugins().filter((other) => other.name !== input.name),
+ { ...input, enabled: true },
+ ]);
+
+ return { installed: input.name, notices };
+ },
+ }),
+ tool({
+ name: "diffr_remove_plugin",
+ description:
+ "Remove an installed plugin by name, or keep it and turn it on or off with enabled.",
+ input: z.object({
+ name: z.string(),
+ enabled: z.optional(
+ described(
+ z.boolean(),
+ "Keep the plugin, on or off, instead of removing it.",
+ ),
+ ),
+ }),
+ async run(input) {
+ const all = agentPlugins();
+
+ if (!all.some((plugin) => plugin.name === input.name))
+ throw new Error(`No plugin ${input.name}`);
+
+ const { enabled } = input;
+
+ await usePlugins(
+ enabled === undefined
+ ? all.filter((plugin) => plugin.name !== input.name)
+ : all.map((plugin) =>
+ plugin.name === input.name ? { ...plugin, enabled } : plugin,
+ ),
+ );
+
+ return enabled === undefined
+ ? `Removed ${input.name}`
+ : `Turned ${input.name} ${enabled ? "on" : "off"}`;
+ },
+ }),
+ ];
+}
+
+const answer = (text: string, isError = false): ToolResult => ({
+ content: [{ type: "text", text }],
+ isError,
+});
+
+/** Offer the tools to the browser's agent. */
+export function registerAgentTools(host: AgentHost): void {
+ const context = document.modelContext ?? navigator.modelContext;
+
+ if (!context) return;
+ // zod/mini says only "Invalid input" until given its messages.
+ z.config(z.locales.en());
+
+ // A tool that fails answers with why, so the agent can correct its call.
+ const registered = tools(host).map(
+ ({ name, description, input, readOnly, run }): RegisteredTool => ({
+ name,
+ description,
+ inputSchema: z.toJSONSchema(input),
+ annotations: { readOnlyHint: !!readOnly },
+ async execute(raw) {
+ const parsed = z.safeParse(input, raw ?? {});
+
+ if (!parsed.success) return answer(z.prettifyError(parsed.error), true);
+
+ try {
+ const result = await run(parsed.data);
+
+ return answer(
+ result instanceof Object ? JSON.stringify(result) : String(result),
+ );
+ } catch (error) {
+ return answer(
+ error instanceof Error ? error.message : String(error),
+ true,
+ );
+ }
+ },
+ }),
+ );
+
+ if (!context.registerTool) {
+ context.provideContext?.({ tools: registered });
+
+ return;
+ }
+
+ for (const tool of registered)
+ Promise.resolve(context.registerTool(tool)).catch((error: Error) =>
+ console.error(`WebMCP refused the tool ${tool.name}: ${error.message}`),
+ );
+}
diff --git a/apps/diffr-web/src/cache.ts b/apps/diffr-web/src/cache.ts
new file mode 100644
index 000000000..0b974615a
--- /dev/null
+++ b/apps/diffr-web/src/cache.ts
@@ -0,0 +1,201 @@
+/**
+ * diffr's results kept in this browser (IndexedDB), so opening a comparison again shows its files
+ * without fetching, diffing or summarizing them again. Each entry is named by everything its result
+ * depends on (the commits, the file, the engine build and its configuration), so nothing in it goes
+ * stale; past a limit the entries read longest ago go first.
+ */
+import type { Diffed, FileEvent } from "./engine/engine.js";
+
+/** What is kept: a file's diff, or its summarized record. */
+export type CachedResult = Omit | FileEvent;
+
+const DB = "diffr-results";
+
+const ENTRIES = "entries";
+
+/** Per entry, its size and when it was last read, so totals and eviction need not load results. */
+const META = "meta";
+
+const LIMIT = 512 * 1024 * 1024;
+
+interface Meta {
+ bytes: number;
+ at: number;
+}
+
+export interface CacheUsage {
+ entries: number;
+ /** About this many bytes: results are stored as JSON, counted in characters. */
+ bytes: number;
+}
+
+const usage: CacheUsage = { entries: 0, bytes: 0 };
+
+const listeners = new Set<() => void>();
+
+const changed = () => listeners.forEach((listener) => listener());
+
+export function cacheUsage(): Readonly {
+ return usage;
+}
+
+export function onCacheChange(listener: () => void): () => void {
+ listeners.add(listener);
+
+ return () => listeners.delete(listener);
+}
+
+const done = (request: IDBRequest) =>
+ new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB"));
+ });
+
+const committed = (transaction: IDBTransaction) =>
+ new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve();
+ transaction.onabort = transaction.onerror = () =>
+ reject(transaction.error ?? new Error("IndexedDB"));
+ });
+
+let opened: Promise | undefined;
+
+/** The database, with the totals read; none where the browser keeps no storage (private windows). */
+function database(): Promise {
+ return (opened ??= (async () => {
+ try {
+ const request = indexedDB.open(DB, 1);
+ request.onupgradeneeded = () => {
+ request.result.createObjectStore(ENTRIES);
+ request.result.createObjectStore(META);
+ };
+
+ const db = await done(request);
+
+ // SAFETY: put writes only Meta to META.
+ const metas = (await done(
+ db.transaction(META).objectStore(META).getAll(),
+ )) as Meta[];
+
+ usage.entries = metas.length;
+ usage.bytes = metas.reduce((sum, meta) => sum + meta.bytes, 0);
+ changed();
+
+ return db;
+ } catch {
+ return undefined;
+ }
+ })());
+}
+
+/** Read the totals, for a page that shows them before anything is cached. */
+export function loadCacheUsage(): void {
+ void database();
+}
+
+/** Name an entry by what its result depends on. */
+export async function cacheKey(parts: unknown[]): Promise {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(JSON.stringify(parts)),
+ );
+
+ return [...new Uint8Array(digest)]
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+export async function cached(
+ key: string,
+): Promise {
+ const db = await database();
+
+ if (!db) return undefined;
+
+ try {
+ // SAFETY: cache writes only JSON strings to ENTRIES.
+ const json = (await done(
+ db.transaction(ENTRIES).objectStore(ENTRIES).get(key),
+ )) as string | undefined;
+
+ if (json === undefined) return undefined;
+ const transaction = db.transaction(META, "readwrite");
+ const meta = transaction.objectStore(META);
+ // SAFETY: put writes only Meta to META.
+ const entry = (await done(meta.get(key))) as Meta | undefined;
+
+ if (entry) meta.put({ ...entry, at: Date.now() } satisfies Meta, key);
+
+ // SAFETY: put wrote it from a T under this key.
+ return JSON.parse(json) as T;
+ } catch {
+ return undefined;
+ }
+}
+
+export async function cache(key: string, value: CachedResult): Promise {
+ const db = await database();
+
+ if (!db) return;
+ const json = JSON.stringify(value);
+
+ // One result this large would push out everything else.
+ if (json.length > LIMIT / 8) return;
+
+ try {
+ const transaction = db.transaction([ENTRIES, META], "readwrite");
+ const meta = transaction.objectStore(META);
+ // SAFETY: put writes only Meta to META.
+ const previous = (await done(meta.get(key))) as Meta | undefined;
+ transaction.objectStore(ENTRIES).put(json, key);
+ meta.put({ bytes: json.length, at: Date.now() } satisfies Meta, key);
+ await committed(transaction);
+ usage.entries += previous ? 0 : 1;
+ usage.bytes += json.length - (previous?.bytes ?? 0);
+ } catch {
+ // Out of quota, most likely: the result is still shown, just not kept.
+ return;
+ }
+
+ if (usage.bytes > LIMIT) await evict(db);
+ changed();
+}
+
+/** Drop the entries read longest ago until the rest fit in three quarters of the limit. */
+async function evict(db: IDBDatabase): Promise {
+ const keys = await done(db.transaction(META).objectStore(META).getAllKeys());
+
+ // SAFETY: put writes only Meta to META, in the same order getAllKeys reads the keys.
+ const metas = (await done(
+ db.transaction(META).objectStore(META).getAll(),
+ )) as Meta[];
+
+ const oldest = keys
+ .map((key, index) => ({ key, ...metas[index]! }))
+ .sort((a, b) => a.at - b.at);
+
+ const transaction = db.transaction([ENTRIES, META], "readwrite");
+
+ for (const { key, bytes } of oldest) {
+ if (usage.bytes <= LIMIT * 0.75) break;
+ transaction.objectStore(ENTRIES).delete(key);
+ transaction.objectStore(META).delete(key);
+ usage.entries--;
+ usage.bytes -= bytes;
+ }
+
+ await committed(transaction);
+}
+
+export async function clearCache(): Promise {
+ const db = await database();
+
+ if (!db) return;
+ const transaction = db.transaction([ENTRIES, META], "readwrite");
+ transaction.objectStore(ENTRIES).clear();
+ transaction.objectStore(META).clear();
+ await committed(transaction);
+ usage.entries = 0;
+ usage.bytes = 0;
+ changed();
+}
diff --git a/apps/diffr-web/src/cacheSection.ts b/apps/diffr-web/src/cacheSection.ts
new file mode 100644
index 000000000..134bcc5dc
--- /dev/null
+++ b/apps/diffr-web/src/cacheSection.ts
@@ -0,0 +1,44 @@
+/** The settings' account of what this browser keeps (cache.ts), and a button to drop it. */
+import {
+ cacheUsage,
+ clearCache,
+ loadCacheUsage,
+ onCacheChange,
+} from "./cache.js";
+import { actionButton, element } from "./ui.js";
+
+export const megabytes = (bytes: number) =>
+ `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
+
+export function cacheSection(parent: HTMLElement): void {
+ const section = parent.appendChild(element("section"));
+ section.appendChild(element("h3", undefined, "Cache"));
+ section.appendChild(
+ element(
+ "p",
+ undefined,
+ "diffr keeps each file's diff and summaries in this browser, so opening a comparison again shows them without fetching, diffing or asking the model again. Past 512 MB, the results read longest ago go first.",
+ ),
+ );
+ const row = section.appendChild(element("div", "app-cache"));
+ const usage = row.appendChild(element("span"));
+ row.appendChild(element("span", "app-spacer"));
+
+ const clear = row.appendChild(
+ actionButton("Clear cache", () => {
+ clear.disabled = true;
+ void clearCache().finally(() => (clear.disabled = false));
+ }),
+ );
+
+ const render = () => {
+ if (!usage.isConnected) return stop();
+ const { entries, bytes } = cacheUsage();
+ usage.textContent = `${entries.toLocaleString("en")} result${entries === 1 ? "" : "s"}, ${megabytes(bytes)}`;
+ clear.disabled = !entries;
+ };
+
+ const stop = onCacheChange(render);
+ loadCacheUsage();
+ render();
+}
diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts
index 047e3a95c..5a562f246 100644
--- a/apps/diffr-web/src/comparison.ts
+++ b/apps/diffr-web/src/comparison.ts
@@ -10,7 +10,14 @@ 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 { cache, cacheKey, cached } from "./cache.js";
+import type {
+ Classified,
+ Diffed,
+ Engine,
+ FileEvent,
+ FileRequest,
+} from "./engine/engine.js";
import { foldIds, gapIds } from "./folds.js";
import {
type Change,
@@ -21,10 +28,13 @@ import {
loadChange,
targetPath,
} from "./github.js";
+import type { Lens } from "./lenses.js";
import type { ReviewDiffFileWire, StructuralLineCounts } from "./protocol.js";
import { orderReviewDiffFiles } from "./review/common/reviewChangedFilesModel.js";
import {
+ type StructuralRegion,
type StructuralTextDiff,
+ regionLines,
reviewFileCounts,
} from "./review/common/reviewStructuralDiff.js";
import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js";
@@ -43,6 +53,9 @@ import { ViewedProgress } from "./viewed.js";
/** Files fetched or diffed at once. The engine spreads them over its workers. */
const IN_FLIGHT = 12;
+/** A lens closes a fold outside its ranges only from this many lines. */
+const MIN_LENS_FOLD = 3;
+
/** Milliseconds from page start, for the engine panel. */
export interface Timing {
listed?: number;
@@ -55,11 +68,19 @@ export interface Work {
fetching: number;
diffing: number;
diffed: number;
+ /** Of those, the files whose results this browser had kept (cache.ts). */
+ cached: number;
hidden: number;
failed: number;
/** Total diffr time, and the file that took longest. */
diffMs: number;
slowest?: { path: string; ms: number };
+ /** Files whose summaries are being written, are in, or failed, and why. */
+ summarizing: number;
+ summarized: number;
+ summaryErrors: { path: string; message: string }[];
+ /** Agent plugins that threw, by file. */
+ pluginErrors: { path: string; message: string }[];
}
interface File {
@@ -78,9 +99,14 @@ export class Comparison extends Disposable {
fetching: 0,
diffing: 0,
diffed: 0,
+ cached: 0,
hidden: 0,
failed: 0,
diffMs: 0,
+ summarizing: 0,
+ summarized: 0,
+ summaryErrors: [],
+ pluginErrors: [],
};
preview: Preview | undefined;
change: Change | undefined;
@@ -92,6 +118,10 @@ export class Comparison extends Disposable {
private viewed: ViewedProgress | undefined;
/** Files the reader asked for, most recent first. */
private requested: string[] = [];
+ private lens: Lens | undefined;
+ /** What the lens changed, to put back: folds by `path\0id`, and files by path. */
+ private readonly lensFolds = new Map();
+ private readonly lensFiles = new Map();
constructor(
private readonly container: HTMLElement,
@@ -158,6 +188,247 @@ export class Comparison extends Disposable {
this.view?.focus();
}
+ /** Every changed file, in the list's order, and whether diffr has it yet. */
+ get fileList(): {
+ path: string;
+ previousPath?: string;
+ status: ChangedFile["status"];
+ hidden?: string;
+ diffed: boolean;
+ additions: number;
+ deletions: number;
+ binary: boolean;
+ }[] {
+ return this.order.map((path) => {
+ const file = this.files.get(path)!;
+ const diff = this.session?.getFileResult(path)?.diff;
+ const counts = diff ? reviewFileCounts(diff) : undefined;
+
+ return {
+ path,
+ previousPath: file.changed.previousPath,
+ status: file.changed.status,
+ hidden: file.classified?.hidden,
+ diffed: file.state === "done",
+ additions:
+ counts && !("binary" in counts)
+ ? counts.added
+ : file.changed.additions,
+ deletions:
+ counts && !("binary" in counts)
+ ? counts.removed
+ : file.changed.deletions,
+ binary: diff?.type === "binary",
+ };
+ });
+ }
+
+ /** Scroll to a file and show it, diffing it first if it is still waiting. */
+ openFile(path: string): void {
+ if (!this.files.has(path)) return;
+ this.requested = [path, ...this.requested.filter((p) => p !== path)];
+ this.pump();
+ this.view?.revealFile(path, true);
+ }
+
+ /** Resolves once a file is diffed, or failed, asking for it first if it waits. */
+ async whenDiffed(path: string): Promise {
+ const file = this.files.get(path);
+
+ if (!file) throw new Error(`${path} is not a changed file`);
+
+ if (file.state === "done") return;
+
+ if (file.state === "waiting") {
+ this.requested = [path, ...this.requested.filter((p) => p !== path)];
+ this.pump();
+ }
+
+ await new Promise((resolve, reject) => {
+ const listener = this.onDidChange(() => {
+ if (file.state !== "done" && !this._store.isDisposed) return;
+ listener.dispose();
+
+ if (this._store.isDisposed) reject(new Error("The comparison closed"));
+ else resolve();
+ });
+ });
+ }
+
+ /** A diffed file's result: its diff, or why it has none. */
+ fileResult(path: string) {
+ return this.session?.getFileResult(path);
+ }
+
+ isRegionCollapsed(path: string, id: number): boolean | undefined {
+ return this.session?.isRegionCollapsed(path, id);
+ }
+
+ setRegionCollapsed(path: string, id: number, collapsed: boolean): void {
+ this.session?.setRegionCollapsed(path, id, collapsed);
+ }
+
+ get activeLens(): Lens | undefined {
+ return this.lens;
+ }
+
+ /**
+ * Show only a lens: its files listed and open, every other file folded and left out of the tree,
+ * and in a file it gives line ranges for, the folds that hold none of them closed. `undefined`
+ * puts everything back as it was.
+ */
+ showLens(lens: Lens | undefined): void {
+ const session = this.session;
+
+ for (const [key, collapsed] of this.lensFolds) {
+ const at = key.lastIndexOf("\0");
+ session?.setRegionCollapsed(
+ key.slice(0, at),
+ Number(key.slice(at + 1)),
+ collapsed,
+ );
+ }
+
+ for (const [path, collapsed] of this.lensFiles)
+ this.view?.setFileCollapsed(path, collapsed);
+ this.lensFolds.clear();
+ this.lensFiles.clear();
+ this.lens = lens;
+
+ this.view?.listOnly(
+ lens
+ ? new Set(this.order.filter((path) => this.lensMember(path)))
+ : undefined,
+ );
+
+ if (lens) {
+ for (const path of this.order) this.applyLens(path);
+ const first = this.order.find((path) => this.lensMember(path));
+
+ if (first) this.openFile(first);
+ }
+
+ this.changed.fire();
+ }
+
+ private lensMember(path: string) {
+ const previous = this.files.get(path)?.changed.previousPath;
+
+ return this.lens?.files.find(
+ (file) => file.path === path || (!!previous && file.path === previous),
+ );
+ }
+
+ /** Apply the lens to one file, once listed and again once diffed. */
+ private applyLens(path: string): void {
+ const file = this.files.get(path);
+
+ if (!this.lens || !file) return;
+ const member = this.lensMember(path);
+
+ const hidden = !!(
+ file.classified?.hidden ?? this.session?.getFileResult(path)?.hidden
+ );
+
+ if (!this.lensFiles.has(path)) this.lensFiles.set(path, hidden);
+ this.view?.setFileCollapsed(path, !member);
+ const session = this.session;
+ const diff = session?.getTextDiff(path);
+
+ if (!member?.ranges?.length || !session || !diff) return;
+ const foldable = new Set(foldIds(diff));
+ // A fold state is shared by both sides: it opens if either side's region holds a range.
+ const open = new Map();
+
+ for (const [name, source] of [
+ ["base", diff.lhs],
+ ["head", diff.rhs],
+ ] as const) {
+ const ranges = member.ranges.filter((range) => range.side === name);
+
+ const visit = (region: StructuralRegion) => {
+ const { start, end } = regionLines(region);
+
+ // Ranges count from 1 and include their last line; regions count from 0 and exclude it.
+ const holds = ranges.some(
+ (range) =>
+ range.from - 1 < Math.max(end, start + 1) && range.to > start,
+ );
+
+ // Folds outside the ranges close; anything inside opens, a context gap included. Lines
+ // between folds, and folds too short to save a row, stay as they are.
+ if (
+ foldable.has(region.fold_state_id) &&
+ (holds || (region.kind === "fold" && end - start >= MIN_LENS_FOLD))
+ )
+ open.set(
+ region.fold_state_id,
+ (open.get(region.fold_state_id) ?? false) || holds,
+ );
+
+ if (holds && region.kind === "fold") region.children.forEach(visit);
+ };
+
+ if (source?.root.kind === "fold") source.root.children.forEach(visit);
+ }
+
+ for (const [id, opened] of open) {
+ const key = `${path}\0${id}`;
+
+ if (!this.lensFolds.has(key))
+ this.lensFolds.set(key, session.isRegionCollapsed(path, id) ?? false);
+ session.setRegionCollapsed(path, id, !opened);
+ }
+ }
+
+ /** Each diffed file's text, by side, for searching. */
+ texts(): { path: string; original?: string; modified?: string }[] {
+ return this.order.flatMap((path) => {
+ const diff = this.session?.getTextDiff(path);
+
+ return diff
+ ? [{ path, original: diff.lhs?.text, modified: diff.rhs?.text }]
+ : [];
+ });
+ }
+
+ /** Whether a 0-based base line of a diffed file was removed or changed. */
+ removedLines(path: string): (line: number) => boolean {
+ const ranges =
+ this.session?.getTextDiff(path)?.structural_changes.base ?? [];
+
+ return (line) => ranges.some(([start, end]) => line >= start && line < end);
+ }
+
+ /** Open every fold over one side's `line` (1-based), then scroll to it. */
+ revealLine(path: string, side: "original" | "modified", line: number): void {
+ const session = this.session;
+
+ const source =
+ session?.getTextDiff(path)?.[side === "original" ? "lhs" : "rhs"];
+
+ if (!session || !source) return;
+
+ const open = (region: StructuralRegion) => {
+ const { start, end } = regionLines(region);
+
+ if (line - 1 < start || line - 1 >= Math.max(end, start + 1)) return;
+
+ if (session.isRegionCollapsed(path, region.fold_state_id))
+ session.setRegionCollapsed(path, region.fold_state_id, false);
+
+ if (region.kind === "fold") region.children.forEach(open);
+ };
+
+ open(source.root);
+ this.view?.revealLine(path, side, line);
+ }
+
+ /** The editor showing one side of a file, while it is on screen. */
+ codeEditor(path: string, side: "original" | "modified") {
+ return this.view?.codeEditor(path, side);
+ }
+
goToChange(direction: "next" | "previous"): void {
this.view?.goToChange(direction);
}
@@ -180,6 +451,15 @@ export class Comparison extends Disposable {
}
/** Folds or unfolds everything diffr can fold, in every file. */
+ filesCollapsed = false;
+
+ toggleAllFiles(): void {
+ this.filesCollapsed = !this.filesCollapsed;
+
+ for (const path of this.order)
+ this.view?.setFileCollapsed(path, this.filesCollapsed);
+ }
+
foldAll(collapsed: boolean): void {
this.setFolds(foldIds, collapsed);
}
@@ -366,6 +646,25 @@ export class Comparison extends Disposable {
this.pump();
}
+ /** Names a file's result in the cache (cache.ts): the commits, the file, and the engine's build and configuration. */
+ private async cacheKey(
+ file: File,
+ kind: "diff" | "summary",
+ ): Promise {
+ const { changed } = file;
+
+ return cacheKey([
+ await this.engine.fingerprint,
+ targetPath(this.target),
+ this.change!.base,
+ this.change!.head,
+ changed.status,
+ changed.previousPath,
+ changed.path,
+ kind,
+ ]);
+ }
+
/** What diffr is told about a file: its sides, with their text once fetched. */
private request(
file: File,
@@ -451,31 +750,54 @@ export class Comparison extends Disposable {
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),
- ]);
+ const key = await this.cacheKey(file, "diff");
+ const hit = await cached>(key);
+
+ if (this._store.isDisposed) return;
+
+ const text =
+ hit?.event.diff?.type === "text"
+ ? { lhs: hit.event.diff.lhs?.text, rhs: hit.event.diff.rhs?.text }
+ : undefined;
+
+ const [lhs, rhs] = hit
+ ? [text?.lhs, text?.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 }));
+
+ const diffed = hit
+ ? { ...hit, ms: 0 }
+ : 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)
+ if (hit) this.work.cached++;
+ else if (diffed.event.diff)
+ void cache(key, {
+ classified: diffed.classified,
+ event: diffed.event,
+ pluginErrors: diffed.pluginErrors,
+ } satisfies Omit);
+
+ if (!hit && (!this.work.slowest || diffed.ms > this.work.slowest.ms))
this.work.slowest = { path: changed.path, ms: diffed.ms };
const event = diffed.event;
@@ -492,6 +814,33 @@ export class Comparison extends Disposable {
diff: event.diff,
hidden: diffed.classified.hidden,
});
+
+ if (
+ !diffed.classified.hidden &&
+ event.diff.type === "text" &&
+ this.engine.summarizes
+ )
+ void this.summarize(file, { lhs, rhs });
+
+ // diffr folds a hidden file's whole text behind its reason; the header already folds the
+ // file, so showing it shows the text instead of a second fold to open.
+ if (diffed.classified.hidden && event.diff.type === "text")
+ for (const side of [event.diff.lhs, event.diff.rhs])
+ if (side?.root.visibility?.collapsed)
+ session.setRegionCollapsed(
+ changed.path,
+ side.root.fold_state_id,
+ false,
+ );
+
+ if (diffed.pluginErrors.length)
+ this.work.pluginErrors.push(
+ ...diffed.pluginErrors.map((message) => ({
+ path: changed.path,
+ message,
+ })),
+ );
+ this.applyLens(changed.path);
} else {
this.work.failed++;
session.setFileResult(changed.path, { error: event.error.message });
@@ -517,6 +866,46 @@ export class Comparison extends Disposable {
this.pump();
}
+ /**
+ * Shown first without them, a file's summaries arrive from a second pass that waits on the
+ * model, and relabel its folds in place.
+ */
+ private async summarize(
+ file: File,
+ text: { lhs?: string; rhs?: string },
+ ): Promise {
+ const path = file.changed.path;
+ this.work.summarizing++;
+ this.changed.fire();
+
+ try {
+ const key = await this.cacheKey(file, "summary");
+ const hit = await cached(key);
+
+ const event =
+ hit ?? (await this.engine.diff(this.request(file, text), true)).event;
+
+ if (this._store.isDisposed) return;
+
+ if (event.diff?.type === "text") {
+ this.session?.updateFileDiff(path, event.diff);
+ this.work.summarized++;
+
+ if (!hit) void cache(key, event);
+ } else if (event.error)
+ this.work.summaryErrors.push({ path, message: event.error.message });
+ } catch (error) {
+ if (this._store.isDisposed) return;
+ this.work.summaryErrors.push({
+ path,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ this.work.summarizing--;
+ this.changed.fire();
+ }
+
/** Review Desktop's observeSession: each result goes to the view once. */
private observe(
session: StructuralDiffSession,
diff --git a/apps/diffr-web/src/display.ts b/apps/diffr-web/src/display.ts
new file mode 100644
index 000000000..d49222f33
--- /dev/null
+++ b/apps/diffr-web/src/display.ts
@@ -0,0 +1,6 @@
+import { observableValue } from "vs/base/common/observable.js";
+
+/** Shared by live editors and the mobile display menu. */
+export const showLineNumbers = observableValue("showLineNumbers", true);
+
+export const mobileWordWrap = observableValue("mobileWordWrap", false);
diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts
index 476126abc..22a5e12a6 100644
--- a/apps/diffr-web/src/engine/engine.ts
+++ b/apps/diffr-web/src/engine/engine.ts
@@ -1,9 +1,12 @@
+import { cacheKey } from "../cache.js";
+import { agentPlugins } from "../settings.js";
/**
* 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.
+ * Every worker goes after a few idle seconds, since wasm memory never shrinks: the compiled module
+ * stays, so the next file's worker starts in tens of milliseconds.
*/
// 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";
@@ -29,6 +32,8 @@ export interface Diffed {
classified: Classified;
event: FileEvent;
ms: number;
+ /** Agent plugins that threw on the file, and why. */
+ pluginErrors: string[];
}
/** What the engine panel shows. */
@@ -53,7 +58,7 @@ const MAX_WORKERS = Math.max(
Math.min(8, (navigator.hardwareConcurrency || 4) - 1),
);
-const IDLE_MS = 10_000;
+const IDLE_MS = 3_000;
/** Past this, a worker is replaced once its queue drains. */
const MEMORY_LIMIT = 1024 * 1024 * 1024;
@@ -106,9 +111,24 @@ async function unpacked(
let compiled: Promise | undefined;
+let resolveBuild!: (build: string) => void;
+
+/** Names the engine's build: its URL, which the build hashes, and what the server says of its bytes. */
+const build = new Promise((resolve) => (resolveBuild = resolve));
+
function module(): Promise {
return (compiled ??= (async () => {
- const response = await fetch(wasmUrl);
+ const response = await fetch(wasmUrl).catch((error: Error) => {
+ resolveBuild(wasmUrl);
+ throw error;
+ });
+
+ resolveBuild(
+ [
+ wasmUrl,
+ response.headers.get("etag") ?? response.headers.get("last-modified"),
+ ].join(" "),
+ );
if (!response.ok)
throw new Error(`The diffr engine failed to load (${response.status}).`);
@@ -131,6 +151,8 @@ interface Slot {
busy: number;
retire: boolean;
ready: boolean;
+ /** It waits on a model for summaries, so it takes nothing else meanwhile. */
+ summarizing: boolean;
}
/** A pool of diffr workers, all with one configuration. */
@@ -146,14 +168,32 @@ export class Engine {
readonly notices: Promise;
private resolveNotices!: (notices: string[]) => void;
private rejectNotices!: (error: Error) => void;
+ /** Whether the summarizer is on, once a worker has read the configuration. */
+ summarizes = false;
+ /** Names the build and everything it was given, so its results can be kept (cache.ts). */
+ readonly fingerprint: Promise;
- constructor(private readonly config: string | undefined) {
+ /**
+ * `config` is the reader's config.toml; `overrides`, JSON merged over it, the page's own settings
+ * (the summarizer's key and model).
+ */
+ constructor(
+ private readonly config: string | undefined,
+ private readonly overrides?: string,
+ /** The agent's enabled plugins, by default as saved. */
+ private readonly plugins = agentPlugins()
+ .filter((plugin) => plugin.enabled)
+ .map(({ name, code }) => ({ name, code })),
+ ) {
this.notices = new Promise((resolve, reject) => {
this.resolveNotices = resolve;
this.rejectNotices = reject;
});
this.notices.catch(() => {});
this.slots.push(this.spawn());
+ this.fingerprint = build.then((build) =>
+ cacheKey([build, config, overrides, this.plugins]),
+ );
}
private spawn(): Slot {
@@ -164,15 +204,20 @@ export class Engine {
busy: 0,
retire: false,
ready: false,
+ summarizing: false,
};
stats.workers++;
module().then(
- (module) =>
+ (module) => {
+ if (this.failure || !this.slots.includes(slot)) return;
slot.worker.postMessage({
module,
config: this.config,
- } satisfies Request),
+ overrides: this.overrides,
+ plugins: this.plugins,
+ } satisfies Request);
+ },
(error: Error) => this.fail(error),
);
slot.worker.onmessage = ({ data }: MessageEvent) =>
@@ -185,11 +230,16 @@ export class Engine {
}
private receive(slot: Slot, data: Response): void {
+ if (this.failure || !this.slots.includes(slot)) return;
+
if ("ready" in data) {
slot.ready = true;
stats.ready ??= data.ready;
+ this.summarizes = data.summarizes;
this.resolveNotices(data.notices);
this.dispatch();
+
+ if (!slot.busy && !this.queue.length) this.scheduleIdle();
changed();
return;
@@ -203,6 +253,7 @@ export class Engine {
slot.busy--;
stats.busy--;
+ slot.summarizing &&= slot.busy > 0;
stats.memory = Math.max(stats.memory, data.memory);
if (data.memory > MEMORY_LIMIT) slot.retire = true;
@@ -227,19 +278,29 @@ export class Engine {
/** The configuration or the engine failed: every call fails with it. */
private fail(error: Error): void {
+ if (this.failure) return;
this.failure = error;
this.rejectNotices(error);
+ 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(error);
this.pending.clear();
this.queue.length = 0;
+ changed();
}
private scheduleIdle(): void {
clearTimeout(this.idle);
this.idle = setTimeout(() => {
- for (const slot of this.slots.splice(1)) {
- if (slot.busy) this.slots.push(slot);
+ for (const slot of this.slots.splice(0)) {
+ // A worker still starting holds no file yet, but may be the one that reports notices.
+ if (slot.busy || !slot.ready) this.slots.push(slot);
else {
slot.worker.terminate();
stats.workers--;
@@ -251,31 +312,48 @@ export class Engine {
}
/**
- * 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.
+ * Queued jobs to ready workers, two each so none waits on the page between jobs. A summary
+ * waits on a model, so it takes an idle worker to itself, and one worker is always left for
+ * diffs. When a job finds no worker 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,
- );
+ const summarizers = Math.max(1, MAX_WORKERS - 1);
+
+ for (let index = 0; index < this.queue.length; ) {
+ const job = this.queue[index]!;
+ const summary = "diff" in job && !!job.summarize;
+
+ const slot =
+ summary &&
+ this.slots.filter((slot) => slot.summarizing).length >= summarizers
+ ? undefined
+ : this.slots.find(
+ (slot) =>
+ slot.ready &&
+ !slot.retire &&
+ !slot.summarizing &&
+ (summary ? slot.busy === 0 : slot.busy < 2),
+ );
if (!slot) {
- if (
- this.slots.every((slot) => slot.ready) &&
- this.slots.length < MAX_WORKERS
- )
- this.slots.push(this.spawn());
-
- return;
+ index++;
+ continue;
}
- const job = this.queue.shift()!;
+ this.queue.splice(index, 1);
slot.busy++;
stats.busy++;
+ slot.summarizing = summary;
slot.worker.postMessage(job);
}
+
+ if (
+ this.queue.length &&
+ this.slots.every((slot) => slot.ready) &&
+ this.slots.length < MAX_WORKERS
+ )
+ this.slots.push(this.spawn());
}
private send(
@@ -294,22 +372,29 @@ export class Engine {
});
}
- /** Diff one file. Resolves with its classification, its record and the time it took. */
- async diff(file: FileRequest): Promise {
+ /**
+ * Diff one file. Resolves with its classification, its record and the time it took. `summarize`
+ * runs the summarizer too, which waits on its model; it goes after other work.
+ */
+ async diff(file: FileRequest, summarize = false): Promise {
const response = await this.send>(
(id) => ({
id,
- diff: JSON.stringify({ ...file, syntax: true }),
+ diff: JSON.stringify({ ...file, syntax: true, summarize }),
+ summarize,
}),
);
- stats.diffed++;
- stats.diffMs += response.ms;
+ if (!summarize) {
+ stats.diffed++;
+ stats.diffMs += response.ms;
+ }
return {
classified: response.classified,
event: response.event,
ms: response.ms,
+ pluginErrors: response.pluginErrors,
};
}
@@ -327,19 +412,16 @@ export class Engine {
return response.previews;
}
- dispose(): void {
- clearTimeout(this.idle);
+ /** The configuration's JSON Schema: every setting, its description and its default. */
+ async schema(): Promise {
+ const response = await this.send>(
+ (id) => ({ id, schema: true }),
+ );
- for (const slot of this.slots.splice(0)) {
- slot.worker.terminate();
- stats.workers--;
- stats.busy -= slot.busy;
- }
+ return response.schema;
+ }
- for (const call of this.pending.values())
- call.reject(new Error("The engine was stopped."));
- this.pending.clear();
- this.queue.length = 0;
- changed();
+ dispose(): void {
+ this.fail(new Error("The engine was stopped."));
}
}
diff --git a/apps/diffr-web/src/engine/worker.ts b/apps/diffr-web/src/engine/worker.ts
index 296595b58..deb8546cd 100644
--- a/apps/diffr-web/src/engine/worker.ts
+++ b/apps/diffr-web/src/engine/worker.ts
@@ -1,3 +1,5 @@
+import * as z from "zod/mini";
+
///
/** 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";
@@ -11,11 +13,21 @@ export interface Classified {
export type FileEvent = Extract;
-/** The first message carries the compiled module and the configuration; every later one is work. */
+/**
+ * The first message carries the compiled module, the configuration and the page's overrides of it
+ * (JSON); every later one is work. A diff that summarizes waits on a model for each body.
+ */
export type Request =
- | { module: WebAssembly.Module; config: string | undefined }
- | { id: number; diff: string }
- | { id: number; preview: string };
+ | {
+ module: WebAssembly.Module;
+ config: string | undefined;
+ overrides: string | undefined;
+ /** Agent plugins (settings.ts), run after diffr's own. */
+ plugins: { name: string; code: string }[];
+ }
+ | { id: number; diff: string; summarize?: boolean }
+ | { id: number; preview: string }
+ | { id: number; schema: true };
interface Usage {
id: number;
@@ -25,10 +37,16 @@ interface Usage {
}
export type Response =
- | { ready: number; notices: string[] }
+ | { ready: number; notices: string[]; summarizes: boolean }
| { failed: string }
- | (Usage & { classified: Classified; event: FileEvent })
+ | (Usage & {
+ classified: Classified;
+ event: FileEvent;
+ /** Agent plugins that threw on this file, and why. */
+ pluginErrors: string[];
+ })
| (Usage & { previews: (Classified | { error: string })[] })
+ | (Usage & { schema: string })
| (Usage & { error: string });
interface WireClassified {
@@ -38,6 +56,178 @@ interface WireClassified {
let differ: Differ | undefined;
+type TextDiff = Extract, { type: "text" }>;
+
+type Region = NonNullable["root"];
+
+/** What an agent plugin is handed: one diffed file, its sides' text and region trees. */
+interface AgentFile {
+ path: string;
+ status: string;
+ tags: string[];
+ lhs?: { text: string; root: Region };
+ rhs?: { text: string; root: Region };
+}
+
+/** A plugin edits the file it is handed; what it returns is ignored. */
+type AgentFold = (file: AgentFile) => void;
+
+let plugins: { name: string; run: AgentFold }[] = [];
+
+/** What a plugin may have set on a region: the page keeps only this. */
+const Visibility = z.object({
+ collapsed: z.optional(z.boolean()),
+ label: z.optional(z.string()),
+});
+
+/**
+ * Run the agent's plugins on a diffed file. Each edits a copy; only what it set on a region's
+ * `visibility` comes back, and a fold state either side changed changes on both, as diffr's own
+ * plugins keep them.
+ */
+function runPlugins(
+ event: FileEvent,
+ diff: TextDiff,
+ tags: string[],
+): string[] {
+ const errors: string[] = [];
+ const path = event.file.rhs?.path ?? event.file.lhs?.path ?? "";
+
+ for (const { name, run } of plugins) {
+ const file: AgentFile = structuredClone({
+ path,
+ status:
+ event.file.rhs && event.file.lhs
+ ? "modified"
+ : event.file.rhs
+ ? "added"
+ : "deleted",
+ tags,
+ lhs: diff.lhs && { text: diff.lhs.text, root: diff.lhs.root },
+ rhs: diff.rhs && { text: diff.rhs.text, root: diff.rhs.root },
+ });
+
+ try {
+ run(file);
+ } catch (error) {
+ errors.push(
+ `${name}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ continue;
+ }
+
+ const changed = new Map();
+
+ const read = (original: Region, edited: Region | undefined) => {
+ const visibility = Visibility.safeParse(edited?.visibility);
+
+ if (visibility.success) {
+ const before = {
+ collapsed: !!original.visibility?.collapsed,
+ label: original.visibility?.label ?? "",
+ };
+
+ const next = {
+ collapsed: visibility.data.collapsed ?? before.collapsed,
+ label: visibility.data.label ?? before.label,
+ };
+
+ if (next.collapsed !== before.collapsed || next.label !== before.label)
+ changed.set(original.fold_state_id, next);
+ }
+
+ if (original.kind === "fold" && edited?.kind === "fold")
+ original.children.forEach((child, index) =>
+ read(child, edited.children?.[index]),
+ );
+ };
+
+ for (const side of ["lhs", "rhs"] as const)
+ if (diff[side]) read(diff[side].root, file[side]?.root);
+
+ const apply = (region: Region) => {
+ const next = changed.get(region.fold_state_id);
+
+ if (next) region.visibility = { ...next };
+
+ if (region.kind === "fold") region.children.forEach(apply);
+ };
+
+ for (const side of ["lhs", "rhs"] as const)
+ if (diff[side]) apply(diff[side].root);
+ }
+
+ return errors;
+}
+
+/** What diffr asks of the page (diffr-web's plugins.rs): an HTTP POST, or a pause. */
+type HostRequest =
+ | {
+ post: string;
+ headers: [string, string][];
+ body: string;
+ timeout_ms: number;
+ }
+ | { sleep_ms: number };
+
+/**
+ * The summarizer's requests to its model. A linked plugin cannot wait on the page, so these are
+ * synchronous, which only a worker may do; the page stays responsive.
+ */
+function host(text: string): string {
+ // SAFETY: diffr-web's PageHttp sends one of these.
+ const request = JSON.parse(text) as HostRequest;
+
+ if ("sleep_ms" in request) {
+ const until = performance.now() + request.sleep_ms;
+
+ if ("SharedArrayBuffer" in globalThis)
+ Atomics.wait(
+ new Int32Array(new SharedArrayBuffer(4)),
+ 0,
+ 0,
+ request.sleep_ms,
+ );
+ else while (performance.now() < until);
+
+ return "{}";
+ }
+
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", request.post, false);
+ xhr.timeout = request.timeout_ms;
+
+ for (const [name, value] of request.headers) {
+ // The browser sets the length itself and refuses to be told it.
+ if (name.toLowerCase() !== "content-length")
+ xhr.setRequestHeader(name, value);
+
+ // Anthropic answers a browser only when it says it means to call from one.
+ if (name.toLowerCase() === "anthropic-version")
+ xhr.setRequestHeader(
+ "anthropic-dangerous-direct-browser-access",
+ "true",
+ );
+ }
+
+ xhr.send(request.body);
+
+ if (!xhr.status)
+ return JSON.stringify({
+ error: `${new URL(request.post).host} could not be reached from the browser (offline, or it refuses other sites' pages)`,
+ });
+
+ return JSON.stringify({ status: xhr.status, body: xhr.responseText });
+ } catch (error) {
+ return JSON.stringify({
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+Object.assign(self, { diffrHost: host });
+
let memory: WebAssembly.Memory | undefined;
self.onmessage = async ({ data }: MessageEvent) => {
@@ -47,10 +237,28 @@ self.onmessage = async ({ data }: MessageEvent) => {
try {
const wasm = await init({ module_or_path: data.module });
memory = wasm.memory;
- differ = new Differ(data.config);
+ differ = new Differ(data.config, data.overrides);
+ const notices: string[] = JSON.parse(differ.notices());
+ plugins = data.plugins.flatMap(({ name, code }) => {
+ try {
+ const run: unknown = (0, eval)(`(${code}\n)`);
+
+ if (!(run instanceof Function))
+ throw new Error("its code is not a function");
+
+ return [{ name, run: (file: AgentFile) => void run(file) }];
+ } catch (error) {
+ notices.push(
+ `The plugin ${name} is skipped: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ return [];
+ }
+ });
self.postMessage({
ready: performance.now() - start,
- notices: JSON.parse(differ.notices()),
+ notices,
+ summarizes: differ.summarizes(),
} satisfies Response);
} catch (error) {
self.postMessage({
@@ -75,6 +283,15 @@ function handle(data: Request): void {
});
try {
+ if ("schema" in data) {
+ self.postMessage({
+ ...usage(),
+ schema: Differ.schema(),
+ } satisfies Response);
+
+ return;
+ }
+
if ("preview" in data) {
// SAFETY: Differ.preview returns an array of these, one per file (diffr-web's lib.rs).
const previews = (
@@ -98,10 +315,19 @@ function handle(data: Request): void {
event: FileEvent;
};
+ const tags = response.entry.tags ?? [];
+ const diff = response.event.diff;
+
+ const pluginErrors =
+ diff?.type === "text" && !response.hidden
+ ? runPlugins(response.event, diff, tags)
+ : [];
+
self.postMessage({
...usage(),
- classified: { tags: response.entry.tags ?? [], hidden: response.hidden },
+ classified: { tags, hidden: response.hidden },
event: response.event,
+ pluginErrors,
} satisfies Response);
} catch (error) {
self.postMessage({
diff --git a/apps/diffr-web/src/find.ts b/apps/diffr-web/src/find.ts
new file mode 100644
index 000000000..ad7e8194f
--- /dev/null
+++ b/apps/diffr-web/src/find.ts
@@ -0,0 +1,344 @@
+import { Range } from "vs/editor/common/core/range.js";
+/**
+ * ⌘/Ctrl+F: find text in every diffed file, not only the lines on screen, which is all the
+ * browser's own find can see of a virtualized list. A match opens the folds around it.
+ */
+import type { IEditorDecorationsCollection } from "vs/editor/common/editorCommon.js";
+
+import type { Comparison } from "./comparison.js";
+
+type Side = "original" | "modified";
+
+interface Match {
+ path: string;
+ side: Side;
+ /** 1-based, as the editors count. */
+ line: number;
+ column: number;
+ length: number;
+}
+
+/** Past this, the count reads "10000+" and the rest are not stepped through. */
+const MAX_MATCHES = 10_000;
+
+function toggle(
+ parent: HTMLElement,
+ icon: string,
+ title: string,
+ onChange: () => void,
+): () => boolean {
+ const button = parent.appendChild(document.createElement("button"));
+ button.type = "button";
+ button.className = `app-find-toggle codicon codicon-${icon}`;
+ button.title = title;
+ button.setAttribute("aria-label", title);
+ button.setAttribute("aria-pressed", "false");
+ button.addEventListener("mousedown", (event) => event.preventDefault());
+ button.addEventListener("click", () => {
+ button.setAttribute(
+ "aria-pressed",
+ String(button.getAttribute("aria-pressed") !== "true"),
+ );
+ onChange();
+ });
+
+ return () => button.getAttribute("aria-pressed") === "true";
+}
+
+function iconButton(
+ parent: HTMLElement,
+ icon: string,
+ title: string,
+ onClick: () => void,
+): HTMLButtonElement {
+ const button = parent.appendChild(document.createElement("button"));
+ button.type = "button";
+ button.className = `app-find-button codicon codicon-${icon}`;
+ button.title = title;
+ button.setAttribute("aria-label", title);
+ button.addEventListener("mousedown", (event) => event.preventDefault());
+ button.addEventListener("click", onClick);
+
+ return button;
+}
+
+export class FindBar {
+ readonly element: HTMLElement;
+ private readonly input: HTMLInputElement;
+ private readonly count: HTMLElement;
+ private readonly matchCase: () => boolean;
+ private readonly wholeWord: () => boolean;
+ private readonly regex: () => boolean;
+ private matches: Match[] = [];
+ private current = -1;
+ private searched: Comparison | undefined;
+ private timer: ReturnType | undefined;
+ private decorations = new Map