use crate::git::load::load_blob;
use crate::git::{git_capture, git_capture_raw, git_run};
use crate::tui::FRAME;
use crate::tui::difffeed::DiffFeed;
use crate::tui::difftool::{DiffTool, run_difftool};
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_down, is_left, is_right, is_up, norm_esc,
stepped,
};
use crate::tui::widgets::{
Command, CommandLine, Modal, NOTE, Query, Typed, diff_hscrollbar, diff_scrollbar, key_footer,
list_scrollbar, pane_block, scope_tabs,
};
use crate::tui::{
clamp_hscroll, clamp_scroll, pane_width, pop_keyboard_enhancement, push_keyboard_enhancement,
};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, 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",
}
}
}
struct Entry {
x: char,
y: char,
path: 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(),
}
}
}
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>,
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");
return;
}
let root = match git_capture(".", &["rev-parse", "--show-toplevel"]) {
Some(r) if !r.is_empty() => r,
_ => {
eprintln!("slu istatus: not inside a git repository");
return;
}
};
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,
accel: Accel::default(),
prepared: RenderedDiff::default(),
diff: Text::default(),
diff_scroll: 0,
diff_hscroll: 0,
dfeed: DiffFeed::default(),
};
let mut terminal = ratatui::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();
}
ratatui::restore();
if let Err(e) = result {
eprintln!("slu istatus: {e}");
}
}
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();
if let Some(path) = keep
&& 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 rescope(&mut self) {
self.visible = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.in_scope(self.scope()) && self.query.keeps(&e.path))
.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) = (self.root.clone(), self.scope());
let entry = Entry {
x: entry.x,
y: entry.y,
path: entry.path.clone(),
hidden: entry.hidden,
};
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]) -> bool {
match self.current() {
Some(e) => {
let mut argv = args.to_vec();
argv.extend_from_slice(&["--", &e.path]);
git_run(&self.root, &argv).0
}
None => false,
}
}
fn on_visible(&self, args: &[&str], wanted: fn(&Entry) -> bool) -> bool {
let paths: Vec<&str> = self
.visible
.iter()
.map(|&i| &self.entries[i])
.filter(|e| wanted(e))
.map(|e| e.path.as_str())
.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"]),
Some(_) => self.on_current(&["restore", "--staged"]),
None => false,
}
}
fn difftool(&mut self, terminal: &mut DefaultTerminal) -> bool {
let Some(e) = self.current() else {
return false;
};
let scope = self.scope();
let cached = scope == Scope::Staged || (scope == Scope::All && !e.unstaged());
let (path, untracked) = (e.path.clone(), e.untracked());
if untracked {
self.set_failed("untracked - nothing to compare");
return false;
}
let args: &[&str] = if cached { &["--cached", "--"] } else { &["--"] };
let mut argv = args.to_vec();
argv.push(&path);
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 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 code = norm_esc(key.code, ctrl);
if ctrl && code == KeyCode::Char('c') {
break;
}
if let Some(modal) = &mut self.modal {
if modal.on_key(code) {
self.modal = None;
}
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.modal = Some(Modal::reader("keys · status", &help()))
}
Command::Quit => break,
Command::Unknown(what) => self.set_failed(format!(
"unknown command `{what}` · :help lists the keys"
)),
}
}
}
continue;
}
if self.editing {
self.filter_key(code);
continue;
}
if code == KeyCode::Char('/') {
self.query.open();
self.editing = true;
continue;
}
if code == KeyCode::Char(':') {
self.command = Some(CommandLine::default());
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 filter_key(&mut self, code: KeyCode) {
let changed = match code {
KeyCode::Enter => {
self.editing = false;
false
}
KeyCode::Esc => {
self.editing = false;
self.query.clear();
true
}
_ => self.query.on_key(code),
};
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"]);
} else if code == KeyCode::Char('u') {
reload = self.on_current(&["restore", "--staged"]);
} else if code == KeyCode::Char('S') {
reload = self.on_visible(&["add"], Entry::unstaged);
} else if code == KeyCode::Char('U') {
reload = self.on_visible(&["restore", "--staged"], Entry::staged);
} else if code == KeyCode::Char(' ') {
reload = self.toggle();
} 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();
}
}
}
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 args: &[&str] = if cached(entry, scope) {
&["diff", "--cached", "--", &entry.path]
} else {
&["diff", "--", &entry.path]
};
git_capture(root, args).unwrap_or_default()
}
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| -> Blob {
let (root, rev, path) = (root.to_string(), rev.to_string(), entry.path.clone());
Box::new(move || load_blob(&root, &rev, &path))
};
if entry.untracked() {
return DiffContext {
old: None,
new: Some(worktree()),
};
}
if cached(entry, scope) {
DiffContext {
old: Some(rev("HEAD")),
new: Some(rev("")),
}
} else {
DiffContext {
old: Some(rev("")),
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(),
};
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() < 3 {
continue;
}
let x = bytes[0] as char;
let y = bytes[1] as char;
if x == 'R' || x == 'C' {
tokens.next(); }
entries.push(Entry {
x,
y,
path: tok[3..].to_string(),
hidden: 0,
});
}
expand_untracked_dirs(root, 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> {
let raw = match git_capture_raw(root, &["status", "--porcelain", "-z", "-uall", "--", dir]) {
Some(r) => r,
None => return Vec::new(),
};
raw.split('\0')
.filter(|t| !t.is_empty() && t.len() > 3)
.map(|t| Entry {
x: t.as_bytes()[0] as char,
y: t.as_bytes()[1] as char,
path: t[3..].to_string(),
hidden: 0,
})
.collect()
}
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 (path, tag) = match app.current() {
Some(e) => (e.path.as_str(), diff_tag(e, app.scope())),
None => ("", ""),
};
let title = if path.is_empty() {
" (nothing to show) ".to_string()
} else {
format!(" {path} {tag} ")
};
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 actions = [
"s stage".to_string(),
"u unstage".to_string(),
"space flip".to_string(),
"enter difftool".to_string(),
"r refresh".to_string(),
];
frame.render_widget(
key_footer(
&actions,
app.note.as_ref(),
app.command.as_ref(),
true,
footer_row.width,
),
footer_row,
);
if let Some(modal) = &mut app.modal {
modal.draw(frame);
}
}
fn help() -> Vec<(String, String)> {
let row = |k: &str, d: &str| (k.to_string(), d.to_string());
vec![
row(Y_MOVE, "move (hold to speed up)"),
row(X_MOVE, "switch tab"),
row("/", "filter the files by path"),
row("s", "stage the file"),
row("u", "unstage it"),
row("S", "stage every file the list shows"),
row("U", "unstage every file the list shows"),
row("space", "flip it between staged and not"),
row(CTRL_Y_MOVE, "scroll the diff"),
row(CTRL_X_MOVE, "pan it sideways"),
row("enter", "open the file in your git difftool"),
row("r", "read it again from git"),
row("q", "quit"),
]
}
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)
};
ListItem::new(Line::from(vec![
Span::styled(xc.to_string(), xs),
Span::styled(yc.to_string(), ys),
Span::raw(" "),
Span::raw(e.path.clone()),
Span::styled(
match e.hidden {
0 => String::new(),
n => format!(" {n} files"),
},
Style::default().fg(Color::DarkGray),
),
]))
}