Skip to main content

git_broom/
ui.rs

1use ratatui::Frame;
2use ratatui::layout::{Alignment, Rect};
3use ratatui::layout::{Constraint, Direction, Layout};
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::app::{
10    App, AppScreen, Branch, CleanupMode, CommandLineState, CommandPlanItem, Decision,
11};
12
13pub fn render(frame: &mut Frame<'_>, app: &App) {
14    let chunks = Layout::default()
15        .direction(Direction::Vertical)
16        .constraints([Constraint::Min(1), Constraint::Length(1)])
17        .split(frame.area());
18
19    let title_width = chunks[0].width.saturating_sub(2) as usize;
20    let block = Block::default()
21        .title(render_title(app, title_width))
22        .borders(Borders::ALL);
23    let inner = block.inner(chunks[0]);
24    frame.render_widget(block, chunks[0]);
25
26    match &app.screen {
27        AppScreen::Triage => {
28            let body_area = inner;
29
30            let items = app
31                .branches
32                .iter()
33                .enumerate()
34                .map(|(index, branch)| {
35                    let next_section = app.branches.get(index + 1).map(Branch::section);
36                    render_branch(
37                        app,
38                        branch,
39                        next_section,
40                        body_area.width.saturating_sub(3) as usize,
41                    )
42                })
43                .collect::<Vec<_>>();
44
45            let list = List::new(items)
46                .highlight_style(Style::default().add_modifier(Modifier::BOLD))
47                .highlight_symbol(">> ");
48
49            let mut state = ListState::default();
50            if !app.is_empty() {
51                state.select(Some(app.selected));
52            }
53
54            frame.render_stateful_widget(list, body_area, &mut state);
55        }
56        AppScreen::Review(review) => {
57            let content = Layout::default()
58                .direction(Direction::Vertical)
59                .constraints([Constraint::Length(1), Constraint::Min(1)])
60                .split(inner);
61            render_review(frame, app, review, content[0], content[1]);
62        }
63        AppScreen::Executing(execution) => {
64            let content = Layout::default()
65                .direction(Direction::Vertical)
66                .constraints([Constraint::Length(1), Constraint::Min(1)])
67                .split(inner);
68            render_execution(frame, app, execution, content[0], content[1]);
69        }
70    }
71
72    let footer_chunks = Layout::default()
73        .direction(Direction::Horizontal)
74        .constraints([Constraint::Min(1), Constraint::Length(28)])
75        .split(chunks[1]);
76
77    let footer_left = Paragraph::new(render_footer_left(app));
78    frame.render_widget(footer_left, footer_chunks[0]);
79
80    let footer_right = Paragraph::new(render_footer_right(app)).alignment(Alignment::Right);
81    frame.render_widget(footer_right, footer_chunks[1]);
82
83    if let Some(modal) = &app.modal {
84        let area = centered_rect(72, 26, frame.area());
85        frame.render_widget(Clear, area);
86        let dialog = Paragraph::new(modal.message.as_str())
87            .block(Block::default().title(modal.title).borders(Borders::ALL))
88            .alignment(Alignment::Center)
89            .wrap(Wrap { trim: true });
90        frame.render_widget(dialog, area);
91    }
92}
93
94fn render_review(
95    frame: &mut Frame<'_>,
96    app: &App,
97    review: &crate::app::ReviewState,
98    header_area: Rect,
99    body_area: Rect,
100) {
101    let summary =
102        Paragraph::new(render_review_summary(app, review.items.len())).wrap(Wrap { trim: true });
103    frame.render_widget(summary, header_area);
104
105    let items = review
106        .items
107        .iter()
108        .map(render_review_command)
109        .collect::<Vec<_>>();
110    frame.render_widget(List::new(items), body_area);
111}
112
113fn render_execution(
114    frame: &mut Frame<'_>,
115    _app: &App,
116    execution: &crate::app::ExecutionState,
117    header_area: Rect,
118    body_area: Rect,
119) {
120    let summary_text = if execution.failure.is_some() {
121        "Cleanup failed"
122    } else {
123        "Executing cleanup commands..."
124    };
125    let summary_style = if execution.failure.is_some() {
126        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
127    } else {
128        Style::default().add_modifier(Modifier::BOLD)
129    };
130    let summary = Paragraph::new(Line::from(vec![Span::styled(summary_text, summary_style)]));
131    frame.render_widget(summary, header_area);
132
133    let body_chunks = if execution.failure.is_some() {
134        Layout::default()
135            .direction(Direction::Vertical)
136            .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
137            .split(body_area)
138    } else {
139        Layout::default()
140            .direction(Direction::Vertical)
141            .constraints([Constraint::Min(1)])
142            .split(body_area)
143    };
144
145    let items = execution
146        .items
147        .iter()
148        .enumerate()
149        .map(|(index, item)| {
150            render_execution_command(
151                item,
152                execution.running_index == Some(index),
153                execution.spinner_frame,
154            )
155        })
156        .collect::<Vec<_>>();
157    frame.render_widget(List::new(items), body_chunks[0]);
158
159    if let Some(failure) = &execution.failure {
160        let error = Paragraph::new(render_failure_output(failure))
161            .block(
162                Block::default()
163                    .title(Span::styled(
164                        format!("Failed: {}", failure.branch),
165                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
166                    ))
167                    .borders(Borders::ALL),
168            )
169            .wrap(Wrap { trim: false });
170        frame.render_widget(error, body_chunks[1]);
171    }
172}
173
174fn render_review_summary(app: &App, count: usize) -> Line<'static> {
175    let noun = if count == 1 { "branch" } else { "branches" };
176    Line::from(vec![
177        Span::raw("About to run cleanup commands for "),
178        Span::styled(
179            format!("{count} {} {noun}", app.group_name),
180            Style::default().add_modifier(Modifier::BOLD),
181        ),
182        Span::raw(" "),
183        Span::styled(
184            format!("({})", app.group_description),
185            Style::default()
186                .fg(Color::DarkGray)
187                .add_modifier(Modifier::ITALIC),
188        ),
189        Span::raw(":"),
190    ])
191}
192
193fn render_review_command(item: &CommandPlanItem) -> ListItem<'static> {
194    let mut spans = vec![Span::raw("  ")];
195    if let Some(remote_command) = &item.remote_command {
196        spans.push(Span::styled(
197            remote_command.clone(),
198            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
199        ));
200        spans.push(Span::styled(" && ", Style::default().fg(Color::DarkGray)));
201    }
202    spans.push(Span::styled(
203        item.local_command.clone(),
204        Style::default().fg(Color::Yellow),
205    ));
206
207    ListItem::new(Line::from(spans))
208}
209
210fn render_execution_command(
211    item: &CommandPlanItem,
212    is_running: bool,
213    spinner_frame: usize,
214) -> ListItem<'static> {
215    let spinner = ["| ", "/ ", "- ", "\\ "];
216    let (prefix, command_style) = match item.state {
217        CommandLineState::Pending if is_running => {
218            (spinner[spinner_frame % spinner.len()], Style::default())
219        }
220        CommandLineState::Pending => ("  ", Style::default()),
221        CommandLineState::Success => (
222            "✓ ",
223            Style::default()
224                .fg(Color::DarkGray)
225                .add_modifier(Modifier::CROSSED_OUT),
226        ),
227        CommandLineState::Failed => ("x ", Style::default().fg(Color::Red)),
228        CommandLineState::Skipped => ("- ", Style::default().fg(Color::DarkGray)),
229    };
230
231    let prefix_style = match item.state {
232        CommandLineState::Success => Style::default()
233            .fg(Color::Green)
234            .add_modifier(Modifier::BOLD),
235        CommandLineState::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
236        CommandLineState::Skipped => Style::default().fg(Color::DarkGray),
237        CommandLineState::Pending if is_running => Style::default()
238            .fg(Color::Cyan)
239            .add_modifier(Modifier::BOLD),
240        CommandLineState::Pending => Style::default(),
241    };
242
243    ListItem::new(Line::from(vec![
244        Span::styled(prefix, prefix_style),
245        Span::styled(item.plain_command(), command_style),
246    ]))
247}
248
249fn render_footer_left(app: &App) -> Line<'static> {
250    match &app.screen {
251        AppScreen::Triage => Line::from(vec![
252            key_hint("j / k"),
253            desc_hint(" (up / down)  "),
254            key_hint("d"),
255            desc_hint(" (delete)  "),
256            key_hint("s"),
257            desc_hint(" (save)  "),
258            key_hint("a"),
259            desc_hint(" (delete all)  "),
260            key_hint("u"),
261            desc_hint(" (clear deletions)  "),
262            key_hint("q"),
263            desc_hint(" (quit)"),
264        ]),
265        AppScreen::Review(_) => Line::from(vec![
266            key_hint("y"),
267            desc_hint(" (confirm)  "),
268            key_hint("n"),
269            desc_hint(" (back)  "),
270            key_hint("q"),
271            desc_hint(" (quit)"),
272        ]),
273        AppScreen::Executing(execution) if execution.failure.is_some() => {
274            Line::from(vec![desc_hint("cleanup failed; review the error below")])
275        }
276        AppScreen::Executing(_) => Line::from(vec![desc_hint("running cleanup commands...")]),
277    }
278}
279
280fn render_footer_right(app: &App) -> Line<'static> {
281    match &app.screen {
282        AppScreen::Triage => Line::from(vec![key_hint("enter"), desc_hint(" (review deletions)")]),
283        AppScreen::Review(review) if review.require_explicit_choice => {
284            Line::from(vec![Span::styled(
285                "y or n required",
286                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
287            )])
288        }
289        AppScreen::Review(_) => Line::from(vec![key_hint("y / n"), desc_hint(" (confirm / back)")]),
290        AppScreen::Executing(execution) if execution.failure.is_some() => {
291            Line::from(vec![key_hint("enter"), desc_hint(" (exit)")])
292        }
293        AppScreen::Executing(_) => Line::from(vec![]),
294    }
295}
296
297fn render_failure_output(failure: &crate::app::ExecutionFailure) -> Vec<Line<'static>> {
298    let mut lines = vec![Line::from(vec![
299        Span::styled(
300            "command: ",
301            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
302        ),
303        Span::styled(failure.command.clone(), Style::default().fg(Color::Red)),
304    ])];
305
306    lines.push(Line::from(""));
307
308    for line in failure.output.lines() {
309        lines.push(Line::from(Span::styled(
310            line.to_string(),
311            Style::default().fg(Color::Red),
312        )));
313    }
314
315    if failure.output.lines().next().is_none() {
316        lines.push(Line::from(Span::styled(
317            "command failed with no output",
318            Style::default()
319                .fg(Color::Red)
320                .add_modifier(Modifier::ITALIC),
321        )));
322    }
323
324    lines
325}
326
327fn render_title(app: &App, width: usize) -> Line<'static> {
328    let left_segments = [
329        "  ".len(),
330        "git-broom".len(),
331        "   ".len(),
332        "[".len(),
333        app.group_name.len(),
334        ": ".len(),
335        app.group_description.len(),
336        "]".len(),
337    ];
338    let left_width = left_segments.into_iter().sum::<usize>();
339    let right_text = format!("({}/{})", app.step_index, app.step_count);
340    let spacer_width = width.saturating_sub(left_width + right_text.chars().count());
341
342    Line::from(vec![
343        Span::raw("  "),
344        Span::styled("git-broom", Style::default().add_modifier(Modifier::BOLD)),
345        Span::raw("   "),
346        Span::styled("[", Style::default().fg(Color::DarkGray)),
347        Span::styled(
348            app.group_name.clone(),
349            Style::default().add_modifier(Modifier::BOLD),
350        ),
351        Span::raw(": "),
352        Span::styled(
353            app.group_description.clone(),
354            Style::default().fg(Color::Gray),
355        ),
356        Span::styled("]", Style::default().fg(Color::DarkGray)),
357        Span::raw(" ".repeat(spacer_width)),
358        Span::styled(right_text, Style::default().fg(Color::Gray)),
359    ])
360}
361
362fn render_branch(
363    app: &App,
364    branch: &Branch,
365    next_section: Option<crate::app::BranchSection>,
366    width: usize,
367) -> ListItem<'static> {
368    let marker = match branch.decision {
369        Decision::Delete => ("✗", Style::default().fg(Color::Red)),
370        Decision::Undecided => ("·", Style::default().fg(Color::DarkGray)),
371    };
372    let compact_age = compact_age_display(branch.committed_at);
373    let (branch_width, secondary_width) = column_widths(
374        app.mode,
375        width.saturating_sub(4),
376        &branch.display_name(),
377        &compact_age,
378    );
379
380    let mut line_style = Style::default();
381    if branch.decision == Decision::Delete {
382        line_style = line_style.add_modifier(Modifier::CROSSED_OUT);
383    }
384    if branch.is_protected() {
385        line_style = line_style.fg(Color::DarkGray);
386    } else if branch.saved {
387        line_style = line_style.fg(Color::Green);
388    }
389    let secondary_value = secondary_column_value(branch, app.mode);
390    let secondary_style = if app.mode.uses_pr_metadata() {
391        if branch.is_protected() {
392            line_style.fg(Color::DarkGray)
393        } else if branch.saved {
394            line_style.fg(Color::Green)
395        } else {
396            line_style.fg(Color::Cyan)
397        }
398    } else {
399        line_style
400            .fg(Color::DarkGray)
401            .add_modifier(Modifier::ITALIC)
402    };
403
404    let branch_name_width = branch_width.saturating_sub(compact_age.chars().count() + 1);
405    let mut lines = vec![Line::from(vec![
406        Span::styled(format!("{} ", marker.0), marker.1),
407        Span::styled(pad(&branch.display_name(), branch_name_width), line_style),
408        Span::raw(" "),
409        Span::styled(compact_age, Style::default().fg(Color::DarkGray)),
410        Span::raw("  "),
411        Span::styled(
412            left_pad(
413                &truncate(&secondary_value, secondary_width),
414                secondary_width,
415            ),
416            secondary_style,
417        ),
418    ])];
419
420    if let Some(detail) = &branch.detail {
421        let detail_width = width.saturating_sub(5);
422        let detail_style = line_style
423            .fg(Color::DarkGray)
424            .add_modifier(Modifier::ITALIC);
425        lines.push(Line::from(vec![
426            Span::raw("     "),
427            Span::styled(truncate(detail, detail_width), detail_style),
428        ]));
429    }
430
431    if next_section.is_some() && next_section != Some(branch.section()) {
432        lines.push(Line::from(""));
433    }
434
435    ListItem::new(lines)
436}
437
438fn secondary_column_value(branch: &Branch, mode: CleanupMode) -> String {
439    if mode.uses_pr_metadata() {
440        branch
441            .pr_url
442            .clone()
443            .unwrap_or_else(|| String::from("no PR"))
444    } else {
445        format!("\"{}\"", truncate_commit_subject(&branch.subject))
446    }
447}
448
449fn truncate_commit_subject(subject: &str) -> String {
450    truncate(subject, 50)
451}
452
453fn column_widths(
454    mode: CleanupMode,
455    width: usize,
456    branch_label: &str,
457    compact_age: &str,
458) -> (usize, usize) {
459    let min_branch = 12;
460    let min_secondary = 12;
461    let branch_width = (branch_label.chars().count() + 1 + compact_age.chars().count())
462        .max(min_branch)
463        .min(width.saturating_sub(min_secondary + 2));
464    let secondary_width = width.saturating_sub(branch_width + 2).max(min_secondary);
465
466    let _ = mode;
467    (branch_width, secondary_width)
468}
469
470fn pad(value: &str, width: usize) -> String {
471    let visible = value.chars().count();
472    if visible >= width {
473        return truncate(value, width);
474    }
475
476    let mut padded = value.to_string();
477    padded.push_str(&" ".repeat(width - visible));
478    padded
479}
480
481fn left_pad(value: &str, width: usize) -> String {
482    let truncated = truncate(value, width);
483    let visible = truncated.chars().count();
484    if visible >= width {
485        return truncated;
486    }
487
488    format!("{}{}", " ".repeat(width - visible), truncated)
489}
490
491fn truncate(value: &str, width: usize) -> String {
492    let visible = value.chars().count();
493    if visible <= width {
494        return value.to_string();
495    }
496
497    value
498        .chars()
499        .take(width.saturating_sub(1))
500        .collect::<String>()
501        + "…"
502}
503
504fn compact_age_display(committed_at: i64) -> String {
505    let age_seconds = current_unix_timestamp().saturating_sub(committed_at).max(0) as u64;
506    if age_seconds < 60 {
507        return String::from("now");
508    }
509
510    let minute = 60;
511    let hour = 60 * minute;
512    let day = 24 * hour;
513    let week = 7 * day;
514    let month = 30 * day;
515
516    if age_seconds < hour {
517        return format!("{}m", age_seconds / minute);
518    }
519    if age_seconds < day {
520        return format!("{}h", age_seconds / hour);
521    }
522    if age_seconds < week {
523        return format!("{}d", age_seconds / day);
524    }
525    if age_seconds < month {
526        return format!("{}w", age_seconds / week);
527    }
528    format!("{}mo", age_seconds / month)
529}
530
531fn current_unix_timestamp() -> i64 {
532    SystemTime::now()
533        .duration_since(UNIX_EPOCH)
534        .map(|duration| duration.as_secs() as i64)
535        .unwrap_or(0)
536}
537
538fn centered_rect(horizontal_percent: u16, vertical_percent: u16, area: Rect) -> Rect {
539    let vertical = Layout::default()
540        .direction(Direction::Vertical)
541        .constraints([
542            Constraint::Percentage((100 - vertical_percent) / 2),
543            Constraint::Percentage(vertical_percent),
544            Constraint::Percentage((100 - vertical_percent) / 2),
545        ])
546        .split(area);
547
548    Layout::default()
549        .direction(Direction::Horizontal)
550        .constraints([
551            Constraint::Percentage((100 - horizontal_percent) / 2),
552            Constraint::Percentage(horizontal_percent),
553            Constraint::Percentage((100 - horizontal_percent) / 2),
554        ])
555        .split(vertical[1])[1]
556}
557
558fn key_hint(text: &'static str) -> Span<'static> {
559    Span::styled(text, Style::default().add_modifier(Modifier::BOLD))
560}
561
562fn desc_hint(text: &'static str) -> Span<'static> {
563    Span::styled(text, Style::default().fg(Color::DarkGray))
564}