mod branches;
mod commits;
mod diff;
mod keys;
mod repos;
mod ui;
pub use branches::Branch;
use crate::git::RepoStatus;
use crate::git::load::{Commit, FileEntry};
use crate::tui::highlight::RenderedDiff;
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;
const COMMITS_PER_BRANCH: usize = 200;
#[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 Sel {
pub visible: Vec<usize>,
pub cur: usize,
pub state: ListState,
pub scope: usize,
}
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));
}
fn show_all(&mut self, len: usize) {
self.show((0..len).collect());
}
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 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
}
}
}
pub struct App {
level: Level,
start: Level,
enhanced: bool,
width: u16,
msg: Option<String>,
repos: Vec<RepoStatus>,
rsel: Sel,
repo: String,
branches: Vec<Branch>,
bsel: Sel,
commits: Vec<Commit>,
csel: Sel,
unpushed: HashSet<String>,
unpushed_for: String,
log_args: Vec<String>,
limit: usize,
paths: Vec<String>,
files: Vec<FileEntry>,
fsel: Sel,
prepared: RenderedDiff,
diff: Text<'static>,
diff_scroll: u16,
diff_hscroll: u16,
}
impl App {
fn new(start: Level, repo: String) -> App {
App {
level: start,
start,
enhanced: false,
width: 120,
msg: None,
repos: Vec::new(),
rsel: Sel::scoped(repos::DEFAULT_SCOPE),
repo,
branches: Vec::new(),
bsel: Sel::scoped(branches::DEFAULT_SCOPE),
commits: Vec::new(),
csel: Sel::scoped(commits::DEFAULT_SCOPE),
unpushed: HashSet::new(),
unpushed_for: String::new(),
log_args: Vec::new(),
limit: COMMITS_PER_BRANCH,
paths: Vec::new(),
files: Vec::new(),
fsel: Sel::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.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();
app.load_commits();
if app.commits.is_empty() {
return None;
}
app.enter_commit();
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 {
terminal.draw(|frame| ui::draw(frame, self))?;
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 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, 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 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 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");
}
}