perf: cache file attribution for instant blame navigation
This commit is contained in:
@@ -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 `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.
|
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 `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.
|
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
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+198
-71
@@ -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::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
fs,
|
fs,
|
||||||
io::{self, Read, Write},
|
io::{self, Read, Write},
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
@@ -15,8 +16,8 @@ use std::{
|
|||||||
const LIMIT: usize = 16 * 1024 * 1024;
|
const LIMIT: usize = 16 * 1024 * 1024;
|
||||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||||
type ResultLine = Result<Option<LineBlame>, String>;
|
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
|
||||||
type Request = (u64, u64, PathBuf, String, usize);
|
type Request = (u64, u64, PathBuf, String);
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub(crate) struct LineBlame {
|
pub(crate) struct LineBlame {
|
||||||
@@ -29,7 +30,7 @@ pub(crate) struct LineBlame {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct State {
|
struct State {
|
||||||
request: Option<Request>,
|
request: Option<Request>,
|
||||||
result: Option<(u64, ResultLine)>,
|
result: Option<(u64, ResultFile)>,
|
||||||
}
|
}
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Shared {
|
struct Shared {
|
||||||
@@ -62,13 +63,13 @@ impl BlameWorker {
|
|||||||
if worker.stopped.load(Ordering::Acquire) {
|
if worker.stopped.load(Ordering::Acquire) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let (generation, id, path, contents, row) = state.request.take().unwrap();
|
let (generation, id, path, contents) = state.request.take().unwrap();
|
||||||
drop(state);
|
drop(state);
|
||||||
let cancelled = || {
|
let cancelled = || {
|
||||||
worker.stopped.load(Ordering::Acquire)
|
worker.stopped.load(Ordering::Acquire)
|
||||||
|| worker.generation.load(Ordering::Acquire) != generation
|
|| 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 {
|
let Ok(mut state) = worker.state.lock() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -83,13 +84,7 @@ impl BlameWorker {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn request(
|
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
|
||||||
&mut self,
|
|
||||||
id: u64,
|
|
||||||
path: PathBuf,
|
|
||||||
contents: String,
|
|
||||||
row: usize,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
if contents.len() > LIMIT {
|
if contents.len() > LIMIT {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
@@ -106,13 +101,13 @@ impl BlameWorker {
|
|||||||
.generation
|
.generation
|
||||||
.fetch_add(1, Ordering::AcqRel)
|
.fetch_add(1, Ordering::AcqRel)
|
||||||
.wrapping_add(1);
|
.wrapping_add(1);
|
||||||
state.request = Some((generation, id, path, contents, row));
|
state.request = Some((generation, id, path, contents));
|
||||||
state.result = None;
|
state.result = None;
|
||||||
self.shared.wake.notify_one();
|
self.shared.wake.notify_one();
|
||||||
Ok(())
|
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()
|
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 {
|
if contents.len() > LIMIT {
|
||||||
return Err("Blame input exceeds 16 MiB".into());
|
return Err("Blame input exceeds 16 MiB".into());
|
||||||
}
|
}
|
||||||
// The editor has a final empty row after a newline; Git has no corresponding source line.
|
// 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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
|
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
|
||||||
@@ -386,39 +382,58 @@ fn read_line(path: &Path, contents: &str, row: usize, cancelled: &dyn Fn() -> bo
|
|||||||
if run(present, &[], cancelled)?.is_none() {
|
if run(present, &[], cancelled)?.is_none() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let line = row.checked_add(1).ok_or("Invalid blame row")?;
|
|
||||||
command
|
command
|
||||||
.args(["blame", "--line-porcelain", "--no-textconv", "-L"])
|
.args([
|
||||||
.arg(format!("{line},{line}"))
|
"blame",
|
||||||
.args(["--contents", "-", "--"])
|
"--incremental",
|
||||||
|
"--no-textconv",
|
||||||
|
"--contents",
|
||||||
|
"-",
|
||||||
|
"--",
|
||||||
|
])
|
||||||
.arg(relative);
|
.arg(relative);
|
||||||
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
|
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 malformed = || "Invalid Git blame metadata".to_owned();
|
||||||
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
|
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
|
||||||
let mut lines = text.lines();
|
let mut lines = text.lines();
|
||||||
let mut header = lines.next().ok_or_else(malformed)?.split(' ');
|
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 commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
|
||||||
if !header
|
let original = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||||
.next()
|
let start = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||||
.and_then(|n| n.parse::<usize>().ok())
|
let count = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||||
.is_some_and(|n| n > 0)
|
let (Some(original), Some(start), Some(count)) = (original, start, count) else {
|
||||||
|| header.next().and_then(|n| n.parse::<usize>().ok()) != Some(row)
|
return Err(malformed());
|
||||||
|| header.next() != Some("1")
|
};
|
||||||
|| header.next().is_some()
|
if original == 0 || start == 0 || count == 0 || header.next().is_some() {
|
||||||
{
|
|
||||||
return Err(malformed());
|
return Err(malformed());
|
||||||
}
|
}
|
||||||
|
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 author = None;
|
||||||
let mut summary = None;
|
let mut summary = None;
|
||||||
let mut source = false;
|
let mut filename = false;
|
||||||
for line in lines.by_ref() {
|
for line in lines.by_ref() {
|
||||||
if line.starts_with('\t') {
|
if let Some(value) = line.strip_prefix("filename ") {
|
||||||
source = true;
|
if value.is_empty() {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
filename = true;
|
||||||
break;
|
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 let Some(value) = line.strip_prefix("author ") {
|
||||||
if author.replace(value).is_some() {
|
if author.replace(value).is_some() {
|
||||||
return Err(malformed());
|
return Err(malformed());
|
||||||
@@ -430,13 +445,19 @@ fn parse(bytes: &[u8], row: usize) -> Result<LineBlame, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !source || lines.next().is_some() {
|
if !filename {
|
||||||
return Err(malformed());
|
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 author = author.ok_or_else(malformed)?;
|
||||||
let summary = summary.ok_or_else(malformed)?;
|
let summary = summary.ok_or_else(malformed)?;
|
||||||
let uncommitted = commit.bytes().all(|byte| byte == b'0');
|
let uncommitted = commit.bytes().all(|byte| byte == b'0');
|
||||||
Ok(LineBlame {
|
let blame = Arc::new(LineBlame {
|
||||||
commit: if uncommitted {
|
commit: if uncommitted {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
@@ -453,7 +474,20 @@ fn parse(bytes: &[u8], row: usize) -> Result<LineBlame, String> {
|
|||||||
summary.into()
|
summary.into()
|
||||||
},
|
},
|
||||||
uncommitted,
|
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 result.iter().any(Option::is_none) {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
@@ -512,11 +546,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn committed_line_has_native_author_summary_and_commit() {
|
fn committed_line_has_native_author_summary_and_commit() {
|
||||||
let (_temp, path, commit) = fixture();
|
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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[0].as_ref().unwrap(),
|
||||||
|
rows[1].as_ref().unwrap()
|
||||||
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
line,
|
**rows[1].as_ref().unwrap(),
|
||||||
LineBlame {
|
LineBlame {
|
||||||
commit,
|
commit,
|
||||||
author: "Blame Author".into(),
|
author: "Blame Author".into(),
|
||||||
@@ -528,11 +567,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||||
let (_temp, path, _) = fixture();
|
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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
line,
|
**rows[0].as_ref().unwrap(),
|
||||||
LineBlame {
|
LineBlame {
|
||||||
commit: String::new(),
|
commit: String::new(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
@@ -546,38 +585,62 @@ mod tests {
|
|||||||
fn inserted_line_preserves_following_original_attribution() {
|
fn inserted_line_preserves_following_original_attribution() {
|
||||||
let (_temp, path, commit) = fixture();
|
let (_temp, path, commit) = fixture();
|
||||||
let contents = "inserted\nfirst\nsecond\n";
|
let contents = "inserted\nfirst\nsecond\n";
|
||||||
assert!(
|
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
||||||
read_line(&path, contents, 0, &|| false)
|
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()
|
||||||
.unwrap()
|
.unwrap();
|
||||||
.uncommitted
|
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!(
|
assert_eq!(
|
||||||
read_line(&path, contents, 2, &|| false)
|
fs::read_to_string(path).unwrap(),
|
||||||
.unwrap()
|
"first\nmiddle\nsecond\ntail\n"
|
||||||
.unwrap()
|
|
||||||
.commit,
|
|
||||||
commit
|
|
||||||
);
|
);
|
||||||
assert_eq!(read_line(&path, contents, 3, &|| false).unwrap(), None);
|
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||||
let temp = Temporary::new().unwrap();
|
let temp = Temporary::new().unwrap();
|
||||||
let path = temp.0.join("file");
|
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();
|
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="]);
|
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 (_committed, tracked, _) = fixture();
|
||||||
let untracked = tracked.parent().unwrap().join("untracked");
|
let untracked = tracked.parent().unwrap().join("untracked");
|
||||||
fs::write(&untracked, "text").unwrap();
|
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");
|
let link = temp.0.join("link");
|
||||||
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_line(&link, "first\nsecond\n", 0, &|| false).unwrap(),
|
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -587,7 +650,7 @@ mod tests {
|
|||||||
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||||
let path = temp.0.join("file");
|
let path = temp.0.join("file");
|
||||||
fs::write(&path, "original\n").unwrap();
|
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(
|
command(
|
||||||
&temp.0,
|
&temp.0,
|
||||||
"jj",
|
"jj",
|
||||||
@@ -635,16 +698,20 @@ mod tests {
|
|||||||
"id",
|
"id",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let line = read_line(&path, "original\n", 0, &|| false)
|
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
||||||
.unwrap()
|
let line = rows[0].as_ref().unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(line.commit, parent);
|
assert_eq!(line.commit, parent);
|
||||||
assert_eq!(line.author, "Jj Author");
|
assert_eq!(line.author, "Jj Author");
|
||||||
assert_eq!(line.summary, "Jj summary");
|
assert_eq!(line.summary, "Jj summary");
|
||||||
fs::write(&path, "dirty on disk\n").unwrap();
|
fs::write(&path, "dirty on disk\n").unwrap();
|
||||||
assert!(
|
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
||||||
read_line(&path, "unsaved\n", 0, &|| false)
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
||||||
|
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
||||||
|
assert!(
|
||||||
|
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
||||||
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.uncommitted
|
.uncommitted
|
||||||
);
|
);
|
||||||
@@ -670,13 +737,67 @@ mod tests {
|
|||||||
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
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]
|
#[test]
|
||||||
fn malformed_metadata_errors_never_echo_metadata() {
|
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
||||||
for bytes in [b"secret metadata".as_slice(), b"\xffsecret metadata", b"0000000000000000000000000000000000000000 1 2 1\nauthor secret\nsummary secret\n\ttext\n"] {
|
let first = "1111111111111111111111111111111111111111";
|
||||||
assert_eq!(parse(bytes, 1).unwrap_err(), "Invalid Git blame metadata");
|
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]
|
#[test]
|
||||||
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||||
@@ -709,20 +830,26 @@ mod tests {
|
|||||||
let mut worker = BlameWorker::start().unwrap();
|
let mut worker = BlameWorker::start().unwrap();
|
||||||
for id in 0..20 {
|
for id in 0..20 {
|
||||||
worker
|
worker
|
||||||
.request(id, path.clone(), "first\nsecond\n".into(), 1)
|
.request(id, path.clone(), "first\nsecond\n".into())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
if let Some((id, result)) = worker.poll() {
|
if let Some((id, result)) = worker.poll() {
|
||||||
assert_eq!(id, 19);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
thread::yield_now();
|
thread::yield_now();
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
worker
|
worker
|
||||||
.request(20, path, "x".repeat(LIMIT + 1), 0)
|
.request(20, path, "x".repeat(LIMIT + 1))
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.kind(),
|
.kind(),
|
||||||
io::ErrorKind::InvalidInput
|
io::ErrorKind::InvalidInput
|
||||||
|
|||||||
+51
-11
@@ -1561,13 +1561,19 @@ impl Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_file(&mut self) -> io::Result<bool> {
|
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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let Some(path) = &self.path else {
|
let Some(path) = &self.path else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let stamp = FileStamp::read(path)?;
|
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 {
|
if stamp == self.disk_stamp {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -1670,8 +1676,10 @@ struct PendingLspNavigation {
|
|||||||
pub(crate) struct BlameSource {
|
pub(crate) struct BlameSource {
|
||||||
buffer: usize,
|
buffer: usize,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
pub(crate) path: PathBuf,
|
pub(crate) path: Arc<Path>,
|
||||||
pub(crate) row: usize,
|
pub(crate) row: usize,
|
||||||
|
save_revision: u64,
|
||||||
|
disk_stamp: Option<FileStamp>,
|
||||||
pane: usize,
|
pane: usize,
|
||||||
tab: usize,
|
tab: usize,
|
||||||
}
|
}
|
||||||
@@ -1712,7 +1720,7 @@ pub struct Editor {
|
|||||||
battery_charge: Option<u8>,
|
battery_charge: Option<u8>,
|
||||||
battery_label: String,
|
battery_label: String,
|
||||||
git_blame: bool,
|
git_blame: bool,
|
||||||
line_blame: Option<(BlameSource, String)>,
|
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
|
||||||
lsp_names: Vec<String>,
|
lsp_names: Vec<String>,
|
||||||
lsp_label: String,
|
lsp_label: String,
|
||||||
quit: bool,
|
quit: bool,
|
||||||
@@ -4682,27 +4690,37 @@ impl Editor {
|
|||||||
|| self.should_quit()
|
|| self.should_quit()
|
||||||
|| self.diff.is_some()
|
|| self.diff.is_some()
|
||||||
|| self.active.diff_snapshot.is_some()
|
|| self.active.diff_snapshot.is_some()
|
||||||
|
|| self.active.reload_error.is_some()
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(BlameSource {
|
Some(BlameSource {
|
||||||
buffer: self.buffer_id(),
|
buffer: self.buffer_id(),
|
||||||
revision: self.text_revision(),
|
revision: self.text_revision(),
|
||||||
path: self.path()?.to_owned(),
|
path: Arc::from(self.path()?),
|
||||||
row: self.cursor().row,
|
row: self.cursor().row,
|
||||||
|
save_revision: self.active.save_revision,
|
||||||
|
disk_stamp: self.active.disk_stamp,
|
||||||
pane: self.pane_id(),
|
pane: self.pane_id(),
|
||||||
tab: self.tab_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.git_blame
|
||||||
&& !self.should_quit()
|
&& !self.should_quit()
|
||||||
&& self.diff.is_none()
|
&& self.diff.is_none()
|
||||||
&& self.active.diff_snapshot.is_none()
|
&& self.active.diff_snapshot.is_none()
|
||||||
|
&& self.active.reload_error.is_none()
|
||||||
&& source.buffer == self.buffer_id()
|
&& source.buffer == self.buffer_id()
|
||||||
&& source.revision == self.text_revision()
|
&& 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.row == self.cursor().row
|
||||||
&& source.pane == self.pane_id()
|
&& source.pane == self.pane_id()
|
||||||
&& source.tab == self.tab_id()
|
&& source.tab == self.tab_id()
|
||||||
@@ -4712,14 +4730,32 @@ impl Editor {
|
|||||||
self.line_blame.take().is_some()
|
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(
|
pub(crate) fn set_line_blame(
|
||||||
&mut self,
|
&mut self,
|
||||||
source: BlameSource,
|
source: BlameSource,
|
||||||
blame: crate::blame::LineBlame,
|
blame: Arc<crate::blame::LineBlame>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !self.blame_source_is_current(&source) {
|
if !self.blame_source_is_current(&source) {
|
||||||
return false;
|
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 {
|
let label = if blame.uncommitted {
|
||||||
"Uncommitted".to_owned()
|
"Uncommitted".to_owned()
|
||||||
} else {
|
} else {
|
||||||
@@ -4728,15 +4764,19 @@ impl Editor {
|
|||||||
blame.author, blame.summary, blame.commit
|
blame.author, blame.summary, blame.commit
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
self.line_blame = Some((source, label));
|
let changed = self
|
||||||
true
|
.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> {
|
pub(crate) fn line_blame(&self) -> Option<&str> {
|
||||||
self.line_blame
|
self.line_blame
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|(source, _)| self.blame_source_is_current(source))
|
.filter(|(source, _, _)| self.blame_source_is_current(source))
|
||||||
.map(|(_, label)| label.as_str())
|
.map(|(_, _, label)| label.as_str())
|
||||||
}
|
}
|
||||||
pub fn lsp_names(&self) -> &[String] {
|
pub fn lsp_names(&self) -> &[String] {
|
||||||
&self.lsp_names
|
&self.lsp_names
|
||||||
|
|||||||
+165
-23
@@ -176,6 +176,7 @@ pub struct EditorSession {
|
|||||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||||
blame_worker: Option<BlameWorker>,
|
blame_worker: Option<BlameWorker>,
|
||||||
blame_source: Option<BlameSource>,
|
blame_source: Option<BlameSource>,
|
||||||
|
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
||||||
blame_request_id: u64,
|
blame_request_id: u64,
|
||||||
battery: Option<crate::battery::Battery>,
|
battery: Option<crate::battery::Battery>,
|
||||||
battery_enabled: bool,
|
battery_enabled: bool,
|
||||||
@@ -281,6 +282,7 @@ impl EditorSession {
|
|||||||
diff_workers: BTreeMap::new(),
|
diff_workers: BTreeMap::new(),
|
||||||
blame_worker: None,
|
blame_worker: None,
|
||||||
blame_source: None,
|
blame_source: None,
|
||||||
|
blame_lines: None,
|
||||||
blame_request_id: 0,
|
blame_request_id: 0,
|
||||||
battery: None,
|
battery: None,
|
||||||
battery_enabled: false,
|
battery_enabled: false,
|
||||||
@@ -1027,10 +1029,11 @@ impl EditorSession {
|
|||||||
if !self
|
if !self
|
||||||
.blame_source
|
.blame_source
|
||||||
.as_ref()
|
.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_source = self.editor.blame_source();
|
||||||
self.blame_request_id = self.blame_request_id.wrapping_add(1);
|
self.blame_request_id = self.blame_request_id.wrapping_add(1);
|
||||||
|
self.blame_lines = None;
|
||||||
changed |= self.editor.clear_line_blame();
|
changed |= self.editor.clear_line_blame();
|
||||||
if let Some(source) = &self.blame_source {
|
if let Some(source) = &self.blame_source {
|
||||||
if self.blame_worker.is_none() {
|
if self.blame_worker.is_none() {
|
||||||
@@ -1040,9 +1043,8 @@ impl EditorSession {
|
|||||||
&& worker
|
&& worker
|
||||||
.request(
|
.request(
|
||||||
self.blame_request_id,
|
self.blame_request_id,
|
||||||
source.path.clone(),
|
source.path.to_path_buf(),
|
||||||
self.editor.lines().join("\n"),
|
self.editor.lines().join("\n"),
|
||||||
source.row,
|
|
||||||
)
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
@@ -1055,21 +1057,44 @@ impl EditorSession {
|
|||||||
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
|
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
|
||||||
changed |= self.apply_blame_result(id, result);
|
changed |= self.apply_blame_result(id, result);
|
||||||
}
|
}
|
||||||
changed
|
changed | self.refresh_line_blame()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_blame_result(
|
fn apply_blame_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: u64,
|
id: u64,
|
||||||
result: Result<Option<crate::blame::LineBlame>, String>,
|
result: Result<Option<Vec<Option<Arc<crate::blame::LineBlame>>>>, String>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if id == self.blame_request_id
|
if id != self.blame_request_id
|
||||||
&& let Some(source) = &self.blame_source
|
|| !self
|
||||||
&& let Ok(Some(blame)) = result
|
.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 {
|
fn poll_battery(&mut self) -> bool {
|
||||||
@@ -1190,37 +1215,68 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
|
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
|
||||||
let fixture = Fixture::new();
|
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();
|
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||||
assert!(session.editor.git_blame());
|
assert!(session.editor.git_blame());
|
||||||
let first = session.blame_request_id;
|
let first = session.blame_request_id;
|
||||||
session.poll_blame();
|
let original = Arc::new(crate::blame::LineBlame {
|
||||||
assert_eq!(session.blame_request_id, first);
|
|
||||||
let blame = || {
|
|
||||||
Ok(Some(crate::blame::LineBlame {
|
|
||||||
commit: "0123456789".into(),
|
commit: "0123456789".into(),
|
||||||
author: "Author".into(),
|
author: "Author".into(),
|
||||||
summary: "Original".into(),
|
summary: "Original".into(),
|
||||||
uncommitted: false,
|
uncommitted: false,
|
||||||
}))
|
});
|
||||||
|
let second = Arc::new(crate::blame::LineBlame {
|
||||||
|
commit: "0123456789".into(),
|
||||||
|
author: "Author".into(),
|
||||||
|
summary: "Second".into(),
|
||||||
|
uncommitted: false,
|
||||||
|
});
|
||||||
|
let blame = || {
|
||||||
|
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!(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!(
|
assert_eq!(
|
||||||
session.editor.line_blame(),
|
session.editor.line_blame(),
|
||||||
Some("Author · Original (01234567)")
|
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());
|
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()));
|
assert!(!session.apply_blame_result(first, blame()));
|
||||||
let source = session.editor.blame_source().unwrap();
|
let source = session.editor.blame_source().unwrap();
|
||||||
|
let before_edit = session.blame_request_id;
|
||||||
session.handle(Input::Char('i'));
|
session.handle(Input::Char('i'));
|
||||||
session.handle(Input::Char('x'));
|
session.handle(Input::Char('x'));
|
||||||
assert!(
|
assert_ne!(session.blame_request_id, before_edit);
|
||||||
!session
|
assert!(!session.apply_blame_result(before_edit, blame()));
|
||||||
.editor
|
assert!(!session.editor.set_line_blame(source, original.clone()));
|
||||||
.set_line_blame(source, blame().unwrap().unwrap())
|
assert!(session.editor.line_blame().is_none());
|
||||||
);
|
|
||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
session.execute("git_blame.toggle");
|
session.execute("git_blame.toggle");
|
||||||
assert!(!session.editor.git_blame());
|
assert!(!session.editor.git_blame());
|
||||||
@@ -1241,6 +1297,92 @@ mod tests {
|
|||||||
assert!(disabled.blame_worker.is_none());
|
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]
|
#[test]
|
||||||
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
||||||
use crate::plugin::Utf16Range;
|
use crate::plugin::Utf16Range;
|
||||||
|
|||||||
+11
-7
@@ -846,7 +846,11 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
|
|||||||
pending = following;
|
pending = following;
|
||||||
}
|
}
|
||||||
Event::Resize(_, _) => redraw = true,
|
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();
|
let source = editor.blame_source().unwrap();
|
||||||
assert!(editor.set_line_blame(
|
assert!(editor.set_line_blame(
|
||||||
source,
|
source,
|
||||||
crate::blame::LineBlame {
|
Arc::new(crate::blame::LineBlame {
|
||||||
commit: "0123456789abcdef".into(),
|
commit: "0123456789abcdef".into(),
|
||||||
author: "A\tuthor".into(),
|
author: "A\tuthor".into(),
|
||||||
summary: "Summary\n\u{1b}done".into(),
|
summary: "Summary\n\u{1b}done".into(),
|
||||||
uncommitted: false,
|
uncommitted: false,
|
||||||
}
|
})
|
||||||
));
|
));
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
@@ -7486,12 +7490,12 @@ end
|
|||||||
let source = editor.blame_source().unwrap();
|
let source = editor.blame_source().unwrap();
|
||||||
editor.set_line_blame(
|
editor.set_line_blame(
|
||||||
source,
|
source,
|
||||||
crate::blame::LineBlame {
|
Arc::new(crate::blame::LineBlame {
|
||||||
commit: String::new(),
|
commit: String::new(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
uncommitted: true,
|
uncommitted: true,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap();
|
||||||
@@ -7532,12 +7536,12 @@ end
|
|||||||
let source = session.editor().blame_source().unwrap();
|
let source = session.editor().blame_source().unwrap();
|
||||||
session.editor_mut().set_line_blame(
|
session.editor_mut().set_line_blame(
|
||||||
source,
|
source,
|
||||||
crate::blame::LineBlame {
|
Arc::new(crate::blame::LineBlame {
|
||||||
commit: String::new(),
|
commit: String::new(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
uncommitted: true,
|
uncommitted: true,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user