feat: show current-line git blame by default
This commit is contained in:
+740
@@ -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
@@ -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,4 +1,5 @@
|
||||
mod battery;
|
||||
mod blame;
|
||||
pub mod chrome;
|
||||
pub mod clipboard;
|
||||
pub mod diff;
|
||||
|
||||
+70
-3
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user