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}