use strop_git::{Hunk, HunkKind, Repo, Sign};
use super::Editor;
impl Editor {
pub(crate) fn discover_git(&mut self) {
let from = self
.buf()
.path
.as_deref()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| self.cwd.clone());
self.git = Repo::discover(&from);
self.hunks.clear();
self.hunks_epoch = u64::MAX; }
pub fn refresh_hunks(&mut self) {
let Some(repo) = &self.git else { return };
let epoch = self.buf().epoch;
if epoch == self.hunks_epoch {
return;
}
let Some(path) = self.buf().path.clone() else {
return;
};
let content = self.buf().rope.to_string();
self.hunks = repo.hunks(std::path::Path::new(&path), &content);
self.hunks_epoch = epoch;
}
pub fn sign_at(&self, line_1based: usize) -> Option<char> {
let total = self.buf().len_lines();
for h in &self.hunks {
for (l, kind) in h.signs() {
let matched = match kind {
Sign::AddOrChange => l == line_1based,
Sign::DeleteAfter => l.min(total) == line_1based,
};
if matched {
return Some(match kind {
Sign::DeleteAfter => '-',
Sign::AddOrChange => {
if h.kind == HunkKind::Add {
'+'
} else {
'~'
}
}
});
}
}
}
None
}
pub(crate) fn jump_hunk(&mut self, forward: bool) {
self.refresh_hunks();
let cur = self.buf().line_of(self.cursor) + 1;
let total = self.buf().len_lines();
let mut lines: Vec<usize> = self
.hunks
.iter()
.flat_map(|h| h.signs().iter().map(|&(l, _)| l).collect::<Vec<_>>())
.map(|l| l.min(total))
.collect();
lines.sort_unstable();
lines.dedup();
let target = if forward {
lines.iter().copied().find(|&l| l > cur)
} else {
lines.iter().copied().rev().find(|&l| l < cur)
};
match target {
Some(l) => {
self.cursor = self.buf().line_start(l - 1);
self.clamp_cursor();
}
None => self.message = "no more hunks".into(),
}
}
fn hunk_under_cursor(&mut self) -> Option<Hunk> {
self.refresh_hunks();
let line = self.buf().line_of(self.cursor) + 1;
let total = self.buf().len_lines();
self.hunks.iter().find(|h| h.covers(line, total)).cloned()
}
pub(crate) fn undo_hunk(&mut self) {
let Some(hunk) = self.hunk_under_cursor() else {
self.message = "no hunk here".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
let Some(repo) = &self.git else { return };
let Some(head) = repo.head_content(std::path::Path::new(&path)) else {
self.message = "untracked — nothing to restore from".into();
return;
};
let head_lines: Vec<&str> = head.lines().collect();
let (new_first, new_count, old_first, old_count) = hunk.changed_region();
let old: String = {
let lo = old_first.saturating_sub(1).min(head_lines.len());
let hi = (old_first - 1 + old_count).min(head_lines.len());
head_lines[lo..hi].join("\n")
};
if new_count == 0 {
let total = self.buf().len_lines();
if new_first > total {
let end = self.buf().len_bytes();
self.buf_mut().insert(end, &format!("\n{old}"));
} else {
let at = self.buf().line_start(new_first - 1);
self.buf_mut().insert(at, &format!("{old}\n"));
}
self.cursor = self.buf().line_start(new_first.saturating_sub(1));
} else if old_count == 0 {
let start = self.buf().line_start(new_first - 1);
let last = (new_first - 1 + new_count).min(self.buf().len_lines());
let end = if last >= self.buf().len_lines() {
self.buf().len_bytes()
} else {
self.buf().line_start(last)
};
self.buf_mut()
.delete(strop_core::Range::charwise(start, end));
self.cursor = self
.buf()
.line_start((new_first - 1).min(self.buf().len_lines() - 1));
} else {
let start = self.buf().line_start(new_first - 1);
let end = self
.buf()
.line_end((new_first - 1 + new_count - 1).min(self.buf().len_lines() - 1));
self.buf_mut()
.delete(strop_core::Range::charwise(start, end));
self.buf_mut().insert(start, &old);
self.cursor = start;
}
self.clamp_cursor();
self.flash(strop_core::Range::charwise(self.cursor, self.cursor));
self.message = "hunk reset".into();
}
pub(crate) fn stage_hunk(&mut self) {
let Some(hunk) = self.hunk_under_cursor() else {
self.message = "no hunk here".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
if self.buf().dirty {
let _ = self.buf_mut().save();
}
let Some(repo) = &self.git else { return };
let Ok(rel) = std::path::Path::new(&path)
.strip_prefix(repo.workdir())
.map(|p| p.to_path_buf())
else {
self.message = "buffer not under workdir".into();
return;
};
match repo.stage_hunk(&rel, &hunk) {
Ok(()) => {
self.hunks_epoch = u64::MAX;
self.message = "hunk staged".into();
}
Err(e) => self.message = format!("stage failed: {e}"),
}
}
pub(crate) fn preview_hunk(&mut self) {
match self.hunk_under_cursor() {
Some(hunk) => self.hunk_preview = Some(hunk),
None => self.message = "no hunk here".into(),
}
}
pub(crate) fn feed_git_pending(&mut self, c: char) {
self.pending.clear();
match c {
'u' => self.undo_hunk(),
's' => self.stage_hunk(),
'p' => self.preview_hunk(),
'l' => self.open_log(false),
'h' => self.open_log(true),
'b' => self.blame_line(),
'y' => self.yank_permalink(),
'o' => self.open_permalink(),
_ => {
self.message =
"Space g: l log · h file history · b blame · y/o permalink · u/s/p hunk".into()
}
}
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
use crate::editor::Key;
use strop_core::Buffer;
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"]);
let path = root.join("f.rs");
std::fs::write(&path, "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "init"]);
let mut e = Editor::new(Buffer::open(path.to_str().unwrap()).unwrap());
e.cwd = root.to_path_buf();
e.discover_git();
(dir, e)
}
#[test]
fn gutter_tracks_live_edits() {
let (_d, mut e) = fixture();
e.refresh_hunks();
assert_eq!(e.sign_at(1), None);
e.feed_text("j"); e.feed_text("cc");
for c in "fn b2() {}".chars() {
e.feed(Key::Char(c));
}
e.feed(Key::Esc);
e.refresh_hunks();
assert!(e.sign_at(2).is_some(), "changed line must carry a sign");
}
#[test]
fn hunk_nav_and_undo() {
let (_d, mut e) = fixture();
e.feed_text("Go") ;
for c in "fn d() {}".chars() {
e.feed(Key::Char(c));
}
e.feed(Key::Esc);
e.feed_text("gg"); e.refresh_hunks();
e.jump_hunk(true); assert_eq!(
e.buf().line_of(e.cursor),
3,
"jumps to the added line 4 (0-based 3)"
);
e.undo_hunk();
e.refresh_hunks();
assert_eq!(e.sign_at(4), None);
let got = e.buf().rope.to_string();
assert_eq!(got, "fn a() {}\nfn b() {}\nfn c() {}\n", "got: {got:?}");
}
#[test]
fn space_g_namespace_dispatches() {
let (_d, mut e) = fixture();
e.feed_text("G");
e.feed_text("o");
for c in "fn new() {}".chars() {
e.feed(Key::Char(c));
}
e.feed(Key::Esc);
e.feed_text(" gp"); assert!(e.hunk_preview.is_some(), "Space g p opens the hunk card");
}
}