From 8566f6c3eea8a895ba18952a5a86e2aa34c4293e Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:00:43 -0700 Subject: [PATCH 01/43] Add the region-based wire protocol types Define the next NDJSON wire format as serde types in src/protocol.rs: one header, one file record per changed file, one footer. Each file carries both sources with per-side region trees paired across sides by id, replacing hunks, aligned rows, and separate fold lists. Sides are a presence-encoded Pairing; errors share one shape; syntax spans carry tree-sitter capture names. Not wired to the CLI yet. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/main.rs | 2 + src/protocol.rs | 545 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 src/protocol.rs diff --git a/src/main.rs b/src/main.rs index c1576de45..0caa8f8ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,8 @@ mod line_parser; mod lines; mod options; mod parse; +#[allow(dead_code)] +mod protocol; mod review; mod stream; mod summary; diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 000000000..ada177357 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,545 @@ +//! The NDJSON wire protocol between diffr and its frontends. +//! +//! One `Event` per line: a `start` header, one `file` record per changed +//! file in completion order, and a `complete` footer. Every enum on the +//! wire is internally tagged with a `type` (or `kind`) key in snake_case. +//! Optional, empty, and default fields are omitted, never `null`. Consumers +//! ignore unknown fields and tolerate unknown enum strings. +//! +//! Coordinates: lines are 0-based and split on `\n` only, so an empty file +//! has zero lines and a file without a trailing newline still counts its +//! last line. Columns are 0-based byte offsets into the UTF-8 text on the +//! wire. All ranges are half-open. +//! +//! Sides are always `lhs` (before) and `rhs` (after). A `Pairing` says which +//! sides exist and serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The current wire version. Changes within a version are additive. +pub(crate) const VERSION: u32 = 2; + +// ── stream ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Event { + /// The header. Sent once, before any result, so a frontend can lay out + /// every file up front. + Start { + version: u32, + lhs: Snapshot, + rhs: Snapshot, + files: Vec, + }, + /// One result. `file` is byte-identical to the manifest entry it + /// answers; the path pair is the identity. + File { + file: Pairing, + #[serde(flatten)] + outcome: Outcome, + }, + /// The footer. `aborted` is present when a run-level failure stopped + /// the comparison early; every file already emitted stays valid. + Complete { + succeeded: u32, + failed: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + aborted: Option, + }, +} + +/// Exactly one of `diff` or `error` appears on a `file` record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum Outcome { + Diff { diff: Diff }, + Error { error: Error }, +} + +/// What one end of the comparison is. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum Snapshot { + Revision { + rev: String, + }, + Index, + WorkingTree, + EmptyTree, + /// A file on disk, for `--no-index`. + Path { + path: String, + }, +} + +/// The one error shape, used for a file failure, a run abort, and a +/// structural fallback. `code` is an open snake_case set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Error { + pub(crate) code: String, + pub(crate) message: String, +} + +/// Which sides a thing exists on. Serializes by presence. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Pairing { + Both { lhs: T, rhs: T }, + LeftOnly { lhs: T }, + RightOnly { rhs: T }, +} + +impl Pairing { + pub(crate) fn lhs(&self) -> Option<&T> { + match self { + Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs), + Self::RightOnly { .. } => None, + } + } + + pub(crate) fn rhs(&self) -> Option<&T> { + match self { + Self::Both { rhs, .. } | Self::RightOnly { rhs } => Some(rhs), + Self::LeftOnly { .. } => None, + } + } +} + +#[derive(Serialize, Deserialize)] +struct PairingRepr { + #[serde(skip_serializing_if = "Option::is_none")] + lhs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rhs: Option, +} + +impl Serialize for Pairing { + fn serialize(&self, serializer: S) -> Result { + let repr = match self { + Self::Both { lhs, rhs } => PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + }, + Self::LeftOnly { lhs } => PairingRepr { + lhs: Some(lhs), + rhs: None, + }, + Self::RightOnly { rhs } => PairingRepr { + lhs: None, + rhs: Some(rhs), + }, + }; + repr.serialize(serializer) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Pairing { + fn deserialize>(deserializer: D) -> Result { + match PairingRepr::deserialize(deserializer)? { + PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + } => Ok(Self::Both { lhs, rhs }), + PairingRepr { + lhs: Some(lhs), + rhs: None, + } => Ok(Self::LeftOnly { lhs }), + PairingRepr { + lhs: None, + rhs: Some(rhs), + } => Ok(Self::RightOnly { rhs }), + PairingRepr { + lhs: None, + rhs: None, + } => Err(D::Error::custom("a pairing needs at least one side")), + } + } +} + +// ── manifest entry ──────────────────────────────────────────────────────── + +/// One changed file, known before any diffing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct FileChange { + /// `LeftOnly` is a deletion, `RightOnly` an addition. + pub(crate) file: Pairing, + pub(crate) status: FileStatus, + /// `source`, `test`, `generated`, `docs`, or a repository's own class, + /// from git attributes and built-in path rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) language: Option, + #[serde(default, skip_serializing_if = "Visibility::is_default")] + pub(crate) visibility: Visibility, +} + +/// libgit2's delta status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, +} + +/// One side of a git delta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct FileRef { + pub(crate) path: String, + pub(crate) oid: String, + /// Git's octal mode text, e.g. `100644`. + pub(crate) mode: String, +} + +/// How a file or region starts out. `label` is shown while collapsed: a +/// reason for a file, a placeholder or pseudocode summary for a region. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub(crate) struct Visibility { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) collapsed: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) label: String, +} + +impl Visibility { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +// ── per-file result ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum Diff { + Text { + #[serde(flatten)] + sides: Pairing, + stats: Stats, + /// Present when tree-sitter did not run and this is a line diff: + /// no folds, and `stats.structural` is absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + fallback: Option, + }, + /// Either side being binary makes the whole diff binary. + Binary { + #[serde(flatten)] + sides: Pairing, + }, +} + +/// One side's text, colors, and regions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Source { + pub(crate) text: String, + /// Every token with its tree-sitter capture name. Per line, sorted, + /// non-overlapping. Empty unless the run asked for syntax. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) syntax: Vec, + /// The largest regions, in order. Leaves tile the file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct BinaryRef { + pub(crate) size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SyntaxSpan { + pub(crate) line: u32, + pub(crate) start_column: u32, + pub(crate) end_column: u32, + /// A tree-sitter capture name such as `keyword` or `function.method`. + pub(crate) capture: String, +} + +/// A range on one side. The same `id` on the other side is its +/// counterpart. Ids appear at most once per side. A paired leaf has the +/// same line count on both sides and its rows pair line for line; a paired +/// leaf whose counterpart is behind the reading cursor is a move, and the +/// frontend chooses how to show it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Region { + pub(crate) id: u32, + pub(crate) start: SourcePos, + pub(crate) end: SourcePos, + /// `body`, `import`, `test`, `unchanged`, or hook-supplied tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tags: Vec, + /// A collapsed leaf is a context gap. A collapsed fold is a folded body. + #[serde(default, skip_serializing_if = "Visibility::is_default")] + pub(crate) visibility: Visibility, + #[serde(flatten)] + pub(crate) node: Node, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum Node { + /// Tiles the file. `changed` holds the byte ranges painted as changed + /// within it; a fully new line carries one span covering it. + Leaf { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + changed: Vec, + }, + /// A foldable region. Its range is the hull of its children. + Fold { children: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Span { + pub(crate) line: u32, + pub(crate) start_column: u32, + pub(crate) end_column: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SourcePos { + pub(crate) line: u32, + pub(crate) column: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Stats { + /// Lines with any byte change. + pub(crate) textual: LineCounts, + /// Lines with a syntactic change. Absent when `fallback` is present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) structural: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct LineCounts { + pub(crate) added: u32, + pub(crate) removed: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn pos(line: u32, column: u32) -> SourcePos { + SourcePos { line, column } + } + + fn leaf(id: u32, start: u32, end: u32, changed: Vec) -> Region { + Region { + id, + start: pos(start, 0), + end: pos(end, 0), + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed }, + } + } + + /// A paired function with one changed word: `fn f() { 1 }` became + /// `fn f() { 1 + 2 }`. + fn example_file() -> Event { + let file_ref = |oid: &str| FileRef { + path: "src/lib.rs".to_owned(), + oid: oid.to_owned(), + mode: "100644".to_owned(), + }; + let side = |text: &str, changed: Vec| Source { + text: text.to_owned(), + syntax: vec![], + regions: vec![Region { + id: 1, + start: pos(0, 0), + end: pos(3, 0), + tags: vec!["body".to_owned()], + visibility: Visibility::default(), + node: Node::Fold { + children: vec![ + leaf(2, 0, 1, vec![]), + leaf(3, 1, 2, changed), + leaf(4, 2, 3, vec![]), + ], + }, + }], + }; + Event::File { + file: Pairing::Both { + lhs: file_ref("3b18e5"), + rhs: file_ref("9be2c1"), + }, + outcome: Outcome::Diff { + diff: Diff::Text { + sides: Pairing::Both { + lhs: side("fn f() {\n 1\n}\n", vec![]), + rhs: side( + "fn f() {\n 1 + 2\n}\n", + vec![Span { + line: 1, + start_column: 5, + end_column: 9, + }], + ), + }, + stats: Stats { + textual: LineCounts { + added: 1, + removed: 1, + }, + structural: Some(LineCounts { + added: 1, + removed: 0, + }), + }, + fallback: None, + }, + }, + } + } + + #[test] + fn file_record_serializes_to_the_documented_shape() { + let region = |changed: serde_json::Value| { + let mut middle = json!({"id": 3, "kind": "leaf", "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}}); + if let Some(spans) = changed.as_array().filter(|spans| !spans.is_empty()) { + middle["changed"] = json!(spans); + } + json!({ + "id": 1, "kind": "fold", "tags": ["body"], + "start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0}, + "children": [ + {"id": 2, "kind": "leaf", "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, + middle, + {"id": 4, "kind": "leaf", "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}}, + ], + }) + }; + let expected = json!({ + "type": "file", + "file": { + "lhs": {"path": "src/lib.rs", "oid": "3b18e5", "mode": "100644"}, + "rhs": {"path": "src/lib.rs", "oid": "9be2c1", "mode": "100644"}, + }, + "diff": { + "type": "text", + "lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(json!([]))]}, + "rhs": {"text": "fn f() {\n 1 + 2\n}\n", + "regions": [region(json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, + "stats": {"textual": {"added": 1, "removed": 1}, "structural": {"added": 1, "removed": 0}}, + }, + }); + assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); + } + + #[test] + fn every_event_round_trips() { + let events = vec![ + Event::Start { + version: VERSION, + lhs: Snapshot::Revision { + rev: "main".to_owned(), + }, + rhs: Snapshot::WorkingTree, + files: vec![FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "gen/schema.json".to_owned(), + oid: "0e1f2a".to_owned(), + mode: "100644".to_owned(), + }, + }, + status: FileStatus::Added, + category: Some("generated".to_owned()), + language: None, + visibility: Visibility { + collapsed: true, + label: "Generated file · hidden by default".to_owned(), + }, + }], + }, + example_file(), + Event::File { + file: Pairing::LeftOnly { + lhs: FileRef { + path: "old.bin".to_owned(), + oid: "aaaaaa".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Diff { + diff: Diff::Binary { + sides: Pairing::LeftOnly { + lhs: BinaryRef { size: 4096 }, + }, + }, + }, + }, + Event::File { + file: Pairing::Both { + lhs: FileRef { + path: "a.txt".to_owned(), + oid: "bbbbbb".to_owned(), + mode: "100644".to_owned(), + }, + rhs: FileRef { + path: "a.txt".to_owned(), + oid: "cccccc".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Error { + error: Error { + code: "not_utf8".to_owned(), + message: "a.txt is not valid UTF-8".to_owned(), + }, + }, + }, + Event::Complete { + succeeded: 2, + failed: 1, + aborted: Some(Error { + code: "hook_failed".to_owned(), + message: "summarizer returned 503 after 4 attempts".to_owned(), + }), + }, + ]; + for event in events { + let line = serde_json::to_string(&event).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + event, + "{line}" + ); + } + } + + #[test] + fn defaults_are_omitted() { + let line = serde_json::to_string(&example_file()).unwrap(); + for absent in [ + "null", + "visibility", + "collapsed", + "syntax", + "fallback", + "\"changed\":[]", + ] { + assert!(!line.contains(absent), "{absent} appeared in {line}"); + } + } + + #[test] + fn a_pairing_needs_a_side() { + let error = serde_json::from_value::>(json!({})).unwrap_err(); + assert!(error.to_string().contains("at least one side")); + assert_eq!( + serde_json::from_value::>(json!({"rhs": 7})).unwrap(), + Pairing::RightOnly { rhs: 7 } + ); + } +} From 3f7c19e849b65ba7637e5f684dffe0387e64c483 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:17:06 -0700 Subject: [PATCH 02/43] Make the protocol types more idiomatic Stats.structural is a Result so the structural-or-fallback rule cannot be violated; Outcome converts to and from Result; Region carries a SourceRange; Error is Problem to stay clear of std; Pairing gains map and as_ref; one is_default helper; Eq everywhere it holds; plain pub fields in a crate-private module. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/main.rs | 2 +- src/protocol.rs | 329 +++++++++++++++++++++++++++++++----------------- 2 files changed, 215 insertions(+), 116 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0caa8f8ac..1ceba8644 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,7 +60,7 @@ mod lines; mod options; mod parse; #[allow(dead_code)] -mod protocol; +pub(crate) mod protocol; mod review; mod stream; mod summary; diff --git a/src/protocol.rs b/src/protocol.rs index ada177357..2a22f156f 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -18,14 +18,18 @@ use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// The current wire version. Changes within a version are additive. -pub(crate) const VERSION: u32 = 2; +pub const VERSION: u32 = 2; + +fn is_default(value: &T) -> bool { + *value == T::default() +} // ── stream ──────────────────────────────────────────────────────────────── -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(clippy::large_enum_variant)] -pub(crate) enum Event { +pub enum Event { /// The header. Sent once, before any result, so a frontend can lay out /// every file up front. Start { @@ -47,22 +51,41 @@ pub(crate) enum Event { succeeded: u32, failed: u32, #[serde(default, skip_serializing_if = "Option::is_none")] - aborted: Option, + aborted: Option, }, } -/// Exactly one of `diff` or `error` appears on a `file` record. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Exactly one of `diff` or `error` appears on a `file` record. This is +/// `Result` in the shape serde needs; convert with `into`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] -pub(crate) enum Outcome { +pub enum Outcome { Diff { diff: Diff }, - Error { error: Error }, + Error { error: Problem }, +} + +impl From> for Outcome { + fn from(result: Result) -> Self { + match result { + Ok(diff) => Self::Diff { diff }, + Err(error) => Self::Error { error }, + } + } +} + +impl From for Result { + fn from(outcome: Outcome) -> Self { + match outcome { + Outcome::Diff { diff } => Ok(diff), + Outcome::Error { error } => Err(error), + } + } } /// What one end of the comparison is. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum Snapshot { +pub enum Snapshot { Revision { rev: String, }, @@ -77,29 +100,48 @@ pub(crate) enum Snapshot { /// The one error shape, used for a file failure, a run abort, and a /// structural fallback. `code` is an open snake_case set. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Error { - pub(crate) code: String, - pub(crate) message: String, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Problem { + pub code: String, + pub message: String, } /// Which sides a thing exists on. Serializes by presence. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum Pairing { +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pairing { Both { lhs: T, rhs: T }, LeftOnly { lhs: T }, RightOnly { rhs: T }, } impl Pairing { - pub(crate) fn lhs(&self) -> Option<&T> { + pub fn as_ref(&self) -> Pairing<&T> { + match self { + Self::Both { lhs, rhs } => Pairing::Both { lhs, rhs }, + Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs }, + Self::RightOnly { rhs } => Pairing::RightOnly { rhs }, + } + } + + pub fn map(self, mut f: impl FnMut(T) -> U) -> Pairing { + match self { + Self::Both { lhs, rhs } => Pairing::Both { + lhs: f(lhs), + rhs: f(rhs), + }, + Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs: f(lhs) }, + Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, + } + } + + pub fn lhs(&self) -> Option<&T> { match self { Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs), Self::RightOnly { .. } => None, } } - pub(crate) fn rhs(&self) -> Option<&T> { + pub fn rhs(&self) -> Option<&T> { match self { Self::Both { rhs, .. } | Self::RightOnly { rhs } => Some(rhs), Self::LeftOnly { .. } => None, @@ -161,25 +203,25 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for Pairing { // ── manifest entry ──────────────────────────────────────────────────────── /// One changed file, known before any diffing. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct FileChange { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { /// `LeftOnly` is a deletion, `RightOnly` an addition. - pub(crate) file: Pairing, - pub(crate) status: FileStatus, + pub file: Pairing, + pub status: FileStatus, /// `source`, `test`, `generated`, `docs`, or a repository's own class, /// from git attributes and built-in path rules. #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) category: Option, + pub category: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) language: Option, - #[serde(default, skip_serializing_if = "Visibility::is_default")] - pub(crate) visibility: Visibility, + pub language: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub visibility: Visibility, } /// libgit2's delta status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum FileStatus { +pub enum FileStatus { Added, Deleted, Modified, @@ -190,42 +232,32 @@ pub(crate) enum FileStatus { /// One side of a git delta. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct FileRef { - pub(crate) path: String, - pub(crate) oid: String, +pub struct FileRef { + pub path: String, + pub oid: String, /// Git's octal mode text, e.g. `100644`. - pub(crate) mode: String, + pub mode: String, } /// How a file or region starts out. `label` is shown while collapsed: a /// reason for a file, a placeholder or pseudocode summary for a region. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub(crate) struct Visibility { +pub struct Visibility { #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) collapsed: bool, + pub collapsed: bool, #[serde(default, skip_serializing_if = "String::is_empty")] - pub(crate) label: String, -} - -impl Visibility { - fn is_default(&self) -> bool { - *self == Self::default() - } + pub label: String, } // ── per-file result ─────────────────────────────────────────────────────── -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum Diff { +pub enum Diff { Text { #[serde(flatten)] sides: Pairing, stats: Stats, - /// Present when tree-sitter did not run and this is a line diff: - /// no folds, and `stats.structural` is absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - fallback: Option, }, /// Either side being binary makes the whole diff binary. Binary { @@ -235,30 +267,30 @@ pub(crate) enum Diff { } /// One side's text, colors, and regions. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Source { - pub(crate) text: String, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Source { + pub text: String, /// Every token with its tree-sitter capture name. Per line, sorted, /// non-overlapping. Empty unless the run asked for syntax. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) syntax: Vec, + pub syntax: Vec, /// The largest regions, in order. Leaves tile the file. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) regions: Vec, + pub regions: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct BinaryRef { - pub(crate) size: u64, +pub struct BinaryRef { + pub size: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SyntaxSpan { - pub(crate) line: u32, - pub(crate) start_column: u32, - pub(crate) end_column: u32, +pub struct SyntaxSpan { + pub line: u32, + pub start_column: u32, + pub end_column: u32, /// A tree-sitter capture name such as `keyword` or `function.method`. - pub(crate) capture: String, + pub capture: String, } /// A range on one side. The same `id` on the other side is its @@ -266,24 +298,24 @@ pub(crate) struct SyntaxSpan { /// same line count on both sides and its rows pair line for line; a paired /// leaf whose counterpart is behind the reading cursor is a move, and the /// frontend chooses how to show it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Region { - pub(crate) id: u32, - pub(crate) start: SourcePos, - pub(crate) end: SourcePos, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Region { + pub id: u32, + #[serde(flatten)] + pub range: SourceRange, /// `body`, `import`, `test`, `unchanged`, or hook-supplied tags. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) tags: Vec, + pub tags: Vec, /// A collapsed leaf is a context gap. A collapsed fold is a folded body. - #[serde(default, skip_serializing_if = "Visibility::is_default")] - pub(crate) visibility: Visibility, + #[serde(default, skip_serializing_if = "is_default")] + pub visibility: Visibility, #[serde(flatten)] - pub(crate) node: Node, + pub node: Node, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub(crate) enum Node { +pub enum Node { /// Tiles the file. `changed` holds the byte ranges painted as changed /// within it; a fully new line carries one span covering it. Leaf { @@ -295,31 +327,96 @@ pub(crate) enum Node { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct Span { - pub(crate) line: u32, - pub(crate) start_column: u32, - pub(crate) end_column: u32, +pub struct Span { + pub line: u32, + pub start_column: u32, + pub end_column: u32, } +/// Half-open. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SourcePos { - pub(crate) line: u32, - pub(crate) column: u32, +pub struct SourceRange { + pub start: SourcePos, + pub end: SourcePos, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct Stats { +impl SourceRange { + /// The lines this range touches, half-open. A range ending at column + /// zero does not touch its end line. + pub fn lines(&self) -> std::ops::Range { + let end = if self.end.column == 0 { + self.end.line + } else { + self.end.line + 1 + }; + self.start.line..end + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePos { + pub line: u32, + pub column: u32, +} + +/// `structural` is `Ok` when tree-sitter compared both sides and `Err` +/// when this is a line diff, carrying why. On the wire that is a +/// `structural` key or a `fallback` key, never both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stats { /// Lines with any byte change. - pub(crate) textual: LineCounts, - /// Lines with a syntactic change. Absent when `fallback` is present. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) structural: Option, + pub textual: LineCounts, + /// Lines with a syntactic change. + pub structural: Result, +} + +#[derive(Serialize, Deserialize)] +struct StatsRepr { + textual: LineCounts, + #[serde(skip_serializing_if = "Option::is_none")] + structural: Option, + #[serde(skip_serializing_if = "Option::is_none")] + fallback: Option, +} + +impl Serialize for Stats { + fn serialize(&self, serializer: S) -> Result { + let (structural, fallback) = match &self.structural { + Ok(counts) => (Some(*counts), None), + Err(problem) => (None, Some(problem.clone())), + }; + StatsRepr { + textual: self.textual, + structural, + fallback, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Stats { + fn deserialize>(deserializer: D) -> Result { + let repr = StatsRepr::deserialize(deserializer)?; + let structural = match (repr.structural, repr.fallback) { + (Some(counts), None) => Ok(counts), + (None, Some(problem)) => Err(problem), + _ => { + return Err(D::Error::custom( + "stats need exactly one of structural or fallback", + )) + } + }; + Ok(Self { + textual: repr.textual, + structural, + }) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct LineCounts { - pub(crate) added: u32, - pub(crate) removed: u32, +pub struct LineCounts { + pub added: u32, + pub removed: u32, } #[cfg(test)] @@ -334,8 +431,10 @@ mod tests { fn leaf(id: u32, start: u32, end: u32, changed: Vec) -> Region { Region { id, - start: pos(start, 0), - end: pos(end, 0), + range: SourceRange { + start: pos(start, 0), + end: pos(end, 0), + }, tags: vec![], visibility: Visibility::default(), node: Node::Leaf { changed }, @@ -355,8 +454,10 @@ mod tests { syntax: vec![], regions: vec![Region { id: 1, - start: pos(0, 0), - end: pos(3, 0), + range: SourceRange { + start: pos(0, 0), + end: pos(3, 0), + }, tags: vec!["body".to_owned()], visibility: Visibility::default(), node: Node::Fold { @@ -373,32 +474,30 @@ mod tests { lhs: file_ref("3b18e5"), rhs: file_ref("9be2c1"), }, - outcome: Outcome::Diff { - diff: Diff::Text { - sides: Pairing::Both { - lhs: side("fn f() {\n 1\n}\n", vec![]), - rhs: side( - "fn f() {\n 1 + 2\n}\n", - vec![Span { - line: 1, - start_column: 5, - end_column: 9, - }], - ), - }, - stats: Stats { - textual: LineCounts { - added: 1, - removed: 1, - }, - structural: Some(LineCounts { - added: 1, - removed: 0, - }), + outcome: Ok(Diff::Text { + sides: Pairing::Both { + lhs: side("fn f() {\n 1\n}\n", vec![]), + rhs: side( + "fn f() {\n 1 + 2\n}\n", + vec![Span { + line: 1, + start_column: 5, + end_column: 9, + }], + ), + }, + stats: Stats { + textual: LineCounts { + added: 1, + removed: 1, }, - fallback: None, + structural: Ok(LineCounts { + added: 1, + removed: 0, + }), }, - }, + }) + .into(), } } @@ -493,7 +592,7 @@ mod tests { }, }, outcome: Outcome::Error { - error: Error { + error: Problem { code: "not_utf8".to_owned(), message: "a.txt is not valid UTF-8".to_owned(), }, @@ -502,7 +601,7 @@ mod tests { Event::Complete { succeeded: 2, failed: 1, - aborted: Some(Error { + aborted: Some(Problem { code: "hook_failed".to_owned(), message: "summarizer returned 503 after 4 attempts".to_owned(), }), From 17bed562880b29df046c25e20132c083dd7709e1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:34:39 -0700 Subject: [PATCH 03/43] Emit the region-based wire as --format ndjson Project DiffResult onto protocol::Event: aligned rows become leaves that tile each side, folds nest by containment with leaves split at their edges (mirrored across paired leaves), long unchanged runs collapse to the context width, and ids pair regions across sides. The manifest carries git's delta sides (oid, mode), status, category and language. Per-file failures are coded problems; a hook failure aborts the run. --syntax adds tree-sitter capture spans from the highlights query. The previous stream stays as --format ndjson-v1 while the frontends move. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/cli.md | 8 +- docs/streaming.md | 237 ++++-- examples/review/viewer/build.py | 2 +- src/cli.rs | 72 +- src/git.rs | 193 ++++- src/lines.rs | 4 +- src/main.rs | 1 - src/parse/tree_sitter_parser.rs | 27 + src/{protocol.rs => protocol/mod.rs} | 3 + src/protocol/project.rs | 1098 ++++++++++++++++++++++++++ src/protocol/stream.rs | 297 +++++++ src/stream.rs | 23 +- tests/streaming/check.py | 184 ++++- 13 files changed, 1986 insertions(+), 163 deletions(-) rename src/{protocol.rs => protocol/mod.rs} (99%) create mode 100644 src/protocol/project.rs create mode 100644 src/protocol/stream.rs diff --git a/docs/cli.md b/docs/cli.md index cfe066595..9db9d045e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,9 +28,11 @@ for changes; ordinary output exits 0. Errors exit 2. `--no-index` supports two files, implies change exit status, and does not yet support metadata options. Structural output uses the existing terminal renderer. `--format json` emits -one domain object per line. `--format ndjson` emits a buffered event stream, -diffing `--jobs N` files at once (default 16) and emitting each as it finishes; -`--format snapshot` is the fixture text adapter. +one domain object per line. `--format ndjson` emits the event stream described +in [streaming.md](streaming.md), diffing `--jobs N` files at once (default 16) +and emitting each as it finishes; `--syntax` adds every token's highlight +capture to it. `--format ndjson-v1` is the previous stream, kept while frontends +migrate. `--format snapshot` is the fixture text adapter. `-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, color, width and inline/split display remain configurable; see `--help`. diff --git a/docs/streaming.md b/docs/streaming.md index 2a0062344..fd903a5ae 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,14 +2,19 @@ ```sh diffr main HEAD --format ndjson -diffr --cached --format ndjson --order source,test -- src/ +diffr --cached --format ndjson --syntax --order source,test -- src/ +diffr --no-index --format ndjson -- before.rs after.rs ``` Spawn one process per comparison and consume stdout line by line. The comparison arguments are the same as the [ordinary CLI](cli.md). There is no HTTP server. -`--format json` remains the bare domain-object output; `ndjson` adds file metadata, -progress, recoverable errors and a completion record. NDJSON currently requires a -repository comparison; it rejects `--no-index`, `--quiet` and metadata output flags. +`--format json` remains the bare domain-object output; `ndjson` is the stream +described here. `ndjson-v1` is the previous stream and will be removed once every +frontend reads this one. NDJSON requires a repository comparison or `--no-index`; +it rejects `--quiet` and metadata output flags. + +The Rust types behind this document are `src/protocol/mod.rs`; the projection +from the internal diff is `src/protocol/project.rs`. ## Configuration and ordering @@ -17,7 +22,7 @@ Each invocation loads repository-root `diffr.toml` and compiles it once. `--config PATH` selects another file instead. Omitted keys retain bundled defaults; query strings replace whole values, and empty queries disable that feature. -File classes come from the current workspace's Git attributes: +File categories come from the current workspace's Git attributes: ```gitattributes * diffr-classify=source @@ -26,71 +31,185 @@ docs/** diffr-classify=docs **/*.lock diffr-classify=generated ``` -`--order source,test,docs` prioritizes those classes. It can also be repeated. +`--order source,test,docs` prioritizes those categories. It can also be repeated. Unlisted and unclassified files follow, with path order breaking ties. Without `--order`, use path order. Renames classify by new path; deletions by old path. -Only string attributes assign classes. Normal Git attribute precedence applies. Paths after `--` accept libgit2 directory prefixes and wildcard patterns, not Git magic pathspecs. Only changed files are selected. An unmatched path yields an empty stream. Filtering precedes rename detection, so selecting one side of a rename can appear as an addition or deletion. `--no-renames` skips rename detection. -## Output contract +## Conventions + +Stdout carries UTF-8 newline-delimited JSON, one record per line. Read complete +lines; pipe reads can split records or contain several. -Stdout contains only UTF-8 newline-delimited JSON records. Read complete lines; -pipe reads can split records or contain several. +- Every enum is tagged: `type` on records, snapshots and diffs; `kind` on regions. + Tags and enum values are `snake_case`. +- Optional, empty, and default fields are omitted, never `null`. Read a missing + `visibility` as open, a missing `tags` as none, a missing `changed` as none. +- Consumers ignore unknown fields and tolerate unknown enum strings. Additions + within a `version` never change the meaning of existing fields. +- Sides are `lhs` (before) and `rhs` (after). Anything that can exist on one side + only is a *pairing*, written by presence: `{"lhs": …, "rhs": …}`, `{"lhs": …}`, + or `{"rhs": …}`. +- Lines are 0-based and split on `\n` only: an empty file has zero lines, and a + file without a trailing newline still counts its last line. Columns are 0-based + byte offsets into the UTF-8 text on the wire. Ranges are half-open. -| Event | Fields | Consumer action | +## Records + +| Record | Fields | Consumer action | | --- | --- | --- | -| `start` | `version: 1`, `before`, `after`, `total`, `files` | Lay out every file up front. | -| `file` | `file`, `diff`, optional `hook_error` | Render a result. | -| `file_error` | `file`, `message` | Report failure and keep reading. | -| `complete` | `succeeded`, `failed` | Mark complete, including partial failures. | - -`before` and `after` identify the operands: `{"kind":"revision","ref":""}`, -`{"kind":"index"}`, `{"kind":"working_tree"}`, or `{"kind":"empty_tree"}`. -Revision refs resolve once. Index source is pinned by blob ID during discovery. -Worktree files are read as results are computed, not as an atomic snapshot. - -The file descriptor contains nullable `old_path`, `new_path`, `class`, and `status` -(added/deleted/modified/renamed/type_changed/conflicted). `diff` is the existing -domain JSON: complete sources, token correspondence, folds and context hunks. -There is no display layout in the response. - -`files` lists every selected file descriptor in priority order. File results -arrive in completion order, not manifest order, since files are diffed -concurrently (`--jobs`, default 16). Match results to the manifest by identity. - -At completion, `succeeded + failed == total`; every selected file has one result -or file error. EOF without `complete` means interrupted/incomplete output. -Setup failures write to stderr and exit 2 before producing any records. -Per-file failures emit `file_error`, allow subsequent results, and finish with -`complete` and exit 2. Success exits 0, or 1 with `--exit-code` if changes exist. -Unexpected computation or output failures can terminate without `complete`. - -Regular UTF-8 text files are supported. Binary/non-UTF-8 files, symlinks, -submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths -fail discovery. +| `start` | `version: 2`, `lhs`, `rhs`, `files` | Lay out every file up front. | +| `file` | `file`, then `diff` or `error` | Render a result, or mark the file failed. | +| `complete` | `succeeded`, `failed`, optional `aborted` | Mark complete; `aborted` means the run stopped early. | + +`lhs` and `rhs` on `start` say what is being compared: `{"type":"revision","rev":""}`, +`{"type":"index"}`, `{"type":"working_tree"}`, `{"type":"empty_tree"}` for an unborn +branch, or `{"type":"path","path":"…"}` for `--no-index`. Revisions resolve once. +Index content is pinned by blob id during discovery; working-tree files are read +as results are computed, not as an atomic snapshot. + +`files` lists every selected file in priority order: + +```jsonc +{"file": {"lhs": {"path": "src/a.rs", "oid": "3b18…", "mode": "100644"}, + "rhs": {"path": "src/a.rs", "oid": "9be2…", "mode": "100644"}}, + "status": "modified", // added | deleted | modified | renamed | copied | type_changed + "category": "source", // from diffr-classify; absent when unset + "language": "Rust", // guessed from the path; absent when unknown + "visibility": {"collapsed": true, "label": "Generated file"}} // absent when open +``` + +`file` is git's delta: a deleted file has `lhs` only, an added file `rhs` only. The +path pair is the file's identity; each `file` record repeats it verbatim so the +record can be matched back to the manifest. Working-tree sides carry git's +all-zero oid. A `--no-index` comparison has empty `oid` and `mode`. + +Results arrive in completion order, not manifest order, since files are diffed +concurrently (`--jobs`, default 16). `--jobs 1` restores priority order. At +completion, `succeeded + failed` equals the manifest length unless `aborted` is +present. EOF without `complete` means the output was cut off. + +### Errors + +One shape everywhere: `{"code": "", "message": ""}`. + +- On a `file` record, `error` replaces `diff` and the run continues. Codes: + `binary`, `not_utf8`, `unsupported_file_type` (symlinks, submodules), + `unmerged`, `read_failed`, `fold_pairing`. +- On `complete`, `aborted` reports a run-level failure, currently only + `hook_failed`. diffr stops pulling files, lets the ones in flight finish, and + exits 2. Every `file` record already written stays valid. +- Setup failures (bad revision, unreadable config, hook that never starts) + write to stderr and exit 2 before any record. + +Exit status is 0 on success, 1 with `--exit-code` when there are changes, 2 when +any file failed or the run aborted. + +## The diff + +```jsonc +{"type": "text", + "lhs": {"text": "…", "syntax": [...], "regions": [...]}, + "rhs": {"text": "…", "syntax": [...], "regions": [...]}, + "stats": {"textual": {"added": 4, "removed": 1}, + "structural": {"added": 3, "removed": 1}}} // or "fallback": {code, message} +``` + +A `binary` diff carries only `{"lhs": {"size": n}, "rhs": {"size": n}}`; either +side being binary makes the whole diff binary. Text sides are a pairing too: a +deleted file has `lhs` only. + +`text` is the complete source. `syntax` is present only with `--syntax`: every +token as `{line, start_column, end_column, capture}`, where `capture` is the +tree-sitter highlight capture name (`keyword`, `function.method`, …). Spans are +per line, sorted, and non-overlapping; where captures nest, the innermost wins. +Files that fell back to a line diff have no syntax. + +`stats.textual` counts lines with any byte change. `stats.structural` counts lines +with a syntactic change and is replaced by `fallback` when tree-sitter did not run: +`unsupported_language`, `too_large`, `too_complex`, `parse_error`. A fallback diff +has leaves but no folds. + +### Regions + +Each side carries a tree of regions. A region is a line range on that side with +an `id`, and the same `id` on the other side marks its counterpart. + +```jsonc +{"id": 7, "kind": "fold", + "start": {"line": 18, "column": 4}, "end": {"line": 52, "column": 33}, + "tags": ["body"], + "visibility": {"collapsed": false, "label": "Body"}, + "children": [ + {"id": 8, "kind": "leaf", "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, + {"id": 9, "kind": "leaf", "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, + "changed": [{"line": 30, "start_column": 8, "end_column": 9}]}, + {"id": 10, "kind": "leaf", "start": {"line": 31, "column": 0}, "end": {"line": 53, "column": 0}}]} +``` + +**Leaves** tile the file: read in order, their line ranges cover every line once. +They always start and end at column 0. A leaf with an `id` on both sides is +paired: the two have the same line count and their rows pair line for line. A +leaf on one side only has no counterpart, and the other side shows blank rows +against it. The row table is the walk over both sides' leaves, zipped by id. +A paired leaf whose counterpart lies behind the reading cursor is a move; the +frontend chooses how to show it. + +`changed` holds the byte ranges inside a leaf that should be painted as changed: +a line that is entirely new carries one span covering it, a changed word inside +an otherwise matching line carries just that word. A line with no span in a leaf +that has spans is a changed line whose tokens all matched elsewhere. Blank +changed lines carry no span. + +**Folds** are regions with `children`. Their line span is the hull of their +children, which tile it exactly; `start` and `end` keep the byte-precise range +the parser found, so the first line of a fold is its header and stays visible +when it collapses. A fold with an `id` on both sides is the same syntax node on +both sides; its contents may differ. Folds nest by containment. A syntactic +region that spans a single line is not a region: it hides nothing. + +`tags` name what a region is (`body`, `import`, `test`, `unchanged`, or tags a +hook adds). `visibility` is how it starts out: `collapsed` and the `label` to +show while collapsed, a placeholder or pseudocode summary for a fold, or the +count for a context gap. Absent means open. + +**Context** is expressed as leaves: an unchanged run longer than twice the +`-U` width keeps that many lines open on each side that faces a change and +collapses the rest into a leaf tagged `unchanged` with a label such as +`"142 unchanged lines"`. A run at the start of the file keeps only its trailing +lines, one at the end only its leading lines, and a file with no change is one +collapsed leaf. Fold edges can split a gap into consecutive collapsed leaves; +render adjacent collapsed leaves as one gap. + +Ids are per file, dense, and assigned in lhs preorder then rhs preorder. They +mean nothing across files or runs. ## Computation and output Discovery and rename detection finish before `start`; syntax matching is lazy. A pool of `--jobs` workers pulls files from the iterator: each worker reads the next file's sources under a lock, then diffs them while other workers pull -further files. The calling thread serializes, writes and flushes each event. -A bounded queue holds one ready event, so computation overlaps slow writes +further files. The calling thread serializes, writes and flushes each record. +A bounded queue holds one ready record, so computation overlaps slow writes without collecting the entire comparison. In-flight files are bounded by the -pool size, not their individual size. `--jobs 1` restores priority order. +pool size, not their individual size. Closing stdout stops production once the files in flight finish. Terminate the process to cancel immediately. The CLI also retains its normal SIGPIPE behavior on Unix. +Regular UTF-8 text files are supported. Binary and non-UTF-8 files, symlinks, +submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths +fail discovery. + ## Fold hooks -A trusted hook can replace fold placeholders with richer text, such as -pseudocode, before each `file` event is emitted. A hook is a JSON-RPC 2.0 +A trusted hook can replace a fold's collapsed label with richer text, such as +pseudocode, before its `file` record is emitted. A hook is a JSON-RPC 2.0 server over HTTP that diffr starts once per invocation and calls on loopback: ```toml @@ -105,16 +224,14 @@ startup_timeout_ms = 30000 # optional; time allowed to start listening The command starts with the caller's environment plus `DIFFR_HOOK_PORT`, the loopback port it must listen on, and `DIFFR_WORKSPACE`, the diffed repository's root. It runs in the directory containing the config file, so relative paths in -`command` resolve against the config wherever it lives, including one given by -`--config` outside the repository. Its stdout is discarded because diffr's own -stdout carries the event stream; log to stderr. diffr polls the port until the -hook accepts connections, exits 2 before `start` if the hook exits or misses -`startup_timeout_ms`, and kills the hook when the comparison ends. +`command` resolve against the config wherever it lives. Its stdout is discarded +because diffr's own stdout carries the stream; log to stderr. diffr polls the +port until the hook accepts connections, exits 2 before `start` if the hook +exits or misses `startup_timeout_ms`, and kills the hook when the comparison ends. Only novel folds on the after side qualify: bodies that exist in the after source with no counterpart in the before source. Files with no qualifying fold -never reach the hook. Streaming is the only output mode that runs hooks; the -terminal frontend streams, so it does too. +never reach the hook. Streaming is the only output mode that runs hooks. One call per file, method `summarize`, params by name. The worker diffing that file blocks on the reply; other workers keep calling, so a hook must serve @@ -128,17 +245,16 @@ requests concurrently rather than one at a time. "tags": ["body"], "placeholder": "Body"}]}} // hook -> diffr {"jsonrpc": "2.0", "id": 7, "result": {"0": "def refresh_token(session):\n ..."}} -{"jsonrpc": "2.0", "id": 8, "error": {"code": -32000, "message": "rate limited"}} ``` -`language` is null for plain text. A fold `id` indexes `rhs_folds` in that file's -`diff`; the matching fold gains a non-null `summary` while `placeholder` is -unchanged. Folds missing from the result keep a null `summary`. An error -object, a timeout, an unknown fold id, or an invalid response leaves every -summary in that file null and adds `hook_error` to its `file` event. +A fold `id` indexes that file's after-side folds in the order the hook received +them; the matching region's `visibility.label` becomes the returned text. Folds +missing from the result keep their placeholder. An error object, a timeout, an +unknown fold id, or an invalid response is a run-level failure: the stream ends +with `complete.aborted` set to `hook_failed` and diffr exits 2. `examples/hooks/summarize.py` is a reference hook: an aiohttp server that hands -each request to jsonrpcserver and asks Gemini 3.8 Flash, with thinking disabled, +each request to jsonrpcserver and asks Gemini Flash, with thinking disabled, for Python-style pseudocode. It needs `GOOGLE_API_KEY` and answers up to 16 files at once on one asyncio loop with a shared httpx client. `uv run --script` installs its dependencies on first use. `tests/hooks/rpc_server.py` is a @@ -146,7 +262,8 @@ dependency-free hook used by the tests. ## Fixture viewer -The viewer reads captured CLI streams from static files: +The static viewer under `examples/review/viewer` still reads the v1 stream; +its build script captures `--format ndjson-v1`: ```sh cargo build --locked diff --git a/examples/review/viewer/build.py b/examples/review/viewer/build.py index ee7466ac6..a9d8f5b41 100644 --- a/examples/review/viewer/build.py +++ b/examples/review/viewer/build.py @@ -82,7 +82,7 @@ def git(repo, *args, input=None): stream_path = OUT / (meta['id'] + '.ndjson') with stream_path.open('wb') as output: subprocess.run([str(ROOT / 'target/debug/diffr'), '--repo', str(REPO), - *commits, '--format', 'ndjson', '--', *paths], + *commits, '--format', 'ndjson-v1', '--', *paths], stdout=output, env=ENV, check=True) view['request'] = 'data/' + stream_path.name path.write_text(json.dumps(view)) diff --git a/src/cli.rs b/src/cli.rs index c67e44727..9af1bec53 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -60,7 +60,8 @@ pub(crate) fn run() -> Result { .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) - .arg(Arg::new("format").long("format").value_parser(["text", "json", "ndjson", "snapshot"]).default_value("text")) + .arg(Arg::new("format").long("format").value_parser(["text", "json", "ndjson", "ndjson-v1", "snapshot"]).default_value("text")) + .arg(flag("syntax").help("Include every token's tree-sitter capture name in --format ndjson output")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) .arg(flag("no-color")) @@ -79,10 +80,14 @@ pub(crate) fn run() -> Result { ) { return launch_tui(&frontend_args); } - let streaming = args.get_one::("format").unwrap() == "ndjson"; + let format = args.get_one::("format").unwrap().as_str(); + let streaming = matches!(format, "ndjson" | "ndjson-v1"); if streaming && (args.get_flag("quiet") || args.contains_id("metadata")) { return Err("--format ndjson cannot be combined with --quiet or metadata output".into()); } + let stream_options = crate::protocol::stream::Options { + syntax: args.get_flag("syntax"), + }; let items: Vec = args .get_many::("items") .into_iter() @@ -129,6 +134,7 @@ pub(crate) fn run() -> Result { items.into_iter().chain(explicit_paths).collect(), &display, &diff_options, + stream_options, ); } if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { @@ -176,7 +182,18 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let failed = crate::stream::write(session, jobs, hook, &mut io::stdout().lock())?; + let failed = if format == "ndjson-v1" { + crate::stream::write(session, jobs, hook, &mut io::stdout().lock())? + } else { + let ended = crate::protocol::stream::write( + session, + jobs, + hook, + stream_options, + &mut io::stdout().lock(), + )?; + ended.failed || ended.aborted + }; return Ok(if failed { 2 } else { @@ -410,6 +427,7 @@ fn no_index( paths: Vec, display: &DisplayOptions, options: &DiffOptions, + stream_options: crate::protocol::stream::Options, ) -> Result { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); @@ -460,19 +478,41 @@ fn no_index( &[], ) }; - if args.get_one::("format").map(String::as_str) == Some("ndjson") { - let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; - crate::stream::write_file( - &paths[0].to_string_lossy(), - &paths[1].to_string_lossy(), - compute, - hook.as_deref(), - &mut io::stdout().lock(), - )?; - Ok(i32::from(changed && args.get_flag("exit-code"))) - } else { - render(&compute(), args, display)?; - Ok(i32::from(changed)) + match args.get_one::("format").map(String::as_str) { + Some("ndjson-v1") => { + let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; + crate::stream::write_file( + &paths[0].to_string_lossy(), + &paths[1].to_string_lossy(), + compute, + hook.as_deref(), + &mut io::stdout().lock(), + )?; + Ok(i32::from(changed && args.get_flag("exit-code"))) + } + Some("ndjson") => { + let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; + let ended = crate::protocol::stream::write_file( + &paths[0].to_string_lossy(), + &paths[1].to_string_lossy(), + (before.len() as u64, after.len() as u64), + compute, + &config, + display.num_context_lines as usize, + hook.as_deref(), + stream_options, + &mut io::stdout().lock(), + )?; + Ok(if ended.failed || ended.aborted { + 2 + } else { + i32::from(changed && args.get_flag("exit-code")) + }) + } + _ => { + render(&compute(), args, display)?; + Ok(i32::from(changed)) + } } } diff --git a/src/git.rs b/src/git.rs index 2d4c9ec6c..014ab049b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,9 +1,12 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; +use crate::parse::guess_language::{guess, language_name}; +use crate::protocol; use crate::summary::DiffResult; use git2::{AttrCheckFlags, AttrValue, Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository}; use serde::{Deserialize, Serialize}; use std::{ + fmt, path::{Path, PathBuf}, sync::Arc, }; @@ -130,6 +133,11 @@ pub(crate) struct FileChange { pub(crate) new_path: Option, pub(crate) status: FileStatus, pub(crate) class: Option, + /// Git's delta sides. Only the v2 wire carries them. + #[serde(skip)] + pub(crate) sides: protocol::Pairing, + #[serde(skip)] + pub(crate) language: Option, } impl FileChange { @@ -139,6 +147,101 @@ impl FileChange { .or(self.old_path.as_deref()) .expect("changed file has a path") } + + /// A standalone comparison of two paths, outside any repository. + pub(crate) fn standalone(before: &str, after: &str) -> Self { + let file_ref = |path: &str| protocol::FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + let old_path = (before != "/dev/null").then(|| before.to_owned()); + let new_path = (after != "/dev/null").then(|| after.to_owned()); + let (status, sides) = match (&old_path, &new_path) { + (Some(old), Some(new)) => ( + FileStatus::Modified, + protocol::Pairing::Both { + lhs: file_ref(old), + rhs: file_ref(new), + }, + ), + (Some(old), None) => ( + FileStatus::Deleted, + protocol::Pairing::LeftOnly { lhs: file_ref(old) }, + ), + (None, Some(new)) => ( + FileStatus::Added, + protocol::Pairing::RightOnly { rhs: file_ref(new) }, + ), + (None, None) => panic!("a standalone comparison needs at least one path"), + }; + let language = language_of(new_path.as_deref().or(old_path.as_deref()).expect("a path")); + Self { + old_path, + new_path, + status, + class: None, + sides, + language, + } + } + + pub(crate) fn manifest_entry(&self) -> protocol::FileChange { + protocol::FileChange { + file: self.sides.clone(), + status: match self.status { + FileStatus::Added => protocol::FileStatus::Added, + FileStatus::Deleted => protocol::FileStatus::Deleted, + FileStatus::Modified => protocol::FileStatus::Modified, + FileStatus::Renamed => protocol::FileStatus::Renamed, + FileStatus::TypeChanged => protocol::FileStatus::TypeChanged, + // Both sides exist; the file record carries the unmerged error. + FileStatus::Conflicted => protocol::FileStatus::Modified, + }, + category: self.class.clone(), + language: self.language.clone(), + visibility: protocol::Visibility::default(), + } + } +} + +fn language_of(path: &str) -> Option { + guess(Path::new(path), "", &[]).map(|language| language_name(language).to_owned()) +} + +/// Why one file could not be diffed. `code` is stable on the wire. +#[derive(Debug)] +pub(crate) struct FileProblem { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +impl FileProblem { + fn new(code: &'static str, message: impl fmt::Display) -> Self { + Self { + code, + message: message.to_string(), + } + } +} + +impl fmt::Display for FileProblem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for FileProblem {} + +impl From<&Operand> for protocol::Snapshot { + fn from(operand: &Operand) -> Self { + match operand { + Operand::Revision { r#ref } => Self::Revision { rev: r#ref.clone() }, + Operand::Index => Self::Index, + Operand::WorkingTree => Self::WorkingTree, + Operand::EmptyTree => Self::EmptyTree, + } + } } #[derive(Debug, Deserialize)] @@ -191,23 +294,35 @@ impl Source { }) } - fn read(&self, repo: &Repository) -> Result { + fn read(&self, repo: &Repository) -> std::result::Result { let mode = match self { Self::Absent => return Ok(String::new()), Self::Blob { mode, .. } | Self::WorkingFile { mode, .. } => mode, }; if !matches!(mode, git2::FileMode::Blob | git2::FileMode::BlobExecutable) { - return Err("structural diffs currently require regular text files (not symlinks or submodules)".into()); + return Err(FileProblem::new( + "unsupported_file_type", + "structural diffs currently require regular text files (not symlinks or submodules)", + )); } let bytes = match self { - Self::Blob { id, .. } => repo.find_blob(*id)?.content().to_vec(), - Self::WorkingFile { path, .. } => std::fs::read(path)?, + Self::Blob { id, .. } => repo + .find_blob(*id) + .map_err(|error| FileProblem::new("read_failed", error))? + .content() + .to_vec(), + Self::WorkingFile { path, .. } => { + std::fs::read(path).map_err(|error| FileProblem::new("read_failed", error))? + } Self::Absent => unreachable!(), }; if bytes.contains(&0) { - return Err("structural diffs currently support text files only".into()); + return Err(FileProblem::new( + "binary", + "structural diffs currently support text files only", + )); } - Ok(String::from_utf8(bytes)?) + String::from_utf8(bytes).map_err(|error| FileProblem::new("not_utf8", error)) } } @@ -267,19 +382,43 @@ impl DiffSession { .ok_or("non-UTF-8 Git paths are unsupported")? .to_owned()) }; - let mut file = FileChange { - old_path: if delta.status() == Delta::Added { - None - } else { - Some(path(delta.old_file())?) + let old_path = if delta.status() == Delta::Added { + None + } else { + Some(path(delta.old_file())?) + }; + let new_path = if delta.status() == Delta::Deleted { + None + } else { + Some(path(delta.new_file())?) + }; + let file_ref = |file: git2::DiffFile<'_>, path: &str| protocol::FileRef { + path: path.to_owned(), + oid: file.id().to_string(), + mode: format!("{:o}", u32::from(file.mode())), + }; + let sides = match (&old_path, &new_path) { + (Some(old), Some(new)) => protocol::Pairing::Both { + lhs: file_ref(delta.old_file(), old), + rhs: file_ref(delta.new_file(), new), + }, + (Some(old), None) => protocol::Pairing::LeftOnly { + lhs: file_ref(delta.old_file(), old), }, - new_path: if delta.status() == Delta::Deleted { - None - } else { - Some(path(delta.new_file())?) + (None, Some(new)) => protocol::Pairing::RightOnly { + rhs: file_ref(delta.new_file(), new), }, + (None, None) => unreachable!("a delta has a path"), + }; + let language = + language_of(new_path.as_deref().or(old_path.as_deref()).expect("a path")); + let mut file = FileChange { + old_path, + new_path, status, class: None, + sides, + language, }; file.class = match AttrValue::from_string(repo.get_attr( Path::new(file.path()), @@ -334,12 +473,16 @@ pub(crate) struct LoadedFile { pub(crate) file: FileChange, before: String, after: String, - params: Arc, - context_lines: u32, + pub(crate) params: Arc, + pub(crate) context_lines: u32, diff_options: crate::options::DiffOptions, } impl LoadedFile { + pub(crate) fn sizes(&self) -> (u64, u64) { + (self.before.len() as u64, self.after.len() as u64) + } + pub(crate) fn diff(&self) -> DiffResult { DiffResult::from_sources_with_options( self.file.path(), @@ -357,11 +500,16 @@ impl LoadedFile { impl DiffSession { /// Read the next file's sources without diffing them. - pub(crate) fn load(&mut self) -> Option<(FileChange, Result)> { + pub(crate) fn load( + &mut self, + ) -> Option<(FileChange, std::result::Result)> { let pending = self.files.next()?; let result = (|| { if matches!(pending.file.status, FileStatus::Conflicted) { - return Err("unmerged index entry: resolve the conflict before requesting a structural diff".into()); + return Err(FileProblem::new( + "unmerged", + "unmerged index entry: resolve the conflict before requesting a structural diff", + )); } Ok(LoadedFile { before: pending.before.read(&self.repo)?, @@ -380,6 +528,11 @@ impl Iterator for DiffSession { type Item = (FileChange, Result); fn next(&mut self) -> Option { let (file, loaded) = self.load()?; - Some((file, loaded.map(|loaded| loaded.diff()))) + Some(( + file, + loaded + .map(|loaded| loaded.diff()) + .map_err(|problem| Box::new(problem) as _), + )) } } diff --git a/src/lines.rs b/src/lines.rs index d46bacfa3..a5fce0246 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -61,13 +61,13 @@ pub(crate) fn is_all_whitespace(s: &str) -> bool { /// A nonempty source interval with an exclusive end. Coordinates must be /// ordered, in bounds, and on UTF-8 boundaries in the associated text source. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct SourceRange { pub(crate) start: SourcePosition, pub(crate) end: SourcePosition, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct SourcePosition { pub(crate) line: LineNumber, /// Zero-based UTF-8 byte offset within the line, not a display column. diff --git a/src/main.rs b/src/main.rs index 1ceba8644..3f6097657 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,6 @@ mod line_parser; mod lines; mod options; mod parse; -#[allow(dead_code)] pub(crate) mod protocol; mod review; mod stream; diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index a42b0c095..1156df31d 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -1468,6 +1468,33 @@ fn tree_highlights( } } +/// Every highlight capture in `src` as byte intervals with the capture +/// name from the language's highlights query. Intervals may nest; the +/// caller decides precedence. +pub(crate) fn highlight_captures( + src: &str, + config: &'static TreeSitterConfig, +) -> Vec<(usize, usize, &'static str)> { + let tree = to_tree(src, config); + let names = config.highlight_query.capture_names(); + let mut cursor = ts::QueryCursor::new(); + let mut matches = cursor.matches(&config.highlight_query, tree.root_node(), src.as_bytes()); + let mut captures = Vec::new(); + while let Some(matched) = matches.next() { + for capture in matched.captures { + let node = capture.node; + if node.start_byte() < node.end_byte() { + captures.push(( + node.start_byte(), + node.end_byte(), + names[capture.index as usize], + )); + } + } + } + captures +} + pub(crate) fn print_tree(src: &str, tree: &tree_sitter::Tree) { let mut cursor = tree.walk(); print_cursor(src, &mut cursor, 0); diff --git a/src/protocol.rs b/src/protocol/mod.rs similarity index 99% rename from src/protocol.rs rename to src/protocol/mod.rs index 2a22f156f..8b8f07750 100644 --- a/src/protocol.rs +++ b/src/protocol/mod.rs @@ -17,6 +17,9 @@ use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +pub(crate) mod project; +pub(crate) mod stream; + /// The current wire version. Changes within a version are additive. pub const VERSION: u32 = 2; diff --git a/src/protocol/project.rs b/src/protocol/project.rs new file mode 100644 index 000000000..5cf8dd361 --- /dev/null +++ b/src/protocol/project.rs @@ -0,0 +1,1098 @@ +//! Projection from the internal `DiffResult` onto the wire types. +//! +//! Leaves come from the full-file row alignment: consecutive rows of one +//! kind (paired unchanged, paired novel, one-sided) become one leaf on each +//! side they touch, and a paired leaf shares its id across sides. Folds +//! come from the per-side fold lists; a fold with a counterpart shares its +//! id too. Leaves are split wherever a fold starts or ends so that every +//! fold's children tile its line span exactly, and a split on one side of +//! a paired leaf is mirrored on the other so paired leaves stay equal in +//! length. Long unchanged paired runs are trimmed to the context width and +//! their middle becomes a collapsed leaf tagged `unchanged`. +use super::{ + BinaryRef, Diff, FileRef, LineCounts, Node, Pairing, Problem, Region, Source, SourcePos, + SourceRange, Span, Stats, SyntaxSpan, Visibility, +}; +use crate::display::line_layout::{aligned_rows, novel_lines}; +use crate::hash::DftHashMap; +use crate::line_parser; +use crate::lines; +use crate::parse::folds::{Fold, FoldMatch}; +use crate::parse::syntax::{MatchKind, MatchedPos}; +use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; +use crate::summary::{DiffResult, FileContent, FileFormat}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Everything the projection needs besides the diff itself. +pub(crate) struct Inputs<'a> { + /// Which sides the file exists on; a one-sided file gets one source. + pub(crate) file: &'a Pairing, + /// Byte length of each side's content, for binary files. + pub(crate) sizes: (u64, u64), + /// Unchanged lines kept open on either side of a change. + pub(crate) context_lines: usize, + /// Highlight spans per side; empty when the run did not ask for syntax. + pub(crate) syntax: (Vec, Vec), +} + +pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Result { + let (lhs_src, rhs_src) = match (&result.lhs_src, &result.rhs_src) { + (FileContent::Text(lhs), FileContent::Text(rhs)) => (lhs.as_str(), rhs.as_str()), + _ => { + let sides = pair( + inputs.file, + BinaryRef { + size: inputs.sizes.0, + }, + BinaryRef { + size: inputs.sizes.1, + }, + ); + return Ok(Diff::Binary { sides }); + } + }; + let (lhs_regions, rhs_regions) = regions(result, lhs_src, rhs_src, inputs.context_lines)?; + let (lhs_syntax, rhs_syntax) = inputs.syntax; + let sides = pair( + inputs.file, + Source { + text: lhs_src.to_owned(), + syntax: lhs_syntax, + regions: lhs_regions, + }, + Source { + text: rhs_src.to_owned(), + syntax: rhs_syntax, + regions: rhs_regions, + }, + ); + Ok(Diff::Text { + sides, + stats: stats(result, lhs_src, rhs_src), + }) +} + +fn pair(file: &Pairing, lhs: T, rhs: T) -> Pairing { + match file { + Pairing::Both { .. } => Pairing::Both { lhs, rhs }, + Pairing::LeftOnly { .. } => Pairing::LeftOnly { lhs }, + Pairing::RightOnly { .. } => Pairing::RightOnly { rhs }, + } +} + +fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { + let (lhs_lines, rhs_lines) = line_parser::change_positions(lhs_src, rhs_src); + let textual = LineCounts { + added: novel_lines(&rhs_lines).len() as u32, + removed: novel_lines(&lhs_lines).len() as u32, + }; + let structural = match &result.file_format { + FileFormat::SupportedLanguage(_) => Ok(LineCounts { + added: novel_lines(&result.rhs_positions).len() as u32, + removed: novel_lines(&result.lhs_positions).len() as u32, + }), + FileFormat::PlainText => Err(Problem { + code: "unsupported_language".to_owned(), + message: "no tree-sitter grammar for this file".to_owned(), + }), + FileFormat::TextFallback { reason } => Err(Problem { + code: fallback_code(reason).to_owned(), + message: reason.clone(), + }), + FileFormat::Binary => unreachable!("binary files never reach text stats"), + }; + Stats { + textual, + structural, + } +} + +/// difftastic reports its fallbacks as prose; the wire wants a code. +fn fallback_code(reason: &str) -> &'static str { + if reason.contains("DFT_BYTE_LIMIT") { + "too_large" + } else if reason.contains("DFT_GRAPH_LIMIT") { + "too_complex" + } else if reason.contains("parse error") { + "parse_error" + } else { + "text_fallback" + } +} + +/// Highlight spans for one side, per line, sorted, non-overlapping. Where +/// captures nest the innermost wins. +pub(crate) fn syntax_spans(src: &str, parser: &'static TreeSitterConfig) -> Vec { + let mut captures = highlight_captures(src, parser); + // Paint larger captures first so smaller (inner) ones overwrite them. + captures.sort_by_key(|(start, end, _)| std::cmp::Reverse(end - start)); + let mut owner: Vec> = vec![None; src.len()]; + for (start, end, name) in captures { + for slot in &mut owner[start..end] { + *slot = Some(name); + } + } + let mut spans = Vec::new(); + let mut line_start = 0; + for (line, text) in src.split_inclusive('\n').enumerate() { + let content_len = text.trim_end_matches('\n').len(); + let mut run: Option<(usize, &'static str)> = None; + for column in 0..=content_len { + let current = (column < content_len) + .then(|| owner[line_start + column]) + .flatten(); + match (run, current) { + (Some((_, name)), Some(now)) if now == name => {} + (Some((start, name)), _) => { + spans.push(SyntaxSpan { + line: line as u32, + start_column: start as u32, + end_column: column as u32, + capture: name.to_owned(), + }); + run = current.map(|name| (column, name)); + } + (None, Some(name)) => run = Some((column, name)), + (None, None) => {} + } + } + line_start += text.len(); + } + spans +} + +// ── regions ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RunKind { + Unchanged, + Novel, +} + +/// A maximal run of aligned rows of one kind before fold splitting. +#[derive(Clone, Debug)] +struct Run { + kind: RunKind, + lhs: Option<(usize, usize)>, + rhs: Option<(usize, usize)>, + collapsed: bool, +} + +impl Run { + fn len(&self) -> usize { + let (start, end) = self.lhs.or(self.rhs).expect("a run has a side"); + end - start + } +} + +/// A leaf after splitting. `key` identifies its counterpart on the other +/// side, when it has one. +#[derive(Clone, Debug)] +struct Leaf { + lines: (usize, usize), + key: Option, + collapsed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum PairKey { + Leaf(LeafKey), + Fold(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct LeafKey { + run: usize, + piece: usize, +} + +struct SideFold<'a> { + fold: &'a Fold, + lines: (usize, usize), + /// Index of the lhs fold this pairs with, on either side. + pair: Option, +} + +fn regions( + result: &DiffResult, + lhs_src: &str, + rhs_src: &str, + context_lines: usize, +) -> Result<(Vec, Vec), Problem> { + let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); + let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); + let lhs_novel = novel_lines(&result.lhs_positions); + let rhs_novel = novel_lines(&result.rhs_positions); + let rows = aligned_rows( + (lhs_src, rhs_src), + (&result.lhs_positions, &result.rhs_positions), + ); + let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), context_lines); + + let lhs_folds = side_folds(&result.lhs_folds, &result.rhs_folds, Side::Left, &lhs_lines)?; + let rhs_folds = side_folds( + &result.lhs_folds, + &result.rhs_folds, + Side::Right, + &rhs_lines, + )?; + let lhs_splits = split_lines(&lhs_folds); + let rhs_splits = split_lines(&rhs_folds); + let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); + + let mut ids = Ids::default(); + let lhs = tree( + &lhs_folds, + &lhs_leaves, + &result.lhs_positions, + &lhs_novel, + &lhs_lines, + &mut ids, + ); + let rhs = tree( + &rhs_folds, + &rhs_leaves, + &result.rhs_positions, + &rhs_novel, + &rhs_lines, + &mut ids, + ); + Ok((lhs, rhs)) +} + +#[derive(Clone, Copy)] +enum Side { + Left, + Right, +} + +/// Rows in order, run-length encoded by kind and side presence. +fn runs( + rows: &[(Option, Option)], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, +) -> Vec { + let mut runs: Vec = Vec::new(); + for &(lhs, rhs) in rows { + let kind = match (lhs, rhs) { + (Some(l), Some(r)) if !lhs_novel.contains(&l) && !rhs_novel.contains(&r) => { + RunKind::Unchanged + } + _ => RunKind::Novel, + }; + let extends = runs.last().is_some_and(|run| { + run.kind == kind + && run.lhs.is_some() == lhs.is_some() + && run.rhs.is_some() == rhs.is_some() + && lhs.is_none_or(|l| run.lhs.is_some_and(|(_, end)| end == l)) + && rhs.is_none_or(|r| run.rhs.is_some_and(|(_, end)| end == r)) + }); + if extends { + let run = runs.last_mut().expect("checked above"); + if let (Some((_, end)), Some(_)) = (&mut run.lhs, lhs) { + *end += 1; + } + if let (Some((_, end)), Some(_)) = (&mut run.rhs, rhs) { + *end += 1; + } + } else { + runs.push(Run { + kind, + lhs: lhs.map(|l| (l, l + 1)), + rhs: rhs.map(|r| (r, r + 1)), + collapsed: false, + }); + } + } + runs +} + +/// Keep `context` lines of every unchanged run on the side that faces a +/// change; collapse the rest. A run at the file start faces no change +/// before it, one at the end none after, and a file with a single run has +/// no change at all. +fn trim_context(runs: Vec, context: usize) -> Vec { + let last = runs.len().saturating_sub(1); + let mut out = Vec::new(); + for (index, run) in runs.into_iter().enumerate() { + if run.kind != RunKind::Unchanged { + out.push(run); + continue; + } + let lead = if index == 0 { 0 } else { context }; + let trail = if index == last { 0 } else { context }; + let len = run.len(); + if len <= lead + trail { + out.push(run); + continue; + } + let (lhs_start, _) = run.lhs.expect("unchanged runs are paired"); + let (rhs_start, _) = run.rhs.expect("unchanged runs are paired"); + let piece = |offset: usize, count: usize, collapsed: bool| Run { + kind: RunKind::Unchanged, + lhs: Some((lhs_start + offset, lhs_start + offset + count)), + rhs: Some((rhs_start + offset, rhs_start + offset + count)), + collapsed, + }; + if lead > 0 { + out.push(piece(0, lead, false)); + } + out.push(piece(lead, len - lead - trail, true)); + if trail > 0 { + out.push(piece(len - trail, trail, false)); + } + } + out +} + +fn line_span(range: &lines::SourceRange, line_count: usize) -> (usize, usize) { + let start = range.start.line.as_usize(); + let end = if range.end.byte_column == 0 { + range.end.line.as_usize() + } else { + range.end.line.as_usize() + 1 + }; + ( + start.min(line_count), + end.min(line_count).max(start.min(line_count)), + ) +} + +fn side_folds<'a>( + lhs_folds: &'a [Fold], + rhs_folds: &'a [Fold], + side: Side, + lines: &[&str], +) -> Result>, Problem> { + let (own, other) = match side { + Side::Left => (lhs_folds, rhs_folds), + Side::Right => (rhs_folds, lhs_folds), + }; + let by_range: DftHashMap = other + .iter() + .enumerate() + .map(|(index, fold)| (fold.range, index)) + .collect(); + own.iter() + .enumerate() + // A fold on a single line hides nothing; it is not a region. + .filter(|(_, fold)| { + let (start, end) = line_span(&fold.range, lines.len()); + end - start >= 2 + }) + .map(|(index, fold)| { + let pair = match &fold.match_kind { + FoldMatch::Novel => None, + FoldMatch::Unchanged { opposite } => { + let other_index = *by_range.get(opposite).ok_or_else(|| Problem { + code: "fold_pairing".to_owned(), + message: format!( + "fold at line {} has no counterpart at {:?}", + fold.range.start.line.as_usize() + 1, + opposite + ), + })?; + Some(match side { + Side::Left => index, + Side::Right => other_index, + }) + } + }; + Ok(SideFold { + fold, + lines: line_span(&fold.range, lines.len()), + pair, + }) + }) + .collect() +} + +fn split_lines(folds: &[SideFold<'_>]) -> BTreeSet { + folds + .iter() + .flat_map(|fold| [fold.lines.0, fold.lines.1]) + .collect() +} + +/// Split every run at its side's fold boundaries, mirroring splits across +/// paired runs so both sides keep equal-length pieces. +fn split_runs( + runs: &[Run], + lhs_splits: &BTreeSet, + rhs_splits: &BTreeSet, +) -> (Vec, Vec) { + let mut lhs_leaves = Vec::new(); + let mut rhs_leaves = Vec::new(); + for (index, run) in runs.iter().enumerate() { + let len = run.len(); + let mut offsets: BTreeSet = BTreeSet::new(); + if let Some((start, end)) = run.lhs { + offsets.extend(lhs_splits.range(start + 1..end).map(|line| line - start)); + } + if let Some((start, end)) = run.rhs { + offsets.extend(rhs_splits.range(start + 1..end).map(|line| line - start)); + } + let paired = run.lhs.is_some() && run.rhs.is_some(); + let mut at = 0; + for (piece, cut) in offsets.into_iter().chain([len]).enumerate() { + let key = paired.then_some(LeafKey { run: index, piece }); + if let Some((start, _)) = run.lhs { + lhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + collapsed: run.collapsed, + }); + } + if let Some((start, _)) = run.rhs { + rhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + collapsed: run.collapsed, + }); + } + at = cut; + } + } + (lhs_leaves, rhs_leaves) +} + +#[derive(Default)] +struct Ids { + next: u32, + shared: DftHashMap, +} + +impl Ids { + fn get(&mut self, key: Option) -> u32 { + if let Some(key) = key { + if let Some(&id) = self.shared.get(&key) { + return id; + } + } + let id = self.next; + self.next += 1; + if let Some(key) = key { + self.shared.insert(key, id); + } + id + } +} + +enum Item<'a> { + Fold(&'a SideFold<'a>), + Leaf(&'a Leaf), +} + +impl Item<'_> { + fn lines(&self) -> (usize, usize) { + match self { + Self::Fold(fold) => fold.lines, + Self::Leaf(leaf) => leaf.lines, + } + } + + /// Outer before inner: earlier start, then later end, then folds before + /// leaves, then the wider byte range. + fn order( + &self, + ) -> ( + usize, + std::cmp::Reverse, + u8, + (usize, usize), + std::cmp::Reverse<(usize, usize)>, + ) { + let (start, end) = self.lines(); + match self { + Self::Fold(fold) => ( + start, + std::cmp::Reverse(end), + 0, + ( + fold.fold.range.start.line.as_usize(), + fold.fold.range.start.byte_column, + ), + std::cmp::Reverse(( + fold.fold.range.end.line.as_usize(), + fold.fold.range.end.byte_column, + )), + ), + Self::Leaf(_) => ( + start, + std::cmp::Reverse(end), + 1, + (0, 0), + std::cmp::Reverse((0, 0)), + ), + } + } +} + +/// Nest folds and leaves by containment on line spans and assign ids in +/// document order. +fn tree( + folds: &[SideFold<'_>], + leaves: &[Leaf], + positions: &[MatchedPos], + novel: &BTreeSet, + lines: &[&str], + ids: &mut Ids, +) -> Vec { + let mut items: Vec> = folds + .iter() + .map(Item::Fold) + .chain(leaves.iter().map(Item::Leaf)) + .collect(); + items.sort_by_key(Item::order); + let by_line = positions_by_line(positions); + + // Build bottom-up with an explicit stack of open folds. + struct Open<'a> { + fold: &'a SideFold<'a>, + id: u32, + children: Vec, + } + let mut root: Vec = Vec::new(); + let mut stack: Vec> = Vec::new(); + let close = |stack: &mut Vec>, root: &mut Vec| { + let open = stack.pop().expect("closing an open fold"); + let fold = open.fold.fold; + let region = Region { + id: open.id, + range: source_range(&fold.range), + tags: fold.tags.clone(), + visibility: Visibility { + collapsed: false, + label: fold + .summary + .clone() + .unwrap_or_else(|| fold.placeholder.clone()), + }, + node: Node::Fold { + children: open.children, + }, + }; + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + }; + for item in items { + let (start, _) = item.lines(); + while stack.last().is_some_and(|open| open.fold.lines.1 <= start) { + close(&mut stack, &mut root); + } + match item { + Item::Fold(fold) => { + let id = ids.get(fold.pair.map(PairKey::Fold)); + stack.push(Open { + fold, + id, + children: Vec::new(), + }); + } + Item::Leaf(leaf) => { + if leaf.lines.0 == leaf.lines.1 { + continue; + } + let region = leaf_region(leaf, ids, &by_line, novel, lines); + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + } + } + } + while !stack.is_empty() { + close(&mut stack, &mut root); + } + root +} + +fn leaf_region( + leaf: &Leaf, + ids: &mut Ids, + by_line: &BTreeMap>, + novel: &BTreeSet, + lines: &[&str], +) -> Region { + let (start, end) = leaf.lines; + let mut changed = Vec::new(); + for line in novel.range(start..end) { + let tokens = by_line.get(line).map(Vec::as_slice).unwrap_or(&[]); + let all_novel = tokens.iter().all(|token| { + matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) + }); + if all_novel { + // A blank novel line has nothing to paint. + if !lines[*line].is_empty() { + changed.push(Span { + line: *line as u32, + start_column: 0, + end_column: lines[*line].len() as u32, + }); + } + continue; + } + for token in tokens { + if !matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) { + continue; + } + let span = Span { + line: *line as u32, + start_column: token.pos.start_col, + end_column: token.pos.end_col, + }; + match changed.last_mut() { + Some(last) if last.line == span.line && last.end_column == span.start_column => { + last.end_column = span.end_column; + } + _ => changed.push(span), + } + } + } + let (tags, visibility) = if leaf.collapsed { + ( + vec!["unchanged".to_owned()], + Visibility { + collapsed: true, + label: match end - start { + 1 => "1 unchanged line".to_owned(), + count => format!("{count} unchanged lines"), + }, + }, + ) + } else { + (Vec::new(), Visibility::default()) + }; + Region { + id: ids.get(leaf.key.map(PairKey::Leaf)), + range: SourceRange { + start: SourcePos { + line: start as u32, + column: 0, + }, + end: SourcePos { + line: end as u32, + column: 0, + }, + }, + tags, + visibility, + node: Node::Leaf { changed }, + } +} + +fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap> { + let mut by_line: BTreeMap> = BTreeMap::new(); + for position in positions { + by_line + .entry(position.pos.line.as_usize()) + .or_default() + .push(position); + } + for tokens in by_line.values_mut() { + tokens.sort_by_key(|token| token.pos.start_col); + } + by_line +} + +fn source_range(range: &lines::SourceRange) -> SourceRange { + SourceRange { + start: SourcePos { + line: range.start.line.as_usize() as u32, + column: range.start.byte_column as u32, + }, + end: SourcePos { + line: range.end.line.as_usize() as u32, + column: range.end.byte_column as u32, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Params; + use crate::options::{DiffOptions, DisplayOptions}; + + fn refs(lhs: bool, rhs: bool) -> Pairing { + let file_ref = FileRef { + path: "a.py".to_owned(), + oid: String::new(), + mode: String::new(), + }; + match (lhs, rhs) { + (true, true) => Pairing::Both { + lhs: file_ref.clone(), + rhs: file_ref, + }, + (true, false) => Pairing::LeftOnly { lhs: file_ref }, + (false, true) => Pairing::RightOnly { rhs: file_ref }, + (false, false) => panic!("a file has a side"), + } + } + + fn project(path: &str, lhs: &str, rhs: &str, context: usize) -> Diff { + let result = DiffResult::from_sources_with_options( + path, + lhs, + rhs, + &Params::default(), + &DisplayOptions { + num_context_lines: context as u32, + ..DisplayOptions::default() + }, + &DiffOptions::default(), + ); + diff( + &result, + Inputs { + file: &refs(!lhs.is_empty(), !rhs.is_empty()), + sizes: (lhs.len() as u64, rhs.len() as u64), + context_lines: context, + syntax: (Vec::new(), Vec::new()), + }, + ) + .unwrap() + } + + fn sources(diff: &Diff) -> (Option<&Source>, Option<&Source>) { + match diff { + Diff::Text { sides, .. } => (sides.lhs(), sides.rhs()), + Diff::Binary { .. } => panic!("text diff"), + } + } + + fn leaves(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + match ®ion.node { + Node::Leaf { .. } => out.push(region), + Node::Fold { children } => out.extend(leaves(children)), + } + } + out + } + + fn all(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + out.push(region); + if let Node::Fold { children } = ®ion.node { + out.extend(all(children)); + } + } + out + } + + fn line_count(text: &str) -> u32 { + text.split_terminator('\n').count() as u32 + } + + fn assert_tiles(source: &Source) { + let mut at = 0; + for leaf in leaves(&source.regions) { + assert_eq!(leaf.range.start.line, at, "gap before {leaf:?}"); + assert_eq!(leaf.range.start.column, 0); + assert_eq!(leaf.range.end.column, 0); + assert!(leaf.range.end.line > at, "empty leaf {leaf:?}"); + at = leaf.range.end.line; + } + assert_eq!(at, line_count(&source.text)); + } + + fn assert_folds_hold_children(regions: &[Region]) { + for region in regions { + if let Node::Fold { children } = ®ion.node { + assert!(!children.is_empty(), "fold without children {region:?}"); + let (start, end) = region.range.lines_spanned(); + let mut at = start; + for child in children { + let (child_start, child_end) = child.range.lines_spanned(); + assert_eq!(child_start, at, "hole inside {region:?}"); + at = child_end; + } + assert_eq!(at, end, "fold {region:?} not tiled by its children"); + assert_folds_hold_children(children); + } + } + } + + impl SourceRange { + fn lines_spanned(&self) -> (u32, u32) { + let range = self.lines(); + (range.start, range.end) + } + } + + #[test] + fn leaves_tile_both_sides_and_paired_leaves_share_ids() { + let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; + let rhs = + "import os\n\ndef f():\n x = 2\n return x\n\ndef g():\n y = 3\n return y\n"; + let diff = project("a.py", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + let lhs_ids: BTreeSet = all(&lhs.regions).iter().map(|r| r.id).collect(); + let rhs_ids: BTreeSet = all(&rhs.regions).iter().map(|r| r.id).collect(); + // Ids are dense and assigned lhs first. + let max = lhs_ids.iter().chain(&rhs_ids).max().copied().unwrap(); + assert_eq!( + lhs_ids.union(&rhs_ids).copied().collect::>(), + (0..=max).collect::>() + ); + for (lhs_leaf, rhs_leaf) in leaves(&lhs.regions).iter().zip(leaves(&rhs.regions)) { + if lhs_leaf.id == rhs_leaf.id { + assert_eq!( + lhs_leaf.range.lines_spanned().1 - lhs_leaf.range.lines_spanned().0, + rhs_leaf.range.lines_spanned().1 - rhs_leaf.range.lines_spanned().0, + "paired leaves have equal length" + ); + } + } + // The new function exists on the rhs only. + let rhs_only: Vec<_> = rhs_ids.difference(&lhs_ids).collect(); + assert!(!rhs_only.is_empty()); + // Python's body fold is the block, which starts on the line after `def`. + let new_fold = all(&rhs.regions) + .into_iter() + .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 7) + .expect("the added function is a fold"); + assert!(rhs_only.contains(&&new_fold.id)); + assert_eq!(new_fold.tags, vec!["body"]); + assert_eq!(new_fold.visibility.label, "Body"); + } + + #[test] + fn the_changed_body_is_a_paired_fold_with_a_novel_leaf_inside() { + let lhs = "def f():\n a = 1\n b = 2\n return a\n"; + let rhs = "def f():\n a = 1\n b = 3\n return a\n"; + let diff = project("a.py", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let fold = |source: &Source| { + let folds: Vec<_> = all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .collect(); + assert_eq!(folds.len(), 1); + folds[0].clone() + }; + let lhs_fold = fold(lhs); + let rhs_fold = fold(rhs); + assert_eq!(lhs_fold.id, rhs_fold.id); + assert_eq!(lhs_fold.range.lines_spanned(), (1, 4)); + let novel: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| matches!(&leaf.node, Node::Leaf { changed } if !changed.is_empty())) + .collect(); + assert_eq!(novel.len(), 1); + assert_eq!(novel[0].range.lines_spanned(), (2, 3)); + let Node::Leaf { changed } = &novel[0].node else { + unreachable!() + }; + // Only the changed token is painted, not the whole line. + assert_eq!( + changed, + &[Span { + line: 2, + start_column: 8, + end_column: 9 + }] + ); + } + + #[test] + fn a_fully_new_line_is_painted_whole_and_blank_lines_not_at_all() { + let diff = project("a.py", "x = 1\n", "x = 1\n\ny = 2\n", 3); + let (_, rhs) = sources(&diff); + let changed: Vec = leaves(&rhs.unwrap().regions) + .into_iter() + .filter_map(|leaf| match &leaf.node { + Node::Leaf { changed } => Some(changed.clone()), + Node::Fold { .. } => None, + }) + .flatten() + .collect(); + assert_eq!( + changed, + vec![Span { + line: 2, + start_column: 0, + end_column: 5 + }] + ); + } + + #[test] + fn long_unchanged_runs_collapse_to_the_context_width() { + let body = (0..20) + .map(|i| format!("x{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("{body}changed = 1\n{body}"); + let rhs = format!("{body}changed = 2\n{body}"); + let diff = project("a.py", &lhs, &rhs, 3); + let (lhs, _) = sources(&diff); + let leaves = leaves(&lhs.unwrap().regions); + let spans: Vec<_> = leaves + .iter() + .map(|leaf| { + ( + leaf.range.lines_spanned(), + leaf.visibility.collapsed, + leaf.tags.clone(), + ) + }) + .collect(); + assert_eq!( + spans, + vec![ + ((0, 17), true, vec!["unchanged".to_owned()]), + ((17, 20), false, vec![]), + ((20, 21), false, vec![]), + ((21, 24), false, vec![]), + ((24, 41), true, vec!["unchanged".to_owned()]), + ] + ); + assert_eq!(leaves[0].visibility.label, "17 unchanged lines"); + } + + #[test] + fn a_fold_edge_splits_paired_leaves_on_both_sides() { + // The unchanged function inside the gap forces a split on both sides + // at the same offset, even though only the rhs shifted. + let lhs = "a = 1\n\ndef f():\n return 1\n\nz = 1\n"; + let rhs = "a = 2\n\ndef f():\n return 1\n\nz = 1\n"; + let diff = project("a.py", lhs, rhs, 1); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + let lhs_leaves: Vec<_> = leaves(&lhs.regions) + .iter() + .map(|l| (l.id, l.range.lines_spanned())) + .collect(); + let rhs_leaves: Vec<_> = leaves(&rhs.regions) + .iter() + .map(|l| (l.id, l.range.lines_spanned())) + .collect(); + assert_eq!(lhs_leaves, rhs_leaves); + } + + #[test] + fn stats_count_text_and_syntax_lines_separately() { + let diff = project("a.py", "x = 1\n", "x = 1 # same\n", 3); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.textual, + LineCounts { + added: 1, + removed: 1 + } + ); + let structural = stats.structural.as_ref().unwrap(); + assert_eq!(structural.added, 1); + let diff = project("a.unknownext", "x\n", "y\n", 3); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.structural.as_ref().unwrap_err().code, + "unsupported_language" + ); + } + + #[test] + fn one_sided_files_have_one_source_and_no_shared_ids() { + let diff = project("a.py", "", "def f():\n return 1\n", 3); + let (lhs, rhs) = sources(&diff); + assert!(lhs.is_none()); + let rhs = rhs.unwrap(); + assert_tiles(rhs); + assert!(leaves(&rhs.regions) + .iter() + .all(|leaf| !leaf.visibility.collapsed)); + } + + #[test] + fn identical_files_are_one_collapsed_leaf() { + let diff = project("a.py", "x = 1\ny = 2\n", "x = 1\ny = 2\n", 3); + let (lhs, rhs) = sources(&diff); + let lhs = lhs.unwrap(); + assert_eq!(leaves(&lhs.regions).len(), 1); + assert!(leaves(&lhs.regions)[0].visibility.collapsed); + assert_eq!(lhs.regions[0].id, rhs.unwrap().regions[0].id); + } + + #[test] + fn binary_sides_carry_sizes() { + let result = DiffResult { + display_path: "a.bin".to_owned(), + extra_info: None, + file_format: FileFormat::Binary, + lhs_src: FileContent::Binary, + rhs_src: FileContent::Binary, + hunks: vec![], + lhs_folds: vec![], + rhs_folds: vec![], + lhs_positions: vec![], + rhs_positions: vec![], + has_byte_changes: Some((3, 5)), + has_syntactic_changes: false, + }; + let diff = diff( + &result, + Inputs { + file: &refs(true, true), + sizes: (3, 5), + context_lines: 3, + syntax: (Vec::new(), Vec::new()), + }, + ) + .unwrap(); + match diff { + Diff::Binary { sides } => { + assert_eq!(sides.lhs().unwrap().size, 3); + assert_eq!(sides.rhs().unwrap().size, 5); + } + Diff::Text { .. } => panic!("binary"), + } + } + + #[test] + fn syntax_spans_are_per_line_sorted_and_innermost() { + let parser = crate::parse::tree_sitter_parser::from_language( + crate::parse::guess_language::Language::Python, + ); + let spans = syntax_spans("def f(x):\n return \"a\"\n", parser); + for pair in spans.windows(2) { + assert!( + pair[0].line < pair[1].line + || (pair[0].line == pair[1].line && pair[0].end_column <= pair[1].start_column), + "{pair:?}" + ); + } + assert!(spans + .iter() + .any(|span| span.capture == "keyword" && span.line == 0)); + assert!(spans + .iter() + .any(|span| span.capture.starts_with("string") && span.line == 1)); + } +} diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs new file mode 100644 index 000000000..ab4337bbc --- /dev/null +++ b/src/protocol/stream.rs @@ -0,0 +1,297 @@ +//! The v2 stdout stream: manifest, one record per file as it finishes, footer. +use super::project::{self, Inputs}; +use super::{Event, FileChange, Outcome, Pairing, Problem, Snapshot, SyntaxSpan, VERSION}; +use crate::git::{DiffSession, FileProblem, LoadedFile}; +use crate::hook::Hook; +use crate::summary::{DiffResult, FileContent, FileFormat}; +use rayon::iter::{ParallelBridge, ParallelIterator}; +use std::io::{BufWriter, Write}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::{Arc, Mutex}; +use std::thread; + +/// Runtime choices that shape every file record. +#[derive(Clone, Copy)] +pub(crate) struct Options { + /// Emit every token's capture name. + pub(crate) syntax: bool, +} + +/// What the stream ended with: whether any file failed, and whether the run +/// was cut short by a run-level failure. +pub(crate) struct Ended { + pub(crate) failed: bool, + pub(crate) aborted: bool, +} + +/// Files are diffed on `jobs` workers and emitted as they finish. The queue +/// holds at most one ready record, so computation overlaps output without +/// retaining the whole comparison. +pub(crate) fn write( + session: DiffSession, + jobs: usize, + hook: Option>, + options: Options, + output: &mut impl Write, +) -> crate::git::Result { + let (sender, receiver) = sync_channel(1); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(jobs) + .thread_name(|index| format!("diffr-worker-{index}")) + .build()?; + let worker = thread::spawn(move || { + // A disconnected consumer cancels production after the files in flight. + let _ = produce(session, &pool, hook.as_deref(), options, sender); + }); + let mut output = BufWriter::new(output); + let result: crate::git::Result = (|| { + let mut ended = Ended { + failed: false, + aborted: false, + }; + for event in &receiver { + if let Event::Complete { + failed, aborted, .. + } = &event + { + ended.failed = *failed > 0; + ended.aborted = aborted.is_some(); + } + serde_json::to_writer(&mut output, &event)?; + output.write_all(b"\n")?; + output.flush()?; + } + Ok(ended) + })(); + // Wake a producer blocked on a full queue if writing failed. + drop(receiver); + let joined = worker.join(); + let ended = result?; + joined.map_err(|_| "diff computation thread panicked")?; + Ok(ended) +} + +/// The consumer went away; production stops after the files in flight. +struct Disconnected; + +fn produce( + session: DiffSession, + pool: &rayon::ThreadPool, + hook: Option<&Hook>, + options: Options, + sender: SyncSender, +) -> Result<(), Disconnected> { + let send = |event: Event| sender.send(event).map_err(|_| Disconnected); + send(Event::Start { + version: VERSION, + lhs: Snapshot::from(&session.comparison.before), + rhs: Snapshot::from(&session.comparison.after), + files: session + .file_manifest() + .iter() + .map(|file| file.manifest_entry()) + .collect(), + })?; + let succeeded = AtomicU32::new(0); + let failed = AtomicU32::new(0); + let cancelled = Arc::new(AtomicBool::new(false)); + let aborted: Mutex> = Mutex::new(None); + let loader = Loader { + session, + cancelled: Arc::clone(&cancelled), + }; + pool.install(|| { + loader.par_bridge().for_each(|(file, loaded)| { + let outcome = match loaded { + Ok(loaded) => match file_outcome(&loaded, hook, options) { + Ok(outcome) => outcome, + Err(problem) => { + // A run-level failure: stop pulling files, let the ones in + // flight finish, and report why in the footer. + failed.fetch_add(1, Ordering::Relaxed); + cancelled.store(true, Ordering::Relaxed); + aborted.lock().expect("abort reason").get_or_insert(problem); + return; + } + }, + Err(problem) => Outcome::Error { + error: problem_from(problem), + }, + }; + match &outcome { + Outcome::Diff { .. } => succeeded.fetch_add(1, Ordering::Relaxed), + Outcome::Error { .. } => failed.fetch_add(1, Ordering::Relaxed), + }; + let event = Event::File { + file: file.sides, + outcome, + }; + if sender.send(event).is_err() { + cancelled.store(true, Ordering::Relaxed); + } + }); + }); + send(Event::Complete { + succeeded: succeeded.into_inner(), + failed: failed.into_inner(), + aborted: aborted.into_inner().expect("abort reason"), + }) +} + +fn problem_from(problem: FileProblem) -> Problem { + Problem { + code: problem.code.to_owned(), + message: problem.message, + } +} + +/// `Err` here is a run-level failure, not a file-level one. +fn file_outcome( + loaded: &LoadedFile, + hook: Option<&Hook>, + options: Options, +) -> Result { + let mut diff = loaded.diff(); + if let Some(hook) = hook { + hook.summarize(&mut diff).map_err(|message| Problem { + code: "hook_failed".to_owned(), + message, + })?; + } + let syntax = if options.syntax { + syntax_spans(&diff, &loaded.params) + } else { + (Vec::new(), Vec::new()) + }; + let inputs = Inputs { + file: &loaded.file.sides, + sizes: loaded.sizes(), + context_lines: loaded.context_lines as usize, + syntax, + }; + Ok(project::diff(&diff, inputs).into()) +} + +pub(crate) fn syntax_spans( + diff: &DiffResult, + params: &crate::config::Params, +) -> (Vec, Vec) { + let FileFormat::SupportedLanguage(language) = diff.file_format else { + return (Vec::new(), Vec::new()); + }; + let parser = params.language(language).parser; + let spans = |content: &FileContent| match content { + FileContent::Text(src) => project::syntax_spans(src, parser), + FileContent::Binary => Vec::new(), + }; + (spans(&diff.lhs_src), spans(&diff.rhs_src)) +} + +/// Reads sources serially on whichever worker pulls next; diffing then +/// proceeds on that worker while others pull further files. +struct Loader { + session: DiffSession, + cancelled: Arc, +} + +impl Iterator for Loader { + type Item = (crate::git::FileChange, Result); + fn next(&mut self) -> Option { + if self.cancelled.load(Ordering::Relaxed) { + return None; + } + self.session.load() + } +} + +/// A standalone two-path comparison through the same three records. +pub(crate) fn write_file( + before: &str, + after: &str, + sizes: (u64, u64), + compute: impl FnOnce() -> DiffResult, + params: &crate::config::Params, + context_lines: usize, + hook: Option<&Hook>, + options: Options, + output: &mut impl Write, +) -> crate::git::Result { + let file = crate::git::FileChange::standalone(before, after); + let mut output = BufWriter::new(output); + let manifest: FileChange = file.manifest_entry(); + serde_json::to_writer( + &mut output, + &Event::Start { + version: VERSION, + lhs: Snapshot::Path { + path: before.to_owned(), + }, + rhs: Snapshot::Path { + path: after.to_owned(), + }, + files: vec![manifest], + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + let mut diff = compute(); + let mut aborted = None; + let outcome = match hook.map(|hook| hook.summarize(&mut diff)) { + Some(Err(message)) => { + aborted = Some(Problem { + code: "hook_failed".to_owned(), + message, + }); + None + } + _ => { + let syntax = if options.syntax { + syntax_spans(&diff, params) + } else { + (Vec::new(), Vec::new()) + }; + let sides: Pairing<_> = file.sides.clone(); + Some(Outcome::from(project::diff( + &diff, + Inputs { + file: &sides, + sizes, + context_lines, + syntax, + }, + ))) + } + }; + let mut ended = Ended { + failed: false, + aborted: aborted.is_some(), + }; + let (succeeded, failed) = match outcome { + Some(outcome) => { + ended.failed = matches!(outcome, Outcome::Error { .. }); + let counts = if ended.failed { (0, 1) } else { (1, 0) }; + serde_json::to_writer( + &mut output, + &Event::File { + file: file.sides, + outcome, + }, + )?; + output.write_all(b"\n")?; + counts + } + None => (0, 1), + }; + serde_json::to_writer( + &mut output, + &Event::Complete { + succeeded, + failed, + aborted, + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + Ok(ended) +} diff --git a/src/stream.rs b/src/stream.rs index 4dd6d502e..822801a93 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,5 +1,5 @@ //! Incremental stdout protocol over the shared file iterator. -use crate::git::{DiffSession, FileChange, LoadedFile, Operand, Result}; +use crate::git::{DiffSession, FileChange, LoadedFile, Operand}; use crate::hook::Hook; use crate::summary::DiffResult; use rayon::iter::{ParallelBridge, ParallelIterator}; @@ -47,7 +47,7 @@ pub(crate) fn write( jobs: usize, hook: Option>, output: &mut impl Write, -) -> Result { +) -> crate::git::Result { let (sender, receiver) = sync_channel(1); let pool = rayon::ThreadPoolBuilder::new() .num_threads(jobs) @@ -58,7 +58,7 @@ pub(crate) fn write( let _ = produce(session, &pool, hook.as_deref(), sender); }); let mut output = BufWriter::new(output); - let result: Result = (|| { + let result: crate::git::Result = (|| { let mut failed = false; for event in &receiver { if let Event::Complete { failed: count, .. } = &event { @@ -142,7 +142,7 @@ struct Loader { } impl Iterator for Loader { - type Item = (FileChange, crate::git::Result); + type Item = (FileChange, Result); fn next(&mut self) -> Option { if self.cancelled.load(Ordering::Relaxed) { return None; @@ -159,19 +159,8 @@ pub(crate) fn write_file( compute: impl FnOnce() -> DiffResult, hook: Option<&Hook>, output: &mut impl Write, -) -> Result<()> { - let file = FileChange { - old_path: (before != "/dev/null").then(|| before.into()), - new_path: (after != "/dev/null").then(|| after.into()), - status: if before == "/dev/null" { - crate::git::FileStatus::Added - } else if after == "/dev/null" { - crate::git::FileStatus::Deleted - } else { - crate::git::FileStatus::Modified - }, - class: None, - }; +) -> crate::git::Result<()> { + let file = FileChange::standalone(before, after); let mut output = BufWriter::new(output); serde_json::to_writer( &mut output, diff --git a/tests/streaming/check.py b/tests/streaming/check.py index db57aea86..a60395c15 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -45,6 +45,11 @@ def cli(repo, *args): ) +def path_of(record): + file = record["file"] + return (file.get("rhs") or file["lhs"])["path"] + + def stream(repo, *args, code=0): with subprocess.Popen( [str(EXE), "--repo", str(repo), "--format", "ndjson", *args], @@ -53,28 +58,65 @@ def stream(repo, *args, code=0): env=ENV, ) as process: first = json.loads(process.stdout.readline()) - assert first["type"] == "start" and first["version"] == 1 - assert len(first["files"]) == first["total"] + assert first["type"] == "start" and first["version"] == 2, first events = [first, *[json.loads(line) for line in process.stdout]] stderr = process.stderr.read() assert process.wait(timeout=30) == code, stderr assert events[-1]["type"] == "complete" - succeeded = sum(e["type"] == "file" for e in events) - failed = sum(e["type"] == "file_error" for e in events) - assert events[-1] == {"type": "complete", "succeeded": succeeded, "failed": failed} - assert succeeded + failed == first["total"] - assert all("layout" not in e for e in events) + files = events[1:-1] + assert all(e["type"] == "file" for e in files) + succeeded = sum("diff" in e for e in files) + failed = sum("error" in e for e in files) + assert all(("diff" in e) != ("error" in e) for e in files) + complete = events[-1] + assert complete["succeeded"] == succeeded, complete + if "aborted" in complete: + assert complete["failed"] >= failed and code == 2 + else: + assert complete["failed"] == failed + assert succeeded + failed == len(first["files"]) + manifest = {json.dumps(f["file"], sort_keys=True) for f in first["files"]} + for record in files: + assert json.dumps(record["file"], sort_keys=True) in manifest + for line in json.dumps(events).split('"'): + assert line != "null" return events +def leaves(regions): + for region in regions: + if region["kind"] == "leaf": + yield region + else: + yield from leaves(region["children"]) + + +def all_regions(regions): + for region in regions: + yield region + if region["kind"] == "fold": + yield from all_regions(region["children"]) + + +def check_tiling(source): + at = 0 + for leaf in leaves(source["regions"]): + assert leaf["start"] == {"line": at, "column": 0}, (leaf, at) + assert leaf["end"]["column"] == 0 and leaf["end"]["line"] > at + at = leaf["end"]["line"] + text = source["text"] + lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) + assert at == lines, (at, lines) + + with tempfile.TemporaryDirectory(prefix="diffr-stream-") as temp: repo = Path(temp) git(repo, "init", "-q") - (repo / "a.rs").write_text("fn run() { old(); }\n") + (repo / "a.rs").write_text("fn run() {\n old();\n}\n") (repo / "remove.py").write_text("print('remove')\n") (repo / "rename.py").write_text("print('same content')\n") base = commit(repo, "base") - (repo / "a.rs").write_text("fn run() { old(); new(); }\n") + (repo / "a.rs").write_text("fn run() {\n old();\n new();\n}\n") (repo / "remove.py").unlink() (repo / "rename.py").rename(repo / "renamed.py") (repo / "binary.bin").write_bytes(b"a\0b") @@ -85,30 +127,59 @@ def stream(repo, *args, code=0): ) (repo / "diffr.toml").write_text('[languages.rust]\nfolds = ""\n') events = stream(repo, base, head, "--order", "test,source,generated", code=2) - assert events[0]["before"] == {"kind": "revision", "ref": base} - assert events[0]["after"] == {"kind": "revision", "ref": head} + assert events[0]["lhs"] == {"type": "revision", "rev": base} + assert events[0]["rhs"] == {"type": "revision", "rev": head} + manifest = events[0]["files"] # Results arrive in completion order; --order governs computation priority only. - assert sorted(e["file"]["class"] for e in events[1:-1]) == [ + assert sorted(f["category"] for f in manifest) == [ "generated", "source", "test", "test", "test", ] - renamed = next(e["file"] for e in events[1:-1] if e["file"]["status"] == "renamed") - assert renamed["old_path"] == "rename.py" and renamed["new_path"] == "renamed.py" - rust = next(e for e in events[1:-1] if e["file"]["new_path"] == "a.rs") - assert rust["diff"]["rhs_folds"] == [] + renamed = next(f for f in manifest if f["status"] == "renamed") + assert renamed["file"]["lhs"]["path"] == "rename.py" + assert renamed["file"]["rhs"]["path"] == "renamed.py" + assert renamed["file"]["lhs"]["mode"] == "100644" + assert len(renamed["file"]["rhs"]["oid"]) == 40 + assert next(f for f in manifest if path_of(f) == "a.rs")["language"] == "Rust" + assert next(f for f in manifest if path_of(f) == "remove.py")["status"] == "deleted" + assert "rhs" not in next(f for f in manifest if path_of(f) == "remove.py")["file"] + records = {path_of(e): e for e in events[1:-1]} + binary = records["binary.bin"]["error"] + assert binary["code"] == "binary" and binary["message"] + rust = records["a.rs"]["diff"] + assert rust["type"] == "text" + assert not any(r["kind"] == "fold" for r in all_regions(rust["rhs"]["regions"])) + assert "syntax" not in rust["rhs"] + assert rust["stats"]["textual"] == {"added": 1, "removed": 0} + assert rust["stats"]["structural"] == {"added": 1, "removed": 0} + check_tiling(rust["lhs"]) + check_tiling(rust["rhs"]) + changed = [leaf for leaf in leaves(rust["rhs"]["regions"]) if "changed" in leaf] + assert changed[0]["changed"] == [{"line": 2, "start_column": 0, "end_column": 10}] + removed = records["remove.py"]["diff"] + assert "rhs" not in removed and check_tiling(removed["lhs"]) is None + assert removed["stats"]["textual"] == {"added": 0, "removed": 1} + added = records["z.py"]["diff"] + assert "lhs" not in added and list(leaves(added["rhs"]["regions"]))[0]["changed"] + same = records["renamed.py"]["diff"] + only = list(leaves(same["lhs"]["regions"])) + assert only[0]["visibility"] == {"collapsed": True, "label": "1 unchanged line"} + assert only[0]["id"] == list(leaves(same["rhs"]["regions"]))[0]["id"] + # Syntax spans are opt-in and carry tree-sitter capture names. + events = stream(repo, base, head, "--syntax", "--", "a.rs") + syntax = events[1]["diff"]["rhs"]["syntax"] + assert {span["capture"] for span in syntax} >= {"keyword", "function"}, syntax + assert all(span["start_column"] < span["end_column"] for span in syntax) # An early file failure must not prevent the later successes. events = stream(repo, base, head, "--order", "generated", code=2) - assert ( - sum(e["type"] == "file_error" for e in events) == 1 - and events[-1]["succeeded"] == 4 - ) + assert sum("error" in e for e in events[1:-1]) == 1 and events[-1]["succeeded"] == 4 events = stream(repo, base, head, "--", "a.rs", "z.py") - assert sorted(e["file"]["new_path"] for e in events[1:-1]) == ["a.rs", "z.py"] + assert sorted(path_of(e) for e in events[1:-1]) == ["a.rs", "z.py"] events = stream(repo, base, head, "--jobs", "1", "--", "a.rs", "z.py") - assert [e["file"]["new_path"] for e in events[1:-1]] == ["a.rs", "z.py"] + assert [path_of(e) for e in events[1:-1]] == ["a.rs", "z.py"] assert len(stream(repo, head, head)) == 2 assert len(stream(repo, base, head, "--", "missing.rs")) == 2 stream(repo, base, head, "--exit-code", "--", "a.rs", code=1) @@ -125,7 +196,14 @@ def stream(repo, *args, code=0): custom = repo / "custom.toml" custom.write_text("") events = stream(repo, base, head, "--config", str(custom), "--", "a.rs") - assert events[1]["diff"]["rhs_folds"] + assert any( + r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) + ) + # The previous stream stays available while frontends migrate. + v1 = cli( + repo, "--format", "ndjson-v1", "--config", str(custom), base, head, "--", "a.rs" + ) + assert json.loads(v1.stdout.splitlines()[0])["version"] == 1 with tempfile.TemporaryDirectory(prefix="diffr-operands-") as temp: repo = Path(temp) @@ -153,13 +231,13 @@ def stream(repo, *args, code=0): ) assert actual.returncode == 0 and actual.stdout == expected diff = stream(repo, *args)[1]["diff"] - assert diff["lhs_src"]["Text"] == (right if reverse else left) - assert diff["rhs_src"]["Text"] == (left if reverse else right) + assert diff["lhs"]["text"] == (right if reverse else left) + assert diff["rhs"]["text"] == (left if reverse else right) assert cli(repo, "--quiet").returncode == 1 assert cli(repo, base, "HEAD", "--exit-code").returncode == 0 git(repo, "rm", "-f", "a.rs") source.write_text(working) - assert stream(repo, base)[1]["file"]["status"] == "deleted" + assert stream(repo, base)[0]["files"][0]["status"] == "deleted" assert cli(repo, base, "--name-status").stdout == b"D\ta.rs\n" with tempfile.TemporaryDirectory(prefix="diffr-unborn-") as temp: @@ -168,10 +246,29 @@ def stream(repo, *args, code=0): (repo / "new.rs").write_text("fn new() {}\n") git(repo, "add", ".") events = stream(repo, "--cached") - assert events[0]["before"] == {"kind": "empty_tree"} - assert events[1]["file"]["status"] == "added" -# A configured fold hook fills summaries before each file event; its failures -# are reported per file without losing the diff. + assert events[0]["lhs"] == {"type": "empty_tree"} + assert events[0]["files"][0]["status"] == "added" + +# A standalone comparison streams the same three records. +with tempfile.TemporaryDirectory(prefix="diffr-no-index-") as temp: + before = Path(temp) / "before.py" + after = Path(temp) / "after.py" + before.write_text("def f():\n return 1\n") + after.write_text("def f():\n return 2\n") + result = subprocess.run( + [str(EXE), "--no-index", "--format", "ndjson", "--", str(before), str(after)], + capture_output=True, + env=ENV, + check=True, + ) + events = [json.loads(line) for line in result.stdout.splitlines()] + assert events[0]["lhs"] == {"type": "path", "path": str(before)} + assert events[1]["file"]["lhs"]["path"] == str(before) + assert events[1]["diff"]["stats"]["structural"] == {"added": 1, "removed": 1} + assert events[2] == {"type": "complete", "succeeded": 1, "failed": 0} + +# A configured fold hook fills fold labels before each file record; a hook +# failure ends the run. with tempfile.TemporaryDirectory(prefix="diffr-hook-") as temp: repo = Path(temp) git(repo, "init", "-q") @@ -188,18 +285,21 @@ def hook_config(mode, *extra): command = [sys.executable, str(rpc_server), mode, *extra] return f"[folds.hook]\ncommand = {json.dumps(command)}\ntags = ['body']\nmin_lines = 3\n" + def fold_labels(record): + return [ + r["visibility"]["label"] + for r in all_regions(record["diff"]["rhs"]["regions"]) + if r["kind"] == "fold" and r["tags"] == ["body"] + ] + (repo / "diffr.toml").write_text(hook_config("echo")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - good = events["good.py"] - assert "hook_error" not in good - assert [ - f["summary"] for f in good["diff"]["rhs_folds"] if f["tags"] == ["body"] - ] == ["pseudo Body", None] - assert all(f["summary"] is None for f in events["small.py"]["diff"]["rhs_folds"]) + events = {path_of(e): e for e in stream(repo, base, head)[1:-1]} + assert fold_labels(events["good.py"]) == ["pseudo Body"] + assert fold_labels(events["small.py"]) == [] (repo / "diffr.toml").write_text(hook_config("error")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - assert events["bad.py"]["hook_error"] == "fold hook reported: declined" - assert all(f["summary"] is None for f in events["good.py"]["diff"]["rhs_folds"]) + events = stream(repo, base, head, "--jobs", "1", code=2) + assert events[-1]["aborted"]["code"] == "hook_failed" + assert "declined" in events[-1]["aborted"]["message"] (repo / "diffr.toml").write_text("[folds.hook]\ncommand = ['./missing-hook']\n") assert cli(repo, "--format", "ndjson", base, head).returncode == 2 (repo / "diffr.toml").write_text(hook_config("exit")) @@ -219,9 +319,7 @@ def hook_config(mode, *extra): "--", "good.py", ) - assert events[1]["diff"]["rhs_folds"][0]["summary"] == str( - Path(elsewhere).resolve() - ) + assert fold_labels(events[1])[0] == str(Path(elsewhere).resolve()) # Closing the pipe while a multi-file producer is active must not leave it # blocked forever on a full queue. Unix CLI output retains normal SIGPIPE behavior. From 7ac3d0bb329a12a0ff94d4ec3e7a8f87d627f5bc Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:34:58 -0700 Subject: [PATCH 04/43] Satisfy ruff in the streaming checker Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tests/streaming/check.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/streaming/check.py b/tests/streaming/check.py index a60395c15..4a63e876c 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -163,11 +163,11 @@ def check_tiling(source): assert "rhs" not in removed and check_tiling(removed["lhs"]) is None assert removed["stats"]["textual"] == {"added": 0, "removed": 1} added = records["z.py"]["diff"] - assert "lhs" not in added and list(leaves(added["rhs"]["regions"]))[0]["changed"] + assert "lhs" not in added and next(leaves(added["rhs"]["regions"]))["changed"] same = records["renamed.py"]["diff"] only = list(leaves(same["lhs"]["regions"])) assert only[0]["visibility"] == {"collapsed": True, "label": "1 unchanged line"} - assert only[0]["id"] == list(leaves(same["rhs"]["regions"]))[0]["id"] + assert only[0]["id"] == next(leaves(same["rhs"]["regions"]))["id"] # Syntax spans are opt-in and carry tree-sitter capture names. events = stream(repo, base, head, "--syntax", "--", "a.rs") syntax = events[1]["diff"]["rhs"]["syntax"] From 4a2748ec6f0fd5cff62b5903ba511ed30aeb829c Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:39:51 -0700 Subject: [PATCH 05/43] Read the region-based wire in the terminal frontend The TUI validates diffr's v2 protocol, zips each side's leaves on their ids into split and unified rows, colours tokens from --syntax capture names, and treats every region the same way: folds and context gaps collapse from one id set seeded by diffr's visibility, with multi-line labels hanging under a collapsed header. Files hidden by default show a Load diff placeholder. A settings screen over diffr config schema/show/ set opens from diffr config. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 91 +++-- tui/packages/hunk/src/diffr/config.test.ts | 61 +++ tui/packages/hunk/src/diffr/config.ts | 146 +++++++ tui/packages/hunk/src/diffr/fileTree.test.ts | 7 +- tui/packages/hunk/src/diffr/fileTree.ts | 18 +- tui/packages/hunk/src/diffr/fixture.ts | 100 +++-- tui/packages/hunk/src/diffr/folds.test.ts | 88 ---- tui/packages/hunk/src/diffr/folds.ts | 112 ------ tui/packages/hunk/src/diffr/regions.test.ts | 117 ++++++ tui/packages/hunk/src/diffr/regions.ts | 124 ++++++ tui/packages/hunk/src/diffr/rows.test.ts | 183 ++++----- tui/packages/hunk/src/diffr/rows.ts | 379 +++++++++--------- tui/packages/hunk/src/diffr/selection.ts | 20 +- tui/packages/hunk/src/diffr/store.ts | 39 +- tui/packages/hunk/src/diffr/stream.test.ts | 94 +++-- tui/packages/hunk/src/diffr/stream.ts | 22 +- tui/packages/hunk/src/diffr/wire.ts | 211 +++++----- tui/packages/hunk/src/main.tsx | 29 +- tui/packages/hunk/src/ui/App.test.tsx | 81 ++-- tui/packages/hunk/src/ui/App.tsx | 133 +++--- tui/packages/hunk/src/ui/Settings.test.tsx | 57 +++ tui/packages/hunk/src/ui/Settings.tsx | 87 ++++ tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 17 +- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 5 +- tui/test/fixtures/comparison.ndjson | 4 +- tui/test/pty/launch-integration.test.ts | 16 +- tui/test/pty/launch.py | 5 +- 27 files changed, 1351 insertions(+), 895 deletions(-) create mode 100644 tui/packages/hunk/src/diffr/config.test.ts create mode 100644 tui/packages/hunk/src/diffr/config.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.test.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.ts create mode 100644 tui/packages/hunk/src/diffr/regions.test.ts create mode 100644 tui/packages/hunk/src/diffr/regions.ts create mode 100644 tui/packages/hunk/src/ui/Settings.test.tsx create mode 100644 tui/packages/hunk/src/ui/Settings.tsx diff --git a/tui/README.md b/tui/README.md index f102419d6..bca2abe83 100644 --- a/tui/README.md +++ b/tui/README.md @@ -29,6 +29,26 @@ Saved streams use the same reader: `--input -` reads a pipe and opens the controlling terminal for keyboard input. +The frontend launches Rust with `--format ndjson --syntax`: the wire is diffr's v2 +protocol (`src/protocol.rs`) and `--syntax` adds a tree-sitter capture name per token, +which is the only source of syntax colour here. + +## Settings screen + +`diffr config` opens a searchable settings screen in this frontend. The CLI contract it +relies on: + +```sh +bun run packages/hunk/src/main.tsx --settings --diffr /path/to/diffr [initial query] +diffr config schema # JSON Schema; each property carries `description` and `default` +diffr config show --json # resolved values, nested like the schema +diffr config set # dotted key, value as typed: true, 12, gemini +``` + +Type to filter (fuzzy over keys, substring over descriptions), `↑`/`↓` to move, Enter to +edit: booleans toggle, enums cycle, numbers and strings open an inline editor (Enter +saves, Esc cancels). `●` marks a value that differs from its default. Esc closes. + ## Controls - Wheel, arrows, j/k, Page Up/Down, Home/End: scroll. @@ -36,19 +56,26 @@ Saved streams use the same reader: - File header click or Enter: collapse/expand the file. - `\` / Cmd-B (when forwarded by the terminal): toggle the file tree. - Click folders to expand/collapse them; click files to navigate. The active file is highlighted and revealed as the diff scrolls. -- File headers stay pinned while scrolling and show unique novel-line counts (`+added −removed`) from Rust hunks. +- File headers stay pinned while scrolling and show textual counts (`+added -removed`) + from Rust, plus `syntax +a -r` when the structural counts differ. +- A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style + placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. - `[` / `]`: previous/next hunk. - Folds follow VS Code with controls always shown: a foldable row shows `▾` in the - gutter, and a collapsed fold shows `▸` plus a `⋯ Placeholder` after the header line. - Click either to toggle. Alt-click also folds or unfolds every nested region. Vim chords - act on the fold whose header is the top row: `za` toggle, `zo` open, `zc` close, with - `zA` / `zO` / `zC` recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or - unfold every visible fold; `zj` / `zk` scroll to the next or previous fold header. - The header and closing delimiter stay visible; paired unchanged folds - collapse on both sides. A fold on one side only blanks that side's cells, keeping the - other side's lines in Rust's alignment. -- `c`: toggle compact/all context. Compact uses Rust-selected nearby and enclosing syntax context, with an ellipsis for each omitted stretch. + gutter, and a collapsed fold shows `▸` plus `⋯ Label` after the header line. A label + with several lines (pseudocode from a summarizer) shows only `⋯` on the header and hangs + the label under it, indented one level, inside the fold tint. Click either to toggle. + Alt-click also folds or unfolds every nested region. Vim chords act on the fold whose + header is the top row: `za` toggle, `zo` open, `zc` close, with `zA` / `zO` / `zC` + recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or unfold every fold; + `zj` / `zk` scroll to the next or previous fold header. The header and closing + delimiter stay visible; regions that share an id collapse on both sides. A fold on one + side only blanks that side's cells, keeping the other side's lines in Rust's alignment. +- Context gaps are folds too: an unchanged run diffr trimmed to N lines around changes + arrives as a collapsed leaf tagged `unchanged`, and renders as one fold row with its + label (`142 unchanged lines`) that toggles like any other. `c` opens or closes every + such gap at once. Folds and gaps start in the state diffr's `visibility` asks for. - `s`: split/unified; initial mode is responsive to width. - `w`: wrap; Left/Right: horizontal scrolling when unwrapped. - `t`: dark/light theme. @@ -61,33 +88,38 @@ Saved streams use the same reader: ```text Rust CLI -- implicit interactive output --> Bun frontend -Bun frontend -- same comparison arguments + --format ndjson --> Rust subprocess +Bun frontend -- same comparison arguments + --format ndjson --syntax --> Rust subprocess Rust stdout --> validated events --> file store -file source + token spans + full-file alignment --> split/unified rows +per-side text + syntax spans + region trees --> leaves zipped by id --> split/unified rows rows + width + wrapping --> measured row bounds row bounds + viewport --> mounted OpenTUI rows -mouse/keyboard --> viewer state --> updated projection +mouse/keyboard --> viewer state (collapsed ids, closed files) --> updated projection ``` - `src/cli.rs` owns argument interpretation and selecting interactive versus explicit output. The frontend does not resolve revisions or invoke Git. -- `src/stream.rs` owns serialization. Repository comparisons retain their existing - wire format. Standalone comparisons add start-event operands `{kind: "file", path}`. -- `packages/hunk/src/diffr/wire.ts` validates the Rust field shapes. Source positions - remain zero-based UTF-8 byte offsets. Fold metadata, tags, pairings and placeholders - are retained intact. -- `diffr/folds.ts` turns fold ranges into VS Code-style regions: one region per header - line (the outermost wins), unchanged pairs share one id, and a trailing line hides only - when nothing follows the range on it. Collapsed ids live in viewer state; folded lines - are masked per side before row building, so no realignment happens. +- `src/protocol.rs` is the wire contract: a `start` manifest, one `file` record per file + with `diff` or `error`, and a `complete` footer. Sides are `lhs`/`rhs` by presence. + Each text side carries its full text, optional `syntax` spans, and a `regions` tree + whose leaves tile the file; the same region `id` on both sides means correspondence. +- `packages/hunk/src/diffr/wire.ts` validates those shapes with Zod and fills omitted + defaults. Columns remain zero-based UTF-8 byte offsets. +- `diffr/regions.ts` flattens each side's tree into leaves and folds, computes which lines + a collapsed fold hides (a trailing line hides only when nothing follows the range on + it), and seeds the default collapsed set from `visibility`. Collapsed ids live in + viewer state, keyed by region id, so paired regions toggle together. - `diffr/stream.ts` validates event ordering, versions and completion counts, handles arbitrary chunk boundaries, and rejects truncated streams. - `diffr/store.ts` holds completed files and errors. The UI can display files while subsequent results are arriving. Quit terminates the comparison subprocess. -- `diffr/rows.ts` consumes the exact full-file line pairs. It adds empty split cells where - Rust supplies null, and groups unified removals before additions between shared - context lines. Syntax colors and novelty emphasis come from Rust token spans. - There is no patch parser, second diff algorithm or frontend syntax highlighter. +- `diffr/rows.ts` zips the two leaf lists on their ids: paired leaves pair rows line for + line, unpaired leaves get blank cells opposite, and a leaf whose partner already went + by is a move, shown one-sided on each side. Folding masks lines per side; nothing is + realigned. Line tint comes from a leaf's `changed` spans, word emphasis from the spans + themselves, and foreground colour from `syntax` capture names through a small theme + table. There is no patch parser, second diff algorithm or frontend tokenizer. +- `diffr/config.ts` and `ui/Settings.tsx` implement the settings screen over the + `diffr config` commands. - `diffr/geometry.ts` measures wrapping and equal-height split rows. Retained Hunk `styledSpanLayout.ts`, `ui/lib/text.ts` and `rowWindowing.ts` handle styled text slicing, terminal column measurement and binary-search viewport selection. @@ -128,7 +160,9 @@ shutdown. They require Python 3 and the debug Rust binary. Set `DIFFR_TEST_BIN` another binary. OpenTUI tests verify drag-copy, split/unified rendering, and bounded mounted widgets while scrolling a 5,000-line file. -Full-file alignment is supplied by Rust as `aligned_rows` (zero-based line pairs, null for padding). Hunks supply the default context selection and navigation; omitted stretches become ellipsis rows without changing alignment. Press `c` to reveal all source. Older saved streams must be regenerated. +Alignment is supplied by Rust as per-side region trees whose leaves share ids across +sides. Context selection arrives as collapsed `unchanged` leaves; `[` and `]` jump +between runs of changed rows. Saved streams from the v1 wire must be regenerated. @@ -140,7 +174,8 @@ d/u or Ctrl-D/Ctrl-U scroll half pages; f/b, PageDown/PageUp, or Ctrl-F/Ctrl-B scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end. h/l or arrows pan horizontally. Cmd-B toggles the tree; backslash is its fallback. -The first NDJSON event includes `files: FileChange[]` in comparison order. +The first NDJSON event includes `files` in comparison order, each with its status, +category, language and default visibility. The tree renders this manifest immediately; pending files are marked ◌ and failures !. Diffs appear in tree order as they arrive, retaining the visible source row when an earlier file loads. Result arrival order need not match manifest order; each diff --git a/tui/packages/hunk/src/diffr/config.test.ts b/tui/packages/hunk/src/diffr/config.test.ts new file mode 100644 index 000000000..1bc080ecf --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { filterSettings, flattenSchema, fuzzyScore, isDefault, parseValue } from "./config"; +export const schemaFixture = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Params", + type: "object", + properties: { + folds: { + type: "object", + properties: { + min_lines: { type: "integer", description: "Bodies shorter than this are never summarized or collapsed.", default: 12 }, + collapse_deleted: { type: "boolean", description: "Collapse deleted function bodies.", default: true }, + collapse_tests: { type: "boolean", description: "Hide files classed as test.", default: true }, + }, + }, + summarize: { + type: "object", + properties: { + provider: { type: "string", enum: ["gemini", "none"], description: "Model provider.", default: "gemini" }, + api_key: { anyOf: [{ type: "string" }, { type: "null" }], description: "API key for the provider.", default: null }, + model: { $ref: "#/$defs/Model" }, + }, + }, + }, + $defs: { Model: { type: "string", description: "Model name.", default: "gemini-2.5-flash" } }, +}; +export const valuesFixture = { + folds: { min_lines: 12, collapse_deleted: true, collapse_tests: false }, + summarize: { provider: "gemini", api_key: null, model: "gemini-2.5-flash" }, +}; +test("schema flattens to dotted keys with descriptions, defaults, and current values", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(settings.map((s) => [s.key, s.type, s.default, s.value])).toEqual([ + ["folds.min_lines", "integer", 12, 12], + ["folds.collapse_deleted", "boolean", true, true], + ["folds.collapse_tests", "boolean", true, false], + ["summarize.provider", "enum", "gemini", "gemini"], + ["summarize.api_key", "string", null, null], + ["summarize.model", "string", "gemini-2.5-flash", "gemini-2.5-flash"], + ]); + expect(settings[3].options).toEqual(["gemini", "none"]); + expect(settings.map(isDefault)).toEqual([true, true, false, true, true, true]); +}); +test("fuzzy filtering narrows over key and description", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(fuzzyScore("cltest", "folds.collapse_tests")).not.toBeNull(); + expect(fuzzyScore("xyz", "folds.collapse_tests")).toBeNull(); + // Key matches rank above a description mention ("... or collapsed."). + expect(filterSettings(settings, "coll").map((s) => s.key)).toEqual(["folds.collapse_deleted", "folds.collapse_tests", "folds.min_lines"]); + expect(filterSettings(settings, "shorter").map((s) => s.key)).toEqual(["folds.min_lines"]); + expect(filterSettings(settings, "")).toHaveLength(6); +}); +test("edited values are parsed in the setting's type and bad input is rejected", () => { + const [minLines, collapse, , provider] = flattenSchema(schemaFixture, valuesFixture); + expect(parseValue(minLines, "20")).toBe(20); + expect(() => parseValue(minLines, "2.5")).toThrow("integer"); + expect(parseValue(collapse, "false")).toBe(false); + expect(() => parseValue(collapse, "yes")).toThrow(); + expect(parseValue(provider, "none")).toBe("none"); + expect(() => parseValue(provider, "openai")).toThrow("gemini, none"); +}); diff --git a/tui/packages/hunk/src/diffr/config.ts b/tui/packages/hunk/src/diffr/config.ts new file mode 100644 index 000000000..d5fa99754 --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.ts @@ -0,0 +1,146 @@ +/** Read diffr's config schema and values through its CLI, and flatten them into settings rows. */ +import { spawnSync } from "node:child_process"; +export type SettingType = "boolean" | "integer" | "number" | "string" | "enum"; +export interface Setting { + /** Dotted path, e.g. `folds.min_lines`. */ + key: string; + description: string; + type: SettingType; + options?: string[]; + default: unknown; + value: unknown; +} +export interface ConfigClient { + schema(): unknown; + show(): unknown; + set(key: string, value: string): void; +} +interface JsonSchema { + type?: string | string[]; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + $ref?: string; + $defs?: Record; + definitions?: Record; +} +function resolve(schema: JsonSchema, root: JsonSchema): JsonSchema { + if (!schema.$ref) return schema; + const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, ""); + const target = root.$defs?.[name] ?? root.definitions?.[name]; + if (!target) throw new Error(`Unresolved schema reference ${schema.$ref}`); + return resolve(target, root); +} +/** `Option` derives to `anyOf: [T, null]`; settings edit the `T`. */ +function unwrap(schema: JsonSchema, root: JsonSchema): JsonSchema { + const resolved = resolve(schema, root); + const variants = resolved.anyOf ?? resolved.oneOf; + if (!variants) return resolved; + const present = variants.map((v) => resolve(v, root)).filter((v) => v.type !== "null"); + if (present.length !== 1) throw new Error("Settings only support optional single-typed values"); + return { + ...present[0], + description: resolved.description ?? present[0].description, + default: "default" in resolved ? resolved.default : present[0].default, + }; +} +function settingType(schema: JsonSchema): SettingType { + if (schema.enum) return "enum"; + const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; + if (type === "boolean" || type === "integer" || type === "number" || type === "string") return type; + throw new Error(`Unsupported setting type ${String(type)}`); +} +export function flattenSchema(rawSchema: unknown, rawValues: unknown): Setting[] { + const root = rawSchema as JsonSchema; + const settings: Setting[] = []; + const walk = (schema: JsonSchema, values: unknown, prefix: string) => { + const node = unwrap(schema, root); + if (node.properties) { + for (const [name, child] of Object.entries(node.properties)) { + const value = values && typeof values === "object" ? (values as Record)[name] : undefined; + walk(child, value, prefix ? `${prefix}.${name}` : name); + } + return; + } + const type = settingType(node); + settings.push({ + key: prefix, + description: node.description ?? "", + type, + options: type === "enum" ? node.enum!.map(String) : undefined, + default: node.default, + value: values, + }); + }; + walk(root, rawValues, ""); + return settings; +} +/** Subsequence match: every query character in order, scored by tightness and word starts. */ +export function fuzzyScore(query: string, text: string): number | null { + const q = query.toLowerCase(), t = text.toLowerCase(); + if (!q) return 0; + let score = 0, position = 0; + for (const char of q) { + const index = t.indexOf(char, position); + if (index < 0) return null; + score += index === position ? 3 : index === 0 || /[^a-z0-9]/.test(t[index - 1]) ? 2 : 1; + position = index + 1; + } + return score; +} +/** Keys match fuzzily; descriptions only as whole substrings, so prose does not swamp the list. */ +function settingScore(setting: Setting, query: string): number | null { + const byKey = fuzzyScore(query, setting.key); + const byDescription = setting.description.toLowerCase().includes(query.toLowerCase()) ? query.length : null; + if (byKey === null && byDescription === null) return null; + return Math.max(byKey ?? -1, byDescription ?? -1); +} +export function filterSettings(settings: Setting[], query: string): Setting[] { + return settings + .map((setting) => ({ setting, score: settingScore(setting, query) })) + .filter((entry): entry is { setting: Setting; score: number } => entry.score !== null) + .sort((a, b) => b.score - a.score || a.setting.key.localeCompare(b.setting.key)) + .map((entry) => entry.setting); +} +export const formatValue = (value: unknown) => + value === undefined || value === null ? "" : typeof value === "string" ? value : JSON.stringify(value); +export const isDefault = (setting: Setting) => formatValue(setting.value) === formatValue(setting.default); +/** Parse an edited value in the setting's type; throws on malformed input. */ +export function parseValue(setting: Setting, text: string): unknown { + switch (setting.type) { + case "boolean": + if (text !== "true" && text !== "false") throw new Error("Expected true or false"); + return text === "true"; + case "integer": + case "number": { + const number = Number(text); + if (text.trim() === "" || Number.isNaN(number) || (setting.type === "integer" && !Number.isInteger(number))) + throw new Error(`Expected ${setting.type === "integer" ? "an integer" : "a number"}`); + return number; + } + case "enum": + if (!setting.options!.includes(text)) throw new Error(`Expected one of ${setting.options!.join(", ")}`); + return text; + case "string": + return text; + } +} +function run(binary: string, args: string[]): string { + const result = spawnSync(binary, args, { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr.trim() || `diffr ${args.join(" ")} exited with ${result.status}`); + return result.stdout; +} +/** The CLI contract: `config schema`, `config show --json`, `config set `. */ +export function cliClient(binary: string): ConfigClient { + return { + schema: () => JSON.parse(run(binary, ["config", "schema"])), + show: () => JSON.parse(run(binary, ["config", "show", "--json"])), + set: (key, value) => { + run(binary, ["config", "set", key, value]); + }, + }; +} diff --git a/tui/packages/hunk/src/diffr/fileTree.test.ts b/tui/packages/hunk/src/diffr/fileTree.test.ts index 59c7b9d3e..5b378fbd1 100644 --- a/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/tui/packages/hunk/src/diffr/fileTree.test.ts @@ -4,7 +4,7 @@ import { createTestDiffFile } from "./fixture"; test("tree shares directories, retains file identities, and folds subtrees", () => { const files = ["src/ui/App.tsx", "README.md", "src/core.rs", "test/ui/App.tsx"].map(path => { const file = createTestDiffFile(); - file.file.new_path = path; + file.file = { rhs: { path, oid: "0", mode: "100644" } }; return file; }); const tree = buildFileTree(files); @@ -14,6 +14,7 @@ test("tree shares directories, retains file identities, and folds subtrees", () ]); expect(flattenFileTree(tree, new Set(["/src"])).some(r => r.node.fileIndex === 0)).toBe(false); expect(parentDirectories(files[0])).toEqual(["/src", "/src/ui"]); - files[0].diff.hunks.push(files[0].diff.hunks[0]); - expect(lineCounts(files[0])).toEqual({added: 2, removed: 1}); + expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }); + files[0].diff = { type: "binary", lhs: { size: 1 } }; + expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 } }); }); diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index eae2bfe0d..1deca649c 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -1,4 +1,4 @@ -import type { DiffFile } from "./wire"; +import { filePath, type DiffFile, type FileChange } from "./wire"; export interface TreeNode { key: string; @@ -11,11 +11,11 @@ export interface TreeRow { depth: number; } /** Paths identify directory nodes; streamed file indexes preserve diff navigation. */ -export function buildFileTree(files: Pick[]): TreeNode[] { +export function buildFileTree(files: Pick[]): TreeNode[] { const roots: TreeNode[] = []; const directories = new Map(); files.forEach((file, fileIndex) => { - const path = file.file.new_path ?? file.file.old_path ?? ""; + const path = filePath(file.file); const parts = path.split("/").filter(Boolean); let children = roots, prefix = ""; parts.forEach((name, i) => { @@ -48,14 +48,12 @@ export function flattenFileTree(nodes: TreeNode[], closed: Set, depth = ? flattenFileTree(node.children, closed, depth + 1) : []), ]); } -export function parentDirectories(file: Pick): string[] { - const parts = (file.file.new_path ?? file.file.old_path ?? "").split("/").filter(Boolean); +export function parentDirectories(file: Pick): string[] { + const parts = filePath(file.file).split("/").filter(Boolean); return parts.slice(0, -1).map((_, i) => "/" + parts.slice(0, i + 1).join("/")); } -/** Count novel source lines once even when context hunks overlap. */ +/** Textual counts always; structural ones when tree-sitter compared the file. */ export function lineCounts(file: DiffFile) { - return { - added: new Set(file.diff.hunks.flatMap(h => h.novel_rhs)).size, - removed: new Set(file.diff.hunks.flatMap(h => h.novel_lhs)).size, - }; + if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 } }; + return { textual: file.diff.stats.textual, structural: file.diff.stats.structural }; } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index 19302ba50..d62645e63 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -1,53 +1,65 @@ -/** Provide a hand-authored source correspondence for renderer and protocol tests. */ -import type { DiffFile } from "./wire"; +/** Provide a hand-authored wire v2 file record for renderer and protocol tests. */ +import type { DiffFile, Region, Span } from "./wire"; +const pos = (line: number, column = 0) => ({ line, column }); +export function leaf(id: number, start: number, end: number, changed: Span[] = []): Region { + return { id, start: pos(start), end: pos(end), tags: [], visibility: { collapsed: false, label: "" }, + kind: "leaf", changed, children: [] }; +} +export function fold( + id: number, + start: [number, number], + end: [number, number], + children: Region[], + label = "Body", + tags = ["body"], + collapsed = false, +): Region { + return { id, start: pos(...start), end: pos(...end), tags, visibility: { collapsed, label }, + kind: "fold", changed: [], children }; +} +export const line = (line: number, start_column: number, end_column: number): Span => + ({ line, start_column, end_column }); export function createTestDiffFile(): DiffFile { return { type: "file", file: { - old_path: "demo.ts", - new_path: "demo.ts", - class: null, - status: "modified", + lhs: { path: "demo.ts", oid: "1111111", mode: "100644" }, + rhs: { path: "demo.ts", oid: "2222222", mode: "100644" }, }, diff: { - display_path: "demo.ts", - extra_info: null, - file_format: { SupportedLanguage: "TypeScript" }, - lhs_src: { Text: 'start();\nsend("old");\nfinish();\n' }, - rhs_src: { Text: 'start();\nsend("new");\nextra();\nfinish();\n' }, - lhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - rhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - aligned_rows: [[0, 0], [1, 1], [null, 2], [2, 3]], - hunks: [ - { - novel_lhs: [1], - novel_rhs: [1, 2], - lines: [ - [0, 0], - [1, 1], - [null, 2], - [2, 3], - ], - }, - ], - lhs_folds: [], - rhs_folds: [], - has_byte_changes: [3, 4], - has_syntactic_changes: true, + type: "text", + lhs: { + text: 'start();\nsend("old");\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [leaf(1, 0, 1), leaf(2, 1, 2, [line(1, 6, 9)]), leaf(4, 2, 3)], + }, + rhs: { + text: 'start();\nsend("new");\nextra();\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [ + leaf(1, 0, 1), + leaf(2, 1, 2, [line(1, 6, 9)]), + leaf(3, 2, 3, [line(2, 0, 8)]), + leaf(4, 3, 4), + ], + }, + stats: { textual: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }, }, }; } +/** Replace both sides with identical numbered lines paired in one leaf. */ +export function withIdenticalLines(file: DiffFile, count: number): DiffFile { + const lines = Array.from({ length: count }, (_, i) => `line ${i}`); + const text = lines.join("\n"); + const source = () => ({ text, syntax: [], regions: [leaf(1, 0, count)] }); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = source(); + file.diff.rhs = source(); + return file; +} diff --git a/tui/packages/hunk/src/diffr/folds.test.ts b/tui/packages/hunk/src/diffr/folds.test.ts deleted file mode 100644 index 3b7af3e50..000000000 --- a/tui/packages/hunk/src/diffr/folds.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { foldRegions, hiddenLines, nestedRegions } from "./folds"; -import { dark, rowsForFile } from "./rows"; -import type { DiffFile } from "./wire"; -/** Rust-style body folds: header and closing brace stay visible, like VS Code. */ -export function createFoldedDiffFile(): DiffFile { - const file = createTestDiffFile(); - const lines = [ - "fn outer() {", // 0 - " inner(|| {", // 1 - " a();", // 2 - " b();", // 3 - " });", // 4 - " // trailing", // 5 - " // comment", // 6 - "}", // 7 - ]; - const text = lines.join("\n") + "\n"; - file.diff.lhs_src = file.diff.rhs_src = { Text: text }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{ novel_lhs: [], novel_rhs: [3], lines: file.diff.aligned_rows }]; - const range = (a: [number, number], b: [number, number]) => ({ - start: { line: a[0], byte_column: a[1] }, - end: { line: b[0], byte_column: b[1] }, - }); - const paired = (r: ReturnType, placeholder: string, tags: string[]) => ({ - tags, range: r, match_kind: { Unchanged: { opposite: r } }, placeholder, - }); - file.diff.rhs_folds = [ - paired(range([0, 12], [7, 0]), "Body", ["body"]), - paired(range([1, 14], [4, 4]), "Body", ["body"]), - // A whole-node fold ending at end of line hides its last line too. - paired(range([5, 4], [6, 14]), "Comment", ["comment"]), - // Single-line folds are never foldable. - paired(range([2, 8], [2, 11]), "Call", ["call"]), - ]; - file.diff.lhs_folds = file.diff.rhs_folds; - return file; -} -test("regions keep headers visible, hide trailing lines only when nothing follows", () => { - const regions = foldRegions(createFoldedDiffFile().diff); - const right = regions.filter((r) => r.side === 1); - expect(right.map((r) => [r.id, r.startLine, r.endLine, r.hideEnd])).toEqual([ - ["R0:7", 0, 7, false], - ["R1:4", 1, 4, false], - ["R5:6", 5, 6, true], - ]); - // Unchanged folds share ids across sides so both collapse together. - expect(regions.filter((r) => r.side === 0).map((r) => r.id)).toEqual(["R0:7", "R1:4", "R5:6"]); - expect(nestedRegions(right, right[0]).map((r) => r.id)).toEqual(["R1:4", "R5:6"]); -}); -test("collapsed regions hide each side's lines between header and close", () => { - const diff = createFoldedDiffFile().diff; - const regions = foldRegions(diff); - const hidden = hiddenLines(diff.aligned_rows, regions, new Set(["R1:4", "R5:6"]), () => true); - expect(hidden.map((side) => [...side].sort())).toEqual([[2, 3, 6], [2, 3, 6]]); - // A fold whose header the viewer does not show cannot hide anything. - expect(hiddenLines(diff.aligned_rows, regions, new Set(["R1:4"]), () => false)[1].size).toBe(0); -}); -test("a fold on one side leaves the other side's lines beside blank cells", () => { - const file = createFoldedDiffFile(); - // The right-hand closure body is novel; the left keeps two lines aligned inside it. - file.diff.rhs_folds = [{ - tags: ["body"], placeholder: "Body", match_kind: "Novel", - range: { start: { line: 1, byte_column: 14 }, end: { line: 4, byte_column: 4 } }, - }]; - file.diff.lhs_folds = []; - const rows = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.left); - expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ - [1, 1], [2, 2], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], - ]); - expect(rows[2].right!.kind).toBe("empty"); - expect(rows[1].right!.fold?.collapsed).toBe(true); - expect(rows[1].left!.fold).toBeUndefined(); -}); -test("rows carry fold headers on both layouts and drop hidden lines", () => { - const file = createFoldedDiffFile(); - const split = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.right); - expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); - expect(split[1].right!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[1].left!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[0].right!.fold?.collapsed).toBe(false); - const unified = rowsForFile(file, 0, "unified", dark, true, new Set(["R0:7"])).filter((r) => r.cell); - expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); - expect(unified[0].cell!.fold?.id).toBe("R0:7"); -}); diff --git a/tui/packages/hunk/src/diffr/folds.ts b/tui/packages/hunk/src/diffr/folds.ts deleted file mode 100644 index d920381cf..000000000 --- a/tui/packages/hunk/src/diffr/folds.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Derive VS Code-style fold regions from diffr folds; collapsed state belongs to the viewer. */ -import type { DiffResult } from "./wire"; -export type Side = 0 | 1; -export interface FoldRegion { - /** Shared by an unchanged fold and its opposite, so both sides collapse together. */ - id: string; - side: Side; - /** The header line, always visible, carrying the chevron and the placeholder. */ - startLine: number; - /** The last line of the fold range; hidden only when nothing follows the range on it. */ - endLine: number; - hideEnd: boolean; - placeholder: string; -} -export interface RowFold { - id: string; - placeholder: string; - collapsed: boolean; -} -export const sourceLines = (text: string) => - text === "" ? [] : text.replace(/\n$/, "").split("\n"); -const encoder = new TextEncoder(); -function regionsForSide(diff: DiffResult, side: Side): FoldRegion[] { - const source = side ? diff.rhs_src : diff.lhs_src; - const folds = side ? diff.rhs_folds : diff.lhs_folds; - if (source === "Binary") return []; - const lines = sourceLines(source.Text); - const byStart = new Map(); - for (const fold of folds) { - const { start, end } = fold.range; - const endLine = lines[end.line]; - if (endLine === undefined) - throw new Error(`diffr fold references missing line ${end.line}`); - const hideEnd = encoder.encode(endLine.trimEnd()).length <= end.byte_column; - const hiddenLines = end.line - start.line - (hideEnd ? 0 : 1); - if (hiddenLines < 1) continue; - const paired = - fold.match_kind === "Novel" - ? null - : side - ? fold.range - : fold.match_kind.Unchanged.opposite; - const id = paired - ? `R${paired.start.line}:${paired.end.line}` - : `${side ? "R" : "L"}${start.line}:${end.line}`; - const region = { id, side, startLine: start.line, endLine: end.line, hideEnd, - placeholder: fold.placeholder }; - // Like VS Code, one region per header line: keep the outermost. - const existing = byStart.get(start.line); - if (!existing || existing.endLine < end.line) byStart.set(start.line, region); - } - return [...byStart.values()].sort((a, b) => a.startLine - b.startLine); -} -export function foldRegions(diff: DiffResult): FoldRegion[] { - return [...regionsForSide(diff, 0), ...regionsForSide(diff, 1)]; -} -/** Regions strictly inside another on the same side, for Alt-click recursive folding. */ -export function nestedRegions(regions: FoldRegion[], outer: FoldRegion) { - return regions.filter( - (r) => r.side === outer.side && r.startLine > outer.startLine && r.endLine <= outer.endLine, - ); -} -/** First aligned row showing each source line, per side. */ -function rowsOfLines(alignedRows: DiffResult["aligned_rows"]) { - const rowOfLine = [new Map(), new Map()]; - for (const [index, pair] of alignedRows.entries()) - for (const side of [0, 1] as const) { - const line = pair[side]; - if (line !== null && !rowOfLine[side].has(line)) rowOfLine[side].set(line, index); - } - return rowOfLine; -} -/** Source lines hidden per side. Each side folds independently; alignment is never recomputed. */ -export function hiddenLines( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet, - headerShown: (rowIndex: number) => boolean, -): [Set, Set] { - const rowOfLine = rowsOfLines(alignedRows); - const hidden: [Set, Set] = [new Set(), new Set()]; - for (const region of regions) { - if (!collapsed.has(region.id)) continue; - const startRow = rowOfLine[region.side].get(region.startLine); - if (startRow === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - if (!headerShown(startRow)) continue; - const last = region.endLine - (region.hideEnd ? 0 : 1); - for (let line = region.startLine + 1; line <= last; line++) hidden[region.side].add(line); - } - return hidden; -} -/** Header rows keyed by aligned-row index, one per side. */ -export function foldHeaders( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet, -): [Map, Map] { - const rowOfLine = rowsOfLines(alignedRows); - const headers: [Map, Map] = [new Map(), new Map()]; - for (const region of regions) { - const row = rowOfLine[region.side].get(region.startLine); - if (row === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - headers[region.side].set(row, { - id: region.id, - placeholder: region.placeholder, - collapsed: collapsed.has(region.id), - }); - } - return headers; -} diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts new file mode 100644 index 000000000..6ba221198 --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { createTestDiffFile, fold, leaf, line } from "./fixture"; +import { defaultCollapsed, flatten, foldHeaders, gapIds, hiddenLines } from "./regions"; +import { dark, rowsForFile } from "./rows"; +import type { DiffFile, Region } from "./wire"; +/** Rust-style body folds: header and closing brace stay visible, like VS Code. */ +export function createFoldedDiffFile(): DiffFile { + const file = createTestDiffFile(); + const lines = [ + "fn outer() {", // 0 + " inner(|| {", // 1 + " a();", // 2 + " b();", // 3 + " });", // 4 + " // trailing", // 5 + " // comment", // 6 + "}", // 7 + ]; + const text = lines.join("\n") + "\n"; + // ids: 10 outer body, 11 closure body, 12 comment; leaves 1..5 tile the file. + const regions = (changed: boolean): Region[] => [ + fold(10, [0, 12], [7, 0], [ + leaf(1, 0, 1), + fold(11, [1, 14], [4, 4], [leaf(2, 1, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : []), leaf(4, 4, 5)]), + // A whole-node fold ending at end of line hides its last line too. + fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["comment"]), + leaf(6, 7, 8), + ]), + ]; + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text, syntax: [], regions: regions(false) }; + file.diff.rhs = { text, syntax: [], regions: regions(true) }; + return file; +} +test("folds keep headers visible, hide trailing lines only when nothing follows", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const { folds, leaves } = flatten(file.diff); + expect(folds[1].map((f) => [f.id, f.headerLine, f.lastHidden])).toEqual([[10, 0, 6], [11, 1, 3], [12, 5, 6]]); + expect(folds[1][0].nested).toEqual([11, 12]); + expect(leaves[1].map((l) => [l.id, l.startLine, l.endLine])).toEqual([[1, 0, 1], [2, 1, 3], [3, 3, 4], [4, 4, 5], [5, 5, 7], [6, 7, 8]]); + expect([...hiddenLines(folds[1], new Set([11, 12]))].sort()).toEqual([2, 3, 6]); + // A fold hidden inside a collapsed outer fold hides nothing of its own. + expect([...hiddenLines(folds[1], new Set([10, 11]))].sort()).toEqual([1, 2, 3, 4, 5, 6]); + expect(foldHeaders(folds[1], leaves[1], new Set([11])).get(1)).toEqual({ id: 11, label: "Body", collapsed: true }); +}); +test("visibility seeds collapsed ids and tags name the context gaps", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "Body" }; + const gap = leaf(6, 7, 8); + gap.tags = ["unchanged"]; + gap.visibility = { collapsed: true, label: "1 unchanged line" }; + file.diff.rhs!.regions[0].children[3] = gap; + expect([...defaultCollapsed(file.diff)]).toEqual([6, 10]); + expect(gapIds(file.diff)).toEqual([6]); +}); +test("a fold on one side leaves the other side's lines beside blank cells", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // The right-hand closure body is a novel fold with its own leaves; the left keeps flat leaves. + file.diff.lhs!.regions = [leaf(1, 0, 1), leaf(2, 1, 3), leaf(3, 3, 4), leaf(4, 4, 5), leaf(5, 5, 7), leaf(6, 7, 8)]; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.left); + expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ + [1, 1], [2, 2], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], + ]); + expect(rows[2].right!.kind).toBe("empty"); + expect(rows[1].right!.fold?.collapsed).toBe(true); + expect(rows[1].left!.fold).toBeUndefined(); +}); +test("rows carry fold headers on both layouts and drop hidden lines", () => { + const file = createFoldedDiffFile(); + const split = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); + expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); + expect(split[1].right!.fold).toEqual({ id: 11, label: "Body", collapsed: true }); + expect(split[1].left!.fold).toEqual({ id: 11, label: "Body", collapsed: true }); + expect(split[0].right!.fold?.collapsed).toBe(false); + const unified = rowsForFile(file, 0, "unified", dark, new Set([10])).filter((r) => r.cell); + expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); + expect(unified[0].cell!.fold?.id).toBe(10); +}); +test("a multi-line label hangs under the collapsed header inside the fold tint", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const pseudocode = "# pseudocode\ncall a\ncall b"; + file.diff.rhs!.regions[0].children[1].visibility = { collapsed: false, label: pseudocode }; + file.diff.lhs!.regions[0].children[1].visibility = { collapsed: false, label: pseudocode }; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])); + const labels = rows.filter((r) => r.right?.foldLabel); + expect(labels.map((r) => r.right!.spans[0].text)).toEqual([" # pseudocode", " call a", " call b"]); + expect(labels.map((r) => r.left!.spans[0].text)).toEqual([" # pseudocode", " call a", " call b"]); + expect(rows.indexOf(labels[0])).toBe(rows.findIndex((r) => r.right?.fold?.id === 11) + 1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([11])); + expect(unified.filter((r) => r.cell?.foldLabel)).toHaveLength(3); +}); +test("a collapsed leaf is one fold row with the chevron, its label, and no line number", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions[0].visibility = file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "1 unchanged line" }; + const split = rowsForFile(file, 0, "split", dark, new Set([1])); + expect(split[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true }); + expect(split[1].left!.lineNumber).toBeUndefined(); + expect(split[1].right!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true }); + expect(split.filter((r) => r.left?.fold?.id === 1)).toHaveLength(1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([1])); + expect(unified[1].cell).toMatchObject({ fold: { id: 1, collapsed: true } }); + // Open, the leaf's first line carries the chevron so it can be collapsed again. + expect(rowsForFile(file, 0, "split", dark, new Set())[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: false }); + // Unlabelled gaps get a computed label; ordinary leaves are not foldable. + const tagged = createTestDiffFile(); + if (tagged.diff.type !== "text") throw new Error(); + tagged.diff.rhs!.regions[0].tags = ["unchanged"]; + const rows = rowsForFile(tagged, 0, "split", dark, new Set()); + expect(rows[1].right!.fold).toEqual({ id: 1, label: "1 unchanged lines", collapsed: false }); + expect(rows[1].left!.fold).toBeUndefined(); + expect(rows[2].right!.fold).toBeUndefined(); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts new file mode 100644 index 000000000..c963e982c --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -0,0 +1,124 @@ +/** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ +import type { Region, Source, Span, TextDiff } from "./wire"; +export type Side = 0 | 1; +/** A leaf tiles its side; the same id on the other side is its counterpart. */ +export interface Leaf { + id: number; + side: Side; + startLine: number; + /** Exclusive. */ + endLine: number; + changed: Map; + tags: string[]; + collapsed: boolean; + label: string; +} +export interface Fold { + id: number; + side: Side; + /** The header line, always visible, carrying the chevron and the label. */ + headerLine: number; + /** Last hidden line, inclusive; the closing delimiter stays visible when text follows the range. */ + lastHidden: number; + label: string; + tags: string[]; + collapsed: boolean; + /** Ids of folds nested inside, for recursive fold commands. */ + nested: number[]; +} +export interface RowFold { + id: number; + label: string; + collapsed: boolean; +} +export const sourceLines = (text: string) => + text === "" ? [] : text.replace(/\n$/, "").split("\n"); +const encoder = new TextEncoder(); +/** Lines a leaf covers, half-open: an end at column zero does not touch its end line. */ +export function leafLines(region: Region): [number, number] { + return [region.start.line, region.end.column === 0 ? region.end.line : region.end.line + 1]; +} +export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds: Fold[] } { + const lines = sourceLines(source.text); + const leaves: Leaf[] = []; + const folds: Fold[] = []; + const visit = (region: Region, ancestors: Fold[]) => { + if (region.kind === "leaf") { + const [startLine, endLine] = leafLines(region); + const changed = new Map(); + for (const span of region.changed) changed.set(span.line, [...(changed.get(span.line) ?? []), span]); + leaves.push({ id: region.id, side, startLine, endLine, changed, tags: region.tags, + collapsed: region.visibility.collapsed, label: region.visibility.label }); + return; + } + const { start, end } = region; + const endText = lines[end.line]; + if (endText === undefined) throw new Error(`diffr fold references missing line ${end.line}`); + const hideEnd = encoder.encode(endText.trimEnd()).length <= end.column; + const fold: Fold = { id: region.id, side, headerLine: start.line, + lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, + collapsed: region.visibility.collapsed, nested: [] }; + for (const ancestor of ancestors) ancestor.nested.push(fold.id); + folds.push(fold); + for (const child of region.children) visit(child, [...ancestors, fold]); + }; + for (const region of source.regions) visit(region, []); + return { leaves, folds }; +} +export function flatten(diff: TextDiff) { + const lhs = diff.lhs ? flattenSide(diff.lhs, 0) : { leaves: [], folds: [] }; + const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; + return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; +} +/** Ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ +export function defaultCollapsed(diff: TextDiff): Set { + const ids = new Set(); + const { leaves, folds } = flatten(diff); + for (const item of [...leaves.flat(), ...folds.flat()]) if (item.collapsed) ids.add(item.id); + return ids; +} +/** Every collapsible id on either side, folds and foldable leaves alike, for fold-all commands. */ +export function foldIds(diff: TextDiff): number[] { + const { folds, leaves } = flatten(diff); + return [...new Set([...folds.flat().map((fold) => fold.id), ...leaves.flat().filter(foldableLeaf).map((l) => l.id)])]; +} +/** Ids nested inside a collapsible region, for recursive fold commands. */ +export function nestedIds(diff: TextDiff, id: number): number[] { + const fold = flatten(diff).folds.flat().find((f) => f.id === id); + if (fold) return fold.nested; + if (!flatten(diff).leaves.flat().some((leaf) => leaf.id === id)) throw new Error(`Unknown region ${id}`); + return []; +} +/** Context-gap leaves: unchanged runs diffr trimmed to N lines around changes. */ +export function gapIds(diff: TextDiff): number[] { + return [...new Set(flatten(diff).leaves.flat().filter((leaf) => leaf.tags.includes("unchanged")).map((l) => l.id))]; +} +/** Source lines hidden on one side by collapsed folds whose header is visible. */ +export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set { + const hidden = new Set(); + // Outer folds first: a fold whose header is already hidden hides nothing of its own. + const ordered = [...folds].sort((a, b) => a.headerLine - b.headerLine || b.lastHidden - a.lastHidden); + for (const fold of ordered) { + if (!collapsed.has(fold.id) || hidden.has(fold.headerLine)) continue; + for (let line = fold.headerLine + 1; line <= fold.lastHidden; line++) hidden.add(line); + } + return hidden; +} +/** A leaf collapses like a fold when diffr labelled it or tagged it as a context gap. */ +export const foldableLeaf = (leaf: Leaf) => leaf.label !== "" || leaf.tags.includes("unchanged"); +export const leafLabel = (leaf: Leaf) => + leaf.label || `${leaf.endLine - leaf.startLine} unchanged lines`; +/** Fold headers keyed by source line, one per side; a fold wins the header line of its first leaf. */ +export function foldHeaders(folds: Fold[], leaves: Leaf[], collapsed: ReadonlySet): Map { + const headers = new Map(); + for (const leaf of leaves) + if (foldableLeaf(leaf)) + headers.set(leaf.startLine, { id: leaf.id, label: leafLabel(leaf), collapsed: collapsed.has(leaf.id) }); + for (const fold of folds) { + const existing = headers.get(fold.headerLine); + const existingFold = existing && folds.find((f) => f.id === existing.id); + if (existingFold && existingFold.lastHidden >= fold.lastHidden) continue; + headers.set(fold.headerLine, { id: fold.id, label: fold.label, collapsed: collapsed.has(fold.id) }); + } + return headers; +} diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index af6489f4d..a5fa244ba 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { dark, lineSpans, rowsForFile } from "./rows"; +import { createTestDiffFile, leaf, line, withIdenticalLines } from "./fixture"; +import { captureColor, dark, lineSpans, rowsForFile } from "./rows"; import { measureRows, visibleRows } from "./geometry"; import { copySelection } from "./selection"; -test("split uses supplied pairs and padding, unified groups old before new", () => { +test("split zips leaves on their ids, unified groups old before new", () => { const file = createTestDiffFile(); const split = rowsForFile(file, 0, "split", dark).filter((r) => r.left); expect(split.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ @@ -12,6 +12,7 @@ test("split uses supplied pairs and padding, unified groups old before new", () [undefined, 3], [3, 4], ]); + expect(split.map((r) => r.right!.kind)).toEqual(["context", "addition", "addition", "context"]); const unified = rowsForFile(file, 0, "unified", dark).filter((r) => r.cell); expect( unified.map( @@ -24,34 +25,47 @@ test("split uses supplied pairs and padding, unified groups old before new", () "+extra();", " finish();", ]); + expect(rowsForFile(file, 0, "split", dark).filter((r) => r.hunkStart)).toHaveLength(1); }); -test("byte spans survive multibyte characters and tabs", () => { +test("moved leaves render one-sided on each side", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: "a\nb\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 2)] }; + file.diff.rhs = { text: "b\na\n", syntax: [], regions: [leaf(2, 0, 1), leaf(1, 1, 2)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.left); + expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ + [undefined, 1], [1, 2], [2, undefined], + ]); +}); +test("byte spans survive multibyte characters and tabs; captures pick theme colours", () => { const spans = lineSpans( "é\t变量", - [ - { - pos: { line: 0, start_col: 3, end_col: 9 }, - kind: { Novel: { highlight: { Atom: "Type" } } }, - }, - ], + [{ line: 0, start_column: 3, end_column: 9, capture: "type.builtin" }], + [line(0, 3, 9)], "right", dark, ); expect(spans.map((s) => s.text).join("")).toBe("é 变量"); expect(spans.at(-1)!.bg).toBe(dark.addWord); - expect(() => - lineSpans( - "é", - [ - { - pos: { line: 0, start_col: 1, end_col: 2 }, - kind: { Novel: { highlight: "Delimiter" } }, - }, - ], - "left", - dark, - ), - ).toThrow(); + expect(spans.at(-1)!.fg).toBe(dark.type); + expect(() => lineSpans("é", [], [line(0, 1, 9)], "left", dark)).toThrow(); + expect(captureColor("function.method", dark)).toBe(dark.function); + expect(captureColor("unknown.thing", dark)).toBe(dark.fg); +}); +test("the innermost syntax capture colours a nested span", () => { + const spans = lineSpans( + 'f("x")', + [ + { line: 0, start_column: 0, end_column: 6, capture: "function.call" }, + { line: 0, start_column: 2, end_column: 5, capture: "string" }, + ], + [], + "left", + dark, + ); + expect(spans.map((s) => [s.text, s.fg])).toEqual([ + ['f(', dark.function], ['"x"', dark.string], [")", dark.function], + ]); }); test("wrapping adds equal split heights and windowing mounts only intersecting rows", () => { const rows = rowsForFile(createTestDiffFile(), 0, "split", dark); @@ -74,88 +88,42 @@ test("selection copies one source side and excludes padding and added lines", () ).toBe('start();\nsend("old");\nfinish();'); }); test("large file mounts a bounded viewport", () => { - const file = createTestDiffFile(); - const lines = Array.from({ length: 20000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks[0] = { - novel_lhs: [], - novel_rhs: [], - lines: lines.map((_, i) => [i, i]), - }; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - const geometry = measureRows( - rowsForFile(file, 0, "split", dark), - 120, - false, - 0, - ); + const file = withIdenticalLines(createTestDiffFile(), 20000); + const geometry = measureRows(rowsForFile(file, 0, "split", dark), 120, false, 0); expect(visibleRows(geometry, 10000, 40)).toHaveLength(40); }); - -test("full-file rows include source outside hunks and retain navigation without headers", () => { - const file = createTestDiffFile(); - file.diff.hunks[0].lines = [[1, 1], [null, 2]]; - for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark, true); - expect(rows.filter(r => r.label).map(r => r.label)).toEqual(["demo.ts"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(copySelection([file], rows, {anchor: rows[1].key, end: rows.at(-1)!.key, side: "left"})) - .toBe(file.diff.lhs_src === "Binary" ? "" : file.diff.lhs_src.Text.trimEnd()); - } - file.diff.hunks = []; - expect(rowsForFile(file, 0, "split", dark, true)).toHaveLength(5); -}); - -test("unchanged string literals use the normal text color", () => { - const span = { line: 0, start_col: 0, end_col: 3 }; - const result = lineSpans('"x"', [{ - pos: span, - kind: { UnchangedToken: { - highlight: { Atom: { String: "StringLiteral" } }, - self_pos: [span], opposite_pos: [span], - } }, - }], "right", dark); - expect(result).toEqual([{ text: '"x"', fg: dark.fg, bg: undefined }]); -}); - -test("matched tokens keep syntax styling even when their counterpart is on another row", () => { - const pos = { line: 2, start_col: 0, end_col: 3 }; - const positions = [{ - pos, kind: { UnchangedToken: { - highlight: "Delimiter" as const, self_pos: [pos], - opposite_pos: [{ ...pos, line: 8 }], - } }, - }]; - for (const side of ["left", "right"] as const) { - expect(lineSpans("foo", positions, side, dark)[0]) - .toEqual({ text: "foo", fg: dark.fg, bg: undefined }); - } -}); - -test("compact context preserves Rust-selected signature and changes with one gap per omitted run", () => { +test("context gaps come from collapsed unchanged leaves, one row per gap", () => { const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({ length: 12 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[1,1], [2,2], [8,8], [9,9]]}]; + const gap = (id: number, start: number, end: number) => { + const region = leaf(id, start, end); + region.tags = ["unchanged"]; + region.visibility = { collapsed: true, label: `${end - start} unchanged lines` }; + return region; + }; + const regions = () => [gap(1, 0, 1), leaf(2, 1, 3, [line(1, 0, 6)]), gap(3, 3, 8), leaf(4, 8, 10), gap(5, 10, 12)]; + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + const split = rowsForFile(file, 0, "split", dark, new Set([1, 3, 5])); + const shown = (fold: { label: string; collapsed: boolean } | undefined, line: number | undefined) => + fold?.collapsed ? fold.label : line; + expect(split.slice(1).map(r => shown(r.left?.fold, r.left?.lineNumber))) + .toEqual(["1 unchanged lines", 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); + // Unified shows the changed line once per side. + const unified = rowsForFile(file, 0, "unified", dark, new Set([1, 3, 5])); + expect(unified.slice(1).map(r => shown(r.cell?.fold, r.cell?.newLineNumber ?? r.cell?.oldLineNumber))) + .toEqual(["1 unchanged lines", 2, 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark); - expect(rows.slice(1).map(r => r.label ?? r.left?.lineNumber ?? r.cell?.oldLineNumber)) - .toEqual(["…", 2, 3, "…", 9, 10, "…"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(rowsForFile(file, 0, layout, dark, true)).toHaveLength(13); + expect(rowsForFile(file, 0, layout, dark, new Set([1, 3, 5])).filter(r => r.hunkStart)).toHaveLength(1); + expect(rowsForFile(file, 0, layout, dark, new Set()).length).toBeGreaterThanOrEqual(13); } }); - -test("unified trusts diffr flags despite different source indentation", () => { +test("unified trusts diffr's changed spans despite different source indentation", () => { const file = createTestDiffFile(); - file.diff.lhs_src = {Text: " call();\n"}; - file.diff.rhs_src = {Text: " call();\n"}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = [[0, 0]]; - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[0, 0]]}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; + file.diff.rhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; const rows = rowsForFile(file, 0, "unified", dark); expect(rows).toHaveLength(2); expect(rows[1].cell).toMatchObject({kind: "context", sign: " ", oldLineNumber: 1, newLineNumber: 1}); @@ -164,13 +132,20 @@ test("unified trusts diffr flags despite different source indentation", () => { expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) .toBe(side === "left" ? " call();" : " call();"); } - // Only the side flagged novel may receive change styling. - file.diff.hunks[0].novel_rhs = [0]; + // Only the side with a changed span may receive change styling. + file.diff.rhs.regions = [leaf(1, 0, 1, [line(0, 0, 11)])]; const changed = rowsForFile(file, 0, "unified", dark).slice(1); expect(changed.map(r => r.cell!.kind)).toEqual(["context", "addition"]); - file.diff.aligned_rows = [[0, null], [null, 0]]; - file.diff.hunks[0].novel_rhs = []; - file.diff.hunks[0].lines = file.diff.aligned_rows; - expect(rowsForFile(file, 0, "unified", dark).slice(1).map(r => r.cell!.kind)) - .toEqual(["context", "context"]); +}); +test("binary and one-sided files render without a second side", () => { + const file = createTestDiffFile(); + file.diff = { type: "binary", lhs: { size: 10 }, rhs: { size: 12 } }; + expect(rowsForFile(file, 0, "split", dark).map((r) => r.label)).toEqual(["demo.ts", "Binary file"]); + const added = createTestDiffFile(); + added.file = { rhs: added.file.rhs }; + if (added.diff.type !== "text") throw new Error(); + added.diff = { type: "text", rhs: { text: "new\n", syntax: [], regions: [leaf(1, 0, 1, [line(0, 0, 3)])] }, + stats: { textual: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } } }; + const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); + expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 0e12f9fc9..dbc519354 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -1,20 +1,19 @@ -/** Project Rust full-file correspondence into Hunk terminal cells; never compute a second diff. */ -import type { DiffFile, DiffResult, Highlight, MatchedPos } from "./wire"; -import type { - RenderSpan, - SplitLineCell, - UnifiedLineCell, -} from "../ui/diff/diffRowModel"; +/** Project diffr's per-side region trees into terminal cells by zipping leaves on their ids. */ +import type { DiffFile, Span, SyntaxSpan } from "./wire"; +import { filePath } from "./wire"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; -import { foldHeaders, foldRegions, hiddenLines, sourceLines } from "./folds"; +import { flatten, foldHeaders, hiddenLines, leafLabel, sourceLines, type Leaf, type RowFold } from "./regions"; export { sourceLines }; export type Layout = "split" | "unified"; export interface ViewerRow { key: string; fileIndex: number; - hunkIndex?: number; + /** First row of a run of changed rows, for `[` and `]`. */ hunkStart?: boolean; label?: string; + /** A hidden-file placeholder: "Load diff" reveals the file. */ + loadDiff?: boolean; left?: SplitLineCell; right?: SplitLineCell; cell?: UnifiedLineCell; @@ -29,7 +28,12 @@ export interface Palette { deleteWord: string; keyword: string; type: string; + string: string; + function: string; + variable: string; + constant: string; comment: string; + punctuation: string; /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ foldBackground: string; foldPlaceholder: string; @@ -44,7 +48,12 @@ export const dark: Palette = { deleteWord: "#74333c", keyword: "#ff7b72", type: "#79c0ff", + string: "#a5d6ff", + function: "#d2a8ff", + variable: "#e6edf3", + constant: "#79c0ff", comment: "#8b949e", + punctuation: "#c9d1d9", foldBackground: "#152434", foldPlaceholder: "#808080", }; @@ -58,69 +67,63 @@ export const light: Palette = { deleteWord: "#ffcecb", keyword: "#cf222e", type: "#0550ae", + string: "#0a3069", + function: "#8250df", + variable: "#24292f", + constant: "#0550ae", comment: "#6e7781", + punctuation: "#24292f", foldBackground: "#e6f3ff", foldPlaceholder: "#808080", }; -function color(token: Highlight, theme: Palette) { - if (token === "Delimiter") return theme.fg; - const atom = token.Atom; - return typeof atom === "object" - ? theme.fg - : atom === "Keyword" - ? theme.keyword - : atom === "Type" - ? theme.type - : atom === "Comment" - ? theme.comment - : theme.fg; +/** Theme key for a tree-sitter capture; dotted names fall back to their first segment. */ +const captureColors = new Map([ + ["keyword", "keyword"], ["conditional", "keyword"], ["repeat", "keyword"], ["exception", "keyword"], + ["include", "keyword"], ["operator", "punctuation"], ["punctuation", "punctuation"], + ["delimiter", "punctuation"], ["string", "string"], ["character", "string"], ["escape", "string"], + ["comment", "comment"], ["type", "type"], ["constructor", "type"], ["storageclass", "type"], + ["namespace", "type"], ["module", "type"], ["function", "function"], ["method", "function"], + ["variable", "variable"], ["parameter", "variable"], ["property", "variable"], ["field", "variable"], + ["attribute", "variable"], ["number", "constant"], ["float", "constant"], ["boolean", "constant"], + ["constant", "constant"], ["label", "constant"], +]); +export function captureColor(capture: string, theme: Palette): string { + const key = captureColors.get(capture) ?? captureColors.get(capture.split(".")[0]); + return key ? theme[key] : theme.fg; } -function byLine(positions: MatchedPos[]) { - const result = new Map(); - for (const position of positions) { - const list = result.get(position.pos.line) ?? []; - list.push(position); - result.set(position.pos.line, list); - } - return result; -} -/** Translate UTF-8 byte spans before expanding tabs into terminal cells. */ +/** Colour a line from byte-addressed syntax and change spans, then expand tabs into cells. */ export function lineSpans( text: string, - positions: MatchedPos[], + syntax: SyntaxSpan[], + changed: Span[], side: "left" | "right", theme: Palette, ): RenderSpan[] { const bytes = new TextEncoder().encode(text), decoder = new TextDecoder("utf-8", { fatal: true }); + const bounds = new Set([0, bytes.length]); + for (const span of [...syntax, ...changed]) { + if (span.start_column > span.end_column || span.end_column > bytes.length) + throw new Error("Invalid diffr span"); + bounds.add(span.start_column); + bounds.add(span.end_column); + } + const sorted = [...bounds].sort((a, b) => a - b); + const wordBg = side === "left" ? theme.deleteWord : theme.addWord; const spans: RenderSpan[] = []; - let cursor = 0; - for (const position of [...positions].sort( - (a, b) => a.pos.start_col - b.pos.start_col, - )) { - const { start_col: start, end_col: end } = position.pos; - if (start < cursor || end < start || end > bytes.length) - throw new Error("Invalid or overlapping diffr token span"); - if (start > cursor) - spans.push({ - text: decoder.decode(bytes.slice(cursor, start)), - fg: theme.fg, - }); - const kind = Object.values(position.kind)[0]; - const novel = "Novel" in position.kind || "NovelWord" in position.kind; + for (let i = 0; i + 1 < sorted.length; i++) { + const start = sorted[i], end = sorted[i + 1]; + // Innermost syntax capture wins, so pick the narrowest span covering this segment. + const capture = syntax + .filter((s) => s.start_column <= start && s.end_column >= end) + .sort((a, b) => a.end_column - a.start_column - (b.end_column - b.start_column))[0]; + const emphasized = changed.some((s) => s.start_column <= start && s.end_column >= end); spans.push({ text: decoder.decode(bytes.slice(start, end)), - fg: color(kind.highlight, theme), - bg: novel - ? side === "left" - ? theme.deleteWord - : theme.addWord - : undefined, + fg: capture ? captureColor(capture.capture, theme) : theme.fg, + bg: emphasized ? wordBg : undefined, }); - cursor = end; } - if (cursor < bytes.length) - spans.push({ text: decoder.decode(bytes.slice(cursor)), fg: theme.fg }); let column = 0; return spans.map((span) => ({ ...span, @@ -134,166 +137,170 @@ export function lineSpans( .join(""), })); } -/** Render the complete Rust alignment; hunks supply change flags and navigation only. */ +function byLine(spans: SyntaxSpan[]) { + const result = new Map(); + for (const span of spans) result.set(span.line, [...(result.get(span.line) ?? []), span]); + return result; +} +const indentOf = (text: string) => text.match(/^\s*/)![0]; +/** Rows for a hidden file: GitHub's "Load diff" placeholder under the header. */ +export function placeholderRows(fileIndex: number, label: string): ViewerRow[] { + return [ + { key: `${fileIndex}:load`, fileIndex, label: "Load diff", loadDiff: true }, + { key: `${fileIndex}:why`, fileIndex, label: label || "Hidden by default" }, + ]; +} +/** Render both sides by zipping leaves on their ids; folding never realigns. */ export function rowsForFile( file: DiffFile, fileIndex: number, layout: Layout, theme: Palette, - fullContext = false, - collapsed: ReadonlySet = new Set(), + collapsed: ReadonlySet = new Set(), ): ViewerRow[] { const d = file.diff; - const rows: ViewerRow[] = [ - { - key: `${fileIndex}:header`, - fileIndex, - label: file.file.new_path ?? file.file.old_path ?? d.display_path, - }, - ]; - if (d.lhs_src === "Binary" || d.rhs_src === "Binary") - return [ - ...rows, - { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }, - ]; - const left = sourceLines(d.lhs_src.Text), - right = sourceLines(d.rhs_src.Text); - const positions = [byLine(d.lhs_positions), byLine(d.rhs_positions)]; - const caches = [ - new Map(), - new Map(), - ]; - const cell = ( - line: number | null, - side: 0 | 1, - novel: Set, - ): SplitLineCell => { - if (line === null) return { kind: "empty", sign: " ", spans: [] }; - const text = (side ? right : left)[line]; - if (text === undefined) - throw new Error(`diffr alignment references missing line ${line}`); + const rows: ViewerRow[] = [{ key: `${fileIndex}:header`, fileIndex, label: filePath(file.file) }]; + if (d.type === "binary") + return [...rows, { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }]; + const texts = [sourceLines(d.lhs?.text ?? ""), sourceLines(d.rhs?.text ?? "")]; + const syntax = [byLine(d.lhs?.syntax ?? []), byLine(d.rhs?.syntax ?? [])]; + const { leaves, folds } = flatten(d); + const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; + const headers = [foldHeaders(folds[0], leaves[0], collapsed), foldHeaders(folds[1], leaves[1], collapsed)]; + const caches = [new Map(), new Map()]; + const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { + if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; + const text = texts[side][line]; + if (text === undefined) throw new Error(`diffr region references missing line ${line}`); let spans = caches[side].get(line); if (!spans) { - spans = lineSpans( - text, - positions[side].get(line) ?? [], - side ? "right" : "left", - theme, - ); + spans = lineSpans(text, syntax[side].get(line) ?? [], leaf.changed.get(line) ?? [], + side ? "right" : "left", theme); caches[side].set(line, spans); } - const changed = novel.has(line); + const changed = leaf.changed.has(line); return { kind: changed ? (side ? "addition" : "deletion") : "context", sign: changed ? (side ? "+" : "-") : " ", lineNumber: line + 1, spans, + fold: headers[side].get(line), }; }; - const novelLeft = new Set(d.hunks.flatMap(h => h.novel_lhs)); - const novelRight = new Set(d.hunks.flatMap(h => h.novel_rhs)); - const hunkLeft = new Map(), hunkRight = new Map(); - for (const [index, hunk] of d.hunks.entries()) { - for (const [l, r] of hunk.lines) { - if (l !== null && !hunkLeft.has(l)) hunkLeft.set(l, index); - if (r !== null && !hunkRight.has(r)) hunkRight.set(r, index); - } - } - const regions = foldRegions(d); - const shown = (index: number) => { - const [l, r] = d.aligned_rows[index]; - return fullContext || (l !== null && hunkLeft.has(l)) || (r !== null && hunkRight.has(r)); - }; - const hidden = hiddenLines(d.aligned_rows, regions, collapsed, shown); - const headers = foldHeaders(d.aligned_rows, regions, collapsed); - let pendingOld: ViewerRow[] = [], - pendingNew: ViewerRow[] = []; + let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { rows.push(...pendingOld, ...pendingNew); pendingOld = []; pendingNew = []; }; - for (const [index, pair] of d.aligned_rows.entries()) { - const hunkIndex = (pair[0] === null ? undefined : hunkLeft.get(pair[0])) - ?? (pair[1] === null ? undefined : hunkRight.get(pair[1])); - // Folded lines lose their cell; a row folded on one side keeps the other side's - // line beside a blank cell, and a row folded on both sides disappears. - const l = pair[0] !== null && hidden[0].has(pair[0]) ? null : pair[0], - r = pair[1] !== null && hidden[1].has(pair[1]) ? null : pair[1]; - if (l === null && r === null) continue; - // Rust's hunk selection includes nearby lines and enclosing syntax context. - // Keep both cells of a selected alignment row; never realign after hiding. - if (!fullContext && hunkIndex === undefined) { + // A collapsed leaf is one fold row: chevron and label, no line number, same toggle as a body fold. + const collapsedLeaf = (left: Leaf | null, right: Leaf | null) => { + flush(); + const anchor = (left ?? right)!; + const folded = (leaf: Leaf | null): SplitLineCell => leaf + ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.id, label: leafLabel(leaf), collapsed: true } } + : { kind: "empty", sign: " ", spans: [] }; + const key = `${fileIndex}:gap:${anchor.id}`; + if (layout === "split") rows.push({ key, fileIndex, left: folded(left), right: folded(right) }); + else rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", spans: [], fold: folded(anchor).fold } }); + }; + const emit = (l: number | null, r: number | null, left: Leaf | null, right: Leaf | null) => { + if (l !== null && hidden[0].has(l)) l = null; + if (r !== null && hidden[1].has(r)) r = null; + if (l === null && r === null) return; + const a = cell(left, l, 0), b = cell(right, r, 1); + const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; + if (layout === "split") { + rows.push({ key, fileIndex, left: a, right: b }); + return; + } + // Correspondence and novelty come from diffr, including formatting-only changes. + if (l !== null && r !== null && a.kind === "context" && b.kind === "context") { flush(); - if (rows.at(-1)?.label !== "…") { - rows.push({ key: `${fileIndex}:gap:${l ?? "_"}:${r ?? "_"}`, fileIndex, label: "…" }); - } - continue; + rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", oldLineNumber: l + 1, + newLineNumber: r + 1, fold: b.fold ?? a.fold, spans: b.spans } }); + return; } - const a = { ...cell(l, 0, novelLeft), fold: headers[0].get(index) }, - b = { ...cell(r, 1, novelRight), fold: headers[1].get(index) }; - const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; - if (layout === "split") - rows.push({ key, fileIndex, hunkIndex, left: a, right: b }); - else { - // Correspondence and novelty come from diffr, including formatting-only changes. - const shared = - l !== null && - r !== null && - !novelLeft.has(l) && - !novelRight.has(r); - if (shared) { - flush(); - rows.push({ - key, - fileIndex, - hunkIndex, - cell: { - kind: "context", - sign: " ", - oldLineNumber: l + 1, - newLineNumber: r + 1, - fold: b.fold ?? a.fold, - spans: b.spans, - }, - }); - } else { - if (l !== null) - pendingOld.push({ - key: `${key}:old`, - fileIndex, - hunkIndex, - cell: { - kind: a.kind === "deletion" ? "deletion" : "context", - sign: a.sign, - oldLineNumber: l + 1, - fold: a.fold, - spans: a.spans, - }, - }); - if (r !== null) - pendingNew.push({ - key: `${key}:new`, - fileIndex, - hunkIndex, - cell: { - kind: b.kind === "addition" ? "addition" : "context", - sign: b.sign, - newLineNumber: r + 1, - fold: b.fold, - spans: b.spans, - }, - }); - } + if (l !== null) + pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: a.kind === "deletion" ? "deletion" : "context", + sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); + if (r !== null) + pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: b.kind === "addition" ? "addition" : "context", + sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + }; + const leafRows = (left: Leaf | null, right: Leaf | null) => { + const anchor = left ?? right; + if (!anchor) return; + if (collapsed.has(anchor.id)) { + if (!hidden[anchor.side].has(anchor.startLine)) collapsedLeaf(left, right); + return; } + const length = Math.max(left ? left.endLine - left.startLine : 0, right ? right.endLine - right.startLine : 0); + for (let i = 0; i < length; i++) + emit(left && i < left.endLine - left.startLine ? left.startLine + i : null, + right && i < right.endLine - right.startLine ? right.startLine + i : null, left, right); + }; + // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.id, index])); + let cursor = 0; + const flushRight = (until: number) => { + for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); + }; + for (const left of leaves[0]) { + const partner = rightIndex.get(left.id); + if (partner === undefined || partner < cursor) { + // Unpaired, or a move whose partner was already shown: one-sided rows. + leafRows(left, null); + continue; + } + flushRight(partner); + leafRows(left, leaves[1][partner]); + cursor = partner + 1; } + flushRight(leaves[1].length); flush(); - const visited = new Set(); + return withFoldLabels(markHunks(rows), texts, theme); +} +function markHunks(rows: ViewerRow[]): ViewerRow[] { + let inHunk = false; for (const row of rows) { - if (row.hunkIndex !== undefined && !visited.has(row.hunkIndex)) { - row.hunkStart = true; - visited.add(row.hunkIndex); - } + const changed = row.cell + ? row.cell.kind !== "context" + : row.left !== undefined && (row.left.kind !== "context" || row.right!.kind !== "context"); + if (changed && !inHunk) row.hunkStart = true; + inHunk = changed; } return rows; } +/** A collapsed fold with a multi-line label shows the label under its header, inside the fold tint. */ +function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): ViewerRow[] { + const result: ViewerRow[] = []; + const labelLines = (fold: RowFold | undefined) => + fold?.collapsed && fold.label.includes("\n") ? fold.label.split("\n") : []; + const labelCell = (text: string | undefined, kind: SplitLineCell["kind"], indent: string): SplitLineCell => + text === undefined + ? { kind: "empty", sign: " ", spans: [] } + : { kind, sign: " ", foldLabel: true, spans: [{ text: indent + text, fg: theme.foldPlaceholder }] }; + for (const row of rows) { + result.push(row); + if (row.cell) { + const lines = labelLines(row.cell.fold); + const line = row.cell.newLineNumber ?? row.cell.oldLineNumber; + const indent = indentOf(texts[row.cell.newLineNumber === undefined ? 0 : 1][(line ?? 1) - 1] ?? "") + " "; + lines.forEach((text, i) => result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + cell: { ...labelCell(text, "context", indent), oldLineNumber: undefined, newLineNumber: undefined } as UnifiedLineCell })); + continue; + } + if (!row.left || !row.right) continue; + const left = labelLines(row.left.fold), right = labelLines(row.right.fold); + const indents = [ + indentOf(texts[0][(row.left.lineNumber ?? 1) - 1] ?? "") + " ", + indentOf(texts[1][(row.right.lineNumber ?? 1) - 1] ?? "") + " ", + ]; + for (let i = 0; i < Math.max(left.length, right.length); i++) + result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + left: labelCell(left[i], row.left.kind === "empty" ? "context" : row.left.kind, indents[0]), + right: labelCell(right[i], row.right.kind === "empty" ? "context" : row.right.kind, indents[1]) }); + } + return result; +} diff --git a/tui/packages/hunk/src/diffr/selection.ts b/tui/packages/hunk/src/diffr/selection.ts index c6abe16e0..af503611f 100644 --- a/tui/packages/hunk/src/diffr/selection.ts +++ b/tui/packages/hunk/src/diffr/selection.ts @@ -33,18 +33,16 @@ export function copySelection( const key = `${row.fileIndex}:${n}`; if (seen.has(key)) continue; seen.add(key); - const source = - selection.side === "left" - ? files[row.fileIndex].diff.lhs_src - : files[row.fileIndex].diff.rhs_src; - if (source !== "Binary") { - let lines = sources.get(row.fileIndex); - if (!lines) { - lines = source.Text.split("\n"); - sources.set(row.fileIndex, lines); - } - result.push(lines[n - 1]); + const diff = files[row.fileIndex].diff; + if (diff.type !== "text") continue; + const source = selection.side === "left" ? diff.lhs : diff.rhs; + if (!source) continue; + let lines = sources.get(row.fileIndex); + if (!lines) { + lines = source.text.split("\n"); + sources.set(row.fileIndex, lines); } + result.push(lines[n - 1]); } return result.join("\n"); } diff --git a/tui/packages/hunk/src/diffr/store.ts b/tui/packages/hunk/src/diffr/store.ts index a34b9c25f..ae44af389 100644 --- a/tui/packages/hunk/src/diffr/store.ts +++ b/tui/packages/hunk/src/diffr/store.ts @@ -1,5 +1,5 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ -import { fileIdentity, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; export interface Snapshot { files: DiffFile[]; inventory: FileChange[]; @@ -27,22 +27,33 @@ export class DiffStore { getSnapshot = () => this.value; accept(event: DiffEvent) { if (event.type === "start") - this.value = { ...this.value, total: event.total, inventory: event.files }; - if (event.type === "file") - this.value = { ...this.value, files: [...this.value.files, event], - inventory: this.value.inventory.some(f => fileIdentity(f) === fileIdentity(event.file)) - ? this.value.inventory : [...this.value.inventory, event.file] }; - if (event.type === "file_error") + this.value = { ...this.value, total: event.files.length, inventory: event.files }; + if (event.type === "file") { + const identity = fileIdentity(event.file); + const inventory = this.value.inventory.some((f) => fileIdentity(f.file) === identity) + ? this.value.inventory + : [ + ...this.value.inventory, + { file: event.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }, + ]; + if (event.diff) + this.value = { ...this.value, inventory, files: [...this.value.files, { ...event, diff: event.diff }] }; + else if (event.error) + this.value = { + ...this.value, + inventory, + failedFiles: new Map(this.value.failedFiles).set(identity, event.error.message), + errors: [...this.value.errors, `${filePath(event.file)}: ${event.error.message}`], + }; + } + if (event.type === "complete") this.value = { ...this.value, - failedFiles: new Map(this.value.failedFiles).set(fileIdentity(event.file), event.message), - errors: [ - ...this.value.errors, - `${event.file.new_path ?? event.file.old_path}: ${event.message}`, - ], + complete: true, + errors: event.aborted + ? [...this.value.errors, `diffr stopped early: ${event.aborted.message}`] + : this.value.errors, }; - if (event.type === "complete") - this.value = { ...this.value, complete: true }; this.listeners.forEach((listener) => listener()); } fail(error: unknown) { diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index 20b26cdd9..cf897aba5 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -1,13 +1,15 @@ import { expect, test } from "bun:test"; import { readDiffStream } from "./stream"; -import { createTestDiffFile } from "./fixture"; +import { createTestDiffFile, fold, leaf } from "./fixture"; +import type { FileChange } from "./wire"; +const manifest = (file: ReturnType): FileChange => + ({ file: file.file, status: "modified", visibility: { collapsed: false, label: "" } }); const start = { type: "start", - version: 1, - before: { kind: "index" }, - after: { kind: "working_tree" }, - total: 1, - files: [createTestDiffFile().file], + version: 2, + lhs: { type: "index" }, + rhs: { type: "working_tree" }, + files: [manifest(createTestDiffFile())], }; async function decode(events: unknown[]) { const text = events.map((e) => JSON.stringify(e)).join("\n"), @@ -17,59 +19,69 @@ async function decode(events: unknown[]) { } return Array.fromAsync(readDiffStream(chunks())); } -test("decode byte-fragmented Unicode stream and preserve fold metadata", async () => { +test("decode byte-fragmented Unicode stream and preserve region trees", async () => { const file = createTestDiffFile(); - file.file.new_path = "变量.ts"; - file.diff.lhs_folds = [ - { - tags: ["function"], - range: { - start: { line: 0, byte_column: 0 }, - end: { line: 2, byte_column: 8 }, - }, - placeholder: "…", - summary: null, - match_kind: "Novel", - }, - ]; - const events = [{...start, files: [file.file]}, file, { type: "complete", succeeded: 1, failed: 0 }]; + file.file = { lhs: { ...file.file.lhs!, path: "变量.ts" }, rhs: { ...file.file.rhs!, path: "变量.ts" } }; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions = [fold(9, [0, 0], [2, 8], [leaf(1, 0, 3)], "Body", ["function"], true)]; + const events = [{...start, files: [manifest(file)]}, file, { type: "complete", succeeded: 1, failed: 0 }]; expect((await decode(events)) as unknown).toEqual(events); }); +test("omitted defaults are filled in", async () => { + const file = createTestDiffFile(); + const bare = { type: "file", file: file.file, diff: { type: "text", + lhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + rhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + stats: { textual: { added: 0, removed: 0 }, structural: { added: 0, removed: 0 } } } }; + const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, + { type: "complete", succeeded: 1, failed: 0 }]); + expect(decoded).toMatchObject({ diff: { lhs: { syntax: [], regions: [{ tags: [], changed: [], children: [], + visibility: { collapsed: false, label: "" } }] } } }); +}); test("reject missing completion, counts, unknown version and invalid ordering", async () => { for (const events of [ [start], - [{ ...start, version: 2 }], + [{ ...start, version: 1 }], [createTestDiffFile()], [start, { type: "complete", succeeded: 1, failed: 0 }], [start, start], + [start, { type: "file", file: createTestDiffFile().file }], + [start, { ...createTestDiffFile(), error: { code: "x", message: "y" } }], ]) await expect(decode(events)).rejects.toThrow(); }); -test("file errors complete without discarding earlier successful results", async () => { +test("file errors and aborts complete without discarding earlier successful results", async () => { const file = createTestDiffFile(); - const failedFile = {...file.file, old_path: "failed.ts", new_path: "failed.ts"}; - expect( - ( - await decode([ - { ...start, total: 2, files: [file.file, failedFile] }, - file, - { type: "file_error", file: failedFile, message: "unreadable" }, - { type: "complete", succeeded: 1, failed: 1 }, - ]) - ).length, - ).toBe(4); + const failed = { lhs: { path: "failed.ts", oid: "3", mode: "100644" }, rhs: { path: "failed.ts", oid: "4", mode: "100644" } }; + const decoded = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "file", file: failed, error: { code: "not_utf8", message: "unreadable" } }, + { type: "complete", succeeded: 1, failed: 1 }, + ]); + expect(decoded.length).toBe(4); + const aborted = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "complete", succeeded: 1, failed: 0, aborted: { code: "hook_failed", message: "503" } }, + ]); + expect(aborted.length).toBe(3); + await expect(decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "complete", succeeded: 1, failed: 0 }, + ])).rejects.toThrow(); }); - -test("manifest validates count and identities while allowing results in arrival order", async () => { +test("manifest validates identities while allowing results in arrival order", async () => { const a = createTestDiffFile(), b = createTestDiffFile(); - b.file = {...b.file, old_path: "b.ts", new_path: "b.ts"}; + b.file = { lhs: { path: "b.ts", oid: "5", mode: "100644" }, rhs: { path: "b.ts", oid: "6", mode: "100644" } }; for (const events of [ - [{...start, files: []}], - [{...start, total: 2, files: [a.file, a.file]}], - [{...start, total: 2, files: [a.file, b.file]}, b, b], + [{...start, files: []}, { type: "complete", succeeded: 0, failed: 0 }, a], + [{...start, files: [manifest(a), manifest(a)]}], + [{...start, files: [manifest(a), manifest(b)]}, b, b], [start, b], ]) await expect(decode(events)).rejects.toThrow(); - expect((await decode([{...start, total: 2, files: [a.file, b.file]}, b, a, + expect((await decode([{...start, files: [manifest(a), manifest(b)]}, b, a, {type: "complete", succeeded: 2, failed: 0}])).map(e => e.type)) .toEqual(["start", "file", "file", "complete"]); }); diff --git a/tui/packages/hunk/src/diffr/stream.ts b/tui/packages/hunk/src/diffr/stream.ts index fabddc313..0fad3d0f6 100644 --- a/tui/packages/hunk/src/diffr/stream.ts +++ b/tui/packages/hunk/src/diffr/stream.ts @@ -20,27 +20,27 @@ export async function* readDiffStream( if (event.type === "start") { if (started) throw new Error("Duplicate diffr start"); started = true; - total = event.total; - inventory = new Set(event.files.map(fileIdentity)); - if (event.files.length !== total || inventory.size !== total) + total = event.files.length; + inventory = new Set(event.files.map((file) => fileIdentity(file.file))); + if (inventory.size !== total) throw new Error("Inconsistent diffr file manifest"); } else { if (!started) throw new Error("Missing diffr start"); - if (event.type === "file" || event.type === "file_error") { + if (event.type === "file") { const identity = fileIdentity(event.file); if (!inventory.has(identity) || received.has(identity)) throw new Error("Unknown or duplicate diffr file result"); received.add(identity); + if (event.diff) succeeded++; + else failed++; } - if (event.type === "file") succeeded++; - if (event.type === "file_error") failed++; if (event.type === "complete") { - if ( - event.succeeded !== succeeded || - event.failed !== failed || - succeeded + failed !== total - ) + const finished = succeeded + failed; + if (event.succeeded !== succeeded || event.failed !== failed) throw new Error("Inconsistent diffr completion counts"); + // An abort ends the run early; every file already answered stays valid. + if ((finished < total) !== (event.aborted !== undefined)) + throw new Error("Inconsistent diffr completion"); complete = true; } } diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index f83d32a16..7cf4d39b3 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -1,120 +1,119 @@ -/** Validate the lossless Rust wire format without translating it through patch metadata. */ +/** Validate diffr's wire v2 (src/protocol.rs): tagged enums, pairings by presence, defaults omitted. */ import { z } from "zod"; const uint = z.number().int().nonnegative(); -const point = z.object({ line: uint, byte_column: uint }); -const range = z.object({ start: point, end: point }); -const span = z.object({ line: uint, start_col: uint, end_col: uint }); -const highlight = z.union([ - z.literal("Delimiter"), - z.object({ - Atom: z.union([ - z.enum([ - "Normal", - "Type", - "Keyword", - "Comment", - "TreeSitterError", - "CanIgnore", - ]), - z.object({ String: z.enum(["StringLiteral", "Text"]) }), - ]), - }), +/** `{lhs, rhs}`, `{lhs}` or `{rhs}`; never neither. */ +function pairing(item: T) { + return z + .object({ lhs: item.optional(), rhs: item.optional() }) + .refine((value) => value.lhs !== undefined || value.rhs !== undefined, { + message: "a pairing needs at least one side", + }); +} +export type Pairing = { lhs?: T; rhs?: T }; +const fileRef = z.object({ path: z.string(), oid: z.string(), mode: z.string() }); +const visibility = z.object({ + collapsed: z.boolean().default(false), + label: z.string().default(""), +}); +const problem = z.object({ code: z.string(), message: z.string() }); +const fileChange = z.object({ + file: pairing(fileRef), + status: z.enum(["added", "deleted", "modified", "renamed", "copied", "type_changed"]), + category: z.string().optional(), + language: z.string().optional(), + visibility: visibility.default({ collapsed: false, label: "" }), +}); +const snapshot = z.discriminatedUnion("type", [ + z.object({ type: z.literal("revision"), rev: z.string() }), + z.object({ type: z.literal("index") }), + z.object({ type: z.literal("working_tree") }), + z.object({ type: z.literal("empty_tree") }), + z.object({ type: z.literal("path"), path: z.string() }), ]); -const position = z.object({ - pos: span, - kind: z.union([ - z.object({ - UnchangedToken: z.object({ - highlight, - self_pos: z.array(span), - opposite_pos: z.array(span), - }), - }), - z.object({ - UnchangedPartOfNovelItem: z.object({ - highlight, - self_pos: span, - opposite_pos: z.array(span), - }), - }), - z.object({ Novel: z.object({ highlight }) }), - z.object({ NovelWord: z.object({ highlight }) }), - z.object({ Ignored: z.object({ highlight }) }), - ]), +const sourcePos = z.object({ line: uint, column: uint }); +const span = z.object({ line: uint, start_column: uint, end_column: uint }); +const syntaxSpan = span.extend({ capture: z.string() }); +export interface Region { + id: number; + start: { line: number; column: number }; + end: { line: number; column: number }; + tags: string[]; + visibility: { collapsed: boolean; label: string }; + kind: "leaf" | "fold"; + changed: { line: number; start_column: number; end_column: number }[]; + children: Region[]; +} +const regionBase = z.object({ + id: uint, + start: sourcePos, + end: sourcePos, + tags: z.array(z.string()).default([]), + visibility: visibility.default({ collapsed: false, label: "" }), }); -const fold = z.object({ - tags: z.array(z.string()), - range, - match_kind: z.union([ - z.literal("Novel"), - z.object({ Unchanged: z.object({ opposite: range }) }), +const region: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + regionBase.extend({ kind: z.literal("leaf"), changed: z.array(span).default([]) }) + .transform((leaf) => ({ ...leaf, children: [] as Region[] })), + regionBase.extend({ kind: z.literal("fold"), children: z.array(region) }) + .transform((fold) => ({ ...fold, changed: [] as Region["changed"] })), ]), - placeholder: z.string(), - summary: z.string().nullable(), +); +const source = z.object({ + text: z.string(), + syntax: z.array(syntaxSpan).default([]), + regions: z.array(region).default([]), }); -const source = z.union([z.literal("Binary"), z.object({ Text: z.string() })]); -export const diffResultSchema = z.object({ - display_path: z.string(), - extra_info: z.string().nullable(), - file_format: z.union([ - z.enum(["PlainText", "Binary"]), - z.object({ SupportedLanguage: z.string() }), - z.object({ TextFallback: z.object({ reason: z.string() }) }), - ]), - lhs_src: source, - rhs_src: source, - lhs_positions: z.array(position), - rhs_positions: z.array(position), - lhs_folds: z.array(fold), - rhs_folds: z.array(fold), - aligned_rows: z.array(z.tuple([uint.nullable(), uint.nullable()])), - hunks: z.array( - z.object({ - novel_lhs: z.array(uint), - novel_rhs: z.array(uint), - lines: z.array(z.tuple([uint.nullable(), uint.nullable()])), +const lineCounts = z.object({ added: uint, removed: uint }); +const stats = z + .object({ + textual: lineCounts, + structural: lineCounts.optional(), + fallback: problem.optional(), + }) + .refine((value) => (value.structural === undefined) !== (value.fallback === undefined), { + message: "stats need exactly one of structural or fallback", + }); +const diff = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), lhs: source.optional(), rhs: source.optional(), stats }) + .refine((value) => value.lhs !== undefined || value.rhs !== undefined, { + message: "a text diff needs at least one side", }), - ), - has_byte_changes: z.tuple([uint, uint]).nullable(), - has_syntactic_changes: z.boolean(), -}); -const operand = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("file"), path: z.string() }), - z.object({ kind: z.literal("revision"), ref: z.string() }), - z.object({ kind: z.literal("index") }), - z.object({ kind: z.literal("working_tree") }), - z.object({ kind: z.literal("empty_tree") }), + z.object({ + type: z.literal("binary"), + lhs: z.object({ size: uint }).optional(), + rhs: z.object({ size: uint }).optional(), + }), ]); -const file = z.object({ - old_path: z.string().nullable(), - new_path: z.string().nullable(), - status: z.enum([ - "added", - "deleted", - "modified", - "renamed", - "type_changed", - "conflicted", - ]), - class: z.string().nullable(), -}); +const fileEvent = z + .object({ type: z.literal("file"), file: pairing(fileRef), diff: diff.optional(), error: problem.optional() }) + .refine((value) => (value.diff === undefined) !== (value.error === undefined), { + message: "a file record needs exactly one of diff or error", + }); export const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("start"), - version: z.literal(1), - before: operand, - after: operand, - total: uint, - files: z.array(file), + version: z.literal(2), + lhs: snapshot, + rhs: snapshot, + files: z.array(fileChange), }), - z.object({ type: z.literal("file"), file, diff: diffResultSchema }), - z.object({ type: z.literal("file_error"), file, message: z.string() }), - z.object({ type: z.literal("complete"), succeeded: uint, failed: uint }), + fileEvent, + z.object({ type: z.literal("complete"), succeeded: uint, failed: uint, aborted: problem.optional() }), ]); -export type FileChange = z.infer; -export const fileIdentity = (file: FileChange) => JSON.stringify([file.old_path, file.new_path]); -export type DiffResult = z.infer; +export type FileRef = z.infer; +export type FileChange = z.infer; +export type Visibility = z.infer; +export type Problem = z.infer; +export type Source = z.infer; +export type TextDiff = Extract, { type: "text" }>; +export type Diff = z.infer; +export type Span = z.infer; +export type SyntaxSpan = z.infer; +export type Stats = z.infer; export type DiffEvent = z.infer; -export type DiffFile = Extract; -export type MatchedPos = DiffResult["lhs_positions"][number]; -export type Highlight = z.infer; +export type FileEvent = Extract; +/** A file record that carries a diff; failures are kept separately by the store. */ +export type DiffFile = FileEvent & { diff: Diff }; +export const fileIdentity = (file: Pairing) => + JSON.stringify([file.lhs?.path ?? null, file.rhs?.path ?? null]); +export const filePath = (file: Pairing) => file.rhs?.path ?? file.lhs?.path ?? ""; diff --git a/tui/packages/hunk/src/main.tsx b/tui/packages/hunk/src/main.tsx index ac7d3f00f..7216f4da8 100644 --- a/tui/packages/hunk/src/main.tsx +++ b/tui/packages/hunk/src/main.tsx @@ -6,9 +6,33 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; +import { cliClient } from "./diffr/config"; import { App } from "./ui/App"; +import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), store = new DiffStore(); +// `diffr config` opens the settings screen: bun run main.tsx --settings --diffr /path/to/diffr [query] +if (args[0] === "--settings") { + if (args[1] !== "--diffr" || !args[2]) { + console.error("Usage: bun run start --settings --diffr /path/to/diffr [initial query]"); + process.exit(2); + } + const settingsRenderer = await createCliRenderer({ + useMouse: false, + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + const quitSettings = () => { + settingsRenderer.destroy(); + process.exit(0); + }; + process.once("SIGTERM", quitSettings); + process.once("SIGINT", quitSettings); + createRoot(settingsRenderer).render( + , + ); + await new Promise(() => {}); +} let child: ReturnType | undefined; let chunks: AsyncIterable; let input: NodeJS.ReadStream = process.stdin; @@ -27,13 +51,14 @@ if (args[0] === "--input" && args[1]) { expectsDifferenceExit = comparison .slice(0, separator < 0 ? comparison.length : separator) .includes("--exit-code"); - child = spawn(args[1], ["--format", "ndjson", ...comparison], { + // The frontend has no tokenizer, so it asks Rust for syntax spans. + child = spawn(args[1], ["--format", "ndjson", "--syntax", ...comparison], { stdio: ["ignore", "pipe", "pipe"], }); chunks = child.stdout!; } else { console.error( - "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -)", + "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -)\n bun run start --settings --diffr /path/to/diffr [query]", ); process.exit(2); } diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 9788b3256..8c87ebcfe 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -5,8 +5,13 @@ import { TextRenderable, type BaseRenderable } from "@opentui/core"; import { App } from "./App"; import { DiffStore } from "../diffr/store"; -import { createTestDiffFile } from "../diffr/fixture"; -import { createFoldedDiffFile } from "../diffr/folds.test"; +import { createTestDiffFile, leaf, line, withIdenticalLines } from "../diffr/fixture"; +import type { DiffFile } from "../diffr/wire"; +import { createFoldedDiffFile } from "../diffr/regions.test"; +const at = (file: DiffFile, path: string) => { + file.file = { lhs: { path, oid: "1", mode: "100644" }, rhs: { path, oid: "2", mode: "100644" } }; + return file; +}; test("render real OpenTUI rows, switch layout, collapse and reopen file with mouse", async () => { const store = new DiffStore(); store.accept(createTestDiffFile()); @@ -60,13 +65,7 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou test("scrolling a large stream keeps terminal renderables bounded", async () => { const store = new DiffStore(), file = createTestDiffFile(); - const lines = Array.from({ length: 5000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks = [ - { novel_lhs: [], novel_rhs: [], lines: lines.map((_, i) => [i, i]) }, - ]; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); + withIdenticalLines(file, 5000); store.accept(file); const testRenderer = await testRender( {}} />, @@ -98,13 +97,11 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", async () => { const store = new DiffStore(); for (const path of ["src/alpha.ts", "src/nested/beta.ts"]) { - const file = createTestDiffFile(); - file.file.new_path = path; + const file = at(createTestDiffFile(), path); const lines = Array.from({length: 60}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [20], novel_rhs: [20,21], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(3, 21, 60)] }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(4, 21, 22, [line(21, 0, 7)]), leaf(3, 22, 60)] }; store.accept(file); } const t = await testRender( {}} />, {width:150, height:20}); @@ -125,9 +122,9 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => f.includes("▾ nested")); await act(async () => { await t.mockMouse.click(9, 0); }); - await t.waitForFrame(f => f.includes("Context: compact")); + await t.waitForFrame(f => f.includes("Toggle context gaps")); await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise(resolve => setTimeout(resolve, 50)); }); - await t.waitForFrame(f => !f.includes("Context: compact")); + await t.waitForFrame(f => !f.includes("Toggle context gaps")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -135,10 +132,8 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as test("Hunk navigation chords and draggable sidebar preserve viewport behavior", async () => { const store = new DiffStore(), file = createTestDiffFile(); const lines = Array.from({length: 150}, (_, i) => `row ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 150)] }; store.accept(file); const t = await testRender( {}} />, {width:150, height:20}); const firstSource = () => Number(t.captureCharFrame().split("\n")[2].match(/row (\d+)/)?.[1]); @@ -179,10 +174,10 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", }); test("initial manifest renders pending tree and remembers a jump until its diff arrives", async () => { const store = new DiffStore(), a = createTestDiffFile(), b = createTestDiffFile(); - a.file = {...a.file, old_path:"src/a.ts", new_path:"src/a.ts"}; - b.file = {...b.file, old_path:"src/b.ts", new_path:"src/b.ts"}; - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:2, files:[a.file,b.file]}); + at(a, "src/a.ts"); + at(b, "src/b.ts"); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); const t = await testRender( {}} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); @@ -202,17 +197,14 @@ test("initial manifest renders pending tree and remembers a jump until its diff test("streaming diffs follow tree order without moving the visible source row", async () => { const store = new DiffStore(); const files = ["z/last.ts", "a/first.ts", "m/middle.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:3, files:files.map(f => f.file)}); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); const t = await testRender( {}} />, {width:150, height:20}); const sidebarLines = () => t.captureCharFrame().split("\n").slice(1,8).map(line => line.slice(0,27).trim()); try { @@ -244,17 +236,14 @@ for (const wrap of [false, true]) for (const unified of [false, true]) test(`stream arrivals preserve code in every commit (wrap=${wrap}, unified=${unified})`, async () => { const store = new DiffStore(); const files = ["m/current.ts", "a/earlier.ts", "z/later.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `source ${path} ${i} ${"word ".repeat(20)}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:files.length, files:files.map(f => f.file)}); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); store.accept(files[0]); let capture: (() => void) | undefined; const commits: string[][] = []; @@ -292,12 +281,12 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a const store = new DiffStore(); const file = createFoldedDiffFile(); // Pad the file past the viewport so the z key can act on a scrolled-to row. - const text = (file.diff.rhs_src as { Text: string }).Text; + if (file.diff.type !== "text") throw new Error(); const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: text + tail.join("\n") + "\n" }; - const rows = file.diff.aligned_rows.length; - file.diff.aligned_rows.push(...tail.map((_, i): [number, number] => [rows + i, rows + i])); - file.diff.hunks[0].lines = file.diff.aligned_rows; + for (const source of [file.diff.lhs!, file.diff.rhs!]) { + source.text += tail.join("\n") + "\n"; + source.regions.push(leaf(99, 8, 28)); + } store.accept(file); const t = await testRender( {}} />, { width: 150, height: 20 }); try { diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 11e455bbc..bc41f40d0 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -28,8 +28,9 @@ import { selectionBounds, type SourceSelection, } from "../diffr/selection"; -import { fileIdentity, type DiffFile } from "../diffr/wire"; -import { foldRegions, nestedRegions, type FoldRegion, type RowFold } from "../diffr/folds"; +import { fileIdentity, filePath, type DiffFile } from "../diffr/wire"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, type RowFold } from "../diffr/regions"; +import { placeholderRows } from "../diffr/rows"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { sliceTextByWidth } from "./lib/text"; @@ -48,15 +49,15 @@ export function App({ const [mode, setMode] = useState("auto"), [showSidebar, setShowSidebar] = useState(true), [wrap, setWrap] = useState(false), - [fullContext, setFullContext] = useState(false), [isLight, setLight] = useState(false); const [scroll, setScroll] = useState(0), - [horizontal, setHorizontal] = useState(0), - [closed, setClosed] = useState>(new Set()); + [horizontal, setHorizontal] = useState(0); + // Files the user closed or opened; unset files follow the manifest's visibility. + const [closed, setClosed] = useState>(new Map()); const [selection, setSelection] = useState(null), [message, setMessage] = useState(""); - // Fold ids collapsed per loaded file; VS Code keeps this per editor model. - const [collapsed, setCollapsed] = useState>>(new Map()); + // Fold ids collapsed per loaded file; unset files start where diffr's visibility says. + const [collapsed, setCollapsed] = useState>>(new Map()); // Vim's z prefix: the next key names the fold command. const pendingZ = useRef(false); const [closedDirectories, setClosedDirectories] = useState>(new Set()); @@ -74,13 +75,19 @@ export function App({ const layout = mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; const loadedByIdentity = useMemo(() => new Map(snapshot.files.map((f, i) => [fileIdentity(f.file), i])), [snapshot.files]); - const inventory = useMemo(() => snapshot.inventory.map(file => ({file})), [snapshot.inventory]); + const inventory = snapshot.inventory; + const manifestOf = (file: DiffFile) => inventory.find(entry => fileIdentity(entry.file) === fileIdentity(file.file)); + const foldsOf = (index: number): ReadonlySet => { + const file = snapshot.files[index]; + return collapsed.get(index) ?? (file.diff.type === "text" ? defaultCollapsed(file.diff) : new Set()); + }; + const isClosed = (index: number) => closed.get(index) ?? manifestOf(snapshot.files[index])?.visibility.collapsed ?? false; const tree = useMemo(() => buildFileTree(inventory), [inventory]); // Keep loaded indexes stable for row keys, selections and file expansion. // Present arriving diffs in tree order throughout loading. const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => { if (node.fileIndex === undefined) return []; - const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex])); + const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex].file)); return loaded === undefined ? [] : [loaded]; }), [snapshot.inventory, tree, loadedByIdentity]); @@ -90,19 +97,23 @@ export function App({ const rows = useMemo(() => { const all = fileOrder.flatMap(index => { const file = snapshot.files[index]; - const folds = collapsed.get(index) ?? new Set(); - const key = `${index}:${layout}:${isLight}:${fullContext}:${[...folds].sort().join(",")}`; + const folds = foldsOf(index); + const key = `${index}:${layout}:${isLight}:${[...folds].sort((a, b) => a - b).join(",")}`; let cached = rowCache.current.get(file); if (cached?.key !== key) { - cached = { key, rows: rowsForFile(file, index, layout, theme, fullContext, folds) }; + cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; rowCache.current.set(file, cached); } - return closed.has(index) ? cached.rows.slice(0, 1) : cached.rows; + if (!isClosed(index)) return cached.rows; + const manifest = manifestOf(file); + return manifest?.visibility.collapsed + ? [cached.rows[0], ...placeholderRows(index, manifest.visibility.label)] + : cached.rows.slice(0, 1); }); for (const [i, error] of snapshot.errors.entries()) all.push({ key: `error:${i}`, fileIndex: -1, label: error }); return all; - }, [snapshot.files, snapshot.errors, layout, theme, closed, fullContext, fileOrder, collapsed]); + }, [snapshot.files, snapshot.errors, snapshot.inventory, layout, theme, closed, fileOrder, collapsed]); const geometry = useMemo( () => measureRows(rows, contentWidth, wrap, horizontal), [rows, contentWidth, wrap, horizontal], @@ -127,39 +138,29 @@ export function App({ } const move = (amount: number) => setScroll((current) => Math.max(0, Math.min(maxScroll, current + amount))); - const toggleFile = (index: number) => { - setClosed((old) => { - const next = new Set(old); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - }; - const regionCache = useRef(new WeakMap()); - const regionsOf = (file: DiffFile) => { - let regions = regionCache.current.get(file); - if (!regions) { - regions = foldRegions(file.diff); - regionCache.current.set(file, regions); - } - return regions; - }; - const setFolds = (fileIndex: number, ids: string[], collapse: boolean) => + const toggleFile = (index: number) => + setClosed((old) => new Map(old).set(index, !isClosed(index))); + const setFolds = (fileIndex: number, ids: number[], collapse: boolean) => setCollapsed((old) => { - const next = new Set(old.get(fileIndex) ?? []); + const next = new Set(foldsOf(fileIndex)); for (const id of ids) if (collapse) next.add(id); else next.delete(id); return new Map(old).set(fileIndex, next); }); - // Recursive commands (Alt-click, zC, zO, zA) include every region nested inside. + // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. const setFold = (fileIndex: number, fold: RowFold, collapse: boolean, recursive: boolean) => { - const regions = regionsOf(snapshot.files[fileIndex]); - const region = regions.find((r) => r.id === fold.id); - if (!region) throw new Error(`Unknown fold ${fold.id}`); - const ids = recursive - ? [fold.id, ...nestedRegions(regions, region).map((r) => r.id)] - : [fold.id]; + const file = snapshot.files[fileIndex]; + if (file.diff.type !== "text") throw new Error("Binary files have no folds"); + const ids = recursive ? [fold.id, ...nestedIds(file.diff, fold.id)] : [fold.id]; setFolds(fileIndex, ids, collapse); }; + // `c`: reveal every context gap, or hide them again. + const toggleContext = () => { + const opened = snapshot.files.some((file, index) => + file.diff.type === "text" && gapIds(file.diff).some((id) => !foldsOf(index).has(id))); + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, gapIds(file.diff), opened); + }); + }; const toggleFold = (fileIndex: number, fold: RowFold, recursive: boolean) => setFold(fileIndex, fold, !fold.collapsed, recursive); const rowFold = (row: ViewerRow) => row.cell?.fold ?? row.right?.fold ?? row.left?.fold; @@ -186,15 +187,10 @@ export function App({ else if (letter === "o") setFold(current.fileIndex, fold, false, recursive); else if (letter === "c") setFold(current.fileIndex, fold, true, recursive); }; - const foldAll = (collapse: boolean) => { - const byFile = new Map(); - for (const row of rows) { - const fold = rowFold(row); - if (fold) byFile.set(row.fileIndex, [...(byFile.get(row.fileIndex) ?? []), fold.id]); - } - if (collapse) for (const [fileIndex, ids] of byFile) setFolds(fileIndex, ids, true); - else setCollapsed(new Map()); - }; + const foldAll = (collapse: boolean) => + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, foldIds(file.diff), collapse); + }); const jump = (index: number) => { const row = geometry.rows.find((r) => r.row.fileIndex === index); if (row) setScroll(Math.min(maxScroll, row.top)); @@ -245,7 +241,7 @@ export function App({ setMode(layout === "split" ? "unified" : "split"); setSelection(null); } else if (key.name === "w") setWrap((v) => !v); - else if (key.name === "c") { setFullContext(v => !v); setSelection(null); } + else if (key.name === "c") { toggleContext(); setSelection(null); } else if (key.name === "t") setLight((v) => !v); else if (key.name === "y") copy(); else if (key.name === "escape") { setSelection(null); setMenu(null); } @@ -266,7 +262,7 @@ export function App({ const viewport = visibleRows(geometry, top, viewportHeight); const currentFile = viewport[0]?.row.fileIndex ?? 0; const activeIdentity = pendingFile ?? (snapshot.files[currentFile] ? fileIdentity(snapshot.files[currentFile].file) : null); - const currentTreeFile = snapshot.inventory.findIndex(file => fileIdentity(file) === activeIdentity); + const currentTreeFile = snapshot.inventory.findIndex(entry => fileIdentity(entry.file) === activeIdentity); useEffect(() => { if (pendingFile === null) return; const index = loadedByIdentity.get(pendingFile); @@ -298,16 +294,20 @@ export function App({ const fileHeader = (fileIndex: number, key: string) => { const file = snapshot.files[fileIndex], count = counts[fileIndex]; if (!file) return null; - const path = file.file.new_path ?? file.file.old_path ?? file.diff.display_path; - const statsWidth = String(count.added).length + String(count.removed).length + 5; + const path = filePath(file.file); + const structural = count.structural && (count.structural.added !== count.textual.added + || count.structural.removed !== count.textual.removed) + ? ` · syntax +${count.structural.added} -${count.structural.removed}` : ""; + const statsWidth = String(count.textual.added).length + String(count.textual.removed).length + 5 + structural.length; return toggleFile(fileIndex)}> - {fit(sanitizeTerminalLine(`${closed.has(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} + {fit(sanitizeTerminalLine(`${isClosed(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} - {` +${count.added}`} - {` -${count.removed} `} + {` +${count.textual.added}`} + {` -${count.textual.removed} `} + {structural && {structural}} ; }; const rendered = []; @@ -328,20 +328,13 @@ export function App({ key={row.key} height={1} width={contentWidth} - fg={theme.muted} + fg={row.loadDiff ? theme.type : theme.muted} selectable={false} onMouseUp={() => { - if (row.key.endsWith(":header")) toggleFile(row.fileIndex); + if (row.loadDiff) toggleFile(row.fileIndex); }} > - {fit( - sanitizeTerminalLine( - row.key.endsWith(":header") - ? `${closed.has(row.fileIndex) ? "▸" : "▾"} ${row.label}` - : row.label, - ), - contentWidth, - )} + {fit(sanitizeTerminalLine(row.loadDiff ? ` ${row.label}` : row.label), contentWidth)} , ); else @@ -396,7 +389,7 @@ export function App({ File: [["Toggle file tree ⌘B / \\", () => setShowSidebar(v => !v)], ["Copy selection y", copy], ["Quit q", onQuit]], View: [[`Layout: ${layout} s`, () => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }], [`Wrap: ${wrap ? "on" : "off"} w`, () => setWrap(v => !v)], - [`Context: ${fullContext ? "all" : "compact"} c`, () => { setFullContext(v => !v); setSelection(null); }], + ["Toggle context gaps c", () => { toggleContext(); setSelection(null); }], ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], ["First file Home", () => setScroll(0)], ["Last file End", () => setScroll(maxScroll)]], @@ -477,7 +470,7 @@ export function App({ selectable={false} onMouseUp={() => { if (node.fileIndex !== undefined) { - const identity = fileIdentity(snapshot.inventory[node.fileIndex]); + const identity = fileIdentity(snapshot.inventory[node.fileIndex].file); const loaded = loadedByIdentity.get(identity); if (loaded !== undefined) { setPendingFile(null); setMessage(""); jump(loaded); } else if (snapshot.failedFiles.has(identity)) setMessage(snapshot.failedFiles.get(identity)!); @@ -490,8 +483,8 @@ export function App({ }); }}> {fit(sanitizeTerminalLine(" ".repeat(depth) + (node.fileIndex === undefined - ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? "! " - : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? " " : "◌ ") + node.name), sidebar - 1)} + ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? "! " + : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? " " : "◌ ") + node.name), sidebar - 1)} ))} diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx new file mode 100644 index 000000000..16a0e06cc --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { Settings } from "./Settings"; +import { schemaFixture, valuesFixture } from "../diffr/config.test"; +import type { ConfigClient } from "../diffr/config"; +test("settings list filters as you type, toggles booleans, and edits numbers through the client", async () => { + const writes: [string, string][] = []; + const client: ConfigClient = { + schema: () => schemaFixture, + show: () => structuredClone(valuesFixture), + set: (key, value) => { writes.push([key, value]); }, + }; + let quit = false; + const t = await testRender( { quit = true; }} />, { width: 120, height: 14 }); + const press = async (key: string) => { + await act(async () => { t.mockInput.pressKey(key); }); + await act(async () => { await t.renderOnce(); }); + }; + const type = async (text: string) => { + for (const char of text) await press(char); + }; + // The rows between the search line and the footer. + const rows = (frame: string) => frame.split("\n").slice(2).filter((l) => !l.includes("esc:")).join("\n"); + try { + await act(async () => { await t.renderOnce(); }); + const frame = () => t.captureCharFrame(); + expect(frame()).toContain("folds.min_lines"); + expect(frame()).toContain("(default: 12)"); + // collapse_tests differs from its default and is marked. + expect(frame().split("\n").find((l) => l.includes("collapse_tests"))).toContain("●"); + await type("cltest"); + expect(rows(frame())).toContain("collapse_tests"); + expect(rows(frame())).not.toContain("min_lines"); + await press("RETURN"); + expect(frame()).toContain("folds.collapse_tests = true"); + expect(writes).toEqual([["folds.collapse_tests", "true"]]); + for (let i = 0; i < 6; i++) await press("BACKSPACE"); + await type("min"); + expect(rows(frame())).toContain("min_lines"); + expect(rows(frame())).not.toContain("collapse_tests"); + await press("RETURN"); + expect(frame()).toContain("enter: save"); + await press("BACKSPACE"); + await press("BACKSPACE"); + await type("20"); + await press("RETURN"); + expect(frame()).toContain("folds.min_lines = 20"); + expect(writes.at(-1)).toEqual(["folds.min_lines", "20"]); + // A lone escape is only recognised once the parser's escape-sequence timeout passes. + await press("ESCAPE"); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + expect(quit).toBe(true); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx new file mode 100644 index 000000000..7a03f962b --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -0,0 +1,87 @@ +/** A searchable settings screen over diffr's config schema; every change writes through the CLI. */ +import { useMemo, useState } from "react"; +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; +import { + filterSettings, + flattenSchema, + formatValue, + isDefault, + parseValue, + type ConfigClient, + type Setting, +} from "../diffr/config"; +import { dark } from "../diffr/rows"; +import { sliceTextByWidth } from "./lib/text"; +const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; +const pad = (text: string, width: number) => fit(text, width).padEnd(width); +export function Settings({ client, onQuit, initialQuery = "" }: { client: ConfigClient; onQuit: () => void; initialQuery?: string }) { + const { width, height } = useTerminalDimensions(); + const [settings, setSettings] = useState(() => flattenSchema(client.schema(), client.show())); + const [query, setQuery] = useState(initialQuery), + [cursor, setCursor] = useState(0), + [draft, setDraft] = useState(null), + [message, setMessage] = useState(""); + const visible = useMemo(() => filterSettings(settings, query), [settings, query]); + const current = visible[Math.min(cursor, Math.max(0, visible.length - 1))]; + const theme = dark; + const commit = (setting: Setting, text: string) => { + const value = parseValue(setting, text); + client.set(setting.key, text); + setSettings((old) => old.map((s) => (s.key === setting.key ? { ...s, value } : s))); + setMessage(`${setting.key} = ${text}`); + }; + useKeyboard((key) => { + if (draft !== null) { + if (key.name === "escape") setDraft(null); + else if (key.name === "return") { + try { + commit(current, draft); + setDraft(null); + } catch (error) { + setMessage(String(error instanceof Error ? error.message : error)); + } + } else if (key.name === "backspace") setDraft(draft.slice(0, -1)); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) setDraft(draft + key.sequence); + return; + } + if (key.name === "escape" || (key.ctrl && key.name === "c")) onQuit(); + else if (key.name === "down" || (key.ctrl && key.name === "n")) setCursor((c) => Math.min(c + 1, visible.length - 1)); + else if (key.name === "up" || (key.ctrl && key.name === "p")) setCursor((c) => Math.max(c - 1, 0)); + else if (key.name === "return" && current) { + if (current.type === "boolean") commit(current, current.value ? "false" : "true"); + else if (current.type === "enum") { + const options = current.options!; + const next = options[(options.indexOf(formatValue(current.value)) + 1) % options.length]; + commit(current, next); + } else setDraft(formatValue(current.value)); + } else if (key.name === "backspace") { setQuery((q) => q.slice(0, -1)); setCursor(0); } + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { setQuery((q) => q + key.sequence); setCursor(0); } + }); + const keyWidth = Math.min(32, Math.max(8, ...settings.map((s) => s.key.length)) + 1); + const valueWidth = Math.min(24, Math.max(6, ...settings.map((s) => formatValue(s.value).length)) + 3); + const descriptionWidth = Math.max(8, width - keyWidth - valueWidth - 4); + const bodyHeight = Math.max(1, height - 4); + const start = Math.max(0, Math.min(cursor - bodyHeight + 1, visible.length - bodyHeight)); + return ( + + {fit(" diffr settings", width)} + {fit(` > ${query}▏`, width)} + {visible.slice(start, start + bodyHeight).map((setting, i) => { + const selected = start + i === cursor; + const value = selected && draft !== null ? `${draft}▏` : formatValue(setting.value); + const description = `${setting.description}${setting.default === undefined ? "" : ` (default: ${formatValue(setting.default)})`}`; + return ( + + {` ${pad(setting.key, keyWidth)} ${pad(description, descriptionWidth)} ${pad(value, valueWidth - 2)}${isDefault(setting) ? " " : "●"}`} + + ); + })} + {visible.length === 0 && {" No settings match"}} + + + {fit(draft !== null ? " enter: save · esc: cancel" : ` type to filter · ↑↓ move · enter: edit · esc: close · ● changed from default ${message}`, width)} + + + ); +} diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 73c71606f..d2a7c6842 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -8,7 +8,7 @@ import type { } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; import type { Palette } from "../../diffr/rows"; -import type { RowFold } from "../../diffr/folds"; +import type { RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map>(); function color(value: string) { @@ -34,8 +34,9 @@ function chevron(fold: RowFold | undefined) { if (!fold) return " "; return fold.collapsed ? "▸" : "▾"; } +/** A multi-line label (pseudocode) hangs under the header, so the header shows only the ellipsis. */ const placeholderText = (fold: RowFold) => - fold.placeholder === "…" ? " ⋯" : ` ⋯ ${fold.placeholder}`; + fold.label === "" || fold.label.includes("\n") ? " ⋯" : ` ⋯ ${fold.label}`; export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -68,12 +69,12 @@ export const CodeRowView = memo(function CodeRowView({ const bg = selectedSide === side ? "#264f78" - : value.kind === "addition" - ? theme.addition - : value.kind === "deletion" - ? theme.deletion - : fold?.collapsed - ? theme.foldBackground + : value.foldLabel || fold?.collapsed + ? theme.foldBackground + : value.kind === "addition" + ? theme.addition + : value.kind === "deletion" + ? theme.deletion : theme.bg; // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index d517665c6..42509833d 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -6,7 +6,7 @@ * column math, the highlight worker, geometry — can share these types without importing * the builders themselves. */ -import type { RowFold } from "../../diffr/folds"; +import type { RowFold } from "../../diffr/regions"; type DiffLineMoveKind = "moved"; export interface RenderSpan { @@ -24,6 +24,8 @@ export interface SplitLineCell { moveKind?: DiffLineMoveKind; /** This cell starts a fold region; the chevron and placeholder come from here. */ fold?: RowFold; + /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ + foldLabel?: boolean; spans: RenderSpan[]; } @@ -34,5 +36,6 @@ export interface UnifiedLineCell { newLineNumber?: number; moveKind?: DiffLineMoveKind; fold?: RowFold; + foldLabel?: boolean; spans: RenderSpan[]; } diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index cb798b4ea..1ded55cad 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"after":{"kind":"file","path":"tui/test/fixtures/after.ts"},"before":{"kind":"file","path":"tui/test/fixtures/before.ts"},"files":[{"class":null,"new_path":"tui/test/fixtures/after.ts","old_path":"tui/test/fixtures/before.ts","status":"modified"}],"total":1,"type":"start","version":1} -{"type":"file","file":{"old_path":"tui/test/fixtures/before.ts","new_path":"tui/test/fixtures/after.ts","status":"modified","class":null},"diff":{"aligned_rows":[[0,0],[null,1],[1,2],[2,3]],"display_path":"tui/test/fixtures/after.ts","extra_info":null,"file_format":{"SupportedLanguage":"TypeScript"},"has_byte_changes":[59,77],"has_syntactic_changes":true,"hunks":[{"lines":[[0,0],[null,1],[1,2],[2,3]],"novel_lhs":[1],"novel_rhs":[1,2]}],"lhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}}}},"placeholder":"String","range":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}},"tags":["string"]}],"lhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":2,"start_col":2}],"self_pos":[{"end_col":8,"line":1,"start_col":2}]}},"pos":{"end_col":8,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":17,"line":1,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":16,"line":2,"start_col":15}],"self_pos":[{"end_col":19,"line":1,"start_col":18}]}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":21,"line":2,"start_col":17}],"self_pos":[{"end_col":24,"line":1,"start_col":20}]}},"pos":{"end_col":24,"line":1,"start_col":20}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":22,"line":2,"start_col":21}],"self_pos":[{"end_col":25,"line":1,"start_col":24}]}},"pos":{"end_col":25,"line":1,"start_col":24}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":3,"start_col":0}],"self_pos":[{"end_col":1,"line":2,"start_col":0}]}},"pos":{"end_col":1,"line":2,"start_col":0}}],"lhs_src":{"Text":"function greet(name: string) {\n return \"hello \" + name;\n}\n"},"rhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}}}},"placeholder":"String","range":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}},"tags":["string"]}],"rhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":9,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":10,"line":1,"start_col":9}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":13,"line":1,"start_col":10}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":14,"line":1,"start_col":13}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":18,"line":1,"start_col":14}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":20,"line":1,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":1,"start_col":2}],"self_pos":[{"end_col":8,"line":2,"start_col":2}]}},"pos":{"end_col":8,"line":2,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":14,"line":2,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":19,"line":1,"start_col":18}],"self_pos":[{"end_col":16,"line":2,"start_col":15}]}},"pos":{"end_col":16,"line":2,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":24,"line":1,"start_col":20}],"self_pos":[{"end_col":21,"line":2,"start_col":17}]}},"pos":{"end_col":21,"line":2,"start_col":17}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":25,"line":1,"start_col":24}],"self_pos":[{"end_col":22,"line":2,"start_col":21}]}},"pos":{"end_col":22,"line":2,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":2,"start_col":0}],"self_pos":[{"end_col":1,"line":3,"start_col":0}]}},"pos":{"end_col":1,"line":3,"start_col":0}}],"rhs_src":{"Text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n"}}} +{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","language":"TypeScript"}]} +{"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} diff --git a/tui/test/pty/launch-integration.test.ts b/tui/test/pty/launch-integration.test.ts index c63877ab3..19001a0ce 100644 --- a/tui/test/pty/launch-integration.test.ts +++ b/tui/test/pty/launch-integration.test.ts @@ -28,19 +28,21 @@ test.skipIf(process.platform === "win32")( ); test("actual Rust output satisfies wire schema and renders both layouts", async () => { const process = Bun.spawn( - [binary, "--no-index", "--format", "ndjson", "--", before, after], + [binary, "--no-index", "--format", "ndjson", "--syntax", "--", before, after], { stdout: "pipe", stderr: "pipe" }, ); const events = await Array.fromAsync(readDiffStream(process.stdout)); expect(await process.exited).toBe(0); const file = events.find((e) => e.type === "file"); expect(file?.type).toBe("file"); - if (file?.type === "file") { - expect(rowsForFile(file, 0, "split", dark).some((r) => r.left)).toBe(true); - expect(rowsForFile(file, 0, "unified", dark).some((r) => r.cell)).toBe( - true, - ); - } + if (file?.type !== "file" || !file.diff) throw new Error("diffr did not produce a diff"); + const loaded = { ...file, diff: file.diff }; + expect(rowsForFile(loaded, 0, "split", dark).some((r) => r.left)).toBe(true); + expect(rowsForFile(loaded, 0, "unified", dark).some((r) => r.cell)).toBe(true); + if (file.diff.type !== "text") throw new Error("fixture diff is not text"); + // --syntax fills capture names; the fixture is TypeScript so keywords are present. + expect(file.diff.rhs!.syntax.some((span) => span.capture.startsWith("keyword"))).toBe(true); + expect(file.diff.rhs!.regions.length).toBeGreaterThan(0); }); test("redirected output and explicit text bypass the TUI even without Bun", () => { for (const format of [[], ["--format", "text"]]) { diff --git a/tui/test/pty/launch.py b/tui/test/pty/launch.py index 8dc47b903..fbf14b3bc 100644 --- a/tui/test/pty/launch.py +++ b/tui/test/pty/launch.py @@ -1,6 +1,7 @@ """Exercise Rust -> Bun -> NDJSON -> terminal, including real mouse escape sequences.""" import errno import fcntl +import re import os import pty import select @@ -18,10 +19,12 @@ os.execv(binary, [binary, '--no-index', *(['--exit-code'] if os.environ.get('DIFFR_TEST_EXIT_CODE') else []), '--', before, after]) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 100, 0, 0)) output = bytearray() +ANSI = re.compile(rb'\x1b\[[0-9;?]*[A-Za-z]') def until(token): + """Wait for token in the screen text; syntax colouring splits words with escape codes.""" deadline = time.monotonic() + 12 while time.monotonic() < deadline: - if token in output: + if token in ANSI.sub(b'', bytes(output)): output.clear() return if select.select([master], [], [], 0.1)[0]: From d2bc109feb825b371be258a72ec1e5e0811162c3 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:53:45 -0700 Subject: [PATCH 06/43] Add layered config, mutations, collapse rules, and a built-in summarizer Configuration is one schema-bearing struct resolved by figment from the bundled defaults, the global file, the repository's diffr.toml, DIFFR_* variables and --set overrides. `diffr config schema|show|set` are the commands frontends drive; `diffr config [query]` opens the terminal settings screen. Doc comments become schema descriptions. Mutations run on the protocol types after projection, never on the diff internals: file mutations on the manifest, fold mutations on each file's region trees. Generated and test files start hidden, classified by git attributes then built-in path rules; deleted bodies start collapsed with a line count; new bodies of at least folds.min_lines lines are summarized as pseudocode by a built-in Gemini client over reqwest, silently off without a key, retried on transient failures and aborting the run otherwise. The JSON-RPC hook is now a fold mutation on the same stack, addresses folds by region id, and jsonrpsee is gone. JavaScript and TypeScript test callbacks carry the `test` fold tag. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- Cargo.lock | 866 ++++++++++++++++++++++++++++++-------- Cargo.toml | 11 +- README.md | 2 +- docs/cli.md | 10 +- docs/config.md | 112 +++++ docs/streaming.md | 72 ++-- src/category.rs | 113 +++++ src/cli.rs | 177 ++++++-- src/config.rs | 396 +++++++++++++++-- src/config/defaults.toml | 32 ++ src/config/store.rs | 165 ++++++++ src/git.rs | 31 +- src/hook.rs | 336 +++++++++------ src/main.rs | 2 + src/mutate/collapse.rs | 116 +++++ src/mutate/mod.rs | 172 ++++++++ src/mutate/summarize.rs | 517 +++++++++++++++++++++++ src/protocol/stream.rs | 120 +++--- src/stream.rs | 18 +- tests/hooks/rpc_server.py | 8 +- tests/streaming/check.py | 136 +++++- 21 files changed, 2920 insertions(+), 492 deletions(-) create mode 100644 docs/config.md create mode 100644 src/category.rs create mode 100644 src/config/store.rs create mode 100644 src/mutate/collapse.rs create mode 100644 src/mutate/mod.rs create mode 100644 src/mutate/summarize.rs diff --git a/Cargo.lock b/Cargo.lock index 1cba9554e..0bdd79a32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,14 +83,12 @@ dependencies = [ ] [[package]] -name = "async-trait" -version = "0.1.92" +name = "atomic" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", + "bytemuck", ] [[package]] @@ -105,12 +103,41 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -134,6 +161,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" @@ -152,18 +185,29 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.5.23" @@ -192,6 +236,15 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -224,6 +277,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -269,14 +331,15 @@ dependencies = [ "bumpalo", "cc", "clap", + "dirs", "encoding_rs", + "figment", "git2", "glob", "hashbrown 0.17.1", "humansize", "ignore", "imara-diff", - "jsonrpsee", "lazy_static", "libc", "line-numbers", @@ -288,17 +351,21 @@ dependencies = [ "radix-heap", "rayon", "regex", + "reqwest", "rustc-hash", + "schemars", "serde", "serde_json", "smallvec", "streaming-iterator", "strsim", "strum", + "tempfile", "terminal_size", "tikv-jemallocator", "tokio", "toml", + "toml_edit 0.25.15+spec-1.1.0", "tree-sitter", "tree-sitter-ada", "tree-sitter-asm", @@ -365,6 +432,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -382,6 +470,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.10.0" @@ -418,12 +518,32 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", ] [[package]] @@ -474,6 +594,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -508,7 +634,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -521,8 +646,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -692,7 +833,6 @@ dependencies = [ "http", "hyper", "hyper-util", - "log", "rustls", "tokio", "tokio-rustls", @@ -705,13 +845,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -857,6 +1000,18 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.12" @@ -882,27 +1037,32 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.20", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.106", ] [[package]] @@ -934,86 +1094,14 @@ dependencies = [ ] [[package]] -name = "jsonrpsee" -version = "0.26.0" +name = "js-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - "jsonrpsee-core", - "jsonrpsee-http-client", - "jsonrpsee-proc-macros", - "jsonrpsee-types", - "tracing", -] - -[[package]] -name = "jsonrpsee-core" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "316c96719901f05d1137f19ba598b5fe9c9bc39f4335f67f6be8613921946480" -dependencies = [ - "async-trait", - "bytes", + "cfg-if", "futures-util", - "http", - "http-body", - "http-body-util", - "jsonrpsee-types", - "pin-project", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tower", - "tracing", -] - -[[package]] -name = "jsonrpsee-http-client" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" -dependencies = [ - "base64", - "http-body", - "hyper", - "hyper-rustls", - "hyper-util", - "jsonrpsee-core", - "jsonrpsee-types", - "rustls", - "rustls-platform-verifier", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tower", - "url", -] - -[[package]] -name = "jsonrpsee-proc-macros" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" -dependencies = [ - "heck", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "jsonrpsee-types" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" -dependencies = [ - "http", - "serde", - "serde_json", - "thiserror 2.0.20", + "wasm-bindgen", ] [[package]] @@ -1046,6 +1134,15 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -1070,6 +1167,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1082,6 +1185,12 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" @@ -1135,12 +1244,41 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.106", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1158,26 +1296,6 @@ dependencies = [ "indexmap", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1250,21 +1368,82 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "proc-macro2" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ - "toml_edit 0.25.14+spec-1.1.0", + "unicode-ident", ] [[package]] -name = "proc-macro2" -version = "1.0.101" +name = "proc-macro2-diagnostics" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.106", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -1276,12 +1455,44 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix-heap" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ffec9df464013295b499298811e6a3de31bf8128092135826517db12dee601" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.10.0" @@ -1302,6 +1513,37 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.12.2" @@ -1331,6 +1573,44 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1339,7 +1619,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1351,6 +1631,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -1360,19 +1649,31 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ - "log", + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1397,14 +1698,15 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.5.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -1417,8 +1719,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] @@ -1433,11 +1735,18 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.17" @@ -1462,6 +1771,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1485,6 +1819,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1515,6 +1855,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -1544,6 +1895,22 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1638,6 +2005,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1650,6 +2020,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1665,7 +2048,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ - "rustix", + "rustix 0.38.34", "windows-sys 0.59.0", ] @@ -1745,6 +2128,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1823,18 +2221,20 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime 0.6.11", + "toml_write", "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.14+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -1847,6 +2247,18 @@ dependencies = [ "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -1857,10 +2269,29 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper", + "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1880,21 +2311,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -2527,6 +2946,15 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -2616,12 +3044,78 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "webpki-root-certs" -version = "0.26.11" +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ - "webpki-root-certs 1.0.9", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] @@ -2650,11 +3144,11 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.48.5", ] [[package]] @@ -2686,17 +3180,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -2717,9 +3211,9 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" @@ -2729,9 +3223,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" @@ -2741,9 +3235,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" @@ -2759,9 +3253,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" @@ -2771,9 +3265,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" @@ -2783,9 +3277,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" @@ -2795,9 +3289,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" diff --git a/Cargo.toml b/Cargo.toml index c1605e89a..690a7a747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,7 @@ ignore = ">= 0.4, < 0.4.30" owo-colors = "4.2.3" imara-diff = { version = "0.2.0", default-features = false } rayon = "1.7.0" -jsonrpsee = { version = "0.26", default-features = false, features = ["http-client", "macros"] } -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "sync"] } tree_magic_mini = "3.2.0" bumpalo = "3.18.0" @@ -134,7 +133,12 @@ tree-sitter-xml = "0.7.0" tree-sitter-yaml = "0.7.0" tree-sitter-zig = "1.1.2" ts-parser-perl = "1.2.0" -toml = { version = "0.8", default-features = false, features = ["parse"] } +toml = { version = "0.8", default-features = false, features = ["display", "parse"] } +figment = { version = "0.10.19", features = ["toml", "env"] } +schemars = "1.2.2" +dirs = "5" +toml_edit = "0.25.15" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] } [target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies] @@ -145,6 +149,7 @@ assert_cmd = "2.0.17" predicates = "3.1.3" pretty_assertions = "1.3.0" +tempfile = "3.27.0" [build-dependencies] # TODO: enable parallel mode, see discussion in diff --git a/README.md b/README.md index 8781fdfe2..81b68f655 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ diffr main HEAD --format ndjson ``` See the [CLI reference](docs/cli.md), [streaming API](docs/streaming.md) -including fold hooks, and [fixture viewer](examples/review/viewer/README.md). +including [configuration](docs/config.md), fold hooks, and [fixture viewer](examples/review/viewer/README.md). The executable is `diffr`; the Cargo package remains `difftastic`. The upstream installation commands below install upstream Difftastic, not this fork. diff --git a/docs/cli.md b/docs/cli.md index 9db9d045e..726a72f50 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,8 +33,14 @@ in [streaming.md](streaming.md), diffing `--jobs N` files at once (default 16) and emitting each as it finishes; `--syntax` adds every token's highlight capture to it. `--format ndjson-v1` is the previous stream, kept while frontends migrate. `--format snapshot` is the fixture text adapter. -`-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, -color, width and inline/split display remain configurable; see `--help`. +`-U N` selects context padding, defaulting to `folds.context_lines`. Matching +limits, `--ignore-comments`, color, width and inline/split display remain +configurable; see `--help`. + +Configuration is layered from the global file, the repository's `diffr.toml`, +`DIFFR_*` variables and `--set key=value`; `--config PATH` replaces the global +file. `diffr config` opens the settings screen, and `diffr config schema`, +`show` and `set` are the commands frontends use. See [config.md](config.md). This is a subset of git diff, not full flag parity: unsupported options and Git magic pathspecs fail explicitly. Untracked files are excluded as in git diff. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 000000000..7ff13e3bd --- /dev/null +++ b/docs/config.md @@ -0,0 +1,112 @@ +# Configuration + +One configuration, layered. Later layers override earlier ones, key by key: + +1. Bundled defaults. +2. The global file, `$XDG_CONFIG_HOME/diffr/config.toml` (usually + `~/.config/diffr/config.toml`). `--config PATH` replaces it and must exist. +3. The repository's `diffr.toml`, when there is one. +4. Environment variables `DIFFR___`, for example + `DIFFR_SUMMARIZE__API_KEY=...` or `DIFFR_FOLDS__MIN_LINES=8`. +5. `--set key=value`, repeatable, for one run. + +Most users need nothing beyond the global file, and `diffr config` writes it. +A repository file is for maintainers: language queries, or a rule the whole +team wants. + +## Commands + +```sh +diffr config # settings screen in the terminal frontend +diffr config summarize # the same, searching for "summarize" +diffr config schema # JSON Schema: description and default per key +diffr config show [--json] # the resolved configuration; api_key redacted +diffr config show --reveal # ...with the key +diffr config set folds.min_lines 20 +diffr config set summarize.api_key "$KEY" +``` + +`set` writes one key into the global file (or the `--config` file), keeping +everything else in it as written. Values are TOML: `true`, `12`, `1.5`, +`["a", "b"]`; anything that is not valid TOML is taken as a string, and a +number given to a string key stays a string. Unknown keys and wrong types are +rejected before anything is written. Frontends drive their settings pages +through these three commands; the schema is the only contract. + +## Keys + +```toml +[folds] +min_lines = 12 # bodies shorter than this are never summarized or collapsed +collapse_deleted = true # deleted function bodies start collapsed, header visible +collapse_generated = true # generated files start hidden +collapse_tests = true # test files start hidden +context_lines = 3 # unchanged lines kept around a change; -U overrides + +[summarize] +enabled = true # off without an API key, silently +provider = "gemini" +model = "gemini-3.8-flash" +api_key = "..." # or GEMINI_API_KEY / GOOGLE_API_KEY in the environment +endpoint = "https://..." # optional base URL override, for proxies and tests +timeout_ms = 60000 +max_concurrency = 16 # requests in flight across files +retries = 3 # on timeouts, rate limits and server errors + +[theme] +name = "default-dark" # a bundled terminal theme +path = "/path/to/theme.toml" # or a Helix-style theme file + +[languages.rust] # tree-sitter queries; see src/config/README.md +folds = '''...''' +context = '''...''' + +[folds.hook] # an external JSON-RPC summarizer; see streaming.md +command = ["uv", "run", "--script", "examples/hooks/summarize.py"] +tags = ["body"] +min_lines = 12 # optional; defaults to folds.min_lines +timeout_ms = 5000 +startup_timeout_ms = 30000 +``` + +`languages` and `folds.hook` are not in the schema, so the settings screen +does not show them; `config set` still accepts their keys. + +## File categories + +Every file in the manifest carries a `category`: `source`, `test`, +`generated`, `docs`, or whatever a repository assigns. In order of precedence: + +1. A `diffr-classify` git attribute. +2. A set `linguist-generated` attribute, which means `generated`. +3. Built-in path rules: lockfiles and `dist/`, `build/`, `vendor/`, + `node_modules/`, `__generated__/`, `*.min.js`, `*.pb.go`, `*.generated.*` + are generated; `tests/`, `test/`, `__tests__/`, `spec/`, `*_test.go`, + `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `conftest.py` are tests; + `docs/` and `*.md` are docs. + +```gitattributes +web/schema.json diffr-classify=generated +fixtures/** diffr-classify=test +``` + +## Mutations + +After each file is diffed and projected onto the wire, mutations adjust what +starts collapsed and what the collapsed label says. They never touch the diff +itself. In order: + +1. `collapse_generated`, `collapse_tests`: the file's `visibility` in the + manifest, before `start` is written. +2. `collapse_deleted`: deleted function bodies of at least `min_lines` lines, + labelled `" lines removed"`. +3. The built-in summarizer: new function bodies of at least `min_lines` lines + on the after side become Python-style pseudocode. The label starts with a + comment line in the file's own syntax, `# pseudocode` or `// pseudocode`, + then the text. +4. `folds.hook`, when configured, over the same selection with its own tags + and threshold; its text gets the same comment line. + +A mutation that fails after its retries ends the run: the stream finishes with +`complete.aborted` (`summarizer_failed` or `hook_failed`) and diffr exits 2. +Files already written stay valid. diff --git a/docs/streaming.md b/docs/streaming.md index fd903a5ae..eec91c9a8 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -18,11 +18,15 @@ from the internal diff is `src/protocol/project.rs`. ## Configuration and ordering -Each invocation loads repository-root `diffr.toml` and compiles it once. -`--config PATH` selects another file instead. Omitted keys retain bundled defaults; -query strings replace whole values, and empty queries disable that feature. +Each invocation resolves one configuration from the bundled defaults, the +global file, the repository's `diffr.toml`, `DIFFR_*` variables and `--set` +overrides, then compiles it once; see [config.md](config.md). Omitted keys +retain defaults; query strings replace whole values, and empty queries disable +that feature. -File categories come from the current workspace's Git attributes: +File categories come from `diffr-classify` and `linguist-generated` Git +attributes, then built-in path rules for lockfiles, build output, tests and +docs: ```gitattributes * diffr-classify=source @@ -100,7 +104,7 @@ One shape everywhere: `{"code": "", "message": ""}`. - On a `file` record, `error` replaces `diff` and the run continues. Codes: `binary`, `not_utf8`, `unsupported_file_type` (symlinks, submodules), `unmerged`, `read_failed`, `fold_pairing`. -- On `complete`, `aborted` reports a run-level failure, currently only +- On `complete`, `aborted` reports a run-level failure: `summarizer_failed` or `hook_failed`. diffr stops pulling files, lets the ones in flight finish, and exits 2. Every `file` record already written stays valid. - Setup failures (bad revision, unreadable config, hook that never starts) @@ -206,32 +210,40 @@ Regular UTF-8 text files are supported. Binary and non-UTF-8 files, symlinks, submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths fail discovery. -## Fold hooks +## Mutations and fold hooks -A trusted hook can replace a fold's collapsed label with richer text, such as -pseudocode, before its `file` record is emitted. A hook is a JSON-RPC 2.0 -server over HTTP that diffr starts once per invocation and calls on loopback: +After projection, mutations set what starts collapsed and what its label says: +generated and test files in the manifest, deleted bodies, and summaries for +new bodies from the built-in summarizer. [config.md](config.md) lists them and +their order. Their output is ordinary `visibility` on files and regions; +frontends need no knowledge of which mutation produced it. + +A trusted hook can add its own summaries. It is a JSON-RPC 2.0 server over +HTTP that diffr starts once per invocation, calls on loopback, and runs after +the built-in summarizer: ```toml [folds.hook] command = ["uv", "run", "--script", "examples/hooks/summarize.py"] -tags = ["body"] # optional; any listed tag qualifies. Omit to send every fold. -min_lines = 12 # optional; default 0 +tags = ["body"] # optional; any listed tag qualifies. Omit to send every tagged fold. +min_lines = 12 # optional; defaults to folds.min_lines timeout_ms = 5000 # optional; per call startup_timeout_ms = 30000 # optional; time allowed to start listening ``` The command starts with the caller's environment plus `DIFFR_HOOK_PORT`, the loopback port it must listen on, and `DIFFR_WORKSPACE`, the diffed repository's -root. It runs in the directory containing the config file, so relative paths in -`command` resolve against the config wherever it lives. Its stdout is discarded -because diffr's own stdout carries the stream; log to stderr. diffr polls the -port until the hook accepts connections, exits 2 before `start` if the hook -exits or misses `startup_timeout_ms`, and kills the hook when the comparison ends. - -Only novel folds on the after side qualify: bodies that exist in the after -source with no counterpart in the before source. Files with no qualifying fold -never reach the hook. Streaming is the only output mode that runs hooks. +root. It runs in the directory of the file that configured it, so relative +paths in `command` resolve against that file wherever it lives. Its stdout is +discarded because diffr's own stdout carries the stream; log to stderr. diffr +polls the port until the hook accepts connections, exits 2 before `start` if +the hook exits or misses `startup_timeout_ms`, and kills the hook when the +comparison ends. + +Only new fold regions on the after side qualify: folds whose id has no +counterpart on the before side, of at least `min_lines` lines. Files with no +qualifying fold never reach the hook. Streaming is the only output mode that +runs mutations. One call per file, method `summarize`, params by name. The worker diffing that file blocks on the reply; other workers keep calling, so a hook must serve @@ -241,24 +253,26 @@ requests concurrently rather than one at a time. // diffr -> hook POST / with a JSON-RPC 2.0 request {"jsonrpc": "2.0", "id": 7, "method": "summarize", "params": { "path": "src/auth.py", "language": "Python", "src": "", - "folds": [{"id": 0, "range": {"start": {"line": 40, "byte_column": 0}, "end": {"line": 88, "byte_column": 1}}, + "folds": [{"id": 3, "start": {"line": 40, "column": 0}, "end": {"line": 88, "column": 1}, "tags": ["body"], "placeholder": "Body"}]}} // hook -> diffr -{"jsonrpc": "2.0", "id": 7, "result": {"0": "def refresh_token(session):\n ..."}} +{"jsonrpc": "2.0", "id": 7, "result": {"3": "def refresh_token(session):\n ..."}} ``` -A fold `id` indexes that file's after-side folds in the order the hook received -them; the matching region's `visibility.label` becomes the returned text. Folds -missing from the result keep their placeholder. An error object, a timeout, an -unknown fold id, or an invalid response is a run-level failure: the stream ends -with `complete.aborted` set to `hook_failed` and diffr exits 2. +A fold `id` is the region's id in that file's `rhs.regions`. The matching +region starts collapsed with the returned text as its label, behind a +`# pseudocode` comment line in the file's own syntax. Folds missing from the +result keep their placeholder. An error object, a timeout, an unknown fold id, +or an invalid response is a run-level failure: the stream ends with +`complete.aborted` set to `hook_failed` and diffr exits 2. `examples/hooks/summarize.py` is a reference hook: an aiohttp server that hands each request to jsonrpcserver and asks Gemini Flash, with thinking disabled, for Python-style pseudocode. It needs `GOOGLE_API_KEY` and answers up to 16 files at once on one asyncio loop with a shared httpx client. `uv run --script` -installs its dependencies on first use. `tests/hooks/rpc_server.py` is a -dependency-free hook used by the tests. +installs its dependencies on first use. The built-in summarizer does the same +job without a subprocess. `tests/hooks/rpc_server.py` is a dependency-free hook +used by the tests. ## Fixture viewer diff --git a/src/category.rs b/src/category.rs new file mode 100644 index 000000000..55e4c67d9 --- /dev/null +++ b/src/category.rs @@ -0,0 +1,113 @@ +//! File categories: `source`, `test`, `generated`, or `docs`. +//! +//! A `diffr-classify` git attribute wins outright. Otherwise a set +//! `linguist-generated` attribute means generated, and failing both, the +//! path is matched against built-in rules. Anything else is unclassified. + +pub(crate) const GENERATED: &str = "generated"; +pub(crate) const TEST: &str = "test"; +pub(crate) const DOCS: &str = "docs"; + +/// Directory names, anywhere in the path, that mark everything under them. +const GENERATED_DIRS: &[&str] = &["dist", "build", "vendor", "node_modules", "__generated__"]; +const TEST_DIRS: &[&str] = &["tests", "test", "__tests__", "spec"]; +const DOCS_DIRS: &[&str] = &["docs"]; + +/// Exact file names. +const GENERATED_FILES: &[&str] = &[ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "Cargo.lock", + "go.sum", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "uv.lock", + "bun.lock", + "bun.lockb", +]; +const TEST_FILES: &[&str] = &["conftest.py"]; + +/// The built-in rule for a repository-relative path, if one matches. +pub(crate) fn from_path(path: &str) -> Option<&'static str> { + let mut parts = path.split('/').filter(|part| !part.is_empty()); + let name = parts.next_back().unwrap_or_default(); + let dirs: Vec<&str> = parts.collect(); + if dirs.iter().any(|dir| GENERATED_DIRS.contains(dir)) + || GENERATED_FILES.contains(&name) + || name.ends_with(".lock") + || name.ends_with(".min.js") + || name.ends_with(".min.css") + || name.ends_with(".pb.go") + || name.contains(".generated.") + || name.contains("_generated.") + { + return Some(GENERATED); + } + if dirs.iter().any(|dir| TEST_DIRS.contains(dir)) + || TEST_FILES.contains(&name) + || name.ends_with("_test.go") + || name.ends_with("_test.py") + || name.starts_with("test_") && name.ends_with(".py") + || name.contains(".test.") + || name.contains(".spec.") + { + return Some(TEST); + } + if dirs.iter().any(|dir| DOCS_DIRS.contains(dir)) || name.ends_with(".md") { + return Some(DOCS); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_in_rules_cover_the_common_layouts() { + for path in [ + "Cargo.lock", + "web/pnpm-lock.yaml", + "dist/app.js", + "src/vendor/lib.c", + "assets/app.min.js", + "api/v1/service.pb.go", + "schema.generated.ts", + "src/__generated__/types.ts", + "src/model_generated.rs", + ] { + assert_eq!(from_path(path), Some(GENERATED), "{path}"); + } + for path in [ + "tests/streaming/check.py", + "src/foo_test.go", + "src/App.test.tsx", + "src/App.spec.ts", + "pkg/test_widgets.py", + "pkg/conftest.py", + "src/__tests__/a.js", + "spec/models/user_spec.rb", + ] { + assert_eq!(from_path(path), Some(TEST), "{path}"); + } + for path in ["README.md", "docs/cli.md", "docs/assets/a.png"] { + assert_eq!(from_path(path), Some(DOCS), "{path}"); + } + for path in [ + "src/main.rs", + "testing/helpers.rs", + "src/testament.py", + "latest.txt", + ] { + assert_eq!(from_path(path), None, "{path}"); + } + } + + #[test] + fn generated_wins_over_test_and_docs() { + assert_eq!(from_path("tests/fixtures/big.lock"), Some(GENERATED)); + assert_eq!(from_path("docs/build/index.html"), Some(GENERATED)); + } +} diff --git a/src/cli.rs b/src/cli.rs index 9af1bec53..64ea5f7f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,7 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. -use crate::config::Config; +use crate::config::{self, Config, Sources}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; -use crate::hook::Hook; +use crate::mutate::Mutations; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; @@ -34,7 +34,8 @@ pub(crate) fn run() -> Result { .version(env!("CARGO_PKG_VERSION")) .about("Structural diffs with Git-style comparison inputs") .arg(Arg::new("repo").long("repo").default_value(".")) - .arg(Arg::new("config").long("config")) + .arg(Arg::new("config_file").long("config").value_name("PATH").help("Replace the global configuration file")) + .arg(Arg::new("set").long("set").value_name("KEY=VALUE").action(ArgAction::Append).help("Override one configuration key for this run")) .arg( Arg::new("jobs") .long("jobs") @@ -59,7 +60,7 @@ pub(crate) fn run() -> Result { .arg(flag("null").short('z')) .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) - .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) + .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to folds.context_lines")) .arg(Arg::new("format").long("format").value_parser(["text", "json", "ndjson", "ndjson-v1", "snapshot"]).default_value("text")) .arg(flag("syntax").help("Include every token's tree-sitter capture name in --format ndjson output")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) @@ -71,14 +72,35 @@ pub(crate) fn run() -> Result { .arg(Arg::new("graph-limit").long("graph-limit").value_parser(clap::value_parser!(usize))) .arg(Arg::new("parse-error-limit").long("parse-error-limit").value_parser(clap::value_parser!(usize))) .arg(Arg::new("items").num_args(0..).value_parser(clap::value_parser!(OsString))) + .subcommand( + Command::new("config") + .about("Show, edit, or open the settings screen for diffr's configuration") + .arg(Arg::new("query").help("Initial search in the settings screen")) + .subcommand(Command::new("schema").about("Print the configuration's JSON Schema")) + .subcommand( + Command::new("show") + .about("Print the resolved configuration") + .arg(flag("json")) + .arg(flag("reveal").help("Do not redact the API key")), + ) + .subcommand( + Command::new("set") + .about("Write one key to the global configuration file") + .arg(Arg::new("key").required(true)) + .arg(Arg::new("value").required(true)), + ), + ) .after_help("Examples:\n diffr\n diffr --cached\n diffr main...HEAD -- src/\n diffr --no-index -- before.rs after.rs\n diffr main HEAD --format ndjson\n\nUnsupported Git flags are rejected; this is not a complete git diff implementation.") .get_matches_from(argv); + if let Some(("config", sub)) = args.subcommand() { + return run_config(&args, sub); + } if opens_tui( args.value_source("format") == Some(clap::parser::ValueSource::CommandLine), args.get_flag("quiet") || args.contains_id("metadata"), io::stdin().is_terminal() && io::stdout().is_terminal(), ) { - return launch_tui(&frontend_args); + return launch_tui(&frontend_args, true); } let format = args.get_one::("format").unwrap().as_str(); let streaming = matches!(format, "ndjson" | "ndjson-v1"); @@ -95,7 +117,7 @@ pub(crate) fn run() -> Result { .cloned() .collect(); let display = DisplayOptions { - num_context_lines: *args.get_one::("unified").unwrap(), + num_context_lines: args.get_one::("unified").copied().unwrap_or(3), terminal_width: args .get_one::("width") .copied() @@ -169,12 +191,18 @@ pub(crate) fn run() -> Result { } changed } else { - let params = Arc::new( - Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?, - ); - let hook = fold_hook(¶ms, workspace)?; + let params = Arc::new(load_config(&args, workspace)?.compile()?); + let context_lines = args + .get_one::("unified") + .copied() + .unwrap_or(params.folds.context_lines); + let mutations = if streaming && format == "ndjson" { + Arc::new(Mutations::from_params(¶ms, workspace)?) + } else { + Arc::new(Mutations::default()) + }; let mut session = DiffSession::open(workspace, comparison, params, &files)?; - session.context_lines = display.num_context_lines; + session.context_lines = context_lines; session.diff_options = diff_options; let changed = session.remaining() > 0; if streaming { @@ -183,12 +211,12 @@ pub(crate) fn run() -> Result { return Err("--jobs must be at least 1".into()); } let failed = if format == "ndjson-v1" { - crate::stream::write(session, jobs, hook, &mut io::stdout().lock())? + crate::stream::write(session, jobs, &mut io::stdout().lock())? } else { let ended = crate::protocol::stream::write( session, jobs, - hook, + mutations, stream_options, &mut io::stdout().lock(), )?; @@ -200,6 +228,10 @@ pub(crate) fn run() -> Result { i32::from(changed && args.get_flag("exit-code")) }); } + let display = DisplayOptions { + num_context_lines: context_lines, + ..display + }; for (file, result) in session { let result = result.map_err(|error| format!("{}: {error}", file.path()))?; render(&result, &args, &display)?; @@ -455,11 +487,15 @@ fn no_index( if args.get_flag("quiet") { return Ok(i32::from(changed)); } - let config = Config::load( - Path::new(args.get_one::("repo").unwrap()), - args.get_one::("config").map(Path::new), - )? - .compile()?; + let workspace = Path::new(args.get_one::("repo").unwrap()); + let config = load_config(args, workspace)?.compile()?; + let display = &DisplayOptions { + num_context_lines: args + .get_one::("unified") + .copied() + .unwrap_or(config.folds.context_lines), + ..display.clone() + }; let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); let rhs = crate::options::FileArgument::from_path_argument(&paths[1]); let compute = || { @@ -480,18 +516,16 @@ fn no_index( }; match args.get_one::("format").map(String::as_str) { Some("ndjson-v1") => { - let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; crate::stream::write_file( &paths[0].to_string_lossy(), &paths[1].to_string_lossy(), compute, - hook.as_deref(), &mut io::stdout().lock(), )?; Ok(i32::from(changed && args.get_flag("exit-code"))) } Some("ndjson") => { - let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; + let mutations = Mutations::from_params(&config, workspace)?; let ended = crate::protocol::stream::write_file( &paths[0].to_string_lossy(), &paths[1].to_string_lossy(), @@ -499,7 +533,7 @@ fn no_index( compute, &config, display.num_context_lines as usize, - hook.as_deref(), + &mutations, stream_options, &mut io::stdout().lock(), )?; @@ -516,13 +550,74 @@ fn no_index( } } -/// Streaming output summarizes large novel folds through the configured hook. -fn fold_hook(params: &crate::config::Params, workspace: &Path) -> Result>> { - params - .hook - .as_ref() - .map(|config| Hook::spawn(config, workspace).map(Arc::new)) - .transpose() +/// Every layer: defaults, the global file (or `--config`), the repository's +/// `diffr.toml`, `DIFFR_*` variables, then `--set` overrides. +fn load_config(args: &ArgMatches, workspace: &Path) -> Result { + let overrides: Vec = args + .get_many::("set") + .into_iter() + .flatten() + .cloned() + .collect(); + Ok(Config::load(Sources { + workspace, + explicit: args.get_one::("config_file").map(Path::new), + overrides: &overrides, + })?) +} + +/// The repository root when `--repo` is inside one, else the directory itself. +fn workspace_for(args: &ArgMatches) -> Result { + let location = std::fs::canonicalize(args.get_one::("repo").unwrap())?; + Ok(match Repository::discover(&location) { + Ok(repo) => repo.workdir().unwrap_or(repo.path()).to_path_buf(), + Err(_) => location, + }) +} + +/// `diffr config`: the settings screen, or one of `schema`, `show`, `set`. +fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result { + let mut stdout = io::stdout().lock(); + match sub.subcommand() { + Some(("schema", _)) => { + serde_json::to_writer_pretty(&mut stdout, &Config::schema())?; + stdout.write_all(b"\n")?; + } + Some(("show", show)) => { + let config = load_config(args, &workspace_for(args)?)?; + let reveal = show.get_flag("reveal"); + if show.get_flag("json") { + serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, reveal))?; + stdout.write_all(b"\n")?; + } else { + let mut redacted = config.clone(); + if !reveal && redacted.summarize.api_key.is_some() { + redacted.summarize.api_key = Some("".to_owned()); + } + stdout.write_all(toml::to_string_pretty(&redacted)?.as_bytes())?; + } + } + Some(("set", set)) => { + let path = match args.get_one::("config_file") { + Some(path) => PathBuf::from(path), + None => config::global_path()?, + }; + config::store::set( + &path, + set.get_one::("key").unwrap(), + set.get_one::("value").unwrap(), + )?; + } + Some((other, _)) => return Err(format!("unknown config command {other}").into()), + None => { + let mut frontend = vec![OsString::from("--settings")]; + if let Some(query) = sub.get_one::("query") { + frontend.push(query.into()); + } + return launch_tui(&frontend, false); + } + } + Ok(0) } /// Explicit machine/text modes and redirected output must never enter the alternate screen. @@ -530,7 +625,9 @@ fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> terminal && !explicit_format && !metadata_or_quiet } -fn launch_tui(args: &[OsString]) -> Result { +/// `comparison` passes the arguments after `--` as the comparison to open; +/// otherwise they are frontend flags such as `--settings`. +fn launch_tui(args: &[OsString], comparison: bool) -> Result { let entry = std::env::var_os("DIFFR_TUI_ENTRY") .map(PathBuf::from) .unwrap_or_else(|| { @@ -543,9 +640,23 @@ fn launch_tui(args: &[OsString]) -> Result { ); } let bun = std::env::var_os("DIFFR_BUN").unwrap_or_else(|| "bun".into()); - let status = std::process::Command::new(bun) - .arg("run").arg(entry).arg("--diffr").arg(std::env::current_exe()?) - .arg("--").args(args).status() + let mut command = std::process::Command::new(bun); + command.arg("run").arg(entry); + if comparison { + command + .arg("--diffr") + .arg(std::env::current_exe()?) + .arg("--") + .args(args); + } else { + // `bun run main.tsx --settings --diffr [query]` + command + .arg(&args[0]) + .arg("--diffr") + .arg(std::env::current_exe()?) + .args(&args[1..]); + } + let status = command.status() .map_err(|error| format!("Could not launch terminal frontend: {error}. Install Bun and run bun install in tui/, or use --format text."))?; Ok(status.code().unwrap_or(2)) } diff --git a/src/config.rs b/src/config.rs index fe29ee81c..170fb6841 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,40 +1,147 @@ -//! Deserialize user settings, resolve defaults, and compile once before diffing. +//! One configuration, layered: bundled defaults, the user's global file, +//! the repository's `diffr.toml`, `DIFFR_*` environment variables, then +//! `--set` overrides. Every field carries a doc comment, which becomes its +//! description in `diffr config schema`. pub(crate) mod query; +pub(crate) mod store; use crate::hash::DftHashMap; use crate::parse::{guess_language::Language, tree_sitter_parser}; +use figment::providers::{Env, Format, Serialized, Toml}; +use figment::Figment; use query::AnnotationQuery; -use serde::Deserialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use strum::IntoEnumIterator; -#[derive(Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct Config { + /// Tree-sitter fold and context queries per language, keyed by the + /// lowercase language name. Omitted queries keep the bundled ones; an + /// empty string disables that feature. + #[schemars(skip)] pub(crate) languages: BTreeMap, + /// What gets folded and what starts collapsed. pub(crate) folds: FoldsConfig, + /// Pseudocode summaries for large new function bodies. + pub(crate) summarize: SummarizeConfig, + /// Colors for the terminal frontend. + pub(crate) theme: ThemeConfig, } -#[derive(Default, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct FoldsConfig { + /// Bodies shorter than this are never summarized or collapsed by a rule. + pub(crate) min_lines: usize, + /// Collapse deleted function bodies, keeping their header line visible. + pub(crate) collapse_deleted: bool, + /// Hide files classified as generated, such as lockfiles and build output. + pub(crate) collapse_generated: bool, + /// Hide files classified as tests. + pub(crate) collapse_tests: bool, + /// Unchanged lines kept visible on either side of a change. `-U` overrides it. + pub(crate) context_lines: u32, + /// An external JSON-RPC summarizer, run after the built-in one. + #[schemars(skip)] pub(crate) hook: Option, } +impl Default for FoldsConfig { + fn default() -> Self { + Self { + min_lines: 12, + collapse_deleted: true, + collapse_generated: true, + collapse_tests: true, + context_lines: 3, + hook: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct SummarizeConfig { + /// Summarize large new function bodies as pseudocode. Silently off + /// without an API key. + pub(crate) enabled: bool, + /// Which model API to call. + pub(crate) provider: Provider, + /// The model name sent to the provider. + pub(crate) model: String, + /// The provider's API key. `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the + /// environment is used when this is unset. + pub(crate) api_key: Option, + /// Override the provider's base URL, for proxies and tests. + pub(crate) endpoint: Option, + /// Per-request limit in milliseconds. + pub(crate) timeout_ms: u64, + /// Requests in flight at once across files. + pub(crate) max_concurrency: usize, + /// Retries after a timeout, rate limit, or server error before the run + /// is aborted. + pub(crate) retries: u32, +} + +impl Default for SummarizeConfig { + fn default() -> Self { + Self { + enabled: true, + provider: Provider::Gemini, + model: "gemini-3.8-flash".to_owned(), + api_key: None, + endpoint: None, + timeout_ms: 60_000, + max_concurrency: 16, + retries: 3, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[schemars(inline)] +pub(crate) enum Provider { + Gemini, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct ThemeConfig { + /// A bundled theme name. + pub(crate) name: String, + /// A Helix-style theme file that replaces the bundled theme. + pub(crate) path: Option, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + name: "default-dark".to_owned(), + path: None, + } + } +} + /// A trusted subprocess that supplies summaries for large novel folds. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct HookConfig { - /// Relative command paths resolve against the config file, wherever it lives. + /// Relative command paths resolve against the file that configured the + /// hook, or the workspace when it came from the environment. #[serde(skip)] pub(crate) dir: PathBuf, pub(crate) command: Vec, /// None sends every tagged fold; otherwise a fold needs one of these tags. #[serde(default)] pub(crate) tags: Option>, + /// Overrides `folds.min_lines` for the hook alone. #[serde(default)] - pub(crate) min_lines: usize, + pub(crate) min_lines: Option, /// Per-call limit once the hook is listening. #[serde(default = "default_timeout_ms")] pub(crate) timeout_ms: u64, @@ -51,7 +158,7 @@ fn default_startup_timeout_ms() -> u64 { 30_000 } -#[derive(Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct LanguageConfig { /// None keeps the bundled query; an empty string disables this feature. @@ -70,6 +177,8 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, + pub(crate) folds: FoldsConfig, + pub(crate) summarize: SummarizeConfig, pub(crate) hook: Option, } @@ -96,33 +205,125 @@ impl LanguageParams { } } +/// Where the layers come from. +pub(crate) struct Sources<'a> { + /// The repository root; `diffr.toml` inside it is the repository layer. + pub(crate) workspace: &'a Path, + /// Replaces the global file. Must exist. + pub(crate) explicit: Option<&'a Path>, + /// `key=value` overrides, applied last. + pub(crate) overrides: &'a [String], +} + +/// The user's global file: `$XDG_CONFIG_HOME/diffr/config.toml`, falling +/// back to `~/.config/diffr/config.toml`. +pub(crate) fn global_path() -> Result { + let dir = match std::env::var_os("XDG_CONFIG_HOME") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => dirs::home_dir() + .ok_or_else(|| ConfigError("no home directory for this user".into()))? + .join(".config"), + }; + Ok(dir.join("diffr").join("config.toml")) +} + +/// A `key=value` override. Values parse as TOML; anything that is not +/// valid TOML is a string. +pub(crate) fn parse_override(text: &str) -> Result<(String, toml::Value), ConfigError> { + let (key, value) = text + .split_once('=') + .ok_or_else(|| ConfigError(format!("--set expects key=value, got {text:?}")))?; + Ok((key.to_owned(), parse_value(value))) +} + +/// TOML scalars and arrays; anything else is the literal string. +pub(crate) fn parse_value(text: &str) -> toml::Value { + match toml::from_str::(&format!("v = {text}")) { + Ok(mut table) => match table.remove("v") { + Some(toml::Value::Table(_)) | Some(toml::Value::Datetime(_)) | None => { + toml::Value::String(text.to_owned()) + } + Some(value) => value, + }, + Err(_) => toml::Value::String(text.to_owned()), + } +} + impl Config { - pub(crate) fn load(workspace: &Path, explicit: Option<&Path>) -> Result { - let path = explicit - .map(Path::to_path_buf) - .unwrap_or_else(|| workspace.join("diffr.toml")); - match std::fs::read_to_string(&path) { - Ok(source) => { - let mut config = Self::from_toml(&source)?; - if let Some(hook) = &mut config.folds.hook { - hook.dir = path - .parent() - .expect("config file has a parent") - .to_path_buf(); + /// Layer every source and resolve it. Missing global and repository + /// files are fine; an explicit `--config` file must exist. + pub(crate) fn load(sources: Sources<'_>) -> Result { + let global = match sources.explicit { + Some(path) => { + if !path.is_file() { + return Err(ConfigError(format!("{}: not found", path.display()))); } - Ok(config) + path.to_path_buf() + } + None => global_path()?, + }; + let overrides = sources + .overrides + .iter() + .map(|text| parse_override(text)) + .collect::, _>>()?; + let figment = |overrides: &[(String, toml::Value)]| { + let mut figment = Figment::from(Serialized::defaults(Config::default())) + .merge(Toml::file(&global)) + .merge(Toml::file(sources.workspace.join("diffr.toml"))) + .merge( + Env::prefixed("DIFFR_") + .filter(|key| key.as_str().contains("__")) + .split("__"), + ); + for (key, value) in overrides { + figment = figment.merge(Serialized::default(key, value)); } - Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => { - Ok(Self::default()) + figment + }; + let typed = figment(&overrides); + let (figment, mut config) = match typed.extract::() { + Ok(config) => (typed, config), + Err(error) => { + // A value like `1234` for a string key parsed as a number; the + // literal text is the intended value. + let as_strings: Vec<_> = sources + .overrides + .iter() + .map(|text| { + let (key, value) = text.split_once('=').expect("validated above"); + (key.to_owned(), toml::Value::String(value.to_owned())) + }) + .collect(); + let retry = figment(&as_strings); + match retry.extract::() { + Ok(config) if as_strings != overrides => (retry, config), + _ => return Err(ConfigError(error.to_string())), + } } - Err(error) => Err(ConfigError(format!("{}: {error}", path.display()))), + }; + if let Some(hook) = &mut config.folds.hook { + hook.dir = figment + .find_metadata("folds.hook.command") + .and_then(|metadata| metadata.source.as_ref()) + .and_then(|source| source.file_path()) + .and_then(|path| path.parent()) + .map(Path::to_path_buf) + .unwrap_or_else(|| sources.workspace.to_path_buf()); } + Ok(config) } pub(crate) fn from_toml(source: &str) -> Result { toml::from_str(source).map_err(|error| ConfigError(error.to_string())) } + /// The JSON Schema of the configuration, with a description and default + /// on every setting. + pub(crate) fn schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes") + } + pub(crate) fn compile(self) -> Result { if let Some(hook) = &self.folds.hook { if hook.command.is_empty() { @@ -132,6 +333,11 @@ impl Config { return Err(ConfigError("folds.hook timeouts must be positive".into())); } } + if self.summarize.timeout_ms == 0 || self.summarize.max_concurrency == 0 { + return Err(ConfigError( + "summarize.timeout_ms and summarize.max_concurrency must be positive".into(), + )); + } let defaults = Self::from_toml(include_str!("config/defaults.toml"))?; let mut resolved = defaults.languages; for (name, overrides) in self.languages { @@ -167,6 +373,8 @@ impl Config { } Ok(Params { languages, + folds: self.folds.clone(), + summarize: self.summarize, hook: self.folds.hook, }) } @@ -301,7 +509,7 @@ mod tests { assert_eq!(hook.tags.as_deref(), Some(&["body".to_owned()][..])); assert_eq!( (hook.min_lines, hook.timeout_ms, hook.startup_timeout_ms), - (30, 5000, 30_000) + (Some(30), 5000, 30_000) ); assert!(Config::from_toml("") .unwrap() @@ -481,6 +689,42 @@ mod tag_tests { } } + #[test] + fn javascript_test_callbacks_are_tagged() { + let params = Params::default(); + for (path, source) in [ + ( + "a.test.ts", + "it('adds', () => {\n expect(1).toBe(1);\n});\n", + ), + ("a.test.js", "describe('x', function () {\n run();\n});\n"), + ( + "a.test.tsx", + "test('y', async () => {\n await run();\n});\n", + ), + ] { + let result = DiffResult::from_sources_with_params(path, "", source, ¶ms); + assert!( + result + .rhs_folds + .iter() + .any(|fold| fold.tags == ["body", "test"]), + "{path}: {:?}", + result.rhs_folds.iter().map(|f| &f.tags).collect::>() + ); + } + let plain = DiffResult::from_sources_with_params( + "a.ts", + "", + "run('z', () => {\n go();\n});\n", + ¶ms, + ); + assert!(plain + .rhs_folds + .iter() + .all(|fold| !fold.tags.iter().any(|tag| tag == "test"))); + } + #[test] fn test_bodies_keep_both_tags_and_remain_paired() { use crate::parse::folds::FoldMatch; @@ -499,3 +743,105 @@ mod tag_tests { FoldMatch::Unchanged { opposite } if *opposite == result.rhs_folds[0].range)); } } + +#[cfg(test)] +mod layer_tests { + use super::*; + + fn load(dir: &Path, overrides: &[&str]) -> Result { + let overrides: Vec = overrides.iter().map(|s| (*s).to_owned()).collect(); + Config::load(Sources { + workspace: dir, + explicit: Some(&dir.join("global.toml")), + overrides: &overrides, + }) + } + + #[test] + fn layers_resolve_in_order() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("global.toml"), + "[folds]\nmin_lines = 5\ncollapse_tests = false\n[summarize]\nmodel = 'global'\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("diffr.toml"), + "[summarize]\nmodel = 'repo'\n", + ) + .unwrap(); + let config = load(dir.path(), &["folds.min_lines=7"]).unwrap(); + assert_eq!(config.folds.min_lines, 7); + assert!(!config.folds.collapse_tests); + assert!(config.folds.collapse_deleted); + assert_eq!(config.summarize.model, "repo"); + assert_eq!(config.summarize.retries, 3); + } + + #[test] + fn overrides_that_look_numeric_still_fill_string_keys() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("global.toml"), "").unwrap(); + let config = load(dir.path(), &["summarize.api_key=1234"]).unwrap(); + assert_eq!(config.summarize.api_key.as_deref(), Some("1234")); + let config = load(dir.path(), &["summarize.api_key=abc-def"]).unwrap(); + assert_eq!(config.summarize.api_key.as_deref(), Some("abc-def")); + let config = load(dir.path(), &["folds.collapse_deleted=false"]).unwrap(); + assert!(!config.folds.collapse_deleted); + } + + #[test] + fn unknown_keys_and_missing_explicit_files_are_errors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("global.toml"), "").unwrap(); + assert!(load(dir.path(), &["folds.typo=1"]).is_err()); + assert!(load(dir.path(), &["folds.min_lines=abc"]).is_err()); + assert!(load(dir.path(), &["folds.min_lines"]).is_err()); + assert!(Config::load(Sources { + workspace: dir.path(), + explicit: Some(&dir.path().join("absent.toml")), + overrides: &[], + }) + .is_err()); + } + + #[test] + fn hook_paths_resolve_against_the_file_that_configured_it() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("elsewhere"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write( + nested.join("global.toml"), + "[folds.hook]\ncommand = ['hook.py']\n", + ) + .unwrap(); + let config = Config::load(Sources { + workspace: dir.path(), + explicit: Some(&nested.join("global.toml")), + overrides: &[], + }) + .unwrap(); + assert_eq!(config.folds.hook.unwrap().dir, nested); + } + + #[test] + fn schema_describes_every_setting_with_its_default() { + let schema = Config::schema(); + let folds = &schema["properties"]["folds"]; + let folds = match folds.get("$ref") { + Some(reference) => { + let name = reference.as_str().unwrap().rsplit('/').next().unwrap(); + &schema["$defs"][name] + } + None => folds, + }; + let min_lines = &folds["properties"]["min_lines"]; + assert_eq!(min_lines["default"], 12); + assert!(min_lines["description"] + .as_str() + .unwrap() + .contains("never summarized")); + assert!(schema["properties"].get("languages").is_none()); + assert!(folds["properties"].get("hook").is_none()); + } +} diff --git a/src/config/defaults.toml b/src/config/defaults.toml index 2bc207200..ce2cb62d5 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -119,6 +119,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -154,6 +162,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -189,6 +205,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -224,6 +248,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ diff --git a/src/config/store.rs b/src/config/store.rs new file mode 100644 index 000000000..981b53ea5 --- /dev/null +++ b/src/config/store.rs @@ -0,0 +1,165 @@ +//! `diffr config`: read the schema and resolved values, and write one key to +//! the global file. +use super::{parse_value, Config, ConfigError}; +use figment::providers::{Format, Serialized, Toml}; +use figment::Figment; +use std::path::Path; + +/// The resolved configuration as JSON, with the same nesting as the TOML. +/// The API key is redacted unless `reveal` is set. +pub(crate) fn show(config: &Config, reveal: bool) -> serde_json::Value { + let mut value = serde_json::to_value(config).expect("config serializes"); + if !reveal && config.summarize.api_key.is_some() { + value["summarize"]["api_key"] = serde_json::Value::String("".to_owned()); + } + value +} + +/// Write `key = value` into the global file, keeping everything else in it +/// as written. The value is validated against the configuration before +/// anything touches the disk: unknown keys and mistyped values are errors. +pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError> { + if key.is_empty() || key.split('.').any(str::is_empty) { + return Err(ConfigError(format!("invalid key {key:?}"))); + } + let existing = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(ConfigError(format!("{}: {error}", path.display()))), + }; + let mut document: toml_edit::DocumentMut = existing + .parse() + .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; + let typed = parse_value(value); + let candidates = [ + Some(typed.clone()), + (!matches!(typed, toml::Value::String(_))).then(|| toml::Value::String(value.to_owned())), + ]; + let mut last_error = None; + for candidate in candidates.into_iter().flatten() { + let mut attempt = document.clone(); + assign(&mut attempt, key, &candidate)?; + match validate(&attempt.to_string()) { + Ok(()) => { + document = attempt; + last_error = None; + break; + } + Err(error) => last_error = Some(error), + } + } + if let Some(error) = last_error { + return Err(error); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| ConfigError(format!("{}: {error}", parent.display())))?; + } + std::fs::write(path, document.to_string()) + .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) +} + +fn validate(text: &str) -> Result<(), ConfigError> { + Figment::from(Serialized::defaults(Config::default())) + .merge(Toml::string(text)) + .extract::() + .map(|_| ()) + .map_err(|error| ConfigError(error.to_string())) +} + +fn assign( + document: &mut toml_edit::DocumentMut, + key: &str, + value: &toml::Value, +) -> Result<(), ConfigError> { + let mut segments = key.split('.').peekable(); + let mut item = document.as_item_mut(); + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + match item.as_table_like_mut() { + Some(table) => table.insert(segment, toml_edit::Item::Value(edit_value(value)?)), + None => return Err(ConfigError(format!("{key}: parent is not a table"))), + }; + return Ok(()); + } + let table = item + .as_table_like_mut() + .ok_or_else(|| ConfigError(format!("{key}: parent is not a table")))?; + if !table.contains_key(segment) { + let mut nested = toml_edit::Table::new(); + nested.set_implicit(true); + table.insert(segment, toml_edit::Item::Table(nested)); + } + item = table.get_mut(segment).expect("just inserted"); + } + Err(ConfigError(format!("invalid key {key:?}"))) +} + +fn edit_value(value: &toml::Value) -> Result { + Ok(match value { + toml::Value::String(text) => toml_edit::Value::from(text.as_str()), + toml::Value::Integer(number) => toml_edit::Value::from(*number), + toml::Value::Float(number) => toml_edit::Value::from(*number), + toml::Value::Boolean(flag) => toml_edit::Value::from(*flag), + toml::Value::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(edit_value(item)?); + } + toml_edit::Value::Array(array) + } + toml::Value::Table(_) | toml::Value::Datetime(_) => { + return Err(ConfigError( + "config set takes a scalar or array value".into(), + )); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_writes_typed_values_and_keeps_the_rest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + set(&path, "folds.min_lines", "20").unwrap(); + set(&path, "summarize.api_key", "1234").unwrap(); + set(&path, "folds.collapse_tests", "false").unwrap(); + set(&path, "summarize.model", "gemini-x").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("min_lines = 20"), "{text}"); + assert!(text.contains("api_key = \"1234\""), "{text}"); + assert!(text.contains("collapse_tests = false"), "{text}"); + assert!(text.contains("model = \"gemini-x\""), "{text}"); + let config = Config::from_toml(&text).unwrap(); + assert_eq!(config.folds.min_lines, 20); + assert_eq!(config.summarize.api_key.as_deref(), Some("1234")); + } + + #[test] + fn set_rejects_unknown_keys_and_wrong_types_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# keep me\n[folds]\nmin_lines = 4\n").unwrap(); + assert!(set(&path, "folds.typo", "1").is_err()); + assert!(set(&path, "folds.min_lines", "abc").is_err()); + assert!(set(&path, "folds", "1").is_err()); + assert!(set(&path, "", "1").is_err()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "# keep me\n[folds]\nmin_lines = 4\n" + ); + } + + #[test] + fn show_redacts_the_key_unless_revealed() { + let mut config = Config::default(); + config.summarize.api_key = Some("secret".to_owned()); + assert_eq!(show(&config, false)["summarize"]["api_key"], ""); + assert_eq!(show(&config, true)["summarize"]["api_key"], "secret"); + assert!(show(&Config::default(), false)["summarize"]["api_key"].is_null()); + assert_eq!(show(&config, false)["folds"]["min_lines"], 12); + } +} diff --git a/src/git.rs b/src/git.rs index 014ab049b..a45ded503 100644 --- a/src/git.rs +++ b/src/git.rs @@ -175,12 +175,14 @@ impl FileChange { ), (None, None) => panic!("a standalone comparison needs at least one path"), }; - let language = language_of(new_path.as_deref().or(old_path.as_deref()).expect("a path")); + let path = new_path.as_deref().or(old_path.as_deref()).expect("a path"); + let language = language_of(path); + let class = crate::category::from_path(path).map(str::to_owned); Self { old_path, new_path, status, - class: None, + class, sides, language, } @@ -205,6 +207,22 @@ impl FileChange { } } +/// `diffr-classify` wins; then `linguist-generated`; then the built-in +/// path rules. +fn category(repo: &Repository, path: &str) -> Result> { + let attr = |name: &str| { + repo.get_attr(Path::new(path), name, AttrCheckFlags::FILE_THEN_INDEX) + .map(AttrValue::from_string) + }; + if let AttrValue::String(value) = attr("diffr-classify")? { + return Ok(Some(value.to_owned())); + } + if let AttrValue::True = attr("linguist-generated")? { + return Ok(Some(crate::category::GENERATED.to_owned())); + } + Ok(crate::category::from_path(path).map(str::to_owned)) +} + fn language_of(path: &str) -> Option { guess(Path::new(path), "", &[]).map(|language| language_name(language).to_owned()) } @@ -420,14 +438,7 @@ impl DiffSession { sides, language, }; - file.class = match AttrValue::from_string(repo.get_attr( - Path::new(file.path()), - "diffr-classify", - AttrCheckFlags::FILE_THEN_INDEX, - )?) { - AttrValue::String(value) => Some(value.to_owned()), - _ => None, - }; + file.class = category(&repo, file.path())?; pending.push(PendingFile { before: Source::from_delta( delta.old_file(), diff --git a/src/hook.rs b/src/hook.rs index 742caca34..af2fa36c7 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -1,59 +1,81 @@ //! Trusted fold-summary hook: a JSON-RPC 2.0 server that diffr starts once per -//! session and calls over loopback HTTP. +//! session and calls over loopback HTTP. It is a fold mutation like the +//! built-in summarizer, and runs after it. //! //! The hook receives its port in `DIFFR_HOOK_PORT` and the diffed repository in -//! `DIFFR_WORKSPACE`. Each call carries one file and its large novel folds on -//! the after side. Workers block on their own call while the client multiplexes -//! every in-flight request on a small tokio runtime, so files still stream out -//! as each worker finishes. +//! `DIFFR_WORKSPACE`. Each call carries one file and its large new fold +//! regions on the after side. Workers block on their own call while the +//! client multiplexes every in-flight request on a small tokio runtime, so +//! files still stream out as each worker finishes. use crate::config::HookConfig; -use crate::parse::folds::FoldMatch; -use crate::review::wire; -use crate::summary::{DiffResult, FileContent, FileFormat}; -use jsonrpsee::core::ClientError; -use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; -use jsonrpsee::proc_macros::rpc; -use serde::Serialize; -use serde_json::Value; +use crate::mutate::{ + collapse, ids, is_fold, line_count, summary_label, walk, walk_mut, FoldMutation, +}; +use crate::protocol::{FileChange, Pairing, Problem, Region, Source, SourceRange}; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; #[derive(Serialize)] pub(crate) struct RequestFold { - /// Index into the file's rhs folds. - id: usize, - range: Value, + /// The region id on the after side. + id: u32, + range: SourceRange, tags: Vec, + /// The label the region has before the hook answers, e.g. `Body`. placeholder: String, } -/// The interface every hook implements. Params are sent by name; the result -/// maps fold ids, as strings, to replacement text. -#[rpc(client)] -trait FoldHook { - #[method(name = "summarize", param_kind = map)] - async fn summarize( - &self, - path: String, - language: Option, - src: String, - folds: Vec, - ) -> jsonrpsee::core::RpcResult>; +#[derive(Serialize)] +struct Params<'a> { + path: &'a str, + language: Option<&'a str>, + src: &'a str, + folds: Vec, +} + +#[derive(Serialize)] +struct Request<'a> { + jsonrpc: &'static str, + id: u64, + method: &'static str, + params: Params<'a>, +} + +#[derive(Deserialize)] +struct Response { + #[serde(default)] + result: Option>, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct RpcError { + message: String, } pub(crate) struct Hook { config: HookConfig, + min_lines: usize, child: Mutex, runtime: tokio::runtime::Runtime, - client: HttpClient, + client: reqwest::Client, + url: String, + next_id: AtomicU64, } impl Hook { - pub(crate) fn spawn(config: &HookConfig, workspace: &Path) -> crate::git::Result { + pub(crate) fn spawn( + config: &HookConfig, + default_min_lines: usize, + workspace: &Path, + ) -> crate::git::Result { let port = free_port()?; let mut child = Command::new(&config.command[0]) .args(&config.command[1..]) @@ -76,93 +98,130 @@ impl Hook { .thread_name("diffr-hook-client") .enable_all() .build()?; - let client = runtime.block_on(async { - HttpClientBuilder::default() - .request_timeout(Duration::from_millis(config.timeout_ms)) - .build(format!("http://127.0.0.1:{port}")) - })?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(config.timeout_ms)) + .build()?; Ok(Self { config: config.clone(), + min_lines: config.min_lines.unwrap_or(default_min_lines), child: Mutex::new(child), runtime, client, + url: format!("http://127.0.0.1:{port}/"), + next_id: AtomicU64::new(1), }) } - /// Fill in summaries for this file's qualifying folds, blocking on the hook. - /// Files without qualifying folds never reach the hook. - pub(crate) fn summarize(&self, diff: &mut DiffResult) -> Result<(), String> { - let selected: Vec = diff - .rhs_folds - .iter() - .enumerate() - .filter(|(_, fold)| self.qualifies(fold)) - .map(|(index, _)| index) - .collect(); - if selected.is_empty() { - return Ok(()); + fn qualifies(&self, region: &Region, lhs_ids: &crate::hash::DftHashSet) -> bool { + if !is_fold(region) || lhs_ids.contains(®ion.id) { + return false; } - let FileContent::Text(src) = &diff.rhs_src else { - return Ok(()); + if line_count(region) < self.min_lines { + return false; + } + match &self.config.tags { + Some(tags) => region.tags.iter().any(|tag| tags.contains(tag)), + None => !region.tags.is_empty(), + } + } + + fn call(&self, params: Params<'_>) -> Result, String> { + let request = Request { + jsonrpc: "2.0", + id: self.next_id.fetch_add(1, Ordering::Relaxed), + method: "summarize", + params, }; - let language = match &diff.file_format { - FileFormat::SupportedLanguage(language) => { - Some(crate::parse::guess_language::language_name(*language).to_owned()) + self.runtime.block_on(async { + let response = self + .client + .post(&self.url) + .json(&request) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + format!("fold hook timed out after {}ms", self.config.timeout_ms) + } else { + format!("fold hook: {error}") + } + })?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("fold hook: {error}"))?; + let response: Response = serde_json::from_str(&body) + .map_err(|error| format!("fold hook: invalid response ({status}): {error}"))?; + match (response.result, response.error) { + (_, Some(error)) => Err(format!("fold hook reported: {}", error.message)), + (Some(result), None) => Ok(result), + (None, None) => Err("fold hook: response has neither result nor error".to_owned()), } - _ => None, + }) + } +} + +impl FoldMutation for Hook { + /// Files without qualifying folds never reach the hook. + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default(); + let Some(rhs) = sides.rhs() else { + return Ok(()); }; - let folds = selected - .iter() - .map(|&index| { - let fold = &diff.rhs_folds[index]; - RequestFold { - id: index, - range: wire::range(&fold.range), - tags: fold.tags.clone(), - placeholder: fold.placeholder.clone(), - } - }) - .collect(); + let mut folds = Vec::new(); + walk(&rhs.regions, &mut |region| { + if self.qualifies(region, &lhs_ids) { + folds.push(RequestFold { + id: region.id, + range: region.range, + tags: region.tags.clone(), + placeholder: region.visibility.label.clone(), + }); + } + }); + if folds.is_empty() { + return Ok(()); + } + let selected: Vec = folds.iter().map(|fold| fold.id).collect(); + let language = file.language.as_deref(); let texts = self - .runtime - .block_on(FoldHookClient::summarize( - &self.client, - diff.display_path.clone(), + .call(Params { + path: file + .file + .rhs() + .map(|side| side.path.as_str()) + .unwrap_or_default(), language, - src.clone(), + src: &rhs.text, folds, - )) - .map_err(|error| match error { - ClientError::Call(error) => format!("fold hook reported: {}", error.message()), - ClientError::RequestTimeout => { - format!("fold hook timed out after {}ms", self.config.timeout_ms) - } - other => format!("fold hook: {other}"), + }) + .map_err(|message| Problem { + code: "hook_failed".to_owned(), + message, })?; + let mut by_id = BTreeMap::new(); for (key, text) in texts { - let index: usize = key + let id: u32 = key .parse() .ok() - .filter(|index| selected.contains(index)) - .ok_or_else(|| format!("fold hook answered for unknown fold {key:?}"))?; - diff.rhs_folds[index].summary = Some(text); + .filter(|id| selected.contains(id)) + .ok_or_else(|| Problem { + code: "hook_failed".to_owned(), + message: format!("fold hook answered for unknown fold {key:?}"), + })?; + by_id.insert(id, text); } + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else { + unreachable!("rhs regions were selected"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if let Some(text) = by_id.get(®ion.id) { + collapse(region, summary_label(language, text)); + } + }); Ok(()) } - - fn qualifies(&self, fold: &crate::parse::folds::Fold) -> bool { - if !matches!(fold.match_kind, FoldMatch::Novel) { - return false; - } - let lines = (fold.range.end.line.0 - fold.range.start.line.0 + 1) as usize; - if lines < self.config.min_lines { - return false; - } - match &self.config.tags { - Some(tags) => fold.tags.iter().any(|tag| tags.contains(tag)), - None => true, - } - } } /// Reserve a loopback port for the hook. The listener is released before the @@ -202,7 +261,7 @@ impl Drop for Hook { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::mutate::summarize::tests::project; use std::sync::Arc; fn hook(mode: &str, timeout_ms: u64) -> crate::git::Result { @@ -214,36 +273,40 @@ mod tests { mode.into(), ], tags: Some(vec!["body".into()]), - min_lines: 2, + min_lines: Some(2), timeout_ms, startup_timeout_ms: 10_000, }; - Hook::spawn(&config, Path::new(".")) - } - - fn diff(rhs: &str) -> DiffResult { - let params = Config::from_toml("").unwrap().compile().unwrap(); - DiffResult::from_sources_with_options( - "file.py", - "", - rhs, - ¶ms, - &crate::options::DisplayOptions::default(), - &crate::options::DiffOptions::default(), - ) + Hook::spawn(&config, 12, Path::new(".")) } const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n"; + fn fold_labels(sides: &Pairing) -> Vec<(bool, String)> { + let mut labels = Vec::new(); + walk(&sides.rhs().unwrap().regions, &mut |region| { + if is_fold(region) { + labels.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + labels + } + #[test] fn summaries_land_on_selected_folds_only() { - let hook = hook("first", 5000).unwrap(); - let mut result = diff(LARGE); - assert_eq!(result.rhs_folds.len(), 2); - hook.summarize(&mut result).unwrap(); - assert_eq!(result.rhs_folds[0].summary.as_deref(), Some("summary of f")); - assert_eq!(result.rhs_folds[1].summary, None); - assert!(result.lhs_folds.iter().all(|fold| fold.summary.is_none())); + let hook = hook("echo", 5000).unwrap(); + let (file, mut sides) = project("file.py", "", LARGE); + hook.apply(&file, &mut sides).unwrap(); + // `g` has a one-line body, which is not a region. + assert_eq!( + fold_labels(&sides), + vec![( + true, + "# pseudocode +pseudo Body" + .to_owned() + )] + ); } #[test] @@ -253,24 +316,26 @@ mod tests { .map(|_| { let hook = Arc::clone(&hook); std::thread::spawn(move || { - let mut result = diff(LARGE); - hook.summarize(&mut result).unwrap(); - result.rhs_folds[0].summary.clone() + let (file, mut sides) = project("file.py", "", LARGE); + hook.apply(&file, &mut sides).unwrap(); + fold_labels(&sides)[0].1.clone() }) }) .collect(); for worker in workers { - assert_eq!(worker.join().unwrap().as_deref(), Some("pseudo Body")); + assert_eq!(worker.join().unwrap(), "# pseudocode\npseudo Body"); } } #[test] - fn small_or_untagged_folds_never_reach_the_hook() { + fn small_paired_or_untagged_folds_never_reach_the_hook() { let hook = hook("error", 5000).unwrap(); - let mut result = diff("import os\nimport sys\n"); - hook.summarize(&mut result).unwrap(); - let mut result = diff("def f():\n a()\n"); - hook.summarize(&mut result).unwrap(); + let (file, mut sides) = project("file.py", "", "import os\nimport sys\n"); + hook.apply(&file, &mut sides).unwrap(); + let (file, mut sides) = project("file.py", "", "def f():\n a()\n"); + hook.apply(&file, &mut sides).unwrap(); + let (file, mut sides) = project("file.py", LARGE, LARGE); + hook.apply(&file, &mut sides).unwrap(); } #[test] @@ -283,23 +348,24 @@ mod tests { } #[test] - fn call_failures_are_reported_without_losing_the_diff() { - let mut result = diff(LARGE); + fn call_failures_are_reported_without_changing_the_regions() { + let (file, mut sides) = project("file.py", "", LARGE); let error = hook("slow", 200) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.contains("timed out"), "{error}"); + assert!(error.message.contains("timed out"), "{}", error.message); let error = hook("error", 5000) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.contains("declined"), "{error}"); + assert!(error.message.contains("declined"), "{}", error.message); let error = hook("bad", 5000) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.starts_with("fold hook:"), "{error}"); - assert!(result.rhs_folds.iter().all(|fold| fold.summary.is_none())); + assert!(error.message.starts_with("fold hook:"), "{}", error.message); + assert_eq!(error.code, "hook_failed"); + assert!(fold_labels(&sides).iter().all(|(collapsed, _)| !collapsed)); } } diff --git a/src/main.rs b/src/main.rs index 3f6097657..64d7a7d1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ #![warn(clippy::todo)] #![warn(clippy::dbg_macro)] +mod category; mod cli; mod config; mod conflicts; @@ -57,6 +58,7 @@ mod hash; mod hook; mod line_parser; mod lines; +mod mutate; mod options; mod parse; pub(crate) mod protocol; diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs new file mode 100644 index 000000000..aa877f2cc --- /dev/null +++ b/src/mutate/collapse.rs @@ -0,0 +1,116 @@ +//! Rules that start things collapsed: whole files by category, and deleted +//! function bodies. +use super::{collapse, ids, is_fold, line_count, walk_mut, FileMutation, FoldMutation}; +use crate::category; +use crate::protocol::{FileChange, Pairing, Problem, Source}; + +/// Generated and test files start hidden behind a placeholder. +pub(crate) struct HiddenCategories { + pub(crate) generated: bool, + pub(crate) tests: bool, +} + +impl FileMutation for HiddenCategories { + fn apply(&self, file: &mut FileChange) -> Result<(), Problem> { + let label = match file.category.as_deref() { + Some(category::GENERATED) if self.generated => "Generated file · hidden by default", + Some(category::TEST) if self.tests => "Test file · hidden by default", + _ => return Ok(()), + }; + file.visibility.collapsed = true; + file.visibility.label = label.to_owned(); + Ok(()) + } +} + +/// Deleted bodies of at least `min_lines` start collapsed with a line count. +/// The header line stays visible by fold semantics. +pub(crate) struct DeletedBodies { + pub(crate) min_lines: usize, +} + +impl FoldMutation for DeletedBodies { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default(); + let Some(lhs) = lhs_mut(sides) else { + return Ok(()); + }; + walk_mut(&mut lhs.regions, &mut |region| { + if is_fold(region) + && region.tags.iter().any(|tag| tag == "body") + && !rhs_ids.contains(®ion.id) + && line_count(region) >= self.min_lines + { + let count = line_count(region); + collapse(region, format!("{count} lines removed")); + } + }); + Ok(()) + } +} + +fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> { + match sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs), + Pairing::RightOnly { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutate::walk; + use crate::protocol::{FileRef, FileStatus, Visibility}; + + fn manifest(category: Option<&str>) -> FileChange { + FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "x".to_owned(), + oid: String::new(), + mode: String::new(), + }, + }, + status: FileStatus::Added, + category: category.map(str::to_owned), + language: None, + visibility: Visibility::default(), + } + } + + #[test] + fn hidden_categories_follow_the_switches() { + let rule = HiddenCategories { + generated: true, + tests: false, + }; + let mut file = manifest(Some("generated")); + rule.apply(&mut file).unwrap(); + assert!(file.visibility.collapsed); + assert_eq!(file.visibility.label, "Generated file · hidden by default"); + let mut file = manifest(Some("test")); + rule.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + let mut file = manifest(None); + rule.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + } + + #[test] + fn deleted_bodies_collapse_when_large_and_one_sided() { + let before = "def gone():\n a()\n b()\n c()\n\ndef kept():\n a()\n b()\n c()\n\ndef tiny():\n a()\n"; + let after = "def kept():\n a()\n b()\n c()\n"; + let (file, mut sides) = crate::mutate::summarize::tests::project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + let lhs = sides.lhs().unwrap(); + let mut collapsed = Vec::new(); + walk(&lhs.regions, &mut |region| { + if is_fold(region) && region.visibility.collapsed { + collapsed.push((region.range.start.line, region.visibility.label.clone())); + } + }); + assert_eq!(collapsed, vec![(1, "3 lines removed".to_owned())]); + } +} diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs new file mode 100644 index 000000000..07fa42917 --- /dev/null +++ b/src/mutate/mod.rs @@ -0,0 +1,172 @@ +//! Mutations run on the wire types after projection and before a record is +//! written: file mutations on the manifest, fold mutations on each file's +//! region trees. diffr's diff internals never see them. +//! +//! Order: file mutations (hidden categories), then fold mutations in this +//! sequence: deleted bodies, the built-in summarizer, the JSON-RPC hook. +//! A mutation failure aborts the run; retries belong inside a mutation. +pub(crate) mod collapse; +pub(crate) mod summarize; +use crate::config::Params; +use crate::hash::DftHashSet; +use crate::hook::Hook; +use crate::protocol::{FileChange, Node, Pairing, Problem, Region, Source, Visibility}; +use std::path::Path; + +pub(crate) trait FileMutation: Send + Sync { + fn apply(&self, file: &mut FileChange) -> Result<(), Problem>; +} + +pub(crate) trait FoldMutation: Send + Sync { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem>; +} + +/// The enabled mutations, in the order they run. +#[derive(Default)] +pub(crate) struct Mutations { + file: Vec>, + fold: Vec>, +} + +impl Mutations { + /// Build the registry from configuration. Starting the hook or the + /// summarizer client can fail, which is a setup error before `start`. + pub(crate) fn from_params(params: &Params, workspace: &Path) -> crate::git::Result { + let mut mutations = Self::default(); + let folds = ¶ms.folds; + if folds.collapse_generated || folds.collapse_tests { + mutations.file.push(Box::new(collapse::HiddenCategories { + generated: folds.collapse_generated, + tests: folds.collapse_tests, + })); + } + if folds.collapse_deleted { + mutations.fold.push(Box::new(collapse::DeletedBodies { + min_lines: folds.min_lines, + })); + } + if params.summarize.enabled { + if let Some(summarizer) = + summarize::Summarizer::new(¶ms.summarize, folds.min_lines)? + { + mutations.fold.push(Box::new(summarizer)); + } + } + if let Some(hook) = ¶ms.hook { + mutations + .fold + .push(Box::new(Hook::spawn(hook, folds.min_lines, workspace)?)); + } + Ok(mutations) + } + + pub(crate) fn apply_file(&self, file: &mut FileChange) -> Result<(), Problem> { + for mutation in &self.file { + mutation.apply(file)?; + } + Ok(()) + } + + pub(crate) fn apply_fold( + &self, + file: &FileChange, + sides: &mut Pairing, + ) -> Result<(), Problem> { + for mutation in &self.fold { + mutation.apply(file, sides)?; + } + Ok(()) + } +} + +// ── region helpers ──────────────────────────────────────────────────────── + +/// Visit every region, parents before children. +pub(crate) fn walk_mut(regions: &mut [Region], visit: &mut impl FnMut(&mut Region)) { + for region in regions { + visit(region); + if let Node::Fold { children } = &mut region.node { + walk_mut(children, visit); + } + } +} + +pub(crate) fn walk(regions: &[Region], visit: &mut impl FnMut(&Region)) { + for region in regions { + visit(region); + if let Node::Fold { children } = ®ion.node { + walk(children, visit); + } + } +} + +/// Every id on a side, to tell one-sided regions from paired ones. +pub(crate) fn ids(regions: &[Region]) -> DftHashSet { + let mut ids = DftHashSet::default(); + walk(regions, &mut |region| { + ids.insert(region.id); + }); + ids +} + +pub(crate) fn line_count(region: &Region) -> usize { + region.range.lines().len() +} + +pub(crate) fn is_fold(region: &Region) -> bool { + matches!(region.node, Node::Fold { .. }) +} + +/// The line-comment marker for a language's display name, `#` when unknown. +pub(crate) fn comment_marker(language: Option<&str>) -> &'static str { + match language.unwrap_or_default() { + "C" | "C++" | "C#" | "Objective-C" | "Rust" | "Go" | "Java" | "Kotlin" | "Swift" + | "Scala" | "Dart" | "JavaScript" | "JavaScript JSX" | "TypeScript" | "TypeScript TSX" + | "QML" | "PHP" | "Zig" | "Solidity" | "Proto" | "Verilog" | "Smali" | "Gleam" | "F#" + | "Apex" => "//", + "Lua" | "SQL" | "Haskell" | "Elm" | "Ada" | "VHDL" | "OCaml" | "OCaml Interface" => "--", + "Common Lisp" | "Emacs Lisp" | "Clojure" | "Scheme" | "Racket" | "Janet" | "Assembly" => { + ";" + } + "Erlang" | "LaTeX" => "%", + "Pascal" => "//", + _ => "#", + } +} + +/// The collapsed label for a summary: a comment line saying it is +/// pseudocode, then the text. +pub(crate) fn summary_label(language: Option<&str>, text: &str) -> String { + format!( + "{} pseudocode\n{}", + comment_marker(language), + text.trim_end() + ) +} + +pub(crate) fn collapse(region: &mut Region, label: String) { + region.visibility = Visibility { + collapsed: true, + label, + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment_markers_follow_the_language() { + assert_eq!(comment_marker(Some("Python")), "#"); + assert_eq!(comment_marker(Some("Rust")), "//"); + assert_eq!(comment_marker(Some("TypeScript TSX")), "//"); + assert_eq!(comment_marker(Some("Lua")), "--"); + assert_eq!(comment_marker(Some("Clojure")), ";"); + assert_eq!(comment_marker(Some("Erlang")), "%"); + assert_eq!(comment_marker(None), "#"); + assert_eq!( + summary_label(Some("Go"), "x = 1\nreturn x\n"), + "// pseudocode\nx = 1\nreturn x" + ); + } +} diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs new file mode 100644 index 000000000..8585bab3d --- /dev/null +++ b/src/mutate/summarize.rs @@ -0,0 +1,517 @@ +//! The built-in summarizer: large new function bodies become Python-style +//! pseudocode, shown in place of the collapsed body. +use super::{collapse, ids, is_fold, line_count, summary_label, walk, walk_mut, FoldMutation}; +use crate::config::{Provider, SummarizeConfig}; +use crate::protocol::{FileChange, Pairing, Problem, Source}; +use serde::Deserialize; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::sync::Semaphore; + +const SYSTEM: &str = + "You rewrite regions of a source file as terse Python-style pseudocode for a diff \ +viewer that shows the pseudocode in place of the collapsed region. The user supplies \ +one numbered source file and a list of folds, each with an id and 1-based line range. \ +For each fold, write pseudocode covering only that fold's lines: keep the control flow \ +and the names that matter, drop types, error plumbing and boilerplate. Aim for about one \ +pseudocode line per five source lines, between one and eight lines per fold. Reply with \ +one {id, pseudocode} object per fold."; + +const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; + +pub(crate) struct Summarizer { + config: SummarizeConfig, + min_lines: usize, + api_key: String, + endpoint: String, + runtime: tokio::runtime::Runtime, + client: reqwest::Client, + limit: Semaphore, +} + +/// One fold to summarize: its region id and 1-based inclusive line range. +struct Request { + id: u32, + first_line: u32, + last_line: u32, +} + +#[derive(Deserialize)] +struct Answer { + id: u32, + pseudocode: String, +} + +impl Summarizer { + /// `None` when no API key resolves: the summarizer is then simply off. + pub(crate) fn new( + config: &SummarizeConfig, + min_lines: usize, + ) -> crate::git::Result> { + let Some(api_key) = resolve_key(config) else { + return Ok(None); + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("diffr-summarizer") + .enable_all() + .build()?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(config.timeout_ms)) + .build()?; + Ok(Some(Self { + config: config.clone(), + min_lines, + api_key, + endpoint: config + .endpoint + .clone() + .unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned()), + runtime, + client, + limit: Semaphore::new(config.max_concurrency), + })) + } + + fn url(&self) -> String { + match self.config.provider { + Provider::Gemini => format!( + "{}/v1beta/models/{}:generateContent", + self.endpoint.trim_end_matches('/'), + self.config.model + ), + } + } + + fn prompt(&self, path: &str, language: Option<&str>, src: &str, folds: &[Request]) -> String { + let numbered: Vec = src + .split_terminator('\n') + .enumerate() + .map(|(index, line)| format!("{:5} | {line}", index + 1)) + .collect(); + let ranges: Vec = folds + .iter() + .map(|fold| { + format!( + "- fold {}: lines {}-{}", + fold.id, fold.first_line, fold.last_line + ) + }) + .collect(); + format!( + "File {path} ({}):\n\n{}\n\nFolds:\n{}", + language.unwrap_or("unknown language"), + numbered.join("\n"), + ranges.join("\n") + ) + } + + /// One request per file, retried on transient failures. Any other + /// failure is a run-level problem. + fn complete( + &self, + path: &str, + language: Option<&str>, + src: &str, + folds: &[Request], + ) -> Result, Problem> { + let body = json!({ + "systemInstruction": {"parts": [{"text": SYSTEM}]}, + "contents": [{"role": "user", "parts": [{"text": self.prompt(path, language, src, folds)}]}], + "generationConfig": { + "temperature": 0, + "maxOutputTokens": 160 * folds.len() + 100, + "thinkingConfig": {"thinkingBudget": 0}, + "responseMimeType": "application/json", + "responseSchema": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": {"id": {"type": "INTEGER"}, "pseudocode": {"type": "STRING"}}, + "required": ["id", "pseudocode"], + }, + }, + }, + }); + let url = self.url(); + let problem = |code: &str, message: String| Problem { + code: code.to_owned(), + message: format!("{}: {message}", self.config.model), + }; + let text = self.runtime.block_on(async { + let _permit = self + .limit + .acquire() + .await + .expect("semaphore is never closed"); + let mut attempt = 0; + loop { + let response = self + .client + .post(&url) + .header("x-goog-api-key", &self.api_key) + .json(&body) + .send() + .await; + let retry = match response { + Ok(response) if response.status().is_success() => { + return response + .json::() + .await + .map_err(|error| problem("summarizer_failed", error.to_string())); + } + Ok(response) + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS + || response.status().is_server_error() => + { + format!("HTTP {}", response.status()) + } + Ok(response) => { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(problem( + "summarizer_failed", + format!( + "HTTP {status} {}", + detail.chars().take(200).collect::() + ), + )); + } + Err(error) + if error.is_timeout() || error.is_connect() || error.is_request() => + { + error.to_string() + } + Err(error) => return Err(problem("summarizer_failed", error.to_string())), + }; + if attempt >= self.config.retries { + return Err(problem( + "summarizer_failed", + format!("{retry} after {} attempts", attempt + 1), + )); + } + attempt += 1; + tokio::time::sleep(Duration::from_millis(250 * (1 << attempt.min(6)))).await; + } + })?; + let content = text["candidates"][0]["content"]["parts"] + .as_array() + .and_then(|parts| parts.last()) + .and_then(|part| part["text"].as_str()) + .ok_or_else(|| problem("summarizer_failed", "no text in the response".to_owned()))?; + let answers: Vec = serde_json::from_str(content) + .map_err(|error| problem("summarizer_failed", format!("{error}: {content}")))?; + let mut texts = BTreeMap::new(); + for answer in answers { + if !folds.iter().any(|fold| fold.id == answer.id) { + return Err(problem( + "summarizer_failed", + format!("answered for unknown fold {}", answer.id), + )); + } + if !answer.pseudocode.trim().is_empty() { + texts.insert(answer.id, answer.pseudocode.trim().to_owned()); + } + } + Ok(texts) + } +} + +/// New function bodies on the after side of at least `min_lines` lines. +pub(crate) fn select(sides: &Pairing, min_lines: usize) -> Vec<(u32, u32, u32)> { + let Some(rhs) = sides.rhs() else { + return Vec::new(); + }; + let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default(); + let mut selected = Vec::new(); + walk(&rhs.regions, &mut |region| { + if is_fold(region) + && region.tags.iter().any(|tag| tag == "body") + && !lhs_ids.contains(®ion.id) + && line_count(region) >= min_lines + { + let lines = region.range.lines(); + selected.push((region.id, lines.start + 1, lines.end)); + } + }); + selected +} + +fn resolve_key(config: &SummarizeConfig) -> Option { + config + .api_key + .clone() + .filter(|key| !key.is_empty()) + .or_else(|| { + std::env::var("GEMINI_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + }) + .or_else(|| { + std::env::var("GOOGLE_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + }) +} + +impl FoldMutation for Summarizer { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let folds: Vec = select(sides, self.min_lines) + .into_iter() + .map(|(id, first_line, last_line)| Request { + id, + first_line, + last_line, + }) + .collect(); + if folds.is_empty() { + return Ok(()); + } + let path = file + .file + .rhs() + .map(|side| side.path.as_str()) + .unwrap_or_default(); + let language = file.language.as_deref(); + let texts = { + let rhs = sides.rhs().expect("selection found rhs regions"); + self.complete(path, language, &rhs.text, &folds)? + }; + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else { + unreachable!("selection found rhs regions"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if let Some(text) = texts.get(®ion.id) { + collapse(region, summary_label(language, text)); + } + }); + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::config::Config; + use crate::protocol::{project, Diff, FileRef, FileStatus, Visibility}; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + + /// Project a two-source comparison the way the stream does. + pub(crate) fn project(path: &str, before: &str, after: &str) -> (FileChange, Pairing) { + let params = Config::from_toml("").unwrap().compile().unwrap(); + let result = crate::summary::DiffResult::from_sources_with_options( + path, + before, + after, + ¶ms, + &crate::options::DisplayOptions::default(), + &crate::options::DiffOptions::default(), + ); + let file_ref = FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + let file = FileChange { + file: Pairing::Both { + lhs: file_ref.clone(), + rhs: file_ref, + }, + status: FileStatus::Modified, + category: None, + language: crate::parse::guess_language::guess(std::path::Path::new(path), "", &[]) + .map(|language| crate::parse::guess_language::language_name(language).to_owned()), + visibility: Visibility::default(), + }; + let diff = project::diff( + &result, + project::Inputs { + file: &file.file, + sizes: (before.len() as u64, after.len() as u64), + context_lines: 3, + syntax: (Vec::new(), Vec::new()), + }, + ) + .unwrap(); + let Diff::Text { sides, .. } = diff else { + panic!("text diff expected"); + }; + (file, sides) + } + + const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n"; + + /// Answer each request with the next canned response. + fn serve(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let handle = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for (status, body) in responses { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + let mut request = vec![0; length]; + reader.read_exact(&mut request).unwrap(); + bodies.push(String::from_utf8(request).unwrap()); + let reason = if status == 200 { "OK" } else { "Error" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + reader.get_mut().write_all(response.as_bytes()).unwrap(); + } + bodies + }); + (endpoint, handle) + } + + /// The label of the only fold region on the after side. + fn fold_label(sides: &Pairing) -> String { + let mut labels = Vec::new(); + walk(&sides.rhs().unwrap().regions, &mut |region| { + if is_fold(region) { + labels.push(region.visibility.label.clone()); + } + }); + assert_eq!(labels.len(), 1, "{labels:?}"); + labels.remove(0) + } + + fn gemini_answer(items: &[(u32, &str)]) -> String { + let answers: Vec<_> = items + .iter() + .map(|(id, text)| json!({"id": id, "pseudocode": text})) + .collect(); + json!({"candidates": [{"content": {"parts": [{"text": serde_json::to_string(&answers).unwrap()}]}}]}) + .to_string() + } + + fn summarizer(endpoint: &str, retries: u32) -> Summarizer { + let config = SummarizeConfig { + api_key: Some("test-key".to_owned()), + endpoint: Some(endpoint.to_owned()), + retries, + timeout_ms: 5000, + ..SummarizeConfig::default() + }; + Summarizer::new(&config, 3).unwrap().unwrap() + } + + #[test] + fn selection_takes_new_bodies_of_at_least_min_lines() { + let (_, sides) = project("a.py", "", LARGE); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1); + assert_eq!((selected[0].1, selected[0].2), (2, 4)); + let (_, sides) = project("a.py", LARGE, LARGE); + assert!(select(&sides, 3).is_empty()); + } + + #[test] + fn summaries_collapse_selected_folds_with_a_pseudocode_comment() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "call a, b, c")]))]); + summarizer(&endpoint, 0).apply(&file, &mut sides).unwrap(); + let bodies = server.join().unwrap(); + assert!(bodies[0].contains("thinkingBudget")); + assert!(bodies[0].contains(&format!("fold {id}: lines 2-4"))); + let rhs = sides.rhs().unwrap(); + let mut labels = Vec::new(); + walk(&rhs.regions, &mut |region| { + if is_fold(region) { + labels.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + // `g` has a one-line body, which is not a region. + assert_eq!( + labels, + vec![( + true, + "# pseudocode +call a, b, c" + .to_owned() + )] + ); + } + + #[test] + fn transient_failures_are_retried_then_succeed() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![ + (503, "{}".to_owned()), + (429, "{}".to_owned()), + (200, gemini_answer(&[(id, "retry ok")])), + ]); + summarizer(&endpoint, 3).apply(&file, &mut sides).unwrap(); + assert_eq!(server.join().unwrap().len(), 3); + let label = fold_label(&sides); + assert!(label.ends_with("retry ok"), "{label}"); + } + + #[test] + fn hard_failures_and_exhausted_retries_are_problems() { + let (file, mut sides) = project("a.py", "", LARGE); + let (endpoint, server) = serve(vec![(400, "{\"error\": \"bad key\"}".to_owned())]); + let problem = summarizer(&endpoint, 3) + .apply(&file, &mut sides) + .unwrap_err(); + server.join().unwrap(); + assert_eq!(problem.code, "summarizer_failed"); + assert!(problem.message.contains("HTTP 400"), "{}", problem.message); + let (endpoint, server) = serve(vec![(500, "{}".to_owned()), (500, "{}".to_owned())]); + let problem = summarizer(&endpoint, 1) + .apply(&file, &mut sides) + .unwrap_err(); + server.join().unwrap(); + assert!( + problem.message.contains("after 2 attempts"), + "{}", + problem.message + ); + assert!(!sides.rhs().unwrap().regions[0].visibility.collapsed); + } + + #[test] + fn small_files_never_call_the_model() { + let (file, mut sides) = project("a.py", "", "def h():\n e()\n"); + let config = SummarizeConfig { + api_key: Some("k".to_owned()), + endpoint: Some("http://127.0.0.1:1".to_owned()), + ..SummarizeConfig::default() + }; + Summarizer::new(&config, 3) + .unwrap() + .unwrap() + .apply(&file, &mut sides) + .unwrap(); + } + + #[test] + fn no_key_means_no_summarizer() { + let config = SummarizeConfig { + api_key: None, + ..SummarizeConfig::default() + }; + // Only meaningful when the environment carries no key. + if std::env::var_os("GEMINI_API_KEY").is_none() + && std::env::var_os("GOOGLE_API_KEY").is_none() + { + assert!(Summarizer::new(&config, 3).unwrap().is_none()); + } + } +} diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index ab4337bbc..68dee5af5 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -1,8 +1,8 @@ //! The v2 stdout stream: manifest, one record per file as it finishes, footer. use super::project::{self, Inputs}; -use super::{Event, FileChange, Outcome, Pairing, Problem, Snapshot, SyntaxSpan, VERSION}; +use super::{Diff, Event, FileChange, Outcome, Pairing, Problem, Snapshot, SyntaxSpan, VERSION}; use crate::git::{DiffSession, FileProblem, LoadedFile}; -use crate::hook::Hook; +use crate::mutate::Mutations; use crate::summary::{DiffResult, FileContent, FileFormat}; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::io::{BufWriter, Write}; @@ -31,10 +31,13 @@ pub(crate) struct Ended { pub(crate) fn write( session: DiffSession, jobs: usize, - hook: Option>, + mutations: Arc, options: Options, output: &mut impl Write, ) -> crate::git::Result { + // File mutations run before the manifest is written; a failure there is + // a setup error, not a stream event. + let manifest = manifest(&session, &mutations)?; let (sender, receiver) = sync_channel(1); let pool = rayon::ThreadPoolBuilder::new() .num_threads(jobs) @@ -42,7 +45,7 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, &pool, hook.as_deref(), options, sender); + let _ = produce(session, manifest, &pool, &mutations, options, sender); }); let mut output = BufWriter::new(output); let result: crate::git::Result = (|| { @@ -75,10 +78,25 @@ pub(crate) fn write( /// The consumer went away; production stops after the files in flight. struct Disconnected; +fn manifest(session: &DiffSession, mutations: &Mutations) -> crate::git::Result> { + session + .file_manifest() + .iter() + .map(|file| { + let mut entry = file.manifest_entry(); + mutations + .apply_file(&mut entry) + .map_err(|problem| format!("{}: {}", problem.code, problem.message))?; + Ok(entry) + }) + .collect() +} + fn produce( session: DiffSession, + manifest: Vec, pool: &rayon::ThreadPool, - hook: Option<&Hook>, + mutations: &Mutations, options: Options, sender: SyncSender, ) -> Result<(), Disconnected> { @@ -87,11 +105,7 @@ fn produce( version: VERSION, lhs: Snapshot::from(&session.comparison.before), rhs: Snapshot::from(&session.comparison.after), - files: session - .file_manifest() - .iter() - .map(|file| file.manifest_entry()) - .collect(), + files: manifest, })?; let succeeded = AtomicU32::new(0); let failed = AtomicU32::new(0); @@ -104,7 +118,7 @@ fn produce( pool.install(|| { loader.par_bridge().for_each(|(file, loaded)| { let outcome = match loaded { - Ok(loaded) => match file_outcome(&loaded, hook, options) { + Ok(loaded) => match file_outcome(&loaded, mutations, options) { Ok(outcome) => outcome, Err(problem) => { // A run-level failure: stop pulling files, let the ones in @@ -149,16 +163,10 @@ fn problem_from(problem: FileProblem) -> Problem { /// `Err` here is a run-level failure, not a file-level one. fn file_outcome( loaded: &LoadedFile, - hook: Option<&Hook>, + mutations: &Mutations, options: Options, ) -> Result { - let mut diff = loaded.diff(); - if let Some(hook) = hook { - hook.summarize(&mut diff).map_err(|message| Problem { - code: "hook_failed".to_owned(), - message, - })?; - } + let diff = loaded.diff(); let syntax = if options.syntax { syntax_spans(&diff, &loaded.params) } else { @@ -170,7 +178,26 @@ fn file_outcome( context_lines: loaded.context_lines as usize, syntax, }; - Ok(project::diff(&diff, inputs).into()) + let mut entry = loaded.file.manifest_entry(); + mutations.apply_file(&mut entry)?; + mutate(mutations, &entry, project::diff(&diff, inputs)) +} + +/// Fold mutations see the projected text diff; a per-file failure passes +/// through untouched. +fn mutate( + mutations: &Mutations, + entry: &FileChange, + projected: Result, +) -> Result { + let outcome = match projected { + Ok(Diff::Text { mut sides, stats }) => { + mutations.apply_fold(entry, &mut sides)?; + Ok(Diff::Text { sides, stats }) + } + other => other, + }; + Ok(outcome.into()) } pub(crate) fn syntax_spans( @@ -213,13 +240,16 @@ pub(crate) fn write_file( compute: impl FnOnce() -> DiffResult, params: &crate::config::Params, context_lines: usize, - hook: Option<&Hook>, + mutations: &Mutations, options: Options, output: &mut impl Write, ) -> crate::git::Result { let file = crate::git::FileChange::standalone(before, after); let mut output = BufWriter::new(output); - let manifest: FileChange = file.manifest_entry(); + let mut entry: FileChange = file.manifest_entry(); + mutations + .apply_file(&mut entry) + .map_err(|problem| format!("{}: {}", problem.code, problem.message))?; serde_json::to_writer( &mut output, &Event::Start { @@ -230,38 +260,34 @@ pub(crate) fn write_file( rhs: Snapshot::Path { path: after.to_owned(), }, - files: vec![manifest], + files: vec![entry.clone()], }, )?; output.write_all(b"\n")?; output.flush()?; - let mut diff = compute(); + let diff = compute(); + let syntax = if options.syntax { + syntax_spans(&diff, params) + } else { + (Vec::new(), Vec::new()) + }; + let sides: Pairing<_> = file.sides.clone(); + let projected = project::diff( + &diff, + Inputs { + file: &sides, + sizes, + context_lines, + syntax, + }, + ); let mut aborted = None; - let outcome = match hook.map(|hook| hook.summarize(&mut diff)) { - Some(Err(message)) => { - aborted = Some(Problem { - code: "hook_failed".to_owned(), - message, - }); + let outcome = match mutate(mutations, &entry, projected) { + Ok(outcome) => Some(outcome), + Err(problem) => { + aborted = Some(problem); None } - _ => { - let syntax = if options.syntax { - syntax_spans(&diff, params) - } else { - (Vec::new(), Vec::new()) - }; - let sides: Pairing<_> = file.sides.clone(); - Some(Outcome::from(project::diff( - &diff, - Inputs { - file: &sides, - sizes, - context_lines, - syntax, - }, - ))) - } }; let mut ended = Ended { failed: false, diff --git a/src/stream.rs b/src/stream.rs index 822801a93..46ddac470 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,6 +1,5 @@ //! Incremental stdout protocol over the shared file iterator. use crate::git::{DiffSession, FileChange, LoadedFile, Operand}; -use crate::hook::Hook; use crate::summary::DiffResult; use rayon::iter::{ParallelBridge, ParallelIterator}; use serde::Serialize; @@ -45,7 +44,6 @@ enum Event { pub(crate) fn write( session: DiffSession, jobs: usize, - hook: Option>, output: &mut impl Write, ) -> crate::git::Result { let (sender, receiver) = sync_channel(1); @@ -55,7 +53,7 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, &pool, hook.as_deref(), sender); + let _ = produce(session, &pool, sender); }); let mut output = BufWriter::new(output); let result: crate::git::Result = (|| { @@ -81,7 +79,6 @@ pub(crate) fn write( fn produce( session: DiffSession, pool: &rayon::ThreadPool, - hook: Option<&Hook>, sender: SyncSender, ) -> std::result::Result<(), SendError> { sender.send(Event::Start { @@ -103,7 +100,7 @@ fn produce( let event = match loaded { Ok(loaded) => { succeeded.fetch_add(1, Ordering::Relaxed); - file_event(file, loaded.diff(), hook) + file_event(file, loaded.diff()) } Err(error) => { failed.fetch_add(1, Ordering::Relaxed); @@ -124,13 +121,12 @@ fn produce( }) } -/// Summaries are filled in before the event so clients never see a fold change. -fn file_event(file: FileChange, mut diff: DiffResult, hook: Option<&Hook>) -> Event { - let hook_error = hook.and_then(|hook| hook.summarize(&mut diff).err()); +/// The previous stream never carries summaries; hooks are a v2 feature. +fn file_event(file: FileChange, diff: DiffResult) -> Event { Event::File { file, diff: diff.domain_json(), - hook_error, + hook_error: None, } } @@ -157,7 +153,6 @@ pub(crate) fn write_file( before: &str, after: &str, compute: impl FnOnce() -> DiffResult, - hook: Option<&Hook>, output: &mut impl Write, ) -> crate::git::Result<()> { let file = FileChange::standalone(before, after); @@ -172,7 +167,7 @@ pub(crate) fn write_file( )?; output.write_all(b"\n")?; output.flush()?; - serde_json::to_writer(&mut output, &file_event(file, compute(), hook))?; + serde_json::to_writer(&mut output, &file_event(file, compute()))?; output.write_all(b"\n")?; serde_json::to_writer( &mut output, @@ -207,7 +202,6 @@ mod tests { "before.rs", "after.rs", || panic!("must not compute after manifest flush fails"), - None, &mut output, ); assert!(result.is_err()); diff --git a/tests/hooks/rpc_server.py b/tests/hooks/rpc_server.py index 668b33032..a5a72ab07 100755 --- a/tests/hooks/rpc_server.py +++ b/tests/hooks/rpc_server.py @@ -3,10 +3,10 @@ Listens on 127.0.0.1:$DIFFR_HOOK_PORT. The first argument selects a behavior: echo answer every fold with "pseudo " - first answer only fold 0 with "summary of f" + first answer only the first fold with "summary of f" error return a JSON-RPC error for every request slow never answer (sleeps inside the handler) - cwd answer fold 0 with the working directory and assert DIFFR_WORKSPACE + cwd answer the first fold with the working directory and assert DIFFR_WORKSPACE bad return a non-JSON body """ @@ -43,12 +43,12 @@ def do_POST(self): str(f["id"]): "pseudo " + f["placeholder"] for f in params["folds"] } elif MODE == "first": - texts = {"0": "summary of f"} + texts = {str(params["folds"][0]["id"]): "summary of f"} elif MODE == "cwd": assert os.environ["DIFFR_WORKSPACE"] == sys.argv[2], os.environ[ "DIFFR_WORKSPACE" ] - texts = {"0": os.getcwd()} + texts = {str(params["folds"][0]["id"]): os.getcwd()} else: raise SystemExit(f"unknown mode {MODE}") body = json.dumps( diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 4a63e876c..a36ea6fb3 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -11,8 +11,15 @@ ROOT = Path(__file__).resolve().parents[2] EXE = ROOT / "target/debug/diffr" +# An empty global configuration and no model API key, whatever the machine has. +CONFIG_HOME = tempfile.mkdtemp(prefix="diffr-config-home-") ENV = dict( - os.environ, + { + k: v + for k, v in os.environ.items() + if k not in ("GEMINI_API_KEY", "GOOGLE_API_KEY") + }, + XDG_CONFIG_HOME=CONFIG_HOME, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_NOSYSTEM="1", GIT_AUTHOR_NAME="Test", @@ -193,8 +200,10 @@ def check_tiling(source): assert result.returncode == 2 and not result.stdout and result.stderr (repo / "diffr.toml").write_text("invalid toml") assert cli(repo, "--format", "ndjson", base, head).returncode == 2 + # --config replaces the global file; the repository file still layers on top. + (repo / "diffr.toml").write_text("") custom = repo / "custom.toml" - custom.write_text("") + custom.write_text("[folds]\nmin_lines = 2\n") events = stream(repo, base, head, "--config", str(custom), "--", "a.rs") assert any( r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) @@ -294,7 +303,7 @@ def fold_labels(record): (repo / "diffr.toml").write_text(hook_config("echo")) events = {path_of(e): e for e in stream(repo, base, head)[1:-1]} - assert fold_labels(events["good.py"]) == ["pseudo Body"] + assert fold_labels(events["good.py"]) == ["# pseudocode\npseudo Body"] assert fold_labels(events["small.py"]) == [] (repo / "diffr.toml").write_text(hook_config("error")) events = stream(repo, base, head, "--jobs", "1", code=2) @@ -305,10 +314,12 @@ def fold_labels(record): (repo / "diffr.toml").write_text(hook_config("exit")) assert cli(repo, "--format", "ndjson", base, head).returncode == 2 # Relative hook paths resolve against the config file, not the repository. + # The repository file still layers over --config, so clear it first. + (repo / "diffr.toml").write_text("") with tempfile.TemporaryDirectory(prefix="diffr-hook-config-") as elsewhere: shutil.copy(rpc_server, Path(elsewhere) / "hook.py") (Path(elsewhere) / "hook.toml").write_text( - f"[folds.hook]\ncommand = [{json.dumps(sys.executable)}, 'hook.py', 'cwd', {json.dumps(str(repo.resolve()) + os.sep)}]\ntags = ['body']\n" + f"[folds.hook]\ncommand = [{json.dumps(sys.executable)}, 'hook.py', 'cwd', {json.dumps(str(repo.resolve()) + os.sep)}]\ntags = ['body']\nmin_lines = 3\n" ) events = stream( repo, @@ -319,7 +330,122 @@ def fold_labels(record): "--", "good.py", ) - assert fold_labels(events[1])[0] == str(Path(elsewhere).resolve()) + assert fold_labels(events[1])[0] == "# pseudocode\n" + str( + Path(elsewhere).resolve() + ) + +# Configuration: one layered file, the three commands frontends drive, and +# the default collapse rules on the wire. +with tempfile.TemporaryDirectory(prefix="diffr-config-") as temp: + repo = Path(temp) / "repo" + repo.mkdir() + git(repo, "init", "-q") + (repo / "src").mkdir() + (repo / "src" / "lib.py").write_text( + "def gone():\n a()\n b()\n c()\n\ndef kept():\n a()\n" + ) + (repo / "Cargo.lock").write_text("[[package]]\nname = 'a'\n") + base = commit(repo, "base") + (repo / "src" / "lib.py").write_text("def kept():\n a()\n") + (repo / "Cargo.lock").write_text("[[package]]\nname = 'b'\n") + (repo / "test_lib.py").write_text("def test_kept():\n assert True\n") + head = commit(repo, "head") + env = dict(ENV, XDG_CONFIG_HOME=str(Path(temp) / "home")) + config_file = Path(temp) / "home" / "diffr" / "config.toml" + + def config(*args, code=0): + result = subprocess.run( + [str(EXE), "--repo", str(repo), "config", *args], + capture_output=True, + env=env, + check=False, + ) + assert result.returncode == code, result.stderr + return result.stdout.decode() + + schema = json.loads(config("schema")) + assert schema["properties"]["folds"] + shown = json.loads(config("show", "--json")) + assert shown["folds"]["min_lines"] == 12 and shown["summarize"]["api_key"] is None + config("set", "folds.min_lines", "3") + config("set", "summarize.api_key", "1234") + config("set", "folds.typo", "1", code=2) + text = config_file.read_text() + assert "min_lines = 3" in text and 'api_key = "1234"' in text, text + shown = json.loads(config("show", "--json")) + assert shown["folds"]["min_lines"] == 3 + assert shown["summarize"]["api_key"] == "" + assert ( + json.loads(config("show", "--json", "--reveal"))["summarize"]["api_key"] + == "1234" + ) + result = subprocess.run( + [ + str(EXE), + "--repo", + str(repo), + "--set", + "folds.min_lines=7", + "config", + "show", + "--json", + ], + capture_output=True, + env=env, + check=True, + ) + assert json.loads(result.stdout)["folds"]["min_lines"] == 7 + + # No key in the environment and summarize disabled: the rules still run. + config("set", "summarize.enabled", "false") + with subprocess.Popen( + [str(EXE), "--repo", str(repo), "--format", "ndjson", base, head], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) as process: + events = [json.loads(line) for line in process.stdout] + assert process.wait(timeout=30) == 0, process.stderr.read() + manifest = { + (f["file"].get("rhs") or f["file"]["lhs"])["path"]: f + for f in events[0]["files"] + } + assert manifest["Cargo.lock"]["category"] == "generated" + assert manifest["Cargo.lock"]["visibility"] == { + "collapsed": True, + "label": "Generated file · hidden by default", + } + assert ( + manifest["test_lib.py"]["visibility"]["label"] + == "Test file · hidden by default" + ) + assert "visibility" not in manifest["src/lib.py"] + lib = next(e for e in events[1:-1] if path_of(e) == "src/lib.py") + deleted = [ + r + for r in all_regions(lib["diff"]["lhs"]["regions"]) + if r["kind"] == "fold" and r.get("visibility", {}).get("collapsed") + ] + assert [r["visibility"]["label"] for r in deleted] == ["3 lines removed"], deleted + config("set", "folds.collapse_deleted", "false") + config("set", "folds.collapse_generated", "false") + with subprocess.Popen( + [str(EXE), "--repo", str(repo), "--format", "ndjson", base, head], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) as process: + events = [json.loads(line) for line in process.stdout] + assert process.wait(timeout=30) == 0, process.stderr.read() + assert "visibility" not in next( + f for f in events[0]["files"] if path_of(f) == "Cargo.lock" + ) + lib = next(e for e in events[1:-1] if path_of(e) == "src/lib.py") + assert not any( + r.get("visibility", {}).get("collapsed") + for r in all_regions(lib["diff"]["lhs"]["regions"]) + if r["kind"] == "fold" + ) # Closing the pipe while a multi-file producer is active must not leave it # blocked forever on a full queue. Unix CLI output retains normal SIGPIPE behavior. From cde1fb2064e048fef42d1f21ce98658804af57c1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 01:56:16 -0700 Subject: [PATCH 07/43] Keep the PTY fixture open under the test-file collapse rule The launcher's fixtures live under test/, which the new category rules hide by default. The test exercises the viewer lifecycle, so pass --set folds.collapse_tests=false. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/test/pty/launch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tui/test/pty/launch.py b/tui/test/pty/launch.py index fbf14b3bc..0f51c2131 100644 --- a/tui/test/pty/launch.py +++ b/tui/test/pty/launch.py @@ -16,7 +16,9 @@ if pid == 0: os.environ['DIFFR_BUN'] = bun os.environ['TERM'] = 'xterm-256color' - os.execv(binary, [binary, '--no-index', *(['--exit-code'] if os.environ.get('DIFFR_TEST_EXIT_CODE') else []), '--', before, after]) + # The fixtures live under test/, which the category rules hide by default; this test is about + # the viewer lifecycle, so keep the file open. + os.execv(binary, [binary, '--no-index', '--set', 'folds.collapse_tests=false', *(['--exit-code'] if os.environ.get('DIFFR_TEST_EXIT_CODE') else []), '--', before, after]) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 100, 0, 0)) output = bytearray() ANSI = re.compile(rb'\x1b\[[0-9;?]*[A-Za-z]') From 1bf8c1c76cf1ae2df8366c904ec6bea1b8fb4a5c Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 02:35:28 -0700 Subject: [PATCH 08/43] Keep enclosing headers open, stop fragmenting gaps, and group collapsed folds Context gaps now follow difftastic's hunks, so the syntax context it already selects (the enclosing function header) stays visible above a deep change, and only unchanged stretches of three or more lines that no hunk shows collapse. Folds that lie entirely inside a gap are dropped from the region tree instead of splitting it, and slivers a fold edge cuts off a gap stay open. A last fold mutation merges adjacent gaps where both sides agree and wraps runs of collapsed sibling folds (deleted or summarized bodies) in one collapsed group fold whose label counts them. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 4 + docs/streaming.md | 24 +- src/cli.rs | 1 - src/mutate/group.rs | 427 ++++++++++++++++++++++++++++ src/mutate/mod.rs | 8 +- src/mutate/summarize.rs | 1 - src/protocol/project.rs | 222 ++++++++++++--- src/protocol/stream.rs | 3 - tui/test/fixtures/comparison.ndjson | 2 +- 9 files changed, 637 insertions(+), 55 deletions(-) create mode 100644 src/mutate/group.rs diff --git a/docs/config.md b/docs/config.md index 7ff13e3bd..9ce98dcc9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,6 +106,10 @@ itself. In order: then the text. 4. `folds.hook`, when configured, over the same selection with its own tags and threshold; its text gets the same comment line. +5. Grouping, always on: adjacent context gaps merge into one, and a run of + two or more sibling folds that start collapsed is wrapped in one `group` + fold labelled `" functions removed"`, `" functions summarized"`, or + `" folded regions"`. Expanding it reveals each child's own row. A mutation that fails after its retries ends the run: the stream finishes with `complete.aborted` (`summarizer_failed` or `hook_failed`) and diffr exits 2. diff --git a/docs/streaming.md b/docs/streaming.md index eec91c9a8..e58cd950a 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -181,13 +181,23 @@ hook adds). `visibility` is how it starts out: `collapsed` and the `label` to show while collapsed, a placeholder or pseudocode summary for a fold, or the count for a context gap. Absent means open. -**Context** is expressed as leaves: an unchanged run longer than twice the -`-U` width keeps that many lines open on each side that faces a change and -collapses the rest into a leaf tagged `unchanged` with a label such as -`"142 unchanged lines"`. A run at the start of the file keeps only its trailing -lines, one at the end only its leading lines, and a file with no change is one -collapsed leaf. Fold edges can split a gap into consecutive collapsed leaves; -render adjacent collapsed leaves as one gap. +**Context** is expressed as leaves. The rows difftastic's hunks display, +which are the `-U` padding around every change plus the enclosing syntax +context such as the header of the function a change sits in, stay open. +Every other stretch of unchanged rows that is at least three lines long +collapses into a leaf tagged `unchanged` with a label such as +`"142 unchanged lines"`; shorter stretches stay open because a fold row +would save nothing. A file with no change is one collapsed leaf however +short. Folds that lie entirely inside a gap are not regions, so a gap is +split only by a fold that crosses its edge, and the grouping mutation merges +adjacent gaps again where both sides agree. + +**Groups** come from the last built-in mutation: a run of two or more +sibling folds that start collapsed, such as several deleted or summarized +functions in a row, is wrapped in one new fold tagged `group` whose label +counts them (`"3 functions removed"`, `"3 functions summarized"`, or +`"3 folded regions"`). It starts collapsed; expanding it reveals each +child's own collapsed row. The wrapped children are untouched. Ids are per file, dense, and assigned in lhs preorder then rhs preorder. They mean nothing across files or runs. diff --git a/src/cli.rs b/src/cli.rs index 64ea5f7f3..8c5d40078 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -532,7 +532,6 @@ fn no_index( (before.len() as u64, after.len() as u64), compute, &config, - display.num_context_lines as usize, &mutations, stream_options, &mut io::stdout().lock(), diff --git a/src/mutate/group.rs b/src/mutate/group.rs new file mode 100644 index 000000000..0f8cf1f25 --- /dev/null +++ b/src/mutate/group.rs @@ -0,0 +1,427 @@ +//! The last fold mutation: tidy what the earlier ones collapsed. +//! +//! Adjacent context gaps become one gap, and a run of adjacent sibling +//! folds that all start collapsed is wrapped in one new `group` fold so the +//! reader sees one row instead of a stack of them. Expanding the group +//! reveals each child's own collapsed row. Both steps keep ids paired: a +//! merge or a group happens on one side only when the other side either +//! does the same to the same ids or holds none of them. +use super::{is_fold, walk, FoldMutation}; +use crate::hash::{DftHashMap, DftHashSet}; +use crate::protocol::{ + FileChange, Node, Pairing, Problem, Region, Source, SourceRange, Visibility, +}; + +pub(crate) struct GroupCollapsed; + +impl FoldMutation for GroupCollapsed { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let mut next_id = next_id(sides); + merge_gaps(sides); + group_folds(sides, &mut next_id); + Ok(()) + } +} + +fn next_id(sides: &Pairing) -> u32 { + let mut max = None; + for source in [sides.lhs(), sides.rhs()].into_iter().flatten() { + walk(&source.regions, &mut |region| { + max = max.max(Some(region.id)); + }); + } + max.map_or(0, |max| max + 1) +} + +fn is_gap(region: &Region) -> bool { + matches!(region.node, Node::Leaf { .. }) + && region.visibility.collapsed + && region.tags.iter().any(|tag| tag == "unchanged") +} + +// ── gaps ────────────────────────────────────────────────────────────────── + +/// Pairs of adjacent sibling gaps, keyed by the first one's id. +fn adjacent_gaps(regions: &[Region], out: &mut DftHashMap) { + for pair in regions.windows(2) { + if is_gap(&pair[0]) && is_gap(&pair[1]) && pair[0].range.end == pair[1].range.start { + out.insert(pair[0].id, pair[1].id); + } + } + for region in regions { + if let Node::Fold { children } = ®ion.node { + adjacent_gaps(children, out); + } + } +} + +fn merge_gaps(sides: &mut Pairing) { + let (lhs_adjacent, rhs_adjacent, lhs_ids, rhs_ids) = { + let mut lhs_adjacent = DftHashMap::default(); + let mut rhs_adjacent = DftHashMap::default(); + if let Some(lhs) = sides.lhs() { + adjacent_gaps(&lhs.regions, &mut lhs_adjacent); + } + if let Some(rhs) = sides.rhs() { + adjacent_gaps(&rhs.regions, &mut rhs_adjacent); + } + let ids = |source: Option<&Source>| { + source + .map(|source| super::ids(&source.regions)) + .unwrap_or_default() + }; + ( + lhs_adjacent, + rhs_adjacent, + ids(sides.lhs()), + ids(sides.rhs()), + ) + }; + // A pair merges when the other side merges the same pair or has neither id. + let agrees = + |first: u32, second: u32, other: &DftHashMap, other_ids: &DftHashSet| { + other.get(&first) == Some(&second) + || (!other_ids.contains(&first) && !other_ids.contains(&second)) + }; + let lhs_merges: DftHashSet = lhs_adjacent + .iter() + .filter(|(&first, &second)| agrees(first, second, &rhs_adjacent, &rhs_ids)) + .map(|(&first, _)| first) + .collect(); + let rhs_merges: DftHashSet = rhs_adjacent + .iter() + .filter(|(&first, &second)| agrees(first, second, &lhs_adjacent, &lhs_ids)) + .map(|(&first, _)| first) + .collect(); + if let Some(lhs) = lhs_mut(sides) { + merge_gap_runs(&mut lhs.regions, &lhs_merges); + } + if let Some(rhs) = rhs_mut(sides) { + merge_gap_runs(&mut rhs.regions, &rhs_merges); + } +} + +/// Fold each run of mergeable gaps into its first member, which keeps its +/// id so the pairing with the other side's merged gap holds. +fn merge_gap_runs(regions: &mut Vec, merges: &DftHashSet) { + let mut merged: Vec = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + if let Node::Fold { children } = &mut region.node { + merge_gap_runs(children, merges); + } + match merged.last_mut() { + Some(last) if merges.contains(&last.id) && is_gap(®ion) => { + last.range.end = region.range.end; + let count = last.range.lines().len(); + last.visibility.label = format!("{count} unchanged lines"); + // The merged gap must itself continue a run only if it was + // marked to; the mark lives on the first id, which it keeps. + } + _ => merged.push(region), + } + } + *regions = merged; +} + +// ── groups ──────────────────────────────────────────────────────────────── + +/// A unit is a collapsed fold, with the single-line leaf just before it +/// when there is one: the `def` or `fn` line whose body the fold hides. +fn unit_at(regions: &[Region], at: usize) -> Option { + let collapsed_fold = |region: &Region| is_fold(region) && region.visibility.collapsed; + let region = regions.get(at)?; + if collapsed_fold(region) { + return Some(at); + } + let header = !is_fold(region) && region.range.lines().len() == 1; + (header && regions.get(at + 1).is_some_and(collapsed_fold)).then_some(at + 1) +} + +/// Lines a separator between units may span: blank lines, at most. +const MAX_SEPARATOR_LINES: usize = 2; + +/// Maximal runs of two or more units among siblings, separated by at most +/// a couple of open leaf lines, as their child ids in document order. +fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { + for region in regions { + if let Node::Fold { children } = ®ion.node { + collapsed_fold_runs(children, out); + } + } + let mut at = 0; + while at < regions.len() { + let Some(mut last) = unit_at(regions, at) else { + at += 1; + continue; + }; + let first = at; + let mut units = 1; + loop { + let mut next = last + 1; + let mut separator_lines = 0; + while next < regions.len() + && !is_fold(®ions[next]) + && !regions[next].visibility.collapsed + && unit_at(regions, next) != Some(next + 1) + { + separator_lines += regions[next].range.lines().len(); + next += 1; + } + if separator_lines > MAX_SEPARATOR_LINES { + break; + } + match unit_at(regions, next) { + Some(end) => { + last = end; + units += 1; + } + None => break, + } + } + if units >= 2 { + out.push(regions[first..=last].iter().map(|r| r.id).collect()); + } + at = last + 1; + } +} + +fn group_folds(sides: &mut Pairing, next_id: &mut u32) { + let mut lhs_runs = Vec::new(); + let mut rhs_runs = Vec::new(); + if let Some(lhs) = sides.lhs() { + collapsed_fold_runs(&lhs.regions, &mut lhs_runs); + } + if let Some(rhs) = sides.rhs() { + collapsed_fold_runs(&rhs.regions, &mut rhs_runs); + } + // A run whose ids form the same run on the other side shares the group id. + let mut group_ids: DftHashMap, u32> = DftHashMap::default(); + for run in lhs_runs.iter().chain(&rhs_runs) { + if !group_ids.contains_key(run) { + group_ids.insert(run.clone(), *next_id); + *next_id += 1; + } + } + let by_first = |runs: &[Vec]| -> DftHashMap { + runs.iter() + .map(|run| (run[0], (run.len(), group_ids[run]))) + .collect() + }; + let lhs_by_first = by_first(&lhs_runs); + let rhs_by_first = by_first(&rhs_runs); + if let Some(lhs) = lhs_mut(sides) { + wrap_runs(&mut lhs.regions, &lhs_by_first); + } + if let Some(rhs) = rhs_mut(sides) { + wrap_runs(&mut rhs.regions, &rhs_by_first); + } +} + +fn wrap_runs(regions: &mut Vec, by_first: &DftHashMap) { + for region in regions.iter_mut() { + if let Node::Fold { children } = &mut region.node { + wrap_runs(children, by_first); + } + } + let mut wrapped: Vec = Vec::with_capacity(regions.len()); + let mut pending = std::mem::take(regions).into_iter(); + while let Some(region) = pending.next() { + let Some(&(len, id)) = by_first.get(®ion.id) else { + wrapped.push(region); + continue; + }; + let mut children = vec![region]; + while children.len() < len { + children.push(pending.next().expect("a run is contiguous")); + } + wrapped.push(group(id, children)); + } + *regions = wrapped; +} + +fn group(id: u32, children: Vec) -> Region { + let folds: Vec<&Region> = children.iter().filter(|child| is_fold(child)).collect(); + let count = folds.len(); + let label = if folds + .iter() + .all(|fold| fold.visibility.label.ends_with("lines removed")) + { + format!("{count} functions removed") + } else if folds.iter().all(|fold| { + fold.visibility + .label + .lines() + .next() + .is_some_and(|first| first.ends_with(" pseudocode")) + }) { + format!("{count} functions summarized") + } else { + format!("{count} folded regions") + }; + Region { + id, + range: SourceRange { + start: children[0].range.start, + end: children[children.len() - 1].range.end, + }, + tags: vec!["group".to_owned()], + visibility: Visibility { + collapsed: true, + label, + }, + node: Node::Fold { children }, + } +} + +fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> { + match sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs), + Pairing::RightOnly { .. } => None, + } +} + +fn rhs_mut(sides: &mut Pairing) -> Option<&mut Source> { + match sides { + Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } => Some(rhs), + Pairing::LeftOnly { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutate::collapse::DeletedBodies; + use crate::mutate::summarize::tests::project; + + #[test] + fn adjacent_deleted_bodies_are_grouped_under_one_collapsed_fold() { + let before = "def a():\n x()\n y()\n z()\n\ndef b():\n x()\n y()\n z()\n\ndef c():\n x()\n y()\n z()\n\nkeep = 1\n"; + let after = "keep = 1\n"; + let (file, mut sides) = project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let lhs = sides.lhs().unwrap(); + let group = lhs + .regions + .iter() + .find(|region| region.tags == ["group"]) + .expect("a group fold"); + assert_eq!(group.visibility.label, "3 functions removed"); + assert!(group.visibility.collapsed); + let Node::Fold { children } = &group.node else { + panic!("a group is a fold"); + }; + for child in children { + eprintln!( + "child {} {:?} {:?} fold={}", + child.id, + child.range.lines(), + child.visibility.label, + is_fold(child) + ); + } + let folds: Vec<_> = children.iter().filter(|child| is_fold(child)).collect(); + assert_eq!(folds.len(), 3); + assert!(folds.iter().all(|child| child.visibility.collapsed)); + // The group starts on the first `def` line and ends with the last body. + assert_eq!(group.range.start.line, 0); + assert_eq!(group.range.end, folds[2].range.end); + let rhs_ids = super::super::ids(&sides.rhs().unwrap().regions); + assert!( + !rhs_ids.contains(&group.id), + "a one-sided group has a fresh id" + ); + let mut seen = DftHashSet::default(); + walk(&lhs.regions, &mut |region| { + assert!(seen.insert(region.id), "duplicate id {}", region.id); + }); + } + + #[test] + fn a_single_collapsed_fold_is_left_alone() { + let before = "def a():\n x()\n y()\n z()\n\nkeep = 1\n"; + let after = "keep = 1\n"; + let (file, mut sides) = project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let mut groups = 0; + walk(&sides.lhs().unwrap().regions, &mut |region| { + groups += usize::from(region.tags == ["group"]); + }); + assert_eq!(groups, 0); + } + + #[test] + fn adjacent_gaps_merge_on_both_sides_and_keep_their_pairing() { + let mut sides = Pairing::Both { + lhs: source(vec![gap(0, 0, 3), gap(1, 3, 7), leaf(2, 7, 8)]), + rhs: source(vec![gap(0, 0, 3), gap(1, 3, 7), leaf(2, 7, 8)]), + }; + merge_gaps(&mut sides); + for source in [sides.lhs().unwrap(), sides.rhs().unwrap()] { + assert_eq!(source.regions.len(), 2); + assert_eq!(source.regions[0].id, 0); + assert_eq!(source.regions[0].range.lines(), 0..7); + assert_eq!(source.regions[0].visibility.label, "7 unchanged lines"); + } + } + + #[test] + fn gaps_do_not_merge_when_the_other_side_separates_them() { + let mut sides = Pairing::Both { + lhs: source(vec![gap(0, 0, 3), gap(1, 3, 7)]), + rhs: source(vec![gap(0, 0, 3), leaf(5, 3, 4), gap(1, 4, 8)]), + }; + merge_gaps(&mut sides); + assert_eq!(sides.lhs().unwrap().regions.len(), 2); + assert_eq!(sides.rhs().unwrap().regions.len(), 3); + } + + fn source(regions: Vec) -> Source { + Source { + text: String::new(), + syntax: Vec::new(), + regions, + } + } + + fn range(start: u32, end: u32) -> SourceRange { + SourceRange { + start: crate::protocol::SourcePos { + line: start, + column: 0, + }, + end: crate::protocol::SourcePos { + line: end, + column: 0, + }, + } + } + + fn gap(id: u32, start: u32, end: u32) -> Region { + Region { + id, + range: range(start, end), + tags: vec!["unchanged".to_owned()], + visibility: Visibility { + collapsed: true, + label: format!("{} unchanged lines", end - start), + }, + node: Node::Leaf { changed: vec![] }, + } + } + + fn leaf(id: u32, start: u32, end: u32) -> Region { + Region { + id, + range: range(start, end), + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed: vec![] }, + } + } +} diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 07fa42917..8da38ca72 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -3,9 +3,12 @@ //! region trees. diffr's diff internals never see them. //! //! Order: file mutations (hidden categories), then fold mutations in this -//! sequence: deleted bodies, the built-in summarizer, the JSON-RPC hook. -//! A mutation failure aborts the run; retries belong inside a mutation. +//! sequence: deleted bodies, the built-in summarizer, the JSON-RPC hook, and +//! last the grouping pass that merges adjacent gaps and wraps runs of +//! collapsed folds. A mutation failure aborts the run; retries belong +//! inside a mutation. pub(crate) mod collapse; +pub(crate) mod group; pub(crate) mod summarize; use crate::config::Params; use crate::hash::DftHashSet; @@ -57,6 +60,7 @@ impl Mutations { .fold .push(Box::new(Hook::spawn(hook, folds.min_lines, workspace)?)); } + mutations.fold.push(Box::new(group::GroupCollapsed)); Ok(mutations) } diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs index 8585bab3d..be335635d 100644 --- a/src/mutate/summarize.rs +++ b/src/mutate/summarize.rs @@ -330,7 +330,6 @@ pub(crate) mod tests { project::Inputs { file: &file.file, sizes: (before.len() as u64, after.len() as u64), - context_lines: 3, syntax: (Vec::new(), Vec::new()), }, ) diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 5cf8dd361..71978b0df 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -7,12 +7,16 @@ //! id too. Leaves are split wherever a fold starts or ends so that every //! fold's children tile its line span exactly, and a split on one side of //! a paired leaf is mirrored on the other so paired leaves stay equal in -//! length. Long unchanged paired runs are trimmed to the context width and -//! their middle becomes a collapsed leaf tagged `unchanged`. +//! length. Unchanged rows that no hunk shows, which is everything outside +//! the `-U` padding and the enclosing syntax context difftastic already +//! selected, become collapsed leaves tagged `unchanged`. Folds that lie +//! entirely inside such a gap are dropped: they would be hidden anyway, and +//! keeping them would only fragment the gap. use super::{ BinaryRef, Diff, FileRef, LineCounts, Node, Pairing, Problem, Region, Source, SourcePos, SourceRange, Span, Stats, SyntaxSpan, Visibility, }; +use crate::display::hunks::Hunk; use crate::display::line_layout::{aligned_rows, novel_lines}; use crate::hash::DftHashMap; use crate::line_parser; @@ -29,8 +33,6 @@ pub(crate) struct Inputs<'a> { pub(crate) file: &'a Pairing, /// Byte length of each side's content, for binary files. pub(crate) sizes: (u64, u64), - /// Unchanged lines kept open on either side of a change. - pub(crate) context_lines: usize, /// Highlight spans per side; empty when the run did not ask for syntax. pub(crate) syntax: (Vec, Vec), } @@ -51,7 +53,7 @@ pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Result Result<(Vec, Vec), Problem> { let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); @@ -227,15 +228,33 @@ fn regions( (lhs_src, rhs_src), (&result.lhs_positions, &result.rhs_positions), ); - let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), context_lines); + let (lhs_shown, rhs_shown) = shown_lines(&result.hunks); + let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), &lhs_shown, &rhs_shown); + let lhs_gaps: Vec<(usize, usize)> = runs + .iter() + .filter(|run| run.collapsed) + .filter_map(|run| run.lhs) + .collect(); + let rhs_gaps: Vec<(usize, usize)> = runs + .iter() + .filter(|run| run.collapsed) + .filter_map(|run| run.rhs) + .collect(); - let lhs_folds = side_folds(&result.lhs_folds, &result.rhs_folds, Side::Left, &lhs_lines)?; - let rhs_folds = side_folds( + let lhs_folds: Vec> = + side_folds(&result.lhs_folds, &result.rhs_folds, Side::Left, &lhs_lines)? + .into_iter() + .filter(|fold| !inside_gap(fold.lines, &lhs_gaps)) + .collect(); + let rhs_folds: Vec> = side_folds( &result.lhs_folds, &result.rhs_folds, Side::Right, &rhs_lines, - )?; + )? + .into_iter() + .filter(|fold| !inside_gap(fold.lines, &rhs_gaps)) + .collect(); let lhs_splits = split_lines(&lhs_folds); let rhs_splits = split_lines(&rhs_folds); let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); @@ -307,44 +326,85 @@ fn runs( runs } -/// Keep `context` lines of every unchanged run on the side that faces a -/// change; collapse the rest. A run at the file start faces no change -/// before it, one at the end none after, and a file with a single run has -/// no change at all. -fn trim_context(runs: Vec, context: usize) -> Vec { - let last = runs.len().saturating_sub(1); - let mut out = Vec::new(); - for (index, run) in runs.into_iter().enumerate() { - if run.kind != RunKind::Unchanged { - out.push(run); - continue; +/// The lines difftastic's hunks display on each side: the `-U` padding +/// around every change plus the enclosing syntax context, such as the +/// header of the function a change sits in. +fn shown_lines(hunks: &[Hunk]) -> (BTreeSet, BTreeSet) { + let mut lhs = BTreeSet::new(); + let mut rhs = BTreeSet::new(); + for hunk in hunks { + for &(l, r) in &hunk.lines { + lhs.extend(l.map(|line| line.as_usize())); + rhs.extend(r.map(|line| line.as_usize())); } - let lead = if index == 0 { 0 } else { context }; - let trail = if index == last { 0 } else { context }; - let len = run.len(); - if len <= lead + trail { + } + (lhs, rhs) +} + +/// Collapsing fewer lines than this saves nothing worth a fold row. +const MIN_GAP: usize = 3; + +/// Collapse every maximal stretch of an unchanged run that no hunk shows +/// and that is at least `MIN_GAP` lines long. A file with no change has no +/// hunks, so it becomes one collapsed run. +fn trim_context( + runs: Vec, + lhs_shown: &BTreeSet, + rhs_shown: &BTreeSet, +) -> Vec { + let no_change = lhs_shown.is_empty() && rhs_shown.is_empty(); + let mut out: Vec = Vec::new(); + for run in runs { + if run.kind != RunKind::Unchanged { out.push(run); continue; } let (lhs_start, _) = run.lhs.expect("unchanged runs are paired"); let (rhs_start, _) = run.rhs.expect("unchanged runs are paired"); - let piece = |offset: usize, count: usize, collapsed: bool| Run { - kind: RunKind::Unchanged, - lhs: Some((lhs_start + offset, lhs_start + offset + count)), - rhs: Some((rhs_start + offset, rhs_start + offset + count)), - collapsed, + let shown = |offset: usize| { + lhs_shown.contains(&(lhs_start + offset)) || rhs_shown.contains(&(rhs_start + offset)) }; - if lead > 0 { - out.push(piece(0, lead, false)); - } - out.push(piece(lead, len - lead - trail, true)); - if trail > 0 { - out.push(piece(len - trail, trail, false)); + let len = run.len(); + let mut from = 0; + while from < len { + let hidden = !shown(from); + let mut to = from + 1; + while to < len && shown(to) != hidden { + to += 1; + } + // A file with no change at all is one gap however short. + let collapsed = hidden && (to - from >= MIN_GAP || no_change); + let piece = Run { + kind: RunKind::Unchanged, + lhs: Some((lhs_start + from, lhs_start + to)), + rhs: Some((rhs_start + from, rhs_start + to)), + collapsed, + }; + match out.last_mut() { + // Open pieces of one run stay one leaf. + Some(last) + if !collapsed + && !last.collapsed + && last.kind == RunKind::Unchanged + && last.lhs.is_some_and(|(_, end)| end == lhs_start + from) => + { + last.lhs = Some((last.lhs.expect("paired").0, lhs_start + to)); + last.rhs = Some((last.rhs.expect("paired").0, rhs_start + to)); + } + _ => out.push(piece), + } + from = to; } } out } +/// Whether a fold's line span lies entirely inside one collapsed gap. +fn inside_gap(lines: (usize, usize), gaps: &[(usize, usize)]) -> bool { + gaps.iter() + .any(|&(start, end)| start <= lines.0 && lines.1 <= end) +} + fn line_span(range: &lines::SourceRange, line_count: usize) -> (usize, usize) { let start = range.start.line.as_usize(); let end = if range.end.byte_column == 0 { @@ -436,18 +496,21 @@ fn split_runs( let mut at = 0; for (piece, cut) in offsets.into_iter().chain([len]).enumerate() { let key = paired.then_some(LeafKey { run: index, piece }); + // A sliver cut off a gap by a fold edge stays open: the same + // offsets apply to both sides, so paired pieces agree. + let collapsed = run.collapsed && (cut - at >= MIN_GAP || cut - at == len); if let Some((start, _)) = run.lhs { lhs_leaves.push(Leaf { lines: (start + at, start + cut), key, - collapsed: run.collapsed, + collapsed, }); } if let Some((start, _)) = run.rhs { rhs_leaves.push(Leaf { lines: (start + at, start + cut), key, - collapsed: run.collapsed, + collapsed, }); } at = cut; @@ -756,7 +819,6 @@ mod tests { Inputs { file: &refs(!lhs.is_empty(), !rhs.is_empty()), sizes: (lhs.len() as u64, rhs.len() as u64), - context_lines: context, syntax: (Vec::new(), Vec::new()), }, ) @@ -969,6 +1031,87 @@ mod tests { assert_eq!(leaves[0].visibility.label, "17 unchanged lines"); } + #[test] + fn folds_inside_a_gap_are_dropped_and_the_gap_stays_whole() { + // Three unchanged functions sit between two changes. They would be + // hidden inside the gap, so they are not regions and do not split it. + let unchanged = (0..3) + .map(|i| format!("def f{i}():\n a = {i}\n b = {i}\n return a + b\n\n")) + .collect::>() + .concat(); + let lhs = format!("first = 1\n\n{unchanged}last = 1\n"); + let rhs = format!("first = 2\n\n{unchanged}last = 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert!(all(&lhs.regions) + .iter() + .all(|r| matches!(r.node, Node::Leaf { .. }))); + let gaps: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| leaf.visibility.collapsed) + .map(|leaf| leaf.range.lines_spanned()) + .collect(); + assert_eq!(gaps, vec![(2, 16)]); + assert_tiles(lhs); + assert_tiles(rhs); + } + + #[test] + fn slivers_cut_from_a_gap_by_a_fold_edge_stay_open() { + // The changed function's fold edge lands one line into the gap + // before it; that one line is not worth a fold row. + let head = (0..8) + .map(|i| format!("x{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 1\n"); + let rhs = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + for leaf in leaves(&rhs.regions) { + let (start, end) = leaf.range.lines_spanned(); + assert!( + !leaf.visibility.collapsed || end - start >= MIN_GAP as u32, + "collapsed sliver {start}..{end}" + ); + } + assert_tiles(rhs); + } + + #[test] + fn the_enclosing_header_stays_open_above_a_deep_change() { + // A change ten lines into a function: the `def` line is syntax + // context, so it is shown even though it is far outside the padding. + let body = (0..10) + .map(|i| format!(" a{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("def outer():\n{body} return 1\n"); + let rhs = format!("def outer():\n{body} return 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + let open_lines: Vec = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| !leaf.visibility.collapsed) + .flat_map(|leaf| { + let (start, end) = leaf.range.lines_spanned(); + start..end + }) + .collect(); + assert!( + open_lines.contains(&0), + "header line hidden: {open_lines:?}" + ); + assert!( + !open_lines.contains(&5), + "middle of the body should be a gap" + ); + assert_tiles(rhs); + } + #[test] fn a_fold_edge_splits_paired_leaves_on_both_sides() { // The unchanged function inside the gap forces a split on both sides @@ -1061,7 +1204,6 @@ mod tests { Inputs { file: &refs(true, true), sizes: (3, 5), - context_lines: 3, syntax: (Vec::new(), Vec::new()), }, ) diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index 68dee5af5..fd7bd7bae 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -175,7 +175,6 @@ fn file_outcome( let inputs = Inputs { file: &loaded.file.sides, sizes: loaded.sizes(), - context_lines: loaded.context_lines as usize, syntax, }; let mut entry = loaded.file.manifest_entry(); @@ -239,7 +238,6 @@ pub(crate) fn write_file( sizes: (u64, u64), compute: impl FnOnce() -> DiffResult, params: &crate::config::Params, - context_lines: usize, mutations: &Mutations, options: Options, output: &mut impl Write, @@ -277,7 +275,6 @@ pub(crate) fn write_file( Inputs { file: &sides, sizes, - context_lines, syntax, }, ); diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index 1ded55cad..a1c6df1d1 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","language":"TypeScript"}]} +{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript"}]} {"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From edcffaec9d7fc40f9d4ccf4c08f5adaf213e9705 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 02:46:28 -0700 Subject: [PATCH 09/43] Collapse the middle of large removed stretches A new fold mutation, on by default at five lines, splits every lhs-only leaf into an open first line, a collapsed middle labelled with its line count and tagged removed, and an open last line, so a big red block reads as red without filling the screen. Leaves under an already collapsed fold are left alone. Configured by folds.collapse_removed_lines; 0 disables. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 12 +- docs/streaming.md | 4 +- src/config.rs | 5 + src/mutate/collapse.rs | 279 ++++++++++++++++++++++++++++++++++++++++- src/mutate/group.rs | 2 +- src/mutate/mod.rs | 11 +- 6 files changed, 301 insertions(+), 12 deletions(-) diff --git a/docs/config.md b/docs/config.md index 9ce98dcc9..5877eb412 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,6 +39,7 @@ through these three commands; the schema is the only contract. [folds] min_lines = 12 # bodies shorter than this are never summarized or collapsed collapse_deleted = true # deleted function bodies start collapsed, header visible +collapse_removed_lines = 5 # removed stretches this long collapse in the middle; 0 disables collapse_generated = true # generated files start hidden collapse_tests = true # test files start hidden context_lines = 3 # unchanged lines kept around a change; -U overrides @@ -100,13 +101,18 @@ itself. In order: manifest, before `start` is written. 2. `collapse_deleted`: deleted function bodies of at least `min_lines` lines, labelled `" lines removed"`. -3. The built-in summarizer: new function bodies of at least `min_lines` lines +3. `collapse_removed_lines`: removed stretches with no counterpart on the + after side and at least that many lines keep their first and last line + open and collapse the middle, tagged `removed` and labelled + `" lines removed"`. Stretches under a fold that already starts + collapsed are left alone. +4. The built-in summarizer: new function bodies of at least `min_lines` lines on the after side become Python-style pseudocode. The label starts with a comment line in the file's own syntax, `# pseudocode` or `// pseudocode`, then the text. -4. `folds.hook`, when configured, over the same selection with its own tags +5. `folds.hook`, when configured, over the same selection with its own tags and threshold; its text gets the same comment line. -5. Grouping, always on: adjacent context gaps merge into one, and a run of +6. Grouping, always on: adjacent context gaps merge into one, and a run of two or more sibling folds that start collapsed is wrapped in one `group` fold labelled `" functions removed"`, `" functions summarized"`, or `" folded regions"`. Expanding it reveals each child's own row. diff --git a/docs/streaming.md b/docs/streaming.md index e58cd950a..93f42b7e7 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -223,8 +223,8 @@ fail discovery. ## Mutations and fold hooks After projection, mutations set what starts collapsed and what its label says: -generated and test files in the manifest, deleted bodies, and summaries for -new bodies from the built-in summarizer. [config.md](config.md) lists them and +generated and test files in the manifest, deleted bodies, the middle of large +removed stretches, and summaries for new bodies from the built-in summarizer. [config.md](config.md) lists them and their order. Their output is ordinary `visibility` on files and regions; frontends need no knowledge of which mutation produced it. diff --git a/src/config.rs b/src/config.rs index 170fb6841..5fb16f03b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,10 @@ pub(crate) struct FoldsConfig { pub(crate) min_lines: usize, /// Collapse deleted function bodies, keeping their header line visible. pub(crate) collapse_deleted: bool, + /// Removed stretches with no counterpart and at least this many lines + /// collapse in the middle, keeping their first and last line visible. + /// `0` disables it. + pub(crate) collapse_removed_lines: usize, /// Hide files classified as generated, such as lockfiles and build output. pub(crate) collapse_generated: bool, /// Hide files classified as tests. @@ -55,6 +59,7 @@ impl Default for FoldsConfig { Self { min_lines: 12, collapse_deleted: true, + collapse_removed_lines: 5, collapse_generated: true, collapse_tests: true, context_lines: 3, diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index aa877f2cc..ea181b1fe 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -1,8 +1,12 @@ -//! Rules that start things collapsed: whole files by category, and deleted -//! function bodies. +//! Rules that start things collapsed: whole files by category, deleted +//! function bodies, and the middle of large removed stretches. +use super::group::next_id; use super::{collapse, ids, is_fold, line_count, walk_mut, FileMutation, FoldMutation}; use crate::category; -use crate::protocol::{FileChange, Pairing, Problem, Source}; +use crate::hash::DftHashSet; +use crate::protocol::{ + FileChange, Node, Pairing, Problem, Region, Source, SourcePos, SourceRange, Visibility, +}; /// Generated and test files start hidden behind a placeholder. pub(crate) struct HiddenCategories { @@ -49,6 +53,114 @@ impl FoldMutation for DeletedBodies { } } +/// Removed stretches with no counterpart and at least `min_lines` lines are +/// split into three leaves: the first line open, the middle collapsed with +/// a line count, the last line open, so the reader still sees red at both +/// ends. Leaves under a fold that already starts collapsed are left alone. +pub(crate) struct RemovedRuns { + pub(crate) min_lines: usize, +} + +impl FoldMutation for RemovedRuns { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default(); + let mut next_id = next_id(sides); + let Some(lhs) = lhs_mut(sides) else { + return Ok(()); + }; + // A leaf needs a first, a middle, and a last line to split. + let threshold = self.min_lines.max(3); + split_removed_runs(&mut lhs.regions, &rhs_ids, threshold, false, &mut next_id); + Ok(()) + } +} + +fn split_removed_runs( + regions: &mut Vec, + rhs_ids: &DftHashSet, + threshold: usize, + under_collapsed: bool, + next_id: &mut u32, +) { + let mut out = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + let collapsed = under_collapsed || region.visibility.collapsed; + let splits = + !under_collapsed && !rhs_ids.contains(®ion.id) && line_count(®ion) >= threshold; + match &mut region.node { + Node::Fold { children } => { + split_removed_runs(children, rhs_ids, threshold, collapsed, next_id); + out.push(region); + } + Node::Leaf { .. } if splits => out.extend(split_leaf(region, next_id)), + Node::Leaf { .. } => out.push(region), + } + } + *regions = out; +} + +/// First line, collapsed middle, last line. The first piece keeps the +/// leaf's id; the others take fresh ones. Every piece is one-sided, so +/// nothing needs pairing. +fn split_leaf(region: Region, next_id: &mut u32) -> Vec { + let Node::Leaf { changed } = region.node else { + unreachable!("only leaves are split"); + }; + let lines = region.range.lines(); + let (first, last) = (lines.start, lines.end - 1); + let at = |line: u32| SourcePos { line, column: 0 }; + let piece = |id: u32, range: SourceRange, tags: Vec, visibility: Visibility| { + let changed = changed + .iter() + .copied() + .filter(|span| range.lines().contains(&span.line)) + .collect(); + Region { + id, + range, + tags, + visibility, + node: Node::Leaf { changed }, + } + }; + let middle_id = *next_id; + let last_id = *next_id + 1; + *next_id += 2; + let hidden = last - first - 1; + vec![ + piece( + region.id, + SourceRange { + start: region.range.start, + end: at(first + 1), + }, + region.tags.clone(), + region.visibility.clone(), + ), + piece( + middle_id, + SourceRange { + start: at(first + 1), + end: at(last), + }, + vec!["removed".to_owned()], + Visibility { + collapsed: true, + label: format!("{hidden} lines removed"), + }, + ), + piece( + last_id, + SourceRange { + start: at(last), + end: region.range.end, + }, + region.tags, + Visibility::default(), + ), + ] +} + fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> { match sides { Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs), @@ -96,6 +208,167 @@ mod tests { assert!(!file.visibility.collapsed); } + use crate::protocol::Span; + + fn removed_leaf(id: u32, start: u32, end: u32, changed: &[u32]) -> Region { + Region { + id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 4, + }) + .collect(), + }, + } + } + + fn left_only(regions: Vec) -> Pairing { + Pairing::LeftOnly { + lhs: Source { + text: String::new(), + syntax: vec![], + regions, + }, + } + } + + fn shape(regions: &[Region]) -> Vec<(u32, u32, u32, bool, String, Vec)> { + regions + .iter() + .map(|region| { + let Node::Leaf { changed } = ®ion.node else { + panic!("leaf expected"); + }; + let lines = region.range.lines(); + ( + region.id, + lines.start, + lines.end, + region.visibility.collapsed, + region.visibility.label.clone(), + changed.iter().map(|span| span.line).collect(), + ) + }) + .collect() + } + + #[test] + fn removed_runs_keep_the_first_and_last_line_open() { + let mut sides = left_only(vec![removed_leaf(0, 10, 17, &[10, 11, 12, 13, 14, 15, 16])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + assert_eq!( + shape(&sides.lhs().unwrap().regions), + vec![ + (0, 10, 11, false, String::new(), vec![10]), + ( + 1, + 11, + 16, + true, + "5 lines removed".to_owned(), + vec![11, 12, 13, 14, 15] + ), + (2, 16, 17, false, String::new(), vec![16]), + ] + ); + let middle = &sides.lhs().unwrap().regions[1]; + assert_eq!(middle.tags, vec!["removed".to_owned()]); + } + + #[test] + fn removed_runs_respect_the_threshold_and_the_switch() { + let mut sides = left_only(vec![removed_leaf(0, 0, 4, &[0, 1, 2, 3])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + assert_eq!(sides.lhs().unwrap().regions.len(), 1); + // The registry never builds the rule at 0; a tiny threshold still + // needs three lines to split. + let mut sides = left_only(vec![removed_leaf(0, 0, 2, &[0, 1])]); + RemovedRuns { min_lines: 1 } + .apply(&manifest(None), &mut sides) + .unwrap(); + assert_eq!(sides.lhs().unwrap().regions.len(), 1); + } + + #[test] + fn removed_runs_skip_paired_leaves_and_collapsed_ancestors() { + let paired = removed_leaf(7, 0, 8, &[]); + let mut sides = Pairing::Both { + lhs: Source { + text: String::new(), + syntax: vec![], + regions: vec![ + paired.clone(), + Region { + id: 1, + range: SourceRange { + start: SourcePos { line: 8, column: 0 }, + end: SourcePos { + line: 20, + column: 0, + }, + }, + tags: vec!["body".to_owned()], + visibility: Visibility { + collapsed: true, + label: "12 lines removed".to_owned(), + }, + node: Node::Fold { + children: vec![removed_leaf(2, 8, 20, &[])], + }, + }, + removed_leaf(3, 20, 30, &[]), + ], + }, + rhs: Source { + text: String::new(), + syntax: vec![], + regions: vec![paired], + }, + }; + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let lhs = &sides.lhs().unwrap().regions; + assert_eq!(lhs.len(), 5, "paired leaf, collapsed fold, three pieces"); + assert_eq!(lhs[0].id, 7); + let Node::Fold { children } = &lhs[1].node else { + panic!("fold expected"); + }; + assert_eq!( + children.len(), + 1, + "leaf under a collapsed fold is untouched" + ); + assert_eq!( + shape(&lhs[2..]), + vec![ + (3, 20, 21, false, String::new(), vec![]), + (8, 21, 29, true, "8 lines removed".to_owned(), vec![]), + (9, 29, 30, false, String::new(), vec![]), + ] + ); + } + #[test] fn deleted_bodies_collapse_when_large_and_one_sided() { let before = "def gone():\n a()\n b()\n c()\n\ndef kept():\n a()\n b()\n c()\n\ndef tiny():\n a()\n"; diff --git a/src/mutate/group.rs b/src/mutate/group.rs index 0f8cf1f25..5aefe500b 100644 --- a/src/mutate/group.rs +++ b/src/mutate/group.rs @@ -23,7 +23,7 @@ impl FoldMutation for GroupCollapsed { } } -fn next_id(sides: &Pairing) -> u32 { +pub(super) fn next_id(sides: &Pairing) -> u32 { let mut max = None; for source in [sides.lhs(), sides.rhs()].into_iter().flatten() { walk(&source.regions, &mut |region| { diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 8da38ca72..058189955 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -3,9 +3,9 @@ //! region trees. diffr's diff internals never see them. //! //! Order: file mutations (hidden categories), then fold mutations in this -//! sequence: deleted bodies, the built-in summarizer, the JSON-RPC hook, and -//! last the grouping pass that merges adjacent gaps and wraps runs of -//! collapsed folds. A mutation failure aborts the run; retries belong +//! sequence: deleted bodies, removed runs, the built-in summarizer, the +//! JSON-RPC hook, and last the grouping pass that merges adjacent gaps and +//! wraps runs of collapsed folds. A mutation failure aborts the run; retries belong //! inside a mutation. pub(crate) mod collapse; pub(crate) mod group; @@ -48,6 +48,11 @@ impl Mutations { min_lines: folds.min_lines, })); } + if folds.collapse_removed_lines > 0 { + mutations.fold.push(Box::new(collapse::RemovedRuns { + min_lines: folds.collapse_removed_lines, + })); + } if params.summarize.enabled { if let Some(summarizer) = summarize::Summarizer::new(¶ms.summarize, folds.min_lines)? From 68c2d9fa12323c1ed35886779cf9204ae1366a59 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 02:48:34 -0700 Subject: [PATCH 10/43] Do not redact an empty API key in config show An empty key resolves to no summarizer, so showing for it misled the user into thinking a key was set. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/cli.rs | 8 +++++++- src/config/store.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8c5d40078..2aaf58bbe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -590,7 +590,13 @@ fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result { stdout.write_all(b"\n")?; } else { let mut redacted = config.clone(); - if !reveal && redacted.summarize.api_key.is_some() { + if !reveal + && redacted + .summarize + .api_key + .as_deref() + .is_some_and(|key| !key.is_empty()) + { redacted.summarize.api_key = Some("".to_owned()); } stdout.write_all(toml::to_string_pretty(&redacted)?.as_bytes())?; diff --git a/src/config/store.rs b/src/config/store.rs index 981b53ea5..fb5ecaeea 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -9,7 +9,13 @@ use std::path::Path; /// The API key is redacted unless `reveal` is set. pub(crate) fn show(config: &Config, reveal: bool) -> serde_json::Value { let mut value = serde_json::to_value(config).expect("config serializes"); - if !reveal && config.summarize.api_key.is_some() { + if !reveal + && config + .summarize + .api_key + .as_deref() + .is_some_and(|key| !key.is_empty()) + { value["summarize"]["api_key"] = serde_json::Value::String("".to_owned()); } value From 88d80d8d0eb86d87b439355dd9d826ff571b0ae0 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 02:49:32 -0700 Subject: [PATCH 11/43] Give the settings screen a dedicated edit view Enter opens a key/value view with the description and default: booleans and enums pick from a list, numbers and strings use a text field that is masked for credential-looking keys. Enter saves through diffr config set and returns to the refreshed list; Esc returns. Both views carry footer hints, and empty values read as . Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 10 ++- tui/packages/hunk/src/ui/Settings.test.tsx | 68 +++++++++++++++--- tui/packages/hunk/src/ui/Settings.tsx | 81 +++++++++++++++++----- 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/tui/README.md b/tui/README.md index bca2abe83..9117349bf 100644 --- a/tui/README.md +++ b/tui/README.md @@ -45,9 +45,13 @@ diffr config show --json # resolved values, nested like the schema diffr config set # dotted key, value as typed: true, 12, gemini ``` -Type to filter (fuzzy over keys, substring over descriptions), `↑`/`↓` to move, Enter to -edit: booleans toggle, enums cycle, numbers and strings open an inline editor (Enter -saves, Esc cancels). `●` marks a value that differs from its default. Esc closes. +The list view: type to filter (fuzzy over keys, substring over descriptions), `↑`/`↓` to +move, Enter to edit, Esc to quit. `●` marks a value that differs from its default and an +empty value shows as ``. Enter opens an edit view for the selected setting with +its key, description and default: booleans and enums pick from a list (`↑`/`↓`, or `y`/`n` +for booleans), numbers and strings use a text field, masked for credential-looking keys +such as `api_key`. Enter saves through `diffr config set` and returns to the refreshed +list; Esc returns without saving. Both views carry a footer hint line. ## Controls diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx index 16a0e06cc..204548b65 100644 --- a/tui/packages/hunk/src/ui/Settings.test.tsx +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -1,10 +1,17 @@ import { expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { Settings } from "./Settings"; +import { Settings, displayValue, isSecret } from "./Settings"; import { schemaFixture, valuesFixture } from "../diffr/config.test"; import type { ConfigClient } from "../diffr/config"; -test("settings list filters as you type, toggles booleans, and edits numbers through the client", async () => { +test("empty values show as unset and credential keys are masked", () => { + expect(displayValue("")).toBe(""); + expect(displayValue(null)).toBe(""); + expect(displayValue(12)).toBe("12"); + expect(isSecret("summarize.api_key")).toBe(true); + expect(isSecret("folds.min_lines")).toBe(false); +}); +test("the list filters as you type and the edit view saves booleans, numbers, and enums", async () => { const writes: [string, string][] = []; const client: ConfigClient = { schema: () => schemaFixture, @@ -12,7 +19,7 @@ test("settings list filters as you type, toggles booleans, and edits numbers thr set: (key, value) => { writes.push([key, value]); }, }; let quit = false; - const t = await testRender( { quit = true; }} />, { width: 120, height: 14 }); + const t = await testRender( { quit = true; }} />, { width: 120, height: 16 }); const press = async (key: string) => { await act(async () => { t.mockInput.pressKey(key); }); await act(async () => { await t.renderOnce(); }); @@ -20,33 +27,72 @@ test("settings list filters as you type, toggles booleans, and edits numbers thr const type = async (text: string) => { for (const char of text) await press(char); }; + const frame = () => t.captureCharFrame(); // The rows between the search line and the footer. - const rows = (frame: string) => frame.split("\n").slice(2).filter((l) => !l.includes("esc:")).join("\n"); + const rows = () => frame().split("\n").slice(2).filter((l) => !l.includes("esc quit")).join("\n"); try { await act(async () => { await t.renderOnce(); }); - const frame = () => t.captureCharFrame(); expect(frame()).toContain("folds.min_lines"); expect(frame()).toContain("(default: 12)"); - // collapse_tests differs from its default and is marked. + expect(frame()).toContain("↑↓ move · enter edit · type to search · esc quit"); + // api_key is unset; collapse_tests differs from its default and is marked. + expect(frame().split("\n").find((l) => l.includes("api_key"))).toContain(""); expect(frame().split("\n").find((l) => l.includes("collapse_tests"))).toContain("●"); + // Boolean: the edit view lists both choices; y picks true, enter saves. await type("cltest"); - expect(rows(frame())).toContain("collapse_tests"); - expect(rows(frame())).not.toContain("min_lines"); + expect(rows()).toContain("collapse_tests"); + expect(rows()).not.toContain("min_lines"); + await press("RETURN"); + expect(frame()).toContain("Hide files classed as test."); + expect(frame()).toContain("default: true"); + expect(frame()).toContain("▸ false"); + expect(frame()).toContain("enter save · esc back"); + await press("y"); + expect(frame()).toContain("▸ true"); await press("RETURN"); expect(frame()).toContain("folds.collapse_tests = true"); expect(writes).toEqual([["folds.collapse_tests", "true"]]); + expect(frame().split("\n").find((l) => l.includes("collapse_tests"))).not.toContain("●"); + // Number: a text field, esc discards, enter saves. for (let i = 0; i < 6; i++) await press("BACKSPACE"); await type("min"); - expect(rows(frame())).toContain("min_lines"); - expect(rows(frame())).not.toContain("collapse_tests"); await press("RETURN"); - expect(frame()).toContain("enter: save"); + expect(frame()).toContain("value: 12▏"); + await press("BACKSPACE"); + await type("9"); + await press("ESCAPE"); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + await act(async () => { await t.renderOnce(); }); + expect(frame()).toContain("↑↓ move"); + expect(writes).toHaveLength(1); + await press("RETURN"); await press("BACKSPACE"); await press("BACKSPACE"); await type("20"); await press("RETURN"); expect(frame()).toContain("folds.min_lines = 20"); expect(writes.at(-1)).toEqual(["folds.min_lines", "20"]); + // Enum: arrows move through the options. + for (let i = 0; i < 3; i++) await press("BACKSPACE"); + await type("provider"); + await press("RETURN"); + expect(frame()).toContain("▸ gemini"); + await act(async () => { t.mockInput.pressArrow("down"); }); + await act(async () => { await t.renderOnce(); }); + expect(frame()).toContain("▸ none"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["summarize.provider", "none"]); + // Secret: typed masked. + for (let i = 0; i < 8; i++) await press("BACKSPACE"); + await type("api_key"); + await press("RETURN"); + await type("abc"); + expect(frame()).toContain("value: •••▏"); + expect(frame()).not.toContain("abc"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["summarize.api_key", "abc"]); + expect(rows().split("\n").find((l) => l.includes("api_key"))).toContain("••••••"); + expect(frame()).not.toContain("abc"); // A lone escape is only recognised once the parser's escape-sequence timeout passes. await press("ESCAPE"); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx index 7a03f962b..cbf47bc1a 100644 --- a/tui/packages/hunk/src/ui/Settings.tsx +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -14,51 +14,94 @@ import { dark } from "../diffr/rows"; import { sliceTextByWidth } from "./lib/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; const pad = (text: string, width: number) => fit(text, width).padEnd(width); +/** Keys that look like credentials are typed masked. */ +export const isSecret = (key: string) => /(api_key|secret|token|password)/i.test(key); +export const displayValue = (value: unknown) => { + const text = formatValue(value); + return text === "" ? "" : text; +}; +interface Edit { + setting: Setting; + /** Text field for numbers and strings; the chosen option for booleans and enums. */ + draft: string; +} export function Settings({ client, onQuit, initialQuery = "" }: { client: ConfigClient; onQuit: () => void; initialQuery?: string }) { const { width, height } = useTerminalDimensions(); const [settings, setSettings] = useState(() => flattenSchema(client.schema(), client.show())); const [query, setQuery] = useState(initialQuery), [cursor, setCursor] = useState(0), - [draft, setDraft] = useState(null), + [edit, setEdit] = useState(null), [message, setMessage] = useState(""); const visible = useMemo(() => filterSettings(settings, query), [settings, query]); const current = visible[Math.min(cursor, Math.max(0, visible.length - 1))]; const theme = dark; - const commit = (setting: Setting, text: string) => { + const options = (setting: Setting) => (setting.type === "boolean" ? ["true", "false"] : setting.options ?? []); + const save = (setting: Setting, text: string) => { const value = parseValue(setting, text); client.set(setting.key, text); setSettings((old) => old.map((s) => (s.key === setting.key ? { ...s, value } : s))); - setMessage(`${setting.key} = ${text}`); + setMessage(`${setting.key} = ${isSecret(setting.key) ? "••••••" : displayValue(value)}`); + setEdit(null); }; useKeyboard((key) => { - if (draft !== null) { - if (key.name === "escape") setDraft(null); + if (edit) { + const { setting, draft } = edit; + const choices = options(setting); + if (key.name === "escape") setEdit(null); else if (key.name === "return") { try { - commit(current, draft); - setDraft(null); + save(setting, draft); } catch (error) { setMessage(String(error instanceof Error ? error.message : error)); } - } else if (key.name === "backspace") setDraft(draft.slice(0, -1)); - else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) setDraft(draft + key.sequence); + } else if (choices.length) { + const index = choices.indexOf(draft); + if (key.name === "down" || key.name === "up" || key.name === "space" || key.name === "tab") + setEdit({ setting, draft: choices[(index + (key.name === "up" ? choices.length - 1 : 1)) % choices.length] }); + else if (setting.type === "boolean" && (key.name === "y" || key.name === "n")) + setEdit({ setting, draft: key.name === "y" ? "true" : "false" }); + } else if (key.name === "backspace") setEdit({ setting, draft: draft.slice(0, -1) }); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) + setEdit({ setting, draft: draft + key.sequence }); return; } if (key.name === "escape" || (key.ctrl && key.name === "c")) onQuit(); else if (key.name === "down" || (key.ctrl && key.name === "n")) setCursor((c) => Math.min(c + 1, visible.length - 1)); else if (key.name === "up" || (key.ctrl && key.name === "p")) setCursor((c) => Math.max(c - 1, 0)); else if (key.name === "return" && current) { - if (current.type === "boolean") commit(current, current.value ? "false" : "true"); - else if (current.type === "enum") { - const options = current.options!; - const next = options[(options.indexOf(formatValue(current.value)) + 1) % options.length]; - commit(current, next); - } else setDraft(formatValue(current.value)); + const choices = options(current); + const value = formatValue(current.value); + setEdit({ setting: current, draft: choices.length && !choices.includes(value) ? choices[0] : value }); } else if (key.name === "backspace") { setQuery((q) => q.slice(0, -1)); setCursor(0); } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { setQuery((q) => q + key.sequence); setCursor(0); } }); + if (edit) { + const { setting, draft } = edit; + const choices = options(setting); + const field = isSecret(setting.key) ? "•".repeat(draft.length) : draft; + return ( + + {fit(` ${setting.key}`, width)} + {fit(` ${setting.description}`, width)} + {fit(` default: ${displayValue(setting.default)}`, width)} + {" "} + {choices.length + ? choices.map((choice) => ( + + {fit(` ${choice === draft ? "▸" : " "} ${choice}`, width)} + + )) + : {fit(` value: ${field}▏`, width)}} + + + {fit(` enter save · esc back${setting.type === "boolean" ? " · y/n or ↑↓ choose" : choices.length ? " · ↑↓ choose" : ""} ${message}`, width)} + + + ); + } const keyWidth = Math.min(32, Math.max(8, ...settings.map((s) => s.key.length)) + 1); - const valueWidth = Math.min(24, Math.max(6, ...settings.map((s) => formatValue(s.value).length)) + 3); + const valueWidth = Math.min(24, Math.max(7, ...settings.map((s) => displayValue(s.value).length)) + 3); const descriptionWidth = Math.max(8, width - keyWidth - valueWidth - 4); const bodyHeight = Math.max(1, height - 4); const start = Math.max(0, Math.min(cursor - bodyHeight + 1, visible.length - bodyHeight)); @@ -68,8 +111,8 @@ export function Settings({ client, onQuit, initialQuery = "" }: { client: Config {fit(` > ${query}▏`, width)} {visible.slice(start, start + bodyHeight).map((setting, i) => { const selected = start + i === cursor; - const value = selected && draft !== null ? `${draft}▏` : formatValue(setting.value); - const description = `${setting.description}${setting.default === undefined ? "" : ` (default: ${formatValue(setting.default)})`}`; + const value = isSecret(setting.key) && formatValue(setting.value) !== "" ? "••••••" : displayValue(setting.value); + const description = `${setting.description}${setting.default === undefined ? "" : ` (default: ${displayValue(setting.default)})`}`; return ( @@ -80,7 +123,7 @@ export function Settings({ client, onQuit, initialQuery = "" }: { client: Config {visible.length === 0 && {" No settings match"}} - {fit(draft !== null ? " enter: save · esc: cancel" : ` type to filter · ↑↓ move · enter: edit · esc: close · ● changed from default ${message}`, width)} + {fit(` ↑↓ move · enter edit · type to search · esc quit · ● changed from default ${message}`, width)} ); From 334bac40e94de3f83bd0af7141d8e98ce94c89ac Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 02:59:37 -0700 Subject: [PATCH 12/43] Tint every line of a novel leaf in the terminal frontend Lines inside a novel leaf that carry no change span, such as blank lines in a new block, now get the light line tint; the darker word tint stays on the spans. Tests assert the two backgrounds are distinct in both themes. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/rows.test.ts | 28 +++++++++++++++++++++--- tui/packages/hunk/src/diffr/rows.ts | 6 ++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index a5fa244ba..b1f5852d7 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createTestDiffFile, leaf, line, withIdenticalLines } from "./fixture"; -import { captureColor, dark, lineSpans, rowsForFile } from "./rows"; +import { captureColor, dark, light, lineSpans, rowsForFile } from "./rows"; import { measureRows, visibleRows } from "./geometry"; import { copySelection } from "./selection"; test("split zips leaves on their ids, unified groups old before new", () => { @@ -37,6 +37,28 @@ test("moved leaves render one-sided on each side", () => { [undefined, 1], [1, 2], [2, undefined], ]); }); +test("changed spans paint the darker word tint, distinct from the line tint", () => { + for (const theme of [dark, light]) { + const spans = lineSpans("let x = old + y;", [], [line(0, 8, 11)], "right", theme); + expect(spans.map((s) => [s.text, s.bg])).toEqual([ + ["let x = ", undefined], ["old", theme.addWord], [" + y;", undefined], + ]); + expect(theme.addWord).not.toBe(theme.addition); + expect(lineSpans("old", [], [line(0, 0, 3)], "left", theme)[0].bg).toBe(theme.deleteWord); + expect(theme.deleteWord).not.toBe(theme.deletion); + } +}); +test("every line of a novel leaf is tinted, even without a span", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: "a\n", syntax: [], regions: [leaf(1, 0, 1)] }; + // A new block: one changed word, a blank line, and an unpaired leaf with no spans at all. + file.diff.rhs = { text: "a\nb = 1\n\nc\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 3, [line(1, 4, 5)]), leaf(3, 3, 4)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.right); + expect(rows.map((r) => [r.right!.kind, r.right!.spans.some((s) => s.bg)])).toEqual([ + ["context", false], ["addition", true], ["addition", false], ["addition", false], + ]); +}); test("byte spans survive multibyte characters and tabs; captures pick theme colours", () => { const spans = lineSpans( "é\t变量", @@ -110,10 +132,10 @@ test("context gaps come from collapsed unchanged leaves, one row per gap", () => fold?.collapsed ? fold.label : line; expect(split.slice(1).map(r => shown(r.left?.fold, r.left?.lineNumber))) .toEqual(["1 unchanged lines", 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); - // Unified shows the changed line once per side. + // Unified shows the novel leaf's lines once per side, removals first. const unified = rowsForFile(file, 0, "unified", dark, new Set([1, 3, 5])); expect(unified.slice(1).map(r => shown(r.cell?.fold, r.cell?.newLineNumber ?? r.cell?.oldLineNumber))) - .toEqual(["1 unchanged lines", 2, 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); + .toEqual(["1 unchanged lines", 2, 3, 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); for (const layout of ["split", "unified"] as const) { expect(rowsForFile(file, 0, layout, dark, new Set([1, 3, 5])).filter(r => r.hunkStart)).toHaveLength(1); expect(rowsForFile(file, 0, layout, dark, new Set()).length).toBeGreaterThanOrEqual(13); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index dbc519354..6c56b37e7 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -168,6 +168,10 @@ export function rowsForFile( const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; const headers = [foldHeaders(folds[0], leaves[0], collapsed), foldHeaders(folds[1], leaves[1], collapsed)]; const caches = [new Map(), new Map()]; + // A leaf is novel when it carries change spans or has no counterpart; every line in it is + // tinted, and the spans inside get the darker word tint on top. + const ids = [new Set(leaves[0].map((l) => l.id)), new Set(leaves[1].map((l) => l.id))]; + const novel = (leaf: Leaf) => leaf.changed.size > 0 || !ids[leaf.side ? 0 : 1].has(leaf.id); const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; const text = texts[side][line]; @@ -178,7 +182,7 @@ export function rowsForFile( side ? "right" : "left", theme); caches[side].set(line, spans); } - const changed = leaf.changed.has(line); + const changed = novel(leaf); return { kind: changed ? (side ? "addition" : "deletion") : "context", sign: changed ? (side ? "+" : "-") : " ", From 1a5ea497a39ffbb006c08d82a4aa279390e837a1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 03:01:37 -0700 Subject: [PATCH 13/43] Make the structural diff limits configurable and raise the graph limit A large rewrite of src/hook.rs exceeded difftastic's 3M graph limit, so the streaming path fell back to a line diff with no folds, collapse rules, or summaries. A [diff] table now carries byte_limit, graph_limit and parse_error_limit, layered like every other key, with the DFT_* variables and the command-line flags still overriding it. The graph limit defaults to 30M, which keeps that file structural for about one extra second in a debug build. Fallback reasons name the key to raise. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 15 ++++++++ src/cli.rs | 33 ++++++++-------- src/config.rs | 81 ++++++++++++++++++++++++++++++++++++++++ src/engine.rs | 10 +++-- src/protocol/project.rs | 4 +- tests/streaming/check.py | 13 ++++++- 6 files changed, 135 insertions(+), 21 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5877eb412..4eaa05422 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,6 +54,11 @@ timeout_ms = 60000 max_concurrency = 16 # requests in flight across files retries = 3 # on timeouts, rate limits and server errors +[diff] +byte_limit = 1000000 # larger files on either side get a line diff +graph_limit = 30000000 # the largest AST matching graph explored per file +parse_error_limit = 0 # more tree-sitter parse errors than this: line diff + [theme] name = "default-dark" # a bundled terminal theme path = "/path/to/theme.toml" # or a Helix-style theme file @@ -73,6 +78,16 @@ startup_timeout_ms = 30000 `languages` and `folds.hook` are not in the schema, so the settings screen does not show them; `config set` still accepts their keys. +When a file exceeds a `[diff]` limit it falls back to a line diff: no folds, +no collapse rules, no summaries, and the file's `stats` carries a `fallback` +with code `too_large`, `too_complex` or `parse_error` and a message naming +the key to raise. `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and +`DFT_PARSE_ERROR_LIMIT` override the file for one run, and `--byte-limit`, +`--graph-limit` and `--parse-error-limit` override both. The graph limit is +ten times difftastic's default because a large rewrite of a large file, such +as replacing an HTTP client, exceeded the old one; that file takes about a +second longer to compare and every other file is unaffected. + ## File categories Every file in the manifest carries a `category`: `source`, `test`, diff --git a/src/cli.rs b/src/cli.rs index 2aaf58bbe..ae84fd477 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -137,25 +137,11 @@ pub(crate) fn run() -> Result { }, ..DisplayOptions::default() }; - let mut diff_options = DiffOptions { - ignore_comments: args.get_flag("ignore-comments"), - ..DiffOptions::default() - }; - if let Some(limit) = args.get_one::("byte-limit") { - diff_options.byte_limit = *limit; - } - if let Some(limit) = args.get_one::("graph-limit") { - diff_options.graph_limit = *limit; - } - if let Some(limit) = args.get_one::("parse-error-limit") { - diff_options.parse_error_limit = *limit; - } if args.get_flag("no-index") { return no_index( &args, items.into_iter().chain(explicit_paths).collect(), &display, - &diff_options, stream_options, ); } @@ -192,6 +178,7 @@ pub(crate) fn run() -> Result { changed } else { let params = Arc::new(load_config(&args, workspace)?.compile()?); + let diff_options = diff_options(&args, ¶ms)?; let context_lines = args .get_one::("unified") .copied() @@ -458,7 +445,6 @@ fn no_index( args: &ArgMatches, paths: Vec, display: &DisplayOptions, - options: &DiffOptions, stream_options: crate::protocol::stream::Options, ) -> Result { if paths.len() != 2 { @@ -489,6 +475,7 @@ fn no_index( } let workspace = Path::new(args.get_one::("repo").unwrap()); let config = load_config(args, workspace)?.compile()?; + let options = &diff_options(args, &config)?; let display = &DisplayOptions { num_context_lines: args .get_one::("unified") @@ -549,6 +536,22 @@ fn no_index( } } +/// The engine limits: the configured `[diff]` table, then the `DFT_*` +/// variables, then the command-line flags. +fn diff_options(args: &ArgMatches, params: &config::Params) -> Result { + let mut options = params.diff.options(args.get_flag("ignore-comments"))?; + if let Some(limit) = args.get_one::("byte-limit") { + options.byte_limit = *limit; + } + if let Some(limit) = args.get_one::("graph-limit") { + options.graph_limit = *limit; + } + if let Some(limit) = args.get_one::("parse-error-limit") { + options.parse_error_limit = *limit; + } + Ok(options) +} + /// Every layer: defaults, the global file (or `--config`), the repository's /// `diffr.toml`, `DIFFR_*` variables, then `--set` overrides. fn load_config(args: &ArgMatches, workspace: &Path) -> Result { diff --git a/src/config.rs b/src/config.rs index 5fb16f03b..dec40280a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ pub(crate) mod query; pub(crate) mod store; use crate::hash::DftHashMap; +use crate::options::DiffOptions; use crate::parse::{guess_language::Language, tree_sitter_parser}; use figment::providers::{Env, Format, Serialized, Toml}; use figment::Figment; @@ -30,6 +31,63 @@ pub(crate) struct Config { pub(crate) summarize: SummarizeConfig, /// Colors for the terminal frontend. pub(crate) theme: ThemeConfig, + /// Limits on the structural comparison itself. + pub(crate) diff: DiffConfig, +} + +/// When a file exceeds one of these, diffr falls back to a line diff for +/// it: no folds, no collapse rules, no summaries, and `stats.structural` +/// carries the reason. The `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and +/// `DFT_PARSE_ERROR_LIMIT` variables override the file, and the matching +/// command-line flags override both. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DiffConfig { + /// Files larger than this many bytes on either side get a line diff. + pub(crate) byte_limit: usize, + /// The largest AST matching graph diffr will explore for one file. + /// A large change to a large file can exceed it; raising it costs time + /// and memory on those files only. + pub(crate) graph_limit: usize, + /// Files with more tree-sitter parse errors than this get a line diff. + pub(crate) parse_error_limit: usize, +} + +impl Default for DiffConfig { + fn default() -> Self { + Self { + byte_limit: crate::options::DEFAULT_BYTE_LIMIT, + graph_limit: 30_000_000, + parse_error_limit: crate::options::DEFAULT_PARSE_ERROR_LIMIT, + } + } +} + +impl DiffConfig { + /// The engine options for these limits, with the `DFT_*` variables + /// applied on top. A variable that is set but not a number is an error. + pub(crate) fn options(&self, ignore_comments: bool) -> Result { + let limit = |name: &str, configured: usize| -> Result { + match std::env::var(name) { + Ok(text) => text.trim().parse().map_err(|_| { + ConfigError(format!( + "{name} must be a non-negative integer, got {text:?}" + )) + }), + Err(std::env::VarError::NotPresent) => Ok(configured), + Err(std::env::VarError::NotUnicode(_)) => { + Err(ConfigError(format!("{name} is not valid UTF-8"))) + } + } + }; + Ok(DiffOptions { + byte_limit: limit("DFT_BYTE_LIMIT", self.byte_limit)?, + graph_limit: limit("DFT_GRAPH_LIMIT", self.graph_limit)?, + parse_error_limit: limit("DFT_PARSE_ERROR_LIMIT", self.parse_error_limit)?, + ignore_comments, + ..DiffOptions::default() + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -185,6 +243,7 @@ pub(crate) struct Params { pub(crate) folds: FoldsConfig, pub(crate) summarize: SummarizeConfig, pub(crate) hook: Option, + pub(crate) diff: DiffConfig, } pub(crate) struct LanguageParams { @@ -380,6 +439,7 @@ impl Config { languages, folds: self.folds.clone(), summarize: self.summarize, + diff: self.diff, hook: self.folds.hook, }) } @@ -424,6 +484,27 @@ mod tests { use super::*; use crate::summary::DiffResult; + #[test] + fn diff_limits_default_and_layer_from_the_file() { + let defaults = Config::default().diff; + assert_eq!(defaults.graph_limit, 30_000_000); + assert_eq!(defaults.byte_limit, crate::options::DEFAULT_BYTE_LIMIT); + let custom = Config::from_toml("[diff]\ngraph_limit = 5").unwrap(); + assert_eq!(custom.diff.graph_limit, 5); + assert_eq!(custom.diff.byte_limit, defaults.byte_limit); + let options = custom.diff.options(true).unwrap(); + assert_eq!(options.graph_limit, 5); + assert!(options.ignore_comments); + let compiled = custom.compile().unwrap(); + assert_eq!(compiled.diff.graph_limit, 5); + let schema = Config::schema(); + assert!( + schema["$defs"]["DiffConfig"]["properties"]["graph_limit"]["description"] + .as_str() + .is_some_and(|text| !text.is_empty()) + ); + } + #[test] fn configuration_is_independent_and_omission_keeps_other_defaults() { let custom = Config::from_toml("[languages.rust]\nfolds = ''") diff --git a/src/engine.rs b/src/engine.rs index cb9f32439..53fa95565 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -222,7 +222,10 @@ pub(crate) fn diff_file_content( line_parser::change_positions(lhs_src, rhs_src); ( FileFormat::TextFallback { - reason: "exceeded DFT_GRAPH_LIMIT".into(), + reason: format!( + "structural diff exceeded diff.graph_limit ({}); raise it in diffr config", + diff_options.graph_limit + ), }, lhs_positions, rhs_positions, @@ -306,8 +309,9 @@ pub(crate) fn diff_file_content( let format_options = FormatSizeOptions::from(BINARY).decimal_places(1); let file_format = FileFormat::TextFallback { reason: format!( - "{} exceeded DFT_BYTE_LIMIT", - format_size(num_bytes, format_options) + "{} exceeded diff.byte_limit ({}); raise it in diffr config", + format_size(num_bytes, format_options), + diff_options.byte_limit ), }; diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 71978b0df..827cec234 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -111,9 +111,9 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { /// difftastic reports its fallbacks as prose; the wire wants a code. fn fallback_code(reason: &str) -> &'static str { - if reason.contains("DFT_BYTE_LIMIT") { + if reason.contains("byte_limit") { "too_large" - } else if reason.contains("DFT_GRAPH_LIMIT") { + } else if reason.contains("graph_limit") { "too_complex" } else if reason.contains("parse error") { "parse_error" diff --git a/tests/streaming/check.py b/tests/streaming/check.py index a36ea6fb3..43ae3d993 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -208,6 +208,17 @@ def check_tiling(source): assert any( r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) ) + # The structural limits come from [diff]; exceeding one names the key. + events = stream(repo, base, head, "--", "a.rs") + assert "structural" in events[1]["diff"]["stats"], events[1]["diff"]["stats"] + for args in (["--graph-limit", "1"], ["--set", "diff.graph_limit=1"]): + events = stream(repo, base, head, *args, "--", "a.rs") + fallback = events[1]["diff"]["stats"]["fallback"] + assert fallback["code"] == "too_complex", fallback + assert "diff.graph_limit (1)" in fallback["message"], fallback + assert not all_regions(events[1]["diff"]["rhs"]["regions"]) or all( + r["kind"] == "leaf" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) + ) # The previous stream stays available while frontends migrate. v1 = cli( repo, "--format", "ndjson-v1", "--config", str(custom), base, head, "--", "a.rs" @@ -364,7 +375,7 @@ def config(*args, code=0): return result.stdout.decode() schema = json.loads(config("schema")) - assert schema["properties"]["folds"] + assert schema["properties"]["folds"] and schema["properties"]["diff"] shown = json.loads(config("show", "--json")) assert shown["folds"]["min_lines"] == 12 and shown["summarize"]["api_key"] is None config("set", "folds.min_lines", "3") From f6848d59e7d23f5c7d634beca2b771afebc3250f Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 03:08:58 -0700 Subject: [PATCH 14/43] Keep difftastic's default graph limit Thirty million nodes across sixteen parallel files was too much memory. The limit stays configurable; large rewrites fall back to a line diff by default, and agent traces are the planned way around that. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 10 +++++----- src/config.rs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index 4eaa05422..485f92c87 100644 --- a/docs/config.md +++ b/docs/config.md @@ -56,7 +56,7 @@ retries = 3 # on timeouts, rate limits and server errors [diff] byte_limit = 1000000 # larger files on either side get a line diff -graph_limit = 30000000 # the largest AST matching graph explored per file +graph_limit = 3000000 # the largest AST matching graph explored per file parse_error_limit = 0 # more tree-sitter parse errors than this: line diff [theme] @@ -83,10 +83,10 @@ no collapse rules, no summaries, and the file's `stats` carries a `fallback` with code `too_large`, `too_complex` or `parse_error` and a message naming the key to raise. `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and `DFT_PARSE_ERROR_LIMIT` override the file for one run, and `--byte-limit`, -`--graph-limit` and `--parse-error-limit` override both. The graph limit is -ten times difftastic's default because a large rewrite of a large file, such -as replacing an HTTP client, exceeded the old one; that file takes about a -second longer to compare and every other file is unaffected. +`--graph-limit` and `--parse-error-limit` override both. The defaults are +difftastic's. A large rewrite can exceed the graph limit and fall back to a +line diff; raising the limit trades memory for it across every file diffed in +parallel, so prefer narrowing the comparison or leaving the fallback. ## File categories diff --git a/src/config.rs b/src/config.rs index dec40280a..9c87ce186 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,7 +57,7 @@ impl Default for DiffConfig { fn default() -> Self { Self { byte_limit: crate::options::DEFAULT_BYTE_LIMIT, - graph_limit: 30_000_000, + graph_limit: crate::options::DEFAULT_GRAPH_LIMIT, parse_error_limit: crate::options::DEFAULT_PARSE_ERROR_LIMIT, } } @@ -487,7 +487,7 @@ mod tests { #[test] fn diff_limits_default_and_layer_from_the_file() { let defaults = Config::default().diff; - assert_eq!(defaults.graph_limit, 30_000_000); + assert_eq!(defaults.graph_limit, crate::options::DEFAULT_GRAPH_LIMIT); assert_eq!(defaults.byte_limit, crate::options::DEFAULT_BYTE_LIMIT); let custom = Config::from_toml("[diff]\ngraph_limit = 5").unwrap(); assert_eq!(custom.diff.graph_limit, 5); From b1d6ed137a271267e63aee6327336b63c34b75df Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 09:28:16 -0700 Subject: [PATCH 15/43] Take folds from the parse and pair them through the line alignment The domain tree is a tree of correlated ranges; the structural engine and the text diff are two producers of the line alignment, and folds are a layer over whichever one exists. When the AST match gives up (graph limit, parse-error limit) the parsed trees still yield folds and enclosing-header context, and the projection pairs folds on both engines with one rule: header lines paired in the alignment, tags equal. The parser's own fold pairing is no longer read. hook.rs goes from no folds to 34/51 folds with 17 pairs and 5 summarizable bodies. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/streaming.md | 10 +- src/engine.rs | 30 ++++ src/mutate/summarize.rs | 29 +++- src/parse/folds.rs | 12 ++ src/protocol/mod.rs | 15 +- src/protocol/project.rs | 244 +++++++++++++++++++++------- tests/streaming/check.py | 8 +- tui/test/fixtures/comparison.ndjson | 2 +- 8 files changed, 275 insertions(+), 75 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 93f42b7e7..3675669bf 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -134,9 +134,13 @@ per line, sorted, and non-overlapping; where captures nest, the innermost wins. Files that fell back to a line diff have no syntax. `stats.textual` counts lines with any byte change. `stats.structural` counts lines -with a syntactic change and is replaced by `fallback` when tree-sitter did not run: -`unsupported_language`, `too_large`, `too_complex`, `parse_error`. A fallback diff -has leaves but no folds. +with a syntactic change and is replaced by `fallback` when the AST match did not +run: `unsupported_language`, `too_large`, `too_complex`, `parse_error`. A fallback +diff is aligned by a line diff and its `changed` spans are word-level, but the +parse still stands: folds and the enclosing-header context are present whenever +the language parsed (`too_complex`, `parse_error`), paired through that alignment +exactly as they are for a structural diff. Only `unsupported_language` and +`too_large` produce leaves alone. ### Regions diff --git a/src/engine.rs b/src/engine.rs index 53fa95565..3860c53e3 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,6 +11,7 @@ use crate::display::hunks::{matched_pos_to_hunks, merge_adjacent}; use crate::line_parser; use crate::lines::MaxLine; use crate::options::{DiffOptions, DisplayOptions, FileArgument}; +use crate::parse::folds; use crate::parse::guess_language::{guess, language_name, LanguageOverride}; use crate::parse::syntax::{self, init_next_prev}; use crate::parse::tree_sitter_parser as tsp; @@ -218,6 +219,14 @@ pub(crate) fn diff_file_content( } if exceeded_graph_limit { + // The parse still stands: folds and enclosing + // context come from it, and the line diff + // supplies the alignment they hang off. + folds::unmatched(&lhs, &mut lhs_folds); + folds::unmatched(&rhs, &mut rhs_folds); + annotations = display::syntax_context::SyntaxAnnotations::collect( + (&lhs, &rhs), + ); let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); ( @@ -299,6 +308,27 @@ pub(crate) fn diff_file_content( ); } + // The trees parsed, only with too many errors to + // match on. Folds and context still come from them. + let (lhs, _) = tsp::to_syntax( + &lhs_tree, + lhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ); + let (rhs, _) = tsp::to_syntax( + &rhs_tree, + rhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ); + folds::unmatched(&lhs, &mut lhs_folds); + folds::unmatched(&rhs, &mut rhs_folds); + annotations = + display::syntax_context::SyntaxAnnotations::collect((&lhs, &rhs)); + let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs index be335635d..21c4c1e02 100644 --- a/src/mutate/summarize.rs +++ b/src/mutate/summarize.rs @@ -300,6 +300,15 @@ pub(crate) mod tests { /// Project a two-source comparison the way the stream does. pub(crate) fn project(path: &str, before: &str, after: &str) -> (FileChange, Pairing) { + project_with(path, before, after, crate::options::DiffOptions::default()) + } + + pub(crate) fn project_with( + path: &str, + before: &str, + after: &str, + options: crate::options::DiffOptions, + ) -> (FileChange, Pairing) { let params = Config::from_toml("").unwrap().compile().unwrap(); let result = crate::summary::DiffResult::from_sources_with_options( path, @@ -307,7 +316,7 @@ pub(crate) mod tests { after, ¶ms, &crate::options::DisplayOptions::default(), - &crate::options::DiffOptions::default(), + &options, ); let file_ref = FileRef { path: path.to_owned(), @@ -419,6 +428,24 @@ pub(crate) mod tests { assert!(select(&sides, 3).is_empty()); } + #[test] + fn selection_still_finds_new_bodies_when_the_match_fell_back() { + let before = "def keep():\n a = 1\n b = 2\n return a + b\n"; + let after = "def keep():\n a = 1\n b = 2\n return a + b\n\ndef fresh():\n x = 1\n y = 2\n return x + y\n"; + let (_, sides) = project_with( + "a.py", + before, + after, + crate::options::DiffOptions { + graph_limit: 1, + ..crate::options::DiffOptions::default() + }, + ); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (7, 9)); + } + #[test] fn summaries_collapse_selected_folds_with_a_pseudocode_comment() { let (file, mut sides) = project("a.py", "", LARGE); diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 8714add33..216b12ec0 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -144,6 +144,18 @@ fn range(node: &Syntax<'_>) -> Option { Some(region) } +/// Every fold in a parsed side, without any correspondence. Used when the +/// AST match did not run: the projection pairs folds through the line +/// alignment instead. +pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) { + for node in nodes { + folds.extend(project(node, ChangeKind::Novel)); + if let Syntax::List { children, .. } = node { + unmatched(children, folds); + } + } +} + /// Project a side-local annotation using the same correspondence as MatchedPos. pub(crate) fn project(node: &Syntax<'_>, change: ChangeKind<'_>) -> Option { let own = node.info().fold.borrow().clone()?; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 8b8f07750..6f1100e66 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -297,10 +297,13 @@ pub struct SyntaxSpan { } /// A range on one side. The same `id` on the other side is its -/// counterpart. Ids appear at most once per side. A paired leaf has the -/// same line count on both sides and its rows pair line for line; a paired -/// leaf whose counterpart is behind the reading cursor is a move, and the -/// frontend chooses how to show it. +/// counterpart. Two leaves correspond when the line alignment pairs their +/// lines. Two folds correspond when their header lines are paired as +/// unchanged in that alignment, whichever engine produced it. Ids appear +/// at most once per side. A paired leaf has the same line count on both +/// sides and its rows pair line for line; a paired leaf whose counterpart +/// is behind the reading cursor is a move, and the frontend chooses how to +/// show it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Region { pub id: u32, @@ -369,7 +372,9 @@ pub struct SourcePos { pub struct Stats { /// Lines with any byte change. pub textual: LineCounts, - /// Lines with a syntactic change. + /// Lines with a syntactic change. `Err` means the AST match did not + /// run: the alignment is a line diff, `changed` spans are word-level, + /// and folds are still present, paired through that alignment. pub structural: Result, } diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 827cec234..f72b51880 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -21,7 +21,7 @@ use crate::display::line_layout::{aligned_rows, novel_lines}; use crate::hash::DftHashMap; use crate::line_parser; use crate::lines; -use crate::parse::folds::{Fold, FoldMatch}; +use crate::parse::folds::Fold; use crate::parse::syntax::{MatchKind, MatchedPos}; use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; use crate::summary::{DiffResult, FileContent, FileFormat}; @@ -241,20 +241,15 @@ fn regions( .filter_map(|run| run.rhs) .collect(); - let lhs_folds: Vec> = - side_folds(&result.lhs_folds, &result.rhs_folds, Side::Left, &lhs_lines)? - .into_iter() - .filter(|fold| !inside_gap(fold.lines, &lhs_gaps)) - .collect(); - let rhs_folds: Vec> = side_folds( - &result.lhs_folds, - &result.rhs_folds, - Side::Right, - &rhs_lines, - )? - .into_iter() - .filter(|fold| !inside_gap(fold.lines, &rhs_gaps)) - .collect(); + let mut lhs_folds: Vec> = side_folds(&result.lhs_folds, &lhs_lines) + .into_iter() + .filter(|fold| !inside_gap(fold.lines, &lhs_gaps)) + .collect(); + let mut rhs_folds: Vec> = side_folds(&result.rhs_folds, &rhs_lines) + .into_iter() + .filter(|fold| !inside_gap(fold.lines, &rhs_gaps)) + .collect(); + pair_folds(&mut lhs_folds, &mut rhs_folds, &rows); let lhs_splits = split_lines(&lhs_folds); let rhs_splits = split_lines(&rhs_folds); let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); @@ -279,12 +274,6 @@ fn regions( Ok((lhs, rhs)) } -#[derive(Clone, Copy)] -enum Side { - Left, - Right, -} - /// Rows in order, run-length encoded by kind and side presence. fn runs( rows: &[(Option, Option)], @@ -418,55 +407,71 @@ fn line_span(range: &lines::SourceRange, line_count: usize) -> (usize, usize) { ) } -fn side_folds<'a>( - lhs_folds: &'a [Fold], - rhs_folds: &'a [Fold], - side: Side, - lines: &[&str], -) -> Result>, Problem> { - let (own, other) = match side { - Side::Left => (lhs_folds, rhs_folds), - Side::Right => (rhs_folds, lhs_folds), - }; - let by_range: DftHashMap = other +fn side_folds<'a>(folds: &'a [Fold], lines: &[&str]) -> Vec> { + folds .iter() - .enumerate() - .map(|(index, fold)| (fold.range, index)) - .collect(); - own.iter() - .enumerate() // A fold on a single line hides nothing; it is not a region. - .filter(|(_, fold)| { + .filter(|fold| { let (start, end) = line_span(&fold.range, lines.len()); end - start >= 2 }) - .map(|(index, fold)| { - let pair = match &fold.match_kind { - FoldMatch::Novel => None, - FoldMatch::Unchanged { opposite } => { - let other_index = *by_range.get(opposite).ok_or_else(|| Problem { - code: "fold_pairing".to_owned(), - message: format!( - "fold at line {} has no counterpart at {:?}", - fold.range.start.line.as_usize() + 1, - opposite - ), - })?; - Some(match side { - Side::Left => index, - Side::Right => other_index, - }) - } - }; - Ok(SideFold { - fold, - lines: line_span(&fold.range, lines.len()), - pair, - }) + .map(|fold| SideFold { + fold, + lines: line_span(&fold.range, lines.len()), + pair: None, }) .collect() } +/// Two folds correspond when the line alignment pairs their header lines +/// and their tags agree. A changed signature is still a paired row, so a +/// function whose header was edited keeps its counterpart. The rule is the +/// same whichever engine produced the alignment: the structural engine +/// pairs rows through matched tokens, the text diff through its line +/// alignment. Folds sharing a header line on one side, such as a body and +/// a collection opened on the same line, pair in order of span length. +fn pair_folds( + lhs_folds: &mut [SideFold<'_>], + rhs_folds: &mut [SideFold<'_>], + rows: &[(Option, Option)], +) { + let aligned: DftHashMap = rows + .iter() + .filter_map(|&(lhs, rhs)| Some((lhs?, rhs?))) + .collect(); + let mut rhs_by_header: BTreeMap> = BTreeMap::new(); + for (index, fold) in rhs_folds.iter().enumerate() { + rhs_by_header.entry(fold.lines.0).or_default().push(index); + } + let span = |fold: &SideFold<'_>| fold.lines.1 - fold.lines.0; + for candidates in rhs_by_header.values_mut() { + candidates.sort_by_key(|&index| std::cmp::Reverse(span(&rhs_folds[index]))); + } + let mut lhs_by_header: BTreeMap> = BTreeMap::new(); + for (index, fold) in lhs_folds.iter().enumerate() { + lhs_by_header.entry(fold.lines.0).or_default().push(index); + } + for (header, mut own) in lhs_by_header { + let Some(other) = aligned + .get(&header) + .and_then(|line| rhs_by_header.get(line)) + else { + continue; + }; + own.sort_by_key(|&index| std::cmp::Reverse(span(&lhs_folds[index]))); + for lhs_index in own { + let tags = &lhs_folds[lhs_index].fold.tags; + let Some(&rhs_index) = other.iter().find(|&&index| { + rhs_folds[index].pair.is_none() && rhs_folds[index].fold.tags == *tags + }) else { + continue; + }; + lhs_folds[lhs_index].pair = Some(lhs_index); + rhs_folds[rhs_index].pair = Some(lhs_index); + } + } +} + fn split_lines(folds: &[SideFold<'_>]) -> BTreeSet { folds .iter() @@ -784,6 +789,7 @@ mod tests { use super::*; use crate::config::Params; use crate::options::{DiffOptions, DisplayOptions}; + use crate::parse::folds::FoldMatch; fn refs(lhs: bool, rhs: bool) -> Pairing { let file_ref = FileRef { @@ -803,6 +809,17 @@ mod tests { } fn project(path: &str, lhs: &str, rhs: &str, context: usize) -> Diff { + project_with(path, lhs, rhs, context, DiffOptions::default()) + } + + /// `graph_limit: 1` forces the text-diff fallback for any real change. + fn project_with( + path: &str, + lhs: &str, + rhs: &str, + context: usize, + options: DiffOptions, + ) -> Diff { let result = DiffResult::from_sources_with_options( path, lhs, @@ -812,7 +829,7 @@ mod tests { num_context_lines: context as u32, ..DisplayOptions::default() }, - &DiffOptions::default(), + &options, ); diff( &result, @@ -894,6 +911,109 @@ mod tests { } } + const RUST_LHS: &str = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + const RUST_RHS: &str = "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn added() -> u32 {\n let p = 3;\n let q = 4;\n p + q\n}\n"; + + fn fold_ids(source: &Source) -> BTreeMap { + all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .map(|r| (r.range.start.line, r.id)) + .collect() + } + + #[test] + fn folds_pair_through_the_line_alignment_on_either_engine() { + for options in [ + DiffOptions::default(), + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ] { + let structural = options.graph_limit != 1; + let diff = project_with("a.rs", RUST_LHS, RUST_RHS, 3, options); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.structural.is_ok(), structural); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); + // `f` changed its signature; its header rows still align, so the + // bodies stay paired. `keep` is untouched. `added` is rhs-only. + assert_eq!( + lhs_folds[&0], rhs_folds[&0], + "f pairs across a changed header" + ); + assert_eq!(lhs_folds[&6], rhs_folds[&6], "keep pairs"); + assert!( + !lhs_folds.values().any(|id| *id == rhs_folds[&12]), + "added is rhs-only" + ); + assert_eq!(lhs_folds.len(), 2); + assert_eq!(rhs_folds.len(), 3); + } + } + + #[test] + fn the_alignment_rule_agrees_with_the_matcher_where_headers_align() { + let result = DiffResult::from_sources_with_options( + "a.rs", + RUST_LHS, + RUST_RHS, + &Params::default(), + &DisplayOptions::default(), + &DiffOptions::default(), + ); + let diff = project("a.rs", RUST_LHS, RUST_RHS, 3); + let (lhs, rhs) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs.unwrap()), fold_ids(rhs.unwrap())); + let rhs_ids: BTreeSet = rhs_folds.values().copied().collect(); + for fold in &result.lhs_folds { + let id = lhs_folds[&(fold.range.start.line.as_usize() as u32)]; + let matcher_paired = matches!(fold.match_kind, FoldMatch::Unchanged { .. }); + assert_eq!(rhs_ids.contains(&id), matcher_paired, "{:?}", fold.range); + } + } + + #[test] + fn the_fallback_keeps_folds_and_enclosing_context() { + let diff = project_with( + "a.rs", + RUST_LHS, + RUST_RHS, + 1, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.structural.as_ref().unwrap_err().code, "too_complex"); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + let bodies: Vec<_> = all(&rhs.regions) + .into_iter() + .filter(|r| r.tags == ["body"]) + .map(|r| r.range.start.line) + .collect(); + // `keep` is untouched and lies inside a collapsed gap, so it is not a + // region; the changed `f` and the new `added` are folds. + assert_eq!(bodies, vec![0, 12]); + // The change inside `f` keeps its header line open, not in a gap. + let gaps: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|r| r.visibility.collapsed) + .map(|r| r.range.lines_spanned()) + .collect(); + assert!(gaps.iter().all(|(start, _)| *start != 0), "{gaps:?}"); + } + #[test] fn leaves_tile_both_sides_and_paired_leaves_share_ids() { let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 43ae3d993..18f2b022d 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -216,9 +216,11 @@ def check_tiling(source): fallback = events[1]["diff"]["stats"]["fallback"] assert fallback["code"] == "too_complex", fallback assert "diff.graph_limit (1)" in fallback["message"], fallback - assert not all_regions(events[1]["diff"]["rhs"]["regions"]) or all( - r["kind"] == "leaf" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) - ) + # The parse still stands in fallback: folds are present, paired + # through the line alignment. + assert any( + r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) + ), events[1]["diff"]["rhs"]["regions"] # The previous stream stays available while frontends migrate. v1 = cli( repo, "--format", "ndjson-v1", "--config", str(custom), base, head, "--", "a.rs" diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index a1c6df1d1..6d891e141 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript"}]} +{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} {"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From d2ab767bd60f926bc2838d2ae8d510f5245a167f Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 09:49:50 -0700 Subject: [PATCH 16/43] State the fold pairing rule as implemented Header lines pair when the alignment pairs them, changed or not, so a function with an edited signature stays paired. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/protocol/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 6f1100e66..eb4f4a5c4 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -298,8 +298,8 @@ pub struct SyntaxSpan { /// A range on one side. The same `id` on the other side is its /// counterpart. Two leaves correspond when the line alignment pairs their -/// lines. Two folds correspond when their header lines are paired as -/// unchanged in that alignment, whichever engine produced it. Ids appear +/// lines. Two folds correspond when the alignment pairs their header +/// lines, changed or not, whichever engine produced it. Ids appear /// at most once per side. A paired leaf has the same line count on both /// sides and its rows pair line for line; a paired leaf whose counterpart /// is behind the reading cursor is a move, and the frontend chooses how to From 037dc93cb59aa8a78cbe8f6bc7672ec9420f6276 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 12:15:47 -0700 Subject: [PATCH 17/43] Colour the terminal frontend from Helix themes Vendor four Helix themes (onedark and onelight as the defaults, gruvbox, solarized_light) under tui/themes with their MPL-2.0 notice, parse the TOML format with palette references and parent-scope fallback, and build the painter's palette from ui.* keys with change tints mixed from diff.plus and diff.minus. The theme comes from diffr config show at startup: theme.path if set, else theme.name; unknown names error. The t key toggles the two defaults. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 25 +- tui/packages/hunk/src/diffr/rows.test.ts | 6 +- tui/packages/hunk/src/diffr/rows.ts | 78 +--- tui/packages/hunk/src/diffr/theme.test.ts | 67 ++++ tui/packages/hunk/src/diffr/theme.ts | 173 ++++++++ tui/packages/hunk/src/main.tsx | 17 +- tui/packages/hunk/src/ui/App.test.tsx | 18 +- tui/packages/hunk/src/ui/App.tsx | 34 +- tui/packages/hunk/src/ui/Settings.tsx | 4 +- tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 4 +- tui/themes/LICENSE | 373 ++++++++++++++++++ tui/themes/README.md | 14 + tui/themes/gruvbox.toml | 153 +++++++ tui/themes/onedark.toml | 118 ++++++ tui/themes/onelight.toml | 201 ++++++++++ tui/themes/solarized_light.toml | 155 ++++++++ 16 files changed, 1335 insertions(+), 105 deletions(-) create mode 100644 tui/packages/hunk/src/diffr/theme.test.ts create mode 100644 tui/packages/hunk/src/diffr/theme.ts create mode 100644 tui/themes/LICENSE create mode 100644 tui/themes/README.md create mode 100644 tui/themes/gruvbox.toml create mode 100644 tui/themes/onedark.toml create mode 100644 tui/themes/onelight.toml create mode 100644 tui/themes/solarized_light.toml diff --git a/tui/README.md b/tui/README.md index 9117349bf..6bb83c51c 100644 --- a/tui/README.md +++ b/tui/README.md @@ -33,6 +33,29 @@ The frontend launches Rust with `--format ndjson --syntax`: the wire is diffr's protocol (`src/protocol.rs`) and `--syntax` adds a tree-sitter capture name per token, which is the only source of syntax colour here. +## Themes + +Colours come from Helix theme files: TOML keyed by tree-sitter capture names such as +`keyword`, `function.method`, `string`, `comment`, `type`, `variable.parameter`, with +a `[palette]` section and `ui.*` keys for chrome. A capture falls back to its parent +scope (`keyword.return` → `keyword`). Four themes are bundled under `themes/` (Helix's +own, MPL-2.0): `default-dark` (onedark), `default-light` (onelight), `gruvbox`, and +`solarized_light`. + +The frontend reads `diffr config show --json` at startup and uses `theme.path` when set, +else `theme.name` from the bundled index. An unknown name is an error, not a fallback. +To use any other Helix theme, download it from +https://github.com/helix-editor/helix/tree/master/runtime/themes and point at it: + +```sh +diffr config set theme.path ~/.config/helix/themes/dracula.toml +``` + +`t` toggles between `default-dark` and `default-light`. Change tints (line and word +backgrounds) are mixed from the theme's background and its `diff.plus`/`diff.minus` +colours. Recordings played with `--input` take `--theme ` and default +to `default-dark`. + ## Settings screen `diffr config` opens a searchable settings screen in this frontend. The CLI contract it @@ -82,7 +105,7 @@ list; Esc returns without saving. Both views carry a footer hint line. such gap at once. Folds and gaps start in the state diffr's `visibility` asks for. - `s`: split/unified; initial mode is responsive to width. - `w`: wrap; Left/Right: horizontal scrolling when unwrapped. -- `t`: dark/light theme. +- `t`: toggle between the bundled dark and light defaults (see Themes). - Drag code rows: select original source lines on the starting side; `y` or Copy copies via OSC 52. Escape clears selection. Character-level selection and drag autoscroll are not implemented in this first pass. diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index b1f5852d7..45a3ad6db 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -69,9 +69,9 @@ test("byte spans survive multibyte characters and tabs; captures pick theme colo ); expect(spans.map((s) => s.text).join("")).toBe("é 变量"); expect(spans.at(-1)!.bg).toBe(dark.addWord); - expect(spans.at(-1)!.fg).toBe(dark.type); + expect(spans.at(-1)!.fg).toBe(dark.syntax("type")!); expect(() => lineSpans("é", [], [line(0, 1, 9)], "left", dark)).toThrow(); - expect(captureColor("function.method", dark)).toBe(dark.function); + expect(captureColor("function.method", dark)).toBe(dark.syntax("function")!); expect(captureColor("unknown.thing", dark)).toBe(dark.fg); }); test("the innermost syntax capture colours a nested span", () => { @@ -86,7 +86,7 @@ test("the innermost syntax capture colours a nested span", () => { dark, ); expect(spans.map((s) => [s.text, s.fg])).toEqual([ - ['f(', dark.function], ['"x"', dark.string], [")", dark.function], + ['f(', dark.syntax("function")], ['"x"', dark.syntax("string")], [")", dark.syntax("function")], ]); }); test("wrapping adds equal split heights and windowing mounts only intersecting rows", () => { diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 6c56b37e7..5292bbbd6 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -4,6 +4,7 @@ import { filePath } from "./wire"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; import { flatten, foldHeaders, hiddenLines, leafLabel, sourceLines, type Leaf, type RowFold } from "./regions"; +import { loadBundledTheme, type Palette } from "./theme"; export { sourceLines }; export type Layout = "split" | "unified"; export interface ViewerRow { @@ -18,78 +19,13 @@ export interface ViewerRow { right?: SplitLineCell; cell?: UnifiedLineCell; } -export interface Palette { - bg: string; - fg: string; - muted: string; - addition: string; - deletion: string; - addWord: string; - deleteWord: string; - keyword: string; - type: string; - string: string; - function: string; - variable: string; - constant: string; - comment: string; - punctuation: string; - /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ - foldBackground: string; - foldPlaceholder: string; -} -export const dark: Palette = { - bg: "#0d1117", - fg: "#e6edf3", - muted: "#8b949e", - addition: "#12261e", - deletion: "#301a20", - addWord: "#24583a", - deleteWord: "#74333c", - keyword: "#ff7b72", - type: "#79c0ff", - string: "#a5d6ff", - function: "#d2a8ff", - variable: "#e6edf3", - constant: "#79c0ff", - comment: "#8b949e", - punctuation: "#c9d1d9", - foldBackground: "#152434", - foldPlaceholder: "#808080", -}; -export const light: Palette = { - bg: "#ffffff", - fg: "#24292f", - muted: "#57606a", - addition: "#dafbe1", - deletion: "#ffebe9", - addWord: "#aceebb", - deleteWord: "#ffcecb", - keyword: "#cf222e", - type: "#0550ae", - string: "#0a3069", - function: "#8250df", - variable: "#24292f", - constant: "#0550ae", - comment: "#6e7781", - punctuation: "#24292f", - foldBackground: "#e6f3ff", - foldPlaceholder: "#808080", -}; -/** Theme key for a tree-sitter capture; dotted names fall back to their first segment. */ -const captureColors = new Map([ - ["keyword", "keyword"], ["conditional", "keyword"], ["repeat", "keyword"], ["exception", "keyword"], - ["include", "keyword"], ["operator", "punctuation"], ["punctuation", "punctuation"], - ["delimiter", "punctuation"], ["string", "string"], ["character", "string"], ["escape", "string"], - ["comment", "comment"], ["type", "type"], ["constructor", "type"], ["storageclass", "type"], - ["namespace", "type"], ["module", "type"], ["function", "function"], ["method", "function"], - ["variable", "variable"], ["parameter", "variable"], ["property", "variable"], ["field", "variable"], - ["attribute", "variable"], ["number", "constant"], ["float", "constant"], ["boolean", "constant"], - ["constant", "constant"], ["label", "constant"], -]); +export type { Palette } from "./theme"; +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); +/** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ export function captureColor(capture: string, theme: Palette): string { - const key = captureColors.get(capture) ?? captureColors.get(capture.split(".")[0]); - return key ? theme[key] : theme.fg; + return theme.syntax(capture) ?? theme.fg; } /** Colour a line from byte-addressed syntax and change spans, then expand tabs into cells. */ export function lineSpans( diff --git a/tui/packages/hunk/src/diffr/theme.test.ts b/tui/packages/hunk/src/diffr/theme.test.ts new file mode 100644 index 000000000..aec8213ed --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { bundledThemes, colorOf, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +const sample = ` +"keyword" = { fg = "red", modifiers = ["bold"] } +"keyword.control" = { fg = "purple" } +"string" = "green" +"comment" = { fg = "#808080", modifiers = ["italic"] } +"ui.background" = { bg = "black" } +"ui.text" = { fg = "white" } +"ui.selection" = { bg = "#264f78" } +"diff.plus" = "green" +"diff.minus" = "red" + +[palette] +red = "#e06c75" +purple = "#c678dd" +green = "#98c379" +black = "#282c34" +white = "#abb2bf" +`; +test("scopes fall back to their parents and colours resolve through the palette", () => { + const theme = parseHelixTheme(sample, "sample"); + expect(scopeStyle(theme, "keyword.return")!.fg).toBe("red"); + expect(scopeStyle(theme, "keyword.control.import")!.fg).toBe("purple"); + expect(scopeStyle(theme, "string.special")!.fg).toBe("green"); + expect(scopeStyle(theme, "variable.parameter")).toBeUndefined(); + expect(colorOf(theme, "red")).toBe("#e06c75"); + expect(colorOf(theme, "#123456")).toBe("#123456"); + expect(colorOf(theme, "light-blue")).toBe("#3b8eea"); + expect(() => colorOf(theme, "chartreuse")).toThrow("unknown colour"); + const palette = paletteFromHelix(theme); + expect(palette.bg).toBe("#282c34"); + expect(palette.isLight).toBe(false); + expect(palette.syntax("keyword.return")).toBe("#e06c75"); + expect(palette.syntax("comment")).toBe("#808080"); + expect(palette.syntax("variable")).toBeUndefined(); + expect(palette.highlight).toBe("#264f78"); + expect(palette.addWord).not.toBe(palette.addition); +}); +test("a theme without ui colours or with inheritance is rejected", () => { + expect(() => paletteFromHelix(parseHelixTheme('"keyword" = "red"', "bare"))).toThrow("ui.background"); + expect(() => parseHelixTheme('inherits = "onedark"\n"ui.background" = { bg = "black" }', "child")).toThrow("inherits"); +}); +test("bundled themes load, the defaults are aliases, and captures are visibly distinct", () => { + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const colours = ["keyword", "string", "comment", "type", "function"].map((c) => palette.syntax(c)); + expect(colours.every((c) => c !== undefined)).toBe(true); + // gruvbox shares one green between strings and functions; everything else stays apart. + expect(new Set(colours).size).toBeGreaterThanOrEqual(4); + expect(palette.syntax("keyword.storage.modifier.ref")).toBeDefined(); + } + expect(loadBundledTheme("default-dark").isLight).toBe(false); + expect(loadBundledTheme("default-light").isLight).toBe(true); + expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); + expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); +}); +test("the theme set follows diffr's config and errors on a missing section", () => { + expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); + expect(() => themeConfig({ folds: {} })).toThrow("theme section"); + const set = themesFromConfig({ name: "gruvbox", path: null }); + expect(set.initial.name).toBe("gruvbox"); + expect(set.dark.name).toBe("default-dark"); + expect(set.light.name).toBe("default-light"); + expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); + expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); +}); diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts new file mode 100644 index 000000000..ec4a4dde7 --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -0,0 +1,173 @@ +/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +export interface Style { + fg?: string; + bg?: string; + modifiers: string[]; +} +export interface HelixTheme { + name: string; + styles: Map; + palette: Map; +} +/** Colours the row painter reads; syntax colours come from the theme by capture name. */ +export interface Palette { + name: string; + isLight: boolean; + bg: string; + fg: string; + muted: string; + /** Header, menu, and sidebar chrome. */ + chrome: string; + /** Sidebar highlight for the active file. */ + highlight: string; + addition: string; + deletion: string; + addWord: string; + deleteWord: string; + addedText: string; + removedText: string; + /** An accent for interactive text such as links and the layout badge. */ + accent: string; + /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ + foldBackground: string; + foldPlaceholder: string; + /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ + syntax: (capture: string) => string | undefined; +} +/** Helix's named terminal colours, used when a theme writes `fg = "red"` outside its palette. */ +const ansi: Record = { + black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", + magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", gray: "#666666", grey: "#666666", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", "light-blue": "#3b8eea", + "light-magenta": "#d670d6", "light-cyan": "#29b8db", "light-white": "#ffffff", "light-gray": "#a0a0a0", + "light-grey": "#a0a0a0", "light-black": "#333333", +}; +interface RawStyle { + fg?: string; + bg?: string; + modifiers?: string[]; +} +export function parseHelixTheme(text: string, name: string): HelixTheme { + const raw = Bun.TOML.parse(text) as Record; + const palette = new Map(); + const rawPalette = raw.palette; + if (rawPalette && typeof rawPalette === "object") + for (const [key, value] of Object.entries(rawPalette as Record)) { + if (typeof value !== "string") throw new Error(`Theme ${name}: palette entry ${key} is not a colour`); + palette.set(key, value); + } + const styles = new Map(); + for (const [scope, value] of Object.entries(raw)) { + if (scope === "palette" || scope === "inherits") continue; + if (typeof value === "string") styles.set(scope, { fg: value, modifiers: [] }); + else if (value && typeof value === "object") { + const style = value as RawStyle; + styles.set(scope, { fg: style.fg, bg: style.bg, modifiers: style.modifiers ?? [] }); + } else throw new Error(`Theme ${name}: scope ${scope} has an unsupported value`); + } + if (typeof raw.inherits === "string") + throw new Error(`Theme ${name} inherits from ${raw.inherits}; inherited themes are not supported`); + return { name, styles, palette }; +} +/** A palette name, a hex colour, or one of Helix's terminal colour names. */ +export function colorOf(theme: HelixTheme, value: string): string { + const fromPalette = theme.palette.get(value); + if (fromPalette !== undefined) return colorOf(theme, fromPalette); + if (/^#[0-9a-fA-F]{6}$/.test(value)) return value; + const named = ansi[value]; + if (named) return named; + throw new Error(`Theme ${theme.name}: unknown colour ${value}`); +} +/** The style for a scope, falling back to its parent scopes: `keyword.return` → `keyword`. */ +export function scopeStyle(theme: HelixTheme, scope: string): Style | undefined { + const parts = scope.split("."); + while (parts.length) { + const style = theme.styles.get(parts.join(".")); + if (style) return style; + parts.pop(); + } + return undefined; +} +const scopeFg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.fg === undefined ? undefined : colorOf(theme, style.fg); +}; +const scopeBg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.bg === undefined ? undefined : colorOf(theme, style.bg); +}; +function luminance(hex: string) { + const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} +function mix(hex: string, other: string, amount: number) { + const channel = (i: number) => + Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + parseInt(other.slice(i, i + 2), 16) * amount); + return `#${[1, 3, 5].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`; +} +/** Build the painter's palette: chrome from `ui.*`, syntax by capture, change tints mixed into the background. */ +export function paletteFromHelix(theme: HelixTheme): Palette { + const bg = scopeBg(theme, "ui.background"); + const fg = scopeFg(theme, "ui.text"); + if (!bg || !fg) throw new Error(`Theme ${theme.name} lacks ui.background or ui.text`); + const isLight = luminance(bg) > 0.5; + const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); + const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); + const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); + return { + name: theme.name, + isLight, + bg, + fg, + muted, + chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), + highlight: scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15), + addition: mix(bg, plus, 0.18), + deletion: mix(bg, minus, 0.18), + addWord: mix(bg, plus, 0.42), + deleteWord: mix(bg, minus, 0.42), + addedText: plus, + removedText: minus, + accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, + foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), + foldPlaceholder: muted, + syntax: (capture) => scopeFg(theme, capture), + }; +} +/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ +export const bundledThemes: Record = { + "default-dark": "onedark", + "default-light": "onelight", + onedark: "onedark", + onelight: "onelight", + gruvbox: "gruvbox", + solarized_light: "solarized_light", +}; +const themesDir = resolve(import.meta.dir, "../../../../themes"); +export function loadBundledTheme(name: string): Palette { + const file = bundledThemes[name]; + if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); + return paletteFromHelix(parseHelixTheme(readFileSync(resolve(themesDir, `${file}.toml`), "utf8"), name)); +} +export function loadThemeFile(path: string): Palette { + return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); +} +export interface ThemeSet { + initial: Palette; + dark: Palette; + light: Palette; +} +/** Resolve the theme diffr's config names, plus the two defaults the `t` key toggles between. */ +export function themesFromConfig(config: { name: string; path: string | null }): ThemeSet { + const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); + const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); + return { initial, dark, light }; +} +export function themeConfig(show: unknown): { name: string; path: string | null } { + const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; + if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) + throw new Error("diffr config show did not include a theme section"); + return { name: theme.name, path: theme.path ?? null }; +} diff --git a/tui/packages/hunk/src/main.tsx b/tui/packages/hunk/src/main.tsx index 7216f4da8..25b9a36e3 100644 --- a/tui/packages/hunk/src/main.tsx +++ b/tui/packages/hunk/src/main.tsx @@ -7,6 +7,7 @@ import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; import { cliClient } from "./diffr/config"; +import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; import { App } from "./ui/App"; import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), @@ -39,7 +40,17 @@ let input: NodeJS.ReadStream = process.stdin; let ttyFd: number | undefined; let expectsDifferenceExit = false; let comparisonExitCode = 0; +let themes: ThemeSet; if (args[0] === "--input" && args[1]) { + // A recording has no diffr to ask; `--theme ` picks one, else the dark default. + const themeArg = args.indexOf("--theme"); + const chosen = themeArg >= 0 ? args[themeArg + 1] : "default-dark"; + if (!chosen) throw new Error("--theme needs a bundled name or a Helix theme path"); + themes = { + initial: chosen.endsWith(".toml") ? loadThemeFile(chosen) : loadBundledTheme(chosen), + dark: loadBundledTheme("default-dark"), + light: loadBundledTheme("default-light"), + }; chunks = args[1] === "-" ? process.stdin : createReadStream(args[1]); if (args[1] === "-") { ttyFd = openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"); @@ -47,6 +58,8 @@ if (args[0] === "--input" && args[1]) { } } else if (args[0] === "--diffr" && args[1]) { const comparison = args.slice(args[2] === "--" ? 3 : 2); + // The theme comes from diffr's own config, so every frontend reads the same choice. + themes = themesFromConfig(themeConfig(cliClient(args[1]).show())); const separator = comparison.indexOf("--"); expectsDifferenceExit = comparison .slice(0, separator < 0 ? comparison.length : separator) @@ -58,7 +71,7 @@ if (args[0] === "--input" && args[1]) { chunks = child.stdout!; } else { console.error( - "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -)\n bun run start --settings --diffr /path/to/diffr [query]", + "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -) [--theme name|file.toml]\n bun run start --settings --diffr /path/to/diffr [query]", ); process.exit(2); } @@ -86,7 +99,7 @@ function quit() { process.once("SIGTERM", quit); process.once("SIGINT", quit); const root = createRoot(renderer); -root.render(); +root.render(); let stderr = ""; child?.stderr?.on("data", (data) => { stderr = (stderr + data.toString()).slice(-16384); diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 8c87ebcfe..20c043c1a 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -8,6 +8,8 @@ import { DiffStore } from "../diffr/store"; import { createTestDiffFile, leaf, line, withIdenticalLines } from "../diffr/fixture"; import type { DiffFile } from "../diffr/wire"; import { createFoldedDiffFile } from "../diffr/regions.test"; +import { loadBundledTheme } from "../diffr/theme"; +const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; const at = (file: DiffFile, path: string) => { file.file = { lhs: { path, oid: "1", mode: "100644" }, rhs: { path, oid: "2", mode: "100644" } }; return file; @@ -16,7 +18,7 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou const store = new DiffStore(); store.accept(createTestDiffFile()); const testRenderer = await testRender( - {}} />, + {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -68,7 +70,7 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => withIdenticalLines(file, 5000); store.accept(file); const testRenderer = await testRender( - {}} />, + {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -104,7 +106,7 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(4, 21, 22, [line(21, 0, 7)]), leaf(3, 22, 60)] }; store.accept(file); } - const t = await testRender( {}} />, {width:150, height:20}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("▾ src")); @@ -135,7 +137,7 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", if (file.diff.type !== "text") throw new Error(); file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 150)] }; store.accept(file); - const t = await testRender( {}} />, {width:150, height:20}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); const firstSource = () => Number(t.captureCharFrame().split("\n")[2].match(/row (\d+)/)?.[1]); const press = async (name: string, ctrl = false) => { await act(async () => { t.mockInput.pressKey(name, {ctrl}); }); @@ -178,7 +180,7 @@ test("initial manifest renders pending tree and remembers a jump until its diff at(b, "src/b.ts"); const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); - const t = await testRender( {}} />, {width:150, height:20}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("◌ a.ts") && f.includes("◌ b.ts")); @@ -205,7 +207,7 @@ test("streaming diffs follow tree order without moving the visible source row", }); const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); - const t = await testRender( {}} />, {width:150, height:20}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); const sidebarLines = () => t.captureCharFrame().split("\n").slice(1,8).map(line => line.slice(0,27).trim()); try { await act(async () => { await t.renderOnce(); store.accept(files[0]); store.accept(files[2]); }); @@ -248,7 +250,7 @@ test(`stream arrivals preserve code in every commit (wrap=${wrap}, unified=${uni let capture: (() => void) | undefined; const commits: string[][] = []; const t = await testRender( capture?.()}> - {}} /> + {}} themes={themes} /> , {width:150, height:20}); const sourceCells = (node: BaseRenderable): string[] => { if (node instanceof TextRenderable) { @@ -288,7 +290,7 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a source.regions.push(leaf(99, 8, 28)); } store.accept(file); - const t = await testRender( {}} />, { width: 150, height: 20 }); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 20 }); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame((f) => f.includes("inner(|| {")); diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index bc41f40d0..172e2bd80 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -16,12 +16,11 @@ import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; import { - dark, - light, rowsForFile, type Layout, type ViewerRow, } from "../diffr/rows"; +import type { Palette, ThemeSet } from "../diffr/theme"; import { measureRows, visibleRows } from "../diffr/geometry"; import { copySelection, @@ -39,9 +38,11 @@ const fit = (text: string, width: number) => export function App({ store, onQuit, + themes, }: { store: DiffStore; onQuit: () => void; + themes: ThemeSet; }) { const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); const renderer = useRenderer(), @@ -49,7 +50,7 @@ export function App({ const [mode, setMode] = useState("auto"), [showSidebar, setShowSidebar] = useState(true), [wrap, setWrap] = useState(false), - [isLight, setLight] = useState(false); + [theme, setTheme] = useState(themes.initial); const [scroll, setScroll] = useState(0), [horizontal, setHorizontal] = useState(0); // Files the user closed or opened; unset files follow the manifest's visibility. @@ -68,8 +69,9 @@ export function App({ const [menu, setMenu] = useState(null); const dragging = useRef(false), thumbDragging = useRef(false); - const theme = isLight ? light : dark, - sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; + // `t` swaps between the two bundled defaults; a configured theme is left by the first press. + const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); + const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), viewportHeight = Math.max(1, height - 2); const layout = @@ -98,7 +100,7 @@ export function App({ const all = fileOrder.flatMap(index => { const file = snapshot.files[index]; const folds = foldsOf(index); - const key = `${index}:${layout}:${isLight}:${[...folds].sort((a, b) => a - b).join(",")}`; + const key = `${index}:${layout}:${theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; let cached = rowCache.current.get(file); if (cached?.key !== key) { cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; @@ -242,7 +244,7 @@ export function App({ setSelection(null); } else if (key.name === "w") setWrap((v) => !v); else if (key.name === "c") { toggleContext(); setSelection(null); } - else if (key.name === "t") setLight((v) => !v); + else if (key.name === "t") toggleTheme(); else if (key.name === "y") copy(); else if (key.name === "escape") { setSelection(null); setMenu(null); } else if (key.name === "return") { @@ -300,13 +302,13 @@ export function App({ ? ` · syntax +${count.structural.added} -${count.structural.removed}` : ""; const statsWidth = String(count.textual.added).length + String(count.textual.removed).length + 5 + structural.length; return toggleFile(fileIndex)}> {fit(sanitizeTerminalLine(`${isClosed(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} - {` +${count.textual.added}`} - {` -${count.textual.removed} `} + {` +${count.textual.added}`} + {` -${count.textual.removed} `} {structural && {structural}} ; }; @@ -328,7 +330,7 @@ export function App({ key={row.key} height={1} width={contentWidth} - fg={row.loadDiff ? theme.type : theme.muted} + fg={row.loadDiff ? theme.accent : theme.muted} selectable={false} onMouseUp={() => { if (row.loadDiff) toggleFile(row.fileIndex); @@ -393,7 +395,7 @@ export function App({ ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], ["First file Home", () => setScroll(0)], ["Last file End", () => setScroll(maxScroll)]], - Theme: [["Dark", () => setLight(false)], ["Light", () => setLight(true)]], + Theme: [[`Dark (${themes.dark.name}) t`, () => setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], @@ -427,14 +429,14 @@ export function App({ sidebarDrag.current = null; }} > - + {["File", "View", "Navigate", "Theme", "Help"].map(name => ( setMenu(old => old === name ? null : name)}> {` ${name} `} ))} - { + { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }}>{` ${layout} [s] `} {` · ${snapshot.total || snapshot.inventory.length} files`} @@ -466,7 +468,7 @@ export function App({ {treeRows.slice(sidebarStart, sidebarStart + viewportHeight).map(({node, depth}) => ( { if (node.fileIndex !== undefined) { @@ -540,7 +542,7 @@ export function App({ {menu && + backgroundColor={theme.chrome}> {menuItems[menu].map(([label, action]) => ( { action(); setMenu(null); }}> diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx index cbf47bc1a..95d7d29ff 100644 --- a/tui/packages/hunk/src/ui/Settings.tsx +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -92,7 +92,7 @@ export function Settings({ client, onQuit, initialQuery = "" }: { client: Config {fit(` ${choice === draft ? "▸" : " "} ${choice}`, width)} )) - : {fit(` value: ${field}▏`, width)}} + : {fit(` value: ${field}▏`, width)}} {fit(` enter save · esc back${setting.type === "boolean" ? " · y/n or ↑↓ choose" : choices.length ? " · ↑↓ choose" : ""} ${message}`, width)} @@ -108,7 +108,7 @@ export function Settings({ client, onQuit, initialQuery = "" }: { client: Config return ( {fit(" diffr settings", width)} - {fit(` > ${query}▏`, width)} + {fit(` > ${query}▏`, width)} {visible.slice(start, start + bodyHeight).map((setting, i) => { const selected = start + i === cursor; const value = isSecret(setting.key) && formatValue(setting.value) !== "" ? "••••••" : displayValue(setting.value); diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index d2a7c6842..737195cdf 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -7,7 +7,7 @@ import type { UnifiedLineCell, } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import type { Palette } from "../../diffr/rows"; +import type { Palette } from "../../diffr/theme"; import type { RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map>(); @@ -68,7 +68,7 @@ export const CodeRowView = memo(function CodeRowView({ const fold = value.fold; const bg = selectedSide === side - ? "#264f78" + ? theme.highlight : value.foldLabel || fold?.collapsed ? theme.foldBackground : value.kind === "addition" diff --git a/tui/themes/LICENSE b/tui/themes/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/tui/themes/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/tui/themes/README.md b/tui/themes/README.md new file mode 100644 index 000000000..05d1a2e35 --- /dev/null +++ b/tui/themes/README.md @@ -0,0 +1,14 @@ +# Bundled themes + +These are Helix editor themes, vendored unchanged from +https://github.com/helix-editor/helix/tree/master/runtime/themes and licensed under the +Mozilla Public License 2.0 (see LICENSE in this directory). + +| name | file | +| ----------------- | --------------------- | +| `default-dark` | `onedark.toml` | +| `default-light` | `onelight.toml` | +| `gruvbox` | `gruvbox.toml` | +| `solarized_light` | `solarized_light.toml` | + +Any other Helix theme works through `theme.path` in diffr's config. diff --git a/tui/themes/gruvbox.toml b/tui/themes/gruvbox.toml new file mode 100644 index 000000000..6c5bcd098 --- /dev/null +++ b/tui/themes/gruvbox.toml @@ -0,0 +1,153 @@ +# Author : Jakub Bartodziej +# The theme uses the gruvbox dark palette with standard contrast: github.com/morhetz/gruvbox + +"annotation" = { fg = "fg1" } + +"attribute" = { fg = "aqua1", modifiers = ["italic"] } + +"comment" = { fg = "gray", modifiers = ["italic"] } + +"constant" = { fg = "purple1" } +"constant.character" = { fg = "aqua1" } +"constant.character.escape" = { fg = "orange1" } +"constant.macro" = { fg = "aqua1" } +"constructor" = { fg = "purple1" } + +"definition" = { underline = { color = "aqua1" } } + +"diagnostic" = { underline = { color = "orange1", style = "curl" } } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"diagnostic.error" = { underline = { color = "red1", style = "curl" } } +"diagnostic.hint" = { underline = { color = "blue1", style = "curl" } } +"diagnostic.info" = { underline = { color = "aqua1", style = "curl" } } +"diagnostic.warning" = { underline = { color = "yellow1", style = "curl" } } +# "diagnostic.unnecessary" = { modifiers = ["dim"] } # do not remove this for future resolving + +"error" = { fg = "red1" } +"hint" = { fg = "blue1" } +"info" = { fg = "aqua1" } +"warning" = { fg = "yellow1" } + +"diff.delta" = { fg = "yellow1" } +"diff.minus" = { fg = "red1" } +"diff.plus" = { fg = "green1" } + +"function" = { fg = "green1" } +"function.builtin" = { fg = "yellow1" } +"function.macro" = { fg = "blue1" } + +"keyword" = { fg = "red1" } +"keyword.control.import" = { fg = "aqua1" } + +"label" = { fg = "red1" } + +"markup.bold" = { modifiers = ["bold"] } +"markup.heading" = "aqua1" +"markup.italic" = { modifiers = ["italic"] } +"markup.link.text" = "red1" +"markup.link.url" = { fg = "green1", modifiers = ["underlined"] } +"markup.raw" = "red1" +"markup.strikethrough" = { modifiers = ["crossed_out"] } + +"module" = { fg = "aqua1" } + +"namespace" = { fg = "fg1" } + +"operator" = { fg = "purple1" } + +"punctuation" = { fg = "orange1" } + +"special" = { fg = "purple0" } + +"string" = { fg = "green1" } +"string.regexp" = { fg = "orange1" } +"string.special" = { fg = "orange1" } +"string.symbol" = { fg = "yellow1" } + +"tag" = { fg = "aqua1" } + +"type" = { fg = "yellow1" } +"type.enum.variant" = { modifiers = ["italic"] } + +"ui.background" = { bg = "bg0" } +"ui.bufferline" = { fg = "fg1", bg = "bg1" } +"ui.bufferline.active" = { fg = "bg0", bg = "yellow0" } +"ui.bufferline.background" = { bg = "bg2" } + +"ui.cursor" = { fg = "bg1", bg = "bg2" } +"ui.cursor.insert" = { fg = "bg1", bg = "blue0" } +"ui.cursor.normal" = { fg = "bg1", bg = "gray" } +"ui.cursor.select" = { fg = "bg1", bg = "orange0" } +"ui.cursor.match" = { fg = "fg3", bg = "bg3" } + +"ui.cursor.primary" = { bg = "fg3", fg = "bg1" } +"ui.cursor.primary.insert" = { fg = "bg1", bg = "blue1" } +"ui.cursor.primary.normal" = { fg = "bg1", bg = "fg3" } +"ui.cursor.primary.select" = { fg = "bg1", bg = "orange1" } + +"ui.cursorline" = { bg = "bg0_s" } +"ui.cursorline.primary" = { bg = "bg1" } + +"ui.help" = { bg = "bg1", fg = "fg1" } +"ui.linenr" = { fg = "bg3" } +"ui.linenr.selected" = { fg = "yellow1" } +"ui.menu" = { fg = "fg1", bg = "bg2" } +"ui.menu.selected" = { fg = "green1", bg = "bg1", modifiers = ["bold"] } +"ui.popup" = { bg = "bg1" } +"ui.picker.header.column" = { underline.style = "line" } +"ui.picker.header.column.active" = { modifiers = ["bold"], underline.style = "line" } +"ui.selection" = { bg = "bg2" } +"ui.selection.primary" = { bg = "bg3" } + +"ui.statusline" = { fg = "fg1", bg = "bg2" } +"ui.statusline.inactive" = { fg = "fg4", bg = "bg2" } +"ui.statusline.insert" = { fg = "bg1", bg = "blue1", modifiers = ["bold"] } +"ui.statusline.normal" = { fg = "bg1", bg = "fg3", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "bg1", bg = "orange1", modifiers = ["bold"] } + +"ui.text" = { fg = "fg1" } +"ui.text.focus" = { fg = "green1", bg="bg1" } +"ui.text.directory" = { fg = "blue1" } +"ui.virtual.inlay-hint" = { fg = "gray" } +"ui.virtual.jump-label" = { fg = "purple0", modifiers = ["bold"] } +"ui.virtual.ruler" = { bg = "bg1" } +"ui.virtual.whitespace" = "bg2" +"ui.virtual.wrap" = { fg = "bg2" } +"ui.window" = { bg = "bg1" } + +"variable" = { fg = "fg1" } +"variable.builtin" = { fg = "orange1", modifiers = ["italic"] } +"variable.other.member" = { fg = "blue1" } +"variable.parameter" = { fg = "blue1", modifiers = ["italic"] } + + +[palette] +bg0 = "#282828" # main background +bg0_s = "#32302f" +bg1 = "#3c3836" +bg2 = "#504945" +bg3 = "#665c54" +bg4 = "#7c6f64" + +fg0 = "#fbf1c7" +fg1 = "#ebdbb2" # main foreground +fg2 = "#d5c4a1" +fg3 = "#bdae93" +fg4 = "#a89984" + +gray = "#928374" + +red0 = "#cc241d" # neutral +red1 = "#fb4934" # bright +green0 = "#98971a" +green1 = "#b8bb26" +yellow0 = "#d79921" +yellow1 = "#fabd2f" +blue0 = "#458588" +blue1 = "#83a598" +purple0 = "#b16286" +purple1 = "#d3869b" +aqua0 = "#689d6a" +aqua1 = "#8ec07c" +orange0 = "#d65d0e" +orange1 = "#fe8019" diff --git a/tui/themes/onedark.toml b/tui/themes/onedark.toml new file mode 100644 index 000000000..9349f02d3 --- /dev/null +++ b/tui/themes/onedark.toml @@ -0,0 +1,118 @@ +# Author : Gokul Soumya + +"tag" = { fg = "red" } +"attribute" = { fg = "yellow" } +"comment" = { fg = "light-gray", modifiers = ["italic"] } +"constant" = { fg = "cyan" } +"constant.numeric" = { fg = "gold" } +"constant.builtin" = { fg = "gold" } +"constant.character.escape" = { fg = "gold" } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.builtin" = { fg = "blue" } +"function.macro" = { fg = "purple" } +"keyword" = { fg = "red" } +"keyword.control" = { fg = "purple" } +"keyword.control.import" = { fg = "red" } +"keyword.directive" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"label" = { fg = "purple" } +"namespace" = { fg = "blue" } +"operator" = { fg = "purple" } +"keyword.operator" = { fg = "purple" } +"special" = { fg = "blue" } +"string" = { fg = "green" } +"type" = { fg = "yellow" } +"variable" = { fg = "white" } +"variable.builtin" = { fg = "blue" } +"variable.parameter" = { fg = "red" } +"variable.other.member" = { fg = "red" } +"punctuation" = { fg = "white" } +"punctuation.special" = { fg = "purple" } + +"markup.heading" = { fg = "red" } +"markup.raw.inline" = { fg = "green" } +"markup.bold" = { fg = "gold", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.list" = { fg = "red" } +"markup.quote" = { fg = "yellow" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"]} +"markup.link.text" = { fg = "purple" } + +"diff.plus" = "green" +"diff.delta" = "gold" +"diff.minus" = "red" + +"diagnostic.info".underline = { color = "blue", style = "curl" } +"diagnostic.hint".underline = { color = "green", style = "curl" } +"diagnostic.warning".underline = { color = "yellow", style = "curl" } +"diagnostic.error".underline = { color = "red", style = "curl" } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"ui.background" = { bg = "black" } +"ui.virtual" = { fg = "faint-gray" } +"ui.virtual.indent-guide" = { fg = "faint-gray" } +"ui.virtual.whitespace" = { fg = "light-gray" } +"ui.virtual.ruler" = { bg = "gray" } +"ui.virtual.inlay-hint" = { fg = "light-gray" } +"ui.virtual.jump-label" = { fg = "light-gray", modifiers = ["bold"] } + +"ui.cursor" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.match" = { fg = "blue", modifiers = ["underlined"]} + +"ui.selection" = { bg = "faint-gray" } +"ui.selection.primary" = { bg = "light-gray" } +"ui.cursorline.primary" = { bg = "light-black" } + +"ui.highlight" = { bg = "gray" } +"ui.highlight.frameline" = { bg = "#97202a" } + +"ui.linenr" = { fg = "linenr" } +"ui.linenr.selected" = { fg = "white" } + +"ui.statusline" = { fg = "white", bg = "light-black" } +"ui.statusline.inactive" = { fg = "light-gray", bg = "light-black" } +"ui.statusline.normal" = { fg = "light-black", bg = "blue", modifiers = ["bold"] } +"ui.statusline.insert" = { fg = "light-black", bg = "green", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "light-black", bg = "purple", modifiers = ["bold"] } + +"ui.bufferline" = { fg = "light-gray", bg = "light-black" } +"ui.bufferline.active" = { fg = "light-black", bg = "blue", underline = { color = "light-black", style = "line" } } +"ui.bufferline.background" = { bg = "light-black" } + +"ui.text" = { fg = "white" } +"ui.text.directory" = { fg = "blue" } +"ui.text.focus" = { fg = "white", bg = "light-black", modifiers = ["bold"] } + +"ui.help" = { fg = "white", bg = "gray" } +"ui.popup" = { bg = "gray" } +"ui.window" = { fg = "gray" } +"ui.menu" = { fg = "white", bg = "gray" } +"ui.menu.selected" = { fg = "black", bg = "blue" } +"ui.menu.scroll" = { fg = "white", bg = "light-gray" } + +"ui.debug" = { fg = "red" } + +[palette] + +yellow = "#E5C07B" +blue = "#61AFEF" +red = "#E06C75" +purple = "#C678DD" +green = "#98C379" +gold = "#D19A66" +cyan = "#56B6C2" +white = "#ABB2BF" +black = "#282C34" +light-black = "#2C323C" +gray = "#3E4452" +faint-gray = "#3B4048" +light-gray = "#5C6370" +linenr = "#4B5263" diff --git a/tui/themes/onelight.toml b/tui/themes/onelight.toml new file mode 100644 index 000000000..9fc8ef650 --- /dev/null +++ b/tui/themes/onelight.toml @@ -0,0 +1,201 @@ +# Author : erasin + +"attribute" = { fg = "yellow" } +"label" = { fg = "cyan" } +"operator" = { fg = "red" } +"tag" = { fg = "cyan" } +"special" = { fg = "deep-purple" } +"property" = { fg = "purple" } +"constructor" = { fg = "blue" } +"namespace" = { fg = "blue" } +"module" = { fg = "blue" } + +"type" = { fg = "gold" } +"type.builtin" = { fg = "yellow" } +"type.enum" = { fg = "cyan" } +"type.enum.variant" = { fg = "cyan" } + +"constant" = { fg = "cyan", modifiers = ["bold"] } +"constant.builtin" = { fg = "deep-purple" } +"constant.builtin.boolean" = { fg = "purple" , modifiers = ["bold"]} +"constant.character" = { fg = "green" } +"constant.character.escape" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.integer" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.float" = { fg = "brown" , modifiers = ["bold"]} + +"string" = { fg = "green" } +"string.regexp" = { fg = "purple" } +"string.special" = { fg = "green" } +"string.special.path" = { fg = "blue" } +"string.special.url" = { fg = "light-blue" } +"string.special.symbol" = { fg = "pink" } + +"comment" = { fg = "grey", modifiers = ["italic"] } +"comment.line" = { fg = "grey", modifiers = ["italic"] } +"comment.line.documentation" = { fg = "grey", modifiers = ["italic"] } +"comment.block" = { fg = "grey", modifiers = ["italic"] } +"comment.block.documentation" = { fg = "grey", modifiers = ["italic"] } + +# "variable" = { fg = "black" } +"variable.builtin" = { fg = "light-blue" } +"variable.parameter" = { fg = "red" } +"variable.other" = { fg = "pink" } +"variable.other.member" = { fg = "pink" } + +"punctuation" = { fg = "black" } +"punctuation.delimiter" = { fg = "purple" } +"punctuation.bracket" = { fg = "brown" } +"punctuation.special" = { fg = "brown" } + +"keyword" = { fg = "purple" } +"keyword.control" = { fg = "purple" } +"keyword.control.conditional" = { fg = "red", modifiers = ["bold"] } +"keyword.control.repeat" = { fg = "pink", modifiers = ["bold"] } +"keyword.control.import" = { fg = "red" } +"keyword.control.return" = { fg = "deep-purple", modifiers = ["bold"] } +"keyword.control.exception" = { fg = "purple" } +"keyword.operator" = { fg = "red" } +"keyword.directive" = { fg = "deep-purple" } +"keyword.function" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"keyword.storage.type" = { fg = "purple" } +"keyword.storage.modifier" = { fg = "purple", modifiers = ["bold"] } + +"function" = { fg = "blue" } +"function.builtin" = { fg = "cyan" } +"function.method" = { fg = "light-blue" } +"function.macro" = { fg = "pink", modifiers = ["bold"] } +"function.special" = { fg = "cyan" } + +"markup.heading" = { fg = "red" } +"markup.heading.marker" = { fg = "red" } +"markup.heading.1" = { fg = "red", modifiers = ["bold"] } +"markup.heading.2" = { fg = "gold", modifiers = [ + "bold", +], underline = { style = "line" } } +"markup.heading.3" = { fg = "yellow", modifiers = ["bold"] } +"markup.heading.4" = { fg = "green", modifiers = ["bold"] } +"markup.heading.5" = { fg = "blue", modifiers = ["bold"] } +"markup.heading.6" = { fg = "purple", modifiers = ["bold"] } +"markup.list" = { fg = "light-blue" } +"markup.list.unnumbered" = { fg = "light-blue" } +"markup.list.numbered" = { fg = "light-blue" } +"markup.list.checked" = { fg = "green" } +"markup.list.unchecked" = { fg = "blue" } +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { fg = "red", modifiers = ["crossed_out"] } +"markup.link" = { fg = "light-blue" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"] } +"markup.link.text" = { fg = "light-blue" } +"markup.quote" = { fg = "grey" } +"markup.raw" = { fg = "brown" } +"markup.raw.inline" = { fg = "green" } +"markup.raw.block" = { fg = "brown" } + +"diff" = { fg = "red" } +"diff.plus" = { fg = "green" } +"diff.minus" = { fg = "red" } +"diff.delta" = { fg = "cyan" } +"diff.delta.moved" = { fg = "cyan" } +"diff.delta.conflict" = {fg = "blue"} + +"ui.background" = { bg = "white" } +"ui.background.separator" = { bg = "white" } + +"ui.cursor" = { fg = "white", bg = "grey" } +"ui.cursor.normal" = { fg = "white", bg = "grey" } +"ui.cursor.insert" = { fg = "white", bg = "grey" } +"ui.cursor.select" = { fg = "white", bg = "grey" } +"ui.cursor.match" = { bg = "grey-300", modifiers = ["bold"] } +"ui.cursor.primary" = { fg = "white", bg = "black" } +"ui.cursor.primary.normal" = { fg = "white", bg = "black" } +"ui.cursor.primary.insert" = { fg = "red", bg = "black" } +"ui.cursor.primary.select" = { fg = "white", bg = "black" } + +"ui.gutter" = { fg = "grey-500" } +"ui.gutter.selected" = { fg = "black" } + +"ui.linenr" = { fg = "grey-500" } +"ui.linenr.selected" = { fg = "black", modifiers = ["bold"] } + +"ui.statusline" = { fg = "black", bg = "grey-300" } +"ui.statusline.inactive" = { fg = "grey", bg = "grey-200" } +"ui.statusline.normal" = { fg = "grey-300", bg = "light-blue" } +"ui.statusline.insert" = { fg = "grey-300", bg = "green" } +"ui.statusline.select" = { fg = "grey-300", bg = "purple" } + +"ui.popup" = { fg = "black", bg = "grey-200" } +"ui.popup.info" = { fg = "black", bg = "grey-200" } +"ui.window" = { fg = "grey-500", bg = "grey-100" } +"ui.help" = { fg = "black", bg = "grey-200" } + +"ui.text" = { fg = "black" } +"ui.text.focus" = { fg = "red", bg = "grey-300", modifiers = ["bold"] } +"ui.text.inactive" = { fg = "grey" } +"ui.text.info" = { fg = "black" } +"ui.text.directory" = { fg = "blue", underline = { style = "line" } } + +"ui.virtual" = { fg = "grey-500" } +"ui.virtual.ruler" = { bg = "grey-200" } +"ui.virtual.wrap" = { fg = "grey-500" } +"ui.virtual.whitespace" = { fg = "grey-400" } +"ui.virtual.indent-guide" = { fg = "grey-500" } +"ui.virtual.inlay-hint" = { fg = "grey-500" } +"ui.virtual.inlay-hint.parameter" = { fg = "grey-500", modifiers = ["italic"] } +"ui.virtual.inlay-hint.type" = { fg = "grey-500" } +"ui.virtual.jump-label" = { fg = "black", bg = "grey-200", modifiers = ["bold" ] } + +"ui.menu" = { fg = "black", bg = "grey-300" } +"ui.menu.selected" = { fg = "white", bg = "light-blue" } +"ui.menu.scroll" = { fg = "light-blue", bg = "white" } + +"ui.selection" = { bg = "grey-300", modifiers = ["dim"] } +"ui.selection.primary" = { bg = "grey-300" } + +"ui.cursorline.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorline.secondary" = { fg = "white", bg = "grey-200" } + +"ui.cursorcolumn.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorcolumn.secondary" = { fg = "white", bg = "grey-200" } + +"ui.highlight" = { bg = "grey-300" } + +"ui.picker.header" = { fg = "purple"} +"ui.picker.header.column.active" = { fg = "blue"} + +"diagnostic.info" = { underline = { color = "blue", style = "dotted" } } +"diagnostic.hint" = { underline = { color = "green", style = "dashed" } } +"diagnostic.warning" = { underline = { color = "yellow", style = "curl" } } +"diagnostic.error" = { underline = { color = "red", style = "curl" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"tabstop" = { modifiers = ["italic"], bg = "grey-300" } + +[palette] +white = "#FAFAFA" +yellow = "#FF6F00" +gold = "#D35400" +brown = "#795548" +blue = "#0061FF" +light-blue = "#0091EA" +red = "#D50000" +pink = "#C2185B" +purple = "#B500A9" +deep-purple = "#651FFF" +green = "#24A443" +cyan = "#0086C1" +black = "#282C34" +grey = "#5C6370" +grey-500 = "#9E9E9E" +grey-400 = "#BDBDBD" +grey-300 = "#E0E0E0" +grey-200 = "#EEEEEE" +grey-100 = "#F2F2F2" diff --git a/tui/themes/solarized_light.toml b/tui/themes/solarized_light.toml new file mode 100644 index 000000000..2db54ca16 --- /dev/null +++ b/tui/themes/solarized_light.toml @@ -0,0 +1,155 @@ +"attribute" = { fg = "violet" } +"keyword" = { fg = "green" } +"keyword.directive" = { fg = "orange" } +"namespace" = { fg = "violet" } +"operator" = { fg = "green" } +"special" = { fg = "orange" } +"variable.builtin" = { fg = "cyan", modifiers = ["bold"] } +"variable.function" = { fg = "blue" } +"type" = { fg = "yellow" } +"type.builtin" = { fg = "yellow", modifiers = ["bold"] } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.macro" = { fg = "magenta" } +"function.builtin" = { fg = "blue", modifiers = ["bold"] } +"function.special" = { fg = "magenta" } +"comment" = { fg = "base01" } +"string" = { fg = "cyan" } +"constant" = { fg = "cyan" } +"constant.builtin" = { fg = "cyan", modifiers = ["bold"] } +"constant.character.escape" = { fg = "red", modifiers = ["bold"] } +"label" = { fg = "green" } +"module" = { fg = "violet" } +"tag" = { fg = "magenta" } + +# TODO +"markup.heading" = "blue" +"markup.list" = "red" +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "magenta", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.link.url" = { fg = "yellow", modifiers = ["underlined"] } +"markup.link.text" = "red" +"markup.quote" = "cyan" +"markup.raw" = "green" + +"diff.plus" = { fg = "green" } +"diff.delta" = { fg = "yellow" } +"diff.minus" = { fg = "red" } + +# 背景 +# background +"ui.background" = { bg = "base03" } + +"ui.virtual.whitespace" = { fg = "base01" } +"ui.virtual.inlay-hint" = { fg = "base01", modifiers = ["italic"] } +"ui.virtual.jump-label" = { fg = "red", modifiers = ["bold"] } + +# 行号栏 +# line number column +"ui.linenr" = { fg = "base0", bg = "base02" } +# 当前行号栏 +# current line number column +"ui.linenr.selected" = { fg = "blue", modifiers = ["bold"] } +# cursorline +"ui.cursorline" = { bg = "base0" } + +# 状态栏 +# status bar +"ui.statusline" = { fg = "base03", bg = "base0" } +"ui.statusline.normal" = { bg = "blue" } +"ui.statusline.insert" = { bg = "green" } +"ui.statusline.select" = { bg = "yellow" } + +# 非活动状态栏 +# inactive status bar +"ui.statusline.inactive" = { fg = "base1", bg = "base01" } + +# 补全窗口, preview窗口 +# Completion window, preview window +"ui.popup" = { bg = "base02" } +# 影响 补全选中 cmd弹出信息选中 +# Affect completion selection, cmd pop-up information selection +"ui.menu.selected" = { fg = "base02", bg = "base2"} +"ui.menu" = { fg = "base0", bg = "base02" } +# ?? +"ui.window" = { fg = "base3" } +# 命令行 补全的帮助信息 +# Command line completion help information +"ui.help" = { modifiers = ["reversed"] } + +# 快捷键窗口 +# Shortcut window +"ui.popup.info" = { bg = "base1" } +# 快捷键字体 +# Shortcut font +"ui.text.info" = {fg = "base02", modifiers = ["bold"]} + +# 普通ui的字体样式 +# Normal ui font style +"ui.text" = { fg = "base1" } +# 影响 picker列表选中, 快捷键帮助窗口文本 +# Affects picker list selection, shortcut key help window text +"ui.text.focus" = { fg = "blue", modifiers = ["bold"]} + +# 主光标/selection +# main cursor/selection +"ui.cursor.primary" = { fg = "base03", bg = "base1" } +"ui.cursor.select" = { fg = "base02", bg = "cyan" } + +"ui.cursorline.primary" = { bg = "base02" } +"ui.cursorline.secondary" = { bg = "base025" } + +"ui.selection" = { bg = "base0175" } +"ui.selection.primary" = { bg = "base015" } + +"ui.virtual.indent-guide" = { fg = "base02" } +"ui.virtual.ruler" = { bg = "base02" } + +# normal模式的光标 +# normal mode cursor +"ui.cursor" = {fg = "base02", bg = "cyan"} +"ui.cursor.insert" = {fg = "base03", bg = "base3"} +# 当前光标匹配的标点符号 +# The punctuation character matched by the current cursor +"ui.cursor.match" = { fg = "base02", bg = "base015" } + +"warning" = { fg = "orange", modifiers= ["bold", "underlined"] } +"error" = { fg = "red", modifiers= ["bold", "underlined"] } +"info" = { fg = "blue", modifiers= ["bold", "underlined"] } +"hint" = { fg = "base01", modifiers= ["bold", "underlined"] } + +"diagnostic.warning" = { underline = { style = "curl", color = "orange" } } +"diagnostic.error" = { underline = { style = "curl", color = "red" } } +"diagnostic.info" = { underline = { style = "curl", color = "blue" } } +"diagnostic.hint" = { underline = { style = "curl", color = "base01" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + + +[palette] +red = '#dc322f' +green = '#859900' +yellow = '#b58900' +blue = '#268bd2' +magenta = '#d33682' +cyan = '#2aa198' +orange = '#cb4b16' +violet = '#6c71c4' + +# 深色 越来越深 +# dark getting darker +base0 = '#657b83' +base1 = '#586e75' +base2 = '#073642' +base3 = '#002b36' + +# 浅色 越來越浅 +# Lighter and lighter +base00 = '#839496' +base01 = '#93a1a1' +base015 = '#c5c8bd' +base0175 = '#dddbcc' +base02 = '#eee8d5' +base025 = '#f5eedb' +base03 = '#fdf6e3' From f7aa95132fd0b443dd61ef452da1dae7c64dc6af Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 12:23:23 -0700 Subject: [PATCH 18/43] Summarize only outermost function bodies and collapse test bodies Function definitions gain a `function` fold tag next to `body`, so the deleted-bodies rule and the summarizer act on real functions rather than every brace block, and the summarizer takes the outermost qualifying body only. `summarize.min_lines` (default 20) separates the summary threshold from the collapse one. Test bodies of three or more lines start collapsed on both sides and are never summarized. The prompt is one sentence of pseudocode instructions; a summary longer than half the body is discarded in code. Grouping no longer looks under a collapsed fold and labels a run of test bodies as such. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 27 +++-- docs/streaming.md | 5 +- src/config.rs | 11 ++- src/config/defaults.toml | 60 ++++++++++++ src/mutate/collapse.rs | 75 +++++++++++++- src/mutate/group.rs | 9 +- src/mutate/mod.rs | 15 +-- src/mutate/summarize.rs | 147 ++++++++++++++++++++++------ src/protocol/project.rs | 4 +- src/review/tests.rs | 2 +- tests/streaming/check.py | 5 +- tui/test/fixtures/comparison.ndjson | 2 +- 12 files changed, 301 insertions(+), 61 deletions(-) diff --git a/docs/config.md b/docs/config.md index 485f92c87..b7bb085a8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -37,8 +37,9 @@ through these three commands; the schema is the only contract. ```toml [folds] -min_lines = 12 # bodies shorter than this are never summarized or collapsed +min_lines = 12 # bodies shorter than this are never collapsed by a rule collapse_deleted = true # deleted function bodies start collapsed, header visible +collapse_test_bodies = true # test bodies start collapsed on both sides, header visible collapse_removed_lines = 5 # removed stretches this long collapse in the middle; 0 disables collapse_generated = true # generated files start hidden collapse_tests = true # test files start hidden @@ -48,6 +49,7 @@ context_lines = 3 # unchanged lines kept around a change; -U overrides enabled = true # off without an API key, silently provider = "gemini" model = "gemini-3.8-flash" +min_lines = 20 # new function bodies shorter than this are shown as code api_key = "..." # or GEMINI_API_KEY / GOOGLE_API_KEY in the environment endpoint = "https://..." # optional base URL override, for proxies and tests timeout_ms = 60000 @@ -114,20 +116,25 @@ itself. In order: 1. `collapse_generated`, `collapse_tests`: the file's `visibility` in the manifest, before `start` is written. -2. `collapse_deleted`: deleted function bodies of at least `min_lines` lines, - labelled `" lines removed"`. -3. `collapse_removed_lines`: removed stretches with no counterpart on the +2. `collapse_deleted`: deleted function bodies (folds tagged `function`) of + at least `min_lines` lines, labelled `" lines removed"`. +3. `collapse_test_bodies`: bodies of test functions (folds tagged `test`) of + three or more lines, on both sides, labelled `"test body"`. +4. `collapse_removed_lines`: removed stretches with no counterpart on the after side and at least that many lines keep their first and last line open and collapse the middle, tagged `removed` and labelled `" lines removed"`. Stretches under a fold that already starts collapsed are left alone. -4. The built-in summarizer: new function bodies of at least `min_lines` lines - on the after side become Python-style pseudocode. The label starts with a - comment line in the file's own syntax, `# pseudocode` or `// pseudocode`, - then the text. -5. `folds.hook`, when configured, over the same selection with its own tags +5. The built-in summarizer: new function bodies (folds tagged `function`, + never a test, never one nested inside another selected body) of at least + `summarize.min_lines` lines on the after side become python-flavored + pseudocode. A summary with more than half the body's non-blank lines is + discarded and the body stays open. The label starts with a comment line + in the file's own syntax, `# pseudocode` or `// pseudocode`, then the + text. +6. `folds.hook`, when configured, over the same selection with its own tags and threshold; its text gets the same comment line. -6. Grouping, always on: adjacent context gaps merge into one, and a run of +7. Grouping, always on: adjacent context gaps merge into one, and a run of two or more sibling folds that start collapsed is wrapped in one `group` fold labelled `" functions removed"`, `" functions summarized"`, or `" folded regions"`. Expanding it reveals each child's own row. diff --git a/docs/streaming.md b/docs/streaming.md index 3675669bf..303cfcf69 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -227,8 +227,9 @@ fail discovery. ## Mutations and fold hooks After projection, mutations set what starts collapsed and what its label says: -generated and test files in the manifest, deleted bodies, the middle of large -removed stretches, and summaries for new bodies from the built-in summarizer. [config.md](config.md) lists them and +generated and test files in the manifest, deleted function bodies, test +bodies on both sides, the middle of large removed stretches, and summaries +for large new function bodies from the built-in summarizer. [config.md](config.md) lists them and their order. Their output is ordinary `visibility` on files and regions; frontends need no knowledge of which mutation produced it. diff --git a/src/config.rs b/src/config.rs index 9c87ce186..b39fae052 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,6 +105,8 @@ pub(crate) struct FoldsConfig { pub(crate) collapse_generated: bool, /// Hide files classified as tests. pub(crate) collapse_tests: bool, + /// Collapse the bodies of test functions on both sides, header visible. + pub(crate) collapse_test_bodies: bool, /// Unchanged lines kept visible on either side of a change. `-U` overrides it. pub(crate) context_lines: u32, /// An external JSON-RPC summarizer, run after the built-in one. @@ -120,6 +122,7 @@ impl Default for FoldsConfig { collapse_removed_lines: 5, collapse_generated: true, collapse_tests: true, + collapse_test_bodies: true, context_lines: 3, hook: None, } @@ -136,6 +139,9 @@ pub(crate) struct SummarizeConfig { pub(crate) provider: Provider, /// The model name sent to the provider. pub(crate) model: String, + /// New function bodies shorter than this are shown as code, not + /// summarized: pseudocode only pays off once the body is long. + pub(crate) min_lines: usize, /// The provider's API key. `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the /// environment is used when this is unset. pub(crate) api_key: Option, @@ -156,6 +162,7 @@ impl Default for SummarizeConfig { enabled: true, provider: Provider::Gemini, model: "gemini-3.8-flash".to_owned(), + min_lines: 20, api_key: None, endpoint: None, timeout_ms: 60_000, @@ -823,8 +830,8 @@ mod tag_tests { ); assert_eq!(result.lhs_folds.len(), 1); assert_eq!(result.rhs_folds.len(), 1); - assert_eq!(result.lhs_folds[0].tags, ["body", "test"]); - assert_eq!(result.rhs_folds[0].tags, ["body", "test"]); + assert_eq!(result.lhs_folds[0].tags, ["body", "function", "test"]); + assert_eq!(result.rhs_folds[0].tags, ["body", "function", "test"]); assert!(matches!(&result.lhs_folds[0].match_kind, FoldMatch::Unchanged { opposite } if *opposite == result.rhs_folds[0].range)); } diff --git a/src/config/defaults.toml b/src/config/defaults.toml index ce2cb62d5..d16eb334d 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -7,6 +7,8 @@ folds = ''' (match_block "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) ([ (array_expression "[" @fold.open "]" @fold.close) (field_initializer_list "{" @fold.open "}" @fold.close) @@ -43,6 +45,8 @@ context = ''' folds = ''' ((block) @fold (#set! tag "body")) +((function_definition body: (block) @fold) + (#set! tag "function")) ([ (list "[" @fold.open "]" @fold.close) (dictionary "{" @fold.open "}" @fold.close) @@ -75,6 +79,10 @@ folds = ''' (field_declaration_list "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) ((literal_value "{" @fold.open "}" @fold.close) @fold (#set! tag "collection")) ((import_declaration) @fold @@ -105,6 +113,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -148,6 +169,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -191,6 +225,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -234,6 +281,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index ea181b1fe..8c8072b42 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -1,5 +1,5 @@ //! Rules that start things collapsed: whole files by category, deleted -//! function bodies, and the middle of large removed stretches. +//! function bodies, test bodies, and the middle of large removed stretches. use super::group::next_id; use super::{collapse, ids, is_fold, line_count, walk_mut, FileMutation, FoldMutation}; use crate::category; @@ -27,8 +27,8 @@ impl FileMutation for HiddenCategories { } } -/// Deleted bodies of at least `min_lines` start collapsed with a line count. -/// The header line stays visible by fold semantics. +/// Deleted function bodies of at least `min_lines` start collapsed with a +/// line count. The header line stays visible by fold semantics. pub(crate) struct DeletedBodies { pub(crate) min_lines: usize, } @@ -41,7 +41,7 @@ impl FoldMutation for DeletedBodies { }; walk_mut(&mut lhs.regions, &mut |region| { if is_fold(region) - && region.tags.iter().any(|tag| tag == "body") + && region.tags.iter().any(|tag| tag == "function") && !rhs_ids.contains(®ion.id) && line_count(region) >= self.min_lines { @@ -53,6 +53,36 @@ impl FoldMutation for DeletedBodies { } } +/// Test bodies start collapsed on both sides, paired or not, so a diff +/// reads as the code under test first. The header stays visible and the +/// fold expands like any other. +pub(crate) struct TestBodies; + +/// A test body shorter than this stays open: a one-line assertion is +/// cheaper to read than a fold row. +const MIN_TEST_BODY_LINES: usize = 3; + +impl FoldMutation for TestBodies { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let sources: Vec<&mut Source> = match sides { + Pairing::Both { lhs, rhs } => vec![lhs, rhs], + Pairing::LeftOnly { lhs } => vec![lhs], + Pairing::RightOnly { rhs } => vec![rhs], + }; + for source in sources { + walk_mut(&mut source.regions, &mut |region| { + if is_fold(region) + && region.tags.iter().any(|tag| tag == "test") + && line_count(region) >= MIN_TEST_BODY_LINES + { + collapse(region, "test body".to_owned()); + } + }); + } + Ok(()) + } +} + /// Removed stretches with no counterpart and at least `min_lines` lines are /// split into three leaves: the first line open, the middle collapsed with /// a line count, the last line open, so the reader still sees red at both @@ -171,6 +201,43 @@ fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn test_bodies_collapse_on_both_sides_and_stay_expandable() { + let before = "#[test]\nfn t() {\n a();\n b();\n c();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let after = "#[test]\nfn t() {\n a();\n b();\n changed();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let (file, mut sides) = crate::mutate::summarize::tests::project("a.rs", before, after); + TestBodies.apply(&file, &mut sides).unwrap(); + for source in [sides.lhs().unwrap(), sides.rhs().unwrap()] { + let mut folds = Vec::new(); + walk(&source.regions, &mut |region| { + if is_fold(region) { + folds.push(( + region.tags.contains(&"test".to_owned()), + region.visibility.collapsed, + region.visibility.label.clone(), + )); + } + }); + assert_eq!( + folds, + vec![ + (true, true, "test body".to_owned()), + (false, false, "Body".to_owned()) + ] + ); + } + // A tiny test body stays open. + let (file, mut sides) = crate::mutate::summarize::tests::project( + "a.rs", + "", + "#[test]\nfn t() {\n a();\n}\n", + ); + TestBodies.apply(&file, &mut sides).unwrap(); + walk(&sides.rhs().unwrap().regions, &mut |region| { + assert!(!region.visibility.collapsed); + }); + } use crate::mutate::walk; use crate::protocol::{FileRef, FileStatus, Visibility}; diff --git a/src/mutate/group.rs b/src/mutate/group.rs index 5aefe500b..830794d9e 100644 --- a/src/mutate/group.rs +++ b/src/mutate/group.rs @@ -144,7 +144,9 @@ const MAX_SEPARATOR_LINES: usize = 2; /// a couple of open leaf lines, as their child ids in document order. fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { for region in regions { - if let Node::Fold { children } = ®ion.node { + // Nothing under a collapsed fold is visible, so nothing there + // needs grouping. + if let (Node::Fold { children }, false) = (®ion.node, region.visibility.collapsed) { collapsed_fold_runs(children, out); } } @@ -247,6 +249,11 @@ fn group(id: u32, children: Vec) -> Region { .all(|fold| fold.visibility.label.ends_with("lines removed")) { format!("{count} functions removed") + } else if folds + .iter() + .all(|fold| fold.visibility.label == "test body") + { + format!("{count} test bodies") } else if folds.iter().all(|fold| { fold.visibility .label diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 058189955..9a589da5d 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -3,10 +3,10 @@ //! region trees. diffr's diff internals never see them. //! //! Order: file mutations (hidden categories), then fold mutations in this -//! sequence: deleted bodies, removed runs, the built-in summarizer, the -//! JSON-RPC hook, and last the grouping pass that merges adjacent gaps and -//! wraps runs of collapsed folds. A mutation failure aborts the run; retries belong -//! inside a mutation. +//! sequence: deleted function bodies, test bodies, removed runs, the +//! built-in summarizer, the JSON-RPC hook, and last the grouping pass that +//! merges adjacent gaps and wraps runs of collapsed folds. A mutation +//! failure aborts the run; retries belong inside a mutation. pub(crate) mod collapse; pub(crate) mod group; pub(crate) mod summarize; @@ -48,15 +48,16 @@ impl Mutations { min_lines: folds.min_lines, })); } + if folds.collapse_test_bodies { + mutations.fold.push(Box::new(collapse::TestBodies)); + } if folds.collapse_removed_lines > 0 { mutations.fold.push(Box::new(collapse::RemovedRuns { min_lines: folds.collapse_removed_lines, })); } if params.summarize.enabled { - if let Some(summarizer) = - summarize::Summarizer::new(¶ms.summarize, folds.min_lines)? - { + if let Some(summarizer) = summarize::Summarizer::new(¶ms.summarize)? { mutations.fold.push(Box::new(summarizer)); } } diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs index 21c4c1e02..dded88d86 100644 --- a/src/mutate/summarize.rs +++ b/src/mutate/summarize.rs @@ -1,22 +1,21 @@ //! The built-in summarizer: large new function bodies become Python-style //! pseudocode, shown in place of the collapsed body. -use super::{collapse, ids, is_fold, line_count, summary_label, walk, walk_mut, FoldMutation}; +#[cfg(test)] +use super::walk; +use super::{collapse, ids, is_fold, line_count, summary_label, walk_mut, FoldMutation}; use crate::config::{Provider, SummarizeConfig}; use crate::protocol::{FileChange, Pairing, Problem, Source}; +use crate::protocol::{Node, Region}; use serde::Deserialize; use serde_json::json; use std::collections::BTreeMap; use std::time::Duration; use tokio::sync::Semaphore; -const SYSTEM: &str = - "You rewrite regions of a source file as terse Python-style pseudocode for a diff \ -viewer that shows the pseudocode in place of the collapsed region. The user supplies \ -one numbered source file and a list of folds, each with an id and 1-based line range. \ -For each fold, write pseudocode covering only that fold's lines: keep the control flow \ -and the names that matter, drop types, error plumbing and boilerplate. Aim for about one \ -pseudocode line per five source lines, between one and eight lines per fold. Reply with \ -one {id, pseudocode} object per fold."; +const SYSTEM: &str = "For each listed fold, rewrite that function body as short python-flavored \ +pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as \ +possible: about one pseudocode line per five source lines, and never more than a third \ +of the body's lines. Answer with a JSON array of {\"id\", \"pseudocode\"} objects, one per fold."; const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; @@ -45,10 +44,7 @@ struct Answer { impl Summarizer { /// `None` when no API key resolves: the summarizer is then simply off. - pub(crate) fn new( - config: &SummarizeConfig, - min_lines: usize, - ) -> crate::git::Result> { + pub(crate) fn new(config: &SummarizeConfig) -> crate::git::Result> { let Some(api_key) = resolve_key(config) else { return Ok(None); }; @@ -62,7 +58,7 @@ impl Summarizer { .build()?; Ok(Some(Self { config: config.clone(), - min_lines, + min_lines: config.min_lines, api_key, endpoint: config .endpoint @@ -121,7 +117,7 @@ impl Summarizer { "contents": [{"role": "user", "parts": [{"text": self.prompt(path, language, src, folds)}]}], "generationConfig": { "temperature": 0, - "maxOutputTokens": 160 * folds.len() + 100, + "maxOutputTokens": 600 * folds.len() + 200, "thinkingConfig": {"thinkingBudget": 0}, "responseMimeType": "application/json", "responseSchema": { @@ -218,26 +214,56 @@ impl Summarizer { } } -/// New function bodies on the after side of at least `min_lines` lines. +/// New function bodies on the after side of at least `min_lines` lines: +/// folds tagged `function` with no counterpart. Only the outermost +/// qualifying body is taken, never one nested inside it; test bodies and +/// folds that already start collapsed are skipped. pub(crate) fn select(sides: &Pairing, min_lines: usize) -> Vec<(u32, u32, u32)> { let Some(rhs) = sides.rhs() else { return Vec::new(); }; let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default(); let mut selected = Vec::new(); - walk(&rhs.regions, &mut |region| { - if is_fold(region) - && region.tags.iter().any(|tag| tag == "body") - && !lhs_ids.contains(®ion.id) - && line_count(region) >= min_lines - { - let lines = region.range.lines(); - selected.push((region.id, lines.start + 1, lines.end)); + fn visit( + regions: &[Region], + lhs_ids: &crate::hash::DftHashSet, + min_lines: usize, + selected: &mut Vec<(u32, u32, u32)>, + ) { + for region in regions { + let tag = |name: &str| region.tags.iter().any(|tag| tag == name); + if is_fold(region) + && tag("function") + && !tag("test") + && !region.visibility.collapsed + && !lhs_ids.contains(®ion.id) + && line_count(region) >= min_lines + { + let lines = region.range.lines(); + selected.push((region.id, lines.start + 1, lines.end)); + continue; + } + if let Node::Fold { children } = ®ion.node { + visit(children, lhs_ids, min_lines, selected); + } } - }); + } + visit(&rhs.regions, &lhs_ids, min_lines, &mut selected); selected } +/// Pseudocode earns its place only when it is clearly shorter than the +/// code: a summary with more than half the body's non-blank lines is +/// dropped and the body stays open. +fn compresses(summary: &str, body: &[&str]) -> bool { + let summary_lines = summary + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let body_lines = body.iter().filter(|line| !line.trim().is_empty()).count(); + summary_lines * 2 <= body_lines +} + fn resolve_key(config: &SummarizeConfig) -> Option { config .api_key @@ -276,7 +302,17 @@ impl FoldMutation for Summarizer { let language = file.language.as_deref(); let texts = { let rhs = sides.rhs().expect("selection found rhs regions"); - self.complete(path, language, &rhs.text, &folds)? + let lines: Vec<&str> = rhs.text.split_terminator('\n').collect(); + let mut texts = self.complete(path, language, &rhs.text, &folds)?; + texts.retain(|id, text| { + let fold = folds + .iter() + .find(|fold| fold.id == *id) + .expect("answered fold"); + let body = &lines[fold.first_line as usize - 1..fold.last_line as usize]; + compresses(text, body) + }); + texts }; let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else { unreachable!("selection found rhs regions"); @@ -413,9 +449,10 @@ pub(crate) mod tests { endpoint: Some(endpoint.to_owned()), retries, timeout_ms: 5000, + min_lines: 3, ..SummarizeConfig::default() }; - Summarizer::new(&config, 3).unwrap().unwrap() + Summarizer::new(&config).unwrap().unwrap() } #[test] @@ -446,6 +483,57 @@ pub(crate) mod tests { assert_eq!((selected[0].1, selected[0].2), (7, 9)); } + #[test] + fn selection_takes_outermost_function_bodies_only() { + // A method inside an impl: the impl's declaration_list is a body but + // not a function, so the method is the outermost selection. + let after = "impl A {\n fn m(&self) {\n a();\n b();\n c();\n let f = || {\n d();\n e();\n g();\n };\n f();\n }\n}\n"; + let (_, sides) = project("a.rs", "", after); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (2, 12)); + // Below the threshold, nothing. + assert!(select(&sides, 30).is_empty()); + } + + #[test] + fn selection_skips_test_bodies_and_collapsed_folds() { + let after = "#[test]\nfn t() {\n a();\n b();\n c();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (8, 11)); + crate::mutate::collapse::TestBodies + .apply(&file, &mut sides) + .unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &mut sides else { + panic!("rhs expected"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if region.range.start.line == 7 { + region.visibility.collapsed = true; + } + }); + assert!(select(&sides, 3).is_empty()); + } + + #[test] + fn long_summaries_are_discarded_and_the_body_stays_open() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "a()\nb()\nc()")]))]); + summarizer(&endpoint, 0).apply(&file, &mut sides).unwrap(); + server.join().unwrap(); + let rhs = sides.rhs().unwrap(); + let mut folds = Vec::new(); + walk(&rhs.regions, &mut |region| { + if is_fold(region) { + folds.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + assert_eq!(folds, vec![(false, "Body".to_owned())]); + } + #[test] fn summaries_collapse_selected_folds_with_a_pseudocode_comment() { let (file, mut sides) = project("a.py", "", LARGE); @@ -518,9 +606,10 @@ call a, b, c" let config = SummarizeConfig { api_key: Some("k".to_owned()), endpoint: Some("http://127.0.0.1:1".to_owned()), + min_lines: 3, ..SummarizeConfig::default() }; - Summarizer::new(&config, 3) + Summarizer::new(&config) .unwrap() .unwrap() .apply(&file, &mut sides) @@ -537,7 +626,7 @@ call a, b, c" if std::env::var_os("GEMINI_API_KEY").is_none() && std::env::var_os("GOOGLE_API_KEY").is_none() { - assert!(Summarizer::new(&config, 3).unwrap().is_none()); + assert!(Summarizer::new(&config).unwrap().is_none()); } } } diff --git a/src/protocol/project.rs b/src/protocol/project.rs index f72b51880..1aa76afee 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -999,7 +999,7 @@ mod tests { let rhs = rhs.unwrap(); let bodies: Vec<_> = all(&rhs.regions) .into_iter() - .filter(|r| r.tags == ["body"]) + .filter(|r| r.tags.iter().any(|tag| tag == "body")) .map(|r| r.range.start.line) .collect(); // `keep` is untouched and lies inside a collapsed gap, so it is not a @@ -1052,7 +1052,7 @@ mod tests { .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 7) .expect("the added function is a fold"); assert!(rhs_only.contains(&&new_fold.id)); - assert_eq!(new_fold.tags, vec!["body"]); + assert_eq!(new_fold.tags, vec!["body", "function"]); assert_eq!(new_fold.visibility.label, "Body"); } diff --git a/src/review/tests.rs b/src/review/tests.rs index be201f4fc..ff37a992e 100644 --- a/src/review/tests.rs +++ b/src/review/tests.rs @@ -78,7 +78,7 @@ mod folds { assert_eq!(result.lhs_folds.len(), 1); assert_eq!(result.rhs_folds.len(), 1); let fold = &result.lhs_folds[0]; - assert_eq!(fold.tags, ["body", "test"]); + assert_eq!(fold.tags, ["body", "function", "test"]); let (left, right) = paired(fold).expect("reuse the replaced-string correspondence"); assert_eq!(text(lhs, left), "\"\"\"some shared words before\"\"\""); assert_eq!(text(rhs, right), "\"\"\"some shared words after\"\"\""); diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 18f2b022d..215e25817 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -219,7 +219,8 @@ def check_tiling(source): # The parse still stands in fallback: folds are present, paired # through the line alignment. assert any( - r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) + r["kind"] == "fold" + for r in all_regions(events[1]["diff"]["rhs"]["regions"]) ), events[1]["diff"]["rhs"]["regions"] # The previous stream stays available while frontends migrate. v1 = cli( @@ -311,7 +312,7 @@ def fold_labels(record): return [ r["visibility"]["label"] for r in all_regions(record["diff"]["rhs"]["regions"]) - if r["kind"] == "fold" and r["tags"] == ["body"] + if r["kind"] == "fold" and "body" in r["tags"] ] (repo / "diffr.toml").write_text(hook_config("echo")) diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index 6d891e141..d1b366750 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ {"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} -{"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} +{"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From c50d486fc7a911b5250a59314f2d40df74f942c0 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 12:23:23 -0700 Subject: [PATCH 19/43] Highlight line-diff fallbacks too Syntax spans came only from the structural path, so a file that fell back to a line diff rendered uncoloured. The language is guessed from the path for fallbacks and both sides get spans when --syntax is set. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/protocol/stream.rs | 51 +++++++++++++++++++++++++++++++++++++--- tests/streaming/check.py | 4 ++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index fd7bd7bae..940cecf17 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -168,7 +168,9 @@ fn file_outcome( ) -> Result { let diff = loaded.diff(); let syntax = if options.syntax { - syntax_spans(&diff, &loaded.params) + let path = loaded.file.sides.rhs().or(loaded.file.sides.lhs()); + let path = path.map(|side| side.path.as_str()).unwrap_or_default(); + syntax_spans(&diff, &loaded.params, std::path::Path::new(path)) } else { (Vec::new(), Vec::new()) }; @@ -199,11 +201,25 @@ fn mutate( Ok(outcome.into()) } +/// Highlight spans for both sides. A line-diff fallback still has a +/// language, guessed from the path, so its sides get colours too. pub(crate) fn syntax_spans( diff: &DiffResult, params: &crate::config::Params, + path: &std::path::Path, ) -> (Vec, Vec) { - let FileFormat::SupportedLanguage(language) = diff.file_format else { + let language = match &diff.file_format { + FileFormat::SupportedLanguage(language) => Some(*language), + FileFormat::TextFallback { .. } => { + let sample = match (&diff.lhs_src, &diff.rhs_src) { + (FileContent::Text(src), _) | (_, FileContent::Text(src)) => src.as_str(), + _ => "", + }; + crate::parse::guess_language::guess(path, sample, &[]) + } + FileFormat::PlainText | FileFormat::Binary => None, + }; + let Some(language) = language else { return (Vec::new(), Vec::new()); }; let parser = params.language(language).parser; @@ -265,7 +281,7 @@ pub(crate) fn write_file( output.flush()?; let diff = compute(); let syntax = if options.syntax { - syntax_spans(&diff, params) + syntax_spans(&diff, params, std::path::Path::new(after)) } else { (Vec::new(), Vec::new()) }; @@ -318,3 +334,32 @@ pub(crate) fn write_file( output.flush()?; Ok(ended) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_files_still_get_syntax_spans() { + let params = crate::config::Config::from_toml("") + .unwrap() + .compile() + .unwrap(); + let src = "fn f() {\n let x = 1;\n}\n"; + let diff = DiffResult::from_sources_with_options( + "a.rs", + "fn g() {\n let y = 2;\n}\n", + src, + ¶ms, + &crate::options::DisplayOptions::default(), + &crate::options::DiffOptions { + graph_limit: 1, + ..crate::options::DiffOptions::default() + }, + ); + assert!(matches!(diff.file_format, FileFormat::TextFallback { .. })); + let (lhs, rhs) = syntax_spans(&diff, ¶ms, std::path::Path::new("a.rs")); + assert!(!lhs.is_empty() && !rhs.is_empty()); + assert!(rhs.iter().any(|span| span.capture.starts_with("keyword"))); + } +} diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 215e25817..591994970 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -222,6 +222,10 @@ def check_tiling(source): r["kind"] == "fold" for r in all_regions(events[1]["diff"]["rhs"]["regions"]) ), events[1]["diff"]["rhs"]["regions"] + # So do highlight spans: the language is guessed from the path. + events = stream(repo, base, head, "--graph-limit", "1", "--syntax", "--", "a.rs") + for side in ("lhs", "rhs"): + assert events[1]["diff"][side]["syntax"], side # The previous stream stays available while frontends migrate. v1 = cli( repo, "--format", "ndjson-v1", "--config", str(custom), base, head, "--", "a.rs" From e38d8d0c5bfae129df94ea19b0490311e2c5e379 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 13:02:32 -0700 Subject: [PATCH 20/43] Show a GitHub-style change summary in the terminal frontend A strip under the menubar names the comparison, the file count, and the visible totals with GitHub's five-block bar. Headline counts are diffr's stats.visible adjusted as folds, gaps, and files toggle; the i key or a click on the totals opens a breakdown of visible, structural, and textual counts for the comparison and the current file. stats.visible is required on the wire. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 11 ++- tui/packages/hunk/src/diffr/counts.test.ts | 38 ++++++++ tui/packages/hunk/src/diffr/counts.ts | 70 ++++++++++++++ tui/packages/hunk/src/diffr/fileTree.test.ts | 4 +- tui/packages/hunk/src/diffr/fileTree.ts | 4 +- tui/packages/hunk/src/diffr/fixture.ts | 2 +- tui/packages/hunk/src/diffr/regions.test.ts | 1 + tui/packages/hunk/src/diffr/regions.ts | 9 ++ tui/packages/hunk/src/diffr/rows.test.ts | 2 +- tui/packages/hunk/src/diffr/rows.ts | 9 +- tui/packages/hunk/src/diffr/store.ts | 6 +- tui/packages/hunk/src/diffr/stream.test.ts | 2 +- tui/packages/hunk/src/diffr/wire.ts | 3 + tui/packages/hunk/src/ui/App.test.tsx | 98 ++++++++++++++------ tui/packages/hunk/src/ui/App.tsx | 66 ++++++++++--- tui/test/fixtures/comparison.ndjson | 4 +- tui/test/pty/launch.py | 7 +- 17 files changed, 278 insertions(+), 58 deletions(-) create mode 100644 tui/packages/hunk/src/diffr/counts.test.ts create mode 100644 tui/packages/hunk/src/diffr/counts.ts diff --git a/tui/README.md b/tui/README.md index 6bb83c51c..a9aa3be35 100644 --- a/tui/README.md +++ b/tui/README.md @@ -83,8 +83,15 @@ list; Esc returns without saving. Both views carry a footer hint line. - File header click or Enter: collapse/expand the file. - `\` / Cmd-B (when forwarded by the terminal): toggle the file tree. - Click folders to expand/collapse them; click files to navigate. The active file is highlighted and revealed as the diff scrolls. -- File headers stay pinned while scrolling and show textual counts (`+added -removed`) - from Rust, plus `syntax +a -r` when the structural counts differ. +- A summary strip under the menubar shows the comparison (`main…HEAD`, `index…working + tree`), the file count, the totals `+N −M` in the theme's diff colours, and GitHub's + five-block bar. Totals and file-header counts are the changed lines currently visible: + diffr's `stats.visible` (its default fold state) adjusted as folds, gaps, and files are + toggled, so lines hidden inside a collapsed region are not counted. While results are + still arriving the total ends in `…`. +- `i` (or clicking the totals) opens a breakdown for the whole comparison and the current + file: `visible`, `structural` (from tree-sitter, or `line diff` when it fell back), and + `textual`. Esc closes it. - A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. diff --git a/tui/packages/hunk/src/diffr/counts.test.ts b/tui/packages/hunk/src/diffr/counts.test.ts new file mode 100644 index 000000000..8a3580b9c --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { blockBar, comparisonLabel, visibleCounts } from "./counts"; +import { createTestDiffFile } from "./fixture"; +import { createFoldedDiffFile } from "./regions.test"; +test("visible counts start from the wire and move with folds, gaps, and closed files", () => { + const file = createTestDiffFile(); + expect(visibleCounts(file, new Set())).toEqual({ added: 2, removed: 1 }); + // diffr's number is the anchor: with nothing toggled it is shown verbatim. + if (file.diff.type !== "text") throw new Error(); + file.diff.stats.visible = { added: 5, removed: 4 }; + expect(visibleCounts(file, new Set())).toEqual({ added: 5, removed: 4 }); + expect(visibleCounts(file, new Set([2]))).toEqual({ added: 4, removed: 3 }); + file.diff.stats.visible = { added: 2, removed: 1 }; + expect(visibleCounts(file, new Set(), true)).toEqual({ added: 0, removed: 0 }); + // Collapsing the changed leaf itself hides its lines. + expect(visibleCounts(file, new Set([2]))).toEqual({ added: 1, removed: 0 }); + const folded = createFoldedDiffFile(); + // rhs line 3 changed inside the closure body (fold 11). + expect(visibleCounts(folded, new Set())).toEqual({ added: 1, removed: 0 }); + expect(visibleCounts(folded, new Set([11]))).toEqual({ added: 0, removed: 0 }); + expect(visibleCounts(folded, new Set([10]))).toEqual({ added: 0, removed: 0 }); + expect(visibleCounts(folded, new Set([12]))).toEqual({ added: 1, removed: 0 }); +}); +test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { + expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); + expect(blockBar({ added: 10, removed: 0 })).toEqual(["added", "added", "added", "added", "added"]); + expect(blockBar({ added: 3, removed: 1 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 1 })).toEqual(["added", "added", "removed", "removed", "neutral"]); + expect(blockBar({ added: 99, removed: 1 })).toEqual(["added", "added", "added", "added", "removed"]); + expect(blockBar({ added: 13, removed: 6 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 99 })).toEqual(["added", "removed", "removed", "removed", "removed"]); +}); +test("comparison labels shorten shas and name the special snapshots", () => { + expect(comparisonLabel({ type: "revision", rev: "main" }, { type: "revision", rev: "0123456789abcdef0123456789abcdef01234567" })) + .toBe("main…0123456"); + expect(comparisonLabel({ type: "index" }, { type: "working_tree" })).toBe("index…working tree"); + expect(comparisonLabel({ type: "empty_tree" }, { type: "path", path: "after.ts" })).toBe("empty tree…after.ts"); +}); diff --git a/tui/packages/hunk/src/diffr/counts.ts b/tui/packages/hunk/src/diffr/counts.ts new file mode 100644 index 000000000..def2ea350 --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.ts @@ -0,0 +1,70 @@ +/** Changed-line counts as shown: only lines on screen, so fold and file state changes them. */ +import { defaultCollapsed, flatten, hiddenLines, novelLeaves } from "./regions"; +import type { DiffFile, LineCounts } from "./wire"; +export type { LineCounts }; +export const zero: LineCounts = { added: 0, removed: 0 }; +export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); +const subtract = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added - b.added, removed: a.removed - b.removed }); +/** Changed lines on screen under a collapsed set, counted from the leaves. */ +function shownLines(file: DiffFile & { diff: { type: "text" } }, collapsed: ReadonlySet): LineCounts { + const { leaves, folds } = flatten(file.diff); + const novel = novelLeaves(leaves); + const counts = [0, 0]; + for (const side of [0, 1] as const) { + const hidden = hiddenLines(folds[side], collapsed); + for (const leaf of leaves[side]) { + if (!novel.has(leaf) || collapsed.has(leaf.id)) continue; + for (let line = leaf.startLine; line < leaf.endLine; line++) if (!hidden.has(line)) counts[side]++; + } + } + return { removed: counts[0], added: counts[1] }; +} +/** + * The headline: diffr's `stats.visible` (its default fold state) adjusted by what the user + * has folded or unfolded since. A closed file shows none. + */ +export function visibleCounts(file: DiffFile, collapsed: ReadonlySet, closed = false): LineCounts { + if (closed || file.diff.type !== "text") return zero; + const text = file as DiffFile & { diff: { type: "text" } }; + const delta = subtract(shownLines(text, collapsed), shownLines(text, defaultCollapsed(file.diff))); + return add(file.diff.stats.visible, delta); +} +/** GitHub's five-block bar: whole blocks by share, any non-zero side keeps one, the rest grey. */ +export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | "neutral")[] { + const total = counts.added + counts.removed; + if (total === 0) return Array.from({ length: blocks }, () => "neutral"); + let green = Math.floor((blocks * counts.added) / total); + let red = Math.floor((blocks * counts.removed) / total); + if (counts.added > 0 && green === 0) green = 1; + if (counts.removed > 0 && red === 0) red = 1; + while (green + red > blocks) { + if (green >= red) green--; + else red--; + } + return [ + ...Array.from({ length: green }, () => "added" as const), + ...Array.from({ length: red }, () => "removed" as const), + ...Array.from({ length: blocks - green - red }, () => "neutral" as const), + ]; +} +export type Snapshot = + | { type: "revision"; rev: string } + | { type: "index" } + | { type: "working_tree" } + | { type: "empty_tree" } + | { type: "path"; path: string }; +export function snapshotLabel(snapshot: Snapshot): string { + switch (snapshot.type) { + case "revision": + return /^[0-9a-f]{40}$/.test(snapshot.rev) ? snapshot.rev.slice(0, 7) : snapshot.rev; + case "index": + return "index"; + case "working_tree": + return "working tree"; + case "empty_tree": + return "empty tree"; + case "path": + return snapshot.path; + } +} +export const comparisonLabel = (lhs: Snapshot, rhs: Snapshot) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; diff --git a/tui/packages/hunk/src/diffr/fileTree.test.ts b/tui/packages/hunk/src/diffr/fileTree.test.ts index 5b378fbd1..3857414de 100644 --- a/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/tui/packages/hunk/src/diffr/fileTree.test.ts @@ -14,7 +14,7 @@ test("tree shares directories, retains file identities, and folds subtrees", () ]); expect(flattenFileTree(tree, new Set(["/src"])).some(r => r.node.fileIndex === 0)).toBe(false); expect(parentDirectories(files[0])).toEqual(["/src", "/src/ui"]); - expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }); + expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }); files[0].diff = { type: "binary", lhs: { size: 1 } }; - expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 } }); + expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }); }); diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index 1deca649c..aa76509bb 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -54,6 +54,6 @@ export function parentDirectories(file: Pick): string[] { } /** Textual counts always; structural ones when tree-sitter compared the file. */ export function lineCounts(file: DiffFile) { - if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 } }; - return { textual: file.diff.stats.textual, structural: file.diff.stats.structural }; + if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; + return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, structural: file.diff.stats.structural }; } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index d62645e63..b2f6c71bf 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -49,7 +49,7 @@ export function createTestDiffFile(): DiffFile { leaf(4, 3, 4), ], }, - stats: { textual: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }, + stats: { textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }, }, }; } diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 6ba221198..fead15b3c 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -30,6 +30,7 @@ export function createFoldedDiffFile(): DiffFile { if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); file.diff.lhs = { text, syntax: [], regions: regions(false) }; file.diff.rhs = { text, syntax: [], regions: regions(true) }; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } }; return file; } test("folds keep headers visible, hide trailing lines only when nothing follows", () => { diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index c963e982c..c0a254ba5 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -70,6 +70,15 @@ export function flatten(diff: TextDiff) { const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; } +/** A leaf is novel when it carries change spans or has no counterpart on the other side. */ +export function novelLeaves(leaves: readonly [Leaf[], Leaf[]]): Set { + const ids = [new Set(leaves[0].map((l) => l.id)), new Set(leaves[1].map((l) => l.id))]; + const novel = new Set(); + for (const side of [0, 1] as const) + for (const leaf of leaves[side]) + if (leaf.changed.size > 0 || !ids[side ? 0 : 1].has(leaf.id)) novel.add(leaf); + return novel; +} /** Ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ export function defaultCollapsed(diff: TextDiff): Set { const ids = new Set(); diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index 45a3ad6db..5753a770a 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -167,7 +167,7 @@ test("binary and one-sided files render without a second side", () => { added.file = { rhs: added.file.rhs }; if (added.diff.type !== "text") throw new Error(); added.diff = { type: "text", rhs: { text: "new\n", syntax: [], regions: [leaf(1, 0, 1, [line(0, 0, 3)])] }, - stats: { textual: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } } }; + stats: { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } } }; const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 5292bbbd6..7f6300fc8 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -3,7 +3,7 @@ import type { DiffFile, Span, SyntaxSpan } from "./wire"; import { filePath } from "./wire"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; -import { flatten, foldHeaders, hiddenLines, leafLabel, sourceLines, type Leaf, type RowFold } from "./regions"; +import { flatten, foldHeaders, hiddenLines, leafLabel, novelLeaves, sourceLines, type Leaf, type RowFold } from "./regions"; import { loadBundledTheme, type Palette } from "./theme"; export { sourceLines }; export type Layout = "split" | "unified"; @@ -104,10 +104,9 @@ export function rowsForFile( const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; const headers = [foldHeaders(folds[0], leaves[0], collapsed), foldHeaders(folds[1], leaves[1], collapsed)]; const caches = [new Map(), new Map()]; - // A leaf is novel when it carries change spans or has no counterpart; every line in it is - // tinted, and the spans inside get the darker word tint on top. - const ids = [new Set(leaves[0].map((l) => l.id)), new Set(leaves[1].map((l) => l.id))]; - const novel = (leaf: Leaf) => leaf.changed.size > 0 || !ids[leaf.side ? 0 : 1].has(leaf.id); + // Every line of a novel leaf is tinted; the spans inside get the darker word tint on top. + const novelSet = novelLeaves(leaves); + const novel = (leaf: Leaf) => novelSet.has(leaf); const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; const text = texts[side][line]; diff --git a/tui/packages/hunk/src/diffr/store.ts b/tui/packages/hunk/src/diffr/store.ts index ae44af389..6ff5a4331 100644 --- a/tui/packages/hunk/src/diffr/store.ts +++ b/tui/packages/hunk/src/diffr/store.ts @@ -1,6 +1,9 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +type StartEvent = Extract; export interface Snapshot { + /** The two ends of the comparison, from the start event. */ + comparison: { lhs: StartEvent["lhs"]; rhs: StartEvent["rhs"] } | null; files: DiffFile[]; inventory: FileChange[]; failedFiles: Map; @@ -10,6 +13,7 @@ export interface Snapshot { } export class DiffStore { private value: Snapshot = { + comparison: null, files: [], inventory: [], failedFiles: new Map(), @@ -27,7 +31,7 @@ export class DiffStore { getSnapshot = () => this.value; accept(event: DiffEvent) { if (event.type === "start") - this.value = { ...this.value, total: event.files.length, inventory: event.files }; + this.value = { ...this.value, comparison: { lhs: event.lhs, rhs: event.rhs }, total: event.files.length, inventory: event.files }; if (event.type === "file") { const identity = fileIdentity(event.file); const inventory = this.value.inventory.some((f) => fileIdentity(f.file) === identity) diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index cf897aba5..876654677 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -32,7 +32,7 @@ test("omitted defaults are filled in", async () => { const bare = { type: "file", file: file.file, diff: { type: "text", lhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, rhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, - stats: { textual: { added: 0, removed: 0 }, structural: { added: 0, removed: 0 } } } }; + stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 }, structural: { added: 0, removed: 0 } } } }; const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, { type: "complete", succeeded: 1, failed: 0 }]); expect(decoded).toMatchObject({ diff: { lhs: { syntax: [], regions: [{ tags: [], changed: [], children: [], diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index 7cf4d39b3..23d705926 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -67,6 +67,8 @@ const lineCounts = z.object({ added: uint, removed: uint }); const stats = z .object({ textual: lineCounts, + /** Changed lines shown under diffr's default fold state; the frontend adjusts it as folds toggle. */ + visible: lineCounts, structural: lineCounts.optional(), fallback: problem.optional(), }) @@ -110,6 +112,7 @@ export type Diff = z.infer; export type Span = z.infer; export type SyntaxSpan = z.infer; export type Stats = z.infer; +export type LineCounts = z.infer; export type DiffEvent = z.infer; export type FileEvent = Extract; /** A file record that carries a diff; failures are kept separately by the store. */ diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 20c043c1a..5123ed44f 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -33,7 +33,7 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou "copyToClipboardOSC52", ).mockReturnValue(true); await act(async () => { - await testRenderer.mockMouse.drag(40, 2, 40, 5); + await testRenderer.mockMouse.drag(40, 3, 40, 6); }); await act(async () => { testRenderer.mockInput.pressKey("y"); @@ -49,13 +49,13 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou frame.indexOf('send("new")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame( (frame) => frame.includes("▸") && !frame.includes('send("old")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame((frame) => frame.includes('send("old")')); } finally { @@ -87,7 +87,7 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => } expect(count(testRenderer.renderer.root)).toBeLessThan(250); await act(async () => { - await testRenderer.mockMouse.scroll(70, 8, "up"); + await testRenderer.mockMouse.scroll(70, 9, "up"); }); await testRenderer.waitForFrame((frame) => !frame.includes("line 4999")); } finally { @@ -112,15 +112,15 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as await t.waitForFrame(f => f.includes("▾ src")); expect(t.captureCharFrame()).toContain("▾ nested"); // Sorted tree: src / nested / beta.ts / alpha.ts. - await act(async () => { await t.mockMouse.click(8, 3); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/nested/beta.ts")); + await act(async () => { await t.mockMouse.click(8, 4); }); + await t.waitForFrame(f => f.split("\n")[2].includes("src/nested/beta.ts")); await act(async () => { t.mockInput.pressKey("\x1b[6~"); }); await t.waitForFrame(f => f.includes("code 20")); - expect(t.captureCharFrame().split("\n")[1]).toContain("src/nested/beta.ts"); - expect(t.captureCharFrame().split("\n")[1]).toContain("+2 -1"); + expect(t.captureCharFrame().split("\n")[2]).toContain("src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2]).toContain("+2 −1"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => !f.includes("▾ src ")); - expect(t.captureCharFrame().split("\n")[1].trimStart()).toStartWith("▾ src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2].trimStart()).toStartWith("▾ src/nested/beta.ts"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => f.includes("▾ nested")); await act(async () => { await t.mockMouse.click(9, 0); }); @@ -138,7 +138,7 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 150)] }; store.accept(file); const t = await testRender( {}} themes={themes} />, {width:150, height:20}); - const firstSource = () => Number(t.captureCharFrame().split("\n")[2].match(/row (\d+)/)?.[1]); + const firstSource = () => Number(t.captureCharFrame().split("\n")[3].match(/row (\d+)/)?.[1]); const press = async (name: string, ctrl = false) => { await act(async () => { t.mockInput.pressKey(name, {ctrl}); }); await t.renderOnce(); @@ -147,11 +147,11 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("row 0")); await press("d", true); - expect(firstSource()).toBe(9); + expect(firstSource()).toBe(8); await press("u", true); expect(firstSource()).toBe(0); await press("f", true); - expect(firstSource()).toBe(18); + expect(firstSource()).toBe(17); await press("b", true); expect(firstSource()).toBe(0); await press("f"); @@ -161,14 +161,14 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", expect(t.captureCharFrame()).toContain("row 149"); await press("g"); expect(firstSource()).toBe(0); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(27); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(27); await act(async () => { await t.mockMouse.drag(27, 8, 47, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 47); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 47); await press("\\"); await press("\\"); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(47); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(47); await act(async () => { await t.mockMouse.drag(47, 8, 2, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 15); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 15); expect(firstSource()).toBe(0); } finally { await act(async () => { t.renderer.destroy(); }); @@ -185,12 +185,12 @@ test("initial manifest renders pending tree and remembers a jump until its diff await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("◌ a.ts") && f.includes("◌ b.ts")); expect(t.captureCharFrame()).not.toContain('send("old")'); - await act(async () => { await t.mockMouse.click(8,3); }); + await act(async () => { await t.mockMouse.click(8,4); }); await t.waitForFrame(f => f.includes("Waiting for b.ts")); await act(async () => { store.accept(a); }); await t.waitForFrame(f => f.includes("◌ b.ts")); await act(async () => { store.accept(b); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/b.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("src/b.ts")); expect(t.captureCharFrame()).not.toContain("◌ b.ts"); } finally { await act(async () => { t.renderer.destroy(); }); @@ -208,27 +208,27 @@ test("streaming diffs follow tree order without moving the visible source row", const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); const t = await testRender( {}} themes={themes} />, {width:150, height:20}); - const sidebarLines = () => t.captureCharFrame().split("\n").slice(1,8).map(line => line.slice(0,27).trim()); + const sidebarLines = () => t.captureCharFrame().split("\n").slice(2,9).map(line => line.slice(0,27).trim()); try { await act(async () => { await t.renderOnce(); store.accept(files[0]); store.accept(files[2]); }); await t.waitForFrame(f => f.includes("m/middle.ts")); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "◌ first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); await act(async () => { t.mockInput.pressKey("d", {ctrl:true}); }); await t.renderOnce(); - const before = t.captureCharFrame().split("\n")[2].slice(28); + const before = t.captureCharFrame().split("\n")[3].slice(28); await act(async () => { store.accept(files[1]); }); - await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[1].includes("m/middle.ts")); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[2].includes("m/middle.ts")); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { store.accept({type:"complete", succeeded:3, failed:0}); }); await t.renderOnce(); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("a/first.ts")); - await act(async () => { await t.mockMouse.click(8,4); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("a/first.ts")); + await act(async () => { await t.mockMouse.click(8,5); }); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -345,3 +345,47 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a await act(async () => { t.renderer.destroy(); }); } }); + +test("the summary strip shows visible totals that follow fold state, and i opens the breakdown", async () => { + const store = new DiffStore(); + store.accept({type:"start", version:2, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, + files:[{ file: createFoldedDiffFile().file, status: "modified", visibility: { collapsed: false, label: "" } }]}); + store.accept(createFoldedDiffFile()); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 24 }); + const render = async () => { await act(async () => { await t.renderOnce(); }); }; + const lines = () => t.captureCharFrame().split("\n"); + try { + await render(); + await render(); + expect(t.captureCharFrame()).toContain("inner(|| {"); + // One changed rhs line inside the closure body; still loading, so the total is partial. + expect(lines()[1]).toContain("main…working tree"); + expect(lines()[1]).toContain("1 files"); + expect(lines()[1]).toContain("+1 −0…"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); + await act(async () => { store.accept({ type: "complete", succeeded: 1, failed: 0 }); }); + await render(); + expect(lines()[1]).not.toContain("−0…"); + // Collapse the closure body: the change is hidden, so the totals and header drop to zero. + const inner = lines().findIndex((l) => l.includes("inner(|| {")); + const chevronX = lines()[inner].indexOf("▾"); + await act(async () => { await t.mockMouse.click(chevronX, inner); }); + await render(); + expect(lines()[1]).toContain("+0 −0"); + expect(lines()[1]).toContain("□□□□□"); + expect(lines()[2]).toContain("+0 −0"); + await act(async () => { t.mockInput.pressKey("i"); }); + await render(); + const frame = t.captureCharFrame(); + expect(frame).toContain("All files"); + expect(frame).toContain("visible +0 −0"); + expect(frame).toContain("structural +1 −0"); + expect(frame).toContain("textual +1 −0"); + await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); + await render(); + expect(t.captureCharFrame()).not.toContain("visible +0"); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 172e2bd80..fab8d31ca 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -30,6 +30,7 @@ import { import { fileIdentity, filePath, type DiffFile } from "../diffr/wire"; import { defaultCollapsed, foldIds, gapIds, nestedIds, type RowFold } from "../diffr/regions"; import { placeholderRows } from "../diffr/rows"; +import { add, blockBar, comparisonLabel, visibleCounts, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { sliceTextByWidth } from "./lib/text"; @@ -67,13 +68,14 @@ export function App({ const sidebarDrag = useRef<{ x: number; width: number } | null>(null); const [pendingFile, setPendingFile] = useState(null); const [menu, setMenu] = useState(null); + const [showBreakdown, setShowBreakdown] = useState(false); const dragging = useRef(false), thumbDragging = useRef(false); // `t` swaps between the two bundled defaults; a configured theme is left by the first press. const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), - viewportHeight = Math.max(1, height - 2); + viewportHeight = Math.max(1, height - 3); const layout = mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; const loadedByIdentity = useMemo(() => new Map(snapshot.files.map((f, i) => [fileIdentity(f.file), i])), [snapshot.files]); @@ -246,7 +248,8 @@ export function App({ else if (key.name === "c") { toggleContext(); setSelection(null); } else if (key.name === "t") toggleTheme(); else if (key.name === "y") copy(); - else if (key.name === "escape") { setSelection(null); setMenu(null); } + else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } + else if (key.name === "i") setShowBreakdown((v) => !v); else if (key.name === "return") { const current = visibleRows(geometry, top, 1)[0]; if (current && current.row.fileIndex >= 0) @@ -278,6 +281,18 @@ export function App({ }, [pendingFile, loadedByIdentity, geometry, maxScroll, snapshot.failedFiles, snapshot.complete]); const treeRows = useMemo(() => flattenFileTree(tree, closedDirectories), [tree, closedDirectories]); const counts = useMemo(() => snapshot.files.map(lineCounts), [snapshot.files]); + // Headline numbers are the changed lines on screen, so every fold or file toggle moves them. + const visibleByFile = useMemo( + () => snapshot.files.map((file, index) => visibleCounts(file, foldsOf(index), isClosed(index))), + [snapshot.files, snapshot.inventory, collapsed, closed], + ); + const totals = useMemo(() => ({ + visible: visibleByFile.reduce(add, zero), + textual: counts.reduce((sum, c) => add(sum, c.textual), zero), + structural: counts.reduce((sum, c) => sum && c.structural ? add(sum, c.structural) : null, zero), + }), [visibleByFile, counts]); + const plusMinus = (c: LineCounts) => `+${c.added} −${c.removed}`; + const structuralText = (c: LineCounts | null | undefined) => (c ? plusMinus(c) : "line diff"); useEffect(() => { const file = inventory[currentTreeFile]; if (!file) return; @@ -294,21 +309,19 @@ export function App({ }, [currentTreeFile, treeRows, viewportHeight]); const sidebarStart = Math.min(treeScroll, Math.max(0, treeRows.length - viewportHeight)); const fileHeader = (fileIndex: number, key: string) => { - const file = snapshot.files[fileIndex], count = counts[fileIndex]; + const file = snapshot.files[fileIndex], count = visibleByFile[fileIndex]; if (!file) return null; const path = filePath(file.file); - const structural = count.structural && (count.structural.added !== count.textual.added - || count.structural.removed !== count.textual.removed) - ? ` · syntax +${count.structural.added} -${count.structural.removed}` : ""; - const statsWidth = String(count.textual.added).length + String(count.textual.removed).length + 5 + structural.length; + const structural = ""; + const statsWidth = String(count.added).length + String(count.removed).length + 5; return toggleFile(fileIndex)}> {fit(sanitizeTerminalLine(`${isClosed(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} - {` +${count.textual.added}`} - {` -${count.textual.removed} `} + {` +${count.added}`} + {` −${count.removed} `} {structural && {structural}} ; }; @@ -400,6 +413,7 @@ export function App({ ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], ["Drag to select · y to copy", () => setMessage("Drag source lines; y copies original source")], + ["Change breakdown i", () => setShowBreakdown(true)], ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")]], }; return ( @@ -439,7 +453,19 @@ export function App({ { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }}>{` ${layout} [s] `} - {` · ${snapshot.total || snapshot.inventory.length} files`} + + + + {` ${snapshot.comparison ? comparisonLabel(snapshot.comparison.lhs, snapshot.comparison.rhs) : "diffr"}`} + + {` · ${snapshot.total || snapshot.inventory.length} files · `} + setShowBreakdown((v) => !v)}>{`+${totals.visible.added}`} + setShowBreakdown((v) => !v)}>{` −${totals.visible.removed}`} + {snapshot.complete ? " " : "… "} + {blockBar(totals.visible).map((block, i) => ( + setShowBreakdown((v) => !v)}>{block === "neutral" ? "□" : "■"} + ))} + {showBreakdown && (() => { + const file = snapshot.files[currentFile]; + const lines = [ + ["All files", totals.visible, totals.structural, totals.textual], + ...(file ? [[filePath(file.file), visibleByFile[currentFile], counts[currentFile].structural ?? null, counts[currentFile].textual]] : []), + ] as [string, LineCounts, LineCounts | null, LineCounts][]; + const boxWidth = Math.min(width, 44); + return + {lines.flatMap(([title, visible, structural, textual]) => [ + {fit(` ${title}`, boxWidth)}, + {fit(` visible ${plusMinus(visible)}`, boxWidth)}, + {fit(` structural ${structuralText(structural)}`, boxWidth)}, + {fit(` textual ${plusMinus(textual)}`, boxWidth)}, + ])} + {fit(" esc close", boxWidth)} + ; + })()} {menu && @@ -552,7 +596,7 @@ export function App({ } {fit( - `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · drag selects lines · y copy · q quit ${message}`, + `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · q quit ${message}`, width, )} diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index d1b366750..031e78853 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"type":"start","version":2,"lhs":{"type":"path","path":"tui/test/fixtures/before.ts"},"rhs":{"type":"path","path":"tui/test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} -{"type":"file","file":{"lhs":{"path":"tui/test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"tui/test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1}}}} +{"type":"start","version":2,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} +{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} diff --git a/tui/test/pty/launch.py b/tui/test/pty/launch.py index 0f51c2131..810834ce1 100644 --- a/tui/test/pty/launch.py +++ b/tui/test/pty/launch.py @@ -44,10 +44,11 @@ def until(token): until(b'console.log(name);') os.write(master, b's') until(b'split [s]') - # Width 100 has a 28-column file sidebar. The header is at x=30,y=2 (1-based). - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') + # Width 100 has a 28-column file sidebar. Below the menubar and summary strip the header + # is at x=30,y=3 (1-based). + os.write(master, b'\x1b[<0;31;3M\x1b[<0;31;3m') until('▸'.encode()) - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') + os.write(master, b'\x1b[<0;31;3M\x1b[<0;31;3m') until('▾'.encode()) os.write(master, b'q') deadline = time.monotonic() + 5 From 4c45d79cd898c9bb0ccee27d20725b04f1a8c1f2 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 13:05:18 -0700 Subject: [PATCH 21/43] Drop structural counts from the terminal frontend stats is textual, visible, and an optional fallback. The breakdown lists visible and textual and notes a line-diff fallback by code; the file header shows visible counts only. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 4 +-- tui/packages/hunk/src/diffr/fileTree.test.ts | 2 +- tui/packages/hunk/src/diffr/fileTree.ts | 2 +- tui/packages/hunk/src/diffr/fixture.ts | 2 +- tui/packages/hunk/src/diffr/regions.test.ts | 2 +- tui/packages/hunk/src/diffr/rows.test.ts | 2 +- tui/packages/hunk/src/diffr/stream.test.ts | 2 +- tui/packages/hunk/src/diffr/wire.ts | 18 ++++++-------- tui/packages/hunk/src/ui/App.test.tsx | 8 +++--- tui/packages/hunk/src/ui/App.tsx | 26 ++++++++++---------- 10 files changed, 32 insertions(+), 36 deletions(-) diff --git a/tui/README.md b/tui/README.md index a9aa3be35..d1987b0c1 100644 --- a/tui/README.md +++ b/tui/README.md @@ -90,8 +90,8 @@ list; Esc returns without saving. Both views carry a footer hint line. toggled, so lines hidden inside a collapsed region are not counted. While results are still arriving the total ends in `…`. - `i` (or clicking the totals) opens a breakdown for the whole comparison and the current - file: `visible`, `structural` (from tree-sitter, or `line diff` when it fell back), and - `textual`. Esc closes it. + file: `visible` and `textual`, plus a `line diff: ` line when tree-sitter fell + back to a line diff for that file. Esc closes it. - A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. diff --git a/tui/packages/hunk/src/diffr/fileTree.test.ts b/tui/packages/hunk/src/diffr/fileTree.test.ts index 3857414de..fc92c6458 100644 --- a/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/tui/packages/hunk/src/diffr/fileTree.test.ts @@ -14,7 +14,7 @@ test("tree shares directories, retains file identities, and folds subtrees", () ]); expect(flattenFileTree(tree, new Set(["/src"])).some(r => r.node.fileIndex === 0)).toBe(false); expect(parentDirectories(files[0])).toEqual(["/src", "/src/ui"]); - expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }); + expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, fallback: undefined }); files[0].diff = { type: "binary", lhs: { size: 1 } }; expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }); }); diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index aa76509bb..03f7687f6 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -55,5 +55,5 @@ export function parentDirectories(file: Pick): string[] { /** Textual counts always; structural ones when tree-sitter compared the file. */ export function lineCounts(file: DiffFile) { if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; - return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, structural: file.diff.stats.structural }; + return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, fallback: file.diff.stats.fallback }; } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index b2f6c71bf..c1018c408 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -49,7 +49,7 @@ export function createTestDiffFile(): DiffFile { leaf(4, 3, 4), ], }, - stats: { textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, structural: { added: 2, removed: 1 } }, + stats: { textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 } }, }, }; } diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index fead15b3c..977d1fbbf 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -30,7 +30,7 @@ export function createFoldedDiffFile(): DiffFile { if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); file.diff.lhs = { text, syntax: [], regions: regions(false) }; file.diff.rhs = { text, syntax: [], regions: regions(true) }; - file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } }; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; return file; } test("folds keep headers visible, hide trailing lines only when nothing follows", () => { diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index 5753a770a..c588979a0 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -167,7 +167,7 @@ test("binary and one-sided files render without a second side", () => { added.file = { rhs: added.file.rhs }; if (added.diff.type !== "text") throw new Error(); added.diff = { type: "text", rhs: { text: "new\n", syntax: [], regions: [leaf(1, 0, 1, [line(0, 0, 3)])] }, - stats: { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 }, structural: { added: 1, removed: 0 } } }; + stats: { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } } }; const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index 876654677..798836d7a 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -32,7 +32,7 @@ test("omitted defaults are filled in", async () => { const bare = { type: "file", file: file.file, diff: { type: "text", lhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, rhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, - stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 }, structural: { added: 0, removed: 0 } } } }; + stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } } } }; const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, { type: "complete", succeeded: 1, failed: 0 }]); expect(decoded).toMatchObject({ diff: { lhs: { syntax: [], regions: [{ tags: [], changed: [], children: [], diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index 23d705926..e2cc9e893 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -64,17 +64,13 @@ const source = z.object({ regions: z.array(region).default([]), }); const lineCounts = z.object({ added: uint, removed: uint }); -const stats = z - .object({ - textual: lineCounts, - /** Changed lines shown under diffr's default fold state; the frontend adjusts it as folds toggle. */ - visible: lineCounts, - structural: lineCounts.optional(), - fallback: problem.optional(), - }) - .refine((value) => (value.structural === undefined) !== (value.fallback === undefined), { - message: "stats need exactly one of structural or fallback", - }); +const stats = z.object({ + textual: lineCounts, + /** Changed lines shown under diffr's default fold state; the frontend adjusts it as folds toggle. */ + visible: lineCounts, + /** Present when tree-sitter did not run and this is a line diff. */ + fallback: problem.optional(), +}); const diff = z.discriminatedUnion("type", [ z.object({ type: z.literal("text"), lhs: source.optional(), rhs: source.optional(), stats }) .refine((value) => value.lhs !== undefined || value.rhs !== undefined, { diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 5123ed44f..fc4f51ee8 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -379,12 +379,12 @@ test("the summary strip shows visible totals that follow fold state, and i opens await render(); const frame = t.captureCharFrame(); expect(frame).toContain("All files"); - expect(frame).toContain("visible +0 −0"); - expect(frame).toContain("structural +1 −0"); - expect(frame).toContain("textual +1 −0"); + expect(frame).toContain("visible +0 −0"); + expect(frame).toContain("textual +1 −0"); + expect(frame).not.toContain("line diff"); await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); await render(); - expect(t.captureCharFrame()).not.toContain("visible +0"); + expect(t.captureCharFrame()).not.toContain("visible +0"); } finally { await act(async () => { t.renderer.destroy(); }); } diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index fab8d31ca..2b96bcc9c 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -289,10 +289,9 @@ export function App({ const totals = useMemo(() => ({ visible: visibleByFile.reduce(add, zero), textual: counts.reduce((sum, c) => add(sum, c.textual), zero), - structural: counts.reduce((sum, c) => sum && c.structural ? add(sum, c.structural) : null, zero), + fallbacks: counts.filter((c) => c.fallback).length, }), [visibleByFile, counts]); const plusMinus = (c: LineCounts) => `+${c.added} −${c.removed}`; - const structuralText = (c: LineCounts | null | undefined) => (c ? plusMinus(c) : "line diff"); useEffect(() => { const file = inventory[currentTreeFile]; if (!file) return; @@ -312,7 +311,6 @@ export function App({ const file = snapshot.files[fileIndex], count = visibleByFile[fileIndex]; if (!file) return null; const path = filePath(file.file); - const structural = ""; const statsWidth = String(count.added).length + String(count.removed).length + 5; return {` +${count.added}`} {` −${count.removed} `} - {structural && {structural}} ; }; const rendered = []; @@ -568,18 +565,21 @@ export function App({ {showBreakdown && (() => { const file = snapshot.files[currentFile]; - const lines = [ - ["All files", totals.visible, totals.structural, totals.textual], - ...(file ? [[filePath(file.file), visibleByFile[currentFile], counts[currentFile].structural ?? null, counts[currentFile].textual]] : []), - ] as [string, LineCounts, LineCounts | null, LineCounts][]; + const fallback = file && counts[currentFile].fallback; + const sections: [string, LineCounts, LineCounts, string | null][] = [ + ["All files", totals.visible, totals.textual, totals.fallbacks ? `line diff: ${totals.fallbacks} files` : null], + ...(file ? [[filePath(file.file), visibleByFile[currentFile], counts[currentFile].textual, + fallback ? `line diff: ${fallback.code}` : null] as [string, LineCounts, LineCounts, string | null]] : []), + ]; const boxWidth = Math.min(width, 44); + const rowCount = sections.reduce((n, s) => n + 3 + (s[3] ? 1 : 0), 0); return - {lines.flatMap(([title, visible, structural, textual]) => [ + height={rowCount + 1} flexDirection="column" zIndex={10} backgroundColor={theme.chrome}> + {sections.flatMap(([title, visible, textual, note]) => [ {fit(` ${title}`, boxWidth)}, - {fit(` visible ${plusMinus(visible)}`, boxWidth)}, - {fit(` structural ${structuralText(structural)}`, boxWidth)}, - {fit(` textual ${plusMinus(textual)}`, boxWidth)}, + {fit(` visible ${plusMinus(visible)}`, boxWidth)}, + {fit(` textual ${plusMinus(textual)}`, boxWidth)}, + ...(note ? [{fit(` ${note}`, boxWidth)}] : []), ])} {fit(" esc close", boxWidth)} ; From 81dc56dbdb52609b8b2c12eb587260dc80a33a38 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 13:09:22 -0700 Subject: [PATCH 22/43] Replace the structural count with a visible count in stats stats.visible counts the changed lines still on screen under the default visibility, computed after the mutations run so configuration and hooks change it. The matcher's own line count leaves the wire; stats.fallback alone says when the AST match did not run. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/streaming.md | 26 ++++-- src/config.rs | 4 +- src/protocol/mod.rs | 73 ++++------------- src/protocol/project.rs | 26 +++--- src/protocol/stream.rs | 172 +++++++++++++++++++++++++++++++++------ tests/streaming/check.py | 37 ++++++++- 6 files changed, 232 insertions(+), 106 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 303cfcf69..28087602e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -120,7 +120,7 @@ any file failed or the run aborted. "lhs": {"text": "…", "syntax": [...], "regions": [...]}, "rhs": {"text": "…", "syntax": [...], "regions": [...]}, "stats": {"textual": {"added": 4, "removed": 1}, - "structural": {"added": 3, "removed": 1}}} // or "fallback": {code, message} + "visible": {"added": 2, "removed": 1}}} // + "fallback": {code, message} on a line diff ``` A `binary` diff carries only `{"lhs": {"size": n}, "rhs": {"size": n}}`; either @@ -133,14 +133,22 @@ tree-sitter highlight capture name (`keyword`, `function.method`, …). Spans ar per line, sorted, and non-overlapping; where captures nest, the innermost wins. Files that fell back to a line diff have no syntax. -`stats.textual` counts lines with any byte change. `stats.structural` counts lines -with a syntactic change and is replaced by `fallback` when the AST match did not -run: `unsupported_language`, `too_large`, `too_complex`, `parse_error`. A fallback -diff is aligned by a line diff and its `changed` spans are word-level, but the -parse still stands: folds and the enclosing-header context are present whenever -the language parsed (`too_complex`, `parse_error`), paired through that alignment -exactly as they are for a structural diff. Only `unsupported_language` and -`too_large` produce leaves alone. +`stats.textual` counts lines with any byte change. `stats.visible` counts the +changed lines still on screen under the default visibility: a line that carries +a `changed` span, or any line of a leaf that exists on one side only, unless it +sits inside a region that starts collapsed. It is computed after the mutations +run, so configuration and hooks change it, and with nothing collapsed it matches +`textual` up to the blank lines of a paired changed run. A frontend that lets the +reader fold and unfold recomputes the same rule locally; the wire value is the +starting point. + +`stats.fallback` is present when the AST match did not run: `unsupported_language`, +`too_large`, `too_complex`, `parse_error`. A fallback diff is aligned by a line +diff and its `changed` spans are word-level, but the parse still stands: folds +and the enclosing-header context are present whenever the language parsed +(`too_complex`, `parse_error`), paired through that alignment exactly as they are +for a structural diff. Only `unsupported_language` and `too_large` produce +leaves alone. ### Regions diff --git a/src/config.rs b/src/config.rs index b39fae052..4fbf47431 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,8 +36,8 @@ pub(crate) struct Config { } /// When a file exceeds one of these, diffr falls back to a line diff for -/// it: no folds, no collapse rules, no summaries, and `stats.structural` -/// carries the reason. The `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and +/// it: the alignment is line-based and `stats.fallback` carries the +/// reason; folds still come from the parse where it succeeded. The `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and /// `DFT_PARSE_ERROR_LIMIT` variables override the file, and the matching /// command-line flags override both. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index eb4f4a5c4..4ebaccd6b 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -365,60 +365,22 @@ pub struct SourcePos { pub column: u32, } -/// `structural` is `Ok` when tree-sitter compared both sides and `Err` -/// when this is a line diff, carrying why. On the wire that is a -/// `structural` key or a `fallback` key, never both. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Line counts for one file. `fallback` is present exactly when the AST +/// match did not run and the alignment is a line diff, carrying why: +/// `too_complex`, `too_large`, `unsupported_language`, `parse_error`. +/// Folds are still present on a fallback whenever the language parsed, +/// paired through that alignment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Stats { /// Lines with any byte change. pub textual: LineCounts, - /// Lines with a syntactic change. `Err` means the AST match did not - /// run: the alignment is a line diff, `changed` spans are word-level, - /// and folds are still present, paired through that alignment. - pub structural: Result, -} - -#[derive(Serialize, Deserialize)] -struct StatsRepr { - textual: LineCounts, - #[serde(skip_serializing_if = "Option::is_none")] - structural: Option, - #[serde(skip_serializing_if = "Option::is_none")] - fallback: Option, -} - -impl Serialize for Stats { - fn serialize(&self, serializer: S) -> Result { - let (structural, fallback) = match &self.structural { - Ok(counts) => (Some(*counts), None), - Err(problem) => (None, Some(problem.clone())), - }; - StatsRepr { - textual: self.textual, - structural, - fallback, - } - .serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for Stats { - fn deserialize>(deserializer: D) -> Result { - let repr = StatsRepr::deserialize(deserializer)?; - let structural = match (repr.structural, repr.fallback) { - (Some(counts), None) => Ok(counts), - (None, Some(problem)) => Err(problem), - _ => { - return Err(D::Error::custom( - "stats need exactly one of structural or fallback", - )) - } - }; - Ok(Self { - textual: repr.textual, - structural, - }) - } + /// Changed lines still on screen under the default visibility: a + /// changed line inside a region that starts collapsed, or under one, + /// is not counted. Computed after mutations run, so hooks and config + /// change it. + pub visible: LineCounts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -499,10 +461,11 @@ mod tests { added: 1, removed: 1, }, - structural: Ok(LineCounts { + visible: LineCounts { added: 1, - removed: 0, - }), + removed: 1, + }, + fallback: None, }, }) .into(), @@ -537,7 +500,7 @@ mod tests { "lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(json!([]))]}, "rhs": {"text": "fn f() {\n 1 + 2\n}\n", "regions": [region(json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, - "stats": {"textual": {"added": 1, "removed": 1}, "structural": {"added": 1, "removed": 0}}, + "stats": {"textual": {"added": 1, "removed": 1}, "visible": {"added": 1, "removed": 1}}, }, }); assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 1aa76afee..5748807f2 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -88,16 +88,13 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { added: novel_lines(&rhs_lines).len() as u32, removed: novel_lines(&lhs_lines).len() as u32, }; - let structural = match &result.file_format { - FileFormat::SupportedLanguage(_) => Ok(LineCounts { - added: novel_lines(&result.rhs_positions).len() as u32, - removed: novel_lines(&result.lhs_positions).len() as u32, - }), - FileFormat::PlainText => Err(Problem { + let fallback = match &result.file_format { + FileFormat::SupportedLanguage(_) => None, + FileFormat::PlainText => Some(Problem { code: "unsupported_language".to_owned(), message: "no tree-sitter grammar for this file".to_owned(), }), - FileFormat::TextFallback { reason } => Err(Problem { + FileFormat::TextFallback { reason } => Some(Problem { code: fallback_code(reason).to_owned(), message: reason.clone(), }), @@ -105,7 +102,11 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { }; Stats { textual, - structural, + // Before any mutation runs nothing starts collapsed except context + // gaps, which hold no changed lines; the stream recounts after + // mutations. + visible: textual, + fallback, } } @@ -936,7 +937,7 @@ mod tests { let Diff::Text { stats, .. } = &diff else { panic!("text diff"); }; - assert_eq!(stats.structural.is_ok(), structural); + assert_eq!(stats.fallback.is_none(), structural); let (lhs, rhs) = sources(&diff); let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); assert_tiles(lhs); @@ -994,7 +995,7 @@ mod tests { let Diff::Text { stats, .. } = &diff else { panic!("text diff"); }; - assert_eq!(stats.structural.as_ref().unwrap_err().code, "too_complex"); + assert_eq!(stats.fallback.as_ref().unwrap().code, "too_complex"); let (_, rhs) = sources(&diff); let rhs = rhs.unwrap(); let bodies: Vec<_> = all(&rhs.regions) @@ -1269,14 +1270,13 @@ mod tests { removed: 1 } ); - let structural = stats.structural.as_ref().unwrap(); - assert_eq!(structural.added, 1); + assert!(stats.fallback.is_none()); let diff = project("a.unknownext", "x\n", "y\n", 3); let Diff::Text { stats, .. } = &diff else { panic!("text") }; assert_eq!( - stats.structural.as_ref().unwrap_err().code, + stats.fallback.as_ref().unwrap().code, "unsupported_language" ); } diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index 940cecf17..ee924741d 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -1,7 +1,11 @@ //! The v2 stdout stream: manifest, one record per file as it finishes, footer. use super::project::{self, Inputs}; -use super::{Diff, Event, FileChange, Outcome, Pairing, Problem, Snapshot, SyntaxSpan, VERSION}; +use super::{ + Diff, Event, FileChange, LineCounts, Node, Outcome, Pairing, Problem, Region, Snapshot, Source, + SyntaxSpan, VERSION, +}; use crate::git::{DiffSession, FileProblem, LoadedFile}; +use crate::hash::DftHashSet; use crate::mutate::Mutations; use crate::summary::{DiffResult, FileContent, FileFormat}; use rayon::iter::{ParallelBridge, ParallelIterator}; @@ -192,8 +196,12 @@ fn mutate( projected: Result, ) -> Result { let outcome = match projected { - Ok(Diff::Text { mut sides, stats }) => { + Ok(Diff::Text { + mut sides, + mut stats, + }) => { mutations.apply_fold(entry, &mut sides)?; + stats.visible = visible_counts(&sides); Ok(Diff::Text { sides, stats }) } other => other, @@ -335,31 +343,147 @@ pub(crate) fn write_file( Ok(ended) } +/// Changed lines that start on screen. A line counts when it carries a +/// `changed` span, or when its leaf exists on one side only (every line of +/// a one-sided leaf is new or removed, blank ones included). Lines inside a +/// collapsed region, or under one, are not counted. +pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { + fn ids(regions: &[Region], out: &mut DftHashSet) { + for region in regions { + out.insert(region.id); + if let Node::Fold { children } = ®ion.node { + ids(children, out); + } + } + } + fn count(regions: &[Region], other: &DftHashSet, hidden: bool) -> u32 { + let mut total = 0; + for region in regions { + let hidden = hidden || region.visibility.collapsed; + match ®ion.node { + Node::Leaf { changed } => { + if hidden { + continue; + } + if other.contains(®ion.id) { + let lines: DftHashSet = changed.iter().map(|span| span.line).collect(); + total += lines.len() as u32; + } else { + total += region.range.lines().len() as u32; + } + } + Node::Fold { children } => total += count(children, other, hidden), + } + } + total + } + let side_ids = |source: Option<&Source>| { + let mut out = DftHashSet::default(); + if let Some(source) = source { + ids(&source.regions, &mut out); + } + out + }; + let (lhs_ids, rhs_ids) = (side_ids(sides.lhs()), side_ids(sides.rhs())); + LineCounts { + added: sides + .rhs() + .map_or(0, |rhs| count(&rhs.regions, &lhs_ids, false)), + removed: sides + .lhs() + .map_or(0, |lhs| count(&lhs.regions, &rhs_ids, false)), + } +} + #[cfg(test)] -mod tests { +mod visible_tests { use super::*; + use crate::protocol::{SourcePos, SourceRange, Span, Visibility}; - #[test] - fn fallback_files_still_get_syntax_spans() { - let params = crate::config::Config::from_toml("") - .unwrap() - .compile() - .unwrap(); - let src = "fn f() {\n let x = 1;\n}\n"; - let diff = DiffResult::from_sources_with_options( - "a.rs", - "fn g() {\n let y = 2;\n}\n", - src, - ¶ms, - &crate::options::DisplayOptions::default(), - &crate::options::DiffOptions { - graph_limit: 1, - ..crate::options::DiffOptions::default() + fn pos(line: u32) -> SourcePos { + SourcePos { line, column: 0 } + } + + fn leaf(id: u32, lines: (u32, u32), changed: &[u32], collapsed: bool) -> Region { + Region { + id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec![], + visibility: Visibility { + collapsed, + label: String::new(), }, - ); - assert!(matches!(diff.file_format, FileFormat::TextFallback { .. })); - let (lhs, rhs) = syntax_spans(&diff, ¶ms, std::path::Path::new("a.rs")); - assert!(!lhs.is_empty() && !rhs.is_empty()); - assert!(rhs.iter().any(|span| span.capture.starts_with("keyword"))); + node: Node::Leaf { + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 1, + }) + .collect(), + }, + } + } + + fn fold(id: u32, lines: (u32, u32), collapsed: bool, children: Vec) -> Region { + Region { + id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec!["body".to_owned()], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Fold { children }, + } + } + + fn source(regions: Vec) -> Source { + Source { + text: String::new(), + syntax: vec![], + regions, + } + } + + #[test] + fn counts_span_lines_and_every_line_of_a_one_sided_leaf() { + let rhs = source(vec![ + // paired leaf: only the lines with spans count (two, one twice) + leaf(1, (0, 3), &[0, 1, 1], false), + // paired leaf without spans: unchanged context, not counted + leaf(9, (3, 4), &[], false), + // one-sided leaf with no spans (blank lines): every line counts + leaf(2, (4, 6), &[], false), + // collapsed leaf: hidden + leaf(3, (6, 9), &[6, 7], true), + // open fold with an open one-sided leaf: every line counts + fold(4, (9, 12), false, vec![leaf(5, (9, 12), &[10], false)]), + // collapsed fold: its open child is hidden by the ancestor + fold(6, (12, 15), true, vec![leaf(7, (12, 15), &[13, 14], false)]), + ]); + let lhs = source(vec![ + leaf(1, (0, 3), &[0], false), + leaf(9, (3, 4), &[], false), + leaf(8, (4, 7), &[4, 5], true), + ]); + let counts = visible_counts(&Pairing::Both { lhs, rhs }); + assert_eq!(counts.added, 2 + 2 + 3); + assert_eq!(counts.removed, 1); + } + + #[test] + fn a_missing_side_counts_nothing() { + let rhs = source(vec![leaf(1, (0, 1), &[0], false)]); + let counts = visible_counts(&Pairing::RightOnly { rhs }); + assert_eq!(counts.added, 1); + assert_eq!(counts.removed, 0); } } diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 591994970..8a3a7e852 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -161,7 +161,13 @@ def check_tiling(source): assert not any(r["kind"] == "fold" for r in all_regions(rust["rhs"]["regions"])) assert "syntax" not in rust["rhs"] assert rust["stats"]["textual"] == {"added": 1, "removed": 0} - assert rust["stats"]["structural"] == {"added": 1, "removed": 0} + assert "fallback" not in rust["stats"] + assert rust["stats"]["visible"] == {"added": 1, "removed": 0} + for record in records.values(): + if "diff" in record and record["diff"]["type"] == "text": + visible, textual = record["diff"]["stats"]["visible"], record["diff"]["stats"]["textual"] + assert visible["added"] <= textual["added"], record["diff"]["stats"] + assert visible["removed"] <= textual["removed"], record["diff"]["stats"] check_tiling(rust["lhs"]) check_tiling(rust["rhs"]) changed = [leaf for leaf in leaves(rust["rhs"]["regions"]) if "changed" in leaf] @@ -210,7 +216,7 @@ def check_tiling(source): ) # The structural limits come from [diff]; exceeding one names the key. events = stream(repo, base, head, "--", "a.rs") - assert "structural" in events[1]["diff"]["stats"], events[1]["diff"]["stats"] + assert "fallback" not in events[1]["diff"]["stats"], events[1]["diff"]["stats"] for args in (["--graph-limit", "1"], ["--set", "diff.graph_limit=1"]): events = stream(repo, base, head, *args, "--", "a.rs") fallback = events[1]["diff"]["stats"]["fallback"] @@ -291,7 +297,8 @@ def check_tiling(source): events = [json.loads(line) for line in result.stdout.splitlines()] assert events[0]["lhs"] == {"type": "path", "path": str(before)} assert events[1]["file"]["lhs"]["path"] == str(before) - assert events[1]["diff"]["stats"]["structural"] == {"added": 1, "removed": 1} + assert events[1]["diff"]["stats"]["textual"] == {"added": 1, "removed": 1} + assert "fallback" not in events[1]["diff"]["stats"] assert events[2] == {"type": "complete", "succeeded": 1, "failed": 0} # A configured fold hook fills fold labels before each file record; a hook @@ -485,4 +492,28 @@ def config(*args, code=0): assert json.loads(process.stdout.readline())["type"] == "start" process.stdout.close() assert process.wait(timeout=30) != 0 +# `stats.visible` follows the default visibility: collapsing test bodies +# removes their changed lines from the count, and never exceeds `textual`. +with tempfile.TemporaryDirectory(prefix="diffr-visible-") as temp: + repo = Path(temp) + git(repo, "init", "-q") + git(repo, "commit", "--allow-empty", "-qm", "empty") + base = git(repo, "rev-parse", "HEAD") + (repo / "t.py").write_text( + "def helper():\n return 1\n\n\ndef test_helper():\n assert helper() == 1\n assert helper() != 2\n assert helper() > 0\n" + ) + git(repo, "add", "-A") + head = commit(repo, "add tests") + collapsed = stream(repo, base, head, "--", "t.py")[1]["diff"]["stats"] + expanded = stream( + repo, base, head, "--set", "folds.collapse_test_bodies=false", "--", "t.py" + )[1]["diff"]["stats"] + assert collapsed["textual"] == expanded["textual"] == {"added": 8, "removed": 0}, ( + collapsed, + expanded, + ) + assert expanded["visible"] == {"added": 8, "removed": 0}, expanded + assert collapsed["visible"]["added"] < expanded["visible"]["added"], collapsed + assert collapsed["visible"]["added"] <= collapsed["textual"]["added"] + print("CLI streaming, cancellation and Git operand checks passed") From 188702eeb0ed12a7d126ee1603c7bd5d72a1f24b Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 13:09:23 -0700 Subject: [PATCH 23/43] Regenerate the terminal fixture without the structural count Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/test/fixtures/comparison.ndjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index 031e78853..0e3668174 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ {"type":"start","version":2,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} -{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"structural":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} +{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From 46d1ceedb1bbde5c48139ec38503b0a1d8c92af6 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 13:33:36 -0700 Subject: [PATCH 24/43] Collapse cfg(test) modules and classify Rust test files A Rust mod carrying #[cfg(test)] is tagged test (and module) so the whole module starts collapsed as one "test module" fold, with the test bodies still foldable inside. tests.rs, test.rs, *_test.rs and *_tests.rs count as test files, so src/review/tests.rs is hidden by default like the other test files. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 9a68def0-2052-49c3-a656-a142d3152c20 --- docs/config.md | 7 +++++-- src/category.rs | 8 ++++++++ src/config/defaults.toml | 5 +++++ src/mutate/collapse.rs | 28 +++++++++++++++++++++++++--- src/review/tests.rs | 20 ++++++++++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/config.md b/docs/config.md index b7bb085a8..b9c0ac635 100644 --- a/docs/config.md +++ b/docs/config.md @@ -100,7 +100,8 @@ Every file in the manifest carries a `category`: `source`, `test`, 3. Built-in path rules: lockfiles and `dist/`, `build/`, `vendor/`, `node_modules/`, `__generated__/`, `*.min.js`, `*.pb.go`, `*.generated.*` are generated; `tests/`, `test/`, `__tests__/`, `spec/`, `*_test.go`, - `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `conftest.py` are tests; + `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `conftest.py`, `tests.rs`, + `test.rs`, `*_test.rs`, `*_tests.rs` are tests; `docs/` and `*.md` are docs. ```gitattributes @@ -119,7 +120,9 @@ itself. In order: 2. `collapse_deleted`: deleted function bodies (folds tagged `function`) of at least `min_lines` lines, labelled `" lines removed"`. 3. `collapse_test_bodies`: bodies of test functions (folds tagged `test`) of - three or more lines, on both sides, labelled `"test body"`. + three or more lines, on both sides, labelled `"test body"`. A whole test + module such as a Rust `#[cfg(test)] mod tests` is one `test` fold too, + labelled `"test module"`, with the test bodies still foldable inside it. 4. `collapse_removed_lines`: removed stretches with no counterpart on the after side and at least that many lines keep their first and last line open and collapse the middle, tagged `removed` and labelled diff --git a/src/category.rs b/src/category.rs index 55e4c67d9..c217edd71 100644 --- a/src/category.rs +++ b/src/category.rs @@ -49,6 +49,10 @@ pub(crate) fn from_path(path: &str) -> Option<&'static str> { || TEST_FILES.contains(&name) || name.ends_with("_test.go") || name.ends_with("_test.py") + || name == "tests.rs" + || name == "test.rs" + || name.ends_with("_test.rs") + || name.ends_with("_tests.rs") || name.starts_with("test_") && name.ends_with(".py") || name.contains(".test.") || name.contains(".spec.") @@ -89,6 +93,10 @@ mod tests { "pkg/conftest.py", "src/__tests__/a.js", "spec/models/user_spec.rb", + "src/review/tests.rs", + "src/parser/test.rs", + "src/git_test.rs", + "src/protocol_tests.rs", ] { assert_eq!(from_path(path), Some(TEST), "{path}"); } diff --git a/src/config/defaults.toml b/src/config/defaults.toml index d16eb334d..a8a07a481 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -29,6 +29,11 @@ folds = ''' ((function_item attributes: (attributes (attribute_item) @_attribute) body: (block "{" @fold.open "}" @fold.close) @fold) (#match? @_attribute "test") (#set! tag "test")) +((mod_item body: (declaration_list "{" @fold.open "}" @fold.close) @fold) + (#set! tag "module")) +((mod_item attributes: (attributes (attribute_item) @_attribute) body: (declaration_list "{" @fold.open "}" @fold.close) @fold) + (#match? @_attribute "cfg\\(test\\)") + (#set! tag "test")) ''' context = ''' [ diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index 8c8072b42..7bffdaf7d 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -54,8 +54,10 @@ impl FoldMutation for DeletedBodies { } /// Test bodies start collapsed on both sides, paired or not, so a diff -/// reads as the code under test first. The header stays visible and the -/// fold expands like any other. +/// reads as the code under test first. A whole test module, such as a +/// Rust `#[cfg(test)] mod tests`, collapses as one fold labelled +/// "test module". The header stays visible and the fold expands like any +/// other. pub(crate) struct TestBodies; /// A test body shorter than this stays open: a one-line assertion is @@ -75,7 +77,12 @@ impl FoldMutation for TestBodies { && region.tags.iter().any(|tag| tag == "test") && line_count(region) >= MIN_TEST_BODY_LINES { - collapse(region, "test body".to_owned()); + let label = if region.tags.iter().any(|tag| tag == "module") { + "test module" + } else { + "test body" + }; + collapse(region, label.to_owned()); } }); } @@ -227,6 +234,21 @@ mod tests { ] ); } + // A `#[cfg(test)]` module collapses as one labelled fold; the test + // bodies inside keep their own label. + let (file, mut sides) = crate::mutate::summarize::tests::project( + "a.rs", + "", + "#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n a();\n b();\n c();\n }\n}\n", + ); + TestBodies.apply(&file, &mut sides).unwrap(); + let mut labels = Vec::new(); + walk(&sides.rhs().unwrap().regions, &mut |region| { + if is_fold(region) && region.visibility.collapsed { + labels.push(region.visibility.label.clone()); + } + }); + assert_eq!(labels, ["test module", "test body"]); // A tiny test body stays open. let (file, mut sides) = crate::mutate::summarize::tests::project( "a.rs", diff --git a/src/review/tests.rs b/src/review/tests.rs index ff37a992e..9086ed55b 100644 --- a/src/review/tests.rs +++ b/src/review/tests.rs @@ -70,6 +70,26 @@ mod folds { && added(fold).is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); } + #[test] + fn cfg_test_modules_are_test_modules_and_plain_modules_are_not() { + let src = "mod plain {\n fn a() {}\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n a();\n }\n}\n"; + let result = DiffResult::from_sources("a.rs", "", src); + let tags: Vec> = result + .rhs_folds + .iter() + .filter(|fold| fold.tags.iter().any(|tag| tag == "module")) + .map(|fold| fold.tags.clone()) + .collect(); + assert_eq!( + tags, + [vec!["body", "module"], vec!["body", "module", "test"]] + ); + assert!(result + .rhs_folds + .iter() + .any(|fold| fold.tags == ["body", "function", "test"])); + } + #[test] fn flattened_test_body_keeps_the_existing_string_match_on_both_sides() { let lhs = "def test_doc():\n \"\"\"some shared words before\"\"\"\n"; From 0123840701301a69f42cce1dbee0213d4ecb1b5a Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 14:03:25 -0700 Subject: [PATCH 25/43] Emit fold regions as a strict tree Fold line spans are nested before the tree is built: when two parser folds cross on one line, such as a collection whose closer sits on the line that opens the next body, the earlier fold gives that line to the later one, and a span left with one line is dropped. A fold's wire range is now the hull of its children, whole lines, instead of the parser's byte range, so every child lies inside its parent and siblings never overlap. A debug assertion and the streaming checker walk every tree. The terminal frontend no longer hides the line after a fold whose end sits at column zero. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 9a68def0-2052-49c3-a656-a142d3152c20 Agent-Session: 6b254b74-b802-4554-b16d-6723bf8b5f1f Agent-Session: 3ad992ce-8922-470c-a21f-ad2de0e31b10 Agent-Session: bf405baa-0eb9-435d-8f3d-0846840aff6c --- docs/streaming.md | 15 +- src/protocol/project.rs | 186 ++++++++++++++++++++++--- tests/streaming/check.py | 35 ++++- tui/packages/hunk/src/diffr/regions.ts | 4 +- 4 files changed, 210 insertions(+), 30 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 28087602e..6a944792e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -181,12 +181,15 @@ an otherwise matching line carries just that word. A line with no span in a leaf that has spans is a changed line whose tokens all matched elsewhere. Blank changed lines carry no span. -**Folds** are regions with `children`. Their line span is the hull of their -children, which tile it exactly; `start` and `end` keep the byte-precise range -the parser found, so the first line of a fold is its header and stays visible -when it collapses. A fold with an `id` on both sides is the same syntax node on -both sides; its contents may differ. Folds nest by containment. A syntactic -region that spans a single line is not a region: it hides nothing. +**Folds** are regions with `children`. Their `start` and `end` are the hull of +their children, which tile it exactly, so a fold's range is whole lines: the +first line is its header and stays visible when it collapses. Regions form a +strict tree: every child lies inside its parent's range and siblings never +overlap. When the parser hands over two folds that cross on one line, such as a +collection whose closer sits on the line that opens the next body, the earlier +fold gives that line to the later one. A fold with an `id` on both sides is the +same syntax node on both sides; its contents may differ. A syntactic region that +spans a single line is not a region: it hides nothing. `tags` name what a region is (`body`, `import`, `test`, `unchanged`, or tags a hook adds). `visibility` is how it starts out: `collapsed` and the `label` to diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 5748807f2..c68c9c773 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -272,9 +272,51 @@ fn regions( &rhs_lines, &mut ids, ); + debug_assert_eq!(tree_violation(&lhs), None); + debug_assert_eq!(tree_violation(&rhs), None); Ok((lhs, rhs)) } +/// The first way `regions` fails to be a strict tree, if any: a child +/// outside its parent's byte range, siblings out of order or overlapping, +/// or a fold with no children. +pub(crate) fn tree_violation(regions: &[Region]) -> Option { + fn pos(position: SourcePos) -> (u32, u32) { + (position.line, position.column) + } + fn walk(regions: &[Region], parent: Option<&Region>) -> Option { + let mut previous_end = None; + for region in regions { + let (start, end) = (pos(region.range.start), pos(region.range.end)); + if let Some(parent) = parent { + if start < pos(parent.range.start) || end > pos(parent.range.end) { + return Some(format!( + "region {} outside its parent {}", + region.id, parent.id + )); + } + } + if previous_end.is_some_and(|previous| start < previous) { + return Some(format!( + "region {} overlaps its previous sibling", + region.id + )); + } + previous_end = Some(end); + if let Node::Fold { children } = ®ion.node { + if children.is_empty() { + return Some(format!("fold {} has no children", region.id)); + } + if let Some(problem) = walk(children, Some(region)) { + return Some(problem); + } + } + } + None + } + walk(regions, None) +} + /// Rows in order, run-length encoded by kind and side presence. fn runs( rows: &[(Option, Option)], @@ -409,21 +451,70 @@ fn line_span(range: &lines::SourceRange, line_count: usize) -> (usize, usize) { } fn side_folds<'a>(folds: &'a [Fold], lines: &[&str]) -> Vec> { + let spans: Vec<(usize, usize)> = folds + .iter() + .map(|fold| line_span(&fold.range, lines.len())) + .collect(); folds .iter() + .zip(nested_spans(&spans)) // A fold on a single line hides nothing; it is not a region. - .filter(|fold| { - let (start, end) = line_span(&fold.range, lines.len()); - end - start >= 2 - }) - .map(|fold| SideFold { - fold, - lines: line_span(&fold.range, lines.len()), - pair: None, + .filter_map(|(fold, span)| { + span.map(|lines| SideFold { + fold, + lines, + pair: None, + }) }) .collect() } +/// Make fold line spans a strict tree: nested or disjoint, never crossing. +/// +/// Leaves tile whole lines, so two folds can share a line only if one +/// contains the other. The parser can hand over folds that cross on one +/// line, such as a collection whose closer sits on the line that opens the +/// next body (`for x in [ … ] {`). The rule: the earlier fold gives the +/// shared line to the later one, so its span ends where the later fold +/// starts. A span left with fewer than two lines hides nothing and is +/// dropped (`None`). Output is in input order. +fn nested_spans(spans: &[(usize, usize)]) -> Vec> { + let mut order: Vec = (0..spans.len()).collect(); + order.sort_by_key(|&index| (spans[index].0, std::cmp::Reverse(spans[index].1))); + let mut out: Vec> = spans.iter().map(|&span| Some(span)).collect(); + let mut open: Vec = Vec::new(); + let closed = |out: &[Option<(usize, usize)>], top: usize, start: usize| { + out[top].is_none_or(|(_, top_end)| top_end <= start) + }; + for index in order { + let (start, end) = spans[index]; + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + // Every open fold that ends before this one does crosses it: clip + // each to hand over the shared lines. + for &top in open.iter().rev() { + let Some((top_start, top_end)) = out[top] else { + continue; + }; + if top_end >= end { + break; + } + out[top] = (start > top_start).then_some((top_start, start)); + } + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + open.push(index); + } + for span in &mut out { + if span.is_some_and(|(start, end)| end - start < 2) { + *span = None; + } + } + out +} + /// Two folds correspond when the line alignment pairs their header lines /// and their tags agree. A changed signature is still a paired row, so a /// function whose header was edited keeps its counterpart. The rule is the @@ -626,9 +717,19 @@ fn tree( let close = |stack: &mut Vec>, root: &mut Vec| { let open = stack.pop().expect("closing an open fold"); let fold = open.fold.fold; + // The wire range is the hull of the children, which tile whole + // lines; the parser's byte columns inside the header line are not + // carried, since nothing narrower than a line can be hidden. + let (Some(first), Some(last)) = (open.children.first(), open.children.last()) else { + return; + }; + let range = SourceRange { + start: first.range.start, + end: last.range.end, + }; let region = Region { id: open.id, - range: source_range(&fold.range), + range, tags: fold.tags.clone(), visibility: Visibility { collapsed: false, @@ -772,19 +873,6 @@ fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap SourceRange { - SourceRange { - start: SourcePos { - line: range.start.line.as_usize() as u32, - column: range.start.byte_column as u32, - }, - end: SourcePos { - line: range.end.line.as_usize() as u32, - column: range.end.byte_column as u32, - }, - } -} - #[cfg(test)] mod tests { use super::*; @@ -889,6 +977,7 @@ mod tests { } fn assert_folds_hold_children(regions: &[Region]) { + assert_eq!(tree_violation(regions), None); for region in regions { if let Node::Fold { children } = ®ion.node { assert!(!children.is_empty(), "fold without children {region:?}"); @@ -923,6 +1012,59 @@ mod tests { .collect() } + #[test] + fn crossing_folds_give_the_shared_line_to_the_later_fold() { + // A collection closing on line 5 where a body opens: `for x in [ … ] {`. + let spans = [(0, 6), (5, 9), (7, 8)]; + assert_eq!( + nested_spans(&spans), + vec![Some((0, 5)), Some((5, 9)), None], + "the earlier fold ends where the later starts; one-line spans go" + ); + // Clipping cascades through every open ancestor that would cross. + assert_eq!( + nested_spans(&[(0, 10), (2, 6), (4, 8)]), + vec![Some((0, 10)), Some((2, 4)), Some((4, 8))] + ); + // An ancestor that is clipped down to its header line disappears. + assert_eq!(nested_spans(&[(3, 5), (4, 9)]), vec![None, Some((4, 9))]); + // Nested and disjoint spans are untouched, whatever their input order. + assert_eq!( + nested_spans(&[(5, 9), (0, 4), (1, 3)]), + vec![Some((5, 9)), Some((0, 4)), Some((1, 3))] + ); + } + + #[test] + fn a_closer_and_an_opener_on_one_line_yield_a_strict_tree() { + let lhs = "fn f() {\n for x in [\n 1,\n 2,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let rhs = "fn f() {\n for x in [\n 1,\n 2,\n 3,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let diff = project("a.rs", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + for source in [lhs.unwrap(), rhs.unwrap()] { + assert_tiles(source); + assert_folds_hold_children(&source.regions); + let folds: Vec<&Region> = all(&source.regions) + .into_iter() + .filter(|region| matches!(region.node, Node::Fold { .. })) + .collect(); + let collection = folds + .iter() + .find(|fold| fold.tags.iter().any(|tag| tag == "collection")) + .expect("the array is a fold"); + let body = folds + .iter() + .find(|fold| fold.tags.iter().any(|tag| tag == "body") && fold.range.start.line > 0) + .expect("the loop body is a fold"); + assert_eq!( + collection.range.end.line, body.range.start.line, + "the collection gives its closing line to the body that opens there" + ); + assert_eq!(body.range.start.column, 0); + assert_eq!(collection.range.end.column, 0); + } + } + #[test] fn folds_pair_through_the_line_alignment_on_either_engine() { for options in [ diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 8a3a7e852..d6672dada 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -67,6 +67,12 @@ def stream(repo, *args, code=0): first = json.loads(process.stdout.readline()) assert first["type"] == "start" and first["version"] == 2, first events = [first, *[json.loads(line) for line in process.stdout]] + for event in events: + diff = event.get("diff") if event.get("type") == "file" else None + if diff and diff["type"] == "text": + for side in ("lhs", "rhs"): + if side in diff: + check_tree(diff[side]) stderr = process.stderr.read() assert process.wait(timeout=30) == code, stderr assert events[-1]["type"] == "complete" @@ -105,6 +111,30 @@ def all_regions(regions): yield from all_regions(region["children"]) +def check_tree(source): + """Regions form a strict tree: children inside parents, siblings in order.""" + + def pos(point): + return (point["line"], point["column"]) + + def walk(regions, parent): + previous_end = None + for region in regions: + start, end = pos(region["start"]), pos(region["end"]) + if parent is not None: + assert pos(parent["start"]) <= start and end <= pos(parent["end"]), ( + region, + parent, + ) + assert previous_end is None or previous_end <= start, (region, previous_end) + previous_end = end + if region["kind"] == "fold": + assert region["children"], region + walk(region["children"], region) + + walk(source["regions"], None) + + def check_tiling(source): at = 0 for leaf in leaves(source["regions"]): @@ -165,7 +195,10 @@ def check_tiling(source): assert rust["stats"]["visible"] == {"added": 1, "removed": 0} for record in records.values(): if "diff" in record and record["diff"]["type"] == "text": - visible, textual = record["diff"]["stats"]["visible"], record["diff"]["stats"]["textual"] + visible, textual = ( + record["diff"]["stats"]["visible"], + record["diff"]["stats"]["textual"], + ) assert visible["added"] <= textual["added"], record["diff"]["stats"] assert visible["removed"] <= textual["removed"], record["diff"]["stats"] check_tiling(rust["lhs"]) diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index c0a254ba5..52cd10e23 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -54,7 +54,9 @@ export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds const { start, end } = region; const endText = lines[end.line]; if (endText === undefined) throw new Error(`diffr fold references missing line ${end.line}`); - const hideEnd = encoder.encode(endText.trimEnd()).length <= end.column; + // An end at column 0 does not touch its line; otherwise the end line is hidden when the + // range covers all of its text. + const hideEnd = end.column > 0 && encoder.encode(endText.trimEnd()).length <= end.column; const fold: Fold = { id: region.id, side, headerLine: start.line, lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, collapsed: region.visibility.collapsed, nested: [] }; From 5579cbf299b8714b25c2db32edf86f454122d27b Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 11 Sep 2026 14:03:26 -0700 Subject: [PATCH 26/43] Regenerate the terminal fixture with hull fold ranges Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 9a68def0-2052-49c3-a656-a142d3152c20 Agent-Session: 6b254b74-b802-4554-b16d-6723bf8b5f1f Agent-Session: 3ad992ce-8922-470c-a21f-ad2de0e31b10 Agent-Session: bf405baa-0eb9-435d-8f3d-0846840aff6c --- tui/test/fixtures/comparison.ndjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index 0e3668174..b195f7590 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ {"type":"start","version":2,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} -{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":30},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} +{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":0},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From 2f845cc74bff0f61b04d02cdf9f6a6874ef12949 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 17:21:13 -0700 Subject: [PATCH 27/43] Collapse removed code only where nothing is paired A function fold whose header line did not align could still hold paired leaves; the deleted-bodies rule treated it as removed and hid a rewrite. A region now counts as one-sided only when nothing under it has a counterpart. Removed stretches split only when their nearest enclosing function fold (or, outside any function, the nearest fold) is one-sided, and a "functions removed" group never wraps a rewrite. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp --- docs/config.md | 15 +++- src/mutate/collapse.rs | 162 +++++++++++++++++++++++++++++++++++++++-- src/mutate/group.rs | 99 ++++++++++++++++++++++--- src/mutate/mod.rs | 11 +++ 4 files changed, 265 insertions(+), 22 deletions(-) diff --git a/docs/config.md b/docs/config.md index b9c0ac635..17fcd8849 100644 --- a/docs/config.md +++ b/docs/config.md @@ -40,7 +40,7 @@ through these three commands; the schema is the only contract. min_lines = 12 # bodies shorter than this are never collapsed by a rule collapse_deleted = true # deleted function bodies start collapsed, header visible collapse_test_bodies = true # test bodies start collapsed on both sides, header visible -collapse_removed_lines = 5 # removed stretches this long collapse in the middle; 0 disables +collapse_removed_lines = 5 # removed stretches this long, in unpaired code, collapse in the middle; 0 disables collapse_generated = true # generated files start hidden collapse_tests = true # test files start hidden context_lines = 3 # unchanged lines kept around a change; -U overrides @@ -118,7 +118,10 @@ itself. In order: 1. `collapse_generated`, `collapse_tests`: the file's `visibility` in the manifest, before `start` is written. 2. `collapse_deleted`: deleted function bodies (folds tagged `function`) of - at least `min_lines` lines, labelled `" lines removed"`. + at least `min_lines` lines, labelled `" lines removed"`. A body is + deleted only when nothing under it is paired with the after side; a + function whose header moved but whose lines still align is a rewrite + and stays open. 3. `collapse_test_bodies`: bodies of test functions (folds tagged `test`) of three or more lines, on both sides, labelled `"test body"`. A whole test module such as a Rust `#[cfg(test)] mod tests` is one `test` fold too, @@ -126,8 +129,12 @@ itself. In order: 4. `collapse_removed_lines`: removed stretches with no counterpart on the after side and at least that many lines keep their first and last line open and collapse the middle, tagged `removed` and labelled - `" lines removed"`. Stretches under a fold that already starts - collapsed are left alone. + `" lines removed"`. Only unpaired code qualifies: the nearest + enclosing `function` fold (or, outside any function, the nearest + enclosing fold) must itself have nothing paired under it, so a rewritten + function shows its removed lines in place. Stretches at the top level + always qualify. Stretches under a fold that already starts collapsed are + left alone. 5. The built-in summarizer: new function bodies (folds tagged `function`, never a test, never one nested inside another selected body) of at least `summarize.min_lines` lines on the after side become python-flavored diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index 7bffdaf7d..375f67e08 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -1,7 +1,7 @@ //! Rules that start things collapsed: whole files by category, deleted //! function bodies, test bodies, and the middle of large removed stretches. use super::group::next_id; -use super::{collapse, ids, is_fold, line_count, walk_mut, FileMutation, FoldMutation}; +use super::{collapse, ids, is_fold, line_count, one_sided, walk_mut, FileMutation, FoldMutation}; use crate::category; use crate::hash::DftHashSet; use crate::protocol::{ @@ -28,7 +28,10 @@ impl FileMutation for HiddenCategories { } /// Deleted function bodies of at least `min_lines` start collapsed with a -/// line count. The header line stays visible by fold semantics. +/// line count. The header line stays visible by fold semantics. A body +/// counts as deleted only when nothing under it is paired: a function +/// whose header moved but whose lines still align is a rewrite and stays +/// open. pub(crate) struct DeletedBodies { pub(crate) min_lines: usize, } @@ -42,7 +45,7 @@ impl FoldMutation for DeletedBodies { walk_mut(&mut lhs.regions, &mut |region| { if is_fold(region) && region.tags.iter().any(|tag| tag == "function") - && !rhs_ids.contains(®ion.id) + && one_sided(region, &rhs_ids) && line_count(region) >= self.min_lines { let count = line_count(region); @@ -93,7 +96,11 @@ impl FoldMutation for TestBodies { /// Removed stretches with no counterpart and at least `min_lines` lines are /// split into three leaves: the first line open, the middle collapsed with /// a line count, the last line open, so the reader still sees red at both -/// ends. Leaves under a fold that already starts collapsed are left alone. +/// ends. Only stretches in unpaired code qualify: the nearest enclosing +/// `function` fold (or, outside any function, the nearest enclosing fold) +/// must itself be one-sided, so a rewritten function shows its red and +/// green lines in place. Stretches at the top level keep the plain rule. +/// Leaves under a fold that already starts collapsed are left alone. pub(crate) struct RemovedRuns { pub(crate) min_lines: usize, } @@ -107,26 +114,69 @@ impl FoldMutation for RemovedRuns { }; // A leaf needs a first, a middle, and a last line to split. let threshold = self.min_lines.max(3); - split_removed_runs(&mut lhs.regions, &rhs_ids, threshold, false, &mut next_id); + split_removed_runs( + &mut lhs.regions, + &rhs_ids, + threshold, + false, + Gates::default(), + &mut next_id, + ); Ok(()) } } +/// Whether the enclosing folds are one-sided: the nearest `function` fold, +/// and the nearest fold of any tag. `None` when there is no such fold. +#[derive(Clone, Copy, Default)] +struct Gates { + function: Option, + any: Option, +} + +impl Gates { + fn enter(self, fold: &Region, rhs_ids: &DftHashSet) -> Self { + let unpaired = one_sided(fold, rhs_ids); + Self { + function: if fold.tags.iter().any(|tag| tag == "function") { + Some(unpaired) + } else { + self.function + }, + any: Some(unpaired), + } + } + + /// Inside a paired function nothing collapses; outside any function + /// the nearest fold decides; at the top level everything qualifies. + fn open(self) -> bool { + self.function.or(self.any).unwrap_or(true) + } +} + fn split_removed_runs( regions: &mut Vec, rhs_ids: &DftHashSet, threshold: usize, under_collapsed: bool, + gates: Gates, next_id: &mut u32, ) { let mut out = Vec::with_capacity(regions.len()); for mut region in regions.drain(..) { let collapsed = under_collapsed || region.visibility.collapsed; - let splits = - !under_collapsed && !rhs_ids.contains(®ion.id) && line_count(®ion) >= threshold; + let splits = !under_collapsed + && gates.open() + && !rhs_ids.contains(®ion.id) + && line_count(®ion) >= threshold; + let inner = if is_fold(®ion) { + gates.enter(®ion, rhs_ids) + } else { + gates + }; match &mut region.node { Node::Fold { children } => { - split_removed_runs(children, rhs_ids, threshold, collapsed, next_id); + split_removed_runs(children, rhs_ids, threshold, collapsed, inner, next_id); out.push(region); } Node::Leaf { .. } if splits => out.extend(split_leaf(region, next_id)), @@ -458,6 +508,102 @@ mod tests { ); } + fn function_fold(id: u32, start: u32, end: u32, children: Vec) -> Region { + Region { + id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec!["body".to_owned(), "function".to_owned()], + visibility: Visibility::default(), + node: Node::Fold { children }, + } + } + + fn both(lhs: Vec, rhs: Vec) -> Pairing { + let source = |regions| Source { + text: String::new(), + syntax: vec![], + regions, + }; + Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + } + } + + #[test] + fn removed_runs_stay_open_under_a_paired_function() { + // The lhs function fold has no counterpart, but its second leaf + // does: a rewrite, so its removed stretch stays in place. + let rewritten = function_fold( + 1, + 0, + 20, + vec![ + removed_leaf(2, 0, 10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + removed_leaf(3, 10, 20, &[]), + ], + ); + // Wholly removed: nothing under it is on the rhs. + let removed = function_fold( + 4, + 20, + 40, + vec![removed_leaf(5, 20, 40, &[20, 21, 22, 23, 24, 25, 26, 27])], + ); + let mut sides = both(vec![rewritten, removed], vec![removed_leaf(3, 0, 10, &[])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let lhs = &sides.lhs().unwrap().regions; + let Node::Fold { children } = &lhs[0].node else { + panic!("fold expected"); + }; + assert_eq!(children.len(), 2, "nothing split under the paired function"); + assert!(children.iter().all(|child| !child.visibility.collapsed)); + let Node::Fold { children } = &lhs[1].node else { + panic!("fold expected"); + }; + assert_eq!( + shape(children), + vec![ + (5, 20, 21, false, String::new(), vec![20]), + ( + 6, + 21, + 39, + true, + "18 lines removed".to_owned(), + (21..28).collect() + ), + (7, 39, 40, false, String::new(), vec![]), + ] + ); + } + + #[test] + fn deleted_bodies_skip_folds_with_paired_content() { + // Header moved (fold id absent on the rhs) but the body lines align. + let rewritten = function_fold(1, 0, 20, vec![removed_leaf(2, 0, 20, &[])]); + let removed = function_fold(3, 20, 40, vec![removed_leaf(4, 20, 40, &[])]); + let mut sides = both(vec![rewritten, removed], vec![removed_leaf(2, 0, 20, &[])]); + DeletedBodies { min_lines: 3 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let lhs = &sides.lhs().unwrap().regions; + assert!(!lhs[0].visibility.collapsed, "a rewrite stays open"); + assert!(lhs[1].visibility.collapsed); + assert_eq!(lhs[1].visibility.label, "20 lines removed"); + } + #[test] fn deleted_bodies_collapse_when_large_and_one_sided() { let before = "def gone():\n a()\n b()\n c()\n\ndef kept():\n a()\n b()\n c()\n\ndef tiny():\n a()\n"; diff --git a/src/mutate/group.rs b/src/mutate/group.rs index 830794d9e..a33e8dea8 100644 --- a/src/mutate/group.rs +++ b/src/mutate/group.rs @@ -6,7 +6,7 @@ //! reveals each child's own collapsed row. Both steps keep ids paired: a //! merge or a group happens on one side only when the other side either //! does the same to the same ids or holds none of them. -use super::{is_fold, walk, FoldMutation}; +use super::{ids, is_fold, one_sided, walk, FoldMutation}; use crate::hash::{DftHashMap, DftHashSet}; use crate::protocol::{ FileChange, Node, Pairing, Problem, Region, Source, SourceRange, Visibility, @@ -125,10 +125,21 @@ fn merge_gap_runs(regions: &mut Vec, merges: &DftHashSet) { // ── groups ──────────────────────────────────────────────────────────────── +/// A collapsed fold labelled as removed lines only counts when nothing +/// under it is paired; a rewrite must not be swept into a "functions +/// removed" group. +fn removed_label(region: &Region) -> bool { + region.visibility.label.ends_with("lines removed") +} + /// A unit is a collapsed fold, with the single-line leaf just before it /// when there is one: the `def` or `fn` line whose body the fold hides. -fn unit_at(regions: &[Region], at: usize) -> Option { - let collapsed_fold = |region: &Region| is_fold(region) && region.visibility.collapsed; +fn unit_at(regions: &[Region], at: usize, other_ids: &DftHashSet) -> Option { + let collapsed_fold = |region: &Region| { + is_fold(region) + && region.visibility.collapsed + && (!removed_label(region) || one_sided(region, other_ids)) + }; let region = regions.get(at)?; if collapsed_fold(region) { return Some(at); @@ -142,17 +153,17 @@ const MAX_SEPARATOR_LINES: usize = 2; /// Maximal runs of two or more units among siblings, separated by at most /// a couple of open leaf lines, as their child ids in document order. -fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { +fn collapsed_fold_runs(regions: &[Region], other_ids: &DftHashSet, out: &mut Vec>) { for region in regions { // Nothing under a collapsed fold is visible, so nothing there // needs grouping. if let (Node::Fold { children }, false) = (®ion.node, region.visibility.collapsed) { - collapsed_fold_runs(children, out); + collapsed_fold_runs(children, other_ids, out); } } let mut at = 0; while at < regions.len() { - let Some(mut last) = unit_at(regions, at) else { + let Some(mut last) = unit_at(regions, at, other_ids) else { at += 1; continue; }; @@ -164,7 +175,7 @@ fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { while next < regions.len() && !is_fold(®ions[next]) && !regions[next].visibility.collapsed - && unit_at(regions, next) != Some(next + 1) + && unit_at(regions, next, other_ids) != Some(next + 1) { separator_lines += regions[next].range.lines().len(); next += 1; @@ -172,7 +183,7 @@ fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { if separator_lines > MAX_SEPARATOR_LINES { break; } - match unit_at(regions, next) { + match unit_at(regions, next, other_ids) { Some(end) => { last = end; units += 1; @@ -190,11 +201,13 @@ fn collapsed_fold_runs(regions: &[Region], out: &mut Vec>) { fn group_folds(sides: &mut Pairing, next_id: &mut u32) { let mut lhs_runs = Vec::new(); let mut rhs_runs = Vec::new(); + let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default(); + let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default(); if let Some(lhs) = sides.lhs() { - collapsed_fold_runs(&lhs.regions, &mut lhs_runs); + collapsed_fold_runs(&lhs.regions, &rhs_ids, &mut lhs_runs); } if let Some(rhs) = sides.rhs() { - collapsed_fold_runs(&rhs.regions, &mut rhs_runs); + collapsed_fold_runs(&rhs.regions, &lhs_ids, &mut rhs_runs); } // A run whose ids form the same run on the other side shares the group id. let mut group_ids: DftHashMap, u32> = DftHashMap::default(); @@ -300,6 +313,72 @@ mod tests { use crate::mutate::collapse::DeletedBodies; use crate::mutate::summarize::tests::project; + #[test] + fn removed_groups_only_wrap_one_sided_folds() { + use crate::protocol::SourcePos; + let leaf = |id: u32, start: u32, end: u32| Region { + id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed: vec![] }, + }; + let removed_fold = |id: u32, start: u32, end: u32, child: Region| Region { + id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec!["body".to_owned(), "function".to_owned()], + visibility: Visibility { + collapsed: true, + label: format!("{} lines removed", end - start), + }, + node: Node::Fold { + children: vec![child], + }, + }; + // Two adjacent "removed" folds, but the first one's leaf is paired. + let lhs = vec![ + leaf(1, 0, 1), + removed_fold(2, 1, 6, leaf(3, 1, 6)), + leaf(4, 6, 7), + removed_fold(5, 7, 12, leaf(6, 7, 12)), + ]; + let rhs = vec![leaf(3, 0, 5)]; + let source = |regions| Source { + text: String::new(), + syntax: vec![], + regions, + }; + let mut sides = Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + }; + let file = crate::mutate::summarize::tests::project("m.py", "", "").0; + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let lhs = &sides.lhs().unwrap().regions; + assert!( + lhs.iter().all(|region| region.tags != ["group"]), + "a rewrite next to a removal is not a run of removals" + ); + } + #[test] fn adjacent_deleted_bodies_are_grouped_under_one_collapsed_fold() { let before = "def a():\n x()\n y()\n z()\n\ndef b():\n x()\n y()\n z()\n\ndef c():\n x()\n y()\n z()\n\nkeep = 1\n"; diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 9a589da5d..60ad5d67e 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -119,6 +119,17 @@ pub(crate) fn ids(regions: &[Region]) -> DftHashSet { ids } +/// True when nothing under `region`, itself included, has a counterpart on +/// the other side. A fold whose header line did not align can still hold +/// paired leaves, and such a fold is a rewrite, not a removal. +pub(crate) fn one_sided(region: &Region, other_ids: &DftHashSet) -> bool { + let mut paired = false; + walk(std::slice::from_ref(region), &mut |inner| { + paired |= other_ids.contains(&inner.id); + }); + !paired +} + pub(crate) fn line_count(region: &Region) -> usize { region.range.lines().len() } From 0186c530b3a5fd57f6701dd5bf5fc51b6a3a8686 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 17:40:43 -0700 Subject: [PATCH 28/43] Split a region's id into alignment_id and fold_state_id alignment_id says what a region is across sides: the same value on the other side is its visual counterpart, one-to-one. fold_state_id says what it moves with: regions sharing it open and close together and may span sides or bundle same-side regions. They coincide for every region today; later mutations will bundle a docstring with its function. While assigning alignment ids, folds now also pair through their content: a fold whose header line did not align pairs with the fold on the other side that holds the counterpart of one of its leaves, most paired lines first, never crossing an existing pair. A function whose signature was reflowed onto more lines is the same function again instead of a removal plus an addition. Across main..HEAD, unpaired function folds that still held paired lines fell from 10 to 4; the remaining four moved into their parent function, which is already paired. Wire version 3. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/streaming.md | 30 +++--- examples/hooks/summarize.py | 2 +- src/hook.rs | 6 +- src/mutate/collapse.rs | 20 ++-- src/mutate/group.rs | 42 ++++++--- src/mutate/mod.rs | 6 +- src/mutate/summarize.rs | 6 +- src/protocol/mod.rs | 42 +++++---- src/protocol/project.rs | 179 +++++++++++++++++++++++++++++++++--- src/protocol/stream.rs | 10 +- tests/hooks/rpc_server.py | 4 +- tests/streaming/check.py | 10 +- 12 files changed, 276 insertions(+), 81 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 6a944792e..f0ffe5ca9 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -66,7 +66,7 @@ lines; pipe reads can split records or contain several. | Record | Fields | Consumer action | | --- | --- | --- | -| `start` | `version: 2`, `lhs`, `rhs`, `files` | Lay out every file up front. | +| `start` | `version: 3`, `lhs`, `rhs`, `files` | Lay out every file up front. | | `file` | `file`, then `diff` or `error` | Render a result, or mark the file failed. | | `complete` | `succeeded`, `failed`, optional `aborted` | Mark complete; `aborted` means the run stopped early. | @@ -153,25 +153,32 @@ leaves alone. ### Regions Each side carries a tree of regions. A region is a line range on that side with -an `id`, and the same `id` on the other side marks its counterpart. +two identities. `alignment_id` says what the region is across sides: the +same value on the other side marks its counterpart, one-to-one. `fold_state_id` +says what it moves with: regions sharing it open and close together, on either +side. For an ordinary region the two hold the same number; a mutation that +bundles regions (a docstring with its function, say) gives them one +`fold_state_id` while each keeps its own `alignment_id`. Consumers key the row +zip by `alignment_id` and collapse state by `fold_state_id`, never the reverse. ```jsonc -{"id": 7, "kind": "fold", +{"alignment_id": 7, "fold_state_id": 7, "kind": "fold", "start": {"line": 18, "column": 4}, "end": {"line": 52, "column": 33}, "tags": ["body"], "visibility": {"collapsed": false, "label": "Body"}, "children": [ - {"id": 8, "kind": "leaf", "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, - {"id": 9, "kind": "leaf", "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, + {"alignment_id": 8, "fold_state_id": 8, "kind": "leaf", "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, + {"alignment_id": 9, "fold_state_id": 9, "kind": "leaf", "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, "changed": [{"line": 30, "start_column": 8, "end_column": 9}]}, - {"id": 10, "kind": "leaf", "start": {"line": 31, "column": 0}, "end": {"line": 53, "column": 0}}]} + {"alignment_id": 10, "fold_state_id": 10, "kind": "leaf", "start": {"line": 31, "column": 0}, "end": {"line": 53, "column": 0}}]} ``` **Leaves** tile the file: read in order, their line ranges cover every line once. -They always start and end at column 0. A leaf with an `id` on both sides is +They always start and end at column 0. A leaf with an `alignment_id` on both sides is paired: the two have the same line count and their rows pair line for line. A leaf on one side only has no counterpart, and the other side shows blank rows -against it. The row table is the walk over both sides' leaves, zipped by id. +against it. The row table is the walk over both sides' leaves, zipped by +`alignment_id`. A paired leaf whose counterpart lies behind the reading cursor is a move; the frontend chooses how to show it. @@ -187,7 +194,7 @@ first line is its header and stays visible when it collapses. Regions form a strict tree: every child lies inside its parent's range and siblings never overlap. When the parser hands over two folds that cross on one line, such as a collection whose closer sits on the line that opens the next body, the earlier -fold gives that line to the later one. A fold with an `id` on both sides is the +fold gives that line to the later one. A fold with an `alignment_id` on both sides is the same syntax node on both sides; its contents may differ. A syntactic region that spans a single line is not a region: it hides nothing. @@ -266,7 +273,7 @@ polls the port until the hook accepts connections, exits 2 before `start` if the hook exits or misses `startup_timeout_ms`, and kills the hook when the comparison ends. -Only new fold regions on the after side qualify: folds whose id has no +Only new fold regions on the after side qualify: folds whose `alignment_id` has no counterpart on the before side, of at least `min_lines` lines. Files with no qualifying fold never reach the hook. Streaming is the only output mode that runs mutations. @@ -285,7 +292,8 @@ requests concurrently rather than one at a time. {"jsonrpc": "2.0", "id": 7, "result": {"3": "def refresh_token(session):\n ..."}} ``` -A fold `id` is the region's id in that file's `rhs.regions`. The matching +A fold `id` in the request is the region's `alignment_id` in that file's +`rhs.regions`. The matching region starts collapsed with the returned text as its label, behind a `# pseudocode` comment line in the file's own syntax. Folds missing from the result keep their placeholder. An error object, a timeout, an unknown fold id, diff --git a/examples/hooks/summarize.py b/examples/hooks/summarize.py index 6821423e7..af8d27202 100755 --- a/examples/hooks/summarize.py +++ b/examples/hooks/summarize.py @@ -34,7 +34,7 @@ SYSTEM = ( "You rewrite regions of a source file as terse Python-style pseudocode for a diff " "viewer that shows the pseudocode in place of the collapsed region. The user supplies " - "one numbered source file and a list of folds, each with an id and 1-based line range. " + "one numbered source file and a list of folds, each with an id (the region's alignment_id) and 1-based line range. " "For each fold, write pseudocode covering only that fold's lines: keep the control flow " "and the names that matter, drop types, error plumbing and boilerplate. Aim for about one " "pseudocode line per five source lines, between one and eight lines per fold. Reply with " diff --git a/src/hook.rs b/src/hook.rs index af2fa36c7..bfbbaabd9 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -113,7 +113,7 @@ impl Hook { } fn qualifies(&self, region: &Region, lhs_ids: &crate::hash::DftHashSet) -> bool { - if !is_fold(region) || lhs_ids.contains(®ion.id) { + if !is_fold(region) || lhs_ids.contains(®ion.alignment_id) { return false; } if line_count(region) < self.min_lines { @@ -173,7 +173,7 @@ impl FoldMutation for Hook { walk(&rhs.regions, &mut |region| { if self.qualifies(region, &lhs_ids) { folds.push(RequestFold { - id: region.id, + id: region.alignment_id, range: region.range, tags: region.tags.clone(), placeholder: region.visibility.label.clone(), @@ -216,7 +216,7 @@ impl FoldMutation for Hook { unreachable!("rhs regions were selected"); }; walk_mut(&mut rhs.regions, &mut |region| { - if let Some(text) = by_id.get(®ion.id) { + if let Some(text) = by_id.get(®ion.alignment_id) { collapse(region, summary_label(language, text)); } }); diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index 375f67e08..665a0219d 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -167,7 +167,7 @@ fn split_removed_runs( let collapsed = under_collapsed || region.visibility.collapsed; let splits = !under_collapsed && gates.open() - && !rhs_ids.contains(®ion.id) + && !rhs_ids.contains(®ion.alignment_id) && line_count(®ion) >= threshold; let inner = if is_fold(®ion) { gates.enter(®ion, rhs_ids) @@ -203,7 +203,8 @@ fn split_leaf(region: Region, next_id: &mut u32) -> Vec { .filter(|span| range.lines().contains(&span.line)) .collect(); Region { - id, + alignment_id: id, + fold_state_id: id, range, tags, visibility, @@ -216,7 +217,7 @@ fn split_leaf(region: Region, next_id: &mut u32) -> Vec { let hidden = last - first - 1; vec![ piece( - region.id, + region.alignment_id, SourceRange { start: region.range.start, end: at(first + 1), @@ -351,7 +352,8 @@ mod tests { fn removed_leaf(id: u32, start: u32, end: u32, changed: &[u32]) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: SourcePos { line: start, @@ -396,7 +398,7 @@ mod tests { }; let lines = region.range.lines(); ( - region.id, + region.alignment_id, lines.start, lines.end, region.visibility.collapsed, @@ -458,7 +460,8 @@ mod tests { regions: vec![ paired.clone(), Region { - id: 1, + alignment_id: 1, + fold_state_id: 1, range: SourceRange { start: SourcePos { line: 8, column: 0 }, end: SourcePos { @@ -489,7 +492,7 @@ mod tests { .unwrap(); let lhs = &sides.lhs().unwrap().regions; assert_eq!(lhs.len(), 5, "paired leaf, collapsed fold, three pieces"); - assert_eq!(lhs[0].id, 7); + assert_eq!(lhs[0].alignment_id, 7); let Node::Fold { children } = &lhs[1].node else { panic!("fold expected"); }; @@ -510,7 +513,8 @@ mod tests { fn function_fold(id: u32, start: u32, end: u32, children: Vec) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: SourcePos { line: start, diff --git a/src/mutate/group.rs b/src/mutate/group.rs index a33e8dea8..409bafdca 100644 --- a/src/mutate/group.rs +++ b/src/mutate/group.rs @@ -27,7 +27,7 @@ pub(super) fn next_id(sides: &Pairing) -> u32 { let mut max = None; for source in [sides.lhs(), sides.rhs()].into_iter().flatten() { walk(&source.regions, &mut |region| { - max = max.max(Some(region.id)); + max = max.max(Some(region.alignment_id)); }); } max.map_or(0, |max| max + 1) @@ -45,7 +45,7 @@ fn is_gap(region: &Region) -> bool { fn adjacent_gaps(regions: &[Region], out: &mut DftHashMap) { for pair in regions.windows(2) { if is_gap(&pair[0]) && is_gap(&pair[1]) && pair[0].range.end == pair[1].range.start { - out.insert(pair[0].id, pair[1].id); + out.insert(pair[0].alignment_id, pair[1].alignment_id); } } for region in regions { @@ -110,7 +110,7 @@ fn merge_gap_runs(regions: &mut Vec, merges: &DftHashSet) { merge_gap_runs(children, merges); } match merged.last_mut() { - Some(last) if merges.contains(&last.id) && is_gap(®ion) => { + Some(last) if merges.contains(&last.alignment_id) && is_gap(®ion) => { last.range.end = region.range.end; let count = last.range.lines().len(); last.visibility.label = format!("{count} unchanged lines"); @@ -192,7 +192,12 @@ fn collapsed_fold_runs(regions: &[Region], other_ids: &DftHashSet, out: &mu } } if units >= 2 { - out.push(regions[first..=last].iter().map(|r| r.id).collect()); + out.push( + regions[first..=last] + .iter() + .map(|r| r.alignment_id) + .collect(), + ); } at = last + 1; } @@ -241,7 +246,7 @@ fn wrap_runs(regions: &mut Vec, by_first: &DftHashMap let mut wrapped: Vec = Vec::with_capacity(regions.len()); let mut pending = std::mem::take(regions).into_iter(); while let Some(region) = pending.next() { - let Some(&(len, id)) = by_first.get(®ion.id) else { + let Some(&(len, id)) = by_first.get(®ion.alignment_id) else { wrapped.push(region); continue; }; @@ -279,7 +284,8 @@ fn group(id: u32, children: Vec) -> Region { format!("{count} folded regions") }; Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: children[0].range.start, end: children[children.len() - 1].range.end, @@ -317,7 +323,8 @@ mod tests { fn removed_groups_only_wrap_one_sided_folds() { use crate::protocol::SourcePos; let leaf = |id: u32, start: u32, end: u32| Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: SourcePos { line: start, @@ -333,7 +340,8 @@ mod tests { node: Node::Leaf { changed: vec![] }, }; let removed_fold = |id: u32, start: u32, end: u32, child: Region| Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: SourcePos { line: start, @@ -402,7 +410,7 @@ mod tests { for child in children { eprintln!( "child {} {:?} {:?} fold={}", - child.id, + child.alignment_id, child.range.lines(), child.visibility.label, is_fold(child) @@ -416,12 +424,16 @@ mod tests { assert_eq!(group.range.end, folds[2].range.end); let rhs_ids = super::super::ids(&sides.rhs().unwrap().regions); assert!( - !rhs_ids.contains(&group.id), + !rhs_ids.contains(&group.alignment_id), "a one-sided group has a fresh id" ); let mut seen = DftHashSet::default(); walk(&lhs.regions, &mut |region| { - assert!(seen.insert(region.id), "duplicate id {}", region.id); + assert!( + seen.insert(region.alignment_id), + "duplicate id {}", + region.alignment_id + ); }); } @@ -450,7 +462,7 @@ mod tests { merge_gaps(&mut sides); for source in [sides.lhs().unwrap(), sides.rhs().unwrap()] { assert_eq!(source.regions.len(), 2); - assert_eq!(source.regions[0].id, 0); + assert_eq!(source.regions[0].alignment_id, 0); assert_eq!(source.regions[0].range.lines(), 0..7); assert_eq!(source.regions[0].visibility.label, "7 unchanged lines"); } @@ -490,7 +502,8 @@ mod tests { fn gap(id: u32, start: u32, end: u32) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: range(start, end), tags: vec!["unchanged".to_owned()], visibility: Visibility { @@ -503,7 +516,8 @@ mod tests { fn leaf(id: u32, start: u32, end: u32) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: range(start, end), tags: vec![], visibility: Visibility::default(), diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 60ad5d67e..45bb5a8c3 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -110,11 +110,11 @@ pub(crate) fn walk(regions: &[Region], visit: &mut impl FnMut(&Region)) { } } -/// Every id on a side, to tell one-sided regions from paired ones. +/// Every alignment id on a side, to tell one-sided regions from paired ones. pub(crate) fn ids(regions: &[Region]) -> DftHashSet { let mut ids = DftHashSet::default(); walk(regions, &mut |region| { - ids.insert(region.id); + ids.insert(region.alignment_id); }); ids } @@ -125,7 +125,7 @@ pub(crate) fn ids(regions: &[Region]) -> DftHashSet { pub(crate) fn one_sided(region: &Region, other_ids: &DftHashSet) -> bool { let mut paired = false; walk(std::slice::from_ref(region), &mut |inner| { - paired |= other_ids.contains(&inner.id); + paired |= other_ids.contains(&inner.alignment_id); }); !paired } diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs index dded88d86..0fedaba38 100644 --- a/src/mutate/summarize.rs +++ b/src/mutate/summarize.rs @@ -236,11 +236,11 @@ pub(crate) fn select(sides: &Pairing, min_lines: usize) -> Vec<(u32, u32 && tag("function") && !tag("test") && !region.visibility.collapsed - && !lhs_ids.contains(®ion.id) + && !lhs_ids.contains(®ion.alignment_id) && line_count(region) >= min_lines { let lines = region.range.lines(); - selected.push((region.id, lines.start + 1, lines.end)); + selected.push((region.alignment_id, lines.start + 1, lines.end)); continue; } if let Node::Fold { children } = ®ion.node { @@ -318,7 +318,7 @@ impl FoldMutation for Summarizer { unreachable!("selection found rhs regions"); }; walk_mut(&mut rhs.regions, &mut |region| { - if let Some(text) = texts.get(®ion.id) { + if let Some(text) = texts.get(®ion.alignment_id) { collapse(region, summary_label(language, text)); } }); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 4ebaccd6b..439cd66f3 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -21,7 +21,7 @@ pub(crate) mod project; pub(crate) mod stream; /// The current wire version. Changes within a version are additive. -pub const VERSION: u32 = 2; +pub const VERSION: u32 = 3; fn is_default(value: &T) -> bool { *value == T::default() @@ -296,17 +296,25 @@ pub struct SyntaxSpan { pub capture: String, } -/// A range on one side. The same `id` on the other side is its -/// counterpart. Two leaves correspond when the line alignment pairs their -/// lines. Two folds correspond when the alignment pairs their header -/// lines, changed or not, whichever engine produced it. Ids appear -/// at most once per side. A paired leaf has the same line count on both -/// sides and its rows pair line for line; a paired leaf whose counterpart -/// is behind the reading cursor is a move, and the frontend chooses how to -/// show it. +/// A range on one side, carrying two identities that must never be +/// conflated. `alignment_id` says what the region *is* across sides: +/// the same value on the other side means the two regions are aligned +/// visually, one-to-one. Two leaves correspond when the line alignment +/// pairs their lines. Two folds correspond when the alignment pairs their +/// header lines, or, failing that, when one contains the counterpart of a +/// leaf the other contains, whichever engine produced the alignment. +/// A paired leaf has the same line count on both sides and its rows pair +/// line for line; a paired leaf whose counterpart is behind the reading +/// cursor is a move, and the frontend chooses how to show it. +/// `fold_state_id` says what the region *moves with*: regions sharing it +/// open and close together. It may span sides (a paired region has the +/// same value on both) and may bundle several same-side regions. For an +/// ordinary region both fields hold the same number. Consumers key the +/// row zip by `alignment_id` and collapse state by `fold_state_id`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Region { - pub id: u32, + pub alignment_id: u32, + pub fold_state_id: u32, #[serde(flatten)] pub range: SourceRange, /// `body`, `import`, `test`, `unchanged`, or hook-supplied tags. @@ -400,7 +408,8 @@ mod tests { fn leaf(id: u32, start: u32, end: u32, changed: Vec) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: pos(start, 0), end: pos(end, 0), @@ -423,7 +432,8 @@ mod tests { text: text.to_owned(), syntax: vec![], regions: vec![Region { - id: 1, + alignment_id: 1, + fold_state_id: 1, range: SourceRange { start: pos(0, 0), end: pos(3, 0), @@ -475,17 +485,17 @@ mod tests { #[test] fn file_record_serializes_to_the_documented_shape() { let region = |changed: serde_json::Value| { - let mut middle = json!({"id": 3, "kind": "leaf", "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}}); + let mut middle = json!({"alignment_id": 3, "fold_state_id": 3, "kind": "leaf", "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}}); if let Some(spans) = changed.as_array().filter(|spans| !spans.is_empty()) { middle["changed"] = json!(spans); } json!({ - "id": 1, "kind": "fold", "tags": ["body"], + "alignment_id": 1, "fold_state_id": 1, "kind": "fold", "tags": ["body"], "start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0}, "children": [ - {"id": 2, "kind": "leaf", "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, + {"alignment_id": 2, "fold_state_id": 2, "kind": "leaf", "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, middle, - {"id": 4, "kind": "leaf", "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}}, + {"alignment_id": 4, "fold_state_id": 4, "kind": "leaf", "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}}, ], }) }; diff --git a/src/protocol/project.rs b/src/protocol/project.rs index c68c9c773..9a402198c 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -250,10 +250,15 @@ fn regions( .into_iter() .filter(|fold| !inside_gap(fold.lines, &rhs_gaps)) .collect(); - pair_folds(&mut lhs_folds, &mut rhs_folds, &rows); let lhs_splits = split_lines(&lhs_folds); let rhs_splits = split_lines(&rhs_folds); let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); + pair_folds( + &mut lhs_folds, + &mut rhs_folds, + &rows, + (&lhs_leaves, &rhs_leaves), + ); let mut ids = Ids::default(); let lhs = tree( @@ -292,20 +297,20 @@ pub(crate) fn tree_violation(regions: &[Region]) -> Option { if start < pos(parent.range.start) || end > pos(parent.range.end) { return Some(format!( "region {} outside its parent {}", - region.id, parent.id + region.alignment_id, parent.alignment_id )); } } if previous_end.is_some_and(|previous| start < previous) { return Some(format!( "region {} overlaps its previous sibling", - region.id + region.alignment_id )); } previous_end = Some(end); if let Node::Fold { children } = ®ion.node { if children.is_empty() { - return Some(format!("fold {} has no children", region.id)); + return Some(format!("fold {} has no children", region.alignment_id)); } if let Some(problem) = walk(children, Some(region)) { return Some(problem); @@ -522,10 +527,18 @@ fn nested_spans(spans: &[(usize, usize)]) -> Vec> { /// pairs rows through matched tokens, the text diff through its line /// alignment. Folds sharing a header line on one side, such as a body and /// a collection opened on the same line, pair in order of span length. +/// Assign fold correspondence. Rule 1: header lines aligned in the row +/// table and equal tags. Rule 2, for a lhs fold rule 1 left unpaired: the +/// rhs fold with equal tags that contains the counterpart of a paired leaf +/// inside it, choosing the candidate holding the most paired lines (ties +/// by the nearest header in row order), provided that fold is free and the +/// pair does not cross an existing pair. This is what keeps a function +/// whose signature was reflowed onto more lines paired with itself. fn pair_folds( lhs_folds: &mut [SideFold<'_>], rhs_folds: &mut [SideFold<'_>], rows: &[(Option, Option)], + (lhs_leaves, rhs_leaves): (&[Leaf], &[Leaf]), ) { let aligned: DftHashMap = rows .iter() @@ -562,6 +575,85 @@ fn pair_folds( rhs_folds[rhs_index].pair = Some(lhs_index); } } + pair_folds_by_content(lhs_folds, rhs_folds, rows, lhs_leaves, rhs_leaves); +} + +/// Rule 2 of `pair_folds`: pair through a shared leaf rather than the +/// header line. Paired leaves carry the same key on both sides. +fn pair_folds_by_content( + lhs_folds: &mut [SideFold<'_>], + rhs_folds: &mut [SideFold<'_>], + rows: &[(Option, Option)], + lhs_leaves: &[Leaf], + rhs_leaves: &[Leaf], +) { + // Row index of each side's line, for crossing checks and tie-breaks. + let mut row_of_lhs: DftHashMap = DftHashMap::default(); + let mut row_of_rhs: DftHashMap = DftHashMap::default(); + for (index, &(lhs, rhs)) in rows.iter().enumerate() { + if let Some(line) = lhs { + row_of_lhs.entry(line).or_insert(index); + } + if let Some(line) = rhs { + row_of_rhs.entry(line).or_insert(index); + } + } + let rhs_leaf_by_key: DftHashMap = rhs_leaves + .iter() + .filter_map(|leaf| leaf.key.map(|key| (key, leaf))) + .collect(); + let contains = |fold: &SideFold<'_>, leaf: &Leaf| { + fold.lines.0 <= leaf.lines.0 && leaf.lines.1 <= fold.lines.1 + }; + // Existing pairs as (lhs header row, rhs header row), to forbid crossings. + let mut pairs: Vec<(usize, usize)> = rhs_folds + .iter() + .filter_map(|rhs| { + let lhs = &lhs_folds[rhs.pair?]; + Some((row_of_lhs[&lhs.lines.0], row_of_rhs[&rhs.lines.0])) + }) + .collect(); + #[allow(clippy::needless_range_loop)] + for lhs_index in 0..lhs_folds.len() { + if lhs_folds[lhs_index].pair.is_some() { + continue; + } + let lhs_fold = &lhs_folds[lhs_index]; + // Paired lines per candidate rhs fold. + let mut score: DftHashMap = DftHashMap::default(); + for leaf in lhs_leaves.iter().filter(|leaf| contains(lhs_fold, leaf)) { + let Some(partner) = leaf.key.and_then(|key| rhs_leaf_by_key.get(&key)) else { + continue; + }; + for (rhs_index, rhs_fold) in rhs_folds.iter().enumerate() { + if rhs_fold.pair.is_none() + && rhs_fold.fold.tags == lhs_fold.fold.tags + && contains(rhs_fold, partner) + { + *score.entry(rhs_index).or_default() += partner.lines.1 - partner.lines.0; + } + } + } + let lhs_row = row_of_lhs[&lhs_fold.lines.0]; + let mut candidates: Vec<(usize, usize)> = score.into_iter().collect(); + candidates.sort_by_key(|&(rhs_index, lines)| { + let rhs_row = row_of_rhs[&rhs_folds[rhs_index].lines.0]; + ( + std::cmp::Reverse(lines), + rhs_row.abs_diff(lhs_row), + rhs_index, + ) + }); + let Some(&(rhs_index, _)) = candidates.iter().find(|&&(rhs_index, _)| { + let rhs_row = row_of_rhs[&rhs_folds[rhs_index].lines.0]; + !pairs.iter().any(|&(a, b)| (a < lhs_row) != (b < rhs_row)) + }) else { + continue; + }; + lhs_folds[lhs_index].pair = Some(lhs_index); + rhs_folds[rhs_index].pair = Some(lhs_index); + pairs.push((lhs_row, row_of_rhs[&rhs_folds[rhs_index].lines.0])); + } } fn split_lines(folds: &[SideFold<'_>]) -> BTreeSet { @@ -728,7 +820,8 @@ fn tree( end: last.range.end, }; let region = Region { - id: open.id, + alignment_id: open.id, + fold_state_id: open.id, range, tags: fold.tags.clone(), visibility: Visibility { @@ -841,8 +934,10 @@ fn leaf_region( } else { (Vec::new(), Visibility::default()) }; + let id = ids.get(leaf.key.map(PairKey::Leaf)); Region { - id: ids.get(leaf.key.map(PairKey::Leaf)), + alignment_id: id, + fold_state_id: id, range: SourceRange { start: SourcePos { line: start as u32, @@ -1008,7 +1103,7 @@ mod tests { all(&source.regions) .into_iter() .filter(|r| matches!(r.node, Node::Fold { .. })) - .map(|r| (r.range.start.line, r.id)) + .map(|r| (r.range.start.line, r.alignment_id)) .collect() } @@ -1122,6 +1217,57 @@ mod tests { } } + #[test] + fn a_reflowed_signature_pairs_through_a_paired_inner_leaf() { + // The signature moves onto three lines, so the header rows no + // longer align, but the body is untouched: still the same function. + let lhs = + "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; + let rhs = "fn f(\n a: u32,\n b: u32,\n) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; + let diff = project("a.rs", lhs, rhs, 3); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + assert_eq!(lhs_folds.len(), 1); + assert_eq!(rhs_folds.len(), 1); + assert_eq!( + lhs_folds.values().next(), + rhs_folds.values().next(), + "the reflowed function keeps one alignment id" + ); + } + + #[test] + fn content_pairing_picks_the_candidate_with_the_most_paired_lines() { + // Both rhs functions have reflowed signatures and both hold a line + // `f` also holds (`let x = ...`), but only `g` holds `f`'s long + // body. Order is preserved so nothing crosses. + let lhs = "fn h(b: u32) -> u32 {\n let x = b;\n b\n}\n\nfn f(a: u32) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; + let rhs = "fn h(\n b: u32,\n) -> u32 {\n let x = b;\n b\n}\n\nfn g(\n a: u32,\n) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; + let diff = project("a.rs", lhs, rhs, 3); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + // Fold headers sit on the `{` line: lhs h=0, f=5; rhs h=2, g=9. + assert_eq!(lhs_folds[&0], rhs_folds[&2], "h pairs with h"); + assert_eq!(lhs_folds[&5], rhs_folds[&9], "f pairs with g by its body"); + } + + #[test] + fn content_pairing_never_crosses_an_existing_pair() { + // `keep` pairs by header on both sides. `f` moved below `keep` on + // the rhs with a reflowed signature; pairing it would cross `keep`, + // so it stays one-sided on each side. + let lhs = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + let rhs = "fn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn f(\n a: u32,\n) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n"; + let diff = project("a.rs", lhs, rhs, 3); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + assert_eq!(lhs_folds[&6], rhs_folds[&0], "keep pairs by header"); + assert_ne!( + lhs_folds[&0], rhs_folds[&8], + "f would cross keep, so it stays unpaired" + ); + } + #[test] fn the_fallback_keeps_folds_and_enclosing_context() { let diff = project_with( @@ -1169,8 +1315,8 @@ mod tests { assert_tiles(rhs); assert_folds_hold_children(&lhs.regions); assert_folds_hold_children(&rhs.regions); - let lhs_ids: BTreeSet = all(&lhs.regions).iter().map(|r| r.id).collect(); - let rhs_ids: BTreeSet = all(&rhs.regions).iter().map(|r| r.id).collect(); + let lhs_ids: BTreeSet = all(&lhs.regions).iter().map(|r| r.alignment_id).collect(); + let rhs_ids: BTreeSet = all(&rhs.regions).iter().map(|r| r.alignment_id).collect(); // Ids are dense and assigned lhs first. let max = lhs_ids.iter().chain(&rhs_ids).max().copied().unwrap(); assert_eq!( @@ -1178,7 +1324,7 @@ mod tests { (0..=max).collect::>() ); for (lhs_leaf, rhs_leaf) in leaves(&lhs.regions).iter().zip(leaves(&rhs.regions)) { - if lhs_leaf.id == rhs_leaf.id { + if lhs_leaf.alignment_id == rhs_leaf.alignment_id { assert_eq!( lhs_leaf.range.lines_spanned().1 - lhs_leaf.range.lines_spanned().0, rhs_leaf.range.lines_spanned().1 - rhs_leaf.range.lines_spanned().0, @@ -1194,7 +1340,7 @@ mod tests { .into_iter() .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 7) .expect("the added function is a fold"); - assert!(rhs_only.contains(&&new_fold.id)); + assert!(rhs_only.contains(&&new_fold.alignment_id)); assert_eq!(new_fold.tags, vec!["body", "function"]); assert_eq!(new_fold.visibility.label, "Body"); } @@ -1216,7 +1362,7 @@ mod tests { }; let lhs_fold = fold(lhs); let rhs_fold = fold(rhs); - assert_eq!(lhs_fold.id, rhs_fold.id); + assert_eq!(lhs_fold.alignment_id, rhs_fold.alignment_id); assert_eq!(lhs_fold.range.lines_spanned(), (1, 4)); let novel: Vec<_> = leaves(&rhs.regions) .into_iter() @@ -1390,11 +1536,11 @@ mod tests { assert_folds_hold_children(&rhs.regions); let lhs_leaves: Vec<_> = leaves(&lhs.regions) .iter() - .map(|l| (l.id, l.range.lines_spanned())) + .map(|l| (l.alignment_id, l.range.lines_spanned())) .collect(); let rhs_leaves: Vec<_> = leaves(&rhs.regions) .iter() - .map(|l| (l.id, l.range.lines_spanned())) + .map(|l| (l.alignment_id, l.range.lines_spanned())) .collect(); assert_eq!(lhs_leaves, rhs_leaves); } @@ -1442,7 +1588,10 @@ mod tests { let lhs = lhs.unwrap(); assert_eq!(leaves(&lhs.regions).len(), 1); assert!(leaves(&lhs.regions)[0].visibility.collapsed); - assert_eq!(lhs.regions[0].id, rhs.unwrap().regions[0].id); + assert_eq!( + lhs.regions[0].alignment_id, + rhs.unwrap().regions[0].alignment_id + ); } #[test] diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index ee924741d..13f9ad3f3 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -350,7 +350,7 @@ pub(crate) fn write_file( pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { fn ids(regions: &[Region], out: &mut DftHashSet) { for region in regions { - out.insert(region.id); + out.insert(region.alignment_id); if let Node::Fold { children } = ®ion.node { ids(children, out); } @@ -365,7 +365,7 @@ pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { if hidden { continue; } - if other.contains(®ion.id) { + if other.contains(®ion.alignment_id) { let lines: DftHashSet = changed.iter().map(|span| span.line).collect(); total += lines.len() as u32; } else { @@ -406,7 +406,8 @@ mod visible_tests { fn leaf(id: u32, lines: (u32, u32), changed: &[u32], collapsed: bool) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: pos(lines.0), end: pos(lines.1), @@ -431,7 +432,8 @@ mod visible_tests { fn fold(id: u32, lines: (u32, u32), collapsed: bool, children: Vec) -> Region { Region { - id, + alignment_id: id, + fold_state_id: id, range: SourceRange { start: pos(lines.0), end: pos(lines.1), diff --git a/tests/hooks/rpc_server.py b/tests/hooks/rpc_server.py index a5a72ab07..0a244ab18 100755 --- a/tests/hooks/rpc_server.py +++ b/tests/hooks/rpc_server.py @@ -40,7 +40,9 @@ def do_POST(self): else: if MODE == "echo": texts = { - str(f["id"]): "pseudo " + f["placeholder"] for f in params["folds"] + # `id` is the region's alignment_id on the after side. + str(f["id"]): "pseudo " + f["placeholder"] + for f in params["folds"] } elif MODE == "first": texts = {str(params["folds"][0]["id"]): "summary of f"} diff --git a/tests/streaming/check.py b/tests/streaming/check.py index d6672dada..3cfea4ba1 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -65,7 +65,7 @@ def stream(repo, *args, code=0): env=ENV, ) as process: first = json.loads(process.stdout.readline()) - assert first["type"] == "start" and first["version"] == 2, first + assert first["type"] == "start" and first["version"] == 3, first events = [first, *[json.loads(line) for line in process.stdout]] for event in events: diff = event.get("diff") if event.get("type") == "file" else None @@ -133,6 +133,10 @@ def walk(regions, parent): walk(region["children"], region) walk(source["regions"], None) + # Two identities per region: alignment pairs across sides, fold state + # groups toggles. Until a mutation bundles regions, they coincide. + for region in all_regions(source["regions"]): + assert region["fold_state_id"] == region["alignment_id"], region def check_tiling(source): @@ -213,7 +217,9 @@ def check_tiling(source): same = records["renamed.py"]["diff"] only = list(leaves(same["lhs"]["regions"])) assert only[0]["visibility"] == {"collapsed": True, "label": "1 unchanged line"} - assert only[0]["id"] == next(leaves(same["rhs"]["regions"]))["id"] + assert ( + only[0]["alignment_id"] == next(leaves(same["rhs"]["regions"]))["alignment_id"] + ) # Syntax spans are opt-in and carry tree-sitter capture names. events = stream(repo, base, head, "--syntax", "--", "a.rs") syntax = events[1]["diff"]["rhs"]["syntax"] From 46e586c85cf89439a5f6cf923ab577352951b1f3 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 17:43:44 -0700 Subject: [PATCH 29/43] Read alignment_id and fold_state_id in the terminal frontend The row zip pairs leaves by alignment_id; collapse state, fold headers, nested and gap commands, and visible counts key on fold_state_id, so regions sharing it toggle together on both sides by construction. Accepts wire version 3; the fixture is regenerated. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 8 ++-- tui/packages/hunk/src/diffr/counts.ts | 2 +- tui/packages/hunk/src/diffr/fixture.ts | 10 ++--- tui/packages/hunk/src/diffr/regions.test.ts | 4 +- tui/packages/hunk/src/diffr/regions.ts | 43 ++++++++++++--------- tui/packages/hunk/src/diffr/rows.ts | 10 ++--- tui/packages/hunk/src/diffr/stream.test.ts | 6 +-- tui/packages/hunk/src/diffr/wire.ts | 10 +++-- tui/packages/hunk/src/ui/App.test.tsx | 8 ++-- tui/test/fixtures/comparison.ndjson | 4 +- 10 files changed, 58 insertions(+), 47 deletions(-) diff --git a/tui/README.md b/tui/README.md index d1987b0c1..833ad611e 100644 --- a/tui/README.md +++ b/tui/README.md @@ -104,7 +104,7 @@ list; Esc returns without saving. Both views carry a footer hint line. header is the top row: `za` toggle, `zo` open, `zc` close, with `zA` / `zO` / `zC` recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or unfold every fold; `zj` / `zk` scroll to the next or previous fold header. The header and closing - delimiter stay visible; regions that share an id collapse on both sides. A fold on one + delimiter stay visible; regions that share a `fold_state_id` collapse together, on both sides. A fold on one side only blanks that side's cells, keeping the other side's lines in Rust's alignment. - Context gaps are folds too: an unchanged run diffr trimmed to N lines around changes arrives as a collapsed leaf tagged `unchanged`, and renders as one fold row with its @@ -124,7 +124,7 @@ list; Esc returns without saving. Both views carry a footer hint line. Rust CLI -- implicit interactive output --> Bun frontend Bun frontend -- same comparison arguments + --format ndjson --syntax --> Rust subprocess Rust stdout --> validated events --> file store -per-side text + syntax spans + region trees --> leaves zipped by id --> split/unified rows +per-side text + syntax spans + region trees --> leaves zipped by alignment_id --> split/unified rows rows + width + wrapping --> measured row bounds row bounds + viewport --> mounted OpenTUI rows mouse/keyboard --> viewer state (collapsed ids, closed files) --> updated projection @@ -135,13 +135,13 @@ mouse/keyboard --> viewer state (collapsed ids, closed files) --> updated projec - `src/protocol.rs` is the wire contract: a `start` manifest, one `file` record per file with `diff` or `error`, and a `complete` footer. Sides are `lhs`/`rhs` by presence. Each text side carries its full text, optional `syntax` spans, and a `regions` tree - whose leaves tile the file; the same region `id` on both sides means correspondence. + whose leaves tile the file; the same `alignment_id` on both sides means correspondence; `fold_state_id` groups what toggles together. - `packages/hunk/src/diffr/wire.ts` validates those shapes with Zod and fills omitted defaults. Columns remain zero-based UTF-8 byte offsets. - `diffr/regions.ts` flattens each side's tree into leaves and folds, computes which lines a collapsed fold hides (a trailing line hides only when nothing follows the range on it), and seeds the default collapsed set from `visibility`. Collapsed ids live in - viewer state, keyed by region id, so paired regions toggle together. + viewer state, keyed by `fold_state_id`, so regions sharing it (paired ones included) toggle together. - `diffr/stream.ts` validates event ordering, versions and completion counts, handles arbitrary chunk boundaries, and rejects truncated streams. - `diffr/store.ts` holds completed files and errors. The UI can display files while diff --git a/tui/packages/hunk/src/diffr/counts.ts b/tui/packages/hunk/src/diffr/counts.ts index def2ea350..2385ec393 100644 --- a/tui/packages/hunk/src/diffr/counts.ts +++ b/tui/packages/hunk/src/diffr/counts.ts @@ -13,7 +13,7 @@ function shownLines(file: DiffFile & { diff: { type: "text" } }, collapsed: Read for (const side of [0, 1] as const) { const hidden = hiddenLines(folds[side], collapsed); for (const leaf of leaves[side]) { - if (!novel.has(leaf) || collapsed.has(leaf.id)) continue; + if (!novel.has(leaf) || collapsed.has(leaf.foldStateId)) continue; for (let line = leaf.startLine; line < leaf.endLine; line++) if (!hidden.has(line)) counts[side]++; } } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index c1018c408..4198a58f5 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -1,9 +1,9 @@ -/** Provide a hand-authored wire v2 file record for renderer and protocol tests. */ +/** Provide a hand-authored wire v3 file record for renderer and protocol tests. */ import type { DiffFile, Region, Span } from "./wire"; const pos = (line: number, column = 0) => ({ line, column }); export function leaf(id: number, start: number, end: number, changed: Span[] = []): Region { - return { id, start: pos(start), end: pos(end), tags: [], visibility: { collapsed: false, label: "" }, - kind: "leaf", changed, children: [] }; + return { alignment_id: id, fold_state_id: id, start: pos(start), end: pos(end), tags: [], + visibility: { collapsed: false, label: "" }, kind: "leaf", changed, children: [] }; } export function fold( id: number, @@ -14,8 +14,8 @@ export function fold( tags = ["body"], collapsed = false, ): Region { - return { id, start: pos(...start), end: pos(...end), tags, visibility: { collapsed, label }, - kind: "fold", changed: [], children }; + return { alignment_id: id, fold_state_id: id, start: pos(...start), end: pos(...end), tags, + visibility: { collapsed, label }, kind: "fold", changed: [], children }; } export const line = (line: number, start_column: number, end_column: number): Span => ({ line, start_column, end_column }); diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 977d1fbbf..e9bdd6200 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -37,9 +37,9 @@ test("folds keep headers visible, hide trailing lines only when nothing follows" const file = createFoldedDiffFile(); if (file.diff.type !== "text") throw new Error(); const { folds, leaves } = flatten(file.diff); - expect(folds[1].map((f) => [f.id, f.headerLine, f.lastHidden])).toEqual([[10, 0, 6], [11, 1, 3], [12, 5, 6]]); + expect(folds[1].map((f) => [f.foldStateId, f.headerLine, f.lastHidden])).toEqual([[10, 0, 6], [11, 1, 3], [12, 5, 6]]); expect(folds[1][0].nested).toEqual([11, 12]); - expect(leaves[1].map((l) => [l.id, l.startLine, l.endLine])).toEqual([[1, 0, 1], [2, 1, 3], [3, 3, 4], [4, 4, 5], [5, 5, 7], [6, 7, 8]]); + expect(leaves[1].map((l) => [l.foldStateId, l.startLine, l.endLine])).toEqual([[1, 0, 1], [2, 1, 3], [3, 3, 4], [4, 4, 5], [5, 5, 7], [6, 7, 8]]); expect([...hiddenLines(folds[1], new Set([11, 12]))].sort()).toEqual([2, 3, 6]); // A fold hidden inside a collapsed outer fold hides nothing of its own. expect([...hiddenLines(folds[1], new Set([10, 11]))].sort()).toEqual([1, 2, 3, 4, 5, 6]); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index 52cd10e23..0ef840608 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -1,9 +1,12 @@ /** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ import type { Region, Source, Span, TextDiff } from "./wire"; export type Side = 0 | 1; -/** A leaf tiles its side; the same id on the other side is its counterpart. */ +/** A leaf tiles its side; the same alignmentId on the other side is its counterpart. */ export interface Leaf { - id: number; + /** Pairs this leaf with its counterpart across sides; the row zip keys on it. */ + alignmentId: number; + /** Regions sharing it toggle together; collapse state keys on it. */ + foldStateId: number; side: Side; startLine: number; /** Exclusive. */ @@ -14,7 +17,8 @@ export interface Leaf { label: string; } export interface Fold { - id: number; + alignmentId: number; + foldStateId: number; side: Side; /** The header line, always visible, carrying the chevron and the label. */ headerLine: number; @@ -27,6 +31,7 @@ export interface Fold { nested: number[]; } export interface RowFold { + /** The fold-state id: what toggling this header toggles. */ id: number; label: string; collapsed: boolean; @@ -47,7 +52,8 @@ export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds const [startLine, endLine] = leafLines(region); const changed = new Map(); for (const span of region.changed) changed.set(span.line, [...(changed.get(span.line) ?? []), span]); - leaves.push({ id: region.id, side, startLine, endLine, changed, tags: region.tags, + leaves.push({ alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, startLine, endLine, + changed, tags: region.tags, collapsed: region.visibility.collapsed, label: region.visibility.label }); return; } @@ -57,10 +63,11 @@ export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds // An end at column 0 does not touch its line; otherwise the end line is hidden when the // range covers all of its text. const hideEnd = end.column > 0 && encoder.encode(endText.trimEnd()).length <= end.column; - const fold: Fold = { id: region.id, side, headerLine: start.line, + const fold: Fold = { alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, + headerLine: start.line, lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, collapsed: region.visibility.collapsed, nested: [] }; - for (const ancestor of ancestors) ancestor.nested.push(fold.id); + for (const ancestor of ancestors) ancestor.nested.push(fold.foldStateId); folds.push(fold); for (const child of region.children) visit(child, [...ancestors, fold]); }; @@ -74,35 +81,35 @@ export function flatten(diff: TextDiff) { } /** A leaf is novel when it carries change spans or has no counterpart on the other side. */ export function novelLeaves(leaves: readonly [Leaf[], Leaf[]]): Set { - const ids = [new Set(leaves[0].map((l) => l.id)), new Set(leaves[1].map((l) => l.id))]; + const ids = [new Set(leaves[0].map((l) => l.alignmentId)), new Set(leaves[1].map((l) => l.alignmentId))]; const novel = new Set(); for (const side of [0, 1] as const) for (const leaf of leaves[side]) - if (leaf.changed.size > 0 || !ids[side ? 0 : 1].has(leaf.id)) novel.add(leaf); + if (leaf.changed.size > 0 || !ids[side ? 0 : 1].has(leaf.alignmentId)) novel.add(leaf); return novel; } -/** Ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ +/** Fold-state ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ export function defaultCollapsed(diff: TextDiff): Set { const ids = new Set(); const { leaves, folds } = flatten(diff); - for (const item of [...leaves.flat(), ...folds.flat()]) if (item.collapsed) ids.add(item.id); + for (const item of [...leaves.flat(), ...folds.flat()]) if (item.collapsed) ids.add(item.foldStateId); return ids; } /** Every collapsible id on either side, folds and foldable leaves alike, for fold-all commands. */ export function foldIds(diff: TextDiff): number[] { const { folds, leaves } = flatten(diff); - return [...new Set([...folds.flat().map((fold) => fold.id), ...leaves.flat().filter(foldableLeaf).map((l) => l.id)])]; + return [...new Set([...folds.flat().map((fold) => fold.foldStateId), ...leaves.flat().filter(foldableLeaf).map((l) => l.foldStateId)])]; } /** Ids nested inside a collapsible region, for recursive fold commands. */ export function nestedIds(diff: TextDiff, id: number): number[] { - const fold = flatten(diff).folds.flat().find((f) => f.id === id); + const fold = flatten(diff).folds.flat().find((f) => f.foldStateId === id); if (fold) return fold.nested; - if (!flatten(diff).leaves.flat().some((leaf) => leaf.id === id)) throw new Error(`Unknown region ${id}`); + if (!flatten(diff).leaves.flat().some((leaf) => leaf.foldStateId === id)) throw new Error(`Unknown region ${id}`); return []; } /** Context-gap leaves: unchanged runs diffr trimmed to N lines around changes. */ export function gapIds(diff: TextDiff): number[] { - return [...new Set(flatten(diff).leaves.flat().filter((leaf) => leaf.tags.includes("unchanged")).map((l) => l.id))]; + return [...new Set(flatten(diff).leaves.flat().filter((leaf) => leaf.tags.includes("unchanged")).map((l) => l.foldStateId))]; } /** Source lines hidden on one side by collapsed folds whose header is visible. */ export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set { @@ -110,7 +117,7 @@ export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set< // Outer folds first: a fold whose header is already hidden hides nothing of its own. const ordered = [...folds].sort((a, b) => a.headerLine - b.headerLine || b.lastHidden - a.lastHidden); for (const fold of ordered) { - if (!collapsed.has(fold.id) || hidden.has(fold.headerLine)) continue; + if (!collapsed.has(fold.foldStateId) || hidden.has(fold.headerLine)) continue; for (let line = fold.headerLine + 1; line <= fold.lastHidden; line++) hidden.add(line); } return hidden; @@ -124,12 +131,12 @@ export function foldHeaders(folds: Fold[], leaves: Leaf[], collapsed: ReadonlySe const headers = new Map(); for (const leaf of leaves) if (foldableLeaf(leaf)) - headers.set(leaf.startLine, { id: leaf.id, label: leafLabel(leaf), collapsed: collapsed.has(leaf.id) }); + headers.set(leaf.startLine, { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: collapsed.has(leaf.foldStateId) }); for (const fold of folds) { const existing = headers.get(fold.headerLine); - const existingFold = existing && folds.find((f) => f.id === existing.id); + const existingFold = existing && folds.find((f) => f.foldStateId === existing.id); if (existingFold && existingFold.lastHidden >= fold.lastHidden) continue; - headers.set(fold.headerLine, { id: fold.id, label: fold.label, collapsed: collapsed.has(fold.id) }); + headers.set(fold.headerLine, { id: fold.foldStateId, label: fold.label, collapsed: collapsed.has(fold.foldStateId) }); } return headers; } diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 7f6300fc8..6d309a9f3 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -137,9 +137,9 @@ export function rowsForFile( flush(); const anchor = (left ?? right)!; const folded = (leaf: Leaf | null): SplitLineCell => leaf - ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.id, label: leafLabel(leaf), collapsed: true } } + ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: true } } : { kind: "empty", sign: " ", spans: [] }; - const key = `${fileIndex}:gap:${anchor.id}`; + const key = `${fileIndex}:gap:${anchor.foldStateId}`; if (layout === "split") rows.push({ key, fileIndex, left: folded(left), right: folded(right) }); else rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", spans: [], fold: folded(anchor).fold } }); }; @@ -170,7 +170,7 @@ export function rowsForFile( const leafRows = (left: Leaf | null, right: Leaf | null) => { const anchor = left ?? right; if (!anchor) return; - if (collapsed.has(anchor.id)) { + if (collapsed.has(anchor.foldStateId)) { if (!hidden[anchor.side].has(anchor.startLine)) collapsedLeaf(left, right); return; } @@ -180,13 +180,13 @@ export function rowsForFile( right && i < right.endLine - right.startLine ? right.startLine + i : null, left, right); }; // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. - const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.id, index])); + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); let cursor = 0; const flushRight = (until: number) => { for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); }; for (const left of leaves[0]) { - const partner = rightIndex.get(left.id); + const partner = rightIndex.get(left.alignmentId); if (partner === undefined || partner < cursor) { // Unpaired, or a move whose partner was already shown: one-sided rows. leafRows(left, null); diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index 798836d7a..bfa526f8a 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -6,7 +6,7 @@ const manifest = (file: ReturnType): FileChange => ({ file: file.file, status: "modified", visibility: { collapsed: false, label: "" } }); const start = { type: "start", - version: 2, + version: 3, lhs: { type: "index" }, rhs: { type: "working_tree" }, files: [manifest(createTestDiffFile())], @@ -30,8 +30,8 @@ test("decode byte-fragmented Unicode stream and preserve region trees", async () test("omitted defaults are filled in", async () => { const file = createTestDiffFile(); const bare = { type: "file", file: file.file, diff: { type: "text", - lhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, - rhs: { text: "a\n", regions: [{ id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + lhs: { text: "a\n", regions: [{ alignment_id: 1, fold_state_id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + rhs: { text: "a\n", regions: [{ alignment_id: 1, fold_state_id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } } } }; const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, { type: "complete", succeeded: 1, failed: 0 }]); diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index e2cc9e893..dca692c30 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -34,7 +34,10 @@ const sourcePos = z.object({ line: uint, column: uint }); const span = z.object({ line: uint, start_column: uint, end_column: uint }); const syntaxSpan = span.extend({ capture: z.string() }); export interface Region { - id: number; + /** Same value on the other side: the visual counterpart, one-to-one. Keys the row zip. */ + alignment_id: number; + /** Regions sharing it open and close together, on either side. Keys collapse state. */ + fold_state_id: number; start: { line: number; column: number }; end: { line: number; column: number }; tags: string[]; @@ -44,7 +47,8 @@ export interface Region { children: Region[]; } const regionBase = z.object({ - id: uint, + alignment_id: uint, + fold_state_id: uint, start: sourcePos, end: sourcePos, tags: z.array(z.string()).default([]), @@ -90,7 +94,7 @@ const fileEvent = z export const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("start"), - version: z.literal(2), + version: z.literal(3), lhs: snapshot, rhs: snapshot, files: z.array(fileChange), diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index fc4f51ee8..7d4ea54d8 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -179,7 +179,7 @@ test("initial manifest renders pending tree and remembers a jump until its diff at(a, "src/a.ts"); at(b, "src/b.ts"); const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); - store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); const t = await testRender( {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); @@ -206,7 +206,7 @@ test("streaming diffs follow tree order without moving the visible source row", return file; }); const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); - store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); const t = await testRender( {}} themes={themes} />, {width:150, height:20}); const sidebarLines = () => t.captureCharFrame().split("\n").slice(2,9).map(line => line.slice(0,27).trim()); try { @@ -245,7 +245,7 @@ test(`stream arrivals preserve code in every commit (wrap=${wrap}, unified=${uni return file; }); const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); - store.accept({type:"start", version:2, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); store.accept(files[0]); let capture: (() => void) | undefined; const commits: string[][] = []; @@ -348,7 +348,7 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a test("the summary strip shows visible totals that follow fold state, and i opens the breakdown", async () => { const store = new DiffStore(); - store.accept({type:"start", version:2, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, + store.accept({type:"start", version:3, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, files:[{ file: createFoldedDiffFile().file, status: "modified", visibility: { collapsed: false, label: "" } }]}); store.accept(createFoldedDiffFile()); const t = await testRender( {}} themes={themes} />, { width: 150, height: 24 }); diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index b195f7590..94dde99fe 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"type":"start","version":2,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} -{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"start":{"line":0,"column":0},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} +{"type":"start","version":3,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} +{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"alignment_id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"alignment_id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"alignment_id":2,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"alignment_id":3,"fold_state_id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"alignment_id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"alignment_id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"alignment_id":4,"fold_state_id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"alignment_id":2,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"alignment_id":3,"fold_state_id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} From a5908a9b748f6ee45894dcbc5a2677fd353166c5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 18:16:14 -0700 Subject: [PATCH 30/43] Hide deleted files by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file that was deleted outright starts collapsed behind a placeholder labelled "Deleted file · hidden by default", whatever its category. folds.collapse_deleted_files turns it off. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 3 ++- src/config.rs | 3 +++ src/mutate/collapse.rs | 40 +++++++++++++++++++++++++++++++++++----- src/mutate/mod.rs | 3 ++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index 17fcd8849..ada4c2a2b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -43,6 +43,7 @@ collapse_test_bodies = true # test bodies start collapsed on both sides, header collapse_removed_lines = 5 # removed stretches this long, in unpaired code, collapse in the middle; 0 disables collapse_generated = true # generated files start hidden collapse_tests = true # test files start hidden +collapse_deleted_files = true # deleted files start hidden context_lines = 3 # unchanged lines kept around a change; -U overrides [summarize] @@ -115,7 +116,7 @@ After each file is diffed and projected onto the wire, mutations adjust what starts collapsed and what the collapsed label says. They never touch the diff itself. In order: -1. `collapse_generated`, `collapse_tests`: the file's `visibility` in the +1. `collapse_deleted_files`, `collapse_generated`, `collapse_tests`: the file's `visibility` in the manifest, before `start` is written. 2. `collapse_deleted`: deleted function bodies (folds tagged `function`) of at least `min_lines` lines, labelled `" lines removed"`. A body is diff --git a/src/config.rs b/src/config.rs index 4fbf47431..1947585b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -101,6 +101,8 @@ pub(crate) struct FoldsConfig { /// collapse in the middle, keeping their first and last line visible. /// `0` disables it. pub(crate) collapse_removed_lines: usize, + /// Hide files that were deleted outright. + pub(crate) collapse_deleted_files: bool, /// Hide files classified as generated, such as lockfiles and build output. pub(crate) collapse_generated: bool, /// Hide files classified as tests. @@ -120,6 +122,7 @@ impl Default for FoldsConfig { min_lines: 12, collapse_deleted: true, collapse_removed_lines: 5, + collapse_deleted_files: true, collapse_generated: true, collapse_tests: true, collapse_test_bodies: true, diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs index 665a0219d..1e8aa4cf8 100644 --- a/src/mutate/collapse.rs +++ b/src/mutate/collapse.rs @@ -5,20 +5,25 @@ use super::{collapse, ids, is_fold, line_count, one_sided, walk_mut, FileMutatio use crate::category; use crate::hash::DftHashSet; use crate::protocol::{ - FileChange, Node, Pairing, Problem, Region, Source, SourcePos, SourceRange, Visibility, + FileChange, FileStatus, Node, Pairing, Problem, Region, Source, SourcePos, SourceRange, + Visibility, }; -/// Generated and test files start hidden behind a placeholder. +/// Deleted, generated and test files start hidden behind a placeholder. pub(crate) struct HiddenCategories { + pub(crate) deleted: bool, pub(crate) generated: bool, pub(crate) tests: bool, } impl FileMutation for HiddenCategories { fn apply(&self, file: &mut FileChange) -> Result<(), Problem> { - let label = match file.category.as_deref() { - Some(category::GENERATED) if self.generated => "Generated file · hidden by default", - Some(category::TEST) if self.tests => "Test file · hidden by default", + let label = match (file.status, file.category.as_deref()) { + (FileStatus::Deleted, _) if self.deleted => "Deleted file · hidden by default", + (_, Some(category::GENERATED)) if self.generated => { + "Generated file · hidden by default" + } + (_, Some(category::TEST)) if self.tests => "Test file · hidden by default", _ => return Ok(()), }; file.visibility.collapsed = true; @@ -333,6 +338,7 @@ mod tests { #[test] fn hidden_categories_follow_the_switches() { let rule = HiddenCategories { + deleted: false, generated: true, tests: false, }; @@ -348,6 +354,30 @@ mod tests { assert!(!file.visibility.collapsed); } + #[test] + fn deleted_files_are_hidden_whatever_their_category() { + let rule = HiddenCategories { + deleted: true, + generated: true, + tests: true, + }; + let mut file = manifest(Some("test")); + file.status = FileStatus::Deleted; + rule.apply(&mut file).unwrap(); + assert!(file.visibility.collapsed); + assert_eq!(file.visibility.label, "Deleted file · hidden by default"); + + let off = HiddenCategories { + deleted: false, + generated: false, + tests: false, + }; + let mut file = manifest(None); + file.status = FileStatus::Deleted; + off.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + } + use crate::protocol::Span; fn removed_leaf(id: u32, start: u32, end: u32, changed: &[u32]) -> Region { diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index 45bb5a8c3..b6b1bb90d 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -37,8 +37,9 @@ impl Mutations { pub(crate) fn from_params(params: &Params, workspace: &Path) -> crate::git::Result { let mut mutations = Self::default(); let folds = ¶ms.folds; - if folds.collapse_generated || folds.collapse_tests { + if folds.collapse_deleted_files || folds.collapse_generated || folds.collapse_tests { mutations.file.push(Box::new(collapse::HiddenCategories { + deleted: folds.collapse_deleted_files, generated: folds.collapse_generated, tests: folds.collapse_tests, })); From d552717f1d0892a938fa08b8c8fcbb24958a85a5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 20:48:16 -0700 Subject: [PATCH 31/43] Accept a fold that ends at the end of the file in the terminal frontend A fold whose hull runs to the last line ends at (line count, 0), one past the last line. The reader required that line to exist even though an end at column 0 never reads it, so any file whose last function ran to end of file stopped the whole view. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/regions.test.ts | 12 ++++++++++++ tui/packages/hunk/src/diffr/regions.ts | 15 ++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index e9bdd6200..95c76bd3f 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -116,3 +116,15 @@ test("a collapsed leaf is one fold row with the chevron, its label, and no line expect(rows[1].left!.fold).toBeUndefined(); expect(rows[2].right!.fold).toBeUndefined(); }); + +test("a fold that runs to the end of the file may end one past its last line", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = "fn a() {\n b();\n}\n"; + // Three lines; the fold's hull ends at (3, 0), just past the last line. + const regions: Region[] = [fold(10, [0, 0], [3, 0], [leaf(1, 0, 1), leaf(2, 1, 3)])]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const { folds } = flatten(file.diff); + expect(folds[0][0]?.lastHidden).toBe(2); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index 0ef840608..a9a0ede1d 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -58,11 +58,16 @@ export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds return; } const { start, end } = region; - const endText = lines[end.line]; - if (endText === undefined) throw new Error(`diffr fold references missing line ${end.line}`); - // An end at column 0 does not touch its line; otherwise the end line is hidden when the - // range covers all of its text. - const hideEnd = end.column > 0 && encoder.encode(endText.trimEnd()).length <= end.column; + // An end at column 0 does not touch its line, so it may sit one past the last line of the + // file. Otherwise the end line must exist, and it is hidden when the range covers its text. + let hideEnd = false; + if (end.column > 0) { + const endText = lines[end.line]; + if (endText === undefined) throw new Error(`diffr fold references missing line ${end.line}`); + hideEnd = encoder.encode(endText.trimEnd()).length <= end.column; + } else if (end.line > lines.length) { + throw new Error(`diffr fold references missing line ${end.line}`); + } const fold: Fold = { alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, headerLine: start.line, lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, From 7770246cd8eac77c3d03dfac99f442c36bc93827 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 21:50:01 -0700 Subject: [PATCH 32/43] Show diffr's change counts verbatim in the terminal frontend File headers, the summary strip and the breakdown read stats.visible and stats.textual from the wire and nothing else. Folding changes what is on screen, never the numbers, so the local recount on every toggle is gone. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/counts.test.ts | 23 +--------------- tui/packages/hunk/src/diffr/counts.ts | 31 +++------------------- tui/packages/hunk/src/diffr/fileTree.ts | 2 +- tui/packages/hunk/src/ui/App.test.tsx | 15 ++++++----- tui/packages/hunk/src/ui/App.tsx | 18 +++++-------- 5 files changed, 20 insertions(+), 69 deletions(-) diff --git a/tui/packages/hunk/src/diffr/counts.test.ts b/tui/packages/hunk/src/diffr/counts.test.ts index 8a3580b9c..0bad94f1c 100644 --- a/tui/packages/hunk/src/diffr/counts.test.ts +++ b/tui/packages/hunk/src/diffr/counts.test.ts @@ -1,26 +1,5 @@ import { expect, test } from "bun:test"; -import { blockBar, comparisonLabel, visibleCounts } from "./counts"; -import { createTestDiffFile } from "./fixture"; -import { createFoldedDiffFile } from "./regions.test"; -test("visible counts start from the wire and move with folds, gaps, and closed files", () => { - const file = createTestDiffFile(); - expect(visibleCounts(file, new Set())).toEqual({ added: 2, removed: 1 }); - // diffr's number is the anchor: with nothing toggled it is shown verbatim. - if (file.diff.type !== "text") throw new Error(); - file.diff.stats.visible = { added: 5, removed: 4 }; - expect(visibleCounts(file, new Set())).toEqual({ added: 5, removed: 4 }); - expect(visibleCounts(file, new Set([2]))).toEqual({ added: 4, removed: 3 }); - file.diff.stats.visible = { added: 2, removed: 1 }; - expect(visibleCounts(file, new Set(), true)).toEqual({ added: 0, removed: 0 }); - // Collapsing the changed leaf itself hides its lines. - expect(visibleCounts(file, new Set([2]))).toEqual({ added: 1, removed: 0 }); - const folded = createFoldedDiffFile(); - // rhs line 3 changed inside the closure body (fold 11). - expect(visibleCounts(folded, new Set())).toEqual({ added: 1, removed: 0 }); - expect(visibleCounts(folded, new Set([11]))).toEqual({ added: 0, removed: 0 }); - expect(visibleCounts(folded, new Set([10]))).toEqual({ added: 0, removed: 0 }); - expect(visibleCounts(folded, new Set([12]))).toEqual({ added: 1, removed: 0 }); -}); +import { blockBar, comparisonLabel } from "./counts"; test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); expect(blockBar({ added: 10, removed: 0 })).toEqual(["added", "added", "added", "added", "added"]); diff --git a/tui/packages/hunk/src/diffr/counts.ts b/tui/packages/hunk/src/diffr/counts.ts index 2385ec393..8244648fe 100644 --- a/tui/packages/hunk/src/diffr/counts.ts +++ b/tui/packages/hunk/src/diffr/counts.ts @@ -1,34 +1,9 @@ -/** Changed-line counts as shown: only lines on screen, so fold and file state changes them. */ -import { defaultCollapsed, flatten, hiddenLines, novelLeaves } from "./regions"; -import type { DiffFile, LineCounts } from "./wire"; +/** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding + * changes what is on screen, never the count. */ +import type { LineCounts } from "./wire"; export type { LineCounts }; export const zero: LineCounts = { added: 0, removed: 0 }; export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); -const subtract = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added - b.added, removed: a.removed - b.removed }); -/** Changed lines on screen under a collapsed set, counted from the leaves. */ -function shownLines(file: DiffFile & { diff: { type: "text" } }, collapsed: ReadonlySet): LineCounts { - const { leaves, folds } = flatten(file.diff); - const novel = novelLeaves(leaves); - const counts = [0, 0]; - for (const side of [0, 1] as const) { - const hidden = hiddenLines(folds[side], collapsed); - for (const leaf of leaves[side]) { - if (!novel.has(leaf) || collapsed.has(leaf.foldStateId)) continue; - for (let line = leaf.startLine; line < leaf.endLine; line++) if (!hidden.has(line)) counts[side]++; - } - } - return { removed: counts[0], added: counts[1] }; -} -/** - * The headline: diffr's `stats.visible` (its default fold state) adjusted by what the user - * has folded or unfolded since. A closed file shows none. - */ -export function visibleCounts(file: DiffFile, collapsed: ReadonlySet, closed = false): LineCounts { - if (closed || file.diff.type !== "text") return zero; - const text = file as DiffFile & { diff: { type: "text" } }; - const delta = subtract(shownLines(text, collapsed), shownLines(text, defaultCollapsed(file.diff))); - return add(file.diff.stats.visible, delta); -} /** GitHub's five-block bar: whole blocks by share, any non-zero side keeps one, the rest grey. */ export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | "neutral")[] { const total = counts.added + counts.removed; diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index 03f7687f6..d16d1fc92 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -52,7 +52,7 @@ export function parentDirectories(file: Pick): string[] { const parts = filePath(file.file).split("/").filter(Boolean); return parts.slice(0, -1).map((_, i) => "/" + parts.slice(0, i + 1).join("/")); } -/** Textual counts always; structural ones when tree-sitter compared the file. */ +/** The wire's counts, verbatim: textual and visible, and why the AST match fell back if it did. */ export function lineCounts(file: DiffFile) { if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, fallback: file.diff.stats.fallback }; diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 7d4ea54d8..95e5b42c0 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -346,7 +346,7 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a } }); -test("the summary strip shows visible totals that follow fold state, and i opens the breakdown", async () => { +test("the summary strip shows the wire's visible totals, unmoved by folding, and i opens the breakdown", async () => { const store = new DiffStore(); store.accept({type:"start", version:3, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, files:[{ file: createFoldedDiffFile().file, status: "modified", visibility: { collapsed: false, label: "" } }]}); @@ -367,24 +367,25 @@ test("the summary strip shows visible totals that follow fold state, and i opens await act(async () => { store.accept({ type: "complete", succeeded: 1, failed: 0 }); }); await render(); expect(lines()[1]).not.toContain("−0…"); - // Collapse the closure body: the change is hidden, so the totals and header drop to zero. + // Collapse the closure body: the change is hidden, but the counts are diffr's and stay put. const inner = lines().findIndex((l) => l.includes("inner(|| {")); const chevronX = lines()[inner].indexOf("▾"); await act(async () => { await t.mockMouse.click(chevronX, inner); }); await render(); - expect(lines()[1]).toContain("+0 −0"); - expect(lines()[1]).toContain("□□□□□"); - expect(lines()[2]).toContain("+0 −0"); + expect(t.captureCharFrame()).not.toContain("a();"); + expect(lines()[1]).toContain("+1 −0"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); await act(async () => { t.mockInput.pressKey("i"); }); await render(); const frame = t.captureCharFrame(); expect(frame).toContain("All files"); - expect(frame).toContain("visible +0 −0"); + expect(frame).toContain("visible +1 −0"); expect(frame).toContain("textual +1 −0"); expect(frame).not.toContain("line diff"); await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); await render(); - expect(t.captureCharFrame()).not.toContain("visible +0"); + expect(t.captureCharFrame()).not.toContain("visible +1"); } finally { await act(async () => { t.renderer.destroy(); }); } diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 2b96bcc9c..54f805f10 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -30,7 +30,7 @@ import { import { fileIdentity, filePath, type DiffFile } from "../diffr/wire"; import { defaultCollapsed, foldIds, gapIds, nestedIds, type RowFold } from "../diffr/regions"; import { placeholderRows } from "../diffr/rows"; -import { add, blockBar, comparisonLabel, visibleCounts, zero, type LineCounts } from "../diffr/counts"; +import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { sliceTextByWidth } from "./lib/text"; @@ -281,16 +281,12 @@ export function App({ }, [pendingFile, loadedByIdentity, geometry, maxScroll, snapshot.failedFiles, snapshot.complete]); const treeRows = useMemo(() => flattenFileTree(tree, closedDirectories), [tree, closedDirectories]); const counts = useMemo(() => snapshot.files.map(lineCounts), [snapshot.files]); - // Headline numbers are the changed lines on screen, so every fold or file toggle moves them. - const visibleByFile = useMemo( - () => snapshot.files.map((file, index) => visibleCounts(file, foldsOf(index), isClosed(index))), - [snapshot.files, snapshot.inventory, collapsed, closed], - ); + // Headline numbers are diffr's stats.visible, verbatim: folding never changes them. const totals = useMemo(() => ({ - visible: visibleByFile.reduce(add, zero), + visible: counts.reduce((sum, c) => add(sum, c.visible), zero), textual: counts.reduce((sum, c) => add(sum, c.textual), zero), fallbacks: counts.filter((c) => c.fallback).length, - }), [visibleByFile, counts]); + }), [counts]); const plusMinus = (c: LineCounts) => `+${c.added} −${c.removed}`; useEffect(() => { const file = inventory[currentTreeFile]; @@ -308,8 +304,8 @@ export function App({ }, [currentTreeFile, treeRows, viewportHeight]); const sidebarStart = Math.min(treeScroll, Math.max(0, treeRows.length - viewportHeight)); const fileHeader = (fileIndex: number, key: string) => { - const file = snapshot.files[fileIndex], count = visibleByFile[fileIndex]; - if (!file) return null; + const file = snapshot.files[fileIndex], count = counts[fileIndex]?.visible; + if (!file || !count) return null; const path = filePath(file.file); const statsWidth = String(count.added).length + String(count.removed).length + 5; return Date: Sat, 12 Sep 2026 21:59:15 -0700 Subject: [PATCH 33/43] Render moved code as moved in the terminal frontend A region whose alignment_id counterpart the row zip has already passed is a move. Both copies now paint in the theme's moved tint with no +/-, under a label naming where the other copy starts (moved from / moved to line N); edits inside a copy keep the word tint. Clicking the label or pressing gm jumps to the other copy. g still goes home at once and remembers the view, so gm jumps from where the reader was. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/README.md | 23 ++++-- tui/packages/hunk/src/diffr/rows.test.ts | 26 +++++-- tui/packages/hunk/src/diffr/rows.ts | 74 ++++++++++++++++++- tui/packages/hunk/src/diffr/theme.ts | 11 ++- tui/packages/hunk/src/ui/App.tsx | 30 +++++++- tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 11 ++- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 12 +++ 7 files changed, 167 insertions(+), 20 deletions(-) diff --git a/tui/README.md b/tui/README.md index 833ad611e..60b9b5be2 100644 --- a/tui/README.md +++ b/tui/README.md @@ -85,13 +85,17 @@ list; Esc returns without saving. Both views carry a footer hint line. - Click folders to expand/collapse them; click files to navigate. The active file is highlighted and revealed as the diff scrolls. - A summary strip under the menubar shows the comparison (`main…HEAD`, `index…working tree`), the file count, the totals `+N −M` in the theme's diff colours, and GitHub's - five-block bar. Totals and file-header counts are the changed lines currently visible: - diffr's `stats.visible` (its default fold state) adjusted as folds, gaps, and files are - toggled, so lines hidden inside a collapsed region are not counted. While results are - still arriving the total ends in `…`. + five-block bar. Totals and file-header counts are diffr's `stats.visible`, shown verbatim + and summed over loaded files: folding changes what is on screen, never the numbers. + Files still pending add nothing, and the total ends in `…` until the stream completes. - `i` (or clicking the totals) opens a breakdown for the whole comparison and the current - file: `visible` and `textual`, plus a `line diff: ` line when tree-sitter fell - back to a line diff for that file. Esc closes it. + file, straight from the wire: `visible` and `textual`, plus a `line diff: ` line + when tree-sitter fell back to a line diff for that file. Esc closes it. +- Moved code: a region whose `alignment_id` counterpart comes earlier in reading order is a + move. Both copies render in the theme's moved tint (`diff.delta.moved`, else `diff.delta`, + else a muted blue from `ui.selection`) with no `+`/`−`, under a label row: `moved from + line N` on the right, `moved to line M` on the left. Edits inside a moved copy keep the + word tint. Click the label, or press `gm` with the copy on screen, to jump to the other copy. - A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. @@ -104,7 +108,9 @@ list; Esc returns without saving. Both views carry a footer hint line. header is the top row: `za` toggle, `zo` open, `zc` close, with `zA` / `zO` / `zC` recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or unfold every fold; `zj` / `zk` scroll to the next or previous fold header. The header and closing - delimiter stay visible; regions that share a `fold_state_id` collapse together, on both sides. A fold on one + delimiter stay visible; regions that share a `fold_state_id` collapse together, on both sides + and within one side (a docstring bundled with its function folds and unfolds with it; an + empty label shows as a bare `⋯`). A fold on one side only blanks that side's cells, keeping the other side's lines in Rust's alignment. - Context gaps are folds too: an unchanged run diffr trimmed to N lines around changes arrives as a collapsed leaf tagged `unchanged`, and renders as one fold row with its @@ -205,7 +211,8 @@ width survives hiding and reopening the tree and is clamped on terminal resize. Navigation uses Hunk's key matcher and defaults: j/k or arrows scroll lines; d/u or Ctrl-D/Ctrl-U scroll half pages; f/b, PageDown/PageUp, or Ctrl-F/Ctrl-B -scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end. +scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end; `gm` jumps +between the two copies of moved code. h/l or arrows pan horizontally. Cmd-B toggles the tree; backslash is its fallback. The first NDJSON event includes `files` in comparison order, each with its status, diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index c588979a0..a4b74bab3 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -27,15 +27,31 @@ test("split zips leaves on their ids, unified groups old before new", () => { ]); expect(rowsForFile(file, 0, "split", dark).filter((r) => r.hunkStart)).toHaveLength(1); }); -test("moved leaves render one-sided on each side", () => { +test("moved code renders as moved on both copies, labelled with where the other copy starts", () => { const file = createTestDiffFile(); if (file.diff.type !== "text") throw new Error(); - file.diff.lhs = { text: "a\nb\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 2)] }; - file.diff.rhs = { text: "b\na\n", syntax: [], regions: [leaf(2, 0, 1), leaf(1, 1, 2)] }; + // `b` moved above `a`, and its line was also edited. + file.diff.lhs = { text: "a\nb\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 2, [line(1, 0, 1)])] }; + file.diff.rhs = { text: "b2\na\n", syntax: [], regions: [leaf(2, 0, 1, [line(0, 0, 2)]), leaf(1, 1, 2)] }; const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.left); - expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ - [undefined, 1], [1, 2], [2, undefined], + const text = (c: { spans: { text: string }[] }) => c.spans.map((s) => s.text).join(""); + expect(rows.map((r) => [r.left!.moveLabel ? text(r.left!) : r.left!.lineNumber, + r.right!.moveLabel ? text(r.right!) : r.right!.lineNumber])).toEqual([ + [undefined, "moved from line 2"], [undefined, 1], [1, 2], ["moved to line 1", undefined], [2, undefined], ]); + const movedRight = rows[1].right!, movedLeft = rows[4].left!; + // No +/−, the moved tint instead of added or removed, and each copy points at the other. + expect([movedRight.kind, movedRight.sign, movedRight.moveKind, movedRight.jump]).toEqual( + ["context", " ", "moved", { side: "left", line: 2 }]); + expect([movedLeft.kind, movedLeft.sign, movedLeft.moveKind, movedLeft.jump]).toEqual( + ["context", " ", "moved", { side: "right", line: 1 }]); + expect(rows[0].right!.jump).toEqual({ side: "left", line: 2 }); + // The edit inside the moved copy still gets the strong word tint. + expect(movedRight.spans.some((s) => s.bg === dark.addWord)).toBe(true); + expect(dark.moved).not.toBe(dark.addition); + expect(dark.moved).not.toBe(dark.deletion); + // The unchanged line between the copies is plain context. + expect([rows[2].left!.moveKind, rows[2].left!.kind]).toEqual([undefined, "context"]); }); test("changed spans paint the darker word tint, distinct from the line tint", () => { for (const theme of [dark, light]) { diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 6d309a9f3..fe26f0163 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -107,6 +107,7 @@ export function rowsForFile( // Every line of a novel leaf is tinted; the spans inside get the darker word tint on top. const novelSet = novelLeaves(leaves); const novel = (leaf: Leaf) => novelSet.has(leaf); + const moves = movedLeaves(leaves); const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; const text = texts[side][line]; @@ -117,6 +118,19 @@ export function rowsForFile( side ? "right" : "left", theme); caches[side].set(line, spans); } + const partner = moves.partner[side].get(leaf); + if (partner) { + // Moved code is neither added nor removed; edits inside keep their word tint. + return { + kind: "context", + sign: " ", + moveKind: "moved", + jump: { side: side ? "left" : "right", line: partner.startLine + (line - leaf.startLine) + 1 }, + lineNumber: line + 1, + spans, + fold: headers[side].get(line), + }; + } const changed = novel(leaf); return { kind: changed ? (side ? "addition" : "deletion") : "context", @@ -126,6 +140,22 @@ export function rowsForFile( fold: headers[side].get(line), }; }; + /** "moved to line M" on the left, "moved from line N" on the right, at the top of a moved copy. */ + const moveLabel = (leaf: Leaf) => { + const target = moves.runStart[leaf.side].get(leaf); + if (target === undefined || hidden[leaf.side].has(leaf.startLine)) return; + const text = leaf.side ? `moved from line ${target}` : `moved to line ${target}`; + const labelled: SplitLineCell = { kind: "context", sign: " ", moveKind: "moved", moveLabel: true, + jump: { side: leaf.side ? "left" : "right", line: target }, spans: [{ text, fg: theme.movedText }] }; + const empty: SplitLineCell = { kind: "empty", sign: " ", spans: [] }; + const key = `${fileIndex}:moved:${leaf.side}:${leaf.startLine}`; + if (layout === "split") { + rows.push({ key, fileIndex, left: leaf.side ? empty : labelled, right: leaf.side ? labelled : empty }); + return; + } + const cell = { ...labelled, kind: "context" as const }; + (leaf.side ? pendingNew : pendingOld).push({ key, fileIndex, cell }); + }; let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { rows.push(...pendingOld, ...pendingNew); @@ -162,10 +192,10 @@ export function rowsForFile( } if (l !== null) pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: a.kind === "deletion" ? "deletion" : "context", - sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); + sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans, moveKind: a.moveKind, jump: a.jump } }); if (r !== null) pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: b.kind === "addition" ? "addition" : "context", - sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans, moveKind: b.moveKind, jump: b.jump } }); }; const leafRows = (left: Leaf | null, right: Leaf | null) => { const anchor = left ?? right; @@ -174,6 +204,7 @@ export function rowsForFile( if (!hidden[anchor.side].has(anchor.startLine)) collapsedLeaf(left, right); return; } + if (!left || !right) moveLabel(anchor); const length = Math.max(left ? left.endLine - left.startLine : 0, right ? right.endLine - right.startLine : 0); for (let i = 0; i < length; i++) emit(left && i < left.endLine - left.startLine ? left.startLine + i : null, @@ -200,12 +231,47 @@ export function rowsForFile( flush(); return withFoldLabels(markHunks(rows), texts, theme); } +/** + * Moves: a leaf whose counterpart the zip has already passed. Both copies are moved, and each run + * of consecutive moved leaves on a side is one copy whose label names where the other copy starts. + */ +export function movedLeaves(leaves: readonly [Leaf[], Leaf[]]) { + const partner = [new Map(), new Map()] as const; + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + for (const left of leaves[0]) { + const index = rightIndex.get(left.alignmentId); + if (index === undefined) continue; + if (index < cursor) { + partner[0].set(left, leaves[1][index]); + partner[1].set(leaves[1][index], left); + continue; + } + cursor = index + 1; + } + // First leaf of each copy -> the other copy's first line, 1-based. + const runStart = [new Map(), new Map()] as const; + for (const side of [0, 1] as const) { + let run: Leaf[] = []; + const close = () => { + if (run.length) runStart[side].set(run[0], Math.min(...run.map((leaf) => partner[side].get(leaf)!.startLine)) + 1); + run = []; + }; + for (const leaf of leaves[side]) { + if (partner[side].has(leaf)) run.push(leaf); + else close(); + } + close(); + } + return { partner, runStart }; +} function markHunks(rows: ViewerRow[]): ViewerRow[] { let inHunk = false; for (const row of rows) { const changed = row.cell - ? row.cell.kind !== "context" - : row.left !== undefined && (row.left.kind !== "context" || row.right!.kind !== "context"); + ? row.cell.kind !== "context" || row.cell.moveKind === "moved" + : row.left !== undefined && (row.left.kind !== "context" || row.right!.kind !== "context" + || row.left.moveKind === "moved" || row.right!.moveKind === "moved"); if (changed && !inHunk) row.hunkStart = true; inHunk = changed; } diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts index ec4a4dde7..99453ea85 100644 --- a/tui/packages/hunk/src/diffr/theme.ts +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -28,6 +28,9 @@ export interface Palette { deleteWord: string; addedText: string; removedText: string; + /** Background and label colour for code that moved. */ + moved: string; + movedText: string; /** An accent for interactive text such as links and the layout badge. */ accent: string; /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ @@ -116,6 +119,10 @@ export function paletteFromHelix(theme: HelixTheme): Palette { const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); + const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); + // Moved code: the theme's own delta colour, else a muted blue drawn from the selection. + const delta = scopeFg(theme, "diff.delta.moved") ?? scopeFg(theme, "diff.delta") + ?? mix(selection, isLight ? "#2f6fbf" : "#6fa8f0", 0.6); return { name: theme.name, isLight, @@ -123,13 +130,15 @@ export function paletteFromHelix(theme: HelixTheme): Palette { fg, muted, chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), - highlight: scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15), + highlight: selection, addition: mix(bg, plus, 0.18), deletion: mix(bg, minus, 0.18), addWord: mix(bg, plus, 0.42), deleteWord: mix(bg, minus, 0.42), addedText: plus, removedText: minus, + moved: mix(bg, delta, 0.16), + movedText: delta, accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), foldPlaceholder: muted, diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 54f805f10..d8f3b59a4 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -15,6 +15,7 @@ import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from ". import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; +import type { MoveJump } from "./diff/diffRowModel"; import { rowsForFile, type Layout, @@ -62,6 +63,9 @@ export function App({ const [collapsed, setCollapsed] = useState>>(new Map()); // Vim's z prefix: the next key names the fold command. const pendingZ = useRef(false); + // `g` goes home at once but remembers where the view was, so a following `m` can jump from there + // to the other copy of moved code. + const pendingG = useRef(null); const [closedDirectories, setClosedDirectories] = useState>(new Set()); const [treeScroll, setTreeScroll] = useState(0); const [sidebarWidth, setSidebarWidth] = useState(28); @@ -199,6 +203,23 @@ export function App({ const row = geometry.rows.find((r) => r.row.fileIndex === index); if (row) setScroll(Math.min(maxScroll, row.top)); }; + // Moved code: scroll to the counterpart line on the other side, in the same file. + const jumpToMove = (fileIndex: number, target: MoveJump) => { + const lineOf = (row: ViewerRow) => target.side === "left" + ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) + : (row.right?.lineNumber ?? row.cell?.newLineNumber); + const found = geometry.rows.find((r) => r.row.fileIndex === fileIndex && lineOf(r.row) === target.line); + if (!found) return setMessage(`Line ${target.line} is folded or not loaded`); + setScroll(Math.min(maxScroll, found.top)); + setMessage(`Moved code: ${target.side} line ${target.line}`); + }; + const jumpFromTop = (from = top) => { + const current = visibleRows(geometry, from, Math.max(1, viewportHeight)).map((r) => r.row) + .find((row) => (row.cell ?? row.right)?.jump ?? row.left?.jump); + const jumpOf = current && ((current.cell ?? current.right)?.jump ?? current.left?.jump); + if (!current || !jumpOf) return setMessage("No moved code on screen"); + jumpToMove(current.fileIndex, jumpOf); + }; const navigateHunk = (direction: number) => { const headers = geometry.rows.filter((r) => r.row.hunkStart); const target = @@ -218,6 +239,11 @@ export function App({ useKeyboard((key) => { // Hunk's chord matcher handles raw control bytes and Kitty events alike. const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); + if (pendingG.current !== null) { + const from = pendingG.current; + pendingG.current = null; + if (is("m")) return jumpFromTop(from); + } if (pendingZ.current) { pendingZ.current = false; const command = key.shift ? key.name?.toUpperCase() : key.name; @@ -235,7 +261,8 @@ export function App({ else if (is("pageup", "b", "shift+space", "ctrl+b")) move(-viewportHeight); else if (is("down", "j")) move(1); else if (is("up", "k")) move(-1); - else if (is("g", "home")) setScroll(0); + else if (is("g")) { pendingG.current = top; setScroll(0); } + else if (is("home")) setScroll(0); else if (is("G", "end")) setScroll(maxScroll); else if (is("right", "shift+right", "l")) setHorizontal(n => n + (key.shift ? 16 : 4)); else if (is("left", "shift+left", "h")) setHorizontal(n => Math.max(0, n - (key.shift ? 16 : 4))); @@ -367,6 +394,7 @@ export function App({ setSelection((s) => (s ? { ...s, end: row.key } : s)); }} onFold={(fold, recursive) => toggleFold(row.fileIndex, fold, recursive)} + onJump={(target) => jumpToMove(row.fileIndex, target)} />, ); } diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 737195cdf..a46fc2947 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import { StyledText, parseColor } from "@opentui/core"; import type { + MoveJump, RenderSpan, SplitLineCell, UnifiedLineCell, @@ -46,6 +47,7 @@ export const CodeRowView = memo(function CodeRowView({ onSelect, onExtend, onFold, + onJump, }: { measured: MeasuredRow; visualLine: number; @@ -55,6 +57,8 @@ export const CodeRowView = memo(function CodeRowView({ onSelect: (side: "left" | "right") => void; onExtend: () => void; onFold: (fold: RowFold, recursive: boolean) => void; + /** Jump to the other copy of moved code. */ + onJump: (jump: MoveJump) => void; }) { const row = measured.row; const lastLine = visualLine === measured.height - 1; @@ -71,6 +75,8 @@ export const CodeRowView = memo(function CodeRowView({ ? theme.highlight : value.foldLabel || fold?.collapsed ? theme.foldBackground + : value.moveKind === "moved" + ? theme.moved : value.kind === "addition" ? theme.addition : value.kind === "deletion" @@ -96,7 +102,10 @@ export const CodeRowView = memo(function CodeRowView({ flexDirection="row" backgroundColor={bg} onMouseDown={(event) => { - if (event.button === 0) onSelect(side); + if (event.button === 0 && !value.moveLabel) onSelect(side); + }} + onMouseUp={(event) => { + if (event.button === 0 && value.moveLabel && value.jump) onJump(value.jump); }} onMouseMove={onExtend} > diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index 42509833d..746d1db07 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -17,11 +17,21 @@ export interface RenderSpan { transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; } +/** Where a moved copy's counterpart starts: the other side and its 1-based line. */ +export interface MoveJump { + side: "left" | "right"; + line: number; +} + export interface SplitLineCell { kind: "context" | "addition" | "deletion" | "empty"; sign: string; lineNumber?: number; moveKind?: DiffLineMoveKind; + /** Moved code: the counterpart line to jump to. */ + jump?: MoveJump; + /** The "moved from/to line N" row at the top of a moved copy. */ + moveLabel?: boolean; /** This cell starts a fold region; the chevron and placeholder come from here. */ fold?: RowFold; /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ @@ -35,6 +45,8 @@ export interface UnifiedLineCell { oldLineNumber?: number; newLineNumber?: number; moveKind?: DiffLineMoveKind; + jump?: MoveJump; + moveLabel?: boolean; fold?: RowFold; foldLabel?: boolean; spans: RenderSpan[]; From 3dcc57c0e09191ec7205e33674cb63ad0e01a4c1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 21:59:15 -0700 Subject: [PATCH 34/43] Fold a fold_state_id bundle as one in the terminal frontend Collapse state already keys on fold_state_id; recursive folding now unions the nested ids of every region sharing it, so a docstring bundled with its function opens and closes with it. A test covers a docstring and body sharing one id: one toggle hides both, the empty docstring label renders as a bare ellipsis, the signature stays. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/regions.test.ts | 30 +++++++++++++++++++++ tui/packages/hunk/src/diffr/regions.ts | 10 ++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 95c76bd3f..fed2f0692 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -128,3 +128,33 @@ test("a fold that runs to the end of the file may end one past its last line", ( const { folds } = flatten(file.diff); expect(folds[0][0]?.lastHidden).toBe(2); }); + +test("regions sharing a fold_state_id collapse and expand as one bundle", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "/// Adds two numbers.", // 0 + "/// Wraps on overflow.", // 1 + "fn add(a: u8, b: u8) -> u8 {", // 2 + " a.wrapping_add(b)", // 3 + "}", // 4 + ].join("\n") + "\n"; + // The docstring (alignment 20) and the function body (alignment 21) share fold state 21. + const docstring: Region = { ...fold(20, [0, 0], [2, 0], [leaf(1, 0, 2)], "", ["comment"], true), fold_state_id: 21 }; + const body = fold(21, [2, 28], [4, 0], [leaf(2, 2, 4)], "// pseudocode\nreturn a + b", ["body", "function"], true); + const regions: Region[] = [docstring, body, leaf(3, 4, 5)]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([21]); + const { folds } = flatten(file.diff); + // One id hides both the docstring's continuation and the body. + expect([...hiddenLines(folds[1], collapsed)].sort()).toEqual([1, 3]); + expect([...hiddenLines(folds[1], new Set())]).toEqual([]); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // The docstring is a one-row ⋯ fold (empty label), the signature stays visible, and both headers + // carry the same id, so either chevron toggles the pair. + const headers = rows.filter((r) => r.right!.fold).map((r) => [r.right!.lineNumber, r.right!.fold!.id, r.right!.fold!.label, r.right!.fold!.collapsed]); + expect(headers).toEqual([[1, 21, "", true], [3, 21, "// pseudocode\nreturn a + b", true]]); + expect(rows.map((r) => r.right!.lineNumber)).toEqual([1, 3, 5]); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index a9a0ede1d..2aebc76ac 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -105,11 +105,13 @@ export function foldIds(diff: TextDiff): number[] { const { folds, leaves } = flatten(diff); return [...new Set([...folds.flat().map((fold) => fold.foldStateId), ...leaves.flat().filter(foldableLeaf).map((l) => l.foldStateId)])]; } -/** Ids nested inside a collapsible region, for recursive fold commands. */ +/** Ids nested inside a collapsible region, for recursive fold commands. Every region sharing the + * fold-state id counts, so a bundle such as a docstring and its function unfolds as one. */ export function nestedIds(diff: TextDiff, id: number): number[] { - const fold = flatten(diff).folds.flat().find((f) => f.foldStateId === id); - if (fold) return fold.nested; - if (!flatten(diff).leaves.flat().some((leaf) => leaf.foldStateId === id)) throw new Error(`Unknown region ${id}`); + const { folds, leaves } = flatten(diff); + const members = folds.flat().filter((f) => f.foldStateId === id); + if (members.length) return [...new Set(members.flatMap((f) => f.nested))].filter((n) => n !== id); + if (!leaves.flat().some((leaf) => leaf.foldStateId === id)) throw new Error(`Unknown region ${id}`); return []; } /** Context-gap leaves: unchanged runs diffr trimmed to N lines around changes. */ From 74bbe388c165fa9a2b2f0f777822e9fc1d358574 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 21:59:19 -0700 Subject: [PATCH 35/43] Keep a collapsed gap whole across a fold edge A fold whose closer sat inside a long unchanged stretch that continued in its parent cut the gap into two collapsed leaves at different depths, so Review drew two unchanged bands back to back. A fold that ends inside a gap now extends to the gap's end, and a fold whose header would be hidden inside one is dropped, but only when the cut would leave two pieces long enough to collapse. The streaming checker asserts no two collapsed unchanged leaves are adjacent. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/streaming.md | 9 ++++-- src/protocol/project.rs | 61 ++++++++++++++++++++++++++++++++++++++-- tests/streaming/check.py | 5 ++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index f0ffe5ca9..52e1b2588 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -210,9 +210,12 @@ Every other stretch of unchanged rows that is at least three lines long collapses into a leaf tagged `unchanged` with a label such as `"142 unchanged lines"`; shorter stretches stay open because a fold row would save nothing. A file with no change is one collapsed leaf however -short. Folds that lie entirely inside a gap are not regions, so a gap is -split only by a fold that crosses its edge, and the grouping mutation merges -adjacent gaps again where both sides agree. +short. Folds that lie entirely inside a gap are not regions. A fold edge +never cuts a gap into two collapsed leaves back to back: a fold that ends +inside a gap extends to the gap's end, and a fold whose header would be +hidden inside one is dropped. A cut leaving a sliver under three lines +leaves that sliver open. The grouping mutation merges adjacent gaps again +where both sides agree. **Groups** come from the last built-in mutation: a run of two or more sibling folds that start collapsed, such as several deleted or summarized diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 9a402198c..e0ae8651e 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -244,11 +244,11 @@ fn regions( let mut lhs_folds: Vec> = side_folds(&result.lhs_folds, &lhs_lines) .into_iter() - .filter(|fold| !inside_gap(fold.lines, &lhs_gaps)) + .filter_map(|fold| fit_to_gaps(fold, &lhs_gaps)) .collect(); let mut rhs_folds: Vec> = side_folds(&result.rhs_folds, &rhs_lines) .into_iter() - .filter(|fold| !inside_gap(fold.lines, &rhs_gaps)) + .filter_map(|fold| fit_to_gaps(fold, &rhs_gaps)) .collect(); let lhs_splits = split_lines(&lhs_folds); let rhs_splits = split_lines(&rhs_folds); @@ -442,6 +442,38 @@ fn inside_gap(lines: (usize, usize), gaps: &[(usize, usize)]) -> bool { .any(|&(start, end)| start <= lines.0 && lines.1 <= end) } +/// A collapsed gap is never split into two collapsed leaves back to back. +/// A fold entirely inside a gap is dropped: it would be hidden anyway. When +/// a fold edge would cut a gap into two pieces that are each long enough to +/// collapse, the fold gives way: a fold whose header starts inside such a +/// gap is dropped (its header would be hidden), and a fold that ends inside +/// one extends to the gap's end, so the gap stays one leaf inside it. Any +/// ancestor ending in the same gap extends to the same line, so nesting +/// holds. A cut leaving a sliver shorter than `MIN_GAP` needs nothing: the +/// sliver stays open. Gaps are aligned runs, so both sides agree. +fn fit_to_gaps<'a>(mut fold: SideFold<'a>, gaps: &[(usize, usize)]) -> Option> { + if inside_gap(fold.lines, gaps) { + return None; + } + let (start, end) = fold.lines; + let splits = |gap_start: usize, cut: usize, gap_end: usize| { + gap_start < cut && cut < gap_end && cut - gap_start >= MIN_GAP && gap_end - cut >= MIN_GAP + }; + if gaps + .iter() + .any(|&(gap_start, gap_end)| splits(gap_start, start, gap_end.min(end))) + { + return None; + } + if let Some(&(_, gap_end)) = gaps + .iter() + .find(|&&(gap_start, gap_end)| splits(gap_start.max(start), end, gap_end)) + { + fold.lines = (start, gap_end); + } + Some(fold) +} + fn line_span(range: &lines::SourceRange, line_count: usize) -> (usize, usize) { let start = range.start.line.as_usize(); let end = if range.end.byte_column == 0 { @@ -1107,6 +1139,31 @@ mod tests { .collect() } + #[test] + fn a_fold_ending_inside_a_gap_does_not_split_it() { + // The inner block's closer sits inside a long unchanged stretch that + // continues in the enclosing function: one gap, not two back to back. + let body: String = (1..=7).map(|n| format!(" u{n}();\n")).collect(); + let tail: String = (1..=5).map(|n| format!(" v{n}();\n")).collect(); + let lhs = format!("fn f() {{\n if a {{\n x();\n{body} }}\n{tail}}}\n"); + let rhs = format!("fn f() {{\n if a {{\n y();\n{body} }}\n{tail}}}\n"); + let diff = project("a.rs", &lhs, &rhs, 1); + let (lhs_src, rhs_src) = sources(&diff); + for source in [lhs_src.unwrap(), rhs_src.unwrap()] { + assert_tiles(source); + let gaps: Vec<&Region> = all(&source.regions) + .into_iter() + .filter(|r| { + matches!(r.node, Node::Leaf { .. }) + && r.visibility.collapsed + && r.tags.iter().any(|t| t == "unchanged") + }) + .collect(); + assert_eq!(gaps.len(), 1, "one gap: {gaps:?}"); + assert!(gaps[0].range.end.line - gaps[0].range.start.line >= 10); + } + } + #[test] fn crossing_folds_give_the_shared_line_to_the_later_fold() { // A collection closing on line 5 where a body opens: `for x in [ … ] {`. diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 3cfea4ba1..92bb0a1d5 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -141,7 +141,12 @@ def walk(regions, parent): def check_tiling(source): at = 0 + previous = None for leaf in leaves(source["regions"]): + # A collapsed unchanged gap is one leaf: two never sit back to back. + gap = leaf.get("visibility", {}).get("collapsed") and "unchanged" in leaf.get("tags", []) + assert not (gap and previous), (previous, leaf) + previous = gap assert leaf["start"] == {"line": at, "column": 0}, (leaf, at) assert leaf["end"]["column"] == 0 and leaf["end"]["line"] > at at = leaf["end"]["line"] From 26e906119ead90562574f049a4ea8c0f84a1af1c Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 21:59:52 -0700 Subject: [PATCH 36/43] Satisfy clippy in the gap test Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/protocol/project.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/protocol/project.rs b/src/protocol/project.rs index e0ae8651e..011514aab 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -1143,8 +1143,14 @@ mod tests { fn a_fold_ending_inside_a_gap_does_not_split_it() { // The inner block's closer sits inside a long unchanged stretch that // continues in the enclosing function: one gap, not two back to back. - let body: String = (1..=7).map(|n| format!(" u{n}();\n")).collect(); - let tail: String = (1..=5).map(|n| format!(" v{n}();\n")).collect(); + let body = (1..=7) + .map(|n| format!(" u{n}();\n")) + .collect::>() + .concat(); + let tail = (1..=5) + .map(|n| format!(" v{n}();\n")) + .collect::>() + .concat(); let lhs = format!("fn f() {{\n if a {{\n x();\n{body} }}\n{tail}}}\n"); let rhs = format!("fn f() {{\n if a {{\n y();\n{body} }}\n{tail}}}\n"); let diff = project("a.rs", &lhs, &rhs, 1); From 1bc56fa585fc5115629c89a71ab89296070d6bec Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 22:09:48 -0700 Subject: [PATCH 37/43] Bundle each function with its docstring A function's docstring is the run of comment lines just above it, with only its signature and blank lines between, or in Python the string that opens its body. A new fold mutation splits those lines into leaves tagged docstring that share the function's fold_state_id, so frontends open and close them together; a paired docstring keeps the bundle only when the other side agrees. After the collapsing mutations run, a docstring whose function starts collapsed collapses too, with an empty label. The summarizer sends the docstring with each body and may get back one sentence from it; the sentence is kept only when it really occurs in the docstring, and then leads the pseudocode. Compression is judged on the pseudocode alone. folds.bundle_docstrings turns it off. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- docs/config.md | 28 +- docs/streaming.md | 8 +- src/config.rs | 5 + src/mutate/docstrings.rs | 539 +++++++++++++++++++++++++++++++++++++++ src/mutate/mod.rs | 16 +- src/mutate/summarize.rs | 119 ++++++++- tests/streaming/check.py | 16 +- 7 files changed, 706 insertions(+), 25 deletions(-) create mode 100644 src/mutate/docstrings.rs diff --git a/docs/config.md b/docs/config.md index ada4c2a2b..aaa14858a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -40,6 +40,7 @@ through these three commands; the schema is the only contract. min_lines = 12 # bodies shorter than this are never collapsed by a rule collapse_deleted = true # deleted function bodies start collapsed, header visible collapse_test_bodies = true # test bodies start collapsed on both sides, header visible +bundle_docstrings = true # a docstring opens and closes with its function collapse_removed_lines = 5 # removed stretches this long, in unpaired code, collapse in the middle; 0 disables collapse_generated = true # generated files start hidden collapse_tests = true # test files start hidden @@ -136,16 +137,29 @@ itself. In order: function shows its removed lines in place. Stretches at the top level always qualify. Stretches under a fold that already starts collapsed are left alone. -5. The built-in summarizer: new function bodies (folds tagged `function`, +5. `bundle_docstrings`: a function's docstring is the run of comment lines + just above it, with only its signature and blank lines between; in + Python it is instead a string that is the body's first statement. Its + lines become leaves tagged `docstring` that share the function's + `fold_state_id`, so they open and close together. A comment separated + from the function by code does not count, a docstring already inside a + collapsed leaf is left alone, and a paired docstring keeps its bundle + only when the other side bundles it with the same function. +6. The built-in summarizer: new function bodies (folds tagged `function`, never a test, never one nested inside another selected body) of at least `summarize.min_lines` lines on the after side become python-flavored - pseudocode. A summary with more than half the body's non-blank lines is - discarded and the body stays open. The label starts with a comment line - in the file's own syntax, `# pseudocode` or `// pseudocode`, then the - text. -6. `folds.hook`, when configured, over the same selection with its own tags + pseudocode. When the body has a docstring its text goes with the request, + and the model may return one sentence from it; that sentence is kept only + when it really occurs in the docstring, and then leads the label. A + summary whose pseudocode has more than half the body's non-blank lines + is discarded and the body stays open. The label starts with a comment + line in the file's own syntax, `# pseudocode` or `// pseudocode`, then + the quoted sentence if any, then the pseudocode. +7. `folds.hook`, when configured, over the same selection with its own tags and threshold; its text gets the same comment line. -7. Grouping, always on: adjacent context gaps merge into one, and a run of +8. With `bundle_docstrings`, each docstring whose function now starts + collapsed collapses too, with an empty label. +9. Grouping, always on: adjacent context gaps merge into one, and a run of two or more sibling folds that start collapsed is wrapped in one `group` fold labelled `" functions removed"`, `" functions summarized"`, or `" folded regions"`. Expanding it reveals each child's own row. diff --git a/docs/streaming.md b/docs/streaming.md index 52e1b2588..0e1ac5bb8 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -160,6 +160,9 @@ side. For an ordinary region the two hold the same number; a mutation that bundles regions (a docstring with its function, say) gives them one `fold_state_id` while each keeps its own `alignment_id`. Consumers key the row zip by `alignment_id` and collapse state by `fold_state_id`, never the reverse. +Today the only bundle is a docstring: leaves tagged `docstring` carry the +`fold_state_id` of the `function` fold they document, and start collapsed, +with an empty label, whenever that function does. ```jsonc {"alignment_id": 7, "fold_state_id": 7, "kind": "fold", @@ -249,8 +252,9 @@ fail discovery. After projection, mutations set what starts collapsed and what its label says: generated and test files in the manifest, deleted function bodies, test -bodies on both sides, the middle of large removed stretches, and summaries -for large new function bodies from the built-in summarizer. [config.md](config.md) lists them and +bodies on both sides, the middle of large removed stretches, docstrings +bundled with their functions, and summaries for large new function bodies +from the built-in summarizer. [config.md](config.md) lists them and their order. Their output is ordinary `visibility` on files and regions; frontends need no knowledge of which mutation produced it. diff --git a/src/config.rs b/src/config.rs index 1947585b9..a51a515a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -109,6 +109,10 @@ pub(crate) struct FoldsConfig { pub(crate) collapse_tests: bool, /// Collapse the bodies of test functions on both sides, header visible. pub(crate) collapse_test_bodies: bool, + /// Bundle each function with the comment above it (in Python, the + /// string that opens its body): they open and close together, and the + /// summarizer quotes the docstring before its pseudocode. + pub(crate) bundle_docstrings: bool, /// Unchanged lines kept visible on either side of a change. `-U` overrides it. pub(crate) context_lines: u32, /// An external JSON-RPC summarizer, run after the built-in one. @@ -126,6 +130,7 @@ impl Default for FoldsConfig { collapse_generated: true, collapse_tests: true, collapse_test_bodies: true, + bundle_docstrings: true, context_lines: 3, hook: None, } diff --git a/src/mutate/docstrings.rs b/src/mutate/docstrings.rs new file mode 100644 index 000000000..d0f748bac --- /dev/null +++ b/src/mutate/docstrings.rs @@ -0,0 +1,539 @@ +//! Bundle each function with its docstring, so they open and close together. +//! +//! A docstring is the run of comment lines just above a function, with only +//! blank lines and the function's own signature between them; in Python it +//! is instead a string that is the body's first statement. Its lines become +//! leaves tagged `docstring` whose `fold_state_id` is the function's. The +//! function's pairing across sides is untouched: a docstring leaf that is +//! paired keeps its bundle only when the other side bundles it with the +//! same function too. [`DocstringVisibility`] then collapses a docstring, +//! with an empty label, wherever its function starts collapsed. +use super::group::next_id; +use super::{collapse, comment_marker, ids, walk, walk_mut, FoldMutation}; +use crate::hash::{DftHashMap, DftHashSet}; +use crate::protocol::{FileChange, Node, Pairing, Problem, Region, Source, SourcePos, SourceRange}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const TAG: &str = "docstring"; + +/// How many signature lines may sit between a docstring and the line a +/// function's body opens on. +const MAX_SIGNATURE_LINES: u32 = 12; + +pub(crate) struct Docstrings; + +impl FoldMutation for Docstrings { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let language = file.language.as_deref(); + let lhs_docs = sides + .lhs() + .map(|source| usable(source, find(source, language))) + .unwrap_or_default(); + let rhs_docs = sides + .rhs() + .map(|source| usable(source, find(source, language))) + .unwrap_or_default(); + if lhs_docs.is_empty() && rhs_docs.is_empty() { + return Ok(()); + } + + // Split every leaf at docstring edges, mirroring splits across + // paired leaves so both sides keep equal-length pieces. + let mut offsets: BTreeMap> = BTreeMap::new(); + for (source, docs) in [(sides.lhs(), &lhs_docs), (sides.rhs(), &rhs_docs)] { + let Some(source) = source else { continue }; + let edges: BTreeSet = docs.iter().flat_map(|doc| [doc.start, doc.end]).collect(); + walk(&source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + for &edge in edges.range(lines.start + 1..lines.end) { + offsets + .entry(region.alignment_id) + .or_default() + .insert(edge - lines.start); + } + } + }); + } + let mut next = next_id(sides); + let mut fresh: BTreeMap<(u32, usize), u32> = BTreeMap::new(); + let (mut lhs, mut rhs) = sides_mut(sides); + if let Some(lhs) = lhs.as_deref_mut() { + split(&mut lhs.regions, &offsets, &mut fresh, &mut next); + } + if let Some(rhs) = rhs.as_deref_mut() { + split(&mut rhs.regions, &offsets, &mut fresh, &mut next); + } + + // Mark, then undo any paired docstring leaf whose partner disagrees. + let lhs_marked = lhs + .as_deref_mut() + .map(|source| mark(source, &lhs_docs)) + .unwrap_or_default(); + let rhs_marked = rhs + .as_deref_mut() + .map(|source| mark(source, &rhs_docs)) + .unwrap_or_default(); + let lhs_ids = lhs + .as_deref() + .map(|source| ids(&source.regions)) + .unwrap_or_default(); + let rhs_ids = rhs + .as_deref() + .map(|source| ids(&source.regions)) + .unwrap_or_default(); + let mut revert: DftHashSet = DftHashSet::default(); + for (marked, other_marked, other_ids) in [ + (&lhs_marked, &rhs_marked, &rhs_ids), + (&rhs_marked, &lhs_marked, &lhs_ids), + ] { + for (id, fold_state) in marked { + if other_ids.contains(id) && other_marked.get(id) != Some(fold_state) { + revert.insert(*id); + } + } + } + if !revert.is_empty() { + for source in [lhs, rhs].into_iter().flatten() { + unmark(&mut source.regions, &revert); + } + } + Ok(()) + } +} + +/// Collapse each docstring, with an empty label, whose function starts +/// collapsed. Runs after every mutation that collapses functions. +pub(crate) struct DocstringVisibility; + +impl FoldMutation for DocstringVisibility { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let (lhs, rhs) = sides_mut(sides); + for source in [lhs, rhs].into_iter().flatten() { + let mut collapsed: DftHashSet = DftHashSet::default(); + walk(&source.regions, &mut |region| { + if is_function(region) && region.visibility.collapsed { + collapsed.insert(region.fold_state_id); + } + }); + walk_mut(&mut source.regions, &mut |region| { + if is_docstring(region) && collapsed.contains(®ion.fold_state_id) { + collapse(region, String::new()); + } + }); + } + Ok(()) + } +} + +/// The docstring text for a function: its docstring lines on this side +/// with comment markers and quotes stripped, or `None` when there is none +/// or it says nothing. +pub(crate) fn text_for(source: &Source, fold_state_id: u32) -> Option { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut words = Vec::new(); + walk(&source.regions, &mut |region| { + if is_docstring(region) && region.fold_state_id == fold_state_id { + for line in region.range.lines() { + if let Some(text) = lines.get(line as usize) { + let stripped = strip_markers(text); + if !stripped.is_empty() { + words.push(stripped.to_owned()); + } + } + } + } + }); + let text = words.join(" "); + (!text.is_empty()).then_some(text) +} + +pub(crate) fn is_docstring(region: &Region) -> bool { + region.tags.iter().any(|tag| tag == TAG) +} + +fn is_function(region: &Region) -> bool { + matches!(region.node, Node::Fold { .. }) && region.tags.iter().any(|tag| tag == "function") +} + +/// A docstring's half-open line range and the function it belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Doc { + fold_state_id: u32, + start: u32, + end: u32, +} + +fn find(source: &Source, language: Option<&str>) -> Vec { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut docs = Vec::new(); + walk(&source.regions, &mut |region| { + if !is_function(region) { + return; + } + let opens = region.range.start.line; + let found = if language == Some("Python") { + python_docstring(&lines, opens) + } else { + comment_above(&lines, opens, comment_marker(language)) + }; + if let Some((start, end)) = found { + docs.push(Doc { + fold_state_id: region.fold_state_id, + start, + end, + }); + } + }); + docs +} + +/// Drop docstrings that touch a leaf which already starts collapsed: those +/// lines are hidden already, and splitting a gap would break it up. +fn usable(source: &Source, docs: Vec) -> Vec { + docs.into_iter() + .filter(|doc| { + let mut clear = true; + walk(&source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + if lines.start < doc.end && doc.start < lines.end && region.visibility.collapsed + { + clear = false; + } + } + }); + clear + }) + .collect() +} + +/// Comment lines above the line a function's body opens on, skipping its +/// signature and then blank lines. A line that ends a statement or a block +/// stops the search, so a comment separated from the function by code does +/// not count. +fn comment_above(lines: &[&str], opens: u32, marker: &str) -> Option<(u32, u32)> { + let is_comment = |line: &str| { + let line = line.trim_start(); + line.starts_with(marker) + || (marker == "//" + && (line.starts_with("/*") || line.starts_with('*') || line.starts_with("*/"))) + }; + let is_blank = |line: &str| line.trim().is_empty(); + let ends_code = |line: &str| { + let line = line.trim_end(); + line.ends_with(';') || line.ends_with('}') || line.ends_with('{') + }; + let mut at = opens; + // The signature: lines above the opening line that are not blank, not + // comments, and do not end a statement or a block. + let mut signature = 0; + while at > 0 { + let line = lines.get(at as usize - 1)?; + if is_blank(line) || is_comment(line) || ends_code(line) || signature >= MAX_SIGNATURE_LINES + { + break; + } + at -= 1; + signature += 1; + } + while at > 0 + && lines + .get(at as usize - 1) + .is_some_and(|line| is_blank(line)) + { + at -= 1; + } + let end = at; + while at > 0 + && lines + .get(at as usize - 1) + .is_some_and(|line| is_comment(line)) + { + at -= 1; + } + (at < end).then_some((at, end)) +} + +/// A string that is the first statement of a Python body. The body fold +/// opens on its first statement line. +fn python_docstring(lines: &[&str], opens: u32) -> Option<(u32, u32)> { + let first = lines.get(opens as usize)?.trim_start(); + let body = first.trim_start_matches(['r', 'R', 'u', 'U', 'b', 'B', 'f', 'F']); + let quote = ["\"\"\"", "'''", "\"", "'"] + .into_iter() + .find(|quote| body.starts_with(quote))?; + if quote.len() == 1 { + return Some((opens, opens + 1)); + } + if body[quote.len()..].contains(quote) { + return Some((opens, opens + 1)); + } + (opens as usize + 1..lines.len()) + .find(|&index| lines[index].contains(quote)) + .map(|index| (opens, index as u32 + 1)) +} + +fn strip_markers(line: &str) -> &str { + let mut text = line.trim(); + for prefix in [ + "///", "//!", "//", "/**", "/*", "*/", "*", "#", "--", ";;", ";", "%", + ] { + if let Some(rest) = text.strip_prefix(prefix) { + text = rest.trim(); + break; + } + } + for quote in ["\"\"\"", "'''"] { + text = text + .trim_start_matches(quote) + .trim_end_matches(quote) + .trim(); + } + text.trim_end_matches("*/").trim() +} + +fn sides_mut(sides: &mut Pairing) -> (Option<&mut Source>, Option<&mut Source>) { + match sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs)), + Pairing::LeftOnly { lhs } => (Some(lhs), None), + Pairing::RightOnly { rhs } => (None, Some(rhs)), + } +} + +/// Cut leaves at the given offsets. The first piece keeps the leaf's ids; +/// later pieces take fresh ids shared by the two sides of a paired leaf. +fn split( + regions: &mut Vec, + offsets: &BTreeMap>, + fresh: &mut BTreeMap<(u32, usize), u32>, + next: &mut u32, +) { + let mut out = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + match &mut region.node { + Node::Fold { children } => { + split(children, offsets, fresh, next); + out.push(region); + } + Node::Leaf { .. } => match offsets.get(®ion.alignment_id) { + Some(cuts) => out.extend(pieces(region, cuts, fresh, next)), + None => out.push(region), + }, + } + } + *regions = out; +} + +fn pieces( + region: Region, + cuts: &BTreeSet, + fresh: &mut BTreeMap<(u32, usize), u32>, + next: &mut u32, +) -> Vec { + let Node::Leaf { changed } = region.node else { + unreachable!("only leaves are split"); + }; + let lines = region.range.lines(); + let at = |line: u32| SourcePos { line, column: 0 }; + let mut bounds: Vec = vec![lines.start]; + bounds.extend(cuts.iter().map(|cut| lines.start + cut)); + bounds.push(lines.end); + bounds + .windows(2) + .enumerate() + .map(|(index, window)| { + let range = SourceRange { + start: if index == 0 { + region.range.start + } else { + at(window[0]) + }, + end: at(window[1]), + }; + let id = if index == 0 { + region.alignment_id + } else { + *fresh + .entry((region.alignment_id, index)) + .or_insert_with(|| { + let id = *next; + *next += 1; + id + }) + }; + let span_lines = range.lines(); + Region { + alignment_id: id, + fold_state_id: if index == 0 { region.fold_state_id } else { id }, + range, + tags: region.tags.clone(), + visibility: region.visibility.clone(), + node: Node::Leaf { + changed: changed + .iter() + .copied() + .filter(|span| span_lines.contains(&span.line)) + .collect(), + }, + } + }) + .collect() +} + +/// Tag the leaves inside each docstring and give them the function's fold +/// state. Returns alignment id to fold state id for every marked leaf. +fn mark(source: &mut Source, docs: &[Doc]) -> DftHashMap { + let mut marked = DftHashMap::default(); + walk_mut(&mut source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + if let Some(doc) = docs + .iter() + .find(|doc| doc.start <= lines.start && lines.end <= doc.end) + { + if !is_docstring(region) { + region.tags.push(TAG.to_owned()); + } + region.fold_state_id = doc.fold_state_id; + marked.insert(region.alignment_id, doc.fold_state_id); + } + } + }); + marked +} + +fn unmark(regions: &mut [Region], ids: &DftHashSet) { + walk_mut(regions, &mut |region| { + if ids.contains(®ion.alignment_id) && is_docstring(region) { + region.tags.retain(|tag| tag != TAG); + region.fold_state_id = region.alignment_id; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutate::summarize::tests::project; + + fn docstrings(source: &Source) -> Vec<(u32, u32, u32)> { + let mut out = Vec::new(); + walk(&source.regions, &mut |region| { + if is_docstring(region) { + let lines = region.range.lines(); + out.push((lines.start, lines.end, region.fold_state_id)); + } + }); + out + } + + fn function_state(source: &Source, header: &str) -> u32 { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut found = None; + walk(&source.regions, &mut |region| { + if is_function(region) { + let start = region.range.start.line as usize; + if (start.saturating_sub(3)..=start).any(|line| lines[line].contains(header)) { + found.get_or_insert(region.fold_state_id); + } + } + }); + found.expect("function fold") + } + + #[test] + fn rust_doc_and_line_comments_above_a_function_are_its_docstring() { + let before = "fn keep() -> u32 {\n 1\n}\n"; + let after = "fn keep() -> u32 {\n 1\n}\n\n/// Adds one.\n/// Twice, really.\nfn add(\n a: u32,\n) -> u32 {\n a + 2\n}\n\n// Plain comment.\nfn sub(a: u32) -> u32 {\n a - 1\n}\n"; + let (file, mut sides) = project("a.rs", before, after); + Docstrings.apply(&file, &mut sides).unwrap(); + let rhs = sides.rhs().unwrap(); + let docs = docstrings(rhs); + let add = function_state(rhs, "fn add("); + let sub = function_state(rhs, "fn sub("); + assert!( + docs.iter().any(|&(s, e, f)| (s, e, f) == (4, 6, add)), + "{docs:?}" + ); + assert!( + docs.iter().any(|&(s, e, f)| (s, e, f) == (12, 13, sub)), + "{docs:?}" + ); + assert_eq!( + text_for(rhs, add).as_deref(), + Some("Adds one. Twice, really.") + ); + assert_eq!(crate::protocol::project::tree_violation(&rhs.regions), None); + } + + #[test] + fn a_comment_separated_from_the_function_by_code_does_not_count() { + let after = "// About the constant.\nconst X: u32 = 1;\nfn f() -> u32 {\n X\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + assert!(docstrings(sides.rhs().unwrap()).is_empty()); + } + + #[test] + fn a_python_string_first_in_the_body_is_its_docstring() { + let after = + "def f(a):\n \"\"\"Double a.\n\n Returns an int.\n \"\"\"\n return a * 2\n"; + let (file, mut sides) = project("a.py", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + let rhs = sides.rhs().unwrap(); + let f = function_state(rhs, "def f("); + let docs = docstrings(rhs); + assert!(!docs.is_empty(), "{docs:?}"); + assert!( + docs.iter() + .all(|&(s, e, state)| s >= 1 && e <= 5 && state == f), + "{docs:?}" + ); + assert_eq!( + text_for(rhs, f).as_deref(), + Some("Double a. Returns an int.") + ); + } + + #[test] + fn a_docstring_collapses_with_its_function_and_shares_its_fold_state() { + let after = "/// Documented.\nfn gone() -> u32 {\n let a = 1;\n let b = 2;\n let c = 3;\n a + b + c\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &mut sides else { + panic!("the after side exists"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if is_function(region) { + collapse(region, "summary".to_owned()); + } + }); + DocstringVisibility.apply(&file, &mut sides).unwrap(); + let rhs = sides.rhs().unwrap(); + let state = function_state(rhs, "fn gone("); + let mut seen = false; + walk(&rhs.regions, &mut |region| { + if is_docstring(region) { + seen = true; + assert_eq!(region.fold_state_id, state); + assert!(region.visibility.collapsed); + assert!(region.visibility.label.is_empty()); + } + }); + assert!(seen); + } + + #[test] + fn a_paired_docstring_over_a_paired_function_bundles_on_both_sides() { + let before = "/// Stays.\nfn f() -> u32 {\n 1\n}\n"; + let after = "/// Stays.\nfn f() -> u32 {\n 2\n}\n"; + let (file, mut sides) = project("a.rs", before, after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (lhs, rhs) = (sides.lhs().unwrap(), sides.rhs().unwrap()); + let (lhs_docs, rhs_docs) = (docstrings(lhs), docstrings(rhs)); + assert_eq!(lhs_docs.len(), 1, "{lhs_docs:?}"); + assert_eq!(lhs_docs, rhs_docs); + assert_eq!(lhs_docs[0].2, function_state(lhs, "fn f(")); + } +} diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs index b6b1bb90d..395f899f8 100644 --- a/src/mutate/mod.rs +++ b/src/mutate/mod.rs @@ -3,11 +3,13 @@ //! region trees. diffr's diff internals never see them. //! //! Order: file mutations (hidden categories), then fold mutations in this -//! sequence: deleted function bodies, test bodies, removed runs, the -//! built-in summarizer, the JSON-RPC hook, and last the grouping pass that -//! merges adjacent gaps and wraps runs of collapsed folds. A mutation +//! sequence: deleted function bodies, test bodies, removed runs, docstring +//! bundling, the built-in summarizer, the JSON-RPC hook, docstring +//! visibility, and last the grouping pass that merges adjacent gaps and +//! wraps runs of collapsed folds. A mutation //! failure aborts the run; retries belong inside a mutation. pub(crate) mod collapse; +pub(crate) mod docstrings; pub(crate) mod group; pub(crate) mod summarize; use crate::config::Params; @@ -57,6 +59,9 @@ impl Mutations { min_lines: folds.collapse_removed_lines, })); } + if folds.bundle_docstrings { + mutations.fold.push(Box::new(docstrings::Docstrings)); + } if params.summarize.enabled { if let Some(summarizer) = summarize::Summarizer::new(¶ms.summarize)? { mutations.fold.push(Box::new(summarizer)); @@ -67,6 +72,11 @@ impl Mutations { .fold .push(Box::new(Hook::spawn(hook, folds.min_lines, workspace)?)); } + if folds.bundle_docstrings { + mutations + .fold + .push(Box::new(docstrings::DocstringVisibility)); + } mutations.fold.push(Box::new(group::GroupCollapsed)); Ok(mutations) } diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs index 0fedaba38..d6d01c31d 100644 --- a/src/mutate/summarize.rs +++ b/src/mutate/summarize.rs @@ -1,5 +1,6 @@ //! The built-in summarizer: large new function bodies become Python-style //! pseudocode, shown in place of the collapsed body. +use super::docstrings; #[cfg(test)] use super::walk; use super::{collapse, ids, is_fold, line_count, summary_label, walk_mut, FoldMutation}; @@ -15,7 +16,9 @@ use tokio::sync::Semaphore; const SYSTEM: &str = "For each listed fold, rewrite that function body as short python-flavored \ pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as \ possible: about one pseudocode line per five source lines, and never more than a third \ -of the body's lines. Answer with a JSON array of {\"id\", \"pseudocode\"} objects, one per fold."; +of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied \ +verbatim from that doc; otherwise leave it empty. Answer with a JSON array of \ +{\"id\", \"summary\", \"pseudocode\"} objects, one per fold."; const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; @@ -29,16 +32,27 @@ pub(crate) struct Summarizer { limit: Semaphore, } -/// One fold to summarize: its region id and 1-based inclusive line range. +/// One fold to summarize: its region id, 1-based inclusive line range, and +/// the text of its docstring when it has one. struct Request { id: u32, first_line: u32, last_line: u32, + doc: Option, } #[derive(Deserialize)] struct Answer { id: u32, + #[serde(default)] + summary: String, + pseudocode: String, +} + +/// What the model returned for one fold: an optional sentence quoted from +/// its docstring, and the pseudocode. +struct Summary { + quote: Option, pseudocode: String, } @@ -89,8 +103,13 @@ impl Summarizer { let ranges: Vec = folds .iter() .map(|fold| { + let doc = fold + .doc + .as_ref() + .map(|doc| format!("\n doc: {doc}")) + .unwrap_or_default(); format!( - "- fold {}: lines {}-{}", + "- fold {}: lines {}-{}{doc}", fold.id, fold.first_line, fold.last_line ) }) @@ -111,7 +130,7 @@ impl Summarizer { language: Option<&str>, src: &str, folds: &[Request], - ) -> Result, Problem> { + ) -> Result, Problem> { let body = json!({ "systemInstruction": {"parts": [{"text": SYSTEM}]}, "contents": [{"role": "user", "parts": [{"text": self.prompt(path, language, src, folds)}]}], @@ -124,7 +143,11 @@ impl Summarizer { "type": "ARRAY", "items": { "type": "OBJECT", - "properties": {"id": {"type": "INTEGER"}, "pseudocode": {"type": "STRING"}}, + "properties": { + "id": {"type": "INTEGER"}, + "summary": {"type": "STRING"}, + "pseudocode": {"type": "STRING"}, + }, "required": ["id", "pseudocode"], }, }, @@ -207,7 +230,17 @@ impl Summarizer { )); } if !answer.pseudocode.trim().is_empty() { - texts.insert(answer.id, answer.pseudocode.trim().to_owned()); + let doc = folds + .iter() + .find(|fold| fold.id == answer.id) + .and_then(|fold| fold.doc.as_deref()); + texts.insert( + answer.id, + Summary { + quote: quoted(doc, &answer.summary), + pseudocode: answer.pseudocode.trim().to_owned(), + }, + ); } } Ok(texts) @@ -252,6 +285,14 @@ pub(crate) fn select(sides: &Pairing, min_lines: usize) -> Vec<(u32, u32 selected } +/// The model's sentence, kept only when it really is a verbatim quote from +/// the docstring: compared with runs of whitespace collapsed. +fn quoted(doc: Option<&str>, summary: &str) -> Option { + let squash = |text: &str| text.split_whitespace().collect::>().join(" "); + let (doc, sentence) = (squash(doc?), squash(summary)); + (!sentence.is_empty() && doc.contains(&sentence)).then_some(sentence) +} + /// Pseudocode earns its place only when it is clearly shorter than the /// code: a summary with more than half the body's non-blank lines is /// dropped and the body stays open. @@ -264,6 +305,17 @@ fn compresses(summary: &str, body: &[&str]) -> bool { summary_lines * 2 <= body_lines } +/// The fold state of the region with this alignment id on one side. +fn fold_state_of(source: &Source, alignment_id: u32) -> u32 { + let mut state = alignment_id; + super::walk(&source.regions, &mut |region| { + if region.alignment_id == alignment_id { + state = region.fold_state_id; + } + }); + state +} + fn resolve_key(config: &SummarizeConfig) -> Option { config .api_key @@ -283,12 +335,14 @@ fn resolve_key(config: &SummarizeConfig) -> Option { impl FoldMutation for Summarizer { fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> { + let rhs_source = sides.rhs(); let folds: Vec = select(sides, self.min_lines) .into_iter() .map(|(id, first_line, last_line)| Request { id, first_line, last_line, + doc: rhs_source.and_then(|rhs| docstrings::text_for(rhs, fold_state_of(rhs, id))), }) .collect(); if folds.is_empty() { @@ -304,13 +358,13 @@ impl FoldMutation for Summarizer { let rhs = sides.rhs().expect("selection found rhs regions"); let lines: Vec<&str> = rhs.text.split_terminator('\n').collect(); let mut texts = self.complete(path, language, &rhs.text, &folds)?; - texts.retain(|id, text| { + texts.retain(|id, summary| { let fold = folds .iter() .find(|fold| fold.id == *id) .expect("answered fold"); let body = &lines[fold.first_line as usize - 1..fold.last_line as usize]; - compresses(text, body) + compresses(&summary.pseudocode, body) }); texts }; @@ -318,8 +372,12 @@ impl FoldMutation for Summarizer { unreachable!("selection found rhs regions"); }; walk_mut(&mut rhs.regions, &mut |region| { - if let Some(text) = texts.get(®ion.alignment_id) { - collapse(region, summary_label(language, text)); + if let Some(summary) = texts.get(®ion.alignment_id) { + let text = match &summary.quote { + Some(quote) => format!("{quote}\n{}", summary.pseudocode), + None => summary.pseudocode.clone(), + }; + collapse(region, summary_label(language, &text)); } }); Ok(()) @@ -562,6 +620,47 @@ call a, b, c" ); } + #[test] + fn a_docstring_is_sent_and_only_a_verbatim_sentence_from_it_is_kept() { + let after = "/// Sums three numbers. Used by tests.\nfn total(a: u32, b: u32, c: u32) -> u32 {\n let x = a;\n let y = b;\n let z = c;\n x + y + z\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + super::docstrings::Docstrings + .apply(&file, &mut sides) + .unwrap(); + let id = select(&sides, 3)[0].0; + let answer = |summary: &str| { + let answers = + vec![json!({"id": id, "summary": summary, "pseudocode": "return a + b + c"})]; + json!({"candidates": [{"content": {"parts": [{"text": serde_json::to_string(&answers).unwrap()}]}}]}) + .to_string() + }; + let (endpoint, server) = serve(vec![(200, answer("Sums three numbers."))]); + let summarizer = summarizer(&endpoint, 0); + summarizer.apply(&file, &mut sides).unwrap(); + let bodies = server.join().unwrap(); + assert!( + bodies[0].contains("doc: Sums three numbers. Used by tests."), + "{}", + bodies[0] + ); + assert_eq!( + fold_label(&sides), + "// pseudocode\nSums three numbers.\nreturn a + b + c" + ); + + // A sentence the docstring does not contain is dropped. + let (file, mut sides) = project("a.rs", "", after); + super::docstrings::Docstrings + .apply(&file, &mut sides) + .unwrap(); + let (endpoint, server) = serve(vec![(200, answer("Adds things up."))]); + super::tests::summarizer(&endpoint, 0) + .apply(&file, &mut sides) + .unwrap(); + server.join().unwrap(); + assert_eq!(fold_label(&sides), "// pseudocode\nreturn a + b + c"); + } + #[test] fn transient_failures_are_retried_then_succeed() { let (file, mut sides) = project("a.py", "", LARGE); diff --git a/tests/streaming/check.py b/tests/streaming/check.py index 92bb0a1d5..d0ceb79cc 100755 --- a/tests/streaming/check.py +++ b/tests/streaming/check.py @@ -134,9 +134,19 @@ def walk(regions, parent): walk(source["regions"], None) # Two identities per region: alignment pairs across sides, fold state - # groups toggles. Until a mutation bundles regions, they coincide. - for region in all_regions(source["regions"]): - assert region["fold_state_id"] == region["alignment_id"], region + # groups toggles. They coincide except for a docstring, which shares the + # fold state of a function on its side. + regions = all_regions(source["regions"]) + functions = { + region["fold_state_id"] + for region in regions + if region["kind"] == "fold" and "function" in region.get("tags", []) + } + for region in regions: + if "docstring" in region.get("tags", []): + assert region["fold_state_id"] in functions, region + else: + assert region["fold_state_id"] == region["alignment_id"], region def check_tiling(source): From ca0499ebbeb5dfe3ce887b34f12bdf9a13bb0b06 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 22:13:56 -0700 Subject: [PATCH 38/43] Fold collapsed docstring leaves in the terminal frontend diffr bundles a docstring leaf with its function: it starts collapsed, carries an empty label, and shares the function's fold_state_id. A leaf diffr starts collapsed is now foldable in its own right, and an empty label renders as a bare ellipsis instead of an unchanged-lines count, which only context gaps use. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/regions.test.ts | 32 ++++++++++++++++++++- tui/packages/hunk/src/diffr/regions.ts | 9 ++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index fed2f0692..0c4973230 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createTestDiffFile, fold, leaf, line } from "./fixture"; -import { defaultCollapsed, flatten, foldHeaders, gapIds, hiddenLines } from "./regions"; +import { defaultCollapsed, flatten, foldHeaders, foldIds, gapIds, hiddenLines } from "./regions"; import { dark, rowsForFile } from "./rows"; import type { DiffFile, Region } from "./wire"; /** Rust-style body folds: header and closing brace stay visible, like VS Code. */ @@ -158,3 +158,33 @@ test("regions sharing a fold_state_id collapse and expand as one bundle", () => expect(headers).toEqual([[1, 21, "", true], [3, 21, "// pseudocode\nreturn a + b", true]]); expect(rows.map((r) => r.right!.lineNumber)).toEqual([1, 3, 5]); }); + +test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + // The shape diffr emits for src/category.rs: a one-line docstring leaf tagged `docstring`, + // collapsed with an empty label, sharing fold state 4 with the function fold after it. + const text = [ + "];", // 0 + "/// The built-in rule for a path.", // 1 + "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 + " None", // 3 + "}", // 4 + ].join("\n") + "\n"; + const docstring: Region = { + ...leaf(38, 1, 2), fold_state_id: 4, tags: ["docstring"], visibility: { collapsed: true, label: "" }, + }; + const body = fold(4, [2, 52], [4, 0], [leaf(5, 2, 4)], "// pseudocode\nreturn None", ["body", "function"], true); + file.diff.rhs = { text, syntax: [], regions: [leaf(3, 0, 1), docstring, body, leaf(6, 4, 5)] }; + file.diff.lhs = undefined; + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([4]); + expect(foldIds(file.diff)).toContain(4); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // Line 1 ("];"), then the docstring as one ⋯ fold row, then the signature carrying the body fold. + expect(rows.map((r) => [r.right!.lineNumber, r.right!.fold?.id, r.right!.fold?.label, r.right!.fold?.collapsed])) + .toEqual([[1, undefined, undefined, undefined], [undefined, 4, "", true], [3, 4, "// pseudocode\nreturn None", true], [5, undefined, undefined, undefined]]); + // Expanding the shared id reveals the docstring line too. + const open = rowsForFile(file, 0, "split", dark, new Set()).filter((r) => r.right && !r.right.foldLabel); + expect(open.map((r) => r.right!.lineNumber)).toEqual([1, 2, 3, 4, 5]); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index 2aebc76ac..3d08503bd 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -129,10 +129,13 @@ export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set< } return hidden; } -/** A leaf collapses like a fold when diffr labelled it or tagged it as a context gap. */ -export const foldableLeaf = (leaf: Leaf) => leaf.label !== "" || leaf.tags.includes("unchanged"); +/** A leaf collapses like a fold when diffr starts it collapsed, labelled it, or tagged it as a + * context gap. A docstring bundled with its function arrives collapsed with an empty label. */ +export const foldableLeaf = (leaf: Leaf) => + leaf.collapsed || leaf.label !== "" || leaf.tags.includes("unchanged"); +/** An empty label renders as a bare `⋯`; only a context gap falls back to its line count. */ export const leafLabel = (leaf: Leaf) => - leaf.label || `${leaf.endLine - leaf.startLine} unchanged lines`; + leaf.label || (leaf.tags.includes("unchanged") ? `${leaf.endLine - leaf.startLine} unchanged lines` : ""); /** Fold headers keyed by source line, one per side; a fold wins the header line of its first leaf. */ export function foldHeaders(folds: Fold[], leaves: Leaf[], collapsed: ReadonlySet): Map { const headers = new Map(); From f1330d2d2e1336415bcdba7223cd8effb862d0fe Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 22:14:00 -0700 Subject: [PATCH 39/43] Group documented folds together with their docstrings A docstring leaf sharing its function's fold state now starts that function's unit, so a run of documented test bodies becomes one group again and the group begins at the first docstring. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/mutate/group.rs | 59 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/mutate/group.rs b/src/mutate/group.rs index 409bafdca..e145f82db 100644 --- a/src/mutate/group.rs +++ b/src/mutate/group.rs @@ -6,6 +6,7 @@ //! reveals each child's own collapsed row. Both steps keep ids paired: a //! merge or a group happens on one side only when the other side either //! does the same to the same ids or holds none of them. +use super::docstrings::is_docstring; use super::{ids, is_fold, one_sided, walk, FoldMutation}; use crate::hash::{DftHashMap, DftHashSet}; use crate::protocol::{ @@ -134,18 +135,30 @@ fn removed_label(region: &Region) -> bool { /// A unit is a collapsed fold, with the single-line leaf just before it /// when there is one: the `def` or `fn` line whose body the fold hides. +/// A docstring leaf that shares the fold's fold state belongs to the unit +/// too and starts it. Returns the index of the unit's fold. fn unit_at(regions: &[Region], at: usize, other_ids: &DftHashSet) -> Option { let collapsed_fold = |region: &Region| { is_fold(region) && region.visibility.collapsed && (!removed_label(region) || one_sided(region, other_ids)) }; - let region = regions.get(at)?; - if collapsed_fold(region) { - return Some(at); + let mut index = at; + let docstring = regions + .get(index) + .filter(|region| !is_fold(region) && is_docstring(region)) + .map(|region| region.fold_state_id); + if docstring.is_some() { + index += 1; } - let header = !is_fold(region) && region.range.lines().len() == 1; - (header && regions.get(at + 1).is_some_and(collapsed_fold)).then_some(at + 1) + let bundled = |region: &Region| docstring.is_none_or(|id| region.fold_state_id == id); + let region = regions.get(index)?; + if collapsed_fold(region) && bundled(region) { + return Some(index); + } + let header = !is_fold(region) && !is_docstring(region) && region.range.lines().len() == 1; + let fold = regions.get(index + 1)?; + (header && collapsed_fold(fold) && bundled(fold)).then_some(index + 1) } /// Lines a separator between units may span: blank lines, at most. @@ -175,7 +188,7 @@ fn collapsed_fold_runs(regions: &[Region], other_ids: &DftHashSet, out: &mu while next < regions.len() && !is_fold(®ions[next]) && !regions[next].visibility.collapsed - && unit_at(regions, next, other_ids) != Some(next + 1) + && unit_at(regions, next, other_ids).is_none() { separator_lines += regions[next].range.lines().len(); next += 1; @@ -437,6 +450,40 @@ mod tests { }); } + #[test] + fn documented_test_bodies_group_with_their_docstrings() { + use crate::mutate::collapse::TestBodies; + use crate::mutate::docstrings::{DocstringVisibility, Docstrings}; + let before = "fn keep() {}\n"; + let after = "fn keep() {}\n\n/// First.\n#[test]\nfn a() {\n x();\n y();\n z();\n}\n\n/// Second.\n#[test]\nfn b() {\n x();\n y();\n z();\n}\n\n/// Third.\n#[test]\nfn c() {\n x();\n y();\n z();\n}\n"; + let (file, mut sides) = project("m.rs", before, after); + TestBodies.apply(&file, &mut sides).unwrap(); + Docstrings.apply(&file, &mut sides).unwrap(); + DocstringVisibility.apply(&file, &mut sides).unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let rhs = sides.rhs().unwrap(); + let mut groups = Vec::new(); + walk(&rhs.regions, &mut |region| { + if region.tags == ["group"] { + groups.push(region.clone()); + } + }); + assert_eq!(groups.len(), 1, "one group for the three documented tests"); + let group = &groups[0]; + assert_eq!(group.visibility.label, "3 test bodies"); + let Node::Fold { children } = &group.node else { + panic!("a group is a fold"); + }; + let docstrings = children.iter().filter(|child| is_docstring(child)).count(); + assert_eq!(docstrings, 3, "every docstring is inside the group"); + let first = children.first().unwrap(); + assert!( + is_docstring(first), + "the group starts at the first docstring" + ); + assert_eq!(group.range.start, first.range.start); + } + #[test] fn a_single_collapsed_fold_is_left_alone() { let before = "def a():\n x()\n y()\n z()\n\nkeep = 1\n"; From bbffe27af8211495af4757b8548ff984efd06ff6 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 22:17:46 -0700 Subject: [PATCH 40/43] Pair a line diff's changed block from whichever end reads alike A line diff pairs the lines of a changed block from the top. In src/hook.rs that paired the new fn qualifies signature with an old doc comment sixty lines above the old signature, leaving a long filler gap above a body that still aligned. When a block's two sides differ in length and its last lines read more alike by identifier tokens, pair those instead. Only line-diff fallbacks are realigned; the structural matcher anchors its rows on matched tokens. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/protocol/project.rs | 166 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 011514aab..e6053714d 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -229,6 +229,13 @@ fn regions( (lhs_src, rhs_src), (&result.lhs_positions, &result.rhs_positions), ); + // A line diff pairs the lines of a changed block from the top, whatever + // they say; the structural matcher anchors its rows on matched tokens + // and is left alone. + let rows = match result.file_format { + FileFormat::SupportedLanguage(_) => rows, + _ => align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)), + }; let (lhs_shown, rhs_shown) = shown_lines(&result.hunks); let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), &lhs_shown, &rhs_shown); let lhs_gaps: Vec<(usize, usize)> = runs @@ -323,6 +330,82 @@ pub(crate) fn tree_violation(regions: &[Region]) -> Option { } /// Rows in order, run-length encoded by kind and side presence. +/// Re-pair the lines of each changed block, a maximal stretch of rows +/// between unchanged rows, when its two sides differ in length. A line diff +/// pairs a block's first lines with each other; when the block's last lines +/// read more alike, pair those instead, so a changed signature sits next to +/// the old one right above a body that still aligns. Blocks that are +/// one-sided, equal in length, or read no better from the bottom keep their +/// rows. +fn align_changed_blocks( + rows: &[(Option, Option)], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, + (lhs_lines, rhs_lines): (&[&str], &[&str]), +) -> Vec<(Option, Option)> { + let unchanged = |&(lhs, rhs): &(Option, Option)| matches!((lhs, rhs), (Some(l), Some(r)) if !lhs_novel.contains(&l) && !rhs_novel.contains(&r)); + let mut out = Vec::with_capacity(rows.len()); + let mut at = 0; + while at < rows.len() { + if unchanged(&rows[at]) { + out.push(rows[at]); + at += 1; + continue; + } + let end = rows[at..] + .iter() + .position(unchanged) + .map_or(rows.len(), |offset| at + offset); + let block = &rows[at..end]; + let lhs: Vec = block.iter().filter_map(|&(l, _)| l).collect(); + let rhs: Vec = block.iter().filter_map(|&(_, r)| r).collect(); + let shared = lhs.len().min(rhs.len()); + let similarity = |lhs_from: usize, rhs_from: usize| -> f64 { + (0..shared) + .map(|offset| { + line_similarity( + lhs_lines[lhs[lhs_from + offset]], + rhs_lines[rhs[rhs_from + offset]], + ) + }) + .sum() + }; + let bottom_reads_better = shared > 0 + && lhs.len() != rhs.len() + && similarity(lhs.len() - shared, rhs.len() - shared) > similarity(0, 0); + if bottom_reads_better { + out.extend(lhs[..lhs.len() - shared].iter().map(|&l| (Some(l), None))); + out.extend(rhs[..rhs.len() - shared].iter().map(|&r| (None, Some(r)))); + out.extend( + lhs[lhs.len() - shared..] + .iter() + .zip(&rhs[rhs.len() - shared..]) + .map(|(&l, &r)| (Some(l), Some(r))), + ); + } else { + out.extend_from_slice(block); + } + at = end; + } + out +} + +/// How alike two lines read: the Dice coefficient of their identifier and +/// number tokens, 0 when neither has any. +fn line_similarity(lhs: &str, rhs: &str) -> f64 { + let tokens = |line: &str| -> BTreeSet { + line.split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect() + }; + let (lhs, rhs) = (tokens(lhs), tokens(rhs)); + if lhs.is_empty() && rhs.is_empty() { + return 0.0; + } + 2.0 * lhs.intersection(&rhs).count() as f64 / (lhs.len() + rhs.len()) as f64 +} + fn runs( rows: &[(Option, Option)], lhs_novel: &BTreeSet, @@ -1007,6 +1090,89 @@ mod tests { use crate::options::{DiffOptions, DisplayOptions}; use crate::parse::folds::FoldMatch; + #[test] + fn a_changed_block_pairs_its_last_lines_when_they_read_alike() { + // A changed block: an old doc comment and body, then the old + // signature; the new side has only the new signature. The unchanged + // row after it is the function body. + let lhs_lines = [ + "/// Fill in summaries.", + "fn summarize() {", + "}", + "fn qualifies(fold: Fold) -> bool {", + " true", + ]; + let rhs_lines = ["fn qualifies(region: Region) -> bool {", " true"]; + let rows = vec![ + (Some(0), Some(0)), + (Some(1), None), + (Some(2), None), + (Some(3), None), + (Some(4), Some(1)), + ]; + let lhs_novel: BTreeSet = [0, 1, 2, 3].into_iter().collect(); + let rhs_novel: BTreeSet = [0].into_iter().collect(); + let realigned = + align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)); + assert_eq!( + realigned, + vec![ + (Some(0), None), + (Some(1), None), + (Some(2), None), + (Some(3), Some(0)), + (Some(4), Some(1)), + ] + ); + } + + #[test] + fn a_changed_block_keeps_its_rows_when_the_top_reads_as_well() { + let lhs_lines = ["let a = 1;", "let b = 2;", "done();"]; + let rhs_lines = ["let a = 3;", "done();"]; + let rows = vec![(Some(0), Some(0)), (Some(1), None), (Some(2), Some(1))]; + let lhs_novel: BTreeSet = [0, 1].into_iter().collect(); + let rhs_novel: BTreeSet = [0].into_iter().collect(); + assert_eq!( + align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)), + rows + ); + } + + #[test] + fn line_diff_fallbacks_pair_a_changed_signature_with_the_old_one() { + use crate::options::DiffOptions; + let before = "fn first() {\n one();\n}\n\n/// Old doc.\nfn helper() {\n x();\n y();\n z();\n}\n\nfn check(fold: Fold) -> bool {\n a();\n b();\n c();\n}\n"; + let after = "fn first() {\n one();\n}\n\nfn check(region: Region) -> bool {\n a();\n b();\n c();\n}\n"; + let options = DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }; + let (_, sides) = + crate::mutate::summarize::tests::project_with("m.rs", before, after, options); + let (lhs, rhs) = (sides.lhs().unwrap(), sides.rhs().unwrap()); + let signature = |source: &Source, needle: &str| { + let mut found = None; + crate::mutate::walk(&source.regions, &mut |region| { + if matches!(region.node, Node::Leaf { .. }) + && source + .text + .lines() + .nth(region.range.start.line as usize) + .is_some_and(|line| line.starts_with(needle)) + { + found = Some(region.alignment_id); + } + }); + found.expect("a leaf starts on the signature line") + }; + assert_eq!( + signature(lhs, "fn check("), + signature(rhs, "fn check("), + "the new signature pairs with the old one, not with the removed doc comment" + ); + } + fn refs(lhs: bool, rhs: bool) -> Pairing { let file_ref = FileRef { path: "a.py".to_owned(), From 8420bdbd6e0b46d99689d83f640bd521282aa797 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 12 Sep 2026 22:38:24 -0700 Subject: [PATCH 41/43] Tint collapsed folds by side in the terminal frontend A collapsed fold, its multi-line label and a bare docstring row now take the same change tint Review's bands use: the added tint for a region whose alignment_id is absent on the left (a summarized new function), the removed tint for one absent on the right (a removed body), and the neutral fold background only for paired regions and unchanged gaps. Tests cover the tint for inserted, removed and paired folds, and a docstring bundled with its function opening and closing together from either row by mouse and by zo, zc and za. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- tui/packages/hunk/src/diffr/regions.test.ts | 63 +++++++++++++++---- tui/packages/hunk/src/diffr/regions.ts | 26 +++++++- tui/packages/hunk/src/diffr/rows.ts | 18 +++--- tui/packages/hunk/src/ui/App.test.tsx | 49 ++++++++++++++- tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 9 ++- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 5 +- 6 files changed, 144 insertions(+), 26 deletions(-) diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 0c4973230..bdfaabecd 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -1,8 +1,9 @@ import { expect, test } from "bun:test"; import { createTestDiffFile, fold, leaf, line } from "./fixture"; -import { defaultCollapsed, flatten, foldHeaders, foldIds, gapIds, hiddenLines } from "./regions"; +import { alignmentIds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines } from "./regions"; import { dark, rowsForFile } from "./rows"; import type { DiffFile, Region } from "./wire"; +import { foldBackground } from "../ui/diff/CodeRowView"; /** Rust-style body folds: header and closing brace stay visible, like VS Code. */ export function createFoldedDiffFile(): DiffFile { const file = createTestDiffFile(); @@ -43,7 +44,7 @@ test("folds keep headers visible, hide trailing lines only when nothing follows" expect([...hiddenLines(folds[1], new Set([11, 12]))].sort()).toEqual([2, 3, 6]); // A fold hidden inside a collapsed outer fold hides nothing of its own. expect([...hiddenLines(folds[1], new Set([10, 11]))].sort()).toEqual([1, 2, 3, 4, 5, 6]); - expect(foldHeaders(folds[1], leaves[1], new Set([11])).get(1)).toEqual({ id: 11, label: "Body", collapsed: true }); + expect(foldHeaders(folds[1], leaves[1], new Set([11]), alignmentIds(file.diff)[0]).get(1)).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); }); test("visibility seeds collapsed ids and tags name the context gaps", () => { const file = createFoldedDiffFile(); @@ -73,8 +74,8 @@ test("rows carry fold headers on both layouts and drop hidden lines", () => { const file = createFoldedDiffFile(); const split = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); - expect(split[1].right!.fold).toEqual({ id: 11, label: "Body", collapsed: true }); - expect(split[1].left!.fold).toEqual({ id: 11, label: "Body", collapsed: true }); + expect(split[1].right!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); + expect(split[1].left!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); expect(split[0].right!.fold?.collapsed).toBe(false); const unified = rowsForFile(file, 0, "unified", dark, new Set([10])).filter((r) => r.cell); expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); @@ -99,20 +100,20 @@ test("a collapsed leaf is one fold row with the chevron, its label, and no line if (file.diff.type !== "text") throw new Error(); file.diff.lhs!.regions[0].visibility = file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "1 unchanged line" }; const split = rowsForFile(file, 0, "split", dark, new Set([1])); - expect(split[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true }); + expect(split[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); expect(split[1].left!.lineNumber).toBeUndefined(); - expect(split[1].right!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true }); + expect(split[1].right!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); expect(split.filter((r) => r.left?.fold?.id === 1)).toHaveLength(1); const unified = rowsForFile(file, 0, "unified", dark, new Set([1])); expect(unified[1].cell).toMatchObject({ fold: { id: 1, collapsed: true } }); // Open, the leaf's first line carries the chevron so it can be collapsed again. - expect(rowsForFile(file, 0, "split", dark, new Set())[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: false }); + expect(rowsForFile(file, 0, "split", dark, new Set())[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: false, tint: "neutral" }); // Unlabelled gaps get a computed label; ordinary leaves are not foldable. const tagged = createTestDiffFile(); if (tagged.diff.type !== "text") throw new Error(); tagged.diff.rhs!.regions[0].tags = ["unchanged"]; const rows = rowsForFile(tagged, 0, "split", dark, new Set()); - expect(rows[1].right!.fold).toEqual({ id: 1, label: "1 unchanged lines", collapsed: false }); + expect(rows[1].right!.fold).toEqual({ id: 1, label: "1 unchanged lines", collapsed: false, tint: "neutral" }); expect(rows[1].left!.fold).toBeUndefined(); expect(rows[2].right!.fold).toBeUndefined(); }); @@ -159,11 +160,14 @@ test("regions sharing a fold_state_id collapse and expand as one bundle", () => expect(rows.map((r) => r.right!.lineNumber)).toEqual([1, 3, 5]); }); -test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { +/** + * The shape diffr emits for a new, summarized function (src/category.rs `from_path`, + * src/protocol/project.rs `syntax_spans`): a one-line docstring leaf tagged `docstring`, collapsed + * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. + */ +export function createBundledDiffFile(): DiffFile { const file = createTestDiffFile(); if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); - // The shape diffr emits for src/category.rs: a one-line docstring leaf tagged `docstring`, - // collapsed with an empty label, sharing fold state 4 with the function fold after it. const text = [ "];", // 0 "/// The built-in rule for a path.", // 1 @@ -177,6 +181,11 @@ test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ const body = fold(4, [2, 52], [4, 0], [leaf(5, 2, 4)], "// pseudocode\nreturn None", ["body", "function"], true); file.diff.rhs = { text, syntax: [], regions: [leaf(3, 0, 1), docstring, body, leaf(6, 4, 5)] }; file.diff.lhs = undefined; + return file; +} +test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); const collapsed = defaultCollapsed(file.diff); expect([...collapsed]).toEqual([4]); expect(foldIds(file.diff)).toContain(4); @@ -184,7 +193,39 @@ test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ // Line 1 ("];"), then the docstring as one ⋯ fold row, then the signature carrying the body fold. expect(rows.map((r) => [r.right!.lineNumber, r.right!.fold?.id, r.right!.fold?.label, r.right!.fold?.collapsed])) .toEqual([[1, undefined, undefined, undefined], [undefined, 4, "", true], [3, 4, "// pseudocode\nreturn None", true], [5, undefined, undefined, undefined]]); + // Right side only, so both the ⋯ row and the summary take the added tint. + expect(rows.filter((r) => r.right!.fold).map((r) => r.right!.fold!.tint)).toEqual(["inserted", "inserted"]); // Expanding the shared id reveals the docstring line too. const open = rowsForFile(file, 0, "split", dark, new Set()).filter((r) => r.right && !r.right.foldLabel); expect(open.map((r) => r.right!.lineNumber)).toEqual([1, 2, 3, 4, 5]); }); + +test("a collapsed fold takes its side's change tint when one-sided and stays neutral when paired", () => { + const text = "fn a() {\n x\n}\n"; + const body = (id: number, label: string) => fold(id, [0, 8], [2, 0], [leaf(id + 100, 0, 2)], label, ["body", "function"], true); + const rowsFor = (lhs: Region[] | undefined, rhs: Region[] | undefined) => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = lhs && { text, syntax: [], regions: [...lhs, leaf(9, 2, 3)] }; + file.diff.rhs = rhs && { text, syntax: [], regions: [...rhs, leaf(9, 2, 3)] }; + return rowsForFile(file, 0, "split", dark, defaultCollapsed(file.diff)); + }; + // Inserted: a summary on the right with no counterpart. The header and every label row are green. + const inserted = rowsFor(undefined, [body(7, "// pseudocode\nreturn x")]); + const header = inserted.find((r) => r.right?.fold)!.right!; + expect(header.fold!.tint).toBe("inserted"); + expect(inserted.filter((r) => r.right?.foldLabel).map((r) => r.right!.foldTint)).toEqual(["inserted", "inserted"]); + expect(foldBackground(dark, "inserted")).toBe(dark.addition); + // Removed: the same body only on the left. + const removed = rowsFor([body(7, "2 lines removed")], undefined); + expect(removed.find((r) => r.left?.fold)!.left!.fold!.tint).toBe("removed"); + expect(foldBackground(dark, "removed")).toBe(dark.deletion); + // Paired: the same alignment id on both sides keeps the neutral fold background. + const paired = rowsFor([body(7, "Body")], [body(7, "Body")]); + const both = paired.find((r) => r.left?.fold && r.right?.fold)!; + expect([both.left!.fold!.tint, both.right!.fold!.tint]).toEqual(["neutral", "neutral"]); + expect(foldBackground(dark, "neutral")).toBe(dark.foldBackground); + expect(foldTint(7, 1, new Set([7]))).toBe("neutral"); + expect(foldTint(7, 1, new Set())).toBe("inserted"); + expect(foldTint(7, 0, new Set())).toBe("removed"); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index 3d08503bd..922d40d11 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -30,11 +30,24 @@ export interface Fold { /** Ids of folds nested inside, for recursive fold commands. */ nested: number[]; } +/** The change tint of a fold: a one-sided region takes its side's change colour, a paired one stays neutral. */ +export type FoldTint = "inserted" | "removed" | "neutral"; export interface RowFold { /** The fold-state id: what toggling this header toggles. */ id: number; label: string; collapsed: boolean; + tint: FoldTint; +} +/** One-sided means the region's alignment_id is absent on the other side. */ +export function foldTint(alignmentId: number, side: Side, otherSide: ReadonlySet): FoldTint { + if (otherSide.has(alignmentId)) return "neutral"; + return side ? "inserted" : "removed"; +} +/** Every alignment id on each side, leaves and folds alike. */ +export function alignmentIds(diff: TextDiff): readonly [Set, Set] { + const { leaves, folds } = flatten(diff); + return [0, 1].map((side) => new Set([...leaves[side].map((l) => l.alignmentId), ...folds[side].map((f) => f.alignmentId)])) as unknown as readonly [Set, Set]; } export const sourceLines = (text: string) => text === "" ? [] : text.replace(/\n$/, "").split("\n"); @@ -137,16 +150,23 @@ export const foldableLeaf = (leaf: Leaf) => export const leafLabel = (leaf: Leaf) => leaf.label || (leaf.tags.includes("unchanged") ? `${leaf.endLine - leaf.startLine} unchanged lines` : ""); /** Fold headers keyed by source line, one per side; a fold wins the header line of its first leaf. */ -export function foldHeaders(folds: Fold[], leaves: Leaf[], collapsed: ReadonlySet): Map { +export function foldHeaders( + folds: Fold[], + leaves: Leaf[], + collapsed: ReadonlySet, + otherSide: ReadonlySet, +): Map { const headers = new Map(); for (const leaf of leaves) if (foldableLeaf(leaf)) - headers.set(leaf.startLine, { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: collapsed.has(leaf.foldStateId) }); + headers.set(leaf.startLine, { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: collapsed.has(leaf.foldStateId), + tint: foldTint(leaf.alignmentId, leaf.side, otherSide) }); for (const fold of folds) { const existing = headers.get(fold.headerLine); const existingFold = existing && folds.find((f) => f.foldStateId === existing.id); if (existingFold && existingFold.lastHidden >= fold.lastHidden) continue; - headers.set(fold.headerLine, { id: fold.foldStateId, label: fold.label, collapsed: collapsed.has(fold.foldStateId) }); + headers.set(fold.headerLine, { id: fold.foldStateId, label: fold.label, collapsed: collapsed.has(fold.foldStateId), + tint: foldTint(fold.alignmentId, fold.side, otherSide) }); } return headers; } diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index fe26f0163..88929ce8c 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -3,7 +3,7 @@ import type { DiffFile, Span, SyntaxSpan } from "./wire"; import { filePath } from "./wire"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; -import { flatten, foldHeaders, hiddenLines, leafLabel, novelLeaves, sourceLines, type Leaf, type RowFold } from "./regions"; +import { alignmentIds, flatten, foldHeaders, foldTint, hiddenLines, leafLabel, novelLeaves, sourceLines, type Leaf, type RowFold } from "./regions"; import { loadBundledTheme, type Palette } from "./theme"; export { sourceLines }; export type Layout = "split" | "unified"; @@ -102,7 +102,8 @@ export function rowsForFile( const syntax = [byLine(d.lhs?.syntax ?? []), byLine(d.rhs?.syntax ?? [])]; const { leaves, folds } = flatten(d); const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; - const headers = [foldHeaders(folds[0], leaves[0], collapsed), foldHeaders(folds[1], leaves[1], collapsed)]; + const ids = alignmentIds(d); + const headers = [foldHeaders(folds[0], leaves[0], collapsed, ids[1]), foldHeaders(folds[1], leaves[1], collapsed, ids[0])]; const caches = [new Map(), new Map()]; // Every line of a novel leaf is tinted; the spans inside get the darker word tint on top. const novelSet = novelLeaves(leaves); @@ -167,7 +168,8 @@ export function rowsForFile( flush(); const anchor = (left ?? right)!; const folded = (leaf: Leaf | null): SplitLineCell => leaf - ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: true } } + ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: true, + tint: foldTint(leaf.alignmentId, leaf.side, ids[leaf.side ? 0 : 1]) } } : { kind: "empty", sign: " ", spans: [] }; const key = `${fileIndex}:gap:${anchor.foldStateId}`; if (layout === "split") rows.push({ key, fileIndex, left: folded(left), right: folded(right) }); @@ -282,10 +284,10 @@ function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): V const result: ViewerRow[] = []; const labelLines = (fold: RowFold | undefined) => fold?.collapsed && fold.label.includes("\n") ? fold.label.split("\n") : []; - const labelCell = (text: string | undefined, kind: SplitLineCell["kind"], indent: string): SplitLineCell => + const labelCell = (text: string | undefined, kind: SplitLineCell["kind"], indent: string, fold?: RowFold): SplitLineCell => text === undefined ? { kind: "empty", sign: " ", spans: [] } - : { kind, sign: " ", foldLabel: true, spans: [{ text: indent + text, fg: theme.foldPlaceholder }] }; + : { kind, sign: " ", foldLabel: true, foldTint: fold?.tint, spans: [{ text: indent + text, fg: theme.foldPlaceholder }] }; for (const row of rows) { result.push(row); if (row.cell) { @@ -293,7 +295,7 @@ function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): V const line = row.cell.newLineNumber ?? row.cell.oldLineNumber; const indent = indentOf(texts[row.cell.newLineNumber === undefined ? 0 : 1][(line ?? 1) - 1] ?? "") + " "; lines.forEach((text, i) => result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, - cell: { ...labelCell(text, "context", indent), oldLineNumber: undefined, newLineNumber: undefined } as UnifiedLineCell })); + cell: { ...labelCell(text, "context", indent, row.cell!.fold), oldLineNumber: undefined, newLineNumber: undefined } as UnifiedLineCell })); continue; } if (!row.left || !row.right) continue; @@ -304,8 +306,8 @@ function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): V ]; for (let i = 0; i < Math.max(left.length, right.length); i++) result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, - left: labelCell(left[i], row.left.kind === "empty" ? "context" : row.left.kind, indents[0]), - right: labelCell(right[i], row.right.kind === "empty" ? "context" : row.right.kind, indents[1]) }); + left: labelCell(left[i], row.left.kind === "empty" ? "context" : row.left.kind, indents[0], row.left.fold), + right: labelCell(right[i], row.right.kind === "empty" ? "context" : row.right.kind, indents[1], row.right.fold) }); } return result; } diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 95e5b42c0..70abc4691 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -7,7 +7,7 @@ import { App } from "./App"; import { DiffStore } from "../diffr/store"; import { createTestDiffFile, leaf, line, withIdenticalLines } from "../diffr/fixture"; import type { DiffFile } from "../diffr/wire"; -import { createFoldedDiffFile } from "../diffr/regions.test"; +import { createBundledDiffFile, createFoldedDiffFile } from "../diffr/regions.test"; import { loadBundledTheme } from "../diffr/theme"; const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; const at = (file: DiffFile, path: string) => { @@ -390,3 +390,50 @@ test("the summary strip shows the wire's visible totals, unmoved by folding, and await act(async () => { t.renderer.destroy(); }); } }); + +test("a docstring and its function sharing a fold_state_id open and close together from either row", async () => { + const store = new DiffStore(); + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // Pad past the viewport so z chords can act on a row scrolled to the top. + const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); + file.diff.rhs!.text += tail.join("\n") + "\n"; + file.diff.rhs!.regions.push(leaf(99, 5, 25)); + store.accept(file); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 20 }); + const lines = () => t.captureCharFrame().split("\n"); + const rowOf = (needle: string) => lines().findIndex((l) => l.includes(needle)); + const docOpen = (f: string) => f.includes("/// The built-in rule") && f.includes(" None"); + const docClosed = (f: string) => !f.includes("/// The built-in rule") && !f.includes(" None") && f.includes("fn from_path"); + const chord = async (...keys: string[]) => { for (const key of keys) await act(async () => { t.mockInput.pressKey(key); }); }; + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame(docClosed); + // Mouse, docstring row: its ⋯ row opens the docstring and the body together. + const bare = lines().findIndex((l) => /▸\s+⋯/.test(l)); + expect(bare).toBeGreaterThan(0); + expect(bare).toBeLessThan(rowOf("fn from_path")); + await act(async () => { await t.mockMouse.click(lines()[bare].indexOf("▸"), bare); }); + await t.waitForFrame(docOpen); + // Mouse, function row: its chevron closes both again. + const header = rowOf("fn from_path"); + await act(async () => { await t.mockMouse.click(lines()[header].indexOf("▾"), header); }); + await t.waitForFrame(docClosed); + // Keys: z chords act on the top row, under the sticky file header. Scroll past the header and + // line 1 so the docstring's ⋯ row is the top row; zo / zc / za then act on the pair. + await chord("j", "j"); + await t.waitForFrame((f) => !f.includes("1 ];") && !/▸\s+⋯/.test(f)); + // The top row sits under the sticky header, so judge by the body: open shows it, closed shows + // the pseudocode label instead. + const bodyOpen = (f: string) => f.includes(" None") && !f.includes("// pseudocode"); + const bodyClosed = (f: string) => !f.includes(" None") && f.includes("// pseudocode"); + await chord("z", "o"); + await t.waitForFrame(bodyOpen); + await chord("z", "c"); + await t.waitForFrame(bodyClosed); + await chord("z", "a"); + await t.waitForFrame(bodyOpen); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index a46fc2947..20d66a646 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -9,7 +9,7 @@ import type { } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; import type { Palette } from "../../diffr/theme"; -import type { RowFold } from "../../diffr/regions"; +import type { FoldTint, RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map>(); function color(value: string) { @@ -30,6 +30,11 @@ function styled(spans: RenderSpan[], theme: Palette, bg: string) { })), ); } +/** A collapsed fold takes its side's change tint when it is one-sided, like Review's bands. */ +export function foldBackground(theme: Palette, tint: FoldTint | undefined) { + if (tint === undefined) throw new Error("A collapsed fold row has no tint"); + return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion : theme.foldBackground; +} /** VS Code's showFoldingControls "always": expandable rows keep their chevron visible. */ function chevron(fold: RowFold | undefined) { if (!fold) return " "; @@ -74,7 +79,7 @@ export const CodeRowView = memo(function CodeRowView({ selectedSide === side ? theme.highlight : value.foldLabel || fold?.collapsed - ? theme.foldBackground + ? foldBackground(theme, value.foldTint ?? fold?.tint) : value.moveKind === "moved" ? theme.moved : value.kind === "addition" diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index 746d1db07..28c1aaf07 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -6,7 +6,7 @@ * column math, the highlight worker, geometry — can share these types without importing * the builders themselves. */ -import type { RowFold } from "../../diffr/regions"; +import type { FoldTint, RowFold } from "../../diffr/regions"; type DiffLineMoveKind = "moved"; export interface RenderSpan { @@ -36,6 +36,8 @@ export interface SplitLineCell { fold?: RowFold; /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ foldLabel?: boolean; + /** The tint of the fold a label line belongs to. */ + foldTint?: FoldTint; spans: RenderSpan[]; } @@ -49,5 +51,6 @@ export interface UnifiedLineCell { moveLabel?: boolean; fold?: RowFold; foldLabel?: boolean; + foldTint?: FoldTint; spans: RenderSpan[]; } From b1b7e0843560cbaebc8a6e5c9c9f54bdf78c0863 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:38:56 -0700 Subject: [PATCH 42/43] Pair folds in the diff step, by engine A structural diff pairs folds exactly as the syntax matcher recorded them: parse::folds::pair_matched maps each FoldMatch::Unchanged opposite range to the fold on the other side. A line-diff fallback pairs folds through the line alignment, by header line and then by the lines they hold, in the new line_folds module, which also owns the fallback's changed-block row alignment. DiffResult carries the result as fold_pairs, and the projection only consumes it: pairing no longer lives in protocol/project.rs. Written with Claude Code. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0de57442-ad9b-4c99-960e-4974e629b71d Agent-Session: e046556c-20f0-4bba-832b-b6784a7783c5 Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/engine.rs | 14 ++ src/line_folds.rs | 286 +++++++++++++++++++++++++++++++++ src/main.rs | 2 + src/parse/folds.rs | 34 +++- src/protocol/project.rs | 346 ++++++++++------------------------------ src/summary.rs | 4 + 6 files changed, 425 insertions(+), 261 deletions(-) create mode 100644 src/line_folds.rs diff --git a/src/engine.rs b/src/engine.rs index 3860c53e3..9577590db 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -8,6 +8,7 @@ use crate::diff::unchanged; use crate::display; use crate::display::context::opposite_positions; use crate::display::hunks::{matched_pos_to_hunks, merge_adjacent}; +use crate::line_folds; use crate::line_parser; use crate::lines::MaxLine; use crate::options::{DiffOptions, DisplayOptions, FileArgument}; @@ -90,6 +91,7 @@ fn check_only_text( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], + fold_pairs: vec![], has_byte_changes, has_syntactic_changes: lhs_src != rhs_src, } @@ -135,6 +137,7 @@ pub(crate) fn diff_file_content( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], + fold_pairs: vec![], has_byte_changes: None, has_syntactic_changes: false, }; @@ -186,6 +189,7 @@ pub(crate) fn diff_file_content( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], + fold_pairs: vec![], has_byte_changes, has_syntactic_changes, }; @@ -390,6 +394,15 @@ pub(crate) fn diff_file_content( Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) }; + let fold_pairs = match file_format { + FileFormat::SupportedLanguage(_) => folds::pair_matched(&lhs_folds, &rhs_folds), + _ => line_folds::pair( + &line_folds::fallback_rows((lhs_src, rhs_src), (&lhs_positions, &rhs_positions)), + (lhs_src, rhs_src), + (&lhs_folds, &rhs_folds), + ), + }; + DiffResult { extra_info, display_path: display_path.to_owned(), @@ -401,6 +414,7 @@ pub(crate) fn diff_file_content( hunks, lhs_folds, rhs_folds, + fold_pairs, has_byte_changes, has_syntactic_changes, } diff --git a/src/line_folds.rs b/src/line_folds.rs new file mode 100644 index 000000000..f36bae540 --- /dev/null +++ b/src/line_folds.rs @@ -0,0 +1,286 @@ +//! Fold pairing and row alignment for files that fell back to a line diff. +//! +//! A structural diff pairs folds through the syntax matcher (see +//! `parse::folds::pair_matched`). A line diff has no matcher, so folds pair +//! through the line alignment instead: first by header line, then through +//! the lines they hold. The same alignment, with changed blocks re-paired +//! from whichever end reads alike, is what the projection lays rows out on. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::display::line_layout::{aligned_rows, novel_lines, Row}; +use crate::hash::DftHashMap; +use crate::lines::SourceRange; +use crate::parse::folds::Fold; +use crate::parse::syntax::MatchedPos; + +/// The row alignment of a line-diff fallback: the line diff's rows, with +/// each changed block re-paired by `align_changed_blocks`. +pub(crate) fn fallback_rows( + (lhs_src, rhs_src): (&str, &str), + (lhs_positions, rhs_positions): (&[MatchedPos], &[MatchedPos]), +) -> Vec { + let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); + let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); + let rows = aligned_rows((lhs_src, rhs_src), (lhs_positions, rhs_positions)); + align_changed_blocks( + &rows, + &novel_lines(lhs_positions), + &novel_lines(rhs_positions), + (&lhs_lines, &rhs_lines), + ) +} + +/// Pair the folds of a line-diff fallback, as `(lhs index, rhs index)`. +/// +/// Rule 1: two folds pair when the alignment pairs their header lines and +/// their tags agree. A changed signature is still a paired row, so a +/// function whose header was edited keeps its counterpart. Folds sharing a +/// header line on one side pair in order of span length. +/// +/// Rule 2, for a lhs fold rule 1 left unpaired: the free rhs fold with +/// equal tags that holds the most aligned counterparts of the lhs fold's +/// lines (ties by the nearest header in row order), provided the pair does +/// not cross an existing pair. This keeps a function whose signature was +/// reflowed onto more lines paired with itself. +pub(crate) fn pair( + rows: &[Row], + (lhs_src, rhs_src): (&str, &str), + (lhs_folds, rhs_folds): (&[Fold], &[Fold]), +) -> Vec<(usize, usize)> { + let lhs_spans = spans(lhs_folds, lhs_src.split_terminator('\n').count()); + let rhs_spans = spans(rhs_folds, rhs_src.split_terminator('\n').count()); + let mut lhs_pair: Vec> = vec![None; lhs_folds.len()]; + let mut rhs_pair: Vec> = vec![None; rhs_folds.len()]; + + let aligned: DftHashMap = rows + .iter() + .filter_map(|&(lhs, rhs)| Some((lhs?, rhs?))) + .collect(); + let by_header = |spans: &[Option<(usize, usize)>]| { + let mut map: BTreeMap> = BTreeMap::new(); + for (index, span) in spans.iter().enumerate() { + if let Some((start, _)) = span { + map.entry(*start).or_default().push(index); + } + } + for candidates in map.values_mut() { + candidates.sort_by_key(|&index| { + let (start, end) = spans[index].expect("indexed spans exist"); + std::cmp::Reverse(end - start) + }); + } + map + }; + let rhs_by_header = by_header(&rhs_spans); + for (header, own) in by_header(&lhs_spans) { + let Some(other) = aligned + .get(&header) + .and_then(|line| rhs_by_header.get(line)) + else { + continue; + }; + for lhs_index in own { + let tags = &lhs_folds[lhs_index].tags; + let Some(&rhs_index) = other + .iter() + .find(|&&index| rhs_pair[index].is_none() && rhs_folds[index].tags == *tags) + else { + continue; + }; + lhs_pair[lhs_index] = Some(rhs_index); + rhs_pair[rhs_index] = Some(lhs_index); + } + } + + // Row index of each side's line, for crossing checks and tie-breaks. + let mut row_of_lhs: DftHashMap = DftHashMap::default(); + let mut row_of_rhs: DftHashMap = DftHashMap::default(); + for (index, &(lhs, rhs)) in rows.iter().enumerate() { + if let Some(line) = lhs { + row_of_lhs.entry(line).or_insert(index); + } + if let Some(line) = rhs { + row_of_rhs.entry(line).or_insert(index); + } + } + let header_row = |rows_of: &DftHashMap, span: Option<(usize, usize)>| { + span.and_then(|(start, _)| rows_of.get(&start).copied()) + }; + let mut pairs: Vec<(usize, usize)> = lhs_pair + .iter() + .enumerate() + .filter_map(|(lhs_index, rhs_index)| { + Some(( + header_row(&row_of_lhs, lhs_spans[lhs_index])?, + header_row(&row_of_rhs, rhs_spans[(*rhs_index)?])?, + )) + }) + .collect(); + for lhs_index in 0..lhs_folds.len() { + let Some((lhs_start, lhs_end)) = lhs_spans[lhs_index] else { + continue; + }; + if lhs_pair[lhs_index].is_some() { + continue; + } + let Some(lhs_row) = row_of_lhs.get(&lhs_start).copied() else { + continue; + }; + let mut score: DftHashMap = DftHashMap::default(); + for &(lhs, rhs) in rows { + let (Some(l), Some(r)) = (lhs, rhs) else { + continue; + }; + if l < lhs_start || l >= lhs_end { + continue; + } + for (rhs_index, span) in rhs_spans.iter().enumerate() { + let Some((rhs_start, rhs_end)) = span else { + continue; + }; + if rhs_pair[rhs_index].is_none() + && rhs_folds[rhs_index].tags == lhs_folds[lhs_index].tags + && *rhs_start <= r + && r < *rhs_end + { + *score.entry(rhs_index).or_default() += 1; + } + } + } + let mut candidates: Vec<(usize, usize, usize)> = score + .into_iter() + .filter_map(|(rhs_index, lines)| { + Some(( + rhs_index, + lines, + header_row(&row_of_rhs, rhs_spans[rhs_index])?, + )) + }) + .collect(); + candidates.sort_by_key(|&(rhs_index, lines, rhs_row)| { + ( + std::cmp::Reverse(lines), + rhs_row.abs_diff(lhs_row), + rhs_index, + ) + }); + let Some(&(rhs_index, _, rhs_row)) = candidates + .iter() + .find(|&&(_, _, rhs_row)| !pairs.iter().any(|&(a, b)| (a < lhs_row) != (b < rhs_row))) + else { + continue; + }; + lhs_pair[lhs_index] = Some(rhs_index); + rhs_pair[rhs_index] = Some(lhs_index); + pairs.push((lhs_row, rhs_row)); + } + + lhs_pair + .into_iter() + .enumerate() + .filter_map(|(lhs_index, rhs_index)| Some((lhs_index, rhs_index?))) + .collect() +} + +/// The whole lines each fold covers, or `None` for a fold that hides +/// nothing (a single line). +fn spans(folds: &[Fold], line_count: usize) -> Vec> { + folds + .iter() + .map(|fold| { + let (start, end) = line_span(&fold.range, line_count); + (end - start >= 2).then_some((start, end)) + }) + .collect() +} + +/// Half-open whole lines a range touches, clamped to the file. +pub(crate) fn line_span(range: &SourceRange, line_count: usize) -> (usize, usize) { + let start = range.start.line.as_usize(); + let end = if range.end.byte_column == 0 { + range.end.line.as_usize() + } else { + range.end.line.as_usize() + 1 + }; + ( + start.min(line_count), + end.min(line_count).max(start.min(line_count)), + ) +} + +/// Re-pair the lines of each changed block, a maximal stretch of rows +/// between unchanged rows, when its two sides differ in length. A line diff +/// pairs a block's first lines with each other; when the block's last lines +/// read more alike, pair those instead, so a changed signature sits next to +/// the old one right above a body that still aligns. Blocks that are +/// one-sided, equal in length, or read no better from the bottom keep their +/// rows. +pub(crate) fn align_changed_blocks( + rows: &[Row], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, + (lhs_lines, rhs_lines): (&[&str], &[&str]), +) -> Vec { + let unchanged = |&(lhs, rhs): &Row| matches!((lhs, rhs), (Some(l), Some(r)) if !lhs_novel.contains(&l) && !rhs_novel.contains(&r)); + let mut out = Vec::with_capacity(rows.len()); + let mut at = 0; + while at < rows.len() { + if unchanged(&rows[at]) { + out.push(rows[at]); + at += 1; + continue; + } + let end = rows[at..] + .iter() + .position(unchanged) + .map_or(rows.len(), |offset| at + offset); + let block = &rows[at..end]; + let lhs: Vec = block.iter().filter_map(|&(l, _)| l).collect(); + let rhs: Vec = block.iter().filter_map(|&(_, r)| r).collect(); + let shared = lhs.len().min(rhs.len()); + let similarity = |lhs_from: usize, rhs_from: usize| -> f64 { + (0..shared) + .map(|offset| { + line_similarity( + lhs_lines[lhs[lhs_from + offset]], + rhs_lines[rhs[rhs_from + offset]], + ) + }) + .sum() + }; + let bottom_reads_better = shared > 0 + && lhs.len() != rhs.len() + && similarity(lhs.len() - shared, rhs.len() - shared) > similarity(0, 0); + if bottom_reads_better { + out.extend(lhs[..lhs.len() - shared].iter().map(|&l| (Some(l), None))); + out.extend(rhs[..rhs.len() - shared].iter().map(|&r| (None, Some(r)))); + out.extend( + lhs[lhs.len() - shared..] + .iter() + .zip(&rhs[rhs.len() - shared..]) + .map(|(&l, &r)| (Some(l), Some(r))), + ); + } else { + out.extend_from_slice(block); + } + at = end; + } + out +} + +/// How alike two lines read: the Dice coefficient of their identifier and +/// number tokens, 0 when neither has any. +fn line_similarity(lhs: &str, rhs: &str) -> f64 { + let tokens = |line: &str| -> BTreeSet { + line.split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect() + }; + let (lhs, rhs) = (tokens(lhs), tokens(rhs)); + if lhs.is_empty() && rhs.is_empty() { + return 0.0; + } + 2.0 * lhs.intersection(&rhs).count() as f64 / (lhs.len() + rhs.len()) as f64 +} diff --git a/src/main.rs b/src/main.rs index 64d7a7d1f..0f753ea00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ mod git; mod gitattributes; mod hash; mod hook; +mod line_folds; mod line_parser; mod lines; mod mutate; @@ -466,6 +467,7 @@ fn diff_file( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], + fold_pairs: vec![], has_byte_changes, has_syntactic_changes: false, }; diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 216b12ec0..65b1964e7 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -144,8 +144,40 @@ fn range(node: &Syntax<'_>) -> Option { Some(region) } +/// Pair the folds of a structural diff, as `(lhs index, rhs index)`. +/// +/// The syntax matcher already decided correspondence: `project` records a +/// fold's matched node as `FoldMatch::Unchanged { opposite }`. A lhs fold +/// pairs with the rhs fold whose range is that opposite range, when the rhs +/// fold records the lhs fold back. The matcher may pair folds out of reading +/// order; such a pair is kept, and consumers show it as a move. +pub(crate) fn pair_matched(lhs: &[Fold], rhs: &[Fold]) -> Vec<(usize, usize)> { + let mut rhs_by_range: DftHashMap = DftHashMap::default(); + for (index, fold) in rhs.iter().enumerate() { + rhs_by_range.entry(fold.range).or_insert(index); + } + let mut taken: DftHashSet = DftHashSet::default(); + let mut pairs = Vec::new(); + for (lhs_index, fold) in lhs.iter().enumerate() { + let FoldMatch::Unchanged { opposite } = &fold.match_kind else { + continue; + }; + let Some(&rhs_index) = rhs_by_range.get(opposite) else { + continue; + }; + let mutual = matches!( + &rhs[rhs_index].match_kind, + FoldMatch::Unchanged { opposite } if *opposite == fold.range + ); + if mutual && taken.insert(rhs_index) { + pairs.push((lhs_index, rhs_index)); + } + } + pairs +} + /// Every fold in a parsed side, without any correspondence. Used when the -/// AST match did not run: the projection pairs folds through the line +/// AST match did not run: `line_folds::pair` pairs them through the line /// alignment instead. pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) { for node in nodes { diff --git a/src/protocol/project.rs b/src/protocol/project.rs index e6053714d..7067f0b9c 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -19,8 +19,8 @@ use super::{ use crate::display::hunks::Hunk; use crate::display::line_layout::{aligned_rows, novel_lines}; use crate::hash::DftHashMap; +use crate::line_folds; use crate::line_parser; -use crate::lines; use crate::parse::folds::Fold; use crate::parse::syntax::{MatchKind, MatchedPos}; use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; @@ -212,7 +212,8 @@ struct LeafKey { struct SideFold<'a> { fold: &'a Fold, lines: (usize, usize), - /// Index of the lhs fold this pairs with, on either side. + /// The pair this fold belongs to, as its lhs fold's index in + /// `DiffResult::lhs_folds`, on either side. pair: Option, } @@ -225,16 +226,16 @@ fn regions( let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); let lhs_novel = novel_lines(&result.lhs_positions); let rhs_novel = novel_lines(&result.rhs_positions); - let rows = aligned_rows( - (lhs_src, rhs_src), - (&result.lhs_positions, &result.rhs_positions), + let sources = (lhs_src, rhs_src); + let positions = ( + result.lhs_positions.as_slice(), + result.rhs_positions.as_slice(), ); - // A line diff pairs the lines of a changed block from the top, whatever - // they say; the structural matcher anchors its rows on matched tokens - // and is left alone. + // The structural matcher anchors its rows on matched tokens; a line + // diff's changed blocks are re-paired from whichever end reads alike. let rows = match result.file_format { - FileFormat::SupportedLanguage(_) => rows, - _ => align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)), + FileFormat::SupportedLanguage(_) => aligned_rows(sources, positions), + _ => line_folds::fallback_rows(sources, positions), }; let (lhs_shown, rhs_shown) = shown_lines(&result.hunks); let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), &lhs_shown, &rhs_shown); @@ -249,23 +250,28 @@ fn regions( .filter_map(|run| run.rhs) .collect(); - let mut lhs_folds: Vec> = side_folds(&result.lhs_folds, &lhs_lines) + // Pairs come with the diff; a pair is keyed by its lhs fold's index. + let rhs_key: DftHashMap = result + .fold_pairs + .iter() + .map(|&(lhs, rhs)| (rhs, lhs)) + .collect(); + let lhs_key: DftHashMap = result + .fold_pairs + .iter() + .map(|&(lhs, _)| (lhs, lhs)) + .collect(); + let lhs_folds: Vec> = side_folds(&result.lhs_folds, &lhs_key, &lhs_lines) .into_iter() .filter_map(|fold| fit_to_gaps(fold, &lhs_gaps)) .collect(); - let mut rhs_folds: Vec> = side_folds(&result.rhs_folds, &rhs_lines) + let rhs_folds: Vec> = side_folds(&result.rhs_folds, &rhs_key, &rhs_lines) .into_iter() .filter_map(|fold| fit_to_gaps(fold, &rhs_gaps)) .collect(); let lhs_splits = split_lines(&lhs_folds); let rhs_splits = split_lines(&rhs_folds); let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); - pair_folds( - &mut lhs_folds, - &mut rhs_folds, - &rows, - (&lhs_leaves, &rhs_leaves), - ); let mut ids = Ids::default(); let lhs = tree( @@ -330,82 +336,6 @@ pub(crate) fn tree_violation(regions: &[Region]) -> Option { } /// Rows in order, run-length encoded by kind and side presence. -/// Re-pair the lines of each changed block, a maximal stretch of rows -/// between unchanged rows, when its two sides differ in length. A line diff -/// pairs a block's first lines with each other; when the block's last lines -/// read more alike, pair those instead, so a changed signature sits next to -/// the old one right above a body that still aligns. Blocks that are -/// one-sided, equal in length, or read no better from the bottom keep their -/// rows. -fn align_changed_blocks( - rows: &[(Option, Option)], - lhs_novel: &BTreeSet, - rhs_novel: &BTreeSet, - (lhs_lines, rhs_lines): (&[&str], &[&str]), -) -> Vec<(Option, Option)> { - let unchanged = |&(lhs, rhs): &(Option, Option)| matches!((lhs, rhs), (Some(l), Some(r)) if !lhs_novel.contains(&l) && !rhs_novel.contains(&r)); - let mut out = Vec::with_capacity(rows.len()); - let mut at = 0; - while at < rows.len() { - if unchanged(&rows[at]) { - out.push(rows[at]); - at += 1; - continue; - } - let end = rows[at..] - .iter() - .position(unchanged) - .map_or(rows.len(), |offset| at + offset); - let block = &rows[at..end]; - let lhs: Vec = block.iter().filter_map(|&(l, _)| l).collect(); - let rhs: Vec = block.iter().filter_map(|&(_, r)| r).collect(); - let shared = lhs.len().min(rhs.len()); - let similarity = |lhs_from: usize, rhs_from: usize| -> f64 { - (0..shared) - .map(|offset| { - line_similarity( - lhs_lines[lhs[lhs_from + offset]], - rhs_lines[rhs[rhs_from + offset]], - ) - }) - .sum() - }; - let bottom_reads_better = shared > 0 - && lhs.len() != rhs.len() - && similarity(lhs.len() - shared, rhs.len() - shared) > similarity(0, 0); - if bottom_reads_better { - out.extend(lhs[..lhs.len() - shared].iter().map(|&l| (Some(l), None))); - out.extend(rhs[..rhs.len() - shared].iter().map(|&r| (None, Some(r)))); - out.extend( - lhs[lhs.len() - shared..] - .iter() - .zip(&rhs[rhs.len() - shared..]) - .map(|(&l, &r)| (Some(l), Some(r))), - ); - } else { - out.extend_from_slice(block); - } - at = end; - } - out -} - -/// How alike two lines read: the Dice coefficient of their identifier and -/// number tokens, 0 when neither has any. -fn line_similarity(lhs: &str, rhs: &str) -> f64 { - let tokens = |line: &str| -> BTreeSet { - line.split(|c: char| !(c.is_alphanumeric() || c == '_')) - .filter(|token| !token.is_empty()) - .map(str::to_owned) - .collect() - }; - let (lhs, rhs) = (tokens(lhs), tokens(rhs)); - if lhs.is_empty() && rhs.is_empty() { - return 0.0; - } - 2.0 * lhs.intersection(&rhs).count() as f64 / (lhs.len() + rhs.len()) as f64 -} - fn runs( rows: &[(Option, Option)], lhs_novel: &BTreeSet, @@ -557,33 +487,27 @@ fn fit_to_gaps<'a>(mut fold: SideFold<'a>, gaps: &[(usize, usize)]) -> Option (usize, usize) { - let start = range.start.line.as_usize(); - let end = if range.end.byte_column == 0 { - range.end.line.as_usize() - } else { - range.end.line.as_usize() + 1 - }; - ( - start.min(line_count), - end.min(line_count).max(start.min(line_count)), - ) -} - -fn side_folds<'a>(folds: &'a [Fold], lines: &[&str]) -> Vec> { +/// One side's folds as nested line spans, each carrying the key of the pair +/// it belongs to, if any. +fn side_folds<'a>( + folds: &'a [Fold], + pair_key: &DftHashMap, + lines: &[&str], +) -> Vec> { let spans: Vec<(usize, usize)> = folds .iter() - .map(|fold| line_span(&fold.range, lines.len())) + .map(|fold| line_folds::line_span(&fold.range, lines.len())) .collect(); folds .iter() + .enumerate() .zip(nested_spans(&spans)) // A fold on a single line hides nothing; it is not a region. - .filter_map(|(fold, span)| { + .filter_map(|((index, fold), span)| { span.map(|lines| SideFold { fold, lines, - pair: None, + pair: pair_key.get(&index).copied(), }) }) .collect() @@ -635,142 +559,6 @@ fn nested_spans(spans: &[(usize, usize)]) -> Vec> { out } -/// Two folds correspond when the line alignment pairs their header lines -/// and their tags agree. A changed signature is still a paired row, so a -/// function whose header was edited keeps its counterpart. The rule is the -/// same whichever engine produced the alignment: the structural engine -/// pairs rows through matched tokens, the text diff through its line -/// alignment. Folds sharing a header line on one side, such as a body and -/// a collection opened on the same line, pair in order of span length. -/// Assign fold correspondence. Rule 1: header lines aligned in the row -/// table and equal tags. Rule 2, for a lhs fold rule 1 left unpaired: the -/// rhs fold with equal tags that contains the counterpart of a paired leaf -/// inside it, choosing the candidate holding the most paired lines (ties -/// by the nearest header in row order), provided that fold is free and the -/// pair does not cross an existing pair. This is what keeps a function -/// whose signature was reflowed onto more lines paired with itself. -fn pair_folds( - lhs_folds: &mut [SideFold<'_>], - rhs_folds: &mut [SideFold<'_>], - rows: &[(Option, Option)], - (lhs_leaves, rhs_leaves): (&[Leaf], &[Leaf]), -) { - let aligned: DftHashMap = rows - .iter() - .filter_map(|&(lhs, rhs)| Some((lhs?, rhs?))) - .collect(); - let mut rhs_by_header: BTreeMap> = BTreeMap::new(); - for (index, fold) in rhs_folds.iter().enumerate() { - rhs_by_header.entry(fold.lines.0).or_default().push(index); - } - let span = |fold: &SideFold<'_>| fold.lines.1 - fold.lines.0; - for candidates in rhs_by_header.values_mut() { - candidates.sort_by_key(|&index| std::cmp::Reverse(span(&rhs_folds[index]))); - } - let mut lhs_by_header: BTreeMap> = BTreeMap::new(); - for (index, fold) in lhs_folds.iter().enumerate() { - lhs_by_header.entry(fold.lines.0).or_default().push(index); - } - for (header, mut own) in lhs_by_header { - let Some(other) = aligned - .get(&header) - .and_then(|line| rhs_by_header.get(line)) - else { - continue; - }; - own.sort_by_key(|&index| std::cmp::Reverse(span(&lhs_folds[index]))); - for lhs_index in own { - let tags = &lhs_folds[lhs_index].fold.tags; - let Some(&rhs_index) = other.iter().find(|&&index| { - rhs_folds[index].pair.is_none() && rhs_folds[index].fold.tags == *tags - }) else { - continue; - }; - lhs_folds[lhs_index].pair = Some(lhs_index); - rhs_folds[rhs_index].pair = Some(lhs_index); - } - } - pair_folds_by_content(lhs_folds, rhs_folds, rows, lhs_leaves, rhs_leaves); -} - -/// Rule 2 of `pair_folds`: pair through a shared leaf rather than the -/// header line. Paired leaves carry the same key on both sides. -fn pair_folds_by_content( - lhs_folds: &mut [SideFold<'_>], - rhs_folds: &mut [SideFold<'_>], - rows: &[(Option, Option)], - lhs_leaves: &[Leaf], - rhs_leaves: &[Leaf], -) { - // Row index of each side's line, for crossing checks and tie-breaks. - let mut row_of_lhs: DftHashMap = DftHashMap::default(); - let mut row_of_rhs: DftHashMap = DftHashMap::default(); - for (index, &(lhs, rhs)) in rows.iter().enumerate() { - if let Some(line) = lhs { - row_of_lhs.entry(line).or_insert(index); - } - if let Some(line) = rhs { - row_of_rhs.entry(line).or_insert(index); - } - } - let rhs_leaf_by_key: DftHashMap = rhs_leaves - .iter() - .filter_map(|leaf| leaf.key.map(|key| (key, leaf))) - .collect(); - let contains = |fold: &SideFold<'_>, leaf: &Leaf| { - fold.lines.0 <= leaf.lines.0 && leaf.lines.1 <= fold.lines.1 - }; - // Existing pairs as (lhs header row, rhs header row), to forbid crossings. - let mut pairs: Vec<(usize, usize)> = rhs_folds - .iter() - .filter_map(|rhs| { - let lhs = &lhs_folds[rhs.pair?]; - Some((row_of_lhs[&lhs.lines.0], row_of_rhs[&rhs.lines.0])) - }) - .collect(); - #[allow(clippy::needless_range_loop)] - for lhs_index in 0..lhs_folds.len() { - if lhs_folds[lhs_index].pair.is_some() { - continue; - } - let lhs_fold = &lhs_folds[lhs_index]; - // Paired lines per candidate rhs fold. - let mut score: DftHashMap = DftHashMap::default(); - for leaf in lhs_leaves.iter().filter(|leaf| contains(lhs_fold, leaf)) { - let Some(partner) = leaf.key.and_then(|key| rhs_leaf_by_key.get(&key)) else { - continue; - }; - for (rhs_index, rhs_fold) in rhs_folds.iter().enumerate() { - if rhs_fold.pair.is_none() - && rhs_fold.fold.tags == lhs_fold.fold.tags - && contains(rhs_fold, partner) - { - *score.entry(rhs_index).or_default() += partner.lines.1 - partner.lines.0; - } - } - } - let lhs_row = row_of_lhs[&lhs_fold.lines.0]; - let mut candidates: Vec<(usize, usize)> = score.into_iter().collect(); - candidates.sort_by_key(|&(rhs_index, lines)| { - let rhs_row = row_of_rhs[&rhs_folds[rhs_index].lines.0]; - ( - std::cmp::Reverse(lines), - rhs_row.abs_diff(lhs_row), - rhs_index, - ) - }); - let Some(&(rhs_index, _)) = candidates.iter().find(|&&(rhs_index, _)| { - let rhs_row = row_of_rhs[&rhs_folds[rhs_index].lines.0]; - !pairs.iter().any(|&(a, b)| (a < lhs_row) != (b < rhs_row)) - }) else { - continue; - }; - lhs_folds[lhs_index].pair = Some(lhs_index); - rhs_folds[rhs_index].pair = Some(lhs_index); - pairs.push((lhs_row, row_of_rhs[&rhs_folds[rhs_index].lines.0])); - } -} - fn split_lines(folds: &[SideFold<'_>]) -> BTreeSet { folds .iter() @@ -1112,8 +900,12 @@ mod tests { ]; let lhs_novel: BTreeSet = [0, 1, 2, 3].into_iter().collect(); let rhs_novel: BTreeSet = [0].into_iter().collect(); - let realigned = - align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)); + let realigned = crate::line_folds::align_changed_blocks( + &rows, + &lhs_novel, + &rhs_novel, + (&lhs_lines, &rhs_lines), + ); assert_eq!( realigned, vec![ @@ -1134,7 +926,12 @@ mod tests { let lhs_novel: BTreeSet = [0, 1].into_iter().collect(); let rhs_novel: BTreeSet = [0].into_iter().collect(); assert_eq!( - align_changed_blocks(&rows, &lhs_novel, &rhs_novel, (&lhs_lines, &rhs_lines)), + crate::line_folds::align_changed_blocks( + &rows, + &lhs_novel, + &rhs_novel, + (&lhs_lines, &rhs_lines) + ), rows ); } @@ -1390,7 +1187,7 @@ mod tests { } #[test] - fn folds_pair_through_the_line_alignment_on_either_engine() { + fn folds_pair_on_either_engine() { for options in [ DiffOptions::default(), DiffOptions { @@ -1409,8 +1206,9 @@ mod tests { assert_tiles(lhs); assert_tiles(rhs); let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); - // `f` changed its signature; its header rows still align, so the - // bodies stay paired. `keep` is untouched. `added` is rhs-only. + // `f` changed its signature and stays paired: through the matcher + // on the structural path, through its aligned header line on the + // fallback. `keep` is untouched. `added` is rhs-only. assert_eq!( lhs_folds[&0], rhs_folds[&0], "f pairs across a changed header" @@ -1426,7 +1224,7 @@ mod tests { } #[test] - fn the_alignment_rule_agrees_with_the_matcher_where_headers_align() { + fn structural_folds_pair_exactly_as_the_matcher_recorded() { let result = DiffResult::from_sources_with_options( "a.rs", RUST_LHS, @@ -1447,13 +1245,22 @@ mod tests { } #[test] - fn a_reflowed_signature_pairs_through_a_paired_inner_leaf() { + fn a_line_diff_pairs_a_reflowed_signature_through_its_body() { // The signature moves onto three lines, so the header rows no // longer align, but the body is untouched: still the same function. let lhs = "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; let rhs = "fn f(\n a: u32,\n b: u32,\n) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; - let diff = project("a.rs", lhs, rhs, 3); + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); let (lhs_src, rhs_src) = sources(&diff); let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); assert_eq!(lhs_folds.len(), 1); @@ -1466,13 +1273,22 @@ mod tests { } #[test] - fn content_pairing_picks_the_candidate_with_the_most_paired_lines() { + fn line_diff_content_pairing_picks_the_candidate_with_the_most_paired_lines() { // Both rhs functions have reflowed signatures and both hold a line // `f` also holds (`let x = ...`), but only `g` holds `f`'s long // body. Order is preserved so nothing crosses. let lhs = "fn h(b: u32) -> u32 {\n let x = b;\n b\n}\n\nfn f(a: u32) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; let rhs = "fn h(\n b: u32,\n) -> u32 {\n let x = b;\n b\n}\n\nfn g(\n a: u32,\n) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; - let diff = project("a.rs", lhs, rhs, 3); + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); let (lhs_src, rhs_src) = sources(&diff); let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); // Fold headers sit on the `{` line: lhs h=0, f=5; rhs h=2, g=9. @@ -1481,13 +1297,22 @@ mod tests { } #[test] - fn content_pairing_never_crosses_an_existing_pair() { + fn line_diff_content_pairing_never_crosses_an_existing_pair() { // `keep` pairs by header on both sides. `f` moved below `keep` on // the rhs with a reflowed signature; pairing it would cross `keep`, // so it stays one-sided on each side. let lhs = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; let rhs = "fn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn f(\n a: u32,\n) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n"; - let diff = project("a.rs", lhs, rhs, 3); + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); let (lhs_src, rhs_src) = sources(&diff); let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); assert_eq!(lhs_folds[&6], rhs_folds[&0], "keep pairs by header"); @@ -1834,6 +1659,7 @@ mod tests { hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], + fold_pairs: vec![], lhs_positions: vec![], rhs_positions: vec![], has_byte_changes: Some((3, 5)), diff --git a/src/summary.rs b/src/summary.rs index 25e049fc2..8e72786a5 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -46,6 +46,10 @@ pub(crate) struct DiffResult { pub(crate) hunks: Vec, pub(crate) lhs_folds: Vec, pub(crate) rhs_folds: Vec, + /// Corresponding folds as `(lhs index, rhs index)`: from the syntax + /// matcher for a structural diff, from the line alignment for a + /// line-diff fallback. + pub(crate) fold_pairs: Vec<(usize, usize)>, pub(crate) lhs_positions: Vec, pub(crate) rhs_positions: Vec, From cfc25efba06cd0e133221bcf894ddad37b4de115 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 21:18:17 -0700 Subject: [PATCH 43/43] Record line-diff fold partners on the folds DiffResult.fold_pairs held a second copy of fold correspondence as index pairs. It existed because the line-diff path found partners but never wrote them onto the folds the way the syntax matcher does. line_folds::pair now sets FoldMatch::Unchanged { opposite } on both folds of each pair, the field goes away, and the projection reads every file's partners from the folds through parse::folds::pair_matched. No output change: per-file function fold pairing across main..HEAD is identical before and after. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- src/engine.rs | 18 ++++++++---------- src/line_folds.rs | 23 +++++++++++++++-------- src/main.rs | 1 - src/parse/folds.rs | 8 +++++--- src/protocol/project.rs | 19 ++++++------------- src/summary.rs | 4 ---- 6 files changed, 34 insertions(+), 39 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 9577590db..370a86127 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -91,7 +91,6 @@ fn check_only_text( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - fold_pairs: vec![], has_byte_changes, has_syntactic_changes: lhs_src != rhs_src, } @@ -137,7 +136,6 @@ pub(crate) fn diff_file_content( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - fold_pairs: vec![], has_byte_changes: None, has_syntactic_changes: false, }; @@ -189,7 +187,6 @@ pub(crate) fn diff_file_content( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - fold_pairs: vec![], has_byte_changes, has_syntactic_changes, }; @@ -394,14 +391,16 @@ pub(crate) fn diff_file_content( Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) }; - let fold_pairs = match file_format { - FileFormat::SupportedLanguage(_) => folds::pair_matched(&lhs_folds, &rhs_folds), - _ => line_folds::pair( + // A structural diff's folds carry the partner the matcher recorded. A + // line-diff fallback's folds arrive unpaired; pair them through the line + // alignment and record the partner on the folds the same way. + if !matches!(file_format, FileFormat::SupportedLanguage(_)) { + line_folds::pair( &line_folds::fallback_rows((lhs_src, rhs_src), (&lhs_positions, &rhs_positions)), (lhs_src, rhs_src), - (&lhs_folds, &rhs_folds), - ), - }; + (&mut lhs_folds, &mut rhs_folds), + ); + } DiffResult { extra_info, @@ -414,7 +413,6 @@ pub(crate) fn diff_file_content( hunks, lhs_folds, rhs_folds, - fold_pairs, has_byte_changes, has_syntactic_changes, } diff --git a/src/line_folds.rs b/src/line_folds.rs index f36bae540..7390ca31b 100644 --- a/src/line_folds.rs +++ b/src/line_folds.rs @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::display::line_layout::{aligned_rows, novel_lines, Row}; use crate::hash::DftHashMap; use crate::lines::SourceRange; -use crate::parse::folds::Fold; +use crate::parse::folds::{Fold, FoldMatch}; use crate::parse::syntax::MatchedPos; /// The row alignment of a line-diff fallback: the line diff's rows, with @@ -46,8 +46,8 @@ pub(crate) fn fallback_rows( pub(crate) fn pair( rows: &[Row], (lhs_src, rhs_src): (&str, &str), - (lhs_folds, rhs_folds): (&[Fold], &[Fold]), -) -> Vec<(usize, usize)> { + (lhs_folds, rhs_folds): (&mut [Fold], &mut [Fold]), +) { let lhs_spans = spans(lhs_folds, lhs_src.split_terminator('\n').count()); let rhs_spans = spans(rhs_folds, rhs_src.split_terminator('\n').count()); let mut lhs_pair: Vec> = vec![None; lhs_folds.len()]; @@ -176,11 +176,18 @@ pub(crate) fn pair( pairs.push((lhs_row, rhs_row)); } - lhs_pair - .into_iter() - .enumerate() - .filter_map(|(lhs_index, rhs_index)| Some((lhs_index, rhs_index?))) - .collect() + for (lhs_index, rhs_index) in lhs_pair.into_iter().enumerate() { + let Some(rhs_index) = rhs_index else { + continue; + }; + let (lhs_range, rhs_range) = (lhs_folds[lhs_index].range, rhs_folds[rhs_index].range); + lhs_folds[lhs_index].match_kind = FoldMatch::Unchanged { + opposite: rhs_range, + }; + rhs_folds[rhs_index].match_kind = FoldMatch::Unchanged { + opposite: lhs_range, + }; + } } /// The whole lines each fold covers, or `None` for a fold that hides diff --git a/src/main.rs b/src/main.rs index 0f753ea00..256a728c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -467,7 +467,6 @@ fn diff_file( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - fold_pairs: vec![], has_byte_changes, has_syntactic_changes: false, }; diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 65b1964e7..36b9575ae 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -22,7 +22,9 @@ pub(crate) struct Fold { #[derive(Debug, Clone)] pub(crate) enum FoldMatch { - /// A corresponding fold, whose contents may differ. Collapsed state is client-owned. + /// A corresponding fold, whose contents may differ: recorded by the syntax + /// matcher, or by the line alignment when the file fell back to a line diff. + /// Collapsed state is client-owned. Unchanged { opposite: SourceRange, }, @@ -177,8 +179,8 @@ pub(crate) fn pair_matched(lhs: &[Fold], rhs: &[Fold]) -> Vec<(usize, usize)> { } /// Every fold in a parsed side, without any correspondence. Used when the -/// AST match did not run: `line_folds::pair` pairs them through the line -/// alignment instead. +/// AST match did not run: `line_folds::pair` then records partners on them +/// through the line alignment. pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) { for node in nodes { folds.extend(project(node, ChangeKind::Novel)); diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 7067f0b9c..5e94a98bf 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -21,7 +21,7 @@ use crate::display::line_layout::{aligned_rows, novel_lines}; use crate::hash::DftHashMap; use crate::line_folds; use crate::line_parser; -use crate::parse::folds::Fold; +use crate::parse::folds::{self, Fold}; use crate::parse::syntax::{MatchKind, MatchedPos}; use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; use crate::summary::{DiffResult, FileContent, FileFormat}; @@ -250,17 +250,11 @@ fn regions( .filter_map(|run| run.rhs) .collect(); - // Pairs come with the diff; a pair is keyed by its lhs fold's index. - let rhs_key: DftHashMap = result - .fold_pairs - .iter() - .map(|&(lhs, rhs)| (rhs, lhs)) - .collect(); - let lhs_key: DftHashMap = result - .fold_pairs - .iter() - .map(|&(lhs, _)| (lhs, lhs)) - .collect(); + // Each fold records its partner; a pair is keyed by its lhs fold's index. + let fold_pairs = folds::pair_matched(&result.lhs_folds, &result.rhs_folds); + let rhs_key: DftHashMap = + fold_pairs.iter().map(|&(lhs, rhs)| (rhs, lhs)).collect(); + let lhs_key: DftHashMap = fold_pairs.iter().map(|&(lhs, _)| (lhs, lhs)).collect(); let lhs_folds: Vec> = side_folds(&result.lhs_folds, &lhs_key, &lhs_lines) .into_iter() .filter_map(|fold| fit_to_gaps(fold, &lhs_gaps)) @@ -1659,7 +1653,6 @@ mod tests { hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - fold_pairs: vec![], lhs_positions: vec![], rhs_positions: vec![], has_byte_changes: Some((3, 5)), diff --git a/src/summary.rs b/src/summary.rs index 8e72786a5..25e049fc2 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -46,10 +46,6 @@ pub(crate) struct DiffResult { pub(crate) hunks: Vec, pub(crate) lhs_folds: Vec, pub(crate) rhs_folds: Vec, - /// Corresponding folds as `(lhs index, rhs index)`: from the syntax - /// matcher for a structural diff, from the line alignment for a - /// line-diff fallback. - pub(crate) fold_pairs: Vec<(usize, usize)>, pub(crate) lhs_positions: Vec, pub(crate) rhs_positions: Vec,