fix: show revision-aware blame in diff buffers

Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
This commit is contained in:
Valdemar Lorenzen
2026-10-20 11:47:49 +02:00
parent 8f8d96ead9
commit 4fb1be7972
16 changed files with 819 additions and 91 deletions
+227 -5
View File
@@ -2593,6 +2593,7 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
diff.apply_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
before: "old revision".into(),
@@ -4805,6 +4806,7 @@ fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
before: "old revision".into(),
@@ -5652,6 +5654,7 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
@@ -5745,6 +5748,7 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: ["live.rs".into()].into(),
before: "baseline".into(),
@@ -6377,6 +6381,7 @@ fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
@@ -6455,6 +6460,218 @@ fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
assert!(titles.contains("2 [No Name]"), "{titles}");
}
#[test]
fn inline_blame_is_visible_in_focused_diff_buffers() {
for unified in [false, true] {
for before in [false, true] {
let mut editor = readonly_diff_editor(deletion_file());
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After })
{
editor.handle(Input::Char('\t'));
}
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: "0123456789abcdef".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains("Author · Original (01234567)"), "{screen}");
let caret = terminal.get_cursor_position().unwrap();
assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author"));
editor.handle(Input::Char('\t'));
assert!(!editor.set_line_blame(source, blame));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Author · Original"));
}
}
}
#[test]
fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() {
for unified in [false, true] {
let text = "x".repeat(200);
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after"));
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
let screen = screen_text(&terminal);
assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}");
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted"));
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap();
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
assert!(!screen_text(&narrow).contains("Uncommitted"));
editor.execute("tab.new");
assert!(editor.diff().is_none());
if !editor.word_wrap() {
editor.execute("word_wrap.toggle");
}
editor.set_cached_line_blame(&source, blame);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
editor.handle(Input::Char('i'));
editor.handle(Input::Char('z'));
assert_eq!(editor.lines().join("\n"), text);
}
}
#[test]
fn inline_blame_loads_real_historical_and_working_diff_sources() {
let directory = Directory::new();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&directory.0)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.test")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.test")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
git(&["init", "-q", "--template="]);
let path = directory.file("file.txt", "original\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Original"]);
let original = git(&["rev-parse", "HEAD"]);
directory.file("file.txt", "historical\n");
git(&["commit", "-qam", "Historical"]);
let historical = git(&["rev-parse", "HEAD"]);
git(&["rm", "-q", "file.txt"]);
git(&["commit", "-qm", "Deleted"]);
let config = directory.file(
".git/vamp-config.rb",
"Vamp.configure { |c| c.git_gutter = 'none' }",
);
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !predicate(session.editor()) {
assert!(
std::time::Instant::now() < deadline,
"message={:?}, state={:?}, blame={:?}, text={:?}",
session.editor().message(),
session.editor().diff().map(|diff| (
diff.phase,
diff.focus,
diff.file,
&diff.error
)),
session.editor().line_blame(),
session.editor().lines()
);
session.poll();
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for revision in [&original, &historical] {
for ch in revision.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
}
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
for expected in ["Original", "Historical"] {
ready(&mut session, &|editor| {
editor
.line_blame()
.is_some_and(|label| label.contains(expected))
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains(&format!("Author · {expected}")), "{screen}");
assert!(!screen.contains("Uncommitted"), "{screen}");
session.handle(Input::Char('\t'));
}
assert!(!path.exists());
session.execute("diff.close");
directory.file("file.txt", "restored\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Restored"]);
directory.file("file.txt", "unsaved baseline\n");
session.editor_mut().refresh_files();
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for ch in git(&["rev-parse", "HEAD"]).chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
session.handle(Input::Char('\t'));
ready(&mut session, &|editor| {
editor.line_blame() == Some("Uncommitted")
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n");
}
#[test]
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
let directory = Directory::new();
@@ -6545,11 +6762,6 @@ fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Uncommitted"));
let mut historical = readonly_diff_editor(deletion_file());
assert!(historical.blame_source().is_none());
historical.execute("tab.new");
assert!(historical.diff().is_none());
assert!(historical.blame_source().is_none());
}
#[test]
@@ -7782,6 +7994,7 @@ fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
@@ -7880,6 +8093,13 @@ fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: std::array::from_fn(|_| {
Some(crate::diff::BlameRevision {
store: editor.project_root().join(".git"),
commit: "0123456789abcdef0123456789abcdef01234567".into(),
keepalive: None,
})
}),
root: editor.project_root().to_path_buf(),
editable_paths: Default::default(),
before: "baseline".into(),
@@ -8732,6 +8952,7 @@ fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: if writable {
[std::path::PathBuf::from("live.rs")].into()
@@ -8862,6 +9083,7 @@ fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt(
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
before: "baseline".into(),