use crate::git::load::load_blob;
use crate::git::{git_capture, git_capture_raw, git_run, git_words};
use crate::tui::FRAME;
use crate::tui::difffeed::DiffFeed;
use crate::tui::difftool::{DiffTool, run_difftool, run_editor};
use crate::tui::highlight::{Blob, DiffContext, RenderedDiff, render_prepared};
use crate::tui::input::{
Accel, CTRL_X_MOVE, CTRL_Y_MOVE, X_MOVE, Y_MOVE, is_ctrl_c, is_down, is_help, is_left,
is_right, is_up, norm_esc, stepped,
};
use crate::tui::widgets::{
BACK, CREATE, Command, CommandLine, DEL, EDIT, FILTER_KEYS, FIND, Field, Form, FormKey, HELP,
HelpSection, Modal, NOTE, QUIT, Query, REFRESH, Typed, diff_hscrollbar, diff_scrollbar,
key_footer, list_scrollbar, pane_block, scope_tabs, typed_popup,
};
use crate::tui::{
self, clamp_hscroll, clamp_scroll, pane_width, pop_keyboard_enhancement,
push_keyboard_enhancement,
};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use std::fs;
use std::io::{self, IsTerminal};
use std::path::Path;
use std::time::{Duration, Instant};
const SCROLL_STEP: u16 = 3;
const PAN_STEP: u16 = 8;
#[derive(clap::Args)]
pub struct Args {}
#[derive(Clone, Copy, PartialEq)]
enum Scope {
Staged,
All,
Unstaged,
}
const SCOPES: [Scope; 3] = [Scope::All, Scope::Staged, Scope::Unstaged];
impl Scope {
fn label(self) -> &'static str {
match self {
Scope::Staged => "staged",
Scope::All => "all",
Scope::Unstaged => "unstaged",
}
}
}
#[derive(Clone)]
struct Entry {
x: char,
y: char,
path: String,
orig: Option<String>,
hidden: usize,
}
impl Entry {
fn staged(&self) -> bool {
self.x != ' ' && self.x != '?'
}
fn unstaged(&self) -> bool {
self.x == '?' || (self.y != ' ' && self.y != '?')
}
fn untracked(&self) -> bool {
self.x == '?'
}
fn in_scope(&self, s: Scope) -> bool {
match s {
Scope::Staged => self.staged(),
Scope::Unstaged => self.unstaged(),
Scope::All => self.staged() || self.unstaged(),
}
}
fn moved_from(&self, cached: bool) -> Option<(&'static str, &str)> {
let flag = match if cached { self.x } else { self.y } {
'R' => "-M",
'C' => "--find-copies-harder",
_ => return None,
};
self.orig.as_deref().map(|orig| (flag, orig))
}
fn to_stage(&self) -> Vec<&str> {
self.with_orig(self.y == 'R')
}
fn to_unstage(&self) -> Vec<&str> {
self.with_orig(self.x == 'R')
}
fn with_orig(&self, moved: bool) -> Vec<&str> {
match self.orig.as_deref() {
Some(orig) if moved => vec![orig, &self.path],
_ => vec![&self.path],
}
}
fn base_name(&self) -> &str {
let path = self.path.trim_end_matches('/');
path.rsplit('/').next().unwrap_or(path)
}
fn label(&self) -> Vec<Span<'static>> {
match &self.orig {
Some(orig) => moved_parts(orig, &self.path)
.into_iter()
.map(|(text, part)| {
let style = match part {
Part::Shared | Part::New => Style::default(),
Part::Mark | Part::Old => Style::default().fg(Color::DarkGray),
};
Span::styled(text.to_string(), style)
})
.collect(),
None => vec![Span::raw(self.path.clone())],
}
}
}
enum Part {
Shared,
Mark,
Old,
New,
}
fn moved_parts<'a>(from: &'a str, to: &'a str) -> Vec<(&'a str, Part)> {
let (a, b) = (from.as_bytes(), to.as_bytes());
let common = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let pfx = a[..common]
.iter()
.rposition(|&c| c == b'/')
.map_or(0, |i| i + 1);
let sfx = a
.iter()
.rev()
.zip(b.iter().rev())
.take(a.len().min(b.len()) - pfx.saturating_sub(1))
.take_while(|(x, y)| x == y)
.enumerate()
.filter(|(_, (c, _))| **c == b'/')
.last()
.map_or(0, |(i, _)| i + 1);
if pfx == 0 && sfx == 0 {
return vec![(from, Part::Old), (" => ", Part::Mark), (to, Part::New)];
}
let middle = |s: &'a str| &s[pfx..(s.len() - sfx).max(pfx)];
vec![
(&from[..pfx], Part::Shared),
("{", Part::Mark),
(middle(from), Part::Old),
(" => ", Part::Mark),
(middle(to), Part::New),
("}", Part::Mark),
(&from[from.len() - sfx..], Part::Shared),
]
}
struct App {
root: String,
enhanced: bool,
width: u16,
entries: Vec<Entry>,
visible: Vec<usize>,
sel: usize,
scope_idx: usize,
state: ListState,
query: Query,
editing: bool,
note: Option<(bool, String)>,
command: Option<CommandLine>,
note_at: Instant,
diff_rows: u16,
modal: Option<Modal>,
discard: Option<Discard>,
form: Option<Form>,
accel: Accel,
prepared: RenderedDiff,
diff: Text<'static>,
diff_scroll: u16,
diff_hscroll: u16,
dfeed: DiffFeed,
}
pub fn run(_args: Args) {
if !io::stdout().is_terminal() {
eprintln!("slu istatus needs an interactive terminal - use `git status` instead");
std::process::exit(1);
}
let root = match git_capture(".", &["rev-parse", "--show-toplevel"]) {
Some(r) if !r.is_empty() => r,
_ => {
eprintln!("slu istatus: not inside a git repository");
std::process::exit(1);
}
};
let mut app = App {
root,
enhanced: false,
width: 120,
entries: Vec::new(),
visible: Vec::new(),
sel: 0,
scope_idx: 0,
state: ListState::default(),
query: Query::default(),
editing: false,
note: None,
command: None,
note_at: Instant::now(),
diff_rows: 1,
modal: None,
discard: None,
form: None,
accel: Accel::default(),
prepared: RenderedDiff::default(),
diff: Text::default(),
diff_scroll: 0,
diff_hscroll: 0,
dfeed: DiffFeed::default(),
};
let mut terminal = tui::init();
app.enhanced = push_keyboard_enhancement();
app.width = pane_width(&terminal);
app.reload();
let result = app.event_loop(&mut terminal);
if app.enhanced {
pop_keyboard_enhancement();
}
tui::restore();
if let Err(e) = result {
eprintln!("slu istatus: {e}");
std::process::exit(1);
}
}
impl App {
fn scope(&self) -> Scope {
SCOPES[self.scope_idx]
}
fn current(&self) -> Option<&Entry> {
self.visible.get(self.sel).map(|&i| &self.entries[i])
}
fn reload(&mut self) {
let keep = self.current().map(|e| e.path.clone());
self.entries = load_status(&self.root);
self.rescope();
self.settle_on(keep);
}
fn settle_on(&mut self, path: Option<String>) {
if let Some(path) = path
&& let Some(i) = self
.visible
.iter()
.position(|&i| self.entries[i].path == path)
&& i != self.sel
{
self.sel = i;
self.state.select(Some(self.sel));
self.diff_scroll = 0;
self.diff_hscroll = 0;
self.refresh_diff();
}
}
fn drop_query(&mut self) {
let keep = self.current().map(|e| e.path.clone());
self.query.clear();
self.rescope();
self.settle_on(keep);
}
fn create_commit(&mut self) {
if !self.entries.iter().any(Entry::staged) {
self.set_failed("nothing staged to commit: s stages a file, S every file");
return;
}
self.form = Some(Form::new(
"new commit",
vec![Field::new(
"message",
"what this commit does, one line",
true,
)],
));
}
fn submit_commit(&mut self, terminal: &mut DefaultTerminal) {
let Some(form) = &self.form else {
return;
};
let message = form.value(0).to_string();
let (ok, out) = git_run(&self.root, &["commit", "-m", &message]);
if self.enhanced {
pop_keyboard_enhancement();
}
tui::restore();
*terminal = tui::init();
if self.enhanced {
push_keyboard_enhancement();
}
let _ = terminal.clear();
if !ok {
let words = git_words(&out);
self.modal = Some(Modal::new("could not commit", words));
return;
}
self.form = None;
let short = git_capture(&self.root, &["rev-parse", "--short", "HEAD"]).unwrap_or_default();
self.set_status(format!("committed {short} \"{message}\""));
self.reload();
}
fn inspect(&mut self) {
let Some(e) = self.current() else {
return;
};
let row = |key: &str, value: String| (key.to_string(), value);
let mut rows = vec![row("path", e.path.clone())];
if e.untracked() {
rows.push(row("state", "untracked".to_string()));
} else {
rows.push(row("staged", code_words(e.x).to_string()));
rows.push(row("not staged", code_words(e.y).to_string()));
}
if let Some(orig) = &e.orig {
rows.push(row("was", orig.clone()));
}
if e.hidden > 0 {
rows.push(row("inside", format!("{} untracked files", e.hidden)));
}
let title = format!("file {}", e.base_name());
self.modal = Some(Modal::reader(title, &rows));
}
fn rescope(&mut self) {
self.visible = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| {
e.in_scope(self.scope())
&& (self.query.keeps(&e.path)
|| e.orig.as_deref().is_some_and(|o| self.query.keeps(o)))
})
.map(|(i, _)| i)
.collect();
if self.sel >= self.visible.len() {
self.sel = self.visible.len().saturating_sub(1);
}
self.state
.select((!self.visible.is_empty()).then_some(self.sel));
self.diff_scroll = 0;
self.diff_hscroll = 0;
self.refresh_diff();
}
fn refresh_diff(&mut self) {
self.prepared = RenderedDiff::default();
self.diff = Text::default();
let Some(entry) = self.current() else {
self.dfeed.idle();
return;
};
let (root, scope, entry) = (self.root.clone(), self.scope(), entry.clone());
self.dfeed.request(move || {
(
diff_for(&root, &entry, scope),
blobs_for(&root, &entry, scope),
)
});
}
fn drain_diff(&mut self) {
if let Some(prepared) = self.dfeed.take() {
self.prepared = prepared;
self.diff = render_prepared(&self.prepared, self.width, self.diff_hscroll);
}
}
fn on_current(&self, args: &[&str], paths: fn(&Entry) -> Vec<&str>) -> bool {
match self.current() {
Some(e) => {
let mut argv = args.to_vec();
argv.push("--");
argv.extend(paths(e));
git_run(&self.root, &argv).0
}
None => false,
}
}
fn on_visible(
&self,
args: &[&str],
wanted: fn(&Entry) -> bool,
paths: fn(&Entry) -> Vec<&str>,
) -> bool {
let paths: Vec<&str> = self
.visible
.iter()
.map(|&i| &self.entries[i])
.filter(|e| wanted(e))
.flat_map(paths)
.collect();
if paths.is_empty() {
return false;
}
let mut argv = args.to_vec();
argv.push("--");
argv.extend(paths);
git_run(&self.root, &argv).0
}
fn toggle(&self) -> bool {
match self.current() {
Some(e) if e.unstaged() => self.on_current(&["add"], Entry::to_stage),
Some(_) => self.on_current(&["restore", "--staged"], Entry::to_unstage),
None => false,
}
}
fn difftool(&mut self, terminal: &mut DefaultTerminal) -> bool {
let Some(e) = self.current().cloned() else {
return false;
};
if e.untracked() {
self.set_failed("untracked - nothing to compare");
return false;
}
let argv = diff_args(&e, cached(&e, self.scope()));
let outcome = run_difftool(terminal, self.enhanced, &self.root, &argv);
self.width = pane_width(terminal);
match outcome {
DiffTool::Quiet => {}
DiffTool::Note(m) => self.set_failed(m),
DiffTool::Failed(modal) => self.modal = Some(modal),
}
true }
fn edit(&mut self, terminal: &mut DefaultTerminal) -> bool {
let Some(e) = self.current().cloned() else {
return false;
};
if !Path::new(&self.root).join(&e.path).is_file() {
self.set_failed(format!(
"nothing to edit: `{}` is not a file on disk",
e.path
));
return false;
}
let outcome = run_editor(terminal, self.enhanced, &self.root, &e.path);
self.width = pane_width(terminal);
match outcome {
DiffTool::Quiet => {}
DiffTool::Note(m) => self.set_failed(m),
DiffTool::Failed(modal) => self.modal = Some(modal),
}
true
}
fn discard(&mut self, entry: Entry) {
let (ok, out) = discard(&self.root, &entry);
if ok {
self.set_status(match entry.untracked() {
true => format!("deleted {}", entry.base_name()),
false => format!("deleted the changes to {}", entry.base_name()),
});
} else {
let words = git_words(&out);
self.modal = Some(Modal::new(
format!("could not delete `{}`", entry.base_name()),
words,
));
}
self.reload();
}
fn set_status(&mut self, text: impl Into<String>) {
self.note = Some((true, text.into()));
self.note_at = Instant::now();
}
fn set_failed(&mut self, text: impl Into<String>) {
self.note = Some((false, text.into()));
self.note_at = Instant::now();
}
fn note_left(&self) -> Option<Duration> {
self.note
.as_ref()
.map(|_| NOTE.saturating_sub(self.note_at.elapsed()))
}
fn event_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
self.drain_diff();
terminal.draw(|frame| draw(frame, self))?;
let wait = match (self.dfeed.loading(), self.note_left()) {
(true, Some(left)) => Some(left.min(FRAME)),
(true, None) => Some(FRAME),
(false, left) => left,
};
if let Some(wait) = wait
&& !event::poll(wait)?
{
if self.note_left() == Some(Duration::ZERO) {
self.note = None;
}
continue;
}
match event::read()? {
Event::Resize(_, _) => {
let w = pane_width(terminal);
if w != self.width {
self.width = w;
self.diff = render_prepared(&self.prepared, self.width, self.diff_hscroll);
}
}
Event::Key(key) if key.kind == KeyEventKind::Press => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let mut code = norm_esc(key.code, ctrl);
if is_ctrl_c(code, ctrl) {
let open = self.modal.is_some()
|| self.discard.is_some()
|| self.form.is_some()
|| self.command.is_some()
|| self.editing;
if !open {
break;
}
code = KeyCode::Esc;
}
if let Some(modal) = &mut self.modal {
if modal.on_key(code, ctrl) {
self.modal = None;
}
continue;
}
if let Some(form) = &mut self.form {
match form.on_key(code, key) {
FormKey::Open => {}
FormKey::Cancel => self.form = None,
FormKey::Submit => self.submit_commit(terminal),
}
continue;
}
if let Some(gate) = &mut self.discard {
match code {
KeyCode::Enter if gate.typed.text == gate.entry.base_name() => {
if let Some(gate) = self.discard.take() {
self.discard(gate.entry);
}
}
KeyCode::Esc => self.discard = None,
_ => {
gate.typed.on_key(key);
}
}
continue;
}
self.note = None;
if let Some(cmd) = &mut self.command {
match cmd.on_key(code) {
Typed::Open => {}
Typed::Cancel => self.command = None,
Typed::Run(run) => {
self.command = None;
match run {
Command::Help => self.help(),
Command::Quit => break,
Command::Unknown(what) => self.set_failed(format!(
"unknown command `{what}` · ? lists every key"
)),
}
}
}
continue;
}
if self.editing {
if is_help(code) {
self.help();
} else {
self.filter_key(code, key);
}
continue;
}
if is_help(code) {
self.help();
continue;
}
if !ctrl && code == KeyCode::Char('/') {
if self.query.open() {
self.sel = 0;
self.rescope();
}
self.editing = true;
continue;
}
if code == KeyCode::Char(':') {
self.command = Some(CommandLine::default());
continue;
}
if code == KeyCode::Esc && !self.query.text.is_empty() {
self.drop_query();
continue;
}
if matches!(code, KeyCode::Char('q') | KeyCode::Esc) {
break;
}
self.on_key(code, ctrl, terminal);
self.diff_scroll =
clamp_scroll(self.diff_scroll, self.diff.lines.len(), self.diff_rows);
}
_ => {}
}
}
Ok(())
}
fn help(&mut self) {
self.modal = Some(Modal::help(HELP_ROWS));
}
fn filter_key(&mut self, code: KeyCode, key: KeyEvent) {
let changed = match code {
KeyCode::Enter => {
self.editing = false;
false
}
KeyCode::Esc => {
self.editing = false;
self.query.clear();
true
}
_ => self.query.on_key(key),
};
if changed {
self.sel = 0;
self.rescope();
}
}
fn on_key(&mut self, code: KeyCode, ctrl: bool, terminal: &mut DefaultTerminal) {
let steps = self.accel.steps(code, ctrl);
let mut moved = false; let mut reload = false;
if ctrl && is_down(code) {
let by = SCROLL_STEP.saturating_mul(steps as u16);
self.diff_scroll = self.diff_scroll.saturating_add(by);
} else if ctrl && is_up(code) {
let by = SCROLL_STEP.saturating_mul(steps as u16);
self.diff_scroll = self.diff_scroll.saturating_sub(by);
} else if ctrl && is_right(code) {
self.diff_hscroll = clamp_hscroll(
self.diff_hscroll
.saturating_add(PAN_STEP.saturating_mul(steps as u16)),
self.prepared.max_line(),
self.prepared.cell_width(self.width),
);
self.diff = render_prepared(&self.prepared, self.width, self.diff_hscroll);
} else if ctrl && is_left(code) {
self.diff_hscroll = self
.diff_hscroll
.saturating_sub(PAN_STEP.saturating_mul(steps as u16));
self.diff = render_prepared(&self.prepared, self.width, self.diff_hscroll);
} else if (is_down(code) || is_up(code))
&& stepped(self.sel, self.visible.len(), is_down(code), steps) != self.sel
{
self.sel = stepped(self.sel, self.visible.len(), is_down(code), steps);
moved = true;
} else if !ctrl && is_left(code) && self.scope_idx > 0 {
self.scope_idx -= 1;
self.sel = 0;
moved = true;
} else if !ctrl && is_right(code) && self.scope_idx + 1 < SCOPES.len() {
self.scope_idx += 1;
self.sel = 0;
moved = true;
} else if code == KeyCode::Char('s') {
reload = self.on_current(&["add"], Entry::to_stage);
} else if code == KeyCode::Char('u') {
reload = self.on_current(&["restore", "--staged"], Entry::to_unstage);
} else if code == KeyCode::Char('S') {
reload = self.on_visible(&["add"], Entry::unstaged, Entry::to_stage);
} else if code == KeyCode::Char('U') {
reload = self.on_visible(&["restore", "--staged"], Entry::staged, Entry::to_unstage);
} else if code == KeyCode::Char(' ') {
reload = self.toggle();
} else if code == KeyCode::Char('c') {
self.create_commit();
} else if code == KeyCode::Char('K') {
self.inspect();
} else if code == KeyCode::Char('e') {
reload = self.edit(terminal);
} else if code == KeyCode::Char('d') {
self.discard = self.current().map(|e| Discard {
entry: e.clone(),
typed: Query::default(),
});
} else if code == KeyCode::Char('r') {
reload = true;
} else if code == KeyCode::Enter {
reload = self.difftool(terminal);
}
if reload {
self.reload();
} else if moved {
self.rescope();
}
}
}
struct Discard {
entry: Entry,
typed: Query,
}
impl Discard {
fn title(&self) -> &'static str {
match (self.entry.untracked(), self.entry.path.ends_with('/')) {
(true, true) => "delete this directory?",
(true, false) => "delete this file?",
(false, _) => "delete its changes?",
}
}
fn note(&self) -> String {
let e = &self.entry;
let name: String = e.label().iter().map(|s| s.content.as_ref()).collect();
let lost = if e.untracked() {
"untracked, so git has no copy: it goes from the disk".to_string()
} else if let Some(orig) = e.orig.as_deref().filter(|_| e.x == 'R' || e.y == 'R') {
format!("every change goes and it moves back to {orig}")
} else if matches!(e.x, 'A' | 'C') || matches!(e.y, 'A' | 'C') {
"added since the last commit, so the file itself goes".to_string()
} else {
"every change since the last commit goes, staged or not".to_string()
};
format!("{name}\n{lost}")
}
}
fn discard(root: &str, entry: &Entry) -> (bool, String) {
if entry.untracked() {
return git_run(root, &["clean", "-fdq", "--", &entry.path]);
}
let mut args = vec!["restore", "--source=HEAD", "--staged", "--worktree", "--"];
args.extend(entry.with_orig(entry.x == 'R' || entry.y == 'R'));
git_run(root, &args)
}
fn diff_for(root: &str, entry: &Entry, scope: Scope) -> String {
if entry.untracked() {
let nul = if cfg!(windows) { "NUL" } else { "/dev/null" };
let (_, out) = git_run(root, &["diff", "--no-index", "--", nul, &entry.path]);
return out;
}
let mut args = vec!["diff"];
args.extend(diff_args(entry, cached(entry, scope)));
git_capture(root, &args).unwrap_or_default()
}
fn diff_args(entry: &Entry, cached: bool) -> Vec<&str> {
let mut args = Vec::new();
if cached {
args.push("--cached");
}
let moved = entry.moved_from(cached);
if let Some((flag, _)) = moved {
args.push(flag);
}
args.push("--");
if let Some((_, orig)) = moved {
args.push(orig);
}
args.push(&entry.path);
args
}
fn cached(entry: &Entry, scope: Scope) -> bool {
match scope {
Scope::Staged => true,
Scope::Unstaged => false,
Scope::All => !entry.unstaged(),
}
}
fn blobs_for(root: &str, entry: &Entry, scope: Scope) -> DiffContext {
let worktree = || -> Blob {
let path = Path::new(root).join(&entry.path);
Box::new(move || fs::read_to_string(path).ok())
};
let rev = |rev: &str, path: &str| -> Blob {
let (root, rev, path) = (root.to_string(), rev.to_string(), path.to_string());
Box::new(move || load_blob(&root, &rev, &path))
};
if entry.untracked() {
return DiffContext {
old: None,
new: Some(worktree()),
};
}
let cached = cached(entry, scope);
let old = entry
.moved_from(cached)
.map_or(&*entry.path, |(_, orig)| orig);
if cached {
DiffContext {
old: Some(rev("HEAD", old)),
new: Some(rev("", &entry.path)),
}
} else {
DiffContext {
old: Some(rev("", old)),
new: Some(worktree()),
}
}
}
fn load_status(root: &str) -> Vec<Entry> {
let raw = match git_capture_raw(root, &["status", "--porcelain", "-z"]) {
Some(r) => r,
None => return Vec::new(),
};
expand_untracked_dirs(root, parse_status(&raw))
}
fn parse_status(raw: &str) -> Vec<Entry> {
let mut tokens = raw.split('\0').filter(|t| !t.is_empty());
let mut entries = Vec::new();
while let Some(tok) = tokens.next() {
let bytes = tok.as_bytes();
if bytes.len() < 4 {
continue;
}
let (x, y) = (bytes[0] as char, bytes[1] as char);
let moved = matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C');
entries.push(Entry {
x,
y,
path: tok[3..].to_string(),
orig: if moved {
tokens.next().map(str::to_string)
} else {
None
},
hidden: 0,
});
}
entries
}
const UNTRACKED_CAP: usize = 25;
fn expand_untracked_dirs(root: &str, entries: Vec<Entry>) -> Vec<Entry> {
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
if !(entry.untracked() && entry.path.ends_with('/')) {
out.push(entry);
continue;
}
let inside = untracked_inside(root, &entry.path);
match inside.len() {
0 => out.push(entry),
n if n > UNTRACKED_CAP => out.push(Entry { hidden: n, ..entry }),
_ => out.extend(inside),
}
}
out
}
fn untracked_inside(root: &str, dir: &str) -> Vec<Entry> {
match git_capture_raw(root, &["status", "--porcelain", "-z", "-uall", "--", dir]) {
Some(raw) => parse_status(&raw),
None => Vec::new(),
}
}
fn draw(frame: &mut ratatui::Frame, app: &mut App) {
let [panes, footer_row] =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).areas(frame.area());
let areas =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)]).split(panes);
app.diff_rows = areas[1].height.saturating_sub(2).max(1);
let items: Vec<ListItem> = app
.visible
.iter()
.map(|&i| status_item(&app.entries[i]))
.collect();
let count = match (app.visible.is_empty(), app.query.text.is_empty()) {
(true, true) => "clean".to_string(),
(true, false) => "(none)".to_string(),
(false, _) => format!("{}/{}", app.sel + 1, app.visible.len()),
};
let labels: Vec<&str> = SCOPES.iter().map(|s| s.label()).collect();
let mut spans = scope_tabs(&labels, app.scope_idx);
spans.push(Span::raw(format!(" {count}")));
spans.extend(app.query.title_span("/", app.editing, true));
spans.push(Span::raw(" "));
let top_title = Line::from(spans);
let list = List::new(items)
.block(pane_block(top_title, true))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("› ");
frame.render_stateful_widget(list, areas[0], &mut app.state);
list_scrollbar(frame, areas[0], app.visible.len(), app.state.offset());
let title = match app.current() {
Some(e) => {
let mut spans = vec![Span::raw(" ")];
match e.moved_from(cached(e, app.scope())) {
Some(_) => spans.extend(e.label()),
None => spans.push(Span::raw(e.path.clone())),
}
spans.push(Span::raw(format!(" {} ", diff_tag(e, app.scope()))));
Line::from(spans)
}
None => Line::from(" (nothing to show) "),
};
let body = if app.diff.lines.is_empty() && app.dfeed.slow() {
Text::from(Line::from(Span::styled(
" loading…",
Style::default().add_modifier(Modifier::DIM),
)))
} else {
app.diff.clone()
};
let diff = Paragraph::new(body)
.block(pane_block(title, true))
.scroll((app.diff_scroll, 0));
frame.render_widget(diff, areas[1]);
diff_scrollbar(frame, areas[1], app.diff.lines.len(), app.diff_scroll);
let cell = app.prepared.cell_width(areas[1].width.saturating_sub(2));
diff_hscrollbar(
frame,
areas[1],
app.prepared.max_line(),
cell,
app.diff_hscroll,
);
let kept = format!("/{}", app.query.text);
let (keys, help) = match app.editing {
true => (FILTER_KEYS.to_vec(), HELP),
false => {
let mut keys = vec![
"↵ difftool",
"s stage",
"u unstage",
"space flip",
"K inspect",
CREATE,
EDIT,
DEL,
FIND,
REFRESH,
QUIT,
];
if !app.query.text.is_empty() {
keys.splice(0..0, [kept.as_str(), BACK]);
}
(keys, HELP)
}
};
frame.render_widget(
key_footer(
&keys,
app.note.as_ref(),
app.command.as_ref(),
Some(help),
footer_row.width,
),
footer_row,
);
if let Some(gate) = &app.discard {
typed_popup(
frame,
panes,
gate.title(),
Some(&gate.note()),
gate.entry.base_name(),
&gate.typed,
"del",
);
}
if let Some(form) = &app.form {
form.draw(frame, panes);
}
if let Some(modal) = &mut app.modal {
modal.draw(frame, panes);
}
}
fn code_words(code: char) -> &'static str {
match code {
' ' => "nothing",
'M' => "modified",
'T' => "type changed",
'A' => "added",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
'U' => "unmerged",
_ => "changed",
}
}
const HELP_ROWS: &[HelpSection] = &[
(
"moving",
&[
(Y_MOVE, "move a file"),
(X_MOVE, "the previous, next tab"),
("", "a held key speeds up"),
],
),
(
"diff",
&[
(CTRL_Y_MOVE, "scroll"),
(CTRL_X_MOVE, "pan"),
("↵", "git difftool"),
],
),
(
"stage",
&[
("s", "stage"),
("u", "unstage"),
("space", "flip between the two"),
("S", "stage every file the list shows"),
("U", "unstage every file the list shows"),
("c", "create a commit from what is staged"),
],
),
("edit", &[("e", "open the file in the editor git uses")]),
(
"delete",
&[
("d", "delete its changes, or the file when git has no copy"),
("", "asks for its name first"),
],
),
(
"every screen",
&[
("/", "find"),
("K", "inspect the file"),
("r", "refresh"),
(":", "a command (:q quits)"),
("?", "this help"),
("q", "quit"),
("esc", "drop a kept find, else quit"),
("ctrl-c", "quit, or esc in a box or the filter"),
],
),
(
"in a box",
&[
("type", "the name"),
("↵", "delete"),
(Y_MOVE, "scroll a long one"),
("esc", "cancel, or close a long one"),
],
),
(
"in a find",
&[
("type", "filter"),
("↵", "keep"),
("esc", "back"),
("?", "help"),
],
),
(
"in a form",
&[
("type", "the commit's message"),
("↵", "commit"),
("esc", "cancel"),
],
),
(
"in this help",
&[
(Y_MOVE, "scroll"),
("ctrl-d ctrl-u", "half a page down, up"),
("g G", "the top, the bottom"),
("esc q ?", "close"),
],
),
];
fn diff_tag(e: &Entry, scope: Scope) -> &'static str {
if e.untracked() {
"[untracked]"
} else if scope == Scope::Staged || (scope == Scope::All && !e.unstaged()) {
"[staged]"
} else {
"[worktree]"
}
}
fn status_item(e: &Entry) -> ListItem<'static> {
let staged = Style::default().fg(Color::Green);
let unstaged = Style::default().fg(Color::Red);
let none = Style::default().fg(Color::DarkGray);
let (xc, xs) = if e.staged() {
(e.x, staged)
} else {
(' ', none)
};
let (yc, ys) = if e.untracked() {
('?', unstaged)
} else if e.y != ' ' {
(e.y, unstaged)
} else {
(' ', none)
};
let mut spans = vec![
Span::styled(xc.to_string(), xs),
Span::styled(yc.to_string(), ys),
Span::raw(" "),
];
spans.extend(e.label());
spans.push(Span::styled(
match e.hidden {
0 => String::new(),
n => format!(" {n} files"),
},
Style::default().fg(Color::DarkGray),
));
ListItem::new(Line::from(spans))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempRepo(PathBuf);
impl TempRepo {
fn new(tag: &str) -> TempRepo {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("sluuz-{tag}-{stamp}"));
fs::create_dir_all(&dir).expect("temp dir");
let repo = TempRepo(dir);
repo.git(&["init", "-q"]);
repo.git(&["config", "status.renames", "true"]);
repo
}
fn path(&self) -> &str {
self.0.to_str().expect("utf-8 temp path")
}
fn git(&self, args: &[&str]) {
git_capture(self.path(), args).unwrap_or_else(|| panic!("git {args:?}"));
}
fn write(&self, file: &str, text: &str) {
let path = self.0.join(file);
fs::create_dir_all(path.parent().expect("a parent")).expect("fixture dir");
fs::write(path, text).expect("write fixture");
}
fn read(&self, file: &str) -> Option<String> {
fs::read_to_string(self.0.join(file)).ok()
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn deleting_every_row_leaves_the_tree_as_head_has_it() {
let repo = TempRepo::new("discard");
let lines = "one\ntwo\nthree\nfour\nfive\n";
repo.write("edited.txt", lines);
repo.write("moved.txt", lines);
repo.git(&["add", "."]);
repo.git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
"base",
]);
repo.write("edited.txt", "staged\n");
repo.git(&["add", "edited.txt"]);
repo.write("edited.txt", "unstaged\n");
repo.write("added.txt", "new\n");
repo.git(&["add", "added.txt"]);
fs::create_dir_all(repo.0.join("sub")).expect("fixture dir");
repo.git(&["mv", "moved.txt", "sub/moved.txt"]);
repo.write("sub/moved.txt", &format!("{lines}six\n"));
repo.write("junk/a.txt", "junk\n");
repo.write("junk/keep.log", "keep\n");
repo.write(".git/info/exclude", "*.log\n");
let rows = load_status(repo.path());
let codes: Vec<String> = rows.iter().map(|e| format!("{}{}", e.x, e.y)).collect();
for code in ["MM", "A ", "RM", "??"] {
assert!(
codes.iter().any(|c| c == code),
"the fixture has no `{code}` row to delete: {codes:?}"
);
}
for row in &rows {
let (ok, out) = discard(repo.path(), row);
assert!(ok, "deleting `{}` failed: {out}", row.path);
}
let left: Vec<String> = load_status(repo.path())
.iter()
.map(|e| format!("{}{} {}", e.x, e.y, e.path))
.collect();
assert!(left.is_empty(), "rows survived their delete: {left:?}");
assert_eq!(repo.read("edited.txt").as_deref(), Some(lines));
assert_eq!(
repo.read("moved.txt").as_deref(),
Some(lines),
"the rename went back to its old path as committed"
);
assert_eq!(
repo.read("junk/keep.log").as_deref(),
Some("keep\n"),
"an ignored file inside the untracked folder was deleted"
);
}
#[test]
fn a_rename_in_either_column_takes_its_source_path_with_it() {
let raw = "R new.txt\0old.txt\0 R moved.txt\0gone.txt\0 M kept.txt\0";
let rows: Vec<(String, Option<String>)> = parse_status(raw)
.into_iter()
.map(|e| (e.path, e.orig))
.collect();
assert_eq!(
rows,
[
("new.txt".into(), Some("old.txt".into())),
("moved.txt".into(), Some("gone.txt".into())),
("kept.txt".into(), None),
],
"a source path was read as a row of its own"
);
}
}