use crate::git::{SEP, first_line, git_capture, git_run, prunes_on_fetch};
use crate::tui::input::{X_MOVE, Y_MOVE, is_back, is_down, is_left, is_right, is_up, norm_esc};
use crate::tui::widgets::{
box_block, box_buttons, box_height, box_hint, box_inner_width, box_width, list_scrollbar,
pane_block, popup_area,
};
use crate::tui::{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};
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph, Wrap};
use std::io::{self, IsTerminal};
use std::time::{Duration, Instant};
const NOTE: Duration = Duration::from_secs(4);
#[derive(clap::Args)]
pub struct Args {}
struct Gone {
name: String,
age: String,
author: String,
}
struct App {
branches: Vec<Gone>,
sel: usize,
state: ListState,
confirm: Option<bool>,
msg: Option<(bool, String)>,
msg_at: Instant,
prunes: bool,
pruned: bool,
pruning: bool,
}
pub fn run(_args: Args) {
if !io::stdout().is_terminal() {
eprintln!(
"slu itidy needs an interactive terminal - use `slu tidy` for the scriptable view"
);
return;
}
let mut state = ListState::default();
state.select(Some(0));
let mut app = App {
branches: load_gone(),
sel: 0,
state,
confirm: None,
msg: None,
msg_at: Instant::now(),
prunes: prunes_on_fetch("."),
pruned: false,
pruning: false,
};
let mut terminal = ratatui::init();
let enhanced = push_keyboard_enhancement();
let result = app.event_loop(&mut terminal);
if enhanced {
pop_keyboard_enhancement();
}
ratatui::restore();
if let Err(e) = result {
eprintln!("slu itidy: {e}");
}
}
impl App {
fn event_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
terminal.draw(|frame| draw(frame, self))?;
if self.pruning {
self.pruning = false;
self.prune();
continue;
}
if let Some(left) = self.note_left()
&& !event::poll(left)?
{
self.msg = None;
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let code = norm_esc(key.code, ctrl);
if ctrl && code == KeyCode::Char('c') {
break;
}
if self.on_key(code) {
break;
}
}
Ok(())
}
fn note_left(&self) -> Option<Duration> {
match &self.msg {
Some((true, _)) => Some(NOTE.saturating_sub(self.msg_at.elapsed())),
_ => None,
}
}
fn note(&mut self, ok: bool, text: String) {
self.msg = Some((ok, text));
self.msg_at = Instant::now();
}
fn on_key(&mut self, code: KeyCode) -> bool {
if let Some(yes) = self.confirm {
if is_left(code) {
self.confirm = Some(true);
} else if is_right(code) {
self.confirm = Some(false);
} else if code == KeyCode::Char('y') {
self.delete();
self.confirm = None;
} else if code == KeyCode::Enter {
if yes {
self.delete();
}
self.confirm = None;
} else if is_back(code) || matches!(code, KeyCode::Char('q' | 'n')) {
self.confirm = None;
}
return false;
}
if matches!(code, KeyCode::Char('q')) || is_back(code) {
return true;
} else if is_down(code) && self.sel + 1 < self.branches.len() {
self.sel += 1;
self.state.select(Some(self.sel));
self.msg = None;
} else if is_up(code) && self.sel > 0 {
self.sel -= 1;
self.state.select(Some(self.sel));
self.msg = None;
} else if code == KeyCode::Enter && !self.branches.is_empty() {
self.confirm = Some(false); self.msg = None;
} else if code == KeyCode::Char('p') {
self.pruning = true;
self.note(true, "pruning…".to_string());
} else if code == KeyCode::Char('r') {
self.reload();
}
false
}
fn reload(&mut self) {
let keep = self.branches.get(self.sel).map(|b| b.name.clone());
self.branches = load_gone();
self.sel = keep
.and_then(|name| self.branches.iter().position(|b| b.name == name))
.unwrap_or(0);
self.state
.select((!self.branches.is_empty()).then_some(self.sel));
self.note(true, "reloaded".to_string());
}
fn prune(&mut self) {
let remotes = git_capture(".", &["remote"]).unwrap_or_default();
for remote in remotes.lines().filter(|r| !r.is_empty()) {
let (ok, out) = git_run(".", &["remote", "prune", remote]);
if !ok {
self.note(false, first_line(&out));
return;
}
}
self.pruned = true;
self.branches = load_gone();
self.sel = 0;
self.state.select((!self.branches.is_empty()).then_some(0));
self.note(true, "pruned".to_string());
}
fn delete(&mut self) {
let Some(target) = self.branches.get(self.sel).map(|b| b.name.clone()) else {
return;
};
let (ok, out) = git_run(".", &["branch", "-D", &target]);
if ok {
self.branches.remove(self.sel);
if self.sel >= self.branches.len() {
self.sel = self.branches.len().saturating_sub(1);
}
self.state
.select((!self.branches.is_empty()).then_some(self.sel));
self.note(true, format!("deleted {target}"));
} else {
self.note(false, format!("{target}: {}", first_line(&out)));
}
}
}
fn draw(frame: &mut ratatui::Frame, app: &mut App) {
let note = u16::from(!app.prunes && !app.pruned);
let areas = Layout::vertical([
Constraint::Min(1),
Constraint::Length(note),
Constraint::Length(1),
])
.split(frame.area());
let title = if app.branches.is_empty() {
" no branches with a deleted upstream - nothing to tidy ".to_string()
} else {
format!(" gone branches {}/{} ", app.sel + 1, app.branches.len())
};
let list = List::new(app.branches.iter().map(gone_item).collect::<Vec<_>>())
.block(pane_block(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.branches.len(), app.state.offset());
if note == 1 {
frame.render_widget(stale_note(), areas[1]);
}
frame.render_widget(footer(&app.msg), areas[2]);
if let Some(yes) = app.confirm
&& let Some(b) = app.branches.get(app.sel)
{
confirm_popup(frame, &b.name, yes);
}
}
fn confirm_popup(frame: &mut ratatui::Frame, name: &str, yes: bool) {
let full = frame.area();
let width = box_width(full.width);
let lines = vec![
Line::from(Span::styled(
truncate(name, box_inner_width(width)),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
box_buttons(Color::Red, yes),
Line::from(""),
box_hint(&format!("{X_MOVE} move · enter select · y/n")),
];
let rows = lines.len() as u16;
let area = popup_area(full, width, box_height(rows, full.height));
frame.render_widget(Clear, area); let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(box_block(Color::Red, "delete this branch?"));
frame.render_widget(body, area);
}
fn footer(msg: &Option<(bool, String)>) -> Paragraph<'static> {
let line = match msg {
Some((true, m)) => Line::from(Span::styled(
format!(" ✓ {m}"),
Style::default().fg(Color::Green),
)),
Some((false, m)) => Line::from(Span::styled(
format!(" ✗ {m}"),
Style::default().fg(Color::Red),
)),
None => Line::from(Span::styled(
format!(" {Y_MOVE} move · enter delete · r refresh · p prune · q quit"),
Style::default().fg(Color::DarkGray),
)),
};
Paragraph::new(line)
}
fn stale_note() -> Paragraph<'static> {
Paragraph::new(Line::from(Span::styled(
" refs are only as fresh as your last prune - `p` to prune now, or set `git config --global fetch.prune true`",
Style::default().add_modifier(Modifier::DIM),
)))
}
fn gone_item(b: &Gone) -> ListItem<'static> {
ListItem::new(Line::from(vec![
Span::styled(
format!("{:<32}", truncate(&b.name, 32)),
Style::default().fg(Color::Cyan),
),
Span::styled(
"[gone]",
Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
),
Span::styled(
format!(" {:<16}", b.age),
Style::default().fg(Color::Magenta),
),
Span::styled(format!(" {}", b.author), Style::default().fg(Color::Blue)),
]))
}
fn load_gone() -> Vec<Gone> {
let fmt = format!(
"--format=%(refname){SEP}%(upstream:track){SEP}%(committerdate:relative){SEP}%(authorname)"
);
git_capture(
".",
&["for-each-ref", "--sort=-committerdate", &fmt, "refs/heads"],
)
.map(|out| {
out.lines()
.filter_map(|line| {
let mut f = line.split(SEP);
let refname = f.next()?;
let track = f.next()?;
if !track.contains("gone") {
return None;
}
Some(Gone {
name: refname
.strip_prefix("refs/heads/")
.unwrap_or(refname)
.to_string(),
age: f.next().unwrap_or("").to_string(),
author: f.next().unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}