use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver, Sender};
use strop_core::Buffer;
use strop_git::memory::{self, BlameCard, BlameLine, ChangedFile, LogRow};
use strop_git::{Hunk, LineOrigin};
use super::{Editor, Key, Mode};
#[derive(Debug, Clone)]
pub enum Surface {
CommitLog {
rows: Vec<LogRow>,
focus: Option<String>,
return_to: Option<ReturnPoint>,
},
ChangedFiles {
sha: String,
files: Vec<ChangedFile>,
return_to: Option<ReturnPoint>,
},
Diff {
label: String,
hunks: Vec<Hunk>,
added: usize,
deleted: usize,
origin: Option<HunkOrigin>,
commit: Option<CommitFiles>,
sidebar_focus: bool,
return_to: Option<ReturnPoint>,
},
}
#[derive(Debug, Clone)]
pub struct CommitFiles {
pub sha: String,
pub files: Vec<ChangedFile>,
}
#[derive(Debug, Clone)]
pub struct ReturnPoint {
pub buffer: usize,
pub cursor: usize,
pub view_top: usize,
}
impl Surface {
fn set_return_point(&mut self, ret: ReturnPoint) {
*self.return_slot() = Some(ret);
}
pub(crate) fn return_point(&self) -> Option<&ReturnPoint> {
match self {
Surface::CommitLog { return_to, .. }
| Surface::ChangedFiles { return_to, .. }
| Surface::Diff { return_to, .. } => return_to.as_ref(),
}
}
fn return_slot(&mut self) -> &mut Option<ReturnPoint> {
match self {
Surface::CommitLog { return_to, .. }
| Surface::ChangedFiles { return_to, .. }
| Surface::Diff { return_to, .. } => return_to,
}
}
}
#[derive(Debug, Clone)]
pub struct HunkOrigin {
pub buffer: usize,
pub epoch: u64,
}
#[derive(Debug, Clone)]
pub struct BlameGutter {
pub lines: Vec<BlameLine>,
pub epoch: u64,
}
pub enum GitJob {
Log {
buffer: usize,
generation: u64,
rows: Vec<LogRow>,
},
Card {
generation: u64,
card: Box<BlameCard>,
},
Gutter {
path: PathBuf,
generation: u64,
lines: Vec<BlameLine>,
},
Error(String),
}
impl Editor {
pub fn surface(&self) -> Option<&Surface> {
self.surfaces.get(self.current).and_then(|s| s.as_ref())
}
fn push_surface(&mut self, name: Option<&str>, text: &str, mut surface: Surface) {
if self.surface().is_none() {
surface.set_return_point(ReturnPoint {
buffer: self.current,
cursor: self.cursor,
view_top: self.view_top,
});
}
let mut buf = Buffer::from_text(text);
buf.readonly = true;
buf.name = name.map(|n| n.to_string());
self.buffers.push(buf);
self.surfaces.push(Some(surface));
self.highlighters.push(None); self.generation += 1; self.current = self.buffers.len() - 1;
self.touch_mru(self.current);
self.cursor = 0;
self.view_top = 0;
}
pub(crate) fn open_diff_surface(
&mut self,
name: &str,
label: &str,
hunks: Vec<Hunk>,
origin: Option<HunkOrigin>,
) {
self.open_delta(name, label, hunks, origin, None);
}
fn open_delta(
&mut self,
name: &str,
label: &str,
hunks: Vec<Hunk>,
origin: Option<HunkOrigin>,
commit: Option<CommitFiles>,
) {
let (added, deleted) = hunk_stats(&hunks);
let text = diff_surface_text(label, &hunks);
self.push_surface(
Some(name),
&text,
Surface::Diff {
label: label.to_string(),
hunks,
added,
deleted,
origin,
commit,
sidebar_focus: false,
return_to: None,
},
);
if let Some(hl) = strop_syntax::Highlighter::for_path(label) {
let last = self.highlighters.len() - 1;
self.highlighters[last] = Some(hl);
}
}
pub(crate) fn open_log(&mut self, file_scoped: bool) {
self.open_log_inner(file_scoped, None);
}
pub(crate) fn open_log_at(&mut self, sha: &str) {
self.open_log_inner(false, Some(sha.to_string()));
}
fn open_log_inner(&mut self, file_scoped: bool, focus: Option<String>) {
let Some(repo) = &self.git else {
self.message = "not a git repo".into();
return;
};
let workdir = repo.workdir().to_path_buf();
let file = if file_scoped {
self.buf().path.as_deref().and_then(|p| {
let abs = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else {
workdir.join(p)
};
abs.strip_prefix(&workdir).ok().map(|r| r.to_path_buf())
})
} else {
None
};
self.push_surface(
Some(if file_scoped {
"git log ·file"
} else {
"git log"
}),
"loading log…",
Surface::CommitLog {
rows: vec![],
focus,
return_to: None,
},
);
let idx = self.current;
let generation = self.generation;
let tx = self.git_tx.clone();
std::thread::spawn(move || {
let msg = match memory::log_graph(&workdir, 200, file.as_deref()) {
Ok(rows) => GitJob::Log {
buffer: idx,
generation,
rows,
},
Err(e) => GitJob::Error(e),
};
let _ = tx.send(msg);
});
}
pub(crate) fn toggle_blame_gutter(&mut self) {
if self.buf().readonly || self.buf().path.is_none() {
return self.blame_line();
}
let key = self.blame_key();
if self.blame_gutters.remove(&key).is_some() {
return; }
self.blame_gutters.insert(
key.clone(),
BlameGutter {
lines: Vec::new(),
epoch: self.buf().epoch,
},
);
self.spawn_blame_file(&key);
self.blame_line(); }
fn blame_key(&self) -> PathBuf {
self.blame_key_of(self.buf().path.as_deref().unwrap_or(""))
}
fn blame_key_of(&self, path: &str) -> PathBuf {
Path::new(path)
.canonicalize()
.unwrap_or_else(|_| self.cwd.join(path))
}
fn spawn_blame_file(&mut self, key: &Path) {
let Some(repo) = &self.git else {
self.message = "not a git repo".into();
return;
};
let workdir = repo.workdir().to_path_buf();
let key = key.to_path_buf(); let Ok(rel) = key.strip_prefix(&workdir).map(|r| r.to_path_buf()) else {
self.message = "buffer not under workdir".into();
return;
};
let generation = self.generation;
let tx = self.git_tx.clone();
std::thread::spawn(move || {
let msg = match memory::blame_file(&workdir, &rel) {
Ok(lines) => GitJob::Gutter {
path: key.to_path_buf(),
generation,
lines,
},
Err(e) => GitJob::Error(e),
};
let _ = tx.send(msg);
});
}
pub fn blame_gutter_for(&self, buffer: usize) -> Option<&BlameGutter> {
let buf = self.buffers.get(buffer)?;
let path = buf.path.as_deref()?;
let key = Path::new(path)
.canonicalize()
.unwrap_or_else(|_| self.cwd.join(path));
let gutter = self.blame_gutters.get(&key)?;
let content_lines = buf.last_content_line() + 1;
(gutter.epoch == buf.epoch && gutter.lines.len() == content_lines).then_some(gutter)
}
pub(crate) fn dive_from_blame(&mut self) {
if self.buf().readonly || self.buf().path.is_none() {
return;
}
let key = self.blame_key();
match self.blame_gutters.get(&key) {
None => {}
Some(_) if self.blame_gutter_for(self.current).is_some() => {
let line = self.buf().line_of(self.cursor);
match self.blame_gutters.get(&key).and_then(|g| g.lines.get(line)) {
Some(bl) if bl.is_uncommitted() => self.message = "uncommitted line".into(),
Some(bl) => {
let sha = bl.sha.clone();
self.open_log_at(&sha);
}
None => {}
}
}
Some(_) => self.blame_line(), }
}
pub(crate) fn blame_line(&mut self) {
let Some(repo) = &self.git else {
self.message = "not a git repo".into();
return;
};
let Some(path) = self.buf().path.clone() else {
self.message = "blame works on file buffers".into();
return;
};
let workdir = repo.workdir().to_path_buf();
let line = self.buf().line_of(self.cursor) + 1;
let generation = self.generation;
let tx = self.git_tx.clone();
std::thread::spawn(move || {
let abs = if Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
workdir.join(&path)
};
let rel = match abs.strip_prefix(&workdir) {
Ok(r) => r.to_path_buf(),
Err(_) => {
let _ = tx.send(GitJob::Error("not under workdir".into()));
return;
}
};
let msg = match memory::blame_line(&workdir, &rel, line) {
Ok(card) => GitJob::Card {
generation,
card: Box::new(card),
},
Err(e) => GitJob::Error(e),
};
let _ = tx.send(msg);
});
}
pub(crate) fn yank_permalink(&mut self) {
match self.build_permalink() {
Ok(url) => {
self.set_register(None, url.clone(), false);
self.osc52 = Some(url);
self.message = "permalink copied".into();
}
Err(e) => self.message = e,
}
}
pub(crate) fn open_permalink(&mut self) {
let url = match self.build_permalink() {
Ok(url) => url,
Err(e) => {
self.message = e;
return;
}
};
for opener in ["wslview", "xdg-open", "open"] {
if std::process::Command::new(opener)
.arg(&url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
{
self.message = format!("opened {url}");
return;
}
}
self.message = format!("no opener found — {url}");
}
fn build_permalink(&self) -> Result<String, String> {
let Some(repo) = self.git.as_ref() else {
return Err("not a git repository".into());
};
let Some(path) = self.buf().path.as_deref() else {
return Err("no file for this buffer".into());
};
let abs = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
repo.workdir().join(path)
};
let Ok(rel) = abs.strip_prefix(repo.workdir()) else {
return Err(format!("{} is outside the repo", abs.display()));
};
let (a, b) = if self.mode == Mode::Visual || self.mode == Mode::VisualLine {
(
self.buf().line_of(self.anchor) + 1,
self.buf().line_of(self.cursor) + 1,
)
} else {
let l = self.buf().line_of(self.cursor) + 1;
(l, l)
};
let remotes = repo.remotes();
if remotes.is_empty() {
return Err("no remote configured".into());
}
if memory::pick_remote(repo).is_none() {
return Err(format!("unsupported remote URL: {}", remotes[0].1));
}
memory::permalink(repo, rel, a.min(b), a.max(b)).ok_or_else(|| "no HEAD commit".into())
}
pub(crate) fn feed_readonly(&mut self, key: Key) {
if !self.pending.is_empty() {
return self.feed_pending_readonly(key);
}
match key {
Key::Char('q') => {
self.close_surface();
}
Key::CtrlW => self.pending = "\x17".into(),
Key::Tab | Key::Backtab => self.toggle_sidebar_focus(),
Key::Char('j') if self.sidebar_focused() => self.commit_file_step(true),
Key::Char('k') if self.sidebar_focused() => self.commit_file_step(false),
Key::Enter if self.sidebar_focused() => self.toggle_sidebar_focus(),
Key::Enter => self.dive(),
Key::Char('n') => self.repeat_search(false),
Key::Char('N') => self.repeat_search(true),
Key::Char('v') => {
self.mode = Mode::Visual;
self.anchor = self.cursor;
}
Key::Char(c) => {
self.pending.push(c);
if !matches!(c, ' ' | ':' | 'g' | 'y' | ']' | '[') {
self.resolve_pending_readonly();
}
}
_ => {}
}
}
fn feed_pending_readonly(&mut self, key: Key) {
match key {
Key::Esc => self.pending.clear(),
Key::Enter => {
if self.pending.starts_with(':') {
self.run_ex(); } else if self.pending.contains('/') {
self.pending.push('\r');
self.resolve_pending_readonly();
} else {
self.pending.clear();
}
}
Key::Char(c) => {
if self.pending == " " {
self.pending.clear();
if c == 'g' {
self.pending = " g".into();
}
return;
}
if self.pending == "\x17" {
self.pending.clear();
return match c {
'h' | 'l' | 'j' | 'k' | 'w' => self.pane_move(c),
'v' => self.split(true, None),
's' => self.split(false, None),
'q' => self.close_surface(),
_ => self.message = "C-w: h l j k w move · v s split · q close".into(),
};
}
if self.pending == " g" {
return self.feed_git_pending(c);
}
if (self.pending == "]" || self.pending == "[") && (c == 'c' || c == 'f') {
let forward = self.pending == "]";
self.pending.clear();
return if c == 'c' {
self.jump_hunk(forward)
} else {
self.commit_file_step(forward)
};
}
self.pending.push(c);
self.resolve_pending_readonly();
}
_ => {}
}
}
fn resolve_pending_readonly(&mut self) {
match strop_grammar::parse(&self.pending) {
strop_grammar::Parse::Incomplete => {}
strop_grammar::Parse::Invalid => {
self.pending.clear();
self.message = "readonly — q closes, enter dives".into();
}
strop_grammar::Parse::Complete(cmd) => {
self.pending.clear();
match cmd.op {
None => self.move_cursor(&cmd),
Some(strop_grammar::Op::Yank) => self.yank_only(&cmd),
Some(_) => self.message = "readonly buffer".into(),
}
}
}
}
fn yank_only(&mut self, cmd: &strop_grammar::Command) {
if let Some(r) = strop_grammar::resolve(self.buf(), self.cursor, cmd) {
let text = self.buf().slice_string(r.range);
self.set_register(cmd.register, text, r.range.linewise);
self.flash(r.range);
}
}
fn dive(&mut self) {
let line = self.buf().line_of(self.cursor);
match self.surface().cloned() {
Some(Surface::CommitLog { rows, .. }) => {
let Some(sha) = rows.get(line).and_then(|r| r.sha.clone()) else {
return;
};
let Some(repo) = &self.git else { return };
match memory::show_stat(repo.workdir(), &sha) {
Ok(files) => {
let mut text = format!("commit {}\n\n", &sha[..10.min(sha.len())]);
for f in &files {
text.push_str(&f.path.display().to_string());
text.push('\n');
}
self.push_surface(
Some("commit files"),
&text,
Surface::ChangedFiles {
sha,
files,
return_to: None,
},
);
}
Err(e) => self.message = e,
}
}
Some(Surface::ChangedFiles { sha, files, .. }) => {
let Some(file) = line.checked_sub(2).and_then(|i| files.get(i)) else {
return;
};
let Some(repo) = &self.git else { return };
match repo.commit_file_diff(&sha, &file.path) {
Ok(diff) => {
let commit = CommitFiles {
sha: sha.clone(),
files: files.clone(),
};
self.open_delta(
"delta",
&file.path.display().to_string(),
diff.hunks,
None,
Some(commit),
);
}
Err(e) => self.message = e,
}
}
_ => {}
}
}
fn toggle_sidebar_focus(&mut self) {
let Some(Some(Surface::Diff {
commit: Some(_),
sidebar_focus,
..
})) = self.surfaces.get_mut(self.current)
else {
self.message = "tab: no file sidebar here".into();
return;
};
*sidebar_focus = !*sidebar_focus;
}
fn sidebar_focused(&self) -> bool {
matches!(
self.surface(),
Some(Surface::Diff {
sidebar_focus: true,
..
})
)
}
fn close_surface(&mut self) {
self.close_pane_or_buffer(true);
if let Some(pane) = self.panes.get_mut(self.active_pane) {
pane.buffer = self.current; }
}
pub(crate) fn commit_file_step(&mut self, forward: bool) {
let Some(Surface::Diff {
commit: Some(cf),
label,
..
}) = self.surface().cloned()
else {
self.message = "]f/[f: file navigation needs a commit diff".into();
return;
};
if cf.files.len() < 2 {
self.message = "single-file commit".into();
return;
}
let Some(cur) = cf
.files
.iter()
.position(|f| f.path.display().to_string() == label)
else {
self.message = "current file not in commit".into();
return;
};
let n = cf.files.len();
let next = if forward {
(cur + 1) % n
} else {
(cur + n - 1) % n
};
let file = cf.files[next].clone();
let Some(repo) = &self.git else { return };
match repo.commit_file_diff(&cf.sha, &file.path) {
Ok(diff) => self.load_commit_delta(&cf, &file.path, diff.hunks),
Err(e) => self.message = e,
}
}
fn load_commit_delta(&mut self, cf: &CommitFiles, path: &Path, hunks: Vec<Hunk>) {
let (added, deleted) = hunk_stats(&hunks);
let label = path.display().to_string();
let text = diff_surface_text(&label, &hunks);
let idx = self.current;
self.buffers[idx].replace_all(&text);
if let Some(Some(Surface::Diff {
label: slot,
hunks: hunk_slot,
added: add_slot,
deleted: del_slot,
..
})) = self.surfaces.get_mut(idx)
{
*slot = label.clone();
*hunk_slot = hunks;
*add_slot = added;
*del_slot = deleted;
}
self.highlighters[idx] = strop_syntax::Highlighter::for_path(&label);
self.cursor = 0;
self.view_top = 0;
let pos = cf
.files
.iter()
.position(|f| f.path == path)
.map_or(0, |i| i + 1);
self.message = format!("{label} · {pos}/{}", cf.files.len());
}
pub fn drain_git_jobs(&mut self) {
while let Ok(job) = self.git_rx.try_recv() {
match job {
GitJob::Log {
buffer,
generation,
rows,
} => {
if generation != self.generation || buffer >= self.buffers.len() {
continue;
}
let text = rows
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
.join("\n")
+ "\n";
self.buffers[buffer].replace_all(&text);
let mut focus_row = None;
if let Some(Some(Surface::CommitLog {
rows: slot, focus, ..
})) = self.surfaces.get_mut(buffer)
{
focus_row = focus.take().and_then(|sha| {
rows.iter().position(|r| r.sha.as_deref() == Some(&sha))
});
*slot = rows;
}
if let Some(row) = focus_row {
if self.current == buffer {
self.cursor = self.buffers[buffer].line_start(row);
self.view_top = row;
}
}
}
GitJob::Card { generation, card } => {
if generation == self.generation {
self.blame_card = Some(*card);
}
}
GitJob::Gutter {
path,
generation,
lines,
} => {
if generation != self.generation {
continue;
}
if let Some(gutter) = self.blame_gutters.get_mut(&path) {
gutter.lines = lines;
if self
.buf()
.path
.as_deref()
.is_some_and(|p| self.blame_key_of(p) == path)
{
self.blame_card = None;
}
}
}
GitJob::Error(e) => self.message = e,
}
}
}
}
fn hunk_stats(hunks: &[Hunk]) -> (usize, usize) {
hunks.iter().fold((0, 0), |(a, d), h| {
let adds = h
.lines
.iter()
.filter(|l| l.origin == LineOrigin::Addition)
.count();
let dels = h
.lines
.iter()
.filter(|l| l.origin == LineOrigin::Deletion)
.count();
(a + adds, d + dels)
})
}
fn diff_surface_text(label: &str, hunks: &[Hunk]) -> String {
let (added, deleted) = hunk_stats(hunks);
let mut text = format!("{label} +{added} -{deleted}\n");
for hunk in hunks {
text.push_str(&hunk.header());
text.push('\n');
for line in &hunk.lines {
text.push_str(&line.text);
text.push('\n');
}
}
text
}
pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
channel()
}
#[cfg(test)]
mod tests {
use std::process::Command;
use crate::editor::{Editor, GitJob, Key, Surface};
use strop_core::Buffer;
use strop_git::memory::LogRow;
use strop_git::LineOrigin;
fn fixture() -> (tempfile::TempDir, Editor) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "first"]);
std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\n").unwrap();
git(&["commit", "-qam", "add b"]);
let mut e = Editor::new(Buffer::open(root.join("f.rs").to_str().unwrap()).unwrap());
e.cwd = root.to_path_buf();
e.discover_git();
(dir, e)
}
fn pump(e: &mut Editor) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
e.drain_git_jobs();
let loaded = e.surface().is_some_and(
|s| matches!(s, crate::editor::Surface::CommitLog { rows, .. } if !rows.is_empty()),
);
if loaded || std::time::Instant::now() > deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn commit_browser_dives_to_delta() {
let (_d, mut e) = fixture();
e.open_log(false);
pump(&mut e);
let text = e.buf().rope.to_string();
assert!(text.contains("add b"), "{text}");
assert!(text.contains("first"), "{text}");
assert!(e.buf().readonly, "browser is a readonly real buffer");
e.feed_text("j");
e.feed_text("k");
e.feed(Key::Enter);
let text = e.buf().rope.to_string();
assert!(text.contains("commit"), "{text}");
assert!(text.contains("f.rs"), "{text}");
assert!(matches!(e.surface(), Some(Surface::ChangedFiles { .. })));
e.feed_text("j");
e.feed_text("j");
e.feed(Key::Enter);
let text = e.buf().rope.to_string();
assert!(text.contains("fn b() {}"), "{text}");
assert!(text.starts_with("f.rs +1 -0\n"), "{text}");
assert!(!text.contains("diff --git"), "no raw patch noise: {text}");
assert!(text.contains("@@ -1,1 +1,2 @@"), "hunk header row: {text}");
e.feed_text("x");
assert!(e.message.contains("readonly"));
e.feed_text("q");
assert!(matches!(e.surface(), Some(Surface::ChangedFiles { .. })));
}
#[test]
fn diff_surface_rows_carry_line_numbers() {
let (_d, mut e) = fixture();
e.open_log(false);
pump(&mut e);
e.feed_text("k"); e.feed(Key::Enter);
e.feed_text("jj");
e.feed(Key::Enter);
let Some(Surface::Diff { hunks, .. }) = e.surface() else {
panic!("not a diff surface");
};
let h = &hunks[0];
let ctx = h
.lines
.iter()
.find(|l| l.origin == LineOrigin::Context)
.expect("context line");
assert_eq!((ctx.old_lineno, ctx.new_lineno), (Some(1), Some(1)));
let add = h
.lines
.iter()
.find(|l| l.origin == LineOrigin::Addition)
.expect("addition");
assert_eq!(add.new_lineno, Some(2));
}
#[test]
fn blame_card_shows_commit() {
let (_d, mut e) = fixture();
e.feed_text("j"); e.blame_line();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while e.blame_card.is_none() && std::time::Instant::now() < deadline {
e.drain_git_jobs();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let card = e.blame_card.as_ref().expect("blame card");
assert_eq!(card.summary, "add b");
assert_eq!(card.author, "t");
}
#[test]
fn permalink_needs_remote() {
let (_d, e) = fixture();
assert_eq!(e.build_permalink().unwrap_err(), "no remote configured");
}
#[test]
fn permalink_resolves_sha_and_ssh_remote() {
let (_d, mut e) = fixture();
let root = e.cwd.clone();
Command::new("git")
.args([
"-C",
&root.display().to_string(),
"remote",
"add",
"origin",
"git@github.com:stropdev/strop.git",
])
.output()
.unwrap();
e.discover_git();
e.feed_text("j"); let url = e.build_permalink().expect("permalink");
assert!(
url.starts_with("https://github.com/stropdev/strop/blob/"),
"{url}"
);
assert!(url.ends_with("/f.rs#L2"), "{url}");
assert!(!url.contains("/main/"), "branch must resolve to SHA: {url}");
e.yank_permalink();
assert_eq!(e.register(None).0, url);
assert!(e.osc52.is_some(), "OSC52 payload staged for the TUI");
}
fn git_out(root: &std::path::Path, args: &[&str]) -> String {
String::from_utf8_lossy(
&Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.trim()
.to_string()
}
fn pump_ready(e: &mut Editor, ready: impl Fn(&Editor) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while !ready(e) && std::time::Instant::now() < deadline {
e.drain_git_jobs();
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn blame_gutter_toggles_and_dives() {
let (dir, mut e) = fixture();
let root = dir.path().to_path_buf();
e.feed_text(" gb");
assert_eq!(e.blame_gutters.len(), 1, "gutter on for the buffer");
pump_ready(&mut e, |e| e.blame_gutter_for(0).is_some());
let gutter = e.blame_gutter_for(0).expect("gutter data loaded");
assert_eq!(gutter.lines.len(), 2, "one blame per file line");
assert_eq!(
gutter.lines[0].sha,
git_out(&root, &["rev-parse", "HEAD~1"])
);
assert_eq!(gutter.lines[1].sha, git_out(&root, &["rev-parse", "HEAD"]));
e.feed(Key::Enter);
pump_ready(&mut e, |e| {
e.surface().is_some_and(
|s| matches!(s, crate::editor::Surface::CommitLog { rows, .. } if !rows.is_empty()),
)
});
assert!(
matches!(e.surface(), Some(Surface::CommitLog { .. })),
"dive opened the browser"
);
assert_eq!(
e.buf().line_of(e.cursor),
1,
"cursor on the first-commit row"
);
assert_eq!(e.view_top, 1, "view positioned at the focused sha");
let text = e.buf().rope.to_string();
assert!(text.contains("first"), "{text}");
e.feed_text("q");
assert_eq!(e.blame_gutters.len(), 1, "gutter is per-buffer view state");
e.feed_text(" gb");
assert!(e.blame_gutters.is_empty(), "second toggle turns it off");
e.feed(Key::Enter);
assert!(
!matches!(e.surface(), Some(Surface::CommitLog { .. })),
"Enter without a gutter stays inert"
);
}
#[test]
fn stale_gutter_falls_back_to_card() {
let (_d, mut e) = fixture();
e.feed_text(" gb");
e.git_tx.send(GitJob::Error("\u{0}settled".into())).unwrap();
pump_ready(&mut e, |e| e.message.contains('\u{0}'));
e.message.clear();
e.blame_card = None;
e.feed_text("o");
e.feed_text("fn c() {}");
e.feed(Key::Esc);
e.feed_text(":w<cr>");
assert!(
e.blame_gutter_for(0).is_none(),
"edits void the line↔blame pairing"
);
e.blame_card = None;
e.feed(Key::Enter);
assert!(
!matches!(e.surface(), Some(Surface::CommitLog { .. })),
"no dive from stale data"
);
pump_ready(&mut e, |e| {
e.blame_card.as_ref().is_some_and(|c| c.line == 3)
});
}
#[test]
fn return_point_restores_when_origin_not_current() {
let (dir, mut e) = fixture();
let root = dir.path();
e.feed_text("j$"); let want = e.cursor;
e.open_log(false);
pump(&mut e);
std::fs::write(root.join("g.rs"), "other\n").unwrap();
e.open_buffer(root.join("g.rs").to_str().unwrap()).unwrap();
assert_eq!(e.current, 2, "switched away from the log's origin");
e.current = 1; e.cursor = 0;
e.feed_text("q");
assert_eq!(e.current, 0, "closing switches back to the origin");
assert_eq!(e.cursor, want, "cursor restored, not line 1");
assert_eq!(e.buf().line_of(e.cursor), 1);
}
#[test]
fn stale_log_results_are_dropped() {
let (_d, mut e) = fixture();
e.open_log(false);
pump(&mut e);
let stale = e.generation;
e.feed_text("q"); assert_ne!(stale, e.generation);
e.git_tx
.send(GitJob::Log {
buffer: 1,
generation: stale,
rows: vec![LogRow {
text: "POISON ROW".into(),
sha: None,
}],
})
.unwrap();
e.drain_git_jobs();
for (i, b) in e.buffers.iter().enumerate() {
let text = b.rope.to_string();
assert!(!text.contains("POISON"), "buffer {i} clobbered: {text}");
}
e.open_log(false);
pump(&mut e);
assert!(e.buf().rope.to_string().contains("add b"));
}
#[test]
fn gutter_result_dropped_after_toggle_off() {
let (dir, mut e) = fixture();
let key = dir.path().join("f.rs").canonicalize().unwrap();
e.feed_text(" gb"); e.feed_text(" gb"); assert!(e.blame_gutters.is_empty());
e.git_tx
.send(GitJob::Gutter {
path: key,
generation: e.generation, lines: vec![strop_git::memory::BlameLine {
sha: "deadbeef".into(),
author: "nobody".into(),
age: "1m".into(),
ts: 0,
}],
})
.unwrap();
e.drain_git_jobs();
assert!(
e.blame_gutters.is_empty(),
"a late job must not re-open a closed gutter"
);
}
fn multi_file_fixture() -> (tempfile::TempDir, Editor) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(root.join("a.rs"), "one\n").unwrap();
std::fs::write(root.join("b.rs"), "uno\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "base"]);
std::fs::write(root.join("a.rs"), "one\ntwo\n").unwrap();
std::fs::write(root.join("b.rs"), "uno\ndos\n").unwrap();
git(&["commit", "-qam", "touch both"]);
let mut e = Editor::new(Buffer::open(root.join("a.rs").to_str().unwrap()).unwrap());
e.cwd = root.to_path_buf();
e.discover_git();
(dir, e)
}
#[test]
fn commit_file_nav_walks_files() {
let (_d, mut e) = multi_file_fixture();
e.open_log(false);
pump(&mut e);
e.feed(Key::Enter); e.feed_text("jj");
e.feed(Key::Enter); let (label, files) = match e.surface() {
Some(Surface::Diff {
label,
commit: Some(cf),
..
}) => (label.clone(), cf.files.len()),
other => panic!("not a commit diff: {other:?}"),
};
assert_eq!(label, "a.rs");
assert_eq!(files, 2, "the sidebar's data rides the surface");
e.feed_text("]f");
match e.surface() {
Some(Surface::Diff { label, .. }) => assert_eq!(label, "b.rs"),
other => panic!("surface lost: {other:?}"),
}
let text = e.buf().rope.to_string();
assert!(text.starts_with("b.rs +1 -0\n"), "{text}");
assert!(text.contains("dos"), "{text}");
assert!(e.message.contains("b.rs · 2/2"), "{}", e.message);
e.feed_text("[f");
assert!(
matches!(e.surface(), Some(Surface::Diff { label, .. }) if label == "a.rs"),
"back to the first file"
);
e.feed_text("[f"); assert!(
matches!(e.surface(), Some(Surface::Diff { label, .. }) if label == "b.rs"),
"wraparound to the last file"
);
assert_eq!(
e.buffers.len(),
4,
"]f rewrites the surface in place (no new buffers)"
);
}
#[test]
fn tab_cycles_focus_between_sidebar_and_diff() {
let (_d, mut e) = multi_file_fixture();
e.open_log(false);
pump(&mut e);
e.feed(Key::Enter); e.feed_text("jj");
e.feed(Key::Enter); assert!(!e.sidebar_focused());
e.feed(crate::editor::Key::Tab);
assert!(e.sidebar_focused(), "tab focuses the sidebar");
e.feed_text("j"); assert!(
matches!(e.surface(), Some(Surface::Diff { label, .. }) if label == "b.rs"),
"j stepped to b.rs"
);
assert!(e.sidebar_focused(), "focus survives the file step");
e.feed(crate::editor::Key::Enter);
assert!(!e.sidebar_focused(), "enter hops back to the diff");
e.feed(crate::editor::Key::Backtab);
assert!(e.sidebar_focused(), "shift-tab focuses too");
}
#[test]
fn q_in_split_closes_pane_then_buffer() {
let (_d, mut e) = fixture();
e.open_log(false);
pump(&mut e);
e.feed(Key::CtrlW);
e.feed_text("v"); assert_eq!(e.panes.len(), 2);
e.feed_text("q");
assert_eq!(e.panes.len(), 1, "q closes the pane in a split");
assert_eq!(e.buffers.len(), 2, "the surface buffer survives");
assert!(
matches!(e.surface(), Some(Surface::CommitLog { .. })),
"still on the log"
);
e.feed_text("q");
assert_eq!(e.buffers.len(), 1, "the last pane's q closes the buffer");
assert_eq!(e.current, 0, "back on the origin buffer");
assert!(e.surface().is_none());
}
#[test]
fn gutters_and_sidebar_render() {
let (dir, mut e) = fixture();
let root = dir.path().to_path_buf();
e.feed_text(" gb");
pump_ready(&mut e, |e| e.blame_gutter_for(0).is_some());
let frame = crate::headless::frame_string(&mut e, 100, 10);
let first_sha = git_out(&root, &["rev-parse", "HEAD~1"]);
assert!(
frame.contains(&format!("{} t ", &first_sha[..7])),
"blame cell: {frame}"
);
assert!(
frame.contains("fn a() {}"),
"content still renders right of the gutter: {frame}"
);
let (_d, mut e) = multi_file_fixture();
e.open_log(false);
pump(&mut e);
e.feed(Key::Enter);
e.feed_text("jj");
e.feed(Key::Enter); let frame = crate::headless::frame_string(&mut e, 100, 12);
assert!(frame.contains("▌a.rs"), "current file marked: {frame}");
assert!(frame.contains(" b.rs"), "sibling files listed: {frame}");
e.feed_text("]f");
let frame = crate::headless::frame_string(&mut e, 100, 12);
assert!(frame.contains("▌b.rs"), "marker follows ]f: {frame}");
}
}