feat: show current-line git blame by default

This commit is contained in:
Valdemar Lorenzen
2026-10-13 12:21:03 +02:00
parent 5382307fcc
commit 7a3c27c30e
12 changed files with 1231 additions and 7 deletions
+740
View File
@@ -0,0 +1,740 @@
//! Read-only current-line attribution; repository commands never touch the editor thread.
use std::{
fs,
io::{self, Read, Write},
path::{Component, Path, PathBuf},
process::{Command, Stdio},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
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);
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame {
pub(crate) commit: String,
pub(crate) author: String,
pub(crate) summary: String,
pub(crate) uncommitted: bool,
}
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultLine)>,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
wake: Condvar,
generation: AtomicU64,
stopped: AtomicBool,
}
pub(crate) struct BlameWorker {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl BlameWorker {
pub(crate) fn start() -> io::Result<Self> {
let shared = Arc::new(Shared::default());
let worker = Arc::clone(&shared);
let thread = thread::Builder::new()
.name("vamp-blame".into())
.spawn(move || {
loop {
let Ok(mut state) = worker.state.lock() else {
return;
};
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
let Ok(next) = worker.wake.wait(state) else {
return;
};
state = next;
}
if worker.stopped.load(Ordering::Acquire) {
break;
}
let (generation, id, path, contents, row) = 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 Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, result));
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
pub(crate) fn request(
&mut self,
id: u64,
path: PathBuf,
contents: String,
row: usize,
) -> io::Result<()> {
if contents.len() > LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Blame input exceeds 16 MiB",
));
}
let mut state = self
.shared
.state
.lock()
.map_err(|_| io::Error::other("Blame worker stopped"))?;
let generation = self
.shared
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, path, contents, row));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultLine)> {
self.shared.state.lock().ok()?.result.take()
}
}
impl Drop for BlameWorker {
fn drop(&mut self) {
let state = self.shared.state.lock();
self.shared.stopped.store(true, Ordering::Release);
self.shared.wake.notify_one();
drop(state);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Result<Self, String> {
for _ in 0..100 {
let path = std::env::temp_dir().join(format!(
"vamp-blame-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(Self(path)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(_) => return Err("Cannot create private blame directory".into()),
}
}
Err("Cannot create private blame directory".into())
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
fn run(
mut command: Command,
input: &[u8],
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
if cancelled() {
return Err("Blame cancelled".into());
}
for key in [
"GIT_DIR",
"GIT_COMMON_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
] {
command.env_remove(key);
}
command
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(not(unix))]
{
return Err("Blame requires Unix process-group cancellation".into());
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
let mut child = command.spawn().map_err(|_| "Cannot start blame command")?;
let stdout = child.stdout.take().unwrap();
let mut stdin = child.stdin.take().unwrap();
thread::scope(|scope| {
let output = scope.spawn(move || {
let mut bytes = Vec::new();
stdout
.take(LIMIT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "Cannot read blame output")?;
if bytes.len() > LIMIT {
return Err("Blame output exceeds 16 MiB");
}
Ok(bytes)
});
let writer = scope.spawn(move || stdin.write_all(input));
let started = Instant::now();
let mut status = None;
let failure = loop {
if cancelled() {
break Some("Blame cancelled");
}
if started.elapsed() >= TIMEOUT {
break Some("Blame command timed out");
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(_) => break Some("Cannot wait for blame command"),
}
}
if status.is_some() && output.is_finished() && writer.is_finished() {
break None;
}
thread::sleep(Duration::from_millis(10));
};
if failure.is_some() {
unsafe {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
let bytes = output.join().map_err(|_| "Blame reader stopped")?;
let written = writer.join().map_err(|_| "Blame writer stopped")?;
if let Some(error) = failure {
return Err(error.into());
}
let bytes = bytes.map_err(str::to_owned)?;
if !status.unwrap().success() {
return Ok(None);
}
written.map_err(|_| "Cannot supply blame contents")?;
Ok(Some(bytes))
})
}
}
fn git(root: &Path) -> Command {
let mut command = Command::new("git");
command
.args([
"--no-pager",
"--literal-pathspecs",
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
])
.arg("-C")
.arg(root);
command
}
fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
#[cfg(unix)]
{
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
}
}
fn read_line(path: &Path, contents: &str, row: usize, cancelled: &dyn Fn() -> bool) -> ResultLine {
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() {
return Ok(None);
}
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
return Ok(None);
}
let path = fs::canonicalize(path).map_err(|_| "Cannot resolve blame file")?;
let Some(root) = path.parent().and_then(|parent| {
parent
.ancestors()
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
}) else {
return Ok(None);
};
let relative = path.strip_prefix(root).map_err(|_| "Invalid blame path")?;
if relative.components().any(|part| {
!matches!(part, Component::Normal(_))
|| part.as_os_str() == ".git"
|| part.as_os_str() == ".jj"
}) {
return Ok(None);
}
let mut temporary = None;
let mut command = git(root);
if root.join(".jj").exists() {
let mut jj = Command::new("jj");
jj.arg("--repository")
.arg(root)
.args([
"--ignore-working-copy",
"--at-operation=@",
"--no-pager",
"--color=never",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
])
.current_dir(root);
let bytes = run(jj, &[], cancelled)?.ok_or("Cannot resolve Jujutsu blame parent")?;
let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu blame metadata")?;
let mut parents = text.lines();
let Some(parent) = parents.next() else {
return Ok(None);
};
// A merge has no single committed baseline; never pick an arbitrary parent.
if parents.next().is_some() || parent.bytes().all(|byte| byte == b'0') {
return Ok(None);
}
if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into());
}
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
repo = root
.join(".jj")
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
}
let store = repo.join("store");
let target = fs::read(store.join("git_target"))
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
let store = fs::canonicalize(
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
)
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.map_err(|_| "Cannot prepare private blame repository")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
.map_err(|_| "Cannot link blame objects")?;
#[cfg(not(unix))]
return Err("Jujutsu blame requires Unix object links".into());
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
.map_err(|_| "Cannot prepare blame parent")?;
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(root);
temporary = Some(private);
}
let mut present = if let Some(private) = &temporary {
let mut command = git(root);
command.arg("--git-dir").arg(&private.0);
command
} else {
git(root)
};
let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative);
present.args(["cat-file", "-e"]).arg(object);
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", "-", "--"])
.arg(relative);
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, line).map(Some)
}
fn parse(bytes: &[u8], row: usize) -> Result<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;
}
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 !source || lines.next().is_some() {
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,
})
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
]);
command.args([
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "blame@example.test"],
);
let path = temp.0.join("-literal [file].txt");
fs::write(&path, "first\nsecond\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Original summary"],
);
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
(temp, path, commit)
}
#[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)
.unwrap()
.unwrap();
assert_eq!(
line,
LineBlame {
commit,
author: "Blame Author".into(),
summary: "Original summary".into(),
uncommitted: false
}
);
}
#[test]
fn unsaved_changed_line_is_uncommitted_without_metadata() {
let (_temp, path, _) = fixture();
let line = read_line(&path, "changed\nsecond\n", 0, &|| false)
.unwrap()
.unwrap();
assert_eq!(
line,
LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true
}
);
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
}
#[test]
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
);
assert_eq!(
read_line(&path, contents, 2, &|| false)
.unwrap()
.unwrap()
.commit,
commit
);
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);
fs::write(&path, "text").unwrap();
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(read_line(&path, "text", 0, &|| 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);
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(),
None
);
}
#[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap();
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);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"describe",
"-m",
"Jj summary",
],
);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"new",
],
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let line = read_line(&path, "original\n", 0, &|| false)
.unwrap()
.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();
assert!(
read_line(&path, "unsaved\n", 0, &|| false)
.unwrap()
.unwrap()
.uncommitted
);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
assert!(!temp.0.join(".git").exists());
command(&temp.0, "jj", &["new", "root()"]);
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);
}
#[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");
}
}
#[test]
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
let temp = Temporary::new().unwrap();
let ready = temp.0.join("ready");
let cancellation = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&cancellation);
let gate = ready.clone();
let worker = thread::spawn(move || {
let mut command = Command::new("sh");
command
.args([
"-c",
"sleep 600 & printf ready > \"$1\"; wait",
"vamp-blame-test",
])
.arg(gate);
run(command, &[], &|| stop.load(Ordering::Acquire))
});
while !ready.exists() && !worker.is_finished() {
thread::yield_now();
}
assert!(ready.exists());
cancellation.store(true, Ordering::Release);
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
}
#[test]
fn worker_keeps_only_latest_request_and_bounds_input() {
let (_temp, path, commit) = fixture();
let mut worker = BlameWorker::start().unwrap();
for id in 0..20 {
worker
.request(id, path.clone(), "first\nsecond\n".into(), 1)
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
assert_eq!(result.unwrap().unwrap().commit, commit);
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, path, "x".repeat(LIMIT + 1), 0)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn subprocess_output_is_bounded() {
let mut command = Command::new("sh");
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
assert_eq!(
run(command, &[], &|| false).unwrap_err(),
"Blame output exceeds 16 MiB"
);
}
}
+113
View File
@@ -776,6 +776,18 @@ core_actions![
],
true,
),
(
"core.git_blame.toggle",
Action::GitBlameToggle,
&[
Mode::Normal,
Mode::Insert,
Mode::Visual,
Mode::Command,
Mode::Search
],
true,
),
(
"core.diff.close",
Action::DiffClose,
@@ -1654,6 +1666,16 @@ struct PendingLspNavigation {
request: Option<u64>,
}
#[derive(Clone)]
pub(crate) struct BlameSource {
buffer: usize,
revision: u64,
pub(crate) path: PathBuf,
pub(crate) row: usize,
pane: usize,
tab: usize,
}
pub struct Editor {
active: Buffer,
parked: BTreeMap<usize, Buffer>,
@@ -1689,6 +1711,8 @@ pub struct Editor {
labels: BTreeMap<String, String>,
battery_charge: Option<u8>,
battery_label: String,
git_blame: bool,
line_blame: Option<(BlameSource, String)>,
lsp_names: Vec<String>,
lsp_label: String,
quit: bool,
@@ -1787,6 +1811,8 @@ impl Editor {
labels: BTreeMap::new(),
battery_charge: None,
battery_label: String::new(),
git_blame: true,
line_blame: None,
lsp_names: Vec::new(),
lsp_label: String::new(),
quit: false,
@@ -3209,6 +3235,7 @@ impl Editor {
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
| Action::GitBlameToggle
| Action::Quit
| Action::ForceQuit
| Action::Restart
@@ -4641,6 +4668,76 @@ impl Editor {
pub fn battery_label(&self) -> &str {
&self.battery_label
}
pub fn git_blame(&self) -> bool {
self.git_blame
}
pub(crate) fn set_git_blame(&mut self, enabled: bool) {
self.git_blame = enabled;
self.line_blame = None;
}
pub(crate) fn blame_source(&self) -> Option<BlameSource> {
if !self.git_blame
|| self.should_quit()
|| self.diff.is_some()
|| self.active.diff_snapshot.is_some()
{
return None;
}
Some(BlameSource {
buffer: self.buffer_id(),
revision: self.text_revision(),
path: self.path()?.to_owned(),
row: self.cursor().row,
pane: self.pane_id(),
tab: self.tab_id(),
})
}
pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool {
self.git_blame
&& !self.should_quit()
&& self.diff.is_none()
&& self.active.diff_snapshot.is_none()
&& source.buffer == self.buffer_id()
&& source.revision == self.text_revision()
&& Some(source.path.as_path()) == self.path()
&& source.row == self.cursor().row
&& source.pane == self.pane_id()
&& source.tab == self.tab_id()
}
pub(crate) fn clear_line_blame(&mut self) -> bool {
self.line_blame.take().is_some()
}
pub(crate) fn set_line_blame(
&mut self,
source: BlameSource,
blame: crate::blame::LineBlame,
) -> bool {
if !self.blame_source_is_current(&source) {
return false;
}
let label = if blame.uncommitted {
"Uncommitted".to_owned()
} else {
sanitize_label(format!(
"{} · {} ({:.8})",
blame.author, blame.summary, blame.commit
))
};
self.line_blame = Some((source, label));
true
}
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())
}
pub fn lsp_names(&self) -> &[String] {
&self.lsp_names
}
@@ -6022,6 +6119,7 @@ impl Editor {
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
| Action::GitBlameToggle
| Action::LspDefinition
| Action::LspHover
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
@@ -6885,6 +6983,21 @@ impl Editor {
}
return None;
}
if entry.action == Action::GitBlameToggle {
if !args.is_empty() {
self.set_message("The git_blame.toggle command takes no arguments");
return None;
}
self.set_git_blame(!self.git_blame);
self.count = None;
self.clear_sequence();
self.palette = None;
if self.active.view.mode == Mode::Command {
self.active.view.command_line.clear();
self.active.view.mode = Mode::Normal;
}
return None;
}
if entry.action == Action::WordWrapToggle {
if !args.is_empty() {
self.set_message("The word_wrap.toggle command takes no arguments");
+1
View File
@@ -1,4 +1,5 @@
mod battery;
mod blame;
pub mod chrome;
pub mod clipboard;
pub mod diff;
+70 -3
View File
@@ -38,6 +38,7 @@ pub struct Config {
pub file_picker_view: String,
pub file_explorer_view: String,
pub clipboard_mode: String,
pub git_blame: bool,
pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine,
pub popup: PopupConfig,
@@ -80,6 +81,7 @@ impl Default for Config {
file_picker_view: "tree".into(),
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
git_blame: true,
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
@@ -109,6 +111,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view,
file_explorer_view,
clipboard_mode,
git_blame,
top_bar,
bottom_bar,
popup,
@@ -139,6 +142,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame;
config.top_bar = top_bar;
config.bottom_bar = bottom_bar;
config.popup = popup;
@@ -453,6 +457,7 @@ struct ConfigFrame {
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
git_blame: bool,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
@@ -854,6 +859,8 @@ fn read_frame(reader: &mut impl BufRead) -> Result<Vec<u8>, String> {
#[cfg(test)]
mod tests {
use crate::{editor::Input, session::EditorSession};
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -1204,14 +1211,73 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[test]
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
let fixture = Fixture::new();
let config = fixture.file("config.rb", "Vamp.configure { |_config| }");
assert!(load_config(&config).unwrap().git_blame);
let config = fixture.file("config.rb", r#"
Vamp.configure do |config|
config.git_blame = false
config.bind "G", config.core.git_blame.toggle
raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen?
end
"#);
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
assert!(!session.editor().git_blame());
session.handle(Input::Char('G'));
assert!(session.editor().git_blame());
session.execute("git_blame.toggle extra");
assert!(session.editor().git_blame());
session.execute("git_blame.toggle");
assert!(!session.editor().git_blame());
session.handle(Input::Ctrl('p'));
for ch in "git blame".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(session.editor().palette().unwrap().entries.len(), 1);
session.handle(Input::Enter);
assert!(session.editor().git_blame());
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_blame = {value} }}"),
);
assert!(
load_config(&config)
.unwrap_err()
.contains("git_blame must be true or false")
);
}
let mut frame = serde_json::json!({
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical"
});
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
for value in [
serde_json::Value::Null,
serde_json::json!(0),
serde_json::json!("false"),
] {
frame["git_blame"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove("git_blame");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
let fixture = Fixture::new();
@@ -1842,6 +1908,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"git_blame": true,
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+10
View File
@@ -136,6 +136,7 @@ module Vamp
@file_picker_view = "tree"
@file_explorer_view = "tree"
@clipboard_mode = "system"
@git_blame = true
@bar = BarConfig.new
@popup = PopupConfig.new
@labels = {}
@@ -187,6 +188,14 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
end
# @param enabled [Boolean]
# @return [Boolean]
def git_blame=(enabled)
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
@git_blame = enabled
end
# @param values [Hash{String, Symbol => String}]
# @return [Hash]
def labels=(values)
@@ -256,6 +265,7 @@ module Vamp
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
+116 -3
View File
@@ -12,8 +12,9 @@ use std::{
};
use crate::{
blame::BlameWorker,
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
lsp::Lsp,
plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
@@ -173,6 +174,9 @@ pub struct EditorSession {
pending_completion: bool,
hover_owner: Option<String>,
diff_workers: BTreeMap<usize, DiffWorker>,
blame_worker: Option<BlameWorker>,
blame_source: Option<BlameSource>,
blame_request_id: u64,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -225,6 +229,7 @@ impl EditorSession {
config.clipboard_mode
));
}
editor.set_git_blame(config.git_blame);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
@@ -274,6 +279,9 @@ impl EditorSession {
pending_completion: false,
hover_owner: None,
diff_workers: BTreeMap::new(),
blame_worker: None,
blame_source: None,
blame_request_id: 0,
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
@@ -512,6 +520,7 @@ impl EditorSession {
}
fn synchronize_documents(&mut self) -> bool {
let blame_changed = self.poll_blame();
// Leaving a request's source invalidates it permanently, even if history returns there.
if let Some(requests) = self.requests.get_mut("core")
&& requests.latest.is_some_and(|(_, source)| {
@@ -536,7 +545,7 @@ impl EditorSession {
}
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return self.refresh_lsp_names();
return self.refresh_lsp_names() | blame_changed;
}
let mut events = Vec::new();
if !self.editor.should_quit() {
@@ -612,7 +621,7 @@ impl EditorSession {
});
}
let mut failures = Vec::new();
let mut changed = false;
let mut changed = blame_changed;
for event in &events {
if let Err(error) = self.lsp.notify_document(event) {
self.editor.set_message(format!("LSP: {error}"));
@@ -1013,6 +1022,56 @@ impl EditorSession {
Ok(changed)
}
fn poll_blame(&mut self) -> bool {
let mut changed = false;
if !self
.blame_source
.as_ref()
.is_some_and(|source| self.editor.blame_source_is_current(source))
{
self.blame_source = self.editor.blame_source();
self.blame_request_id = self.blame_request_id.wrapping_add(1);
changed |= self.editor.clear_line_blame();
if let Some(source) = &self.blame_source {
if self.blame_worker.is_none() {
self.blame_worker = BlameWorker::start().ok();
}
if let Some(worker) = &mut self.blame_worker
&& worker
.request(
self.blame_request_id,
source.path.clone(),
self.editor.lines().join("\n"),
source.row,
)
.is_err()
{
self.blame_worker = None;
}
} else {
self.blame_worker = None;
}
}
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
changed |= self.apply_blame_result(id, result);
}
changed
}
fn apply_blame_result(
&mut self,
id: u64,
result: Result<Option<crate::blame::LineBlame>, String>,
) -> bool {
if id == self.blame_request_id
&& let Some(source) = &self.blame_source
&& let Ok(Some(blame)) = result
{
return self.editor.set_line_blame(source.clone(), blame);
}
false
}
fn poll_battery(&mut self) -> bool {
let enabled = self.editor.label("battery").is_empty()
&& [
@@ -1128,6 +1187,60 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond");
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 blame = || {
Ok(Some(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
}))
};
assert!(session.apply_blame_result(first, blame()));
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
session.handle(Input::Down);
assert!(session.editor.line_blame().is_none());
session.handle(Input::Up);
assert!(!session.apply_blame_result(first, blame()));
let source = session.editor.blame_source().unwrap();
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert!(
!session
.editor
.set_line_blame(source, blame().unwrap().unwrap())
);
session.handle(Input::Escape);
session.execute("git_blame.toggle");
assert!(!session.editor.git_blame());
assert!(session.blame_worker.is_none());
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
assert!(!session.apply_blame_result(session.blame_request_id, blame()));
session.poll_blame();
assert!(session.blame_worker.is_none());
let config = fixture.file(
"config.rb",
"Vamp.configure { |config| config.git_blame = false }",
);
let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
assert!(!disabled.editor.git_blame());
assert!(disabled.blame_worker.is_none());
disabled.poll_blame();
assert!(disabled.blame_worker.is_none());
}
#[test]
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
use crate::plugin::Utf16Range;
+163
View File
@@ -1834,6 +1834,36 @@ fn draw_document(
document_caret = Some(position);
}
}
if focused
&& document_caret.is_some()
&& let Some(label) = editor.line_blame()
&& let Some((screen_row, &(_, continuation, segment))) = document
.visible
.iter()
.enumerate()
.rev()
.find(|(_, (row, _, _))| *row == cursor.row)
{
let line = &editor.lines()[cursor.row];
let end = display_column(line, usize::MAX);
let start = end.saturating_sub(segment.start).saturating_add(2);
let label_width = UnicodeWidthStr::width(label);
if end >= segment.start
&& end <= segment.end
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len())
&& start.saturating_add(label_width) <= width
{
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
Rect::new(
viewport.x + start as u16,
viewport.y + screen_row as u16,
label_width as u16,
1,
),
);
}
}
if !focused {
return;
}
@@ -7394,6 +7424,139 @@ end
assert!(titles.contains("2 [No Name]"), "{titles}");
}
#[test]
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("source.txt", "界 e\u{301}\nsecond")),
vec![],
)
.unwrap();
let source = editor.blame_source().unwrap();
assert!(editor.set_line_blame(
source,
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();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let screen = screen_text(&terminal);
assert!(
screen.contains("界") && screen.contains("e\u{301}"),
"{screen}"
);
assert!(
screen.contains("A�uthor · Summary��done (01234567)"),
"{screen}"
);
let caret = terminal.get_cursor_position().unwrap();
let cell = terminal.backend().buffer()[(caret.x, caret.y)].clone();
editor.handle(Input::Down);
assert!(editor.line_blame().is_none());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Summary"));
editor.handle(Input::Up);
assert_eq!(
editor.line_blame(),
Some("A�uthor · Summary��done (01234567)")
);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("A�uthor · Summary��done (01234567)"));
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
assert_eq!(terminal.backend().buffer()[(caret.x, caret.y)], cell);
}
#[test]
fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap();
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
editor.set_line_blame(
source,
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();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 50, 1)).contains("Uncommitted"));
assert!(pane_screen(&terminal, Rect::new(0, caret.y + 1, 50, 1)).contains("Uncommitted"));
let cell = terminal.backend().buffer()[(16, caret.y + 1)].clone();
assert_eq!(cell.symbol(), "U");
assert_eq!(cell.fg, Color::DarkGray);
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 8)).unwrap();
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
assert!(!screen_text(&narrow).contains("Uncommitted"));
assert!(screen_text(&narrow).contains("xxx"));
editor.execute("git_blame.toggle");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Uncommitted"));
let mut historical = readonly_diff_editor(deletion_file());
assert!(historical.blame_source().is_none());
historical.execute("tab.new");
assert!(historical.diff().is_none());
assert!(historical.blame_source().is_none());
}
#[test]
fn inline_blame_disappears_when_wheel_scroll_hides_the_primary_caret() {
let directory = Directory::new();
let text = (0..40)
.map(|row| format!("row {row}"))
.collect::<Vec<_>>()
.join("\n");
let mut session =
EditorSession::open(Some(directory.file("source.txt", &text)), vec![], None).unwrap();
let source = session.editor().blame_source().unwrap();
session.editor_mut().set_line_blame(
source,
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();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
let area = views.document.geometry.unwrap().1;
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, area.x + 2, area.y + 2)
));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(!screen_text(&terminal).contains("Uncommitted"));
assert_eq!(session.editor().cursor().row, 0);
}
#[test]
fn default_native_battery_and_custom_override_redraw_through_clear() {
let directory = Directory::new();