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::difffeed::DiffFeed;
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, PathBuf};
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;
use crate::tui::FRAME;
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 restore: Option<String>,
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 restored_at(&mut self, at: usize) {
self.cur = at;
self.state.select(Some(at));
self.restore = None;
}
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,
base: PathBuf,
depth: usize,
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,
dfeed: DiffFeed,
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,
base: PathBuf::from("."),
depth: 0,
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(),
dfeed: DiffFeed::default(),
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.base = base.to_path_buf();
app.depth = depth;
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 || self.dfeed.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 let Some(prepared) = self.dfeed.take() {
self.prepared = prepared;
self.relayout_diff();
}
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 refresh(&mut self) {
self.unpushed_for.clear();
self.files_for.clear();
match self.level {
Level::Repos => {
let keep = self.rsel.idx().map(|i| self.repos[i].path.clone());
let (base, depth) = (self.base.clone(), self.depth);
self.load_repos(&base, depth);
self.rsel.restore = keep;
self.rescope_repos();
self.pending = true;
}
Level::Branches => {
self.bsel.restore = self.bsel.idx().map(|i| self.branches[i].name.clone());
self.request_branches();
self.pending = true;
}
Level::Commits => {
self.ensure_unpushed();
self.csel.restore = self.commit_hash().map(str::to_string);
self.request_commits();
self.pending = true;
}
Level::Diff => self.open_diff(),
}
}
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::{App, Level, Query, Sel};
use crate::git::{RepoStatus, git_capture};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct Stage {
dir: PathBuf,
}
impl Stage {
fn new() -> Stage {
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-refresh-{stamp}"));
fs::create_dir_all(dir.join("work")).expect("temp dir");
let stage = Stage { dir };
let (work, remote) = (stage.work(), stage.remote());
git_capture(".", &["init", "-q", "--bare", &remote]).expect("bare remote");
git_capture(".", &["init", "-q", &work]).expect("work tree");
stage.git(&["remote", "add", "origin", &remote]);
stage.commit("base");
stage.git(&["push", "-q", "-u", "origin", "HEAD"]);
stage
}
fn work(&self) -> String {
self.dir
.join("work")
.to_str()
.expect("utf-8 temp path")
.to_string()
}
fn remote(&self) -> String {
self.dir
.join("remote.git")
.to_str()
.expect("utf-8 temp path")
.to_string()
}
fn git(&self, args: &[&str]) {
git_capture(&self.work(), args).unwrap_or_else(|| panic!("git {args:?}"));
}
fn commit(&self, msg: &str) {
self.git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
msg,
]);
}
fn head(&self) -> String {
git_capture(&self.work(), &["rev-parse", "HEAD"]).expect("rev-parse")
}
}
impl Drop for Stage {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn a_refreshed_log_sees_a_commit_made_after_it_opened_as_unpushed() {
let stage = Stage::new();
let app = App::at_commits(stage.work(), Vec::new(), 200, Vec::new());
let mut app = app.expect("the repo has a commit, so the view opens");
assert!(
app.unpushed.is_empty(),
"everything was pushed when the view opened"
);
stage.commit("made while the view was open");
let fresh = stage.head();
app.refresh();
assert!(
app.unpushed.contains(&fresh),
"a refresh has to read the push state again, not trust the cached one"
);
}
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");
}
}