feat: show line change counts in diff tabs

This commit is contained in:
Valdemar Lorenzen
2026-10-16 12:02:42 +02:00
parent c689d9e782
commit a664b807b3
5 changed files with 122 additions and 49 deletions
+1 -19
View File
@@ -1,22 +1,4 @@
- [x] jj conflict resolution with ui akin to the diff viewer
- [x] make selecting a file in the diff view file picker focus the after buffer instead of the before buffer
- [x] improve the lsp hover floating window:
- it should be taller
- [x] add support for zig syntax highlighting
- [x] add the `*` action from vim on word to highlights and searches in buffer in one action.
- [x] diff view: improve word-diff highlighting
- [x] diff view: show the function/class context that a change hunk belongs to.
- inline scope labels with sticky continuation, chosen before implementation
- each side uses its own syntax tree; scope changes within a hunk get separate labels
- [x] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed.
- [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
- [x] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`).
- [x] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable.
- [x] file picker file search should also be fuzzy (we should be able to use the same fuzzy implemented for diff view search, i believe)
- [x] diff view: labels are currently attached also when scrolling the view port horizontally (such that e.g. a function name is not fully visible in the viewport), which is good, but it currently attaches at that line, thus pushing down the function by one line, which causes a one-line jump when scrolling horizontally.
- horizontal labels overlay the row above the declaration without shifting source rows; hide the label when it would cover the pane border or a visible declaration name.
- sticky context retains the immediately adjacent declaration as numbered source when its name fits; only use the label after it scrolls farther out.
- [ ] diff view: add change count (added and removed) to the tab name. format could be something like `Diff · <changes> · <buffer name>`.
- [x] diff view: add change count (added and removed) to the tab name. format could be something like `Diff · <changes> · <buffer name>`.
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
+1 -1
View File
@@ -140,7 +140,7 @@ Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:
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 · "` when the tab owns a comparison or revision selector, otherwise `""`. 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}`, 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.
`: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.
+2
View File
@@ -427,6 +427,8 @@ 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}`.
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.
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below.
+5 -3
View File
@@ -3381,9 +3381,11 @@ impl Editor {
}
pub fn tab_is_diff(&self, index: usize) -> bool {
self.tab_order
.get(index)
.is_some_and(|id| self.tab_diff(*id).is_some())
self.tab_diff_at(index).is_some()
}
pub(crate) fn tab_diff_at(&self, index: usize) -> Option<&DiffState> {
self.tab_diff(*self.tab_order.get(index)?)
}
pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> {
+113 -26
View File
@@ -1626,7 +1626,7 @@ fn chrome_title(
index: usize,
path: Option<&std::path::Path>,
dirty: bool,
is_diff: bool,
diff: Option<&DiffState>,
) -> String {
let name = path
.map(|path| {
@@ -1645,7 +1645,23 @@ fn chrome_title(
};
match field {
"index" => write!(title, "{index}").unwrap(),
"view" if is_diff => title.push_str("Diff · "),
"view" if diff.is_some() => {
title.push_str("Diff · ");
if let Some(diff) = diff
&& diff.phase == Phase::View
&& let Some(file) = diff
.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(diff.file))
&& file.loaded
{
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();
}
}
"name" => title.push_str(&name),
"path" => title.push_str(&path),
"modified" if dirty => title.push_str(" [+]"),
@@ -1680,7 +1696,7 @@ fn chrome_items<'a>(
index + 1,
buffer.as_ref().and_then(|buffer| buffer.path),
buffer.is_some_and(|buffer| buffer.dirty),
editor.tab_is_diff(index),
editor.tab_diff_at(index),
)),
config,
tab: Some(index),
@@ -1705,7 +1721,7 @@ fn chrome_items<'a>(
editor.tab_index() + 1,
editor.path(),
editor.dirty(),
editor.tab_is_diff(editor.tab_index()),
editor.tab_diff_at(editor.tab_index()),
)),
"position" => Cow::Owned(format!(
"{}:{}",
@@ -1780,7 +1796,7 @@ fn chrome_items<'a>(
editor.tab_index() + 1,
editor.path(),
editor.dirty(),
editor.tab_is_diff(editor.tab_index()),
editor.tab_diff_at(editor.tab_index()),
));
} else if text.chars().any(char::is_control) {
text = Cow::Owned(display_text(&text));
@@ -10728,7 +10744,7 @@ mod tests {
let path = directory.file("λ\t{index}.rs", "source");
let editor = Editor::open(Some(path.clone()), vec![]).unwrap();
let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new());
let title = chrome_title("{path}", 2, Some(&path), false, false);
let title = chrome_title("{path}", 2, Some(&path), false, None);
assert_eq!(title, display_text(&display_path(&path)));
assert!(groups.iter().flatten().any(|item| item.text == title));
assert_eq!(editor.path(), Some(path.as_path()));
@@ -11411,10 +11427,10 @@ end
}
#[test]
fn comparison_titles_distinguish_same_named_tabs_and_follow_current_context() {
fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
let directory = Directory::new();
let path = directory.file("source.rs", "fn main() {}");
for phase in ["loading", "before", "after", "view"] {
for phase in ["loading", "before", "after", "unloaded", "view"] {
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.execute("tab.new");
editor.execute("diff");
@@ -11422,14 +11438,26 @@ end
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
}
if matches!(phase, "after" | "view") {
if matches!(phase, "after" | "unloaded" | "view") {
editor.handle(Input::Down);
editor.handle(Input::Enter);
}
if phase == "view" {
if matches!(phase, "unloaded" | "view") {
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
let mut file = syntax_diff_file(
"source.rs",
"old\ncontext\nremove",
"new\ncontext\nextra\nmore",
);
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
if phase == "unloaded" {
file.loaded = false;
file.before.clear();
file.after.clear();
file.hunks.clear();
}
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
@@ -11437,24 +11465,24 @@ end
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
files: vec![syntax_diff_file("source.rs", "fn old() {}", "fn main() {}")],
files: vec![file],
})),
);
}
let view = if phase == "view" {
"Diff · +3 -2 · "
} else {
"Diff · "
};
let default_tabs = editor.top_bar_config().segment("tabs").clone();
for (template, ordinary, comparison, marker_count) in [
(None, "1 source.rs", "2 Diff · source.rs", 1),
(
Some("{index} {name}{modified}"),
"1 source.rs",
"2 source.rs",
0,
),
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}"),
"T1:source.rs",
"T2:Diff · source.rs",
1,
"T2:",
true,
),
] {
configure_chrome(&mut editor, |top, bottom| {
@@ -11486,19 +11514,21 @@ end
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
assert!(titles.contains(ordinary), "{phase}: {titles}");
assert!(titles.contains(comparison), "{phase}: {titles}");
let comparison =
format!("{prefix}{}source.rs", if uses_view { view } else { "" });
assert!(titles.contains(&comparison), "{phase}: {titles}");
assert_eq!(
titles.matches("Diff · ").count(),
marker_count,
usize::from(uses_view),
"{phase}: {titles}"
);
let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1));
let expected = if comparison_active {
"Current:Diff · source.rs"
format!("Current:{view}source.rs")
} else {
"Current:source.rs"
"Current:source.rs".into()
};
assert!(current.contains(expected), "{phase}: {current}");
assert!(current.contains(&expected), "{phase}: {current}");
editor.execute(if comparison_active {
"tab.previous"
} else {
@@ -11509,6 +11539,63 @@ end
}
}
#[test]
fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
let directory = Directory::new();
let path = directory.file("live.rs", "new");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: ["live.rs".into()].into(),
before: "baseline".into(),
after: "Working checkout".into(),
files: vec![syntax_diff_file("live.rs", "old", "new")],
})),
);
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"),
(
vec![
Input::Char('i'),
Input::Paste("extra\n".into()),
Input::Escape,
],
"+2 -1",
),
(vec![Input::Char('u')], "+1 -1"),
(vec![Input::Char('U')], "+2 -1"),
] {
for input in inputs {
editor.handle(input);
}
editor.refresh_live_diff();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
assert!(
titles.contains(&format!("1 Diff · {counts} · live.rs")),
"{titles}"
);
assert!(titles.contains("2 live.rs"), "{titles}");
}
}
#[test]
fn tab_titles_update_templates_without_reinterpreting_file_names() {
let directory = Directory::new();