diff --git a/TODO.md b/TODO.md index 3f5f540..df1e4a5 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ - [x] bug: diff view - git blame doesn't show in diff buffers for some reason. - [x] feature: diff view - command to copy all review comments in the diff to the configured clipboard. - format should be the same as when printed via the cli -- [ ] improve: diff view - instead of a tab name following the format `Diff · + - · `, let's make it `Diff · + -`. +- [x] improve: diff view - instead of a tab name following the format `Diff · + - · `, let's make it `Diff · + -`. - let's also insert another element in the top-right status bar that displays the total changes across the diff, so we don't have only per-file changes for the currently selected file - [ ] feature: diff view - GitHub code review integration after the local review UX is usable. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. diff --git a/docs/configuration.md b/docs/configuration.md index e1b5443..b74371e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,7 +121,7 @@ The renderer inserts one unstyled blank column between visible items in each gro Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported. -Defaults put `left: [:tabs]` and `right: [:shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: +Defaults put `left: [:tabs]` and `right: [:diff, :shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus diff totals, live battery charge, and shell status below: ```ruby Vamp.configure do |config| @@ -132,9 +132,9 @@ Vamp.configure do |config| position: { padding: 1, leading_edge: "░▓", trailing_edge: "" } } config.bar.bottom = { - left: [:tabs], center: [], right: [:shell, :battery], + left: [:tabs], center: [], right: [:diff, :shell, :battery], tabs: { - title: "{index} {view}{name}{modified}", padding: 1, + title: "{index} {view}{name}{changes}{modified}", padding: 1, leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" }, battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } @@ -142,11 +142,11 @@ Vamp.configure do |config| end ``` -Builtins are `:tabs`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, running shell command/spinner, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. +Builtins are `:tabs`, `:diff`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, whole-comparison line counts, battery charge, running shell command/spinner, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row. -Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · +N -M · "` for a loaded comparison file, counting added and removed lines; revision selectors and files still loading use `"Diff · "`, and ordinary tabs use `""`. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. +Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, `{changes}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, selected-file line counts, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` for comparisons and revision selectors, or `""` for ordinary tabs. `{changes}` expands to `" +N -M"` for a loaded comparison file and `""` otherwise. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{changes}{modified}"`; custom templates must include `{changes}` to display per-file counts. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. `:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated. @@ -154,7 +154,7 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}` `margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits. -`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, and shell status. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. +`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, diff totals, and shell status. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs, diff totals, and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar. @@ -164,6 +164,8 @@ Move `:battery` to any anchor group or remove it from both bars to stop battery `:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators. +`:diff` displays `Diff +N -M` for the active comparison, including unopened and filtered-out files, and hides in revision selectors and ordinary tabs. Live edits and undo/redo update totals. An empty comparison shows `Diff +0 -0`; incomplete statistics show `Diff …`, never a partial total. It defaults left of shell status and battery; move it to either bar or omit it like any builtin. See [counting semantics](editing.md#repository-diff). + ### Command and search popup anchors Configure the two input windows independently: diff --git a/docs/development.md b/docs/development.md index cebfe6c..d707b20 100644 --- a/docs/development.md +++ b/docs/development.md @@ -68,6 +68,8 @@ Clipboard checks use isolated providers rather than reading or overwriting the d Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed. +Diff-stat checks cover unopened frozen files, additions/deletions/renames, binary/mode changes, CRLF/EOF, refresh, oversized-file laziness, filtering, live edits/undo, and inactive tabs. Live PTY smoke opens a two-file Git Working comparison, observes selected-file `+2 -1` alongside whole-diff `+2 -2` before opening the second file, edits and undoes text, switches to an ordinary tab, and exits with source bytes and HEAD unchanged. + Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes. Review clipboard checks cover System/Separate routing with isolated providers, exact Unicode excerpts, resolved/stale comments across files, retained unsaved drafts, unchanged storage revisions, and empty/invalid/failed-copy preservation. Live PTY smoke imports a review, copies it, pastes into an empty buffer, and verifies the saved bytes exactly match CLI `show --all`; default CLI output still excludes resolved comments. diff --git a/docs/editing.md b/docs/editing.md index 8b0973f..6843050 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -442,7 +442,9 @@ Comparisons start in **split, vertical (left/right)** view by default. In Normal Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text. -Default diff tab titles show `Diff · +N -M · filename`, with added and removed line counts for the selected file, not the whole comparison. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit counts until their text diff is ready. Custom chrome templates include this prefix through `{view}`. +Default diff tab titles show `Diff · filename +N -M`, with added and removed line counts for the selected file. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit per-file counts until their text diff is ready. Custom chrome templates use `{view}{name}{changes}` for this layout. + +The default top-right `:diff` segment shows `Diff +N -M` across the entire active comparison, including unopened and filtered-out files. Unopened files use native Git text statistics from frozen snapshot blobs; loaded files use their displayed hunks, including live edits. Binary and mode-only changes contribute no text lines. Git's binary detection and diff algorithm can differ from the renderer for non-UTF-8 content, late NUL bytes, or difficult comparisons, so opening such a file can adjust totals. Oversized blobs, unmaterialized jj conflict projections, or failed statistics collection leave totals as `Diff …` until all counts are known; partial sums are never shown as complete totals. For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions. diff --git a/examples/config.rb b/examples/config.rb index cfa1f1b..85a9d7d 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -29,9 +29,9 @@ Vamp.configure do |config| position: { padding: 1, leading_edge: "░▓", trailing_edge: "" } } config.bar.bottom = { - left: [:tabs], center: [], right: [:shell, :battery], + left: [:tabs], center: [], right: [:diff, :shell, :battery], tabs: { - title: "{index} {view}{name}{modified}", padding: 1, + title: "{index} {view}{name}{changes}{modified}", padding: 1, leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" }, battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } diff --git a/src/chrome.rs b/src/chrome.rs index 6c3369a..9e07e86 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -117,9 +117,10 @@ impl ChromeLine { Self { left: vec!["tabs".into()], center: Vec::new(), - right: vec!["shell".into(), "battery".into()], + right: vec!["diff".into(), "shell".into(), "battery".into()], segments: BTreeMap::from([ ("tabs".into(), default_segment("tabs")), + ("diff".into(), default_segment("diff")), ("battery".into(), default_segment("battery")), ("shell".into(), default_segment("shell")), ]), @@ -194,17 +195,20 @@ impl ChromeLine { fn default_segment(name: &str) -> SegmentConfig { let mut segment = SegmentConfig::default(); - if matches!(name, "tabs" | "position" | "battery" | "shell") { + if matches!(name, "tabs" | "position" | "battery" | "shell" | "diff") { segment.leading_edge = "░▓".into(); } - if matches!(name, "tabs" | "mode" | "shell") { + if matches!(name, "tabs" | "mode" | "shell" | "diff") { segment.trailing_edge = "▓░".into(); } - if matches!(name, "tabs" | "mode" | "position" | "battery" | "shell") { + if matches!( + name, + "tabs" | "mode" | "position" | "battery" | "shell" | "diff" + ) { segment.padding = 1; } if name == "tabs" { - segment.title = "{index} {view}{name}{modified}".into(); + segment.title = "{index} {view}{name}{changes}{modified}".into(); segment.first_leading_edge = Some(String::new()); } segment @@ -233,9 +237,12 @@ fn validate_title(title: &str) -> Result<(), String> { } let end = rest.find('}').ok_or("title contains an unmatched '{'")?; let placeholder = &rest[1..end]; - if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") { + if !matches!( + placeholder, + "index" | "view" | "name" | "path" | "changes" | "modified" + ) { return Err(format!( - "unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified" + "unknown title placeholder {{{placeholder}}}; expected index, view, name, path, changes, or modified" )); } rest = &rest[end + 1..]; diff --git a/src/diff.rs b/src/diff.rs index 9a500c6..7bdee2a 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -135,6 +135,8 @@ pub struct ChangedFile { pub status: FileStatus, /// False means text, EOF flags, hunks and notices have not been prepared. pub loaded: bool, + /// Frozen (added, removed) counts for catalog entries whose text is unloaded. + pub stats: Option<(usize, usize)>, pub before: Vec, pub after: Vec, pub before_eof: bool, @@ -142,6 +144,17 @@ pub struct ChangedFile { pub hunks: Vec, pub notice: Option, } +impl ChangedFile { + pub(crate) fn line_counts(&self) -> Option<(usize, usize)> { + if self.loaded { + Some(self.hunks.iter().fold((0, 0), |(added, removed), hunk| { + (added + hunk.after_len, removed + hunk.before_len) + })) + } else { + self.stats + } + } +} #[derive(Clone, Debug, PartialEq, Eq)] pub struct BlameRevision { pub store: PathBuf, @@ -690,6 +703,7 @@ impl DiffState { comparison.root = root.clone(); comparison.files.resize_with(paths.len(), || ChangedFile { review_revisions: None, + stats: None, path: PathBuf::new(), old_path: None, status: FileStatus::Modified, diff --git a/src/repository.rs b/src/repository.rs index fdbf392..c8fcfcd 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -2817,7 +2817,7 @@ impl Repository { } fn build_comparison( - &self, + &mut self, prepared: &mut PreparedComparison, same: bool, ) -> Result { @@ -2835,6 +2835,7 @@ impl Repository { } files.push(ChangedFile { review_revisions: None, + stats: None, path: path.clone(), old_path: None, status: if a.is_none() { @@ -2887,11 +2888,18 @@ impl Repository { Ok(self.comparison_catalog(prepared)) } - fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison { + fn comparison_catalog(&mut self, prepared: &PreparedComparison) -> Comparison { + let stats = if prepared.files.is_empty() { + Ok(Vec::new()) + } else { + self.ensure_private() + .and_then(|()| self.comparison_stats(prepared)) + }; let files = prepared .files .iter() - .map(|(path, old_path, status)| ChangedFile { + .enumerate() + .map(|(index, (path, old_path, status))| ChangedFile { review_revisions: Some([ review_file_revision(prepared.left.get(old_path.as_ref().unwrap_or(path))), review_file_revision(prepared.right.get(path)), @@ -2900,12 +2908,16 @@ impl Repository { old_path: old_path.clone(), status: *status, loaded: false, + stats: stats.as_ref().ok().and_then(|stats| stats[index]), before: vec![], after: vec![], before_eof: false, after_eof: false, hunks: vec![], - notice: None, + notice: stats + .as_ref() + .err() + .map(|error| format!("Line statistics unavailable: {error}")), }) .collect(); let editable_paths = if prepared.editable_after { @@ -2934,6 +2946,125 @@ impl Repository { } } + fn comparison_stats( + &self, + prepared: &PreparedComparison, + ) -> Result>, String> { + // ponytail: unopened counts use Git's text/Myers rules; share the renderer's + // counter if exact binary-detection and pathological-diff parity is needed. + let store = &self.private.as_ref().unwrap().1; + let sides: Vec<_> = prepared + .files + .iter() + .map(|(path, old_path, _)| { + [ + prepared.left.get(old_path.as_ref().unwrap_or(path)), + prepared.right.get(path), + ] + }) + .collect(); + let objects: BTreeSet<_> = sides + .iter() + .flatten() + .flatten() + .filter(|file| file.projection.is_none()) + .filter_map(|file| file.object.as_ref().map(|(_, id)| id.as_str())) + .collect(); + let mut sizes = BTreeMap::new(); + if !objects.is_empty() { + let mut input = Vec::new(); + for id in objects { + writeln!(&mut input, "{id}").unwrap(); + } + let mut command = git_command(store); + command.arg("cat-file").arg("--batch-check"); + let output = text(checked(run(command, input, &self.cancelled)?)?)?; + for line in output.lines() { + let fields: Vec<_> = line.split(' ').collect(); + if fields.len() != 3 || fields[1] != "blob" { + return Err("Invalid statistics blob metadata".into()); + } + let size = fields[2] + .parse::() + .map_err(|_| "Invalid statistics blob size")?; + sizes.insert(fields[0].to_owned(), size); + } + } + let mut stats = vec![Some((0, 0)); sides.len()]; + let mut trees = [Vec::new(), Vec::new()]; + for (index, files) in sides.iter().enumerate() { + self.check_cancelled()?; + if files.iter().flatten().any(|file| { + file.projection.is_some() + || file.object.as_ref().is_some_and(|(_, id)| { + sizes + .get(id) + .is_none_or(|size| *size > COMPARISON_FILE_LIMIT) + }) + }) { + stats[index] = None; + continue; + } + for (side, file) in files.iter().enumerate() { + if let Some((_, id)) = file.and_then(|file| file.object.as_ref()) { + // Flat synthetic paths avoid attributes, quoting and rename detection. + // Normalize modes: the renderer compares link targets as ordinary text. + write!(&mut trees[side], "100644 blob {id}\t{index}\0").unwrap(); + } + } + } + if trees.iter().all(Vec::is_empty) { + return Ok(stats); + } + let mut ids = Vec::new(); + for tree in trees { + let mut command = git_command(store); + command.args(["mktree", "-z"]); + ids.push(text(checked(run(command, tree, &self.cancelled)?)?)?); + } + let mut command = git_command(store); + command + .args(["-c", "core.attributesFile=/dev/null"]) + .args([ + "diff-tree", + "--numstat", + "-z", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + "--diff-algorithm=myers", + "--no-indent-heuristic", + ]) + .args(ids.iter().map(|id| id.trim())) + .env("GIT_ATTR_NOSYSTEM", "1") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null"); + let output = checked(run(command, Vec::new(), &self.cancelled)?)?; + for record in output.split(|byte| *byte == 0).filter(|r| !r.is_empty()) { + let record = std::str::from_utf8(record).map_err(|_| "Invalid line statistics")?; + let fields: Vec<_> = record.split('\t').collect(); + if fields.len() != 3 { + return Err("Invalid line statistics".into()); + } + let index = fields[2] + .parse::() + .ok() + .filter(|index| *index < stats.len()) + .ok_or("Invalid line statistics index")?; + stats[index] = Some(if fields[0] == "-" && fields[1] == "-" { + (0, 0) + } else { + ( + fields[0].parse().map_err(|_| "Invalid added line count")?, + fields[1] + .parse() + .map_err(|_| "Invalid removed line count")?, + ) + }); + } + Ok(stats) + } + /// Prepare one catalog entry using its original comparison's frozen sources. pub fn load_file( &mut self, @@ -3044,6 +3175,7 @@ impl Repository { notices.dedup(); Ok(ChangedFile { review_revisions: None, + stats: None, path: path.to_path_buf(), old_path: None, status, diff --git a/src/tui.rs b/src/tui.rs index 74d727f..11a1d40 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1643,8 +1643,8 @@ fn chrome_title( }; match field { "index" => write!(title, "{index}").unwrap(), - "view" if diff.is_some() => { - title.push_str("Diff · "); + "view" if diff.is_some() => title.push_str("Diff · "), + "changes" => { if let Some(diff) = diff && diff.phase == Phase::View && let Some(file) = diff @@ -1652,12 +1652,9 @@ fn chrome_title( .as_ref() .and_then(|comparison| comparison.files.get(diff.file)) && file.loaded + && let Some((added, removed)) = file.line_counts() { - let (added, removed) = - file.hunks.iter().fold((0, 0), |(added, removed), hunk| { - (added + hunk.after_len, removed + hunk.before_len) - }); - write!(title, "+{added} -{removed} · ").unwrap(); + write!(title, " +{added} -{removed}").unwrap(); } } "name" => title.push_str(&name), @@ -1783,6 +1780,24 @@ fn chrome_items<'a>( } else { String::new() }), + "diff" => { + let Some(comparison) = editor + .workspace_diff() + .filter(|diff| diff.phase == Phase::View) + .and_then(|diff| diff.comparison.as_ref()) + else { + continue; + }; + match comparison + .files + .iter() + .try_fold((0, 0), |(added, removed), file| { + file.line_counts().map(|(a, r)| (added + a, removed + r)) + }) { + Some((added, removed)) => Cow::Owned(format!("Diff +{added} -{removed}")), + None => Cow::Borrowed("Diff …"), + } + } "battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()), "shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()), _ => Cow::Borrowed(editor.label(name)), diff --git a/tests/repository.rs b/tests/repository.rs index 159b801..07ad084 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -275,6 +275,93 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() { } } +#[test] +fn unopened_catalog_counts_frozen_text_but_not_binary_modes_or_exact_renames() { + let fixture = Fixture::new(); + let root = fixture.dir("stats"); + init_git(&root); + for (path, bytes) in [ + ("first", &b"old\nkeep\n"[..]), + ("second", b"remove\nkeep\n"), + ("removed", b"gone\n"), + ("rename-old", b"move\n"), + ("binary", b"\0old"), + ("mode", b"script\n"), + ("crlf", b"a\r\n"), + ("eof", b"same\n"), + ] { + fs::write(root.join(path), bytes).unwrap(); + } + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "before"]); + let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); + for (path, bytes) in [ + ("first", &b"new\nextra\nkeep\n"[..]), + ("second", b"keep\n"), + ("added", b"one\nlast"), + ("binary", b"\0new"), + ("crlf", b"b\r\n"), + ("eof", b"same"), + ] { + fs::write(root.join(path), bytes).unwrap(); + } + fs::remove_file(root.join("removed")).unwrap(); + fs::rename(root.join("rename-old"), root.join("-renamed\t\n")).unwrap(); + fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); + let mut repository = open(&root); + let catalog = repository + .compare( + 1, + &before, + &working(RepoKind::Git), + ComparisonMode::Snapshots, + &[], + ) + .unwrap(); + assert!(catalog.files.iter().all(|file| !file.loaded + && file.before.is_empty() + && file.after.is_empty() + && file.hunks.is_empty())); + for (path, counts) in [ + ("first", (2, 1)), + ("second", (0, 1)), + ("added", (2, 0)), + ("removed", (0, 1)), + ("-renamed\t\n", (0, 0)), + ("binary", (0, 0)), + ("mode", (0, 0)), + ("crlf", (1, 1)), + ("eof", (1, 1)), + ] { + assert_eq!( + changed(&catalog, Path::new(path)).stats, + Some(counts), + "{path}" + ); + } + assert_eq!( + changed(&catalog, Path::new("-renamed\t\n")).status, + FileStatus::Renamed + ); + fs::write(root.join("second"), "changed after freezing\n").unwrap(); + for (index, file) in catalog.files.iter().enumerate() { + let loaded = repository.load_file(1, index).unwrap(); + let counts = loaded.hunks.iter().fold((0, 0), |(added, removed), hunk| { + (added + hunk.after_len, removed + hunk.before_len) + }); + assert_eq!(file.stats, Some(counts), "{}", file.path.display()); + } + git(&root, &["add", "-A"]); + git(&root, &["commit", "-m", "advance checkout"]); + let refreshed = repository + .refresh_checkout(1, 2, true, &[]) + .unwrap() + .map(checkout_comparison) + .unwrap(); + assert_eq!(changed(&refreshed, Path::new("first")).stats, Some((2, 1))); + assert_eq!(changed(&refreshed, Path::new("second")).stats, Some((1, 2))); +} + #[test] fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() { let fixture = Fixture::new(); @@ -1710,6 +1797,8 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable &[], ) .unwrap(); + assert_eq!(changed(&branch, Path::new("conflict")).stats, None); + assert_eq!(changed(&branch, Path::new("ordinary")).stats, Some((1, 1))); let index = branch .files .iter() @@ -2003,6 +2092,8 @@ fn comparison_size_and_line_ceilings_are_lazy_and_explicit() { .unwrap(); assert_eq!(catalog.files.len(), 3); assert!(catalog.files.iter().all(|file| !file.loaded)); + assert_eq!(changed(&catalog, Path::new("too-many-bytes")).stats, None); + assert_eq!(changed(&catalog, Path::new("selected")).stats, Some((1, 0))); for (index, entry) in catalog.files.iter().enumerate() { match entry.path.to_str().unwrap() { "selected" => assert_eq!( diff --git a/tests/unit/chrome.rs b/tests/unit/chrome.rs index 44c41b8..cc4b777 100644 --- a/tests/unit/chrome.rs +++ b/tests/unit/chrome.rs @@ -77,6 +77,9 @@ fn omitted_options_inherit_and_explicit_groups_replace_defaults() { #[test] fn both_positions_accept_all_builtins_and_custom_styles() { + let defaults = ChromeLine::top(); + assert_eq!(defaults.right, ["diff", "shell", "battery"]); + assert_eq!(defaults.segment("diff"), defaults.segment("shell")); let names = [ "tabs", "mode", @@ -90,6 +93,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() { "lsp", "position", "battery", + "diff", "workspace", ]; let options = serde_json::json!({ @@ -119,7 +123,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() { } #[test] -fn view_placeholder_is_valid_in_every_segment_and_bar() { +fn comparison_placeholders_are_valid_in_every_segment_and_bar() { for name in [ "tabs", "mode", @@ -133,11 +137,12 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() { "lsp", "position", "battery", + "diff", "workspace", ] { let options = serde_json::json!({ "left": [name], - (name): {"title": "{index}:{view}{name}:{path}{modified}"} + (name): {"title": "{index}:{view}{name}{changes}:{path}{modified}"} }); let lines: Lines = serde_json::from_value(serde_json::json!({ "top": options, @@ -147,7 +152,7 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() { for line in [&lines.top, &lines.bottom] { assert_eq!( line.segment(name).title, - "{index}:{view}{name}:{path}{modified}" + "{index}:{view}{name}{changes}:{path}{modified}" ); } } diff --git a/tests/unit/diff.rs b/tests/unit/diff.rs index 2225d1e..03a51d7 100644 --- a/tests/unit/diff.rs +++ b/tests/unit/diff.rs @@ -789,6 +789,7 @@ fn comparison() -> Comparison { files: vec![ ChangedFile { review_revisions: None, + stats: None, path: "renamed.txt".into(), old_path: Some("old.txt".into()), status: FileStatus::Renamed, @@ -827,6 +828,7 @@ fn comparison() -> Comparison { }, ChangedFile { review_revisions: None, + stats: None, path: "added.txt".into(), old_path: None, status: FileStatus::Added, @@ -845,6 +847,7 @@ fn comparison() -> Comparison { }, ChangedFile { review_revisions: None, + stats: None, path: "removed.bin".into(), old_path: None, status: FileStatus::Removed, @@ -860,6 +863,19 @@ fn comparison() -> Comparison { } } +#[test] +fn line_counts_use_frozen_metadata_only_until_hunks_are_loaded() { + let mut file = comparison().files.remove(0); + file.loaded = false; + assert_eq!(file.line_counts(), None); + file.stats = Some((99, 98)); + assert_eq!(file.line_counts(), Some((99, 98))); + file.loaded = true; + assert_eq!(file.line_counts(), Some((2, 2))); + file.hunks.clear(); + assert_eq!(file.line_counts(), Some((0, 0))); +} + #[test] fn status_layout_cycles_only_present_panes_and_normalizes_selection() { let mut state = live_state(vec!["old".into()], vec!["new".into()], true); @@ -1566,6 +1582,7 @@ fn closing_diff_rejects_pending_file_details_even_after_reopening() { catalog.files.clear(); catalog.files.push(ChangedFile { review_revisions: None, + stats: None, path: detail.path.clone(), old_path: detail.old_path.clone(), status: detail.status, @@ -1816,6 +1833,7 @@ fn live_state(before: Vec, after: Vec, eof: bool) -> DiffState { editable_paths: BTreeSet::from([PathBuf::from("file")]), files: vec![ChangedFile { review_revisions: None, + stats: None, path: "file".into(), old_path: None, status: FileStatus::Modified, @@ -2503,6 +2521,7 @@ fn reverting_loaded_file_keeps_unloaded_changes_selectable() { state.pending = None; state.comparison.as_mut().unwrap().files.push(ChangedFile { review_revisions: None, + stats: None, path: "unloaded.txt".into(), old_path: None, status: FileStatus::Modified, @@ -2731,6 +2750,7 @@ fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache( for detail in &details { catalog.files.push(ChangedFile { review_revisions: None, + stats: None, path: detail.path.clone(), old_path: detail.old_path.clone(), status: detail.status, diff --git a/tests/unit/editor.rs b/tests/unit/editor.rs index 18450d0..2a93553 100644 --- a/tests/unit/editor.rs +++ b/tests/unit/editor.rs @@ -1887,6 +1887,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ editable_paths: Default::default(), files: vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, path: "file.txt".into(), old_path: None, @@ -3053,6 +3054,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB let lines: Vec = text.lines().map(str::to_owned).collect(); ChangedFile { review_revisions: None, + stats: None, loaded: true, path: PathBuf::from(*path), old_path: None, @@ -3115,6 +3117,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec, after: editable_paths: Default::default(), files: vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, path: "file.txt".into(), old_path: None, @@ -4078,6 +4081,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives( let cursor = editor.cursor(); let file = |path: &str, loaded| ChangedFile { review_revisions: None, + stats: None, path: path.into(), old_path: None, status: FileStatus::Modified, @@ -4288,6 +4292,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n if changed { let file = crate::diff::ChangedFile { review_revisions: None, + stats: None, path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: crate::diff::FileStatus::Renamed, @@ -4329,6 +4334,7 @@ fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { let comparison = diff.comparison.as_mut().unwrap(); comparison.files.push(ChangedFile { review_revisions: None, + stats: None, path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: FileStatus::Renamed, @@ -4350,6 +4356,7 @@ fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { )); ChangedFile { review_revisions: None, + stats: None, path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: FileStatus::Renamed, diff --git a/tests/unit/session.rs b/tests/unit/session.rs index 713b09e..e596547 100644 --- a/tests/unit/session.rs +++ b/tests/unit/session.rs @@ -3730,6 +3730,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection( editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), files: vec![ChangedFile { review_revisions: None, + stats: None, path: PathBuf::from("buffer.txt"), old_path: None, status: FileStatus::Modified, diff --git a/tests/unit/tui.rs b/tests/unit/tui.rs index 13ca146..8e99c42 100644 --- a/tests/unit/tui.rs +++ b/tests/unit/tui.rs @@ -1985,6 +1985,7 @@ fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile { let after: Vec = after.split('\n').map(str::to_owned).collect(); ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -2608,6 +2609,7 @@ fn diff_comparison(files: Vec) -> DiffState { fn deletion_file() -> ChangedFile { ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -3559,6 +3561,7 @@ fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() { after[60] = "let second_change = 901;".into(); let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -3699,6 +3702,7 @@ fn diff_row_tint_covers_blank_lines_and_wrapped_gutters() { fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -3757,6 +3761,7 @@ fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -3887,6 +3892,7 @@ fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { let added = ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -4649,6 +4655,7 @@ fn large_generated_additions_render_first_scroll_and_final_rows_without_reparsin let hunks = crate::diff::line_hunks(&[], false, &after, true); let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, path: "parser.c".into(), old_path: None, status: FileStatus::Added, @@ -4699,6 +4706,7 @@ fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { assert!(screen_text(&terminal).contains("Explorer · Tree")); let binary = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -4714,6 +4722,7 @@ fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { assert!(screen_text(&terminal).contains("Binary file")); let blank = diff_comparison(vec![ChangedFile { review_revisions: None, + stats: None, loaded: true, before_eof: false, after_eof: false, @@ -5664,17 +5673,13 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { })), ); } - let view = if phase == "view" { - "Diff · +3 -2 · " - } else { - "Diff · " - }; + let changes = if phase == "view" { " +3 -2" } else { "" }; let default_tabs = editor.top_bar_config().segment("tabs").clone(); for (template, ordinary, prefix, uses_view) in [ (None, "1 source.rs", "2 ", true), (Some("{index} {name}{modified}"), "1 source.rs", "2 ", false), ( - Some("T{index}:{view}{name}{modified}"), + Some("T{index}:{view}{name}{changes}{modified}"), "T1:source.rs", "T2:", true, @@ -5688,7 +5693,8 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { bottom.left = vec!["mode".into()]; bottom.center.clear(); bottom.right.clear(); - bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}".into(); + bottom.segments.get_mut("mode").unwrap().title = + "Current:{view}{name}{changes}".into(); }); for comparison_active in [true, false] { let mut terminal = @@ -5709,16 +5715,33 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); assert!(titles.contains(ordinary), "{phase}: {titles}"); - let comparison = format!("{prefix}{}source.rs", if uses_view { view } else { "" }); + let comparison = if uses_view { + format!("{prefix}Diff · source.rs{changes}") + } else { + format!("{prefix}source.rs") + }; assert!(titles.contains(&comparison), "{phase}: {titles}"); assert_eq!( titles.matches("Diff · ").count(), usize::from(uses_view), "{phase}: {titles}" ); + if matches!(phase, "view" | "unloaded") && comparison_active { + let total = if phase == "view" { + "Diff +3 -2" + } else { + "Diff …" + }; + assert!(titles.contains(total), "{phase}: {titles}"); + } else { + assert!( + !titles.contains("Diff +") && !titles.contains("Diff …"), + "{phase}: {titles}" + ); + } let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1)); let expected = if comparison_active { - format!("Current:{view}source.rs") + format!("Current:Diff · source.rs{changes}") } else { "Current:source.rs".into() }; @@ -5745,6 +5768,12 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { editor.handle(Input::Enter); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); + let mut unopened = syntax_diff_file("unopened.rs", "", ""); + unopened.loaded = false; + unopened.before.clear(); + unopened.after.clear(); + unopened.hunks.clear(); + unopened.stats = Some((7, 4)); editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { @@ -5754,17 +5783,25 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { before: "baseline".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![syntax_diff_file("live.rs", "old", "new")], + files: vec![syntax_diff_file("live.rs", "old", "new"), unopened], })), ); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + for input in [Input::Down, Input::Char('l'), Input::Ctrl('u'), Input::Home] { + editor.handle(input); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); + assert!(titles.contains("Diff +8 -5"), "{titles}"); + } editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); assert!(editor.diff_editing()); editor.execute("tab.new"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - for (inputs, counts) in [ - (vec![], "+1 -1"), + for (inputs, counts, total) in [ + (vec![], "+1 -1", "+8 -5"), ( vec![ Input::Char('i'), @@ -5772,9 +5809,10 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { Input::Escape, ], "+2 -1", + "+9 -5", ), - (vec![Input::Char('u')], "+1 -1"), - (vec![Input::Char('U')], "+2 -1"), + (vec![Input::Char('u')], "+1 -1", "+8 -5"), + (vec![Input::Char('U')], "+2 -1", "+9 -5"), ] { for input in inputs { editor.handle(input); @@ -5785,10 +5823,18 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); assert!( - titles.contains(&format!("1 Diff · {counts} · live.rs")), + titles.contains(&format!("1 Diff · live.rs {counts}")), "{titles}" ); assert!(titles.contains("2 live.rs"), "{titles}"); + assert!(!titles.contains("Diff +"), "{titles}"); + editor.execute("tab.previous"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); + assert!(titles.contains(&format!("Diff {total}")), "{titles}"); + editor.execute("tab.next"); } } @@ -7049,6 +7095,7 @@ fn custom_value_stays_above_split_overlays_and_diff_geometry() { assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); let mut editor = readonly_diff_editor(deletion_file()); + configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); @@ -7234,6 +7281,7 @@ fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome() for (width, height) in [(120, 30), (32, 9)] { for chrome in [false, true] { let mut editor = readonly_diff_editor(deletion_file()); + configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]); if chrome { editor.set_label("battery".into(), "live".into()).unwrap(); }