perf: cache file attribution for instant blame navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-13 12:33:15 +02:00
parent 7a3c27c30e
commit e36c28c981
6 changed files with 461 additions and 148 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
Current-line blame is enabled by default. Session schedules one request per focused tab/pane/buffer/text-revision/path/row identity and rejects superseded results, including results returning after navigation away and back. The editor stores a sanitized borrowed label; rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
+1 -1
View File
@@ -210,7 +210,7 @@ Text wraps at word boundaries where possible, with grapheme-safe breaks for long
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution.
Loading happens in the background without network access or changing repository refs/checkouts. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. Moving, editing, switching views, or disabling clears stale results. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
+229 -102
View File
@@ -1,5 +1,6 @@
//! Read-only current-line attribution; repository commands never touch the editor thread.
//! Read-only file attribution; repository commands never touch the editor thread.
use std::{
collections::HashMap,
fs,
io::{self, Read, Write},
path::{Component, Path, PathBuf},
@@ -15,8 +16,8 @@ use std::{
const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultLine = Result<Option<LineBlame>, String>;
type Request = (u64, u64, PathBuf, String, usize);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
type Request = (u64, u64, PathBuf, String);
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame {
@@ -29,7 +30,7 @@ pub(crate) struct LineBlame {
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultLine)>,
result: Option<(u64, ResultFile)>,
}
#[derive(Default)]
struct Shared {
@@ -62,13 +63,13 @@ impl BlameWorker {
if worker.stopped.load(Ordering::Acquire) {
break;
}
let (generation, id, path, contents, row) = state.request.take().unwrap();
let (generation, id, path, contents) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation
};
let result = read_line(&path, &contents, row, &cancelled);
let result = read_file(&path, &contents, &cancelled);
let Ok(mut state) = worker.state.lock() else {
return;
};
@@ -83,13 +84,7 @@ impl BlameWorker {
})
}
pub(crate) fn request(
&mut self,
id: u64,
path: PathBuf,
contents: String,
row: usize,
) -> io::Result<()> {
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
if contents.len() > LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -106,13 +101,13 @@ impl BlameWorker {
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, path, contents, row));
state.request = Some((generation, id, path, contents));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultLine)> {
pub(crate) fn poll(&mut self) -> Option<(u64, ResultFile)> {
self.shared.state.lock().ok()?.result.take()
}
}
@@ -283,12 +278,13 @@ fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
))
}
}
fn read_line(path: &Path, contents: &str, row: usize, cancelled: &dyn Fn() -> bool) -> ResultLine {
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
// The editor has a final empty row after a newline; Git has no corresponding source line.
if row >= contents.split_inclusive('\n').count() {
let rows = contents.split_inclusive('\n').count();
if rows == 0 {
return Ok(None);
}
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
@@ -386,74 +382,112 @@ fn read_line(path: &Path, contents: &str, row: usize, cancelled: &dyn Fn() -> bo
if run(present, &[], cancelled)?.is_none() {
return Ok(None);
}
let line = row.checked_add(1).ok_or("Invalid blame row")?;
command
.args(["blame", "--line-porcelain", "--no-textconv", "-L"])
.arg(format!("{line},{line}"))
.args(["--contents", "-", "--"])
.args([
"blame",
"--incremental",
"--no-textconv",
"--contents",
"-",
"--",
])
.arg(relative);
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, line).map(Some)
parse(&output, rows).map(Some)
}
fn parse(bytes: &[u8], row: usize) -> Result<LineBlame, String> {
fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, String> {
let malformed = || "Invalid Git blame metadata".to_owned();
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
let mut lines = text.lines();
let mut header = lines.next().ok_or_else(malformed)?.split(' ');
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
if !header
.next()
.and_then(|n| n.parse::<usize>().ok())
.is_some_and(|n| n > 0)
|| header.next().and_then(|n| n.parse::<usize>().ok()) != Some(row)
|| header.next() != Some("1")
|| header.next().is_some()
{
return Err(malformed());
}
let mut author = None;
let mut summary = None;
let mut source = false;
for line in lines.by_ref() {
if line.starts_with('\t') {
source = true;
break;
let mut commits = HashMap::<&str, Arc<LineBlame>>::new();
let mut result = vec![None; rows];
while let Some(line) = lines.next() {
let mut header = line.split(' ');
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
let original = header.next().and_then(|n| n.parse::<usize>().ok());
let start = header.next().and_then(|n| n.parse::<usize>().ok());
let count = header.next().and_then(|n| n.parse::<usize>().ok());
let (Some(original), Some(start), Some(count)) = (original, start, count) else {
return Err(malformed());
};
if original == 0 || start == 0 || count == 0 || header.next().is_some() {
return Err(malformed());
}
if let Some(value) = line.strip_prefix("author ") {
if author.replace(value).is_some() {
original.checked_add(count - 1).ok_or_else(malformed)?;
let start = start - 1;
let end = start
.checked_add(count)
.filter(|&end| end <= rows)
.ok_or_else(malformed)?;
let mut author = None;
let mut summary = None;
let mut filename = false;
for line in lines.by_ref() {
if let Some(value) = line.strip_prefix("filename ") {
if value.is_empty() {
return Err(malformed());
}
filename = true;
break;
}
if line.is_empty() || line.starts_with('\t') || line.split(' ').next().is_some_and(oid)
{
return Err(malformed());
}
if let Some(value) = line.strip_prefix("author ") {
if author.replace(value).is_some() {
return Err(malformed());
}
}
if let Some(value) = line.strip_prefix("summary ") {
if summary.replace(value).is_some() {
return Err(malformed());
}
}
}
if let Some(value) = line.strip_prefix("summary ") {
if summary.replace(value).is_some() {
if !filename {
return Err(malformed());
}
let blame = if let Some(blame) = commits.get(commit) {
if author.is_some() || summary.is_some() {
return Err(malformed());
}
Arc::clone(blame)
} else {
let author = author.ok_or_else(malformed)?;
let summary = summary.ok_or_else(malformed)?;
let uncommitted = commit.bytes().all(|byte| byte == b'0');
let blame = Arc::new(LineBlame {
commit: if uncommitted {
String::new()
} else {
commit.into()
},
author: if uncommitted {
String::new()
} else {
author.into()
},
summary: if uncommitted {
String::new()
} else {
summary.into()
},
uncommitted,
});
commits.insert(commit, Arc::clone(&blame));
blame
};
for row in &mut result[start..end] {
if row.replace(Arc::clone(&blame)).is_some() {
return Err(malformed());
}
}
}
if !source || lines.next().is_some() {
if result.iter().any(Option::is_none) {
return Err(malformed());
}
let author = author.ok_or_else(malformed)?;
let summary = summary.ok_or_else(malformed)?;
let uncommitted = commit.bytes().all(|byte| byte == b'0');
Ok(LineBlame {
commit: if uncommitted {
String::new()
} else {
commit.into()
},
author: if uncommitted {
String::new()
} else {
author.into()
},
summary: if uncommitted {
String::new()
} else {
summary.into()
},
uncommitted,
})
Ok(result)
}
#[cfg(all(test, unix))]
@@ -512,11 +546,16 @@ mod tests {
#[test]
fn committed_line_has_native_author_summary_and_commit() {
let (_temp, path, commit) = fixture();
let line = read_line(&path, "first\nsecond\n", 1, &|| false)
let rows = read_file(&path, "first\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows.len(), 2);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
assert_eq!(
line,
**rows[1].as_ref().unwrap(),
LineBlame {
commit,
author: "Blame Author".into(),
@@ -528,11 +567,11 @@ mod tests {
#[test]
fn unsaved_changed_line_is_uncommitted_without_metadata() {
let (_temp, path, _) = fixture();
let line = read_line(&path, "changed\nsecond\n", 0, &|| false)
let rows = read_file(&path, "changed\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(
line,
**rows[0].as_ref().unwrap(),
LineBlame {
commit: String::new(),
author: String::new(),
@@ -546,38 +585,62 @@ mod tests {
fn inserted_line_preserves_following_original_attribution() {
let (_temp, path, commit) = fixture();
let contents = "inserted\nfirst\nsecond\n";
assert!(
read_line(&path, contents, 0, &|| false)
.unwrap()
.unwrap()
.uncommitted
);
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
assert_eq!(rows.len(), 3);
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
}
#[test]
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
let (temp, path, original) = fixture();
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
let rows = read_file(
&path,
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
&|| false,
)
.unwrap()
.unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert_eq!(rows[2].as_ref().unwrap().commit, original);
assert!(Arc::ptr_eq(
rows[1].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(rows[3].as_ref().unwrap().uncommitted);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
assert_eq!(rows[4].as_ref().unwrap().commit, added);
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
assert_eq!(
read_line(&path, contents, 2, &|| false)
.unwrap()
.unwrap()
.commit,
commit
fs::read_to_string(path).unwrap(),
"first\nmiddle\nsecond\ntail\n"
);
assert_eq!(read_line(&path, contents, 3, &|| false).unwrap(), None);
}
#[test]
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
let temp = Temporary::new().unwrap();
let path = temp.0.join("file");
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
fs::write(&path, "text").unwrap();
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
let (_committed, tracked, _) = fixture();
let untracked = tracked.parent().unwrap().join("untracked");
fs::write(&untracked, "text").unwrap();
assert_eq!(read_line(&untracked, "text", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
let link = temp.0.join("link");
std::os::unix::fs::symlink(tracked, &link).unwrap();
assert_eq!(
read_line(&link, "first\nsecond\n", 0, &|| false).unwrap(),
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
None
);
}
@@ -587,7 +650,7 @@ mod tests {
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
let path = temp.0.join("file");
fs::write(&path, "original\n").unwrap();
assert_eq!(read_line(&path, "original\n", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
command(
&temp.0,
"jj",
@@ -635,16 +698,20 @@ mod tests {
"id",
],
);
let line = read_line(&path, "original\n", 0, &|| false)
.unwrap()
.unwrap();
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
let line = rows[0].as_ref().unwrap();
assert_eq!(line.commit, parent);
assert_eq!(line.author, "Jj Author");
assert_eq!(line.summary, "Jj summary");
fs::write(&path, "dirty on disk\n").unwrap();
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
.unwrap()
.unwrap();
assert!(shifted[0].as_ref().unwrap().uncommitted);
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
assert!(
read_line(&path, "unsaved\n", 0, &|| false)
.unwrap()
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
.as_ref()
.unwrap()
.uncommitted
);
@@ -670,13 +737,67 @@ mod tests {
fs::write(temp.0.join("other"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert_eq!(read_line(&path, "original\n", 0, &|| false).unwrap(), None);
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
}
#[test]
fn malformed_metadata_errors_never_echo_metadata() {
for bytes in [b"secret metadata".as_slice(), b"\xffsecret metadata", b"0000000000000000000000000000000000000000 1 2 1\nauthor secret\nsummary secret\n\ttext\n"] {
assert_eq!(parse(bytes, 1).unwrap_err(), "Invalid Git blame metadata");
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
let first = "1111111111111111111111111111111111111111";
let second = "2222222222222222222222222222222222222222";
let output = format!(
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
{first} 1 1 1\nfilename old-file\n"
);
let rows = parse(output.as_bytes(), 4).unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, first);
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
assert_eq!(rows[1].as_ref().unwrap().commit, second);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(Arc::ptr_eq(
rows[2].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
}
#[test]
fn malformed_incremental_groups_never_echo_metadata() {
let commit = "1111111111111111111111111111111111111111";
let metadata = "author secret\nsummary secret\nfilename secret\n";
let valid = format!("{commit} 1 1 1\n{metadata}");
for output in [
"secret metadata".into(),
"\u{fffd}secret metadata".into(),
format!("{commit} 1 2 1\n{metadata}"),
format!("{commit} 1 1 2\n{metadata}"),
format!("{commit} 0 1 1\n{metadata}"),
format!("{commit} 1 1 0\n{metadata}"),
format!("{commit} 1 1 1 extra\n{metadata}"),
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
format!(
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
),
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
format!(
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
),
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
] {
assert_eq!(
parse(output.as_bytes(), 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
assert_eq!(
parse(valid.as_bytes(), 2).unwrap_err(),
"Invalid Git blame metadata"
);
assert_eq!(
parse(b"\xffsecret metadata", 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
#[test]
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
@@ -709,20 +830,26 @@ mod tests {
let mut worker = BlameWorker::start().unwrap();
for id in 0..20 {
worker
.request(id, path.clone(), "first\nsecond\n".into(), 1)
.request(id, path.clone(), "first\nsecond\n".into())
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
assert_eq!(result.unwrap().unwrap().commit, commit);
let rows = result.unwrap().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, path, "x".repeat(LIMIT + 1), 0)
.request(20, path, "x".repeat(LIMIT + 1))
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
+51 -11
View File
@@ -1561,13 +1561,19 @@ impl Buffer {
}
fn refresh_file(&mut self) -> io::Result<bool> {
if self.diff_snapshot.is_some() || self.dirty() {
if self.diff_snapshot.is_some() {
return Ok(false);
}
let Some(path) = &self.path else {
return Ok(false);
};
let stamp = FileStamp::read(path)?;
if self.dirty() {
if stamp.is_none() && self.disk_stamp.is_some() {
return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed"));
}
return Ok(false);
}
if stamp == self.disk_stamp {
return Ok(false);
}
@@ -1670,8 +1676,10 @@ struct PendingLspNavigation {
pub(crate) struct BlameSource {
buffer: usize,
revision: u64,
pub(crate) path: PathBuf,
pub(crate) path: Arc<Path>,
pub(crate) row: usize,
save_revision: u64,
disk_stamp: Option<FileStamp>,
pane: usize,
tab: usize,
}
@@ -1712,7 +1720,7 @@ pub struct Editor {
battery_charge: Option<u8>,
battery_label: String,
git_blame: bool,
line_blame: Option<(BlameSource, String)>,
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
lsp_names: Vec<String>,
lsp_label: String,
quit: bool,
@@ -4682,27 +4690,37 @@ impl Editor {
|| self.should_quit()
|| self.diff.is_some()
|| self.active.diff_snapshot.is_some()
|| self.active.reload_error.is_some()
{
return None;
}
Some(BlameSource {
buffer: self.buffer_id(),
revision: self.text_revision(),
path: self.path()?.to_owned(),
path: Arc::from(self.path()?),
row: self.cursor().row,
save_revision: self.active.save_revision,
disk_stamp: self.active.disk_stamp,
pane: self.pane_id(),
tab: self.tab_id(),
})
}
pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool {
pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool {
self.git_blame
&& !self.should_quit()
&& self.diff.is_none()
&& self.active.diff_snapshot.is_none()
&& self.active.reload_error.is_none()
&& source.buffer == self.buffer_id()
&& source.revision == self.text_revision()
&& Some(source.path.as_path()) == self.path()
&& source.save_revision == self.active.save_revision
&& source.disk_stamp == self.active.disk_stamp
&& Some(source.path.as_ref()) == self.path()
}
pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool {
self.blame_file_source_is_current(source)
&& source.row == self.cursor().row
&& source.pane == self.pane_id()
&& source.tab == self.tab_id()
@@ -4712,14 +4730,32 @@ impl Editor {
self.line_blame.take().is_some()
}
pub(crate) fn set_cached_line_blame(
&mut self,
source: &BlameSource,
blame: Arc<crate::blame::LineBlame>,
) -> bool {
let mut source = source.clone();
source.row = self.cursor().row;
source.pane = self.pane_id();
source.tab = self.tab_id();
self.set_line_blame(source, blame)
}
pub(crate) fn set_line_blame(
&mut self,
source: BlameSource,
blame: crate::blame::LineBlame,
blame: Arc<crate::blame::LineBlame>,
) -> bool {
if !self.blame_source_is_current(&source) {
return false;
}
if let Some((previous, metadata, _)) = &mut self.line_blame
&& Arc::ptr_eq(metadata, &blame)
{
*previous = source;
return false;
}
let label = if blame.uncommitted {
"Uncommitted".to_owned()
} else {
@@ -4728,15 +4764,19 @@ impl Editor {
blame.author, blame.summary, blame.commit
))
};
self.line_blame = Some((source, label));
true
let changed = self
.line_blame
.as_ref()
.is_none_or(|(_, _, previous)| previous != &label);
self.line_blame = Some((source, blame, label));
changed
}
pub(crate) fn line_blame(&self) -> Option<&str> {
self.line_blame
.as_ref()
.filter(|(source, _)| self.blame_source_is_current(source))
.map(|(_, label)| label.as_str())
.filter(|(source, _, _)| self.blame_source_is_current(source))
.map(|(_, _, label)| label.as_str())
}
pub fn lsp_names(&self) -> &[String] {
&self.lsp_names
+168 -26
View File
@@ -176,6 +176,7 @@ pub struct EditorSession {
diff_workers: BTreeMap<usize, DiffWorker>,
blame_worker: Option<BlameWorker>,
blame_source: Option<BlameSource>,
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
blame_request_id: u64,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
@@ -281,6 +282,7 @@ impl EditorSession {
diff_workers: BTreeMap::new(),
blame_worker: None,
blame_source: None,
blame_lines: None,
blame_request_id: 0,
battery: None,
battery_enabled: false,
@@ -1027,10 +1029,11 @@ impl EditorSession {
if !self
.blame_source
.as_ref()
.is_some_and(|source| self.editor.blame_source_is_current(source))
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
{
self.blame_source = self.editor.blame_source();
self.blame_request_id = self.blame_request_id.wrapping_add(1);
self.blame_lines = None;
changed |= self.editor.clear_line_blame();
if let Some(source) = &self.blame_source {
if self.blame_worker.is_none() {
@@ -1040,9 +1043,8 @@ impl EditorSession {
&& worker
.request(
self.blame_request_id,
source.path.clone(),
source.path.to_path_buf(),
self.editor.lines().join("\n"),
source.row,
)
.is_err()
{
@@ -1055,21 +1057,44 @@ impl EditorSession {
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
changed |= self.apply_blame_result(id, result);
}
changed
changed | self.refresh_line_blame()
}
fn apply_blame_result(
&mut self,
id: u64,
result: Result<Option<crate::blame::LineBlame>, String>,
result: Result<Option<Vec<Option<Arc<crate::blame::LineBlame>>>>, String>,
) -> bool {
if id == self.blame_request_id
&& let Some(source) = &self.blame_source
&& let Ok(Some(blame)) = result
if id != self.blame_request_id
|| !self
.blame_source
.as_ref()
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
{
return self.editor.set_line_blame(source.clone(), blame);
return false;
}
false
self.blame_lines = result.ok().flatten();
self.refresh_line_blame()
}
fn refresh_line_blame(&mut self) -> bool {
if let Some(source) = &self.blame_source
&& self.editor.blame_file_source_is_current(source)
&& let Some(blame) = self
.blame_lines
.as_ref()
.and_then(|lines| lines.get(self.editor.cursor().row))
.and_then(Option::as_ref)
{
self.editor.set_cached_line_blame(source, blame.clone())
} else {
self.editor.clear_line_blame()
}
}
pub(crate) fn refresh_blame(&mut self) -> bool {
self.blame_source = None;
self.poll_blame()
}
fn poll_battery(&mut self) -> bool {
@@ -1190,37 +1215,68 @@ mod tests {
#[test]
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond");
let path = fixture.file("source.txt", "first\nsecond\nthird");
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
assert!(session.editor.git_blame());
let first = session.blame_request_id;
session.poll_blame();
assert_eq!(session.blame_request_id, first);
let original = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
let second = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Second".into(),
uncommitted: false,
});
let blame = || {
Ok(Some(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
}))
Ok(Some(vec![
Some(original.clone()),
Some(second.clone()),
Some(second.clone()),
]))
};
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
session.blame_worker = None;
assert!(session.apply_blame_result(first, blame()));
assert_eq!(
session.editor.line_blame(),
Some("Author · Second (01234567)")
);
let label = session.editor.line_blame().unwrap().as_ptr();
session.handle(Input::Down);
assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label);
assert!(!session.poll_blame());
session.handle(Input::Up);
session.handle(Input::Up);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
session.execute("tab.new");
assert_eq!(session.blame_request_id, first);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
let other = fixture.file("other.txt", "other");
session.execute(&format!("edit {}", other.display()));
assert!(session.editor.line_blame().is_none());
session.handle(Input::Up);
assert!(!session.apply_blame_result(first, blame()));
session.execute("buffer 1");
assert!(!session.apply_blame_result(first, blame()));
let source = session.editor.blame_source().unwrap();
let before_edit = session.blame_request_id;
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert!(
!session
.editor
.set_line_blame(source, blame().unwrap().unwrap())
);
assert_ne!(session.blame_request_id, before_edit);
assert!(!session.apply_blame_result(before_edit, blame()));
assert!(!session.editor.set_line_blame(source, original.clone()));
assert!(session.editor.line_blame().is_none());
session.handle(Input::Escape);
session.execute("git_blame.toggle");
assert!(!session.editor.git_blame());
@@ -1241,6 +1297,92 @@ mod tests {
assert!(disabled.blame_worker.is_none());
}
#[test]
fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q"]);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Original"]);
fixture.file("source.txt", "first\nchanged\n");
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Second"]);
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let ready = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
while session.blame_lines.is_none() {
assert!(Instant::now() < deadline, "file attribution did not finish");
session.poll();
thread::sleep(Duration::from_millis(5));
}
};
let first = session.blame_request_id;
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Up);
assert!(session.editor.line_blame().unwrap().contains("Original"));
session.handle(Input::Down);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Down);
assert!(session.editor.line_blame().is_none());
assert_eq!(session.blame_request_id, first);
session.handle(Input::Up);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert_ne!(session.blame_request_id, first);
ready(&mut session);
assert_eq!(session.editor.line_blame(), Some("Uncommitted"));
session.handle(Input::Escape);
let edited = session.blame_request_id;
session.execute("save");
assert_ne!(session.blame_request_id, edited);
ready(&mut session);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Saved"]);
let saved = session.blame_request_id;
session.refresh_blame();
assert_ne!(session.blame_request_id, saved);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Saved"));
fixture.file("source.txt", "first\nxchanged\n");
let refreshed = session.blame_request_id;
assert!(!session.editor.refresh_files());
session.synchronize_documents();
assert_ne!(session.blame_request_id, refreshed);
ready(&mut session);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
ready(&mut session);
assert!(session.editor.dirty());
fs::remove_file(path).unwrap();
assert!(session.editor.refresh_files());
session.synchronize_documents();
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
}
#[test]
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
use crate::plugin::Utf16Range;
+11 -7
View File
@@ -846,7 +846,11 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
pending = following;
}
Event::Resize(_, _) => redraw = true,
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
Event::FocusGained => {
redraw |= session.refresh_blame();
query_theme = true;
}
Event::ColorSchemeChanged => query_theme = true,
_ => {}
}
}
@@ -7435,12 +7439,12 @@ end
let source = editor.blame_source().unwrap();
assert!(editor.set_line_blame(
source,
crate::blame::LineBlame {
Arc::new(crate::blame::LineBlame {
commit: "0123456789abcdef".into(),
author: "A\tuthor".into(),
summary: "Summary\n\u{1b}done".into(),
uncommitted: false,
}
})
));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
let mut views = BufferViewports::default();
@@ -7486,12 +7490,12 @@ end
let source = editor.blame_source().unwrap();
editor.set_line_blame(
source,
crate::blame::LineBlame {
Arc::new(crate::blame::LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true,
},
}),
);
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap();
@@ -7532,12 +7536,12 @@ end
let source = session.editor().blame_source().unwrap();
session.editor_mut().set_line_blame(
source,
crate::blame::LineBlame {
Arc::new(crate::blame::LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true,
},
}),
);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
let mut views = BufferViewports::default();