mod branches;
mod commits;
mod diff;
mod keys;
mod repos;
mod ui;
pub use branches::Branch;
use crate::git::RepoStatus;
use crate::git::load::{Batch, Commit, FileEntry};
use crate::tui::highlight::RenderedDiff;
use crate::tui::input::char_to_byte;
use crate::tui::widgets::Modal;
use crate::tui::{pane_width, pop_keyboard_enhancement, push_keyboard_enhancement};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::text::Text;
use ratatui::widgets::ListState;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
const COMMITS_PER_BRANCH: usize = 10_000;
const FRAME: Duration = Duration::from_millis(33);
const SLOW: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Level {
Repos,
Branches,
Commits,
Diff,
}
impl Level {
fn back(self) -> Option<Level> {
match self {
Level::Repos => None,
Level::Branches => Some(Level::Repos),
Level::Commits => Some(Level::Branches),
Level::Diff => Some(Level::Commits),
}
}
}
#[derive(Default)]
pub struct Query {
pub text: String,
pub caret: usize,
}
impl Query {
pub fn keeps(&self, row: &str) -> bool {
if self.text.trim().is_empty() {
return true;
}
let row = row.to_lowercase();
self.text
.split_whitespace()
.all(|term| row.contains(&term.to_lowercase()))
}
fn insert(&mut self, c: char) {
self.text.insert(char_to_byte(&self.text, self.caret), c);
self.caret += 1;
}
fn backspace(&mut self) {
if self.caret > 0 {
self.text.remove(char_to_byte(&self.text, self.caret - 1));
self.caret -= 1;
}
}
fn delete(&mut self) {
if self.caret < self.text.chars().count() {
self.text.remove(char_to_byte(&self.text, self.caret));
}
}
fn clear(&mut self) {
self.text.clear();
self.caret = 0;
}
}
#[derive(Default)]
pub struct Sel {
pub visible: Vec<usize>,
pub cur: usize,
pub state: ListState,
pub scope: usize,
pub query: Query,
}
impl Sel {
fn scoped(scope: usize) -> Sel {
Sel {
scope,
..Sel::default()
}
}
fn show(&mut self, visible: Vec<usize>) {
self.visible = visible;
self.cur = 0;
self.state.select((!self.visible.is_empty()).then_some(0));
}
pub fn len(&self) -> usize {
self.visible.len()
}
pub fn is_empty(&self) -> bool {
self.visible.is_empty()
}
pub fn idx(&self) -> Option<usize> {
self.visible.get(self.cur).copied()
}
fn step(&mut self, down: bool) -> bool {
let moved = if down {
self.cur + 1 < self.visible.len() && {
self.cur += 1;
true
}
} else {
self.cur > 0 && {
self.cur -= 1;
true
}
};
if moved {
self.state.select(Some(self.cur));
}
moved
}
fn append(&mut self, more: Vec<usize>) {
let was_empty = self.visible.is_empty();
self.visible.extend(more);
if was_empty && !self.visible.is_empty() {
self.cur = 0;
self.state.select(Some(0));
}
}
fn jump(&mut self, down: bool, rows: usize) -> bool {
if self.visible.is_empty() {
return false;
}
let last = self.visible.len() - 1;
let to = if down {
(self.cur + rows).min(last)
} else {
self.cur.saturating_sub(rows)
};
if to == self.cur {
return false;
}
self.cur = to;
self.state.select(Some(to));
true
}
fn slide(&mut self, right: bool, len: usize) -> bool {
if right && self.scope + 1 < len {
self.scope += 1;
true
} else if !right && self.scope > 0 {
self.scope -= 1;
true
} else {
false
}
}
}
#[derive(Default)]
struct Feed {
latest: Arc<AtomicU64>,
shown: u64,
loading: bool,
since: Option<Instant>,
}
impl Feed {
fn issue(&mut self) -> u64 {
let seq = self.latest.fetch_add(1, Ordering::Relaxed) + 1;
self.shown = seq;
self.loading = true;
self.since = Some(Instant::now());
seq
}
fn slow(&self) -> bool {
self.loading && self.since.is_some_and(|t| t.elapsed() >= SLOW)
}
fn accepts(&self, seq: u64) -> bool {
seq == self.shown
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Pane {
Top,
Bottom,
}
impl Pane {
pub fn sigil(self) -> char {
match self {
Pane::Top => '/',
Pane::Bottom => '?',
}
}
}
pub struct App {
level: Level,
start: Level,
enhanced: bool,
width: u16,
msg: Option<String>,
modal: Option<Modal>,
pending: bool,
editing: Option<Pane>,
tx: Sender<Batch>,
rx: Receiver<Batch>,
repos: Vec<RepoStatus>,
rsel: Sel,
repo: String,
branches: Vec<Branch>,
bsel: Sel,
bfeed: Feed,
commits: Vec<Commit>,
csel: Sel,
cfeed: Feed,
unpushed: HashSet<String>,
unpushed_for: String,
log_args: Vec<String>,
limit: usize,
paths: Vec<String>,
files: Vec<FileEntry>,
fsel: Sel,
ffeed: Feed,
files_for: String,
prepared: RenderedDiff,
diff: Text<'static>,
diff_scroll: u16,
diff_hscroll: u16,
}
impl App {
fn new(start: Level, repo: String) -> App {
let (tx, rx) = mpsc::channel();
App {
level: start,
start,
enhanced: false,
width: 120,
msg: None,
modal: None,
pending: false,
editing: None,
tx,
rx,
repos: Vec::new(),
rsel: Sel::scoped(repos::DEFAULT_SCOPE),
repo,
branches: Vec::new(),
bsel: Sel::scoped(branches::DEFAULT_SCOPE),
bfeed: Feed::default(),
commits: Vec::new(),
csel: Sel::scoped(commits::DEFAULT_SCOPE),
cfeed: Feed::default(),
unpushed: HashSet::new(),
unpushed_for: String::new(),
log_args: Vec::new(),
limit: COMMITS_PER_BRANCH,
paths: Vec::new(),
files: Vec::new(),
fsel: Sel::default(),
ffeed: Feed::default(),
files_for: String::new(),
prepared: RenderedDiff::default(),
diff: Text::default(),
diff_scroll: 0,
diff_hscroll: 0,
}
}
pub fn at_repos(base: &Path, depth: usize, scope: usize) -> Option<App> {
let mut app = App::new(Level::Repos, ".".to_string());
app.rsel.scope = scope;
app.load_repos(base, depth);
if app.repos.is_empty() {
return None;
}
app.rescope_repos();
app.enter_repo();
Some(app)
}
pub fn at_branches(repo: String, scope: usize) -> Option<App> {
let mut app = App::new(Level::Branches, repo);
app.bsel.scope = scope;
app.load_branches();
if app.branches.is_empty() {
return None;
}
app.rescope_branches();
app.enter_branch();
Some(app)
}
pub fn at_commits(
repo: String,
log_args: Vec<String>,
limit: usize,
paths: Vec<String>,
) -> Option<App> {
let mut app = App::new(Level::Commits, repo);
app.log_args = log_args;
app.limit = limit;
app.paths = paths;
app.ensure_unpushed();
if !app.has_any_commit() {
return None;
}
app.request_commits();
Some(app)
}
pub fn run(mut self, cmd: &str) {
let mut terminal = ratatui::init();
self.enhanced = push_keyboard_enhancement();
self.width = pane_width(&terminal);
let result = self.event_loop(&mut terminal);
if self.enhanced {
pop_keyboard_enhancement();
}
ratatui::restore();
if let Err(e) = result {
eprintln!("slu {cmd}: {e}");
}
}
fn event_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
self.drain();
if self.pending && !event::poll(Duration::ZERO)? {
self.settle();
}
terminal.draw(|frame| ui::draw(frame, self))?;
if self.filling() && !event::poll(FRAME)? {
continue;
}
match event::read()? {
Event::Resize(_, _) => {
let w = pane_width(terminal);
if w != self.width {
self.width = w;
if self.level == Level::Diff {
self.relayout_diff();
}
}
}
Event::Key(key) if key.kind == KeyEventKind::Press => {
if keys::on_key(self, key, terminal) {
break;
}
}
_ => {}
}
}
Ok(())
}
fn filling(&self) -> bool {
self.bfeed.loading || self.cfeed.loading || self.ffeed.loading
}
fn drain(&mut self) {
while let Ok(batch) = self.rx.try_recv() {
match batch {
Batch::Branches { seq, rows, done } => {
if !self.bfeed.accepts(seq) {
continue;
}
let from = self.branches.len();
self.branches.extend(rows);
self.extend_branches(from);
if done {
self.bfeed.loading = false;
}
}
Batch::Commits { seq, rows, done } => {
if !self.cfeed.accepts(seq) {
continue;
}
let from = self.commits.len();
self.commits.extend(rows);
self.extend_commits(from);
if done {
self.cfeed.loading = false;
}
}
Batch::Files { seq, rows, done } => {
if !self.ffeed.accepts(seq) {
continue;
}
let from = self.files.len();
self.files.extend(rows);
self.extend_files(from);
if done {
self.ffeed.loading = false;
}
}
}
}
if !self.pending {
self.sync_files();
}
}
fn settle(&mut self) {
self.pending = false;
match self.level {
Level::Repos => self.enter_repo(),
Level::Branches => self.enter_branch(),
Level::Commits => self.enter_commit(),
Level::Diff => {}
}
}
fn pane_sel(&mut self, pane: Pane) -> Option<&mut Sel> {
Some(match (self.level, pane) {
(Level::Repos, Pane::Top) => &mut self.rsel,
(Level::Repos, Pane::Bottom) | (Level::Branches, Pane::Top) => &mut self.bsel,
(Level::Branches, Pane::Bottom) | (Level::Commits, Pane::Top) => &mut self.csel,
(Level::Commits, Pane::Bottom) => &mut self.fsel,
(Level::Diff, _) => return None,
})
}
fn open_query(&mut self, pane: Pane) {
let Some(sel) = self.pane_sel(pane) else {
return;
};
sel.query.caret = sel.query.text.chars().count();
self.editing = Some(pane);
}
fn refilter(&mut self, pane: Pane) {
match (self.level, pane) {
(Level::Repos, Pane::Top) => self.rescope_repos(),
(Level::Repos, Pane::Bottom) | (Level::Branches, Pane::Top) => self.rescope_branches(),
(Level::Branches, Pane::Bottom) | (Level::Commits, Pane::Top) => self.rescope_commits(),
(Level::Commits, Pane::Bottom) => self.rescope_files(),
(Level::Diff, _) => {}
}
if pane == Pane::Top {
self.pending = true;
}
}
fn back(&mut self) -> bool {
match self.level.back() {
Some(prev) if self.level > self.start => {
self.level = prev;
false
}
_ => true,
}
}
}
pub fn branch_scope(all: bool, remotes: bool) -> usize {
if remotes && !all {
2
} else if all {
1
} else {
branches::DEFAULT_SCOPE
}
}
pub fn repo_scope(dirty: bool) -> usize {
if dirty { 0 } else { repos::DEFAULT_SCOPE }
}
#[cfg(test)]
mod tests {
use super::branches::{Branch, Scope as BranchScope};
use super::repos::Scope as RepoScope;
use super::{Level, Query, Sel};
use crate::git::RepoStatus;
fn repo(dirty: usize, ahead: usize) -> RepoStatus {
RepoStatus {
name: "r".into(),
path: "/r".into(),
branch: "main".into(),
has_upstream: true,
dirty,
ahead,
behind: 0,
origin: "github.com:o/r".into(),
}
}
fn query(text: &str) -> Query {
Query {
text: text.to_string(),
caret: 0,
}
}
fn branch(remote: bool, upstream: bool, track: &str) -> Branch {
Branch {
is_head: false,
remote,
name: "b".into(),
rel: String::new(),
author: String::new(),
has_upstream: upstream,
track: track.into(),
}
}
#[test]
fn esc_steps_back_one_level_at_a_time() {
assert_eq!(Level::Diff.back(), Some(Level::Commits));
assert_eq!(Level::Commits.back(), Some(Level::Branches));
assert_eq!(Level::Branches.back(), Some(Level::Repos));
assert_eq!(Level::Repos.back(), None);
}
#[test]
fn the_variant_order_is_the_drill_order() {
assert!(Level::Repos < Level::Branches);
assert!(Level::Branches < Level::Commits);
assert!(Level::Commits < Level::Diff);
}
#[test]
fn the_cursor_stops_at_both_ends() {
let mut sel = Sel::default();
sel.show(vec![0, 1]);
assert!(!sel.step(false), "already at the top");
assert!(sel.step(true));
assert!(!sel.step(true), "already at the bottom");
assert_eq!(sel.cur, 1);
}
#[test]
fn an_empty_list_selects_nothing() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert!(sel.is_empty());
assert_eq!(sel.idx(), None);
assert!(!sel.step(true));
}
#[test]
fn the_cursor_indexes_the_underlying_list_not_the_filtered_one() {
let mut sel = Sel::default();
sel.show(vec![3, 7]); sel.step(true);
assert_eq!(sel.idx(), Some(7));
}
#[test]
fn paging_stops_at_both_ends() {
let mut sel = Sel::default();
sel.show((0..10).collect());
assert!(sel.jump(true, 4));
assert_eq!(sel.cur, 4);
assert!(sel.jump(true, 99), "a jump past the bottom still moves");
assert_eq!(sel.cur, 9);
assert!(!sel.jump(true, 4), "already on the last row");
assert!(sel.jump(false, 99));
assert_eq!(sel.cur, 0);
assert!(!sel.jump(false, 1), "already on the first row");
}
#[test]
fn an_empty_list_cannot_be_paged() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert!(!sel.jump(true, 5));
assert_eq!(sel.idx(), None);
}
#[test]
fn rows_arriving_in_the_background_leave_the_cursor_alone() {
let mut sel = Sel::default();
sel.show(vec![0, 1, 2]);
sel.step(true);
sel.step(true);
assert_eq!(sel.idx(), Some(2));
sel.append(vec![3, 4, 5]);
assert_eq!(sel.idx(), Some(2), "the cursor stayed where it was put");
assert_eq!(sel.len(), 6);
}
#[test]
fn the_first_rows_to_arrive_are_what_gets_selected() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert_eq!(sel.idx(), None);
sel.append(vec![7, 8]);
assert_eq!(sel.idx(), Some(7));
assert_eq!(sel.state.selected(), Some(0));
}
#[test]
fn a_filter_keeps_only_rows_every_term_is_in() {
assert!(
query("").keeps("anything at all"),
"an empty filter keeps everything"
);
let q = query("pablo fix");
assert!(q.keeps("a1b2c3 2026-09-03 pablo fix: the thing"));
assert!(!q.keeps("a1b2c3 2026-09-03 pablo feat: the thing"));
assert!(!q.keeps("a1b2c3 2026-09-03 marta fix: the thing"));
}
#[test]
fn a_filter_ignores_case_on_both_sides() {
assert!(query("FIX Pablo").keeps("pablo fix: lowercase row"));
assert!(query("fix").keeps("PABLO FIX: UPPERCASE ROW"));
}
#[test]
fn a_filter_of_only_spaces_is_no_filter() {
assert!(query(" ").keeps("anything"));
}
#[test]
fn the_scope_slider_stops_at_its_ends() {
let mut sel = Sel::scoped(1);
assert!(sel.slide(true, 3));
assert!(!sel.slide(true, 3), "already on the last stop");
assert_eq!(sel.scope, 2);
assert!(sel.slide(false, 3));
}
#[test]
fn repo_scopes_split_dirty_from_unpushed() {
assert!(RepoScope::Dirty.keeps(&repo(2, 0)));
assert!(!RepoScope::Dirty.keeps(&repo(0, 2)));
assert!(RepoScope::Unpushed.keeps(&repo(0, 2)));
assert!(!RepoScope::Unpushed.keeps(&repo(2, 0)));
assert!(RepoScope::All.keeps(&repo(0, 0)));
}
#[test]
fn branch_scopes_split_local_from_remote() {
assert!(BranchScope::Local.keeps(&branch(false, true, "")));
assert!(!BranchScope::Local.keeps(&branch(true, false, "")));
assert!(BranchScope::Remote.keeps(&branch(true, false, "")));
assert!(BranchScope::All.keeps(&branch(true, false, "")));
}
#[test]
fn a_branch_counts_as_unpushed_when_no_remote_has_it() {
assert!(branch(false, false, "").unpushed(), "never pushed");
assert!(branch(false, true, "[gone]").unpushed(), "upstream deleted");
assert!(
branch(false, true, "[ahead 2]").unpushed(),
"ahead of upstream"
);
assert!(!branch(false, true, "").unpushed(), "in sync");
assert!(
!branch(true, false, "").unpushed(),
"a remote branch is on a remote"
);
}
#[test]
fn push_state_reads_the_track_field() {
assert_eq!(branch(false, false, "").status(), "no remote");
assert_eq!(branch(false, true, "[gone]").status(), "gone");
assert_eq!(branch(false, true, "[ahead 2]").status(), "↑2");
assert_eq!(branch(false, true, "[behind 3]").status(), "↓3");
assert_eq!(branch(false, true, "[ahead 2, behind 1]").status(), "↑2 ↓1");
assert_eq!(branch(false, true, "").status(), "synced");
}
}