From f21a8a4ebfc8a959f25b1984c205dfebab33efbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:02:21 +0000 Subject: [PATCH 01/26] Run the summarize plugin in the browser engine The plugin links natively into diffr-web and reaches its model through a synchronous request the worker makes. It runs in a second pass, so a file shows first and its summaries follow. The page's settings merge over config.toml as JSON overrides. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/Cargo.lock | 30 ++++++ diffr/crates/diffr-plugin-sdk/src/native.rs | 59 +++++++++++ diffr/crates/diffr-web/Cargo.toml | 2 + diffr/crates/diffr-web/src/lib.rs | 99 +++++++++++++++++-- diffr/crates/diffr-web/src/plugins.rs | 77 ++++++++++++++- diffr/plugins/shape/summarize/rust/Cargo.toml | 8 +- diffr/plugins/shape/summarize/rust/src/lib.rs | 4 + .../shape/summarize/rust/src/native_http.rs | 16 +++ 8 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 diffr/plugins/shape/summarize/rust/src/native_http.rs 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..b5a5691cb 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.1.1", features = ["native"] } + diff --git a/diffr/plugins/shape/summarize/rust/src/lib.rs b/diffr/plugins/shape/summarize/rust/src/lib.rs index 3e9a40171..0117cd54d 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}; 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); +} From 1cb6ed6fffa66af0f43b49931bbf145848d49a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:02:21 +0000 Subject: [PATCH 02/26] Add search, onboarding, agent tools and lenses to diffr web Cmd+P goes to a file and Cmd+F finds text in every diffed file. A first visit asks for a GitHub token and a summarizer key, both skippable, and the settings explain diffr's configuration with a reference to every key. WebMCP tools let an agent read the comparison, save lenses, and install plugins that fold regions. The file tree follows the scroll, a hidden file opens to its text, line numbers keep clear of the splitter, and idle workers stop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/package.json | 3 +- apps/diffr-web/src/agent.ts | 562 ++++++++++++++++++ apps/diffr-web/src/comparison.ts | 301 ++++++++++ apps/diffr-web/src/engine/engine.ts | 110 +++- apps/diffr-web/src/engine/worker.ts | 244 +++++++- apps/diffr-web/src/find.ts | 344 +++++++++++ apps/diffr-web/src/lenses.ts | 104 ++++ apps/diffr-web/src/main.ts | 237 +++----- apps/diffr-web/src/panels.ts | 29 +- apps/diffr-web/src/pluginsSection.ts | 84 +++ apps/diffr-web/src/quickOpen.ts | 202 +++++++ .../review/browser/reviewChangedFilesTree.ts | 13 +- .../review/services/reviewFilesDiffView.ts | 153 ++++- .../services/reviewStructuralDiffSession.ts | 28 + apps/diffr-web/src/settings.ts | 94 +++ apps/diffr-web/src/settingsDialog.ts | 437 ++++++++++++++ apps/diffr-web/src/styles.css | 457 ++++++++++++++ apps/diffr-web/src/ui.ts | 101 ++++ pnpm-lock.yaml | 3 + 19 files changed, 3316 insertions(+), 190 deletions(-) create mode 100644 apps/diffr-web/src/agent.ts create mode 100644 apps/diffr-web/src/find.ts create mode 100644 apps/diffr-web/src/lenses.ts create mode 100644 apps/diffr-web/src/pluginsSection.ts create mode 100644 apps/diffr-web/src/quickOpen.ts create mode 100644 apps/diffr-web/src/settingsDialog.ts create mode 100644 apps/diffr-web/src/ui.ts diff --git a/apps/diffr-web/package.json b/apps/diffr-web/package.json index e710f89b1..1e822a45a 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", diff --git a/apps/diffr-web/src/agent.ts b/apps/diffr-web/src/agent.ts new file mode 100644 index 000000000..75934a4cc --- /dev/null +++ b/apps/diffr-web/src/agent.ts @@ -0,0 +1,562 @@ +/** + * The page's tools for an agent in the reader's browser, through WebMCP (`navigator.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 { + provideContext?(context: { tools: RegisteredTool[] }): void; + registerTool?(tool: RegisteredTool): void; +} + +declare global { + interface Navigator { + 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 = 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.provideContext) context.provideContext({ tools: registered }); + else for (const tool of registered) context.registerTool?.(tool); +} diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 047e3a95c..0c329a939 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -21,10 +21,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 +46,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; @@ -60,6 +66,12 @@ export interface Work { /** 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 { @@ -81,6 +93,10 @@ export class Comparison extends Disposable { hidden: 0, failed: 0, diffMs: 0, + summarizing: 0, + summarized: 0, + summaryErrors: [], + pluginErrors: [], }; preview: Preview | undefined; change: Change | undefined; @@ -92,6 +108,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 +178,226 @@ 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; + }[] { + return this.order.map((path) => { + const file = this.files.get(path)!; + + return { + path, + previousPath: file.changed.previousPath, + status: file.changed.status, + hidden: file.classified?.hidden, + diffed: file.state === "done", + }; + }); + } + + /** 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 open, every other file folded, 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; + + 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); } @@ -492,6 +732,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 +784,40 @@ 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 { event } = await this.engine.diff(this.request(file, text), true); + + if (this._store.isDisposed) return; + + if (event.diff?.type === "text") { + this.session?.updateFileDiff(path, event.diff); + this.work.summarized++; + } 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/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts index 476126abc..2d64d6946 100644 --- a/apps/diffr-web/src/engine/engine.ts +++ b/apps/diffr-web/src/engine/engine.ts @@ -1,9 +1,11 @@ +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 +31,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 +57,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; @@ -131,6 +135,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,8 +152,21 @@ 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; - 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; @@ -164,6 +183,7 @@ export class Engine { busy: 0, retire: false, ready: false, + summarizing: false, }; stats.workers++; @@ -172,6 +192,8 @@ export class Engine { slot.worker.postMessage({ module, config: this.config, + overrides: this.overrides, + plugins: this.plugins, } satisfies Request), (error: Error) => this.fail(error), ); @@ -188,8 +210,11 @@ export class Engine { 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 +228,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; @@ -238,8 +264,9 @@ export class Engine { 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 +278,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 +338,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,6 +378,15 @@ export class Engine { return response.previews; } + /** The configuration's JSON Schema: every setting, its description and its default. */ + async schema(): Promise { + const response = await this.send>( + (id) => ({ id, schema: true }), + ); + + return response.schema; + } + dispose(): void { clearTimeout(this.idle); 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..e842eb1a7 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -10,12 +10,22 @@ import "./styles.css"; import "./editorFont.js"; import { Comparison } from "./comparison.js"; import { Engine, onEngineChange } from "./engine/engine.js"; -import { parseTarget, setToken, targetPath, token } from "./github.js"; +import { FindBar } from "./find.js"; +import { parseTarget, targetPath } from "./github.js"; +import { renderLensBar } from "./lenses.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({}); @@ -42,6 +52,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")); @@ -54,21 +84,11 @@ const panels = new Panels(() => comparison); root.appendChild(panels.element); -onEngineChange(() => panels.update()); - -function element( - tag: K, - className?: string, - text?: string, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); +const find = new FindBar(() => comparison); - if (className) node.className = className; +body.appendChild(find.element); - if (text !== undefined) node.textContent = text; - - return node; -} +onEngineChange(() => panels.update()); function iconButton( icon: string, @@ -85,18 +105,47 @@ function iconButton( /** 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) => + agentPluginsSection(parent, (next) => settings.replaceEngine(next)), +}; + +// Only where the browser offers its agent the page's tools, which bring their own decoder. +if (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(); @@ -119,7 +168,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); @@ -137,11 +188,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"; @@ -204,14 +264,11 @@ 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, + "settings-gear", + "Settings: GitHub token, summaries, diffr", + () => openSettings(settings), ), ); @@ -258,118 +315,6 @@ 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); /** A `z` waiting for its fold command. */ @@ -389,8 +334,16 @@ 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 () => 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 +429,5 @@ window.addEventListener( ); route(); + +if (!readSetting("onboarded")) openOnboarding(settings); diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts index 9c4c2a1a3..716b0d3b6 100644 --- a/apps/diffr-web/src/panels.ts +++ b/apps/diffr-web/src/panels.ts @@ -115,6 +115,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 +154,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)], 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..2a4091395 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -32,7 +32,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 +45,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"; @@ -170,6 +177,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 +195,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. @@ -272,6 +289,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,6 +300,7 @@ export class ReviewFilesDiffView extends Disposable { private readonly streamStatus: HTMLElement; private offscreen = false; private layoutDeferred = false; + private sideBySide = true; viewedScope: "lens" | undefined; constructor( @@ -411,8 +430,10 @@ export class ReviewFilesDiffView extends Disposable { ); // 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 = layout.get() === "split"; + this.widget.setRenderSideBySide(this.sideBySide); + }; this._register(layout.onDidChange(applyLayout)); applyLayout(); if (document) { @@ -583,6 +604,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 +624,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); + } } }); }), @@ -767,6 +793,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 +908,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. */ @@ -1082,8 +1211,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/settings.ts b/apps/diffr-web/src/settings.ts index 5c45ff9a5..fd7fa435f 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(): string | undefined { + const summary = summaries(); + + 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..db36eb422 --- /dev/null +++ b/apps/diffr-web/src/settingsDialog.ts @@ -0,0 +1,437 @@ +/** + * 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, +): void { + const candidate = new Engine(config, overrides); + status.textContent = "Checking…"; + 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 { + return rich( + `For private repositories, and 5,000 requests an hour instead of 60. A [fine-grained token](${GITHUB_TOKEN_URL}) with read access to contents and pull requests is enough. It stays in this browser and is sent only to GitHub.`, + ); +} + +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( + `Leave it empty to turn summaries off. [Get a ${details.title} key](${details.keys}). It stays in this browser and is sent only to ${details.title}.`, + ), + ); + }; + + provider.addEventListener("change", update); + update(); + field(parent, "Provider", provider); + field(parent, "API key", key, keyHint); + field( + parent, + "Model", + model, + "Optional. A small, fast model reads best: summaries are written for every long new function.", + ); + + 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, + }; +} + +/** A first visit: the GitHub token, then the summarizer's key. Either can be skipped. */ +export function openOnboarding(host: SettingsHost): void { + const finish = () => writeSetting("onboarded", "1"); + + dialog( + host.root, + "Welcome to diffr", + (node, actions) => { + // Closing it any way counts: the page does not ask again. + node.addEventListener("close", finish); + const content = node.appendChild(element("div", "app-onboarding")); + + const step = ( + title: string, + build: (content: HTMLElement) => void, + buttons: HTMLButtonElement[], + ) => { + content.replaceChildren(element("h3", undefined, title)); + build(content); + actions.replaceChildren(...buttons); + content.querySelector("input, select")?.focus(); + }; + + const summariesStep = () => { + let controls: SummaryControls; + const status = element("p", "app-dialog-status"); + + step( + "2 of 2 · Summaries", + (content) => { + content.appendChild( + element( + "p", + undefined, + "diffr can fold long new functions and tests behind short pseudocode, written by a model you choose. It needs that provider's API key.", + ), + ); + controls = summaryFields(content); + content.appendChild(status); + }, + [ + actionButton("Skip", () => node.close()), + actionButton( + "Finish", + () => { + const value = controls.read(); + + if (!value) { + node.close(); + + return; + } + + setSummaries(value); + tryEngine( + host, + readSetting("config"), + configOverrides(), + status, + () => node.close(), + ); + }, + true, + ), + ], + ); + }; + + const input = tokenInput(); + + step( + "1 of 2 · GitHub", + (content) => { + content.appendChild( + element( + "p", + undefined, + "diffr reads pull requests from GitHub and diffs them here, in your browser. Public repositories work without a token.", + ), + ); + field(content, "GitHub token", input, tokenHint()); + }, + [ + actionButton("Skip", summariesStep), + actionButton( + "Continue", + () => { + const value = input.value.trim(); + + if (value !== (token() ?? "")) { + setToken(value || null); + host.reload(); + } + + summariesStep(); + }, + true, + ), + ], + ); + }, + "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 = (() => { + const saved = summaries(); + setSummaries(summaryValue); + const value = configOverrides(); + setSummaries(saved); + + return value; + })(); + + // 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..ce52cac86 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -269,3 +269,460 @@ 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-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 { + display: flex; + align-items: center; + gap: 8px; +} + +.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; +} 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/pnpm-lock.yaml b/pnpm-lock.yaml index 38203b627..915fd0a15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,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 From 2b70b0558a81ae4fe7417a910fd5866cc70140f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:12:22 +0000 Subject: [PATCH 03/26] Offer diffr web's agent tools through document.modelContext and registerTool Chrome moved WebMCP to document.modelContext, and the current draft dropped provideContext for registerTool, which returns a promise. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/agent.ts | 25 ++++++++++++++++++++----- apps/diffr-web/src/main.ts | 2 +- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/diffr-web/src/agent.ts b/apps/diffr-web/src/agent.ts index 75934a4cc..8f845d160 100644 --- a/apps/diffr-web/src/agent.ts +++ b/apps/diffr-web/src/agent.ts @@ -1,5 +1,5 @@ /** - * The page's tools for an agent in the reader's browser, through WebMCP (`navigator.modelContext`): + * 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. */ @@ -38,12 +38,19 @@ interface RegisteredTool { } 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; - registerTool?(tool: RegisteredTool): void; } declare global { + interface Document { + modelContext?: ModelContext; + } + interface Navigator { + /** Where Chrome first put it, kept for a while as an alias. */ modelContext?: ModelContext; } } @@ -523,7 +530,7 @@ const answer = (text: string, isError = false): ToolResult => ({ /** Offer the tools to the browser's agent. */ export function registerAgentTools(host: AgentHost): void { - const context = navigator.modelContext; + const context = document.modelContext ?? navigator.modelContext; if (!context) return; // zod/mini says only "Invalid input" until given its messages. @@ -557,6 +564,14 @@ export function registerAgentTools(host: AgentHost): void { }), ); - if (context.provideContext) context.provideContext({ tools: registered }); - else for (const tool of registered) context.registerTool?.(tool); + 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/main.ts b/apps/diffr-web/src/main.ts index e842eb1a7..c18c91688 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -122,7 +122,7 @@ const settings: SettingsHost = { }; // Only where the browser offers its agent the page's tools, which bring their own decoder. -if (navigator.modelContext) +if (document.modelContext ?? navigator.modelContext) void import("./agent.js").then(({ registerAgentTools }) => registerAgentTools({ comparison: () => comparison, From 32451ac30a6ad076f7862edcbcbcd19a3e228b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:30:57 +0000 Subject: [PATCH 04/26] Keep diffr web's results in IndexedDB across reloads Opening a comparison again shows each file's diff and summaries without fetching, diffing or calling the model again. Entries are named by the commits, the file, the engine build and its configuration; past 512 MB the ones read longest ago go. Settings and the engine panel show the cache's size, and Settings can clear it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/cache.ts | 201 ++++++++++++++++++++++++++++ apps/diffr-web/src/cacheSection.ts | 44 ++++++ apps/diffr-web/src/comparison.ts | 90 ++++++++++--- apps/diffr-web/src/engine/engine.ts | 23 +++- apps/diffr-web/src/main.ts | 12 +- apps/diffr-web/src/panels.ts | 17 ++- apps/diffr-web/src/styles.css | 8 +- 7 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 apps/diffr-web/src/cache.ts create mode 100644 apps/diffr-web/src/cacheSection.ts diff --git a/apps/diffr-web/src/cache.ts b/apps/diffr-web/src/cache.ts new file mode 100644 index 000000000..5eacf1e66 --- /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-cache"; + +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 0c329a939..94253f686 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, @@ -61,6 +68,8 @@ 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. */ @@ -90,6 +99,7 @@ export class Comparison extends Disposable { fetching: 0, diffing: 0, diffed: 0, + cached: 0, hidden: 0, failed: 0, diffMs: 0, @@ -606,6 +616,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, @@ -691,31 +720,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; @@ -797,13 +849,19 @@ export class Comparison extends Disposable { this.changed.fire(); try { - const { event } = await this.engine.diff(this.request(file, text), true); + 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) { diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts index 2d64d6946..d49ac4fdc 100644 --- a/apps/diffr-web/src/engine/engine.ts +++ b/apps/diffr-web/src/engine/engine.ts @@ -1,3 +1,4 @@ +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 @@ -110,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}).`); @@ -154,6 +170,8 @@ export class Engine { 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; /** * `config` is the reader's config.toml; `overrides`, JSON merged over it, the page's own settings @@ -173,6 +191,9 @@ export class Engine { }); this.notices.catch(() => {}); this.slots.push(this.spawn()); + this.fingerprint = build.then((build) => + cacheKey([build, config, overrides, this.plugins]), + ); } private spawn(): Slot { diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index c18c91688..685549702 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -8,6 +8,8 @@ import { 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 { Engine, onEngineChange } from "./engine/engine.js"; import { FindBar } from "./find.js"; @@ -90,6 +92,10 @@ body.appendChild(find.element); onEngineChange(() => panels.update()); +onCacheChange(() => panels.update()); + +loadCacheUsage(); + function iconButton( icon: string, tooltip: string, @@ -117,8 +123,10 @@ const settings: SettingsHost = { route(); }, reload: () => route(), - sections: (parent) => - agentPluginsSection(parent, (next) => settings.replaceEngine(next)), + 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. diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts index 716b0d3b6..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", @@ -183,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/styles.css b/apps/diffr-web/src/styles.css index ce52cac86..3fa1b1108 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -685,12 +685,18 @@ body { color: var(--vscode-descriptionForeground); } -.app-plugin-head { +.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; From 775b170afb583a3cedc26bce333e41ca57938d8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:46:01 +0000 Subject: [PATCH 05/26] Make the summarizer's pseudocode readable The model's answer loses the code fence models wrap it in, a docstring opened on a line of its own is led by its first words instead of a bare /**, and the page draws summaries upright in the editor's colour rather than as faint italic comments. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/styles.css | 28 +++++++ diffr/plugins/shape/summarize/rust/src/lib.rs | 77 ++++++++++++++++--- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 3fa1b1108..1bd425c6e 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -732,3 +732,31 @@ body { 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); +} diff --git a/diffr/plugins/shape/summarize/rust/src/lib.rs b/diffr/plugins/shape/summarize/rust/src/lib.rs index 0117cd54d..f96744be6 100644 --- a/diffr/plugins/shape/summarize/rust/src/lib.rs +++ b/diffr/plugins/shape/summarize/rust/src/lib.rs @@ -119,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())) } } @@ -219,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; From c0279436d9a9729c77cc2540aeac7e9f37a6c7e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:46:01 +0000 Subject: [PATCH 06/26] Name diffr web's cache database diffr-results A browser that once held a different diffr-cache database kept the cache off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns4ZYciirL61M66WFtLXRp Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/diffr-web/src/cache.ts b/apps/diffr-web/src/cache.ts index 5eacf1e66..0b974615a 100644 --- a/apps/diffr-web/src/cache.ts +++ b/apps/diffr-web/src/cache.ts @@ -9,7 +9,7 @@ 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-cache"; +const DB = "diffr-results"; const ENTRIES = "entries"; From 3edec38b520051379fc6bd3d40917c9e66a57e94 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 11:43:19 -0700 Subject: [PATCH 07/26] List only a lens's files while it is shown The file tree and the diff leave out every file outside the lens, and the summary counts only its files, as "7 of 32 files". All files lists everything again. Co-Authored-By: Claude Opus 5.5 Agent-Session: 0e3ef086-d04d-46f7-a982-868add70ee04 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/comparison.ts | 11 +++- .../review/services/reviewFilesDiffView.ts | 59 +++++++++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 94253f686..8f9218219 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -259,8 +259,9 @@ export class Comparison extends Disposable { } /** - * Show only a lens: its files open, every other file folded, and in a file it gives line ranges - * for, the folds that hold none of them closed. `undefined` puts everything back as it was. + * 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; @@ -280,6 +281,12 @@ export class Comparison extends Disposable { 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)); diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index 2a4091395..fbc6baeaa 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -127,6 +127,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[], @@ -149,11 +151,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(); @@ -302,6 +316,7 @@ export class ReviewFilesDiffView extends Disposable { private layoutDeferred = false; private sideBySide = true; viewedScope: "lens" | undefined; + private listedPaths: ReadonlySet | undefined; constructor( private readonly container: HTMLElement, @@ -558,13 +573,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; @@ -664,6 +674,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, { @@ -694,9 +724,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 }; @@ -718,7 +751,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", { From 0481217322050b73e792883efd8e8b36de5bcae9 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 23:04:03 -0700 Subject: [PATCH 08/26] Make diffr usable on mobile with native scrolling and a file tree Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/comparison.ts | 23 + apps/diffr-web/src/display.ts | 6 + apps/diffr-web/src/main.ts | 143 +++- apps/diffr-web/src/mobileFiles.ts | 360 +++++++++ apps/diffr-web/src/nativeDiffScroll.ts | 124 ++++ .../review/services/reviewFilesDiffView.ts | 53 +- .../review/services/reviewStructuralFolds.ts | 219 +++++- apps/diffr-web/src/styles.css | 683 +++++++++++++++++- apps/diffr-web/src/theme.ts | 23 +- apps/diffr-web/vite.config.ts | 7 +- 10 files changed, 1608 insertions(+), 33 deletions(-) create mode 100644 apps/diffr-web/src/display.ts create mode 100644 apps/diffr-web/src/mobileFiles.ts create mode 100644 apps/diffr-web/src/nativeDiffScroll.ts diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 8f9218219..5a562f246 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -195,9 +195,14 @@ export class Comparison extends Disposable { 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, @@ -205,6 +210,15 @@ export class Comparison extends Disposable { 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", }; }); } @@ -437,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); } 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/main.ts b/apps/diffr-web/src/main.ts index 685549702..3e55fa351 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -3,7 +3,7 @@ * `/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"; @@ -11,10 +11,13 @@ 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 { 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"; @@ -35,12 +38,18 @@ 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")); @@ -82,6 +91,10 @@ 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); @@ -168,7 +181,7 @@ function route(): void { target, currentEngine(), layout, - wordWrap, + effectiveWordWrap, instantiation, )); @@ -186,6 +199,8 @@ function route(): void { } function renderHeader(): void { + mobileFiles.update(); + const focusedButton = Array.from(header.querySelectorAll("button")).findIndex( (button) => button === document.activeElement, ); @@ -219,6 +234,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", @@ -237,7 +266,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)"], @@ -272,13 +301,76 @@ function renderHeader(): void { ); } - header.appendChild( - iconButton( - "settings-gear", - "Settings: GitHub token, summaries, diffr", - () => openSettings(settings), - ), - ); + 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); @@ -286,6 +378,24 @@ 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, () => panel.togglePopover()); + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-haspopup", "dialog"); + panel.setAttribute("role", "dialog"); + panel.addEventListener("toggle", () => + button.setAttribute( + "aria-expanded", + String(panel.matches(":popover-open")), + ), + ); + + return { button, panel }; +} + function togglePanel(panel: "stats" | "engine"): void { panels.toggle(panel); renderHeader(); @@ -325,6 +435,11 @@ function navigate(path: string): void { window.addEventListener("popstate", route); +mobileViewport.addEventListener("change", () => { + narrow.set(mobileViewport.matches, undefined); + renderHeader(); +}); + /** A `z` waiting for its fold command. */ let chord = false; @@ -343,7 +458,11 @@ function command(event: KeyboardEvent): (() => void) | undefined { const current = comparison; if ((event.metaKey || event.ctrlKey) && !event.altKey) { - if (key === "b") return () => current.toggleFileTree(); + 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(); diff --git a/apps/diffr-web/src/mobileFiles.ts b/apps/diffr-web/src/mobileFiles.ts new file mode 100644 index 000000000..7fc44f111 --- /dev/null +++ b/apps/diffr-web/src/mobileFiles.ts @@ -0,0 +1,360 @@ +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 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.sheet.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.sheet.close(); + }), + actionButton("Expand all", () => { + this.comparison()?.foldAll(false); + this.sheet.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.sheet.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.sheet.close(); + }; + + mobileViewport.addEventListener("change", resize); + this._register({ + dispose: () => mobileViewport.removeEventListener("change", resize), + }); + this.element.append(this.sheet); + } + + toggle(): void { + if (this.sheet.open) this.sheet.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.sheet.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..bffcb84d5 --- /dev/null +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -0,0 +1,124 @@ +import { Dimension } from "vs/base/browser/dom.js"; +import { Disposable } from "vs/base/common/lifecycle.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)"); + +/** 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; + this.widget.setScrollTop(this.scroller.scrollTop); + 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") && + !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; + 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(); + + if (enabled) this.scroller.scrollTop = top; + } + + private updateExtent(): void { + this.extent.style.height = this.enabled + ? `${Math.max(this.scroller.clientHeight, this.widget?.getContentHeight() ?? 0)}px` + : "100%"; + } +} diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index fbc6baeaa..f5bdd87e8 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, @@ -56,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, @@ -83,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', @@ -228,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); @@ -435,21 +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.sideBySide = layout.get() === "split"; + 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( @@ -527,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, diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index f810d7376..86f3b6200 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -29,6 +29,7 @@ import { } from "vs/editor/common/model.js"; import { nestedIds } from "../../folds.js"; +import { mobileViewport } from "../../nativeDiffScroll.js"; import { bandDetail, structuralFoldables, @@ -89,6 +90,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 +121,7 @@ export class StructuralFoldControls extends Disposable { ) { super(); controls.set(editor, this); + this.installPinchFolding(); this._register({ dispose: () => controls.delete(editor) }); this._register({ dispose: () => { @@ -120,7 +136,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( @@ -263,6 +280,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, @@ -326,6 +434,7 @@ 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; } @@ -379,6 +488,14 @@ export class StructuralFoldControls extends Disposable { ? region.getHiddenModifiedRange(undefined) : region.getHiddenOriginalRange(undefined), ); + this.touchFolds.show( + this.foldablesOf(diff).filter( + (foldable) => + foldable.chevron && + !hidden.some((range) => range.contains(foldable.line + 1)), + ), + (foldable) => this.isFolded(foldable) || this.isSummaryFolded(foldable), + ); const visibleScopes = this.foldablesOf(diff).filter( (foldable) => foldable.rail && @@ -527,7 +644,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 +920,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 +980,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 +1004,95 @@ 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 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 = "44px"; + 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 = "44px"; + 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/styles.css b/apps/diffr-web/src/styles.css index 1bd425c6e..a87eb397a 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; } @@ -760,3 +760,684 @@ body { 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-x 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) { + .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) { + .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) { + .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) { + .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) { + .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, + display 240ms allow-discrete, + overlay 240ms allow-discrete; + } + .app-mobile-files[open] { + opacity: 1; + transform: translateY(0); + } + .app-mobile-files::backdrop { + opacity: 0; + transition: + opacity 240ms ease, + display 240ms allow-discrete, + overlay 240ms allow-discrete; + } + .app-mobile-files[open]::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] { + opacity: 0; + transform: translateY(100%); + } + .app-mobile-files[open]::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); + } +} 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/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: { From 36d21809c95d61684ee4f48357ec77b275168a9f Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 23:07:23 -0700 Subject: [PATCH 09/26] Fix mobile horizontal swipes and keep background updates from disrupting controls Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/engine/engine.ts | 29 +++-- apps/diffr-web/src/main.ts | 13 ++- apps/diffr-web/src/nativeDiffScroll.ts | 103 ++++++++++++++++++ .../review/services/reviewStructuralFolds.ts | 6 +- apps/diffr-web/src/styles.css | 2 +- 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts index d49ac4fdc..d3977d2a0 100644 --- a/apps/diffr-web/src/engine/engine.ts +++ b/apps/diffr-web/src/engine/engine.ts @@ -209,13 +209,15 @@ export class Engine { stats.workers++; module().then( - (module) => + (module) => { + if (this.failure || !this.slots.includes(slot)) return; slot.worker.postMessage({ module, config: this.config, overrides: this.overrides, plugins: this.plugins, - } satisfies Request), + } satisfies Request); + }, (error: Error) => this.fail(error), ); slot.worker.onmessage = ({ data }: MessageEvent) => @@ -228,6 +230,7 @@ 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; @@ -274,12 +277,20 @@ 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 { @@ -409,18 +420,6 @@ export class Engine { } dispose(): void { - clearTimeout(this.idle); - - for (const slot of this.slots.splice(0)) { - slot.worker.terminate(); - stats.workers--; - stats.busy -= slot.busy; - } - - for (const call of this.pending.values()) - call.reject(new Error("The engine was stopped.")); - this.pending.clear(); - this.queue.length = 0; - changed(); + this.fail(new Error("The engine was stopped.")); } } diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 3e55fa351..35602089f 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -200,6 +200,8 @@ 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")) return; const focusedButton = Array.from(header.querySelectorAll("button")).findIndex( (button) => button === document.activeElement, @@ -386,12 +388,11 @@ function quickMenu(icon: string, label: string) { button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-haspopup", "dialog"); panel.setAttribute("role", "dialog"); - panel.addEventListener("toggle", () => - button.setAttribute( - "aria-expanded", - String(panel.matches(":popover-open")), - ), - ); + panel.addEventListener("toggle", () => { + const open = panel.matches(":popover-open"); + button.setAttribute("aria-expanded", String(open)); + if (!open && panel.isConnected) renderHeader(); + }); return { button, panel }; } diff --git a/apps/diffr-web/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts index bffcb84d5..bc714870c 100644 --- a/apps/diffr-web/src/nativeDiffScroll.ts +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -1,5 +1,6 @@ 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"; @@ -122,3 +123,105 @@ export class NativeDiffScroll extends Disposable { : "100%"; } } + +/** Monaco owns horizontal geometry; the browser retains vertical touch inertia. */ +export function horizontalTouchScroll(editor: ICodeEditor): { + dispose(): void; +} { + const node = editor.getContainerDomNode(); + let gesture: + | { + x: number; + y: number; + lastX: number; + time: number; + horizontal?: boolean; + velocity: number; + } + | undefined; + let frame = 0; + const stop = () => { + cancelAnimationFrame(frame); + frame = 0; + }; + const start = (event: TouchEvent) => { + stop(); + 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, + }; + }; + 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); + editor.setScrollLeft(editor.getScrollLeft() + delta); + 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 = editor.getScrollLeft(); + editor.setScrollLeft(before + velocity * elapsed); + velocity *= Math.exp(-elapsed / 180); + if ( + Math.abs(velocity) > 0.02 && + editor.getScrollLeft() !== before && + mobileViewport.matches + ) + frame = requestAnimationFrame(coast); + }; + frame = requestAnimationFrame(coast); + }; + const cancel = () => { + gesture = undefined; + stop(); + }; + 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("touchstart", start); + node.removeEventListener("touchmove", move); + node.removeEventListener("touchend", end); + node.removeEventListener("touchcancel", cancel); + }, + }; +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index 86f3b6200..a8437c30f 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -29,7 +29,10 @@ import { } from "vs/editor/common/model.js"; import { nestedIds } from "../../folds.js"; -import { mobileViewport } from "../../nativeDiffScroll.js"; +import { + horizontalTouchScroll, + mobileViewport, +} from "../../nativeDiffScroll.js"; import { bandDetail, structuralFoldables, @@ -122,6 +125,7 @@ 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: () => { diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index a87eb397a..4fc86273e 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -782,7 +782,7 @@ body { .is-native .app-diff-viewport { position: sticky; top: 0; - touch-action: pan-x pan-y; + touch-action: pan-y; } .is-native .monaco-editor .view-lines, .is-native .monaco-editor .view-line, From 8ec26fb4202fe3560557ead743f4bc69b9457a06 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 23:10:43 -0700 Subject: [PATCH 10/26] Apply repository lint conventions to review fixes Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/engine/engine.ts | 2 ++ apps/diffr-web/src/main.ts | 2 ++ apps/diffr-web/src/nativeDiffScroll.ts | 22 +++++++++++++++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts index d3977d2a0..22a5e12a6 100644 --- a/apps/diffr-web/src/engine/engine.ts +++ b/apps/diffr-web/src/engine/engine.ts @@ -231,6 +231,7 @@ 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; @@ -281,6 +282,7 @@ export class Engine { this.failure = error; this.rejectNotices(error); clearTimeout(this.idle); + for (const slot of this.slots.splice(0)) { slot.worker.terminate(); stats.workers--; diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 35602089f..0e558489e 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -200,6 +200,7 @@ 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")) return; @@ -391,6 +392,7 @@ function quickMenu(icon: string, label: string) { panel.addEventListener("toggle", () => { const open = panel.matches(":popover-open"); button.setAttribute("aria-expanded", String(open)); + if (!open && panel.isConnected) renderHeader(); }); diff --git a/apps/diffr-web/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts index bc714870c..3372c3614 100644 --- a/apps/diffr-web/src/nativeDiffScroll.ts +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -125,10 +125,9 @@ export class NativeDiffScroll extends Disposable { } /** Monaco owns horizontal geometry; the browser retains vertical touch inertia. */ -export function horizontalTouchScroll(editor: ICodeEditor): { - dispose(): void; -} { +export function horizontalTouchScroll(editor: ICodeEditor) { const node = editor.getContainerDomNode(); + let gesture: | { x: number; @@ -139,14 +138,18 @@ export function horizontalTouchScroll(editor: ICodeEditor): { velocity: number; } | undefined; + let frame = 0; + const stop = () => { cancelAnimationFrame(frame); frame = 0; }; + const start = (event: TouchEvent) => { stop(); gesture = undefined; + if ( !mobileViewport.matches || event.touches.length !== 1 || @@ -162,18 +165,23 @@ export function horizontalTouchScroll(editor: ICodeEditor): { velocity: 0, }; }; + 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(); @@ -183,19 +191,23 @@ export function horizontalTouchScroll(editor: ICodeEditor): { 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 = editor.getScrollLeft(); editor.setScrollLeft(before + velocity * elapsed); velocity *= Math.exp(-elapsed / 180); + if ( Math.abs(velocity) > 0.02 && editor.getScrollLeft() !== before && @@ -203,17 +215,21 @@ export function horizontalTouchScroll(editor: ICodeEditor): { ) frame = requestAnimationFrame(coast); }; + frame = requestAnimationFrame(coast); }; + const cancel = () => { gesture = undefined; stop(); }; + 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(); From 59283fd6e45c70e20b4aaf87d607ea9c32b25a12 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 23:20:34 -0700 Subject: [PATCH 11/26] Use native popover toggles and preserve exit animations Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/main.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 0e558489e..1c4e65334 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -112,12 +112,13 @@ 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; } @@ -385,7 +386,8 @@ 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, () => panel.togglePopover()); + const button = iconButton(icon, label); + button.popoverTargetElement = panel; button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-haspopup", "dialog"); panel.setAttribute("role", "dialog"); @@ -393,7 +395,13 @@ function quickMenu(icon: string, label: string) { const open = panel.matches(":popover-open"); button.setAttribute("aria-expanded", String(open)); - if (!open && panel.isConnected) renderHeader(); + if (!open) + void Promise.allSettled( + panel.getAnimations().map((animation) => animation.finished), + ).then(() => { + if (panel.isConnected && !panel.matches(":popover-open")) + renderHeader(); + }); }); return { button, panel }; From 273af8900db7f6a307baad7780a913218798dd12 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 00:46:34 -0700 Subject: [PATCH 12/26] fix(diffr-web): smooth mobile scrolling and sheet dismissal Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/package.json | 1 + apps/diffr-web/scripts/mobile-fixture.mjs | 47 ++ apps/diffr-web/scripts/mobile-stress.mjs | 457 ++++++++++++++++++ apps/diffr-web/src/mobileFiles.ts | 41 +- apps/diffr-web/src/nativeDiffScroll.ts | 45 +- .../review/services/reviewFilesDiffView.ts | 3 +- .../review/services/reviewStructuralFolds.ts | 105 ++-- apps/diffr-web/src/styles.css | 46 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + 10 files changed, 677 insertions(+), 75 deletions(-) create mode 100644 apps/diffr-web/scripts/mobile-fixture.mjs create mode 100644 apps/diffr-web/scripts/mobile-stress.mjs diff --git a/apps/diffr-web/package.json b/apps/diffr-web/package.json index 1e822a45a..727088f25 100644 --- a/apps/diffr-web/package.json +++ b/apps/diffr-web/package.json @@ -24,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/mobile-fixture.mjs b/apps/diffr-web/scripts/mobile-fixture.mjs new file mode 100644 index 000000000..b77a91df4 --- /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.\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-stress.mjs b/apps/diffr-web/scripts/mobile-stress.mjs new file mode 100644 index 000000000..c6a3ec812 --- /dev/null +++ b/apps/diffr-web/scripts/mobile-stress.mjs @@ -0,0 +1,457 @@ +/** 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: "Skip", exact: true }).click(); + await page.getByRole("button", { name: "Skip", 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" && 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.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 || n.contains(document.elementFromPoint(x, y)); + }), + "diff must not cover the scrollbar", + ); + + const copy = await page.evaluate(() => { + const line = [...document.querySelectorAll(".modified .view-line")].find( + (n) => + n.textContent.replace(/\u00a0/g, " ").includes("long code content") && + n.getBoundingClientRect().height, + ); + + 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"]'), + ); + + 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/src/mobileFiles.ts b/apps/diffr-web/src/mobileFiles.ts index 7fc44f111..b3ee1910e 100644 --- a/apps/diffr-web/src/mobileFiles.ts +++ b/apps/diffr-web/src/mobileFiles.ts @@ -10,6 +10,7 @@ export class MobileFiles extends Disposable { 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 = ""; @@ -28,7 +29,7 @@ export class MobileFiles extends Disposable { const title = element("h2", undefined, "Changed files"); title.id = "mobile-files-title"; this.sheet.setAttribute("aria-labelledby", title.id); - const close = actionButton("", () => this.sheet.close()); + 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"); @@ -95,11 +96,11 @@ export class MobileFiles extends Disposable { folds.append( actionButton("Fold all", () => { this.comparison()?.foldAll(true); - this.sheet.close(); + this.close(); }), actionButton("Expand all", () => { this.comparison()?.foldAll(false); - this.sheet.close(); + this.close(); }), ); @@ -137,7 +138,11 @@ export class MobileFiles extends Disposable { event.clientX < bounds.left || event.clientX > bounds.right) ) - this.sheet.close(); + this.close(); + }); + this.sheet.addEventListener("cancel", (event) => { + event.preventDefault(); + this.close(); }); this.sheet.addEventListener("close", () => { const trigger = @@ -148,7 +153,7 @@ export class MobileFiles extends Disposable { }); const resize = () => { - if (!mobileViewport.matches) this.sheet.close(); + if (!mobileViewport.matches) this.close(); }; mobileViewport.addEventListener("change", resize); @@ -158,8 +163,30 @@ export class MobileFiles extends Disposable { this.element.append(this.sheet); } + private close(): void { + if (!this.sheet.open || this.closing) return; + this.closing = true; + this.sheet.classList.add("is-closing"); + // Safari removes a closed dialog from the top layer before discrete CSS transitions finish. + // Keep it modal until its exit finishes, including when navigation dismisses the sheet. + const animations = this.sheet.getAnimations({ subtree: true }); + + const finish = () => { + if (!this.closing) return; + 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.sheet.open) this.sheet.close(); + if (this.closing) return; + + if (this.sheet.open) this.close(); else { document .querySelector(".app-header-files") @@ -257,7 +284,7 @@ export class MobileFiles extends Disposable { )) { const button = actionButton(file.path.split("/").at(-1)!, () => { this.selectedPath = file.path; - this.sheet.close(); + this.close(); this.comparison()?.openFile(file.path); this.refreshRows(this.comparison()?.fileList ?? []); }); diff --git a/apps/diffr-web/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts index 3372c3614..763a13838 100644 --- a/apps/diffr-web/src/nativeDiffScroll.ts +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -5,7 +5,9 @@ import type { MultiDiffEditorWidget } from "vs/editor/browser/widget/multiDiffEd import { element } from "./ui.js"; -export const mobileViewport = window.matchMedia("(max-width: 760px)"); +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 { @@ -51,7 +53,7 @@ export class NativeDiffScroll extends Disposable { if (!this.enabled || !(event.target instanceof Element)) return; if ( - event.target.closest(".view-line") && + event.target.closest(".view-line, pre.diff-hidden-lines-detail") && !event.target.closest( ".review-fold-pill, .review-fold-hint, .review-fold-closer", ) @@ -108,13 +110,15 @@ export class NativeDiffScroll extends Disposable { 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(); - if (enabled) this.scroller.scrollTop = top; + // Height changes (including Safari's collapsing toolbar) must not restart native inertia. + if (enteringNative) this.scroller.scrollTop = top; } private updateExtent(): void { @@ -136,10 +140,20 @@ export function horizontalTouchScroll(editor: ICodeEditor) { 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); @@ -148,6 +162,7 @@ export function horizontalTouchScroll(editor: ICodeEditor) { const start = (event: TouchEvent) => { stop(); + suppressClickUntil = 0; gesture = undefined; if ( @@ -163,6 +178,12 @@ export function horizontalTouchScroll(editor: ICodeEditor) { lastX: touch.clientX, time: performance.now(), velocity: 0, + surface: + event.target instanceof Element + ? (event.target.closest( + "pre.diff-hidden-lines-detail", + ) ?? undefined) + : undefined, }; }; @@ -187,7 +208,8 @@ export function horizontalTouchScroll(editor: ICodeEditor) { const now = performance.now(); const delta = gesture.lastX - x; gesture.velocity = delta / Math.max(1, now - gesture.time); - editor.setScrollLeft(editor.getScrollLeft() + delta); + scrollTo(position(gesture.surface) + delta, gesture.surface); + suppressClickUntil = now + 500; gesture.lastX = x; gesture.time = now; }; @@ -204,13 +226,13 @@ export function horizontalTouchScroll(editor: ICodeEditor) { const coast = (now: number) => { const elapsed = Math.min(32, now - last); last = now; - const before = editor.getScrollLeft(); - editor.setScrollLeft(before + velocity * elapsed); + const before = position(finished.surface); + scrollTo(before + velocity * elapsed, finished.surface); velocity *= Math.exp(-elapsed / 180); if ( Math.abs(velocity) > 0.02 && - editor.getScrollLeft() !== before && + position(finished.surface) !== before && mobileViewport.matches ) frame = requestAnimationFrame(coast); @@ -224,6 +246,14 @@ export function horizontalTouchScroll(editor: ICodeEditor) { 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); @@ -234,6 +264,7 @@ export function horizontalTouchScroll(editor: ICodeEditor) { dispose() { cancel(); modelChange.dispose(); + node.removeEventListener("click", click, true); node.removeEventListener("touchstart", start); node.removeEventListener("touchmove", move); node.removeEventListener("touchend", end); diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index f5bdd87e8..596fbe791 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -1195,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 && diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index a8437c30f..4ac52abf2 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -182,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())); @@ -427,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; @@ -443,46 +443,49 @@ export class StructuralFoldControls extends Disposable { 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 @@ -492,10 +495,18 @@ 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), @@ -503,11 +514,12 @@ export class StructuralFoldControls extends Disposable { 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< @@ -517,10 +529,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); diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 4fc86273e..2d7b8cddc 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -880,7 +880,7 @@ body { .app-mobile-folder-label:focus-visible { outline: 1px solid var(--vscode-focusBorder); } -@media (max-width: 760px) { +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { .app { height: 100dvh; } @@ -934,7 +934,7 @@ body { min-height: 44px; } } -@media (max-width: 760px) { +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { .app-dialog { max-height: 85dvh; overflow: auto; @@ -998,7 +998,7 @@ body { .app-mobile-fold-actions > button { flex: 1; } -@media (max-width: 760px) { +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { .review-workbench .monaco-editor .review-fold-chevron { visibility: hidden; } @@ -1207,14 +1207,14 @@ body { width: 18px; height: 18px; } -@media (max-width: 760px) { +@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) { +@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { .app-header { background: var(--vscode-editor-background); padding-inline: 8px; @@ -1362,22 +1362,17 @@ body { transform: translateY(100%); transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1), - opacity 180ms ease, - display 240ms allow-discrete, - overlay 240ms allow-discrete; + opacity 180ms ease; } - .app-mobile-files[open] { + .app-mobile-files[open]:not(.is-closing) { opacity: 1; transform: translateY(0); } .app-mobile-files::backdrop { opacity: 0; - transition: - opacity 240ms ease, - display 240ms allow-discrete, - overlay 240ms allow-discrete; + transition: opacity 240ms ease; } - .app-mobile-files[open]::backdrop { + .app-mobile-files[open]:not(.is-closing)::backdrop { opacity: 1; } @@ -1396,11 +1391,11 @@ body { transform: translateY(0) scale(1); } @starting-style { - .app-mobile-files[open] { + .app-mobile-files[open]:not(.is-closing) { opacity: 0; transform: translateY(100%); } - .app-mobile-files[open]::backdrop { + .app-mobile-files[open]:not(.is-closing)::backdrop { opacity: 0; } .app-header-popover:popover-open { @@ -1441,3 +1436,22 @@ body { transform: translateY(0); } } + +/* Summary bands keep their code-like line layout, but long lines remain readable by panning. */ +.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; +} +/* File headers and editor overlays must not cover the shared scroll controls. */ +.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/pnpm-lock.yaml b/pnpm-lock.yaml index 915fd0a15..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 @@ -73,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 From 99d66f95026a2abca8ae84c05fd43a56f59d8e0d Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 01:08:02 -0700 Subject: [PATCH 13/26] fix(diffr-web): keep touch fold controls inside the gutter Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/scripts/mobile-stress.mjs | 19 +++++++++++++++++++ .../review/services/reviewStructuralFolds.ts | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/diffr-web/scripts/mobile-stress.mjs b/apps/diffr-web/scripts/mobile-stress.mjs index c6a3ec812..e1a4474d0 100644 --- a/apps/diffr-web/scripts/mobile-stress.mjs +++ b/apps/diffr-web/scripts/mobile-stress.mjs @@ -200,6 +200,11 @@ try { 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); @@ -362,6 +367,20 @@ try { 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( diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index 4ac52abf2..1adaadb5f 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -1063,6 +1063,8 @@ class TouchFoldButtons implements IOverlayWidget { return; } const layout = this.editor.getLayoutInfo(); + // Hiding line numbers narrows the gutter; touch targets must not cover code. + const width = Math.min(44, layout.contentLeft); const lineHeight = this.editor.getOption(EditorOption.lineHeight); const visible = new Set(); for (const foldable of foldables) { @@ -1086,7 +1088,7 @@ class TouchFoldButtons implements IOverlayWidget { ); button.setAttribute("aria-expanded", String(!folded)); button.style.top = `${top}px`; - button.style.width = "44px"; + button.style.width = `${width}px`; button.style.height = `${lineHeight}px`; button.onclick = (event) => { event.stopPropagation(); @@ -1099,7 +1101,7 @@ class TouchFoldButtons implements IOverlayWidget { this.buttons.delete(line); } this.node.style.height = `${layout.height}px`; - this.node.style.width = "44px"; + this.node.style.width = `${width}px`; this.node.style.display = ""; if (this.added) this.editor.layoutOverlayWidget(this); else { From bdd7b546974910cae9fd4b23977dabe6610d5eb3 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 10:08:21 -0700 Subject: [PATCH 14/26] fix(diffr-web): avoid rebuilding idle menu triggers on close Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/scripts/menu-close.mjs | 106 ++++++++++++++++++++++++++ apps/diffr-web/src/main.ts | 16 +++- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 apps/diffr-web/scripts/menu-close.mjs diff --git a/apps/diffr-web/scripts/menu-close.mjs b/apps/diffr-web/scripts/menu-close.mjs new file mode 100644 index 000000000..e8c767de2 --- /dev/null +++ b/apps/diffr-web/scripts/menu-close.mjs @@ -0,0 +1,106 @@ +/** 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"; + +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("http://127.0.0.1:4181/fixture/mobile/pull/1?ui-fixture"); + await p.getByRole("button", { name: "Skip", exact: true }).click(); + await p.getByRole("button", { name: "Skip", 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(); + 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); + 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( + "http://127.0.0.1:4181/fixture/mobile/pull/1?ui-fixture", + ); + await loading.getByRole("button", { name: "Skip", exact: true }).click(); + await loading.getByRole("button", { name: "Skip", 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/src/main.ts b/apps/diffr-web/src/main.ts index 1c4e65334..e8b0bbce4 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -53,6 +53,8 @@ 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; @@ -203,7 +205,13 @@ function renderHeader(): void { mobileFiles.update(); // Streaming file results must not tear down the menu the reader is using. - if (header.querySelector(":popover-open")) return; + if (header.querySelector(":popover-open")) { + headerUpdatePending = true; + + return; + } + + headerUpdatePending = false; const focusedButton = Array.from(header.querySelectorAll("button")).findIndex( (button) => button === document.activeElement, @@ -399,7 +407,11 @@ function quickMenu(icon: string, label: string) { void Promise.allSettled( panel.getAnimations().map((animation) => animation.finished), ).then(() => { - if (panel.isConnected && !panel.matches(":popover-open")) + if ( + headerUpdatePending && + panel.isConnected && + !panel.matches(":popover-open") + ) renderHeader(); }); }); From a3a434a6b494da86c12e255b1af9d6b66503ea18 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 10:29:10 -0700 Subject: [PATCH 15/26] refactor(diffr-web): remove redundant mobile checks and comments Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121b0-989d-70f2-af0e-3053592e6839 --- apps/diffr-web/src/mobileFiles.ts | 4 +--- apps/diffr-web/src/review/services/reviewStructuralFolds.ts | 3 --- apps/diffr-web/src/styles.css | 2 -- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/diffr-web/src/mobileFiles.ts b/apps/diffr-web/src/mobileFiles.ts index b3ee1910e..b5584f880 100644 --- a/apps/diffr-web/src/mobileFiles.ts +++ b/apps/diffr-web/src/mobileFiles.ts @@ -167,12 +167,10 @@ export class MobileFiles extends Disposable { if (!this.sheet.open || this.closing) return; this.closing = true; this.sheet.classList.add("is-closing"); - // Safari removes a closed dialog from the top layer before discrete CSS transitions finish. - // Keep it modal until its exit finishes, including when navigation dismisses the sheet. + // Keep the dialog modal until Safari finishes its exit animation. const animations = this.sheet.getAnimations({ subtree: true }); const finish = () => { - if (!this.closing) return; this.closing = false; this.sheet.close(); this.sheet.classList.remove("is-closing"); diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index 1adaadb5f..98b7a9d6b 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -513,7 +513,6 @@ export class StructuralFoldControls extends Disposable { ); const visibleScopes = this.foldablesOf(diff).filter( (foldable) => - foldable.rail && intersectsViewport(foldable) && !this.isFolded(foldable) && !hidden.some((range) => range.contains(foldable.line + 1)), @@ -528,7 +527,6 @@ export class StructuralFoldControls extends Disposable { >(); const completed = this.foldablesOf(diff).filter( (scope) => - scope.rail && intersectsViewport(scope) && this.viewed?.get(path, scope.foldStateId).state === "viewed", ); @@ -1063,7 +1061,6 @@ class TouchFoldButtons implements IOverlayWidget { return; } const layout = this.editor.getLayoutInfo(); - // Hiding line numbers narrows the gutter; touch targets must not cover code. const width = Math.min(44, layout.contentLeft); const lineHeight = this.editor.getOption(EditorOption.lineHeight); const visible = new Set(); diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 2d7b8cddc..bcdf0098d 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -1437,7 +1437,6 @@ body { } } -/* Summary bands keep their code-like line layout, but long lines remain readable by panning. */ .app .monaco-editor .diff-hidden-lines .detail pre.diff-hidden-lines-detail { overflow-x: auto; text-overflow: clip; @@ -1445,7 +1444,6 @@ body { user-select: text; -webkit-user-select: text; } -/* File headers and editor overlays must not cover the shared scroll controls. */ .app .multiDiffEditor > div > .monaco-scrollable-element > .scrollbar { z-index: 2000; } From 85fba6b5e70cdc9e9132ee9db93c7965b6c56f4c Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 10:29:10 -0700 Subject: [PATCH 16/26] test(diffr-web): tighten mobile interaction assertions Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121b0-989d-70f2-af0e-3053592e6839 --- apps/diffr-web/scripts/menu-close.mjs | 10 ++++++---- apps/diffr-web/scripts/mobile-fixture.mjs | 2 +- apps/diffr-web/scripts/mobile-stress.mjs | 18 +++++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/diffr-web/scripts/menu-close.mjs b/apps/diffr-web/scripts/menu-close.mjs index e8c767de2..1710e9461 100644 --- a/apps/diffr-web/scripts/menu-close.mjs +++ b/apps/diffr-web/scripts/menu-close.mjs @@ -5,6 +5,8 @@ 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], @@ -20,7 +22,7 @@ for (const [name, engine] of [ 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("http://127.0.0.1:4181/fixture/mobile/pull/1?ui-fixture"); + await p.goto(`${base}/fixture/mobile/pull/1?ui-fixture`); await p.getByRole("button", { name: "Skip", exact: true }).click(); await p.getByRole("button", { name: "Skip", exact: true }).click(); await p.locator(".modified .view-line").first().waitFor({ timeout: 60000 }); @@ -29,6 +31,7 @@ for (const [name, engine] of [ 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); @@ -54,6 +57,7 @@ for (const [name, engine] of [ }, 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( @@ -75,9 +79,7 @@ for (const [name, engine] of [ await ready; await route.fulfill({ json: data }); }); - await loading.goto( - "http://127.0.0.1:4181/fixture/mobile/pull/1?ui-fixture", - ); + await loading.goto(`${base}/fixture/mobile/pull/1?ui-fixture`); await loading.getByRole("button", { name: "Skip", exact: true }).click(); await loading.getByRole("button", { name: "Skip", exact: true }).click(); diff --git a/apps/diffr-web/scripts/mobile-fixture.mjs b/apps/diffr-web/scripts/mobile-fixture.mjs index b77a91df4..0c9df810f 100644 --- a/apps/diffr-web/scripts/mobile-fixture.mjs +++ b/apps/diffr-web/scripts/mobile-fixture.mjs @@ -1,5 +1,5 @@ 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.\nReturn the processed entries."; + "// 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", diff --git a/apps/diffr-web/scripts/mobile-stress.mjs b/apps/diffr-web/scripts/mobile-stress.mjs index e1a4474d0..6f0061bfc 100644 --- a/apps/diffr-web/scripts/mobile-stress.mjs +++ b/apps/diffr-web/scripts/mobile-stress.mjs @@ -283,7 +283,9 @@ try { ); assert.ok( await summary.evaluate( - (n) => n.scrollLeft >= Math.min(20, n.scrollWidth - n.clientWidth), + (n) => + n.scrollWidth > n.clientWidth && + n.scrollLeft >= Math.min(20, n.scrollWidth - n.clientWidth), ), "long summary must pan without opening the fold", ); @@ -319,18 +321,16 @@ try { const x = r.x + Math.min(20, r.width / 2), y = r.y + r.height / 2; - return r.width === 0 || n.contains(document.elementFromPoint(x, y)); + return ( + r.width > 0 && + r.height > 0 && + n.contains(document.elementFromPoint(x, y)) + ); }), "diff must not cover the scrollbar", ); - const copy = await page.evaluate(() => { - const line = [...document.querySelectorAll(".modified .view-line")].find( - (n) => - n.textContent.replace(/\u00a0/g, " ").includes("long code content") && - n.getBoundingClientRect().height, - ); - + const copy = await longLine.evaluate((line) => { const range = document.createRange(); range.selectNodeContents(line); getSelection().removeAllRanges(); From 800e38ec15d792f643c51410209e8e9edb93b041 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 10:30:43 -0700 Subject: [PATCH 17/26] deploy(diffr-web): serve diffs.fast and diffr.fast Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121b0-989d-70f2-af0e-3053592e6839 --- apps/diffr-web/wrangler.jsonc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/diffr-web/wrangler.jsonc b/apps/diffr-web/wrangler.jsonc index 00c1f1661..2ae67e3b1 100644 --- a/apps/diffr-web/wrangler.jsonc +++ b/apps/diffr-web/wrangler.jsonc @@ -1,4 +1,4 @@ -// diffsthatgetyoustiff.com: the built page as Workers static assets, with no Worker code. +// The built page as Workers static assets, with no Worker code. // Deployed by .github/workflows/diffr-web.yml; pull requests get preview URLs. { "name": "diffsthatgetyoustiff", @@ -9,5 +9,9 @@ "not_found_handling": "single-page-application", }, "preview_urls": true, - "routes": [{ "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }], + "routes": [ + { "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }, + { "pattern": "diffs.fast", "custom_domain": true }, + { "pattern": "diffr.fast", "custom_domain": true }, + ], } From d13f53732df347ea4a0947cacd7fe46191122076 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 10:40:47 -0700 Subject: [PATCH 18/26] deploy(diffr-web): retain diffs.fast redirect Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c --- apps/diffr-web/wrangler.jsonc | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/diffr-web/wrangler.jsonc b/apps/diffr-web/wrangler.jsonc index 2ae67e3b1..c12aa4911 100644 --- a/apps/diffr-web/wrangler.jsonc +++ b/apps/diffr-web/wrangler.jsonc @@ -11,7 +11,6 @@ "preview_urls": true, "routes": [ { "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }, - { "pattern": "diffs.fast", "custom_domain": true }, { "pattern": "diffr.fast", "custom_domain": true }, ], } From 19772d14f785c7b86211f1748b84de340db8352f Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 11:20:52 -0700 Subject: [PATCH 19/26] fix(diffr-web): preserve upward scrolling across file boundaries Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- .../scripts/mobile-scroll-boundary.mjs | 159 ++++++++++++++++++ apps/diffr-web/src/nativeDiffScroll.ts | 7 +- .../multiDiffEditor/diffEditorItemTemplate.ts | 15 +- .../multiDiffEditorWidgetImpl.ts | 22 ++- 4 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 apps/diffr-web/scripts/mobile-scroll-boundary.mjs 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..00b5be88a --- /dev/null +++ b/apps/diffr-web/scripts/mobile-scroll-boundary.mjs @@ -0,0 +1,159 @@ +/** 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: "Skip", exact: true }).click(); + await page.getByRole("button", { name: "Skip", 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/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts index 763a13838..5de0b8806 100644 --- a/apps/diffr-web/src/nativeDiffScroll.ts +++ b/apps/diffr-web/src/nativeDiffScroll.ts @@ -28,7 +28,12 @@ export class NativeDiffScroll extends Disposable { () => { if (!this.enabled || this.syncing || !this.widget) return; this.syncing = true; - this.widget.setScrollTop(this.scroller.scrollTop); + 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 }, 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..6428d8cf2 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 @@ -300,15 +300,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; From 321c0cca88cc463033ec2a1363a0ca7cf3b9104b Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 11:29:53 -0700 Subject: [PATCH 20/26] fix(diffr-web): clarify optional onboarding and credential setup Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/scripts/menu-close.mjs | 16 +- .../scripts/mobile-scroll-boundary.mjs | 8 +- apps/diffr-web/scripts/mobile-stress.mjs | 8 +- apps/diffr-web/scripts/onboarding.mjs | 111 +++++++++ apps/diffr-web/src/settings.ts | 6 +- apps/diffr-web/src/settingsDialog.ts | 228 ++++++++++-------- apps/diffr-web/src/styles.css | 8 + 7 files changed, 276 insertions(+), 109 deletions(-) create mode 100644 apps/diffr-web/scripts/onboarding.mjs diff --git a/apps/diffr-web/scripts/menu-close.mjs b/apps/diffr-web/scripts/menu-close.mjs index 1710e9461..cd85cea2a 100644 --- a/apps/diffr-web/scripts/menu-close.mjs +++ b/apps/diffr-web/scripts/menu-close.mjs @@ -23,8 +23,12 @@ for (const [name, engine] of [ 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: "Skip", exact: true }).click(); - await p.getByRole("button", { name: "Skip", exact: true }).click(); + 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); @@ -80,8 +84,12 @@ for (const [name, engine] of [ await route.fulfill({ json: data }); }); await loading.goto(`${base}/fixture/mobile/pull/1?ui-fixture`); - await loading.getByRole("button", { name: "Skip", exact: true }).click(); - await loading.getByRole("button", { name: "Skip", exact: true }).click(); + 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", diff --git a/apps/diffr-web/scripts/mobile-scroll-boundary.mjs b/apps/diffr-web/scripts/mobile-scroll-boundary.mjs index 00b5be88a..2e7cc8d20 100644 --- a/apps/diffr-web/scripts/mobile-scroll-boundary.mjs +++ b/apps/diffr-web/scripts/mobile-scroll-boundary.mjs @@ -55,8 +55,12 @@ try { 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: "Skip", exact: true }).click(); - await page.getByRole("button", { name: "Skip", exact: true }).click(); + 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() diff --git a/apps/diffr-web/scripts/mobile-stress.mjs b/apps/diffr-web/scripts/mobile-stress.mjs index 6f0061bfc..41e354cce 100644 --- a/apps/diffr-web/scripts/mobile-stress.mjs +++ b/apps/diffr-web/scripts/mobile-stress.mjs @@ -55,8 +55,12 @@ try { route.fulfill({ json: fixture() }), ); await page.goto(`${base}/fixture/mobile/pull/1?ui-fixture`); - await page.getByRole("button", { name: "Skip", exact: true }).click(); - await page.getByRole("button", { name: "Skip", exact: true }).click(); + 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() 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/settings.ts b/apps/diffr-web/src/settings.ts index fd7fa435f..f148751cf 100644 --- a/apps/diffr-web/src/settings.ts +++ b/apps/diffr-web/src/settings.ts @@ -65,9 +65,9 @@ export function setSummaries(value: Summaries | undefined): void { } /** The page's settings as diffr configuration, merged over the reader's config.toml. */ -export function configOverrides(): string | undefined { - const summary = summaries(); - +export function configOverrides( + summary: Summaries | null = summaries() ?? null, +): string | undefined { if (!summary) return undefined; return JSON.stringify({ diff --git a/apps/diffr-web/src/settingsDialog.ts b/apps/diffr-web/src/settingsDialog.ts index db36eb422..ab57b76c6 100644 --- a/apps/diffr-web/src/settingsDialog.ts +++ b/apps/diffr-web/src/settingsDialog.ts @@ -39,10 +39,11 @@ function tryEngine( overrides: string | undefined, status: HTMLElement, onReady: () => void, -): void { +): Promise { const candidate = new Engine(config, overrides); status.textContent = "Checking…"; - candidate.notices.then( + + return candidate.notices.then( () => { onReady(); host.replaceEngine(candidate); @@ -66,7 +67,7 @@ function tokenInput(): HTMLInputElement { function tokenHint(): DocumentFragment { return rich( - `For private repositories, and 5,000 requests an hour instead of 60. A [fine-grained token](${GITHUB_TOKEN_URL}) with read access to contents and pull requests is enough. It stays in this browser and is sent only to GitHub.`, + `For private repositories or higher GitHub API limits, create a [fine-grained token](${GITHUB_TOKEN_URL}) with read access to Contents and Pull requests for the repositories you want to review. Your token is stored in this browser and sent only to GitHub.`, ); } @@ -101,12 +102,17 @@ function summaryFields(parent: HTMLElement): SummaryControls { key.placeholder = details.placeholder; keyHint.replaceChildren( rich( - `Leave it empty to turn summaries off. [Get a ${details.title} key](${details.keys}). It stays in this browser and is sent only to ${details.title}.`, + `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", update); + 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); @@ -114,7 +120,7 @@ function summaryFields(parent: HTMLElement): SummaryControls { parent, "Model", model, - "Optional. A small, fast model reads best: summaries are written for every long new function.", + "Optional. Leave blank to use the default model for this provider.", ); return { @@ -130,106 +136,139 @@ function summaryFields(parent: HTMLElement): SummaryControls { }; } -/** A first visit: the GitHub token, then the summarizer's key. Either can be skipped. */ +/** Optional first-visit setup. Both credentials can also be added in Settings. */ export function openOnboarding(host: SettingsHost): void { - const finish = () => writeSetting("onboarded", "1"); - dialog( host.root, "Welcome to diffr", (node, actions) => { - // Closing it any way counts: the page does not ask again. - node.addEventListener("close", finish); + node.addEventListener("close", () => writeSetting("onboarded", "1")); const content = node.appendChild(element("div", "app-onboarding")); - - const step = ( - title: string, - build: (content: HTMLElement) => void, - buttons: HTMLButtonElement[], - ) => { - content.replaceChildren(element("h3", undefined, title)); - build(content); + const github = element("div"); + github.append( + element("h3", undefined, "1 of 2 · GitHub access"), + element( + "p", + undefined, + "Review GitHub pull requests in your browser. Public repositories work without a token. You can add one later in Settings.", + ), + ); + 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); - content.querySelector("input, select")?.focus(); + // Focus the step rather than opening the mobile keyboard for an optional key. + step.tabIndex = -1; + step.setAttribute("autofocus", ""); + step.focus(); }; - const summariesStep = () => { - let controls: SummaryControls; - const status = element("p", "app-dialog-status"); - - step( - "2 of 2 · Summaries", - (content) => { - content.appendChild( - element( - "p", - undefined, - "diffr can fold long new functions and tests behind short pseudocode, written by a model you choose. It needs that provider's API key.", - ), - ); - controls = summaryFields(content); - content.appendChild(status); - }, - [ - actionButton("Skip", () => node.close()), - actionButton( - "Finish", - () => { - const value = controls.read(); - - if (!value) { - node.close(); - - return; - } - - setSummaries(value); - tryEngine( - host, - readSetting("config"), - configOverrides(), - status, - () => node.close(), - ); - }, - true, - ), - ], - ); - }; + const next = actionButton( + "Continue without token", + () => { + const value = input.value.trim() || null; - const input = tokenInput(); + if (value !== token()) { + setToken(value); + host.reload(); + } - step( - "1 of 2 · GitHub", - (content) => { - content.appendChild( - element( - "p", - undefined, - "diffr reads pull requests from GitHub and diffs them here, in your browser. Public repositories work without a token.", - ), - ); - field(content, "GitHub token", input, tokenHint()); + showSummaries(); }, - [ - actionButton("Skip", summariesStep), - actionButton( - "Continue", - () => { - const value = input.value.trim(); + 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"; - if (value !== (token() ?? "")) { - setToken(value || null); - host.reload(); - } + const back = actionButton("Back", () => show(github, [next])); + const skip = actionButton("Not now", () => node.close()); - summariesStep(); + 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(); }, - true, - ), - ], + ).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", ); @@ -311,14 +350,7 @@ hide_deleted = false`; return; } - const overrides = (() => { - const saved = summaries(); - setSummaries(summaryValue); - const value = configOverrides(); - setSummaries(saved); - - return value; - })(); + 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, () => { diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index bcdf0098d..32fb776cb 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -520,6 +520,14 @@ body { 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); From d804041fbc627925fd7bccb2c196b02777141710 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 11:42:47 -0700 Subject: [PATCH 21/26] fix(diffr-web): keep fold headers anchored across file boundaries Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/scripts/fold-scroll-anchor.mjs | 120 ++++++++++++++++++ .../widget/diffEditor/diffEditorWidget.ts | 3 + .../multiDiffEditor/diffEditorItemTemplate.ts | 6 +- 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/diffr-web/scripts/fold-scroll-anchor.mjs 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/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 6428d8cf2..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; } From 8137f49dc4258c4255ce3aeae65b2df79f2e4448 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 11:50:22 -0700 Subject: [PATCH 22/26] Use diffr.fast as the canonical app domain Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/worker.mjs | 12 ++++++++++++ apps/diffr-web/wrangler.jsonc | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 apps/diffr-web/worker.mjs diff --git a/apps/diffr-web/worker.mjs b/apps/diffr-web/worker.mjs new file mode 100644 index 000000000..fb5faf446 --- /dev/null +++ b/apps/diffr-web/worker.mjs @@ -0,0 +1,12 @@ +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 c12aa4911..b31561bd0 100644 --- a/apps/diffr-web/wrangler.jsonc +++ b/apps/diffr-web/wrangler.jsonc @@ -1,9 +1,12 @@ -// 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", @@ -11,6 +14,6 @@ "preview_urls": true, "routes": [ { "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }, - { "pattern": "diffr.fast", "custom_domain": true }, + { "pattern": "diffr.fast/*", "zone_name": "diffr.fast" }, ], } From 6c43454dae0dd16cbf2e143e939c6f06b7a45d9e Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 11:50:30 -0700 Subject: [PATCH 23/26] Apply worker spacing conventions Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/worker.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/diffr-web/worker.mjs b/apps/diffr-web/worker.mjs index fb5faf446..3647ec285 100644 --- a/apps/diffr-web/worker.mjs +++ b/apps/diffr-web/worker.mjs @@ -1,9 +1,11 @@ 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); } From 5697eb88314cb1e5fe47d645cbbca5a1faf62daf Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 12:00:40 -0700 Subject: [PATCH 24/26] Explain GitHub rate limits and token privacy in onboarding Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/src/settingsDialog.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/diffr-web/src/settingsDialog.ts b/apps/diffr-web/src/settingsDialog.ts index ab57b76c6..20fadeb1f 100644 --- a/apps/diffr-web/src/settingsDialog.ts +++ b/apps/diffr-web/src/settingsDialog.ts @@ -66,9 +66,18 @@ function tokenInput(): HTMLInputElement { } function tokenHint(): DocumentFragment { - return rich( - `For private repositories or higher GitHub API limits, create a [fine-grained token](${GITHUB_TOKEN_URL}) with read access to Contents and Pull requests for the repositories you want to review. Your token is stored in this browser and sent only to GitHub.`, + 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 { @@ -150,7 +159,7 @@ export function openOnboarding(host: SettingsHost): void { element( "p", undefined, - "Review GitHub pull requests in your browser. Public repositories work without a token. You can add one later in Settings.", + "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(); From 5df4eaed92d1568a67b9cd03987e3e73f4bff893 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 12:01:33 -0700 Subject: [PATCH 25/26] Apply onboarding hint spacing convention Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 Agent-Session: 01a121bc-677b-7a70-a558-53bb1365164c Agent-Session: 10dc1e44-eba6-438e-b46d-1228abd8fd81 Agent-Session: e137d86a-da30-480f-9f2f-152b1946fef2 --- apps/diffr-web/src/settingsDialog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/diffr-web/src/settingsDialog.ts b/apps/diffr-web/src/settingsDialog.ts index 20fadeb1f..87044509a 100644 --- a/apps/diffr-web/src/settingsDialog.ts +++ b/apps/diffr-web/src/settingsDialog.ts @@ -69,6 +69,7 @@ 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", From 4a007f459ba7ef4a3ed7ca62521b9961c87b4590 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Mon, 12 Oct 2026 11:41:21 -0500 Subject: [PATCH 26/26] Ask for plugin SDK 0.3.0 in summarize's native build too Merging main moved the SDK to 0.3.0, but the native dependency still asked for 0.1.1, so cargo could not resolve the workspace. Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- diffr/plugins/shape/summarize/rust/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diffr/plugins/shape/summarize/rust/Cargo.toml b/diffr/plugins/shape/summarize/rust/Cargo.toml index b5a5691cb..b0c34320d 100644 --- a/diffr/plugins/shape/summarize/rust/Cargo.toml +++ b/diffr/plugins/shape/summarize/rust/Cargo.toml @@ -26,5 +26,5 @@ 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.1.1", features = ["native"] } +diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.3.0", features = ["native"] }