perf: cache file attribution for instant blame navigation
This commit is contained in:
+229
-102
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user