use crate::git::{SEP, git_capture, git_run};
use crate::tui::input::{X_MOVE, Y_MOVE, is_back, is_down, is_left, is_right, is_up, norm_esc};
use crate::tui::widgets::{list_scrollbar, pane_block};
use crate::tui::{pop_keyboard_enhancement, push_keyboard_enhancement};
use ratatui::DefaultTerminal;
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 std::io::{self, IsTerminal};
#[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)>,
}
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,
};
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))?;
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 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;
}
false
}
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]);
self.msg = 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));
Some((true, format!("deleted {target}")))
} else {
Some((false, format!("{target}: {}", first_line(&out))))
};
}
}
fn draw(frame: &mut ratatui::Frame, app: &mut App) {
let areas = Layout::vertical([Constraint::Min(1), 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());
frame.render_widget(footer(&app.msg), areas[1]);
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 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}…")
}