use anyhow::Result;
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
use std::collections::HashSet;
use crate::event::{self, Msg};
use crate::git::rebase::{self, RebaseInfo, TodoAction, TodoItem};
use crate::git::{
self, BlameLine, BranchEntry, CommitEntry, DiffTarget, FileEntry, Git, ReflogEntry, Req, Resp,
SubmoduleEntry, WorktreeEntry, patch,
};
use crate::keys::{Action, action_for};
use crate::tree::{self, TreeRow};
use crate::ui;
pub const PANELS: [&str; 5] = ["Status", "Files", "Branches", "Commits", "Stash"];
const LOG_CHUNK: usize = 100;
#[derive(Default)]
pub struct RepoState {
pub head: Option<String>,
pub files: Vec<FileEntry>,
pub branches: Vec<BranchEntry>,
pub commits: Vec<CommitEntry>,
pub stashes: Vec<String>,
pub log_done: bool,
pub diff: String,
pub diff_staged: String,
pub ahead: u32,
pub behind: u32,
pub bisecting: bool,
pub unpushed: HashSet<String>,
pub tags: std::collections::HashMap<String, Vec<String>>,
pub filter: Option<String>,
pub compare: Option<String>,
pub upstream: Option<String>,
}
pub enum InputPurpose {
NewBranch,
RenameBranch(String),
Shell,
Tag(String),
Search,
}
pub enum CommitPurpose {
New,
Reword(usize),
}
pub struct LogEntry {
pub ok: bool,
pub cmd: String,
pub ms: u64,
pub output: Vec<String>,
}
pub enum ConfirmAction {
DeleteBranch {
name: String,
force: bool,
},
DropStash(usize),
Merge(String),
Revert(String),
RemoveWorktree(String),
GoToWorktree(String),
Fixup(String),
StageAllThenCommit,
Absorb,
Split(usize),
RunAll(Vec<Vec<String>>),
}
pub enum Mode {
Normal,
Input {
prompt: &'static str,
buffer: String,
purpose: InputPurpose,
},
Confirm {
prompt: String,
action: ConfirmAction,
},
Hunks {
path: String,
header: String,
hunks: Vec<String>,
cursor: usize,
line: usize,
picked: Vec<usize>,
},
Help {
scroll: u16,
},
Worktrees {
list: Vec<WorktreeEntry>,
cursor: usize,
},
NewWorktree {
branch: String,
path: String,
on_path: bool,
path_edited: bool,
},
Ignore {
pattern: String,
tracked: bool,
},
Reset {
target: String,
subject: String,
},
Reflog {
list: Vec<ReflogEntry>,
cursor: usize,
},
Error {
cmd: String,
output: Vec<String>,
},
Blame {
path: String,
lines: Vec<BlameLine>,
cursor: usize,
},
Stash {
path: Option<String>,
},
Submodules {
list: Vec<SubmoduleEntry>,
cursor: usize,
},
CommitMsg {
summary: String,
body: String,
on_body: bool,
purpose: CommitPurpose,
},
Rebase {
items: Vec<TodoItem>,
cursor: usize,
base: Option<String>,
},
}
type Suspend = (Vec<String>, Vec<(String, String)>);
pub struct App {
pub focus: usize,
pub selected: [usize; 6],
pub quit: bool,
pub repo: RepoState,
pub mode: Mode,
pub message: String,
pub message_ok: bool,
pub zoom: bool,
pub diff_scroll: u16,
diff_lines: u16,
pub tree: Vec<TreeRow>,
pub collapsed: HashSet<String>,
pub marked: HashSet<String>,
pub cmd_log: Vec<LogEntry>,
pub show_log: bool,
pub first_parent: bool,
pub running: Vec<String>,
tick: usize,
area: ratatui::layout::Rect,
pub rebase: Option<RebaseInfo>,
git: Option<Git>,
log_inflight: bool,
diff_seq: u64,
diff_target: Option<DiffTarget>,
pending_suspend: Option<Suspend>,
pending_open: Option<(String, String)>,
pending_fixup: Option<String>,
pending_commit_window: bool,
pending_split: bool,
pub splitting: bool,
have_baseline: bool,
pause: Arc<AtomicBool>,
tx: Option<mpsc::Sender<Msg>>,
}
impl App {
pub fn new() -> Self {
Self {
focus: 1,
selected: [0; 6],
quit: false,
repo: RepoState::default(),
mode: Mode::Normal,
message: String::new(),
message_ok: true,
zoom: false,
diff_scroll: 0,
diff_lines: 0,
tree: Vec::new(),
collapsed: HashSet::new(),
marked: HashSet::new(),
cmd_log: Vec::new(),
show_log: true,
first_parent: false,
running: Vec::new(),
tick: 0,
area: ratatui::layout::Rect::ZERO,
rebase: None,
git: None,
log_inflight: false,
diff_seq: 0,
diff_target: None,
pending_suspend: None,
pending_open: None,
pending_fixup: None,
pending_commit_window: false,
pending_split: false,
splitting: false,
have_baseline: false,
pause: Arc::new(AtomicBool::new(false)),
tx: None,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
let (tx, rx) = mpsc::channel();
event::spawn_input(tx.clone(), self.pause.clone());
let git = git::spawn(tx.clone())?;
git::watch::spawn(git.git_dir.clone(), tx.clone());
git::watch::spawn_worktree(git.root.clone(), tx.clone());
self.git = Some(git);
self.tx = Some(tx);
self.refresh_all();
execute!(std::io::stdout(), EnableMouseCapture)?;
while !self.quit {
let size = terminal.size()?;
self.area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
terminal.draw(|f| ui::render(f, self))?;
let msg = if self.running.is_empty() {
rx.recv()?
} else {
match rx.recv_timeout(Duration::from_millis(90)) {
Ok(msg) => msg,
Err(mpsc::RecvTimeoutError::Timeout) => {
self.tick += 1;
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
};
self.update(msg);
while let Ok(msg) = rx.try_recv() {
self.update(msg);
}
self.flush_requests();
if let Some((args, envs)) = self.pending_suspend.take() {
self.suspend_and_run(terminal, args, envs)?;
}
if let Some((program, file)) = self.pending_open.take() {
self.suspend_and_open(terminal, program, file)?;
}
}
let _ = execute!(std::io::stdout(), DisableMouseCapture);
Ok(())
}
fn suspend_and_run(
&mut self,
terminal: &mut DefaultTerminal,
args: Vec<String>,
envs: Vec<(String, String)>,
) -> Result<()> {
let Some(git) = &self.git else { return Ok(()) };
self.pause.store(true, Ordering::Relaxed);
let start = std::time::Instant::now();
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
let status = std::process::Command::new("git")
.arg("-C")
.arg(&git.root)
.args(&args)
.envs(envs)
.status();
let _ = enable_raw_mode();
let _ = execute!(std::io::stdout(), EnterAlternateScreen, EnableMouseCapture);
let _ = terminal.clear();
self.pause.store(false, Ordering::Relaxed);
let ok = matches!(&status, Ok(s) if s.success());
let err = match status {
Ok(s) if s.success() => None,
Ok(s) => Some(s.to_string()),
Err(e) => Some(e.to_string()),
};
let ms = start.elapsed().as_millis() as u64;
self.log_cmd(ok, format!("git {}", args.join(" ")), ms, err.into_iter().collect());
self.refresh_all();
if self.pending_split {
self.pending_split = false;
if self.rebase.is_some() {
self.splitting = true;
self.focus = 1;
self.write(svec(&["reset", "HEAD^"]));
}
}
Ok(())
}
fn suspend_and_open(
&mut self,
terminal: &mut DefaultTerminal,
program: String,
file: String,
) -> Result<()> {
let Some(git) = &self.git else { return Ok(()) };
let root = git.root.clone();
self.pause.store(true, Ordering::Relaxed);
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
let (shell, flag) = if cfg!(windows) { ("cmd", "/C") } else { ("sh", "-c") };
let status = std::process::Command::new(shell)
.arg(flag)
.arg(format!("{program} \"{file}\""))
.current_dir(&root)
.status();
let _ = enable_raw_mode();
let _ = execute!(std::io::stdout(), EnterAlternateScreen, EnableMouseCapture);
let _ = terminal.clear();
self.pause.store(false, Ordering::Relaxed);
let ok = matches!(&status, Ok(s) if s.success());
let err = match status {
Ok(s) if s.success() => Vec::new(),
Ok(s) => vec![s.to_string()],
Err(e) => vec![e.to_string()],
};
self.log_cmd(ok, format!("{program} {file}"), 0, err);
self.refresh_all();
Ok(())
}
pub fn update(&mut self, msg: Msg) {
match msg {
Msg::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key),
Msg::Mouse(m) => self.handle_mouse(m),
Msg::Git(resp) => self.apply_resp(resp),
Msg::Refresh => self.refresh_all(),
Msg::Dirty(paths) => match paths {
Some(paths) if self.have_baseline => {
if let Some(git) = &self.git {
git.send(Req::StatusPaths(paths));
}
}
_ => self.refresh(true),
},
_ => {}
}
}
fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) {
match &mut self.mode {
Mode::Normal => {
self.message.clear();
if self.rebase.is_some() && self.focus != 1 {
match key.code {
KeyCode::Char('c') => return self.apply(Action::RebaseContinue),
KeyCode::Char('s') => return self.apply(Action::RebaseSkip),
KeyCode::Char('A') => return self.apply(Action::RebaseAbort),
_ => {}
}
}
if let Some(action) = action_for(key, self.focus) {
self.apply(action);
}
}
Mode::Help { scroll } => {
let last = crate::ui::help_max_scroll(self.area);
match key.code {
KeyCode::Char('j') | KeyCode::Down => *scroll = (*scroll + 1).min(last),
KeyCode::Char('k') | KeyCode::Up => *scroll = scroll.saturating_sub(1),
KeyCode::PageDown => *scroll = (*scroll + 10).min(last),
KeyCode::PageUp => *scroll = scroll.saturating_sub(10),
KeyCode::Home => *scroll = 0,
KeyCode::End => *scroll = last,
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*scroll = (*scroll + 10).min(last)
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*scroll = scroll.saturating_sub(10)
}
_ => self.mode = Mode::Normal,
}
}
Mode::Error { .. } => self.mode = Mode::Normal,
Mode::Worktrees { list, cursor } => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('W') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
*cursor = (*cursor + 1).min(list.len().saturating_sub(1))
}
KeyCode::Char('k') | KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Char('n') => {
self.mode = Mode::NewWorktree {
branch: String::new(),
path: String::new(),
on_path: false,
path_edited: false,
};
}
KeyCode::Char('p') => {
self.mode = Mode::Normal;
self.write(svec(&["worktree", "prune"]));
}
KeyCode::Char('d') => {
let Some(w) = list.get(*cursor) else { return };
let stop = if w.current {
Some("cannot remove the worktree you are in")
} else if w.main {
Some("cannot remove the main worktree")
} else if w.locked {
Some("that worktree is locked")
} else {
None
};
if let Some(reason) = stop {
self.mode = Mode::Normal;
self.message = reason.into();
self.message_ok = false;
return;
}
let path = w.path.clone();
self.mode = Mode::Confirm {
prompt: format!("remove the worktree {path}?"),
action: ConfirmAction::RemoveWorktree(path),
};
}
KeyCode::Enter => {
let Some(w) = list.get(*cursor) else { return };
let path = w.path.clone();
self.mode = Mode::Normal;
self.open_worktree(path);
}
_ => {}
},
Mode::Reset { target, .. } => {
let target = target.clone();
let go = |app: &mut Self, how: &str| {
app.mode = Mode::Normal;
app.write(svec(&["reset", how, &target]));
};
match key.code {
KeyCode::Char('s') => go(self, "--soft"),
KeyCode::Char('m') => go(self, "--mixed"),
KeyCode::Char('h') => {
self.mode = Mode::Confirm {
prompt: format!(
"hard reset to {target}? it throws away every change that has no commit."
),
action: ConfirmAction::RunAll(vec![svec(&[
"reset", "--hard", &target,
])]),
};
}
_ => self.mode = Mode::Normal,
}
}
Mode::Stash { path } => {
let path = path.clone();
let args: Option<Vec<String>> = match key.code {
KeyCode::Char('a') => Some(svec(&["stash", "push"])),
KeyCode::Char('u') => Some(svec(&["stash", "push", "--include-untracked"])),
KeyCode::Char('s') => Some(svec(&["stash", "push", "--staged"])),
KeyCode::Char('f') => path.map(|p| svec(&["stash", "push", "--", &p])),
_ => None,
};
self.mode = Mode::Normal;
if let Some(args) = args {
self.write(args);
}
}
Mode::Blame { lines, cursor, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('b') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
*cursor = (*cursor + 1).min(lines.len().saturating_sub(1))
}
KeyCode::Char('k') | KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Char('d') => *cursor = (*cursor + 20).min(lines.len().saturating_sub(1)),
KeyCode::Char('u') => *cursor = cursor.saturating_sub(20),
KeyCode::Char('g') => *cursor = 0,
KeyCode::Char('G') => *cursor = lines.len().saturating_sub(1),
_ => {}
},
Mode::Submodules { list, cursor } => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('M') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
*cursor = (*cursor + 1).min(list.len().saturating_sub(1))
}
KeyCode::Char('k') | KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Enter => {
let Some(s) = list.get(*cursor) else { return };
let path = s.path.clone();
self.mode = Mode::Normal;
self.write(svec(&["submodule", "update", "--init", "--", &path]));
}
KeyCode::Char('u') => {
self.mode = Mode::Normal;
self.write(svec(&["submodule", "update", "--init", "--recursive"]));
}
_ => {}
},
Mode::Reflog { list, cursor } => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('U') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
*cursor = (*cursor + 1).min(list.len().saturating_sub(1))
}
KeyCode::Char('k') | KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Enter => {
let Some(e) = list.get(*cursor) else { return };
let (target, subject) = (e.at.clone(), e.what.clone());
self.mode = Mode::Reset { target, subject };
}
_ => {}
},
Mode::Ignore { pattern, .. } => {
let pattern = pattern.clone();
match key.code {
KeyCode::Char('i') => {
self.mode = Mode::Normal;
self.ignore(pattern, false);
}
KeyCode::Char('e') => {
self.mode = Mode::Normal;
self.ignore(pattern, true);
}
_ => self.mode = Mode::Normal,
}
}
Mode::NewWorktree { branch, path, on_path, path_edited } => match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Tab | KeyCode::BackTab | KeyCode::Down | KeyCode::Up => {
*on_path = !*on_path
}
KeyCode::Backspace => {
if *on_path {
path.pop();
*path_edited = true;
} else {
branch.pop();
}
}
KeyCode::Char(c) => {
if *on_path {
path.push(c);
*path_edited = true;
} else {
branch.push(c);
}
}
KeyCode::Enter => {
let Mode::NewWorktree { branch, path, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.add_worktree(branch, path);
}
_ => {}
},
Mode::CommitMsg { summary, body, on_body, .. } => match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Tab | KeyCode::BackTab => *on_body = !*on_body,
KeyCode::Down if !*on_body => *on_body = true,
KeyCode::Up if *on_body => *on_body = false,
KeyCode::Backspace => {
if *on_body {
body.pop()
} else {
summary.pop()
};
}
KeyCode::Enter if *on_body => body.push('\n'),
KeyCode::Enter => {
let Mode::CommitMsg { summary, body, purpose, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.submit_commit(summary, body, purpose);
}
KeyCode::Char(c) => {
if *on_body {
body.push(c)
} else {
summary.push(c)
}
}
_ => {}
},
Mode::Rebase { items, cursor, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => *cursor = (*cursor + 1).min(items.len() - 1),
KeyCode::Char('k') | KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Char('J') if *cursor + 1 < items.len() => {
items.swap(*cursor, *cursor + 1);
*cursor += 1;
}
KeyCode::Char('K') if *cursor > 0 => {
items.swap(*cursor, *cursor - 1);
*cursor -= 1;
}
KeyCode::Char(c @ ('p' | 'r' | 'e' | 's' | 'f' | 'd')) => {
items[*cursor].action = match c {
'p' => TodoAction::Pick,
'r' => TodoAction::Reword,
'e' => TodoAction::Edit,
's' => TodoAction::Squash,
'f' => TodoAction::Fixup,
_ => TodoAction::Drop,
};
}
KeyCode::Enter => {
let Mode::Rebase { items, base, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.run_rebase(items, base);
}
_ => {}
},
Mode::Input { buffer, .. } => match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Backspace => {
buffer.pop();
}
KeyCode::Char(c) => buffer.push(c),
KeyCode::Enter => {
let Mode::Input { buffer, purpose, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.submit_input(purpose, buffer);
}
_ => {}
},
Mode::Confirm { .. } => match key.code {
KeyCode::Char('y') => {
let Mode::Confirm { action, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
match action {
ConfirmAction::RunAll(cmds) => {
for cmd in cmds {
self.write(cmd);
}
return;
}
ConfirmAction::GoToWorktree(path) => return self.open_worktree(path),
ConfirmAction::Fixup(id) => {
self.pending_fixup = Some(id.clone());
self.write(svec(&["commit", &format!("--fixup={id}")]));
return;
}
ConfirmAction::StageAllThenCommit => {
self.pending_commit_window = true;
self.write(svec(&["add", "-A"]));
return;
}
ConfirmAction::Split(index) => {
let Some((mut items, base)) = self.rebase_slice(index) else {
return;
};
items[index].action = TodoAction::Edit;
self.pending_split = true;
self.run_rebase(items, base);
return;
}
ConfirmAction::Absorb => {
let own = self.repo.unpushed.clone();
self.running.push("absorb".into());
if let Some(git) = &self.git {
git.send(Req::Absorb { own });
}
return;
}
_ => {}
}
let args: Vec<String> = match action {
ConfirmAction::DeleteBranch { name, force } => {
svec(&["branch", if force { "-D" } else { "-d" }, &name])
}
ConfirmAction::DropStash(i) => {
svec(&["stash", "drop", &format!("stash@{{{i}}}")])
}
ConfirmAction::Merge(name) => svec(&["merge", "--no-edit", &name]),
ConfirmAction::Revert(id) => svec(&["revert", "--no-edit", &id]),
ConfirmAction::RemoveWorktree(path) => svec(&["worktree", "remove", &path]),
ConfirmAction::RunAll(_)
| ConfirmAction::GoToWorktree(_)
| ConfirmAction::Fixup(_)
| ConfirmAction::StageAllThenCommit
| ConfirmAction::Absorb
| ConfirmAction::Split(_) => return,
};
self.write(args);
}
_ => self.mode = Mode::Normal,
},
Mode::Hunks { header, hunks, cursor, line, picked, .. } => {
let body_len = hunks[*cursor].lines().count().saturating_sub(1);
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
*line = (*line + 1).min(body_len.saturating_sub(1))
}
KeyCode::Char('k') | KeyCode::Up => *line = line.saturating_sub(1),
KeyCode::Char('J') | KeyCode::Tab => {
*cursor = (*cursor + 1).min(hunks.len() - 1);
*line = 0;
picked.clear();
}
KeyCode::Char('K') | KeyCode::BackTab => {
*cursor = cursor.saturating_sub(1);
*line = 0;
picked.clear();
}
KeyCode::Char(' ') => {
match picked.iter().position(|p| p == line) {
Some(i) => {
picked.remove(i);
}
None => picked.push(*line),
}
*line = (*line + 1).min(body_len.saturating_sub(1));
}
KeyCode::Char('a') => {
let patch = patch::hunk_patch(header, &hunks[*cursor]);
self.apply_and_leave(patch);
}
KeyCode::Enter => {
let marks = if picked.is_empty() { vec![*line] } else { picked.clone() };
match patch::subset_hunk(&hunks[*cursor], &marks) {
Some(sub) => {
let patch = patch::hunk_patch(header, &sub);
self.apply_and_leave(patch);
}
None => {
self.message = "mark a line that adds or removes text".into();
self.message_ok = false;
}
}
}
_ => {}
}
}
}
}
fn submit_input(&mut self, purpose: InputPurpose, buffer: String) {
if buffer.is_empty() {
return;
}
if let InputPurpose::Shell = purpose {
self.running.push(format!(": {buffer}"));
if let Some(git) = &self.git {
git.send(Req::Shell(buffer));
}
return;
}
let args: Vec<String> = match purpose {
InputPurpose::NewBranch => svec(&["checkout", "-b", &buffer]),
InputPurpose::RenameBranch(old) => svec(&["branch", "-m", &old, &buffer]),
InputPurpose::Tag(id) => svec(&["tag", "-a", &buffer, "-m", &buffer, &id]),
InputPurpose::Search => {
self.repo.filter = Some(buffer.clone());
self.selected[3] = 0;
self.focus = 3;
if let Some(git) = &self.git {
git.send(Req::LogFilter(Some(buffer)));
}
return;
}
InputPurpose::Shell => return,
};
self.write(args);
}
fn open_commit_window(&mut self) {
self.mode = Mode::CommitMsg {
summary: String::new(),
body: String::new(),
on_body: false,
purpose: CommitPurpose::New,
};
}
fn submit_commit(&mut self, summary: String, body: String, purpose: CommitPurpose) {
if summary.trim().is_empty() {
self.message = "the summary must have text".into();
self.message_ok = false;
return;
}
let msg_args = |verb: &str| {
let mut args = svec(&[verb, "-m", &summary]);
if !body.trim().is_empty() {
args.push("-m".into());
args.push(body.clone());
}
args
};
match purpose {
CommitPurpose::New => self.write(msg_args("commit")),
CommitPurpose::Reword(0) => {
let mut args = msg_args("commit");
args.insert(1, "--amend".into());
self.write(args);
}
CommitPurpose::Reword(index) => {
let Some(git) = &self.git else { return };
let mut text = summary.clone();
if !body.trim().is_empty() {
text.push_str("\n\n");
text.push_str(body.trim_end());
}
text.push('\n');
let msg_path = git.git_dir.join("lazier-msg");
if let Err(e) = std::fs::write(&msg_path, text) {
self.message = e.to_string();
self.message_ok = false;
return;
}
let Some((mut items, base)) = self.rebase_slice(index) else {
return;
};
items[index].action = TodoAction::Reword;
let editor = self.seq_editor_cmd(&msg_path);
self.run_rebase_with(items, base, vec![("GIT_EDITOR".into(), editor)]);
}
}
}
pub fn suggested_worktree_path(&self, branch: &str) -> String {
let Some(git) = &self.git else {
return String::new();
};
if branch.is_empty() {
return String::new();
}
let root = &git.root;
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let safe = branch.replace(['/', ' '], "-");
let parent = root.parent().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
format!("{parent}/{name}-{safe}")
}
fn ignore(&mut self, pattern: String, local: bool) {
let Some(git) = &self.git else { return };
for line in pattern.lines() {
git.send(Req::Ignore { pattern: line.to_string(), local });
}
}
fn add_worktree(&mut self, branch: String, path: String) {
if branch.is_empty() {
self.message = "the branch name must have text".into();
self.message_ok = false;
return;
}
let path = if path.is_empty() { self.suggested_worktree_path(&branch) } else { path };
let exists = self.repo.branches.iter().any(|b| b.name == branch);
let args = if exists {
svec(&["worktree", "add", &path, &branch])
} else {
svec(&["worktree", "add", "-b", &branch, &path])
};
self.write(args);
}
fn open_worktree(&mut self, path: String) {
if let Err(e) = std::env::set_current_dir(&path) {
self.message = e.to_string();
self.message_ok = false;
return;
}
let Some(tx) = self.tx.clone() else { return };
match git::spawn(tx.clone()) {
Ok(git) => {
git::watch::spawn(git.git_dir.clone(), tx);
self.git = Some(git);
self.repo = RepoState::default();
self.selected = [0; 6];
self.tree.clear();
self.refresh_all();
self.log_cmd(true, format!("open worktree {path}"), 0, Vec::new());
}
Err(e) => {
self.message = e.to_string();
self.message_ok = false;
}
}
}
fn apply_and_leave(&mut self, patch: String) {
self.mode = Mode::Normal;
if let Some(git) = &self.git {
git.send(Req::ApplyPatch { patch, reverse: false });
}
}
fn rebase_slice(&mut self, index: usize) -> Option<(Vec<TodoItem>, Option<String>)> {
let last = self.repo.commits.get(index)?;
let base = if index + 1 < self.repo.commits.len() {
Some(format!("{}^", last.id_str()))
} else if self.repo.log_done {
None } else {
self.message = "load more commits first".into();
self.message_ok = false;
return None;
};
let items = self.repo.commits[..=index]
.iter()
.map(|c| TodoItem {
action: TodoAction::Pick,
id: c.id_str().to_string(),
subject: c.subject.to_string(),
})
.collect();
Some((items, base))
}
fn seq_editor_cmd(&self, file: &std::path::Path) -> String {
let exe = std::env::current_exe().unwrap_or_default();
format!(
"{} --seq-editor {}",
rebase::sh_quote(&exe.to_string_lossy()),
rebase::sh_quote(&file.to_string_lossy())
)
}
fn write(&mut self, args: Vec<String>) {
if git::is_network(&args) {
self.running.push(format!("git {}", args.join(" ")));
}
if let Some(git) = &self.git {
git.send(Req::Write(args));
}
}
fn suspend(&mut self, args: Vec<String>) {
self.pending_suspend = Some((args, Vec::new()));
}
fn start_rebase(&mut self) {
if let Some((items, base)) = self.rebase_slice(self.selected[3]) {
self.mode = Mode::Rebase { items, cursor: 0, base };
}
}
fn run_rebase(&mut self, items: Vec<TodoItem>, base: Option<String>) {
self.run_rebase_with(items, base, Vec::new());
}
fn run_rebase_with(
&mut self,
items: Vec<TodoItem>,
base: Option<String>,
mut envs: Vec<(String, String)>,
) {
let Some(git) = &self.git else { return };
let todo_path = git.git_dir.join("lazier-todo");
if let Err(e) = std::fs::write(&todo_path, rebase::serialize(&items)) {
self.message = e.to_string();
self.message_ok = false;
return;
}
envs.push(("GIT_SEQUENCE_EDITOR".into(), self.seq_editor_cmd(&todo_path)));
let args = match &base {
Some(b) => svec(&["rebase", "-i", b]),
None => svec(&["rebase", "-i", "--root"]),
};
self.pending_suspend = Some((args, envs));
}
fn log_cmd(&mut self, ok: bool, cmd: String, ms: u64, output: Vec<String>) {
self.cmd_log.push(LogEntry { ok, cmd, ms, output });
if self.cmd_log.len() > 100 {
self.cmd_log.remove(0);
}
}
fn handle_mouse(&mut self, m: ratatui::crossterm::event::MouseEvent) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
if let Mode::Help { scroll } = &mut self.mode {
let last = ui::help_max_scroll(self.area);
match m.kind {
MouseEventKind::ScrollDown => *scroll = (*scroll + 3).min(last),
MouseEventKind::ScrollUp => *scroll = scroll.saturating_sub(3),
_ => {}
}
return;
}
if !matches!(self.mode, Mode::Normal) || self.zoom {
return;
}
let p = ui::panes(self.area, self.show_log);
let hit = p
.left
.iter()
.position(|r| contains(*r, m.column, m.row))
.or_else(|| contains(p.diff, m.column, m.row).then_some(5));
let Some(panel) = hit else { return };
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.message.clear();
self.focus = panel;
if panel == 5 {
return;
}
let area = p.left[panel];
let visible = area.height.saturating_sub(2) as usize;
let len = self.panel_len(panel);
let inside = m.row.saturating_sub(area.y + 1) as usize;
let idx = ui::list_offset(self.selected[panel], len, visible) + inside;
if inside < visible && idx < len {
self.selected[panel] = idx;
}
}
MouseEventKind::ScrollDown => {
self.focus = panel;
self.apply(Action::Down);
}
MouseEventKind::ScrollUp => {
self.focus = panel;
self.apply(Action::Up);
}
_ => {}
}
}
pub fn spinner(&self) -> Option<char> {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
(!self.running.is_empty()).then(|| FRAMES[self.tick % FRAMES.len()])
}
pub fn panel_len(&self, panel: usize) -> usize {
match panel {
0 => 1,
1 => self.tree.len(),
2 => self.repo.branches.len(),
3 => self.repo.commits.len(),
4 => self.repo.stashes.len(),
_ => 0,
}
}
fn selected_row(&self) -> Option<&TreeRow> {
self.tree.get(self.selected[1])
}
fn selected_file(&self) -> Option<&FileEntry> {
self.selected_row().and_then(|r| r.file).and_then(|i| self.repo.files.get(i))
}
fn action_paths(&self) -> Vec<String> {
if !self.marked.is_empty() {
let mut out: Vec<String> = self.marked.iter().cloned().collect();
out.sort();
return out;
}
self.selected_file().map(|f| vec![f.path.clone()]).unwrap_or_default()
}
fn action_label(&self) -> String {
match self.marked.len() {
0 => self.selected_file().map(|f| f.path.clone()).unwrap_or_default(),
1 => self.marked.iter().next().cloned().unwrap_or_default(),
n => format!("{n} files"),
}
}
fn file_target(&self) -> Option<(String, bool, String)> {
let row = self.selected_row()?;
if let Some(dir) = &row.dir {
let path = if dir.is_empty() { ".".to_string() } else { dir.clone() };
let label =
if dir.is_empty() { "the whole work tree".into() } else { format!("{dir}/") };
return Some((path, false, label));
}
let f = self.repo.files.get(row.file?)?;
Some((f.path.clone(), f.work == '?', f.path.clone()))
}
fn rebuild_tree(&mut self) {
self.tree = tree::build(&self.repo.files, &self.collapsed);
self.clamp(1);
}
fn apply(&mut self, action: Action) {
match action {
Action::Quit => self.quit = true,
Action::NextPanel => self.focus = (self.focus + 1) % 6,
Action::PrevPanel => self.focus = (self.focus + 5) % 6,
Action::FocusPanel(i) => self.focus = i,
Action::Down if self.focus == 5 => self.scroll_diff(1),
Action::Up if self.focus == 5 => self.scroll_diff(-1),
Action::PageDown if self.focus == 5 => self.scroll_diff(15),
Action::PageUp if self.focus == 5 => self.scroll_diff(-15),
Action::Top if self.focus == 5 => self.diff_scroll = 0,
Action::Bottom if self.focus == 5 => self.scroll_diff(i16::MAX),
Action::Down => {
let len = self.panel_len(self.focus);
let sel = &mut self.selected[self.focus];
if *sel + 1 < len {
*sel += 1;
}
}
Action::Up => {
let sel = &mut self.selected[self.focus];
*sel = sel.saturating_sub(1);
}
Action::PageDown => {
let len = self.panel_len(self.focus);
let sel = &mut self.selected[self.focus];
*sel = (*sel + 15).min(len.saturating_sub(1));
}
Action::PageUp => {
let sel = &mut self.selected[self.focus];
*sel = sel.saturating_sub(15);
}
Action::Top => self.selected[self.focus] = 0,
Action::Bottom => {
self.selected[self.focus] = self.panel_len(self.focus).saturating_sub(1);
}
Action::DiffScroll(delta) => self.scroll_diff(delta as i16),
Action::ZoomGraph => self.zoom = !self.zoom,
Action::Help => self.mode = Mode::Help { scroll: 0 },
Action::ToggleLog => self.show_log = !self.show_log,
Action::FirstParent => {
self.first_parent = !self.first_parent;
self.selected[3] = 0;
if let Some(git) = &self.git {
git.send(Req::LogFirstParent(self.first_parent));
}
}
Action::Refresh => self.refresh_all(),
Action::ToggleStage => {
if let Some(dir) = self.selected_row().and_then(|r| r.dir.clone()) {
let args = if dir.is_empty() {
svec(&["add", "-A"])
} else {
svec(&["add", "--", &dir])
};
self.write(args);
} else if !self.marked.is_empty() {
let paths = self.action_paths();
let mut args = svec(&["add", "--"]);
args.extend(paths);
self.marked.clear();
self.write(args);
} else if let Some(f) = self.selected_file() {
let args = if f.staged() && f.work == ' ' {
svec(&["restore", "--staged", "--", &f.path])
} else {
svec(&["add", "--", &f.path])
};
self.write(args);
}
}
Action::StageAll => self.write(svec(&["add", "-A"])),
Action::CommitPrompt => {
let staged = self.repo.files.iter().any(|f| f.staged());
match (staged, self.repo.files.is_empty()) {
(false, true) => {
self.mode = Mode::Error {
cmd: "commit".into(),
output: vec!["there is nothing to commit".into()],
};
}
(false, false) => {
self.mode = Mode::Confirm {
prompt:
"nothing is staged. stage every change, new files too, and commit?"
.into(),
action: ConfirmAction::StageAllThenCommit,
};
}
_ => self.open_commit_window(),
}
}
Action::CommitEditor => self.suspend(svec(&["commit"])),
Action::StashPrompt => {
let path = self.selected_file().map(|f| f.path.clone());
self.mode = Mode::Stash { path };
}
Action::EnterHunks => {
if let Some(dir) = self.selected_row().and_then(|r| r.dir.clone()) {
if !self.collapsed.remove(&dir) {
self.collapsed.insert(dir);
}
self.rebuild_tree();
return;
}
let Some(f) = self.selected_file() else {
return;
};
if f.work == '?' {
self.message = "stage the whole file first".into();
self.message_ok = false;
return;
}
let want = DiffTarget::WorktreeFile { path: f.path.clone(), untracked: false };
if self.diff_target.as_ref() != Some(&want) {
return;
}
match patch::split_diff(&self.repo.diff) {
Some((header, hunks)) if !hunks.is_empty() => {
self.mode = Mode::Hunks {
path: f.path.clone(),
header,
hunks,
cursor: 0,
line: 0,
picked: Vec::new(),
};
}
_ => self.message = "no hunks in this file".into(),
}
}
Action::DiscardChanges => {
let (targets, label) = if self.marked.is_empty() {
let Some((t, _, l)) = self.file_target() else {
return;
};
(vec![t], l)
} else {
(self.action_paths(), self.action_label())
};
let mut restore = svec(&["restore", "--staged", "--worktree", "--"]);
restore.extend(targets.iter().cloned());
let mut clean = svec(&["clean", "-fd", "--"]);
clean.extend(targets);
self.marked.clear();
self.mode = Mode::Confirm {
prompt: format!("discard all changes in {label}? this cannot be undone."),
action: ConfirmAction::RunAll(vec![restore, clean]),
};
}
Action::DeleteFile => {
let (targets, label) = if self.marked.is_empty() {
let Some((t, _, l)) = self.file_target() else {
return;
};
(vec![t], l)
} else {
(self.action_paths(), self.action_label())
};
if targets.iter().any(|t| t == ".") {
self.message = "select a file or a directory, not the root".into();
self.message_ok = false;
return;
}
let mut rm = svec(&["rm", "-r", "-f", "--ignore-unmatch", "--"]);
rm.extend(targets.iter().cloned());
let mut clean = svec(&["clean", "-fd", "--"]);
clean.extend(targets);
self.marked.clear();
self.mode = Mode::Confirm {
prompt: format!("delete {label} from the disk?"),
action: ConfirmAction::RunAll(vec![rm, clean]),
};
}
Action::ToggleMark => {
let Some(row) = self.selected_row() else {
return;
};
match &row.dir {
Some(dir) => {
let under: Vec<String> = self
.repo
.files
.iter()
.filter(|f| dir.is_empty() || f.path.starts_with(&format!("{dir}/")))
.map(|f| f.path.clone())
.collect();
if under.iter().all(|p| self.marked.contains(p)) {
for p in under {
self.marked.remove(&p);
}
} else {
self.marked.extend(under);
}
}
None => {
let Some(f) = self.selected_file() else {
return;
};
let path = f.path.clone();
if !self.marked.remove(&path) {
self.marked.insert(path);
}
}
}
let len = self.panel_len(1);
let sel = &mut self.selected[1];
if *sel + 1 < len {
*sel += 1;
}
}
Action::IgnorePrompt => {
if !self.marked.is_empty() {
let paths = self.action_paths();
self.mode = Mode::Ignore {
pattern: paths
.iter()
.map(|p| format!("/{p}"))
.collect::<Vec<_>>()
.join("\n"),
tracked: false,
};
self.marked.clear();
return;
}
let Some(row) = self.selected_row() else {
return;
};
let (pattern, tracked) = match &row.dir {
Some(d) if d.is_empty() => return,
Some(d) => (format!("/{d}/"), false),
None => {
let Some(f) = self.selected_file() else {
return;
};
(format!("/{}", f.path), f.work != '?')
}
};
self.mode = Mode::Ignore { pattern, tracked };
}
Action::TakeOurs | Action::TakeTheirs => {
let side = if matches!(action, Action::TakeOurs) { "--ours" } else { "--theirs" };
if let Some(f) = self.selected_file()
&& f.conflicted()
{
let path = f.path.clone();
self.write(svec(&["checkout", side, "--", &path]));
self.write(svec(&["add", "--", &path]));
}
}
Action::AmendLast => {
self.mode = Mode::Confirm {
prompt: "add the staged changes to the last commit?".into(),
action: ConfirmAction::RunAll(vec![svec(&["commit", "--amend", "--no-edit"])]),
};
}
Action::ForcePush => {
let head = self.repo.head.clone().unwrap_or_else(|| "HEAD".into());
self.mode = Mode::Confirm {
prompt: format!("force push {head}? it replaces the branch on the remote."),
action: ConfirmAction::RunAll(vec![svec(&["push", "--force-with-lease"])]),
};
}
Action::ResetPrompt => {
if let Some(c) = self.repo.commits.get(self.selected[3]) {
self.mode = Mode::Reset {
target: c.id_str().to_string(),
subject: c.subject.to_string(),
};
}
}
Action::Absorb => {
if !self.repo.files.iter().any(|f| f.staged()) {
self.mode = Mode::Error {
cmd: "absorb".into(),
output: vec!["stage the changes you want to send back first".into()],
};
return;
}
if self.repo.unpushed.is_empty() {
self.mode = Mode::Error {
cmd: "absorb".into(),
output: vec![
"every commit is on the remote already".into(),
"a change can only go back into a commit that is still yours.".into(),
],
};
return;
}
let n = self.repo.unpushed.len();
self.mode = Mode::Confirm {
prompt: format!(
"send each staged change back to the commit that wrote those lines? it rewrites your {n} commits that no remote has."
),
action: ConfirmAction::Absorb,
};
}
Action::BlameFile => {
let Some(f) = self.selected_file() else {
return;
};
if f.work == '?' {
self.message = "git does not track this file yet".into();
self.message_ok = false;
return;
}
let path = f.path.clone();
if let Some(git) = &self.git {
git.send(Req::Blame(path));
}
}
Action::SubmoduleList => {
if let Some(git) = &self.git {
git.send(Req::Submodules);
}
}
Action::MarkForCompare => {
let Some(c) = self.repo.commits.get(self.selected[3]) else {
return;
};
let id = c.id_str().to_string();
self.repo.compare = match &self.repo.compare {
Some(old) if *old == id => None,
_ => Some(id),
};
self.diff_target = None;
}
Action::CopyId => {
if let Some(c) = self.repo.commits.get(self.selected[3])
&& let Some(git) = &self.git
{
git.send(Req::Copy(c.id_str().to_string()));
}
}
Action::TagPrompt => {
if let Some(c) = self.repo.commits.get(self.selected[3]) {
self.mode = Mode::Input {
prompt: "name for the tag",
buffer: String::new(),
purpose: InputPurpose::Tag(c.id_str().to_string()),
};
}
}
Action::PushTags => self.write(svec(&["push", "--tags"])),
Action::FixupInto => {
let Some(c) = self.repo.commits.get(self.selected[3]) else {
return;
};
let id = c.id_str().to_string();
let subject = c.subject.to_string();
self.mode = Mode::Confirm {
prompt: format!("fold the staged changes into {id} \"{subject}\"?"),
action: ConfirmAction::Fixup(id),
};
}
Action::OpenInEditor => {
let Some(f) = self.selected_file() else {
return;
};
let path = f.path.clone();
let Some(git) = &self.git else { return };
self.pending_open = Some((crate::git::editor(&git.root), path));
}
Action::SearchPrompt => {
self.mode = Mode::Input {
prompt: "search the commit messages",
buffer: String::new(),
purpose: InputPurpose::Search,
};
}
Action::ClearFilter => {
if !self.marked.is_empty() {
self.marked.clear();
return;
}
if self.repo.filter.take().is_some() {
self.selected[3] = 0;
if let Some(git) = &self.git {
git.send(Req::LogFilter(None));
}
}
}
Action::ReflogList => {
if let Some(git) = &self.git {
git.send(Req::Reflog);
}
}
Action::Checkout => {
let Some(b) = self.repo.branches.get(self.selected[2]) else {
return;
};
if b.remote {
let name = b.name.clone();
self.write(svec(&["checkout", "--track", &name]));
return;
}
if b.current {
self.message = format!("you are already on {}", b.name);
self.message_ok = true;
return;
}
let name = b.name.clone();
self.write(svec(&["checkout", &name]));
}
Action::NewBranchPrompt => {
self.mode = Mode::Input {
prompt: "new branch name",
buffer: String::new(),
purpose: InputPurpose::NewBranch,
};
}
Action::DeleteBranch { force } => {
let Some(b) = self.repo.branches.get(self.selected[2]) else {
return;
};
if b.current {
self.message = "cannot delete the branch you are on".into();
self.message_ok = false;
return;
}
let word = if force { "force delete" } else { "delete" };
self.mode = Mode::Confirm {
prompt: format!("{word} branch {}?", b.name),
action: ConfirmAction::DeleteBranch { name: b.name.clone(), force },
};
}
Action::RenameBranchPrompt => {
if let Some(b) = self.repo.branches.get(self.selected[2]) {
self.mode = Mode::Input {
prompt: "new name for the branch",
buffer: b.name.clone(),
purpose: InputPurpose::RenameBranch(b.name.clone()),
};
}
}
Action::RebaseOnto => {
let Some(b) = self.repo.branches.get(self.selected[2]) else {
return;
};
if b.current {
self.message = "that is the branch you are on".into();
self.message_ok = false;
return;
}
let name = b.name.clone();
let head = self.repo.head.clone().unwrap_or_else(|| "HEAD".into());
self.mode = Mode::Confirm {
prompt: format!("put the commits of {head} on top of {name}?"),
action: ConfirmAction::RunAll(vec![svec(&["rebase", "--autostash", &name])]),
};
}
Action::MergeBranch => {
let Some(b) = self.repo.branches.get(self.selected[2]) else {
return;
};
if b.current {
self.message = "cannot merge a branch into itself".into();
self.message_ok = false;
return;
}
let name = b.name.clone();
let head = self.repo.head.clone().unwrap_or_else(|| "HEAD".into());
self.mode = Mode::Confirm {
prompt: format!("merge {name} into {head}?"),
action: ConfirmAction::Merge(name),
};
}
Action::CherryPick => {
if let Some(c) = self.repo.commits.get(self.selected[3]) {
let id = c.id_str().to_string();
self.write(svec(&["cherry-pick", "--no-commit", &id]));
}
}
Action::BisectBad | Action::BisectGood | Action::BisectSkip | Action::BisectReset => {
let id = self.repo.commits.get(self.selected[3]).map(|c| c.id_str().to_string());
if let Some(args) = bisect_command(self.repo.bisecting, &action, id.as_deref()) {
self.write(args);
}
}
Action::Worktrees => {
if let Some(git) = &self.git {
git.send(Req::Worktrees);
}
}
Action::RewordCommit => {
let index = self.selected[3];
if let Some(c) = self.repo.commits.get(index)
&& let Some(git) = &self.git
{
git.send(Req::ReadMessage { id: c.id_str().to_string(), index });
}
}
Action::RevertCommit => {
if let Some(c) = self.repo.commits.get(self.selected[3]) {
let id = c.id_str().to_string();
let subject = c.subject.to_string();
self.mode = Mode::Confirm {
prompt: format!("revert {id} \"{subject}\"?"),
action: ConfirmAction::Revert(id),
};
}
}
Action::Push => self.write(svec(&["push"])),
Action::Pull => self.write(svec(&["pull"])),
Action::Fetch => self.write(svec(&["fetch"])),
Action::ShellPrompt => {
self.mode = Mode::Input {
prompt: "shell command",
buffer: String::new(),
purpose: InputPurpose::Shell,
};
}
Action::InteractiveRebase => self.start_rebase(),
Action::SplitCommit => {
let Some(c) = self.repo.commits.get(self.selected[3]) else {
return;
};
let (id, subject) = (c.id_str().to_string(), c.subject.to_string());
self.mode = Mode::Confirm {
prompt: format!(
"open {id} \"{subject}\" so you can make several commits from it?"
),
action: ConfirmAction::Split(self.selected[3]),
};
}
Action::RebaseContinue => self.suspend(svec(&["rebase", "--continue"])),
Action::RebaseSkip => self.suspend(svec(&["rebase", "--skip"])),
Action::RebaseAbort => self.write(svec(&["rebase", "--abort"])),
Action::ApplyStash => {
let i = self.selected[4];
if i < self.repo.stashes.len() {
self.write(svec(&["stash", "apply", &format!("stash@{{{i}}}")]));
}
}
Action::PopStash => {
let i = self.selected[4];
if i < self.repo.stashes.len() {
self.write(svec(&["stash", "pop", &format!("stash@{{{i}}}")]));
}
}
Action::DropStash => {
let i = self.selected[4];
if i < self.repo.stashes.len() {
self.mode = Mode::Confirm {
prompt: format!("drop stash@{{{i}}}?"),
action: ConfirmAction::DropStash(i),
};
}
}
}
}
fn apply_resp(&mut self, resp: Resp) {
match resp {
Resp::Status(files) => {
self.repo.files = files;
self.have_baseline = true;
self.rebuild_tree();
}
Resp::StatusPaths { scanned, files } => {
self.repo.files.retain(|f| !scanned.iter().any(|s| under(&f.path, s)));
self.repo.files.extend(files);
self.repo.files.sort_by(|a, b| a.path.cmp(&b.path));
self.rebuild_tree();
}
Resp::Branches { current, entries } => {
self.repo.head = current;
self.repo.branches = entries;
self.clamp(2);
}
Resp::Stashes(stashes) => {
self.repo.stashes = stashes;
self.clamp(4);
}
Resp::LogChunk { entries, done } => {
self.repo.commits.extend(entries);
self.repo.log_done = done;
self.log_inflight = false;
self.clamp(3);
}
Resp::LogReplace { entries, done } => {
self.repo.commits = entries;
self.repo.log_done = done;
self.log_inflight = false;
self.clamp(3);
}
Resp::Diff { seq, text, staged } => {
if seq == self.diff_seq {
let total = text.lines().count() + staged.lines().count() + 2;
self.diff_lines = total.min(u16::MAX as usize) as u16;
self.repo.diff = text;
self.repo.diff_staged = staged;
self.diff_scroll = 0;
}
}
Resp::WriteDone { ok, cmd, output, ms } => {
if let Some(i) = self.running.iter().position(|c| *c == cmd) {
self.running.remove(i);
}
if !ok && let Some(path) = worktree_in_use(&output) {
self.mode = Mode::Confirm {
prompt: format!("that branch is checked out at {path}. go there?"),
action: ConfirmAction::GoToWorktree(path),
};
}
if !ok
&& output.iter().any(|l| l.contains("has no upstream branch"))
&& let Some(head) = self.repo.head.clone()
{
self.mode = Mode::Confirm {
prompt: format!("{head} has no upstream. push it to origin and follow it?"),
action: ConfirmAction::RunAll(vec![svec(&["push", "-u", "origin", &head])]),
};
}
if let Some(id) = self.pending_fixup.take()
&& cmd.contains("--fixup=")
&& ok
{
self.pending_suspend = Some((
svec(&["rebase", "--autosquash", "--autostash", &format!("{id}^")]),
vec![("GIT_SEQUENCE_EDITOR".into(), "true".into())],
));
}
if self.pending_commit_window && cmd.contains("add -A") {
self.pending_commit_window = false;
if ok {
self.open_commit_window();
}
}
let was_worktree = cmd.contains("worktree");
if !ok && matches!(self.mode, Mode::Normal) {
self.mode = Mode::Error { cmd: cmd.clone(), output: output.clone() };
}
let files = touches_files(&cmd);
self.log_cmd(ok, cmd, ms, output);
if ok {
self.refresh(files);
if was_worktree && let Some(git) = &self.git {
git.send(Req::Worktrees);
}
}
}
Resp::Message { text, index } => {
let (summary, body) = match text.split_once('\n') {
Some((s, b)) => (s.to_string(), b.trim_start_matches('\n').to_string()),
None => (text, String::new()),
};
self.mode = Mode::CommitMsg {
summary,
body,
on_body: false,
purpose: CommitPurpose::Reword(index),
};
}
Resp::Sync { ahead, behind, unpushed, upstream } => {
self.repo.ahead = ahead;
self.repo.behind = behind;
self.repo.unpushed = unpushed;
self.repo.upstream = upstream;
}
Resp::Worktrees(list) => self.mode = Mode::Worktrees { list, cursor: 0 },
Resp::Reflog(list) => self.mode = Mode::Reflog { list, cursor: 0 },
Resp::Background { ok, cmd, output, ms } => self.log_cmd(ok, cmd, ms, output),
Resp::Tags(map) => self.repo.tags = map,
Resp::Blame { path, lines } => {
if lines.is_empty() {
self.message = "no blame for that file".into();
self.message_ok = false;
} else {
self.mode = Mode::Blame { path, lines, cursor: 0 };
}
}
Resp::Submodules(list) => {
if list.is_empty() {
self.message = "this repository has no submodules".into();
self.message_ok = true;
} else {
self.mode = Mode::Submodules { list, cursor: 0 };
}
}
}
}
fn scroll_diff(&mut self, delta: i16) {
let last = self.diff_lines.saturating_sub(1);
self.diff_scroll = self.diff_scroll.saturating_add_signed(delta).min(last);
}
fn clamp(&mut self, panel: usize) {
let len = self.panel_len(panel);
self.selected[panel] = self.selected[panel].min(len.saturating_sub(1));
}
fn refresh_all(&mut self) {
self.refresh(true);
}
fn refresh(&mut self, files: bool) {
let Some(git) = &self.git else { return };
if files {
git.send(Req::Status);
}
for req in [Req::Branches, Req::Stashes, Req::Sync, Req::Tags] {
git.send(req);
}
git.send(Req::LogRefresh { count: LOG_CHUNK });
self.diff_target = None;
let dir = self.git.as_ref().map(|g| g.git_dir.clone());
self.rebase = dir.as_deref().and_then(rebase::detect);
self.repo.bisecting = dir.as_deref().is_some_and(rebase::bisecting);
if self.rebase.is_none() {
self.splitting = false;
}
}
fn flush_requests(&mut self) {
let Some(git) = &self.git else { return };
let near_end = self.selected[3] + LOG_CHUNK / 2 >= self.repo.commits.len();
if self.focus == 3 && near_end && !self.repo.log_done && !self.log_inflight {
git.send(Req::LogChunk { count: LOG_CHUNK });
self.log_inflight = true;
}
let target = match self.focus {
1 => self.selected_file().map(|f| DiffTarget::WorktreeFile {
path: f.path.clone(),
untracked: f.work == '?',
}),
3 => self.repo.commits.get(self.selected[3]).map(|c| match &self.repo.compare {
Some(from) if *from != c.id_str() => {
DiffTarget::Range { from: from.clone(), to: c.id_str().to_string() }
}
_ => DiffTarget::Commit(c.id_str().to_string()),
}),
4 => (self.selected[4] < self.repo.stashes.len())
.then(|| DiffTarget::Stash(self.selected[4])),
_ => None,
};
if let Some(want) = target
&& Some(&want) != self.diff_target.as_ref()
{
self.diff_seq += 1;
self.diff_target = Some(want.clone());
git.send(Req::Diff { seq: self.diff_seq, target: want });
}
}
}
fn contains(r: ratatui::layout::Rect, x: u16, y: u16) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}
fn svec(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
fn under(path: &str, name: &str) -> bool {
path == name || path.strip_prefix(name).is_some_and(|r| r.starts_with('/'))
}
fn touches_files(cmd: &str) -> bool {
let Some(verb) = cmd.strip_prefix("git ").and_then(|r| r.split_whitespace().next()) else {
return true;
};
!matches!(verb, "tag" | "push" | "fetch" | "branch" | "remote" | "config" | "reflog")
}
fn worktree_in_use(output: &[String]) -> Option<String> {
for line in output {
if let Some((_, rest)) = line.split_once("is already used by worktree at ") {
let path = rest.trim().trim_matches('\'').trim_matches('"');
if !path.is_empty() {
return Some(path.to_string());
}
}
}
None
}
fn bisect_command(bisecting: bool, action: &Action, id: Option<&str>) -> Option<Vec<String>> {
Some(match (action, bisecting) {
(Action::BisectBad, true) => svec(&["bisect", "bad"]),
(Action::BisectGood, true) => svec(&["bisect", "good"]),
(Action::BisectSkip, true) => svec(&["bisect", "skip"]),
(Action::BisectReset, true) => svec(&["bisect", "reset"]),
(Action::BisectBad, false) => svec(&["bisect", "start", id?]),
(Action::BisectGood, false) => svec(&["bisect", "good", id?]),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn demo() -> App {
let mut app = App::new();
app.repo.head = Some("main".into());
app.repo.ahead = 2;
app.repo.files =
[('M', 'M', "src/main.rs"), (' ', 'A', "src/app.rs"), (' ', '?', "notes.txt")]
.into_iter()
.map(|(index, work, path)| FileEntry { index, work, path: path.into() })
.collect();
app.repo.unpushed = ["0a0c000".to_string(), "0a0c001".to_string()].into();
app.repo.branches = [
("main", true, 2, 0, "2h"),
("feature/ui", false, 1, 3, "1d"),
("old/thing", false, 0, 0, "3w"),
]
.into_iter()
.map(|(name, current, ahead, behind, age)| BranchEntry {
name: name.into(),
current,
ahead,
behind,
gone: false,
remote: false,
age: age.into(),
})
.collect();
app.repo.stashes = vec!["stash@{0}: WIP on main".into()];
app.repo.commits = (0..100_000)
.map(|i| {
let mut id = [b'0'; 7];
id.copy_from_slice(format!("{:07x}", 0xa0c000 + i).as_bytes());
let graph = ["●", "◉─╮", "● │", "│ ●", "●─╯"][i % 5];
CommitEntry {
id,
graph: graph.into(),
subject: format!("fake: commit subject #{i}").into(),
author: "Test Author".into(),
time: 1_753_000_000 + i as u32,
}
})
.collect();
app.repo.diff = "diff --git a/src/main.rs b/src/main.rs\n+added line\n-removed line".into();
app.repo.diff_staged = "diff --git a/src/main.rs b/src/main.rs\n+staged line".into();
app.rebuild_tree();
app
}
fn draw(app: &App, width: u16, height: u16) -> Terminal<TestBackend> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|f| ui::render(f, app)).unwrap();
terminal
}
#[test]
fn layout_80x24() {
insta::assert_snapshot!(draw(&demo(), 80, 24).backend());
}
#[test]
fn layout_200x50() {
insta::assert_snapshot!(draw(&demo(), 200, 50).backend());
}
#[test]
fn commits_panel_scrolled_deep() {
let mut app = demo();
app.focus = 3;
app.selected[3] = 99_999;
insta::assert_snapshot!(draw(&app, 80, 24).backend());
}
#[test]
fn rebase_editor() {
let mut app = demo();
app.focus = 3;
app.selected[3] = 3;
app.start_rebase();
if let Mode::Rebase { items, cursor, .. } = &mut app.mode {
items[1].action = TodoAction::Squash;
items[2].action = TodoAction::Drop;
items[3].action = TodoAction::Reword;
*cursor = 2;
} else {
panic!("the rebase editor did not open");
}
insta::assert_snapshot!(draw(&app, 100, 24).backend());
}
#[test]
fn rebase_todo_matches_editor_order() {
let mut app = demo();
app.selected[3] = 2;
app.start_rebase();
let Mode::Rebase { items, base, .. } = &app.mode else { panic!("no editor") };
assert_eq!(base.as_deref(), Some("0a0c002^"));
assert!(rebase::serialize(items).starts_with("pick 0a0c002"));
}
#[test]
fn commit_window() {
let mut app = demo();
app.mode = Mode::CommitMsg {
summary: "feat: add the commit window".into(),
body: "The window has a summary line and a body.".into(),
on_body: true,
purpose: CommitPurpose::New,
};
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn reword_window() {
let mut app = demo();
app.mode = Mode::CommitMsg {
summary: "fix: the old summary".into(),
body: String::new(),
on_body: false,
purpose: CommitPurpose::Reword(0),
};
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn commit_with_no_changes_reports_it() {
let mut app = demo();
app.repo.files.clear();
app.rebuild_tree();
app.apply(Action::CommitPrompt);
match &app.mode {
Mode::Error { output, .. } => assert!(output[0].contains("nothing to commit")),
_ => panic!("expected the failure window"),
}
}
#[test]
fn commit_with_nothing_staged_offers_to_stage() {
let mut app = demo();
for f in &mut app.repo.files {
f.index = ' ';
}
app.apply(Action::CommitPrompt);
assert!(
matches!(&app.mode, Mode::Confirm { action: ConfirmAction::StageAllThenCommit, .. }),
"expected the offer to stage everything"
);
app.handle_key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('y'),
ratatui::crossterm::event::KeyModifiers::NONE,
));
assert!(app.pending_commit_window);
assert!(matches!(app.mode, Mode::Normal), "the window is not open yet");
app.apply_resp(Resp::WriteDone {
ok: true,
cmd: "git add -A".into(),
output: Vec::new(),
ms: 4,
});
assert!(matches!(app.mode, Mode::CommitMsg { .. }), "now it opens");
}
#[test]
fn commit_with_staged_files_opens_the_window() {
let mut app = demo();
assert!(app.repo.files.iter().any(|f| f.staged()));
app.apply(Action::CommitPrompt);
assert!(matches!(app.mode, Mode::CommitMsg { .. }));
}
#[test]
fn empty_summary_is_refused() {
let mut app = demo();
app.submit_commit(String::new(), "body only".into(), CommitPurpose::New);
assert!(!app.message_ok);
assert!(app.cmd_log.is_empty());
}
#[test]
fn the_current_branch_cannot_be_deleted() {
let mut app = demo();
app.focus = 2;
app.selected[2] = 0; assert!(app.repo.branches[0].current);
app.apply(Action::DeleteBranch { force: false });
assert!(!app.message_ok);
assert!(matches!(app.mode, Mode::Normal), "no confirm window opens");
}
#[test]
fn command_log_keeps_the_result_and_the_time() {
let mut app = demo();
app.apply_resp(Resp::WriteDone {
ok: false,
cmd: "git branch -d old".into(),
output: vec!["error: the branch is not merged".into()],
ms: 12,
});
assert_eq!(app.cmd_log.len(), 1);
assert!(!app.cmd_log[0].ok);
assert_eq!(app.cmd_log[0].ms, 12);
assert_eq!(app.cmd_log[0].output, ["error: the branch is not merged"]);
assert!(app.message.is_empty(), "no command may hide the key hints");
app.apply_resp(Resp::WriteDone {
ok: true,
cmd: "git add -A".into(),
output: Vec::new(),
ms: 3,
});
assert!(app.message.is_empty());
assert!(app.cmd_log[1].output.is_empty());
}
#[test]
fn hunk_view_marks_lines() {
let mut app = demo();
app.mode = Mode::Hunks {
path: "src/app.rs".into(),
header: "diff --git a/src/app.rs b/src/app.rs\n".into(),
hunks: vec!["@@ -1,2 +1,3 @@\n keep\n-old line\n+new line\n".into()],
cursor: 0,
line: 1,
picked: vec![1],
};
insta::assert_snapshot!(draw(&app, 100, 24).backend());
}
#[test]
fn worktree_list() {
let mut app = demo();
app.mode = Mode::Worktrees {
list: vec![
WorktreeEntry {
path: "/home/max/lazier".into(),
branch: "main".into(),
current: true,
main: true,
locked: false,
prunable: false,
},
WorktreeEntry {
path: "/home/max/lazier-fix".into(),
branch: "fix/x".into(),
current: false,
main: false,
locked: false,
prunable: false,
},
WorktreeEntry {
path: "/home/max/lazier-old".into(),
branch: "old/thing".into(),
current: false,
main: false,
locked: true,
prunable: true,
},
],
cursor: 1,
};
insta::assert_snapshot!(draw(&app, 100, 24).backend());
}
#[test]
fn bisect_keys_need_a_running_bisect() {
let id = Some("abc1234");
assert_eq!(bisect_command(false, &Action::BisectSkip, id), None);
assert_eq!(bisect_command(false, &Action::BisectReset, id), None);
assert_eq!(
bisect_command(false, &Action::BisectBad, id),
Some(svec(&["bisect", "start", "abc1234"]))
);
assert_eq!(bisect_command(true, &Action::BisectBad, None), Some(svec(&["bisect", "bad"])));
assert_eq!(
bisect_command(true, &Action::BisectReset, None),
Some(svec(&["bisect", "reset"]))
);
assert_eq!(bisect_command(false, &Action::BisectBad, None), None);
}
#[test]
fn a_click_moves_the_focus_and_the_selection() {
use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let mut app = demo();
app.area = ratatui::layout::Rect::new(0, 0, 80, 30);
let p = ui::panes(app.area, app.show_log);
let click = |x, y| MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
};
let b = p.left[2];
app.handle_mouse(click(b.x + 4, b.y + 1));
assert_eq!(app.focus, 2, "the click must move the focus");
assert_eq!(app.selected[2], 0, "the first row is under that point");
app.handle_mouse(click(b.x + 4, b.y + 3));
assert_eq!(app.selected[2], 2, "the third row is under that point");
app.handle_mouse(click(p.diff.x + 2, p.diff.y + 2));
assert_eq!(app.focus, 5);
}
#[test]
fn the_wheel_moves_the_selection() {
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
let mut app = demo();
app.area = ratatui::layout::Rect::new(0, 0, 80, 30);
let p = ui::panes(app.area, app.show_log);
let wheel = |kind, y| MouseEvent {
kind,
column: p.left[2].x + 2,
row: y,
modifiers: KeyModifiers::NONE,
};
app.handle_mouse(wheel(MouseEventKind::ScrollDown, p.left[2].y + 1));
assert_eq!((app.focus, app.selected[2]), (2, 1));
app.handle_mouse(wheel(MouseEventKind::ScrollUp, p.left[2].y + 1));
assert_eq!(app.selected[2], 0);
}
#[test]
fn the_key_list_scrolls_and_stops_at_the_end() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
let mut app = demo();
app.area = ratatui::layout::Rect::new(0, 0, 100, 34);
let last = ui::help_max_scroll(app.area);
assert!(last > 0, "the list must be taller than the window");
app.apply(Action::Help);
for _ in 0..500 {
app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
}
let Mode::Help { scroll } = app.mode else { panic!("the help must stay open") };
assert_eq!(scroll, last, "j must stop at the last row, and not go past it");
app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
let Mode::Help { scroll } = app.mode else { panic!("the help must stay open") };
assert_eq!(scroll, last - 1);
let wheel = |kind| MouseEvent { kind, column: 10, row: 10, modifiers: KeyModifiers::NONE };
app.handle_mouse(wheel(MouseEventKind::ScrollUp));
let Mode::Help { scroll } = app.mode else { panic!("the help must stay open") };
assert_eq!(scroll, last - 4);
app.handle_mouse(wheel(MouseEventKind::ScrollDown));
let Mode::Help { scroll } = app.mode else { panic!("the help must stay open") };
assert_eq!(scroll, last - 1);
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(app.mode, Mode::Normal));
}
#[test]
fn the_spinner_turns_only_when_busy() {
let mut app = demo();
assert_eq!(app.spinner(), None, "nothing runs, thus no spinner");
app.apply(Action::Push);
let first = app.spinner().expect("a push must show a spinner");
app.tick += 1;
assert_ne!(app.spinner(), Some(first), "the next tick shows another frame");
app.tick += 9;
assert_eq!(app.spinner(), Some(first), "ten frames make a full turn");
app.apply_resp(Resp::WriteDone {
ok: true,
cmd: "git push".into(),
output: Vec::new(),
ms: 12,
});
assert_eq!(app.spinner(), None, "the spinner stops with the command");
}
#[test]
fn the_branch_row_shows_the_spinner() {
let mut app = demo();
app.focus = 2;
app.apply(Action::Push);
insta::assert_snapshot!(draw(&app, 80, 24).backend());
}
#[test]
fn a_running_command_shows_and_then_clears() {
let mut app = demo();
app.apply(Action::Push);
assert_eq!(app.running, ["git push"]);
app.apply_resp(Resp::WriteDone {
ok: true,
cmd: "git push".into(),
output: vec!["To github.com:max/lazier.git".into()],
ms: 900,
});
assert!(app.running.is_empty());
assert_eq!(app.cmd_log[0].output.len(), 1);
}
#[test]
fn a_busy_branch_offers_its_worktree() {
let mut app = demo();
app.apply_resp(Resp::WriteDone {
ok: false,
cmd: "git checkout max/pratt".into(),
output: vec![
"fatal: 'max/pratt' is already used by worktree at '/home/max/pratt'".into(),
],
ms: 31,
});
match &app.mode {
Mode::Confirm { action: ConfirmAction::GoToWorktree(p), .. } => {
assert_eq!(p, "/home/max/pratt")
}
_ => panic!("expected the offer to go to the worktree"),
}
}
#[test]
fn long_branch_names_keep_their_counts() {
let mut app = demo();
app.focus = 2;
app.repo.branches = [
("max/macro-warehouse-selection-and-more", true, 5u32, 0u32),
("max/pratt-parser-cleanup-with-a-very-long-tail", false, 12, 3),
("main", false, 0, 0),
]
.into_iter()
.map(|(name, current, ahead, behind)| BranchEntry {
name: name.into(),
current,
ahead,
behind,
gone: false,
remote: false,
age: "2d".into(),
})
.collect();
insta::assert_snapshot!(draw(&app, 80, 24).backend());
}
#[test]
fn reset_window() {
let mut app = demo();
app.mode =
Mode::Reset { target: "0a0c003".into(), subject: "fake: commit subject #3".into() };
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn reflog_window() {
let mut app = demo();
app.mode = Mode::Reflog {
list: [
("a1b2c3d", "HEAD@{0}", "commit: feat: add the thing"),
("d4e5f6a", "HEAD@{1}", "rebase (finish): returning to refs/heads/main"),
("9876543", "HEAD@{2}", "checkout: moving from main to feature/x"),
]
.into_iter()
.map(|(id, at, what)| ReflogEntry { id: id.into(), at: at.into(), what: what.into() })
.collect(),
cursor: 1,
};
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn a_remote_branch_is_followed_not_switched_to() {
let mut app = demo();
app.focus = 2;
app.repo.branches.push(BranchEntry {
name: "origin/theirs".into(),
current: false,
ahead: 0,
behind: 0,
gone: false,
age: "1d".into(),
remote: true,
});
app.selected[2] = app.repo.branches.len() - 1;
app.apply(Action::Checkout);
assert!(app.message.is_empty());
assert!(matches!(app.mode, Mode::Normal));
}
#[test]
fn marks_drive_the_next_action() {
let mut app = demo();
app.focus = 1;
app.selected[1] = app.tree.iter().position(|r| r.file.is_some()).unwrap();
app.apply(Action::ToggleMark);
assert_eq!(app.marked.len(), 1, "one file is marked");
while app.selected_row().is_some_and(|r| r.file.is_none()) {
app.apply(Action::Down);
}
app.apply(Action::ToggleMark);
assert_eq!(app.marked.len(), 2);
assert_eq!(app.action_paths().len(), 2);
assert_eq!(app.action_label(), "2 files");
app.repo.filter = Some("x".into());
app.apply(Action::ClearFilter);
assert!(app.marked.is_empty());
assert_eq!(app.repo.filter.as_deref(), Some("x"), "the search is still set");
app.apply(Action::ClearFilter);
assert!(app.repo.filter.is_none());
}
#[test]
fn a_directory_marks_all_of_its_files() {
let mut app = demo();
app.focus = 1;
let src = app.tree.iter().position(|r| r.dir.as_deref() == Some("src")).unwrap();
app.selected[1] = src;
app.apply(Action::ToggleMark);
assert_eq!(app.marked.len(), 2, "both files under src are marked");
app.selected[1] = src;
app.apply(Action::ToggleMark);
assert!(app.marked.is_empty(), "the same key drops them again");
}
#[test]
fn with_no_marks_the_action_uses_the_row() {
let mut app = demo();
app.focus = 1;
app.selected[1] = app.tree.iter().position(|r| r.file.is_some()).unwrap();
assert_eq!(app.action_paths().len(), 1);
}
#[test]
fn ignore_window() {
let mut app = demo();
app.mode = Mode::Ignore { pattern: "/notes.txt".into(), tracked: false };
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn ignore_window_warns_for_a_tracked_file() {
let mut app = demo();
app.mode = Mode::Ignore { pattern: "/src/main.rs".into(), tracked: true };
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn ignore_makes_the_right_pattern() {
let mut app = demo();
app.focus = 1;
app.selected[1] = 0;
app.apply(Action::IgnorePrompt);
assert!(matches!(app.mode, Mode::Normal), "the root opens no window");
let i = app
.tree
.iter()
.position(|r| r.file.is_some_and(|f| app.repo.files[f].path == "notes.txt"))
.unwrap();
app.selected[1] = i;
app.apply(Action::IgnorePrompt);
match &app.mode {
Mode::Ignore { pattern, tracked } => {
assert_eq!(pattern, "/notes.txt");
assert!(!tracked);
}
_ => panic!("expected the ignore window"),
}
}
#[test]
fn new_worktree_window() {
let mut app = demo();
app.mode = Mode::NewWorktree {
branch: "feature/parser".into(),
path: String::new(),
on_path: false,
path_edited: false,
};
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn a_worktree_uses_an_existing_branch_as_it_is() {
let mut app = demo();
assert!(app.repo.branches.iter().any(|b| b.name == "feature/ui"));
app.add_worktree("feature/ui".into(), "/tmp/wt".into());
app.add_worktree("brand/new".into(), "/tmp/wt2".into());
assert!(app.message.is_empty(), "both names are good");
}
#[test]
fn a_worktree_needs_a_branch_name() {
let mut app = demo();
app.add_worktree(String::new(), String::new());
assert!(!app.message_ok);
}
#[test]
fn confirm_window() {
let mut app = demo();
app.mode = Mode::Confirm {
prompt: "that branch is checked out at /Users/max/dbt/fs-warehouses. go there?".into(),
action: ConfirmAction::GoToWorktree("/Users/max/dbt/fs-warehouses".into()),
};
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn a_failure_opens_a_window() {
let mut app = demo();
app.apply_resp(Resp::WriteDone {
ok: false,
cmd: "git checkout nope".into(),
output: vec!["error: pathspec 'nope' did not match".into()],
ms: 5,
});
match &app.mode {
Mode::Error { cmd, output } => {
assert_eq!(cmd, "git checkout nope");
assert_eq!(output.len(), 1);
}
_ => panic!("expected the failure window"),
}
assert_eq!(app.cmd_log.len(), 1);
assert!(!app.cmd_log[0].ok);
}
#[test]
fn an_offer_wins_over_the_failure_window() {
let mut app = demo();
app.apply_resp(Resp::WriteDone {
ok: false,
cmd: "git push".into(),
output: vec!["fatal: The current branch main has no upstream branch".into()],
ms: 5,
});
assert!(matches!(&app.mode, Mode::Confirm { .. }), "the upstream offer must come first");
}
#[test]
fn a_failure_never_closes_an_open_window() {
let mut app = demo();
app.mode = Mode::CommitMsg {
summary: "feat: half typed".into(),
body: String::new(),
on_body: false,
purpose: CommitPurpose::New,
};
app.apply_resp(Resp::WriteDone {
ok: false,
cmd: "git fetch".into(),
output: vec!["fatal: could not read from remote".into()],
ms: 5,
});
match &app.mode {
Mode::CommitMsg { summary, .. } => assert_eq!(summary, "feat: half typed"),
_ => panic!("the commit window must stay"),
}
assert!(!app.cmd_log[0].ok, "the log still holds the failure");
}
#[test]
fn no_checkout_of_the_branch_you_are_on() {
let mut app = demo();
app.focus = 2;
app.selected[2] = 0;
assert!(app.repo.branches[0].current);
app.apply(Action::Checkout);
assert!(app.running.is_empty());
assert!(app.message.contains("you are already on"), "{}", app.message);
assert!(app.message_ok, "this is not an error");
}
#[test]
fn only_the_right_commands_scan_the_work_tree() {
for cmd in ["git tag -a v1 -m v1", "git push", "git fetch", "git branch -d old"] {
assert!(!touches_files(cmd), "{cmd} should not scan");
}
for cmd in [
"git add -A",
"git checkout main",
"git reset --hard HEAD",
"git stash push",
"git commit -m x",
"git rebase --continue",
"git clean -fd",
": rm -rf build",
"absorb",
] {
assert!(touches_files(cmd), "{cmd} should scan");
}
}
#[test]
fn a_partial_scan_touches_only_what_it_looked_at() {
let mut app = demo();
app.have_baseline = true;
let mut before: Vec<String> = app.repo.files.iter().map(|f| f.path.clone()).collect();
before.sort();
assert!(before.contains(&"notes.txt".to_string()));
app.apply_resp(Resp::StatusPaths {
scanned: vec!["src/main.rs".into()],
files: vec![FileEntry { index: ' ', work: 'M', path: "src/main.rs".into() }],
});
let mut after: Vec<String> = app.repo.files.iter().map(|f| f.path.clone()).collect();
after.sort();
assert_eq!(before, after, "the set of files is the same");
let m = app.repo.files.iter().find(|f| f.path == "src/main.rs").unwrap();
assert_eq!((m.index, m.work), (' ', 'M'), "that one file changed");
}
#[test]
fn a_partial_scan_removes_a_file_that_is_clean_again() {
let mut app = demo();
app.have_baseline = true;
app.apply_resp(Resp::StatusPaths { scanned: vec!["notes.txt".into()], files: Vec::new() });
assert!(!app.repo.files.iter().any(|f| f.path == "notes.txt"));
assert!(app.repo.files.iter().any(|f| f.path == "src/main.rs"), "the rest stays");
}
#[test]
fn a_partial_scan_of_a_directory_covers_its_files() {
let mut app = demo();
app.have_baseline = true;
app.apply_resp(Resp::StatusPaths { scanned: vec!["src".into()], files: Vec::new() });
assert!(!app.repo.files.iter().any(|f| f.path.starts_with("src/")));
assert!(app.repo.files.iter().any(|f| f.path == "notes.txt"), "outside src it stays");
}
#[test]
fn no_partial_scan_before_the_first_full_one() {
let mut app = demo();
app.have_baseline = false;
app.update(Msg::Dirty(Some(vec!["a.txt".into()])));
assert!(!app.have_baseline);
}
#[test]
fn under_matches_a_path_and_its_directory() {
assert!(under("src/main.rs", "src"));
assert!(under("src/main.rs", "src/main.rs"));
assert!(!under("srcfile.rs", "src"));
assert!(!under("other/main.rs", "src"));
}
#[test]
fn the_current_branch_is_first() {
let app = demo();
assert!(app.repo.branches[0].current);
}
#[test]
fn help_overlay() {
let mut app = demo();
app.mode = Mode::Help { scroll: 0 };
insta::assert_snapshot!(draw(&app, 100, 30).backend());
}
#[test]
fn zoomed_graph() {
let mut app = demo();
app.focus = 3;
app.zoom = true;
insta::assert_snapshot!(draw(&app, 80, 24).backend());
}
#[test]
fn branch_prompt() {
let mut app = demo();
app.focus = 2;
app.mode = Mode::Input {
prompt: "new branch name",
buffer: "feature/x".into(),
purpose: InputPurpose::NewBranch,
};
insta::assert_snapshot!(draw(&app, 80, 24).backend());
}
#[test]
fn navigation() {
let mut app = demo();
app.apply(Action::Down);
assert_eq!(app.selected[1], 1);
app.apply(Action::Up);
app.apply(Action::Up); assert_eq!(app.selected[1], 0);
app.apply(Action::FocusPanel(3));
assert_eq!(app.focus, 3);
app.apply(Action::NextPanel);
app.apply(Action::NextPanel); assert_eq!(app.focus, 5);
app.apply(Action::NextPanel); assert_eq!(app.focus, 0);
app.apply(Action::PrevPanel);
assert_eq!(app.focus, 5);
}
}