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
+71 -1
View File
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap();
assert_eq!(first.revisions[0], id);
assert!(first.revisions[1].starts_with("working:"));
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
assert_eq!(
first.blame[1]
.as_ref()
.map(|baseline| baseline.commit.as_str()),
(kind == RepoKind::Git).then_some(id.as_str())
);
let reopened = open(&root)
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap();
assert_eq!(working_pair.revisions[0], first.revisions[1]);
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
assert_eq!(
working_pair.blame,
[first.blame[1].clone(), first.blame[1].clone()]
);
assert_eq!(disk_state(&root), state);
fs::write(root.join("file"), "working").unwrap();
let no_newline = repository
@@ -785,6 +796,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
panic!("Bookmark movement must replace contents even when follow is disabled");
};
assert_eq!(comparison.after, topic.label);
assert!(comparison.blame[0].is_none());
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
assert!(repository.load_file(1, 0).is_err());
assert!(
@@ -944,6 +957,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
.unwrap();
assert!(original.before.ends_with(&base));
assert_eq!(original.revisions, [base.clone(), tip.clone()]);
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap()
@@ -952,6 +967,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
};
assert!(comparison.before.ends_with(&tip));
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
assert!(comparison.files.is_empty());
jj(&root, &["bookmark", "delete", "after"]);
let state = disk_state(&root);
@@ -1331,6 +1348,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
assert_eq!(promoted.blame, branch.blame);
assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state);
@@ -2260,6 +2278,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
.unwrap();
assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after);
assert_eq!(refreshed.blame[0], original.blame[0]);
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
@@ -2526,13 +2546,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root);
compare_loaded(
let initial = compare_loaded(
&mut repository,
&revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
&[],
)
.unwrap();
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root);
@@ -2545,6 +2566,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new"]);
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root);
@@ -2553,6 +2575,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(comparison.blame[0], initial.blame[0]);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
@@ -2727,12 +2751,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base));
assert_eq!(branch.blame[0].as_ref().unwrap().commit, base);
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(reverse.blame[0].as_ref().unwrap().commit, base);
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!(
repository
@@ -2866,3 +2894,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
assert_eq!(disk_state(&root), state);
}
}
#[test]
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
let fixture = Fixture::new();
let root = fixture.dir("blame-parents");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
let state = disk_state(&root);
let mut repository = open(&root);
let one_parent = repository
.compare(
1,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new", "root()"]);
fs::write(root.join("other"), "sibling\n").unwrap();
jj(&root, &["describe", "-m", "sibling"]);
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base, &sibling]);
let state = disk_state(&root);
let merged = repository
.compare(
2,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(merged.blame, [None, None]);
assert_eq!(disk_state(&root), state);
}
+255 -2
View File
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
for entry in fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
let metadata = fs::symlink_metadata(&path).unwrap();
if metadata.is_dir() {
visit(root, &path, state);
} else if metadata.is_file() {
state.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
fs::read(path).unwrap(),
);
}
}
}
let mut state = std::collections::BTreeMap::new();
visit(root, root, &mut state);
state
}
fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
None
);
}
#[test]
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
for is_jj in [false, true] {
let (temp, old_path, original) = fixture();
if is_jj {
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
command(&temp.0, "jj", &["new", &original]);
}
fs::write(&old_path, "historical\nsecond\n").unwrap();
let selected = if is_jj {
command(
&temp.0,
"jj",
&["describe", "-m", "Selected historical lines"],
);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "."]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Selected historical lines"],
);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
let renamed = temp.0.join("renamed.txt");
fs::rename(&old_path, &renamed).unwrap();
let renamed_commit = if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
fs::remove_file(&renamed).unwrap();
if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
command(&temp.0, "jj", &["new"]);
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
}
let store = if is_jj {
crate::repository::store_for_jj(&temp.0).unwrap()
} else {
temp.0.join(".git")
};
let state = disk_state(&temp.0);
for (commit, path) in [
(
selected.clone(),
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
),
(renamed_commit, PathBuf::from("renamed.txt")),
] {
let source = RevisionSource {
store: store.clone(),
commit,
path,
keepalive: None,
};
let mut worker = BlameWorker::start().unwrap();
worker
.request(
1,
old_path.clone(),
"historical\nsecond\n".into(),
Some(source),
)
.unwrap();
let started = Instant::now();
let rows = loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 1);
break result.unwrap().unwrap();
}
assert!(
started.elapsed() < Duration::from_secs(20),
"Historical blame stalled"
);
thread::yield_now();
};
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(
rows[0].as_ref().unwrap().summary,
"Selected historical lines"
);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
}
assert!(!old_path.exists() && !renamed.exists());
assert_eq!(disk_state(&temp.0), state);
}
}
#[test]
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
let (remote, remote_path, original) = fixture();
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
command(&remote.0, "git", &["add", "."]);
command(
&remote.0,
"git",
&["commit", "--quiet", "-m", "Private selected"],
);
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
for is_jj in [false, true] {
let (active, active_path, _) = fixture();
fs::remove_file(&active_path).unwrap();
if is_jj {
fs::remove_dir_all(active.0.join(".git")).unwrap();
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
command(
&active.0,
"jj",
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
);
} else {
command(
&active.0,
"git",
&["remote", "add", "origin", remote.0.to_str().unwrap()],
);
}
let revision = |id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.clone(),
id: id.into(),
},
label: id.into(),
description: String::new(),
category: Category::Commits,
};
let state = disk_state(&active.0);
let mut repository =
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
.unwrap();
let comparison = repository
.compare(
1,
&revision(&original),
&revision(&selected),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let baseline = comparison.blame[1].as_ref().unwrap();
assert_eq!(baseline.commit, selected);
assert!(!baseline.store.starts_with(&active.0));
let source = RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
keepalive: baseline.keepalive.clone(),
};
let store = source.store.clone();
drop(comparison);
drop(repository);
assert!(
store.exists(),
"Snapshot provenance must retain private fetched objects"
);
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(disk_state(&active.0), state);
drop(source);
assert!(
!store.exists(),
"Last snapshot release must remove private objects"
);
}
}
#[test]
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
let (temp, path, commit) = fixture();
let mut source = RevisionSource {
store: temp.0.join(".git"),
commit,
path: "not-in-baseline".into(),
keepalive: None,
};
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
source.path = path.into();
assert!(read_revision(&source, "new\n", &|| false).is_err());
}
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
fs::create_dir(temp.0.join("nested")).unwrap();
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
command(&temp.0, "git", &["add", "link", "nested/file"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
source.path = "link".into();
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
source.path = "nested".into();
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
source.path = "nested/file".into();
let nested = read_revision(&source, "nested\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
source.path = path.strip_prefix(&temp.0).unwrap().into();
assert_eq!(
read_revision(&source, "first\n", &|| true).unwrap_err(),
"Blame cancelled"
);
assert_eq!(
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
"Blame input exceeds 16 MiB"
);
}
#[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap();
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
let mut worker = BlameWorker::start().unwrap();
for id in 0..20 {
worker
.request(id, path.clone(), "first\nsecond\n".into())
.request(id, path.clone(), "first\nsecond\n".into(), None)
.unwrap();
}
loop {
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
}
assert_eq!(
worker
.request(20, path, "x".repeat(LIMIT + 1))
.request(20, path, "x".repeat(LIMIT + 1), None)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
+3
View File
@@ -780,6 +780,7 @@ fn choose_pair(editor: &mut Editor) -> u64 {
fn comparison() -> Comparison {
Comparison {
blame: Default::default(),
revisions: ["abcdef123".into(), "working:snapshot".into()],
before: "abcdef123".into(),
after: "Working checkout".into(),
@@ -1774,6 +1775,7 @@ fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
assert!(editor.apply_diff_result(
id,
Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["working:snapshot".into(), "working:snapshot".into()],
before: "Working".into(),
after: "Working".into(),
@@ -1806,6 +1808,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
Category::Commits,
));
state.comparison = Some(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()],
root: PathBuf::new(),
before: "base".into(),
+5
View File
@@ -1879,6 +1879,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
assert!(editor.apply_diff_result(
comparison_request,
Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: editor.project_root().to_owned(),
revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(),
@@ -3068,6 +3069,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
assert!(editor.apply_diff_result(
id,
Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.canonicalize().unwrap(),
revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(),
@@ -3105,6 +3107,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
editor.apply_diff_result(
id,
Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.to_owned(),
revisions: ["base".into(), "head".into()],
before: "base".into(),
@@ -4108,6 +4111,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
assert!(editor.apply_diff_result(
refresh,
Ok(Outcome::BookmarkRefresh(Comparison {
blame: Default::default(),
root: directory.0.clone(),
revisions: ["base".into(), "topic-snapshot".into()],
before: "base".into(),
@@ -4229,6 +4233,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
let loaded_after = target.after.clone();
target.loaded = false;
let replacement = crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(),
revisions: ["base".into(), "ahead-snapshot".into()],
before: "base".into(),
+2
View File
@@ -588,6 +588,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "after".into()],
root: fixture.0.clone(),
before: "base".into(),
@@ -3721,6 +3722,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()],
root: fixture.0.canonicalize().unwrap(),
before: "base".into(),
+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(),