use crate::git::{git_capture, git_run};
use crate::tui::{
is_back, is_down, is_left, is_right, is_up, list_scrollbar, norm_esc, pane_block,
pop_keyboard_enhancement, push_keyboard_enhancement, X_MOVE, Y_MOVE, SEP,
};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::DefaultTerminal;
use std::io::{self, IsTerminal};
#[derive(clap::Args)]
pub struct Args {}
struct Gone {
name: String,
age: String,
author: String,
}
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 branches = load_gone();
let mut terminal = ratatui::init();
let enhanced = push_keyboard_enhancement();
let result = event_loop(&mut terminal, branches);
if enhanced {
pop_keyboard_enhancement();
}
ratatui::restore();
if let Err(e) = result {
eprintln!("slu itidy: {e}");
}
}
fn event_loop(terminal: &mut DefaultTerminal, mut branches: Vec<Gone>) -> io::Result<()> {
let mut sel = 0usize;
let mut state = ListState::default();
state.select(Some(0));
let mut confirm: Option<bool> = None;
let mut msg: Option<(bool, String)> = None;
loop {
terminal.draw(|frame| draw(frame, &branches, sel, &mut state, &msg, confirm))?;
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 let Some(yes) = confirm {
if is_left(code) {
confirm = Some(true);
} else if is_right(code) {
confirm = Some(false);
} else if code == KeyCode::Char('y') {
msg = delete(&mut branches, &mut sel, &mut state);
confirm = None;
} else if code == KeyCode::Enter {
if yes {
msg = delete(&mut branches, &mut sel, &mut state);
}
confirm = None;
} else if is_back(code) || matches!(code, KeyCode::Char('q' | 'n')) {
confirm = None;
}
continue;
}
if matches!(code, KeyCode::Char('q')) || is_back(code) {
break;
} else if is_down(code) && sel + 1 < branches.len() {
sel += 1;
state.select(Some(sel));
msg = None;
} else if is_up(code) && sel > 0 {
sel -= 1;
state.select(Some(sel));
msg = None;
} else if code == KeyCode::Enter && !branches.is_empty() {
confirm = Some(false); msg = None;
}
}
Ok(())
}
fn delete(branches: &mut Vec<Gone>, sel: &mut usize, state: &mut ListState) -> Option<(bool, String)> {
let target = branches.get(*sel)?.name.clone();
let (ok, out) = git_run(".", &["branch", "-D", &target]);
if ok {
branches.remove(*sel);
if *sel >= branches.len() {
*sel = branches.len().saturating_sub(1);
}
state.select((!branches.is_empty()).then_some(*sel));
Some((true, format!("deleted {target}")))
} else {
Some((false, format!("{target}: {}", first_line(&out))))
}
}
fn draw(
frame: &mut ratatui::Frame,
branches: &[Gone],
sel: usize,
state: &mut ListState,
msg: &Option<(bool, String)>,
confirm: Option<bool>,
) {
let areas =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
let title = if branches.is_empty() {
" no branches with a deleted upstream — nothing to tidy ".to_string()
} else {
format!(" gone branches {}/{} ", sel + 1, branches.len())
};
let list = List::new(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], state);
list_scrollbar(frame, areas[0], branches.len(), state.offset());
frame.render_widget(footer(msg), areas[1]);
if let Some(yes) = confirm
&& let Some(b) = branches.get(sel)
{
confirm_popup(frame, &b.name, yes);
}
}
fn confirm_popup(frame: &mut ratatui::Frame, name: &str, yes: bool) {
let area = popup_area(frame.area());
frame.render_widget(Clear, area);
let picked = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
let idle = Style::default().fg(Color::DarkGray);
let buttons = Line::from(vec![
Span::styled(" Yes ", if yes { picked } else { idle }),
Span::raw(" "),
Span::styled(" No ", if yes { idle } else { picked }),
]);
let body = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
truncate(name, area.width.saturating_sub(4) as usize),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
Line::from(""),
buttons,
Line::from(""),
Line::from(Span::styled(
format!("{X_MOVE} toggle · enter select · y/n"),
Style::default().fg(Color::DarkGray),
)),
])
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" delete this branch? "),
);
frame.render_widget(body, area);
}
fn popup_area(area: Rect) -> Rect {
let w = area.width.saturating_sub(4).clamp(24, 54);
let h = 9u16.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
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 · q quit"),
Style::default().fg(Color::DarkGray),
)),
};
Paragraph::new(line)
}
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 first_line(s: &str) -> String {
s.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
}
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}…")
}