From 7fec0a398f2f716893a1c0e914f4ebd0177255cf Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 17:16:27 -0700 Subject: [PATCH 01/21] Build diffr-core for wasm32-unknown-unknown `cargo xtask build-core-wasm` compiles the engine and every grammar for the browser. The grammars compile against tree-sitter-language's wasm libc headers; `crates/diffr-core/wasm` supplies the few functions newer scanners use beyond them. On wasm32, diffr-grammars links each grammar's tables unpacked and drops zstd, since a page is served compressed anyway. Agent-Session: 12453e0c-e113-407e-915b-4c9ef7f94bac Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/crates/diffr-core/wasm/compat.h | 67 +++++++++++ .../diffr-core/wasm/include/sys/types.h | 5 + diffr/crates/diffr-core/wasm/include/wchar.h | 5 + diffr/crates/diffr-grammars/Cargo.toml | 5 +- diffr/crates/diffr-grammars/build.rs | 20 ++++ diffr/crates/diffr-grammars/src/lib.rs | 3 +- diffr/xtask/src/main.rs | 109 +++++++++++++++++- 7 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 diffr/crates/diffr-core/wasm/compat.h create mode 100644 diffr/crates/diffr-core/wasm/include/sys/types.h create mode 100644 diffr/crates/diffr-core/wasm/include/wchar.h diff --git a/diffr/crates/diffr-core/wasm/compat.h b/diffr/crates/diffr-core/wasm/compat.h new file mode 100644 index 000000000..3c0a198c3 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/compat.h @@ -0,0 +1,67 @@ +/* Included before every C file of a wasm32-unknown-unknown build. The + * libc tree-sitter ships for that target leaves out a few things some + * grammars' scanners use. */ +#ifndef DIFFR_WASM_COMPAT_H +#define DIFFR_WASM_COMPAT_H +#ifndef __cplusplus +/* tree-sitter-language's wctype.h uses bool without including this. */ +#include +/* tree-sitter-cpp, tree-sitter-xml */ +typedef __WCHAR_TYPE__ wchar_t; +#ifndef static_assert +#define static_assert _Static_assert +#endif +#endif +/* tree-sitter-bash */ +static inline int diffr_isdigit(int c) { return c >= '0' && c <= '9'; } +#define isdigit diffr_isdigit +/* tree-sitter-containerfile */ +static inline int diffr_iswxdigit(int c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} +#define iswxdigit diffr_iswxdigit +static inline void *diffr_memchr(const void *s, int c, __SIZE_TYPE__ n) { + const unsigned char *p = (const unsigned char *)s; + for (; n; n--, p++) + if (*p == (unsigned char)c) return (void *)p; + return 0; +} +#define memchr diffr_memchr +/* tree-sitter-fortran, tree-sitter-r */ +static inline int diffr_iswblank(int c) { return c == ' ' || c == '\t'; } +#define iswblank diffr_iswblank +/* ts-parser-perl */ +static inline int diffr_strcmp(const char *a, const char *b) { + for (; *a && *a == *b; a++, b++) { + } + return (unsigned char)*a - (unsigned char)*b; +} +#define strcmp diffr_strcmp +/* tree-sitter-ocaml, tree-sitter-ruby, tree-sitter-html: ASCII case only. */ +static inline int diffr_iswlower(int c) { return c >= 'a' && c <= 'z'; } +#define iswlower diffr_iswlower +static inline int diffr_iswupper(int c) { return c >= 'A' && c <= 'Z'; } +#define iswupper diffr_iswupper +static inline int diffr_towupper(int c) { return diffr_iswlower(c) ? c - 'a' + 'A' : c; } +#define towupper diffr_towupper +static inline char *diffr_strchr(const char *s, int c) { + for (;; s++) { + if (*s == (char)c) return (char *)s; + if (!*s) return 0; + } +} +#define strchr diffr_strchr +static inline char *diffr_strncpy(char *dst, const char *src, __SIZE_TYPE__ n) { + __SIZE_TYPE__ i = 0; + for (; i < n && src[i]; i++) dst[i] = src[i]; + for (; i < n; i++) dst[i] = 0; + return dst; +} +#define strncpy diffr_strncpy +/* The vendored scanners: a scanner that gives up traps. */ +static inline __attribute__((noreturn)) void diffr_exit(int status) { + (void)status; + __builtin_trap(); +} +#define exit diffr_exit +#endif diff --git a/diffr/crates/diffr-core/wasm/include/sys/types.h b/diffr/crates/diffr-core/wasm/include/sys/types.h new file mode 100644 index 000000000..76342d496 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/include/sys/types.h @@ -0,0 +1,5 @@ +/* ts-parser-perl includes this for ssize_t. */ +#ifndef DIFFR_WASM_SYS_TYPES_H +#define DIFFR_WASM_SYS_TYPES_H +typedef __PTRDIFF_TYPE__ ssize_t; +#endif diff --git a/diffr/crates/diffr-core/wasm/include/wchar.h b/diffr/crates/diffr-core/wasm/include/wchar.h new file mode 100644 index 000000000..185708ce1 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/include/wchar.h @@ -0,0 +1,5 @@ +/* tree-sitter-php includes this; the compat header defines wchar_t. */ +#ifndef DIFFR_WASM_WCHAR_H +#define DIFFR_WASM_WCHAR_H +#include +#endif diff --git a/diffr/crates/diffr-grammars/Cargo.toml b/diffr/crates/diffr-grammars/Cargo.toml index e3e023e1b..f535508c9 100644 --- a/diffr/crates/diffr-grammars/Cargo.toml +++ b/diffr/crates/diffr-grammars/Cargo.toml @@ -21,7 +21,10 @@ include = [ [dependencies] tree-sitter-language = "0.1.7" -# The grammars' C code calls ZSTD_decompress. + +# The grammars' C code calls ZSTD_decompress, except on wasm32, where the +# tables are not packed. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] zstd-sys = { version = "2.1.0", default-features = false } [build-dependencies] diff --git a/diffr/crates/diffr-grammars/build.rs b/diffr/crates/diffr-grammars/build.rs index 78eb61459..b4955005d 100644 --- a/diffr/crates/diffr-grammars/build.rs +++ b/diffr/crates/diffr-grammars/build.rs @@ -6,6 +6,11 @@ //! as zstd bytes, and leaves an empty array in its place. A generated C //! function, `diffr_unpack_`, fills the arrays, and `src/lib.rs` runs it //! once before the grammar's first use. +//! +//! A wasm32 build compiles each grammar as generated, with an empty unpack +//! function: a page is served compressed, so packing would only cost the +//! decompressor, and wasm object files hold their tables in a form this +//! script does not read. use object::{Object, ObjectSection, ObjectSymbol}; use rayon::prelude::*; use std::collections::BTreeMap; @@ -185,6 +190,21 @@ fn package_roots() -> BTreeMap { /// fills them, and the grammar's constructor renamed to `diffr_`, then compile it. fn pack(symbol: &str, source: &Path, out: &Path) { std::fs::create_dir_all(out).unwrap(); + if std::env::var("CARGO_CFG_TARGET_ARCH").unwrap() == "wasm32" { + let grammar = format!( + "#define {symbol} diffr_{symbol}\n\ + #include \"parser.c\"\n\ + void diffr_unpack_{symbol}(void) {{}}\n" + ); + std::fs::write(out.join("grammar.c"), grammar).unwrap(); + let mut build = compiler(source, out); + build.file(out.join("grammar.c")); + if source.join("scanner.c").is_file() { + build.file(source.join("scanner.c")); + } + build.compile(symbol); + return; + } let parser = std::fs::read_to_string(source.join("parser.c")).unwrap(); let header = std::fs::read_to_string(source.join("tree_sitter/parser.h")).unwrap(); let tables = tables(&parser, &header); diff --git a/diffr/crates/diffr-grammars/src/lib.rs b/diffr/crates/diffr-grammars/src/lib.rs index 4d8748793..35848e6a7 100644 --- a/diffr/crates/diffr-grammars/src/lib.rs +++ b/diffr/crates/diffr-grammars/src/lib.rs @@ -4,7 +4,8 @@ //! The first call unpacks that grammar's tables. use std::sync::Once; use tree_sitter_language::LanguageFn; -// Links libzstd for the grammars' C code. +// Links libzstd for the grammars' C code; wasm32 grammars are not packed. +#[cfg(not(target_arch = "wasm32"))] use zstd_sys as _; macro_rules! grammar { diff --git a/diffr/xtask/src/main.rs b/diffr/xtask/src/main.rs index 109a8c697..c25ae49c9 100644 --- a/diffr/xtask/src/main.rs +++ b/diffr/xtask/src/main.rs @@ -178,6 +178,108 @@ fn wasm_artifact(output: &[u8], package_id: &Value) -> Result { bail!("Cargo did not report a WASM artifact for {package_id}") } +/// Build diffr-core for `wasm32-unknown-unknown`, the target a browser runs. +/// +/// tree-sitter ships the C library its parser needs there, and +/// tree-sitter-language the headers grammars compile against; grammar crates +/// that do not add those headers get them from `CFLAGS`. Grammars generated +/// for tree-sitter 0.26 also compile tree-sitter-language's copy of that +/// library, which is now a stub that fails on purpose, so its `wasm-src` +/// points at empty files instead. `crates/diffr-core/wasm` fills what a few +/// scanners use beyond it. macOS's `ar` writes no index a wasm linker +/// reads, so the archives are made with rustup's `llvm-ar`. +fn build_core_wasm(root: &Path) -> Result<()> { + wasm_cargo( + root, + &["build", "--locked", "--release", "--package", "diffr-core"], + ) +} + +/// Run cargo with `args` for `wasm32-unknown-unknown`, set up as +/// [`build_core_wasm`] describes. +fn wasm_cargo(root: &Path, args: &[&str]) -> Result<()> { + let target = "wasm32-unknown-unknown"; + let output = cargo() + .current_dir(root) + .args(["metadata", "--format-version", "1", "--locked"]) + .output()?; + anyhow::ensure!( + output.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let metadata: Value = serde_json::from_slice(&output.stdout)?; + let language = metadata["packages"] + .as_array() + .context("packages")? + .iter() + .find(|package| package["name"] == "tree-sitter-language") + .context("tree-sitter-language is not a dependency")?; + let headers = Path::new( + language["manifest_path"] + .as_str() + .context("manifest path")?, + ) + .parent() + .context("tree-sitter-language directory")? + .join("wasm/include"); + let empty = root.join("target/wasm-core/empty-libc"); + std::fs::create_dir_all(&empty)?; + for file in ["stdio.c", "stdlib.c", "string.c"] { + std::fs::write(empty.join(file), "")?; + } + let compat = root.join("crates/diffr-core/wasm"); + let cflags = format!( + "-I{} -I{} -include {}", + headers.display(), + compat.join("include").display(), + compat.join("compat.h").display() + ); + run( + cargo() + .current_dir(root) + .args(args) + .args(["--target", target, "--config"]) + .arg(format!( + "target.{target}.tree-sitter-language.wasm-headers={:?}", + headers.display().to_string() + )) + .arg("--config") + .arg(format!( + "target.{target}.tree-sitter-language.wasm-src={:?}", + empty.display().to_string() + )) + .env("AR_wasm32_unknown_unknown", llvm_ar()?) + .env("CFLAGS_wasm32_unknown_unknown", cflags), + "Running cargo for wasm32-unknown-unknown (needs `rustup target add wasm32-unknown-unknown` and clang)", + ) +} + +/// rustup's `llvm-ar`, from the `llvm-tools` component. +fn llvm_ar() -> Result { + let rustc = |args: &[&str]| -> Result { + let output = Command::new("rustc").args(args).output()?; + anyhow::ensure!(output.status.success(), "rustc {args:?} failed"); + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) + }; + let sysroot = rustc(&["--print", "sysroot"])?; + let host = rustc(&["-vV"])? + .lines() + .find_map(|line| line.strip_prefix("host: ").map(str::to_owned)) + .context("rustc -vV names no host")?; + let ar = Path::new(&sysroot) + .join("lib/rustlib") + .join(host) + .join("bin") + .join(format!("llvm-ar{}", std::env::consts::EXE_SUFFIX)); + anyhow::ensure!( + ar.is_file(), + "{} not found; install it with `rustup component add llvm-tools`", + ar.display() + ); + Ok(ar) +} + fn main() -> Result<()> { let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); let task = std::env::args().nth(1).unwrap_or_default(); @@ -185,6 +287,11 @@ fn main() -> Result<()> { "install" => install(root, true), "install-tui" => install(root, false), "build-plugins" => build_plugins(root), + "build-core-wasm" => build_core_wasm(root), + "wasm" => { + let args: Vec = std::env::args().skip(2).collect(); + wasm_cargo(root, &args.iter().map(String::as_str).collect::>()) + } "test-plugins" => { build_plugins(root)?; let status = cargo() @@ -203,7 +310,7 @@ fn main() -> Result<()> { anyhow::ensure!(status.success(), "plugin tests failed"); Ok(()) } - _ => bail!("usage: cargo xtask "), + _ => bail!("usage: cargo xtask "), } } From b284a1aae0db2b763c06a6621c5abf2514f3c940 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 17:42:34 -0700 Subject: [PATCH 02/21] Move file tag rules into diffr-core diffr-web classifies files too, and needs the generated tag and the tag name check without the native diffr. Agent-Session: 12453e0c-e113-407e-915b-4c9ef7f94bac Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/crates/diffr-core/src/lib.rs | 1 + diffr/{src/tags/mod.rs => crates/diffr-core/src/tags.rs} | 4 ++-- diffr/src/main.rs | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) rename diffr/{src/tags/mod.rs => crates/diffr-core/src/tags.rs} (86%) diff --git a/diffr/crates/diffr-core/src/lib.rs b/diffr/crates/diffr-core/src/lib.rs index 4f26e02cd..121d3a465 100644 --- a/diffr/crates/diffr-core/src/lib.rs +++ b/diffr/crates/diffr-core/src/lib.rs @@ -54,6 +54,7 @@ pub mod plugin; pub mod present; pub mod protocol; pub mod summary; +pub mod tags; mod words; #[macro_use] diff --git a/diffr/src/tags/mod.rs b/diffr/crates/diffr-core/src/tags.rs similarity index 86% rename from diffr/src/tags/mod.rs rename to diffr/crates/diffr-core/src/tags.rs index 983ca3374..316635b03 100644 --- a/diffr/src/tags/mod.rs +++ b/diffr/crates/diffr-core/src/tags.rs @@ -3,11 +3,11 @@ //! names of custom tags. /// The tag that makes a file diff as text. -pub(crate) const GENERATED: &str = "generated"; +pub const GENERATED: &str = "generated"; /// A tag name: lowercase letters, digits, `-` and `_`, starting with a letter /// or digit. -pub(crate) fn is_tag(tag: &str) -> bool { +pub fn is_tag(tag: &str) -> bool { tag.chars() .next() .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit()) diff --git a/diffr/src/main.rs b/diffr/src/main.rs index ef0838c13..537da3259 100644 --- a/diffr/src/main.rs +++ b/diffr/src/main.rs @@ -49,10 +49,9 @@ mod options; mod plugin; mod pprint; mod run; -mod tags; mod version; -use diffr_core::{config, engine, pairing, parse, present, protocol, summary}; +use diffr_core::{config, engine, pairing, parse, present, protocol, summary, tags}; use crate::config::{ConfigError, Params}; From b01cc25e64e7a1d1630352ac5f67363daab1fdab Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 17:42:47 -0700 Subject: [PATCH 03/21] Let a host link plugins into itself through the SDK's native feature A browser runs no component runtime. With `native`, diffr-plugin-sdk provides the plugin contract as plain Rust under the paths wit-bindgen generates, so a plugin's unchanged source compiles into the host: the host implements CursorHost and GitHost, and the export macros expand to nothing. The bundled plugins also build as rlibs for this. Agent-Session: 12453e0c-e113-407e-915b-4c9ef7f94bac Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/crates/diffr-plugin-sdk/Cargo.toml | 4 + diffr/crates/diffr-plugin-sdk/src/lib.rs | 16 + diffr/crates/diffr-plugin-sdk/src/native.rs | 412 ++++++++++++++++++ diffr/plugins/classify/rust/Cargo.toml | 2 +- diffr/plugins/shape/context/rust/Cargo.toml | 2 +- .../shape/deleted-bodies/rust/Cargo.toml | 2 +- .../shape/removed-runs/rust/Cargo.toml | 2 +- .../plugins/shape/test-bodies/rust/Cargo.toml | 2 +- 8 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 diffr/crates/diffr-plugin-sdk/src/native.rs diff --git a/diffr/crates/diffr-plugin-sdk/Cargo.toml b/diffr/crates/diffr-plugin-sdk/Cargo.toml index c8d621d10..e304f97ec 100644 --- a/diffr/crates/diffr-plugin-sdk/Cargo.toml +++ b/diffr/crates/diffr-plugin-sdk/Cargo.toml @@ -10,3 +10,7 @@ license = "MIT" # Component bindings and the records used by the host. # Async exports share the runtime used by WASIp3 HTTP. wit-bindgen = "0.62.0" + +[features] +# The contract as plain Rust, for a host that links plugins into itself. +native = [] diff --git a/diffr/crates/diffr-plugin-sdk/src/lib.rs b/diffr/crates/diffr-plugin-sdk/src/lib.rs index 26de7c48a..573e5343b 100644 --- a/diffr/crates/diffr-plugin-sdk/src/lib.rs +++ b/diffr/crates/diffr-plugin-sdk/src/lib.rs @@ -2,6 +2,10 @@ //! implements [`GuestPlugin`] and reads and edits the host's trees through the //! borrowed [`Cursor`]; the classifier implements [`GuestClassifier`]. Both //! may read the repository through [`git`]. +//! +//! With the `native` feature the same API is plain Rust, for a host that +//! links plugins into itself; see [`native`]. +#[cfg(not(feature = "native"))] pub mod bindings { wit_bindgen::generate!({ path: "wit", @@ -14,6 +18,7 @@ pub mod bindings { } /// The classifier's world. Shared records and `git` are the ones above. +#[cfg(not(feature = "native"))] pub mod classifier { wit_bindgen::generate!({ path: "wit", @@ -28,15 +33,26 @@ pub mod classifier { }); } +#[cfg(feature = "native")] +pub mod native; +#[cfg(feature = "native")] +pub use native::{bindings, classifier}; + pub mod error; +#[cfg(feature = "native")] +pub use __diffr_native_export as export_shape; +#[cfg(feature = "native")] +pub use __diffr_native_export as export_classifier; pub use bindings::diffr::plugin::git; pub use bindings::diffr::plugin::host::Cursor; pub use bindings::diffr::plugin::types::{ Attribute, FileEntry, FileRef, FileSides, FileStatus, Kind, MoveError, Region, RegionIds, RegionView, Side, Tag, Visit, }; +#[cfg(not(feature = "native"))] pub use bindings::export_shape; pub use bindings::exports::diffr::plugin::api::{Guest, GuestPlugin}; +#[cfg(not(feature = "native"))] pub use classifier::export_classifier; pub use classifier::exports::diffr::plugin::classify::{ Classification, Guest as ClassifierGuest, GuestClassifier, diff --git a/diffr/crates/diffr-plugin-sdk/src/native.rs b/diffr/crates/diffr-plugin-sdk/src/native.rs new file mode 100644 index 000000000..aca36ba41 --- /dev/null +++ b/diffr/crates/diffr-plugin-sdk/src/native.rs @@ -0,0 +1,412 @@ +//! The plugin contract as plain Rust, for a host that links plugins into +//! itself instead of running their components: diffr in the browser, where +//! no component runtime runs. The modules mirror the paths and shapes +//! `wit_bindgen` generates from `wit/plugin.wit`, so a plugin's source +//! compiles unchanged against either. The host implements [`CursorHost`] +//! and [`GitHost`]; the export macros expand to nothing, since the host +//! names each plugin's type itself. +use std::any::Any; +use std::cell::RefCell; + +pub mod bindings { + pub mod diffr { + pub mod plugin { + pub mod types { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum Visit { + Pre, + Post, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum Side { + Lhs, + Rhs, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum FileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct FileRef { + pub path: String, + pub oid: String, + pub mode: String, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum FileSides { + Both((FileRef, FileRef)), + LeftOnly(FileRef), + RightOnly(FileRef), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct FileEntry { + pub file: FileSides, + pub status: FileStatus, + pub tags: Vec, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Position { + pub line: u32, + pub column: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Range { + pub start: Position, + pub end: Position, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Span { + pub line: u32, + pub start_column: u32, + pub end_column: u32, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Visibility { + pub collapsed: bool, + pub label: String, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Leaf { + pub alignment_id: u32, + pub changed: Vec, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum Kind { + Leaf(Leaf), + Fold, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Region { + pub id: u32, + pub parent: Option, + pub fold_state_id: u32, + pub range: Range, + pub tags: Vec, + pub visibility: Visibility, + pub kind: Kind, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct RegionView { + pub side: Side, + pub data: Region, + pub children: Vec, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum RegionIds { + Both((u32, u32)), + LeftOnly(u32), + RightOnly(u32), + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct RowSummary { + pub collapsed: u32, + pub leading: u32, + pub trailing: u32, + pub longest_gap: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum Grouping { + Link, + Join, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct CutOutside { + pub id: u32, + pub offset: u32, + pub len: u32, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Repeated { + pub grouping: Grouping, + pub ids: Vec, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum MoveError { + NoRegion(u32), + CutFold(u32), + CutOutside(CutOutside), + UnevenSides(u32), + TooFewRegions(Grouping), + Repeated(Repeated), + OneSided(Vec), + NotSiblings(Vec), + NoNextSibling(u32), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum Tag { + Generated, + Vendored, + Docs, + Test, + Custom(String), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum Attribute { + Unspecified, + Unset, + Set, + Value(String), + } + } + + pub mod host { + use super::types::{ + FileEntry, MoveError, Region, RegionIds, RegionView, RowSummary, Side, + }; + use crate::native::CursorHost; + use std::cell::RefCell; + + /// The host's cursor over one file's trees. + pub struct Cursor(pub(crate) RefCell>); + + impl Cursor { + pub fn file(&self) -> FileEntry { + self.0.borrow().file() + } + pub fn id(&self) -> u32 { + self.0.borrow().id() + } + pub fn siblings(&self, id: u32) -> Result, MoveError> { + self.0.borrow().siblings(id) + } + pub fn ancestors(&self, id: u32) -> Result, MoveError> { + self.0.borrow().ancestors(id) + } + pub fn get(&self, id: u32) -> Result { + self.0.borrow().get(id) + } + pub fn text(&self, id: u32) -> Result { + self.0.borrow().text(id) + } + pub fn display(&self, id: u32) -> Result { + self.0.borrow().display(id) + } + pub fn matching_siblings( + &self, + ids: &[u32], + ) -> Result>, MoveError> { + self.0.borrow().matching_siblings(ids) + } + pub fn leaves(&self, side: Side, start: u32, end: u32) -> Vec { + self.0.borrow().leaves(side, start, end) + } + pub fn has_changes(&self, id: u32) -> Result { + self.0.borrow().has_changes(id) + } + pub fn paired_leaf(&self, id: u32) -> Result, MoveError> { + self.0.borrow().paired_leaf(id) + } + pub fn linked_regions(&self, id: u32) -> Result, MoveError> { + self.0.borrow().linked_regions(id) + } + pub fn is_one_sided(&self, id: u32) -> Result { + self.0.borrow().is_one_sided(id) + } + pub fn source(&self, side: Side) -> Option { + self.0.borrow().source(side) + } + pub fn cut(&self, region: u32, offset: u32) -> Result { + self.0.borrow_mut().cut(region, offset) + } + pub fn join(&self, regions: &[u32]) -> Result { + self.0.borrow_mut().join(regions) + } + pub fn link(&self, regions: &[u32]) -> Result<(), MoveError> { + self.0.borrow_mut().link(regions) + } + pub fn set_collapsed( + &self, + region: u32, + collapsed: bool, + ) -> Result<(), MoveError> { + self.0.borrow_mut().set_collapsed(region, collapsed) + } + pub fn set_label( + &self, + region: u32, + label: Option<&str>, + ) -> Result<(), MoveError> { + self.0.borrow_mut().set_label(region, label) + } + } + } + + pub mod git { + use super::types::Attribute; + + pub fn check_attr( + attributes: &[String], + path: &str, + ) -> Result, String> { + crate::native::with_git(|git| git.check_attr(attributes, path)) + } + + pub fn cat_file(object: &str) -> Result, String> { + crate::native::with_git(|git| git.cat_file(object)) + } + } + } + } + + pub mod exports { + pub mod diffr { + pub mod plugin { + pub mod api { + use crate::bindings::diffr::plugin::host::Cursor; + use crate::bindings::diffr::plugin::types::Visit; + + pub trait Guest { + type Plugin: GuestPlugin; + } + + #[allow(async_fn_in_trait)] + pub trait GuestPlugin: Sized + 'static { + fn new(options: String) -> Result; + async fn visit( + &self, + cursor: &Cursor, + phase: Visit, + ) -> Result; + } + } + } + } + } +} + +pub mod classifier { + pub mod exports { + pub mod diffr { + pub mod plugin { + pub mod classify { + use crate::bindings::diffr::plugin::types::{FileEntry, Tag}; + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Classification { + pub tags: Vec, + pub hidden: Option, + } + + pub trait Guest { + type Classifier: GuestClassifier; + } + + pub trait GuestClassifier: Sized + 'static { + fn new(options: String) -> Result; + fn classify(&self, file: FileEntry) -> Result; + } + } + } + } + } +} + +use bindings::diffr::plugin::host::Cursor; +use bindings::diffr::plugin::types::{ + Attribute, FileEntry, MoveError, Region, RegionIds, RegionView, RowSummary, Side, +}; + +/// The host's side of a [`Cursor`]: one file's trees, positioned on a node. +pub trait CursorHost: Any { + fn file(&self) -> FileEntry; + fn id(&self) -> u32; + fn siblings(&self, id: u32) -> Result, MoveError>; + fn ancestors(&self, id: u32) -> Result, MoveError>; + fn get(&self, id: u32) -> Result; + fn text(&self, id: u32) -> Result; + fn display(&self, id: u32) -> Result; + fn matching_siblings(&self, ids: &[u32]) -> Result>, MoveError>; + fn leaves(&self, side: Side, start: u32, end: u32) -> Vec; + fn has_changes(&self, id: u32) -> Result; + fn paired_leaf(&self, id: u32) -> Result, MoveError>; + fn linked_regions(&self, id: u32) -> Result, MoveError>; + fn is_one_sided(&self, id: u32) -> Result; + fn source(&self, side: Side) -> Option; + fn cut(&mut self, region: u32, offset: u32) -> Result; + fn join(&mut self, regions: &[u32]) -> Result; + fn link(&mut self, regions: &[u32]) -> Result<(), MoveError>; + fn set_collapsed(&mut self, region: u32, collapsed: bool) -> Result<(), MoveError>; + fn set_label(&mut self, region: u32, label: Option<&str>) -> Result<(), MoveError>; +} + +impl Cursor { + /// A cursor a plugin walks, over the host's trees. + pub fn new(host: Box) -> Self { + Self(RefCell::new(host)) + } + + /// The host's side, to position the cursor between visits. + pub fn host_mut(&self) -> std::cell::RefMut<'_, Box> { + self.0.borrow_mut() + } + + /// The host's trees back, once every plugin has walked them. + pub fn into_host(self) -> Box { + self.0.into_inner() + } +} + +/// The host's side of the `git` interface: the repository being diffed. +pub trait GitHost { + fn check_attr(&self, attributes: &[String], path: &str) -> Result, String>; + fn cat_file(&self, object: &str) -> Result, String>; +} + +thread_local! { + static GIT: RefCell>> = const { RefCell::new(None) }; +} + +/// Answer the plugins' `git` calls on this thread with `git` until the +/// returned guard drops. +pub fn set_git(git: Box) -> GitGuard { + GIT.with(|slot| *slot.borrow_mut() = Some(git)); + GitGuard(()) +} + +pub struct GitGuard(()); + +impl Drop for GitGuard { + fn drop(&mut self) { + GIT.with(|slot| *slot.borrow_mut() = None); + } +} + +fn with_git(call: impl FnOnce(&dyn GitHost) -> Result) -> Result { + GIT.with(|slot| match slot.borrow().as_deref() { + Some(git) => call(git), + None => Err("no repository is open".to_owned()), + }) +} + +/// The export macros: a native host names each plugin's type itself. +#[doc(hidden)] +#[macro_export] +macro_rules! __diffr_native_export { + ($($tokens:tt)*) => {}; +} diff --git a/diffr/plugins/classify/rust/Cargo.toml b/diffr/plugins/classify/rust/Cargo.toml index 24ce684ec..e47e328e1 100644 --- a/diffr/plugins/classify/rust/Cargo.toml +++ b/diffr/plugins/classify/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] diffr-plugin-sdk = { path = "../../../crates/diffr-plugin-sdk", version = "0.1.1" } diff --git a/diffr/plugins/shape/context/rust/Cargo.toml b/diffr/plugins/shape/context/rust/Cargo.toml index 1baea8943..c1dcf2ed0 100644 --- a/diffr/plugins/shape/context/rust/Cargo.toml +++ b/diffr/plugins/shape/context/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.1.1" } diff --git a/diffr/plugins/shape/deleted-bodies/rust/Cargo.toml b/diffr/plugins/shape/deleted-bodies/rust/Cargo.toml index 7800b4055..d544e5089 100644 --- a/diffr/plugins/shape/deleted-bodies/rust/Cargo.toml +++ b/diffr/plugins/shape/deleted-bodies/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.1.1" } diff --git a/diffr/plugins/shape/removed-runs/rust/Cargo.toml b/diffr/plugins/shape/removed-runs/rust/Cargo.toml index 9b5233674..f125498df 100644 --- a/diffr/plugins/shape/removed-runs/rust/Cargo.toml +++ b/diffr/plugins/shape/removed-runs/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.1.1" } diff --git a/diffr/plugins/shape/test-bodies/rust/Cargo.toml b/diffr/plugins/shape/test-bodies/rust/Cargo.toml index c471c5c7c..5abfba184 100644 --- a/diffr/plugins/shape/test-bodies/rust/Cargo.toml +++ b/diffr/plugins/shape/test-bodies/rust/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85.0" license = "MIT" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] diffr-plugin-sdk = { path = "../../../../crates/diffr-plugin-sdk", version = "0.1.1" } From 0c6762dddc9163959f610fe159aa3d8bdb2a6ed1 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 17:42:47 -0700 Subject: [PATCH 04/21] Add diffr-web: the engine and bundled plugins for the browser `cargo xtask build-web` builds a wasm-bindgen package whose Differ classifies files before their sources are fetched (`preview`) and turns one file's two sides into its `file` record (`diff`), shaped by the bundled shape plugins linked in through the SDK's native contract. A configuration naming plugins the browser cannot run gets notices; one reading plugins from disk is an error. For wasm32-unknown-unknown, tree-sitter-language's libc sources (a scanner heap for tree-sitter's own wasm runtime) are replaced: malloc and friends come from Rust's allocator, and the stdio tree-sitter only uses for debug output writes nothing. The shim headers are hashed into CFLAGS so editing one rebuilds the grammars. Agent-Session: 12453e0c-e113-407e-915b-4c9ef7f94bac Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/Cargo.lock | 116 +++++- diffr/Cargo.toml | 2 +- diffr/crates/diffr-core/src/lib.rs | 2 + diffr/crates/diffr-core/src/wasm_libc.rs | 77 ++++ diffr/crates/diffr-core/wasm/compat.h | 9 + diffr/crates/diffr-core/wasm/src/stdio.c | 49 +++ diffr/crates/diffr-core/wasm/src/stdlib.c | 5 + diffr/crates/diffr-core/wasm/src/string.c | 11 + diffr/crates/diffr-web/Cargo.toml | 28 ++ diffr/crates/diffr-web/src/lib.rs | 411 +++++++++++++++++++ diffr/crates/diffr-web/src/plugins.rs | 465 ++++++++++++++++++++++ diffr/xtask/src/main.rs | 74 +++- 12 files changed, 1220 insertions(+), 29 deletions(-) create mode 100644 diffr/crates/diffr-core/src/wasm_libc.rs create mode 100644 diffr/crates/diffr-core/wasm/src/stdio.c create mode 100644 diffr/crates/diffr-core/wasm/src/stdlib.c create mode 100644 diffr/crates/diffr-core/wasm/src/string.c create mode 100644 diffr/crates/diffr-web/Cargo.toml create mode 100644 diffr/crates/diffr-web/src/lib.rs create mode 100644 diffr/crates/diffr-web/src/plugins.rs diff --git a/diffr/Cargo.lock b/diffr/Cargo.lock index 19efaf42a..4aa311d7d 100644 --- a/diffr/Cargo.lock +++ b/diffr/Cargo.lock @@ -392,6 +392,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "cpp_demangle" version = "0.5.1" @@ -924,6 +934,44 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "diffr-plugin-classify" +version = "0.1.1" +dependencies = [ + "diffr-plugin-sdk", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", +] + +[[package]] +name = "diffr-plugin-context" +version = "0.1.1" +dependencies = [ + "diffr-plugin-sdk", + "serde", + "serde_json", +] + +[[package]] +name = "diffr-plugin-deleted-bodies" +version = "0.1.1" +dependencies = [ + "diffr-plugin-sdk", + "serde", + "serde_json", +] + +[[package]] +name = "diffr-plugin-removed-runs" +version = "0.1.1" +dependencies = [ + "diffr-plugin-sdk", + "serde", + "serde_json", +] + [[package]] name = "diffr-plugin-sdk" version = "0.1.1" @@ -931,6 +979,33 @@ dependencies = [ "wit-bindgen 0.62.0", ] +[[package]] +name = "diffr-plugin-test-bodies" +version = "0.1.1" +dependencies = [ + "diffr-plugin-sdk", + "serde", + "serde_json", +] + +[[package]] +name = "diffr-web" +version = "0.1.16" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "diffr-core", + "diffr-plugin-classify", + "diffr-plugin-context", + "diffr-plugin-deleted-bodies", + "diffr-plugin-removed-runs", + "diffr-plugin-sdk", + "diffr-plugin-test-bodies", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "digest" version = "0.10.7" @@ -2579,9 +2654,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "wasm-bindgen", @@ -3488,6 +3563,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1dc" version = "0.1.5" @@ -4583,6 +4671,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4680,9 +4774,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4693,9 +4787,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4703,22 +4797,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] diff --git a/diffr/Cargo.toml b/diffr/Cargo.toml index 370224318..4f39bff54 100644 --- a/diffr/Cargo.toml +++ b/diffr/Cargo.toml @@ -68,7 +68,7 @@ tokio = { version = "1", features = ["macros", "net", "process"] } version_check = "0.9.4" [workspace] -members = ["xtask", "crates/diffr-core", "crates/diffr-grammars", "crates/diffr-plugin-sdk"] +members = ["xtask", "crates/diffr-core", "crates/diffr-grammars", "crates/diffr-plugin-sdk", "crates/diffr-web"] default-members = [".", "crates/diffr-core", "crates/diffr-grammars", "crates/diffr-plugin-sdk"] exclude = ["plugins", "tests/fixtures/concurrent-guest"] diff --git a/diffr/crates/diffr-core/src/lib.rs b/diffr/crates/diffr-core/src/lib.rs index 121d3a465..3098bd949 100644 --- a/diffr/crates/diffr-core/src/lib.rs +++ b/diffr/crates/diffr-core/src/lib.rs @@ -55,6 +55,8 @@ pub mod present; pub mod protocol; pub mod summary; pub mod tags; +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +mod wasm_libc; mod words; #[macro_use] diff --git a/diffr/crates/diffr-core/src/wasm_libc.rs b/diffr/crates/diffr-core/src/wasm_libc.rs new file mode 100644 index 000000000..f0b5fd764 --- /dev/null +++ b/diffr/crates/diffr-core/src/wasm_libc.rs @@ -0,0 +1,77 @@ +//! The C allocator for a `wasm32-unknown-unknown` build, which has no libc: +//! tree-sitter and the grammars' scanners call `malloc` and friends, and +//! these hand them Rust's allocator. Each block keeps its size in a header +//! in front of it, since `free` and `realloc` are not told it. +use std::alloc::{alloc, alloc_zeroed, dealloc, realloc as grow, Layout}; + +/// The header's size, which is also every block's alignment: the most any +/// C type on wasm32 needs. +const HEADER: usize = 16; + +fn layout(size: usize) -> Option { + Layout::from_size_align(size.checked_add(HEADER)?, HEADER).ok() +} + +/// Store `size` in the header at `base` and return the block after it. +unsafe fn block(base: *mut u8, size: usize) -> *mut u8 { + if base.is_null() { + return base; + } + unsafe { + (base as *mut usize).write(size); + base.add(HEADER) + } +} + +/// The header in front of `block`, and the size stored in it. +unsafe fn header(block: *mut u8) -> (*mut u8, usize) { + unsafe { + let base = block.sub(HEADER); + (base, (base as *const usize).read()) + } +} + +#[no_mangle] +pub unsafe extern "C" fn malloc(size: usize) -> *mut u8 { + match layout(size) { + Some(layout) => unsafe { block(alloc(layout), size) }, + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn calloc(count: usize, size: usize) -> *mut u8 { + match count + .checked_mul(size) + .and_then(|size| Some((size, layout(size)?))) + { + Some((size, layout)) => unsafe { block(alloc_zeroed(layout), size) }, + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn realloc(old: *mut u8, size: usize) -> *mut u8 { + if old.is_null() { + return unsafe { malloc(size) }; + } + let Some(new_layout) = layout(size) else { + return std::ptr::null_mut(); + }; + unsafe { + let (base, old_size) = header(old); + let old_layout = layout(old_size).expect("a live block's layout is valid"); + block(grow(base, old_layout, new_layout.size()), size) + } +} + +#[no_mangle] +pub unsafe extern "C" fn free(block: *mut u8) { + if block.is_null() { + return; + } + unsafe { + let (base, size) = header(block); + dealloc(base, layout(size).expect("a live block's layout is valid")); + } +} diff --git a/diffr/crates/diffr-core/wasm/compat.h b/diffr/crates/diffr-core/wasm/compat.h index 3c0a198c3..caa4c26ca 100644 --- a/diffr/crates/diffr-core/wasm/compat.h +++ b/diffr/crates/diffr-core/wasm/compat.h @@ -58,6 +58,15 @@ static inline char *diffr_strncpy(char *dst, const char *src, __SIZE_TYPE__ n) { return dst; } #define strncpy diffr_strncpy +/* tree-sitter-language's assert.h defines __assert_fail in every file that + * includes it, which links as duplicates. Take its place with a static one. */ +#define TREE_SITTER_WASM_ASSERT_H_ +#ifdef NDEBUG +#define assert(e) ((void)0) +#else +static inline __attribute__((noreturn)) void diffr_assert_fail(void) { __builtin_trap(); } +#define assert(expression) ((expression) ? (void)0 : diffr_assert_fail()) +#endif /* The vendored scanners: a scanner that gives up traps. */ static inline __attribute__((noreturn)) void diffr_exit(int status) { (void)status; diff --git a/diffr/crates/diffr-core/wasm/src/stdio.c b/diffr/crates/diffr-core/wasm/src/stdio.c new file mode 100644 index 000000000..7ff04f971 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/src/stdio.c @@ -0,0 +1,49 @@ +/* What tree-sitter's lib.c uses of stdio, for wasm32-unknown-unknown: its + * debug log and dot graphs, which diffr never turns on, so nothing is + * written. Weak, since grammars may compile this file too. */ +#include + +__attribute__((weak)) int fclose(FILE *stream) { (void)stream; return 0; } + +__attribute__((weak)) FILE *fdopen(int fd, const char *mode) { + (void)fd; + (void)mode; + return (FILE *)0; +} + +__attribute__((weak)) int fputc(int c, FILE *stream) { (void)stream; return c; } + +__attribute__((weak)) int fputs(const char *restrict s, FILE *restrict stream) { + (void)s; + (void)stream; + return 0; +} + +__attribute__((weak)) size_t fwrite(const void *restrict buffer, size_t size, size_t nmemb, + FILE *restrict stream) { + (void)buffer; + (void)size; + (void)stream; + return nmemb; +} + +__attribute__((weak)) int fprintf(FILE *restrict stream, const char *restrict format, ...) { + (void)stream; + (void)format; + return 0; +} + +__attribute__((weak)) int vsnprintf(char *restrict buffer, size_t buffsz, + const char *restrict format, va_list vlist) { + (void)format; + (void)vlist; + if (buffsz) buffer[0] = 0; + return 0; +} + +__attribute__((weak)) int snprintf(char *restrict buffer, size_t buffsz, + const char *restrict format, ...) { + (void)format; + if (buffsz) buffer[0] = 0; + return 0; +} diff --git a/diffr/crates/diffr-core/wasm/src/stdlib.c b/diffr/crates/diffr-core/wasm/src/stdlib.c new file mode 100644 index 000000000..7d03bfbb4 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/src/stdlib.c @@ -0,0 +1,5 @@ +/* The allocator is Rust's (diffr-core's wasm_libc.rs); abort traps. Weak, + * since grammars may compile this file too. */ +#include + +__attribute__((weak, noreturn)) void abort(void) { __builtin_trap(); } diff --git a/diffr/crates/diffr-core/wasm/src/string.c b/diffr/crates/diffr-core/wasm/src/string.c new file mode 100644 index 000000000..429a777a3 --- /dev/null +++ b/diffr/crates/diffr-core/wasm/src/string.c @@ -0,0 +1,11 @@ +/* What tree-sitter's lib.c uses of string.h beyond the compiler's builtins. + * Weak, since grammars may compile this file too. */ +#include + +__attribute__((weak)) int strncmp(const char *left, const char *right, size_t n) { + for (; n; n--, left++, right++) { + if (*left != *right) return (unsigned char)*left - (unsigned char)*right; + if (!*left) return 0; + } + return 0; +} diff --git a/diffr/crates/diffr-web/Cargo.toml b/diffr/crates/diffr-web/Cargo.toml new file mode 100644 index 000000000..47e21a06c --- /dev/null +++ b/diffr/crates/diffr-web/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "diffr-web" +description = "diffr for the browser: the engine and the bundled plugins behind a wasm-bindgen API." +repository = "https://github.com/devdotfast/whiteboard" +license = "MIT" +version = "0.1.16" +edition = "2021" +rust-version = "1.99.0" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +diffr-core = { path = "../diffr-core" } +# The plugin contract as plain Rust, and the bundled plugins linked against it. +diffr-plugin-sdk = { path = "../diffr-plugin-sdk", features = ["native"] } +diffr-plugin-classify = { path = "../../plugins/classify/rust" } +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" } +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +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 new file mode 100644 index 000000000..24a9b28e2 --- /dev/null +++ b/diffr/crates/diffr-web/src/lib.rs @@ -0,0 +1,411 @@ +//! diffr in the browser. A [`Differ`] holds what the native diffr builds +//! once per run: the configuration, its compiled queries, the classifier +//! and the shape plugins, linked in (see [`plugins`]). [`Differ::diff`] +//! turns the two sides of one changed file into its `file` record of the +//! wire protocol, shaped by the plugins as the native diffr shapes it. +//! Fetching the sources is the page's job. +use diffr_core::config::{Config, Params}; +use diffr_core::engine::QueryConflict; +use diffr_core::options::DiffOptions; +use diffr_core::pairing::Pairing; +use diffr_core::plugin::MutationFailed; +use diffr_core::present::present; +use diffr_core::protocol::project::{self, Inputs}; +use diffr_core::protocol::{Diff, Event, FileChange, FileRef, FileStatus, Outcome, Problem}; +use diffr_core::summary::{DiffResult, FallbackCause, FileContent, FileFormat}; +use diffr_core::tags; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; +use wasm_bindgen::prelude::*; + +mod plugins; + +#[wasm_bindgen] +pub struct Differ { + params: Params, + options: DiffOptions, + classifier: Option, + pipeline: plugins::Pipeline, + notices: Vec, +} + +/// One side of a changed file, as the page names it. +#[derive(Deserialize)] +struct Side { + path: String, + #[serde(default)] + oid: String, + #[serde(default)] + mode: String, + /// The file's text; absent before it is fetched. + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct Request { + status: FileStatus, + lhs: Option, + rhs: Option, + /// Ask for highlight captures. + #[serde(default)] + syntax: bool, +} + +/// A file as the classifier sees it: its manifest entry, with tags, and why +/// it starts hidden. +#[derive(Serialize)] +struct Classified { + entry: FileChange, + #[serde(skip_serializing_if = "Option::is_none")] + hidden: Option, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum Preview { + Ready(Classified), + Failed { error: String }, +} + +#[derive(Serialize)] +struct Response { + #[serde(flatten)] + classified: Classified, + event: Event, +} + +fn js_error(error: anyhow::Error) -> JsError { + JsError::new(&format!("{error:#}")) +} + +fn file_ref(side: &Side) -> FileRef { + FileRef { + path: side.path.clone(), + oid: side.oid.clone(), + mode: side.mode.clone(), + } +} + +fn entry(request: &Request) -> anyhow::Result { + let file = match (&request.lhs, &request.rhs) { + (Some(lhs), Some(rhs)) => Pairing::Both { + lhs: file_ref(lhs), + rhs: file_ref(rhs), + }, + (Some(lhs), None) => Pairing::LeftOnly { lhs: file_ref(lhs) }, + (None, Some(rhs)) => Pairing::RightOnly { rhs: file_ref(rhs) }, + (None, None) => anyhow::bail!("a changed file needs a side"), + }; + Ok(FileChange { + file, + status: request.status, + tags: Vec::new(), + }) +} + +/// The wire record for an error, with the code of its typed cause. +fn problem(error: &anyhow::Error) -> Problem { + let code = if error.downcast_ref::().is_some() { + "query_conflict" + } else if error.downcast_ref::().is_some() { + "mutation_failed" + } else { + "internal" + }; + Problem { + code: code.to_owned(), + message: format!("{error:#}"), + } +} + +#[wasm_bindgen] +impl Differ { + /// A differ for `config`, the text of a `config.toml`, or the bundled + /// defaults when it is absent. + #[wasm_bindgen(constructor)] + pub fn new(config: Option) -> Result { + console_error_panic_hook::set_once(); + Self::from_config(config.as_deref()).map_err(js_error) + } + + /// The configuration's JSON Schema, with every setting's description + /// and default. + pub fn schema() -> String { + Config::schema().to_string() + } + + /// What the configuration asked for that the browser does not run, as + /// a JSON array of messages. + pub fn notices(&self) -> String { + serde_json::to_string(&self.notices).expect("strings serialize") + } + + /// Classify many files before their sources are fetched. `requests` is a + /// JSON array of [`Differ::diff`] requests without text. Returns a JSON + /// array in the same order of `{"entry", "hidden"?}`, or `{"error"}` for + /// a file the classifier failed on. Content rules need the text, so a + /// file's diff may tag it further. + pub fn preview(&self, requests: &str) -> Result { + self.preview_json(requests).map_err(js_error) + } + + /// 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 + /// `{"entry", "hidden"?, "event"}`: the file classified with its text, + /// and its `file` record. + pub fn diff(&self, request: &str) -> Result { + self.diff_json(request).map_err(js_error) + } +} + +impl Differ { + fn from_config(config: Option<&str>) -> anyhow::Result { + let config = match config { + Some(text) => Config::from_toml_in(text, Path::new(""))?, + None => Config::default(), + }; + let mut notices = Vec::new(); + let classifier = plugins::Classifier::from_config(&config.plugins, &mut notices)?; + let pipeline = plugins::Pipeline::from_config(&config.plugins, &mut notices)?; + let params = config.compile()?; + Ok(Differ { + options: config.diff.options(false), + params, + classifier, + pipeline, + notices, + }) + } + + fn preview_json(&self, requests: &str) -> anyhow::Result { + let requests: Vec = serde_json::from_str(requests)?; + let previews: Vec = requests + .iter() + .map(|request| match self.classify(request, BTreeMap::new()) { + Ok(classified) => Preview::Ready(classified), + Err(error) => Preview::Failed { + error: format!("{error:#}"), + }, + }) + .collect(); + Ok(serde_json::to_string(&previews)?) + } + + fn diff_json(&self, request: &str) -> anyhow::Result { + fn text(side: &Option) -> &str { + side.as_ref() + .and_then(|side| side.text.as_deref()) + .unwrap_or("") + } + let request: Request = serde_json::from_str(request)?; + let blobs = [&request.lhs, &request.rhs] + .into_iter() + .flatten() + .filter(|side| !side.oid.is_empty()) + .filter_map(|side| Some((side.oid.clone(), side.text.clone()?.into_bytes()))) + .collect(); + let classified = self.classify(&request, blobs)?; + let outcome = match self.shaped( + &classified, + text(&request.lhs), + text(&request.rhs), + request.syntax, + ) { + Ok(diff) => Outcome::Diff { diff }, + Err(error) => Outcome::Error { + error: problem(&error), + }, + }; + let event = Event::File { + file: classified.entry.file.clone(), + outcome, + }; + Ok(serde_json::to_string(&Response { classified, event })?) + } + + fn classify( + &self, + request: &Request, + blobs: BTreeMap>, + ) -> anyhow::Result { + let mut entry = entry(request)?; + let hidden = match &self.classifier { + Some(classifier) => { + let (tags, hidden) = classifier.classify(&entry, blobs)?; + entry.tags = tags; + hidden + } + None => None, + }; + Ok(Classified { entry, hidden }) + } + + /// Diff, project and shape one file, as the native diffr's `run.rs` does. + fn shaped( + &self, + classified: &Classified, + before: &str, + after: &str, + syntax: bool, + ) -> anyhow::Result { + let entry = &classified.entry; + let sizes = (before.len() as u64, after.len() as u64); + // A binary file is a successful, size-only record. + let result = if before.contains('\0') || after.contains('\0') { + DiffResult { + file_format: FileFormat::Binary, + lhs_src: FileContent::Binary, + rhs_src: FileContent::Binary, + lhs_positions: vec![], + rhs_positions: vec![], + lhs_folds: vec![], + rhs_folds: vec![], + lhs_highlights: vec![], + rhs_highlights: vec![], + } + } else { + let path = match &entry.file { + Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } => &rhs.path, + Pairing::LeftOnly { lhs } => &lhs.path, + }; + let options = DiffOptions { + by_line: if entry.tags.iter().any(|tag| tag == tags::GENERATED) { + Some(FallbackCause::Generated) + } else if classified.hidden.is_some() { + Some(FallbackCause::Hidden) + } else { + None + }, + syntax, + ..self.options.clone() + }; + DiffResult::from_sources_with_options(path, before, after, &self.params, &options)? + }; + let diff = project::diff( + &result, + Inputs { + file: &entry.file, + sizes, + }, + ); + plugins::ready(present(classified.hidden.as_deref(), diff, async |sides| { + self.pipeline.run(entry, sides) + }))? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn differ(config: Option<&str>) -> Differ { + Differ::from_config(config).expect("the config loads") + } + + fn diff(differ: &Differ, request: Value) -> Value { + serde_json::from_str( + &differ + .diff_json(&request.to_string()) + .expect("the file diffs"), + ) + .unwrap() + } + + #[test] + fn a_modified_file_gets_a_shaped_record() { + let before = "export function f(a: number) {\n const x = a + 1;\n return x * 2;\n}\n"; + let after = + "export function f(a: number, b: number) {\n const x = a + b;\n return x * 2;\n}\n"; + let response = diff( + &differ(None), + json!({ + "status": "modified", + "lhs": {"path": "src/a.ts", "text": before}, + "rhs": {"path": "src/a.ts", "text": after}, + "syntax": true, + }), + ); + assert_eq!(response["entry"]["status"], "modified"); + let event = &response["event"]; + assert_eq!(event["type"], "file"); + assert_eq!(event["diff"]["type"], "text", "{event}"); + assert!(!event["diff"]["rhs"]["syntax"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!(event["diff"]["stats"]["textual"]["added"], 2); + } + + #[test] + fn the_context_plugin_folds_unchanged_stretches() { + let mut before = String::new(); + for line in 0..40 { + before.push_str(&format!("const v{line} = {line};\n")); + } + let after = before.replace("const v20 = 20;", "const v20 = 21;"); + let response = diff( + &differ(None), + json!({ + "status": "modified", + "lhs": {"path": "src/a.ts", "text": before}, + "rhs": {"path": "src/a.ts", "text": after}, + }), + ); + let text = response["event"]["diff"]["rhs"].to_string(); + assert!(text.contains("\"collapsed\":true"), "{text}"); + } + + #[test] + fn a_preview_hides_vendored_and_deleted_files_before_any_source() { + let previews: Value = serde_json::from_str( + &differ(None) + .preview_json( + &json!([ + {"status": "modified", "lhs": {"path": "src/a.ts"}, "rhs": {"path": "src/a.ts"}}, + {"status": "modified", "lhs": {"path": "vendor/lib.js"}, "rhs": {"path": "vendor/lib.js"}}, + {"status": "deleted", "lhs": {"path": "src/old.ts"}}, + ]) + .to_string(), + ) + .expect("the files preview"), + ) + .unwrap(); + let hidden: Vec = previews + .as_array() + .unwrap() + .iter() + .map(|preview| preview["hidden"].is_string()) + .collect(); + assert_eq!(hidden, [false, true, true], "{previews}"); + assert!(previews[1]["entry"]["tags"] + .as_array() + .unwrap() + .contains(&"vendored".into())); + } + + #[test] + fn a_hidden_file_is_collapsed_and_diffed_by_line() { + let response = diff( + &differ(None), + json!({ + "status": "modified", + "lhs": {"path": "vendor/lib.js", "text": "a();\n"}, + "rhs": {"path": "vendor/lib.js", "text": "b();\n"}, + }), + ); + assert!(response["hidden"].is_string(), "{response}"); + let diff = &response["event"]["diff"]; + assert_eq!(diff["stats"]["fallback"]["code"], "hidden", "{diff}"); + } + + #[test] + fn a_plugin_from_disk_is_a_config_error() { + let error = Differ::from_config(Some("[plugins.shape.mine]\npath = \"plugins/mine\"\n")); + assert!(error.is_err()); + } +} diff --git a/diffr/crates/diffr-web/src/plugins.rs b/diffr/crates/diffr-web/src/plugins.rs new file mode 100644 index 000000000..47118eb12 --- /dev/null +++ b/diffr/crates/diffr-web/src/plugins.rs @@ -0,0 +1,465 @@ +//! The bundled plugins, linked into the engine. No component runtime runs in +//! 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`. +use anyhow::{anyhow, Context as _}; +use diffr_core::pairing::Pairing; +use diffr_core::plugin::config::{ComponentSource, Folder, PluginsConfig}; +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::prelude::{GuestClassifier, GuestPlugin}; +use diffr_plugin_sdk::{FileEntry, MoveError, Region, RegionIds, RegionView, Side, Tag, Visit}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::future::Future; +use std::pin::{pin, Pin}; +use std::task::{Context, Poll, Waker}; + +/// A configured shape plugin, behind one object-safe call. +trait Shape { + fn visit<'a>( + &'a self, + cursor: &'a diffr_plugin_sdk::Cursor, + phase: Visit, + ) -> Pin> + 'a>>; +} + +impl Shape for T { + fn visit<'a>( + &'a self, + cursor: &'a diffr_plugin_sdk::Cursor, + phase: Visit, + ) -> Pin> + 'a>> { + Box::pin(GuestPlugin::visit(self, cursor, phase)) + } +} + +fn make(options: String) -> Result, String> { + Ok(Box::new(T::new(options)?)) +} + +type Constructor = fn(String) -> Result, String>; + +/// 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::), + ( + "deleted-bodies", + make::, + ), + ( + "removed-runs", + make::, + ), + ("test-bodies", make::), +]; + +fn is_bundled(folder: &Folder) -> bool { + matches!(folder.component(), ComponentSource::Bundled(_)) +} + +/// The enabled shape plugins, made from their configured options, and a +/// notice for each enabled plugin the browser cannot run. +pub(crate) struct Pipeline { + plugins: Vec<(String, Box)>, +} + +impl Pipeline { + pub(crate) fn from_config( + config: &PluginsConfig, + notices: &mut Vec, + ) -> anyhow::Result { + let mut plugins = Vec::new(); + for (name, entry) in config.shape.enabled() { + let folder = entry.folder(); + let linked = LINKED + .iter() + .find(|(own, _)| *own == folder.name()) + .filter(|_| is_bundled(folder)); + let Some((_, constructor)) = linked else { + notices.push(format!( + "plugins.shape.{name} is skipped: the browser runs only the bundled plugins {}", + LINKED + .iter() + .map(|(own, _)| *own) + .collect::>() + .join(", ") + )); + continue; + }; + let options = Value::Object(entry.options.clone()).to_string(); + let plugin = constructor(options) + .map_err(|error| anyhow!(error)) + .with_context(|| format!("plugins.shape.{name}"))?; + plugins.push((folder.name().to_owned(), plugin)); + } + Ok(Self { plugins }) + } + + /// Walk one file's sides with every plugin in order. + pub(crate) fn run( + &self, + file: &FileChange, + sides: Pairing, + ) -> anyhow::Result> { + let cursor = match Cursor::new(file.clone(), sides) { + Ok(cursor) => cursor, + Err(sides) => return Ok(sides), + }; + let cursor = diffr_plugin_sdk::Cursor::new(Box::new(Host(cursor))); + let mut walked = Ok(()); + for (name, plugin) in &self.plugins { + host(&cursor).0.rewind(); + walked = walk(&cursor, plugin.as_ref()).with_context(|| MutationFailed(name.clone())); + if walked.is_err() { + break; + } + } + walked?; + let host = (cursor.into_host() as Box) + .downcast::() + .expect("the cursor's host is the engine's"); + Ok(host.0.sides) + } +} + +/// The engine's cursor under the SDK's [`CursorHost`]. +struct Host(Cursor); + +fn host(cursor: &diffr_plugin_sdk::Cursor) -> std::cell::RefMut<'_, Host> { + std::cell::RefMut::map(cursor.host_mut(), |host| { + (host.as_mut() as &mut dyn std::any::Any) + .downcast_mut::() + .expect("the cursor's host is the engine's") + }) +} + +/// Walk the live tree, calling the plugin on each node before (Pre) and after +/// (Post) its children. False on Pre skips the children and Post; errors stop. +fn walk(cursor: &diffr_plugin_sdk::Cursor, plugin: &dyn Shape) -> anyhow::Result<()> { + let mut after = None; + while let Some(node) = next_child(cursor, None, after)? { + subtree(cursor, plugin, node)?; + after = Some(node); + } + Ok(()) +} + +fn subtree(cursor: &diffr_plugin_sdk::Cursor, plugin: &dyn Shape, node: u32) -> anyhow::Result<()> { + let visit = |phase| -> anyhow::Result { + host(cursor).0.id = node; + ready(plugin.visit(cursor, phase))?.map_err(anyhow::Error::msg) + }; + if !visit(Visit::Pre)? { + return Ok(()); + } + let mut after = None; + while let Some(child) = next_child(cursor, Some(node), after)? { + subtree(cursor, plugin, child)?; + after = Some(child); + } + visit(Visit::Post)?; + Ok(()) +} + +fn next_child( + cursor: &diffr_plugin_sdk::Cursor, + parent: Option, + after: Option, +) -> anyhow::Result> { + host(cursor) + .0 + .next_child(parent, after) + .map_err(|error| anyhow!(String::from(move_error(error)))) +} + +/// A linked plugin's visit never waits: nothing it calls does I/O. +pub(crate) fn ready(future: impl Future) -> anyhow::Result { + match pin!(future).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(value) => Ok(value), + Poll::Pending => Err(anyhow!("a plugin waited, which the browser cannot do")), + } +} + +/// The bundled classifier, linked in, and what it reads of the repository: +/// blobs the page has fetched. A browser has no `.gitattributes` to read, +/// so every attribute is unspecified. +pub(crate) struct Classifier(diffr_plugin_classify::Classify); + +impl Classifier { + /// `None`, with a notice, for a classifier other than the bundled one. + pub(crate) fn from_config( + config: &PluginsConfig, + notices: &mut Vec, + ) -> anyhow::Result> { + if !is_bundled(config.classify.folder()) { + notices.push( + "plugins.classify is skipped: the browser runs only the bundled classifier" + .to_owned(), + ); + return Ok(None); + } + let options = Value::Object(config.classify.options.clone()).to_string(); + let classify = ::new(options) + .map_err(|error| anyhow!(error)) + .context("plugins.classify")?; + Ok(Some(Self(classify))) + } + + /// Tag `file`, reading the blobs in `blobs` by object id. A blob the + /// page has not fetched reads as empty, so content rules wait for the + /// file's diff, which passes its text. + pub(crate) fn classify( + &self, + file: &FileChange, + blobs: BTreeMap>, + ) -> anyhow::Result<(Vec, Option)> { + let _git = diffr_plugin_sdk::native::set_git(Box::new(Blobs(blobs))); + let path = match &file.file { + Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } => &rhs.path, + Pairing::LeftOnly { lhs } => &lhs.path, + }; + let classification = GuestClassifier::classify(&self.0, file_entry(file)) + .map_err(anyhow::Error::msg) + .with_context(|| format!("classifier: {path}"))?; + let names = classification + .tags + .into_iter() + .map(|tag| match tag { + Tag::Generated => Ok(tags::GENERATED.to_owned()), + Tag::Vendored => Ok("vendored".to_owned()), + Tag::Docs => Ok("docs".to_owned()), + Tag::Test => Ok("test".to_owned()), + Tag::Custom(name) if tags::is_tag(&name) => Ok(name), + Tag::Custom(name) => Err(anyhow!( + "classifier: {path}: {name:?} is not a tag; use lowercase letters, digits, '-' and '_'" + )), + }) + .collect::>>()?; + Ok((names.into_iter().collect(), classification.hidden)) + } +} + +struct Blobs(BTreeMap>); + +impl GitHost for Blobs { + fn check_attr( + &self, + attributes: &[String], + _path: &str, + ) -> Result, String> { + Ok(vec![ + diffr_plugin_sdk::Attribute::Unspecified; + attributes.len() + ]) + } + + fn cat_file(&self, object: &str) -> Result, String> { + Ok(self.0.get(object).cloned().unwrap_or_default()) + } +} + +impl CursorHost for Host { + fn file(&self) -> FileEntry { + file_entry(&self.0.file) + } + fn id(&self) -> u32 { + self.0.id + } + fn siblings(&self, id: u32) -> Result, MoveError> { + self.0.siblings(id).map_err(move_error) + } + fn ancestors(&self, id: u32) -> Result, MoveError> { + self.0 + .ancestors(id) + .map(|regions| regions.into_iter().map(region).collect()) + .map_err(move_error) + } + fn get(&self, id: u32) -> Result { + self.0.get(id).map(region_view).map_err(move_error) + } + fn text(&self, id: u32) -> Result { + self.0.text(id).map_err(move_error) + } + fn display( + &self, + id: u32, + ) -> Result { + self.0 + .display(id) + .map( + |summary| diffr_plugin_sdk::bindings::diffr::plugin::types::RowSummary { + collapsed: summary.collapsed, + leading: summary.leading, + trailing: summary.trailing, + longest_gap: summary.longest_gap, + }, + ) + .map_err(move_error) + } + fn matching_siblings(&self, ids: &[u32]) -> Result>, MoveError> { + self.0.matching_siblings(ids).map_err(move_error) + } + fn leaves(&self, side: Side, start: u32, end: u32) -> Vec { + self.0.leaves(core_side(side), start, end) + } + fn has_changes(&self, id: u32) -> Result { + self.0.has_changes(id).map_err(move_error) + } + fn paired_leaf(&self, id: u32) -> Result, MoveError> { + self.0.paired_leaf(id).map_err(move_error) + } + fn linked_regions(&self, id: u32) -> Result, MoveError> { + self.0.linked_regions(id).map_err(move_error) + } + fn is_one_sided(&self, id: u32) -> Result { + self.0.is_one_sided(id).map_err(move_error) + } + fn source(&self, side: Side) -> Option { + self.0.source(core_side(side)) + } + fn cut(&mut self, region: u32, offset: u32) -> Result { + self.0 + .cut(region, offset) + .map(region_ids) + .map_err(move_error) + } + fn join(&mut self, regions: &[u32]) -> Result { + self.0.join(regions).map(region_ids).map_err(move_error) + } + fn link(&mut self, regions: &[u32]) -> Result<(), MoveError> { + self.0.link(regions).map_err(move_error) + } + fn set_collapsed(&mut self, region: u32, collapsed: bool) -> Result<(), MoveError> { + self.0.set_collapsed(region, collapsed).map_err(move_error) + } + fn set_label(&mut self, region: u32, label: Option<&str>) -> Result<(), MoveError> { + self.0 + .set_label(region, label.map(str::to_owned)) + .map_err(move_error) + } +} + +// The engine's types as the contract's records; the native diffr's +// `src/plugin/bindings.rs` does the same for its component host. + +fn file_entry(file: &FileChange) -> FileEntry { + use diffr_plugin_sdk::{FileRef, FileSides, FileStatus as Status}; + let file_ref = |side: &protocol::FileRef| FileRef { + path: side.path.clone(), + oid: side.oid.clone(), + mode: side.mode.clone(), + }; + FileEntry { + file: match &file.file { + Pairing::Both { lhs, rhs } => FileSides::Both((file_ref(lhs), file_ref(rhs))), + Pairing::LeftOnly { lhs } => FileSides::LeftOnly(file_ref(lhs)), + Pairing::RightOnly { rhs } => FileSides::RightOnly(file_ref(rhs)), + }, + status: match file.status { + FileStatus::Added => Status::Added, + FileStatus::Deleted => Status::Deleted, + FileStatus::Modified => Status::Modified, + FileStatus::Renamed => Status::Renamed, + FileStatus::Copied => Status::Copied, + FileStatus::TypeChanged => Status::TypeChanged, + }, + tags: file.tags.clone(), + } +} + +fn core_side(side: Side) -> cursor::Side { + match side { + Side::Lhs => cursor::Side::Lhs, + Side::Rhs => cursor::Side::Rhs, + } +} + +fn region(view: cursor::RegionView) -> Region { + use diffr_plugin_sdk::bindings::diffr::plugin::types::{ + Kind, Leaf, Position, Range, Span, Visibility, + }; + let position = |position: protocol::SourcePos| Position { + line: position.line, + column: position.column, + }; + Region { + id: view.id, + parent: view.parent, + fold_state_id: view.fold_state_id, + range: Range { + start: position(view.range.start), + end: position(view.range.end), + }, + tags: view.tags, + visibility: Visibility { + collapsed: view.visibility.collapsed, + label: view.visibility.label, + }, + kind: match view.kind { + cursor::Kind::Leaf { + alignment_id, + changed, + } => Kind::Leaf(Leaf { + alignment_id, + changed: changed + .into_iter() + .map(|span| Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), + }), + cursor::Kind::Fold => Kind::Fold, + }, + } +} + +fn region_view(view: cursor::RegionView) -> RegionView { + RegionView { + side: match view.side { + cursor::Side::Lhs => Side::Lhs, + cursor::Side::Rhs => Side::Rhs, + }, + children: view.children.clone(), + data: region(view), + } +} + +fn region_ids(ids: cursor::RegionIds) -> RegionIds { + match ids { + cursor::RegionIds::Both(lhs, rhs) => RegionIds::Both((lhs, rhs)), + cursor::RegionIds::LeftOnly(lhs) => RegionIds::LeftOnly(lhs), + cursor::RegionIds::RightOnly(rhs) => RegionIds::RightOnly(rhs), + } +} + +fn move_error(error: cursor::MoveError) -> MoveError { + use diffr_plugin_sdk::bindings::diffr::plugin::types::{CutOutside, Grouping, Repeated}; + let grouping = |grouping: cursor::Grouping| match grouping { + cursor::Grouping::Link => Grouping::Link, + cursor::Grouping::Join => Grouping::Join, + }; + match error { + cursor::MoveError::NoRegion(id) => MoveError::NoRegion(id), + cursor::MoveError::CutFold(id) => MoveError::CutFold(id), + cursor::MoveError::CutOutside { id, offset, len } => { + MoveError::CutOutside(CutOutside { id, offset, len }) + } + cursor::MoveError::UnevenSides(id) => MoveError::UnevenSides(id), + cursor::MoveError::TooFewRegions(g) => MoveError::TooFewRegions(grouping(g)), + cursor::MoveError::Repeated { grouping: g, ids } => MoveError::Repeated(Repeated { + grouping: grouping(g), + ids, + }), + cursor::MoveError::OneSided(ids) => MoveError::OneSided(ids), + cursor::MoveError::NotSiblings(ids) => MoveError::NotSiblings(ids), + cursor::MoveError::NoNextSibling(id) => MoveError::NoNextSibling(id), + } +} diff --git a/diffr/xtask/src/main.rs b/diffr/xtask/src/main.rs index c25ae49c9..e9d14647e 100644 --- a/diffr/xtask/src/main.rs +++ b/diffr/xtask/src/main.rs @@ -180,14 +180,14 @@ fn wasm_artifact(output: &[u8], package_id: &Value) -> Result { /// Build diffr-core for `wasm32-unknown-unknown`, the target a browser runs. /// -/// tree-sitter ships the C library its parser needs there, and -/// tree-sitter-language the headers grammars compile against; grammar crates -/// that do not add those headers get them from `CFLAGS`. Grammars generated -/// for tree-sitter 0.26 also compile tree-sitter-language's copy of that -/// library, which is now a stub that fails on purpose, so its `wasm-src` -/// points at empty files instead. `crates/diffr-core/wasm` fills what a few -/// scanners use beyond it. macOS's `ar` writes no index a wasm linker -/// reads, so the archives are made with rustup's `llvm-ar`. +/// tree-sitter-language ships the libc headers tree-sitter and the grammars +/// compile against there; grammar crates that do not add them get them from +/// `CFLAGS`. Its libc sources (`wasm-src`) are a scanner heap for +/// tree-sitter's own wasm runtime, so they are replaced by +/// `crates/diffr-core/wasm/src`, with `malloc` and friends from Rust (see +/// diffr-core's `wasm_libc.rs`). `crates/diffr-core/wasm` also fills what a +/// few scanners use beyond those headers. macOS's `ar` writes no index a +/// wasm linker reads, so the archives are made with rustup's `llvm-ar`. fn build_core_wasm(root: &Path) -> Result<()> { wasm_cargo( root, @@ -223,17 +223,27 @@ fn wasm_cargo(root: &Path, args: &[&str]) -> Result<()> { .parent() .context("tree-sitter-language directory")? .join("wasm/include"); - let empty = root.join("target/wasm-core/empty-libc"); - std::fs::create_dir_all(&empty)?; - for file in ["stdio.c", "stdlib.c", "string.c"] { - std::fs::write(empty.join(file), "")?; - } let compat = root.join("crates/diffr-core/wasm"); + let libc = compat.join("src"); + // Grammar build scripts rerun when CFLAGS change, not when a header they + // include does, so the shims' hash is part of the flags. + let mut hasher = std::hash::DefaultHasher::new(); + for file in [ + "compat.h", + "include/sys/types.h", + "include/wchar.h", + "src/stdio.c", + "src/stdlib.c", + "src/string.c", + ] { + std::hash::Hash::hash(&std::fs::read(compat.join(file))?, &mut hasher); + } let cflags = format!( - "-I{} -I{} -include {}", + "-I{} -I{} -include {} -DDIFFR_WASM_COMPAT={:x}", headers.display(), compat.join("include").display(), - compat.join("compat.h").display() + compat.join("compat.h").display(), + std::hash::Hasher::finish(&hasher) ); run( cargo() @@ -247,7 +257,7 @@ fn wasm_cargo(root: &Path, args: &[&str]) -> Result<()> { .arg("--config") .arg(format!( "target.{target}.tree-sitter-language.wasm-src={:?}", - empty.display().to_string() + libc.display().to_string() )) .env("AR_wasm32_unknown_unknown", llvm_ar()?) .env("CFLAGS_wasm32_unknown_unknown", cflags), @@ -255,6 +265,24 @@ fn wasm_cargo(root: &Path, args: &[&str]) -> Result<()> { ) } +/// Build diffr-web for the browser and generate its bindings into `out_dir` +/// (by default `target/diffr-web`), where a page imports them. Needs +/// `cargo install wasm-bindgen-cli` at the crate's wasm-bindgen version. +fn build_web(root: &Path, out_dir: &Path) -> Result<()> { + wasm_cargo( + root, + &["build", "--locked", "--release", "--package", "diffr-web"], + )?; + run( + Command::new("wasm-bindgen") + .current_dir(root) + .args(["--target", "web", "--out-dir"]) + .arg(out_dir) + .arg("target/wasm32-unknown-unknown/release/diffr_web.wasm"), + "Running wasm-bindgen (install it with `cargo install wasm-bindgen-cli --version 0.2.129`)", + ) +} + /// rustup's `llvm-ar`, from the `llvm-tools` component. fn llvm_ar() -> Result { let rustc = |args: &[&str]| -> Result { @@ -288,6 +316,18 @@ fn main() -> Result<()> { "install-tui" => install(root, false), "build-plugins" => build_plugins(root), "build-core-wasm" => build_core_wasm(root), + "build-web" => { + let out_dir = match std::env::args().nth(2).as_deref() { + Some("--out-dir") => PathBuf::from( + std::env::args() + .nth(3) + .context("--out-dir needs a directory")?, + ), + Some(other) => bail!("unexpected argument {other}"), + None => root.join("target/diffr-web"), + }; + build_web(root, &out_dir) + } "wasm" => { let args: Vec = std::env::args().skip(2).collect(); wasm_cargo(root, &args.iter().map(String::as_str).collect::>()) @@ -310,7 +350,7 @@ fn main() -> Result<()> { anyhow::ensure!(status.success(), "plugin tests failed"); Ok(()) } - _ => bail!("usage: cargo xtask "), + _ => bail!("usage: cargo xtask "), } } From 0f53ea2e8a219900dca65b9f5172e7e7173f906c Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 18:41:39 -0700 Subject: [PATCH 05/21] Add the diffr-web page: GitHub pull requests diffed in the browser `/owner/repo/pull/N` and `/owner/repo/compare/base...head` list the change from GitHub, fetch each file's two sides and diff them in a pool of diffr workers, nearest the reader first, with files diffr hides (generated, vendored, lock files) last. Files show in Review Desktop's multi-diff view, forked under src/review: structural folds, scope and file viewed marks kept per change in this browser and dropped when a file changes, the unified/split switch, and diffr's highlights painted in the Whiteboard themes. F2 and F3 show diff stats and engine timing. The editor is built from Review Desktop's code-oss sources; the standalone services the fork removed are copied from upstream VS Code under src/standalone. Both copies are left out of lint, like code-oss. The engine ships gzipped, under static hosts' 25 MiB file cap. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- .oxlintrc.json | 2 + apps/diffr-web/.gitignore | 3 + apps/diffr-web/index.html | 12 + apps/diffr-web/package.json | 32 + apps/diffr-web/public/_headers | 3 + apps/diffr-web/src/comparison.ts | 474 +++++ apps/diffr-web/src/engine/engine.ts | 345 +++ apps/diffr-web/src/engine/worker.ts | 112 + apps/diffr-web/src/fonts.css | 229 ++ apps/diffr-web/src/github.ts | 349 +++ apps/diffr-web/src/main.ts | 322 +++ apps/diffr-web/src/panels.ts | 149 ++ apps/diffr-web/src/protocol.ts | 125 ++ .../src/review/browser/media/review.css | 1651 ++++++++++++++ .../review/browser/reviewChangedFilesTree.ts | 462 ++++ .../src/review/browser/reviewScrollPadding.ts | 4 + .../src/review/browser/reviewTooltip.ts | 217 ++ .../review/common/reviewChangedFilesModel.ts | 96 + .../src/review/common/reviewStructuralDiff.ts | 582 +++++ .../review/common/reviewStructuralViewed.ts | 128 ++ .../src/review/services/reviewDiffLayout.ts | 36 + .../review/services/reviewFilesDiffView.ts | 1018 +++++++++ .../src/review/services/reviewMultiDiff.ts | 343 +++ .../review/services/reviewStructuralDiff.ts | 323 +++ .../services/reviewStructuralDiffSession.ts | 154 ++ .../review/services/reviewStructuralFolds.ts | 764 +++++++ .../review/services/reviewStructuralSyntax.ts | 141 ++ .../review/services/reviewStructuralViewed.ts | 167 ++ .../services/reviewStructuralViewedControl.ts | 127 ++ apps/diffr-web/src/settings.ts | 19 + .../standalone/browser/clipboardService.ts | 40 + .../quickInput/standaloneQuickInput.css | 71 + .../quickInput/standaloneQuickInputService.ts | 319 +++ .../services/standaloneWebWorkerService.ts | 66 + .../standalone/browser/standalone-tokens.css | 84 + .../browser/standaloneCodeEditorService.ts | 121 ++ .../browser/standaloneLayoutService.ts | 99 + .../standalone/browser/standaloneServices.ts | 1887 +++++++++++++++++ .../browser/standaloneThemeService.ts | 526 +++++ .../standaloneTreeSitterLibraryService.ts | 50 + .../src/standalone/common/standaloneTheme.ts | 48 + .../diffr-web/src/standalone/common/themes.ts | 276 +++ apps/diffr-web/src/styles.css | 303 +++ apps/diffr-web/src/theme.ts | 51 + apps/diffr-web/src/viewed.ts | 255 +++ apps/diffr-web/tsconfig.json | 38 + apps/diffr-web/vite.config.ts | 69 + apps/diffr-web/wrangler.jsonc | 13 + packages/review-protocol/package.json | 3 +- pnpm-lock.yaml | 64 + 50 files changed, 12771 insertions(+), 1 deletion(-) create mode 100644 apps/diffr-web/.gitignore create mode 100644 apps/diffr-web/index.html create mode 100644 apps/diffr-web/package.json create mode 100644 apps/diffr-web/public/_headers create mode 100644 apps/diffr-web/src/comparison.ts create mode 100644 apps/diffr-web/src/engine/engine.ts create mode 100644 apps/diffr-web/src/engine/worker.ts create mode 100644 apps/diffr-web/src/fonts.css create mode 100644 apps/diffr-web/src/github.ts create mode 100644 apps/diffr-web/src/main.ts create mode 100644 apps/diffr-web/src/panels.ts create mode 100644 apps/diffr-web/src/protocol.ts create mode 100644 apps/diffr-web/src/review/browser/media/review.css create mode 100644 apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts create mode 100644 apps/diffr-web/src/review/browser/reviewScrollPadding.ts create mode 100644 apps/diffr-web/src/review/browser/reviewTooltip.ts create mode 100644 apps/diffr-web/src/review/common/reviewChangedFilesModel.ts create mode 100644 apps/diffr-web/src/review/common/reviewStructuralDiff.ts create mode 100644 apps/diffr-web/src/review/common/reviewStructuralViewed.ts create mode 100644 apps/diffr-web/src/review/services/reviewDiffLayout.ts create mode 100644 apps/diffr-web/src/review/services/reviewFilesDiffView.ts create mode 100644 apps/diffr-web/src/review/services/reviewMultiDiff.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralDiff.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralFolds.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralSyntax.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralViewed.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts create mode 100644 apps/diffr-web/src/settings.ts create mode 100644 apps/diffr-web/src/standalone/browser/clipboardService.ts create mode 100644 apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInput.css create mode 100644 apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInputService.ts create mode 100644 apps/diffr-web/src/standalone/browser/services/standaloneWebWorkerService.ts create mode 100644 apps/diffr-web/src/standalone/browser/standalone-tokens.css create mode 100644 apps/diffr-web/src/standalone/browser/standaloneCodeEditorService.ts create mode 100644 apps/diffr-web/src/standalone/browser/standaloneLayoutService.ts create mode 100644 apps/diffr-web/src/standalone/browser/standaloneServices.ts create mode 100644 apps/diffr-web/src/standalone/browser/standaloneThemeService.ts create mode 100644 apps/diffr-web/src/standalone/browser/standaloneTreeSitterLibraryService.ts create mode 100644 apps/diffr-web/src/standalone/common/standaloneTheme.ts create mode 100644 apps/diffr-web/src/standalone/common/themes.ts create mode 100644 apps/diffr-web/src/styles.css create mode 100644 apps/diffr-web/src/theme.ts create mode 100644 apps/diffr-web/src/viewed.ts create mode 100644 apps/diffr-web/tsconfig.json create mode 100644 apps/diffr-web/vite.config.ts create mode 100644 apps/diffr-web/wrangler.jsonc diff --git a/.oxlintrc.json b/.oxlintrc.json index 7c59078fe..51f1f7173 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,6 +24,8 @@ "**/dist/**", "**/.vite/**", "apps/review-desktop/code-oss/**", + "apps/diffr-web/src/standalone/**", + "apps/diffr-web/src/review/**", "diffr/**", ".agent/**", ".agents/**", diff --git a/apps/diffr-web/.gitignore b/apps/diffr-web/.gitignore new file mode 100644 index 000000000..0fd5a6058 --- /dev/null +++ b/apps/diffr-web/.gitignore @@ -0,0 +1,3 @@ +dist/ +src/wasm/ +.wrangler/ diff --git a/apps/diffr-web/index.html b/apps/diffr-web/index.html new file mode 100644 index 000000000..b5ca78b24 --- /dev/null +++ b/apps/diffr-web/index.html @@ -0,0 +1,12 @@ + + + + + + diffr + + +
+ + + diff --git a/apps/diffr-web/package.json b/apps/diffr-web/package.json new file mode 100644 index 000000000..e710f89b1 --- /dev/null +++ b/apps/diffr-web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@dev.fast/diffr-web", + "version": "0.0.0", + "private": true, + "description": "diffr in the browser: GitHub pull requests and comparisons diffed on the reader's machine", + "license": "MIT", + "type": "module", + "scripts": { + "wasm": "cd ../../diffr && cargo xtask build-web --out-dir ../apps/diffr-web/src/wasm", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "pnpm -w exec tsc --noEmit -p apps/diffr-web" + }, + "dependencies": { + "@dev.fast/diffr": "workspace:*", + "@dev.fast/review-protocol": "workspace:*" + }, + "devDependencies": { + "@types/trusted-types": "2.0.7", + "@types/wicg-file-system-access": "2023.10.7", + "@vscode/codicons": "0.0.46-21", + "@vscode/diff": "0.0.2-7", + "@vscode/tree-sitter-wasm": "0.3.1", + "@webgpu/types": "0.1.66", + "typescript": "6.0.3", + "vite": "8.0.16" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/apps/diffr-web/public/_headers b/apps/diffr-web/public/_headers new file mode 100644 index 000000000..746f89989 --- /dev/null +++ b/apps/diffr-web/public/_headers @@ -0,0 +1,3 @@ +# Built assets are named by their hash, so they never change. +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts new file mode 100644 index 000000000..b891b7aae --- /dev/null +++ b/apps/diffr-web/src/comparison.ts @@ -0,0 +1,474 @@ +/** + * One pull request or comparison on the page: its files listed from GitHub, each diffed by diffr in + * a worker once its text is fetched, nearest the reader first, into Review Desktop's multi-diff view. + * A file joins the list once diffed; the list keeps the reader's place as files arrive above. + */ +import { Emitter } from "vs/base/common/event.js"; +import { Disposable, DisposableStore } from "vs/base/common/lifecycle.js"; +import { URI } from "vs/base/common/uri.js"; +import { IHoverService } from "vs/platform/hover/browser/hover.js"; +import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; + +import type { Classified, Engine, FileRequest } from "./engine/engine.js"; +import { + type Change, + type ChangedFile, + type Preview, + type Target, + fileText, + loadChange, + targetPath, +} from "./github.js"; +import type { ReviewDiffFileWire, StructuralLineCounts } from "./protocol.js"; +import { orderReviewDiffFiles } from "./review/common/reviewChangedFilesModel.js"; +import { reviewFileCounts } from "./review/common/reviewStructuralDiff.js"; +import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; +import { + ReviewFilesDiffView, + type ReviewFilesEditorEntry, + ReviewFilesEditorInput, +} from "./review/services/reviewFilesDiffView.js"; +import { createStructuralDiffEditors } from "./review/services/reviewStructuralDiff.js"; +import { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; +import { StructuralViewedState } from "./review/services/reviewStructuralViewed.js"; +import { ScopeViewedControl } from "./review/services/reviewStructuralViewedControl.js"; +import { ViewedProgress } from "./viewed.js"; + +/** Files fetched or diffed at once. The engine spreads them over its workers. */ +const IN_FLIGHT = 12; + +/** Milliseconds from page start, for the engine panel. */ +export interface Timing { + listed?: number; + previewed?: number; + firstDiff?: number; + done?: number; +} + +export interface Work { + fetching: number; + diffing: number; + diffed: number; + hidden: number; + failed: number; + /** Total diffr time, and the file that took longest. */ + diffMs: number; + slowest?: { path: string; ms: number }; +} + +interface File { + changed: ChangedFile; + entry: ReviewFilesEditorEntry; + classified?: Classified; + state: "waiting" | "fetching" | "diffing" | "done"; +} + +export class Comparison extends Disposable { + private readonly changed = this._register(new Emitter()); + /** The title, timing or work counts changed. */ + readonly onDidChange = this.changed.event; + readonly timing: Timing = {}; + readonly work: Work = { + fetching: 0, + diffing: 0, + diffed: 0, + hidden: 0, + failed: 0, + diffMs: 0, + }; + preview: Preview | undefined; + change: Change | undefined; + error: string | undefined; + private readonly files = new Map(); + private order: string[] = []; + private view: ReviewFilesDiffView | undefined; + private session: StructuralDiffSession | undefined; + private viewed: ViewedProgress | undefined; + /** Files the reader asked for, most recent first. */ + private requested: string[] = []; + + constructor( + private readonly container: HTMLElement, + private readonly overflow: HTMLElement, + readonly target: Target, + private readonly engine: Engine, + private readonly layout: ReviewDiffLayoutSetting, + private readonly instantiation: IInstantiationService, + ) { + super(); + void this.load().catch((error: Error) => { + if (this._store.isDisposed) return; + this.error = error.message; + this.session?.fail(error.message); + this.changed.fire(); + }); + } + + get title(): string | undefined { + return (this.change ?? this.preview)?.title; + } + + get url(): string | undefined { + return (this.change ?? this.preview)?.url; + } + + get fileCount(): number { + return this.files.size; + } + + /** Lines added and removed so far, from diffr, or GitHub's totals while files are still waiting. */ + get counts(): StructuralLineCounts { + const change = this.change ?? this.preview; + + if (this.work.diffed < this.files.size && change?.additions !== undefined) + return { added: change.additions, removed: change.deletions ?? 0 }; + + let added = 0, + removed = 0; + + for (const path of this.order) { + const diff = this.session?.getFileResult(path)?.diff; + + if (diff?.type !== "text") continue; + added += diff.stats.visible.added; + removed += diff.stats.visible.removed; + } + + return { added, removed }; + } + + focus(): void { + this.view?.focus(); + } + + private async load(): Promise { + const change = await loadChange(this.target, (preview) => { + this.preview = preview; + this.changed.fire(); + }); + + if (this._store.isDisposed) return; + this.change = change; + this.timing.listed = performance.now(); + this.changed.fire(); + + const wires = new Map(); + + for (const file of change.files) { + wires.set( + { + path: file.path, + previousPath: file.previousPath, + status: file.status === "copied" ? "renamed" : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + }, + file, + ); + } + + const ordered = orderReviewDiffFiles([...wires.keys()]); + const { owner, repo } = this.target; + + const blob = (commit: string, path: string) => + URI.from({ + scheme: "https", + authority: "github.com", + path: `/${owner}/${repo}/blob/${commit}/${path}`, + }); + + const entries: ReviewFilesEditorEntry[] = ordered.map((file) => ({ + file, + original: URI.from({ + scheme: "diffr", + authority: "base", + path: `/${file.previousPath ?? file.path}`, + }), + modified: URI.from({ + scheme: "diffr", + authority: "head", + path: `/${file.path}`, + }), + goToFileResource: + file.status === "deleted" + ? blob(change.base, file.previousPath ?? file.path) + : blob(change.head, file.path), + })); + + for (const entry of entries) + this.files.set(entry.file.path, { + changed: wires.get(entry.file)!, + entry, + state: "waiting", + }); + this.order = entries.map((entry) => entry.file.path); + + const store = this._register(new DisposableStore()); + + const session = (this.session = store.add( + new StructuralDiffSession(new Set(this.order)), + )); + + const viewed = (this.viewed = store.add( + new ViewedProgress(targetPath(this.target)), + )); + + const hover = this.instantiation.invokeFunction((accessor) => + accessor.get(IHoverService), + ); + + const viewedState = store.add( + new StructuralViewedState( + session, + entries, + () => viewed.progress, + viewed.onDidChange, + (ranges, isViewed) => viewed.setViewed(ranges, isViewed), + (editor, onToggle) => new ScopeViewedControl(editor, hover, onToggle), + ), + ); + + const structural = createStructuralDiffEditors( + this.instantiation, + entries, + store, + session, + viewedState, + ); + + const input = store.add( + structural.instantiation.createInstance( + ReviewFilesEditorInput, + entries, + session, + ), + ); + + const view = (this.view = store.add( + structural.instantiation.createInstance( + ReviewFilesDiffView, + this.container, + this.overflow, + this.layout, + undefined, + (path: string) => void viewed.toggleFile(path), + undefined, + undefined, + ), + )); + + view.setProgress(viewed.progress); + store.add(viewed.onDidChange(() => view.setProgress(viewed.progress))); + view.startLoading(entries); + store.add( + view.onDidRequestFile((path) => { + this.requested = [path, ...this.requested.filter((p) => p !== path)]; + this.pump(); + }), + ); + store.add(view.onDidScroll(() => this.pump())); + this.observe(session, entries, view, store); + await view.setInput(input, undefined); + + if (this._store.isDisposed) return; + + // Paths alone say which files diffr hides (generated, vendored, lockfiles); those go last. + const previews = await this.engine.preview( + entries.map((entry) => this.request(this.files.get(entry.file.path)!)), + ); + + if (this._store.isDisposed) return; + previews.forEach((preview, index) => { + if (!("error" in preview)) + this.files.get(this.order[index]!)!.classified = preview; + }); + this.work.hidden = [...this.files.values()].filter( + (file) => file.classified?.hidden, + ).length; + this.timing.previewed = performance.now(); + this.changed.fire(); + this.pump(); + } + + /** What diffr is told about a file: its sides, with their text once fetched. */ + private request( + file: File, + text?: { lhs?: string; rhs?: string }, + ): FileRequest { + const { changed } = file; + const base = changed.previousPath ?? changed.path; + + return { + status: changed.status, + // The base blob's id is not in GitHub's listing; any id names the text diffr is given. + lhs: + changed.status === "added" + ? undefined + : { + path: base, + oid: `base:${base}`, + mode: "100644", + text: text?.lhs, + }, + rhs: + changed.status === "deleted" + ? undefined + : { + path: changed.path, + oid: changed.sha ?? `head:${changed.path}`, + mode: "100644", + text: text?.rhs, + }, + }; + } + + /** The next file to diff: one the reader asked for, else the nearest after the one on screen. */ + private next(): File | undefined { + for (const path of this.requested) { + const file = this.files.get(path); + + if (file?.state === "waiting") return file; + } + + const active = this.view?.activePath; + const anchor = Math.max(0, active ? this.order.indexOf(active) : 0); + + let best: File | undefined, + bestScore = Infinity; + + this.order.forEach((path, index) => { + const file = this.files.get(path)!; + + if (file.state !== "waiting") return; + // Ahead of the reader first; files behind cost double; hidden ones wait for the rest. + const distance = index >= anchor ? index - anchor : (anchor - index) * 2; + const score = distance + (file.classified?.hidden ? 1e6 : 0); + + if (score < bestScore) [best, bestScore] = [file, score]; + }); + + return best; + } + + private pump(): void { + if ( + !this.session || + this.timing.previewed === undefined || + this._store.isDisposed + ) + return; + + while (this.work.fetching + this.work.diffing < IN_FLIGHT) { + const file = this.next(); + + if (!file) break; + void this.diff(file); + } + } + + private async diff(file: File): Promise { + const { changed, entry } = file; + const change = this.change!; + const session = this.session!; + file.state = "fetching"; + this.work.fetching++; + this.changed.fire(); + + try { + const [lhs, rhs] = await Promise.all([ + changed.status === "added" + ? undefined + : fileText( + this.target, + change.base, + changed.previousPath ?? changed.path, + ), + changed.status === "deleted" + ? undefined + : fileText(this.target, change.head, changed.path), + ]); + + if (this._store.isDisposed) return; + this.work.fetching--; + this.work.diffing++; + file.state = "diffing"; + this.changed.fire(); + const diffed = await this.engine.diff(this.request(file, { lhs, rhs })); + + if (this._store.isDisposed) return; + this.work.diffing--; + this.work.diffMs += diffed.ms; + + if (!this.work.slowest || diffed.ms > this.work.slowest.ms) + this.work.slowest = { path: changed.path, ms: diffed.ms }; + const event = diffed.event; + + if (event.diff) { + // diffr decides what is binary, from the bytes. + if (event.diff.type === "binary") entry.file.binary = true; + this.viewed!.addFile( + changed.path, + changed.previousPath, + changed.sha ?? "deleted", + event.diff, + ); + session.setFileResult(changed.path, { + diff: event.diff, + hidden: diffed.classified.hidden, + }); + } else { + this.work.failed++; + session.setFileResult(changed.path, { error: event.error.message }); + } + } catch (error) { + if (this._store.isDisposed) return; + + if (file.state === "fetching") this.work.fetching--; + else this.work.diffing--; + this.work.failed++; + session.setFileResult(changed.path, { + error: error instanceof Error ? error.message : String(error), + }); + } + + file.state = "done"; + this.work.diffed++; + this.timing.firstDiff ??= performance.now(); + + if (this.work.diffed === this.files.size) + this.timing.done = performance.now(); + this.changed.fire(); + this.pump(); + } + + /** Review Desktop's observeSession: each result goes to the view once. */ + private observe( + session: StructuralDiffSession, + entries: readonly ReviewFilesEditorEntry[], + view: ReviewFilesDiffView, + store: DisposableStore, + ): void { + const rendered = new Set(); + store.add( + session.onDidChange((change) => { + for (const entry of entries) { + const path = entry.file.path; + + if (!change.files.has(path) || rendered.has(path)) continue; + const result = session.getFileResult(path); + + if (!result) continue; + rendered.add(path); + + if (result.hidden !== undefined) view.hideFile(path, result.hidden); + + if (result.diff) view.fileCounts(path, reviewFileCounts(result.diff)); + view.fileLoaded(path, result.error); + } + + if (session.error) view.loadingFailed(session.error); + }), + ); + } +} diff --git a/apps/diffr-web/src/engine/engine.ts b/apps/diffr-web/src/engine/engine.ts new file mode 100644 index 000000000..476126abc --- /dev/null +++ b/apps/diffr-web/src/engine/engine.ts @@ -0,0 +1,345 @@ +/** + * The page's handle on diffr: the wasm module is fetched and compiled once, then handed to a pool + * of workers, so files diff in parallel without each worker paying for its own compile. The pool + * starts at one and grows while work queues, a worker at a time: each spends a second or two + * compiling diffr's queries, and workers starting together slow each other several times over. + * All but one go after ten idle seconds, since wasm memory never shrinks. + */ +// Gzipped by the build (vite.config.ts): static hosts cap a file at 25 MiB, and the engine is larger. +import wasmUrl from "../wasm/diffr_web_bg.wasm.gz?url"; +import type { Classified, FileEvent, Request, Response } from "./worker.js"; + +export type { Classified, FileEvent }; + +/** One side of a file, named as diffr's classifier reads it. */ +export interface Side { + path: string; + oid: string; + mode: string; + text?: string; +} + +export interface FileRequest { + status: "added" | "deleted" | "modified" | "renamed" | "copied"; + lhs?: Side; + rhs?: Side; +} + +export interface Diffed { + classified: Classified; + event: FileEvent; + ms: number; +} + +/** What the engine panel shows. */ +export interface EngineStats { + /** Most workers the pool grows to. */ + maxWorkers: number; + workers: number; + busy: number; + /** Milliseconds from page start until the module was compiled. */ + compiled?: number; + /** Milliseconds a worker took to load the module and the configuration. */ + ready?: number; + bytes: number; + /** The largest wasm memory any worker holds. */ + memory: number; + diffed: number; + diffMs: number; +} + +const MAX_WORKERS = Math.max( + 1, + Math.min(8, (navigator.hardwareConcurrency || 4) - 1), +); + +const IDLE_MS = 10_000; + +/** Past this, a worker is replaced once its queue drains. */ +const MEMORY_LIMIT = 1024 * 1024 * 1024; + +const stats: EngineStats = { + maxWorkers: MAX_WORKERS, + workers: 0, + busy: 0, + bytes: 0, + memory: 0, + diffed: 0, + diffMs: 0, +}; + +const listeners = new Set<() => void>(); + +export function engineStats(): Readonly { + return stats; +} + +export function onEngineChange(listener: () => void): () => void { + listeners.add(listener); + + return () => listeners.delete(listener); +} + +const changed = () => listeners.forEach((listener) => listener()); + +/** + * The engine's bytes, unpacked as they arrive so compiling still streams. A server that sent the + * file with `Content-Encoding: gzip` has unpacked it already, so the first bytes decide. + */ +async function unpacked( + response: globalThis.Response, +): Promise { + const [peek, body] = response.body!.tee(); + const reader = peek.getReader(); + const { value } = await reader.read(); + void reader.cancel(); + const gzipped = !!value && value[0] === 0x1f && value[1] === 0x8b; + + const stream = gzipped + ? body.pipeThrough(new DecompressionStream("gzip")) + : body; + + return new globalThis.Response(stream, { + headers: { "content-type": "application/wasm" }, + }); +} + +let compiled: Promise | undefined; + +function module(): Promise { + return (compiled ??= (async () => { + const response = await fetch(wasmUrl); + + if (!response.ok) + throw new Error(`The diffr engine failed to load (${response.status}).`); + stats.bytes = Number(response.headers.get("content-length")) || 0; + const module = await WebAssembly.compileStreaming(await unpacked(response)); + stats.compiled = performance.now(); + changed(); + + return module; + })()); +} + +interface Call { + resolve(response: Response): void; + reject(error: Error): void; +} + +interface Slot { + worker: Worker; + busy: number; + retire: boolean; + ready: boolean; +} + +/** A pool of diffr workers, all with one configuration. */ +export class Engine { + private readonly slots: Slot[] = []; + private readonly pending = new Map(); + /** Jobs no worker has taken yet. */ + private readonly queue: Request[] = []; + private next = 0; + private idle: ReturnType | undefined; + private failure: Error | undefined; + /** What the configuration asked for that the browser does not run. */ + readonly notices: Promise; + private resolveNotices!: (notices: string[]) => void; + private rejectNotices!: (error: Error) => void; + + constructor(private readonly config: string | undefined) { + this.notices = new Promise((resolve, reject) => { + this.resolveNotices = resolve; + this.rejectNotices = reject; + }); + this.notices.catch(() => {}); + this.slots.push(this.spawn()); + } + + private spawn(): Slot { + const slot: Slot = { + worker: new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }), + busy: 0, + retire: false, + ready: false, + }; + + stats.workers++; + module().then( + (module) => + slot.worker.postMessage({ + module, + config: this.config, + } satisfies Request), + (error: Error) => this.fail(error), + ); + slot.worker.onmessage = ({ data }: MessageEvent) => + this.receive(slot, data); + slot.worker.onerror = (event) => + this.fail(new Error(event.message || "A diffr worker failed to start.")); + changed(); + + return slot; + } + + private receive(slot: Slot, data: Response): void { + if ("ready" in data) { + slot.ready = true; + stats.ready ??= data.ready; + this.resolveNotices(data.notices); + this.dispatch(); + changed(); + + return; + } + + if ("failed" in data) { + this.fail(new Error(data.failed)); + + return; + } + + slot.busy--; + stats.busy--; + stats.memory = Math.max(stats.memory, data.memory); + + if (data.memory > MEMORY_LIMIT) slot.retire = true; + + if (slot.retire && !slot.busy) this.replace(slot); + const call = this.pending.get(data.id)!; + this.pending.delete(data.id); + + if ("error" in data) call.reject(new Error(data.error)); + else call.resolve(data); + this.dispatch(); + + if (!stats.busy && !this.queue.length) this.scheduleIdle(); + changed(); + } + + private replace(slot: Slot): void { + slot.worker.terminate(); + stats.workers--; + this.slots.splice(this.slots.indexOf(slot), 1, this.spawn()); + } + + /** The configuration or the engine failed: every call fails with it. */ + private fail(error: Error): void { + this.failure = error; + this.rejectNotices(error); + + for (const call of this.pending.values()) call.reject(error); + this.pending.clear(); + this.queue.length = 0; + } + + private scheduleIdle(): void { + clearTimeout(this.idle); + this.idle = setTimeout(() => { + for (const slot of this.slots.splice(1)) { + if (slot.busy) this.slots.push(slot); + else { + slot.worker.terminate(); + stats.workers--; + } + } + + changed(); + }, IDLE_MS); + } + + /** + * Queued jobs to ready workers, two each so none waits on the page between jobs. When every + * ready worker is full and none is starting, another starts. A retiring worker takes nothing + * new, so it drains and goes. + */ + private dispatch(): void { + while (this.queue.length) { + const slot = this.slots.find( + (slot) => slot.ready && !slot.retire && slot.busy < 2, + ); + + if (!slot) { + if ( + this.slots.every((slot) => slot.ready) && + this.slots.length < MAX_WORKERS + ) + this.slots.push(this.spawn()); + + return; + } + + const job = this.queue.shift()!; + slot.busy++; + stats.busy++; + slot.worker.postMessage(job); + } + } + + private send( + message: (id: number) => Request, + ): Promise { + if (this.failure) return Promise.reject(this.failure); + clearTimeout(this.idle); + const id = this.next++; + + return new Promise((resolve, reject) => { + // SAFETY: the worker answers this id with the response kind its request asks for. + this.pending.set(id, { resolve: resolve as Call["resolve"], reject }); + this.queue.push(message(id)); + this.dispatch(); + changed(); + }); + } + + /** Diff one file. Resolves with its classification, its record and the time it took. */ + async diff(file: FileRequest): Promise { + const response = await this.send>( + (id) => ({ + id, + diff: JSON.stringify({ ...file, syntax: true }), + }), + ); + + stats.diffed++; + stats.diffMs += response.ms; + + return { + classified: response.classified, + event: response.event, + ms: response.ms, + }; + } + + /** Classify files from their paths alone, before any source is fetched. In the order asked. */ + async preview( + files: readonly FileRequest[], + ): Promise<(Classified | { error: string })[]> { + const response = await this.send>( + (id) => ({ + id, + preview: JSON.stringify(files), + }), + ); + + return response.previews; + } + + dispose(): void { + clearTimeout(this.idle); + + for (const slot of this.slots.splice(0)) { + slot.worker.terminate(); + stats.workers--; + stats.busy -= slot.busy; + } + + for (const call of this.pending.values()) + call.reject(new Error("The engine was stopped.")); + this.pending.clear(); + this.queue.length = 0; + changed(); + } +} diff --git a/apps/diffr-web/src/engine/worker.ts b/apps/diffr-web/src/engine/worker.ts new file mode 100644 index 000000000..296595b58 --- /dev/null +++ b/apps/diffr-web/src/engine/worker.ts @@ -0,0 +1,112 @@ +/// +/** Runs diffr off the main thread: one engine, one request at a time, in order. The page sends work only once it is ready. */ +import type { StructuralDiffEvent } from "../protocol.js"; +import init, { Differ } from "../wasm/diffr_web.js"; + +/** A file as the classifier sees it: its tags, and why it starts hidden. */ +export interface Classified { + tags: string[]; + hidden?: string; +} + +export type FileEvent = Extract; + +/** The first message carries the compiled module and the configuration; every later one is work. */ +export type Request = + | { module: WebAssembly.Module; config: string | undefined } + | { id: number; diff: string } + | { id: number; preview: string }; + +interface Usage { + id: number; + ms: number; + /** Bytes of wasm memory, which never shrinks. */ + memory: number; +} + +export type Response = + | { ready: number; notices: string[] } + | { failed: string } + | (Usage & { classified: Classified; event: FileEvent }) + | (Usage & { previews: (Classified | { error: string })[] }) + | (Usage & { error: string }); + +interface WireClassified { + entry: { tags?: string[] }; + hidden?: string; +} + +let differ: Differ | undefined; + +let memory: WebAssembly.Memory | undefined; + +self.onmessage = async ({ data }: MessageEvent) => { + if ("module" in data) { + const start = performance.now(); + + try { + const wasm = await init({ module_or_path: data.module }); + memory = wasm.memory; + differ = new Differ(data.config); + self.postMessage({ + ready: performance.now() - start, + notices: JSON.parse(differ.notices()), + } satisfies Response); + } catch (error) { + self.postMessage({ + failed: error instanceof Error ? error.message : String(error), + } satisfies Response); + } + + return; + } + + handle(data); +}; + +function handle(data: Request): void { + if ("module" in data) return; + const start = performance.now(); + + const usage = () => ({ + id: data.id, + ms: performance.now() - start, + memory: memory?.buffer.byteLength ?? 0, + }); + + try { + if ("preview" in data) { + // SAFETY: Differ.preview returns an array of these, one per file (diffr-web's lib.rs). + const previews = ( + JSON.parse(differ!.preview(data.preview)) as ( + | WireClassified + | { error: string } + )[] + ).map((preview) => + "error" in preview + ? preview + : { tags: preview.entry.tags ?? [], hidden: preview.hidden }, + ); + + self.postMessage({ ...usage(), previews } satisfies Response); + + return; + } + + // SAFETY: Differ.diff returns the classified entry and its record (diffr-web's lib.rs). + const response = JSON.parse(differ!.diff(data.diff)) as WireClassified & { + event: FileEvent; + }; + + self.postMessage({ + ...usage(), + classified: { tags: response.entry.tags ?? [], hidden: response.hidden }, + event: response.event, + } satisfies Response); + } catch (error) { + self.postMessage({ + ...usage(), + error: error instanceof Error ? error.message : String(error), + } satisfies Response); + } +} diff --git a/apps/diffr-web/src/fonts.css b/apps/diffr-web/src/fonts.css new file mode 100644 index 000000000..772951292 --- /dev/null +++ b/apps/diffr-web/src/fonts.css @@ -0,0 +1,229 @@ +/* Geist Mono, the editor font, from the canvas's self-hosted fonts (packages/review/app/src/fonts). */ + +/* cyrillic-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2) + format("woff2"); + unicode-range: + U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2) + format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* symbols2 */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2) + format("woff2"); + unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F; +} + +/* vietnamese */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2) + format("woff2"); + unicode-range: + U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2) + format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2) + format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +/* cyrillic-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2) + format("woff2"); + unicode-range: + U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2) + format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* symbols2 */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2) + format("woff2"); + unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F; +} + +/* vietnamese */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2) + format("woff2"); + unicode-range: + U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2) + format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2) + format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +/* cyrillic-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-0.woff2) + format("woff2"); + unicode-range: + U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-1.woff2) + format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* symbols2 */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-2.woff2) + format("woff2"); + unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F; +} + +/* vietnamese */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-3.woff2) + format("woff2"); + unicode-range: + U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-4.woff2) + format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../../../packages/review/app/src/fonts/geist-mono-5.woff2) + format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} diff --git a/apps/diffr-web/src/github.ts b/apps/diffr-web/src/github.ts new file mode 100644 index 000000000..62a3e7030 --- /dev/null +++ b/apps/diffr-web/src/github.ts @@ -0,0 +1,349 @@ +/** + * Read a pull request or a comparison straight from GitHub. Requests go only to api.github.com and + * raw.githubusercontent.com, both of which allow cross-origin reads; the token, when there is one, + * stays in this browser's localStorage and goes only to api.github.com. + */ + +export type Target = + | { kind: "pull"; owner: string; repo: string; number: number } + | { + kind: "compare"; + owner: string; + repo: string; + base: string; + head: string; + }; + +export type Status = "added" | "deleted" | "modified" | "renamed" | "copied"; + +export interface ChangedFile { + path: string; + /** The path at the base, for a rename or copy. */ + previousPath?: string; + status: Status; + /** GitHub's patch for the file; absent for binary files and ones too large for the listing. */ + patch?: string; + additions: number; + deletions: number; + /** The file's blob at the head; absent for a deletion. */ + sha?: string; +} + +export interface Change { + target: Target; + title: string; + url: string; + /** The merge base, as GitHub's own PR diff uses: what the head is compared with. */ + base: string; + head: string; + files: ChangedFile[]; + /** GitHub's totals for a pull request. Its file listing reports 0 for files too large to patch. */ + additions?: number; + deletions?: number; +} + +const TOKEN_KEY = "diffr.githubToken"; + +export function token(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function setToken(value: string | null) { + try { + if (value) localStorage.setItem(TOKEN_KEY, value); + else localStorage.removeItem(TOKEN_KEY); + } catch { + // Storage is off: the token lasts until the page closes, which is never, since nothing keeps it. + } +} + +/** `owner/repo/pull/N` or `owner/repo/compare/base...head`, with or without github.com in front. */ +export function parseTarget(input: string): Target | null { + const path = input + .trim() + .replace(/^https?:\/\/(www\.)?github\.com\//, "") + .replace(/^\/+/, "") + .replace(/[?#].*$/, ""); + + const pull = path.match(/^([\w.-]+)\/([\w.-]+)\/pulls?\/(\d+)/); + + if (pull) + return { + kind: "pull", + owner: pull[1]!, + repo: pull[2]!, + number: Number(pull[3]), + }; + + const compare = path.match( + /^([\w.-]+)\/([\w.-]+)\/compare\/(.+?)\.\.\.?(.+)$/, + ); + + if (compare) + return { + kind: "compare", + owner: compare[1]!, + repo: compare[2]!, + base: decodeURIComponent(compare[3]!), + head: decodeURIComponent(compare[4]!), + }; + + return null; +} + +export function targetPath(target: Target): string { + const repo = `/${target.owner}/${target.repo}`; + + return target.kind === "pull" + ? `${repo}/pull/${target.number}` + : `${repo}/compare/${encodeURIComponent(target.base)}...${encodeURIComponent(target.head)}`; +} + +export class GitHubError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +async function api(path: string): Promise { + const auth = token(); + + const headers = new Headers({ Accept: "application/vnd.github+json" }); + + if (auth) headers.set("Authorization", `Bearer ${auth}`); + + const response = await fetch(`https://api.github.com${path}`, { headers }); + + if (!response.ok) { + // SAFETY: GitHub's error bodies carry a message; without one the status text is shown. + const body = (await response.json().catch(() => ({}))) as { + message?: string; + }; + + const limited = response.headers.get("x-ratelimit-remaining") === "0"; + const reset = Number(response.headers.get("x-ratelimit-reset")) * 1000; + + const until = reset + ? ` until ${new Date(reset).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` + : ""; + + const message = limited + ? `GitHub's rate limit is spent${auth ? "" : " (60 requests an hour without a token)"}${until}. Add a token or try again then.` + : response.status === 404 && !auth + ? "Not found. A private repository needs a token" + : (body.message ?? response.statusText); + + throw new GitHubError( + `GitHub ${response.status}: ${message}`, + response.status, + ); + } + + // SAFETY: each caller names the shape GitHub documents for its endpoint. + return response.json() as Promise; +} + +interface ApiFile { + filename: string; + previous_filename?: string; + status: + | "added" + | "removed" + | "modified" + | "renamed" + | "copied" + | "changed" + | "unchanged"; + patch?: string; + additions: number; + deletions: number; + sha?: string; +} + +const statuses: Record = { + added: "added", + removed: "deleted", + modified: "modified", + renamed: "renamed", + copied: "copied", + changed: "modified", + unchanged: "modified", +}; + +const changedFile = (file: ApiFile): ChangedFile => ({ + path: file.filename, + previousPath: file.previous_filename, + status: statuses[file.status], + patch: file.patch, + additions: file.additions, + deletions: file.deletions, + sha: file.status === "removed" ? undefined : file.sha, +}); + +/** Every page of a listing GitHub caps at 100 a page; it stops listing a PR's files at 3000. */ +async function pages( + path: (page: number) => string, + items: (response: R) => T[], +): Promise { + const all: T[] = []; + + for (let page = 1; page <= 30; page++) { + const batch = items(await api(path(page))); + all.push(...batch); + + if (batch.length < 100) break; + } + + return all; +} + +/** What can be shown before the whole listing is in: the title and the first page of files. */ +export type Preview = Omit & { base?: string }; + +/** + * Load a change. A pull request calls `onPreview` as soon as its first hundred files are known, + * so they can be shown while the rest of the listing and the merge base load. + */ +export async function loadChange( + target: Target, + onPreview?: (preview: Preview) => void, +): Promise { + const repo = `/repos/${target.owner}/${target.repo}`; + + if (target.kind === "pull") { + const page = (n: number) => + api( + `${repo}/pulls/${target.number}/files?per_page=100&page=${n}`, + ); + + // The first page of files needs nothing from the PR, so it is asked for alongside it. + const [pr, first] = await Promise.all([ + api<{ + title: string; + html_url: string; + base: { sha: string }; + head: { sha: string }; + changed_files: number; + additions: number; + deletions: number; + }>(`${repo}/pulls/${target.number}`), + page(1), + ]); + + const preview: Preview = { + target, + title: pr.title, + url: pr.html_url, + head: pr.head.sha, + files: first.map(changedFile), + additions: pr.additions, + deletions: pr.deletions, + }; + + onPreview?.(preview); + + // The merge base is all diffr needs to start on the files already shown. + const base = api<{ merge_base_commit: { sha: string } }>( + `${repo}/compare/${pr.base.sha}...${pr.head.sha}?per_page=1`, + ); + + void base.then( + (compare) => + onPreview?.({ ...preview, base: compare.merge_base_commit.sha }), + () => {}, + ); + // The PR says how many files it changed, so every other page can be asked for at once. + const count = Math.min(30, Math.max(1, Math.ceil(pr.changed_files / 100))); + + const [compare, ...rest] = await Promise.all([ + base, + ...Array.from({ length: count - 1 }, (_, i) => page(i + 2)), + ]); + + const files = [first, ...rest].flat(); + + return { + target, + title: pr.title, + url: pr.html_url, + base: compare.merge_base_commit.sha, + head: pr.head.sha, + files: files.map(changedFile), + additions: pr.additions, + deletions: pr.deletions, + }; + } + + const range = `${encodeURIComponent(target.base)}...${encodeURIComponent(target.head)}`; + + type Compare = { + merge_base_commit: { sha: string }; + html_url: string; + files?: ApiFile[]; + }; + + let compare: Compare | undefined; + + const [head, files] = await Promise.all([ + api<{ sha: string }>(`${repo}/commits/${encodeURIComponent(target.head)}`), + pages( + (page) => `${repo}/compare/${range}?per_page=100&page=${page}`, + (r: Compare) => { + compare ??= r; + + return r.files ?? []; + }, + ), + ]); + + return { + target, + title: `${target.base}...${target.head}`, + url: compare!.html_url, + base: compare!.merge_base_commit.sha, + head: head.sha, + files: files.map(changedFile), + }; +} + +const encodePath = (path: string) => + path.split("/").map(encodeURIComponent).join("/"); + +/** A file's text at a commit. Without a token, raw.githubusercontent.com serves public files outside the API's rate limit. */ +export async function fileText( + target: Target, + sha: string, + path: string, +): Promise { + const auth = token(); + + const response = auth + ? await fetch( + `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${encodePath(path)}?ref=${sha}`, + { + headers: { + Accept: "application/vnd.github.raw+json", + Authorization: `Bearer ${auth}`, + }, + }, + ) + : await fetch( + `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${sha}/${encodePath(path)}`, + ); + + if (!response.ok) + throw new GitHubError( + `GitHub ${response.status} fetching ${path}`, + response.status, + ); + + // diffr decides what is binary from the bytes, so keep NULs; invalid UTF-8 becomes U+FFFD. + return new TextDecoder().decode(await response.arrayBuffer()); +} diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts new file mode 100644 index 000000000..fe70f4fc5 --- /dev/null +++ b/apps/diffr-web/src/main.ts @@ -0,0 +1,322 @@ +/** + * The page: a landing form, or one pull request or comparison at `/owner/repo/pull/N` or + * `/owner/repo/compare/base...head`. Everything runs here; GitHub is the only server it talks to. + */ +import "vs/base/browser/ui/codicons/codiconStyles.js"; +import "./fonts.css"; +import "./styles.css"; +import { Comparison } from "./comparison.js"; +import { Engine, onEngineChange } from "./engine/engine.js"; +import { parseTarget, setToken, targetPath, token } from "./github.js"; +import { Panels } from "./panels.js"; +import { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; +import { readSetting, writeSetting } from "./settings.js"; +import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; +import { applyTheme } from "./theme.js"; + +const instantiation = StandaloneServices.initialize({}); + +const layout = new ReviewDiffLayoutSetting(); + +const root = document.getElementById("app")!; + +// Review Desktop's stylesheet is scoped to its workbench; the theme's variables to Monaco components. +root.className = "app monaco-workbench review-workbench monaco-component"; + +applyTheme(root); + +const header = root.appendChild(element("header", "app-header")); + +const body = root.appendChild(element("main", "app-body")); + +const overflow = root.appendChild(element("div", "monaco-editor app-overflow")); + +let engine: Engine | undefined; + +let comparison: Comparison | undefined; + +const panels = new Panels(() => comparison); + +root.appendChild(panels.element); + +onEngineChange(() => panels.update()); + +function element( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + + if (className) node.className = className; + + if (text !== undefined) node.textContent = text; + + return node; +} + +function iconButton( + icon: string, + tooltip: string, + onClick: () => void, +): HTMLButtonElement { + const button = element("button", `app-icon-button codicon codicon-${icon}`); + button.title = tooltip; + button.setAttribute("aria-label", tooltip); + button.addEventListener("click", onClick); + + return button; +} + +/** One engine for the page, remade when the configuration changes. */ +function currentEngine(): Engine { + return (engine ??= new Engine(readSetting("config"))); +} + +function route(): void { + comparison?.dispose(); + comparison = undefined; + body.replaceChildren(); + const target = parseTarget(location.pathname); + + if (!target) { + document.title = "diffr"; + renderHeader(); + renderLanding(); + panels.update(); + + return; + } + + const container = body.appendChild(element("div", "app-comparison")); + + const current = (comparison = new Comparison( + container, + overflow, + target, + currentEngine(), + layout, + instantiation, + )); + + const update = () => { + if (comparison !== current) return; + document.title = current.title ? `${current.title} · diffr` : "diffr"; + renderHeader(); + panels.update(); + }; + + current.onDidChange(update); + update(); +} + +function renderHeader(): void { + header.replaceChildren(); + const home = header.appendChild(element("a", "app-wordmark", "diffr")); + home.href = "/"; + home.title = "Open another pull request"; + home.addEventListener("click", (event) => { + event.preventDefault(); + navigate("/"); + }); + + if (comparison) { + const title = header.appendChild( + element( + "a", + "app-title", + comparison.title ?? targetPath(comparison.target).slice(1), + ), + ); + + if (comparison.url) { + title.href = comparison.url; + title.target = "_blank"; + title.rel = "noopener"; + title.title = `Open on GitHub · ${targetPath(comparison.target).slice(1)}`; + } + + if (comparison.error) + header.appendChild(element("span", "app-error", comparison.error)); + } + + header.appendChild(element("span", "app-spacer")); + + if (comparison) { + const split = layout.get() === "split"; + header.appendChild( + iconButton( + split ? "split-horizontal" : "list-flat", + split ? "Split · show unified" : "Unified · show split", + () => { + void layout.toggle().then(renderHeader); + }, + ), + ); + } + + header.appendChild( + iconButton("settings-gear", "diffr configuration", openConfig), + ); + header.appendChild( + iconButton( + token() ? "unlock" : "key", + token() ? "GitHub token set" : "Add a GitHub token", + openToken, + ), + ); +} + +function renderLanding(): void { + const landing = body.appendChild(element("form", "app-landing")); + landing.appendChild(element("h1", undefined, "diffr")); + landing.appendChild( + element( + "p", + undefined, + "Structural diffs of GitHub pull requests, worked out in your browser.", + ), + ); + const input = landing.appendChild(element("input")); + input.placeholder = "github.com/owner/repo/pull/123"; + input.setAttribute("aria-label", "Pull request or comparison URL"); + input.autofocus = true; + const error = landing.appendChild(element("p", "app-landing-error")); + landing.addEventListener("submit", (event) => { + event.preventDefault(); + const target = parseTarget(input.value); + + if (target) navigate(targetPath(target)); + else + error.textContent = + "A pull request or compare URL, like github.com/owner/repo/pull/123"; + }); + input.focus(); +} + +function navigate(path: string): void { + if (path !== location.pathname) history.pushState(null, "", path); + route(); +} + +function dialog( + title: string, + build: (dialog: HTMLDialogElement, actions: HTMLElement) => void, +): void { + const node = root.appendChild(element("dialog", "app-dialog")); + node.appendChild(element("h2", undefined, title)); + const actions = element("div", "app-dialog-actions"); + build(node, actions); + node.appendChild(actions); + node.addEventListener("close", () => node.remove()); + node.showModal(); +} + +function actionButton( + label: string, + onClick: () => void, + primary = false, +): HTMLButtonElement { + const button = element( + "button", + primary ? "app-button primary" : "app-button", + label, + ); + + button.type = "button"; + button.addEventListener("click", onClick); + + return button; +} + +function openToken(): void { + dialog("GitHub token", (node, actions) => { + node.appendChild( + element( + "p", + undefined, + "For private repositories, and GitHub's higher rate limit. It stays in this browser and is sent only to GitHub.", + ), + ); + const input = node.appendChild(element("input")); + input.type = "password"; + input.placeholder = "github_pat_…"; + input.value = token() ?? ""; + input.setAttribute("aria-label", "GitHub token"); + + const save = (value: string | null) => { + setToken(value); + node.close(); + route(); + }; + + if (token()) actions.appendChild(actionButton("Remove", () => save(null))); + actions.appendChild(actionButton("Cancel", () => node.close())); + actions.appendChild( + actionButton("Save", () => save(input.value.trim() || null), true), + ); + }); +} + +function openConfig(): void { + dialog("diffr configuration", (node, actions) => { + node.appendChild( + element( + "p", + undefined, + "diffr's config.toml: file tags, hidden files and plugins. It stays in this browser.", + ), + ); + const input = node.appendChild(element("textarea")); + input.spellcheck = false; + input.value = readSetting("config") ?? ""; + input.placeholder = "# diffr's defaults"; + input.setAttribute("aria-label", "config.toml"); + const status = node.appendChild(element("p", "app-dialog-status")); + + const show = (candidate: Engine) => { + candidate.notices.then( + (notices) => (status.textContent = notices.join("\n")), + (error: Error) => (status.textContent = error.message), + ); + }; + + show(currentEngine()); + actions.appendChild(actionButton("Cancel", () => node.close())); + actions.appendChild( + actionButton( + "Save", + () => { + const config = input.value.trim() ? input.value : undefined; + // Tried in its own engine first, so a broken file leaves the page as it was. + const candidate = new Engine(config); + status.textContent = "Checking…"; + candidate.notices.then( + () => { + writeSetting("config", config); + engine?.dispose(); + engine = candidate; + node.close(); + route(); + }, + (error: Error) => { + candidate.dispose(); + status.textContent = error.message; + }, + ); + }, + true, + ), + ); + }); +} + +window.addEventListener("popstate", route); + +window.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key !== "F2" && event.key !== "F3") return; + event.preventDefault(); + panels.toggle(event.key === "F2" ? "stats" : "engine"); +}); + +route(); diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts new file mode 100644 index 000000000..1d6afda47 --- /dev/null +++ b/apps/diffr-web/src/panels.ts @@ -0,0 +1,149 @@ +/** The diff stats (F2) and engine (F3) panels: a card over the foot of the file tree. */ +import type { Comparison } from "./comparison.js"; +import { engineStats } from "./engine/engine.js"; +import { readSetting, writeSetting } from "./settings.js"; + +type Panel = "stats" | "engine"; + +/** A label, its value, and what the value means. */ +type Row = [label: string, value: string, tooltip?: string]; + +const number = (n: number) => n.toLocaleString("en"); + +const seconds = (ms: number) => `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`; + +const megabytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(0)} MB`; + +export class Panels { + readonly element: HTMLElement; + private readonly open = new Set(); + private frame = 0; + + constructor(private readonly comparison: () => Comparison | undefined) { + this.element = document.createElement("div"); + this.element.className = "app-panels"; + + for (const panel of ["stats", "engine"] as const) + if (readSetting(`panel.${panel}`) === "1") this.open.add(panel); + this.render(); + } + + toggle(panel: Panel): void { + if (this.open.has(panel)) this.open.delete(panel); + else this.open.add(panel); + writeSetting(`panel.${panel}`, this.open.has(panel) ? "1" : undefined); + this.render(); + } + + /** Redraw on the next frame; work updates arrive per file. */ + update(): void { + if (this.frame) return; + this.frame = requestAnimationFrame(() => { + this.frame = 0; + this.render(); + }); + } + + private render(): void { + const comparison = this.comparison(); + this.element.replaceChildren(); + this.element.hidden = !comparison || !this.open.size; + + if (!comparison) return; + + if (this.open.has("stats")) + this.section("Diff stats", "F2", this.stats(comparison)); + + if (this.open.has("engine")) + this.section("Engine", "F3", this.engine(comparison)); + } + + private section(title: string, key: string, rows: Row[]): void { + const section = this.element.appendChild(document.createElement("section")); + const head = section.appendChild(document.createElement("h2")); + head.textContent = title; + head.title = `${key} to close`; + + for (const [label, value, tooltip] of rows) { + const row = section.appendChild(document.createElement("div")); + row.className = "app-panel-row"; + + if (tooltip) row.title = tooltip; + row.appendChild(document.createElement("span")).textContent = label; + row.appendChild(document.createElement("span")).textContent = value; + } + } + + private stats(comparison: Comparison): Row[] { + const { work } = comparison; + const counts = comparison.counts; + + const rows: Row[] = [ + ["Files", number(comparison.fileCount)], + [ + "Additions", + number(counts.added), + "diffr's counts once every file is diffed; GitHub's until then", + ], + ["Deletions", number(counts.removed)], + [ + "Diffed by diffr", + `${number(work.diffed)} / ${number(comparison.fileCount)}`, + "Files near the screen go first; hidden files last", + ], + ]; + + if (work.hidden) + rows.push([ + "Hidden by diffr", + number(work.hidden), + "Generated, vendored and lock files, folded until opened", + ]); + + if (work.failed) rows.push(["Failed", number(work.failed)]); + + return rows; + } + + private engine(comparison: Comparison): Row[] { + const stats = engineStats(); + const { timing, work } = comparison; + const at = (ms?: number) => (ms === undefined ? "…" : seconds(ms)); + + const rows: Row[] = [ + [ + "Workers", + `${stats.workers} of ${stats.maxWorkers}`, + "Workers grow while files queue and shrink after ten idle seconds", + ], + ["Engine download", stats.bytes ? megabytes(stats.bytes) : "…"], + ["Engine compiled", at(stats.compiled)], + [ + "Worker ready", + stats.ready === undefined ? "…" : seconds(stats.ready), + "Loading the module and the configuration in a worker", + ], + ["Files listed", at(timing.listed)], + ["Files previewed", at(timing.previewed)], + ["First diff", at(timing.firstDiff)], + ["All diffed", at(timing.done)], + ["Fetching", number(work.fetching)], + ["Diffing", number(work.diffing)], + [ + "Engine time", + work.diffed ? seconds(work.diffMs) : "…", + "Time diffr spent across all workers", + ], + ]; + + if (work.slowest) + rows.push(["Slowest", seconds(work.slowest.ms), work.slowest.path]); + rows.push([ + "Memory", + stats.memory ? megabytes(stats.memory) : "…", + "The largest wasm memory a worker holds; it never shrinks", + ]); + + return rows; + } +} diff --git a/apps/diffr-web/src/protocol.ts b/apps/diffr-web/src/protocol.ts new file mode 100644 index 000000000..7176810fe --- /dev/null +++ b/apps/diffr-web/src/protocol.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// What the forked review code imported from Review Desktop's generated +// `reviewProtocol.ts`: diffr's wire types and the review contracts it uses. +// Only types come from the contract module, so its zod validators stay out +// of the page. +import type { + StructuralChanges, + StructuralLineCounts, +} from "@dev.fast/diffr/src/contract.ts"; + +export type { + StructuralPairing, + StructuralProblem, + StructuralPos, + StructuralSpan, + StructuralVisibility, + StructuralRegion, + StructuralSyntaxSpan, + StructuralSource, + StructuralLineCounts, + StructuralStats, + StructuralDiff, + StructuralFileRef, + StructuralFileChange, + StructuralFileStatus, + StructuralDiffEvent, +} from "@dev.fast/diffr/src/contract.ts"; + +export { structuralRows } from "@dev.fast/review-protocol/src/source-alignment.ts"; + +export function structuralChangeCounts( + changes: StructuralChanges, +): StructuralLineCounts { + const count = (ranges: [number, number][]) => + ranges.reduce((sum, [start, end]) => sum + end - start, 0); + + return { added: count(changes.head), removed: count(changes.base) }; +} + +/** One changed file, as the file tree and headers name it. */ +export interface ReviewDiffFileWire { + path: string; + previousPath?: string; + status: "added" | "modified" | "deleted" | "renamed" | "unchanged"; + additions: number; + deletions: number; + /** The file's contents are binary. */ + binary?: true; + patch?: string; +} + +export interface ReviewDiffLens { + wholeFiles?: boolean; + id: string; + title: string; + reviewId: string; + version: number; + ranges: readonly { + side: "base" | "head"; + file: string; + fromLine: number; + toLine: number; + }[]; +} + +/** "folded": nothing left to read and nothing marked; diffr folds all of it by default. */ +export type ReviewDiffProgressState = + | "unread" + | "partial" + | "viewed" + | "folded"; + +/** Reader progress is supplied independently of the immutable comparison. */ +export interface ReviewDiffProgressFile { + path: string; + state: ReviewDiffProgressState; + remaining: { additions: number; deletions: number }; + total: { additions: number; deletions: number }; + viewedRanges: ReviewDiffLens["ranges"]; + changedRanges: ReviewDiffLens["ranges"]; + unfoldRanges?: ReviewDiffLens["ranges"]; +} + +export interface ReviewDiffSection { + files?: readonly ReviewDiffProgressFile[]; + id: string; + label: string; + sources: ReviewDiffLens["ranges"]; + state: ReviewDiffProgressState; + total: { additions: number; deletions: number }; + remaining: { additions: number; deletions: number }; +} + +export interface ReviewDiffProgress { + sections?: readonly ReviewDiffSection[]; + files: readonly ReviewDiffProgressFile[]; + /** Only present while applying a new viewed action, to reset affected fold overrides. */ + changedPaths?: readonly string[]; +} + +/** Marks lines viewed or unviewed; resolves once the new progress is applied. */ +export type SetViewed = ( + ranges: ReviewDiffLens["ranges"], + viewed: boolean, +) => Promise; + +/** What the forked view reads from Review Desktop's view spec. */ +export interface ReviewDiffViewSpec { + /** Embed the same diff renderer in the review document. */ + document?: { + heightMode: "capped" | "content"; + onDidChangeHeight(height: number): void; + onDidFocus?: () => void; + onDidOpen?: () => void; + }; + /** Mark a structural scope using the same persisted line coverage as file marks. */ + onSetViewed?: ( + ranges: ReviewDiffLens["ranges"], + viewed: boolean, + ) => void | Promise; +} diff --git a/apps/diffr-web/src/review/browser/media/review.css b/apps/diffr-web/src/review/browser/media/review.css new file mode 100644 index 000000000..8fcaa829d --- /dev/null +++ b/apps/diffr-web/src/review/browser/media/review.css @@ -0,0 +1,1651 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Wherever the workbench root shows around the parts, it reads as the editor + rather than the stock WORKBENCH_BACKGROUND (#252526 on dark). */ +.monaco-workbench.review-workbench { + background: var(--vscode-editor-background); +} + +/* Monaco's scrollbars take the canvas pill (packages/review/app/src/styles.css). + The slider fills its 10px lane (the scrollable element's default, and the + editor.scrollbar.* sizes in reviewConfigurationDefaults.ts), and a transparent + border insets the paint to a 6px rounded pill, the same construction as the + canvas's ::-webkit-scrollbar-thumb. The stock hover and active rules reset + `background-clip` through their shorthand, which this rule outranks. */ +.monaco-workbench.review-workbench + .monaco-scrollable-element + > .scrollbar + > .slider { + box-sizing: border-box; + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} + +.monaco-workbench.review-workbench .monaco-scrollable-element > .scrollbar { + overflow: hidden; +} + +/* KEEP IN SYNC WITH REVIEW_CHROME_HEIGHT in review/common/reviewChrome.ts. The + overlay must be as tall as the editor tab strip below it, or the window + controls and the navigation arrows stop aligning with the tab pills. */ +.monaco-workbench .part.titlebar.review-chrome-overlay { + position: absolute; + top: 0; + left: 0; + z-index: 100; + width: 100%; + height: 40px; + box-shadow: none; + pointer-events: none; + background: transparent; +} + +.monaco-workbench + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-left, +.monaco-workbench + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-right { + width: max-content !important; + min-width: 0; + flex: 0 0 auto !important; + padding-left: 0 !important; + pointer-events: auto; +} + +/* Windows and Linux draw their window controls on the right. Without this the + right section packs against the left one, and the invisible Windows controls + placeholder sits over the first editor tabs and swallows their clicks. */ +.monaco-workbench:is(.linux, .windows) + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-right { + margin-left: auto; +} + +/* The native macOS stoplights sit over the left container. Make its unused + area draggable, including the space between and around the stoplights. The + navigation control below removes itself from this drag region. */ +.monaco-workbench + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-left { + -webkit-app-region: drag; +} + +.monaco-workbench + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-right { + -webkit-app-region: no-drag; +} + +.monaco-workbench + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-drag-region { + left: var(--review-chrome-left-width, 0px); + right: var(--review-chrome-right-width, 0px); + width: auto; + display: none; + pointer-events: none; +} + +.monaco-workbench.nomaineditorarea + .part.titlebar.review-chrome-overlay + > .review-titlebar-container + > .titlebar-drag-region { + display: block; + pointer-events: auto; + -webkit-app-region: drag; +} + +.monaco-workbench + .part.editor + .editor-group-container + > .title + > .tabs-and-actions-container.review-chrome-tab-strip { + box-sizing: border-box; + padding-left: 6px; + padding-right: 6px; + user-select: none; + -webkit-app-region: drag; +} + +.monaco-workbench.review-tab-dragging + .part.editor + .editor-group-container + > .title + > .tabs-and-actions-container.review-chrome-tab-strip { + -webkit-app-region: no-drag; +} + +.monaco-workbench + .part.editor + .tabs-and-actions-container.review-chrome-tab-strip + .tabs-container { + -webkit-app-region: drag; +} + +.monaco-workbench.review-tab-dragging + .part.editor + .tabs-and-actions-container.review-chrome-tab-strip + .tabs-container { + -webkit-app-region: no-drag; +} + +.monaco-workbench + .part.editor + .editor-group-container + > .title + > .tabs-and-actions-container.review-chrome-inset { + padding-left: calc(var(--review-chrome-left-width, 0px) + 6px); + padding-right: calc(var(--review-chrome-right-width, 0px) + 6px); +} + +.monaco-workbench .part.editor .tabs-container > .tab, +.monaco-workbench .part.editor .tabs-container > .tabs-bar-add-tab, +.monaco-workbench .part.editor .tabs-and-actions-container > .editor-actions, +.monaco-workbench + .part.editor + .tabs-and-actions-container + > .editor-layout-actions, +.monaco-workbench + .part.editor + .tabs-and-actions-container + > .monaco-scrollable-element + > .scrollbar { + -webkit-app-region: no-drag; +} + +/* --------------------------------------------------------------------------- + Review file explorer + + The explorer is the leftmost surface whenever it is visible, so the window + controls and the navigation arrows sit over its top row instead of over the + editor tab strip. Two rules follow from that: the explorer reserves one chrome + row for them, and the tab strip drops the traffic-light inset it would + otherwise need. + --------------------------------------------------------------------------- */ + +/* The grid sizes the `.split-view-view` wrapper, not the part inside it, and the + part is a static block. Without this the part grows to the list's content + height, the list never scrolls, and every row renders. */ +.monaco-workbench .part.review-explorer { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--vscode-editor-background); + /* An inset shadow rather than a border, so the tree keeps the full part width + and `ReviewExplorerPart.layout` needs no separator correction. */ + box-shadow: inset -1px 0 0 + var(--vscode-panel-border, var(--vscode-sideBar-border, transparent)); + overflow: hidden; +} + +/* The one chrome row keeps the editor tab-strip background, so the top of the + window reads as a single bar running across the tree and the tabs. The step + from that background down to the tree is the whole divider — deliberately no + bottom rule. + + A 1px rule here would be the only horizontal line at this edge: the editor tab + strip ends at the same y with no border and no shadow, because the Review pill + tabs carry no separators by design (see the pill-tab section below). A line on + just the tree half stops dead at the sash and reads as an unfinished stub. + + `ReviewExplorerPart` writes `--review-chrome-height` on this part and the + workbench layout writes `--review-explorer-width` on the inset tab strip + (never on the workbench root; see `publishReviewChromeInset`), so + REVIEW_CHROME_HEIGHT stays the only source. The fallbacks cover the frame + before the part first lays out. */ +.monaco-workbench .part.review-explorer > .review-explorer-chrome-spacer { + box-sizing: border-box; + height: var(--review-chrome-height, 40px); + flex-shrink: 0; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + user-select: none; + -webkit-app-region: drag; +} + +/* Flex rather than `calc(100% - )`: the row above is the only fixed + height, so the tree takes whatever is left and the height is written once. */ +.monaco-workbench .part.review-explorer > .review-explorer-tree { + flex: 1; + min-height: 0; +} + +/* --------------------------------------------------------------------------- + The file-tree toggle + + The button always occupies its 22px slot, so the titlebar's left cluster keeps + a constant width. That matters because a ResizeObserver on the cluster feeds + `--review-chrome-left-width`, which the editor tab strip pads itself by — a + button that grew from 0 would slide every tab as it appeared. Only opacity and + transform animate here, so nothing reflows. + --------------------------------------------------------------------------- */ + +.monaco-workbench .review-titlebar-file-tree-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: 4px; + border-radius: 5px; + flex-shrink: 0; + color: var(--vscode-icon-foreground); + /* Nothing to toggle until a file or diff tab is active. */ + opacity: 0; + transform: scale(0.8); + pointer-events: none; + /* The cluster around it drags the window; this button must not. */ + -webkit-app-region: no-drag; +} + +.monaco-workbench.review-explorer-available .review-titlebar-file-tree-toggle { + opacity: 1; + transform: none; + pointer-events: auto; + cursor: pointer; +} + +/* Reduced motion gets a straight cut: `monaco-enable-motion` is absent then, + because accessibility/browser/accessibilityService.ts toggles it from + `workbench.reduceMotion`. */ +.monaco-workbench.monaco-enable-motion .review-titlebar-file-tree-toggle { + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.monaco-workbench .review-titlebar-file-tree-toggle:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-workbench .review-titlebar-file-tree-toggle:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* One icon per state, picked here so the titlebar part needs no explorer state. */ +.monaco-workbench + .review-titlebar-file-tree-toggle + > .review-file-tree-icon-open, +.monaco-workbench.review-explorer-visible + .review-titlebar-file-tree-toggle + > .review-file-tree-icon-closed { + display: none; +} + +.monaco-workbench.review-explorer-visible + .review-titlebar-file-tree-toggle + > .review-file-tree-icon-open { + display: block; +} + +/* The tab strip pads itself past the traffic lights and the navigation arrows, + which is correct only while the editor is the leftmost surface. The tree now + covers that chrome, so the strip only has to clear whatever the tree does NOT + cover — subtract the tree's width rather than assuming it covers all of it. + + A flat `6px` would be wrong whenever the left cluster is wider than the tree. + The cluster counter-zooms (see `ReviewTitlebarPart.layout`), so zooming the + window out grows it in CSS pixels until it passes even the tree's 170px + minimum, and it would then sit over the first tab and swallow clicks as window + drags. `max()` keeps the plain 6px gutter in the normal case. */ +.monaco-workbench.review-explorer-visible + .part.editor + .editor-group-container + > .title + > .tabs-and-actions-container.review-chrome-inset { + padding-left: max( + 6px, + calc( + var(--review-chrome-left-width, 0px) + 6px - + var(--review-explorer-width, 0px) + ) + ); +} + +/* --------------------------------------------------------------------------- + Review pill tabs + + The tab row keeps the titlebar background and carries no separators, no + bottom border, and no per-tab top-border indicator. The active tab is a + raised pill. The other tabs are flat. + + Each rule below starts with `body` because it must outrank an upstream rule + of the same class count in multieditortabscontrol.css. + --------------------------------------------------------------------------- */ + +.monaco-workbench.review-workbench { + /* Dark boards are darker than the tray, so the active pill mixes lighter. */ + --review-tab-fill: color-mix( + in srgb, + #ffffff 8%, + var(--vscode-editorGroupHeader-tabsBackground) + ); + --review-tab-border: var(--vscode-editorWidget-border); + --review-tab-hover-fill: color-mix( + in srgb, + #ffffff 4%, + var(--vscode-editorGroupHeader-tabsBackground) + ); + --review-tab-separator: var(--vscode-panel-border); + --review-chrome-font: + "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* Light themes: a board-colored pill. */ +.monaco-workbench.review-workbench.vs, +.monaco-workbench.review-workbench.hc-light { + --review-tab-fill: var(--vscode-editor-background); + --review-tab-border: var(--vscode-panel-border); + --review-tab-hover-fill: color-mix( + in srgb, + #ffffff 40%, + var(--vscode-editorGroupHeader-tabsBackground) + ); +} + +/* 28px pill + 6px above + 6px below = the 40px row. EditorTabsControl reports + the same 40px through EDITOR_TAB_HEIGHT.review. The tab-height variable is + an inline style on `.title`, so this rule redeclares it one level down. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + > .tabs-and-actions-container { + --editor-group-tab-height: 28px; + padding-top: 6px; + padding-bottom: 6px; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title.compact-height + > .tabs-and-actions-container { + --editor-group-tab-height: 20px; +} + +/* No bottom border below the row. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title.title-border-bottom::after, +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + > .tabs-and-actions-container.tabs-border-bottom::after { + display: none; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container { + gap: 4px; +} + +/* Flat by default. `redrawTabBorders` writes the separator as an inline + `border-right`, so only `!important` removes it. + + The pill carries no border at all: a 1px border would leave a 22px content + box inside the 24px tab, and the label line is `--editor-group-tab-height` + tall, so the icon and the name would sit 1px below the center. The active + pill draws its outline as an inset shadow instead, which costs no layout. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab { + border: none !important; + border-radius: 6px; + background-color: transparent; + box-shadow: none; + /* The whole inset. Upstream pads 10px on the left and nothing on the right, + which reads wider than the Paper design. */ + padding-left: 4px; + padding-right: 4px; +} + +/* Upstream shrinking tabs keep the label at its natural width, clip its flex + parent, and paint a gradient over the clipped edge. That treatment assumes + flat tabs. Inside Review's raised pill it becomes a dark block, and changing + only `text-overflow` cannot produce an ellipsis because the label itself + never gets narrower. + + Give the label the pill's available width instead. The browser then draws an + ellipsis only when the text is actually clipped. Remove the upstream + gradient in every tab state; theme rules can still assign its background, + but the pseudo-element has no box to paint. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab:is(.sizing-shrink, .sizing-fixed) + > .monaco-icon-label + > .monaco-icon-label-container { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab:is(.sizing-shrink, .sizing-fixed) + .monaco-icon-label-container::after { + content: none; + display: none; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab.active { + background-color: var(--review-tab-fill); + box-shadow: inset 0 0 0 1px var(--review-tab-border); +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab:hover:not(.active) { + background-color: var(--review-tab-hover-fill); + box-shadow: none; +} + +/* The close button carries three insets of its own: a 28px column, a 16px + button centered in it, and 2px of padding around the glyph. Together they put + the glyph 10px inside the pill while the icon on the left sits at 4px. The + column hugs the glyph instead, so both sides read the same. The vertical + padding stays, so the hit target keeps its height. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab + > .tab-actions, +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab + > .tab-actions + > .monaco-action-bar { + width: 16px; +} + +/* The 28px column also held the gap after the name. Restore it as the 6px the + icon puts between itself and the name. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab + > .tab-actions { + margin-left: 6px; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab + > .tab-actions + .action-label.codicon { + padding: 2px 0; +} + +/* Home carries no close button, but `sizing-fit` still stretches it to a fixed + 120px. That leaves ~30px of dead space on each side of the home icon and the + name, which reads as an off-center label. The pill hugs them instead. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab.sticky { + width: fit-content; +} + +/* Home is the one sticky tab, so it is always the last one before the review + tabs. A rule after it separates the two groups. `redrawTabBorders` draws that + rule as an inline border-right, which the flat rule above removes, so the + pseudo-element below draws it instead. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab.sticky:not(:last-child) { + margin-right: 8px; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab.sticky:not(:last-child)::after { + content: ""; + position: absolute; + right: -7px; + top: 4px; + bottom: 4px; + width: 1px; + pointer-events: none; + background-color: var(--review-tab-separator); +} + +.monaco-workbench + .part.titlebar.review-chrome-overlay + .review-titlebar-container + .window-controls-container.review-traffic-light-inset { + flex-shrink: 0; + /* macOS moves the traffic lights right as the zoom makes the row taller. + The doubled class outranks upstream's fixed 70px. */ + width: calc(50px + 20px * var(--zoom-factor, 1)); + -webkit-app-region: drag; +} + +/* The navigation control sits in .titlebar-left, after the traffic lights. + titlebarpart.css scopes every .command-center rule to `.titlebar-center > + .window-title >`, so the rules below restate the ones that still apply here. + The margin is the gap from the traffic lights. It is also the only left + padding in macOS fullscreen, where the inset collapses to zero. */ +.review-titlebar-container .review-titlebar-navigation { + display: flex; + align-items: center; + flex: 0 0 auto; + font-size: 12px; + margin-left: 8px; +} + +.review-titlebar-container .review-application-menu { + display: flex; + align-items: center; + justify-content: center; + align-self: center; + width: 30px; + height: 28px; + margin-left: 6px; + padding: 0; + border: 0; + border-radius: 4px; + color: inherit; + background: transparent; + cursor: pointer; + -webkit-app-region: no-drag; +} + +.review-titlebar-container .review-application-menu:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.review-titlebar-container .review-application-menu:focus-visible, +.review-titlebar-container .window-controls-container > button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -2px; +} + +.review-titlebar-container .window-controls-container > button { + padding: 0; + border: 0; + background: transparent; + -webkit-app-region: no-drag; +} + +/* Equal space on each side of the arrows. + + `CodeWindow.updateWindowControls` moves the traffic lights itself: it calls + `setWindowButtonPosition({ x: floor((REVIEW_CHROME_HEIGHT - buttonHeight) / 2) + 1 })`. + At 40px with the 14px macOS Tahoe button that is x=14, and three 14px buttons + on the 20px macOS pitch end at 68. Change REVIEW_CHROME_HEIGHT and that + number moves. + + The arrow glyph carries 2px of side bearing inside its box, so the back arrow + starts drawing 19px after the last light. On the other side the tab strip + adds 6px of its own (`.review-chrome-inset`), so 8px here lands the Home tab + 19px after the forward arrow draws. */ +.monaco-workbench + .part.titlebar.review-chrome-overlay + .review-titlebar-navigation { + margin-left: 12px; + margin-right: 8px; +} + +/* Without no-drag the full-size .titlebar-drag-region takes the clicks and the + arrows stop working. */ +.review-titlebar-container .review-titlebar-navigation > .command-center { + z-index: 2500; + -webkit-app-region: no-drag; +} + +.review-titlebar-container .review-titlebar-navigation > .command-center.hide { + visibility: hidden; +} + +.review-titlebar-container + .review-titlebar-navigation + > .command-center + > .monaco-toolbar + > .monaco-action-bar + > .actions-container + > .action-item + > .action-label { + color: inherit; +} + +.review-canvas-part, +.review-canvas-part > .review-canvas-container, +.review-canvas-part .review-canvas-host { + width: 100%; + height: 100%; +} + +/* Whiteboard: every label on the tray is set in the same mono face as the + canvas chrome. The faces ship with the canvas stylesheet, whose @font-face + rules are hoisted out of its @scope so they apply document-wide. */ +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab + .monaco-icon-label { + font-family: var(--review-chrome-font); + font-size: 12px; +} + +body + .monaco-workbench.review-workbench + .part.editor + > .content + .editor-group-container + > .title + .tabs-container + > .tab.active + .monaco-icon-label { + font-weight: 600; +} + +/* Multi-diff headers print a full Review path in one label. Keep the file name + visible in narrow panes by placing the ellipsis at the leading edge. */ +.review-path-label .monaco-icon-label-container { + direction: rtl; + text-align: left; +} + +.review-path-label .label-name { + unicode-bidi: plaintext; +} + +.review-multidiff-counts { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 4px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.review-multidiff-counts[hidden] { + display: none; +} + +.review-multidiff-additions { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.review-multidiff-deletions { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.review-counts-folded .review-multidiff-additions { + color: var(--vscode-descriptionForeground); +} + +.review-multidiff-open-container { + display: inline-flex; + align-items: center; + margin: 0 3px; +} + +.review-multidiff-open-container[hidden] { + display: none; +} + +.review-multidiff-open.monaco-button { + display: inline-flex; + align-items: center; + gap: 6px; + width: auto; + min-width: 0; + height: 24px; + line-height: 22px; + padding: 0 9px; + border: 1px solid + color-mix(in srgb, var(--vscode-icon-foreground) 20%, transparent); + border-radius: 5px; + background: color-mix(in srgb, var(--vscode-icon-foreground) 5%, transparent); + color: var(--vscode-icon-foreground); + font-size: 12px; + white-space: nowrap; +} + +.review-multidiff-open.monaco-button .codicon[class*="codicon-"] { + margin: 0; + font-size: 13px; +} + +.review-multidiff-open.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.review-files-editor, +.review-files-editor-tree, +.review-files-editor-diffs { + min-width: 0; + min-height: 0; + height: 100%; +} + +.review-files-editor { + width: 100%; + overflow: hidden; + background: var(--vscode-editor-background); +} + +/* The sidebar is one tray from the progress line to the last file: the tree + paints no ground of its own and inherits the tray behind it (see the + "Sidebar tone" board). Hover and selection come from the theme's list + colors, the well and the marker tint. */ +.review-files-editor-tree { + border-right: 1px solid var(--vscode-sideBar-border, transparent); +} + +.review-changed-files { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + font-family: var(--review-chrome-font, system-ui, sans-serif); +} + +.review-changed-files-tree { + flex: 1 1 auto; + min-height: 0; +} + +.review-changed-files-row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding-right: 8px; +} + +.review-changed-files-icon { + flex: 0 0 16px; + width: 16px; + color: var(--vscode-icon-foreground); + text-align: center; +} + +.review-changed-files-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-changed-files-folder .review-changed-files-label { + margin-left: 7px; + font-weight: 600; +} + +.review-files-editor-diffs { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--vscode-multiDiffEditor-background); +} + +.review-files-editor-diffs + .monaco-component.multiDiffEditor + .multiDiffEntry + > .header + > .header-content { + margin-top: 0; +} + +.review-files-editor-diffs + .monaco-component.multiDiffEditor + .multiDiffEntry + > .header + > .header-content + .file-path + .title { + font-size: 12px; +} + +.review-files-editor-diffs + .monaco-component.multiDiffEditor + .multiDiffEntry + > .header + > .header-content + > .actions { + padding: 0; +} + +.review-files-editor-diffs + .multiDiffEntry + > .header + > .header-content + > .actions.has-no-actions { + display: none; +} + +.review-canvas-part > .review-canvas-container { + display: grid; + grid-template-rows: minmax(0, 1fr); + overflow: hidden; +} + +.review-canvas-part .review-canvas-host { + position: relative; + min-height: 0; + overflow: hidden; + background: var(--vscode-editor-background); + -webkit-user-select: text; + user-select: text; +} + +/* Hosts fixedOverflowWidgets (hover, definition) for the inline peek editors. + Lives in the workbench container — inside .monaco-workbench so theme + variables apply, outside .review-canvas-root whose container-query + containment would re-anchor and clip position: fixed widgets. The widgets + position themselves against the viewport; z-index matches the workbench's + other overflow-widget hosts (scm, keybindings editor). */ +.review-overflow-widgets { + position: fixed; + top: 0; + left: 0; + z-index: 5000; +} + +/* The host carries tabindex="-1" so the canvas app can focus it programmatically + (desktop-entry's mount handle calls container.focus()). It is a document + surface rather than a control, and it is not tab-reachable, so suppress the + workbench-wide `[tabindex="-1"]:focus` ring that would otherwise trace the + whole pane -- the same treatment upstream gives editor group containers. */ +.monaco-workbench .review-canvas-part .review-canvas-host:focus { + outline: none; +} + +.review-reveal-line { + background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent); + border-left: 2px solid var(--vscode-panel-border); +} + +/* Give commenting and folding separate hit targets in the Files view. */ +.review-files-editor + .monaco-editor + .margin-view-overlays + .cldr:is( + .codicon-folding-expanded, + .codicon-folding-collapsed, + .codicon-folding-manual-expanded, + .codicon-folding-manual-collapsed + ) { + margin-left: 30px; + width: 20px !important; +} + +.review-files-editor-diffs { + position: relative; +} +.review-files-editor-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-sideBar-border, transparent); + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.review-files-editor-summary[hidden] { + display: none; +} + +.review-files-editor-summary-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.review-files-editor-summary-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.review-files-editor-summary-bar { + display: inline-flex; + gap: 1px; +} + +.review-files-editor-summary-block { + width: 8px; + height: 8px; + background: var(--vscode-editorWidget-border); +} + +.review-files-editor-summary-block.added { + background: var(--vscode-gitDecoration-addedResourceForeground); +} + +.review-files-editor-summary-block.removed { + background: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.review-multidiff-note { + padding: 0 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.review-multidiff-note[hidden] { + display: none; +} + +.review-structural-label-inline { + color: var(--vscode-editor-foldPlaceholderForeground); + font-style: italic; +} + +.review-structural-label-zone { + background-color: var(--vscode-editor-foldBackground); + color: var(--vscode-editor-foldPlaceholderForeground); +} + +.review-structural-label-zone pre { + margin: 0; + padding-left: 4ch; + font: inherit; + white-space: pre; +} + +.review-structural-stream-status:not([hidden]) { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); +} +.review-structural-stream-status.loading::before { + content: ""; + width: 14px; + height: 14px; + flex-shrink: 0; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: review-structural-loading 1s linear infinite; +} +@keyframes review-structural-loading { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .review-structural-stream-status.loading::before { + animation: none; + } +} +.review-counts-viewed { + opacity: 0.45; +} + +/* The viewed box: one look for the canvas lens rows and the diff headers. + The canvas mounts into this document, so inside it the canvas tokens win; + elsewhere the box falls back to the theme's own colors. */ +.review-viewed-check { + --review-check-ink: var(--ink, var(--vscode-editor-foreground)); + --review-check-board: var(--surface, var(--vscode-editor-background)); + --review-check-line: var( + --rule-soft, + var(--vscode-editorWidget-border, var(--vscode-descriptionForeground)) + ); + --review-check-marker: var(--accent, var(--vscode-button-background)); + --review-check-on-marker: var( + --on-accent, + var(--vscode-button-foreground, #ffffff) + ); + position: relative; + display: inline-block; + flex-shrink: 0; + box-sizing: border-box; + width: 14px; + height: 14px; + margin: 0; + padding: 0; + border: 1px solid var(--review-check-line); + border-radius: 3px; + background: var(--review-check-board); + cursor: pointer; +} +/* The headers give the box the 32px column their old 16px box had. */ +.review-multidiff-open-container + .review-viewed-check, +.review-diff-group > .review-viewed-check { + margin: 0 9px; +} +.review-viewed-check:hover:not(:disabled) { + border-color: var(--review-check-ink); +} +.review-viewed-check:focus-visible { + outline: 1px solid var(--review-check-marker); + outline-offset: 1px; +} +.review-viewed-check:disabled { + cursor: default; + opacity: 0.4; +} +/* Nothing to view: the box hides but keeps its slot, so columns stay aligned. */ +.review-viewed-check.is-empty { + visibility: hidden; +} +/* File headers without a viewed action leave Open file at the trailing edge. */ +.review-multidiff-open-container + .review-viewed-check.is-empty { + display: none; +} +.review-viewed-check[aria-checked="mixed"]::after { + content: ""; + position: absolute; + top: 5.25px; + left: 3px; + width: 6px; + height: 1.5px; + border-radius: 1px; + background: var(--review-check-ink); +} +.review-viewed-check[aria-checked="true"] { + border-color: var(--review-check-marker); + background: var(--review-check-marker); +} +.review-viewed-check[aria-checked="true"]::after { + content: ""; + position: absolute; + inset: 0; + background: var(--review-check-on-marker); + -webkit-mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; +} + +/* The Whiteboard tooltip (ReviewTooltip): a raised card in the page's own ink, + 11/16 mono. Scoped by its `review-tooltip` class so no other hover changes. + Colors live in these variables, one block per theme. */ +.workbench-hover-container:has(> .review-tooltip) { + --review-tooltip-bg: var(--vscode-editorWidget-background, #1a1f29); + --review-tooltip-fg: var(--vscode-editor-foreground, #eef0f4); + --review-tooltip-fg-muted: var(--vscode-descriptionForeground, #9199a8); + --review-tooltip-border: var(--vscode-editorWidget-border, #39404d); + --review-tooltip-shadow: 0 4px 12px rgba(0, 0, 0, 0.45); +} +.monaco-workbench.vs .workbench-hover-container:has(> .review-tooltip), +.monaco-workbench.hc-light .workbench-hover-container:has(> .review-tooltip) { + --review-tooltip-bg: var(--vscode-editor-background, #ffffff); + --review-tooltip-fg: var(--vscode-editor-foreground, #15181e); + --review-tooltip-fg-muted: var(--vscode-descriptionForeground, #5d6472); + --review-tooltip-border: var(--vscode-panel-border, #d8dbe1); + --review-tooltip-shadow: 0 4px 12px rgba(21, 24, 30, 0.1); +} +.monaco-hover.workbench-hover.review-tooltip { + max-width: 240px; + border: 1px solid var(--review-tooltip-border); + border-radius: 4px; + background: var(--review-tooltip-bg); + color: var(--review-tooltip-fg); + box-shadow: var(--review-tooltip-shadow); + font: 11px/16px + var(--review-chrome-font, "Geist Mono", ui-monospace, monospace); +} +.monaco-hover.workbench-hover.review-tooltip .hover-contents { + padding: 4px 8px; +} +.review-tooltip-label, +.review-tooltip-detail { + display: block; +} +.review-tooltip-detail { + color: var(--review-tooltip-fg-muted); +} +.workbench-hover-container:has(> .review-tooltip) + > .workbench-hover-pointer::after { + background-color: var(--review-tooltip-bg); + border-right: 1px solid var(--review-tooltip-border); + border-bottom: 1px solid var(--review-tooltip-border); +} +.review-tree-counts { + display: inline-flex; + flex-shrink: 0; + gap: 6px; + margin-left: auto; + padding-left: 8px; + padding-right: 8px; + font: 11px/22px var(--review-chrome-font, monospace); + font-variant-numeric: tabular-nums; +} +.review-tree-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} +.review-tree-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +/* One disclosure glyph. The canvas draws it as an inline SVG (DisclosureChevron); + the workbench trees and the multi-diff headers use codicons, so their glyph is + swapped for the same 12px stroke path through a mask. The tree sets the + twistie's left padding to the row's indent; stock sizing is border-box, so + past the first level the padding eats the whole 16px box and the glyph + lands on the label. Content-box keeps the 16px glyph slot beside the + indent at every depth, so the chevron sits the same distance from its + label as a file mark does from its name. The file header button and the + section header toggle are keyed on their codicon name, down = open. */ +.review-workbench .monaco-tl-twistie.codicon-tree-item-expanded { + box-sizing: content-box; + width: 16px; + padding-right: 0; + justify-content: center; +} +.review-workbench .monaco-tl-twistie.codicon-tree-item-expanded::before, +.review-workbench .collapse-button .codicon-chevron-down::before, +.review-workbench .collapse-button .codicon-chevron-right::before, +.review-workbench .review-diff-group-toggle .codicon-chevron-down::before, +.review-workbench .review-diff-group-toggle .codicon-chevron-right::before { + content: ""; + display: block; + flex: none; + width: 12px; + height: 12px; + background: currentColor; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; + transform: rotate(90deg); + transition: transform 120ms ease; +} +.review-workbench + .monaco-tl-twistie.collapsed.codicon-tree-item-expanded::before, +.review-workbench .collapse-button .codicon-chevron-right::before, +.review-workbench .review-diff-group-toggle .codicon-chevron-right::before { + transform: rotate(0deg); +} + +/* File marks in the changed-files tree: status is one stroke beside the name, + never a boxed codicon. Plus, minus, dot, arrow, a ghost file for referenced + context, three dots while loading, a bang on error; viewed replaces the mark + with a check. Colors come from the theme's gitDecoration foregrounds (add, + del, warn amber, ink-muted). Folder rows keep an empty slot and paint nothing. */ +.review-workbench { + --review-mark-plus: url("data:image/svg+xml;utf8,"); + --review-mark-minus: url("data:image/svg+xml;utf8,"); + --review-mark-dot: url("data:image/svg+xml;utf8,"); + --review-mark-arrow: url("data:image/svg+xml;utf8,"); + --review-mark-file: url("data:image/svg+xml;utf8,"); + --review-mark-check: url("data:image/svg+xml;utf8,"); + --review-mark-fold: url("data:image/svg+xml;utf8,"); + --review-mark-dots: url("data:image/svg+xml;utf8,"); + --review-mark-bang: url("data:image/svg+xml;utf8,"); +} +.review-workbench + .review-changed-files-icon[class*="review-changed-files-icon-"]::before, +.review-workbench .review-changed-files-icon.codicon-loading::before, +.review-workbench .review-changed-files-icon.codicon-error::before, +.review-workbench .review-file-viewed .review-changed-files-icon::before, +.review-workbench .review-file-folded .review-changed-files-icon::before { + content: ""; + display: block; + width: 16px; + height: 16px; + background: var(--review-mark-color, currentColor); + mask: var(--review-mark) center / 16px 16px no-repeat; + animation: none !important; +} +.review-workbench .review-changed-files-icon:not([hidden]) { + display: flex; + align-items: center; + justify-content: center; +} +.review-workbench .review-changed-files-icon-added { + --review-mark: var(--review-mark-plus); + --review-mark-color: var(--vscode-gitDecoration-addedResourceForeground); +} +.review-workbench .review-changed-files-icon-deleted { + --review-mark: var(--review-mark-minus); + --review-mark-color: var(--vscode-gitDecoration-deletedResourceForeground); +} +.review-workbench .review-changed-files-icon-modified { + --review-mark: var(--review-mark-dot); + --review-mark-color: var(--vscode-gitDecoration-modifiedResourceForeground); +} +.review-workbench .review-changed-files-icon-renamed { + --review-mark: var(--review-mark-arrow); + --review-mark-color: var(--vscode-gitDecoration-renamedResourceForeground); +} +.review-workbench .review-changed-files-icon-unchanged { + --review-mark: var(--review-mark-file); + --review-mark-color: var(--vscode-disabledForeground); +} +.review-workbench .review-changed-files-icon.codicon-loading { + --review-mark: var(--review-mark-dots); + --review-mark-color: var(--vscode-descriptionForeground); +} +.review-workbench .review-changed-files-icon.codicon-error { + --review-mark: var(--review-mark-bang); + --review-mark-color: var(--vscode-gitDecoration-deletedResourceForeground); +} +.review-workbench .review-file-viewed { + opacity: 1; + color: var(--vscode-descriptionForeground); +} +.review-workbench .review-file-viewed .review-changed-files-icon { + --review-mark: var(--review-mark-check); + --review-mark-color: var(--vscode-disabledForeground); +} +/* Folded by default counts as done: the viewed treatment, with a fold mark. */ +.review-workbench .review-file-folded { + opacity: 1; + color: var(--vscode-descriptionForeground); +} +.review-workbench .review-file-folded .review-changed-files-icon { + --review-mark: var(--review-mark-fold); + --review-mark-color: var(--vscode-disabledForeground); +} + +/* Folds get out of the way: ghost ink on the code's own background, in the + chrome font. The theme sets the unchangedRegion background, foreground and + shadow. The row keeps Monaco's 24px height: its view zone and the handle + transforms assume it, and a shorter row rides up over the previous line. */ +.review-workbench .monaco-editor .diff-hidden-lines .center { + font: 11px/24px var(--review-chrome-font, monospace); +} +.review-workbench .monaco-editor .diff-hidden-lines .center:hover { + color: var(--vscode-descriptionForeground); +} + +/* Scope chevrons, rails and braces in structural diffs. The marker blue is + fixed because some themes make focusBorder transparent. */ +.review-workbench .monaco-editor { + --review-scope-marker: #2b55e6; +} +.review-workbench .monaco-editor:is(.vs-dark, .hc-black) { + --review-scope-marker: #5b7cff; +} +.review-workbench .monaco-editor .review-fold-chevron { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.45; +} +.review-workbench .monaco-editor .review-fold-chevron.is-collapsed { + opacity: 0.7; +} +.review-workbench .monaco-editor .review-fold-chevron.is-target { + opacity: 1; + color: var(--review-scope-marker); +} +/* Keep the full gutter hit target; only the glyph needs less visual weight. */ +.review-workbench .monaco-editor .review-fold-chevron.codicon, +.review-workbench .monaco-editor .diff-fold-chevron.codicon, +.review-workbench .monaco-editor .diff-fold-chevron .codicon { + font-size: 12px; +} +.review-workbench .monaco-editor.review-fold-rail .view-lines { + cursor: pointer; +} +.review-workbench .monaco-editor .review-scope-rails { + z-index: 1; + overflow: hidden; + pointer-events: none; +} +.review-workbench .monaco-editor .review-scope-rail { + position: absolute; + width: 7px; + transform: translateX(-3px); + pointer-events: auto; + cursor: pointer; +} +.review-workbench .monaco-editor .review-scope-rail::before { + content: ""; + position: absolute; + left: 3px; + height: 100%; + border-left: 1px solid var(--vscode-editorIndentGuide-background1); +} +.review-workbench .monaco-editor .review-scope-rail.is-active::before { + border-left-color: var(--review-scope-marker); +} +.review-workbench .monaco-editor .review-scope-rail.is-target::before { + border-left-width: 2px; +} +.review-workbench .monaco-editor .review-scope-brace { + color: var(--review-scope-marker) !important; +} +.review-workbench .monaco-editor .review-scope-brace.is-target { + color: var(--vscode-editor-foreground) !important; + background: color-mix(in srgb, var(--review-scope-marker) 30%, transparent); + outline: 1px solid var(--review-scope-marker); + border-radius: 2px; +} +.review-workbench .monaco-editor .review-fold-hint { + margin-left: 2ch; + color: var(--review-scope-marker); + font-family: var(--review-chrome-font, inherit); + font-size: 11px; +} + +/* A scope folded into its header line: `opener { ⋯ N lines }`. */ +.review-workbench .monaco-editor .review-fold-pill { + margin: 0 0.5ch; + padding: 0 0.6ch; + border: 1px solid var(--vscode-editorIndentGuide-activeBackground1); + border-radius: 3px; + color: var(--vscode-descriptionForeground); + font-size: 0.92em; + cursor: pointer; +} +.review-workbench .monaco-editor .review-fold-pill.is-target { + border-color: var(--review-scope-marker); + color: var(--vscode-editor-foreground); +} +.review-workbench .monaco-editor .review-fold-closer { + color: var(--vscode-editor-foreground); + cursor: pointer; +} + +/* A fold inside removed code, drawn as a row of the removed block. */ +.review-workbench .monaco-editor .diff-fold-placeholder { + color: var(--vscode-descriptionForeground) !important; + opacity: 0.8; +} +.review-workbench .monaco-editor .diff-fold-placeholder-hit:hover { + background: color-mix( + in srgb, + var(--vscode-editor-foreground) 4%, + transparent + ); +} + +/* Align fold labels with the hidden code's indent. */ +.review-workbench + .review-files-editor + .monaco-editor + .diff-hidden-lines:not(.kind-inserted):not(.kind-removed) + .center { + background: transparent; + box-shadow: none; +} +.review-workbench + .review-files-editor + .monaco-editor + .diff-hidden-lines + .diff-hidden-lines-label { + flex: 1; + justify-content: flex-start !important; + color: var(--vscode-descriptionForeground); +} +.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal { + appearance: none; + border: 0; + background: transparent; + padding: 0; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal::before { + content: "\22EF\00a0"; +} +.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal:hover { + color: var(--review-scope-marker); +} +.review-workbench + .monaco-editor + .diff-hidden-lines + .diff-fold-reveal:focus-visible { + outline: 1px solid var(--review-scope-marker); + outline-offset: 2px; +} + +/* The canvas stylesheet resets every element to border-box, and the Diff + view's editors live inside the canvas. Monaco sizes the fold's drag + handles as content-box (4px tall plus 6px of transparent borders) and + offsets the whole widget by that sum, so a border-box handle is 4px short + and the fold row rides up over the previous line. */ +.monaco-editor .diff-hidden-lines .top, +.monaco-editor .diff-hidden-lines .bottom { + box-sizing: content-box; +} +.review-changed-files-label { + flex: 1; + min-width: 0; +} +/* Left inset 10px and title gap 5px mirror the file header below it (5px + padding + 5px collapse-button margin, then 5px to the path), so the + section chevron and the file chevron sit on one vertical line. */ +.review-diff-group { + height: 48px; + min-height: 48px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px 4px 10px; + border-top: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} +.review-diff-group[hidden] { + display: none; +} +.review-diff-group-title { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.review-diff-group-counts { + margin-left: auto; + font: 11px var(--vscode-editor-font-family, monospace); + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} +.review-diff-group.is-viewed > span { + opacity: 0.45; +} + +.review-diff-group-toggle { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + border: 0; + padding: 0; + color: inherit; + background: transparent; + cursor: pointer; + text-align: left; +} +.review-diff-group-toggle:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +/* The surrounding navigation owns the top edge of the diff pane. */ +.multi-diff-sticky-section .review-diff-group, +.review-files-editor-diffs .multiDiffEntry:first-child > .review-diff-group { + border-top: 0; +} + +/* Placeholder rows share the scope chevron lane after the line numbers. */ +.review-workbench .monaco-editor .diff-fold-chevron { + appearance: none; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 100%; + border: 0; + padding: 0; + background: transparent; + color: var(--vscode-descriptionForeground); + opacity: 0.7; + cursor: pointer; +} +.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-chevron, +.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-reveal, +.review-workbench .monaco-editor .diff-fold-chevron.is-target { + color: var(--review-scope-marker); + opacity: 1; +} +.review-workbench .monaco-editor .diff-fold-chevron:focus-visible { + outline: 1px solid var(--review-scope-marker); + outline-offset: -2px; +} +.review-workbench .monaco-editor .diff-fold-placeholder.is-target { + color: var(--review-scope-marker) !important; + opacity: 1; +} + +.review-workbench + .monaco-editor + .diff-hidden-lines + .diff-fold-chevron + .codicon { + color: inherit; +} + +.review-workbench .monaco-editor .review-scope-viewed-control { + display: flex; + align-items: center; + box-sizing: border-box; + gap: 8px; + padding: 0 8px; + border: 1px solid + var(--vscode-widget-border, var(--vscode-editorWidget-border)); + border-radius: 5px; + background: var( + --vscode-editorWidget-background, + var(--vscode-editor-background) + ); + font: 11px/20px var(--monaco-monospace-font); + z-index: 8; +} +.review-workbench .monaco-editor .review-scope-viewed-control > span:empty { + display: none; +} +.review-workbench .monaco-editor .review-scope-viewed-status { + color: var(--vscode-descriptionForeground); +} +.review-workbench + .monaco-editor + .review-scope-viewed-control + .review-viewed-check { + margin-left: 4px; +} +/* Keep the shared 14px glyph while making the padded space around it clickable. */ +.review-workbench + .monaco-editor + .review-scope-viewed-control + .review-viewed-check::before { + content: ""; + position: absolute; + inset: -5px; +} +.review-workbench .monaco-editor .review-scope-viewed-ink, +.review-workbench .monaco-editor .review-scope-viewed-ink span, +.review-workbench .monaco-editor .review-fold-pill.is-viewed { + color: var(--vscode-descriptionForeground) !important; +} +.review-workbench .monaco-editor .review-scope-viewed-tint { + background: color-mix( + in srgb, + var(--vscode-editor-background) 70%, + transparent + ); +} +.review-workbench .monaco-editor .review-fold-pill.is-viewed:not(.is-target) { + opacity: 0.65; +} diff --git a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts new file mode 100644 index 000000000..68ee46a11 --- /dev/null +++ b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts @@ -0,0 +1,462 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from "vs/base/browser/dom.js"; +import type { IListVirtualDelegate } from "vs/base/browser/ui/list/list.js"; +import { RenderIndentGuides } from "vs/base/browser/ui/tree/abstractTree.js"; +import type { + ICompressedTreeElement, + ICompressedTreeNode, +} from "vs/base/browser/ui/tree/compressedObjectTreeModel.js"; +import type { ICompressibleTreeRenderer } from "vs/base/browser/ui/tree/objectTree.js"; +import { + ObjectTreeElementCollapseState, + type ITreeNode, +} from "vs/base/browser/ui/tree/tree.js"; +import { Codicon } from "vs/base/common/codicons.js"; +import { Emitter, type Event } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +import { ThemeIcon } from "vs/base/common/themables.js"; +import { localize } from "vs/nls.js"; +import { IHoverService } from "vs/platform/hover/browser/hover.js"; +import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; +import { WorkbenchCompressibleObjectTree } from "vs/platform/list/browser/listService.js"; +import { registerColor } from "vs/platform/theme/common/colorRegistry.js"; + +import type { + ReviewDiffFileWire, + ReviewDiffProgressFile, +} from "../../protocol.js"; +import { + buildTree, + type ChangedFileElement, + type ChangedTreeElement, +} from "../common/reviewChangedFilesModel.js"; +import { + binarySizeLabel, + isBinaryCounts, + type ReviewFileCounts, +} from "../common/reviewStructuralDiff.js"; +import { reviewBottomScrollPadding } from "./reviewScrollPadding.js"; +import { + REVIEW_COUNTS_PENDING_TOOLTIP, + reviewCountsTooltip, + ReviewTooltip, + type ReviewTooltipHoverService, +} from "./reviewTooltip.js"; + +registerColor( + "gitDecoration.addedResourceForeground", + { + light: "#587c0c", + dark: "#81b88b", + hcDark: "#a1e3ad", + hcLight: "#374e06", + }, + localize("review.gitDecoration.added", "Color for added file resources."), +); +registerColor( + "gitDecoration.modifiedResourceForeground", + { + light: "#895503", + dark: "#E2C08D", + hcDark: "#E2C08D", + hcLight: "#895503", + }, + localize( + "review.gitDecoration.modified", + "Color for modified file resources.", + ), +); +registerColor( + "gitDecoration.deletedResourceForeground", + { + light: "#ad0707", + dark: "#c74e39", + hcDark: "#c74e39", + hcLight: "#ad0707", + }, + localize("review.gitDecoration.deleted", "Color for deleted file resources."), +); +registerColor( + "gitDecoration.renamedResourceForeground", + { + light: "#007100", + dark: "#73C991", + hcDark: "#73C991", + hcLight: "#007100", + }, + localize("review.gitDecoration.renamed", "Color for renamed file resources."), +); + +const CHANGED_FILE_ROW_HEIGHT = 22; + +interface ChangedFilesTreeTemplate { + readonly row: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; + readonly counts: HTMLElement; + readonly countsTooltip: ReviewTooltip; +} + +class ChangedFilesTreeDelegate implements IListVirtualDelegate { + getHeight(): number { + return CHANGED_FILE_ROW_HEIGHT; + } + + getTemplateId(): string { + return ChangedFilesTreeRenderer.TEMPLATE_ID; + } +} + +export class ChangedFilesTreeRenderer implements ICompressibleTreeRenderer< + ChangedTreeElement, + void, + ChangedFilesTreeTemplate +> { + static readonly TEMPLATE_ID = "review.changedFiles.entry"; + readonly templateId = ChangedFilesTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly counts: Map, + private readonly progress: Map, + private readonly states: Map< + string, + { status: "loading" | "error"; message?: string } + >, + private readonly hoverService: ReviewTooltipHoverService, + ) {} + + renderTemplate(container: HTMLElement): ChangedFilesTreeTemplate { + const row = append(container, $(".review-changed-files-row")); + const icon = append(row, $("span.review-changed-files-icon")); + icon.setAttribute("aria-hidden", "true"); + const label = append(row, $("span.review-changed-files-label")); + const counts = append(row, $("span.review-tree-counts")); + return { + row, + icon, + label, + counts, + countsTooltip: new ReviewTooltip(this.hoverService, counts), + }; + } + + renderElement( + node: ITreeNode, + _index: number, + template: ChangedFilesTreeTemplate, + ): void { + this.renderElements([node.element], template); + } + + renderCompressedElements( + node: ITreeNode>, + _index: number, + template: ChangedFilesTreeTemplate, + ): void { + this.renderElements(node.element.elements, template); + } + + disposeTemplate(template: ChangedFilesTreeTemplate): void { + template.countsTooltip.dispose(); + } + + private renderElements( + elements: readonly ChangedTreeElement[], + template: ChangedFilesTreeTemplate, + ): void { + const element = elements[elements.length - 1]; + const isFile = element.kind === "file"; + + template.row.classList.toggle("review-changed-files-folder", !isFile); + const done = isFile + ? this.progress.get(element.file.path)?.state + : undefined; + // Folded files read as done, like viewed ones: greyed, with the reason in place of counts. + template.row.classList.toggle("review-file-viewed", done === "viewed"); + // A binary stays folded: nothing in it can be read, so it looks and counts like a folded file. + template.row.classList.toggle( + "review-file-folded", + done === "folded" || + (isFile && !!element.file.binary && done !== "viewed"), + ); + template.icon.hidden = !isFile; + template.icon.className = isFile + ? `review-changed-files-icon review-changed-files-icon-${element.file.status} ${ThemeIcon.asClassName(fileStatusIcon(element.file.status))}` + : "review-changed-files-icon"; + template.counts.hidden = !isFile; + template.counts.replaceChildren(); + template.countsTooltip.content = undefined; + if (isFile) { + const progress = this.progress.get(element.file.path); + const compact = (n: number) => + new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }) + .format(n) + .toLowerCase(); + const stored = this.counts.get(element.file.path); + const lines = isBinaryCounts(stored) ? undefined : stored; + const additions = progress?.remaining.additions ?? lines?.added; + const deletions = progress?.remaining.deletions ?? lines?.removed; + if (element.file.binary) { + // Where a folded file says "Folded", a binary says its size, or "Binary" until diffr reports it. + const size = isBinaryCounts(stored) + ? binarySizeLabel(stored) + : undefined; + template.counts.textContent = + progress?.state === "viewed" + ? "Viewed" + : progress?.state === "folded" + ? "Folded" + : (size ?? "Binary"); + template.countsTooltip.content = { + label: "No text to show · binary files stay folded", + detail: size, + }; + } else if (element.file.status === "unchanged") + template.counts.textContent = "Unchanged"; + else if (progress?.state === "viewed") + template.counts.textContent = "Viewed"; + else if (progress?.state === "folded") + template.counts.textContent = "Folded"; + else if (additions !== undefined && deletions !== undefined) { + const added = append(template.counts, $("span.review-tree-added")); + added.textContent = `+${compact(additions)}`; + const removed = append(template.counts, $("span.review-tree-removed")); + removed.textContent = `−${compact(deletions)}`; + } + const total = progress?.total ?? { + additions: lines?.added ?? 0, + deletions: lines?.removed ?? 0, + }; + if (!element.file.binary) + template.countsTooltip.content = + element.file.status === "unchanged" + ? { label: "Referenced context; no changed lines" } + : additions === undefined || deletions === undefined + ? REVIEW_COUNTS_PENDING_TOOLTIP + : progress?.state === "folded" + ? { + label: "Folded by default; counted as done", + detail: `+${total.additions} −${total.deletions} total`, + } + : reviewCountsTooltip({ + remaining: { additions, deletions }, + total, + }); + } + const state = isFile ? this.states.get(element.file.path) : undefined; + if (state) + template.icon.className = `review-changed-files-icon codicon codicon-${state.status === "loading" ? "loading codicon-modifier-spin" : "error"}`; + template.row.title = + state?.message ?? (state?.status === "loading" ? "Loading diff…" : ""); + template.row.setAttribute("aria-busy", String(state?.status === "loading")); + template.label.textContent = isFile + ? element.name + : elements.map((item) => item.name).join("/"); + } +} + +/** A Workbench-compressed tree over one Review's changed files. */ +export class ReviewChangedFilesTree extends Disposable { + private readonly _onDidOpenFile = this._register( + new Emitter(), + ); + readonly onDidOpenFile: Event = this._onDidOpenFile.event; + + private readonly tree: WorkbenchCompressibleObjectTree< + ChangedTreeElement, + void + >; + private readonly fileElements = new Map(); + private files: readonly ReviewDiffFileWire[] = []; + private readonly states = new Map< + string, + { status: "loading" | "error"; message?: string } + >(); + private activePath: string | undefined; + private readonly counts = new Map(); + setCounts(path: string, counts: ReviewFileCounts): void { + const previous = this.counts.get(path); + this.counts.set(path, counts); + if (JSON.stringify(previous) !== JSON.stringify(counts)) + this.refreshFile(path); + } + private readonly progress = new Map(); + setProgressFiles(files: readonly ReviewDiffProgressFile[]): void { + const previous = new Map(this.progress); + this.progress.clear(); + for (const file of files) this.progress.set(file.path, file); + for (const path of new Set([...previous.keys(), ...this.progress.keys()])) { + const before = previous.get(path), + after = this.progress.get(path); + if ( + before?.state !== after?.state || + before?.remaining.additions !== after?.remaining.additions || + before?.remaining.deletions !== after?.remaining.deletions || + before?.total.additions !== after?.total.additions || + before?.total.deletions !== after?.total.deletions + ) + this.refreshFile(path); + } + } + + private refreshFile(path: string): void { + // The parameterless rerender only remeasures dynamic heights; these rows + // have fixed heights. Target the model node to refresh its rendered data. + const element = this.fileElements.get(path); + if (element) this.tree.rerender(element); + } + private syncingActiveFile = false; + + constructor( + container: HTMLElement, + @IInstantiationService instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, + ) { + super(); + container.classList.add("review-changed-files"); + const treeContainer = append(container, $(".review-changed-files-tree")); + const renderer = new ChangedFilesTreeRenderer( + this.counts, + this.progress, + this.states, + hoverService, + ); + this.tree = this._register( + instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + "ReviewChangedFiles", + treeContainer, + new ChangedFilesTreeDelegate(), + [renderer], + { + accessibilityProvider: { + getAriaLabel: (element) => + element.kind === "file" ? element.file.path : element.path, + getWidgetAriaLabel: () => + localize("review.changedFiles.ariaLabel", "Changed files"), + }, + alwaysConsumeMouseWheel: false, + horizontalScrolling: false, + paddingBottom: reviewBottomScrollPadding, + identityProvider: { + getId: (element) => + element.kind === "file" + ? element.file.path + : `dir:${element.path}`, + }, + indent: 12, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (element) => element.name, + getCompressedNodeKeyboardNavigationLabel: (elements) => + elements.map((element) => element.name).join("/"), + }, + compressionEnabled: true, + multipleSelectionSupport: false, + openOnSingleClick: true, + renderIndentGuides: RenderIndentGuides.None, + }, + ), + ); + this._register( + this.tree.onDidOpen((event) => { + if (this.syncingActiveFile || event.element?.kind !== "file") return; + this._onDidOpenFile.fire(event.element.file); + }), + ); + this.refresh(); + } + + setFiles(files: readonly ReviewDiffFileWire[]): void { + const selectedElement = this.tree + .getSelection() + .find((element) => element?.kind === "file"); + const selectedPath = + selectedElement?.kind === "file" ? selectedElement.file.path : undefined; + this.files = files; + this.refresh(selectedPath); + } + + setFileState( + path: string, + status: "loading" | "error" | undefined, + message?: string, + ): void { + if (status) this.states.set(path, { status, message }); + else this.states.delete(path); + const element = this.fileElements.get(path); + if (element) this.tree.rerender(element); + } + + setActiveFile(path: string | undefined, reveal = true): void { + if (!reveal && this.activePath === path) return; + this.activePath = path; + this.syncSelection(path, reveal); + } + + layout(height: number, width: number): void { + this.tree.layout(Math.max(0, height), width); + } + + private refresh(selectedPath?: string): void { + const roots = buildTree(this.files); + this.fileElements.clear(); + collectFileElements(roots, this.fileElements); + this.tree.setChildren(null, toTreeElements(roots)); + this.syncSelection(this.activePath ?? selectedPath); + } + + private syncSelection(path: string | undefined, reveal = true): void { + const element = path ? this.fileElements.get(path) : undefined; + this.syncingActiveFile = true; + try { + this.tree.setSelection(element ? [element] : []); + this.tree.setFocus(element ? [element] : []); + if (element && reveal) this.tree.reveal(element); + } finally { + this.syncingActiveFile = false; + } + } +} + +function fileStatusIcon(status: ReviewDiffFileWire["status"]): ThemeIcon { + if (status === "unchanged") return Codicon.file; + if (status === "added") return Codicon.diffAdded; + if (status === "deleted") return Codicon.diffRemoved; + if (status === "renamed") return Codicon.diffRenamed; + return Codicon.diffModified; +} + +function toTreeElements( + elements: readonly ChangedTreeElement[], +): ICompressedTreeElement[] { + return elements.map((element) => + element.kind === "file" + ? { element, incompressible: true } + : { + element, + collapsible: true, + collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, + children: toTreeElements(element.children), + }, + ); +} + +function collectFileElements( + elements: readonly ChangedTreeElement[], + target: Map, +): void { + for (const element of elements) { + if (element.kind === "file") { + target.set(element.file.path, element); + } else { + collectFileElements(element.children, target); + } + } +} diff --git a/apps/diffr-web/src/review/browser/reviewScrollPadding.ts b/apps/diffr-web/src/review/browser/reviewScrollPadding.ts new file mode 100644 index 000000000..f94f0159d --- /dev/null +++ b/apps/diffr-web/src/review/browser/reviewScrollPadding.ts @@ -0,0 +1,4 @@ +import { isMacintosh } from "vs/base/common/platform.js"; + +// Leave room to scroll the last item above an auto-hidden macOS Dock. +export const reviewBottomScrollPadding = isMacintosh ? 128 : 0; diff --git a/apps/diffr-web/src/review/browser/reviewTooltip.ts b/apps/diffr-web/src/review/browser/reviewTooltip.ts new file mode 100644 index 000000000..a56044ac2 --- /dev/null +++ b/apps/diffr-web/src/review/browser/reviewTooltip.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append } from "vs/base/browser/dom.js"; +import type { IHoverWidget } from "vs/base/browser/ui/hover/hover.js"; +import { HoverPosition } from "vs/base/browser/ui/hover/hoverWidget.js"; +import { Disposable, MutableDisposable } from "vs/base/common/lifecycle.js"; +import type { IHoverService } from "vs/platform/hover/browser/hover.js"; + +import type { ReviewDiffProgressState } from "../../protocol.js"; + +/** A label line and an optional fainter second line. */ +export interface ReviewTooltipContent { + readonly label: string; + readonly detail?: string; +} + +export type ReviewTooltipHoverService = Pick; + +/** + * The Whiteboard tooltip: a raised card above its target, shown the moment the + * pointer or keyboard focus lands and gone on leave, click, scroll or key. + * The `review-tooltip` class keeps its look off every other hover. + */ +export class ReviewTooltip extends Disposable { + private readonly hover = this._register( + new MutableDisposable(), + ); + private readonly scroll = this._register(new MutableDisposable()); + private current: ReviewTooltipContent | undefined; + + constructor( + private readonly hoverService: ReviewTooltipHoverService, + private readonly target: HTMLElement, + content?: ReviewTooltipContent, + ) { + super(); + this.current = content; + this._register( + addDisposableListener(target, "mouseenter", () => this.show()), + ); + this._register( + addDisposableListener(target, "focus", () => { + if (target.matches(":focus-visible")) this.show(); + }), + ); + for (const event of [ + "mouseleave", + "blur", + "pointerdown", + "click", + "keydown", + ]) { + this._register(addDisposableListener(target, event, () => this.hide())); + } + } + + set content(content: ReviewTooltipContent | undefined) { + if ( + content?.label === this.current?.label && + content?.detail === this.current?.detail + ) + return; + this.current = content; + if (this.hover.value) this.show(); + } + + private show(): void { + this.hide(); + if (!this.current?.label) return; + const body = $("span.review-tooltip-body"); + appendCounts( + append(body, $("span.review-tooltip-label")), + this.current.label, + ); + if (this.current.detail) + appendCounts( + append(body, $("span.review-tooltip-detail")), + this.current.detail, + ); + this.hover.value = this.hoverService.showInstantHover({ + target: this.target, + content: body, + additionalClasses: ["review-tooltip"], + position: { hoverPosition: HoverPosition.ABOVE }, + appearance: { + compact: true, + showPointer: true, + skipFadeInAnimation: true, + }, + persistence: { hideOnHover: true, hideOnKeyDown: true }, + }); + if (this.hover.value) { + this.scroll.value = addDisposableListener( + this.target.ownerDocument, + "scroll", + () => this.hide(), + true, + ); + } + } + + private hide(): void { + this.hover.clear(); + this.scroll.clear(); + } +} + +const signedPair = (additions: number, deletions: number) => + `+${additions} −${deletions}`; + +const SIGNED_PAIR = /\+([\d.]+[kKM]?) −([\d.]+[kKM]?)/; + +/** Colors each `+N −N` pair like the tree's counts. */ +function appendCounts(parent: HTMLElement, text: string): void { + const match = SIGNED_PAIR.exec(text); + if (!match) { + parent.append(text); + return; + } + parent.append(text.slice(0, match.index)); + append(parent, $("span.review-tree-added")).textContent = `+${match[1]}`; + parent.append(" "); + append(parent, $("span.review-tree-removed")).textContent = `−${match[2]}`; + appendCounts(parent, text.slice(match.index + match[0].length)); +} + +/** What a diff count's tooltip says: what is left, then the whole. */ +export function reviewCountsTooltip(counts: { + readonly remaining: { + readonly additions: number; + readonly deletions: number; + }; + readonly total: { readonly additions: number; readonly deletions: number }; +}): ReviewTooltipContent { + return { + label: `${signedPair(counts.remaining.additions, counts.remaining.deletions)} remaining`, + detail: `of ${signedPair(counts.total.additions, counts.total.deletions)} total`, + }; +} + +/** A count that has no coverage yet: the structural diff's own totals. */ +export function reviewChangesTooltip( + added: number, + removed: number, +): ReviewTooltipContent { + return { label: `${signedPair(added, removed)} changed` }; +} + +export const REVIEW_COUNTS_PENDING_TOOLTIP: ReviewTooltipContent = { + label: "Waiting for structural coverage", +}; + +/** + * The one viewed checkbox: a 14px box whose dash and check are drawn in CSS, + * with an instant tooltip saying what a click does. With nothing to view it + * keeps its slot but hides, so the columns beside it stay aligned. + */ +export class ReviewViewedCheckbox extends Disposable { + readonly element: HTMLButtonElement; + private readonly tooltip: ReviewTooltip; + + constructor( + hoverService: ReviewTooltipHoverService, + ownerDocument: Document, + onToggle: () => void, + ) { + super(); + this.element = ownerDocument.createElement("button"); + this.element.type = "button"; + this.element.className = "review-viewed-check"; + this.element.setAttribute("role", "checkbox"); + this.tooltip = this._register( + new ReviewTooltip(hoverService, this.element), + ); + this._register( + addDisposableListener(this.element, "click", (event) => { + event.stopPropagation(); + onToggle(); + }), + ); + } + + /** `subject` names what the box marks, for screen readers. */ + update( + state: ReviewDiffProgressState | undefined, + subject: string, + empty: boolean, + ): void { + const viewed = state === "viewed"; + this.element.setAttribute( + "aria-checked", + state === "partial" ? "mixed" : String(viewed), + ); + this.element.setAttribute( + "aria-label", + `${viewed ? "Mark unviewed" : "Mark viewed"}: ${subject}`, + ); + this.element.classList.toggle("is-empty", empty); + this.element.disabled = empty; + this.tooltip.content = empty + ? undefined + : { label: reviewViewedTooltip(state) }; + } +} + +export function reviewViewedTooltip( + state: ReviewDiffProgressState | undefined, +): string { + return state === "viewed" + ? "Click to mark as unviewed" + : state === "partial" + ? "Click to mark all as viewed" + : "Click to mark as viewed"; +} diff --git a/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts b/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts new file mode 100644 index 000000000..4cc471592 --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewChangedFilesModel.ts @@ -0,0 +1,96 @@ +import type { ReviewDiffFileWire } from "../../protocol.js"; + +export interface ChangedFileElement { + readonly kind: "file"; + readonly file: ReviewDiffFileWire; + readonly name: string; +} + +export interface ChangedFolderElement { + readonly kind: "folder"; + readonly name: string; + readonly path: string; + readonly children: ChangedTreeElement[]; +} + +export type ChangedTreeElement = ChangedFileElement | ChangedFolderElement; + +interface MutableFolder { + readonly name: string; + readonly path: string; + readonly folders: Map; + readonly files: ChangedFileElement[]; +} + +/** Returns changed files in the same folder-first order that the tree shows. */ +export function orderReviewDiffFiles( + files: readonly ReviewDiffFileWire[], +): readonly ReviewDiffFileWire[] { + const ordered: ReviewDiffFileWire[] = []; + collectFiles(buildTree(files), ordered); + return ordered; +} + +export function buildTree( + files: readonly ReviewDiffFileWire[], +): ChangedTreeElement[] { + const root: MutableFolder = { + name: "", + path: "", + folders: new Map(), + files: [], + }; + + for (const file of files) { + const segments = file.path.split("/").filter(Boolean); + const name = segments.pop() ?? file.path; + let folder = root; + for (const segment of segments) { + const path = folder.path ? `${folder.path}/${segment}` : segment; + let child = folder.folders.get(segment); + if (!child) { + child = { + name: segment, + path, + folders: new Map(), + files: [], + }; + folder.folders.set(segment, child); + } + folder = child; + } + folder.files.push({ kind: "file", file, name }); + } + + return folderChildren(root); +} + +function folderChildren(folder: MutableFolder): ChangedTreeElement[] { + const folders = [...folder.folders.values()] + .map( + (child): ChangedFolderElement => ({ + kind: "folder", + name: child.name, + path: child.path, + children: folderChildren(child), + }), + ) + .sort((left, right) => left.name.localeCompare(right.name)); + const files = [...folder.files].sort((left, right) => + left.name.localeCompare(right.name), + ); + return [...folders, ...files]; +} + +function collectFiles( + elements: readonly ChangedTreeElement[], + target: ReviewDiffFileWire[], +): void { + for (const element of elements) { + if (element.kind === "file") { + target.push(element.file); + } else { + collectFiles(element.children, target); + } + } +} diff --git a/apps/diffr-web/src/review/common/reviewStructuralDiff.ts b/apps/diffr-web/src/review/common/reviewStructuralDiff.ts new file mode 100644 index 000000000..56e666eef --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewStructuralDiff.ts @@ -0,0 +1,582 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { structuralRows } from "../../protocol.js"; +export { structuralRows } from "../../protocol.js"; + +import { structuralChangeCounts } from "../../protocol.js"; +import type { + StructuralPairing, + StructuralFileRef, + StructuralRegion, + StructuralSource, + StructuralDiff, + StructuralLineCounts, +} from "../../protocol.js"; +export type { + StructuralPairing, + StructuralProblem, + StructuralPos, + StructuralSpan, + StructuralVisibility, + StructuralRegion, + StructuralSyntaxSpan, + StructuralSource, + StructuralLineCounts, + StructuralStats, + StructuralDiff, + StructuralFileRef, + StructuralFileChange, +} from "../../protocol.js"; +export type { StructuralDiffEvent as StructuralEvent } from "../../protocol.js"; +export type StructuralLeaf = Extract; +export type StructuralFold = Extract; +export type StructuralTextDiff = Extract; +export type StructuralBinaryDiff = Extract; + +/** What a file's count lane and header show: changed lines, or a binary file's byte sizes. */ +export type ReviewFileCounts = + | StructuralLineCounts + | { + readonly binary: true; + readonly baseSize?: number; + readonly headSize?: number; + }; + +export function isBinaryCounts( + counts: ReviewFileCounts | undefined, +): counts is Extract { + return !!counts && "binary" in counts; +} + +/** A diffr result's counts: its changed lines, or the sizes of a binary file's sides. */ +export function reviewFileCounts(diff: StructuralDiff): ReviewFileCounts { + return diff.type === "text" + ? structuralChangeCounts(diff.structural_changes) + : { binary: true, baseSize: diff.lhs?.size, headSize: diff.rhs?.size }; +} + +/** "9.4 KB", or "66.1 KB → 14.7 KB" when both sides exist; undefined until a size is known. */ +export function binarySizeLabel(counts: { + readonly baseSize?: number; + readonly headSize?: number; +}): string | undefined { + const { baseSize, headSize } = counts; + if (baseSize !== undefined && headSize !== undefined) + return `${formatByteSize(baseSize)} → ${formatByteSize(headSize)}`; + const size = headSize ?? baseSize; + return size === undefined ? undefined : formatByteSize(size); +} + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +/** Review keys a file by its head path, or its base path for a deletion. */ +export function structuralFilePath( + file: StructuralPairing, +): string { + return file.rhs?.path ?? file.lhs!.path; +} + +/** The 0-based, half-open line span a region touches. An end at column 0 does not touch its end line. */ +export function regionLines(region: StructuralRegion): { + start: number; + end: number; +} { + return { + start: region.start.line, + end: region.end.column === 0 ? region.end.line : region.end.line + 1, + }; +} + +/** Preserve the context plugin's scope boundaries for lens projection. */ +export function structuralContextScopes(diff: StructuralTextDiff) { + const scopes = (source: StructuralSource | undefined) => { + const result: [number, number][] = []; + const visit = (region: StructuralRegion) => { + if (region.kind !== "fold") return; + if (region.tags?.includes("context:scope")) { + const { start, end } = regionLines(region); + result.push([start, end]); + } + region.children.forEach(visit); + }; + if (source) visit(source.root); + return result; + }; + return { original: scopes(diff.lhs), modified: scopes(diff.rhs) }; +} + +/** + * The zero-based lines a fold hides when it folds into its header line, as + * `opener { ⋯ N lines }`, and the closer text after the pill. Undefined when + * the fold needs a band: no opener, a summary, or code before its closer. + */ +function structuralInlineFold( + region: StructuralRegion, + lines: readonly string[], +) { + if ( + region.kind !== "fold" || + !region.syntax || + bandDetail(region.visibility?.label ?? "") + ) + return undefined; + const { start, end } = regionLines(region); + const header = region.syntax.start.line, + closerLine = region.syntax.end.line; + if (closerLine <= header || start !== header + 1 || end > closerLine + 1) + return undefined; + const text = lines[closerLine] ?? ""; + const column = utf16Column(text, region.syntax.end.column) - 1; + if (text.slice(0, column).trim()) return undefined; + return { + start: header + 1, + end: closerLine + 1, + closer: text.slice(column).trim(), + }; +} + +/** + * A region the reader can fold. `line` (zero-based) is the line with its + * chevron. A fold with an opener has a rail from the line below its header to + * its closer, and braces (one-based, UTF-16 columns). + */ +export interface StructuralFoldable { + readonly foldStateId: number; + readonly line: number; + readonly rail?: { readonly start: number; readonly end: number }; + readonly braces?: { + readonly opener: { line: number; column: number }; + readonly closer: { line: number; column: number }; + }; + /** Set when the fold folds into its header line: the pill text and the closer text. */ + readonly inline?: { readonly label: string; readonly closer: string }; + /** False when another region on the same line has the chevron. */ + readonly chevron: boolean; + readonly collapsedByDefault: boolean; +} + +/** + * The regions of one side the reader can fold. Each line has one chevron. A + * fold with an opener gets it first, so that the chevron and the rail fold the + * same body. Then a fold gets it before a leaf, and an outer region before an + * inner one. + */ +export function structuralFoldables( + source: StructuralSource | undefined, +): StructuralFoldable[] { + const lines = source ? source.text.replace(/\r\n/g, "\n").split("\n") : []; + const found: { + foldable: Omit; + rank: [number, number, number]; + }[] = []; + const visit = (region: StructuralRegion) => { + const { start, end } = regionLines(region); + const fold = region.kind === "fold"; + if ( + (fold || + region.visibility?.collapsed === true || + !!region.visibility?.label) && + end > start + ) { + const syntax = fold ? region.syntax : undefined; + const inline = structuralInlineFold(region, lines); + const hidden = inline ? inline.end - inline.start - 1 : 0; + found.push({ + foldable: { + foldStateId: region.fold_state_id, + line: syntax ? syntax.start.line : start, + rail: + syntax && syntax.end.line > syntax.start.line + ? { start: syntax.start.line + 1, end: syntax.end.line } + : undefined, + braces: syntax + ? { + opener: { + line: syntax.start.line + 1, + column: + utf16Column( + lines[syntax.start.line] ?? "", + syntax.start.column, + ) - 1, + }, + closer: { + line: syntax.end.line + 1, + column: utf16Column( + lines[syntax.end.line] ?? "", + syntax.end.column, + ), + }, + } + : undefined, + inline: inline && { + label: + region.visibility?.label || + `${hidden} line${hidden === 1 ? "" : "s"}`, + closer: inline.closer, + }, + collapsedByDefault: region.visibility?.collapsed === true, + }, + rank: [syntax ? 1 : 0, fold ? 1 : 0, end], + }); + } + if (region.kind === "fold") region.children.forEach(visit); + }; + // The file header folds the root. + if (source?.root.kind === "fold") source.root.children.forEach(visit); + const holder = new Map(); + for (const entry of found) { + const held = holder.get(entry.foldable.line); + const [a, b] = [entry.rank, held?.rank]; + if (!b || (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) > 0) + holder.set(entry.foldable.line, entry); + } + return found.map((entry) => ({ + ...entry.foldable, + chevron: holder.get(entry.foldable.line) === entry, + })); +} + +function structuralLeaves(root: StructuralRegion): StructuralLeaf[] { + const leaves: StructuralLeaf[] = []; + const walk = (region: StructuralRegion) => { + if (region.kind === "leaf") leaves.push(region); + else for (const child of region.children) walk(child); + }; + walk(root); + return leaves; +} + +export function utf16Column(text: string, byteColumn: number): number { + let bytes = 0, + units = 0; + for (const character of text) { + if (bytes >= byteColumn) break; + const code = character.codePointAt(0)!; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + units += character.length; + } + return units + 1; +} + +/** + * Whole-line tint follows diffr’s structural coverage, matching change counts. + * Changed token spans supply the stronger tint within those lines. + */ +export function structuralHighlights(diff: StructuralTextDiff) { + function side( + source: StructuralSource | undefined, + ranges: readonly (readonly [number, number])[], + ) { + if (!source) return { spans: [], lines: [] }; + const lines = source.text.replace(/\r\n/g, "\n").split("\n"); + const changedLines: number[] = []; + for (const [start, end] of ranges) { + for (let line = start; line < end; line++) changedLines.push(line + 1); + } + const spans = []; + for (const leaf of structuralLeaves(source.root)) { + for (const span of leaf.changed ?? []) { + spans.push({ + startLineNumber: span.line + 1, + startColumn: utf16Column(lines[span.line], span.start_column), + endLineNumber: span.line + 1, + endColumn: utf16Column(lines[span.line], span.end_column), + }); + } + } + // Keep coverage for counts, but reserve token tint for partially changed lines. + const byLine = new Map(); + for (const span of spans) { + const group = byLine.get(span.startLineNumber) ?? []; + group.push(span); + byLine.set(span.startLineNumber, group); + } + const fullyNovel = new Set(); + for (const [line, group] of byLine) { + let end = 0; + let unchanged = ""; + for (const span of group.sort((a, b) => a.startColumn - b.startColumn)) { + unchanged += lines[line - 1].slice( + end, + Math.max(end, span.startColumn - 1), + ); + end = Math.max(end, span.endColumn - 1); + } + unchanged += lines[line - 1].slice(end); + if (!unchanged.trim()) fullyNovel.add(line); + } + return { + spans: spans.filter((span) => !fullyNovel.has(span.startLineNumber)), + lines: changedLines, + }; + } + const original = side(diff.lhs, diff.structural_changes.base); + const modified = side(diff.rhs, diff.structural_changes.head); + return { + original: original.spans, + modified: modified.spans, + originalLines: original.lines, + modifiedLines: modified.lines, + }; +} + +/** Tags are `:`; several plugins capture docstrings, each under its own prefix. */ +const isDocstring = (region: StructuralRegion) => + region.tags?.some( + (tag) => tag.slice(tag.lastIndexOf(":") + 1) === "docstring", + ) === true; + +/** A hidden band on one or both sides, one-based like Monaco's diff editor. */ +export interface StructuralGap { + originalStart: number; + modifiedStart: number; + originalCount: number; + modifiedCount: number; + label: string; + /** What the band hides: unchanged context, or lines that exist on one side only. */ + owner: "base" | "head" | "both"; + change: "unchanged" | "inserted" | "removed" | "modified"; + /** False for a region the reader revealed: it stays a band the editor can fold again. */ + collapsed: boolean; + /** The fold-state id of the region(s) this band hides; toggling the band toggles it. */ + foldStateId: number; + /** False for a bundled docstring: its band shows the bare count, no symbol names. */ + breadcrumbs: boolean; + /** False when the fold folds into its header line and has no band. */ + band: boolean; +} + +/** + * The text a band shows under its title. diffr prepends a ` pseudocode` + * line to a summary for terminals; the app has its own caption, so that line + * is dropped here. A one-line label has no detail. + */ +export function bandDetail(label: string): string { + const lines = label.split("\n"); + if (lines.length < 2) return ""; + const body = /^(\/\/|#|--|;|%)\s*pseudocode$/.test(lines[0].trim()) + ? lines.slice(1) + : lines; + return body.join("\n"); +} + +/** + * Regions of one side the state knows about, outermost first: collapsed ones + * and ones a reader revealed. A collapsed region subsumes its descendants; a + * revealed one still lists them, since a child may be collapsed on its own. + */ +function knownRegions( + root: StructuralRegion | undefined, + state: (foldStateId: number) => boolean | undefined, +): { region: StructuralRegion; collapsed: boolean }[] { + const result: { region: StructuralRegion; collapsed: boolean }[] = []; + const walk = (region: StructuralRegion) => { + const known = state(region.fold_state_id); + const { start, end } = regionLines(region); + const hides = end > start; + if (known === true) { + if (hides) result.push({ region, collapsed: true }); + return; + } + // Open, but a band by the wire's default: a reader revealed it, and the editor can fold it again. + if (known === false && hides && region.visibility?.collapsed === true) + result.push({ region, collapsed: false }); + if (region.kind === "fold") + for (const child of region.children) walk(child); + }; + if (root) walk(root); + return result; +} + +/** + * Every collapsed region as a diff-editor band. A region collapsed on both + * sides becomes one band: a leaf with the leaf sharing its `alignment_id`, a + * fold with the fold sharing its `fold_state_id` (a docstring only with a + * docstring, since a docstring shares its body's fold state). A region on one side only + * becomes a band on that side; the other side's range starts right after the + * row that precedes the region and covers only the opposite lines the zip put + * inside the region's rows, none when those rows are filler. + */ +export function structuralContextGaps( + diff: StructuralTextDiff, + isCollapsed: (foldStateId: number) => boolean, + state: (foldStateId: number) => boolean | undefined = (id) => + isCollapsed(id) ? true : undefined, +): StructuralGap[] { + const rows = structuralRows(diff); + const rowOfLeft = new Map(), + rowOfRight = new Map(); + rows.forEach(([l, r], index) => { + if (l !== null) rowOfLeft.set(l, index); + if (r !== null) rowOfRight.set(r, index); + }); + // One-based start and count of the opposite range for lines hidden on `side`. Rows are monotone, so + // the opposite lines inside the region's rows follow the opposite line of the row before it. + const oppositeSpan = ( + side: 0 | 1, + hidden: { start: number; end: number }, + ): { start: number; count: number } => { + const other = side === 0 ? 1 : 0; + const rowOf = side === 0 ? rowOfLeft : rowOfRight; + const first = rowOf.get(hidden.start)!, + last = rowOf.get(hidden.end - 1)!; + let before = -1; + for (let index = first - 1; index >= 0 && before === -1; index--) + before = rows[index][other] ?? -1; + let count = 0; + for (let index = first; index <= last; index++) + if (rows[index][other] !== null) count++; + return { start: before + 2, count }; + }; + const lhs = knownRegions(diff.lhs?.root, state); + const rhs = knownRegions(diff.rhs?.root, state); + const lhsLines = (diff.lhs?.text ?? "").replace(/\r\n/g, "\n").split("\n"); + const rhsLines = (diff.rhs?.text ?? "").replace(/\r\n/g, "\n").split("\n"); + // Leaves pair by alignment; folds pair by fold state, the only identity they share across sides. + const usedRhs = new Set(); + const pairs = (left: StructuralRegion, right: StructuralRegion) => { + if (left.kind === "leaf") + return right.kind === "leaf" && right.alignment_id === left.alignment_id; + return ( + right.kind === "fold" && + right.fold_state_id === left.fold_state_id && + isDocstring(right) === isDocstring(left) + ); + }; + const counterpart = (left: StructuralRegion) => + rhs.find(({ region: right }) => !usedRhs.has(right) && pairs(left, right)); + const gaps: StructuralGap[] = []; + for (const { region: left, collapsed } of lhs) { + const partner = counterpart(left); + if (partner) { + usedRhs.add(partner.region); + // Both sides fold into their header lines, or both keep a band. + const leftInline = structuralInlineFold(left, lhsLines), + rightInline = structuralInlineFold(partner.region, rhsLines); + const inline = leftInline && rightInline; + const hidden = inline ? leftInline : regionLines(left); + const right = inline ? rightInline : regionLines(partner.region); + gaps.push({ + originalStart: hidden.start + 1, + originalCount: hidden.end - hidden.start, + modifiedStart: right.start + 1, + modifiedCount: right.end - right.start, + label: partner.region.visibility?.label || left.visibility?.label || "", + owner: "both", + change: "unchanged", + collapsed: collapsed && partner.collapsed, + foldStateId: left.fold_state_id, + breadcrumbs: !isDocstring(left) && !isDocstring(partner.region), + band: !inline, + }); + continue; + } + // A base-only fold keeps its band. The unified layout has no header line for it. + const hidden = regionLines(left); + const opposite = oppositeSpan(0, hidden); + gaps.push({ + originalStart: hidden.start + 1, + originalCount: hidden.end - hidden.start, + modifiedStart: opposite.start, + modifiedCount: opposite.count, + label: left.visibility?.label || "", + owner: "base", + change: "unchanged", + collapsed, + foldStateId: left.fold_state_id, + breadcrumbs: !isDocstring(left), + band: true, + }); + } + for (const { region: right, collapsed } of rhs) { + if (usedRhs.has(right)) continue; + const inline = structuralInlineFold(right, rhsLines); + const hidden = inline ?? regionLines(right); + const opposite = oppositeSpan(1, hidden); + gaps.push({ + originalStart: opposite.start, + originalCount: opposite.count, + modifiedStart: hidden.start + 1, + modifiedCount: hidden.end - hidden.start, + label: right.visibility?.label || "", + owner: "head", + change: "unchanged", + collapsed, + foldStateId: right.fold_state_id, + breadcrumbs: !isDocstring(right), + band: !inline, + }); + } + // Ownership says which fold state to toggle, never whether its contents were deleted. + const highlights = structuralHighlights(diff); + const removedLines = new Set(highlights.originalLines), + addedLines = new Set(highlights.modifiedLines); + for (const [left, right] of rows) { + if (left !== null && right === null) removedLines.add(left + 1); + if (right !== null && left === null) addedLines.add(right + 1); + } + for (const gap of gaps) { + const removed = [...removedLines].some( + (line) => + line >= gap.originalStart && + line < gap.originalStart + gap.originalCount, + ); + const added = [...addedLines].some( + (line) => + line >= gap.modifiedStart && + line < gap.modifiedStart + gap.modifiedCount, + ); + gap.change = + removed && added + ? "modified" + : removed + ? "removed" + : added + ? "inserted" + : "unchanged"; + } + gaps.sort( + (a, b) => + a.modifiedStart - b.modifiedStart || a.originalStart - b.originalStart, + ); + // A rewritten body can fold on each side under its own fold state, over the same rows. + // Show one fold: drop a one-sided fold that another fold hides fully. Of two equal folds, keep the first. + const within = (inner: StructuralGap, outer: StructuralGap) => + inner.originalStart >= outer.originalStart && + inner.originalStart + inner.originalCount <= + outer.originalStart + outer.originalCount && + inner.modifiedStart >= outer.modifiedStart && + inner.modifiedStart + inner.modifiedCount <= + outer.modifiedStart + outer.modifiedCount; + const redundant = (gap: StructuralGap, index: number) => + gaps.some( + (other, at) => + at !== index && + other.collapsed && + within(gap, other) && + !(within(other, gap) && at > index), + ); + const shown = gaps.filter( + (gap, index) => + gap.owner === "both" || !gap.collapsed || !redundant(gap, index), + ); + for (const gap of shown) + if (!gap.label) { + const count = Math.max(gap.originalCount, gap.modifiedCount); + gap.label = `${count} hidden line${count === 1 ? "" : "s"}`; + } + return shown; +} diff --git a/apps/diffr-web/src/review/common/reviewStructuralViewed.ts b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts new file mode 100644 index 000000000..5d6a980ff --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { ReviewDiffLens, ReviewDiffProgressFile } from "../../protocol.js"; +import { + regionLines, + type StructuralRegion, + type StructuralTextDiff, +} from "./reviewStructuralDiff.js"; + +type Sources = ReviewDiffLens["ranges"]; +type Source = Sources[number]; + +/** All members of a fold state, including a bundled doc comment and both revisions. */ +export function structuralViewedScopes( + diff: StructuralTextDiff, + path: string, + previousPath = path, +): ReadonlyMap { + const result = new Map(); + const scopes = new Set(); + for (const [side, source, file] of [ + ["base", diff.lhs, previousPath], + ["head", diff.rhs, path], + ] as const) { + const visit = (region: StructuralRegion) => { + if (region.kind !== "fold") return; + if (region.syntax) scopes.add(region.fold_state_id); + const span = regionLines(region); + const fromLine = (region.syntax?.start.line ?? span.start) + 1; + const toLine = region.syntax ? region.syntax.end.line + 1 : span.end; + if (toLine >= fromLine) { + const ranges = result.get(region.fold_state_id) ?? []; + ranges.push({ side, file, fromLine, toLine }); + result.set(region.fold_state_id, ranges); + } + region.children.forEach(visit); + }; + // The file header owns the root's viewed control. + if (source?.root.kind === "fold") source.root.children.forEach(visit); + } + for (const [id, ranges] of result) { + if (scopes.has(id)) result.set(id, mergeSources(ranges)); + else result.delete(id); + } + return result; +} + +/** Viewed state depends on changed-line coverage, never on whether a fold is open. */ +export function structuralViewedProgress( + sources: Sources, + file: ReviewDiffProgressFile | undefined, +) { + const changes = intersectSources(file?.changedRanges ?? [], sources); + const viewed = intersectSources(changes, file?.viewedRanges ?? []); + const count = (ranges: Sources, side: Source["side"]) => + ranges.reduce( + (sum, r) => sum + (r.side === side ? r.toLine - r.fromLine + 1 : 0), + 0, + ); + const total = { + additions: count(changes, "head"), + deletions: count(changes, "base"), + }; + const remaining = { + additions: total.additions - count(viewed, "head"), + deletions: total.deletions - count(viewed, "base"), + }; + const size = total.additions + total.deletions, + unread = remaining.additions + remaining.deletions; + const state = + size > 0 && unread === 0 ? "viewed" : unread < size ? "partial" : "unread"; + return { state, total, remaining } as const; +} + +/** Lens marks can cover individual changes without covering any complete syntax scope. */ +export function structuralViewedRanges( + scopes: ReadonlyMap | undefined, + file: ReviewDiffProgressFile | undefined, +): Sources { + const ranges = intersectSources( + file?.changedRanges ?? [], + file?.viewedRanges ?? [], + ); + for (const sources of scopes?.values() ?? []) { + if (structuralViewedProgress(sources, file).state === "viewed") + ranges.push(...sources); + } + return mergeSources(ranges); +} + +function intersectSources(a: Sources, b: Sources): Source[] { + return mergeSources( + a.flatMap((left) => + b.flatMap((right) => { + if (left.side !== right.side || left.file !== right.file) return []; + const fromLine = Math.max(left.fromLine, right.fromLine), + toLine = Math.min(left.toLine, right.toLine); + return fromLine <= toLine ? [{ ...left, fromLine, toLine }] : []; + }), + ), + ); +} + +function mergeSources(ranges: Sources): Source[] { + const result: Source[] = []; + for (const range of [...ranges].sort( + (a, b) => + a.side.localeCompare(b.side) || + a.file.localeCompare(b.file) || + a.fromLine - b.fromLine, + )) { + const last = result[result.length - 1]; + if ( + last && + last.side === range.side && + last.file === range.file && + range.fromLine <= last.toLine + 1 + ) { + result[result.length - 1] = { + ...last, + toLine: Math.max(last.toLine, range.toLine), + }; + } else result.push(range); + } + return result; +} diff --git a/apps/diffr-web/src/review/services/reviewDiffLayout.ts b/apps/diffr-web/src/review/services/reviewDiffLayout.ts new file mode 100644 index 000000000..5254482af --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewDiffLayout.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; + +import { readSetting, writeSetting } from "../../settings.js"; + +export type ReviewDiffLayout = "split" | "unified"; + +/** The unified/split choice, remembered in this browser. */ +export class ReviewDiffLayoutSetting extends Disposable { + private readonly _onDidChange = this._register( + new Emitter(), + ); + readonly onDidChange = this._onDidChange.event; + private layout: ReviewDiffLayout = + readSetting("layout") === "unified" ? "unified" : "split"; + + get(): ReviewDiffLayout { + return this.layout; + } + + async set(layout: ReviewDiffLayout): Promise { + if (layout === this.layout) return; + this.layout = layout; + writeSetting("layout", layout); + this._onDidChange.fire(layout); + } + + toggle(): Promise { + return this.set(this.layout === "split" ? "unified" : "split"); + } +} diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts new file mode 100644 index 000000000..f5b913741 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -0,0 +1,1018 @@ +import { + $, + addDisposableListener, + append, + Dimension, + getWindow, + scheduleAtNextAnimationFrame, +} from "vs/base/browser/dom.js"; +import { + Orientation, + SplitView, +} from "vs/base/browser/ui/splitview/splitview.js"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import "../browser/media/review.css"; +import { disposableTimeout } from "vs/base/common/async.js"; +import { Emitter, Event } from "vs/base/common/event.js"; +import { + Disposable, + DisposableStore, + MutableDisposable, + toDisposable, +} from "vs/base/common/lifecycle.js"; +import { autorun } from "vs/base/common/observable.js"; +import { isEqual } from "vs/base/common/resources.js"; +import { URI } from "vs/base/common/uri.js"; +import { ElementSizeObserver } from "vs/editor/browser/config/elementSizeObserver.js"; +import type { IDiffEditor } from "vs/editor/browser/editorBrowser.js"; +import { RefCounted } from "vs/editor/browser/widget/diffEditor/utils.js"; +import type { IDocumentDiffItem } from "vs/editor/browser/widget/multiDiffEditor/model.js"; +import { MultiDiffEditorViewModel } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js"; +import { + MultiDiffEditorWidget, + type RevealOptions, +} from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; +import type { IMultiDiffEditorViewState } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js"; +import { IDiffEditorOptions } from "vs/editor/common/config/editorOptions.js"; +import { Range } from "vs/editor/common/core/range.js"; +import { ILanguageService } from "vs/editor/common/languages/language.js"; +import { PLAINTEXT_LANGUAGE_ID } from "vs/editor/common/languages/modesRegistry.js"; +import { IModelService } from "vs/editor/common/services/model.js"; +import { IHoverService } from "vs/platform/hover/browser/hover.js"; +import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; + +import type { + ReviewDiffProgress, + ReviewDiffLens, + ReviewDiffViewSpec, +} from "../../protocol.js"; +import { type ReviewDiffFileWire } from "../../protocol.js"; +import { ReviewChangedFilesTree } from "../browser/reviewChangedFilesTree.js"; +import { + REVIEW_COUNTS_PENDING_TOOLTIP, + reviewChangesTooltip, + reviewCountsTooltip, + ReviewTooltip, + type ReviewTooltipContent, +} from "../browser/reviewTooltip.js"; +import { + binarySizeLabel, + isBinaryCounts, + type ReviewFileCounts, +} from "../common/reviewStructuralDiff.js"; +import type { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; +import { + reviewMultiDiffLabelUris, + ReviewMultiDiffUIElementFactory, +} from "./reviewMultiDiff.js"; +import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; + +const FILE_TREE_MINIMUM_WIDTH = 180; +const DIFF_MINIMUM_WIDTH = 320; +const FILE_TREE_COLLAPSE_WIDTH = FILE_TREE_MINIMUM_WIDTH + DIFF_MINIMUM_WIDTH; +const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { + hideUnchangedRegions: { enabled: true }, + originalEditable: false, + readOnly: true, + glyphMargin: false, + lineNumbersMinChars: 3, + // A peek or diff on the page is read until it is clicked into; the current + // line highlight belongs to the cursor, not to the first revealed line. + renderLineHighlightOnlyWhenFocus: true, + // Review Desktop's editor defaults (reviewConfigurationDefaults.ts), which + // the page has no configuration service to supply. + fontFamily: '"Geist Mono", Menlo, Monaco, "Courier New", monospace', + fontSize: 12, + minimap: { enabled: false }, + scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 }, + renderIndicators: false, +} satisfies IDiffEditorOptions; + +export interface ReviewFilesEditorEntry { + readonly sectionId?: string; + readonly sectionStart?: boolean; + readonly file: ReviewDiffFileWire; + readonly original: URI | undefined; + readonly modified: URI | undefined; + readonly goToFileResource: URI; +} + +/** + * The files of one comparison as the multi-diff widget's model. Review + * Desktop builds this on the workbench's MultiDiffEditorInput and resolves + * text through content providers; here a file's models are made from its + * diffr result once the file is diffed, and only diffed files are listed. + */ +export class ReviewFilesEditorInput extends Disposable { + private readonly changes = this._register(new Emitter()); + private readonly documents = new Map>(); + private current: readonly RefCounted[] = []; + private loading: boolean; + + constructor( + readonly entries: readonly ReviewFilesEditorEntry[], + private readonly session: StructuralDiffSession, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, + @IInstantiationService + private readonly instantiationService: IInstantiationService, + ) { + super(); + this.loading = entries.length > 0; + this._register( + toDisposable(() => { + for (const document of this.documents.values()) document.dispose(); + this.documents.clear(); + }), + ); + } + + setReadyFiles(paths: ReadonlySet, loading: boolean): void { + this.loading = loading; + this.current = this.entries.flatMap((entry) => { + if (!paths.has(entry.file.path)) return []; + const document = + this.documents.get(entry.file.path) ?? this.createDocument(entry); + return document ? [document] : []; + }); + this.changes.fire(); + } + + private createDocument( + entry: ReviewFilesEditorEntry, + ): RefCounted | undefined { + const diff = this.session.getFileResult(entry.file.path)?.diff; + if (!diff) return undefined; + const store = new DisposableStore(); + const model = (uri: URI, text: string) => + store.add( + this.modelService.getModel(uri) ?? + this.modelService.createModel( + text, + this.languageService.createById(PLAINTEXT_LANGUAGE_ID), + uri, + ), + ); + const text = (side: "lhs" | "rhs") => + diff.type === "text" ? (diff[side]?.text ?? "") : ""; + const item: IDocumentDiffItem = { + original: model(entry.original!, text("lhs")), + modified: model(entry.modified!, text("rhs")), + labelUris: reviewMultiDiffLabelUris(entry.file), + // Every hidden line comes from the one fold model: diffr's regions, + // never the diff editor's own unchanged-region hiding. + options: { + ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, + hideOriginalLineNumbers: + entry.file.status === "added" || entry.file.status === "unchanged", + hideUnchangedRegions: { + enabled: true, + minimumLineCount: 1, + contextLineCount: 0, + }, + folding: false, + // Structural rails use one geometry through code, folds and deleted rows. + guides: { indentation: false, bracketPairs: false }, + // Fold controls follow the line numbers, with breathing room before code. + lineDecorationsWidth: 24, + glyphMargin: true, + experimental: { useTrueInlineView: false }, + }, + }; + const document = RefCounted.createOfNonDisposable(item, store, this); + this.documents.set(entry.file.path, document); + return document; + } + + async getViewModel(): Promise { + const input = this; + // Its instantiation service is a plain parameter, not an injected one. + return this._register( + new MultiDiffEditorViewModel( + { + documents: { + get value() { + return input.current.length || !input.loading + ? input.current + : "loading"; + }, + onDidChange: this.changes.event, + }, + }, + this.instantiationService, + ), + ); + } +} + +/** + * The changed-files diff UI — a file list beside a multi-diff widget — as a + * plain widget. It owns no editor pane, so the Review canvas can mount it into + * a container the app supplies. The container's size drives the layout: the + * canvas gives the host element its bounds through CSS, not through a pane + * layout call. + */ +export class ReviewFilesDiffView extends Disposable { + private readonly _onDidChangeActiveControl = this._register( + new Emitter(), + ); + readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; + private readonly _onDidScroll = this._register(new Emitter()); + /** The list scrolled or its topmost file changed. */ + readonly onDidScroll = this._onDidScroll.event; + private readonly _onDidRequestFile = this._register(new Emitter()); + /** The reader asked for a file from the tree, diffed or not. */ + readonly onDidRequestFile = this._onDidRequestFile.event; + private readonly diffContainer: HTMLElement; + + private readonly root: HTMLElement; + private readonly splitView: SplitView; + private readonly changedFilesTree: ReviewChangedFilesTree | undefined; + private readonly widget: MultiDiffEditorWidget; + private pendingViewState: IMultiDiffEditorViewState | undefined; + private viewModel: MultiDiffEditorViewModel | undefined; + private input: ReviewFilesEditorInput | undefined; + private readonly readyFiles = new Set(); + private readonly fileStates = new Map(); + private readonly settleHold = this._register( + new MutableDisposable(), + ); + /** Full structural counts for views without persisted coverage. */ + private readonly streamStats = new Map< + string, + { counts: ReviewFileCounts; tooltip?: ReviewTooltipContent } + >(); + private readonly headerFactory: ReviewMultiDiffUIElementFactory; + private readonly summary: HTMLElement; + private readonly summaryTooltip: ReviewTooltip; + /** Files the stream says start hidden, with the reason shown in their header. */ + private readonly hiddenFiles = new Map(); + private readonly hiddenApplied = new Set(); + private pendingPath: string | undefined; + private pendingSectionId: string | undefined; + private pendingSource: ReviewDiffLens["ranges"][number] | undefined; + private progress: ReviewDiffProgress | undefined; + private documentCollapsed = false; + private readonly initializedDocumentItems = new WeakSet(); + private readonly viewedApplied = new Map(); + private readonly streamStatus: HTMLElement; + private offscreen = false; + private layoutDeferred = false; + viewedScope: "lens" | undefined; + + constructor( + private readonly container: HTMLElement, + overflowWidgetsDomNode: HTMLElement | undefined, + layout: ReviewDiffLayoutSetting, + private readonly fileTreeContainer: HTMLElement | undefined, + private readonly onToggleViewed: + | ((path: string, sectionId?: string) => void) + | undefined, + private readonly onToggleSection: ((id: string) => void) | undefined, + private readonly document: ReviewDiffViewSpec["document"], + @IInstantiationService + private readonly reviewInstantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, + ) { + super(); + this.root = append(container, $(".review-files-editor")); + const fileTree = document + ? container.ownerDocument.createElement("div") + : append( + this.fileTreeContainer ?? this.root, + $(".review-files-editor-tree"), + ); + const diffContainer = append(this.root, $(".review-files-editor-diffs")); + this.diffContainer = diffContainer; + if (document) fileTree.hidden = true; + + const factory = this.reviewInstantiationService.createInstance( + ReviewMultiDiffUIElementFactory, + () => + this.input + ? this.input.entries.map((entry) => ({ + original: entry.original, + modified: entry.modified, + additions: + entry.file.status === "unchanged" || entry.file.binary + ? undefined + : (this.entryProgress(entry)?.remaining.additions ?? + (this.fileTreeContainer || this.document + ? undefined + : this.lineCounts(entry.file.path)?.added)), + deletions: + entry.file.status === "unchanged" || entry.file.binary + ? undefined + : (this.entryProgress(entry)?.remaining.deletions ?? + (this.fileTreeContainer || this.document + ? undefined + : this.lineCounts(entry.file.path)?.removed)), + collapseLocked: !!entry.file.binary, + countsTooltip: + this.progressTooltip(entry) ?? + this.streamStats.get(entry.file.path)?.tooltip, + viewedState: this.entryProgress(entry)?.state, + viewedScope: this.viewedScope, + onToggleViewed: + entry.file.status !== "unchanged" && this.onToggleViewed + ? () => this.onToggleViewed!(entry.file.path, entry.sectionId) + : undefined, + sectionId: entry.sectionId, + sectionCollapsed: + !!entry.sectionId && + this.collapsedSections.has(entry.sectionId), + onToggleSectionCollapsed: () => { + if (entry.sectionId) { + if (this.collapsedSections.has(entry.sectionId)) + this.collapsedSections.delete(entry.sectionId); + else this.collapsedSections.add(entry.sectionId); + this.headerFactory.refreshHeaders(); + } + }, + section: entry.sectionStart + ? this.progress?.sections?.find( + (section) => section.id === entry.sectionId, + ) + : undefined, + onToggleSection: () => + entry.sectionId && this.onToggleSection?.(entry.sectionId), + // A binary is folded like a hidden file, with its size as the reason. + note: + entry.file.status === "unchanged" + ? "Unchanged" + : entry.file.binary + ? this.binaryNote(entry.file.path) + : this.hiddenFiles.get(entry.file.path), + noteTooltip: entry.file.binary + ? { label: "No text to show · binary files stay folded" } + : undefined, + // A binary has no text to open. + // The file on GitHub, at the comparison's head. + onDidOpen: + entry.file.binary || entry.goToFileResource.scheme !== "https" + ? undefined + : () => { + if (document?.onDidOpen) { + document.onDidOpen(); + return; + } + container.ownerDocument.defaultView?.open( + entry.goToFileResource.toString(true), + "_blank", + "noopener", + ); + }, + })) + : [], + "auto", + // Hover and definition widgets must escape the canvas root, whose + // container-query containment clips position: fixed descendants. + overflowWidgetsDomNode, + false, + undefined, + ); + factory.alwaysShowScrollbars = Boolean(document); + // A document embed shows one file, so its header stays pinned at the top. + factory.scrollbarBelowResourceHeader = Boolean(document); + if (document) factory.bottomScrollPadding = 0; + this.headerFactory = factory; + + this.widget = this._register( + this.reviewInstantiationService.createInstance( + MultiDiffEditorWidget, + diffContainer, + factory, + undefined, + ), + ); + // 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"); + this._register(layout.onDidChange(applyLayout)); + applyLayout(); + if (document) { + this._register( + this.widget.onDidChangeContentHeight(() => { + const height = Math.max(40, this.widget.getContentHeight()); + document.onDidChangeHeight( + document.heightMode === "capped" ? Math.min(400, height) : height, + ); + }), + ); + } + this.streamStatus = append( + diffContainer, + $(".review-structural-stream-status"), + ); + this.streamStatus.setAttribute("role", "status"); + this.streamStatus.hidden = true; + this._register( + this.widget.onDidChangeActiveControl(() => + this._onDidChangeActiveControl.fire(), + ), + ); + this._register(this.widget.onDidScroll(() => this._onDidScroll.fire())); + this._register( + this.widget.onDidChangeActiveItem(() => + this.syncFileSelectionFromWidget(), + ), + ); + this.summary = append(fileTree, $(".review-files-editor-summary")); + this.summary.hidden = true; + this.summaryTooltip = this._register( + new ReviewTooltip(hoverService, this.summary), + ); + this.changedFilesTree = document + ? undefined + : this._register( + this.reviewInstantiationService.createInstance( + ReviewChangedFilesTree, + fileTree, + ), + ); + if (this.changedFilesTree) + this._register( + this.changedFilesTree.onDidOpenFile((file) => { + this._onDidRequestFile.fire(file.path); + this.revealFile(file.path); + }), + ); + this.splitView = this._register( + new SplitView(this.root, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }), + ); + if (!this.fileTreeContainer && !document) + this.splitView.addView( + { + element: fileTree, + layout: (width, _offset, height) => { + fileTree.style.width = `${width}px`; + this.changedFilesTree?.layout( + (height ?? 0) - + (this.summary.hidden ? 0 : this.summary.offsetHeight), + width, + ); + }, + maximumSize: 380, + minimumSize: FILE_TREE_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 260, + ); + this.splitView.addView( + { + element: diffContainer, + layout: (width, _offset, height) => { + diffContainer.style.width = `${width}px`; + this.widget.layout(new Dimension(width, height ?? 0)); + }, + maximumSize: Number.POSITIVE_INFINITY, + minimumSize: DIFF_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 740, + ); + + if (this.fileTreeContainer) { + const treeSize = this._register( + new ElementSizeObserver(this.fileTreeContainer, undefined), + ); + this._register(treeSize.onDidChange(() => this.layout())); + treeSize.startObserving(); + this._register(toDisposable(() => fileTree.remove())); + } + if (document) { + const visibility = new IntersectionObserver( + ([entry]) => { + this.offscreen = !entry.isIntersecting; + if (!this.offscreen && this.layoutDeferred) this.layout(); + }, + { rootMargin: "200px 0px" }, + ); + visibility.observe(container); + this._register(toDisposable(() => visibility.disconnect())); + } + const sizeObserver = this._register( + new ElementSizeObserver(this.container, undefined), + ); + this._register(sizeObserver.onDidChange(() => this.layout())); + sizeObserver.startObserving(); + this.layout(); + // Registered last so it runs last: the widgets above tear their own DOM + // down, and they must do that while the tree is still attached. + this._register(toDisposable(() => this.root.remove())); + } + + async setInput( + input: ReviewFilesEditorInput, + viewState: IMultiDiffEditorViewState | undefined, + ): Promise { + 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(), + ), + ); + const viewModel = await input.getViewModel(); + if (this._store.isDisposed) return; + this.viewModel = viewModel; + // The canvas mounts this view without a user gesture, so the widget's + // first-change navigation must never take keyboard focus. + this.widget.setViewModel(viewModel, { + preserveFocus: true, + viewState, + initialScrollPosition: this.document ? "top" : "firstChange", + }); + if (viewState) { + this.settle(() => { + this.widget.setViewState(viewState); + this.syncFileSelectionFromWidget(); + }).add( + toDisposable(() => { + this.pendingViewState = undefined; + this.widget.clearPendingRestorationState(); + }), + ); + } + this.changedFilesTree?.setFiles( + Array.from( + new Map( + input.entries.map((entry) => [entry.file.path, entry.file]), + ).values(), + ), + ); + this.syncFileSelectionFromWidget(); + this._register( + autorun((reader) => { + const items = viewModel.items.read(reader); + if (this.document) + for (const item of items) { + if (this.initializedDocumentItems.has(item)) continue; + this.initializedDocumentItems.add(item); + item.collapsed.set(this.documentCollapsed, undefined); + } + this.applyHiddenFiles(items); + this.applyViewedFiles(); + const entry = this.input?.entries.find( + (e) => e.file.path === this.pendingPath, + ); + if (!entry || !this.readyFiles.has(entry.file.path)) return; + if ( + !items.some( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ) + ) + return; + this.pendingPath = undefined; + this.showStreamStatus(); + queueMicrotask(() => { + if (!this._store.isDisposed) { + if (this.pendingSource) + this.revealSource(this.pendingSource, this.pendingSectionId); + else this.reveal(entry); + } + }); + }), + ); + } + + startLoading(entries: readonly ReviewFilesEditorEntry[]): void { + this.changedFilesTree?.setFiles( + Array.from( + new Map(entries.map((entry) => [entry.file.path, entry.file])).values(), + ), + ); + for (const entry of entries) { + this.fileStates.set(entry.file.path, "Loading diff…"); + this.changedFilesTree?.setFileState(entry.file.path, "loading"); + } + this.showStreamStatus(); + } + + fileLoaded(path: string, error?: string): void { + if (error) { + this.fileStates.set(path, error); + this.changedFilesTree?.setFileState(path, "error", error); + } else { + this.fileStates.delete(path); + this.readyFiles.add(path); + this.changedFilesTree?.setFileState(path, undefined); + } + this.input?.setReadyFiles( + this.readyFiles, + [...this.fileStates.values()].some((state) => state === "Loading diff…"), + ); + this.showStreamStatus(); + } + + /** Coverage counts are independent of fold state. */ + fileCounts(path: string, counts: ReviewFileCounts): void { + this.streamStats.set(path, { + counts, + // A binary's header shows no counts, so it has no counts tooltip. + tooltip: isBinaryCounts(counts) + ? undefined + : reviewChangesTooltip(counts.added, counts.removed), + }); + // Coverage owns line counts in a hosted tree; a binary's size has no other source. + if (!this.fileTreeContainer || isBinaryCounts(counts)) + this.changedFilesTree?.setCounts(path, counts); + this.headerFactory.refreshHeaders(); + this.renderSummary(); + } + + private lineCounts(path: string) { + const counts = this.streamStats.get(path)?.counts; + return isBinaryCounts(counts) ? undefined : counts; + } + + private binaryNote(path: string): string { + const counts = this.streamStats.get(path)?.counts; + const size = isBinaryCounts(counts) ? binarySizeLabel(counts) : undefined; + return size ? `Binary file · ${size}` : "Binary file"; + } + + private renderSummary(): void { + if (this.fileTreeContainer) return; + const paths = new Set( + (this.input?.entries ?? []).map((entry) => entry.file.path), + ); + const complete = [...paths].every((path) => this.streamStats.has(path)); + const total = { added: 0, removed: 0 }; + for (const path of paths) { + const counts = this.lineCounts(path); + if (counts) { + total.added += counts.added; + total.removed += counts.removed; + } + } + this.summary.replaceChildren(); + const files = append( + this.summary, + $("span.review-files-editor-summary-files"), + ); + files.textContent = `${paths.size} files`; + if (complete) { + const format = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }); + append( + this.summary, + $("span.review-files-editor-summary-added"), + ).textContent = `+${format.format(total.added).toLowerCase()}`; + append( + this.summary, + $("span.review-files-editor-summary-removed"), + ).textContent = `−${format.format(total.removed).toLowerCase()}`; + } else append(this.summary, $("span")).textContent = "Counting changes…"; + this.summaryTooltip.content = complete + ? reviewChangesTooltip(total.added, total.removed) + : REVIEW_COUNTS_PENDING_TOOLTIP; + const wasHidden = this.summary.hidden; + this.summary.hidden = false; + if (wasHidden) this.layout(); + } + + /** A file the stream says starts collapsed, and why: its record's `visibility`. */ + hideFile(path: string, label: string): void { + this.hiddenFiles.set(path, label); + this.headerFactory.refreshHeaders(); + if (this.viewModel) this.applyHiddenFiles(this.viewModel.items.get()); + } + + /** GitHub's shape for a hidden file: the header stays, the body waits for a click. */ + private applyHiddenFiles( + items: readonly { + originalUri: URI | undefined; + modifiedUri: URI | undefined; + collapsed: { set(value: boolean, tx: undefined): void }; + }[], + ): void { + for (const entry of this.input?.entries ?? []) { + if ( + !this.hiddenFiles.has(entry.file.path) || + this.hiddenApplied.has(entry.file.path) + ) + continue; + const item = items.find( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ); + if (!item) continue; + item.collapsed.set(true, undefined); + this.hiddenApplied.add(entry.file.path); + } + } + + private readonly collapsedSections = new Set(); + private readonly sectionViewed = new Map(); + private entryProgress(entry: ReviewFilesEditorEntry) { + return ( + entry.sectionId + ? this.progress?.sections?.find( + (section) => section.id === entry.sectionId, + )?.files + : this.progress?.files + )?.find((file) => file.path === entry.file.path); + } + private progressTooltip( + entry: ReviewFilesEditorEntry, + ): ReviewTooltipContent | undefined { + const file = this.entryProgress(entry); + return file ? reviewCountsTooltip(file) : undefined; + } + setProgress(progress: ReviewDiffProgress): void { + this.progress = progress; + for (const section of progress.sections ?? []) { + const previous = this.sectionViewed.get(section.id); + if (section.state === "viewed" && previous !== "viewed") + this.collapsedSections.add(section.id); + else if (previous === "viewed" && section.state !== "viewed") + this.collapsedSections.delete(section.id); + this.sectionViewed.set(section.id, section.state); + } + + this.changedFilesTree?.setProgressFiles(progress.files); + this.headerFactory.refreshHeaders(); + this.applyViewedFiles(); + } + private applyViewedFiles(): void { + for (const entry of this.input?.entries ?? []) { + const file = this.entryProgress(entry); + if (!file) continue; + const key = `${entry.sectionId ?? ""}:${file.path}`; + const previous = this.viewedApplied.get(key); + if (previous === file.state) continue; + const item = this.itemFor(entry); + if (!item) continue; + if (file.state === "viewed") item.collapsed.set(true, undefined); + else if ( + previous === "viewed" || + this.progress?.changedPaths?.includes(file.path) + ) + item.collapsed.set(false, undefined); + this.viewedApplied.set(key, file.state); + } + } + revealSource( + source: ReviewDiffLens["ranges"][number], + sectionId?: string, + ): void { + this.settleHold.clear(); + const entry = this.input?.entries.find( + (entry) => + (!sectionId || entry.sectionId === sectionId) && + (!entry.sectionId || + this.progress?.sections + ?.find((section) => section.id === entry.sectionId) + ?.sources.some( + (range) => + range.file === source.file && + range.side === source.side && + range.fromLine <= source.fromLine && + range.toLine >= source.fromLine, + )) && + source.file === + (source.side === "base" + ? (entry.file.previousPath ?? entry.file.path) + : entry.file.path), + ); + if (!entry) return; + if (entry.sectionId && this.collapsedSections.delete(entry.sectionId)) + this.headerFactory.refreshHeaders(); + this.pendingSource = source; + this.pendingSectionId = sectionId; + if (this.fileStates.has(entry.file.path)) { + this.pendingPath = entry.file.path; + return; + } + this.itemFor(entry)?.collapsed.set(false, undefined); + this.reveal(entry, { + highlight: true, + side: source.side === "base" ? "original" : "modified", + range: new Range(source.fromLine, 1, source.toLine, 1), + }); + this.pendingSource = undefined; + } + + /** Scroll to a file, or to it once its diff has loaded. */ + revealFile(path: string): 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.showStreamStatus(); + if (!this.pendingPath) this.reveal(entry); + } + + /** The file at the top of the list, among those diffed. */ + get activePath(): string | undefined { + const resource = this.widget.getActiveItem(); + return ( + resource && + this.input?.entries.find( + (entry) => + sameResource(entry.original, resource.original) && + sameResource(entry.modified, resource.modified), + )?.file.path + ); + } + + get viewportHeight(): number { + return this.diffContainer.clientHeight; + } + + /** + * Pixels from the reading line (the list's top edge) to the first line of + * `source`, negative once scrolled past. Rendered files measure their + * editor; the rest are placed by file order around the topmost file, a + * million pixels per file, which keeps them sorted and on the right side. + */ + sourceOffset(source: ReviewDiffLens["ranges"][number]): number | undefined { + const entries = this.input?.entries ?? []; + const pathOf = (entry: (typeof entries)[number]) => + source.side === "base" + ? (entry.file.previousPath ?? entry.file.path) + : entry.file.path; + const index = entries.findIndex((entry) => pathOf(entry) === source.file); + if (index < 0) return undefined; + const entry = entries[index]!; + const resource = source.side === "base" ? entry.original : entry.modified; + const editor = resource && this.widget.tryGetCodeEditor(resource)?.editor; + const node = editor?.getDomNode(); + if (editor && node) { + return ( + node.getBoundingClientRect().top - + this.diffContainer.getBoundingClientRect().top + + editor.getTopForLineNumber(source.fromLine) - + editor.getScrollTop() + ); + } + const active = this.viewModel?.activeDiffItem.get(); + const activeIndex = active + ? entries.findIndex( + (e) => + sameResource(active.modifiedUri, e.modified) && + sameResource(active.originalUri, e.original), + ) + : 0; + return (index - Math.max(0, activeIndex)) * 1_000_000 + source.fromLine; + } + + loadingFailed(message: string): void { + for (const [path, state] of this.fileStates) + if (state === "Loading diff…") this.fileLoaded(path, message); + this.showStreamStatus(); + } + + private showStreamStatus(): void { + const message = this.pendingPath + ? this.fileStates.get(this.pendingPath) + : undefined; + this.streamStatus.hidden = + (!message && this.readyFiles.size > 0) || this.fileStates.size === 0; + this.streamStatus.textContent = message + ? `${this.pendingPath}: ${message}` + : this.readyFiles.size === 0 + ? ([...this.fileStates.values()][0] ?? "") + : ""; + this.streamStatus.classList.toggle( + "loading", + [...this.fileStates.values()].some((s) => s === "Loading diff…"), + ); + } + + getViewState(): IMultiDiffEditorViewState | undefined { + return ( + this.pendingViewState ?? + (this.viewModel ? this.widget.getViewState() : undefined) + ); + } + + getActiveControl(): IDiffEditor | undefined { + return this.widget.getActiveControl(); + } + + setCollapsed(collapsed: boolean): void { + this.settleHold.clear(); + this.documentCollapsed = collapsed; + for (const item of this.viewModel?.items.get() ?? []) + item.collapsed.set(collapsed, undefined); + } + + focus(): void { + this.widget.getActiveControl()?.focus(); + } + + layout(): void { + this.layoutDeferred = this.offscreen; + if (this.offscreen) return; + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width <= 0 || height <= 0) return; + + const fileTreeVisible = + !this.fileTreeContainer && width >= FILE_TREE_COLLAPSE_WIDTH; + if ( + !this.fileTreeContainer && + !this.document && + this.splitView.isViewVisible(0) !== fileTreeVisible + ) { + this.splitView.setViewVisible(0, fileTreeVisible); + } + + this.splitView.layout(width, height); + if (this.fileTreeContainer) + this.changedFilesTree?.layout( + this.fileTreeContainer.clientHeight, + this.fileTreeContainer.clientWidth, + ); + } + private reveal( + resource: { original: URI | undefined; modified: URI | undefined }, + options: RevealOptions = { highlight: true }, + ): void { + this.widget.reveal(resource, options); + this.settle(() => { + if (this.itemFor(resource)) + this.widget.reveal(resource, { ...options, highlight: false }); + }); + } + + /** + * Streamed files and lazy editor measurements move content after the widget + * first scrolls; re-apply `apply` as heights settle, until the reader takes + * over, another reveal or input replaces it, or 30s pass. + */ + private settle(apply: () => void): DisposableStore { + const hold = (this.settleHold.value = new DisposableStore()); + const frame = hold.add(new MutableDisposable()); + const schedule = () => { + if (frame.value) return; + frame.value = scheduleAtNextAnimationFrame(getWindow(this.root), () => { + apply(); + frame.clear(); + }); + }; + hold.add(this.widget.onDidChangeContentHeight(schedule)); + for (const type of ["wheel", "pointerdown", "keydown", "touchstart"]) + hold.add( + addDisposableListener( + this.diffContainer, + type, + () => this.settleHold.clear(), + { capture: true, passive: true }, + ), + ); + hold.add(disposableTimeout(() => this.settleHold.clear(), 30_000)); + schedule(); + return hold; + } + + private itemFor(resource: { + original: URI | undefined; + modified: URI | undefined; + }) { + return this.viewModel?.items + .get() + .find( + (item) => + sameResource(item.originalUri, resource.original) && + sameResource(item.modifiedUri, resource.modified), + ); + } + + private syncFileSelectionFromWidget(): void { + const resource = this.widget.getActiveItem(); + const input = this.input; + if (!resource || !input || this.pendingPath) return; + const index = input.entries.findIndex( + (entry) => + sameResource(entry.original, resource.original) && + 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); + } +} +function sameResource(left: URI | undefined, right: URI | undefined): boolean { + return left === undefined + ? right === undefined + : !!right && isEqual(left, right); +} diff --git a/apps/diffr-web/src/review/services/reviewMultiDiff.ts b/apps/diffr-web/src/review/services/reviewMultiDiff.ts new file mode 100644 index 000000000..3dcedc19e --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewMultiDiff.ts @@ -0,0 +1,343 @@ +import { Button } from "vs/base/browser/ui/button/button.js"; +import { IconLabel } from "vs/base/browser/ui/iconLabel/iconLabel.js"; +import { observableValue } from "vs/base/common/observable.js"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ +import { isEqual } from "vs/base/common/resources.js"; +import { URI } from "vs/base/common/uri.js"; +import type { IDiffCodeEditorWidgetOptions } from "vs/editor/browser/widget/diffEditor/diffEditorWidget.js"; +import type { + IResourceLabel, + IResourceHeaderMetadata, + IWorkbenchUIElementFactory, +} from "vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js"; +import { IHoverService } from "vs/platform/hover/browser/hover.js"; + +import type { + ReviewDiffProgressState, + ReviewDiffSection, +} from "../../protocol.js"; +import type { ReviewDiffFileWire } from "../../protocol.js"; +import { reviewBottomScrollPadding } from "../browser/reviewScrollPadding.js"; +import { + reviewCountsTooltip, + ReviewTooltip, + type ReviewTooltipContent, + ReviewViewedCheckbox, +} from "../browser/reviewTooltip.js"; + +export interface ReviewMultiDiffHeaderEntry { + readonly original: URI | undefined; + readonly modified: URI | undefined; + readonly additions?: number; + readonly deletions?: number; + /** Why the file starts collapsed, e.g. "Generated file · hidden by default". */ + readonly note?: string; + /** Tooltip for the note, e.g. why a file stays folded. */ + readonly noteTooltip?: ReviewTooltipContent; + /** A file with no text to diff: it stays folded and its header never opens it. */ + readonly collapseLocked?: boolean; + /** Tooltip for the counts: what is left of the file, then its whole. */ + readonly countsTooltip?: ReviewTooltipContent; + readonly sectionId?: string; + readonly section?: ReviewDiffSection; + readonly sectionCollapsed?: boolean; + readonly onToggleSectionCollapsed?: () => void; + readonly onToggleSection?: () => void; + readonly onDidOpen?: () => void; + readonly viewedState?: ReviewDiffProgressState; + readonly viewedScope?: "lens"; + readonly onToggleViewed?: () => void; +} + +export class ReviewMultiDiffUIElementFactory implements IWorkbenchUIElementFactory { + bottomScrollPadding = reviewBottomScrollPadding; + alwaysShowScrollbars = false; + scrollbarBelowResourceHeader = false; + + get headerClickToCollapse(): boolean { + return !this.hideResourceHeader; + } + + private readonly headers = new Set<() => void>(); + + /** Re-reads every live header's entry, for counts that change without the items changing. */ + refreshHeaders(): void { + for (const refresh of this.headers) refresh(); + } + + constructor( + private readonly entries: () => readonly ReviewMultiDiffHeaderEntry[], + readonly horizontalScrollbar: "auto" | "hidden", + readonly overflowWidgetsDomNode: HTMLElement | undefined, + readonly hideResourceHeader = false, + readonly codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions | undefined, + @IHoverService private readonly hoverService: IHoverService, + ) {} + + isResourceCollapseLocked( + uris: Parameters[0], + ): boolean { + return ( + !!uris && + !!this.entries().find( + (entry) => + sameResource(entry.original, uris.original) && + sameResource(entry.modified, uris.modified), + )?.collapseLocked + ); + } + + getResourceSectionId( + uris: Parameters[0], + ): string | undefined { + // Lens entries encode their section identity in both source URI fragments. + return uris?.modified?.fragment || uris?.original?.fragment || undefined; + } + + createResourceSectionHeader(element: HTMLElement, sticky = false) { + const height = observableValue(this, 0); + const bodyHidden = observableValue(this, false); + let uris: Parameters[0]; + let current: ReviewMultiDiffHeaderEntry | undefined; + const counts = document.createElement("span"); + counts.className = "review-diff-group-counts"; + const countsTooltip = new ReviewTooltip(this.hoverService, counts); + const viewed = new ReviewViewedCheckbox( + this.hoverService, + element.ownerDocument, + () => current?.onToggleSection?.(), + ); + const refresh = () => { + const entry = (current = + uris && + this.entries().find( + (entry) => + sameResource(entry.original, uris!.original) && + sameResource(entry.modified, uris!.modified), + )); + const section = + sticky && entry?.sectionId + ? this.entries().find( + (candidate) => + candidate.sectionId === entry.sectionId && candidate.section, + )?.section + : entry?.section; + bodyHidden.set(entry?.sectionCollapsed ?? false, undefined); + element.replaceChildren(); + element.className = "review-diff-group"; + element.hidden = !section; + height.set(section ? 48 : 0, undefined); + if (!section) return; + const toggle = document.createElement("button"); + toggle.className = "review-diff-group-toggle"; + toggle.setAttribute("aria-expanded", String(!entry?.sectionCollapsed)); + toggle.setAttribute( + "aria-label", + `${entry?.sectionCollapsed ? "Expand" : "Collapse"} section: ${section.label}`, + ); + toggle.onclick = () => entry?.onToggleSectionCollapsed?.(); + const chevron = document.createElement("span"); + chevron.className = `codicon codicon-chevron-${entry?.sectionCollapsed ? "right" : "down"}`; + const title = document.createElement("span"); + title.className = "review-diff-group-title"; + title.textContent = section.label; + counts.textContent = + section.total.additions + section.total.deletions === 0 + ? "Unchanged" + : section.state === "viewed" + ? "Viewed" + : section.state === "folded" + ? "Folded" + : `+${compactCount(section.remaining.additions)} −${compactCount(section.remaining.deletions)}`; + countsTooltip.content = + section.total.additions + section.total.deletions === 0 + ? undefined + : reviewCountsTooltip(section); + viewed.update( + section.state, + section.label, + section.total.additions + section.total.deletions === 0, + ); + element.classList.toggle("is-viewed", section.state === "viewed"); + toggle.append(chevron, title); + element.append(toggle, counts, viewed.element); + }; + this.headers.add(refresh); + return { + height, + bodyHidden, + setUris: (value: typeof uris) => { + uris = value; + refresh(); + }, + dispose: () => { + this.headers.delete(refresh); + countsTooltip.dispose(); + viewed.dispose(); + element.remove(); + }, + }; + } + + createResourceLabel(element: HTMLElement): IResourceLabel { + element.classList.add("review-path-label"); + // The desktop's workbench ResourceLabel is this IconLabel plus a file + // icon theme, which the page has none of. + const label = new IconLabel(element, { supportHighlights: false }); + // A deleted file keeps a legible name: the tree's minus already says + // deleted, so the header never strikes the path through. + return { + setUri: (uri) => { + // The header prints the whole path, and the CSS elides it from the left. + label.setLabel(uri ? reviewMultiDiffLabelPath(uri) : "", undefined, { + title: uri ? reviewMultiDiffLabelPath(uri) : undefined, + }); + }, + setLabel: (name, description) => label.setLabel(name, description), + dispose() { + label.dispose(); + }, + }; + } + + createResourceHeaderMetadata(element: HTMLElement): IResourceHeaderMetadata { + const ownerDocument = element.ownerDocument; + const additions = ownerDocument.createElement("span"); + additions.className = "review-multidiff-additions"; + const deletions = ownerDocument.createElement("span"); + deletions.className = "review-multidiff-deletions"; + const counts = ownerDocument.createElement("span"); + counts.className = "review-multidiff-counts"; + counts.append(additions, deletions); + element.append(counts); + const note = ownerDocument.createElement("span"); + note.className = "review-multidiff-note"; + element.append(note); + const noteTooltip = new ReviewTooltip(this.hoverService, note); + + const openContainer = ownerDocument.createElement("span"); + openContainer.className = "review-multidiff-open-container"; + element.append(openContainer); + const open = new Button(openContainer, { + ariaLabel: "Open File", + title: "Open File", + supportIcons: true, + }); + open.label = "Open file"; + open.element.classList.add("review-multidiff-open"); + const countsTooltip = new ReviewTooltip(this.hoverService, counts); + const viewed = new ReviewViewedCheckbox( + this.hoverService, + ownerDocument, + () => current?.onToggleViewed?.(), + ); + element.append(viewed.element); + let current: ReviewMultiDiffHeaderEntry | undefined; + let lastUris: Parameters[0]; + const openListener = open.onDidClick(() => current?.onDidOpen?.()); + const refresh = () => setUris(lastUris); + this.headers.add(refresh); + + const setUris: IResourceHeaderMetadata["setUris"] = (uris) => { + lastUris = uris; + current = uris + ? this.entries().find( + (entry) => + sameResource(entry.original, uris.original) && + sameResource(entry.modified, uris.modified), + ) + : undefined; + element.hidden = !current; + if (!current) return; + const hasCounts = + current.additions !== undefined && current.deletions !== undefined; + counts.hidden = !hasCounts; + if (hasCounts) { + additions.textContent = `+${compactCount(current.additions!)}`; + deletions.textContent = `−${compactCount(current.deletions!)}`; + counts.setAttribute( + "aria-label", + `${current.additions} lines added, ${current.deletions} lines removed`, + ); + } + countsTooltip.content = current.countsTooltip; + const path = current.modified ?? current.original; + viewed.update( + current.viewedState, + `${current.viewedScope === "lens" ? "selected lens in " : ""}${path ? reviewMultiDiffLabelPath(path) : "file"}`, + !current.onToggleViewed, + ); + counts.classList.toggle( + "review-counts-viewed", + current.viewedState === "viewed" || current.viewedState === "folded", + ); + counts.classList.toggle( + "review-counts-folded", + current.viewedState === "folded", + ); + if (current.viewedState === "viewed") { + additions.textContent = + current.viewedScope === "lens" ? "Lens viewed" : "Viewed"; + deletions.textContent = ""; + } else if (current.viewedState === "folded") { + // A hidden file's note already says why it is folded. + additions.textContent = "Folded"; + deletions.textContent = ""; + counts.hidden ||= !!current.note; + } + + note.hidden = !current.note; + note.textContent = current.note ?? ""; + noteTooltip.content = current.noteTooltip; + openContainer.hidden = !current.onDidOpen; + }; + return { + setUris, + dispose: () => { + this.headers.delete(refresh); + countsTooltip.dispose(); + noteTooltip.dispose(); + viewed.dispose(); + openListener.dispose(); + open.dispose(); + element.replaceChildren(); + }, + }; + } +} + +function sameResource(left: URI | undefined, right: URI | undefined): boolean { + return left === undefined + ? right === undefined + : !!right && isEqual(left, right); +} + +export function reviewMultiDiffLabelUris(file: ReviewDiffFileWire): { + readonly original: URI | undefined; + readonly modified: URI | undefined; +} { + return { + original: + file.status === "added" + ? undefined + : reviewFileLabelUri(file.previousPath ?? file.path), + modified: + file.status === "deleted" ? undefined : reviewFileLabelUri(file.path), + }; +} + +function reviewFileLabelUri(path: string): URI { + return URI.from({ scheme: "file", path: `/${path}` }); +} + +function reviewMultiDiffLabelPath(uri: URI): string { + return uri.path.startsWith("/") ? uri.path.slice(1) : uri.path; +} + +const compactCount = (count: number) => + new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }) + .format(count) + .toLowerCase(); diff --git a/apps/diffr-web/src/review/services/reviewStructuralDiff.ts b/apps/diffr-web/src/review/services/reviewStructuralDiff.ts new file mode 100644 index 000000000..f0face066 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralDiff.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationError } from "vs/base/common/errors.js"; +import { Event } from "vs/base/common/event.js"; +import { DisposableMap, DisposableStore } from "vs/base/common/lifecycle.js"; +import { autorun, type IObservable } from "vs/base/common/observable.js"; +import type { IDiffEditor } from "vs/editor/browser/editorBrowser.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; +import type { UnchangedRegion } from "vs/editor/browser/widget/diffEditor/diffEditorViewModel.js"; +import { IDiffProviderFactoryService } from "vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js"; +import { LineRange } from "vs/editor/common/core/ranges/lineRange.js"; +import type { + IDocumentDiffProvider, + IDocumentDiff, +} from "vs/editor/common/diff/documentDiffProvider.js"; +import { DetailedLineRangeMapping } from "vs/editor/common/diff/rangeMapping.js"; +import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; +import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection.js"; + +import { + structuralContextGaps, + structuralContextScopes, + structuralRows, + structuralHighlights, +} from "../common/reviewStructuralDiff.js"; +import type { ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; +import { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; +import { + StructuralFoldControls, + StructuralFoldHover, +} from "./reviewStructuralFolds.js"; +import { StructuralSyntax } from "./reviewStructuralSyntax.js"; +import type { StructuralViewedState } from "./reviewStructuralViewed.js"; + +/** + * The structural diff provider for these entries' editors, and the fold + * controls on each one. The view creates each file's models from diffr's + * result, under the entry's URIs, once the file is diffed. + */ +export function createStructuralDiffEditors( + instantiation: IInstantiationService, + entries: readonly ReviewFilesEditorEntry[], + lifetime: DisposableStore, + session: StructuralDiffSession, + viewed?: StructuralViewedState, +): { + instantiation: IInstantiationService; + entries: readonly ReviewFilesEditorEntry[]; +} { + const unchanged = new Set( + entries + .filter((e) => e.file.status === "unchanged") + .map((e) => e.file.path), + ); + const pairs = new Map( + entries.map((e) => [ + e.original!.toString() + "\n" + e.modified!.toString(), + e.file.path, + ]), + ); + const factory: IDiffProviderFactoryService = { + _serviceBrand: undefined, + createDiffProvider: () => + new StructuralDiffProvider(session, pairs, unchanged), + }; + const child = lifetime.add( + instantiation.createChild( + new ServiceCollection([IDiffProviderFactoryService, factory]), + ), + ); + attachStructuralEditors(instantiation, entries, session, lifetime, viewed); + return { instantiation: child, entries }; +} + +/** Adapts session snapshots and fold state to Monaco's diff interface. */ +export class StructuralDiffProvider implements IDocumentDiffProvider { + private path: string | undefined; + readonly onDidChange: Event; + constructor( + private readonly session: StructuralDiffSession, + private readonly pairs: ReadonlyMap, + private readonly unchanged: ReadonlySet, + ) { + this.onDidChange = Event.map( + Event.filter( + session.onDidChange, + (change) => this.path !== undefined && change.files.has(this.path), + ), + () => undefined, + ); + } + async computeDiff( + ...[original, modified, _options, token]: Parameters< + IDocumentDiffProvider["computeDiff"] + > + ): Promise { + if (token.isCancellationRequested) throw new CancellationError(); + const path = this.pairs.get( + original.uri.with({ fragment: "" }).toString() + + "\n" + + modified.uri.with({ fragment: "" }).toString(), + ); + this.path = path; + if (path !== undefined && this.unchanged.has(path)) { + if (original.getValue() !== modified.getValue()) + throw new Error("Referenced context file changed; reload the session."); + return { changes: [], moves: [], identical: true, quitEarly: false }; + } + if ( + path !== undefined && + this.session.getFileResult(path)?.diff?.type === "binary" + ) { + return { + changes: [], + moves: [], + identical: false, + quitEarly: false, + changeHighlights: { original: [], modified: [] }, + }; + } + const diff = + path === undefined ? undefined : this.session.getTextDiff(path); + if (!diff) throw new Error("diffr did not supply a result for this file."); + const left = (diff.lhs?.text ?? "").replace(/\r\n/g, "\n"); + const right = (diff.rhs?.text ?? "").replace(/\r\n/g, "\n"); + if ( + original.getLinesContent().join("\n") !== left || + modified.getLinesContent().join("\n") !== right + ) { + throw new Error( + "diffr sources differ from Whiteboard's editor snapshots; reload the session.", + ); + } + const rows = structuralRows(diff); + // Changed-ness comes from the wire: a one-sided row, or a paired row whose line carries a changed span. + const highlights = structuralHighlights(diff); + const changedLeft = new Set(highlights.originalLines), + changedRight = new Set(highlights.modifiedLines); + const changes: DetailedLineRangeMapping[] = []; + let l = 0, + r = 0; + let start: [number, number] | undefined; + const flush = () => { + if (start) + changes.push( + new DetailedLineRangeMapping( + new LineRange(start[0] + 1, l + 1), + new LineRange(start[1] + 1, r + 1), + undefined, + ), + ); + start = undefined; + }; + for (const [a, b] of rows) { + const changed = + a === null || + b === null || + changedLeft.has(a + 1) || + changedRight.has(b + 1); + if (changed) start ??= [l, r]; + else flush(); + if (a !== null) l = a + 1; + if (b !== null) r = b + 1; + } + flush(); + return { + changes, + moves: [], + identical: left === right, + quitEarly: false, + sourceLineAlignment: rows, + contextScopes: structuralContextScopes(diff), + // Every collapsed region is hidden, labelled by the wire, as a band or on its header line. + // StructuralFoldControls draws the fold controls. + contextGaps: structuralContextGaps( + diff, + (id) => this.session.isRegionCollapsed(path!, id) === true, + (id) => this.session.isRegionCollapsed(path!, id), + ).map((gap) => ({ ...gap, foldControl: false, breadcrumbs: false })), + changeHighlights: highlights, + }; + } +} + +/** + * Keeps each structural diff editor's bands in step with the collapse state: + * a band a reader reveals (its arrows, or double-click) marks its fold state + * open, which covers both sides by construction, and the visible counts follow. + */ +function attachStructuralEditors( + instantiation: IInstantiationService, + entries: readonly ReviewFilesEditorEntry[], + session: StructuralDiffSession, + lifetime: DisposableStore, + viewed?: StructuralViewedState, +): void { + const editors = instantiation.invokeFunction((a) => + a.get(ICodeEditorService), + ); + const pairs = new Map( + entries.map((e) => [ + e.original!.toString() + "\n" + e.modified!.toString(), + e.file.path, + ]), + ); + const watched = lifetime.add( + new DisposableMap(), + ); + function watch(editor: IDiffEditor) { + const widget = editor as unknown as { + unchangedRegions?: IObservable; + }; + if (!widget.unchangedRegions) return; + const store = new DisposableStore(); + watched.set(editor, store); + const pathOf = () => { + const model = editor.getModel(); + return model + ? pairs.get( + model.original.uri.with({ fragment: "" }).toString() + + "\n" + + model.modified.uri.with({ fragment: "" }).toString(), + ) + : undefined; + }; + const hover = new StructuralFoldHover(); + store.add( + new StructuralFoldControls( + editor.getOriginalEditor(), + "lhs", + pathOf, + session, + widget.unchangedRegions, + hover, + viewed, + ), + ); + store.add( + new StructuralFoldControls( + editor.getModifiedEditor(), + "rhs", + pathOf, + session, + widget.unchangedRegions, + hover, + viewed, + ), + ); + store.add( + new StructuralSyntax( + editor.getOriginalEditor(), + "lhs", + pathOf, + session, + editor.onDidChangeModel, + ), + ); + store.add( + new StructuralSyntax( + editor.getModifiedEditor(), + "rhs", + pathOf, + session, + editor.onDidChangeModel, + ), + ); + let revealed = new Set(); + store.add( + autorun((reader) => { + const path = pathOf(); + const regions = widget.unchangedRegions!.read(reader); + if (!path || !session.getTextDiff(path)) return; + const gaps = structuralContextGaps( + session.getTextDiff(path)!, + (id) => session.isRegionCollapsed(path, id) === true, + (id) => session.isRegionCollapsed(path, id), + ); + const next = new Set(); + const gapOf = (region: UnchangedRegion) => + gaps.find( + (g) => + g.originalStart === region.originalLineNumber && + g.modifiedStart === region.modifiedLineNumber && + g.foldStateId === region.foldStateId, + ); + for (const region of regions) { + const shown = + region.visibleLineCountTop.read(reader) + + region.visibleLineCountBottom.read(reader); + const fullyShown = shown >= region.lineCount; + if (shown > 0 && !fullyShown && !region.band) { + // A fold on its header line has no band to show a partial reveal, so open it fully. + const gap = gapOf(region); + if (gap) session.setRegionCollapsed(path, gap.foldStateId, false); + continue; + } + if (fullyShown) { + next.add(region); + if (revealed.has(region)) continue; + const gap = gapOf(region); + if (!gap) continue; + session.setRegionCollapsed(path, gap.foldStateId, false); + } else if (revealed.has(region)) { + // Monaco's own fold control closed a region we had marked open. + const gap = gapOf(region); + if (!gap) continue; + session.setRegionCollapsed(path, gap.foldStateId, true); + } + } + revealed = next; + }), + ); + } + + lifetime.add( + editors.onDiffEditorRemove((editor) => watched.deleteAndDispose(editor)), + ); + lifetime.add(editors.onDiffEditorAdd(watch)); + for (const editor of editors.listDiffEditors()) watch(editor); +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts b/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts new file mode 100644 index 000000000..6416817e0 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralDiffSession.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Review Desktop's session reads one diffr stream in order. In the browser +// files are diffed on demand, nearest the reader first, so results arrive +// in any order through `setFileResult`; the views observe it the same way. +import { RunOnceScheduler } from "vs/base/common/async.js"; +import { CancellationError } from "vs/base/common/errors.js"; +import { Emitter } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; + +import type { StructuralDiff } from "../../protocol.js"; +import type { + StructuralRegion, + StructuralTextDiff, +} from "../common/reviewStructuralDiff.js"; + +export interface StructuralFileResult { + diff?: StructuralDiff; + error?: string; + hidden?: string; +} + +export interface StructuralSessionChange { + /** Files whose diff or fold state changed. */ + readonly files: ReadonlySet; + readonly status: boolean; +} + +/** One comparison. Views only observe it and change fold state. */ +export class StructuralDiffSession extends Disposable { + private readonly changed = this._register( + new Emitter(), + ); + readonly onDidChange = this.changed.event; + private readonly pendingFiles = new Set(); + private pendingStatus = false; + private readonly notification = this._register( + new RunOnceScheduler(() => this.flushChanges(), 16), + ); + private readonly results = new Map(); + private readonly folds = new Map(); + private disposed = false; + error: string | undefined; + get complete(): boolean { + return this.results.size === this.manifest.size; + } + + constructor(private readonly manifest: ReadonlySet) { + super(); + } + + getFileResult(path: string): StructuralFileResult | undefined { + return this.results.get(path); + } + /** Wait only for this file; a view can render while other files are diffed. */ + fileResult(path: string): Promise { + const current = this.results.get(path); + if (current) return Promise.resolve(current); + if (this.disposed) return Promise.reject(new CancellationError()); + return new Promise((resolve, reject) => { + const listener = this.onDidChange((change) => { + if (this.disposed) { + listener.dispose(); + reject(new CancellationError()); + return; + } + const result = change.files.has(path) + ? this.results.get(path) + : undefined; + if (result) { + listener.dispose(); + resolve(result); + } + }); + }); + } + getTextDiff(path: string): StructuralTextDiff | undefined { + const diff = this.results.get(path)?.diff; + return diff?.type === "text" ? diff : undefined; + } + isRegionCollapsed(path: string, id: number): boolean | undefined { + return this.folds.get(`${path}:${id}`); + } + setRegionCollapsed(path: string, id: number, collapsed: boolean): void { + const key = `${path}:${id}`; + if (this.disposed || this.folds.get(key) === collapsed) return; + this.folds.set(key, collapsed); + this.notify(path); + } + + /** One file's result, once: its diff, or why it has none. */ + setFileResult(path: string, result: StructuralFileResult): void { + if (this.disposed) return; + if (!this.manifest.has(path) || this.results.has(path)) + throw new Error(`Unexpected or repeated diffr result: ${path}`); + const diff = result.diff; + if (diff?.type === "text") { + const seed = (region: StructuralRegion) => { + const key = `${path}:${region.fold_state_id}`; + if (!this.folds.has(key)) + this.folds.set(key, region.visibility?.collapsed === true); + if (region.kind === "fold") region.children.forEach(seed); + }; + for (const side of [diff.lhs, diff.rhs]) if (side) seed(side.root); + } + const visibility = + diff?.type === "text" + ? [diff.rhs, diff.lhs].find((side) => side?.root.visibility?.collapsed) + ?.root.visibility + : undefined; + this.results.set(path, { + ...result, + hidden: + result.hidden ?? + (visibility ? visibility.label || "Hidden by default" : undefined), + }); + this.notify(path, this.complete); + } + + /** The comparison could not be loaded at all. */ + fail(error: string): void { + this.error = error; + this.notify(undefined, true); + } + + private notify(path?: string, status = false): void { + if (path) this.pendingFiles.add(path); + this.pendingStatus ||= status; + if (!this.notification.isScheduled()) this.notification.schedule(); + } + + private flushChanges(): void { + if (!this.pendingFiles.size && !this.pendingStatus) return; + const change: StructuralSessionChange = { + files: new Set(this.pendingFiles), + status: this.pendingStatus, + }; + this.pendingFiles.clear(); + this.pendingStatus = false; + this.changed.fire(change); + } + + override dispose(): void { + this.disposed = true; + this.changed.fire({ files: new Set(), status: true }); + this.results.clear(); + this.folds.clear(); + this.pendingFiles.clear(); + super.dispose(); + } +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts new file mode 100644 index 000000000..5e2a338d1 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -0,0 +1,764 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from "vs/base/common/codicons.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +import { + autorun, + observableValue, + type IObservable, +} from "vs/base/common/observable.js"; +import { ThemeIcon } from "vs/base/common/themables.js"; +import { + MouseTargetType, + type ICodeEditor, + type IOverlayWidget, + type IOverlayWidgetPosition, + type IEditorMouseEvent, +} from "vs/editor/browser/editorBrowser.js"; +import type { UnchangedRegion } from "vs/editor/browser/widget/diffEditor/diffEditorViewModel.js"; +import { EditorOption } from "vs/editor/common/config/editorOptions.js"; +import { CursorColumns } from "vs/editor/common/core/cursorColumns.js"; +import { Range } from "vs/editor/common/core/range.js"; +import { + InjectedTextCursorStops, + type IModelDeltaDecoration, + type ITextModel, +} from "vs/editor/common/model.js"; + +import { + bandDetail, + structuralFoldables, + type StructuralFoldable, + type StructuralTextDiff, +} from "../common/reviewStructuralDiff.js"; +import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; +import type { StructuralViewedState } from "./reviewStructuralViewed.js"; +import type { ScopeViewedControl } from "./reviewStructuralViewedControl.js"; + +/** The scope under the pointer. */ +export interface FoldTarget { + readonly foldable: StructuralFoldable; + /** The pointer is on the scope's rail. */ + readonly rail: boolean; + /** The pointer is on the scope's chevron, or on a folded scope's pill. */ + readonly control: boolean; + readonly collapsed: boolean; +} + +/** Transient presentation state shared only by the two panes of one file. */ +export class StructuralFoldHover { + readonly state = observableValue< + | { + readonly owner: StructuralFoldControls; + readonly path: string | undefined; + readonly target: FoldTarget | undefined; + readonly column: boolean; + } + | undefined + >("structuralFoldHover", undefined); +} + +const CHEVRON = "review-fold-chevron"; +const RAIL_CURSOR = "review-fold-rail"; +/** Marks the text after a folded header, so that a click on it unfolds the scope. */ +const PILL = Symbol("review-fold-pill"); + +/** + * Chevrons, rails and header-line folds for one side of a structural diff + * editor. The pointer's scope shows its chevron, rail and braces. A click on + * the chevron or the rail sets the scope's fold state in the session. + */ +export class StructuralFoldControls extends Disposable { + private readonly decorations = this.editor.createDecorationsCollection(); + private readonly foldables = new WeakMap< + StructuralTextDiff, + StructuralFoldable[] + >(); + private readonly rails = new ScopeRails(this.editor); + private hovered: FoldTarget | undefined; + private overChevronColumn = false; + private pressed: FoldTarget | undefined; + private readonly viewedControls = new Map(); + + constructor( + private readonly editor: ICodeEditor, + private readonly side: "lhs" | "rhs", + private readonly path: () => string | undefined, + private readonly session: StructuralDiffSession, + /** The regions the diff editor hides now. */ + private readonly regions: IObservable, + private readonly sharedHover: StructuralFoldHover, + private readonly viewed?: StructuralViewedState, + ) { + super(); + this._register({ + dispose: () => { + for (const control of this.viewedControls.values()) control.dispose(); + this.viewedControls.clear(); + }, + }); + if (viewed) this._register(viewed.onDidChange(() => this.render())); + this._register({ + dispose: () => { + if (this.sharedHover.state.get()?.owner === this) { + this.sharedHover.state.set(undefined, undefined); + } + this.decorations.clear(); + this.rails.hide(); + }, + }); + this._register( + editor.onMouseMove((e) => { + this.hover(this.targetAt(e), this.inChevronColumn(e)); + }), + ); + this._register(editor.onMouseLeave(() => this.hover(undefined))); + this._register( + editor.onMouseDown((e) => { + const target = e.event.leftButton ? this.targetAt(e) : undefined; + this.pressed = target?.rail || target?.control ? target : undefined; + if (this.pressed) { + e.event.preventDefault(); + } + }), + ); + this._register( + editor.onMouseUp((e) => { + const pressed = this.pressed; + this.pressed = undefined; + const target = this.targetAt(e); + if ( + pressed && + target?.foldable === pressed.foldable && + target.rail === pressed.rail && + target.control === pressed.control + ) { + this.toggle(pressed); + } + }), + ); + this._register( + editor.onDidChangeModel(() => { + this.hovered = undefined; + this.render(); + }), + ); + // 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.onDidLayoutChange(() => this.render())); + this._register(editor.onDidChangeViewZones(() => this.render())); + this._register(editor.onDidChangeConfiguration(() => this.render())); + // The pills follow what the editor hides, not the fold state. + this._register( + autorun((reader) => { + this.sharedHover.state.read(reader); + for (const region of regions.read(reader)) { + region.visibleLineCountTop.read(reader); + region.visibleLineCountBottom.read(reader); + } + this.render(); + }), + ); + this._register( + session.onDidChange((change) => { + const path = this.path(); + if (path && change.files.has(path)) { + this.hovered = undefined; + this.render(); + } + }), + ); + this.render(); + } + + private toggle(target: FoldTarget): void { + const path = this.path(); + if (!path) { + return; + } + // A rail click puts the caret in the body, and a caret in hidden lines reveals them. + if (target.rail) { + this.editor.setPosition({ + lineNumber: target.foldable.line + 1, + column: 1, + }); + } + this.session.setRegionCollapsed( + path, + target.foldable.foldStateId, + !target.collapsed, + ); + } + + private inChevronColumn(e: IEditorMouseEvent): boolean { + const node = this.editor.getDomNode(); + if (!node) { + return false; + } + const bounds = node.getBoundingClientRect(); + const x = + ((e.event.browserEvent.clientX - bounds.left) * node.offsetWidth) / + bounds.width; + const layout = this.editor.getLayoutInfo(); + return x >= layout.decorationsLeft && x < layout.contentLeft; + } + + private hover( + target: FoldTarget | undefined, + overChevronColumn = false, + ): void { + const hovered = this.hovered; + if ( + this.overChevronColumn === overChevronColumn && + hovered?.foldable === target?.foldable && + hovered?.rail === target?.rail && + hovered?.control === target?.control + ) { + return; + } + this.hovered = target; + this.overChevronColumn = overChevronColumn; + if (target || overChevronColumn) { + this.sharedHover.state.set( + { owner: this, path: this.path(), target, column: overChevronColumn }, + undefined, + ); + } else if (this.sharedHover.state.get()?.owner === this) { + this.sharedHover.state.set(undefined, undefined); + } + } + + private render(): void { + const model = this.editor.getModel(); + const path = this.path(); + const diff = path ? this.session.getTextDiff(path) : undefined; + const hover = this.sharedHover.state.get(); + const shared = hover?.path === path ? hover : undefined; + const target = shared?.target; + this.editor.getDomNode()?.classList.toggle(RAIL_CURSOR, !!target?.rail); + if (!model || !path || !diff) { + this.decorations.clear(); + this.rails.hide(); + for (const control of this.viewedControls.values()) control.hide(); + return; + } + const decorations: IModelDeltaDecoration[] = []; + for (const range of this.viewed?.getViewedRanges(path) ?? []) { + if (range.side !== (this.side === "rhs" ? "head" : "base")) continue; + decorations.push({ + range: new Range( + range.fromLine, + 1, + range.toLine, + model.getLineMaxColumn(range.toLine), + ), + options: { + description: "review-scope-viewed", + isWholeLine: true, + inlineClassName: "review-scope-viewed-ink", + className: "review-scope-viewed-tint", + zIndex: 5, + }, + }); + } + for (const foldable of this.foldablesOf(diff)) { + // A function and its doc comment can share one fold state. Every + // member participates in hover, rather than only the first match. + const active = target?.foldable.foldStateId === foldable.foldStateId; + if (this.isFolded(foldable)) { + decorations.push(...this.folded(model, foldable, active)); + } else if (active) { + decorations.push( + ...this.lit(model, { + ...target!, + foldable, + collapsed: this.isSummaryFolded(foldable), + }), + ); + } else if ( + shared?.column && + foldable.chevron && + (!this.isCollapsed(path, foldable) || this.isSummaryFolded(foldable)) + ) { + decorations.push( + this.chevron(foldable, this.isSummaryFolded(foldable), false), + ); + } + } + const hidden = this.regions + .get() + .map((region) => + this.side === "rhs" + ? region.getHiddenModifiedRange(undefined) + : region.getHiddenOriginalRange(undefined), + ); + const visibleScopes = this.foldablesOf(diff).filter( + (foldable) => + foldable.rail && + !this.isFolded(foldable) && + !hidden.some((range) => range.contains(foldable.line + 1)), + ); + this.rails.show(model, visibleScopes, target); + 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< + number, + { target: FoldTarget; progress: ReturnType } + >(); + const completed = this.foldablesOf(diff).filter( + (scope) => + scope.rail && + 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))) + continue; + const active = scope.foldStateId === target?.foldable.foldStateId; + const progress = this.viewed?.get(path, scope.foldStateId); + if ( + !progress || + progress.total.additions + progress.total.deletions === 0 + ) + continue; + if (!active && progress.state !== "viewed") continue; + // A viewed function already accounts for its viewed return/body folds. + // Keep their actions on hover, but don't stack persistent completion badges. + if ( + !active && + completed.some( + (outer) => + outer.line < scope.line && outer.rail!.end >= scope.rail!.end, + ) + ) + continue; + const top = + this.editor.getTopForLineNumber(scope.line + 1) - + this.editor.getScrollTop(); + if (top < 0 || top >= this.editor.getLayoutInfo().height) continue; + if (active || !wanted.has(scope.line)) + wanted.set(scope.line, { + target: { + foldable: scope, + collapsed: this.isFolded(scope), + rail: false, + control: false, + }, + progress, + }); + } + for (const [line, control] of this.viewedControls) { + if (!wanted.has(line)) { + control.dispose(); + this.viewedControls.delete(line); + } + } + for (const [line, entry] of wanted) { + let control = this.viewedControls.get(line); + if (!control) { + control = this.viewed!.createControl(this.editor, (target) => { + const path = this.path(); + if (!path) return; + // Keep the caret out of the body that the viewed mark will fold. + this.editor.setPosition({ + lineNumber: target.foldable.line + 1, + column: 1, + }); + void this.viewed!.toggle(path, target.foldable.foldStateId); + }); + this.viewedControls.set(line, control); + } + control.show(entry.target, entry.progress); + } + } + + /** A folded scope on its header line: a chevron to unfold it, then `⋯ N lines` and the closer. */ + private folded( + model: ITextModel, + foldable: StructuralFoldable, + targeted: boolean, + ): IModelDeltaDecoration[] { + const line = foldable.line + 1; + const end = model.getLineMaxColumn(line); + const inline = foldable.inline!; + const viewed = + this.viewed?.get(this.path()!, foldable.foldStateId).state === "viewed"; + return [ + this.chevron(foldable, true, targeted), + { + range: new Range(line, end, line, end), + options: { + description: "review-fold-pill", + // Monaco drops text on an empty range without this. + showIfCollapsed: true, + after: { + content: `${viewed ? "✓" : "⋯"} ${inline.label}`, + inlineClassName: `review-fold-pill${targeted ? " is-target" : ""}${viewed ? " is-viewed" : ""}`, + cursorStops: InjectedTextCursorStops.None, + attachedData: PILL, + }, + }, + }, + { + range: new Range(line, end, line, end), + options: { + description: "review-fold-closer", + showIfCollapsed: true, + after: { + content: inline.closer, + inlineClassName: "review-fold-closer", + cursorStops: InjectedTextCursorStops.None, + attachedData: PILL, + }, + }, + }, + ]; + } + + /** An open scope the pointer is in: its chevron and braces, and when targeted, a fold hint. */ + private lit(model: ITextModel, target: FoldTarget): IModelDeltaDecoration[] { + const { foldable } = target; + const line = foldable.line + 1; + const targeted = target.rail || target.control; + const state = targeted ? " is-target" : ""; + const result: IModelDeltaDecoration[] = foldable.chevron + ? [this.chevron(foldable, target.collapsed, targeted)] + : []; + for (const brace of foldable.braces + ? [foldable.braces.opener, foldable.braces.closer] + : []) { + result.push({ + range: new Range( + brace.line, + brace.column, + brace.line, + brace.column + 1, + ), + options: { + description: "review-scope-brace", + inlineClassName: `review-scope-brace${state}`, + }, + }); + } + if (targeted && foldable.rail) { + const lines = foldable.rail.end - foldable.rail.start; + const end = model.getLineMaxColumn(line); + result.push({ + range: new Range(line, end, line, end), + options: { + description: "review-fold-hint", + showIfCollapsed: true, + after: { + content: `click · ${target.collapsed ? "expand" : "fold"} ${lines} line${lines === 1 ? "" : "s"}`, + inlineClassName: "review-fold-hint", + cursorStops: InjectedTextCursorStops.None, + }, + }, + }); + } + return result; + } + + private chevron( + foldable: StructuralFoldable, + collapsed: boolean, + targeted: boolean, + ): IModelDeltaDecoration { + const line = foldable.line + 1; + return { + range: new Range(line, 1, line, 1), + options: { + description: CHEVRON, + linesDecorationsClassName: `${CHEVRON} ${ThemeIcon.asClassName(collapsed ? Codicon.chevronRight : Codicon.chevronDown)}${collapsed ? " is-collapsed" : ""}${targeted ? " is-target" : ""}`, + }, + }; + } + + private targetAt(e: IEditorMouseEvent): FoldTarget | undefined { + for (const control of this.viewedControls.values()) { + const viewed = control.targetOf( + e.event.browserEvent.target as HTMLElement | null, + ); + if (viewed) return { ...viewed, rail: false, control: false }; + } + const model = this.editor.getModel(); + const path = this.path(); + const diff = path ? this.session.getTextDiff(path) : undefined; + const summary = ( + e.event.browserEvent.target as HTMLElement | null + )?.closest?.("[data-summary-fold-state-id]"); + if (summary && diff) { + const foldable = this.foldablesOf(diff).find( + (f) => + f.rail && + f.foldStateId === + Number(summary.getAttribute("data-summary-fold-state-id")), + ); + if (foldable) + return { foldable, rail: true, control: false, collapsed: true }; + } + const position = e.target.position; + const rail = this.rails.targetOf( + e.event.browserEvent.target as HTMLElement | null, + ); + if (rail) { + return { + foldable: rail, + rail: true, + control: false, + collapsed: this.isSummaryFolded(rail), + }; + } + if (!model || !path || !diff || !position) { + return undefined; + } + const line = position.lineNumber - 1; + const foldables = this.foldablesOf(diff); + const content = + e.target.type === MouseTargetType.CONTENT_TEXT || + e.target.type === MouseTargetType.CONTENT_EMPTY; + const gutter = + e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN || + e.target.type === MouseTargetType.GUTTER_LINE_NUMBERS || + e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS; + if (!content && !gutter) { + return undefined; + } + const onChevron = this.inChevronColumn(e); + + // A folded scope's header: its chevron and its pill unfold it. + const folded = foldables.find( + (f) => f.line === line && (this.isFolded(f) || this.isSummaryFolded(f)), + ); + if (folded) { + const onPill = + e.target.type === MouseTargetType.CONTENT_TEXT && + e.target.detail.injectedText?.options.attachedData === PILL; + return { + foldable: folded, + rail: false, + control: onChevron || onPill, + collapsed: true, + }; + } + + const open = foldables.filter((f) => !this.isCollapsed(path, f)); + const railsHere = open + .filter((f) => f.rail && f.rail.start <= line && line <= f.rail.end) + .sort( + (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), + ); + if ( + content && + e.target.mouseColumn - 1 < leadingWidth(model, position.lineNumber) + ) { + const column = e.target.mouseColumn - 1; + const rail = railsHere.find( + (f) => leadingWidth(model, f.line + 1) === column, + ); + if (rail) { + return { + foldable: rail, + rail: true, + control: false, + collapsed: this.isSummaryFolded(rail), + }; + } + } + const header = open.find((f) => f.chevron && f.line === line); + if (header) { + return { + foldable: header, + rail: false, + control: onChevron, + collapsed: false, + }; + } + // A blank spot in the chevron column reveals controls without lighting a scope. + if (this.inChevronColumn(e)) { + return undefined; + } + // Inside a body, the innermost rail's scope. + return railsHere[0] + ? { + foldable: railsHere[0], + rail: false, + control: false, + collapsed: false, + } + : undefined; + } + + private foldablesOf(diff: StructuralTextDiff): StructuralFoldable[] { + let foldables = this.foldables.get(diff); + if (!foldables) { + foldables = structuralFoldables(diff[this.side]); + this.foldables.set(diff, foldables); + } + return foldables; + } + + private isCollapsed(path: string, foldable: StructuralFoldable): boolean { + return ( + this.session.isRegionCollapsed(path, foldable.foldStateId) ?? + foldable.collapsedByDefault + ); + } + + private isSummaryFolded(foldable: StructuralFoldable): boolean { + return ( + !!foldable.rail && + this.regions.get().some((region) => { + if ( + region.foldStateId !== foldable.foldStateId || + !bandDetail(region.readLabel(undefined) ?? "") + ) + return false; + return !( + this.side === "rhs" + ? region.getHiddenModifiedRange(undefined) + : region.getHiddenOriginalRange(undefined) + ).isEmpty; + }) + ); + } + + /** Whether the editor hides the scope's body with no band, so the header shows the fold. */ + private isFolded(foldable: StructuralFoldable): boolean { + return ( + !!foldable.inline && + this.regions.get().some((region) => { + if (region.band || region.foldStateId !== foldable.foldStateId) { + return false; + } + const hidden = + this.side === "rhs" + ? region.getHiddenModifiedRange(undefined) + : region.getHiddenOriginalRange(undefined); + return hidden.startLineNumber === foldable.line + 2 && !hidden.isEmpty; + }) + ); + } +} + +/** Scope rails share one clipped overlay, including the gaps occupied by view zones. */ +class ScopeRails implements IOverlayWidget { + readonly allowEditorOverflow = false; + private readonly node: HTMLElement; + private readonly targets = new WeakMap(); + private readonly segments = new Map(); + private added = false; + + constructor(private readonly editor: ICodeEditor) { + this.node = editor.getContainerDomNode().ownerDocument.createElement("div"); + this.node.className = "review-scope-rails"; + } + + getId(): string { + return "review.scopeRails"; + } + getDomNode(): HTMLElement { + return this.node; + } + targetOf(element: HTMLElement | null): StructuralFoldable | undefined { + const rail = element?.closest?.(".review-scope-rail"); + return rail ? this.targets.get(rail) : undefined; + } + + getPosition(): IOverlayWidgetPosition { + return { + preference: { top: 0, left: this.editor.getLayoutInfo().contentLeft }, + }; + } + + show( + model: ITextModel, + foldables: readonly StructuralFoldable[], + target: FoldTarget | undefined, + ): void { + const layout = this.editor.getLayoutInfo(); + const scrollTop = this.editor.getScrollTop(); + const scrollLeft = this.editor.getScrollLeft(); + const spaceWidth = this.editor.getOption(EditorOption.fontInfo).spaceWidth; + this.node.style.width = `${layout.contentWidth}px`; + this.node.style.height = `${layout.height}px`; + const segments: HTMLElement[] = []; + for (const foldable of foldables) { + // Start after the header, not at the first body line: it may be hidden + // behind a band, which Monaco maps back onto the header itself. + const top = Math.max( + 0, + this.editor.getBottomForLineNumber(foldable.line + 1) - scrollTop, + ); + const bottom = Math.min( + layout.height, + this.editor.getTopForLineNumber(foldable.rail!.end + 1) - scrollTop, + ); + const left = + Math.round(leadingWidth(model, foldable.line + 1) * spaceWidth) - + scrollLeft; + if ( + bottom <= top || + !Number.isFinite(left) || + left < 0 || + left >= layout.contentWidth + ) { + continue; + } + let segment = this.segments.get(foldable); + if (!segment) { + segment = this.node.ownerDocument.createElement("div"); + this.segments.set(foldable, segment); + } + const active = target?.foldable.foldStateId === foldable.foldStateId; + segment.className = `review-scope-rail${active ? " is-active" : ""}${active && (target.rail || target.control) ? " is-target" : ""}`; + this.targets.set(segment, foldable); + segment.style.top = `${top}px`; + segment.style.left = `${left}px`; + segment.style.height = `${bottom - top}px`; + segments.push(segment); + } + for (const [foldable, segment] of this.segments) { + if (!segments.includes(segment)) { + this.segments.delete(foldable); + } + } + // Keep the hovered element attached while its paint changes. + if ( + segments.length !== this.node.children.length || + segments.some((segment, i) => this.node.children[i] !== segment) + ) { + this.node.replaceChildren(...segments); + } + if (this.added) { + this.editor.layoutOverlayWidget(this); + } else { + this.added = true; + this.editor.addOverlayWidget(this); + } + } + + hide(): void { + if (this.added) { + this.added = false; + this.editor.removeOverlayWidget(this); + } + } +} + +/** The visible width of a line's indent. A blank line has no limit, so rails continue through it. */ +function leadingWidth(model: ITextModel, lineNumber: number): number { + const column = model.getLineFirstNonWhitespaceColumn(lineNumber); + if (column === 0) { + return Number.POSITIVE_INFINITY; + } + return CursorColumns.visibleColumnFromColumn( + model.getLineContent(lineNumber), + column, + model.getOptions().tabSize, + ); +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts new file mode 100644 index 000000000..fc07aacc3 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Review Desktop colours code with its TextMate grammars. The page has none: +// diffr already parsed each side, so its highlight captures paint the text, +// in the Whiteboard theme's roles (extensions/review-themes). +import type { Event } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; +import { Range } from "vs/editor/common/core/range.js"; +import type { IModelDeltaDecoration } from "vs/editor/common/model.js"; + +import { + utf16Column, + type StructuralSyntaxSpan, +} from "../common/reviewStructuralDiff.js"; +import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; + +type Role = + | "comment" + | "keyword" + | "type" + | "function" + | "variable" + | "string" + | "number" + | "constant" + | "operator" + | "heading"; + +/** Most specific first: a capture takes the first rule its dotted name starts with. */ +const RULES: readonly (readonly [string, Role])[] = [ + ["comment", "comment"], + ["string.special.symbol", "constant"], + ["string", "string"], + ["character", "string"], + ["escape", "number"], + ["number", "number"], + ["float", "number"], + ["boolean", "constant"], + ["constant.builtin", "constant"], + ["constant", "constant"], + ["keyword.operator", "operator"], + ["keyword", "keyword"], + ["conditional", "keyword"], + ["repeat", "keyword"], + ["include", "keyword"], + ["exception", "keyword"], + ["storageclass", "keyword"], + ["preproc", "keyword"], + ["define", "keyword"], + ["operator", "operator"], + ["punctuation", "operator"], + ["variable.builtin", "keyword"], + ["variable", "variable"], + ["property", "variable"], + ["field", "variable"], + ["label", "variable"], + ["function", "function"], + ["method", "function"], + ["attribute", "function"], + ["constructor", "type"], + ["type", "type"], + ["module", "type"], + ["namespace", "type"], + ["tag", "type"], + ["markup.heading", "heading"], + ["text.title", "heading"], +]; + +function role(capture: string): Role | undefined { + for (const [prefix, role] of RULES) { + if (capture === prefix || capture.startsWith(prefix + ".")) return role; + } + return undefined; +} + +/** Paints one side of a structural diff editor with diffr's highlight captures. */ +export class StructuralSyntax extends Disposable { + private readonly decorations = this.editor.createDecorationsCollection(); + private painted: readonly StructuralSyntaxSpan[] | undefined; + + constructor( + private readonly editor: ICodeEditor, + private readonly side: "lhs" | "rhs", + private readonly path: () => string | undefined, + private readonly session: StructuralDiffSession, + /** The diff editor's model is set after its sides', and only it names the file. */ + onDidChangePath: Event, + ) { + super(); + this._register({ dispose: () => this.decorations.clear() }); + this._register(editor.onDidChangeModel(() => this.render())); + this._register(onDidChangePath(() => this.render())); + this._register( + session.onDidChange((change) => { + const path = this.path(); + if (path && change.files.has(path)) this.render(); + }), + ); + this.render(); + } + + private render(): void { + const model = this.editor.getModel(); + const path = this.path(); + const source = path + ? this.session.getTextDiff(path)?.[this.side] + : undefined; + const spans = source?.syntax; + if (!model || !spans) { + this.painted = undefined; + this.decorations.clear(); + return; + } + if (spans === this.painted) return; + this.painted = spans; + const lines = model.getLinesContent(); + const decorations: IModelDeltaDecoration[] = []; + for (const span of spans) { + const kind = role(span.capture); + const text = lines[span.line]; + if (!kind || text === undefined) continue; + decorations.push({ + range: new Range( + span.line + 1, + utf16Column(text, span.start_column), + span.line + 1, + utf16Column(text, span.end_column), + ), + options: { + description: "diffr-syntax", + inlineClassName: `diffr-syntax-${kind}`, + }, + }); + } + this.decorations.set(decorations); + } +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewed.ts b/apps/diffr-web/src/review/services/reviewStructuralViewed.ts new file mode 100644 index 000000000..a71120ebb --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralViewed.ts @@ -0,0 +1,167 @@ +import { Emitter, type Event } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; + +import type { ReviewDiffProgress, ReviewDiffViewSpec } from "../../protocol.js"; +import { + structuralViewedProgress, + structuralViewedRanges, + structuralViewedScopes, +} from "../common/reviewStructuralViewed.js"; +import type { ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; +import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; +import type { FoldTarget } from "./reviewStructuralFolds.js"; +import type { ScopeViewedControl } from "./reviewStructuralViewedControl.js"; + +// Several peeks can mount on one comparison. Only its first coverage snapshot +// restores viewed folds; mounting another peek must not close a reopened fold. +const initializedScopes = new WeakMap>(); + +/** Viewed marks persist as line coverage; opening a fold only changes session fold state. */ +export class StructuralViewedState extends Disposable { + private readonly changed = this._register(new Emitter()); + readonly onDidChange = this.changed.event; + private readonly scopes = new Map< + string, + ReturnType + >(); + private readonly states = new Map(); + private pending = false; + private requestedFold: + | { path: string; id: number; viewed: boolean } + | undefined; + + constructor( + private readonly session: StructuralDiffSession, + private readonly entries: readonly ReviewFilesEditorEntry[], + private readonly progress: () => ReviewDiffProgress | undefined, + onProgress: Event, + private readonly onSetViewed: NonNullable< + ReviewDiffViewSpec["onSetViewed"] + >, + readonly createControl: ( + editor: ICodeEditor, + onToggle: (target: FoldTarget) => void, + ) => ScopeViewedControl, + ) { + super(); + this._register(onProgress(() => this.sync())); + this._register(session.onDidChange(() => this.sync())); + this.sync(); + } + + private getScopes(path: string) { + let scopes = this.scopes.get(path); + const diff = this.session.getTextDiff(path); + if (!scopes && diff) { + scopes = structuralViewedScopes( + diff, + path, + this.entries.find((e) => e.file.path === path)?.file.previousPath, + ); + this.scopes.set(path, scopes); + } + return scopes; + } + + get(path: string, id: number) { + return structuralViewedProgress( + this.getScopes(path)?.get(id) ?? [], + this.progress()?.files.find((f) => f.path === path), + ); + } + + getViewedRanges(path: string) { + return structuralViewedRanges( + this.getScopes(path), + this.progress()?.files.find((f) => f.path === path), + ); + } + + async toggle(path: string, id: number): Promise { + const ranges = this.getScopes(path)?.get(id); + if (!ranges || this.pending) return; + const viewed = this.get(path, id).state !== "viewed"; + this.pending = true; + this.requestedFold = { path, id, viewed }; + try { + await this.onSetViewed(ranges, viewed); + // Fold only the box the reader checked, not every newly covered + // descendant. Reopening should recover the body's previous folds. + this.applyRequestedFold(); + } catch (error) { + this.requestedFold = undefined; + throw error; + } finally { + this.pending = false; + } + } + + private sync(): void { + const initialized = + initializedScopes.get(this.session) ?? new Set(); + initializedScopes.set(this.session, initialized); + for (const entry of this.entries) { + const path = entry.file.path; + if (!this.progress()?.files.some((f) => f.path === path)) continue; + const scopes = this.getScopes(path); + const viewed = [...(scopes ?? [])].filter( + ([id]) => this.get(path, id).state === "viewed", + ); + for (const [id, ranges] of scopes ?? []) { + const state = this.get(path, id).state; + const key = `${path}:${id}`, + previous = this.states.get(key); + this.states.set(key, state); + // Only transitions (and initial load) fold a viewed scope. Reopening + // it must survive unrelated progress updates and layout changes. + const enclosed = viewed.some( + ([other, outer]) => + other !== id && + ranges.every((r) => + outer.some( + (o) => + r.side === o.side && + r.file === o.file && + o.fromLine <= r.fromLine && + o.toLine >= r.toLine, + ), + ) && + !outer.every((o) => + ranges.some( + (r) => + r.side === o.side && + r.file === o.file && + r.fromLine <= o.fromLine && + r.toLine >= o.toLine, + ), + ), + ); + if (state === "viewed" && !initialized.has(key) && !enclosed) + this.session.setRegionCollapsed(path, id, true); + else if (previous === "viewed" && state !== "viewed") + this.session.setRegionCollapsed(path, id, false); + initialized.add(key); + } + } + this.applyRequestedFold(); + this.changed.fire(); + } + + private applyRequestedFold(): void { + const request = this.requestedFold; + // React may deliver the new coverage after the mutation promise resolves. + // Wait for that coverage, rather than folding optimistically on a failed save. + if ( + request && + (this.get(request.path, request.id).state === "viewed") === request.viewed + ) { + this.requestedFold = undefined; + this.session.setRegionCollapsed(request.path, request.id, request.viewed); + } + } +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts new file mode 100644 index 000000000..315ae5253 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts @@ -0,0 +1,127 @@ +import { Disposable } from "vs/base/common/lifecycle.js"; +import type { + ICodeEditor, + IOverlayWidget, + IOverlayWidgetPosition, +} from "vs/editor/browser/editorBrowser.js"; +import { EditorOption } from "vs/editor/common/config/editorOptions.js"; +import type { IHoverService } from "vs/platform/hover/browser/hover.js"; + +import { ReviewViewedCheckbox } from "../browser/reviewTooltip.js"; +import type { FoldTarget } from "./reviewStructuralFolds.js"; +import type { StructuralViewedState } from "./reviewStructuralViewed.js"; + +/** A scope header action, persistent when viewed and otherwise shown on hover. */ +export class ScopeViewedControl extends Disposable implements IOverlayWidget { + private static nextId = 0; + private readonly id = `review.scopeViewed.${ScopeViewedControl.nextId++}`; + readonly allowEditorOverflow = false; + private readonly node: HTMLElement; + private readonly additions: HTMLElement; + private readonly deletions: HTMLElement; + private readonly status: HTMLElement; + private readonly check: ReviewViewedCheckbox; + private target: FoldTarget | undefined; + private added = false; + private top = 0; + + constructor( + private readonly editor: ICodeEditor, + hoverService: IHoverService, + onToggle: (target: FoldTarget) => void, + ) { + super(); + const document = editor.getContainerDomNode().ownerDocument; + this.node = document.createElement("div"); + this.node.className = "review-scope-viewed-control"; + this.additions = document.createElement("span"); + this.additions.className = "review-multidiff-additions"; + this.deletions = document.createElement("span"); + this.deletions.className = "review-multidiff-deletions"; + this.status = document.createElement("span"); + this.status.className = "review-scope-viewed-status"; + this.check = this._register( + new ReviewViewedCheckbox(hoverService, document, () => { + if (this.target) onToggle(this.target); + }), + ); + this.node.append( + this.additions, + this.deletions, + this.status, + this.check.element, + ); + this._register({ dispose: () => this.hide() }); + } + + getId(): string { + return this.id; + } + getDomNode(): HTMLElement { + return this.node; + } + getPosition(): IOverlayWidgetPosition { + const layout = this.editor.getLayoutInfo(); + return { + preference: { + top: this.top, + left: + layout.contentLeft + layout.contentWidth - this.node.offsetWidth - 12, + }, + }; + } + targetOf(element: HTMLElement | null): FoldTarget | undefined { + return element && this.node.contains(element) ? this.target : undefined; + } + + show( + target: FoldTarget, + progress: ReturnType, + ): void { + // Unified diffs retain a narrow original editor for their gutter. + // It has no code area and must not render a second viewed checkbox. + if (this.editor.getLayoutInfo().width < 60) { + this.hide(); + return; + } + const line = target.foldable.line + 1; + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + const height = Math.max(22, lineHeight); + this.top = + this.editor.getTopForLineNumber(line) - + this.editor.getScrollTop() - + (height - lineHeight) / 2; + if ( + this.top < 0 || + this.top + height > this.editor.getLayoutInfo().height + ) { + this.hide(); + return; + } + this.target = target; + this.node.style.height = `${height}px`; + this.additions.textContent = progress.remaining.additions + ? `+${progress.remaining.additions}` + : ""; + this.deletions.textContent = progress.remaining.deletions + ? `−${progress.remaining.deletions}` + : ""; + this.status.textContent = progress.state === "viewed" ? "Viewed" : ""; + const subject = + this.editor.getModel()?.getLineContent(line).trim() ?? "scope"; + this.check.update(progress.state, subject, false); + if (!this.added) { + this.added = true; + this.editor.addOverlayWidget(this); + } + this.editor.layoutOverlayWidget(this); + } + + hide(): void { + this.target = undefined; + if (this.added) { + this.added = false; + this.editor.removeOverlayWidget(this); + } + } +} diff --git a/apps/diffr-web/src/settings.ts b/apps/diffr-web/src/settings.ts new file mode 100644 index 000000000..5c45ff9a5 --- /dev/null +++ b/apps/diffr-web/src/settings.ts @@ -0,0 +1,19 @@ +/** Per-browser preferences. Storage can be missing or refuse (private windows), so every access may fail quietly. */ +const PREFIX = "diffr."; + +export function readSetting(key: string): string | undefined { + try { + return localStorage.getItem(PREFIX + key) ?? undefined; + } catch { + return undefined; + } +} + +export function writeSetting(key: string, value: string | undefined): void { + try { + if (value === undefined) localStorage.removeItem(PREFIX + key); + else localStorage.setItem(PREFIX + key, value); + } catch { + // Not remembered. + } +} diff --git a/apps/diffr-web/src/standalone/browser/clipboardService.ts b/apps/diffr-web/src/standalone/browser/clipboardService.ts new file mode 100644 index 000000000..8f48e118f --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/clipboardService.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// The fork dropped VS Code's browser clipboard service; a read-only diff +// page needs only copying text out. +import type { URI } from "vs/base/common/uri.js"; +import type { IClipboardService } from "vs/platform/clipboard/common/clipboardService.js"; + +export class BrowserClipboardService implements IClipboardService { + declare readonly _serviceBrand: undefined; + private findText = ""; + + triggerPaste(): Promise | undefined { + return undefined; + } + async writeText(text: string): Promise { + await navigator.clipboard?.writeText(text); + } + async readText(): Promise { + return (await navigator.clipboard?.readText()) ?? ""; + } + async readFindText(): Promise { + return this.findText; + } + async writeFindText(text: string): Promise { + this.findText = text; + } + async writeResources(_resources: URI[]): Promise {} + async readResources(): Promise { + return []; + } + async hasResources(): Promise { + return false; + } + async readImage(): Promise { + return new Uint8Array(); + } +} diff --git a/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInput.css b/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInput.css new file mode 100644 index 000000000..52c763be7 --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInput.css @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.quick-input-widget { + font-size: 13px; +} + +.quick-input-widget .monaco-highlighted-label .highlight, +.quick-input-widget .monaco-highlighted-label .highlight { + color: #0066bf; +} + +.vs + .quick-input-widget + .monaco-list-row.focused + .monaco-highlighted-label + .highlight, +.vs + .quick-input-widget + .monaco-list-row.focused + .monaco-highlighted-label + .highlight { + color: #9dddff; +} + +.vs-dark .quick-input-widget .monaco-highlighted-label .highlight, +.vs-dark .quick-input-widget .monaco-highlighted-label .highlight { + color: #0097fb; +} + +.hc-black .quick-input-widget .monaco-highlighted-label .highlight, +.hc-black .quick-input-widget .monaco-highlighted-label .highlight { + color: #f38518; +} + +.hc-light .quick-input-widget .monaco-highlighted-label .highlight, +.hc-light .quick-input-widget .monaco-highlighted-label .highlight { + color: #0f4a85; +} + +.monaco-keybinding > .monaco-keybinding-key { + background-color: rgba(221, 221, 221, 0.4); + border: solid 1px rgba(204, 204, 204, 0.4); + border-bottom-color: rgba(187, 187, 187, 0.4); + box-shadow: inset 0 -1px 0 rgba(187, 187, 187, 0.4); + color: #555; +} + +.hc-black .monaco-keybinding > .monaco-keybinding-key { + background-color: transparent; + border: solid 1px rgb(111, 195, 223); + box-shadow: none; + color: #fff; +} + +.hc-light .monaco-keybinding > .monaco-keybinding-key { + background-color: transparent; + border: solid 1px #0f4a85; + box-shadow: none; + color: #292929; +} + +.vs-dark .monaco-keybinding > .monaco-keybinding-key { + background-color: rgba(128, 128, 128, 0.17); + border: solid 1px rgba(51, 51, 51, 0.6); + border-bottom-color: rgba(68, 68, 68, 0.6); + box-shadow: inset 0 -1px 0 rgba(68, 68, 68, 0.6); + color: #ccc; +} diff --git a/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInputService.ts b/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInputService.ts new file mode 100644 index 000000000..61c791ca9 --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/quickInput/standaloneQuickInputService.ts @@ -0,0 +1,319 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import "./standaloneQuickInput.css"; +import { CancellationToken } from "vs/base/common/cancellation.js"; +import { Event } from "vs/base/common/event.js"; +import { createSingleCallFunction } from "vs/base/common/functional.js"; +import { + ICodeEditor, + IOverlayWidget, + IOverlayWidgetPosition, +} from "vs/editor/browser/editorBrowser.js"; +import { + EditorContributionInstantiation, + registerEditorContribution, +} from "vs/editor/browser/editorExtensions.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; +import { IEditorContribution } from "vs/editor/common/editorCommon.js"; +import { IConfigurationService } from "vs/platform/configuration/common/configuration.js"; +import { IContextKeyService } from "vs/platform/contextkey/common/contextkey.js"; +import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; +import { + QuickInputController, + IQuickInputControllerHost, +} from "vs/platform/quickinput/browser/quickInputController.js"; +import { QuickInputService } from "vs/platform/quickinput/browser/quickInputService.js"; +import { + IQuickInputService, + IQuickPickItem, + IQuickPick, + IInputBox, + IQuickNavigateConfiguration, + IPickOptions, + QuickPickInput, + IInputOptions, + IQuickWidget, + IQuickTree, + IQuickTreeItem, +} from "vs/platform/quickinput/common/quickInput.js"; +import { IThemeService } from "vs/platform/theme/common/themeService.js"; + +import { EditorScopedLayoutService } from "../standaloneLayoutService.js"; + +class EditorScopedQuickInputService extends QuickInputService { + private host: IQuickInputControllerHost | undefined = undefined; + + constructor( + editor: ICodeEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @ICodeEditorService codeEditorService: ICodeEditorService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super( + instantiationService, + contextKeyService, + themeService, + new EditorScopedLayoutService( + editor.getContainerDomNode(), + codeEditorService, + ), + configurationService, + ); + + // Use the passed in code editor as host for the quick input widget + const contribution = QuickInputEditorContribution.get(editor); + if (contribution) { + const widget = contribution.widget; + this.host = { + _serviceBrand: undefined, + get mainContainer() { + return widget.getDomNode(); + }, + getContainer() { + return widget.getDomNode(); + }, + whenContainerStylesLoaded() { + return undefined; + }, + get containers() { + return [widget.getDomNode()]; + }, + get activeContainer() { + return widget.getDomNode(); + }, + get mainContainerDimension() { + return editor.getLayoutInfo(); + }, + get activeContainerDimension() { + return editor.getLayoutInfo(); + }, + get onDidLayoutMainContainer() { + return editor.onDidLayoutChange; + }, + get onDidLayoutActiveContainer() { + return editor.onDidLayoutChange; + }, + get onDidLayoutContainer() { + return Event.map(editor.onDidLayoutChange, (dimension) => ({ + container: widget.getDomNode(), + dimension, + })); + }, + get onDidChangeActiveContainer() { + return Event.None; + }, + get onDidAddContainer() { + return Event.None; + }, + get mainContainerOffset() { + return { top: 0, quickPickTop: 0 }; + }, + get activeContainerOffset() { + return { top: 0, quickPickTop: 0 }; + }, + focus: () => editor.focus(), + }; + } else { + this.host = undefined; + } + } + + protected override createController(): QuickInputController { + return super.createController(this.host); + } +} + +export class StandaloneQuickInputService implements IQuickInputService { + declare readonly _serviceBrand: undefined; + + private mapEditorToService = new Map< + ICodeEditor, + EditorScopedQuickInputService + >(); + private get activeService(): IQuickInputService { + const editor = this.codeEditorService.getFocusedCodeEditor(); + if (!editor) { + throw new Error("Quick input service needs a focused editor to work."); + } + + // Find the quick input implementation for the focused + // editor or create it lazily if not yet created + let quickInputService = this.mapEditorToService.get(editor); + if (!quickInputService) { + const newQuickInputService = (quickInputService = + this.instantiationService.createInstance( + EditorScopedQuickInputService, + editor, + )); + this.mapEditorToService.set(editor, quickInputService); + + createSingleCallFunction(editor.onDidDispose)(() => { + newQuickInputService.dispose(); + this.mapEditorToService.delete(editor); + }); + } + + return quickInputService; + } + + get currentQuickInput() { + return this.activeService.currentQuickInput; + } + get quickAccess() { + return this.activeService.quickAccess; + } + get backButton() { + return this.activeService.backButton; + } + get alignment() { + return this.activeService.alignment; + } + get onShow() { + return this.activeService.onShow; + } + get onHide() { + return this.activeService.onHide; + } + + constructor( + @IInstantiationService + private readonly instantiationService: IInstantiationService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + ) {} + + pick>( + picks: Promise[]> | QuickPickInput[], + options?: O, + token: CancellationToken = CancellationToken.None, + ): Promise<(O extends { canPickMany: true } ? T[] : T) | undefined> { + return (this.activeService as unknown as QuickInputController) /* TS fail */ + .pick(picks, options, token); + } + + input( + options?: IInputOptions | undefined, + token?: CancellationToken | undefined, + ): Promise { + return this.activeService.input(options, token); + } + + createQuickPick(options: { + useSeparators: true; + }): IQuickPick; + createQuickPick(options?: { + useSeparators: boolean; + }): IQuickPick; + createQuickPick( + options: { useSeparators: boolean } = { useSeparators: false }, + ): IQuickPick { + return this.activeService.createQuickPick(options); + } + + createInputBox(): IInputBox { + return this.activeService.createInputBox(); + } + + createQuickWidget(): IQuickWidget { + return this.activeService.createQuickWidget(); + } + + createQuickTree(): IQuickTree { + return this.activeService.createQuickTree(); + } + + focus(): void { + return this.activeService.focus(); + } + + toggle(): void { + return this.activeService.toggle(); + } + + navigate( + next: boolean, + quickNavigate?: IQuickNavigateConfiguration | undefined, + ): void { + return this.activeService.navigate(next, quickNavigate); + } + + accept(): Promise { + return this.activeService.accept(); + } + + back(): Promise { + return this.activeService.back(); + } + + cancel(): Promise { + return this.activeService.cancel(); + } + + setAlignment( + alignment: "top" | "center" | { top: number; left: number }, + ): void { + return this.activeService.setAlignment(alignment); + } + + toggleHover(): void { + return this.activeService.toggleHover(); + } +} + +export class QuickInputEditorContribution implements IEditorContribution { + static readonly ID = "editor.controller.quickInput"; + + static get(editor: ICodeEditor): QuickInputEditorContribution | null { + return editor.getContribution( + QuickInputEditorContribution.ID, + ); + } + + readonly widget: QuickInputEditorWidget; + + constructor(private editor: ICodeEditor) { + this.widget = new QuickInputEditorWidget(this.editor); + } + + dispose(): void { + this.widget.dispose(); + } +} + +export class QuickInputEditorWidget implements IOverlayWidget { + private static readonly ID = "editor.contrib.quickInputWidget"; + + private domNode: HTMLElement; + + constructor(private codeEditor: ICodeEditor) { + this.domNode = document.createElement("div"); + + this.codeEditor.addOverlayWidget(this); + } + + getId(): string { + return QuickInputEditorWidget.ID; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return { preference: { top: 0, left: 0 } }; + } + + dispose(): void { + this.codeEditor.removeOverlayWidget(this); + } +} + +registerEditorContribution( + QuickInputEditorContribution.ID, + QuickInputEditorContribution, + EditorContributionInstantiation.Lazy, +); diff --git a/apps/diffr-web/src/standalone/browser/services/standaloneWebWorkerService.ts b/apps/diffr-web/src/standalone/browser/services/standaloneWebWorkerService.ts new file mode 100644 index 000000000..7978b180f --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/services/standaloneWebWorkerService.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getMonacoEnvironment } from "vs/base/browser/browser.js"; +import { WebWorkerDescriptor } from "vs/platform/webWorker/browser/webWorkerDescriptor.js"; +import { WebWorkerService } from "vs/platform/webWorker/browser/webWorkerServiceImpl.js"; + +export class StandaloneWebWorkerService extends WebWorkerService { + protected override _createWorker( + descriptor: WebWorkerDescriptor, + ): Promise { + const monacoEnvironment = getMonacoEnvironment(); + if (monacoEnvironment) { + if (typeof monacoEnvironment.getWorker === "function") { + const worker = monacoEnvironment.getWorker( + "workerMain.js", + descriptor.label, + ); + if (worker !== undefined) { + return Promise.resolve(worker); + } + } + } + + return super._createWorker(descriptor); + } + + protected override _getWorkerLoadingFailedErrorMessage( + descriptor: WebWorkerDescriptor, + ): string | undefined { + const examplePath = "'...?esm'"; // Broken up to avoid detection by bundler plugin + return `Failed to load worker script for label: ${descriptor.label}. +Ensure your bundler properly bundles modules referenced by "new URL(${examplePath}, import.meta.url)".`; + } + + override getWorkerUrl(descriptor: WebWorkerDescriptor): string { + const monacoEnvironment = getMonacoEnvironment(); + if (monacoEnvironment) { + if (typeof monacoEnvironment.getWorkerUrl === "function") { + const workerUrl = monacoEnvironment.getWorkerUrl( + "workerMain.js", + descriptor.label, + ); + if (workerUrl !== undefined) { + const absoluteUrl = new URL(workerUrl, document.baseURI).toString(); + return absoluteUrl; + } + } + } + + if (!descriptor.esmModuleLocationBundler) { + throw new Error( + `You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker for the worker label: ${descriptor.label}`, + ); + } + + const url = + typeof descriptor.esmModuleLocationBundler === "function" + ? descriptor.esmModuleLocationBundler() + : descriptor.esmModuleLocationBundler; + const urlStr = url.toString(); + return urlStr; + } +} diff --git a/apps/diffr-web/src/standalone/browser/standalone-tokens.css b/apps/diffr-web/src/standalone/browser/standalone-tokens.css new file mode 100644 index 000000000..0698b5954 --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standalone-tokens.css @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Default standalone editor fonts */ +.monaco-editor { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", + "HelveticaNeue-Light", system-ui, "Ubuntu", "Droid Sans", sans-serif; + --monaco-monospace-font: + "SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", + "DejaVu Sans Mono", "Courier New", monospace; +} + +.monaco-menu + .monaco-action-bar.vertical + .action-item + .action-menu-item:focus + .action-label { + stroke-width: 1.2px; +} + +.monaco-editor.vs-dark + .monaco-menu + .monaco-action-bar.vertical + .action-menu-item:focus + .action-label, +.monaco-editor.hc-black + .monaco-menu + .monaco-action-bar.vertical + .action-menu-item:focus + .action-label, +.monaco-editor.hc-light + .monaco-menu + .monaco-action-bar.vertical + .action-menu-item:focus + .action-label { + stroke-width: 1.2px; +} + +.monaco-hover p { + margin: 0; +} + +/* See https://github.com/microsoft/monaco-editor/issues/2168#issuecomment-780078600 */ +.monaco-aria-container { + position: absolute !important; + top: 0; /* avoid being placed underneath a sibling element */ + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); +} + +.monaco-editor .synthetic-focus, +.monaco-diff-editor .synthetic-focus, +.monaco-editor [tabindex="0"]:focus, +.monaco-diff-editor [tabindex="0"]:focus, +.monaco-editor [tabindex="-1"]:focus, +.monaco-diff-editor [tabindex="-1"]:focus, +.monaco-editor button:focus, +.monaco-diff-editor button:focus, +.monaco-editor input[type="button"]:focus, +.monaco-diff-editor input[type="button"]:focus, +.monaco-editor input[type="checkbox"]:focus, +.monaco-diff-editor input[type="checkbox"]:focus, +.monaco-editor input[type="search"]:focus, +.monaco-diff-editor input[type="search"]:focus, +.monaco-editor input[type="text"]:focus, +.monaco-diff-editor input[type="text"]:focus, +.monaco-editor select:focus, +.monaco-diff-editor select:focus, +.monaco-editor textarea:focus, +.monaco-diff-editor textarea:focus { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + opacity: 1; +} diff --git a/apps/diffr-web/src/standalone/browser/standaloneCodeEditorService.ts b/apps/diffr-web/src/standalone/browser/standaloneCodeEditorService.ts new file mode 100644 index 000000000..548f9f0eb --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standaloneCodeEditorService.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { windowOpenNoOpener } from "vs/base/browser/dom.js"; +import { Schemas } from "vs/base/common/network.js"; +import { URI } from "vs/base/common/uri.js"; +import { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; +import { AbstractCodeEditorService } from "vs/editor/browser/services/abstractCodeEditorService.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; +import { IRange } from "vs/editor/common/core/range.js"; +import { ScrollType } from "vs/editor/common/editorCommon.js"; +import { ITextModel } from "vs/editor/common/model.js"; +import { + IContextKey, + IContextKeyService, +} from "vs/platform/contextkey/common/contextkey.js"; +import { ITextResourceEditorInput } from "vs/platform/editor/common/editor.js"; +import { + InstantiationType, + registerSingleton, +} from "vs/platform/instantiation/common/extensions.js"; +import { IThemeService } from "vs/platform/theme/common/themeService.js"; + +export class StandaloneCodeEditorService extends AbstractCodeEditorService { + private readonly _editorIsOpen: IContextKey; + private _activeCodeEditor: ICodeEditor | null; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + ) { + super(themeService); + this._register(this.onCodeEditorAdd(() => this._checkContextKey())); + this._register(this.onCodeEditorRemove(() => this._checkContextKey())); + this._editorIsOpen = contextKeyService.createKey("editorIsOpen", false); + this._activeCodeEditor = null; + + this._register( + this.registerCodeEditorOpenHandler(async (input, source, sideBySide) => { + if (!source) { + return null; + } + return this.doOpenEditor(source, input); + }), + ); + } + + private _checkContextKey(): void { + let hasCodeEditor = false; + for (const editor of this.listCodeEditors()) { + if (!editor.isSimpleWidget) { + hasCodeEditor = true; + break; + } + } + this._editorIsOpen.set(hasCodeEditor); + } + + public setActiveCodeEditor(activeCodeEditor: ICodeEditor | null): void { + this._activeCodeEditor = activeCodeEditor; + } + + public getActiveCodeEditor(): ICodeEditor | null { + return this._activeCodeEditor; + } + + private doOpenEditor( + editor: ICodeEditor, + input: ITextResourceEditorInput, + ): ICodeEditor | null { + const model = this.findModel(editor, input.resource); + if (!model) { + if (input.resource) { + const schema = input.resource.scheme; + if (schema === Schemas.http || schema === Schemas.https) { + // This is a fully qualified http or https URL + windowOpenNoOpener(input.resource.toString()); + return editor; + } + } + return null; + } + + const selection = (input.options ? input.options.selection : null); + if (selection) { + if ( + typeof selection.endLineNumber === "number" && + typeof selection.endColumn === "number" + ) { + editor.setSelection(selection); + editor.revealRangeInCenter(selection, ScrollType.Immediate); + } else { + const pos = { + lineNumber: selection.startLineNumber, + column: selection.startColumn, + }; + editor.setPosition(pos); + editor.revealPositionInCenter(pos, ScrollType.Immediate); + } + } + + return editor; + } + + private findModel(editor: ICodeEditor, resource: URI): ITextModel | null { + const model = editor.getModel(); + if (model && model.uri.toString() !== resource.toString()) { + return null; + } + + return model; + } +} + +registerSingleton( + ICodeEditorService, + StandaloneCodeEditorService, + InstantiationType.Eager, +); diff --git a/apps/diffr-web/src/standalone/browser/standaloneLayoutService.ts b/apps/diffr-web/src/standalone/browser/standaloneLayoutService.ts new file mode 100644 index 000000000..16adad15c --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standaloneLayoutService.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from "vs/base/browser/dom.js"; +import { mainWindow } from "vs/base/browser/window.js"; +import { coalesce } from "vs/base/common/arrays.js"; +import { Event } from "vs/base/common/event.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; +import { + InstantiationType, + registerSingleton, +} from "vs/platform/instantiation/common/extensions.js"; +import { + ILayoutOffsetInfo, + ILayoutService, +} from "vs/platform/layout/browser/layoutService.js"; + +class StandaloneLayoutService implements ILayoutService { + declare readonly _serviceBrand: undefined; + + readonly onDidLayoutMainContainer = Event.None; + readonly onDidLayoutActiveContainer = Event.None; + readonly onDidLayoutContainer = Event.None; + readonly onDidChangeActiveContainer = Event.None; + readonly onDidAddContainer = Event.None; + + get mainContainer(): HTMLElement { + return ( + this._codeEditorService.listCodeEditors().at(0)?.getContainerDomNode() ?? + mainWindow.document.body + ); + } + + get activeContainer(): HTMLElement { + const activeCodeEditor = + this._codeEditorService.getFocusedCodeEditor() ?? + this._codeEditorService.getActiveCodeEditor(); + + return activeCodeEditor?.getContainerDomNode() ?? this.mainContainer; + } + + get mainContainerDimension(): dom.IDimension { + return dom.getClientArea(this.mainContainer); + } + + get activeContainerDimension() { + return dom.getClientArea(this.activeContainer); + } + + readonly mainContainerOffset: ILayoutOffsetInfo = { top: 0, quickPickTop: 0 }; + readonly activeContainerOffset: ILayoutOffsetInfo = { + top: 0, + quickPickTop: 0, + }; + + get containers(): Iterable { + return coalesce( + this._codeEditorService + .listCodeEditors() + .map((codeEditor) => codeEditor.getContainerDomNode()), + ); + } + + getContainer() { + return this.activeContainer; + } + + whenContainerStylesLoaded() { + return undefined; + } + + focus(): void { + this._codeEditorService.getFocusedCodeEditor()?.focus(); + } + + constructor( + @ICodeEditorService private _codeEditorService: ICodeEditorService, + ) {} +} + +export class EditorScopedLayoutService extends StandaloneLayoutService { + override get mainContainer(): HTMLElement { + return this._container; + } + constructor( + private _container: HTMLElement, + @ICodeEditorService codeEditorService: ICodeEditorService, + ) { + super(codeEditorService); + } +} + +registerSingleton( + ILayoutService, + StandaloneLayoutService, + InstantiationType.Delayed, +); diff --git a/apps/diffr-web/src/standalone/browser/standaloneServices.ts b/apps/diffr-web/src/standalone/browser/standaloneServices.ts new file mode 100644 index 000000000..90bd8320e --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standaloneServices.ts @@ -0,0 +1,1887 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import "vs/platform/hover/browser/hoverService.js"; +import "vs/platform/undoRedo/common/undoRedoService.js"; +import "vs/editor/browser/services/inlineCompletionsService.js"; +import "vs/editor/common/services/languageFeatureDebounce.js"; +import "vs/editor/common/services/languageFeaturesService.js"; +import "vs/editor/common/services/semanticTokensStylingService.js"; +import "./standaloneCodeEditorService.js"; +import "./standaloneLayoutService.js"; +import * as dom from "vs/base/browser/dom.js"; +import { StandardKeyboardEvent } from "vs/base/browser/keyboardEvent.js"; +import { mainWindow } from "vs/base/browser/window.js"; +import { + IDefaultAccount, + IDefaultAccountAuthenticationProvider, + IPolicyData, +} from "vs/base/common/defaultAccount.js"; +import { onUnexpectedError } from "vs/base/common/errors.js"; +import { + Emitter, + Event, + IValueWithChangeEvent, + ValueWithChangeEvent, +} from "vs/base/common/event.js"; +import { + KeyCodeChord, + Keybinding, + ResolvedKeybinding, + decodeKeybinding, +} from "vs/base/common/keybindings.js"; +import { + Disposable, + DisposableStore, + IDisposable, + IReference, + ImmortalReference, + combinedDisposable, + toDisposable, +} from "vs/base/common/lifecycle.js"; +import { ResourceMap } from "vs/base/common/map.js"; +import { OS, isLinux, isMacintosh } from "vs/base/common/platform.js"; +import { basename } from "vs/base/common/resources.js"; +import Severity from "vs/base/common/severity.js"; +import * as strings from "vs/base/common/strings.js"; +import { URI } from "vs/base/common/uri.js"; +import { ICodeEditor, IDiffEditor } from "vs/editor/browser/editorBrowser.js"; +import { + IBulkEditOptions, + IBulkEditResult, + IBulkEditService, + ResourceEdit, + ResourceTextEdit, +} from "vs/editor/browser/services/bulkEditService.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; +import { OpenerService } from "vs/editor/browser/services/openerService.js"; +import { + IRenameSymbolTrackerService, + NullRenameSymbolTrackerService, +} from "vs/editor/browser/services/renameSymbolTrackerService.js"; +import { + isDiffEditorConfigurationKey, + isEditorConfigurationKey, +} from "vs/editor/common/config/editorConfigurationSchema.js"; +import { EditorOption } from "vs/editor/common/config/editorOptions.js"; +import { + EditOperation, + ISingleEditOperation, +} from "vs/editor/common/core/editOperation.js"; +import { IPosition, Position as Pos } from "vs/editor/common/core/position.js"; +import { Range } from "vs/editor/common/core/range.js"; +import { getEditorFeatures } from "vs/editor/common/editorFeatures.js"; +import { WorkspaceEdit } from "vs/editor/common/languages.js"; +import { ILanguageService } from "vs/editor/common/languages/language.js"; +import { ITextModel, ITextSnapshot } from "vs/editor/common/model.js"; +import { LanguageService } from "vs/editor/common/services/languageService.js"; +import { IMarkerDecorationsService } from "vs/editor/common/services/markerDecorations.js"; +import { MarkerDecorationsService } from "vs/editor/common/services/markerDecorationsService.js"; +import { IModelService } from "vs/editor/common/services/model.js"; +import { ModelService } from "vs/editor/common/services/modelService.js"; +import { + IResolvedTextEditorModel, + ITextModelContentProvider, + ITextModelService, +} from "vs/editor/common/services/resolverService.js"; +import { + ITextResourceConfigurationChangeEvent, + ITextResourceConfigurationService, + ITextResourcePropertiesService, +} from "vs/editor/common/services/textResourceConfiguration.js"; +import { ITreeSitterLibraryService } from "vs/editor/common/services/treeSitter/treeSitterLibraryService.js"; +import { StandaloneServicesNLS } from "vs/editor/common/standaloneStrings.js"; +import { AccessibilityService } from "vs/platform/accessibility/browser/accessibilityService.js"; +import { IAccessibilityService } from "vs/platform/accessibility/common/accessibility.js"; +import { + AccessibilityModality, + AccessibilitySignal, + IAccessibilitySignalService, + Sound, +} from "vs/platform/accessibilitySignal/browser/accessibilitySignalService.js"; +import { IMenuService } from "vs/platform/actions/common/actions.js"; +import { MenuService } from "vs/platform/actions/common/menuService.js"; +import { IClipboardService } from "vs/platform/clipboard/common/clipboardService.js"; +import { + CommandsRegistry, + ICommandEvent, + ICommandHandler, + ICommandService, +} from "vs/platform/commands/common/commands.js"; +import { + ConfigurationTarget, + IConfigurationChangeEvent, + IConfigurationData, + IConfigurationModel, + IConfigurationOverrides, + IConfigurationService, + IConfigurationValue, +} from "vs/platform/configuration/common/configuration.js"; +import { + Configuration, + ConfigurationChangeEvent, + ConfigurationModel, +} from "vs/platform/configuration/common/configurationModels.js"; +import { DefaultConfiguration } from "vs/platform/configuration/common/configurations.js"; +import { ContextKeyService } from "vs/platform/contextkey/browser/contextKeyService.js"; +import { + ContextKeyExpression, + IContextKeyService, +} from "vs/platform/contextkey/common/contextkey.js"; +import { ContextMenuService } from "vs/platform/contextview/browser/contextMenuService.js"; +import { + IContextMenuService, + IContextViewDelegate, + IContextViewService, + IOpenContextView, +} from "vs/platform/contextview/browser/contextView.js"; +import { ContextViewService } from "vs/platform/contextview/browser/contextViewService.js"; +import { + IDataChannelService, + NullDataChannelService, +} from "vs/platform/dataChannel/common/dataChannel.js"; +import { IDefaultAccountService } from "vs/platform/defaultAccount/common/defaultAccount.js"; +import { + IConfirmation, + IConfirmationResult, + IDialogService, + IInputResult, + IPrompt, + IPromptBaseButton, + IPromptResult, + IPromptResultWithCancel, + IPromptWithCustomCancel, + IPromptWithDefaultCancel, +} from "vs/platform/dialogs/common/dialogs.js"; +import { + ExtensionKind, + IEnvironmentService, + IExtensionHostDebugParams, +} from "vs/platform/environment/common/environment.js"; +import { SyncDescriptor } from "vs/platform/instantiation/common/descriptors.js"; +import { + InstantiationType, + getSingletonServiceDescriptors, + registerSingleton, +} from "vs/platform/instantiation/common/extensions.js"; +import { + IInstantiationService, + ServiceIdentifier, + createDecorator, +} from "vs/platform/instantiation/common/instantiation.js"; +import { InstantiationService } from "vs/platform/instantiation/common/instantiationService.js"; +import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection.js"; +import { AbstractKeybindingService } from "vs/platform/keybinding/common/abstractKeybindingService.js"; +import { + IKeybindingService, + IKeyboardEvent, + KeybindingsSchemaContribution, +} from "vs/platform/keybinding/common/keybinding.js"; +import { KeybindingResolver } from "vs/platform/keybinding/common/keybindingResolver.js"; +import { + IKeybindingItem, + KeybindingsRegistry, +} from "vs/platform/keybinding/common/keybindingsRegistry.js"; +import { ResolvedKeybindingItem } from "vs/platform/keybinding/common/resolvedKeybindingItem.js"; +import { USLayoutResolvedKeybinding } from "vs/platform/keybinding/common/usLayoutResolvedKeybinding.js"; +import { + IFormatterChangeEvent, + ILabelService, + ResourceLabelFormatter, + Verbosity, +} from "vs/platform/label/common/label.js"; +import { ILayoutService } from "vs/platform/layout/browser/layoutService.js"; +import { + IListService, + ListService, +} from "vs/platform/list/browser/listService.js"; +import { + ConsoleLogger, + ILogService, + ILoggerService, + NullLoggerService, +} from "vs/platform/log/common/log.js"; +import { LogService } from "vs/platform/log/common/logService.js"; +import { IMarkerService } from "vs/platform/markers/common/markers.js"; +import { MarkerService } from "vs/platform/markers/common/markerService.js"; +import { + INotification, + INotificationHandle, + INotificationService, + INotificationSource, + INotificationSourceFilter, + IPromptChoice, + IPromptOptions, + IStatusHandle, + IStatusMessageOptions, + NoOpNotification, + NotificationsFilter, +} from "vs/platform/notification/common/notification.js"; +import { IOpenerService } from "vs/platform/opener/common/opener.js"; +import { + IEditorProgressService, + IProgress, + IProgressCompositeOptions, + IProgressDialogOptions, + IProgressNotificationOptions, + IProgressOptions, + IProgressRunner, + IProgressService, + IProgressStep, + IProgressWindowOptions, +} from "vs/platform/progress/common/progress.js"; +import { IQuickInputService } from "vs/platform/quickinput/common/quickInput.js"; +import { + IStorageService, + InMemoryStorageService, +} from "vs/platform/storage/common/storage.js"; +import { + ITelemetryService, + TelemetryLevel, +} from "vs/platform/telemetry/common/telemetry.js"; +import { IUserInteractionService } from "vs/platform/userInteraction/browser/userInteractionService.js"; +import { UserInteractionService } from "vs/platform/userInteraction/browser/userInteractionServiceImpl.js"; +import { IWebWorkerService } from "vs/platform/webWorker/browser/webWorkerService.js"; +import { + ISingleFolderWorkspaceIdentifier, + IWorkspace, + IWorkspaceContextService, + IWorkspaceFolder, + IWorkspaceFoldersChangeEvent, + IWorkspaceFoldersWillChangeEvent, + IWorkspaceIdentifier, + STANDALONE_EDITOR_WORKSPACE_ID, + WorkbenchState, + WorkspaceFolder, +} from "vs/platform/workspace/common/workspace.js"; +import { + IWorkspaceTrustManagementService, + IWorkspaceTrustTransitionParticipant, + IWorkspaceTrustUriInfo, +} from "vs/platform/workspace/common/workspaceTrust.js"; + +import { IStandaloneThemeService } from "../common/standaloneTheme.js"; +import { BrowserClipboardService } from "./clipboardService.js"; +import { StandaloneQuickInputService } from "./quickInput/standaloneQuickInputService.js"; +import { StandaloneWebWorkerService } from "./services/standaloneWebWorkerService.js"; +import { StandaloneThemeService } from "./standaloneThemeService.js"; +import { StandaloneTreeSitterLibraryService } from "./standaloneTreeSitterLibraryService.js"; + +class SimpleModel implements IResolvedTextEditorModel { + private readonly model: ITextModel; + private readonly _onWillDispose: Emitter; + + constructor(model: ITextModel) { + this.model = model; + this._onWillDispose = new Emitter(); + } + + public get onWillDispose(): Event { + return this._onWillDispose.event; + } + + public resolve(): Promise { + return Promise.resolve(); + } + + public get textEditorModel(): ITextModel { + return this.model; + } + + public createSnapshot(): ITextSnapshot { + return this.model.createSnapshot(); + } + + public isReadonly(): boolean { + return false; + } + + private disposed = false; + public dispose(): void { + this.disposed = true; + + this._onWillDispose.fire(); + } + + public isDisposed(): boolean { + return this.disposed; + } + + public isResolved(): boolean { + return true; + } + + public getLanguageId(): string | undefined { + return this.model.getLanguageId(); + } +} + +class StandaloneTextModelService implements ITextModelService { + public _serviceBrand: undefined; + + constructor(@IModelService private readonly modelService: IModelService) {} + + public createModelReference( + resource: URI, + ): Promise> { + const model = this.modelService.getModel(resource); + + if (!model) { + return Promise.reject(new Error(`Model not found`)); + } + + return Promise.resolve(new ImmortalReference(new SimpleModel(model))); + } + + public registerTextModelContentProvider( + scheme: string, + provider: ITextModelContentProvider, + ): IDisposable { + return { + dispose: function () { + /* no op */ + }, + }; + } + + public canHandleResource(resource: URI): boolean { + return false; + } +} + +class StandaloneEditorProgressService implements IEditorProgressService { + declare readonly _serviceBrand: undefined; + + private static NULL_PROGRESS_RUNNER: IProgressRunner = { + done: () => {}, + total: () => {}, + worked: () => {}, + }; + + show(infinite: true, delay?: number): IProgressRunner; + show(total: number, delay?: number): IProgressRunner; + show(): IProgressRunner { + return StandaloneEditorProgressService.NULL_PROGRESS_RUNNER; + } + + async showWhile(promise: Promise, delay?: number): Promise { + await promise; + } +} + +class StandaloneProgressService implements IProgressService { + declare readonly _serviceBrand: undefined; + + withProgress( + _options: + | IProgressOptions + | IProgressDialogOptions + | IProgressNotificationOptions + | IProgressWindowOptions + | IProgressCompositeOptions, + task: (progress: IProgress) => Promise, + onDidCancel?: ((choice?: number | undefined) => void) | undefined, + ): Promise { + return task({ + report: () => {}, + }); + } +} + +class StandaloneEnvironmentService implements IEnvironmentService { + declare readonly _serviceBrand: undefined; + + readonly stateResource: URI = URI.from({ + scheme: "monaco", + authority: "stateResource", + }); + readonly userRoamingDataHome: URI = URI.from({ + scheme: "monaco", + authority: "userRoamingDataHome", + }); + readonly keyboardLayoutResource: URI = URI.from({ + scheme: "monaco", + authority: "keyboardLayoutResource", + }); + readonly argvResource: URI = URI.from({ + scheme: "monaco", + authority: "argvResource", + }); + readonly untitledWorkspacesHome: URI = URI.from({ + scheme: "monaco", + authority: "untitledWorkspacesHome", + }); + readonly workspaceStorageHome: URI = URI.from({ + scheme: "monaco", + authority: "workspaceStorageHome", + }); + readonly appSharedDataHome: URI = URI.from({ + scheme: "monaco", + authority: "appSharedDataHome", + }); + readonly localHistoryHome: URI = URI.from({ + scheme: "monaco", + authority: "localHistoryHome", + }); + readonly cacheHome: URI = URI.from({ + scheme: "monaco", + authority: "cacheHome", + }); + readonly userDataSyncHome: URI = URI.from({ + scheme: "monaco", + authority: "userDataSyncHome", + }); + readonly sync: "on" | "off" | undefined = undefined; + readonly continueOn?: string | undefined = undefined; + readonly editSessionId?: string | undefined = undefined; + readonly debugExtensionHost: IExtensionHostDebugParams = { + port: null, + break: false, + }; + readonly isExtensionDevelopment: boolean = false; + readonly disableExtensions: boolean | string[] = false; + readonly disableExperiments: boolean = false; + readonly enableExtensions?: readonly string[] | undefined = undefined; + readonly extensionDevelopmentLocationURI?: URI[] | undefined = undefined; + readonly extensionDevelopmentKind?: ExtensionKind[] | undefined = undefined; + readonly extensionTestsLocationURI?: URI | undefined = undefined; + readonly logsHome: URI = URI.from({ + scheme: "monaco", + authority: "logsHome", + }); + readonly logLevel?: string | undefined = undefined; + readonly extensionLogLevel?: [string, string][] | undefined = undefined; + readonly verbose: boolean = false; + readonly isBuilt: boolean = false; + readonly disableTelemetry: boolean = false; + readonly serviceMachineIdResource: URI = URI.from({ + scheme: "monaco", + authority: "serviceMachineIdResource", + }); + readonly agentSessionsWorkspace: URI = URI.from({ + scheme: "monaco", + authority: "agentSessionsWorkspace", + }); + readonly policyFile?: URI | undefined = undefined; +} + +class StandaloneDialogService implements IDialogService { + _serviceBrand: undefined; + + readonly onWillShowDialog = Event.None; + readonly onDidShowDialog = Event.None; + + async confirm(confirmation: IConfirmation): Promise { + const confirmed = this.doConfirm(confirmation.message, confirmation.detail); + + return { + confirmed, + checkboxChecked: false, // unsupported + }; + } + + private doConfirm(message: string, detail?: string): boolean { + let messageText = message; + if (detail) { + messageText = messageText + "\n\n" + detail; + } + + return mainWindow.confirm(messageText); + } + + prompt( + prompt: IPromptWithCustomCancel, + ): Promise>; + prompt(prompt: IPrompt): Promise>; + prompt(prompt: IPromptWithDefaultCancel): Promise>; + async prompt( + prompt: IPrompt | IPromptWithCustomCancel, + ): Promise | IPromptResultWithCancel> { + let result: T | undefined = undefined; + const confirmed = this.doConfirm(prompt.message, prompt.detail); + if (confirmed) { + const promptButtons: IPromptBaseButton[] = [...(prompt.buttons ?? [])]; + if ( + prompt.cancelButton && + typeof prompt.cancelButton !== "string" && + typeof prompt.cancelButton !== "boolean" + ) { + promptButtons.push(prompt.cancelButton); + } + + result = await promptButtons[0]?.run({ checkboxChecked: false }); + } + + return { result }; + } + + async info(message: string, detail?: string): Promise { + await this.prompt({ type: Severity.Info, message, detail }); + } + + async warn(message: string, detail?: string): Promise { + await this.prompt({ type: Severity.Warning, message, detail }); + } + + async error(message: string, detail?: string): Promise { + await this.prompt({ type: Severity.Error, message, detail }); + } + + input(): Promise { + return Promise.resolve({ confirmed: false }); // unsupported + } + + about(): Promise { + return Promise.resolve(undefined); + } +} + +export class StandaloneNotificationService implements INotificationService { + readonly onDidChangeFilter: Event = Event.None; + + public _serviceBrand: undefined; + + private static readonly NO_OP: INotificationHandle = new NoOpNotification(); + + public info(message: string): INotificationHandle { + return this.notify({ severity: Severity.Info, message }); + } + + public warn(message: string): INotificationHandle { + return this.notify({ severity: Severity.Warning, message }); + } + + public error(error: string | Error): INotificationHandle { + return this.notify({ severity: Severity.Error, message: error }); + } + + public notify(notification: INotification): INotificationHandle { + switch (notification.severity) { + case Severity.Error: + console.error(notification.message); + break; + case Severity.Warning: + console.warn(notification.message); + break; + default: + console.log(notification.message); + break; + } + + return StandaloneNotificationService.NO_OP; + } + + public prompt( + severity: Severity, + message: string, + choices: IPromptChoice[], + options?: IPromptOptions, + ): INotificationHandle { + return StandaloneNotificationService.NO_OP; + } + + public status( + message: string | Error, + options?: IStatusMessageOptions, + ): IStatusHandle { + return { close: () => {} }; + } + + public setFilter( + filter: NotificationsFilter | INotificationSourceFilter, + ): void {} + + public getFilter(source?: INotificationSource): NotificationsFilter { + return NotificationsFilter.OFF; + } + + public getFilters(): INotificationSourceFilter[] { + return []; + } + + public removeFilter(sourceId: string): void {} +} + +export class StandaloneCommandService implements ICommandService { + declare readonly _serviceBrand: undefined; + + private readonly _instantiationService: IInstantiationService; + + private readonly _onWillExecuteCommand = new Emitter(); + private readonly _onDidExecuteCommand = new Emitter(); + public readonly onWillExecuteCommand: Event = + this._onWillExecuteCommand.event; + public readonly onDidExecuteCommand: Event = + this._onDidExecuteCommand.event; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + this._instantiationService = instantiationService; + } + + public executeCommand(id: string, ...args: unknown[]): Promise { + const command = CommandsRegistry.getCommand(id); + if (!command) { + return Promise.reject(new Error(`command '${id}' not found`)); + } + + try { + this._onWillExecuteCommand.fire({ commandId: id, args }); + const result = this._instantiationService.invokeFunction.apply( + this._instantiationService, + [command.handler, ...args], + ) as T; + + this._onDidExecuteCommand.fire({ commandId: id, args }); + return Promise.resolve(result); + } catch (err) { + return Promise.reject(err); + } + } +} + +export interface IKeybindingRule { + keybinding: number; + command?: string | null; + commandArgs?: unknown; + when?: ContextKeyExpression | null; +} + +export class StandaloneKeybindingService extends AbstractKeybindingService { + private _cachedResolver: KeybindingResolver | null; + private _dynamicKeybindings: IKeybindingItem[]; + private readonly _domNodeListeners: DomNodeListeners[]; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService, + @ITelemetryService telemetryService: ITelemetryService, + @INotificationService notificationService: INotificationService, + @ILogService logService: ILogService, + @ICodeEditorService codeEditorService: ICodeEditorService, + ) { + super( + contextKeyService, + commandService, + telemetryService, + notificationService, + logService, + ); + + this._cachedResolver = null; + this._dynamicKeybindings = []; + this._domNodeListeners = []; + + const addContainer = (domNode: HTMLElement) => { + const disposables = new DisposableStore(); + + // for standard keybindings + disposables.add( + dom.addDisposableListener( + domNode, + dom.EventType.KEY_DOWN, + (e: KeyboardEvent) => { + const keyEvent = new StandardKeyboardEvent(e); + const shouldPreventDefault = this._dispatch( + keyEvent, + keyEvent.target, + ); + if (shouldPreventDefault) { + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + } + }, + ), + ); + + // for single modifier chord keybindings (e.g. shift shift) + disposables.add( + dom.addDisposableListener( + domNode, + dom.EventType.KEY_UP, + (e: KeyboardEvent) => { + const keyEvent = new StandardKeyboardEvent(e); + const shouldPreventDefault = this._singleModifierDispatch( + keyEvent, + keyEvent.target, + ); + if (shouldPreventDefault) { + keyEvent.preventDefault(); + } + }, + ), + ); + + this._domNodeListeners.push(new DomNodeListeners(domNode, disposables)); + }; + const removeContainer = (domNode: HTMLElement) => { + for (let i = 0; i < this._domNodeListeners.length; i++) { + const domNodeListeners = this._domNodeListeners[i]; + if (domNodeListeners.domNode === domNode) { + this._domNodeListeners.splice(i, 1); + domNodeListeners.dispose(); + } + } + }; + + const addCodeEditor = (codeEditor: ICodeEditor) => { + if (codeEditor.getOption(EditorOption.inDiffEditor)) { + return; + } + addContainer(codeEditor.getContainerDomNode()); + }; + const removeCodeEditor = (codeEditor: ICodeEditor) => { + if (codeEditor.getOption(EditorOption.inDiffEditor)) { + return; + } + removeContainer(codeEditor.getContainerDomNode()); + }; + this._register(codeEditorService.onCodeEditorAdd(addCodeEditor)); + this._register(codeEditorService.onCodeEditorRemove(removeCodeEditor)); + codeEditorService.listCodeEditors().forEach(addCodeEditor); + + const addDiffEditor = (diffEditor: IDiffEditor) => { + addContainer(diffEditor.getContainerDomNode()); + }; + const removeDiffEditor = (diffEditor: IDiffEditor) => { + removeContainer(diffEditor.getContainerDomNode()); + }; + this._register(codeEditorService.onDiffEditorAdd(addDiffEditor)); + this._register(codeEditorService.onDiffEditorRemove(removeDiffEditor)); + codeEditorService.listDiffEditors().forEach(addDiffEditor); + } + + public addDynamicKeybinding( + command: string, + keybinding: number, + handler: ICommandHandler, + when: ContextKeyExpression | undefined, + ): IDisposable { + return combinedDisposable( + CommandsRegistry.registerCommand(command, handler), + this.addDynamicKeybindings([ + { + keybinding, + command, + when, + }, + ]), + ); + } + + public addDynamicKeybindings(rules: IKeybindingRule[]): IDisposable { + const entries: IKeybindingItem[] = rules.map((rule) => { + const keybinding = decodeKeybinding(rule.keybinding, OS); + return { + keybinding, + command: rule.command ?? null, + commandArgs: rule.commandArgs, + when: rule.when, + weight1: 1000, + weight2: 0, + extensionId: null, + isBuiltinExtension: false, + }; + }); + this._dynamicKeybindings = this._dynamicKeybindings.concat(entries); + + this.updateResolver(); + + return toDisposable(() => { + // Search the first entry and remove them all since they will be contiguous + for (let i = 0; i < this._dynamicKeybindings.length; i++) { + if (this._dynamicKeybindings[i] === entries[0]) { + this._dynamicKeybindings.splice(i, entries.length); + this.updateResolver(); + return; + } + } + }); + } + + private updateResolver(): void { + this._cachedResolver = null; + this._onDidUpdateKeybindings.fire(); + } + + protected _getResolver(): KeybindingResolver { + if (!this._cachedResolver) { + const defaults = this._toNormalizedKeybindingItems( + KeybindingsRegistry.getDefaultKeybindings(), + true, + ); + const overrides = this._toNormalizedKeybindingItems( + this._dynamicKeybindings, + false, + ); + this._cachedResolver = new KeybindingResolver( + defaults, + overrides, + (str) => this._log(str), + ); + } + return this._cachedResolver; + } + + protected _documentHasFocus(): boolean { + return mainWindow.document.hasFocus(); + } + + private _toNormalizedKeybindingItems( + items: IKeybindingItem[], + isDefault: boolean, + ): ResolvedKeybindingItem[] { + const result: ResolvedKeybindingItem[] = []; + let resultLen = 0; + for (const item of items) { + const when = item.when || undefined; + const keybinding = item.keybinding; + + if (!keybinding) { + // This might be a removal keybinding item in user settings => accept it + result[resultLen++] = new ResolvedKeybindingItem( + undefined, + item.command, + item.commandArgs, + when, + isDefault, + null, + false, + ); + } else { + const resolvedKeybindings = + USLayoutResolvedKeybinding.resolveKeybinding(keybinding, OS); + for (const resolvedKeybinding of resolvedKeybindings) { + result[resultLen++] = new ResolvedKeybindingItem( + resolvedKeybinding, + item.command, + item.commandArgs, + when, + isDefault, + null, + false, + ); + } + } + } + + return result; + } + + public resolveKeybinding(keybinding: Keybinding): ResolvedKeybinding[] { + return USLayoutResolvedKeybinding.resolveKeybinding(keybinding, OS); + } + + public resolveKeyboardEvent( + keyboardEvent: IKeyboardEvent, + ): ResolvedKeybinding { + const chord = new KeyCodeChord( + keyboardEvent.ctrlKey, + keyboardEvent.shiftKey, + keyboardEvent.altKey, + keyboardEvent.metaKey, + keyboardEvent.keyCode, + ); + return new USLayoutResolvedKeybinding([chord], OS); + } + + public resolveUserBinding(userBinding: string): ResolvedKeybinding[] { + return []; + } + + public _dumpDebugInfo(): string { + return ""; + } + + public _dumpDebugInfoJSON(): string { + return ""; + } + + public registerSchemaContribution( + contribution: KeybindingsSchemaContribution, + ): IDisposable { + return Disposable.None; + } + + /** + * not yet supported + */ + public override enableKeybindingHoldMode( + commandId: string, + ): Promise | undefined { + return undefined; + } +} + +class DomNodeListeners extends Disposable { + constructor( + public readonly domNode: HTMLElement, + disposables: DisposableStore, + ) { + super(); + this._register(disposables); + } +} + +function isConfigurationOverrides( + thing: unknown, +): thing is IConfigurationOverrides { + return ( + !!thing && + typeof thing === "object" && + (!(thing as IConfigurationOverrides).overrideIdentifier || + typeof (thing as IConfigurationOverrides).overrideIdentifier === + "string") && + (!(thing as IConfigurationOverrides).resource || + (thing as IConfigurationOverrides).resource instanceof URI) + ); +} + +export class StandaloneConfigurationService implements IConfigurationService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConfiguration = + new Emitter(); + public readonly onDidChangeConfiguration: Event = + this._onDidChangeConfiguration.event; + + private readonly _configuration: Configuration; + + constructor(@ILogService private readonly logService: ILogService) { + const defaultConfiguration = new DefaultConfiguration(logService); + this._configuration = new Configuration( + defaultConfiguration.reload(), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + logService, + ); + defaultConfiguration.dispose(); + } + + getValue(): T; + getValue(section: string): T; + getValue(overrides: IConfigurationOverrides): T; + getValue(section: string, overrides: IConfigurationOverrides): T; + getValue(arg1?: unknown, arg2?: unknown): unknown { + const section = typeof arg1 === "string" ? arg1 : undefined; + const overrides = isConfigurationOverrides(arg1) + ? arg1 + : isConfigurationOverrides(arg2) + ? arg2 + : {}; + return this._configuration.getValue(section, overrides, undefined); + } + + public updateValues(values: [string, unknown][]): Promise { + const previous = { data: this._configuration.toData() }; + + const changedKeys: string[] = []; + + for (const entry of values) { + const [key, value] = entry; + if (this.getValue(key) === value) { + continue; + } + this._configuration.updateValue(key, value); + changedKeys.push(key); + } + + if (changedKeys.length > 0) { + const configurationChangeEvent = new ConfigurationChangeEvent( + { keys: changedKeys, overrides: [] }, + previous, + this._configuration, + undefined, + this.logService, + ); + configurationChangeEvent.source = ConfigurationTarget.MEMORY; + this._onDidChangeConfiguration.fire(configurationChangeEvent); + } + + return Promise.resolve(); + } + + public updateValue( + key: string, + value: unknown, + arg3?: unknown, + arg4?: unknown, + ): Promise { + return this.updateValues([[key, value]]); + } + + public inspect( + key: string, + options: IConfigurationOverrides = {}, + ): IConfigurationValue { + return this._configuration.inspect(key, options, undefined); + } + + public keys() { + return this._configuration.keys(undefined); + } + + public reloadConfiguration(): Promise { + return Promise.resolve(undefined); + } + + public getConfigurationData(): IConfigurationData | null { + const emptyModel: IConfigurationModel = { + contents: {}, + keys: [], + overrides: [], + }; + return { + defaults: emptyModel, + policy: emptyModel, + application: emptyModel, + userLocal: emptyModel, + userRemote: emptyModel, + workspace: emptyModel, + folders: [], + }; + } +} + +class StandaloneResourceConfigurationService + extends Disposable + implements ITextResourceConfigurationService +{ + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConfiguration = this._register( + new Emitter(), + ); + public readonly onDidChangeConfiguration = + this._onDidChangeConfiguration.event; + + constructor( + @IConfigurationService + private readonly configurationService: StandaloneConfigurationService, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + this._register( + this.configurationService.onDidChangeConfiguration((e) => { + this._onDidChangeConfiguration.fire({ + affectedKeys: e.affectedKeys, + affectsConfiguration: (resource: URI, configuration: string) => + e.affectsConfiguration(configuration), + }); + }), + ); + } + + getValue(resource: URI, section?: string): T; + getValue(resource: URI, position?: IPosition, section?: string): T; + getValue(resource: URI | undefined, arg2?: unknown, arg3?: unknown) { + const position: IPosition | null = Pos.isIPosition(arg2) ? arg2 : null; + const section: string | undefined = position + ? typeof arg3 === "string" + ? arg3 + : undefined + : typeof arg2 === "string" + ? arg2 + : undefined; + const language = resource + ? this.getLanguage(resource, position) + : undefined; + if (typeof section === "undefined") { + return this.configurationService.getValue({ + resource, + overrideIdentifier: language, + }); + } + return this.configurationService.getValue(section, { + resource, + overrideIdentifier: language, + }); + } + + inspect( + resource: URI | undefined, + position: IPosition | null, + section: string, + ): IConfigurationValue> { + const language = resource + ? this.getLanguage(resource, position) + : undefined; + return this.configurationService.inspect(section, { + resource, + overrideIdentifier: language, + }); + } + + private getLanguage( + resource: URI, + position: IPosition | null, + ): string | null { + const model = this.modelService.getModel(resource); + if (model) { + return position + ? model.getLanguageIdAtPosition(position.lineNumber, position.column) + : model.getLanguageId(); + } + return this.languageService.guessLanguageIdByFilepathOrFirstLine(resource); + } + + updateValue( + resource: URI, + key: string, + value: unknown, + configurationTarget?: ConfigurationTarget, + ): Promise { + return this.configurationService.updateValue( + key, + value, + { resource }, + configurationTarget, + ); + } +} + +class StandaloneResourcePropertiesService implements ITextResourcePropertiesService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigurationService + private readonly configurationService: IConfigurationService, + ) {} + + getEOL(resource: URI, language?: string): string { + const eol = this.configurationService.getValue("files.eol", { + overrideIdentifier: language, + resource, + }); + if (eol && typeof eol === "string" && eol !== "auto") { + return eol; + } + return isLinux || isMacintosh ? "\n" : "\r\n"; + } +} + +class StandaloneTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.NONE; + readonly sessionId = "someValue.sessionId"; + readonly machineId = "someValue.machineId"; + readonly sqmId = "someValue.sqmId"; + readonly devDeviceId = "someValue.devDeviceId"; + readonly firstSessionDate = "someValue.firstSessionDate"; + readonly sendErrorTelemetry = false; + setEnabled(): void {} + setExperimentProperty(): void {} + setCommonProperty(): void {} + publicLog() {} + publicLog2() {} + publicLogError() {} + publicLogError2() {} +} + +class StandaloneWorkspaceContextService implements IWorkspaceContextService { + public _serviceBrand: undefined; + + private static readonly SCHEME = "inmemory"; + + private readonly _onDidChangeWorkspaceName = new Emitter(); + public readonly onDidChangeWorkspaceName: Event = + this._onDidChangeWorkspaceName.event; + + private readonly _onWillChangeWorkspaceFolders = + new Emitter(); + public readonly onWillChangeWorkspaceFolders: Event = + this._onWillChangeWorkspaceFolders.event; + + private readonly _onDidChangeWorkspaceFolders = + new Emitter(); + public readonly onDidChangeWorkspaceFolders: Event = + this._onDidChangeWorkspaceFolders.event; + + private readonly _onDidChangeWorkbenchState = new Emitter(); + public readonly onDidChangeWorkbenchState: Event = + this._onDidChangeWorkbenchState.event; + + private readonly workspace: IWorkspace; + + constructor() { + const resource = URI.from({ + scheme: StandaloneWorkspaceContextService.SCHEME, + authority: "model", + path: "/", + }); + this.workspace = { + id: STANDALONE_EDITOR_WORKSPACE_ID, + folders: [new WorkspaceFolder({ uri: resource, name: "", index: 0 })], + }; + } + + getCompleteWorkspace(): Promise { + return Promise.resolve(this.getWorkspace()); + } + + public getWorkspace(): IWorkspace { + return this.workspace; + } + + public getWorkbenchState(): WorkbenchState { + if (this.workspace) { + if (this.workspace.configuration) { + return WorkbenchState.WORKSPACE; + } + return WorkbenchState.FOLDER; + } + return WorkbenchState.EMPTY; + } + + public hasWorkspaceData(): boolean { + return this.getWorkbenchState() !== WorkbenchState.EMPTY; + } + + public getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { + return resource && + resource.scheme === StandaloneWorkspaceContextService.SCHEME + ? this.workspace.folders[0] + : null; + } + + public isInsideWorkspace(resource: URI): boolean { + return ( + resource && resource.scheme === StandaloneWorkspaceContextService.SCHEME + ); + } + + public isCurrentWorkspace( + workspaceIdOrFolder: + | IWorkspaceIdentifier + | ISingleFolderWorkspaceIdentifier + | URI, + ): boolean { + return true; + } +} + +export function updateConfigurationService( + configurationService: IConfigurationService, + source: any, + isDiffEditor: boolean, +): void { + if (!source) { + return; + } + if (!(configurationService instanceof StandaloneConfigurationService)) { + return; + } + const toUpdate: [string, unknown][] = []; + Object.keys(source).forEach((key) => { + if (isEditorConfigurationKey(key)) { + toUpdate.push([`editor.${key}`, source[key]]); + } + if (isDiffEditor && isDiffEditorConfigurationKey(key)) { + toUpdate.push([`diffEditor.${key}`, source[key]]); + } + }); + if (toUpdate.length > 0) { + configurationService.updateValues(toUpdate); + } +} + +class StandaloneBulkEditService implements IBulkEditService { + declare readonly _serviceBrand: undefined; + + constructor(@IModelService private readonly _modelService: IModelService) { + // + } + + hasPreviewHandler(): false { + return false; + } + + setPreviewHandler(): IDisposable { + return Disposable.None; + } + + async apply( + editsIn: ResourceEdit[] | WorkspaceEdit, + _options?: IBulkEditOptions, + ): Promise { + const edits = Array.isArray(editsIn) + ? editsIn + : ResourceEdit.convert(editsIn); + const textEdits = new Map(); + + for (const edit of edits) { + if (!(edit instanceof ResourceTextEdit)) { + throw new Error("bad edit - only text edits are supported"); + } + const model = this._modelService.getModel(edit.resource); + if (!model) { + throw new Error("bad edit - model not found"); + } + if ( + typeof edit.versionId === "number" && + model.getVersionId() !== edit.versionId + ) { + throw new Error("bad state - model changed in the meantime"); + } + let array = textEdits.get(model); + if (!array) { + array = []; + textEdits.set(model, array); + } + array.push( + EditOperation.replaceMove( + Range.lift(edit.textEdit.range), + edit.textEdit.text, + ), + ); + } + + let totalEdits = 0; + let totalFiles = 0; + for (const [model, edits] of textEdits) { + model.pushStackElement(); + model.pushEditOperations([], edits, () => []); + model.pushStackElement(); + totalFiles += 1; + totalEdits += edits.length; + } + + return { + ariaSummary: strings.format( + StandaloneServicesNLS.bulkEditServiceSummary, + totalEdits, + totalFiles, + ), + isApplied: totalEdits > 0, + }; + } +} + +class StandaloneUriLabelService implements ILabelService { + declare readonly _serviceBrand: undefined; + + public readonly onDidChangeFormatters: Event = + Event.None; + + public getUriLabel( + resource: URI, + options?: { relative?: boolean; forceNoTildify?: boolean }, + ): string { + if (resource.scheme === "file") { + return resource.fsPath; + } + return resource.path; + } + + getUriBasenameLabel(resource: URI): string { + return basename(resource); + } + + public getWorkspaceLabel( + workspace: + | IWorkspaceIdentifier + | ISingleFolderWorkspaceIdentifier + | URI + | IWorkspace, + options?: { verbose: Verbosity }, + ): string { + return ""; + } + + public getSeparator(scheme: string, authority?: string): "/" | "\\" { + return "/"; + } + + public registerFormatter(formatter: ResourceLabelFormatter): IDisposable { + throw new Error("Not implemented"); + } + + public registerCachedFormatter( + formatter: ResourceLabelFormatter, + ): IDisposable { + return this.registerFormatter(formatter); + } + + public getHostLabel(): string { + return ""; + } + + public getHostTooltip(): string | undefined { + return undefined; + } +} + +class StandaloneContextViewService extends ContextViewService { + constructor( + @ILayoutService layoutService: ILayoutService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, + ) { + super(layoutService); + } + + override showContextView( + delegate: IContextViewDelegate, + container?: HTMLElement, + shadowRoot?: boolean, + ): IOpenContextView { + if (!container) { + const codeEditor = + this._codeEditorService.getFocusedCodeEditor() || + this._codeEditorService.getActiveCodeEditor(); + if (codeEditor) { + container = codeEditor.getContainerDomNode(); + } + } + return super.showContextView(delegate, container, shadowRoot); + } +} + +class StandaloneWorkspaceTrustManagementService implements IWorkspaceTrustManagementService { + _serviceBrand: undefined; + + private _neverEmitter = new Emitter(); + public readonly onDidChangeTrust: Event = this._neverEmitter.event; + readonly onDidChangeTrustedFolders: Event = this._neverEmitter.event; + public readonly workspaceResolved = Promise.resolve(); + public readonly workspaceTrustInitialized = Promise.resolve(); + public readonly acceptsOutOfWorkspaceFiles = true; + + isWorkspaceTrusted(): boolean { + return true; + } + isWorkspaceTrustForced(): boolean { + return false; + } + canSetParentFolderTrust(): boolean { + return false; + } + async setParentFolderTrust(trusted: boolean): Promise { + // noop + } + canSetWorkspaceTrust(): boolean { + return false; + } + async setWorkspaceTrust(trusted: boolean): Promise { + // noop + } + getUriTrustInfo(uri: URI): Promise { + throw new Error("Method not supported."); + } + async setUrisTrust(uri: URI[], trusted: boolean): Promise { + // noop + } + getTrustedUris(): URI[] { + return []; + } + async setTrustedUris(uris: URI[]): Promise { + // noop + } + addWorkspaceTrustTransitionParticipant( + participant: IWorkspaceTrustTransitionParticipant, + ): IDisposable { + throw new Error("Method not supported."); + } +} + +class StandaloneLanguageService extends LanguageService { + constructor() { + super(); + } +} + +class StandaloneLogService extends LogService { + constructor() { + super(new ConsoleLogger()); + } +} + +class StandaloneContextMenuService extends ContextMenuService { + constructor( + @ITelemetryService telemetryService: ITelemetryService, + @INotificationService notificationService: INotificationService, + @IContextViewService contextViewService: IContextViewService, + @IKeybindingService keybindingService: IKeybindingService, + @IMenuService menuService: IMenuService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super( + telemetryService, + notificationService, + contextViewService, + keybindingService, + menuService, + contextKeyService, + ); + this.configure({ blockMouse: false }); // we do not want that in the standalone editor + } +} + +class StandaloneAccessbilitySignalService implements IAccessibilitySignalService { + _serviceBrand: undefined; + async playSignal(cue: AccessibilitySignal, options: {}): Promise {} + + async playSignals(cues: AccessibilitySignal[]): Promise {} + + getEnabledState( + signal: AccessibilitySignal, + userGesture: boolean, + modality?: AccessibilityModality | undefined, + ): IValueWithChangeEvent { + return ValueWithChangeEvent.const(false); + } + + getDelayMs( + signal: AccessibilitySignal, + modality: AccessibilityModality, + ): number { + return 0; + } + + isSoundEnabled(cue: AccessibilitySignal): boolean { + return false; + } + + isAnnouncementEnabled(cue: AccessibilitySignal): boolean { + return false; + } + + onSoundEnabledChanged(cue: AccessibilitySignal): Event { + return Event.None; + } + + async playSound( + cue: Sound, + allowManyInParallel?: boolean | undefined, + ): Promise {} + playSignalLoop(cue: AccessibilitySignal): IDisposable { + return toDisposable(() => {}); + } +} + +class StandaloneDefaultAccountService implements IDefaultAccountService { + declare readonly _serviceBrand: undefined; + + readonly onDidChangeDefaultAccount: Event = + Event.None; + readonly onDidChangePolicyData: Event = Event.None; + readonly policyData: IPolicyData | null = null; + readonly currentDefaultAccount: IDefaultAccount | null = null; + readonly copilotTokenInfo = null; + readonly onDidChangeCopilotTokenInfo: Event = Event.None; + readonly managedSettingsFetchStatus: null = null; + readonly managedSettingsFetchedAt: null = null; + readonly managedSettingsRawResponse: unknown = null; + + async getDefaultAccount(): Promise { + return null; + } + + setDefaultAccountProvider(): void { + // no-op + } + + async refresh(): Promise { + return null; + } + + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return { id: "default", name: "Default", enterprise: false }; + } + + resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + + async signIn(): Promise { + return null; + } + + async signOut(): Promise { + // no-op + } +} + +export interface IEditorOverrideServices { + [index: string]: unknown; +} + +registerSingleton( + IWebWorkerService, + StandaloneWebWorkerService, + InstantiationType.Eager, +); +registerSingleton(ILogService, StandaloneLogService, InstantiationType.Eager); +registerSingleton( + IConfigurationService, + StandaloneConfigurationService, + InstantiationType.Eager, +); +registerSingleton( + ITextResourceConfigurationService, + StandaloneResourceConfigurationService, + InstantiationType.Eager, +); +registerSingleton( + ITextResourcePropertiesService, + StandaloneResourcePropertiesService, + InstantiationType.Eager, +); +registerSingleton( + IWorkspaceContextService, + StandaloneWorkspaceContextService, + InstantiationType.Eager, +); +registerSingleton( + ILabelService, + StandaloneUriLabelService, + InstantiationType.Eager, +); +registerSingleton( + ITelemetryService, + StandaloneTelemetryService, + InstantiationType.Eager, +); +registerSingleton( + IDialogService, + StandaloneDialogService, + InstantiationType.Eager, +); +registerSingleton( + IEnvironmentService, + StandaloneEnvironmentService, + InstantiationType.Eager, +); +registerSingleton( + INotificationService, + StandaloneNotificationService, + InstantiationType.Eager, +); +registerSingleton(IMarkerService, MarkerService, InstantiationType.Eager); +registerSingleton( + ILanguageService, + StandaloneLanguageService, + InstantiationType.Eager, +); +registerSingleton( + IStandaloneThemeService, + StandaloneThemeService, + InstantiationType.Eager, +); +registerSingleton(IModelService, ModelService, InstantiationType.Eager); +registerSingleton( + IMarkerDecorationsService, + MarkerDecorationsService, + InstantiationType.Eager, +); +registerSingleton( + IContextKeyService, + ContextKeyService, + InstantiationType.Eager, +); +registerSingleton( + IProgressService, + StandaloneProgressService, + InstantiationType.Eager, +); +registerSingleton( + IEditorProgressService, + StandaloneEditorProgressService, + InstantiationType.Eager, +); +registerSingleton( + IStorageService, + InMemoryStorageService, + InstantiationType.Eager, +); +registerSingleton( + IBulkEditService, + StandaloneBulkEditService, + InstantiationType.Eager, +); +registerSingleton( + IWorkspaceTrustManagementService, + StandaloneWorkspaceTrustManagementService, + InstantiationType.Eager, +); +registerSingleton( + ITextModelService, + StandaloneTextModelService, + InstantiationType.Eager, +); +registerSingleton( + IAccessibilityService, + AccessibilityService, + InstantiationType.Eager, +); +registerSingleton(IListService, ListService, InstantiationType.Eager); +registerSingleton( + ICommandService, + StandaloneCommandService, + InstantiationType.Eager, +); +registerSingleton( + IKeybindingService, + StandaloneKeybindingService, + InstantiationType.Eager, +); +registerSingleton( + IQuickInputService, + StandaloneQuickInputService, + InstantiationType.Eager, +); +registerSingleton( + IContextViewService, + StandaloneContextViewService, + InstantiationType.Eager, +); +registerSingleton(IOpenerService, OpenerService, InstantiationType.Eager); +registerSingleton( + IClipboardService, + BrowserClipboardService, + InstantiationType.Eager, +); +registerSingleton( + IContextMenuService, + StandaloneContextMenuService, + InstantiationType.Eager, +); +registerSingleton(IMenuService, MenuService, InstantiationType.Eager); +registerSingleton( + IAccessibilitySignalService, + StandaloneAccessbilitySignalService, + InstantiationType.Eager, +); +registerSingleton( + ITreeSitterLibraryService, + StandaloneTreeSitterLibraryService, + InstantiationType.Eager, +); +registerSingleton(ILoggerService, NullLoggerService, InstantiationType.Eager); +registerSingleton( + IDataChannelService, + NullDataChannelService, + InstantiationType.Eager, +); +registerSingleton( + IDefaultAccountService, + StandaloneDefaultAccountService, + InstantiationType.Eager, +); +registerSingleton( + IRenameSymbolTrackerService, + NullRenameSymbolTrackerService, + InstantiationType.Eager, +); +registerSingleton( + IUserInteractionService, + UserInteractionService, + InstantiationType.Eager, +); + +/** + * We don't want to eagerly instantiate services because embedders get a one time chance + * to override services when they create the first editor. + */ +export namespace StandaloneServices { + const serviceCollection = new ServiceCollection(); + for (const [id, descriptor] of getSingletonServiceDescriptors()) { + serviceCollection.set(id, descriptor); + } + + const instantiationService = new InstantiationService( + serviceCollection, + true, + ); + serviceCollection.set(IInstantiationService, instantiationService); + + export function get(serviceId: ServiceIdentifier): T { + if (!initialized) { + initialize({}); + } + const r = serviceCollection.get(serviceId); + if (!r) { + throw new Error("Missing service " + serviceId); + } + if (r instanceof SyncDescriptor) { + return instantiationService.invokeFunction((accessor) => + accessor.get(serviceId), + ); + } else { + return r; + } + } + + let initialized = false; + const onDidInitialize = new Emitter(); + export function initialize( + overrides: IEditorOverrideServices, + ): IInstantiationService { + if (initialized) { + return instantiationService; + } + initialized = true; + + // Add singletons that were registered after this module loaded + for (const [id, descriptor] of getSingletonServiceDescriptors()) { + if (!serviceCollection.get(id)) { + serviceCollection.set(id, descriptor); + } + } + + // Initialize the service collection with the overrides, but only if the + // service was not instantiated in the meantime. + for (const serviceId in overrides) { + if (overrides.hasOwnProperty(serviceId)) { + const serviceIdentifier = createDecorator(serviceId); + const r = serviceCollection.get(serviceIdentifier); + if (r instanceof SyncDescriptor) { + serviceCollection.set(serviceIdentifier, overrides[serviceId]); + } + } + } + + // Instantiate all editor features + const editorFeatures = getEditorFeatures(); + for (const feature of editorFeatures) { + try { + instantiationService.createInstance(feature); + } catch (err) { + onUnexpectedError(err); + } + } + + onDidInitialize.fire(); + + return instantiationService; + } + + /** + * Executes callback once services are initialized. + */ + export function withServices(callback: () => IDisposable): IDisposable { + if (initialized) { + return callback(); + } + + const disposable = new DisposableStore(); + + const listener = disposable.add( + onDidInitialize.event(() => { + listener.dispose(); + disposable.add(callback()); + }), + ); + + return disposable; + } +} diff --git a/apps/diffr-web/src/standalone/browser/standaloneThemeService.ts b/apps/diffr-web/src/standalone/browser/standaloneThemeService.ts new file mode 100644 index 000000000..91cc9ba02 --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standaloneThemeService.ts @@ -0,0 +1,526 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addMatchMediaChangeListener } from "vs/base/browser/browser.js"; +import * as dom from "vs/base/browser/dom.js"; +import * as domStylesheetsJs from "vs/base/browser/domStylesheets.js"; +import { mainWindow } from "vs/base/browser/window.js"; +import { Color } from "vs/base/common/color.js"; +import { Emitter } from "vs/base/common/event.js"; +import { IDisposable, Disposable } from "vs/base/common/lifecycle.js"; +import { + FontStyle, + TokenMetadata, +} from "vs/editor/common/encodedTokenAttributes.js"; +import { TokenizationRegistry } from "vs/editor/common/languages.js"; +import { + ITokenThemeRule, + TokenTheme, + generateTokensCSSForColorMap, +} from "vs/editor/common/languages/supports/tokenization.js"; +import { IEnvironmentService } from "vs/platform/environment/common/environment.js"; +import { Registry } from "vs/platform/registry/common/platform.js"; +import { + getIconsStyleSheet, + UnthemedProductIconTheme, +} from "vs/platform/theme/browser/iconsStyleSheet.js"; +import { + asCssVariableName, + ColorIdentifier, + Extensions, + IColorRegistry, +} from "vs/platform/theme/common/colorRegistry.js"; +import { + ColorScheme, + isDark, + isHighContrast, +} from "vs/platform/theme/common/theme.js"; +import { + Extensions as ThemingExtensions, + ICssStyleCollector, + IFileIconTheme, + IProductIconTheme, + IThemingRegistry, + ITokenStyle, + IFontTokenOptions, +} from "vs/platform/theme/common/themeService.js"; + +import { + BuiltinTheme, + IStandaloneTheme, + IStandaloneThemeData, + IStandaloneThemeService, +} from "../common/standaloneTheme.js"; +import { hc_black, hc_light, vs, vs_dark } from "../common/themes.js"; + +export const VS_LIGHT_THEME_NAME = "vs"; +export const VS_DARK_THEME_NAME = "vs-dark"; +export const HC_BLACK_THEME_NAME = "hc-black"; +export const HC_LIGHT_THEME_NAME = "hc-light"; + +const colorRegistry = Registry.as(Extensions.ColorContribution); +const themingRegistry = Registry.as( + ThemingExtensions.ThemingContribution, +); + +class StandaloneTheme implements IStandaloneTheme { + public readonly id: string; + public readonly themeName: string; + + private readonly themeData: IStandaloneThemeData; + private colors: Map | null; + private readonly defaultColors: { [colorId: string]: Color | undefined }; + private _tokenTheme: TokenTheme | null; + + constructor(name: string, standaloneThemeData: IStandaloneThemeData) { + this.themeData = standaloneThemeData; + const base = standaloneThemeData.base; + if (name.length > 0) { + if (isBuiltinTheme(name)) { + this.id = name; + } else { + this.id = base + " " + name; + } + this.themeName = name; + } else { + this.id = base; + this.themeName = base; + } + this.colors = null; + this.defaultColors = Object.create(null); + this._tokenTheme = null; + } + + public get label(): string { + return this.themeName; + } + + public get base(): string { + return this.themeData.base; + } + + public notifyBaseUpdated() { + if (this.themeData.inherit) { + this.colors = null; + this._tokenTheme = null; + } + } + + private getColors(): Map { + if (!this.colors) { + const colors = new Map(); + for (const id in this.themeData.colors) { + colors.set(id, Color.fromHex(this.themeData.colors[id])); + } + if (this.themeData.inherit) { + const baseData = getBuiltinRules(this.themeData.base); + for (const id in baseData.colors) { + if (!colors.has(id)) { + colors.set(id, Color.fromHex(baseData.colors[id])); + } + } + } + this.colors = colors; + } + return this.colors; + } + + public getColor( + colorId: ColorIdentifier, + useDefault?: boolean, + ): Color | undefined { + const color = this.getColors().get(colorId); + if (color) { + return color; + } + if (useDefault !== false) { + return this.getDefault(colorId); + } + return undefined; + } + + private getDefault(colorId: ColorIdentifier): Color | undefined { + let color = this.defaultColors[colorId]; + if (color) { + return color; + } + color = colorRegistry.resolveDefaultColor(colorId, this); + this.defaultColors[colorId] = color; + return color; + } + + public defines(colorId: ColorIdentifier): boolean { + return this.getColors().has(colorId); + } + + public get type(): ColorScheme { + switch (this.base) { + case VS_LIGHT_THEME_NAME: + return ColorScheme.LIGHT; + case HC_BLACK_THEME_NAME: + return ColorScheme.HIGH_CONTRAST_DARK; + case HC_LIGHT_THEME_NAME: + return ColorScheme.HIGH_CONTRAST_LIGHT; + default: + return ColorScheme.DARK; + } + } + + public get tokenTheme(): TokenTheme { + if (!this._tokenTheme) { + let rules: ITokenThemeRule[] = []; + let encodedTokensColors: string[] = []; + if (this.themeData.inherit) { + const baseData = getBuiltinRules(this.themeData.base); + rules = baseData.rules; + if (baseData.encodedTokensColors) { + encodedTokensColors = baseData.encodedTokensColors; + } + } + // Pick up default colors from `editor.foreground` and `editor.background` if available + const editorForeground = this.themeData.colors["editor.foreground"]; + const editorBackground = this.themeData.colors["editor.background"]; + if (editorForeground || editorBackground) { + const rule: ITokenThemeRule = { token: "" }; + if (editorForeground) { + rule.foreground = editorForeground; + } + if (editorBackground) { + rule.background = editorBackground; + } + rules.push(rule); + } + rules = rules.concat(this.themeData.rules); + if (this.themeData.encodedTokensColors) { + encodedTokensColors = this.themeData.encodedTokensColors; + } + this._tokenTheme = TokenTheme.createFromRawTokenTheme( + rules, + encodedTokensColors, + ); + } + return this._tokenTheme; + } + + public getTokenStyleMetadata( + type: string, + modifiers: string[], + modelLanguage: string, + ): ITokenStyle | undefined { + // use theme rules match + const style = this.tokenTheme._match([type].concat(modifiers).join(".")); + const metadata = style.metadata; + const foreground = TokenMetadata.getForeground(metadata); + const fontStyle = TokenMetadata.getFontStyle(metadata); + return { + foreground: foreground, + italic: Boolean(fontStyle & FontStyle.Italic), + bold: Boolean(fontStyle & FontStyle.Bold), + underline: Boolean(fontStyle & FontStyle.Underline), + strikethrough: Boolean(fontStyle & FontStyle.Strikethrough), + }; + } + + public get tokenColorMap(): string[] { + return []; + } + + public get tokenFontMap(): IFontTokenOptions[] { + return []; + } + + public readonly semanticHighlighting = false; +} + +function isBuiltinTheme(themeName: string): themeName is BuiltinTheme { + return ( + themeName === VS_LIGHT_THEME_NAME || + themeName === VS_DARK_THEME_NAME || + themeName === HC_BLACK_THEME_NAME || + themeName === HC_LIGHT_THEME_NAME + ); +} + +function getBuiltinRules(builtinTheme: BuiltinTheme): IStandaloneThemeData { + switch (builtinTheme) { + case VS_LIGHT_THEME_NAME: + return vs; + case VS_DARK_THEME_NAME: + return vs_dark; + case HC_BLACK_THEME_NAME: + return hc_black; + case HC_LIGHT_THEME_NAME: + return hc_light; + } +} + +function newBuiltInTheme(builtinTheme: BuiltinTheme): StandaloneTheme { + const themeData = getBuiltinRules(builtinTheme); + return new StandaloneTheme(builtinTheme, themeData); +} + +export class StandaloneThemeService + extends Disposable + implements IStandaloneThemeService +{ + declare readonly _serviceBrand: undefined; + + private readonly _onColorThemeChange = this._register( + new Emitter(), + ); + public readonly onDidColorThemeChange = this._onColorThemeChange.event; + + private readonly _onFileIconThemeChange = this._register( + new Emitter(), + ); + public readonly onDidFileIconThemeChange = this._onFileIconThemeChange.event; + + private readonly _onProductIconThemeChange = this._register( + new Emitter(), + ); + public readonly onDidProductIconThemeChange = + this._onProductIconThemeChange.event; + + private readonly _environment: IEnvironmentService = Object.create(null); + private readonly _knownThemes: Map; + private _autoDetectHighContrast: boolean; + private _codiconCSS: string; + private _themeCSS: string; + private _allCSS: string; + private _globalStyleElement: HTMLStyleElement | null; + private _styleElements: HTMLStyleElement[]; + private _colorMapOverride: Color[] | null; + private _theme!: IStandaloneTheme; + + private _builtInProductIconTheme = new UnthemedProductIconTheme(); + + constructor() { + super(); + + this._autoDetectHighContrast = true; + + this._knownThemes = new Map(); + this._knownThemes.set( + VS_LIGHT_THEME_NAME, + newBuiltInTheme(VS_LIGHT_THEME_NAME), + ); + this._knownThemes.set( + VS_DARK_THEME_NAME, + newBuiltInTheme(VS_DARK_THEME_NAME), + ); + this._knownThemes.set( + HC_BLACK_THEME_NAME, + newBuiltInTheme(HC_BLACK_THEME_NAME), + ); + this._knownThemes.set( + HC_LIGHT_THEME_NAME, + newBuiltInTheme(HC_LIGHT_THEME_NAME), + ); + + const iconsStyleSheet = this._register(getIconsStyleSheet(this)); + + this._codiconCSS = iconsStyleSheet.getCSS(); + this._themeCSS = ""; + this._allCSS = `${this._codiconCSS}\n${this._themeCSS}`; + this._globalStyleElement = null; + this._styleElements = []; + this._colorMapOverride = null; + this.setTheme(VS_LIGHT_THEME_NAME); + this._onOSSchemeChanged(); + + this._register( + iconsStyleSheet.onDidChange(() => { + this._codiconCSS = iconsStyleSheet.getCSS(); + this._updateCSS(); + }), + ); + + addMatchMediaChangeListener(mainWindow, "(forced-colors: active)", () => { + // Update theme selection for auto-detecting high contrast + this._onOSSchemeChanged(); + }); + } + + public registerEditorContainer(domNode: HTMLElement): IDisposable { + if (dom.isInShadowDOM(domNode)) { + return this._registerShadowDomContainer(domNode); + } + return this._registerRegularEditorContainer(); + } + + private _registerRegularEditorContainer(): IDisposable { + if (!this._globalStyleElement) { + this._globalStyleElement = domStylesheetsJs.createStyleSheet( + undefined, + (style) => { + style.className = "monaco-colors"; + style.textContent = this._allCSS; + }, + ); + this._styleElements.push(this._globalStyleElement); + } + return Disposable.None; + } + + private _registerShadowDomContainer(domNode: HTMLElement): IDisposable { + const styleElement = domStylesheetsJs.createStyleSheet(domNode, (style) => { + style.className = "monaco-colors"; + style.textContent = this._allCSS; + }); + this._styleElements.push(styleElement); + return { + dispose: () => { + for (let i = 0; i < this._styleElements.length; i++) { + if (this._styleElements[i] === styleElement) { + this._styleElements.splice(i, 1); + return; + } + } + }, + }; + } + + public defineTheme(themeName: string, themeData: IStandaloneThemeData): void { + if (!/^[a-z0-9\-]+$/i.test(themeName)) { + throw new Error("Illegal theme name!"); + } + if (!isBuiltinTheme(themeData.base) && !isBuiltinTheme(themeName)) { + throw new Error("Illegal theme base!"); + } + // set or replace theme + this._knownThemes.set(themeName, new StandaloneTheme(themeName, themeData)); + + if (isBuiltinTheme(themeName)) { + this._knownThemes.forEach((theme) => { + if (theme.base === themeName) { + theme.notifyBaseUpdated(); + } + }); + } + if (this._theme.themeName === themeName) { + this.setTheme(themeName); // refresh theme + } + } + + public getColorTheme(): IStandaloneTheme { + return this._theme; + } + + public setColorMapOverride(colorMapOverride: Color[] | null): void { + this._colorMapOverride = colorMapOverride; + this._updateThemeOrColorMap(); + } + + public setTheme(themeName: string): void { + let theme: StandaloneTheme | undefined; + if (this._knownThemes.has(themeName)) { + theme = this._knownThemes.get(themeName); + } else { + theme = this._knownThemes.get(VS_LIGHT_THEME_NAME); + } + this._updateActualTheme(theme); + } + + private _updateActualTheme(desiredTheme: IStandaloneTheme | undefined): void { + if (!desiredTheme || this._theme === desiredTheme) { + // Nothing to do + return; + } + this._theme = desiredTheme; + this._updateThemeOrColorMap(); + } + + private _onOSSchemeChanged() { + if (this._autoDetectHighContrast) { + const wantsHighContrast = mainWindow.matchMedia( + `(forced-colors: active)`, + ).matches; + if (wantsHighContrast !== isHighContrast(this._theme.type)) { + // switch to high contrast or non-high contrast but stick to dark or light + let newThemeName; + if (isDark(this._theme.type)) { + newThemeName = wantsHighContrast + ? HC_BLACK_THEME_NAME + : VS_DARK_THEME_NAME; + } else { + newThemeName = wantsHighContrast + ? HC_LIGHT_THEME_NAME + : VS_LIGHT_THEME_NAME; + } + this._updateActualTheme(this._knownThemes.get(newThemeName)); + } + } + } + + public setAutoDetectHighContrast(autoDetectHighContrast: boolean): void { + this._autoDetectHighContrast = autoDetectHighContrast; + this._onOSSchemeChanged(); + } + + private _updateThemeOrColorMap(): void { + const cssRules: string[] = []; + const hasRule: { [rule: string]: boolean } = {}; + const ruleCollector: ICssStyleCollector = { + addRule: (rule: string) => { + if (!hasRule[rule]) { + cssRules.push(rule); + hasRule[rule] = true; + } + }, + }; + themingRegistry + .getThemingParticipants() + .forEach((p) => p(this._theme, ruleCollector, this._environment)); + + const colorVariables: string[] = []; + for (const item of colorRegistry.getColors()) { + const color = this._theme.getColor(item.id, true); + if (color) { + colorVariables.push( + `${asCssVariableName(item.id)}: ${color.toString()};`, + ); + } + } + ruleCollector.addRule( + `.monaco-editor, .monaco-diff-editor, .monaco-component { ${colorVariables.join("\n")} }`, + ); + + const colorMap = + this._colorMapOverride || this._theme.tokenTheme.getColorMap(); + ruleCollector.addRule(generateTokensCSSForColorMap(colorMap)); + + // If the OS has forced-colors active, disable forced color adjustment for + // Monaco editor elements so that VS Code's built-in high contrast themes + // (hc-black / hc-light) are used instead of the OS forcing system colors. + ruleCollector.addRule( + `.monaco-editor, .monaco-diff-editor, .monaco-component { forced-color-adjust: none; }`, + ); + + this._themeCSS = cssRules.join("\n"); + this._updateCSS(); + + TokenizationRegistry.setColorMap(colorMap); + this._onColorThemeChange.fire(this._theme); + } + + private _updateCSS(): void { + this._allCSS = `${this._codiconCSS}\n${this._themeCSS}`; + this._styleElements.forEach( + (styleElement) => (styleElement.textContent = this._allCSS), + ); + } + + public getFileIconTheme(): IFileIconTheme { + return { + hasFileIcons: false, + hasFolderIcons: false, + hidesExplorerArrows: false, + }; + } + + public getProductIconTheme(): IProductIconTheme { + return this._builtInProductIconTheme; + } +} diff --git a/apps/diffr-web/src/standalone/browser/standaloneTreeSitterLibraryService.ts b/apps/diffr-web/src/standalone/browser/standaloneTreeSitterLibraryService.ts new file mode 100644 index 000000000..486ad474e --- /dev/null +++ b/apps/diffr-web/src/standalone/browser/standaloneTreeSitterLibraryService.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Parser, Language, Query } from "@vscode/tree-sitter-wasm"; +import { IReader } from "vs/base/common/observable.js"; +import { ITreeSitterLibraryService } from "vs/editor/common/services/treeSitter/treeSitterLibraryService.js"; + +export class StandaloneTreeSitterLibraryService implements ITreeSitterLibraryService { + readonly _serviceBrand: undefined; + + getParserClass(): Promise { + throw new Error("not implemented in StandaloneTreeSitterLibraryService"); + } + + supportsLanguage(languageId: string, reader: IReader | undefined): boolean { + return false; + } + + getLanguage( + languageId: string, + ignoreSupportsCheck: boolean, + reader: IReader | undefined, + ): Language | undefined { + return undefined; + } + + async getLanguagePromise(languageId: string): Promise { + return undefined; + } + + getInjectionQueries( + languageId: string, + reader: IReader | undefined, + ): Query | null | undefined { + return null; + } + + getHighlightingQueries( + languageId: string, + reader: IReader | undefined, + ): Query | null | undefined { + return null; + } + + async createQuery(language: Language, querySource: string): Promise { + throw new Error("not implemented in StandaloneTreeSitterLibraryService"); + } +} diff --git a/apps/diffr-web/src/standalone/common/standaloneTheme.ts b/apps/diffr-web/src/standalone/common/standaloneTheme.ts new file mode 100644 index 000000000..dd14c489a --- /dev/null +++ b/apps/diffr-web/src/standalone/common/standaloneTheme.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from "vs/base/common/color.js"; +import { + ITokenThemeRule, + TokenTheme, +} from "vs/editor/common/languages/supports/tokenization.js"; +import { createDecorator } from "vs/platform/instantiation/common/instantiation.js"; +import { + IColorTheme, + IThemeService, +} from "vs/platform/theme/common/themeService.js"; + +export const IStandaloneThemeService = + createDecorator("themeService"); + +export type BuiltinTheme = "vs" | "vs-dark" | "hc-black" | "hc-light"; +export type IColors = { [colorId: string]: string }; + +export interface IStandaloneThemeData { + base: BuiltinTheme; + inherit: boolean; + rules: ITokenThemeRule[]; + encodedTokensColors?: string[]; + colors: IColors; +} + +export interface IStandaloneTheme extends IColorTheme { + tokenTheme: TokenTheme; + themeName: string; +} + +export interface IStandaloneThemeService extends IThemeService { + readonly _serviceBrand: undefined; + + setTheme(themeName: string): void; + + setAutoDetectHighContrast(autoDetectHighContrast: boolean): void; + + defineTheme(themeName: string, themeData: IStandaloneThemeData): void; + + getColorTheme(): IStandaloneTheme; + + setColorMapOverride(colorMapOverride: Color[] | null): void; +} diff --git a/apps/diffr-web/src/standalone/common/themes.ts b/apps/diffr-web/src/standalone/common/themes.ts new file mode 100644 index 000000000..275bddb0a --- /dev/null +++ b/apps/diffr-web/src/standalone/common/themes.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + editorActiveIndentGuide1, + editorIndentGuide1, +} from "vs/editor/common/core/editorColorRegistry.js"; +import { + editorBackground, + editorForeground, + editorInactiveSelection, + editorSelectionHighlight, +} from "vs/platform/theme/common/colorRegistry.js"; + +import { IStandaloneThemeData } from "./standaloneTheme.js"; + +/* -------------------------------- Begin vs theme -------------------------------- */ +export const vs: IStandaloneThemeData = { + base: "vs", + inherit: false, + rules: [ + { token: "", foreground: "000000", background: "fffffe" }, + { token: "invalid", foreground: "cd3131" }, + { token: "emphasis", fontStyle: "italic" }, + { token: "strong", fontStyle: "bold" }, + + { token: "variable", foreground: "001188" }, + { token: "variable.predefined", foreground: "4864AA" }, + { token: "constant", foreground: "dd0000" }, + { token: "comment", foreground: "008000" }, + { token: "number", foreground: "098658" }, + { token: "number.hex", foreground: "3030c0" }, + { token: "regexp", foreground: "800000" }, + { token: "annotation", foreground: "808080" }, + { token: "type", foreground: "008080" }, + + { token: "delimiter", foreground: "000000" }, + { token: "delimiter.html", foreground: "383838" }, + { token: "delimiter.xml", foreground: "0000FF" }, + + { token: "tag", foreground: "800000" }, + { token: "tag.id.pug", foreground: "4F76AC" }, + { token: "tag.class.pug", foreground: "4F76AC" }, + { token: "meta.scss", foreground: "800000" }, + { token: "metatag", foreground: "e00000" }, + { token: "metatag.content.html", foreground: "FF0000" }, + { token: "metatag.html", foreground: "808080" }, + { token: "metatag.xml", foreground: "808080" }, + { token: "metatag.php", fontStyle: "bold" }, + + { token: "key", foreground: "863B00" }, + { token: "string.key.json", foreground: "A31515" }, + { token: "string.value.json", foreground: "0451A5" }, + + { token: "attribute.name", foreground: "FF0000" }, + { token: "attribute.value", foreground: "0451A5" }, + { token: "attribute.value.number", foreground: "098658" }, + { token: "attribute.value.unit", foreground: "098658" }, + { token: "attribute.value.html", foreground: "0000FF" }, + { token: "attribute.value.xml", foreground: "0000FF" }, + + { token: "string", foreground: "A31515" }, + { token: "string.html", foreground: "0000FF" }, + { token: "string.sql", foreground: "FF0000" }, + { token: "string.yaml", foreground: "0451A5" }, + + { token: "keyword", foreground: "0000FF" }, + { token: "keyword.json", foreground: "0451A5" }, + { token: "keyword.flow", foreground: "AF00DB" }, + { token: "keyword.flow.scss", foreground: "0000FF" }, + + { token: "operator.scss", foreground: "666666" }, + { token: "operator.sql", foreground: "778899" }, + { token: "operator.swift", foreground: "666666" }, + { token: "predefined.sql", foreground: "C700C7" }, + ], + colors: { + [editorBackground]: "#FFFFFE", + [editorForeground]: "#000000", + [editorInactiveSelection]: "#E5EBF1", + [editorIndentGuide1]: "#D3D3D3", + [editorActiveIndentGuide1]: "#939393", + [editorSelectionHighlight]: "#ADD6FF4D", + }, +}; +/* -------------------------------- End vs theme -------------------------------- */ + +/* -------------------------------- Begin vs-dark theme -------------------------------- */ +export const vs_dark: IStandaloneThemeData = { + base: "vs-dark", + inherit: false, + rules: [ + { token: "", foreground: "D4D4D4", background: "1E1E1E" }, + { token: "invalid", foreground: "f44747" }, + { token: "emphasis", fontStyle: "italic" }, + { token: "strong", fontStyle: "bold" }, + + { token: "variable", foreground: "74B0DF" }, + { token: "variable.predefined", foreground: "4864AA" }, + { token: "variable.parameter", foreground: "9CDCFE" }, + { token: "constant", foreground: "569CD6" }, + { token: "comment", foreground: "608B4E" }, + { token: "number", foreground: "B5CEA8" }, + { token: "number.hex", foreground: "5BB498" }, + { token: "regexp", foreground: "B46695" }, + { token: "annotation", foreground: "cc6666" }, + { token: "type", foreground: "3DC9B0" }, + + { token: "delimiter", foreground: "DCDCDC" }, + { token: "delimiter.html", foreground: "808080" }, + { token: "delimiter.xml", foreground: "808080" }, + + { token: "tag", foreground: "569CD6" }, + { token: "tag.id.pug", foreground: "4F76AC" }, + { token: "tag.class.pug", foreground: "4F76AC" }, + { token: "meta.scss", foreground: "A79873" }, + { token: "meta.tag", foreground: "CE9178" }, + { token: "metatag", foreground: "DD6A6F" }, + { token: "metatag.content.html", foreground: "9CDCFE" }, + { token: "metatag.html", foreground: "569CD6" }, + { token: "metatag.xml", foreground: "569CD6" }, + { token: "metatag.php", fontStyle: "bold" }, + + { token: "key", foreground: "9CDCFE" }, + { token: "string.key.json", foreground: "9CDCFE" }, + { token: "string.value.json", foreground: "CE9178" }, + + { token: "attribute.name", foreground: "9CDCFE" }, + { token: "attribute.value", foreground: "CE9178" }, + { token: "attribute.value.number.css", foreground: "B5CEA8" }, + { token: "attribute.value.unit.css", foreground: "B5CEA8" }, + { token: "attribute.value.hex.css", foreground: "D4D4D4" }, + + { token: "string", foreground: "CE9178" }, + { token: "string.sql", foreground: "FF0000" }, + + { token: "keyword", foreground: "569CD6" }, + { token: "keyword.flow", foreground: "C586C0" }, + { token: "keyword.json", foreground: "CE9178" }, + { token: "keyword.flow.scss", foreground: "569CD6" }, + + { token: "operator.scss", foreground: "909090" }, + { token: "operator.sql", foreground: "778899" }, + { token: "operator.swift", foreground: "909090" }, + { token: "predefined.sql", foreground: "FF00FF" }, + ], + colors: { + [editorBackground]: "#1E1E1E", + [editorForeground]: "#D4D4D4", + [editorInactiveSelection]: "#3A3D41", + [editorIndentGuide1]: "#404040", + [editorActiveIndentGuide1]: "#707070", + [editorSelectionHighlight]: "#ADD6FF26", + }, +}; +/* -------------------------------- End vs-dark theme -------------------------------- */ + +/* -------------------------------- Begin hc-black theme -------------------------------- */ +export const hc_black: IStandaloneThemeData = { + base: "hc-black", + inherit: false, + rules: [ + { token: "", foreground: "FFFFFF", background: "000000" }, + { token: "invalid", foreground: "f44747" }, + { token: "emphasis", fontStyle: "italic" }, + { token: "strong", fontStyle: "bold" }, + + { token: "variable", foreground: "1AEBFF" }, + { token: "variable.parameter", foreground: "9CDCFE" }, + { token: "constant", foreground: "569CD6" }, + { token: "comment", foreground: "608B4E" }, + { token: "number", foreground: "FFFFFF" }, + { token: "regexp", foreground: "C0C0C0" }, + { token: "annotation", foreground: "569CD6" }, + { token: "type", foreground: "3DC9B0" }, + + { token: "delimiter", foreground: "FFFF00" }, + { token: "delimiter.html", foreground: "FFFF00" }, + + { token: "tag", foreground: "569CD6" }, + { token: "tag.id.pug", foreground: "4F76AC" }, + { token: "tag.class.pug", foreground: "4F76AC" }, + { token: "meta", foreground: "D4D4D4" }, + { token: "meta.tag", foreground: "CE9178" }, + { token: "metatag", foreground: "569CD6" }, + { token: "metatag.content.html", foreground: "1AEBFF" }, + { token: "metatag.html", foreground: "569CD6" }, + { token: "metatag.xml", foreground: "569CD6" }, + { token: "metatag.php", fontStyle: "bold" }, + + { token: "key", foreground: "9CDCFE" }, + { token: "string.key", foreground: "9CDCFE" }, + { token: "string.value", foreground: "CE9178" }, + + { token: "attribute.name", foreground: "569CD6" }, + { token: "attribute.value", foreground: "3FF23F" }, + + { token: "string", foreground: "CE9178" }, + { token: "string.sql", foreground: "FF0000" }, + + { token: "keyword", foreground: "569CD6" }, + { token: "keyword.flow", foreground: "C586C0" }, + + { token: "operator.sql", foreground: "778899" }, + { token: "operator.swift", foreground: "909090" }, + { token: "predefined.sql", foreground: "FF00FF" }, + ], + colors: { + [editorBackground]: "#000000", + [editorForeground]: "#FFFFFF", + [editorIndentGuide1]: "#FFFFFF", + [editorActiveIndentGuide1]: "#FFFFFF", + }, +}; +/* -------------------------------- End hc-black theme -------------------------------- */ + +/* -------------------------------- Begin hc-light theme -------------------------------- */ +export const hc_light: IStandaloneThemeData = { + base: "hc-light", + inherit: false, + rules: [ + { token: "", foreground: "292929", background: "FFFFFF" }, + { token: "invalid", foreground: "B5200D" }, + { token: "emphasis", fontStyle: "italic" }, + { token: "strong", fontStyle: "bold" }, + + { token: "variable", foreground: "264F70" }, + { token: "variable.predefined", foreground: "4864AA" }, + { token: "constant", foreground: "dd0000" }, + { token: "comment", foreground: "008000" }, + { token: "number", foreground: "098658" }, + { token: "number.hex", foreground: "3030c0" }, + { token: "regexp", foreground: "800000" }, + { token: "annotation", foreground: "808080" }, + { token: "type", foreground: "008080" }, + + { token: "delimiter", foreground: "000000" }, + { token: "delimiter.html", foreground: "383838" }, + + { token: "tag", foreground: "800000" }, + { token: "tag.id.pug", foreground: "4F76AC" }, + { token: "tag.class.pug", foreground: "4F76AC" }, + { token: "meta.scss", foreground: "800000" }, + { token: "metatag", foreground: "e00000" }, + { token: "metatag.content.html", foreground: "B5200D" }, + { token: "metatag.html", foreground: "808080" }, + { token: "metatag.xml", foreground: "808080" }, + { token: "metatag.php", fontStyle: "bold" }, + + { token: "key", foreground: "863B00" }, + { token: "string.key.json", foreground: "A31515" }, + { token: "string.value.json", foreground: "0451A5" }, + + { token: "attribute.name", foreground: "264F78" }, + { token: "attribute.value", foreground: "0451A5" }, + + { token: "string", foreground: "A31515" }, + { token: "string.sql", foreground: "B5200D" }, + + { token: "keyword", foreground: "0000FF" }, + { token: "keyword.flow", foreground: "AF00DB" }, + + { token: "operator.sql", foreground: "778899" }, + { token: "operator.swift", foreground: "666666" }, + { token: "predefined.sql", foreground: "C700C7" }, + ], + colors: { + [editorBackground]: "#FFFFFF", + [editorForeground]: "#292929", + [editorIndentGuide1]: "#292929", + [editorActiveIndentGuide1]: "#292929", + }, +}; +/* -------------------------------- End hc-light theme -------------------------------- */ diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css new file mode 100644 index 000000000..3fc0f89b9 --- /dev/null +++ b/apps/diffr-web/src/styles.css @@ -0,0 +1,303 @@ +/* The page around Review Desktop's diff view: a header, the landing form, dialogs and panels. Colors + come from the theme's --vscode-* variables, so the page follows Whiteboard Dark and Light. */ + +html, +body { + margin: 0; + height: 100%; + background: var(--vscode-editor-background, #0c0f15); +} + +body { + overflow: hidden; +} + +@media (prefers-color-scheme: light) { + html, + body { + background: var(--vscode-editor-background, #ffffff); + } +} + +.app { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + color: var(--vscode-foreground); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +/* Whiteboard's syntax roles (extensions/review-themes), painted from diffr's highlights. */ +.app { + --syntax-comment: #778090; + --syntax-keyword: #5b7cff; + --syntax-type: #6fc3e6; + --syntax-function: #b7c4ff; + --syntax-variable: #eef0f4; + --syntax-string: #d9a85c; + --syntax-number: #d48fd6; + --syntax-operator: #9199a8; +} + +.app.vs { + --syntax-comment: #6b7382; + --syntax-keyword: #2b55e6; + --syntax-type: #0c6e8e; + --syntax-function: #1f3f99; + --syntax-variable: #15181e; + --syntax-string: #8a5a1c; + --syntax-number: #8b3f8a; + --syntax-operator: #5d6472; +} + +.monaco-editor .diffr-syntax-comment { + color: var(--syntax-comment); + font-style: italic; +} +.monaco-editor .diffr-syntax-keyword { + color: var(--syntax-keyword); +} +.monaco-editor .diffr-syntax-type { + color: var(--syntax-type); +} +.monaco-editor .diffr-syntax-function { + color: var(--syntax-function); +} +.monaco-editor .diffr-syntax-variable { + color: var(--syntax-variable); +} +.monaco-editor .diffr-syntax-string { + color: var(--syntax-string); +} +.monaco-editor .diffr-syntax-number { + color: var(--syntax-number); +} +.monaco-editor .diffr-syntax-constant { + color: var(--syntax-number); + font-weight: bold; +} +.monaco-editor .diffr-syntax-operator { + color: var(--syntax-operator); +} +.monaco-editor .diffr-syntax-heading { + color: var(--syntax-keyword); + font-weight: bold; +} + +.app-header { + flex: none; + display: flex; + align-items: center; + gap: 10px; + height: 35px; + padding: 0 8px 0 14px; + background: var(--vscode-sideBar-background); + border-bottom: 1px solid var(--vscode-panel-border); + min-width: 0; +} + +.app-wordmark { + font-family: "Geist Mono", ui-monospace, monospace; + font-weight: 600; + color: var(--vscode-foreground); + text-decoration: none; +} + +.app-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-descriptionForeground); + text-decoration: none; +} + +.app-title[href]:hover { + color: var(--vscode-foreground); +} + +.app-error { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.app-spacer { + flex: 1; +} + +.app-icon-button { + flex: none; + width: 26px; + height: 26px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; +} + +.app-icon-button:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.app-body { + position: relative; + flex: 1; + min-height: 0; +} + +.app-comparison { + position: absolute; + inset: 0; +} + +.app-overflow { + position: fixed; + inset: 0 auto auto 0; + z-index: 50; +} + +.app-landing { + max-width: 520px; + margin: 18vh auto 0; + padding: 0 16px; +} + +.app-landing h1 { + margin: 0 0 6px; + font-family: "Geist Mono", ui-monospace, monospace; + font-size: 28px; + font-weight: 600; +} + +.app-landing p { + margin: 0 0 20px; + color: var(--vscode-descriptionForeground); +} + +.app-landing input, +.app-dialog input, +.app-dialog textarea { + box-sizing: border-box; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + font: inherit; + outline: none; +} + +.app-landing input:focus, +.app-dialog input:focus, +.app-dialog textarea:focus { + border-color: var(--vscode-focusBorder); +} + +.app-landing .app-landing-error { + margin-top: 8px; + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.app-dialog { + width: min(560px, calc(100vw - 32px)); + padding: 18px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 10px; + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); + box-shadow: 0 12px 40px var(--vscode-widget-shadow); +} + +.app-dialog::backdrop { + background: rgb(0 0 0 / 0.4); +} + +.app-dialog h2 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; +} + +.app-dialog p { + margin: 0 0 12px; + color: var(--vscode-descriptionForeground); +} + +.app-dialog textarea { + height: 280px; + resize: vertical; + font-family: "Geist Mono", ui-monospace, monospace; + font-size: 12px; +} + +.app-dialog .app-dialog-status { + margin: 8px 0 0; + white-space: pre-wrap; +} + +.app-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +.app-button { + padding: 5px 12px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 6px; + background: transparent; + color: var(--vscode-foreground); + font: inherit; + cursor: pointer; +} + +.app-button.primary { + border-color: var(--vscode-focusBorder); + background: var(--vscode-focusBorder); + color: #fff; +} + +.app-panels { + position: fixed; + left: 10px; + bottom: 10px; + z-index: 40; + width: 240px; + padding: 4px 10px 8px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 8px; + background: var(--vscode-editorWidget-background); + box-shadow: 0 6px 24px var(--vscode-widget-shadow); + font-size: 12px; +} + +.app-panels h2 { + margin: 8px 0 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.app-panel-row { + display: flex; + justify-content: space-between; + gap: 12px; + line-height: 20px; +} + +.app-panel-row > :last-child { + font-family: "Geist Mono", ui-monospace, monospace; + font-variant-numeric: tabular-nums; +} diff --git a/apps/diffr-web/src/theme.ts b/apps/diffr-web/src/theme.ts new file mode 100644 index 000000000..2ef93442e --- /dev/null +++ b/apps/diffr-web/src/theme.ts @@ -0,0 +1,51 @@ +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 { StandaloneServices } from "./standalone/browser/standaloneServices.js"; +import type { StandaloneThemeService } from "./standalone/browser/standaloneThemeService.js"; +/** + * Whiteboard Dark and Light, Review Desktop's themes, following the system's choice. The colors are + * read from the desktop's theme files; syntax is painted by class (styles.css), not by token rules, + * since diffr supplies the highlights. + */ +import { IStandaloneThemeService } from "./standalone/common/standaloneTheme.js"; + +/** The theme files carry line comments. */ +const colors = (source: string): Record => + // SAFETY: both files are Review Desktop's color themes, whose `colors` maps ids to colors. + ( + JSON.parse(source.replace(/^\s*\/\/.*$/gm, "")) as { + colors: Record; + } + ).colors; + +export function applyTheme(root: HTMLElement): void { + // SAFETY: StandaloneServices registers StandaloneThemeService for this id; its + // container registration is on the implementation only. + const themes = StandaloneServices.get( + IStandaloneThemeService, + ) as StandaloneThemeService; + + themes.defineTheme("whiteboard-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: colors(dark), + }); + themes.defineTheme("whiteboard-light", { + base: "vs", + inherit: true, + rules: [], + colors: colors(light), + }); + themes.registerEditorContainer(root); + const query = matchMedia("(prefers-color-scheme: dark)"); + + const apply = () => { + themes.setTheme(query.matches ? "whiteboard-dark" : "whiteboard-light"); + root.classList.toggle("vs-dark", query.matches); + root.classList.toggle("vs", !query.matches); + }; + + query.addEventListener("change", apply); + apply(); +} diff --git a/apps/diffr-web/src/viewed.ts b/apps/diffr-web/src/viewed.ts new file mode 100644 index 000000000..4240d429c --- /dev/null +++ b/apps/diffr-web/src/viewed.ts @@ -0,0 +1,255 @@ +/** + * What the reader marked viewed, as line ranges per file, kept in this browser for each pull + * request or comparison. A file's marks are dropped once its contents change, the way GitHub + * clears a file's viewed box after a push. Review Desktop gets this progress from its host; here + * it is worked out from diffr's changed lines. + */ +import { Emitter } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; + +import type { + ReviewDiffLens, + ReviewDiffProgress, + ReviewDiffProgressFile, + StructuralDiff, +} from "./protocol.js"; +import { readSetting, writeSetting } from "./settings.js"; + +type Ranges = ReviewDiffLens["ranges"]; + +type Range = Ranges[number]; + +interface StoredFile { + /** What the file was when it was marked: its blob ids. */ + version: string; + ranges: Range[]; +} + +interface Known { + /** The file's base-side name. */ + base: string; + version: string; + changed: Range[]; +} + +export class ViewedProgress extends Disposable { + private readonly changed = this._register(new Emitter()); + readonly onDidChange = this.changed.event; + private readonly stored: Record; + private readonly known = new Map(); + private current: ReviewDiffProgress = { files: [] }; + + constructor(private readonly key: string) { + super(); + let stored: Record = {}; + + try { + stored = JSON.parse(readSetting(`viewed.${key}`) ?? "{}"); + } catch { + // Unreadable: start over. + } + + this.stored = stored; + } + + get progress(): ReviewDiffProgress { + return this.current; + } + + /** A file is diffed: its changed lines, and the version its marks belong to. */ + addFile( + path: string, + previousPath: string | undefined, + version: string, + diff: StructuralDiff, + ): void { + const changed: Range[] = []; + + if (diff.type === "text") { + for (const [start, end] of diff.structural_changes.base) + changed.push({ + side: "base", + file: previousPath ?? path, + fromLine: start + 1, + toLine: end, + }); + + for (const [start, end] of diff.structural_changes.head) + changed.push({ + side: "head", + file: path, + fromLine: start + 1, + toLine: end, + }); + } + + this.known.set(path, { base: previousPath ?? path, version, changed }); + const stored = this.stored[path]; + + if (stored && stored.version !== version) { + delete this.stored[path]; + this.save(); + } + + this.update(); + } + + async setViewed( + ranges: Ranges, + viewed: boolean, + paths?: readonly string[], + ): Promise { + const touched = new Set(paths); + + for (const [path, known] of this.known) { + const mine = ranges.filter( + (range) => range.file === known.base || range.file === path, + ); + + if (!mine.length) continue; + touched.add(path); + const current = this.stored[path]?.ranges ?? []; + + const next = viewed + ? merge([...current, ...mine]) + : subtract(current, mine); + + if (next.length) + this.stored[path] = { version: known.version, ranges: next }; + else delete this.stored[path]; + } + + this.save(); + this.update([...touched]); + } + + /** The file's header box: all of its changes viewed, or none. */ + toggleFile(path: string): Promise { + const file = this.current.files.find((file) => file.path === path); + const known = this.known.get(path); + + if (!known) return Promise.resolve(); + + return this.setViewed(known.changed, file?.state !== "viewed", [path]); + } + + private save(): void { + writeSetting( + `viewed.${this.key}`, + Object.keys(this.stored).length ? JSON.stringify(this.stored) : undefined, + ); + } + + private update(changedPaths?: readonly string[]): void { + const files: ReviewDiffProgressFile[] = []; + + for (const [path, known] of this.known) { + const viewedRanges = this.stored[path]?.ranges ?? []; + const viewed = intersect(known.changed, viewedRanges); + + const count = (ranges: Ranges, side: Range["side"]) => + ranges.reduce( + (sum, r) => sum + (r.side === side ? r.toLine - r.fromLine + 1 : 0), + 0, + ); + + const total = { + additions: count(known.changed, "head"), + deletions: count(known.changed, "base"), + }; + + const remaining = { + additions: total.additions - count(viewed, "head"), + deletions: total.deletions - count(viewed, "base"), + }; + + const size = total.additions + total.deletions; + const unread = remaining.additions + remaining.deletions; + files.push({ + path, + state: + size > 0 && unread === 0 + ? "viewed" + : unread < size + ? "partial" + : "unread", + total, + remaining, + viewedRanges, + changedRanges: known.changed, + }); + } + + this.current = { files, changedPaths }; + this.changed.fire(); + + if (changedPaths) this.current = { files }; + } +} + +const same = (a: Range, b: Range) => a.side === b.side && a.file === b.file; + +function merge(ranges: readonly Range[]): Range[] { + const result: Range[] = []; + + for (const range of [...ranges].sort( + (a, b) => + a.side.localeCompare(b.side) || + a.file.localeCompare(b.file) || + a.fromLine - b.fromLine, + )) { + const last = result[result.length - 1]; + + if (last && same(last, range) && range.fromLine <= last.toLine + 1) + result[result.length - 1] = { + ...last, + toLine: Math.max(last.toLine, range.toLine), + }; + else result.push(range); + } + + return result; +} + +function intersect(a: readonly Range[], b: readonly Range[]): Range[] { + return merge( + a.flatMap((left) => + b.flatMap((right) => { + if (!same(left, right)) return []; + const fromLine = Math.max(left.fromLine, right.fromLine); + const toLine = Math.min(left.toLine, right.toLine); + + return fromLine <= toLine ? [{ ...left, fromLine, toLine }] : []; + }), + ), + ); +} + +function subtract( + ranges: readonly Range[], + removed: readonly Range[], +): Range[] { + let result = merge(ranges); + + for (const cut of removed) { + result = result.flatMap((range) => { + if ( + !same(range, cut) || + cut.toLine < range.fromLine || + cut.fromLine > range.toLine + ) + return [range]; + const pieces: Range[] = []; + + if (cut.fromLine > range.fromLine) + pieces.push({ ...range, toLine: cut.fromLine - 1 }); + + if (cut.toLine < range.toLine) + pieces.push({ ...range, fromLine: cut.toLine + 1 }); + + return pieces; + }); + } + + return result; +} diff --git a/apps/diffr-web/tsconfig.json b/apps/diffr-web/tsconfig.json new file mode 100644 index 000000000..37d7911e2 --- /dev/null +++ b/apps/diffr-web/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2024", + "ESNext.Disposable", + "DOM", + "DOM.Iterable", + "WebWorker.ImportScripts" + ], + "strict": true, + "noImplicitOverride": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "allowImportingTsExtensions": true, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "noEmit": true, + "types": [ + "vite/client", + "@webgpu/types", + "trusted-types", + "wicg-file-system-access" + ], + "paths": { + "vs/*": ["../review-desktop/code-oss/src/vs/*"], + "@dev.fast/diffr": ["../../diffr/diffr-ts/src/index.ts"] + } + }, + "include": [ + "src", + "vite.config.ts", + "../review-desktop/code-oss/src/typings/*.d.ts", + "../review-desktop/code-oss/src/vs/monaco.d.ts" + ] +} diff --git a/apps/diffr-web/vite.config.ts b/apps/diffr-web/vite.config.ts new file mode 100644 index 000000000..0031eb239 --- /dev/null +++ b/apps/diffr-web/vite.config.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +import { type Plugin, defineConfig } from "vite"; + +// The editor is Review Desktop's fork of VS Code's, built from its sources. +const vs = fileURLToPath( + new URL("../review-desktop/code-oss/src/vs", import.meta.url), +); + +// Review Desktop's build copies the icon font into its sources; the page takes it from the package. +const codicons = join( + dirname( + createRequire(import.meta.url).resolve("@vscode/codicons/package.json"), + ), + "dist/codicon.ttf", +); + +const engine = fileURLToPath( + new URL("src/wasm/diffr_web_bg.wasm", import.meta.url), +); + +/** + * The engine is over 100 MB and Workers static assets cap a file at 25 MiB, so the page loads a + * gzipped copy and unpacks it as it compiles. Written beside the engine whenever it is newer. + */ +function gzipEngine(): Plugin { + const write = () => { + if (!existsSync(engine)) return; + const gz = `${engine}.gz`; + + if (existsSync(gz) && statSync(gz).mtimeMs >= statSync(engine).mtimeMs) + return; + writeFileSync(gz, gzipSync(readFileSync(engine), { level: 9 })); + }; + + return { name: "gzip-engine", buildStart: write, configureServer: write }; +} + +/** + * wasm-bindgen's loader names the raw engine as its default source, which would ship it too; the + * page always hands workers the compiled module instead, so the raw file is never fetched. + */ +function dropRawEngine(): Plugin { + return { + name: "drop-raw-engine", + apply: "build", + generateBundle(_, bundle) { + for (const name of Object.keys(bundle)) + if (/diffr_web_bg-[\w-]+\.wasm$/.test(name)) delete bundle[name]; + }, + }; +} + +export default defineConfig({ + plugins: [gzipEngine(), dropRawEngine()], + resolve: { + alias: [ + { find: /^vs\//, replacement: `${vs}/` }, + { find: /^\.\/codicon\.ttf$/, replacement: codicons }, + ], + }, + server: { fs: { allow: [fileURLToPath(new URL("../..", import.meta.url))] } }, + worker: { format: "es", plugins: () => [dropRawEngine()] }, + build: { target: "es2024", chunkSizeWarningLimit: 4096 }, +}); diff --git a/apps/diffr-web/wrangler.jsonc b/apps/diffr-web/wrangler.jsonc new file mode 100644 index 000000000..00c1f1661 --- /dev/null +++ b/apps/diffr-web/wrangler.jsonc @@ -0,0 +1,13 @@ +// diffsthatgetyoustiff.com: 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", + "compatibility_date": "2026-09-01", + "assets": { + "directory": "./dist", + // Every path is a route of the page: /owner/repo/pull/N, /owner/repo/compare/a...b. + "not_found_handling": "single-page-application", + }, + "preview_urls": true, + "routes": [{ "pattern": "diffsthatgetyoustiff.com", "custom_domain": true }], +} diff --git a/packages/review-protocol/package.json b/packages/review-protocol/package.json index d53a00bfe..c2fa8e43a 100644 --- a/packages/review-protocol/package.json +++ b/packages/review-protocol/package.json @@ -12,7 +12,8 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./src/*": "./src/*" }, "scripts": { "build": "pnpm -w exec tsc -p packages/review-protocol/tsconfig.build.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e71fa231..b3ba25e73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,40 @@ importers: specifier: 'catalog:' version: 4.6.5 + apps/diffr-web: + dependencies: + '@dev.fast/diffr': + specifier: workspace:* + version: link:../../diffr/diffr-ts + '@dev.fast/review-protocol': + specifier: workspace:* + version: link:../../packages/review-protocol + devDependencies: + '@types/trusted-types': + specifier: 2.0.7 + version: 2.0.7 + '@types/wicg-file-system-access': + specifier: 2023.10.7 + version: 2023.10.7 + '@vscode/codicons': + specifier: 0.0.46-21 + version: 0.0.46-21 + '@vscode/diff': + specifier: 0.0.2-7 + version: 0.0.2-7 + '@vscode/tree-sitter-wasm': + specifier: 0.3.1 + version: 0.3.1 + '@webgpu/types': + specifier: 0.1.66 + version: 0.1.66 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(tsx@4.21.0) + apps/review-desktop: dependencies: '@dev.fast/review-protocol': @@ -1416,9 +1450,15 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/wicg-file-system-access@2023.10.7': + resolution: {integrity: sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1592,6 +1632,18 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vscode/codicons@0.0.46-21': + resolution: {integrity: sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==} + + '@vscode/diff@0.0.2-7': + resolution: {integrity: sha512-zGPIPeUAmQs79u7g6FTLmhlXFIocUTtuHYmCV5lRZf6vlDk7SWEOpBlYY6SsShC3TB/lm2KxXMj9tP32YOPrhg==} + + '@vscode/tree-sitter-wasm@0.3.1': + resolution: {integrity: sha512-RJFoomET6FajjG511fmQxeBQfU6M24a0aFZPqpid+ttIxanWf1VGytBG0UmsGjt07qmIPJS8U31D+aecuCucsQ==} + + '@webgpu/types@0.1.66': + resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==} + '@xyflow/react@12.10.2': resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} peerDependencies: @@ -4006,8 +4058,12 @@ snapshots: '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} + '@types/wicg-file-system-access@2023.10.7': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4144,6 +4200,14 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@vscode/codicons@0.0.46-21': {} + + '@vscode/diff@0.0.2-7': {} + + '@vscode/tree-sitter-wasm@0.3.1': {} + + '@webgpu/types@0.1.66': {} + '@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@xyflow/system': 0.0.76 From e7391f19a4e591e42b9b6b0b66e777f2de9f12b6 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 18:41:43 -0700 Subject: [PATCH 06/21] Deploy diffr-web to Cloudflare Workers Pushes to main that touch diffr, the page or the Review Desktop sources it is built from deploy diffsthatgetyoustiff.com as Workers static assets; pull requests from this repository upload a preview version aliased pr-N. Needs the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID secrets. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- .github/workflows/diffr-web.yml | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/diffr-web.yml diff --git a/.github/workflows/diffr-web.yml b/.github/workflows/diffr-web.yml new file mode 100644 index 000000000..f03b28fdb --- /dev/null +++ b/.github/workflows/diffr-web.yml @@ -0,0 +1,116 @@ +on: + push: + branches: [main] + paths: + - "diffr/**" + - "apps/diffr-web/**" + # The page is built from Review Desktop's editor and review sources. + - "apps/review-desktop/code-oss/src/vs/**" + - "apps/review-desktop/code-oss/extensions/review-themes/**" + - "packages/review-protocol/**" + - ".github/workflows/diffr-web.yml" + pull_request: + paths: + - "diffr/**" + - "apps/diffr-web/**" + # The page is built from Review Desktop's editor and review sources. + - "apps/review-desktop/code-oss/src/vs/**" + - "apps/review-desktop/code-oss/extensions/review-themes/**" + - "packages/review-protocol/**" + - ".github/workflows/diffr-web.yml" + workflow_dispatch: + +name: Diffr Web + +permissions: + contents: read + +concurrency: + group: diffr-web-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Build and deploy + runs-on: ubuntu-24.04 + env: + WRANGLER: npx --yes wrangler@4.135.0 + # A fork's pull request gets no secrets, so it builds without deploying. + DEPLOY: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - name: Checkout source code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.99.0 + with: + targets: wasm32-unknown-unknown + # llvm-ar archives the grammars' wasm objects; clang comes with the runner. + components: llvm-tools + + - name: Install wasm-bindgen + uses: taiki-e/install-action@1329c298aa20c3257846c9b2e0e55967df3e3c37 # v2 + with: + tool: wasm-bindgen@0.2.129 + + - name: Restore diffr build + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + diffr/target + key: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}-${{ github.sha }} + restore-keys: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}- + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter @dev.fast/diffr-web... + + - name: Build the engine + run: pnpm --filter @dev.fast/diffr-web wasm + + - name: Save diffr build + if: github.ref == 'refs/heads/main' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + diffr/target + key: diffr-web-${{ hashFiles('diffr/Cargo.lock', 'diffr/rust-toolchain.toml') }}-${{ github.sha }} + + # Not the repo's typecheck: it needs the engine's bindings, built above. + - name: Typecheck + run: pnpm --filter @dev.fast/diffr-web check + + - name: Build the page + run: pnpm --filter @dev.fast/diffr-web build + + - name: Deploy + if: env.DEPLOY == 'true' && github.event_name != 'pull_request' + working-directory: apps/diffr-web + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: $WRANGLER deploy + + - name: Upload a preview + if: env.DEPLOY == 'true' && github.event_name == 'pull_request' + working-directory: apps/diffr-web + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + ALIAS: pr-${{ github.event.pull_request.number }} + run: | + $WRANGLER versions upload --preview-alias "$ALIAS" | tee upload.log + url=$(grep -o "https://$ALIAS-[^ ]*workers.dev" upload.log | head -1) + echo "Preview: ${url:-see the log above}" >> "$GITHUB_STEP_SUMMARY" From b6e7ce91812f5310dd2cd5f22d9bd61f0887c763 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 19:08:24 -0700 Subject: [PATCH 07/21] Measure the editor font as monospace, as Review Desktop does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geist Mono has no "→", one of Monaco's monospace probes, so Monaco measured carets from the DOM and drew one at a line's end past the fold hint's margin, over its text. Review Desktop measures before Geist Mono loads and gets Menlo's uniform widths; the page keeps Geist Mono's widths and overrules only the monospace verdict. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/editorFont.ts | 19 +++++++++++++++++++ apps/diffr-web/src/main.ts | 1 + 2 files changed, 20 insertions(+) create mode 100644 apps/diffr-web/src/editorFont.ts diff --git a/apps/diffr-web/src/editorFont.ts b/apps/diffr-web/src/editorFont.ts new file mode 100644 index 000000000..aef7bda88 --- /dev/null +++ b/apps/diffr-web/src/editorFont.ts @@ -0,0 +1,19 @@ +/** + * Monaco takes a font as monospace only when its probe glyphs share one width, and otherwise + * measures every caret and selection from the DOM, which lands a caret at a line's end past the + * margin of text injected there (the fold hint's). Geist Mono has no "→", one of the probes, so it + * always fails. Review Desktop measures before Geist Mono loads and gets Menlo's uniform widths; + * the page measures Geist Mono itself and only overrules that one verdict. + */ +import { FontMeasurements } from "vs/editor/browser/config/fontMeasurements.js"; +import { FontInfo } from "vs/editor/common/config/fontInfo.js"; + +const readFontInfo = FontMeasurements.readFontInfo.bind(FontMeasurements); + +FontMeasurements.readFontInfo = (targetWindow, bareFontInfo) => { + const info = readFontInfo(targetWindow, bareFontInfo); + + if (info.isMonospace || !info.fontFamily.startsWith('"Geist Mono"')) return info; + + return new FontInfo({ ...info, isMonospace: true }, info.isTrusted); +}; diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index fe70f4fc5..90d70f497 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -5,6 +5,7 @@ import "vs/base/browser/ui/codicons/codiconStyles.js"; import "./fonts.css"; import "./styles.css"; +import "./editorFont.js"; import { Comparison } from "./comparison.js"; import { Engine, onEngineChange } from "./engine/engine.js"; import { parseTarget, setToken, targetPath, token } from "./github.js"; From 00a460db9fbdb048b370e008fe2600ecec21925e Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 19:31:22 -0700 Subject: [PATCH 08/21] Light the enclosing scope when hovering a statement diffr makes each statement its own one-line fold, with a chevron and no rail. Hovering one targeted the statement, so the body around it never lit up. Like diffr's TUI, a scope without a rail is now a target only from its chevron; elsewhere the innermost body around the line lights. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/review/services/reviewStructuralFolds.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index 5e2a338d1..269d8f74e 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -569,7 +569,11 @@ export class StructuralFoldControls extends Disposable { }; } } - const header = open.find((f) => f.chevron && f.line === line); + // A scope without a rail (a statement) is a target only from its chevron; elsewhere on its + // line the pointer reads the innermost body around it, as diffr's TUI does. + const header = open.find( + (f) => f.chevron && f.line === line && (f.rail || onChevron), + ); if (header) { return { foldable: header, From 2945557c35a08f7573da377b834b2badb9806dee Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 19:35:24 -0700 Subject: [PATCH 09/21] Light the enclosing scope when hovering a deleted row In the unified view deleted rows are view zones after the modified line above them, which the fold controls ignored. A deleted row now reads the innermost open body around its gap, as a body line does. Other view zones, such as unchanged-line bands, still light nothing. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- .../review/services/reviewStructuralFolds.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts index 269d8f74e..a0b941e8b 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -516,8 +516,40 @@ export class StructuralFoldControls extends Disposable { if (!model || !path || !diff || !position) { return undefined; } - const line = position.lineNumber - 1; const foldables = this.foldablesOf(diff); + // A deleted row in the unified view is a view zone after the modified line above it. Inside + // a body it reads the innermost body around its gap, as a body line does. + const element = e.event.browserEvent.target as HTMLElement | null; + if ( + (e.target.type === MouseTargetType.CONTENT_VIEW_ZONE || + e.target.type === MouseTargetType.GUTTER_VIEW_ZONE) && + element?.closest?.( + ".line-delete-selectable, .inline-original-margin-view-zone, .inline-deleted-margin-view-zone", + ) + ) { + // The zone follows this 1-based line, so it is the 0-based line below the gap. + const below = e.target.detail.afterLineNumber; + const around = foldables + .filter( + (f) => + f.rail && + !this.isCollapsed(path, f) && + f.rail.start <= below && + below <= f.rail.end, + ) + .sort( + (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), + ); + return around[0] + ? { + foldable: around[0], + rail: false, + control: false, + collapsed: false, + } + : undefined; + } + const line = position.lineNumber - 1; const content = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; From 118a87d286ff300ab5201cba13b74b482924b359 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 19:48:29 -0700 Subject: [PATCH 10/21] Give diffr-web the diffr TUI's keys and fallback count Fold commands (za zo zc and capitals, zM zR, zj zk) at the caret, Alt+click to fold recursively, ] and [ between changes, c for context gaps, s, w, i, \ and Cmd+B for the tree, and j k d u f b space g G to scroll. The diff stats panel counts files diffr diffed line by line. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/comparison.ts | 82 +++++++++++- apps/diffr-web/src/folds.ts | 118 ++++++++++++++++++ apps/diffr-web/src/main.ts | 98 ++++++++++++++- apps/diffr-web/src/panels.ts | 9 ++ .../review/services/reviewFilesDiffView.ts | 77 +++++++++--- .../review/services/reviewStructuralFolds.ts | 93 +++++++++++++- 6 files changed, 446 insertions(+), 31 deletions(-) create mode 100644 apps/diffr-web/src/folds.ts diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index b891b7aae..914105a3a 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -5,11 +5,14 @@ */ import { Emitter } from "vs/base/common/event.js"; import { Disposable, DisposableStore } from "vs/base/common/lifecycle.js"; +import type { IObservable } from "vs/base/common/observable.js"; import { URI } from "vs/base/common/uri.js"; +import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; import { IHoverService } from "vs/platform/hover/browser/hover.js"; import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; import type { Classified, Engine, FileRequest } from "./engine/engine.js"; +import { foldIds, gapIds } from "./folds.js"; import { type Change, type ChangedFile, @@ -21,7 +24,10 @@ import { } from "./github.js"; import type { ReviewDiffFileWire, StructuralLineCounts } from "./protocol.js"; import { orderReviewDiffFiles } from "./review/common/reviewChangedFilesModel.js"; -import { reviewFileCounts } from "./review/common/reviewStructuralDiff.js"; +import { + type StructuralTextDiff, + reviewFileCounts, +} from "./review/common/reviewStructuralDiff.js"; import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; import { ReviewFilesDiffView, @@ -30,6 +36,7 @@ import { } from "./review/services/reviewFilesDiffView.js"; import { createStructuralDiffEditors } from "./review/services/reviewStructuralDiff.js"; import { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; +import { structuralFoldCommand } from "./review/services/reviewStructuralFolds.js"; import { StructuralViewedState } from "./review/services/reviewStructuralViewed.js"; import { ScopeViewedControl } from "./review/services/reviewStructuralViewedControl.js"; import { ViewedProgress } from "./viewed.js"; @@ -93,6 +100,7 @@ export class Comparison extends Disposable { readonly target: Target, private readonly engine: Engine, private readonly layout: ReviewDiffLayoutSetting, + private readonly wordWrap: IObservable, private readonly instantiation: IInstantiationService, ) { super(); @@ -137,10 +145,81 @@ export class Comparison extends Disposable { return { added, removed }; } + /** Files diffr diffed line by line, its structural diff having failed, and why. */ + get fallbacks(): { path: string; message: string }[] { + return this.order.flatMap((path) => { + const diff = this.session?.getFileResult(path)?.diff; + const fallback = diff?.type === "text" ? diff.stats.fallback : undefined; + + return fallback ? [{ path, message: fallback.message }] : []; + }); + } + focus(): void { this.view?.focus(); } + goToChange(direction: "next" | "previous"): void { + this.view?.goToChange(direction); + } + + scroll(to: number | "top" | "end"): void { + this.view?.scroll(to); + } + + toggleFileTree(): void { + this.view?.toggleFileTree(); + } + + /** A fold command at the caret of the focused diff editor (`StructuralFoldControls.command`). */ + foldCommand(command: string): void { + const editor = this.instantiation + .invokeFunction((accessor) => accessor.get(ICodeEditorService)) + .getFocusedCodeEditor(); + + if (editor) structuralFoldCommand(editor, command); + } + + /** Folds or unfolds everything diffr can fold, in every file. */ + foldAll(collapsed: boolean): void { + this.setFolds(foldIds, collapsed); + } + + /** Shows every context gap, or hides them all again once any is shown. */ + toggleContextGaps(): void { + const session = this.session; + + if (!session) return; + + const shown = this.order.some((path) => { + const diff = session.getTextDiff(path); + + return ( + !!diff && + gapIds(diff).some((id) => session.isRegionCollapsed(path, id) === false) + ); + }); + + this.setFolds(gapIds, shown); + } + + private setFolds( + ids: (diff: StructuralTextDiff) => number[], + collapsed: boolean, + ): void { + const session = this.session; + + if (!session) return; + + for (const path of this.order) { + const diff = session.getTextDiff(path); + + if (diff) + for (const id of ids(diff)) + session.setRegionCollapsed(path, id, collapsed); + } + } + private async load(): Promise { const change = await loadChange(this.target, (preview) => { this.preview = preview; @@ -242,6 +321,7 @@ export class Comparison extends Disposable { ReviewFilesEditorInput, entries, session, + this.wordWrap, ), ); diff --git a/apps/diffr-web/src/folds.ts b/apps/diffr-web/src/folds.ts new file mode 100644 index 000000000..35f0139a8 --- /dev/null +++ b/apps/diffr-web/src/folds.ts @@ -0,0 +1,118 @@ +/** + * The fold sets the diffr TUI's commands act on (diffr/tui/packages/hunk/src/diffr/regions.ts): + * everything foldable, what a fold holds, and the context gaps. Only fold-state ids: the session + * keeps whether each is collapsed. + */ +import type { + StructuralRegion, + StructuralTextDiff, +} from "./review/common/reviewStructuralDiff.js"; + +type Region = StructuralRegion; + +/** Each side's regions under its root, depth first. */ +function regions(diff: StructuralTextDiff): Region[][] { + return [diff.lhs, diff.rhs].map((side) => { + const out: Region[] = []; + + const walk = (region: Region) => { + out.push(region); + + if (region.kind === "fold") region.children.forEach(walk); + }; + + if (side?.root.kind === "fold") side.root.children.forEach(walk); + + return out; + }); +} + +/** Every fold, and every leaf diffr collapsed or labelled: what fold all and unfold all touch. */ +export function foldIds(diff: StructuralTextDiff): number[] { + const ids = regions(diff) + .flat() + .filter( + (region) => + region.kind === "fold" || + region.visibility?.collapsed === true || + !!region.visibility?.label, + ) + .map((region) => region.fold_state_id); + + return [...new Set(ids)]; +} + +/** The folds inside every region sharing `id`, so a docstring and its function open as one. */ +export function nestedIds(diff: StructuralTextDiff, id: number): number[] { + const nested = new Set(); + + const collect = (region: Region) => { + nested.add(region.fold_state_id); + + if (region.kind === "fold") region.children.forEach(collect); + }; + + for (const region of regions(diff).flat()) + if (region.fold_state_id === id && region.kind === "fold") + region.children.forEach(collect); + nested.delete(id); + + return [...nested].filter((n) => foldIds(diff).includes(n)); +} + +/** + * Context gaps: what diffr collapsed far from any change. A region that starts collapsed, has no + * tags, has a counterpart on the other side, and holds no change on either side. + */ +export function gapIds(diff: StructuralTextDiff): number[] { + const sides = regions(diff); + + const leaves = sides.map((side) => + side.filter( + (region): region is Extract => + region.kind === "leaf", + ), + ); + + const states = sides.map( + (side) => new Set(side.map((region) => region.fold_state_id)), + ); + + const alignments = leaves.map( + (side) => new Set(side.map((leaf) => leaf.alignment_id)), + ); + + const changed = new Set( + leaves + .flat() + .filter((leaf) => leaf.changed?.length) + .map((leaf) => leaf.alignment_id), + ); + + const gaps = new Set(); + + sides.forEach((side, index) => { + const other = index ? 0 : 1; + + const paired = (region: Region) => + region.kind === "leaf" + ? alignments[other]!.has(region.alignment_id) + : states[other]!.has(region.fold_state_id); + + const unchanged = (region: Region): boolean => + region.kind === "leaf" + ? paired(region) && !changed.has(region.alignment_id) + : region.children.every(unchanged); + + for (const region of side) + if ( + region.visibility?.collapsed === true && + !region.tags?.length && + paired(region) && + unchanged(region) + ) + gaps.add(region.fold_state_id); + }); + + return [...gaps]; +} diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 90d70f497..a63743e40 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -3,6 +3,8 @@ * `/owner/repo/compare/base...head`. Everything runs here; GitHub is the only server it talks to. */ import "vs/base/browser/ui/codicons/codiconStyles.js"; +import { observableValue } from "vs/base/common/observable.js"; + import "./fonts.css"; import "./styles.css"; import "./editorFont.js"; @@ -19,6 +21,8 @@ const instantiation = StandaloneServices.initialize({}); const layout = new ReviewDiffLayoutSetting(); +const wordWrap = observableValue("wordWrap", false); + const root = document.getElementById("app")!; // Review Desktop's stylesheet is scoped to its workbench; the theme's variables to Monaco components. @@ -97,6 +101,7 @@ function route(): void { target, currentEngine(), layout, + wordWrap, instantiation, )); @@ -314,10 +319,93 @@ function openConfig(): void { window.addEventListener("popstate", route); -window.addEventListener("keydown", (event: KeyboardEvent) => { - if (event.key !== "F2" && event.key !== "F3") return; - event.preventDefault(); - panels.toggle(event.key === "F2" ? "stats" : "engine"); -}); +/** A `z` waiting for its fold command. */ +let chord = false; + +/** The diffr TUI's keys, besides its menus and panning, which the page has as clicks and scrolls. */ +function command(event: KeyboardEvent): (() => void) | undefined { + // A shifted letter, as some keyboards and input tools report it. + const key = + event.shiftKey && event.key.length === 1 + ? event.key.toUpperCase() + : event.key; + + if (key === "F2" || key === "F3") + return () => panels.toggle(key === "F2" ? "stats" : "engine"); + + if (!comparison) return undefined; + const current = comparison; + + if ((event.metaKey || event.ctrlKey) && !event.altKey && key === "b") + return () => current.toggleFileTree(); + + // Plain keys only, and never while typing. The editors are read only, so their input area types + // nothing. + if (event.metaKey || event.ctrlKey || event.altKey) return undefined; + const target = event.target; + + if ( + (target instanceof Element && + target.closest("input, dialog, .find-widget")) || + (target instanceof HTMLTextAreaElement && + !target.classList.contains("inputarea")) + ) + return undefined; + + // Shift, for a capital, is pressed before the letter it shifts. + if (["Shift", "Alt", "Control", "Meta"].includes(key)) return undefined; + + if (chord) { + chord = false; + + if (key === "M" || key === "R") return () => current.foldAll(key === "M"); + + return "aocAOCjk".includes(key) && key.length === 1 + ? () => current.foldCommand(key) + : undefined; + } + + const viewport = + (document.querySelector(".app-comparison")?.clientHeight ?? 0) - 2 * LINE; + + const keys = new Map void>([ + ["z", () => (chord = true)], + ["]", () => current.goToChange("next")], + ["[", () => current.goToChange("previous")], + ["s", () => void layout.toggle().then(renderHeader)], + ["w", () => wordWrap.set(!wordWrap.get(), undefined)], + ["c", () => current.toggleContextGaps()], + ["i", () => panels.toggle("stats")], + ["\\", () => current.toggleFileTree()], + ["j", () => current.scroll(LINE)], + ["k", () => current.scroll(-LINE)], + ["d", () => current.scroll(viewport / 2)], + ["u", () => current.scroll(-viewport / 2)], + ["f", () => current.scroll(viewport)], + [" ", () => current.scroll(event.shiftKey ? -viewport : viewport)], + ["b", () => current.scroll(-viewport)], + ["g", () => current.scroll("top")], + ["G", () => current.scroll("end")], + ]); + + return keys.get(key); +} + +/** One row of the diff, in pixels: the editor's 12px font at Monaco's line height. */ +const LINE = 18; + +// Capturing, so a read-only editor never answers a letter with its "cannot edit" note. +window.addEventListener( + "keydown", + (event: KeyboardEvent) => { + const run = command(event); + + if (!run) return; + event.preventDefault(); + event.stopPropagation(); + run(); + }, + true, +); route(); diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts index 1d6afda47..cc7a41a2c 100644 --- a/apps/diffr-web/src/panels.ts +++ b/apps/diffr-web/src/panels.ts @@ -100,6 +100,15 @@ export class Panels { "Generated, vendored and lock files, folded until opened", ]); + const fallbacks = comparison.fallbacks; + + if (fallbacks.length) + rows.push([ + "Line diff", + number(fallbacks.length), + fallbacks.map(({ path, message }) => `${path}: ${message}`).join("\n"), + ]); + if (work.failed) rows.push(["Failed", number(work.failed)]); return rows; diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index f5b913741..3441b13b5 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -24,7 +24,7 @@ import { MutableDisposable, toDisposable, } from "vs/base/common/lifecycle.js"; -import { autorun } from "vs/base/common/observable.js"; +import { autorun, type IObservable } from "vs/base/common/observable.js"; import { isEqual } from "vs/base/common/resources.js"; import { URI } from "vs/base/common/uri.js"; import { ElementSizeObserver } from "vs/editor/browser/config/elementSizeObserver.js"; @@ -116,6 +116,8 @@ export class ReviewFilesEditorInput extends Disposable { constructor( readonly entries: readonly ReviewFilesEditorEntry[], private readonly session: StructuralDiffSession, + /** Whether long lines wrap, in every file. */ + private readonly wordWrap: IObservable, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @IInstantiationService @@ -159,29 +161,37 @@ export class ReviewFilesEditorInput extends Disposable { ); const text = (side: "lhs" | "rhs") => diff.type === "text" ? (diff[side]?.text ?? "") : ""; + const wordWrap = this.wordWrap; + // Every hidden line comes from the one fold model: diffr's regions, + // never the diff editor's own unchanged-region hiding. + const options = { + ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, + hideOriginalLineNumbers: + entry.file.status === "added" || entry.file.status === "unchanged", + hideUnchangedRegions: { + enabled: true, + minimumLineCount: 1, + contextLineCount: 0, + }, + folding: false, + // Structural rails use one geometry through code, folds and deleted rows. + guides: { indentation: false, bracketPairs: false }, + // Fold controls follow the line numbers, with breathing room before code. + lineDecorationsWidth: 24, + glyphMargin: true, + experimental: { useTrueInlineView: false }, + } satisfies IDiffEditorOptions; const item: IDocumentDiffItem = { original: model(entry.original!, text("lhs")), modified: model(entry.modified!, text("rhs")), labelUris: reviewMultiDiffLabelUris(entry.file), - // Every hidden line comes from the one fold model: diffr's regions, - // never the diff editor's own unchanged-region hiding. - options: { - ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, - hideOriginalLineNumbers: - entry.file.status === "added" || entry.file.status === "unchanged", - hideUnchangedRegions: { - enabled: true, - minimumLineCount: 1, - contextLineCount: 0, - }, - folding: false, - // Structural rails use one geometry through code, folds and deleted rows. - guides: { indentation: false, bracketPairs: false }, - // Fold controls follow the line numbers, with breathing room before code. - lineDecorationsWidth: 24, - glyphMargin: true, - experimental: { useTrueInlineView: false }, + get options() { + return { + ...options, + wordWrap: wordWrap.get() ? ("on" as const) : ("off" as const), + }; }, + onOptionsDidChange: Event.fromObservableLight(wordWrap), }; const document = RefCounted.createOfNonDisposable(item, store, this); this.documents.set(entry.file.path, document); @@ -257,6 +267,7 @@ export class ReviewFilesDiffView extends Disposable { private pendingSource: ReviewDiffLens["ranges"][number] | undefined; private progress: ReviewDiffProgress | undefined; private documentCollapsed = false; + private fileTreeHidden = false; private readonly initializedDocumentItems = new WeakSet(); private readonly viewedApplied = new Map(); private readonly streamStatus: HTMLElement; @@ -908,6 +919,30 @@ export class ReviewFilesDiffView extends Disposable { return this.widget.getActiveControl(); } + /** The next or previous change, into the next file past the last. */ + goToChange(direction: "next" | "previous"): void { + this.settleHold.clear(); + if (direction === "next") this.widget.goToNextChange(); + else this.widget.goToPreviousChange(); + } + + /** Scrolls by `delta` pixels, or to the top or the end. */ + scroll(to: number | "top" | "end"): void { + this.settleHold.clear(); + this.widget.setScrollTop( + to === "top" + ? 0 + : to === "end" + ? this.widget.getContentHeight() + : this.widget.getScrollTop() + to, + ); + } + + toggleFileTree(): void { + this.fileTreeHidden = !this.fileTreeHidden; + this.layout(); + } + setCollapsed(collapsed: boolean): void { this.settleHold.clear(); this.documentCollapsed = collapsed; @@ -927,7 +962,9 @@ export class ReviewFilesDiffView extends Disposable { if (width <= 0 || height <= 0) return; const fileTreeVisible = - !this.fileTreeContainer && width >= FILE_TREE_COLLAPSE_WIDTH; + !this.fileTreeContainer && + !this.fileTreeHidden && + 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 a0b941e8b..f810d7376 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts @@ -28,6 +28,7 @@ import { type ITextModel, } from "vs/editor/common/model.js"; +import { nestedIds } from "../../folds.js"; import { bandDetail, structuralFoldables, @@ -66,6 +67,16 @@ const RAIL_CURSOR = "review-fold-rail"; /** Marks the text after a folded header, so that a click on it unfolds the scope. */ const PILL = Symbol("review-fold-pill"); +const controls = new WeakMap(); + +/** Runs a fold command (see `StructuralFoldControls.command`) in a structural diff editor. */ +export function structuralFoldCommand( + editor: ICodeEditor, + command: string, +): void { + controls.get(editor)?.command(command); +} + /** * Chevrons, rails and header-line folds for one side of a structural diff * editor. The pointer's scope shows its chevron, rail and braces. A click on @@ -94,6 +105,8 @@ export class StructuralFoldControls extends Disposable { private readonly viewed?: StructuralViewedState, ) { super(); + controls.set(editor, this); + this._register({ dispose: () => controls.delete(editor) }); this._register({ dispose: () => { for (const control of this.viewedControls.values()) control.dispose(); @@ -136,7 +149,7 @@ export class StructuralFoldControls extends Disposable { target.rail === pressed.rail && target.control === pressed.control ) { - this.toggle(pressed); + this.toggle(pressed, e.event.altKey); } }), ); @@ -175,9 +188,63 @@ export class StructuralFoldControls extends Disposable { this.render(); } - private toggle(target: FoldTarget): void { + /** + * The diffr TUI's fold commands, at the caret where the TUI takes its top row. `a` toggles, `o` + * opens and `c` closes the scope whose chevron is on the caret's line, else the innermost open + * scope around it; capitals take the folds inside too. `j` and `k` go to the next or previous + * chevron. + */ + command(command: string): void { + const path = this.path(); + const diff = path ? this.session.getTextDiff(path) : undefined; + const position = this.editor.getPosition(); + if (!path || !diff || !position) { + return; + } + const line = position.lineNumber - 1; + const foldables = this.foldablesOf(diff).filter((f) => f.chevron); + if (command === "j" || command === "k") { + const lines = foldables.map((f) => f.line).sort((a, b) => a - b); + const next = + command === "j" + ? lines.find((l) => l > line) + : lines.findLast((l) => l < line); + if (next !== undefined) { + this.editor.setPosition({ lineNumber: next + 1, column: 1 }); + this.editor.revealLineInCenterIfOutsideViewport(next + 1); + } + return; + } + const foldable = + foldables.find((f) => f.line === line) ?? + foldables + .filter( + (f) => + f.rail && + !this.isCollapsed(path, f) && + f.rail.start <= line && + line <= f.rail.end, + ) + .sort( + (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), + )[0]; + const letter = command.toLowerCase(); + if (!foldable || !"aoc".includes(letter)) { + return; + } + const collapse = + letter === "a" ? !this.isCollapsed(path, foldable) : letter === "c"; + // A caret in hidden lines reveals them, so it waits on the header. + if (collapse) { + this.editor.setPosition({ lineNumber: foldable.line + 1, column: 1 }); + } + this.setCollapsed(path, diff, foldable, collapse, command !== letter); + } + + private toggle(target: FoldTarget, recursive: boolean): void { const path = this.path(); - if (!path) { + const diff = path ? this.session.getTextDiff(path) : undefined; + if (!path || !diff) { return; } // A rail click puts the caret in the body, and a caret in hidden lines reveals them. @@ -187,13 +254,29 @@ export class StructuralFoldControls extends Disposable { column: 1, }); } - this.session.setRegionCollapsed( + this.setCollapsed( path, - target.foldable.foldStateId, + diff, + target.foldable, !target.collapsed, + recursive, ); } + /** Recursively, every fold inside takes the same state. */ + private setCollapsed( + path: string, + diff: StructuralTextDiff, + foldable: StructuralFoldable, + collapsed: boolean, + recursive: boolean, + ): void { + const id = foldable.foldStateId; + for (const each of recursive ? [id, ...nestedIds(diff, id)] : [id]) { + this.session.setRegionCollapsed(path, each, collapsed); + } + } + private inChevronColumn(e: IEditorMouseEvent): boolean { const node = this.editor.getDomNode(); if (!node) { From 80de98d7390cbbd4d19399cbc02d3fc57fc5a945 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 19:57:51 -0700 Subject: [PATCH 11/21] Place a scope's viewed control as it appears Monaco positions overlay widgets on its next render, which a hover may not cause for a second, so each new control first sat in the editor's top left corner and then jumped to its scope's header. Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- .../services/reviewStructuralViewedControl.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts index 315ae5253..0552056b2 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts @@ -61,13 +61,15 @@ export class ScopeViewedControl extends Disposable implements IOverlayWidget { return this.node; } getPosition(): IOverlayWidgetPosition { + return { preference: this.place() }; + } + /** At the right of the code area, on the scope's header line. */ + private place(): { top: number; left: number } { const layout = this.editor.getLayoutInfo(); return { - preference: { - top: this.top, - left: - layout.contentLeft + layout.contentWidth - this.node.offsetWidth - 12, - }, + top: this.top, + left: + layout.contentLeft + layout.contentWidth - this.node.offsetWidth - 12, }; } targetOf(element: HTMLElement | null): FoldTarget | undefined { @@ -115,6 +117,11 @@ export class ScopeViewedControl extends Disposable implements IOverlayWidget { this.editor.addOverlayWidget(this); } this.editor.layoutOverlayWidget(this); + // The editor places overlay widgets when it next renders, which a hover alone may not cause + // for a while; until then a new control would sit in the editor's top left corner. + const { top, left } = this.place(); + this.node.style.top = `${top}px`; + this.node.style.left = `${left}px`; } hide(): void { From e7faa5909d69a12d47eb93076b151e840ca7df81 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 20:00:27 -0700 Subject: [PATCH 12/21] Drop the viewed checkbox's tooltip Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/comparison.ts | 7 +----- .../src/review/browser/reviewTooltip.ts | 23 +------------------ .../src/review/services/reviewMultiDiff.ts | 12 ++++------ .../services/reviewStructuralViewedControl.ts | 4 +--- 4 files changed, 7 insertions(+), 39 deletions(-) diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 914105a3a..047e3a95c 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableStore } from "vs/base/common/lifecycle.js"; import type { IObservable } from "vs/base/common/observable.js"; import { URI } from "vs/base/common/uri.js"; import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; -import { IHoverService } from "vs/platform/hover/browser/hover.js"; import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; import type { Classified, Engine, FileRequest } from "./engine/engine.js"; @@ -293,10 +292,6 @@ export class Comparison extends Disposable { new ViewedProgress(targetPath(this.target)), )); - const hover = this.instantiation.invokeFunction((accessor) => - accessor.get(IHoverService), - ); - const viewedState = store.add( new StructuralViewedState( session, @@ -304,7 +299,7 @@ export class Comparison extends Disposable { () => viewed.progress, viewed.onDidChange, (ranges, isViewed) => viewed.setViewed(ranges, isViewed), - (editor, onToggle) => new ScopeViewedControl(editor, hover, onToggle), + (editor, onToggle) => new ScopeViewedControl(editor, onToggle), ), ); diff --git a/apps/diffr-web/src/review/browser/reviewTooltip.ts b/apps/diffr-web/src/review/browser/reviewTooltip.ts index a56044ac2..08f2d3bf1 100644 --- a/apps/diffr-web/src/review/browser/reviewTooltip.ts +++ b/apps/diffr-web/src/review/browser/reviewTooltip.ts @@ -160,21 +160,13 @@ export const REVIEW_COUNTS_PENDING_TOOLTIP: ReviewTooltipContent = { */ export class ReviewViewedCheckbox extends Disposable { readonly element: HTMLButtonElement; - private readonly tooltip: ReviewTooltip; - constructor( - hoverService: ReviewTooltipHoverService, - ownerDocument: Document, - onToggle: () => void, - ) { + constructor(ownerDocument: Document, onToggle: () => void) { super(); this.element = ownerDocument.createElement("button"); this.element.type = "button"; this.element.className = "review-viewed-check"; this.element.setAttribute("role", "checkbox"); - this.tooltip = this._register( - new ReviewTooltip(hoverService, this.element), - ); this._register( addDisposableListener(this.element, "click", (event) => { event.stopPropagation(); @@ -200,18 +192,5 @@ export class ReviewViewedCheckbox extends Disposable { ); this.element.classList.toggle("is-empty", empty); this.element.disabled = empty; - this.tooltip.content = empty - ? undefined - : { label: reviewViewedTooltip(state) }; } } - -export function reviewViewedTooltip( - state: ReviewDiffProgressState | undefined, -): string { - return state === "viewed" - ? "Click to mark as unviewed" - : state === "partial" - ? "Click to mark all as viewed" - : "Click to mark as viewed"; -} diff --git a/apps/diffr-web/src/review/services/reviewMultiDiff.ts b/apps/diffr-web/src/review/services/reviewMultiDiff.ts index 3dcedc19e..9f6f32668 100644 --- a/apps/diffr-web/src/review/services/reviewMultiDiff.ts +++ b/apps/diffr-web/src/review/services/reviewMultiDiff.ts @@ -105,10 +105,8 @@ export class ReviewMultiDiffUIElementFactory implements IWorkbenchUIElementFacto const counts = document.createElement("span"); counts.className = "review-diff-group-counts"; const countsTooltip = new ReviewTooltip(this.hoverService, counts); - const viewed = new ReviewViewedCheckbox( - this.hoverService, - element.ownerDocument, - () => current?.onToggleSection?.(), + const viewed = new ReviewViewedCheckbox(element.ownerDocument, () => + current?.onToggleSection?.(), ); const refresh = () => { const entry = (current = @@ -229,10 +227,8 @@ export class ReviewMultiDiffUIElementFactory implements IWorkbenchUIElementFacto open.label = "Open file"; open.element.classList.add("review-multidiff-open"); const countsTooltip = new ReviewTooltip(this.hoverService, counts); - const viewed = new ReviewViewedCheckbox( - this.hoverService, - ownerDocument, - () => current?.onToggleViewed?.(), + const viewed = new ReviewViewedCheckbox(ownerDocument, () => + current?.onToggleViewed?.(), ); element.append(viewed.element); let current: ReviewMultiDiffHeaderEntry | undefined; diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts index 0552056b2..7cbdd7dc6 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts @@ -5,7 +5,6 @@ import type { IOverlayWidgetPosition, } from "vs/editor/browser/editorBrowser.js"; import { EditorOption } from "vs/editor/common/config/editorOptions.js"; -import type { IHoverService } from "vs/platform/hover/browser/hover.js"; import { ReviewViewedCheckbox } from "../browser/reviewTooltip.js"; import type { FoldTarget } from "./reviewStructuralFolds.js"; @@ -27,7 +26,6 @@ export class ScopeViewedControl extends Disposable implements IOverlayWidget { constructor( private readonly editor: ICodeEditor, - hoverService: IHoverService, onToggle: (target: FoldTarget) => void, ) { super(); @@ -41,7 +39,7 @@ export class ScopeViewedControl extends Disposable implements IOverlayWidget { this.status = document.createElement("span"); this.status.className = "review-scope-viewed-status"; this.check = this._register( - new ReviewViewedCheckbox(hoverService, document, () => { + new ReviewViewedCheckbox(document, () => { if (this.target) onToggle(this.target); }), ); From 38908a77267d36e896c52b49a87b12f50c773cef Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 20:03:16 -0700 Subject: [PATCH 13/21] Hide the caret's mark in each file's overview ruler Agent-Session: 8f2a69da-568a-4c81-bb98-4b75bfff948f Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/review/services/reviewFilesDiffView.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index 3441b13b5..97ffcc003 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -90,6 +90,10 @@ const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { minimap: { enabled: false }, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 }, renderIndicators: false, + // Each file's editor keeps an overview ruler where its hidden scrollbar would be; drawn alone, + // the caret's mark there reads as a stray blue dash beside the code. + hideCursorInOverviewRuler: true, + overviewRulerLanes: 0, } satisfies IDiffEditorOptions; export interface ReviewFilesEditorEntry { From b9a8bc90463b78c346a94748cc736c124a35150d Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 22:27:18 -0700 Subject: [PATCH 14/21] Polish browser diff UI and restore diagnostic panels Preserve the deployed browser UI, development fixture hook, and production asset filtering. AI-assisted with Codex. Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/github.ts | 19 ++++ apps/diffr-web/src/main.ts | 79 +++++++++++++-- apps/diffr-web/src/panels.ts | 4 + .../src/review/browser/media/review.css | 24 +++++ .../review/services/reviewFilesDiffView.ts | 37 ++++++- .../review/services/reviewStructuralSyntax.ts | 94 ++++++++++++------ apps/diffr-web/src/styles.css | 96 +++++++------------ apps/diffr-web/src/theme.ts | 47 ++++++--- apps/diffr-web/vite.config.ts | 15 ++- 9 files changed, 296 insertions(+), 119 deletions(-) diff --git a/apps/diffr-web/src/github.ts b/apps/diffr-web/src/github.ts index 62a3e7030..bde0ac7a7 100644 --- a/apps/diffr-web/src/github.ts +++ b/apps/diffr-web/src/github.ts @@ -215,6 +215,16 @@ export async function loadChange( target: Target, onPreview?: (preview: Preview) => void, ): Promise { + if ( + import.meta.env.DEV && + new URLSearchParams(location.search).has("ui-fixture") + ) { + const data = await (await fetch("/__ui-fixture.json")).json(); + onPreview?.(data.change); + + return data.change; + } + const repo = `/repos/${target.owner}/${target.repo}`; if (target.kind === "pull") { @@ -322,6 +332,15 @@ export async function fileText( sha: string, path: string, ): Promise { + if ( + import.meta.env.DEV && + new URLSearchParams(location.search).has("ui-fixture") + ) { + const data = await (await fetch("/__ui-fixture.json")).json(); + + return data.texts[sha][path]; + } + const auth = token(); const response = auth diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index a63743e40..54d6c1bae 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -32,6 +32,16 @@ applyTheme(root); const header = root.appendChild(element("header", "app-header")); +const status = root.appendChild(element("div", "app-status")); + +status.hidden = true; + +status.setAttribute("role", "status"); + +const statusMessage = status.appendChild(element("span", "app-status-message")); + +const retry = status.appendChild(actionButton("Retry", route)); + const body = root.appendChild(element("main", "app-body")); const overflow = root.appendChild(element("div", "monaco-editor app-overflow")); @@ -117,7 +127,21 @@ function route(): void { } function renderHeader(): void { + const focusedButton = Array.from(header.querySelectorAll("button")).findIndex( + (button) => button === document.activeElement, + ); + header.replaceChildren(); + const error = comparison?.error; + + const loading = + comparison && !comparison.preview && !comparison.change && !error; + + status.hidden = !error && !loading; + status.classList.toggle("is-error", !!error); + status.setAttribute("role", error ? "alert" : "status"); + statusMessage.textContent = error ?? (loading ? "Loading comparison…" : ""); + retry.hidden = !error; const home = header.appendChild(element("a", "app-wordmark", "diffr")); home.href = "/"; home.title = "Open another pull request"; @@ -141,14 +165,33 @@ function renderHeader(): void { title.rel = "noopener"; title.title = `Open on GitHub · ${targetPath(comparison.target).slice(1)}`; } - - if (comparison.error) - header.appendChild(element("span", "app-error", comparison.error)); } header.appendChild(element("span", "app-spacer")); if (comparison) { + for (const [panel, icon, label] of [ + ["stats", "graph", "Diff stats (F2)"], + ["engine", "dashboard", "Engine stats (F3)"], + ] as const) { + const button = iconButton(icon, label, () => togglePanel(panel)); + button.setAttribute("aria-pressed", String(panels.isOpen(panel))); + header.appendChild(button); + } + + header.appendChild( + iconButton("layout-sidebar-left", "Toggle file tree (⌘/Ctrl+B)", () => + comparison?.toggleFileTree(), + ), + ); + + const wrap = iconButton("word-wrap", "Toggle word wrap (W)", () => { + wordWrap.set(!wordWrap.get(), undefined); + renderHeader(); + }); + + wrap.setAttribute("aria-pressed", String(wordWrap.get())); + header.appendChild(wrap); const split = layout.get() === "split"; header.appendChild( iconButton( @@ -171,6 +214,16 @@ function renderHeader(): void { openToken, ), ); + + if (focusedButton >= 0) { + const button = header.querySelectorAll("button").item(focusedButton); + button?.focus({ preventScroll: true }); + } +} + +function togglePanel(panel: "stats" | "engine"): void { + panels.toggle(panel); + renderHeader(); } function renderLanding(): void { @@ -331,7 +384,7 @@ function command(event: KeyboardEvent): (() => void) | undefined { : event.key; if (key === "F2" || key === "F3") - return () => panels.toggle(key === "F2" ? "stats" : "engine"); + return () => togglePanel(key === "F2" ? "stats" : "engine"); if (!comparison) return undefined; const current = comparison; @@ -352,6 +405,14 @@ function command(event: KeyboardEvent): (() => void) | undefined { ) return undefined; + // Space activates a focused control. The diff scrolling shortcut must not steal that action. + if ( + key === " " && + target instanceof Element && + target.closest("button, a[href], [role=checkbox], [role=button]") + ) + return undefined; + // Shift, for a capital, is pressed before the letter it shifts. if (["Shift", "Alt", "Control", "Meta"].includes(key)) return undefined; @@ -373,9 +434,15 @@ function command(event: KeyboardEvent): (() => void) | undefined { ["]", () => current.goToChange("next")], ["[", () => current.goToChange("previous")], ["s", () => void layout.toggle().then(renderHeader)], - ["w", () => wordWrap.set(!wordWrap.get(), undefined)], + [ + "w", + () => { + wordWrap.set(!wordWrap.get(), undefined); + renderHeader(); + }, + ], ["c", () => current.toggleContextGaps()], - ["i", () => panels.toggle("stats")], + ["i", () => togglePanel("stats")], ["\\", () => current.toggleFileTree()], ["j", () => current.scroll(LINE)], ["k", () => current.scroll(-LINE)], diff --git a/apps/diffr-web/src/panels.ts b/apps/diffr-web/src/panels.ts index cc7a41a2c..9c4c2a1a3 100644 --- a/apps/diffr-web/src/panels.ts +++ b/apps/diffr-web/src/panels.ts @@ -28,6 +28,10 @@ export class Panels { this.render(); } + isOpen(panel: Panel): boolean { + return this.open.has(panel); + } + toggle(panel: Panel): void { if (this.open.has(panel)) this.open.delete(panel); else this.open.add(panel); diff --git a/apps/diffr-web/src/review/browser/media/review.css b/apps/diffr-web/src/review/browser/media/review.css index 8fcaa829d..a21c4fb0a 100644 --- a/apps/diffr-web/src/review/browser/media/review.css +++ b/apps/diffr-web/src/review/browser/media/review.css @@ -1649,3 +1649,27 @@ body .review-workbench .monaco-editor .review-fold-pill.is-viewed:not(.is-target) { opacity: 0.65; } + +.review-files-editor-summary { + flex-wrap: wrap; +} +.review-files-editor-summary-files { + flex-basis: 100%; +} +.review-files-editor-summary-progress { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} +.review-files-editor-summary-progress::before { + content: ""; + width: 16px; + height: 16px; + border-radius: 50%; + background: conic-gradient( + var(--vscode-focusBorder) var(--viewed-progress), + var(--vscode-editorWidget-border) 0 + ); + mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 0); +} diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index 97ffcc003..542757c9a 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -661,17 +661,25 @@ export class ReviewFilesDiffView extends Disposable { } private renderSummary(): void { - if (this.fileTreeContainer) return; + if (this.fileTreeContainer || !this.input) return; + const previousHeight = this.summary.offsetHeight; const paths = new Set( (this.input?.entries ?? []).map((entry) => entry.file.path), ); const complete = [...paths].every((path) => this.streamStats.has(path)); const total = { added: 0, removed: 0 }; + const remaining = { added: 0, removed: 0 }; + const progressByPath = new Map( + this.progress?.files.map((file) => [file.path, file]), + ); for (const path of paths) { const counts = this.lineCounts(path); if (counts) { total.added += counts.added; total.removed += counts.removed; + const progress = progressByPath.get(path); + remaining.added += progress?.remaining.additions ?? counts.added; + remaining.removed += progress?.remaining.deletions ?? counts.removed; } } this.summary.replaceChildren(); @@ -681,6 +689,7 @@ export class ReviewFilesDiffView extends Disposable { ); files.textContent = `${paths.size} files`; if (complete) { + append(this.summary, $("span")).textContent = "Remaining"; const format = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1, @@ -688,18 +697,37 @@ export class ReviewFilesDiffView extends Disposable { append( this.summary, $("span.review-files-editor-summary-added"), - ).textContent = `+${format.format(total.added).toLowerCase()}`; + ).textContent = `+${format.format(remaining.added).toLowerCase()}`; append( this.summary, $("span.review-files-editor-summary-removed"), - ).textContent = `−${format.format(total.removed).toLowerCase()}`; + ).textContent = `−${format.format(remaining.removed).toLowerCase()}`; + const size = total.added + total.removed; + const viewed = Math.max(0, size - remaining.added - remaining.removed); + const percent = size ? Math.round((viewed * 100) / size) : 100; + const progress = append( + this.summary, + $("span.review-files-editor-summary-progress"), + ); + progress.setAttribute("role", "progressbar"); + progress.setAttribute("aria-label", "Changed lines viewed"); + progress.setAttribute("aria-valuemin", "0"); + progress.setAttribute("aria-valuemax", "100"); + progress.setAttribute("aria-valuenow", String(percent)); + progress.setAttribute( + "aria-valuetext", + `${viewed} of ${size} changed lines viewed`, + ); + progress.style.setProperty("--viewed-progress", `${percent}%`); + progress.textContent = `${percent}%`; } else append(this.summary, $("span")).textContent = "Counting changes…"; this.summaryTooltip.content = complete ? reviewChangesTooltip(total.added, total.removed) : REVIEW_COUNTS_PENDING_TOOLTIP; const wasHidden = this.summary.hidden; this.summary.hidden = false; - if (wasHidden) this.layout(); + if (wasHidden || previousHeight !== this.summary.offsetHeight) + this.layout(); } /** A file the stream says starts collapsed, and why: its record's `visibility`. */ @@ -763,6 +791,7 @@ export class ReviewFilesDiffView extends Disposable { } this.changedFilesTree?.setProgressFiles(progress.files); + this.renderSummary(); this.headerFactory.refreshHeaders(); this.applyViewedFiles(); } diff --git a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts index fc07aacc3..2ed469ba9 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts @@ -9,9 +9,12 @@ import type { Event } from "vs/base/common/event.js"; import { Disposable } from "vs/base/common/lifecycle.js"; import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; -import { Range } from "vs/editor/common/core/range.js"; -import type { IModelDeltaDecoration } from "vs/editor/common/model.js"; +import { MetadataConsts } from "vs/editor/common/encodedTokenAttributes.js"; +import type { ITextModel } from "vs/editor/common/model.js"; +import { SparseMultilineTokens } from "vs/editor/common/tokens/sparseMultilineTokens.js"; +import { StandaloneServices } from "../../standalone/browser/standaloneServices.js"; +import { IStandaloneThemeService } from "../../standalone/common/standaloneTheme.js"; import { utf16Column, type StructuralSyntaxSpan, @@ -60,7 +63,7 @@ const RULES: readonly (readonly [string, Role])[] = [ ["label", "variable"], ["function", "function"], ["method", "function"], - ["attribute", "function"], + ["attribute", "type"], ["constructor", "type"], ["type", "type"], ["module", "type"], @@ -77,23 +80,42 @@ function role(capture: string): Role | undefined { return undefined; } -/** Paints one side of a structural diff editor with diffr's highlight captures. */ +const SCOPES: Record = { + comment: "comment", + keyword: "keyword", + type: "entity.name.type", + function: "entity.name.function", + variable: "variable.other", + string: "string", + number: "constant.numeric", + constant: "constant.language", + operator: "keyword.operator", + heading: "markup.heading", +}; + +/** Model tokens also reach unified mode's removed-code view zones and copied HTML. */ export class StructuralSyntax extends Disposable { - private readonly decorations = this.editor.createDecorationsCollection(); + private readonly themes = StandaloneServices.get(IStandaloneThemeService); private painted: readonly StructuralSyntaxSpan[] | undefined; + private model: ITextModel | undefined; constructor( private readonly editor: ICodeEditor, private readonly side: "lhs" | "rhs", private readonly path: () => string | undefined, private readonly session: StructuralDiffSession, - /** The diff editor's model is set after its sides', and only it names the file. */ onDidChangePath: Event, ) { super(); - this._register({ dispose: () => this.decorations.clear() }); + this._register({ dispose: () => this.clear() }); this._register(editor.onDidChangeModel(() => this.render())); this._register(onDidChangePath(() => this.render())); + this._register( + this.themes.onDidColorThemeChange(() => { + this.painted = undefined; + this.render(); + }), + ); this._register( session.onDidChange((change) => { const path = this.path(); @@ -103,39 +125,53 @@ export class StructuralSyntax extends Disposable { this.render(); } + private clear(): void { + if (this.model && !this.model.isDisposed()) + this.model.tokenization.setSemanticTokens(null, false); + this.model = undefined; + this.painted = undefined; + } + private render(): void { const model = this.editor.getModel(); const path = this.path(); - const source = path - ? this.session.getTextDiff(path)?.[this.side] + const spans = path + ? this.session.getTextDiff(path)?.[this.side]?.syntax : undefined; - const spans = source?.syntax; + if (model !== this.model) this.clear(); if (!model || !spans) { - this.painted = undefined; - this.decorations.clear(); + this.clear(); return; } if (spans === this.painted) return; + this.model = model; this.painted = spans; - const lines = model.getLinesContent(); - const decorations: IModelDeltaDecoration[] = []; + const theme = this.themes.getColorTheme().tokenTheme; + const tokens: number[] = []; + // The protocol supplies sorted, non-overlapping spans, with inner captures taking precedence. for (const span of spans) { const kind = role(span.capture); - const text = lines[span.line]; - if (!kind || text === undefined) continue; - decorations.push({ - range: new Range( - span.line + 1, - utf16Column(text, span.start_column), - span.line + 1, - utf16Column(text, span.end_column), - ), - options: { - description: "diffr-syntax", - inlineClassName: `diffr-syntax-${kind}`, - }, - }); + if (!kind || span.line >= model.getLineCount()) continue; + const text = model.getLineContent(span.line + 1); + const metadata = theme.match(0, SCOPES[kind]); + tokens.push( + span.line, + utf16Column(text, span.start_column) - 1, + utf16Column(text, span.end_column) - 1, + (metadata & + (MetadataConsts.FOREGROUND_MASK | MetadataConsts.FONT_STYLE_MASK)) | + MetadataConsts.SEMANTIC_USE_FOREGROUND | + MetadataConsts.SEMANTIC_USE_ITALIC | + MetadataConsts.SEMANTIC_USE_BOLD | + MetadataConsts.SEMANTIC_USE_UNDERLINE | + MetadataConsts.SEMANTIC_USE_STRIKETHROUGH, + ); } - this.decorations.set(decorations); + model.tokenization.setSemanticTokens( + tokens.length + ? [SparseMultilineTokens.create(1, new Uint32Array(tokens))] + : [], + true, + ); } } diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 3fc0f89b9..c777e46ee 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -25,68 +25,10 @@ body { display: flex; flex-direction: column; color: var(--vscode-foreground); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-family: "Geist Mono", ui-monospace, Menlo, monospace; font-size: 13px; } -/* Whiteboard's syntax roles (extensions/review-themes), painted from diffr's highlights. */ -.app { - --syntax-comment: #778090; - --syntax-keyword: #5b7cff; - --syntax-type: #6fc3e6; - --syntax-function: #b7c4ff; - --syntax-variable: #eef0f4; - --syntax-string: #d9a85c; - --syntax-number: #d48fd6; - --syntax-operator: #9199a8; -} - -.app.vs { - --syntax-comment: #6b7382; - --syntax-keyword: #2b55e6; - --syntax-type: #0c6e8e; - --syntax-function: #1f3f99; - --syntax-variable: #15181e; - --syntax-string: #8a5a1c; - --syntax-number: #8b3f8a; - --syntax-operator: #5d6472; -} - -.monaco-editor .diffr-syntax-comment { - color: var(--syntax-comment); - font-style: italic; -} -.monaco-editor .diffr-syntax-keyword { - color: var(--syntax-keyword); -} -.monaco-editor .diffr-syntax-type { - color: var(--syntax-type); -} -.monaco-editor .diffr-syntax-function { - color: var(--syntax-function); -} -.monaco-editor .diffr-syntax-variable { - color: var(--syntax-variable); -} -.monaco-editor .diffr-syntax-string { - color: var(--syntax-string); -} -.monaco-editor .diffr-syntax-number { - color: var(--syntax-number); -} -.monaco-editor .diffr-syntax-constant { - color: var(--syntax-number); - font-weight: bold; -} -.monaco-editor .diffr-syntax-operator { - color: var(--syntax-operator); -} -.monaco-editor .diffr-syntax-heading { - color: var(--syntax-keyword); - font-weight: bold; -} - .app-header { flex: none; display: flex; @@ -119,12 +61,33 @@ body { color: var(--vscode-foreground); } -.app-error { +.app-status { + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); +} + +.app-status[hidden] { + display: none; +} +.app-status-message { + flex: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--vscode-gitDecoration-deletedResourceForeground); + overflow-wrap: anywhere; +} +.app-status.is-error { + color: var(--vscode-errorForeground); +} +.app-status .app-button { + flex: none; +} + +.app .review-files-editor-tree { + background: var(--vscode-sideBar-background); } .app-spacer { @@ -142,6 +105,7 @@ body { cursor: pointer; } +.app-icon-button[aria-pressed="true"], .app-icon-button:hover { background: var(--vscode-toolbar-hoverBackground); color: var(--vscode-foreground); @@ -208,6 +172,7 @@ body { } .app-dialog { + box-sizing: border-box; width: min(560px, calc(100vw - 32px)); padding: 18px; border: 1px solid var(--vscode-editorWidget-border); @@ -273,6 +238,9 @@ body { bottom: 10px; z-index: 40; width: 240px; + max-width: calc(100vw - 42px); + max-height: calc(100dvh - 70px); + overflow-y: auto; padding: 4px 10px 8px; border: 1px solid var(--vscode-editorWidget-border); border-radius: 8px; diff --git a/apps/diffr-web/src/theme.ts b/apps/diffr-web/src/theme.ts index 2ef93442e..9fae57ff0 100644 --- a/apps/diffr-web/src/theme.ts +++ b/apps/diffr-web/src/theme.ts @@ -4,19 +4,31 @@ import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; import type { StandaloneThemeService } from "./standalone/browser/standaloneThemeService.js"; /** * Whiteboard Dark and Light, Review Desktop's themes, following the system's choice. The colors are - * read from the desktop's theme files; syntax is painted by class (styles.css), not by token rules, - * since diffr supplies the highlights. + * read from the desktop's theme files; diffr’s captures use their syntax token rules. */ import { IStandaloneThemeService } from "./standalone/common/standaloneTheme.js"; -/** The theme files carry line comments. */ -const colors = (source: string): Record => - // SAFETY: both files are Review Desktop's color themes, whose `colors` maps ids to colors. - ( - JSON.parse(source.replace(/^\s*\/\/.*$/gm, "")) as { - colors: Record; - } - ).colors; +/** The bundled TextMate scopes also work as Monaco token theme rules. */ +function themeData(source: string) { + // SAFETY: both bundled theme files define colors and scoped TextMate token settings. + const theme = JSON.parse(source.replace(/^\s*\/\/.*$/gm, "")) as { + colors: Record; + tokenColors: { + scope: string | string[]; + settings: { foreground?: string; fontStyle?: string }; + }[]; + }; + + return { + colors: theme.colors, + rules: theme.tokenColors.flatMap(({ scope, settings }) => + (Array.isArray(scope) ? scope : [scope]).map((token) => ({ + token, + ...settings, + })), + ), + }; +} export function applyTheme(root: HTMLElement): void { // SAFETY: StandaloneServices registers StandaloneThemeService for this id; its @@ -25,22 +37,29 @@ export function applyTheme(root: HTMLElement): void { IStandaloneThemeService, ) as StandaloneThemeService; + const darkTheme = themeData(dark); + const lightTheme = themeData(light); themes.defineTheme("whiteboard-dark", { base: "vs-dark", inherit: true, - rules: [], - colors: colors(dark), + ...darkTheme, }); themes.defineTheme("whiteboard-light", { base: "vs", inherit: true, - rules: [], - colors: colors(light), + ...lightTheme, }); themes.registerEditorContainer(root); const query = matchMedia("(prefers-color-scheme: dark)"); const apply = () => { + // 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, + )) { + 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); diff --git a/apps/diffr-web/vite.config.ts b/apps/diffr-web/vite.config.ts index 0031eb239..24ee60cda 100644 --- a/apps/diffr-web/vite.config.ts +++ b/apps/diffr-web/vite.config.ts @@ -56,7 +56,17 @@ function dropRawEngine(): Plugin { } export default defineConfig({ - plugins: [gzipEngine(), dropRawEngine()], + plugins: [gzipEngine(), dropRawEngine(), { + name: "production-headers", + apply: "build", + generateBundle() { + this.emitFile({ + type: "asset", + fileName: "_headers", + source: readFileSync(new URL("public/_headers", import.meta.url), "utf8"), + }); + }, + }], resolve: { alias: [ { find: /^vs\//, replacement: `${vs}/` }, @@ -65,5 +75,6 @@ export default defineConfig({ }, server: { fs: { allow: [fileURLToPath(new URL("../..", import.meta.url))] } }, worker: { format: "es", plugins: () => [dropRawEngine()] }, - build: { target: "es2024", chunkSizeWarningLimit: 4096 }, + // Keep local UI fixtures available in dev without publishing them. + build: { target: "es2024", chunkSizeWarningLimit: 4096, copyPublicDir: false }, }); From 7a500645c5e7d6bb0bebe2f7f58368737de7c163 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 22:30:20 -0700 Subject: [PATCH 15/21] Adapt browser host to the indexed cursor API Consume the cursor with into_sides after shaping, matching the native host in PR #1011. AI-assisted with Codex. Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/crates/diffr-web/src/plugins.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diffr/crates/diffr-web/src/plugins.rs b/diffr/crates/diffr-web/src/plugins.rs index 47118eb12..13271c267 100644 --- a/diffr/crates/diffr-web/src/plugins.rs +++ b/diffr/crates/diffr-web/src/plugins.rs @@ -124,7 +124,7 @@ impl Pipeline { let host = (cursor.into_host() as Box) .downcast::() .expect("the cursor's host is the engine's"); - Ok(host.0.sides) + Ok(host.0.into_sides()) } } From 15581d1985dee6cc35cd983642a2cdb3bb87b2ea Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 22:31:52 -0700 Subject: [PATCH 16/21] Resolve folds against the models currently in each editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid applying the previous file’s ranges during a virtualized editor model switch. AI-assisted with Codex. Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- .../src/review/services/reviewStructuralDiff.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/diffr-web/src/review/services/reviewStructuralDiff.ts b/apps/diffr-web/src/review/services/reviewStructuralDiff.ts index f0face066..27ee9c3a3 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralDiff.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralDiff.ts @@ -217,12 +217,15 @@ function attachStructuralEditors( const store = new DisposableStore(); watched.set(editor, store); const pathOf = () => { - const model = editor.getModel(); - return model + // Inner editors change models before the diff editor publishes its new pair. + // Resolve the models being rendered, not the previous file's pair. + const original = editor.getOriginalEditor().getModel(); + const modified = editor.getModifiedEditor().getModel(); + return original && modified ? pairs.get( - model.original.uri.with({ fragment: "" }).toString() + + original.uri.with({ fragment: "" }).toString() + "\n" + - model.modified.uri.with({ fragment: "" }).toString(), + modified.uri.with({ fragment: "" }).toString(), ) : undefined; }; From ae7300939367ec03fc97e31408b5200d31afe8d1 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 23:00:28 -0700 Subject: [PATCH 17/21] Preserve browser query error codes after lazy initialization Handle the DiffError variants introduced by PR #1016, matching the native host. AI-assisted with Codex. Agent-Session: 01a1197a-7e1b-7742-a9bd-04ed4353bed1 Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- diffr/crates/diffr-web/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/diffr/crates/diffr-web/src/lib.rs b/diffr/crates/diffr-web/src/lib.rs index 24a9b28e2..58969cc82 100644 --- a/diffr/crates/diffr-web/src/lib.rs +++ b/diffr/crates/diffr-web/src/lib.rs @@ -5,7 +5,7 @@ //! wire protocol, shaped by the plugins as the native diffr shapes it. //! Fetching the sources is the page's job. use diffr_core::config::{Config, Params}; -use diffr_core::engine::QueryConflict; +use diffr_core::engine::DiffError; use diffr_core::options::DiffOptions; use diffr_core::pairing::Pairing; use diffr_core::plugin::MutationFailed; @@ -107,8 +107,11 @@ fn entry(request: &Request) -> anyhow::Result { /// The wire record for an error, with the code of its typed cause. fn problem(error: &anyhow::Error) -> Problem { - let code = if error.downcast_ref::().is_some() { - "query_conflict" + let code = if let Some(error) = error.downcast_ref::() { + match error { + DiffError::Query(_) => "query_error", + DiffError::Conflict(_) => "query_conflict", + } } else if error.downcast_ref::().is_some() { "mutation_failed" } else { From db1fbffd0603d241dc5f35045e9220006df2833f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:55:07 +0000 Subject: [PATCH 18/21] Format the diffr web page's font and Vite config 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/editorFont.ts | 3 ++- apps/diffr-web/vite.config.ts | 33 +++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/diffr-web/src/editorFont.ts b/apps/diffr-web/src/editorFont.ts index aef7bda88..38661a597 100644 --- a/apps/diffr-web/src/editorFont.ts +++ b/apps/diffr-web/src/editorFont.ts @@ -13,7 +13,8 @@ const readFontInfo = FontMeasurements.readFontInfo.bind(FontMeasurements); FontMeasurements.readFontInfo = (targetWindow, bareFontInfo) => { const info = readFontInfo(targetWindow, bareFontInfo); - if (info.isMonospace || !info.fontFamily.startsWith('"Geist Mono"')) return info; + if (info.isMonospace || !info.fontFamily.startsWith('"Geist Mono"')) + return info; return new FontInfo({ ...info, isMonospace: true }, info.isTrusted); }; diff --git a/apps/diffr-web/vite.config.ts b/apps/diffr-web/vite.config.ts index 24ee60cda..91d9e531e 100644 --- a/apps/diffr-web/vite.config.ts +++ b/apps/diffr-web/vite.config.ts @@ -56,17 +56,24 @@ function dropRawEngine(): Plugin { } export default defineConfig({ - plugins: [gzipEngine(), dropRawEngine(), { - name: "production-headers", - apply: "build", - generateBundle() { - this.emitFile({ - type: "asset", - fileName: "_headers", - source: readFileSync(new URL("public/_headers", import.meta.url), "utf8"), - }); + plugins: [ + gzipEngine(), + dropRawEngine(), + { + name: "production-headers", + apply: "build", + generateBundle() { + this.emitFile({ + type: "asset", + fileName: "_headers", + source: readFileSync( + new URL("public/_headers", import.meta.url), + "utf8", + ), + }); + }, }, - }], + ], resolve: { alias: [ { find: /^vs\//, replacement: `${vs}/` }, @@ -76,5 +83,9 @@ export default defineConfig({ server: { fs: { allow: [fileURLToPath(new URL("../..", import.meta.url))] } }, worker: { format: "es", plugins: () => [dropRawEngine()] }, // Keep local UI fixtures available in dev without publishing them. - build: { target: "es2024", chunkSizeWarningLimit: 4096, copyPublicDir: false }, + build: { + target: "es2024", + chunkSizeWarningLimit: 4096, + copyPublicDir: false, + }, }); From d1aae54b91df61315d070c4f4c7c766dbd28f163 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:02:21 +0000 Subject: [PATCH 19/21] Compile grammar lexers for wasm a step at a time LLVM's WebAssembly backend checks each loop for irreducible control flow by working out which blocks reach which, in memory square in the function's blocks. A generated lexer is one loop around a branch per state, so tree-sitter-fortran's took over 11 GB at -O0 and the Diffr Web job's runner was killed. The lexer now takes one step and returns where to go next; a driver advances and calls it again. The engine build peaks under 3 GB, and parse trees match the original lexers'. 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/crates/diffr-core/wasm/compat.h | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/diffr/crates/diffr-core/wasm/compat.h b/diffr/crates/diffr-core/wasm/compat.h index caa4c26ca..b932c074b 100644 --- a/diffr/crates/diffr-core/wasm/compat.h +++ b/diffr/crates/diffr-core/wasm/compat.h @@ -73,4 +73,66 @@ static inline __attribute__((noreturn)) void diffr_exit(int status) { __builtin_trap(); } #define exit diffr_exit +/* A grammar's generated lexer is one function: a loop around a switch with a + * branch per state. LLVM's WebAssembly backend checks every loop for + * irreducible control flow by working out which blocks reach which, which + * takes memory in the square of the function's blocks: tens of gigabytes + * for the largest grammars (tree-sitter-fortran, tree-sitter-julia), past a + * CI runner's. So the loop moves out: the generated function takes one step + * and says where to go next, and a small driver advances the lexer and + * calls it again. tree-sitter's macros are replaced with ones that return + * instead of jumping back; the rest of the generated code is unchanged. */ +#if __has_include("tree_sitter/parser.h") +#include "tree_sitter/parser.h" +static bool diffr_lex_result, diffr_lex_skip; +static TSStateId diffr_lex_next; +static __attribute__((noinline)) bool diffr_lex_step(TSLexer *lexer, TSStateId state); +static __attribute__((noinline)) bool diffr_lex_keywords_step(TSLexer *lexer, TSStateId state); +static inline bool diffr_lex(bool (*step)(TSLexer *, TSStateId), TSLexer *lexer, TSStateId state) { + diffr_lex_result = false; + while (step(lexer, state)) { + lexer->advance(lexer, diffr_lex_skip); + state = diffr_lex_next; + } + return diffr_lex_result; +} +/* Unused, and so not compiled, outside a grammar's parser.c. */ +static bool ts_lex(TSLexer *lexer, TSStateId state) { return diffr_lex(diffr_lex_step, lexer, state); } +static bool ts_lex_keywords(TSLexer *lexer, TSStateId state) { + return diffr_lex(diffr_lex_keywords_step, lexer, state); +} +/* parser.c defines these by their names with arguments, and refers to them without. */ +#define ts_lex(lexer, state) diffr_lex_step(lexer, state) +#define ts_lex_keywords(lexer, state) diffr_lex_keywords_step(lexer, state) +#undef START_LEXER +#define START_LEXER() \ + bool result = diffr_lex_result; \ + __attribute__((unused)) bool skip = false; \ + __attribute__((unused)) bool eof = lexer->eof(lexer); \ + int32_t lookahead = lexer->lookahead; +#define DIFFR_LEX_NEXT(state_value, skipping) \ + { \ + diffr_lex_result = result; \ + diffr_lex_skip = skipping; \ + diffr_lex_next = state_value; \ + return true; \ + } +#undef ADVANCE +#define ADVANCE(state_value) DIFFR_LEX_NEXT(state_value, false) +#undef SKIP +#define SKIP(state_value) DIFFR_LEX_NEXT(state_value, true) +#undef ADVANCE_MAP +#define ADVANCE_MAP(...) \ + { \ + static const uint16_t map[] = {__VA_ARGS__}; \ + for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) \ + if (map[i] == lookahead) ADVANCE(map[i + 1]); \ + } +#undef END_STATE +#define END_STATE() \ + { \ + diffr_lex_result = result; \ + return false; \ + } +#endif #endif From c2b30feabb5b3251638d0307c3a01a6f5227846c Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Thu, 8 Oct 2026 23:11:24 -0700 Subject: [PATCH 20/21] Adapt browser diffs and integration expectations to current main Agent-Session: 01a11e9f-d478-7a50-b799-c97cc8a58020 --- apps/diffr-web/src/review/services/reviewFilesDiffView.ts | 7 ++++++- .../src/review-api/unsupported-files.integration.ts | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts index 542757c9a..e99574cef 100644 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts @@ -24,7 +24,11 @@ import { MutableDisposable, toDisposable, } from "vs/base/common/lifecycle.js"; -import { autorun, type IObservable } from "vs/base/common/observable.js"; +import { + autorun, + observableValue, + type IObservable, +} from "vs/base/common/observable.js"; import { isEqual } from "vs/base/common/resources.js"; import { URI } from "vs/base/common/uri.js"; import { ElementSizeObserver } from "vs/editor/browser/config/elementSizeObserver.js"; @@ -186,6 +190,7 @@ export class ReviewFilesEditorInput extends Disposable { experimental: { useTrueInlineView: false }, } satisfies IDiffEditorOptions; const item: IDocumentDiffItem = { + collapsed: observableValue("fileCollapsed", false), original: model(entry.original!, text("lhs")), modified: model(entry.modified!, text("rhs")), labelUris: reviewMultiDiffLabelUris(entry.file), diff --git a/packages/review/src/review-api/unsupported-files.integration.ts b/packages/review/src/review-api/unsupported-files.integration.ts index 3a7628fbd..9dd9a6eb6 100644 --- a/packages/review/src/review-api/unsupported-files.integration.ts +++ b/packages/review/src/review-api/unsupported-files.integration.ts @@ -16,7 +16,7 @@ import { createReviewApi } from "./http"; import { openLocalReviewStore } from "./local-data"; it.each([false, true])( - "omits unsupported files from progress, with text changes=%s", + "preserves Git coverage when structural diff cannot handle files, with text changes=%s", async (textChanged) => { const root = mkdtempSync(path.join(tmpdir(), "unsupported-files-")); @@ -76,11 +76,11 @@ it.each([false, true])( expect(response.status).toBe(200); const progress = await response.json(); expect(progress.files.map((file: { path: string }) => file.path)).toEqual( - textChanged ? ["value.ts"] : [], + textChanged ? ["link", "module", "value.ts"] : ["link", "module"], ); expect(coverageProgress(progress.files).total).toEqual({ - additions: textChanged ? 1 : 0, - deletions: textChanged ? 1 : 0, + additions: textChanged ? 3 : 2, + deletions: textChanged ? 3 : 2, }); } finally { await local.data.close(); From ae8c53414475ef888406a5844fc47291ae2eed05 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Fri, 9 Oct 2026 12:15:18 -0700 Subject: [PATCH 21/21] Restore unsupported-file integration expectations from main 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 --- .../src/review-api/unsupported-files.integration.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/review/src/review-api/unsupported-files.integration.ts b/packages/review/src/review-api/unsupported-files.integration.ts index 9dd9a6eb6..3a7628fbd 100644 --- a/packages/review/src/review-api/unsupported-files.integration.ts +++ b/packages/review/src/review-api/unsupported-files.integration.ts @@ -16,7 +16,7 @@ import { createReviewApi } from "./http"; import { openLocalReviewStore } from "./local-data"; it.each([false, true])( - "preserves Git coverage when structural diff cannot handle files, with text changes=%s", + "omits unsupported files from progress, with text changes=%s", async (textChanged) => { const root = mkdtempSync(path.join(tmpdir(), "unsupported-files-")); @@ -76,11 +76,11 @@ it.each([false, true])( expect(response.status).toBe(200); const progress = await response.json(); expect(progress.files.map((file: { path: string }) => file.path)).toEqual( - textChanged ? ["link", "module", "value.ts"] : ["link", "module"], + textChanged ? ["value.ts"] : [], ); expect(coverageProgress(progress.files).total).toEqual({ - additions: textChanged ? 3 : 2, - deletions: textChanged ? 3 : 2, + additions: textChanged ? 1 : 0, + deletions: textChanged ? 1 : 0, }); } finally { await local.data.close();