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(); + private frame = 0; + + constructor(private readonly comparison: () => Comparison | undefined) { + this.element = document.createElement("div"); + this.element.className = "app-find"; + this.element.hidden = true; + this.element.setAttribute("role", "search"); + + const field = this.element.appendChild(document.createElement("div")); + field.className = "app-find-field"; + this.input = field.appendChild(document.createElement("input")); + this.input.placeholder = "Find in all files"; + this.input.spellcheck = false; + this.input.setAttribute("aria-label", "Find in all files"); + const search = () => this.search(); + this.matchCase = toggle(field, "case-sensitive", "Match case", search); + this.wholeWord = toggle(field, "whole-word", "Match whole word", search); + this.regex = toggle(field, "regex", "Use regular expression", search); + + this.count = this.element.appendChild(document.createElement("span")); + this.count.className = "app-find-count"; + this.count.setAttribute("aria-live", "polite"); + iconButton(this.element, "arrow-up", "Previous match (⇧Enter)", () => + this.step(-1), + ); + iconButton(this.element, "arrow-down", "Next match (Enter)", () => + this.step(1), + ); + iconButton(this.element, "close", "Close (Escape)", () => this.hide()); + + this.input.addEventListener("input", () => { + clearTimeout(this.timer); + this.timer = setTimeout(search, 120); + }); + this.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + clearTimeout(this.timer); + + if (this.searched !== this.comparison() || this.current < 0) + this.search(); + else this.step(event.shiftKey ? -1 : 1); + } else if (event.key === "Escape") this.hide(); + else return; + event.preventDefault(); + event.stopPropagation(); + }); + } + + get isOpen(): boolean { + return !this.element.hidden; + } + + /** Open with the page's selection, if any, as the query. */ + show(): void { + const selection = window.getSelection()?.toString() ?? ""; + + if (selection && !selection.includes("\n")) this.input.value = selection; + this.element.hidden = false; + this.input.focus(); + this.input.select(); + this.search(); + } + + hide(): void { + if (this.element.hidden) return; + this.element.hidden = true; + this.clearDecorations(); + this.comparison()?.focus(); + } + + /** Files arrived or the comparison changed: search again, keeping the current match. */ + refresh(): void { + if (this.element.hidden || !this.input.value) return; + clearTimeout(this.timer); + this.timer = setTimeout(() => this.search(false), 250); + } + + private pattern(): RegExp | undefined { + const query = this.input.value; + + if (!query) return undefined; + + let source = this.regex() + ? query + : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + if (this.wholeWord()) source = `\\b(?:${source})\\b`; + + try { + return new RegExp(source, this.matchCase() ? "gu" : "giu"); + } catch { + return undefined; + } + } + + /** Search again, from the match nearest the current one; `reveal` scrolls to it. */ + private search(reveal = true): void { + const comparison = this.comparison(); + const previous = this.matches[this.current]; + this.searched = comparison; + this.matches = []; + this.current = -1; + const pattern = this.pattern(); + + if (!comparison || !pattern) { + this.count.textContent = + this.input.value && !pattern ? "Invalid expression" : ""; + this.element.classList.toggle( + "no-results", + !!this.input.value && !pattern, + ); + this.clearDecorations(); + + return; + } + + const files = comparison.texts(); + const order = new Map(files.map(({ path }, i) => [path, i])); + + // The head side whole, and of the base side only its removed lines, so an unchanged line is + // found once, as a unified diff shows it. + outer: for (const file of files) { + const removed = comparison.removedLines(file.path); + + for (const side of ["modified", "original"] as const) { + const text = file[side]; + + if (!text) continue; + + for (const [index, line] of text.split("\n").entries()) { + if (side === "original" && !removed(index)) continue; + + for (const found of line.matchAll(pattern)) { + if (!found[0].length) continue; + this.matches.push({ + path: file.path, + side, + line: index + 1, + column: found.index + 1, + length: found[0].length, + }); + + if (this.matches.length >= MAX_MATCHES) break outer; + } + } + } + } + + // Reading order: file by file, line by line, a removed line before the line that replaced it. + this.matches.sort( + (a, b) => + order.get(a.path)! - order.get(b.path)! || + a.line - b.line || + (a.side === b.side ? 0 : a.side === "original" ? -1 : 1) || + a.column - b.column, + ); + + if (this.matches.length) { + const index = previous + ? this.matches.findIndex( + (match) => + order.get(match.path)! > order.get(previous.path)! || + (match.path === previous.path && + (match.line > previous.line || + (match.line === previous.line && + match.column >= previous.column))), + ) + : 0; + + this.current = Math.max(0, index); + } + + this.render(reveal); + } + + private step(by: number): void { + if (!this.matches.length) return; + this.current = + (this.current + by + this.matches.length) % this.matches.length; + this.render(true); + } + + private render(reveal: boolean): void { + const comparison = this.comparison(); + const total = this.matches.length; + + const pending = comparison + ? comparison.fileList.filter((file) => !file.diffed).length + : 0; + + this.count.textContent = !this.input.value + ? "" + : total + ? `${this.current + 1} of ${total >= MAX_MATCHES ? `${MAX_MATCHES}+` : total}` + : "No results"; + + this.count.title = pending + ? `${pending} file${pending === 1 ? "" : "s"} not diffed yet; their matches join as they load` + : ""; + this.element.classList.toggle("no-results", !!this.input.value && !total); + + const match = this.matches[this.current]; + + if (reveal && match && comparison) + comparison.revealLine(match.path, match.side, match.line); + + this.decorate(); + } + + /** Mark the matches in the files on screen, once their editors exist. */ + private decorate(): void { + cancelAnimationFrame(this.frame); + const current = this.matches[this.current]; + const groups = new Map(); + + for (const match of this.matches) { + const key = `${match.side}\0${match.path}`; + const group = groups.get(key); + + if (group) group.push(match); + else groups.set(key, [match]); + } + + let frames = 0; + + const apply = () => { + const comparison = this.comparison(); + this.clearDecorations(); + + if (!comparison || this.element.hidden) return; + + for (const group of groups.values()) { + const { path, side } = group[0]!; + const editor = comparison.codeEditor(path, side); + + // Unified, a removed line is drawn outside the editor's model. + if (!editor || editor.getModel()?.uri.authority !== sideAuthority(side)) + continue; + this.decorations.set( + editor, + editor.createDecorationsCollection( + group.map((m) => ({ + range: new Range(m.line, m.column, m.line, m.column + m.length), + options: { + description: "diffr-find", + className: m === current ? "currentFindMatch" : "findMatch", + stickiness: 1, + }, + })), + ), + ); + } + + // Files render, and a reveal settles, over the next frames. + if (++frames < 20) this.frame = requestAnimationFrame(apply); + }; + + this.frame = requestAnimationFrame(apply); + } + + private clearDecorations(): void { + for (const collection of this.decorations.values()) collection.clear(); + this.decorations.clear(); + } +} + +/** The authority of each side's model URIs (comparison.ts). */ +const sideAuthority = (side: Side) => (side === "original" ? "base" : "head"); diff --git a/apps/diffr-web/src/lenses.ts b/apps/diffr-web/src/lenses.ts new file mode 100644 index 000000000..2138be78e --- /dev/null +++ b/apps/diffr-web/src/lenses.ts @@ -0,0 +1,104 @@ +/** + * Lenses: named parts of a comparison an agent picks out for the reader, such as "the data model" + * or "tests". Showing one folds every file outside it, and in its files every fold that holds none + * of its lines. They are kept per comparison, in this browser. + */ +import type { Comparison } from "./comparison.js"; +import { targetPath } from "./github.js"; +import { readSetting, writeSetting } from "./settings.js"; +import { element } from "./ui.js"; + +/** Lines of one side of a file, from 1, both ends included. */ +export interface LensRange { + side: "base" | "head"; + from: number; + to: number; +} + +export interface Lens { + id: string; + title: string; + description?: string; + /** A file without ranges is shown whole. */ + files: { path: string; ranges?: LensRange[] }[]; +} + +const key = (comparison: Comparison) => + `lenses:${targetPath(comparison.target)}`; + +export function lenses(comparison: Comparison): Lens[] { + try { + // SAFETY: setLenses wrote it. + const value = JSON.parse(readSetting(key(comparison)) ?? "[]") as Lens[]; + + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +export function setLenses( + comparison: Comparison, + value: readonly Lens[], +): void { + writeSetting( + key(comparison), + value.length ? JSON.stringify(value) : undefined, + ); +} + +/** A row of the comparison's lenses over the diff; empty, and it hides. */ +export function renderLensBar( + parent: HTMLElement, + comparison: Comparison | undefined, +): void { + parent.replaceChildren(); + const all = comparison ? lenses(comparison) : []; + parent.hidden = !all.length; + + if (!comparison || !all.length) return; + parent.appendChild(element("span", "app-lens-label", "Lenses")); + const active = comparison.activeLens?.id; + + const chip = ( + title: string, + pressed: boolean, + onClick: () => void, + tooltip?: string, + ) => { + const button = parent.appendChild(element("button", "app-lens", title)); + button.type = "button"; + button.setAttribute("aria-pressed", String(pressed)); + + if (tooltip) button.title = tooltip; + button.addEventListener("click", onClick); + + return button; + }; + + chip("All files", !active, () => comparison.showLens(undefined)); + + for (const lens of all) { + const files = lens.files.length; + chip( + lens.title, + lens.id === active, + () => comparison.showLens(lens.id === active ? undefined : lens), + [lens.description, `${files} file${files === 1 ? "" : "s"}`] + .filter(Boolean) + .join("\n"), + ); + } + + const remove = parent.appendChild( + element("button", "app-lens-clear codicon codicon-clear-all"), + ); + + remove.type = "button"; + remove.title = "Remove this comparison's lenses"; + remove.setAttribute("aria-label", remove.title); + remove.addEventListener("click", () => { + setLenses(comparison, []); + comparison.showLens(undefined); + }); +} diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 54d6c1bae..e8b0bbce4 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -3,19 +3,34 @@ * `/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 { derived, observableValue } from "vs/base/common/observable.js"; import "./fonts.css"; import "./styles.css"; import "./editorFont.js"; +import { loadCacheUsage, onCacheChange } from "./cache.js"; +import { cacheSection } from "./cacheSection.js"; import { Comparison } from "./comparison.js"; +import { mobileWordWrap, showLineNumbers } from "./display.js"; import { Engine, onEngineChange } from "./engine/engine.js"; -import { parseTarget, setToken, targetPath, token } from "./github.js"; +import { FindBar } from "./find.js"; +import { parseTarget, targetPath } from "./github.js"; +import { renderLensBar } from "./lenses.js"; +import { MobileFiles } from "./mobileFiles.js"; +import { mobileViewport } from "./nativeDiffScroll.js"; import { Panels } from "./panels.js"; +import { agentPluginsSection } from "./pluginsSection.js"; +import { quickOpen } from "./quickOpen.js"; import { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; -import { readSetting, writeSetting } from "./settings.js"; +import { configOverrides, readSetting } from "./settings.js"; +import { + type SettingsHost, + openOnboarding, + openSettings, +} from "./settingsDialog.js"; import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; import { applyTheme } from "./theme.js"; +import { actionButton, element } from "./ui.js"; const instantiation = StandaloneServices.initialize({}); @@ -23,15 +38,23 @@ const layout = new ReviewDiffLayoutSetting(); const wordWrap = observableValue("wordWrap", false); +const narrow = observableValue("narrow", mobileViewport.matches); + +const effectiveWordWrap = derived((reader) => + narrow.read(reader) ? mobileWordWrap.read(reader) : wordWrap.read(reader), +); + 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 setTheme = applyTheme(root); const header = root.appendChild(element("header", "app-header")); +let headerUpdatePending = false; + const status = root.appendChild(element("div", "app-status")); status.hidden = true; @@ -42,6 +65,26 @@ const statusMessage = status.appendChild(element("span", "app-status-message")); const retry = status.appendChild(actionButton("Retry", route)); +const openSettingsButton = status.appendChild( + actionButton("Settings", () => openSettings(settings)), +); + +const dismiss = status.appendChild( + actionButton("Dismiss", () => { + summaryNoticeDismissed = true; + renderHeader(); + }), +); + +const lensBar = root.appendChild(element("nav", "app-lens-bar")); + +lensBar.hidden = true; + +lensBar.setAttribute("aria-label", "Lenses"); + +/** The reader closed this comparison's notice that summaries failed. */ +let summaryNoticeDismissed = false; + const body = root.appendChild(element("main", "app-body")); const overflow = root.appendChild(element("div", "monaco-editor app-overflow")); @@ -50,53 +93,83 @@ let engine: Engine | undefined; let comparison: Comparison | undefined; +const mobileFiles = new MobileFiles(() => comparison); + +root.appendChild(mobileFiles.element); + const panels = new Panels(() => comparison); root.appendChild(panels.element); -onEngineChange(() => panels.update()); +const find = new FindBar(() => comparison); -function element( - tag: K, - className?: string, - text?: string, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); +body.appendChild(find.element); - if (className) node.className = className; +onEngineChange(() => panels.update()); - if (text !== undefined) node.textContent = text; +onCacheChange(() => panels.update()); - return node; -} +loadCacheUsage(); function iconButton( icon: string, tooltip: string, - onClick: () => void, + 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); + + if (onClick) 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"))); + return (engine ??= new Engine(readSetting("config"), configOverrides())); } +const settings: SettingsHost = { + root, + engine: currentEngine, + replaceEngine(next) { + engine?.dispose(); + engine = next; + route(); + }, + reload: () => route(), + sections: (parent) => { + cacheSection(parent); + agentPluginsSection(parent, (next) => settings.replaceEngine(next)); + }, +}; + +// Only where the browser offers its agent the page's tools, which bring their own decoder. +if (document.modelContext ?? navigator.modelContext) + void import("./agent.js").then(({ registerAgentTools }) => + registerAgentTools({ + comparison: () => comparison, + replaceEngine: (next) => settings.replaceEngine(next), + changed: () => { + renderLensBar(lensBar, comparison); + panels.update(); + }, + }), + ); + function route(): void { comparison?.dispose(); comparison = undefined; - body.replaceChildren(); + summaryNoticeDismissed = false; + find.hide(); + body.replaceChildren(find.element); const target = parseTarget(location.pathname); if (!target) { document.title = "diffr"; renderHeader(); + renderLensBar(lensBar, undefined); renderLanding(); panels.update(); @@ -111,7 +184,7 @@ function route(): void { target, currentEngine(), layout, - wordWrap, + effectiveWordWrap, instantiation, )); @@ -119,7 +192,9 @@ function route(): void { if (comparison !== current) return; document.title = current.title ? `${current.title} · diffr` : "diffr"; renderHeader(); + renderLensBar(lensBar, current); panels.update(); + find.refresh(); }; current.onDidChange(update); @@ -127,6 +202,17 @@ function route(): void { } function renderHeader(): void { + mobileFiles.update(); + + // Streaming file results must not tear down the menu the reader is using. + if (header.querySelector(":popover-open")) { + headerUpdatePending = true; + + return; + } + + headerUpdatePending = false; + const focusedButton = Array.from(header.querySelectorAll("button")).findIndex( (button) => button === document.activeElement, ); @@ -137,11 +223,20 @@ function renderHeader(): void { 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…" : ""); + const summaryErrors = comparison?.work.summaryErrors ?? []; + + const summaryNotice = + !error && !loading && !summaryNoticeDismissed && summaryErrors.length + ? `Summaries failed for ${summaryErrors.length} file${summaryErrors.length === 1 ? "" : "s"}: ${summaryErrors[0]!.message}` + : undefined; + + status.hidden = !error && !loading && !summaryNotice; + status.classList.toggle("is-error", !!error || !!summaryNotice); + status.setAttribute("role", error || summaryNotice ? "alert" : "status"); + statusMessage.textContent = + error ?? summaryNotice ?? (loading ? "Loading comparison…" : ""); retry.hidden = !error; + openSettingsButton.hidden = dismiss.hidden = !summaryNotice; const home = header.appendChild(element("a", "app-wordmark", "diffr")); home.href = "/"; home.title = "Open another pull request"; @@ -151,6 +246,20 @@ function renderHeader(): void { }); if (comparison) { + if (mobileViewport.matches) { + const files = iconButton("list-tree", "Open file tree", () => + mobileFiles.toggle(), + ); + + files.classList.add("app-header-files"); + files.setAttribute("aria-haspopup", "dialog"); + files.setAttribute( + "aria-expanded", + String(!!document.querySelector(".app-mobile-files[open]")), + ); + header.appendChild(files); + } + const title = header.appendChild( element( "a", @@ -169,7 +278,7 @@ function renderHeader(): void { header.appendChild(element("span", "app-spacer")); - if (comparison) { + if (comparison && !mobileViewport.matches) { for (const [panel, icon, label] of [ ["stats", "graph", "Diff stats (F2)"], ["engine", "dashboard", "Engine stats (F3)"], @@ -204,16 +313,76 @@ function renderHeader(): void { ); } - header.appendChild( - iconButton("settings-gear", "diffr configuration", openConfig), - ); - header.appendChild( - iconButton( - token() ? "unlock" : "key", - token() ? "GitHub token set" : "Add a GitHub token", - openToken, - ), - ); + if (comparison && mobileViewport.matches) { + const quick = element("div", "app-header-quick"); + + const fold = iconButton( + comparison.filesCollapsed ? "expand-all" : "collapse-all", + comparison.filesCollapsed ? "Expand all files" : "Collapse all files", + () => { + comparison?.toggleAllFiles(); + renderHeader(); + }, + ); + + fold.setAttribute("aria-pressed", String(comparison.filesCollapsed)); + const theme = quickMenu("color-mode", "Theme settings"); + + for (const mode of ["auto", "light", "dark"] as const) { + const button = actionButton(mode[0].toUpperCase() + mode.slice(1), () => { + setTheme(mode); + + for (const child of theme.panel.querySelectorAll("button")) + child.setAttribute("aria-pressed", String(child === button)); + }); + + button.setAttribute( + "aria-pressed", + String((readSetting("theme") ?? "auto") === mode), + ); + theme.panel.append(button); + } + + const display = quickMenu("settings-gear", "Display settings"); + + for (const [label, value] of [ + ["Word wrap", mobileWordWrap], + ["Line numbers", showLineNumbers], + ] as const) { + const row = element("label", "app-quick-setting"); + const input = element("input"); + input.type = "checkbox"; + input.checked = value.get(); + input.addEventListener("change", () => + value.set(input.checked, undefined), + ); + row.append(element("span", undefined, label), input); + display.panel.append(row); + } + + display.panel.append( + actionButton("All settings…", () => { + display.panel.hidePopover(); + openSettings(settings); + }), + ); + quick.append( + fold, + theme.button, + theme.panel, + display.button, + display.panel, + ); + header.append(quick); + } else { + header.appendChild( + iconButton( + "settings-gear", + "Settings: GitHub token, summaries, diffr", + () => openSettings(settings), + ), + ); + } if (focusedButton >= 0) { const button = header.querySelectorAll("button").item(focusedButton); @@ -221,6 +390,35 @@ function renderHeader(): void { } } +function quickMenu(icon: string, label: string) { + const panel = element("div", "app-header-popover"); + panel.popover = "auto"; + panel.setAttribute("aria-label", label); + const button = iconButton(icon, label); + button.popoverTargetElement = panel; + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-haspopup", "dialog"); + panel.setAttribute("role", "dialog"); + panel.addEventListener("toggle", () => { + const open = panel.matches(":popover-open"); + button.setAttribute("aria-expanded", String(open)); + + if (!open) + void Promise.allSettled( + panel.getAnimations().map((animation) => animation.finished), + ).then(() => { + if ( + headerUpdatePending && + panel.isConnected && + !panel.matches(":popover-open") + ) + renderHeader(); + }); + }); + + return { button, panel }; +} + function togglePanel(panel: "stats" | "engine"): void { panels.toggle(panel); renderHeader(); @@ -258,120 +456,13 @@ function navigate(path: string): void { 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); +mobileViewport.addEventListener("change", () => { + narrow.set(mobileViewport.matches, undefined); + renderHeader(); +}); + /** A `z` waiting for its fold command. */ let chord = false; @@ -389,8 +480,20 @@ function command(event: KeyboardEvent): (() => void) | undefined { if (!comparison) return undefined; const current = comparison; - if ((event.metaKey || event.ctrlKey) && !event.altKey && key === "b") - return () => current.toggleFileTree(); + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + if (key === "b") + return () => + mobileViewport.matches + ? mobileFiles.toggle() + : current.toggleFileTree(); + + // The browser's own find sees only the lines on screen; print has nothing to print. + if (key === "f") return () => find.show(); + + if (key === "p") + return () => + quickOpen(body, current.fileList, (path) => current.openFile(path)); + } // Plain keys only, and never while typing. The editors are read only, so their input area types // nothing. @@ -476,3 +579,5 @@ window.addEventListener( ); route(); + +if (!readSetting("onboarded")) openOnboarding(settings); diff --git a/apps/diffr-web/src/mobileFiles.ts b/apps/diffr-web/src/mobileFiles.ts new file mode 100644 index 000000000..b5584f880 --- /dev/null +++ b/apps/diffr-web/src/mobileFiles.ts @@ -0,0 +1,385 @@ +import { Disposable } from "vs/base/common/lifecycle.js"; + +import type { Comparison } from "./comparison.js"; +import { mobileViewport } from "./nativeDiffScroll.js"; +import { actionButton, element } from "./ui.js"; + +/** A native, searchable bottom sheet; navigating still goes through the existing diff view. */ +export class MobileFiles extends Disposable { + readonly element = element("div", "app-mobile-files-host"); + private readonly sheet = element("dialog", "app-mobile-files"); + private readonly list = element("div", "app-mobile-file-list"); + private readonly search = element("input", "app-mobile-file-search"); + private closing = false; + private paths = ""; + private selectedPath = ""; + private status = ""; + private readonly empty = element( + "p", + "app-mobile-files-empty", + "No matching files", + ); + private readonly collapsed = new Set(); + private readonly fileRows: { path: string; button: HTMLButtonElement }[] = []; + private readonly folders: { path: string; node: HTMLDetailsElement }[] = []; + + constructor(private readonly comparison: () => Comparison | undefined) { + super(); + const heading = element("div", "app-mobile-files-heading"); + const title = element("h2", undefined, "Changed files"); + title.id = "mobile-files-title"; + this.sheet.setAttribute("aria-labelledby", title.id); + const close = actionButton("", () => this.close()); + close.append(element("span", "codicon codicon-close")); + close.classList.add("app-mobile-files-close"); + close.setAttribute("aria-label", "Close file tree"); + close.autofocus = true; + + const searchToggle = actionButton("", () => { + this.search.hidden = !this.search.hidden; + searchToggle.setAttribute("aria-expanded", String(!this.search.hidden)); + + if (!this.search.hidden) this.search.focus(); + else { + this.search.value = ""; + this.filter(); + } + }); + + searchToggle.className = "app-mobile-tree-tool"; + searchToggle.setAttribute("aria-label", "Show file search"); + searchToggle.setAttribute("aria-expanded", "false"); + searchToggle.append(element("span", "codicon codicon-search")); + this.search.hidden = true; + const filterMenu = element("div", "app-mobile-filter-menu"); + filterMenu.hidden = true; + filterMenu.setAttribute("role", "group"); + filterMenu.setAttribute("aria-label", "Filter by change type"); + + const filterToggle = actionButton("", () => { + filterMenu.hidden = !filterMenu.hidden; + filterToggle.setAttribute("aria-expanded", String(!filterMenu.hidden)); + }); + + filterToggle.className = "app-mobile-tree-tool"; + filterToggle.setAttribute("aria-label", "Filter by change type"); + filterToggle.setAttribute("aria-expanded", "false"); + filterToggle.append(element("span", "codicon codicon-filter")); + const filterWrapper = element("div", "app-mobile-filter-wrapper"); + filterWrapper.append(filterToggle, filterMenu); + const tools = element("div", "app-mobile-files-tools"); + tools.append(searchToggle, filterWrapper, close); + heading.append(title, tools); + this.sheet.addEventListener("click", (event) => { + if ( + event.target instanceof Node && + !filterWrapper.contains(event.target) + ) { + filterMenu.hidden = true; + filterToggle.setAttribute("aria-expanded", "false"); + } + }); + this.sheet.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !filterMenu.hidden) { + event.preventDefault(); + event.stopPropagation(); + filterMenu.hidden = true; + filterToggle.setAttribute("aria-expanded", "false"); + filterToggle.focus(); + } + }); + this.search.type = "search"; + this.search.placeholder = "Find a file…"; + this.search.setAttribute("aria-label", "Find a file"); + this.search.addEventListener("input", () => this.filter()); + const folds = element("div", "app-mobile-fold-actions"); + folds.append( + actionButton("Fold all", () => { + this.comparison()?.foldAll(true); + this.close(); + }), + actionButton("Expand all", () => { + this.comparison()?.foldAll(false); + this.close(); + }), + ); + + for (const [value, label] of [ + ["", "All changes"], + ["modified", "Modified"], + ["added", "Added"], + ["deleted", "Deleted"], + ["renamed", "Renamed"], + ]) { + const option = actionButton(label, () => { + this.status = value; + + for (const button of filterMenu.querySelectorAll("button")) + button.setAttribute("aria-pressed", String(button === option)); + filterToggle.classList.toggle("is-active", !!value); + filterMenu.hidden = true; + filterToggle.setAttribute("aria-expanded", "false"); + filterToggle.focus(); + this.filter(); + }); + + option.setAttribute("aria-pressed", String(!value)); + filterMenu.append(option); + } + + this.empty.hidden = true; + this.sheet.append(heading, this.search, this.list, this.empty, folds); + this.sheet.addEventListener("click", (event) => { + const bounds = this.sheet.getBoundingClientRect(); + + if ( + event.target === this.sheet && + (event.clientY < bounds.top || + event.clientX < bounds.left || + event.clientX > bounds.right) + ) + this.close(); + }); + this.sheet.addEventListener("cancel", (event) => { + event.preventDefault(); + this.close(); + }); + this.sheet.addEventListener("close", () => { + const trigger = + document.querySelector(".app-header-files"); + + trigger?.setAttribute("aria-expanded", "false"); + trigger?.focus({ preventScroll: true }); + }); + + const resize = () => { + if (!mobileViewport.matches) this.close(); + }; + + mobileViewport.addEventListener("change", resize); + this._register({ + dispose: () => mobileViewport.removeEventListener("change", resize), + }); + this.element.append(this.sheet); + } + + private close(): void { + if (!this.sheet.open || this.closing) return; + this.closing = true; + this.sheet.classList.add("is-closing"); + // Keep the dialog modal until Safari finishes its exit animation. + const animations = this.sheet.getAnimations({ subtree: true }); + + const finish = () => { + this.closing = false; + this.sheet.close(); + this.sheet.classList.remove("is-closing"); + }; + + if (animations.length) + void Promise.allSettled(animations.map((a) => a.finished)).then(finish); + else finish(); + } + + toggle(): void { + if (this.closing) return; + + if (this.sheet.open) this.close(); + else { + document + .querySelector(".app-header-files") + ?.setAttribute("aria-expanded", "true"); + this.sheet.showModal(); + } + } + + update(): void { + const comparison = this.comparison(); + this.element.hidden = !comparison; + const files = comparison?.fileList ?? []; + const paths = files.map((file) => file.path).join("\0"); + + if (paths === this.paths) { + this.refreshRows(files); + + return; + } + + this.paths = paths; + this.search.value = ""; + this.list.replaceChildren(); + + this.fileRows.length = 0; + this.folders.length = 0; + + const root: FileFolder = { + path: "", + name: "", + folders: new Map(), + files: [], + }; + + for (const file of files) { + const parts = file.path.split("/"); + parts.pop(); + let parent = root; + + for (const name of parts) { + let folder = parent.folders.get(name); + + if (!folder) { + folder = { + name, + path: parent.path ? `${parent.path}/${name}` : name, + folders: new Map(), + files: [], + }; + parent.folders.set(name, folder); + } + + parent = folder; + } + + parent.files.push(file); + } + + this.renderFolder(root, this.list); + this.refreshRows(files); + } + + private renderFolder(folder: FileFolder, parent: HTMLElement): void { + for (let child of [...folder.folders.values()].sort((a, b) => + a.name.localeCompare(b.name), + )) { + let label = child.name; + + // Compact single-child directory chains, as the desktop tree does. + while (!child.files.length && child.folders.size === 1) { + child = child.folders.values().next().value!; + label += `/${child.name}`; + } + + const path = child.path; + const node = element("details", "app-mobile-folder"); + node.open = !this.collapsed.has(path); + const summary = element("summary", "app-mobile-folder-label", label); + summary.title = path; + const group = element("div", "app-mobile-folder-children"); + node.append(summary, group); + parent.append(node); + this.folders.push({ path, node }); + node.addEventListener("toggle", () => { + if (this.search.value.trim() || this.status) return; + + if (node.open) this.collapsed.delete(path); + else this.collapsed.add(path); + }); + this.renderFolder(child, group); + } + + for (const file of folder.files.sort((a, b) => + a.path.localeCompare(b.path), + )) { + const button = actionButton(file.path.split("/").at(-1)!, () => { + this.selectedPath = file.path; + this.close(); + this.comparison()?.openFile(file.path); + this.refreshRows(this.comparison()?.fileList ?? []); + }); + + button.className = "app-mobile-file-row review-changed-files-row"; + const name = file.path.split("/").at(-1)!; + const dot = name.lastIndexOf("."); + const label = element("span", "review-changed-files-label"); + label.append( + element( + "span", + "app-mobile-file-stem", + dot > 0 ? name.slice(0, dot) : name, + ), + ); + + if (dot > 0) + label.append( + element("span", "app-mobile-file-extension", name.slice(dot)), + ); + button.replaceChildren( + element( + "span", + `review-changed-files-icon review-changed-files-icon-${file.status === "copied" ? "renamed" : file.status}`, + ), + label, + element("span", "review-tree-counts"), + ); + button.title = `${file.status}: ${file.path}`; + button.setAttribute("aria-label", file.path); + parent.append(button); + this.fileRows.push({ path: file.path, button }); + } + } + + private refreshRows(files: Comparison["fileList"]): void { + const byPath = new Map(files.map((file) => [file.path, file])); + + const compact = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }); + + for (const { path, button } of this.fileRows) { + const file = byPath.get(path); + + if (!file) continue; + button.dataset.status = + file.status === "copied" ? "renamed" : file.status; + button.setAttribute("aria-current", String(path === this.selectedPath)); + const description = `${file.status}${file.previousPath ? ` from ${file.previousPath}` : ""}, ${file.binary ? "binary file" : `${file.additions} lines added, ${file.deletions} lines deleted`}`; + button.setAttribute("aria-description", description); + button.title = `${path} — ${description}`; + const counts = button.querySelector(".review-tree-counts")!; + + if (file.binary) counts.textContent = "Binary"; + else + counts.replaceChildren( + element( + "span", + `review-tree-added${file.additions ? "" : " is-zero"}`, + `+${compact.format(file.additions).toLowerCase()}`, + ), + element( + "span", + `review-tree-removed${file.deletions ? "" : " is-zero"}`, + `−${compact.format(file.deletions).toLowerCase()}`, + ), + ); + } + + this.filter(); + } + + private filter(): void { + const query = this.search.value.trim().toLowerCase(); + + for (const { path, button } of this.fileRows) + button.hidden = + !path.toLowerCase().includes(query) || + (!!this.status && button.dataset.status !== this.status); + + this.empty.hidden = this.fileRows.some((file) => !file.button.hidden); + + for (const { path, node } of this.folders) { + node.hidden = !this.fileRows.some( + (file) => file.path.startsWith(`${path}/`) && !file.button.hidden, + ); + node.open = + query || this.status ? !node.hidden : !this.collapsed.has(path); + } + } +} + +interface FileFolder { + path: string; + name: string; + folders: Map; + files: Comparison["fileList"][number][]; +} diff --git a/apps/diffr-web/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts new file mode 100644 index 000000000..5de0b8806 --- /dev/null +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -0,0 +1,279 @@ +import { Dimension } from "vs/base/browser/dom.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; +import type { MultiDiffEditorWidget } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; + +import { element } from "./ui.js"; + +export const mobileViewport = window.matchMedia( + "(max-width: 760px), (pointer: coarse) and (max-height: 500px)", +); + +/** Let the browser own touch inertia; Monaco still owns virtualized diffs and structural folds. */ +export class NativeDiffScroll extends Disposable { + readonly viewport = element("div", "app-diff-viewport"); + private readonly scroller = element("div", "app-native-scroll"); + private readonly extent = element("div", "app-native-scroll-extent"); + private enabled = false; + private syncing = false; + private widget: MultiDiffEditorWidget | undefined; + + constructor(parent: HTMLElement) { + super(); + this.extent.append(this.viewport); + this.scroller.append(this.extent); + parent.append(this.scroller); + this.scroller.addEventListener( + "scroll", + () => { + if (!this.enabled || this.syncing || !this.widget) return; + this.syncing = true; + const requested = this.scroller.scrollTop; + this.widget.setScrollTop(requested); + // Measuring an incoming file can move the viewport during setScrollTop. + const measured = this.widget.getScrollTop(); + + if (measured !== requested) this.scroller.scrollTop = measured; + this.syncing = false; + }, + { passive: true }, + ); + + // Monaco's document-level Gesture handler prevents native scrolling, including inertia. + for (const type of ["touchstart", "touchmove", "touchend", "touchcancel"]) { + this.scroller.addEventListener( + type, + (event) => { + if (this.enabled) event.stopPropagation(); + }, + { passive: true }, + ); + } + + // Leave fold pills and gutters to Monaco. Plain text uses the browser's selection handles. + for (const type of ["pointerdown", "mousedown", "contextmenu"]) { + this.scroller.addEventListener( + type, + (event) => { + if (!this.enabled || !(event.target instanceof Element)) return; + + if ( + event.target.closest(".view-line, pre.diff-hidden-lines-detail") && + !event.target.closest( + ".review-fold-pill, .review-fold-hint, .review-fold-closer", + ) + ) + event.stopPropagation(); + }, + true, + ); + } + + const copy = (event: ClipboardEvent) => { + if (!this.enabled) return; + const selection = window.getSelection(); + + if ( + !selection || + selection.isCollapsed || + !selection.anchorNode || + !this.viewport.contains(selection.anchorNode) + ) + return; + // Monaco renders ordinary spaces as non-breaking spaces to preserve code indentation. + event.clipboardData?.setData( + "text/plain", + selection.toString().replace(/\u00a0/g, " "), + ); + + if (event.clipboardData) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + // Copy targets the focused element, which can be outside the selected code. + document.addEventListener("copy", copy, true); + this._register({ + dispose: () => document.removeEventListener("copy", copy, true), + }); + } + + attach(widget: MultiDiffEditorWidget): void { + this.widget = widget; + this._register(widget.onDidChangeContentHeight(() => this.updateExtent())); + this._register( + widget.onDidScroll(() => { + if (!this.enabled || this.syncing) return; + this.syncing = true; + this.scroller.scrollTop = widget.getScrollTop(); + this.syncing = false; + }), + ); + } + + layout(width: number, height: number): void { + const enabled = mobileViewport.matches; + const top = this.widget?.getScrollTop() ?? 0; + const enteringNative = enabled && !this.enabled; + this.enabled = enabled; + this.scroller.classList.toggle("is-native", enabled); + this.viewport.style.height = `${height}px`; + this.widget?.layout(new Dimension(width, height)); + this.updateExtent(); + + // Height changes (including Safari's collapsing toolbar) must not restart native inertia. + if (enteringNative) this.scroller.scrollTop = top; + } + + private updateExtent(): void { + this.extent.style.height = this.enabled + ? `${Math.max(this.scroller.clientHeight, this.widget?.getContentHeight() ?? 0)}px` + : "100%"; + } +} + +/** Monaco owns horizontal geometry; the browser retains vertical touch inertia. */ +export function horizontalTouchScroll(editor: ICodeEditor) { + const node = editor.getContainerDomNode(); + + let gesture: + | { + x: number; + y: number; + lastX: number; + time: number; + horizontal?: boolean; + velocity: number; + surface?: HTMLElement; + } + | undefined; + + let frame = 0; + let suppressClickUntil = 0; + + const position = (surface?: HTMLElement) => + surface ? surface.scrollLeft : editor.getScrollLeft(); + + const scrollTo = (left: number, surface?: HTMLElement) => { + if (surface) surface.scrollLeft = left; + else editor.setScrollLeft(left); + }; + + const stop = () => { + cancelAnimationFrame(frame); + frame = 0; + }; + + const start = (event: TouchEvent) => { + stop(); + suppressClickUntil = 0; + gesture = undefined; + + if ( + !mobileViewport.matches || + event.touches.length !== 1 || + !window.getSelection()?.isCollapsed + ) + return; + const touch = event.touches[0]; + gesture = { + x: touch.clientX, + y: touch.clientY, + lastX: touch.clientX, + time: performance.now(), + velocity: 0, + surface: + event.target instanceof Element + ? (event.target.closest( + "pre.diff-hidden-lines-detail", + ) ?? undefined) + : undefined, + }; + }; + + const move = (event: TouchEvent) => { + if (!gesture || event.touches.length !== 1) { + gesture = undefined; + + return; + } + + const x = event.touches[0].clientX; + const y = event.touches[0].clientY; + + if (gesture.horizontal === undefined) { + if (Math.max(Math.abs(x - gesture.x), Math.abs(y - gesture.y)) < 8) + return; + gesture.horizontal = Math.abs(x - gesture.x) > Math.abs(y - gesture.y); + } + + if (!gesture.horizontal) return; + event.preventDefault(); + const now = performance.now(); + const delta = gesture.lastX - x; + gesture.velocity = delta / Math.max(1, now - gesture.time); + scrollTo(position(gesture.surface) + delta, gesture.surface); + suppressClickUntil = now + 500; + gesture.lastX = x; + gesture.time = now; + }; + + const end = () => { + const finished = gesture; + gesture = undefined; + + if (!finished?.horizontal || performance.now() - finished.time > 100) + return; + let velocity = finished.velocity; + let last = performance.now(); + + const coast = (now: number) => { + const elapsed = Math.min(32, now - last); + last = now; + const before = position(finished.surface); + scrollTo(before + velocity * elapsed, finished.surface); + velocity *= Math.exp(-elapsed / 180); + + if ( + Math.abs(velocity) > 0.02 && + position(finished.surface) !== before && + mobileViewport.matches + ) + frame = requestAnimationFrame(coast); + }; + + frame = requestAnimationFrame(coast); + }; + + const cancel = () => { + gesture = undefined; + stop(); + }; + + const click = (event: MouseEvent) => { + if (performance.now() < suppressClickUntil) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + + node.addEventListener("click", click, true); + node.addEventListener("touchstart", start, { passive: true }); + node.addEventListener("touchmove", move, { passive: false }); + node.addEventListener("touchend", end); + node.addEventListener("touchcancel", cancel); + const modelChange = editor.onDidChangeModel(cancel); + + return { + dispose() { + cancel(); + modelChange.dispose(); + node.removeEventListener("click", click, true); + node.removeEventListener("touchstart", start); + node.removeEventListener("touchmove", move); + node.removeEventListener("touchend", end); + node.removeEventListener("touchcancel", cancel); + }, + }; +} diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts index 9c4c2a1a3..594b56e00 100644 --- a/apps/diffr-web/src/panels.ts +++ b/apps/diffr-web/src/panels.ts @@ -1,4 +1,6 @@ /** The diff stats (F2) and engine (F3) panels: a card over the foot of the file tree. */ +import { cacheUsage } from "./cache.js"; +import { megabytes } from "./cacheSection.js"; import type { Comparison } from "./comparison.js"; import { engineStats } from "./engine/engine.js"; import { readSetting, writeSetting } from "./settings.js"; @@ -12,8 +14,6 @@ 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(); @@ -97,6 +97,13 @@ export class Panels { ], ]; + if (work.cached) + rows.push([ + "From cache", + number(work.cached), + "Files this browser had diffed before, shown without fetching or diffing them again", + ]); + if (work.hidden) rows.push([ "Hidden by diffr", @@ -115,6 +122,33 @@ export class Panels { if (work.failed) rows.push(["Failed", number(work.failed)]); + if (work.summarizing || work.summarized) + rows.push([ + "Summarized", + work.summarizing + ? `${number(work.summarized)} · ${number(work.summarizing)} waiting` + : number(work.summarized), + "Files whose long new bodies the model has summarized; they arrive after the file shows", + ]); + + if (work.summaryErrors.length) + rows.push([ + "Summaries failed", + number(work.summaryErrors.length), + work.summaryErrors + .map(({ path, message }) => `${path}: ${message}`) + .join("\n"), + ]); + + if (work.pluginErrors.length) + rows.push([ + "Plugin errors", + number(work.pluginErrors.length), + work.pluginErrors + .map(({ path, message }) => `${path}: ${message}`) + .join("\n"), + ]); + return rows; } @@ -127,7 +161,7 @@ export class Panels { [ "Workers", `${stats.workers} of ${stats.maxWorkers}`, - "Workers grow while files queue and shrink after ten idle seconds", + "Workers grow while files queue and all stop after three idle seconds", ], ["Engine download", stats.bytes ? megabytes(stats.bytes) : "…"], ["Engine compiled", at(stats.compiled)], @@ -156,6 +190,12 @@ export class Panels { stats.memory ? megabytes(stats.memory) : "…", "The largest wasm memory a worker holds; it never shrinks", ]); + const cache = cacheUsage(); + rows.push([ + "Cache", + `${number(cache.entries)} · ${megabytes(cache.bytes)}`, + "Results kept in this browser; clear them in Settings", + ]); return rows; } diff --git a/apps/diffr-web/src/pluginsSection.ts b/apps/diffr-web/src/pluginsSection.ts new file mode 100644 index 000000000..2baff0702 --- /dev/null +++ b/apps/diffr-web/src/pluginsSection.ts @@ -0,0 +1,84 @@ +/** The settings' list of plugins agents installed (agent.ts): each one's code, on or off, or gone. */ +import { Engine } from "./engine/engine.js"; +import { + type AgentPlugin, + agentPlugins, + configOverrides, + readSetting, + setAgentPlugins, +} from "./settings.js"; +import { actionButton, element, rich } from "./ui.js"; + +export function agentPluginsSection( + parent: HTMLElement, + replaceEngine: (engine: Engine) => void, +): void { + const section = parent.appendChild(element("section")); + section.appendChild(element("h3", undefined, "Agent plugins")); + section + .appendChild(element("p")) + .appendChild( + rich( + "Plugins an agent wrote for this page through its WebMCP tools (`diffr_install_plugin`). Each folds parts of a file diffr's own plugins leave open, runs on every comparison in this browser. It runs as any script on this page does, so keep only code you trust.", + ), + ); + const list = section.appendChild(element("ul", "app-plugin-list")); + const status = section.appendChild(element("p", "app-dialog-status")); + + const apply = (next: AgentPlugin[]) => { + const previous = agentPlugins(); + setAgentPlugins(next); + const engine = new Engine(readSetting("config"), configOverrides()); + status.textContent = "Checking…"; + engine.notices.then( + (notices) => { + status.textContent = notices.join("\n"); + replaceEngine(engine); + render(); + }, + (error: Error) => { + engine.dispose(); + setAgentPlugins(previous); + status.textContent = error.message; + }, + ); + }; + + const render = () => { + list.replaceChildren(); + const plugins = agentPlugins(); + + if (!plugins.length) + list.appendChild(element("li", "app-plugin-empty", "None yet.")); + + for (const plugin of plugins) { + const row = list.appendChild(element("li", "app-plugin")); + const toggle = element("input"); + toggle.type = "checkbox"; + toggle.checked = plugin.enabled; + toggle.setAttribute("aria-label", `Run ${plugin.name}`); + toggle.addEventListener("change", () => + apply( + plugins.map((other) => + other === plugin ? { ...other, enabled: toggle.checked } : other, + ), + ), + ); + const head = row.appendChild(element("div", "app-plugin-head")); + head.appendChild(toggle); + head.appendChild(element("strong", undefined, plugin.name)); + head.appendChild(element("span", "app-spacer")); + head.appendChild( + actionButton("Remove", () => + apply(plugins.filter((other) => other !== plugin)), + ), + ); + row.appendChild(element("p", undefined, plugin.description)); + const code = row.appendChild(element("details")); + code.appendChild(element("summary", undefined, "Code")); + code.appendChild(element("pre")).textContent = plugin.code; + } + }; + + render(); +} diff --git a/apps/diffr-web/src/quickOpen.ts b/apps/diffr-web/src/quickOpen.ts new file mode 100644 index 000000000..e9725f9b4 --- /dev/null +++ b/apps/diffr-web/src/quickOpen.ts @@ -0,0 +1,202 @@ +/** + * ⌘/Ctrl+P: go to a changed file by name, scored as VS Code's Quick Open scores files: the name + * first, then its folder. + */ +import type { IMatch } from "vs/base/common/filters.js"; +import { + type FuzzyScorerCache, + type IItemAccessor, + compareItemsByFuzzyScore, + prepareQuery, + scoreItemFuzzy, +} from "vs/base/common/fuzzyScorer.js"; + +export interface QuickOpenFile { + path: string; + status: "added" | "deleted" | "modified" | "renamed" | "copied"; + /** Why diffr folds the file, if it does. */ + hidden?: string; +} + +const MAX_RESULTS = 200; + +const name = (path: string) => path.slice(path.lastIndexOf("/") + 1); + +const folder = (path: string) => + path.slice(0, Math.max(0, path.lastIndexOf("/"))); + +const accessor: IItemAccessor = { + getItemLabel: (file) => name(file.path), + getItemDescription: (file) => folder(file.path), + getItemPath: (file) => file.path, +}; + +const statusIcon: Record = { + added: "diff-added", + deleted: "diff-removed", + modified: "diff-modified", + renamed: "diff-renamed", + copied: "diff-renamed", +}; + +/** Text with its matched characters in bold. */ +function highlighted(text: string, matches: IMatch[] | undefined): Node { + const fragment = document.createDocumentFragment(); + let at = 0; + + for (const { start, end } of matches ?? []) { + fragment.append(text.slice(at, start)); + fragment.appendChild(document.createElement("mark")).textContent = + text.slice(start, end); + at = end; + } + + fragment.append(text.slice(at)); + + return fragment; +} + +let open: HTMLElement | undefined; + +/** Show the picker over `host`; `onPick` gets the chosen path. A second call closes it. */ +export function quickOpen( + host: HTMLElement, + files: readonly QuickOpenFile[], + onPick: (path: string) => void, +): void { + if (open) { + const widget = open; + open = undefined; + widget.remove(); + + return; + } + + const restore = + document.activeElement instanceof HTMLElement + ? document.activeElement + : undefined; + + const widget = host.appendChild(document.createElement("div")); + widget.className = "app-quick-open"; + widget.setAttribute("role", "dialog"); + widget.setAttribute("aria-label", "Go to file"); + open = widget; + + const input = widget.appendChild(document.createElement("input")); + input.placeholder = "Go to a changed file"; + input.spellcheck = false; + input.setAttribute("aria-label", "File name"); + input.setAttribute("role", "combobox"); + input.setAttribute("aria-controls", "app-quick-open-list"); + input.setAttribute("aria-expanded", "true"); + + const list = widget.appendChild(document.createElement("ul")); + list.id = "app-quick-open-list"; + list.setAttribute("role", "listbox"); + + let shown: QuickOpenFile[] = []; + let active = 0; + const cache: FuzzyScorerCache = {}; + + const close = (focus = true) => { + if (open !== widget) return; + // Removing the input blurs it, which closes again. + open = undefined; + widget.remove(); + + if (focus) restore?.focus({ preventScroll: true }); + }; + + const pick = (file: QuickOpenFile | undefined) => { + if (!file) return; + close(false); + onPick(file.path); + }; + + const select = (index: number) => { + if (!shown.length) return; + active = (index + shown.length) % shown.length; + + for (const [i, row] of [...list.children].entries()) { + row.setAttribute("aria-selected", String(i === active)); + + if (i === active) { + row.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", row.id); + } + } + }; + + const render = () => { + const query = prepareQuery(input.value); + + shown = query.normalized + ? files + .filter( + (file) => + scoreItemFuzzy(file, query, true, accessor, cache).score > 0, + ) + .sort((a, b) => + compareItemsByFuzzyScore(a, b, query, true, accessor, cache), + ) + .slice(0, MAX_RESULTS) + : files.slice(0, MAX_RESULTS); + list.replaceChildren(); + + for (const [index, file] of shown.entries()) { + const score = query.normalized + ? scoreItemFuzzy(file, query, true, accessor, cache) + : undefined; + + const row = list.appendChild(document.createElement("li")); + row.id = `app-quick-open-${index}`; + row.setAttribute("role", "option"); + const icon = row.appendChild(document.createElement("span")); + icon.className = `app-quick-open-icon codicon codicon-${statusIcon[file.status]} is-${file.status}`; + const label = row.appendChild(document.createElement("span")); + label.className = "app-quick-open-name"; + label.appendChild(highlighted(name(file.path), score?.labelMatch)); + const description = row.appendChild(document.createElement("span")); + description.className = "app-quick-open-folder"; + description.appendChild( + highlighted(folder(file.path), score?.descriptionMatch), + ); + + if (file.hidden) { + const note = row.appendChild(document.createElement("span")); + note.className = "app-quick-open-note"; + note.textContent = file.hidden; + } + + row.addEventListener("mousedown", (event) => event.preventDefault()); + row.addEventListener("click", () => pick(file)); + } + + if (!shown.length) { + const empty = list.appendChild(document.createElement("li")); + empty.className = "app-quick-open-empty"; + empty.textContent = "No matching files"; + } + + select(0); + }; + + input.addEventListener("input", render); + input.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) + select(active + 1); + else if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) + select(active - 1); + else if (event.key === "PageDown") select(active + 10); + else if (event.key === "PageUp") select(active - 10); + else if (event.key === "Enter") pick(shown[active]); + else if (event.key === "Escape") close(); + else return; + event.preventDefault(); + event.stopPropagation(); + }); + input.addEventListener("blur", () => close(false)); + render(); + input.focus(); +} diff --git a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts index 68ee46a11..0907979ed 100644 --- a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts +++ b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts @@ -394,10 +394,17 @@ export class ReviewChangedFilesTree extends Disposable { if (element) this.tree.rerender(element); } - setActiveFile(path: string | undefined, reveal = true): void { - if (!reveal && this.activePath === path) return; + /** + * Select `path`. `"follow"` scrolls the tree only when the active file changes, so a reader who + * scrolled the tree away keeps their place until the diff moves on to another file. + */ + setActiveFile( + path: string | undefined, + reveal: boolean | "follow" = true, + ): void { + if (reveal !== true && this.activePath === path) return; this.activePath = path; - this.syncSelection(path, reveal); + this.syncSelection(path, reveal !== false); } layout(height: number, width: number): void { diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index e99574cef..596fbe791 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -2,7 +2,6 @@ import { $, addDisposableListener, append, - Dimension, getWindow, scheduleAtNextAnimationFrame, } from "vs/base/browser/dom.js"; @@ -10,14 +9,14 @@ import { Orientation, SplitView, } from "vs/base/browser/ui/splitview/splitview.js"; +import { disposableTimeout } from "vs/base/common/async.js"; +import { Emitter, Event } from "vs/base/common/event.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, @@ -32,7 +31,11 @@ import { 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 { + type ICodeEditor, + type IDiffEditor, + isDiffEditor, +} 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"; @@ -41,7 +44,10 @@ import { 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 { + EditorOption, + 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"; @@ -49,6 +55,8 @@ 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 { showLineNumbers } from "../../display.js"; +import { mobileViewport, NativeDiffScroll } from "../../nativeDiffScroll.js"; import type { ReviewDiffProgress, ReviewDiffLens, @@ -76,17 +84,18 @@ import { 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 DIFF_MINIMUM_WIDTH = 0; +const FILE_TREE_COLLAPSE_WIDTH = 760; const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { hideUnchangedRegions: { enabled: true }, originalEditable: false, readOnly: true, + domReadOnly: true, + renderLineHighlight: "none", + cursorBlinking: "solid", 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', @@ -120,6 +129,8 @@ export class ReviewFilesEditorInput extends Disposable { private readonly documents = new Map>(); private current: readonly RefCounted[] = []; private loading: boolean; + private ready: ReadonlySet = new Set(); + private listed: ReadonlySet | undefined; constructor( readonly entries: readonly ReviewFilesEditorEntry[], @@ -142,11 +153,23 @@ export class ReviewFilesEditorInput extends Disposable { } setReadyFiles(paths: ReadonlySet, loading: boolean): void { + this.ready = paths; this.loading = loading; + this.update(); + } + + /** List only `paths`, as a lens does; `undefined` lists every ready file. */ + setListedFiles(paths: ReadonlySet | undefined): void { + this.listed = paths; + this.update(); + } + + private update(): void { this.current = this.entries.flatMap((entry) => { - if (!paths.has(entry.file.path)) return []; - const document = - this.documents.get(entry.file.path) ?? this.createDocument(entry); + const path = entry.file.path; + if (!this.ready.has(path) || (this.listed && !this.listed.has(path))) + return []; + const document = this.documents.get(path) ?? this.createDocument(entry); return document ? [document] : []; }); this.changes.fire(); @@ -170,6 +193,12 @@ export class ReviewFilesEditorInput extends Disposable { const text = (side: "lhs" | "rhs") => diff.type === "text" ? (diff[side]?.text ?? "") : ""; const wordWrap = this.wordWrap; + // Line numbers sit right-aligned against the split's sash; one spare column keeps the longest + // clear of it. + const longest = Math.max( + text("lhs").split("\n").length, + text("rhs").split("\n").length, + ); // Every hidden line comes from the one fold model: diffr's regions, // never the diff editor's own unchanged-region hiding. const options = { @@ -182,6 +211,10 @@ export class ReviewFilesEditorInput extends Disposable { contextLineCount: 0, }, folding: false, + lineNumbersMinChars: Math.max( + REVIEW_FILES_DIFF_EDITOR_OPTIONS.lineNumbersMinChars, + String(longest).length + 1, + ), // 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. @@ -197,10 +230,28 @@ export class ReviewFilesEditorInput extends Disposable { get options() { return { ...options, + lineNumbers: showLineNumbers.get() + ? ("on" as const) + : ("off" as const), + hideOriginalLineNumbers: + mobileViewport.matches || options.hideOriginalLineNumbers, + glyphMargin: !mobileViewport.matches && options.glyphMargin, + lineNumbersMinChars: mobileViewport.matches + ? Math.max(3, String(longest).length) + : options.lineNumbersMinChars, + lineHeight: mobileViewport.matches ? 24 : 0, + fontSize: mobileViewport.matches ? 12 : options.fontSize, + lineDecorationsWidth: mobileViewport.matches + ? 24 + : options.lineDecorationsWidth, wordWrap: wordWrap.get() ? ("on" as const) : ("off" as const), }; }, - onOptionsDidChange: Event.fromObservableLight(wordWrap), + onOptionsDidChange: Event.any( + Event.fromObservableLight(wordWrap), + Event.fromObservableLight(showLineNumbers), + Event.fromDOMEventEmitter(mobileViewport, "change"), + ), }; const document = RefCounted.createOfNonDisposable(item, store, this); this.documents.set(entry.file.path, document); @@ -272,6 +323,7 @@ export class ReviewFilesDiffView extends Disposable { private readonly hiddenFiles = new Map(); private readonly hiddenApplied = new Set(); private pendingPath: string | undefined; + private pendingOpen = false; private pendingSectionId: string | undefined; private pendingSource: ReviewDiffLens["ranges"][number] | undefined; private progress: ReviewDiffProgress | undefined; @@ -282,7 +334,9 @@ export class ReviewFilesDiffView extends Disposable { private readonly streamStatus: HTMLElement; private offscreen = false; private layoutDeferred = false; + private sideBySide = true; viewedScope: "lens" | undefined; + private listedPaths: ReadonlySet | undefined; constructor( private readonly container: HTMLElement, @@ -401,19 +455,30 @@ export class ReviewFilesDiffView extends Disposable { if (document) factory.bottomScrollPadding = 0; this.headerFactory = factory; + const nativeScroll = this._register(new NativeDiffScroll(diffContainer)); + this.widget = this._register( this.reviewInstantiationService.createInstance( MultiDiffEditorWidget, - diffContainer, + nativeScroll.viewport, factory, undefined, ), ); + nativeScroll.attach(this.widget); // The widget's own switch, not the per-item option refresh: it pins the // width heuristic off, so the chosen layout is what renders at any width. - const applyLayout = () => - this.widget.setRenderSideBySide(layout.get() === "split"); + const applyLayout = () => { + this.sideBySide = !mobileViewport.matches && layout.get() === "split"; + this.widget.setRenderSideBySide(this.sideBySide); + }; this._register(layout.onDidChange(applyLayout)); + mobileViewport.addEventListener("change", applyLayout); + this._register( + toDisposable(() => + mobileViewport.removeEventListener("change", applyLayout), + ), + ); applyLayout(); if (document) { this._register( @@ -491,7 +556,7 @@ export class ReviewFilesDiffView extends Disposable { element: diffContainer, layout: (width, _offset, height) => { diffContainer.style.width = `${width}px`; - this.widget.layout(new Dimension(width, height ?? 0)); + nativeScroll.layout(width, height ?? 0); }, maximumSize: Number.POSITIVE_INFINITY, minimumSize: DIFF_MINIMUM_WIDTH, @@ -537,13 +602,8 @@ export class ReviewFilesDiffView extends Disposable { this.settleHold.clear(); this.pendingViewState = viewState; this.input = input; - this.changedFilesTree?.setFiles( - Array.from( - new Map( - input.entries.map((entry) => [entry.file.path, entry.file]), - ).values(), - ), - ); + input.setListedFiles(this.listedPaths); + this.listFiles(); const viewModel = await input.getViewModel(); if (this._store.isDisposed) return; this.viewModel = viewModel; @@ -583,6 +643,7 @@ export class ReviewFilesDiffView extends Disposable { item.collapsed.set(this.documentCollapsed, undefined); } this.applyHiddenFiles(items); + this.applyFileFolds(); this.applyViewedFiles(); const entry = this.input?.entries.find( (e) => e.file.path === this.pendingPath, @@ -602,7 +663,11 @@ export class ReviewFilesDiffView extends Disposable { if (!this._store.isDisposed) { if (this.pendingSource) this.revealSource(this.pendingSource, this.pendingSectionId); - else this.reveal(entry); + else { + if (this.pendingOpen) + this.itemFor(entry)?.collapsed.set(false, undefined); + this.reveal(entry); + } } }); }), @@ -638,6 +703,26 @@ export class ReviewFilesDiffView extends Disposable { this.showStreamStatus(); } + /** List only `paths` in the tree and summary, as a lens does; `undefined` lists every file. */ + listOnly(paths: ReadonlySet | undefined): void { + this.listedPaths = paths; + this.input?.setListedFiles(paths); + this.listFiles(); + this.renderSummary(); + } + + private listFiles(): void { + this.changedFilesTree?.setFiles( + Array.from( + new Map( + (this.input?.entries ?? []) + .filter((entry) => this.listedPaths?.has(entry.file.path) ?? true) + .map((entry) => [entry.file.path, entry.file]), + ).values(), + ), + ); + } + /** Coverage counts are independent of fold state. */ fileCounts(path: string, counts: ReviewFileCounts): void { this.streamStats.set(path, { @@ -668,9 +753,12 @@ export class ReviewFilesDiffView extends Disposable { private renderSummary(): void { if (this.fileTreeContainer || !this.input) return; const previousHeight = this.summary.offsetHeight; - const paths = new Set( + const all = new Set( (this.input?.entries ?? []).map((entry) => entry.file.path), ); + const paths = this.listedPaths + ? new Set([...all].filter((path) => this.listedPaths!.has(path))) + : all; const complete = [...paths].every((path) => this.streamStats.has(path)); const total = { added: 0, removed: 0 }; const remaining = { added: 0, removed: 0 }; @@ -692,7 +780,9 @@ export class ReviewFilesDiffView extends Disposable { this.summary, $("span.review-files-editor-summary-files"), ); - files.textContent = `${paths.size} files`; + files.textContent = this.listedPaths + ? `${paths.size} of ${all.size} files` + : `${paths.size} files`; if (complete) { append(this.summary, $("span")).textContent = "Remaining"; const format = new Intl.NumberFormat("en", { @@ -767,6 +857,29 @@ export class ReviewFilesDiffView extends Disposable { } } + /** Files to fold or unfold once they join the list. */ + private readonly fileFolds = new Map(); + + /** Fold or unfold a file's body, as its header's chevron does, now or once it is listed. */ + setFileCollapsed(path: string, collapsed: boolean): void { + this.fileFolds.set(path, collapsed); + this.applyFileFolds(); + } + + private applyFileFolds(): void { + for (const [path, collapsed] of this.fileFolds) { + const entry = this.input?.entries.find( + (entry) => entry.file.path === path, + ); + const item = entry && this.itemFor(entry); + if (!item) continue; + // After a hidden file's own fold, which waits for the same item. + this.hiddenApplied.add(path); + item.collapsed.set(collapsed, undefined); + this.fileFolds.delete(path); + } + } + private readonly collapsedSections = new Set(); private readonly sectionViewed = new Map(); private entryProgress(entry: ReviewFilesEditorEntry) { @@ -859,14 +972,94 @@ export class ReviewFilesDiffView extends Disposable { this.pendingSource = undefined; } - /** Scroll to a file, or to it once its diff has loaded. */ - revealFile(path: string): void { + /** Scroll to a file, or to it once its diff has loaded; `open` also unfolds a folded file. */ + revealFile(path: string, open = false): void { this.settleHold.clear(); const entry = this.input?.entries.find((entry) => entry.file.path === path); if (!entry) return; this.pendingPath = this.fileStates.has(path) ? path : undefined; + this.pendingOpen = open; this.showStreamStatus(); - if (!this.pendingPath) this.reveal(entry); + if (this.pendingPath) return; + if (open) this.itemFor(entry)?.collapsed.set(false, undefined); + this.reveal(entry); + } + + /** + * Scroll so one side's `line` (1-based) of a diffed file sits a third of the way down the list, + * opening the file if it is folded. Its folds are the session's; open them first. + */ + revealLine(path: string, side: "original" | "modified", line: number): void { + this.settleHold.clear(); + const entry = this.input?.entries.find((entry) => entry.file.path === path); + if (!entry || !this.readyFiles.has(path)) return; + this.itemFor(entry)?.collapsed.set(false, undefined); + this.widget.reveal(entry, { highlight: false }); + // The file renders, and its folds open, over the next frames. + this.settle(() => { + const offset = this.lineOffset(entry, side, line); + if (offset === undefined) return; + const delta = offset - this.diffContainer.clientHeight / 3; + if (Math.abs(delta) > 1) + this.widget.setScrollTop(this.widget.getScrollTop() + delta); + }); + } + + /** The editor that shows one side of a file, while the file is rendered. */ + codeEditor( + path: string, + side: "original" | "modified", + ): ICodeEditor | undefined { + const entry = this.input?.entries.find((entry) => entry.file.path === path); + const diffEditor = + entry?.modified && + this.widget.tryGetCodeEditor(entry.modified)?.diffEditor; + if (!isDiffEditor(diffEditor)) return undefined; + // Unified, the base side's lines are drawn inside the head side's editor. + return side === "original" && this.sideBySide + ? diffEditor.getOriginalEditor() + : diffEditor.getModifiedEditor(); + } + + /** Pixels from the top of the list to one side's line, as it is laid out now. */ + private lineOffset( + entry: ReviewFilesEditorEntry, + side: "original" | "modified", + line: number, + ): number | undefined { + const diffEditor = + entry.modified && + this.widget.tryGetCodeEditor(entry.modified)?.diffEditor; + const editor = this.codeEditor(entry.file.path, side); + const node = editor?.getDomNode(); + if (!isDiffEditor(diffEditor) || !editor || !node) return undefined; + let top: number | undefined; + if (side === "original" && !this.sideBySide) { + // A removed line sits in the block drawn above its change's head lines; any other base line + // is shifted by the changes before it. + const lineHeight = editor.getOption(EditorOption.lineHeight); + let shift = 0; + for (const change of diffEditor.getDiffComputationResult()?.changes2 ?? + []) { + if (line < change.original.startLineNumber) break; + if (line < change.original.endLineNumberExclusive) { + top = + editor.getTopForLineNumber(change.modified.startLineNumber) - + (change.original.endLineNumberExclusive - line) * lineHeight; + break; + } + shift = + change.modified.endLineNumberExclusive - + change.original.endLineNumberExclusive; + } + top ??= editor.getTopForLineNumber(line + shift); + } else top = editor.getTopForLineNumber(line); + return ( + node.getBoundingClientRect().top - + this.diffContainer.getBoundingClientRect().top + + top - + editor.getScrollTop() + ); } /** The file at the top of the list, among those diffed. */ @@ -1002,7 +1195,8 @@ export class ReviewFilesDiffView extends Disposable { const fileTreeVisible = !this.fileTreeContainer && !this.fileTreeHidden && - width >= FILE_TREE_COLLAPSE_WIDTH; + !mobileViewport.matches && + width > FILE_TREE_COLLAPSE_WIDTH; if ( !this.fileTreeContainer && !this.document && @@ -1082,8 +1276,12 @@ export class ReviewFilesDiffView extends Disposable { sameResource(entry.modified, resource.modified), ); if (index === -1) return; - // Passive editor updates must not move a sidebar the reader scrolled independently. - this.changedFilesTree?.setActiveFile(input.entries[index].file.path, false); + // The tree follows the diff from file to file, but leaves a sidebar the reader scrolled alone + // while the same file stays on top. + this.changedFilesTree?.setActiveFile( + input.entries[index].file.path, + "follow", + ); } } function sameResource(left: URI | undefined, right: URI | undefined): boolean { diff --git a/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts b/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts index 6416817e0..d44b9ca1a 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts @@ -120,6 +120,34 @@ export class StructuralDiffSession extends Disposable { this.notify(path, this.complete); } + /** + * A later pass over a file's same text, as the summarizer's: its regions' labels and default + * folds change. A fold the reader has moved keeps the reader's state. + */ + updateFileDiff(path: string, diff: StructuralTextDiff): void { + const result = this.results.get(path); + const previous = result?.diff; + if (this.disposed || !result || previous?.type !== "text") return; + const defaults = (source: StructuralTextDiff) => { + const states = new Map(); + const visit = (region: StructuralRegion) => { + states.set(region.fold_state_id, region.visibility?.collapsed === true); + if (region.kind === "fold") region.children.forEach(visit); + }; + for (const side of [source.lhs, source.rhs]) if (side) visit(side.root); + return states; + }; + const before = defaults(previous); + for (const [id, collapsed] of defaults(diff)) { + const key = `${path}:${id}`; + const current = this.folds.get(key); + if (current === undefined || current === before.get(id)) + this.folds.set(key, collapsed); + } + this.results.set(path, { ...result, diff }); + this.notify(path); + } + /** The comparison could not be loaded at all. */ fail(error: string): void { this.error = error; diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index f810d7376..98b7a9d6b 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -29,6 +29,10 @@ import { } from "vs/editor/common/model.js"; import { nestedIds } from "../../folds.js"; +import { + horizontalTouchScroll, + mobileViewport, +} from "../../nativeDiffScroll.js"; import { bandDetail, structuralFoldables, @@ -89,6 +93,20 @@ export class StructuralFoldControls extends Disposable { StructuralFoldable[] >(); private readonly rails = new ScopeRails(this.editor); + private readonly touchFolds = new TouchFoldButtons( + this.editor, + (foldable) => { + this.toggle( + { + foldable, + collapsed: this.isFolded(foldable) || this.isSummaryFolded(foldable), + rail: false, + control: true, + }, + false, + ); + }, + ); private hovered: FoldTarget | undefined; private overChevronColumn = false; private pressed: FoldTarget | undefined; @@ -106,6 +124,8 @@ export class StructuralFoldControls extends Disposable { ) { super(); controls.set(editor, this); + this.installPinchFolding(); + this._register(horizontalTouchScroll(this.editor)); this._register({ dispose: () => controls.delete(editor) }); this._register({ dispose: () => { @@ -120,7 +140,8 @@ export class StructuralFoldControls extends Disposable { this.sharedHover.state.set(undefined, undefined); } this.decorations.clear(); - this.rails.hide(); + this.rails.dispose(); + this.touchFolds.dispose(); }, }); this._register( @@ -161,7 +182,7 @@ export class StructuralFoldControls extends Disposable { ); // Folding changes which lines show, and so the rail's length. this._register(editor.onDidContentSizeChange(() => this.render())); - this._register(editor.onDidScrollChange(() => this.render())); + this._register(editor.onDidScrollChange(() => this.render(false))); this._register(editor.onDidLayoutChange(() => this.render())); this._register(editor.onDidChangeViewZones(() => this.render())); this._register(editor.onDidChangeConfiguration(() => this.render())); @@ -263,6 +284,97 @@ export class StructuralFoldControls extends Disposable { ); } + private installPinchFolding(): void { + const node = this.editor.getContainerDomNode(); + let pinch: + | { distance: number; line: number; y: number; done: boolean } + | undefined; + const distance = (touches: TouchList) => + Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ); + const start = (event: TouchEvent) => { + if (!mobileViewport.matches || event.touches.length !== 2) { + pinch = undefined; + return; + } + event.preventDefault(); + const x = (event.touches[0].clientX + event.touches[1].clientX) / 2; + const y = (event.touches[0].clientY + event.touches[1].clientY) / 2; + const position = this.editor.getTargetAtClientPoint(x, y)?.position; + pinch = { + distance: distance(event.touches), + line: position ? position.lineNumber - 1 : -1, + y, + done: false, + }; + }; + const move = (event: TouchEvent) => { + if (!pinch || event.touches.length !== 2) return; + event.preventDefault(); + if (pinch.done) return; + const delta = distance(event.touches) - pinch.distance; + if (Math.abs(delta) < Math.max(24, pinch.distance * 0.18)) return; + pinch.done = true; + const path = this.path(); + const diff = path ? this.session.getTextDiff(path) : undefined; + if (!path || !diff) return; + const collapse = delta < 0; + const line = pinch.line; + const localY = pinch.y - node.getBoundingClientRect().top; + const candidates = this.foldablesOf(diff).filter( + (f) => f.chevron && this.isCollapsed(path, f) !== collapse, + ); + const containing = candidates.filter( + (f) => + f.line === line || + (collapse && f.rail && f.rail.start <= line && line <= f.rail.end), + ); + const foldable = + containing.sort( + (a, b) => + (a.rail ? a.rail.end - a.rail.start : 0) - + (b.rail ? b.rail.end - b.rail.start : 0), + )[0] ?? + candidates + .map((f) => ({ + f, + distance: Math.abs( + this.editor.getTopForLineNumber(f.line + 1) - + this.editor.getScrollTop() + + this.editor.getOption(EditorOption.lineHeight) / 2 - + localY, + ), + })) + .filter((f) => f.distance <= 44) + .sort((a, b) => a.distance - b.distance)[0]?.f; + if (foldable) this.setCollapsed(path, diff, foldable, collapse, false); + }; + const end = () => { + pinch = undefined; + }; + const gesture = (event: Event) => { + if (mobileViewport.matches) event.preventDefault(); + }; + node.addEventListener("touchstart", start, { passive: false }); + node.addEventListener("touchmove", move, { passive: false }); + node.addEventListener("touchend", end); + node.addEventListener("touchcancel", end); + node.addEventListener("gesturestart", gesture); + node.addEventListener("gesturechange", gesture); + this._register({ + dispose: () => { + node.removeEventListener("touchstart", start); + node.removeEventListener("touchmove", move); + node.removeEventListener("touchend", end); + node.removeEventListener("touchcancel", end); + node.removeEventListener("gesturestart", gesture); + node.removeEventListener("gesturechange", gesture); + }, + }); + } + /** Recursively, every fold inside takes the same state. */ private setCollapsed( path: string, @@ -315,7 +427,7 @@ export class StructuralFoldControls extends Disposable { } } - private render(): void { + private render(updateDecorations = true): void { const model = this.editor.getModel(); const path = this.path(); const diff = path ? this.session.getTextDiff(path) : undefined; @@ -326,50 +438,54 @@ export class StructuralFoldControls extends Disposable { if (!model || !path || !diff) { this.decorations.clear(); this.rails.hide(); + this.touchFolds.hide(); for (const control of this.viewedControls.values()) control.hide(); return; } const decorations: IModelDeltaDecoration[] = []; - for (const range of this.viewed?.getViewedRanges(path) ?? []) { - if (range.side !== (this.side === "rhs" ? "head" : "base")) continue; - decorations.push({ - range: new Range( - range.fromLine, - 1, - range.toLine, - model.getLineMaxColumn(range.toLine), - ), - options: { - description: "review-scope-viewed", - isWholeLine: true, - inlineClassName: "review-scope-viewed-ink", - className: "review-scope-viewed-tint", - zIndex: 5, - }, - }); - } - for (const foldable of this.foldablesOf(diff)) { - // A function and its doc comment can share one fold state. Every - // member participates in hover, rather than only the first match. - const active = target?.foldable.foldStateId === foldable.foldStateId; - if (this.isFolded(foldable)) { - decorations.push(...this.folded(model, foldable, active)); - } else if (active) { - decorations.push( - ...this.lit(model, { - ...target!, - foldable, - collapsed: this.isSummaryFolded(foldable), - }), - ); - } else if ( - shared?.column && - foldable.chevron && - (!this.isCollapsed(path, foldable) || this.isSummaryFolded(foldable)) - ) { - decorations.push( - this.chevron(foldable, this.isSummaryFolded(foldable), false), - ); + // Scrolling only moves controls; replacing model decorations also invalidates text layout. + if (updateDecorations) { + for (const range of this.viewed?.getViewedRanges(path) ?? []) { + if (range.side !== (this.side === "rhs" ? "head" : "base")) continue; + decorations.push({ + range: new Range( + range.fromLine, + 1, + range.toLine, + model.getLineMaxColumn(range.toLine), + ), + options: { + description: "review-scope-viewed", + isWholeLine: true, + inlineClassName: "review-scope-viewed-ink", + className: "review-scope-viewed-tint", + zIndex: 5, + }, + }); + } + for (const foldable of this.foldablesOf(diff)) { + // A function and its doc comment can share one fold state. Every + // member participates in hover, rather than only the first match. + const active = target?.foldable.foldStateId === foldable.foldStateId; + if (this.isFolded(foldable)) { + decorations.push(...this.folded(model, foldable, active)); + } else if (active) { + decorations.push( + ...this.lit(model, { + ...target!, + foldable, + collapsed: this.isSummaryFolded(foldable), + }), + ); + } else if ( + shared?.column && + foldable.chevron && + (!this.isCollapsed(path, foldable) || this.isSummaryFolded(foldable)) + ) { + decorations.push( + this.chevron(foldable, this.isSummaryFolded(foldable), false), + ); + } } } const hidden = this.regions @@ -379,14 +495,30 @@ export class StructuralFoldControls extends Disposable { ? region.getHiddenModifiedRange(undefined) : region.getHiddenOriginalRange(undefined), ); + const visible = this.editor.getVisibleRanges(); + const first = visible[0]?.startLineNumber ?? 1; + const last = visible.at(-1)?.endLineNumber ?? 0; + const headerVisible = (scope: StructuralFoldable) => + scope.line + 1 >= first && scope.line + 1 <= last; + const intersectsViewport = (scope: StructuralFoldable) => + scope.line + 1 <= last && !!scope.rail && scope.rail.end + 1 >= first; + this.touchFolds.show( + this.foldablesOf(diff).filter( + (foldable) => + foldable.chevron && + headerVisible(foldable) && + !hidden.some((range) => range.contains(foldable.line + 1)), + ), + (foldable) => this.isFolded(foldable) || this.isSummaryFolded(foldable), + ); const visibleScopes = this.foldablesOf(diff).filter( (foldable) => - foldable.rail && + intersectsViewport(foldable) && !this.isFolded(foldable) && !hidden.some((range) => range.contains(foldable.line + 1)), ); this.rails.show(model, visibleScopes, target); - this.decorations.set(decorations); + if (updateDecorations) this.decorations.set(decorations); // Completed scopes remain discoverable after the pointer leaves. Only // visible headers get widgets, so folded descendants never float over code. const wanted = new Map< @@ -395,11 +527,15 @@ export class StructuralFoldControls extends Disposable { >(); const completed = this.foldablesOf(diff).filter( (scope) => - scope.rail && + intersectsViewport(scope) && this.viewed?.get(path, scope.foldStateId).state === "viewed", ); for (const scope of this.foldablesOf(diff)) { - if (!scope.rail || hidden.some((range) => range.contains(scope.line + 1))) + if ( + !scope.rail || + !headerVisible(scope) || + hidden.some((range) => range.contains(scope.line + 1)) + ) continue; const active = scope.foldStateId === target?.foldable.foldStateId; const progress = this.viewed?.get(path, scope.foldStateId); @@ -527,7 +663,7 @@ export class StructuralFoldControls extends Disposable { }, }); } - if (targeted && foldable.rail) { + if (targeted && foldable.rail && !mobileViewport.matches) { const lines = foldable.rail.end - foldable.rail.start; const end = model.getLineMaxColumn(line); result.push({ @@ -803,6 +939,7 @@ class ScopeRails implements IOverlayWidget { const scrollTop = this.editor.getScrollTop(); const scrollLeft = this.editor.getScrollLeft(); const spaceWidth = this.editor.getOption(EditorOption.fontInfo).spaceWidth; + this.node.style.display = ""; this.node.style.width = `${layout.contentWidth}px`; this.node.style.height = `${layout.height}px`; const segments: HTMLElement[] = []; @@ -862,6 +999,11 @@ class ScopeRails implements IOverlayWidget { } hide(): void { + // Model attachment can synchronously trigger layout while Monaco iterates + // its overlay registry. Keep the entry stable until this control is disposed. + this.node.style.display = "none"; + } + dispose(): void { if (this.added) { this.added = false; this.editor.removeOverlayWidget(this); @@ -881,3 +1023,96 @@ function leadingWidth(model: ITextModel, lineNumber: number): number { model.getOptions().tabSize, ); } + +/** Native buttons bypass Monaco's mouse-only press/release cycle on touch screens. */ +class TouchFoldButtons implements IOverlayWidget { + private readonly node = document.createElement("div"); + private readonly buttons = new Map(); + private added = false; + constructor( + private readonly editor: ICodeEditor, + private readonly toggle: (foldable: StructuralFoldable) => void, + ) { + this.node.className = "app-touch-folds"; + for (const event of ["pointerdown", "mousedown", "mouseup"]) + this.node.addEventListener(event, (e) => e.stopPropagation()); + } + getId(): string { + return "review.touchFolds"; + } + getDomNode(): HTMLElement { + return this.node; + } + getPosition(): IOverlayWidgetPosition { + return { + // Share the number column's hit area rather than reserving extra code width. + preference: { + top: 0, + left: Math.max(0, this.editor.getLayoutInfo().contentLeft - 44), + }, + }; + } + show( + foldables: readonly StructuralFoldable[], + collapsed: (foldable: StructuralFoldable) => boolean, + ): void { + if (!mobileViewport.matches) { + this.hide(); + return; + } + const layout = this.editor.getLayoutInfo(); + const width = Math.min(44, layout.contentLeft); + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + const visible = new Set(); + for (const foldable of foldables) { + const top = + this.editor.getTopForLineNumber(foldable.line + 1) - + this.editor.getScrollTop(); + if (top < 0 || top >= layout.height) continue; + visible.add(foldable.line); + let button = this.buttons.get(foldable.line); + if (!button) { + button = document.createElement("button"); + button.type = "button"; + button.className = "app-touch-fold"; + this.buttons.set(foldable.line, button); + this.node.append(button); + } + const folded = collapsed(foldable); + button.setAttribute( + "aria-label", + `${folded ? "Expand" : "Fold"} scope at line ${foldable.line + 1}`, + ); + button.setAttribute("aria-expanded", String(!folded)); + button.style.top = `${top}px`; + button.style.width = `${width}px`; + button.style.height = `${lineHeight}px`; + button.onclick = (event) => { + event.stopPropagation(); + this.toggle(foldable); + }; + } + for (const [line, button] of this.buttons) + if (!visible.has(line)) { + button.remove(); + this.buttons.delete(line); + } + this.node.style.height = `${layout.height}px`; + this.node.style.width = `${width}px`; + this.node.style.display = ""; + if (this.added) this.editor.layoutOverlayWidget(this); + else { + this.added = true; + this.editor.addOverlayWidget(this); + } + } + hide(): void { + this.node.style.display = "none"; + } + dispose(): void { + if (this.added) { + this.editor.removeOverlayWidget(this); + this.added = false; + } + } +} diff --git a/apps/diffr-web/src/settings.ts b/apps/diffr-web/src/settings.ts index 5c45ff9a5..f148751cf 100644 --- a/apps/diffr-web/src/settings.ts +++ b/apps/diffr-web/src/settings.ts @@ -17,3 +17,97 @@ export function writeSetting(key: string, value: string | undefined): void { // Not remembered. } } + +/** The model APIs the summarizer can call (diffr/plugins/shape/summarize). */ +export const PROVIDERS = { + anthropic: { + title: "Anthropic", + keys: "https://console.anthropic.com/settings/keys", + placeholder: "sk-ant-…", + }, + openai: { + title: "OpenAI", + keys: "https://platform.openai.com/api-keys", + placeholder: "sk-…", + }, + gemini: { + title: "Gemini", + keys: "https://aistudio.google.com/apikey", + placeholder: "AIza…", + }, +} as const; + +export type Provider = keyof typeof PROVIDERS; + +/** The summarizer's key and model; none, and it stays off. */ +export interface Summaries { + provider: Provider; + apiKey: string; + /** Unset uses the provider's default. */ + model?: string; +} + +export function summaries(): Summaries | undefined { + try { + // SAFETY: setSummaries wrote it; a value without a key or a known provider is ignored below. + const value = JSON.parse( + readSetting("summaries") ?? "null", + ) as Summaries | null; + + return value?.apiKey && value.provider in PROVIDERS ? value : undefined; + } catch { + return undefined; + } +} + +export function setSummaries(value: Summaries | undefined): void { + writeSetting("summaries", value ? JSON.stringify(value) : undefined); +} + +/** The page's settings as diffr configuration, merged over the reader's config.toml. */ +export function configOverrides( + summary: Summaries | null = summaries() ?? null, +): string | undefined { + if (!summary) return undefined; + + return JSON.stringify({ + plugins: { + // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- diffr's config key. + shape: { + bundled: { + summarize: { + enabled: true, + provider: summary.provider, + api_key: summary.apiKey, + // Unset, it is left out: JSON has no undefined. + model: summary.model, + }, + }, + }, + }, + }); +} + +/** A shape plugin an agent wrote for this page (agent.ts), run in the workers after diffr's own. */ +export interface AgentPlugin { + name: string; + description: string; + /** The source of one JavaScript function, `(file) => void`. */ + code: string; + enabled: boolean; +} + +export function agentPlugins(): AgentPlugin[] { + try { + // SAFETY: setAgentPlugins wrote it. + const value = JSON.parse(readSetting("plugins") ?? "[]") as AgentPlugin[]; + + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +export function setAgentPlugins(plugins: readonly AgentPlugin[]): void { + writeSetting("plugins", plugins.length ? JSON.stringify(plugins) : undefined); +} diff --git a/apps/diffr-web/src/settingsDialog.ts b/apps/diffr-web/src/settingsDialog.ts new file mode 100644 index 000000000..87044509a --- /dev/null +++ b/apps/diffr-web/src/settingsDialog.ts @@ -0,0 +1,479 @@ +/** + * The page's settings: the GitHub token, the summarizer's key, the agent's plugins, and diffr's + * own config.toml, with a reference to what it holds. A first visit walks through the two keys, + * each of which can be skipped. + */ +import { Engine } from "./engine/engine.js"; +import { setToken, token } from "./github.js"; +import { + PROVIDERS, + type Provider, + type Summaries, + configOverrides, + readSetting, + setSummaries, + summaries, + writeSetting, +} from "./settings.js"; +import { actionButton, dialog, element, field, link, rich } from "./ui.js"; + +export interface SettingsHost { + readonly root: HTMLElement; + /** The engine the page diffs with. */ + engine(): Engine; + /** Diff with `engine` from now on, and load the page again. */ + replaceEngine(engine: Engine): void; + /** Load the page again, as after a new token. */ + reload(): void; + /** Extra sections, such as the agent's plugins. */ + sections?: (parent: HTMLElement) => void; +} + +const GITHUB_TOKEN_URL = + "https://github.com/settings/personal-access-tokens/new?name=diffr&description=Read+pull+requests+in+diffr&contents=read&pull_requests=read"; + +/** Make an engine for new settings and use it once it loads; the old one stays if it fails. */ +function tryEngine( + host: SettingsHost, + config: string | undefined, + overrides: string | undefined, + status: HTMLElement, + onReady: () => void, +): Promise { + const candidate = new Engine(config, overrides); + status.textContent = "Checking…"; + + return candidate.notices.then( + () => { + onReady(); + host.replaceEngine(candidate); + }, + (error: Error) => { + candidate.dispose(); + status.textContent = error.message; + }, + ); +} + +function tokenInput(): HTMLInputElement { + const input = element("input"); + input.type = "password"; + input.autocomplete = "off"; + input.placeholder = "github_pat_…"; + input.value = token() ?? ""; + + return input; +} + +function tokenHint(): DocumentFragment { + const hint = rich( + ` Create a [fine-grained token](${GITHUB_TOKEN_URL}) with read access to Contents and Pull requests for the repositories you want to review. A token also lets you review private repositories you can access.`, + ); + + hint.prepend( + element( + "strong", + undefined, + "Your token is stored in this browser and sent only to GitHub.", + ), + ); + + return hint; +} + +interface SummaryControls { + read(): Summaries | undefined; +} + +/** Provider, key and model, filled from what is saved. */ +function summaryFields(parent: HTMLElement): SummaryControls { + const saved = summaries(); + const provider = element("select"); + + for (const [value, { title }] of Object.entries(PROVIDERS)) { + const option = provider.appendChild(element("option", undefined, title)); + option.value = value; + } + + provider.value = saved?.provider ?? "anthropic"; + const key = element("input"); + key.type = "password"; + key.autocomplete = "off"; + key.value = saved?.apiKey ?? ""; + const keyHint = element("span"); + const model = element("input"); + model.value = saved?.model ?? ""; + model.placeholder = "The provider's default"; + model.spellcheck = false; + + const update = () => { + // SAFETY: the options are PROVIDERS' keys. + const details = PROVIDERS[provider.value as Provider]; + key.placeholder = details.placeholder; + keyHint.replaceChildren( + rich( + `Optional. Leave blank to keep AI summaries off. [Create an API key](${details.keys}). Your key is stored in this browser and sent only to ${details.title}.`, + ), + ); + }; + + provider.addEventListener("change", () => { + // Credentials and model names belong to a specific provider. + key.value = ""; + model.value = ""; + update(); + }); + update(); + field(parent, "Provider", provider); + field(parent, "API key", key, keyHint); + field( + parent, + "Model", + model, + "Optional. Leave blank to use the default model for this provider.", + ); + + return { + read: () => + key.value.trim() + ? { + // SAFETY: the options are PROVIDERS' keys. + provider: provider.value as Provider, + apiKey: key.value.trim(), + model: model.value.trim() || undefined, + } + : undefined, + }; +} + +/** Optional first-visit setup. Both credentials can also be added in Settings. */ +export function openOnboarding(host: SettingsHost): void { + dialog( + host.root, + "Welcome to diffr", + (node, actions) => { + node.addEventListener("close", () => writeSetting("onboarded", "1")); + const content = node.appendChild(element("div", "app-onboarding")); + const github = element("div"); + github.append( + element("h3", undefined, "1 of 2 · GitHub access"), + element( + "p", + undefined, + "Without a token, GitHub limits you to just 60 API requests per hour, shared by everyone on your IP address. A token raises your account’s limit to 5,000 requests per hour. Loading a single pull request can take several requests.", + ), + ); + const input = tokenInput(); + field(github, "GitHub token (optional)", input, tokenHint()); + + const summary = element("div"); + summary.append( + element("h3", undefined, "2 of 2 · AI summaries"), + element( + "p", + undefined, + "Add short AI summaries to long functions and tests. Reviewing diffs and folding code work without them.", + ), + element( + "p", + "app-field-hint", + "Summaries send code to your selected provider. API usage may be billed by that provider. You can set this up later in Settings.", + ), + ); + const controls = summaryFields(summary); + const status = summary.appendChild(element("p", "app-dialog-status")); + status.setAttribute("role", "status"); + let busy = false; + node.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + + const show = (step: HTMLElement, buttons: HTMLButtonElement[]) => { + content.replaceChildren(step); + actions.replaceChildren(...buttons); + // Focus the step rather than opening the mobile keyboard for an optional key. + step.tabIndex = -1; + step.setAttribute("autofocus", ""); + step.focus(); + }; + + const next = actionButton( + "Continue without token", + () => { + const value = input.value.trim() || null; + + if (value !== token()) { + setToken(value); + host.reload(); + } + + showSummaries(); + }, + true, + ); + + input.addEventListener("input", () => { + next.textContent = input.value.trim() + ? "Save token and continue" + : "Continue without token"; + }); + next.textContent = input.value.trim() + ? "Save token and continue" + : "Continue without token"; + + const back = actionButton("Back", () => show(github, [next])); + const skip = actionButton("Not now", () => node.close()); + + const start = actionButton( + "Start reviewing", + () => { + const value = controls.read(); + + if (!value) { + node.close(); + + return; + } + + busy = true; + + const fields = [ + ...summary.querySelectorAll( + "input, select", + ), + ]; + + for (const control of [...fields, back, skip, start]) + control.disabled = true; + void tryEngine( + host, + readSetting("config"), + configOverrides(value), + status, + () => { + setSummaries(value); + node.close(); + }, + ).finally(() => { + busy = false; + + for (const control of [...fields, back, skip, start]) + control.disabled = false; + }); + }, + true, + ); + + const updateSummaryActions = () => { + const enabled = !!controls.read(); + start.textContent = enabled ? "Enable summaries" : "Start reviewing"; + skip.hidden = !enabled; + }; + + summary.addEventListener("input", updateSummaryActions); + summary.addEventListener("change", updateSummaryActions); + + const showSummaries = () => { + updateSummaryActions(); + show(summary, [back, skip, start]); + }; + + show(github, [next]); + }, + "app-onboarding-dialog", + ); +} + +export function openSettings(host: SettingsHost): void { + dialog( + host.root, + "Settings", + (node, actions) => { + const scroll = node.appendChild(element("div", "app-settings")); + + const section = (title: string, description?: string) => { + const part = scroll.appendChild(element("section")); + part.appendChild(element("h3", undefined, title)); + + if (description) part.appendChild(element("p", undefined, description)); + + return part; + }; + + const github = tokenInput(); + field(section("GitHub"), "Token", github, tokenHint()); + + const summary = summaryFields( + section( + "Summaries", + "Long new functions and tests fold behind pseudocode written by a model. Each file shows first, and its summaries follow.", + ), + ); + + host.sections?.(scroll); + + const advanced = section("diffr configuration"); + advanced.appendChild( + rich( + "diffr's own `config.toml`, as the diffr command line reads it: which files start hidden, how much context stays around a change, and each bundled plugin's options. Leave it empty for diffr's defaults; anything set here applies over them. The settings above are applied over it.", + ), + ); + const config = advanced.appendChild(element("textarea")); + config.spellcheck = false; + config.value = readSetting("config") ?? ""; + config.placeholder = `# For example: keep more context, and show deleted files. +[plugins.shape.bundled.context] +lines = 8 + +[plugins.classify.bundled] +hide_deleted = false`; + config.setAttribute("aria-label", "config.toml"); + advanced.appendChild(reference(host)); + + const status = node.appendChild(element("p", "app-dialog-status")); + host.engine().notices.then( + (notices) => (status.textContent = notices.join("\n")), + (error: Error) => (status.textContent = error.message), + ); + + actions.appendChild(actionButton("Cancel", () => node.close())); + actions.appendChild( + actionButton( + "Save", + () => { + const tokenValue = github.value.trim() || null; + const tokenChanged = tokenValue !== token(); + const configValue = config.value.trim() ? config.value : undefined; + const summaryValue = summary.read(); + + const engineChanged = + configValue !== readSetting("config") || + JSON.stringify(summaryValue) !== JSON.stringify(summaries()); + + if (tokenChanged) setToken(tokenValue); + + if (!engineChanged) { + node.close(); + + if (tokenChanged) host.reload(); + + return; + } + + const overrides = configOverrides(summaryValue ?? null); + + // Tried in its own engine first, so a broken file leaves the page as it was. + tryEngine(host, configValue, overrides, status, () => { + writeSetting("config", configValue); + setSummaries(summaryValue); + node.close(); + }); + }, + true, + ), + ); + }, + "app-settings-dialog", + ); +} + +interface Schema { + type?: string | string[]; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + items?: Schema; + $ref?: string; + $defs?: Record; + definitions?: Record; + oneOf?: Schema[]; + anyOf?: Schema[]; +} + +/** Every setting config.toml can hold, from diffr's schema: its key, type, default and meaning. */ +function reference(host: SettingsHost): HTMLElement { + const details = element("details", "app-config-reference"); + details.appendChild( + element("summary", undefined, "Reference: every setting"), + ); + const list = details.appendChild(element("dl")); + let loaded = false; + + details.addEventListener("toggle", () => { + if (!details.open || loaded) return; + loaded = true; + list.textContent = "Loading…"; + host + .engine() + .schema() + .then( + (text) => { + // SAFETY: Differ.schema is diffr's configuration JSON Schema. + const root = JSON.parse(text) as Schema; + list.replaceChildren(); + + const resolve = (schema: Schema): Schema => { + const ref = schema.$ref?.match(/^#\/(\$defs|definitions)\/(.+)$/); + const target = ref && (root.$defs ?? root.definitions)?.[ref[2]!]; + + return target ? resolve(target) : schema; + }; + + const walk = (key: string, raw: Schema, depth: number) => { + const schema = resolve(raw); + + if (schema.properties && depth < 8) { + for (const [name, child] of Object.entries(schema.properties)) + walk(key ? `${key}.${name}` : name, child, depth + 1); + + return; + } + + const term = list.appendChild(element("dt")); + term.appendChild(element("code", undefined, key)); + + const type = schema.enum + ? schema.enum.map((value) => JSON.stringify(value)).join(" | ") + : [schema.type ?? (schema.oneOf || schema.anyOf ? "value" : "")] + .flat() + .join(" | "); + + if (type) + term.appendChild(element("span", "app-config-type", type)); + + const definition = list.appendChild(element("dd")); + + if (schema.description) + definition.appendChild(rich(schema.description)); + + if ( + schema.default !== undefined && + schema.default !== null && + !(schema.default instanceof Object) + ) + definition.appendChild( + element( + "span", + "app-config-default", + `Default: ${JSON.stringify(schema.default)}`, + ), + ); + }; + + walk("", root, 0); + }, + (error: Error) => (list.textContent = error.message), + ); + }); + + details + .appendChild(element("p", "app-field-hint")) + .appendChild( + rich( + "The same settings as the diffr command line's `diffr config`; see [the plugin guide](https://github.com/devdotfast/whiteboard/blob/main/diffr/docs/plugin.md#configuration-format-2). The browser runs only the bundled plugins.", + ), + ); + + return details; +} diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index c777e46ee..32fb776cb 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -25,7 +25,7 @@ body { display: flex; flex-direction: column; color: var(--vscode-foreground); - font-family: "Geist Mono", ui-monospace, Menlo, monospace; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; } @@ -269,3 +269,1195 @@ body { font-family: "Geist Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; } + +/* ⌘P: VS Code's Quick Open, centred at the top of the diff. */ +.app-quick-open { + position: absolute; + top: 8px; + left: 50%; + z-index: 60; + width: min(600px, calc(100vw - 32px)); + transform: translateX(-50%); + padding: 6px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 8px; + background: var( + --vscode-quickInput-background, + var(--vscode-editorWidget-background) + ); + box-shadow: 0 8px 28px var(--vscode-widget-shadow); +} + +.app-quick-open input { + box-sizing: border-box; + width: 100%; + padding: 6px 8px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 5px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font: inherit; + outline: none; +} + +.app-quick-open ul { + max-height: min(440px, 60vh); + margin: 6px 0 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.app-quick-open li { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + height: 24px; + padding: 0 8px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.app-quick-open li[aria-selected="true"] { + background: var( + --vscode-quickInputList-focusBackground, + var(--vscode-list-activeSelectionBackground) + ); + color: var( + --vscode-quickInputList-focusForeground, + var(--vscode-list-activeSelectionForeground) + ); +} + +.app-quick-open li:not([aria-selected="true"]):hover { + background: var(--vscode-list-hoverBackground); +} + +.app-quick-open mark { + background: none; + color: var(--vscode-list-highlightForeground, var(--vscode-focusBorder)); + font-weight: 600; +} + +.app-quick-open-icon { + flex: none; +} + +.app-quick-open-icon.is-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.app-quick-open-icon.is-deleted { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.app-quick-open-icon.is-modified { + color: var(--vscode-gitDecoration-modifiedResourceForeground); +} + +.app-quick-open-icon.is-renamed, +.app-quick-open-icon.is-copied { + color: var(--vscode-gitDecoration-renamedResourceForeground); +} + +.app-quick-open-name { + flex: none; +} + +.app-quick-open-folder, +.app-quick-open-note { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.app-quick-open-note { + flex: none; + margin-left: auto; +} + +.app-quick-open li.app-quick-open-empty { + color: var(--vscode-descriptionForeground); + cursor: default; +} + +/* ⌘F: VS Code's find widget, at the top right of the diff, searching every file. */ +.app-find { + position: absolute; + top: 6px; + right: 22px; + z-index: 45; + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 6px; + background: var(--vscode-editorWidget-background); + box-shadow: 0 4px 16px var(--vscode-widget-shadow); +} + +.app-find[hidden] { + display: none; +} + +.app-find-field { + display: flex; + align-items: center; + width: min(300px, 50vw); + border: 1px solid + var(--vscode-input-border, var(--vscode-editorWidget-border)); + border-radius: 4px; + background: var(--vscode-input-background); +} + +.app-find-field:focus-within { + border-color: var(--vscode-focusBorder); +} + +.app-find.no-results .app-find-field { + border-color: var( + --vscode-inputValidation-errorBorder, + var(--vscode-errorForeground) + ); +} + +.app-find input { + flex: 1; + min-width: 0; + padding: 3px 6px; + border: 0; + background: transparent; + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font: inherit; + font-size: 12px; + outline: none; +} + +.app-find-toggle, +.app-find-button { + flex: none; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; +} + +.app-find-toggle:hover, +.app-find-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.app-find-toggle[aria-pressed="true"] { + border-color: var( + --vscode-inputOption-activeBorder, + var(--vscode-focusBorder) + ); + background: var(--vscode-inputOption-activeBackground); + color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground)); +} + +.app-find-count { + min-width: 70px; + padding: 0 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + white-space: nowrap; +} + +.app-find.no-results .app-find-count { + color: var(--vscode-errorForeground); +} + +/* Settings and the first visit's walk through them. */ +.app-settings-dialog { + width: min(640px, calc(100vw - 32px)); + max-height: calc(100dvh - 48px); + display: flex; + flex-direction: column; +} + +.app-settings-dialog[open] { + display: flex; +} + +.app-settings { + min-height: 0; + margin: 0 -18px; + padding: 0 18px; + overflow-y: auto; +} + +.app-settings section { + padding: 12px 0; + border-top: 1px solid var(--vscode-panel-border); +} + +.app-settings section:first-child { + border-top: 0; +} + +.app-settings h3, +.app-onboarding h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 600; +} + +.app-onboarding h3 { + color: var(--vscode-descriptionForeground); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.app-onboarding > div:focus { + outline: none; +} + +.app-onboarding-dialog .app-dialog-actions { + flex-wrap: wrap; +} + +.app-settings p, +.app-settings section > :is(span, a, code) { + color: var(--vscode-descriptionForeground); +} + +.app-field { + display: flex; + flex-direction: column; + gap: 4px; + margin: 10px 0 0; +} + +.app-field-label { + font-size: 12px; + font-weight: 600; +} + +.app-field-hint { + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.45; +} + +.app-dialog select { + box-sizing: border-box; + width: 100%; + padding: 7px 8px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + font: inherit; +} + +.app-dialog a { + color: var(--vscode-textLink-foreground); +} + +.app-dialog code { + padding: 0 3px; + border-radius: 3px; + background: var(--vscode-textCodeBlock-background, rgb(127 127 127 / 0.15)); + font-size: 12px; +} + +.app-settings textarea { + height: 180px; + margin-top: 10px; +} + +.app-config-reference { + margin-top: 10px; +} + +.app-config-reference summary { + cursor: pointer; + font-size: 12px; +} + +.app-config-reference dl { + margin: 8px 0; + font-size: 12px; +} + +.app-config-reference dt { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + margin-top: 10px; +} + +.app-config-reference dd { + margin: 2px 0 0 12px; + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +.app-config-type, +.app-config-default { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.app-config-default { + display: block; + margin-top: 2px; +} + +/* Lenses an agent made for this comparison (lenses.ts). */ +.app-lens-bar { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 5px 8px 5px 14px; + background: var(--vscode-sideBar-background); + border-bottom: 1px solid var(--vscode-panel-border); +} + +.app-lens-bar[hidden] { + display: none; +} + +.app-lens-label { + color: var(--vscode-descriptionForeground); + margin-right: 4px; +} + +.app-lens { + font: inherit; + color: var(--vscode-foreground); + background: transparent; + border: 1px solid var(--vscode-panel-border); + border-radius: 11px; + padding: 1px 10px; + cursor: pointer; + max-width: 28ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-lens:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.app-lens[aria-pressed="true"] { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.app-lens-clear { + margin-left: auto; + color: var(--vscode-icon-foreground); + background: transparent; + border: 0; + border-radius: 4px; + padding: 3px; + cursor: pointer; +} + +.app-lens-clear:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +/* Agent plugins, in the settings (pluginsSection.ts). */ +.app-plugin-list { + list-style: none; + margin: 0; + padding: 0; +} + +.app-plugin, +.app-plugin-empty { + padding: 8px 0; + border-top: 1px solid var(--vscode-panel-border); +} + +.app-plugin-empty { + color: var(--vscode-descriptionForeground); +} + +.app-plugin-head, +.app-cache { + display: flex; + align-items: center; + gap: 8px; +} + +.app-cache button { + flex: none; + white-space: nowrap; +} + +.app-plugin-head input { + flex: none; + width: auto; + margin: 0; +} + +.app-plugin p { + margin: 4px 0; + color: var(--vscode-descriptionForeground); +} + +.app-plugin pre { + max-height: 200px; + overflow: auto; + padding: 8px; + background: var(--vscode-textCodeBlock-background); + font-size: 12px; +} + +/* Find in all files (find.ts): the editor's own find styles, which the page does not load. */ +.app .monaco-editor .findMatch { + background-color: var( + --vscode-editor-findMatchHighlightBackground, + rgba(234, 92, 0, 0.33) + ); +} + +.app .monaco-editor .currentFindMatch { + background-color: var( + --vscode-editor-findMatchBackground, + rgba(234, 92, 0, 0.6) + ); + border: 2px solid var(--vscode-editor-findMatchBorder, transparent); + box-sizing: border-box; +} + +/* + * A summary stands in for the code it folds, so it reads like code: the editor's own colour, upright, + * with an accent rule that marks it as written by a model. Click it for the code. + */ +.app + .monaco-editor + .diff-hidden-lines.summary-fold + .detail + pre.diff-hidden-lines-detail { + color: color-mix( + in srgb, + var(--vscode-editor-foreground) 85%, + transparent + ) !important; + font-style: normal; + padding-left: 10px; + border-left: 2px solid + color-mix(in srgb, var(--vscode-textLink-foreground) 60%, transparent); +} + +.app + .monaco-editor + .diff-hidden-lines.summary-fold + .detail:hover + pre.diff-hidden-lines-detail { + border-left-color: var(--vscode-textLink-foreground); +} + +/* Read-only diffs keep selection and copy, without an editing caret. */ +.app .monaco-editor .cursors-layer { + display: none; +} +.app-native-scroll, +.app-native-scroll-extent, +.app-diff-viewport { + height: 100%; + width: 100%; +} +.app-native-scroll { + overflow: hidden; +} +.app-native-scroll.is-native { + overflow-y: auto; + overscroll-behavior-y: contain; + overflow-anchor: none; +} +.is-native .app-diff-viewport { + position: sticky; + top: 0; + touch-action: pan-y; +} +.is-native .monaco-editor .view-lines, +.is-native .monaco-editor .view-line, +.is-native .monaco-editor .lines-content { + user-select: text !important; + -webkit-user-select: text !important; +} +.is-native .monaco-editor .inputarea { + pointer-events: none; +} +.is-native + .multiDiffEditor + > div + > .monaco-scrollable-element + > .scrollbar.vertical { + display: none !important; +} +.app-mobile-files-host:not([hidden]) { + display: contents; +} +.app-mobile-files { + position: fixed; + inset: auto 0 0; + box-sizing: border-box; + width: 100%; + max-width: none; + height: min(85dvh, 760px); + max-height: 85dvh; + margin: 0; + padding: 8px 12px calc(12px + env(safe-area-inset-bottom)); + border: 1px solid var(--vscode-panel-border); + border-radius: 14px 14px 0 0; + color: var(--vscode-foreground); + background: var(--vscode-sideBar-background); +} +.app-mobile-files[open] { + display: flex; + flex-direction: column; +} +.app-mobile-files::backdrop { + background: rgb(0 0 0 / 0.25); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.app-mobile-files-heading { + display: flex; + align-items: center; + justify-content: space-between; +} +.app-mobile-files-heading h2 { + margin: 8px 0; + font-size: 13px; + font-weight: 600; +} +.app-mobile-file-search { + flex: none; + box-sizing: border-box; + width: 100%; + margin: 8px 0; + padding: 12px; + font-size: 16px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; +} +.app-mobile-file-list { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} +.app-mobile-file-list .app-button { + display: block; + width: 100%; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + margin-bottom: 4px; +} +.app-mobile-file-list .app-button[hidden] { + display: none; +} +.app-mobile-folder-label { + min-height: 44px; + box-sizing: border-box; + padding: 12px 4px; + cursor: pointer; + overflow-wrap: anywhere; +} +.app-mobile-folder-children { + margin-left: 12px; + padding-left: 8px; + border-left: 1px solid var(--vscode-panel-border); +} +.app-mobile-folder-label:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + .app { + height: 100dvh; + } + .app-header { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) 44px; + grid-template-rows: 48px auto; + height: auto; + gap: 0; + padding: env(safe-area-inset-top) 12px 0; + } + .app-header .app-wordmark { + grid-column: 2; + grid-row: 1; + justify-self: center; + } + .app-header > .app-icon-button { + grid-column: 3; + grid-row: 1; + } + .app-header > .app-header-files { + grid-column: 1; + } + .app-header .app-spacer { + display: none; + } + .app-header .app-title { + grid-column: 1 / -1; + grid-row: 2; + display: block; + padding: 10px 0; + border-top: 1px solid var(--vscode-panel-border); + font-size: 13px; + } + .app-icon-button { + width: 44px; + height: 44px; + } + .app-lens-bar { + display: none; + } + .app-status { + flex-wrap: wrap; + } + .app-landing input { + box-sizing: border-box; + max-width: 100%; + font-size: 16px; + } + .app-mobile-files .app-button { + min-height: 44px; + } +} +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + .app-dialog { + max-height: 85dvh; + overflow: auto; + } + .app-dialog input, + .app-dialog select, + .app-dialog textarea { + font-size: 16px; + } +} +.is-native .monaco-editor .inputarea { + font-size: 16px !important; +} +.app-touch-folds { + position: absolute; + pointer-events: none; + z-index: 10; + overflow: hidden; +} +.app-touch-fold { + position: absolute; + left: 0; + pointer-events: auto; + border: 0; + background: transparent; + color: var(--vscode-descriptionForeground); + padding: 0; + touch-action: manipulation; +} +.app-touch-fold::before, +.app .monaco-editor .diff-fold-chevron.codicon::before, +.app .monaco-editor .diff-fold-chevron .codicon::before { + content: ""; + display: block; + width: 12px; + height: 12px; + background: currentColor; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; +} +.app-touch-fold::before { + position: absolute; + right: 6px; + top: calc(50% - 6px); +} +.app-touch-fold[aria-expanded="true"]::before { + transform: rotate(90deg); +} +.app-touch-fold:active { + background: var(--vscode-toolbar-hoverBackground); +} +.app-touch-fold:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -2px; +} +.app-mobile-fold-actions { + display: flex; + gap: 8px; + padding: 4px 0; +} +.app-mobile-fold-actions > button { + flex: 1; +} +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + .review-workbench .monaco-editor .review-fold-chevron { + visibility: hidden; + } + .app.review-workbench .monaco-editor .review-scope-viewed-control, + .app .review-multidiff-note, + .app .review-multidiff-open-container { + display: none; + } +} + +/* Mobile navigation uses the desktop tree's status marks and line-count colors. */ +.app-mobile-files-heading .app-mobile-files-close:focus:not(:focus-visible) { + outline: none; +} +.app-mobile-files-heading .app-mobile-files-close { + border: 0; + background: transparent; + width: 40px; + padding: 0; + font: 24px/1 sans-serif; +} +.app-mobile-file-row .review-changed-files-label { + display: flex; +} +.app-mobile-file-stem { + overflow: hidden; + text-overflow: ellipsis; +} +.app-mobile-file-extension { + flex: none; +} +.app-mobile-files-tools { + display: flex; + align-items: center; + gap: 0; + flex: none; +} +.app-mobile-file-search { + min-width: 0; + flex: none; + padding: 10px; +} +.app-mobile-file-search[hidden], +.app-mobile-filter-menu[hidden] { + display: none; +} +.app-mobile-files-heading .app-mobile-tree-tool { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} +.app-mobile-tree-tool .codicon { + font-size: 18px; +} +.app-mobile-tree-tool:hover, +.app-mobile-tree-tool.is-active { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} +.app-mobile-filter-wrapper { + position: relative; +} +.app-mobile-filter-menu { + position: absolute; + top: 44px; + right: 0; + z-index: 2; + width: 160px; + padding: 4px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); + box-shadow: 0 6px 20px #0003; +} +.app-mobile-filter-menu .app-button { + display: block; + width: 100%; + border: 0; + text-align: left; + font-size: 13px; +} +.app-mobile-filter-menu .app-button[aria-pressed="true"] { + background: var(--vscode-list-inactiveSelectionBackground); +} +.app-mobile-file-list { + flex: 1; + padding: 4px 0; + font-size: 13px; +} +.app-mobile-folder-label { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 4px; + white-space: nowrap; + overflow: hidden; + font-weight: 500; + list-style: none; +} +.app-mobile-folder-label::-webkit-details-marker { + display: none; +} +.app-mobile-folder-label::before { + content: ""; + width: 12px; + height: 12px; + flex: 0 0 12px; + background: currentColor; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; + opacity: 0.65; +} +.app-mobile-folder[open] > .app-mobile-folder-label::before { + transform: rotate(90deg); +} +.app-mobile-folder-children { + margin-left: 8px; + padding-left: 6px; + border: 0; +} +.app-mobile-file-row { + width: 100%; + min-height: 28px; + height: 28px; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + font: inherit; + text-align: left; + cursor: pointer; +} +.app-mobile-file-row[hidden] { + display: none; +} +.app-mobile-file-row:hover, +.app-mobile-folder-label:hover { + background: var(--vscode-list-hoverBackground); +} +.app-mobile-file-row[aria-current="true"] { + background: var(--vscode-list-inactiveSelectionBackground); +} +.app-mobile-file-row:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} +.app-mobile-file-row .review-tree-counts { + font-size: 11px; + gap: 5px; + padding: 0; +} +.app-mobile-fold-actions { + border-top: 1px solid var(--vscode-panel-border); + padding-top: 8px; +} +.app-mobile-files-empty { + text-align: center; + color: var(--vscode-descriptionForeground); +} +.app-header-quick { + display: flex; + align-items: center; + grid-column: 3; + grid-row: 1; +} +.app-header-popover { + position: fixed; + inset: calc(48px + env(safe-area-inset-top)) 12px auto auto; + margin: 0; + padding: 6px; + min-width: 190px; + background: var(--vscode-sideBar-background); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + box-shadow: 0 6px 24px #0004; +} +.app-header-popover .app-button { + display: block; + width: 100%; + min-height: 40px; + border: 0; + text-align: left; +} +.app-header-popover .app-button[aria-pressed="true"] { + background: var(--vscode-list-inactiveSelectionBackground); +} +.app-quick-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 44px; + padding: 0 8px; + font-size: 13px; +} +.app-quick-setting input { + width: 18px; + height: 18px; +} +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + .app-header:has(.app-header-quick) { + grid-template-columns: 132px minmax(0, 1fr) 132px; + } +} + +/* Shared mobile chrome: quiet surfaces, compact labels, generous control hit areas. */ +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + .app-header { + background: var(--vscode-editor-background); + padding-inline: 8px; + } + .app-header .app-wordmark { + font-size: 15px; + letter-spacing: -0.06em; + } + .app-header .app-title { + padding-inline: 6px; + font-size: 12px; + line-height: 18px; + } + .app-header .app-icon-button { + display: grid; + place-items: center; + padding: 0; + color: var(--vscode-descriptionForeground); + -webkit-tap-highlight-color: transparent; + } + .app-header .app-icon-button .codicon { + font-size: 18px; + } + .app-header .app-icon-button[aria-expanded="true"] { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); + } + .app .multiDiffEntry > .header > .header-content { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .app + .monaco-component.multiDiffEditor + .multiDiffEntry.active + > .header + .header-content { + border-top-color: var(--vscode-multiDiffEditor-border); + } + .app + .monaco-component.multiDiffEditor + .multiDiffEntry.active + > .editorParent { + border-bottom-color: var(--vscode-multiDiffEditor-border); + } + .app .review-multidiff-counts { + font-size: 11px; + gap: 5px; + } + .app-mobile-files { + background: var(--vscode-editor-background); + box-shadow: 0 -12px 48px rgb(0 0 0 / 0.12); + } + .app-mobile-files-heading { + min-height: 44px; + padding: 0 0 6px 6px; + border-bottom: 1px solid var(--vscode-panel-border); + } + .app-mobile-file-list { + padding-block: 6px; + font-family: "Geist Mono", ui-monospace, Menlo, monospace; + font-size: 12px; + } + .app-mobile-folder-label { + color: var(--vscode-foreground); + font-weight: 400; + min-height: 28px; + } + .app-mobile-file-row { + gap: 4px; + border-radius: 4px; + } + .app-mobile-folder-children { + margin-left: 9px; + padding-left: 9px; + border-left: 1px solid + color-mix(in srgb, var(--vscode-foreground) 9%, transparent); + } + .app-mobile-file-row .review-tree-counts > span { + min-width: 3.5ch; + text-align: right; + } + .app-mobile-file-row .review-tree-counts > .is-zero { + color: var(--vscode-descriptionForeground); + opacity: 0.5; + } + .app-mobile-file-row .review-tree-counts { + font-variant-numeric: tabular-nums; + font-size: 11px; + } + .app-mobile-fold-actions { + gap: 4px; + } + .app-mobile-fold-actions .app-button { + min-height: 32px; + border: 0; + font-size: 12px; + color: var(--vscode-descriptionForeground); + } + .app-mobile-fold-actions .app-button:active { + background: var(--vscode-toolbar-hoverBackground); + } +} +.app-header-popover { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 13px; + border-radius: 12px; + padding: 6px; + box-shadow: 0 8px 32px rgb(0 0 0 / 0.18); +} +.app-quick-setting input { + appearance: none; + -webkit-appearance: none; + position: relative; + width: 30px; + height: 18px; + margin: 0; + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-foreground) 24%, transparent); + cursor: pointer; +} +.app-quick-setting input::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--vscode-editor-background); +} +.app-quick-setting input:checked { + background: var(--vscode-textLink-foreground); +} +.app-quick-setting input:checked::before { + transform: translateX(12px); +} +.app-quick-setting input:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 3px; +} + +/* Keep motion on the surrounding UI, never on the scrolling diff surface. */ +@media (prefers-reduced-motion: no-preference) { + .app-mobile-files { + opacity: 0; + transform: translateY(100%); + transition: + transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 180ms ease; + } + .app-mobile-files[open]:not(.is-closing) { + opacity: 1; + transform: translateY(0); + } + .app-mobile-files::backdrop { + opacity: 0; + transition: opacity 240ms ease; + } + .app-mobile-files[open]:not(.is-closing)::backdrop { + opacity: 1; + } + + .app-header-popover { + opacity: 0; + transform: translateY(-4px) scale(0.98); + transform-origin: top right; + transition: + opacity 140ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1), + display 180ms allow-discrete, + overlay 180ms allow-discrete; + } + .app-header-popover:popover-open { + opacity: 1; + transform: translateY(0) scale(1); + } + @starting-style { + .app-mobile-files[open]:not(.is-closing) { + opacity: 0; + transform: translateY(100%); + } + .app-mobile-files[open]:not(.is-closing)::backdrop { + opacity: 0; + } + .app-header-popover:popover-open { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + } + .app-mobile-filter-menu:not([hidden]), + .app-mobile-file-search:not([hidden]) { + animation: app-control-enter 160ms ease-out; + } + .app-mobile-folder-label::before, + .app-touch-fold::before { + transition: transform 160ms ease; + } + .app-icon-button, + .app-mobile-tree-tool, + .app-mobile-file-row, + .app-mobile-folder-label { + transition: + background-color 140ms ease, + color 140ms ease; + } + .app-quick-setting input { + transition: background-color 160ms ease; + } + .app-quick-setting input::before { + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); + } +} +@keyframes app-control-enter { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.app .monaco-editor .diff-hidden-lines .detail pre.diff-hidden-lines-detail { + overflow-x: auto; + text-overflow: clip; + scrollbar-width: thin; + user-select: text; + -webkit-user-select: text; +} +.app .multiDiffEditor > div > .monaco-scrollable-element > .scrollbar { + z-index: 2000; +} + +/* Monaco translates this virtual content itself. A hidden overflow container can still be + scrolled by browser focus, which makes Safari shift the whole diff after a resize. */ +.is-native .multiDiffEditor .scrollContent > div:first-child { + overflow: clip !important; +} diff --git a/apps/diffr-web/src/theme.ts b/apps/diffr-web/src/theme.ts index 9fae57ff0..e838aa9b9 100644 --- a/apps/diffr-web/src/theme.ts +++ b/apps/diffr-web/src/theme.ts @@ -1,5 +1,6 @@ import dark from "../../review-desktop/code-oss/extensions/review-themes/themes/review-dark.json?raw"; import light from "../../review-desktop/code-oss/extensions/review-themes/themes/review-light.json?raw"; +import { readSetting, writeSetting } from "./settings.js"; import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; import type { StandaloneThemeService } from "./standalone/browser/standaloneThemeService.js"; /** @@ -30,7 +31,9 @@ function themeData(source: string) { }; } -export function applyTheme(root: HTMLElement): void { +export function applyTheme( + root: HTMLElement, +): (mode: "auto" | "light" | "dark") => void { // SAFETY: StandaloneServices registers StandaloneThemeService for this id; its // container registration is on the implementation only. const themes = StandaloneServices.get( @@ -52,19 +55,29 @@ export function applyTheme(root: HTMLElement): void { themes.registerEditorContainer(root); const query = matchMedia("(prefers-color-scheme: dark)"); + let mode = readSetting("theme") ?? "auto"; + const apply = () => { + const isDark = mode === "dark" || (mode === "auto" && query.matches); + // Standalone Monaco only emits registered editor colors; the page also uses workbench colors. for (const [id, color] of Object.entries( - (query.matches ? darkTheme : lightTheme).colors, + (isDark ? darkTheme : lightTheme).colors, )) { root.style.setProperty(`--vscode-${id.replaceAll(".", "-")}`, color); } - themes.setTheme(query.matches ? "whiteboard-dark" : "whiteboard-light"); - root.classList.toggle("vs-dark", query.matches); - root.classList.toggle("vs", !query.matches); + themes.setTheme(isDark ? "whiteboard-dark" : "whiteboard-light"); + root.classList.toggle("vs-dark", isDark); + root.classList.toggle("vs", !isDark); }; query.addEventListener("change", apply); apply(); + + return (next) => { + mode = next; + writeSetting("theme", next); + apply(); + }; } diff --git a/apps/diffr-web/src/ui.ts b/apps/diffr-web/src/ui.ts new file mode 100644 index 000000000..e601928e7 --- /dev/null +++ b/apps/diffr-web/src/ui.ts @@ -0,0 +1,101 @@ +/** The page's own controls: plain elements styled by styles.css. */ + +export 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; +} + +export 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; +} + +/** A modal over `parent`, removed once closed. */ +export function dialog( + parent: HTMLElement, + title: string, + build: (dialog: HTMLDialogElement, actions: HTMLElement) => void, + className?: string, +): HTMLDialogElement { + const node = parent.appendChild( + element("dialog", className ? `app-dialog ${className}` : "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(); + + return node; +} + +/** A labelled field: its label, the control, and a hint under it. */ +export function field( + parent: HTMLElement, + label: string, + control: T, + hint?: string | Node, +): T { + const wrapper = parent.appendChild(element("label", "app-field")); + wrapper.appendChild(element("span", "app-field-label", label)); + wrapper.appendChild(control); + + if (hint !== undefined) { + const note = wrapper.appendChild(element("span", "app-field-hint")); + note.append(hint); + } + + return control; +} + +/** A link that opens in a new tab. */ +export function link(href: string, text: string): HTMLAnchorElement { + const anchor = element("a", undefined, text); + anchor.href = href; + anchor.target = "_blank"; + anchor.rel = "noopener"; + + return anchor; +} + +/** Text with links and code spans, from a template: `[label](url)` and `` `code` ``. */ +export function rich(text: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + const pattern = /\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`/g; + let at = 0; + + for (const match of text.matchAll(pattern)) { + fragment.append(text.slice(at, match.index)); + + if (match[1] !== undefined) fragment.appendChild(link(match[2]!, match[1])); + else fragment.appendChild(element("code", undefined, match[3])); + at = match.index + match[0].length; + } + + fragment.append(text.slice(at)); + + return fragment; +} diff --git a/apps/diffr-web/vite.config.ts b/apps/diffr-web/vite.config.ts index 91d9e531e..bdc0a285f 100644 --- a/apps/diffr-web/vite.config.ts +++ b/apps/diffr-web/vite.config.ts @@ -80,7 +80,12 @@ export default defineConfig({ { find: /^\.\/codicon\.ttf$/, replacement: codicons }, ], }, - server: { fs: { allow: [fileURLToPath(new URL("../..", import.meta.url))] } }, + server: { + fs: { + // Linked dependencies can resolve outside the workspace in local checkouts. + allow: [fileURLToPath(new URL("../..", import.meta.url)), codicons], + }, + }, worker: { format: "es", plugins: () => [dropRawEngine()] }, // Keep local UI fixtures available in dev without publishing them. build: { diff --git a/apps/diffr-web/worker.mjs b/apps/diffr-web/worker.mjs new file mode 100644 index 000000000..3647ec285 --- /dev/null +++ b/apps/diffr-web/worker.mjs @@ -0,0 +1,14 @@ +export default { + fetch(request, env) { + const url = new URL(request.url); + + if (url.hostname === "diffsthatgetyoustiff.com") { + url.hostname = "diffr.fast"; + url.protocol = "https:"; + + return Response.redirect(url.href, 308); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/apps/diffr-web/wrangler.jsonc b/apps/diffr-web/wrangler.jsonc index 00c1f1661..b31561bd0 100644 --- a/apps/diffr-web/wrangler.jsonc +++ b/apps/diffr-web/wrangler.jsonc @@ -1,13 +1,19 @@ -// diffsthatgetyoustiff.com: the built page as Workers static assets, with no Worker code. +// Static assets with a hostname redirect to diffr.fast. // Deployed by .github/workflows/diffr-web.yml; pull requests get preview URLs. { "name": "diffsthatgetyoustiff", + "main": "worker.mjs", "compatibility_date": "2026-09-01", "assets": { + "binding": "ASSETS", + "run_worker_first": true, "directory": "./dist", // Every path is a route of the page: /owner/repo/pull/N, /owner/repo/compare/a...b. "not_found_handling": "single-page-application", }, "preview_urls": true, - "routes": [{ "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }], + "routes": [ + { "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }, + { "pattern": "diffr.fast/*", "zone_name": "diffr.fast" }, + ], } diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index 0b95abb21..f7f1e50aa 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -50,6 +50,8 @@ import './style.css'; import { CSSStyle, ObservableElementSizeObserver, RefCounted, applyStyle, applyViewZones, translatePosition } from './utils.js'; export interface IDiffCodeEditorWidgetOptions { + /** The containing file list preserves the viewport when fold view zones change. */ + scrollPositionManagedExternally?: boolean; originalEditor?: ICodeEditorWidgetOptions; modifiedEditor?: ICodeEditorWidgetOptions; } @@ -334,6 +336,7 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { let scrollState: StableEditorScrollState | undefined; this._register(applyViewZones(this._editors.modified, modifiedViewZones, isUpdatingModViewZones => { isUpdatingViewZones = isUpdatingModViewZones; + if (codeEditorWidgetOptions.scrollPositionManagedExternally) { return; } if (isUpdatingViewZones) { scrollState = StableEditorScrollState.capture(this._editors.modified); } else { diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index 1c058ebd2..950e08029 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -97,7 +97,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this.editor = this._register(this._instantiationService.createInstance(DiffEditorWidget, this._elements.editor, { overflowWidgetsDomNode: this._overflowWidgetsDomNode, fixedOverflowWidgets: true - }, this._workbenchUIElementFactory.codeEditorWidgetOptions ?? {})); + }, { ...this._workbenchUIElementFactory.codeEditorWidgetOptions, scrollPositionManagedExternally: true })); this.isModifedFocused = observableCodeEditor(this.editor.getModifiedEditor()).isFocused; this.isOriginalFocused = observableCodeEditor(this.editor.getOriginalEditor()).isFocused; this.isFocused = derived(this, reader => this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader)); @@ -151,7 +151,9 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< })); this._register(this.editor.getOriginalEditor().onDidScrollChange(e => { - if (this._isSettingScrollTop) { + // Folding can clamp this editor to its new bottom. The outer list owns + // the viewport, so shrinking a file must reveal the following file instead. + if (this._isSettingScrollTop || e.scrollHeightChanged) { return; } @@ -300,15 +302,16 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< const delta = Math.max(0, Math.min(viewPort.start + stickySectionHeight - verticalRange.start - (this._sectionHeader?.height.get() ?? 0), maxDelta)); this._resourceHeader.element.style.transform = `translateY(${delta}px)`; - globalTransaction(tx => { - const dimension = { - width: width - 2 * 8 - 2 * 1, - height: Math.max(0, verticalRange.length - this._outerEditorHeight - (this._sectionHeader?.height.get() ?? 0)), - }; - this.editor.layout(dimension); - }); try { + // Layout may clamp the inner scroll position; it must not scroll the file list. this._isSettingScrollTop = true; + globalTransaction(tx => { + const dimension = { + width: width - 2 * 8 - 2 * 1, + height: Math.max(0, verticalRange.length - this._outerEditorHeight - (this._sectionHeader?.height.get() ?? 0)), + }; + this.editor.layout(dimension); + }); this._lastScrollTop = editorScroll; this.editor.getOriginalEditor().setScrollTop(editorScroll); } finally { diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index e08f11ed5..6f484715f 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -258,7 +258,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const index = viewItems.indexOf(previousItems[i]); if (index >= 0) { const newBefore = viewItems.slice(0, index).reduce((sum, item) => sum + item.contentHeight.get() + this._spaceBetweenPx, 0); - anchoredScrollTop = newBefore + oldTop - before - this._scrollStart(); + const heightDelta = viewItems[index].preserveBottomOnMeasure + ? viewItems[index].contentHeight.get() + this._spaceBetweenPx - previousHeights[i] : 0; + anchoredScrollTop = newBefore + oldTop - before + heightDelta - this._scrollStart(); } break; } @@ -695,7 +697,13 @@ export class MultiDiffEditorWidgetImpl extends Disposable { if (itemContentRange.isBefore(contentViewPort)) { contentScrollOffsetToScrollOffset -= itemContentHeight - itemHeight; - v.hide(); + // Measure the preceding viewport before a fast upward flick can skip its placeholders. + if (itemContentRange.endExclusive > contentViewPort.start - viewPortHeight) { + const viewPort = OffsetRange.ofStartAndLength(scrollTop + contentScrollOffsetToScrollOffset, viewPortHeight); + v.render(itemRange, itemContentHeight - itemHeight, width, viewPort); + } else { + v.hide(); + } } else if (itemContentRange.isAfter(contentViewPort)) { v.hide(); } else { @@ -749,6 +757,7 @@ export interface IMultiDiffEditorOptionsViewState { export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; class VirtualizedViewItem extends Disposable { + public preserveBottomOnMeasure = false; private readonly _templateRef = this._register(disposableObservableValue | undefined>(this, undefined)); public readonly contentHeight = derived(this, reader => @@ -859,7 +868,14 @@ class VirtualizedViewItem extends Disposable { let ref = this._templateRef.get(); if (!ref) { - ref = this._objectPool.getUnusedObj(new TemplateData(this.viewModel, this._deltaScrollVertical)); + // Entering an unmeasured file from below must retain its bottom edge. + // Keep the anchor through the observable updates flushed after render. + this.preserveBottomOnMeasure = verticalSpace.start < viewPort.start; + queueMicrotask(() => { this.preserveBottomOnMeasure = false; }); + ref = this._objectPool.getUnusedObj(new TemplateData(this.viewModel, delta => { + // Initial diff alignment is already included in the outer height anchor. + if (!this.preserveBottomOnMeasure) { this._deltaScrollVertical(delta); } + })); this._templateRef.set(ref, undefined); const selections = this.viewModel.lastTemplateData.get().selections; diff --git a/diffr/Cargo.lock b/diffr/Cargo.lock index dd1abac3e..434af80cf 100644 --- a/diffr/Cargo.lock +++ b/diffr/Cargo.lock @@ -1007,6 +1007,20 @@ dependencies = [ "wit-bindgen 0.62.0", ] +[[package]] +name = "diffr-plugin-summarize" +version = "0.1.1" +dependencies = [ + "anyhow", + "bytes", + "diffr-plugin-sdk", + "http", + "http-body-util", + "serde", + "serde_json", + "wasip3", +] + [[package]] name = "diffr-plugin-test-bodies" version = "0.1.1" @@ -1028,9 +1042,11 @@ dependencies = [ "diffr-plugin-deleted-bodies", "diffr-plugin-removed-runs", "diffr-plugin-sdk", + "diffr-plugin-summarize", "diffr-plugin-test-bodies", "serde", "serde_json", + "toml 0.8.23", "wasm-bindgen", ] @@ -4899,6 +4915,19 @@ dependencies = [ "wit-bindgen 0.57.1", ] +[[package]] +name = "wasip3" +version = "0.9.0+wasi-0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d5749fdf69bb9400562eba50f8f25f5cd800ef4b74300038b6f99ae7409674" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.20", + "wit-bindgen 0.62.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -5566,6 +5595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53cb4b5556c3a791e86838ea287782bdafa704d55b0e68b5b81a3a16b9ea5f4b" dependencies = [ "bitflags 2.13.2", + "futures", "wit-bindgen-rust-macro", ] diff --git a/diffr/crates/diffr-plugin-sdk/src/native.rs b/diffr/crates/diffr-plugin-sdk/src/native.rs index aca36ba41..0f5398501 100644 --- a/diffr/crates/diffr-plugin-sdk/src/native.rs +++ b/diffr/crates/diffr-plugin-sdk/src/native.rs @@ -404,6 +404,65 @@ fn with_git(call: impl FnOnce(&dyn GitHost) -> Result) -> Result Result<(u16, Vec), String>; + /// Wait, as between retries. + fn sleep(&self, duration: std::time::Duration); +} + +thread_local! { + static HTTP: RefCell>> = const { RefCell::new(None) }; +} + +/// Answer the plugins' HTTP calls on this thread with `http` until the +/// returned guard drops. +pub fn set_http(http: Box) -> HttpGuard { + HTTP.with(|slot| *slot.borrow_mut() = Some(http)); + HttpGuard(()) +} + +pub struct HttpGuard(()); + +impl Drop for HttpGuard { + fn drop(&mut self) { + HTTP.with(|slot| *slot.borrow_mut() = None); + } +} + +/// What a plugin calls, in place of `wasi:http`. +pub mod http { + /// POST through the host; an error when the host has no HTTP. + pub fn post( + url: &str, + headers: &[(&str, String)], + body: &str, + timeout_ms: u64, + ) -> Result<(u16, Vec), String> { + super::HTTP.with(|slot| match slot.borrow().as_deref() { + Some(http) => http.post(url, headers, body, timeout_ms), + None => Err("this host makes no HTTP requests".to_owned()), + }) + } + + pub fn sleep(duration: std::time::Duration) { + super::HTTP.with(|slot| { + if let Some(http) = slot.borrow().as_deref() { + http.sleep(duration); + } + }); + } +} + /// The export macros: a native host names each plugin's type itself. #[doc(hidden)] #[macro_export] diff --git a/diffr/crates/diffr-web/Cargo.toml b/diffr/crates/diffr-web/Cargo.toml index 47e21a06c..7656bbe6d 100644 --- a/diffr/crates/diffr-web/Cargo.toml +++ b/diffr/crates/diffr-web/Cargo.toml @@ -21,8 +21,10 @@ diffr-plugin-context = { path = "../../plugins/shape/context/rust" } diffr-plugin-deleted-bodies = { path = "../../plugins/shape/deleted-bodies/rust" } diffr-plugin-removed-runs = { path = "../../plugins/shape/removed-runs/rust" } diffr-plugin-test-bodies = { path = "../../plugins/shape/test-bodies/rust" } +diffr-plugin-summarize = { path = "../../plugins/shape/summarize/rust" } anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = { version = "0.8", default-features = false, features = ["display", "parse"] } wasm-bindgen = "0.2.129" console_error_panic_hook = "0.1.7" diff --git a/diffr/crates/diffr-web/src/lib.rs b/diffr/crates/diffr-web/src/lib.rs index 58969cc82..88f1e308c 100644 --- a/diffr/crates/diffr-web/src/lib.rs +++ b/diffr/crates/diffr-web/src/lib.rs @@ -51,6 +51,9 @@ struct Request { /// Ask for highlight captures. #[serde(default)] syntax: bool, + /// Run the summarizer too, when it is enabled. + #[serde(default)] + summarize: bool, } /// A file as the classifier sees it: its manifest entry, with tags, and why @@ -123,14 +126,45 @@ fn problem(error: &anyhow::Error) -> Problem { } } +/// `config` with `overrides`, a JSON object, merged over it: a table into a +/// table, anything else replaced. +fn merged(config: Option<&str>, overrides: Option<&str>) -> anyhow::Result> { + fn merge(base: &mut toml::Table, over: toml::Table) { + for (key, value) in over { + match (base.get_mut(&key), value) { + (Some(toml::Value::Table(base)), toml::Value::Table(over)) => merge(base, over), + (_, value) => { + base.insert(key, value); + } + } + } + } + let Some(overrides) = overrides else { + return Ok(config.map(str::to_owned)); + }; + let mut table: toml::Table = toml::from_str(config.unwrap_or_default())?; + let over: toml::Table = serde_json::from_str(overrides)?; + merge(&mut table, over); + Ok(Some(toml::to_string(&table)?)) +} + #[wasm_bindgen] impl Differ { /// A differ for `config`, the text of a `config.toml`, or the bundled - /// defaults when it is absent. + /// defaults when it is absent. `overrides` is JSON merged over it, table + /// by table: the page's own settings, such as the summarizer's key. #[wasm_bindgen(constructor)] - pub fn new(config: Option) -> Result { + pub fn new(config: Option, overrides: Option) -> Result { console_error_panic_hook::set_once(); - Self::from_config(config.as_deref()).map_err(js_error) + merged(config.as_deref(), overrides.as_deref()) + .and_then(|config| Self::from_config(config.as_deref())) + .map_err(js_error) + } + + /// Whether the summarizer is enabled: the page then asks for each file's + /// summaries after showing it. + pub fn summarizes(&self) -> bool { + self.pipeline.summarizes() } /// The configuration's JSON Schema, with every setting's description @@ -156,8 +190,10 @@ impl Differ { /// Diff one changed file. `request` is JSON: /// `{"status": "modified", "lhs": {"path", "oid", "mode", "text"}, "rhs": {...}}`, - /// with `lhs` absent for an added file and `rhs` for a deleted one, and - /// an optional `"syntax": true` for highlight captures. Returns JSON + /// with `lhs` absent for an added file and `rhs` for a deleted one, an + /// optional `"syntax": true` for highlight captures, and an optional + /// `"summarize": true` to run the summarizer, which blocks on the page's + /// requests to the model. Returns JSON /// `{"entry", "hidden"?, "event"}`: the file classified with its text, /// and its `file` record. pub fn diff(&self, request: &str) -> Result { @@ -212,11 +248,13 @@ impl Differ { .filter_map(|side| Some((side.oid.clone(), side.text.clone()?.into_bytes()))) .collect(); let classified = self.classify(&request, blobs)?; + let _http = diffr_plugin_sdk::native::set_http(Box::new(plugins::PageHttp)); let outcome = match self.shaped( &classified, text(&request.lhs), text(&request.rhs), request.syntax, + request.summarize, ) { Ok(diff) => Outcome::Diff { diff }, Err(error) => Outcome::Error { @@ -254,6 +292,7 @@ impl Differ { before: &str, after: &str, syntax: bool, + summarize: bool, ) -> anyhow::Result { let entry = &classified.entry; let sizes = (before.len() as u64, after.len() as u64); @@ -296,7 +335,7 @@ impl Differ { }, ); plugins::ready(present(classified.hidden.as_deref(), diff, async |sides| { - self.pipeline.run(entry, sides) + self.pipeline.run(entry, sides, summarize) }))? } } @@ -406,6 +445,54 @@ mod tests { assert_eq!(diff["stats"]["fallback"]["code"], "hidden", "{diff}"); } + #[test] + fn overrides_merge_into_the_config_table_by_table() { + let config = merged( + Some("[plugins.shape.bundled.summarize]\nmin_lines = 5\nprovider = \"openai\"\n"), + Some(r#"{"plugins": {"shape": {"bundled": {"summarize": {"enabled": true, "provider": "anthropic"}}}}}"#), + ) + .unwrap() + .unwrap(); + let table: toml::Table = toml::from_str(&config).unwrap(); + let summarize = &table["plugins"]["shape"]["bundled"]["summarize"]; + assert_eq!(summarize["min_lines"].as_integer(), Some(5)); + assert_eq!(summarize["provider"].as_str(), Some("anthropic")); + assert_eq!(summarize["enabled"].as_bool(), Some(true)); + } + + #[test] + fn the_summarizer_runs_only_in_its_own_pass_and_asks_the_page() { + let differ = Differ::from_config( + merged( + None, + Some(r#"{"plugins": {"shape": {"bundled": {"summarize": {"enabled": true, "api_key": "key", "min_lines": 1}}}}}"#), + ) + .unwrap() + .as_deref(), + ) + .expect("the config loads"); + assert!(differ.summarizes()); + let body: String = (0..30).map(|i| format!(" const v{i} = {i};\n")).collect(); + let request = |summarize: bool| { + json!({ + "status": "added", + "rhs": {"path": "src/a.ts", "text": format!("export function f() {{\n{body}}}\n")}, + "summarize": summarize, + }) + }; + let first = diff(&differ, request(false)); + assert_eq!(first["event"]["diff"]["type"], "text", "{first}"); + // Off the web there is no page, so the summarizer's request fails the file. + let summarized = diff(&differ, request(true)); + let error = summarized["event"]["error"]["message"] + .as_str() + .unwrap_or_default(); + assert!( + error.contains("no page to make HTTP requests"), + "{summarized}" + ); + } + #[test] fn a_plugin_from_disk_is_a_config_error() { let error = Differ::from_config(Some("[plugins.shape.mine]\npath = \"plugins/mine\"\n")); diff --git a/diffr/crates/diffr-web/src/plugins.rs b/diffr/crates/diffr-web/src/plugins.rs index 13271c267..0a58d77bf 100644 --- a/diffr/crates/diffr-web/src/plugins.rs +++ b/diffr/crates/diffr-web/src/plugins.rs @@ -2,7 +2,8 @@ //! a browser, so each plugin's own crate is compiled in against the SDK's //! `native` contract and walks the engine's [`Cursor`] directly. The walk is //! the native host's: every node before and after its children, every plugin -//! in `plugins.shape.order`. +//! in `plugins.shape.order`. The summarizer's model calls go through the +//! page ([`PageHttp`]). use anyhow::{anyhow, Context as _}; use diffr_core::pairing::Pairing; use diffr_core::plugin::config::{ComponentSource, Folder, PluginsConfig}; @@ -10,7 +11,7 @@ use diffr_core::plugin::cursor::{self, Cursor}; use diffr_core::plugin::MutationFailed; use diffr_core::protocol::{self, FileChange, FileStatus, Source}; use diffr_core::tags; -use diffr_plugin_sdk::native::{CursorHost, GitHost}; +use diffr_plugin_sdk::native::{CursorHost, GitHost, HttpHost}; use diffr_plugin_sdk::prelude::{GuestClassifier, GuestPlugin}; use diffr_plugin_sdk::{FileEntry, MoveError, Region, RegionIds, RegionView, Side, Tag, Visit}; use serde_json::Value; @@ -44,8 +45,11 @@ fn make(options: String) -> Result, String> { type Constructor = fn(String) -> Result, String>; +/// The bundled shape plugin that calls a model, which the page runs in a +/// pass of its own (see [`Pipeline::run`]). +pub(crate) const SUMMARIZE: &str = "summarize"; + /// The bundled shape plugins this build links, by manifest name. -/// `summarize` calls a model over HTTP and is left to the native diffr. const LINKED: &[(&str, Constructor)] = &[ ("context", make::), ( @@ -57,6 +61,7 @@ const LINKED: &[(&str, Constructor)] = &[ make::, ), ("test-bodies", make::), + (SUMMARIZE, make::), ]; fn is_bundled(folder: &Folder) -> bool { @@ -101,11 +106,19 @@ impl Pipeline { Ok(Self { plugins }) } - /// Walk one file's sides with every plugin in order. + /// Whether the summarizer is enabled. + pub(crate) fn summarizes(&self) -> bool { + self.plugins.iter().any(|(name, _)| name == SUMMARIZE) + } + + /// Walk one file's sides with every plugin in order. The summarizer + /// waits on a model for each body, so it runs only when `summarize` asks: + /// the page shows a file first and asks again for its summaries. pub(crate) fn run( &self, file: &FileChange, sides: Pairing, + summarize: bool, ) -> anyhow::Result> { let cursor = match Cursor::new(file.clone(), sides) { Ok(cursor) => cursor, @@ -114,6 +127,9 @@ impl Pipeline { let cursor = diffr_plugin_sdk::Cursor::new(Box::new(Host(cursor))); let mut walked = Ok(()); for (name, plugin) in &self.plugins { + if name == SUMMARIZE && !summarize { + continue; + } host(&cursor).0.rewind(); walked = walk(&cursor, plugin.as_ref()).with_context(|| MutationFailed(name.clone())); if walked.is_err() { @@ -245,6 +261,59 @@ impl Classifier { } } +/// The page's HTTP: synchronous requests from the worker (`diffrHost` in +/// worker.ts), which a worker, unlike a page, may make. +pub(crate) struct PageHttp; + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +extern "C" { + /// One JSON request to the worker; it answers in JSON and never throws. + #[wasm_bindgen(js_name = diffrHost)] + fn diffr_host(request: &str) -> String; +} + +/// Tests run off the web, with no page to ask. +#[cfg(not(target_arch = "wasm32"))] +fn diffr_host(_request: &str) -> String { + serde_json::json!({"error": "no page to make HTTP requests"}).to_string() +} + +impl HttpHost for PageHttp { + fn post( + &self, + url: &str, + headers: &[(&str, String)], + body: &str, + timeout_ms: u64, + ) -> Result<(u16, Vec), String> { + #[derive(serde::Deserialize)] + struct Response { + status: Option, + #[serde(default)] + body: String, + error: Option, + } + let request = serde_json::json!({ + "post": url, + "headers": headers, + "body": body, + "timeout_ms": timeout_ms, + }); + let response: Response = serde_json::from_str(&diffr_host(&request.to_string())) + .map_err(|error| format!("the page answered badly: {error}"))?; + match (response.error, response.status) { + (Some(error), _) => Err(error), + (None, Some(status)) => Ok((status, response.body.into_bytes())), + (None, None) => Err("the page sent no status".to_owned()), + } + } + + fn sleep(&self, duration: std::time::Duration) { + diffr_host(&serde_json::json!({"sleep_ms": duration.as_millis() as u64}).to_string()); + } +} + struct Blobs(BTreeMap>); impl GitHost for Blobs { diff --git a/diffr/plugins/shape/summarize/rust/Cargo.toml b/diffr/plugins/shape/summarize/rust/Cargo.toml index e55f877b7..b0c34320d 100644 --- a/diffr/plugins/shape/summarize/rust/Cargo.toml +++ b/diffr/plugins/shape/summarize/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.87.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] anyhow = "1.0" @@ -17,8 +17,14 @@ diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0. serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +# The component calls models through wasi:http. +[target.'cfg(target_os = "wasi")'.dependencies] wasip3 = { version = "0.9.0", features = ["http-compat"] } http = "1" http-body-util = "0.1" bytes = "1" +# Linked into a host (diffr in the browser), it calls through the host's HTTP. +[target.'cfg(not(target_os = "wasi"))'.dependencies] +diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.3.0", features = ["native"] } + diff --git a/diffr/plugins/shape/summarize/rust/src/lib.rs b/diffr/plugins/shape/summarize/rust/src/lib.rs index 3e9a40171..f96744be6 100644 --- a/diffr/plugins/shape/summarize/rust/src/lib.rs +++ b/diffr/plugins/shape/summarize/rust/src/lib.rs @@ -8,6 +8,10 @@ use anyhow::anyhow; use diffr_plugin_sdk::prelude::*; use serde::Deserialize; use std::time::Duration; +#[cfg(target_os = "wasi")] +mod http; +#[cfg(not(target_os = "wasi"))] +#[path = "native_http.rs"] mod http; mod provider; pub use provider::{Details, Provider}; @@ -115,10 +119,11 @@ impl Summarize { http::sleep(Duration::from_millis(250 * (1 << attempt.min(6)))).await; } }; - let pseudocode = provider - .text(&text) - .ok_or_else(|| failed("no text in the response".to_owned()))? - .trim(); + let pseudocode = unfenced( + provider + .text(&text) + .ok_or_else(|| failed("no text in the response".to_owned()))?, + ); Ok((!pseudocode.is_empty()).then(|| pseudocode.to_owned())) } } @@ -215,17 +220,71 @@ impl GuestPlugin for Summarize { cursor.set_label(data.id, Some(&pseudocode))?; return Ok(false); }; - let docstring_text = cursor.text(docstring)?; - let first = docstring_text - .lines() - .next() - .ok_or_else(|| format!("empty docstring region {docstring}"))? - .trim(); - cursor.set_label(data.id, Some(&format!("{first}\n{pseudocode}")))?; + let label = match lead(&cursor.text(docstring)?) { + Some(lead) => format!("{lead}\n{pseudocode}"), + None => pseudocode, + }; + cursor.set_label(data.id, Some(&label))?; Ok(false) } } +/// The model's answer without the code fence models wrap it in despite the +/// prompt, which says the fences only delimit its examples. +fn unfenced(text: &str) -> &str { + let mut text = text.trim(); + while let (Some(open), Some(close)) = (text.find('\n'), text.rfind('\n')) { + if open >= close || !text.starts_with("```") || text[close..].trim() != "```" { + break; + } + text = text[open + 1..close].trim_matches(['\n', '\r']).trim_end(); + } + text +} + +/// The docstring's first line with words in it, which stands for the whole +/// docstring while it is folded. A comment opened on a line of its own, as +/// `/**` or `"""`, keeps that opener in front of it. +fn lead(docstring: &str) -> Option { + let mut lines = docstring.lines().map(str::trim); + let first = lines.next()?; + if first.chars().any(char::is_alphanumeric) { + return Some(first.to_owned()); + } + let words = lines + .map(|line| line.trim_start_matches('*').trim()) + .find(|line| line.chars().any(char::is_alphanumeric))?; + Some(format!("{first} {words}")) +} + +#[cfg(test)] +mod tests { + use super::{lead, unfenced}; + + #[test] + fn fenced_answers_lose_their_fences_and_keep_their_indentation() { + assert_eq!(unfenced("```pseudo\nif a\n b\n```"), "if a\n b"); + assert_eq!(unfenced("```\nif a\n b\n```\n"), "if a\n b"); + assert_eq!(unfenced("if a\n b"), "if a\n b"); + // Fences inside the answer are its own. + assert_eq!(unfenced("a\n```\nb\n```"), "a\n```\nb\n```"); + } + + #[test] + fn a_docstring_opened_on_its_own_line_is_led_by_its_first_words() { + assert_eq!(lead("/// Sums.\n/// More.").as_deref(), Some("/// Sums.")); + assert_eq!( + lead("/**\n * The requests to its model.\n */").as_deref(), + Some("/** The requests to its model.") + ); + assert_eq!( + lead("\"\"\"\n Parse it.\n \"\"\"").as_deref(), + Some("\"\"\" Parse it.") + ); + assert_eq!(lead("/**\n */"), None); + } +} + /// The `summarize:docstring` region on `id`'s side that shares its fold state. fn docstring(cursor: &Cursor, id: u32) -> Result, String> { let side = cursor.get(id)?.side; diff --git a/diffr/plugins/shape/summarize/rust/src/native_http.rs b/diffr/plugins/shape/summarize/rust/src/native_http.rs new file mode 100644 index 000000000..28fd0765b --- /dev/null +++ b/diffr/plugins/shape/summarize/rust/src/native_http.rs @@ -0,0 +1,16 @@ +//! HTTP and retry delays through a host that links the plugin in: the +//! SDK's native `http`, which answers each call before it returns. +use std::time::Duration; + +pub async fn post( + url: &str, + headers: &[(&'static str, String)], + body: &str, + timeout_ms: u64, +) -> anyhow::Result<(u16, Vec)> { + diffr_plugin_sdk::native::http::post(url, headers, body, timeout_ms).map_err(anyhow::Error::msg) +} + +pub async fn sleep(duration: Duration) { + diffr_plugin_sdk::native::http::sleep(duration); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38203b627..5039cf3a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + playwright: + specifier: 1.62.1 + version: 1.62.1 zod: specifier: 4.6.5 version: 4.6.5 @@ -51,6 +54,9 @@ importers: '@dev.fast/review-protocol': specifier: workspace:* version: link:../../packages/review-protocol + zod: + specifier: 'catalog:' + version: 4.6.5 devDependencies: '@types/trusted-types': specifier: 2.0.7 @@ -70,6 +76,9 @@ importers: '@webgpu/types': specifier: 0.1.66 version: 0.1.66 + playwright: + specifier: 'catalog:' + version: 1.62.1 typescript: specifier: 6.0.3 version: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d79af1db..6463cce18 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - "apps/*" catalog: + playwright: 1.62.1 zod: "4.6.5" catalogMode: strict