1use ratatui::Frame;
8use ratatui::layout::{Constraint, Layout, Margin, Rect};
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12 Block, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Scrollbar,
13 ScrollbarOrientation, ScrollbarState, Wrap,
14};
15
16use crate::agent::{AgentModel, Effort};
17use crate::keys::KeyAction;
18use crate::model::{MergeState, PrState, SortKey, SortSpec, Worktree};
19use crate::output::render::branch_display;
20use crate::time::{now_unix, parse_iso8601, relative};
21use crate::tui::app::{
22 App, CheckoutState, ComposeField, CreateState, CreateStep, ExitBlockedState, ExitIntent,
23 InitSubmodulesState, Mode, Pane, PrComposeState, PrPickerState, StaleBaseState,
24};
25use crate::tui::glyphs::Glyphs;
26use crate::tui::hints::{self, Hint};
27use crate::tui::options::OptionList;
28use crate::tui::theme::Theme;
29
30mod detail;
31mod list;
32mod modals;
33
34pub fn render(app: &App, frame: &mut Frame) {
36 let area = frame.area();
37 let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
38 let (main, status) = (rows[0], rows[1]);
39
40 if app.show_sidebar && app.detail_visible() {
41 let cols = Layout::horizontal([Constraint::Length(app.sidebar_width), Constraint::Min(20)])
42 .split(main);
43 list::render_list(app, frame, cols[0]);
44 detail::render_detail(app, frame, cols[1]);
45 } else if app.show_sidebar {
46 list::render_list(app, frame, main);
47 } else {
48 detail::render_detail(app, frame, main);
49 }
50 render_status_bar(app, frame, status);
51
52 match &app.mode {
53 Mode::Help => modals::render_help(app, frame, area),
54 Mode::Create(state) => modals::render_create(app, state, frame, area),
55 Mode::PrPicker(state) => modals::render_pr_picker(app, state, frame, area),
56 Mode::PrCompose(state) => modals::render_pr_compose(app, state, frame, area),
57 Mode::Checkout(state) => modals::render_checkout(app, state, frame, area),
58 Mode::ConfirmRemove(index) => modals::render_confirm(app, *index, frame, area),
59 Mode::ConfirmCreate(index) => modals::render_confirm_create(app, *index, frame, area),
60 Mode::ConfirmDeleteBranch { index, force } => {
61 modals::render_confirm_delete_branch(app, *index, *force, frame, area)
62 }
63 Mode::ConfirmStaleBase(state) => modals::render_confirm_stale_base(app, state, frame, area),
64 Mode::ConfirmInitSubmodules(state) => {
65 modals::render_confirm_init_submodules(app, state, frame, area)
66 }
67 Mode::ExitBlocked(state) => modals::render_exit_blocked(app, state, frame, area),
68 _ => {}
69 }
70}
71
72fn ahead_behind_spans(
75 worktree: &Worktree,
76 theme: &Theme,
77 loaded: bool,
78 glyphs: &Glyphs,
79) -> Vec<Span<'static>> {
80 if !loaded {
81 return vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())];
82 }
83 match (worktree.ahead, worktree.behind) {
84 (Some(ahead), Some(behind)) => vec![
85 Span::styled(format!("↑{ahead}"), theme.ahead(ahead)),
86 Span::raw(" "),
87 Span::styled(format!("↓{behind}"), theme.behind(behind)),
88 ],
89 _ => vec![Span::styled(glyphs.absent().to_string(), theme.absent())],
90 }
91}
92
93fn commit_spans(
95 worktree: &Worktree,
96 theme: &Theme,
97 loaded: bool,
98 glyphs: &Glyphs,
99 now: i64,
100) -> Vec<Span<'static>> {
101 match (&worktree.commit, loaded) {
102 (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
103 (Some(c), true) => {
104 let rel = parse_iso8601(&c.timestamp)
105 .map(|u| relative(now, u))
106 .unwrap_or_default();
107 vec![
108 Span::styled(c.hash.clone(), theme.commit_hash()),
109 Span::raw(" "),
110 Span::raw(c.subject.clone()),
111 Span::raw(" "),
112 Span::styled(format!("({rel})"), theme.time()),
113 ]
114 }
115 (None, true) if !worktree.is_missing => {
117 vec![Span::styled(glyphs.absent().to_string(), theme.absent())]
118 }
119 (None, true) => Vec::new(),
120 }
121}
122
123fn pr_spans(
125 worktree: &Worktree,
126 theme: &Theme,
127 loaded: bool,
128 glyphs: &Glyphs,
129) -> Vec<Span<'static>> {
130 match (&worktree.pr, loaded) {
131 (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
132 (Some(pr), true) => vec![Span::styled(
133 format!("#{} ({})", pr.number, pr.state.as_str()),
134 theme.pr_state(pr.state),
135 )],
136 (None, true) => Vec::new(),
137 }
138}
139
140fn dirty_label_span(worktree: &Worktree, theme: &Theme) -> Span<'static> {
142 match (worktree.dirty, worktree.has_untracked) {
143 (Some(true), _) => Span::styled("modified", theme.dirty()),
144 (_, Some(true)) => Span::styled("untracked", theme.untracked()),
145 (Some(false), _) => Span::styled("clean", theme.hint_label()),
146 _ => Span::raw(""),
147 }
148}
149
150fn merge_state_note(
158 worktree: &Worktree,
159 theme: &Theme,
160 include_warnings: bool,
161) -> Option<Line<'static>> {
162 match &worktree.merge_state {
163 Some(MergeState::Merged { into: Some(base) }) => Some(Line::from(Span::styled(
164 format!("(merged into {base} — safe to delete)"),
165 theme.success(),
166 ))),
167 Some(MergeState::Merged { into: None }) => Some(Line::from(Span::styled(
168 "(merged via PR — safe to delete)",
169 theme.success(),
170 ))),
171 Some(MergeState::UpstreamGone) => Some(Line::from(Span::styled(
172 "(upstream branch deleted — likely merged)",
173 theme.label(),
174 ))),
175 Some(MergeState::NoUpstreamLocal) if include_warnings => Some(Line::from(Span::styled(
176 "(no upstream — local-only, unpushed work)",
177 theme.warning(),
178 ))),
179 Some(MergeState::Tracked) | None if include_warnings => match worktree.ahead {
180 Some(ahead) if ahead > 0 => Some(Line::from(Span::styled(
181 format!("({ahead} unpushed commit(s))"),
182 theme.warning(),
183 ))),
184 _ => None,
185 },
186 _ => None,
187 }
188}
189
190fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) {
192 let theme = Theme::with_palette(app.color, app.palette);
193 let mut spans = vec![Span::styled(
194 format!(" {} ", mode_label(&app.mode)),
195 theme.mode_chip(&app.mode),
196 )];
197 if !app.filter.is_empty() {
198 spans.push(Span::raw(" "));
199 spans.push(Span::styled(format!("/{}", app.filter), theme.accent()));
200 }
201 spans.push(Span::raw(" "));
202 if let Some(summary) = app.job_summary() {
207 let glyphs = Glyphs::new(app.nerd_fonts);
208 spans.push(Span::styled(
209 glyphs.spinner_frame(app.spinner_frame).to_string(),
210 theme.spinner(),
211 ));
212 spans.push(Span::raw(" "));
213 spans.push(Span::styled(summary, theme.label()));
214 if let Some(message) = &app.status_message {
215 spans.push(Span::raw(" "));
216 spans.push(Span::styled(message.clone(), theme.status(app.status_kind)));
217 }
218 } else if let Some(message) = &app.status_message {
219 spans.push(Span::styled(message.clone(), theme.status(app.status_kind)));
220 } else {
221 for (i, (key, label)) in mode_hints(app).into_iter().enumerate() {
222 if i > 0 {
223 spans.push(Span::raw(" "));
224 }
225 spans.push(Span::styled(key, theme.hint_key()));
226 spans.push(Span::raw(" "));
227 spans.push(Span::styled(label, theme.hint_label()));
228 }
229 }
230 frame.render_widget(Paragraph::new(Line::from(spans)), area);
231}
232
233fn mode_label(mode: &Mode) -> &'static str {
235 match mode {
236 Mode::List => "LIST",
237 Mode::Filter => "FILTER",
238 Mode::Create(_) => "CREATE",
239 Mode::PrPicker(_) => "PR",
240 Mode::PrCompose(_) => "COMPOSE",
241 Mode::Checkout(_) => "CHECKOUT",
242 Mode::ConfirmRemove(_) => "REMOVE",
243 Mode::ConfirmCreate(_) => "CREATE",
244 Mode::ConfirmDeleteBranch { .. } => "DELETE",
245 Mode::ConfirmStaleBase(_) => "CREATE",
246 Mode::ConfirmInitSubmodules(_) => "SUBMODULES",
247 Mode::ExitBlocked(state) => match state.intent {
248 ExitIntent::Quit => "QUIT",
249 ExitIntent::Switch(_) => "SWITCH",
250 },
251 Mode::Help => "HELP",
252 }
253}
254
255const LIST_BAR: [KeyAction; 8] = [
260 KeyAction::Switch,
261 KeyAction::New,
262 KeyAction::Remove,
263 KeyAction::PrCheckout,
264 KeyAction::Checkout,
265 KeyAction::Filter,
266 KeyAction::Help,
267 KeyAction::Quit,
268];
269
270fn mode_hints(app: &App) -> Vec<(String, String)> {
274 match &app.mode {
275 Mode::List => LIST_BAR
276 .iter()
277 .filter_map(|&action| {
278 app.keymap
279 .display_for(action)
280 .map(|keys| (keys, action.label().to_string()))
281 })
282 .collect(),
283 Mode::Filter => hint_pairs(hints::filter_hints()),
284 Mode::Create(_) => hint_pairs(hints::create_hints()),
285 Mode::PrPicker(_) => hint_pairs(hints::pr_picker_hints()),
286 Mode::PrCompose(_) => hint_pairs(hints::compose_edit_hints()),
287 Mode::Checkout(_) => hint_pairs(hints::checkout_hints()),
288 Mode::ConfirmRemove(_) => hint_pairs(hints::confirm_hints()),
289 Mode::ConfirmCreate(_) => hint_pairs(hints::confirm_create_hints()),
290 Mode::ConfirmDeleteBranch { .. } => hint_pairs(hints::confirm_delete_branch_hints()),
291 Mode::ConfirmStaleBase(_) => hint_pairs(hints::confirm_stale_base_hints()),
292 Mode::ConfirmInitSubmodules(_) => hint_pairs(hints::confirm_init_submodules_hints()),
293 Mode::ExitBlocked(_) => hint_pairs(hints::exit_blocked_hints()),
294 Mode::Help => hint_pairs(hints::help_hints()),
295 }
296}
297
298fn hint_pairs(table: &[Hint]) -> Vec<(String, String)> {
300 table
301 .iter()
302 .map(|h| (h.key.to_string(), h.label.to_string()))
303 .collect()
304}
305
306fn centered(area: Rect, width: u16, height: u16) -> Rect {
308 let w = width.min(area.width);
309 let h = height.min(area.height);
310 Rect {
311 x: area.x + (area.width.saturating_sub(w)) / 2,
312 y: area.y + (area.height.saturating_sub(h)) / 2,
313 width: w,
314 height: h,
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::keys::KeyChord;
322 use crate::tui::app::testutil::{app, wt};
323 use crate::tui::app::{
324 ComposeField, CreateState, PrComposeState, PrItem, PrPickerState, StatusKind,
325 };
326 use crossterm::event::KeyCode;
327 use ratatui::Terminal;
328 use ratatui::backend::TestBackend;
329 use ratatui::buffer::Buffer;
330 use ratatui::style::Color;
331
332 fn render_to_text(app: &App, w: u16, h: u16) -> String {
334 buffer_text(&render_to_buffer(app, w, h))
335 }
336
337 fn render_to_buffer(app: &App, w: u16, h: u16) -> Buffer {
339 let backend = TestBackend::new(w, h);
340 let mut terminal = Terminal::new(backend).unwrap();
341 terminal.draw(|f| render(app, f)).unwrap();
342 terminal.backend().buffer().clone()
343 }
344
345 fn cell_fg(buffer: &Buffer, symbol: &str) -> Color {
347 let area = buffer.area;
348 for y in 0..area.height {
349 for x in 0..area.width {
350 if buffer[(x, y)].symbol() == symbol {
351 return buffer[(x, y)].fg;
352 }
353 }
354 }
355 panic!("symbol {symbol:?} not found");
356 }
357
358 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
359 let area = buffer.area;
360 let mut out = String::new();
361 for y in 0..area.height {
362 for x in 0..area.width {
363 out.push_str(buffer[(x, y)].symbol());
364 }
365 out.push('\n');
366 }
367 out
368 }
369
370 #[test]
371 fn renders_list_and_detail() {
372 let a = app(&[("main", true), ("feature/x", false)]);
373 let text = render_to_text(&a, 100, 20);
374 assert!(text.contains("worktrees"));
375 assert!(text.contains("detail"));
376 assert!(text.contains("main"));
377 assert!(text.contains("feature/x"));
378 assert!(text.contains('*'));
380 }
381
382 #[test]
383 fn list_shows_branch_rows_with_marker_and_count() {
384 use crate::tui::app::testutil::branch_row;
385 let mut a = app(&[("main", true)]);
386 let mut br = branch_row("feature/lonely");
387 br.ahead = Some(3);
388 br.behind = Some(1);
389 a.worktrees.push(br);
390 a.apply_filter(String::new());
391 a.mark_loaded(a.worktrees[1].path.clone());
392 let text = render_to_text(&a, 100, 20);
393 assert!(text.contains("feature/lonely"));
394 assert!(text.contains('○')); assert!(text.contains("↑3"));
396 assert!(text.contains("↓1"));
397 assert!(text.contains("branches"));
399 }
400
401 #[test]
402 fn detail_pane_for_branch_row_is_pathless() {
403 use crate::tui::app::testutil::branch_row;
404 let mut a = app(&[("main", true)]);
405 let mut br = branch_row("topic");
406 br.ahead = Some(2);
407 br.behind = Some(0);
408 br.base_ref = Some("main".into());
409 a.worktrees.push(br);
410 a.apply_filter(String::new());
411 a.mark_loaded(a.worktrees[1].path.clone());
412 a.selected = 1; let text = render_to_text(&a, 100, 20);
414 assert!(text.contains("no worktree"));
415 assert!(text.contains("vs base"));
416 assert!(text.contains("base:"));
417 assert!(!text.contains("branch://"));
419 }
420
421 #[test]
422 fn confirm_create_dialog_renders() {
423 use crate::tui::app::testutil::branch_row;
424 let mut a = app(&[("main", true)]);
425 let mut br = branch_row("topic");
426 br.base_ref = Some("main".into());
427 br.ahead = Some(2);
428 br.behind = Some(0);
429 a.worktrees.push(br);
430 a.apply_filter(String::new());
431 a.mark_loaded(a.worktrees[1].path.clone());
432 a.mode = Mode::ConfirmCreate(1);
433 let text = render_to_text(&a, 100, 30);
434 assert!(text.contains("create worktree"));
435 assert!(text.contains("topic"));
436 assert!(text.contains("switch into it"));
437 assert!(text.contains("[y/N]"));
438 }
439
440 #[test]
441 fn renders_confirm_init_submodules_modal() {
442 let mut a = app(&[("main", true)]);
443 a.mode = Mode::ConfirmInitSubmodules(InitSubmodulesState {
444 dir: std::path::PathBuf::from("/wt/feature"),
445 branch: "feature".into(),
446 count: 3,
447 });
448 let text = render_to_text(&a, 100, 30);
449 assert!(text.contains("initialize submodules"));
450 assert!(text.contains("feature"));
451 assert!(text.contains("3 uninitialized"));
452 assert!(text.contains("[Y/n]"));
454 }
455
456 #[test]
457 fn narrow_terminal_hides_detail() {
458 let a = app(&[("main", true)]);
459 let mut a = a;
460 a.size = (50, 20);
461 let text = render_to_text(&a, 50, 20);
462 assert!(text.contains("worktrees"));
463 assert!(!text.contains("detail")); }
465
466 #[test]
467 fn pending_rows_show_spinner() {
468 let mut a = app(&[("main", true)]);
469 a.mark_loading(); let text = render_to_text(&a, 100, 20);
471 assert!(text.contains('…'));
472 }
473
474 #[test]
475 fn loaded_no_upstream_shows_absent_marker() {
476 let a = app(&[("main", true)]);
478 let text = render_to_text(&a, 100, 20);
479 assert!(text.contains('–'));
480 }
481
482 #[test]
483 fn help_overlay_renders() {
484 let mut a = app(&[("main", true)]);
485 a.mode = Mode::Help;
486 let text = render_to_text(&a, 100, 40);
487 assert!(text.contains("help"));
488 assert!(text.contains("navigate"));
489 assert!(text.contains("quit"));
490 assert!(text.contains("checkout"));
492 }
493
494 #[test]
495 fn help_overlay_documents_every_action() {
496 let mut a = app(&[("main", true)]);
499 a.mode = Mode::Help;
500 let text = render_to_text(&a, 100, 40);
501 for action in KeyAction::ALL {
502 assert!(
503 text.contains(action.label()),
504 "help overlay missing label for {action:?}: {:?}",
505 action.label()
506 );
507 }
508 }
509
510 #[test]
511 fn list_bar_includes_checkout() {
512 let a = app(&[("main", true)]);
515 let text = render_to_text(&a, 100, 30);
516 assert!(text.contains("checkout"));
517 assert!(text.contains(" c "));
518 }
519
520 #[test]
521 fn list_bar_follows_rebind() {
522 let mut a = app(&[("main", true)]);
525 a.keymap
526 .rebind(KeyAction::Checkout, KeyChord::key(KeyCode::Char('x')));
527 let text = render_to_text(&a, 100, 30);
528 assert!(text.contains(" x "));
529 }
530
531 #[test]
532 fn create_overlay_shows_fields_and_error() {
533 let mut a = app(&[("main", true)]);
534 a.mode = Mode::Create(CreateState {
535 branch: "feat".into(),
536 error: Some("branch name is required".into()),
537 ..Default::default()
538 });
539 let text = render_to_text(&a, 100, 30);
540 assert!(text.contains("new worktree"));
541 assert!(text.contains("feat"));
542 assert!(text.contains("required"));
543 }
544
545 #[test]
546 fn create_overlay_shows_open_branch_options() {
547 use crate::tui::options::OptionList;
548 let mut a = app(&[("main", true)]);
549 let mut options = OptionList::new(vec![
550 "main".into(),
551 "origin/main".into(),
552 "origin/dev".into(),
553 ]);
554 options.open();
555 a.mode = Mode::Create(CreateState {
556 options,
557 ..Default::default()
558 });
559 let text = render_to_text(&a, 100, 30);
560 assert!(text.contains("origin/main"));
562 assert!(text.contains("origin/dev"));
563 assert!(text.contains('▌')); assert!(text.contains("options")); }
566
567 #[test]
568 fn checkout_overlay_renders_branches_and_target() {
569 use crate::tui::app::CheckoutState;
570 use crate::tui::options::OptionList;
571 let mut a = app(&[("main", true), ("feature/x", false)]);
572 let mut options = OptionList::new(vec!["main".into(), "feature/x".into()]);
573 options.open();
574 a.mode = Mode::Checkout(CheckoutState {
575 worktree_index: 0,
576 query: "feat".into(),
577 options,
578 ..Default::default()
579 });
580 let text = render_to_text(&a, 100, 30);
581 assert!(text.contains("checkout branch"));
582 assert!(text.contains("feature/x"));
583 assert!(text.contains("branches")); }
585
586 #[test]
587 fn pr_compose_model_field_shows_options_dropdown() {
588 let mut a = app(&[("main", true)]);
589 a.mode = Mode::PrCompose(PrComposeState {
590 field: ComposeField::Model,
591 branch: "feat".into(),
592 trunk: "main".into(),
593 action_label: "create".into(),
594 ..Default::default()
595 });
596 let text = render_to_text(&a, 100, 30);
597 assert!(text.contains("Opus 4.8"));
599 assert!(text.contains("Sonnet 4.6"));
600 assert!(text.contains("Haiku 4.5"));
601 assert!(text.contains("> model:"));
602 }
603
604 #[test]
605 fn pr_compose_effort_field_shows_options_dropdown() {
606 let mut a = app(&[("main", true)]);
607 a.mode = Mode::PrCompose(PrComposeState {
608 field: ComposeField::Effort,
609 branch: "feat".into(),
610 trunk: "main".into(),
611 action_label: "create".into(),
612 ..Default::default()
613 });
614 let text = render_to_text(&a, 100, 30);
615 assert!(text.contains("low"));
616 assert!(text.contains("medium"));
617 assert!(text.contains("high"));
618 }
619
620 #[test]
621 fn pr_compose_overlay_shows_header_fields_and_hints() {
622 let mut a = app(&[("main", true)]);
623 a.mode = Mode::PrCompose(PrComposeState {
624 field: ComposeField::Body,
625 title: "Add login".into(),
626 body: "Summary line".into(),
627 draft: true,
628 branch: "feat/login".into(),
629 trunk: "main".into(),
630 action_label: "create".into(),
631 error: Some("boom".into()),
632 ..Default::default()
633 });
634 let text = render_to_text(&a, 100, 30);
635 assert!(text.contains("open pull request"));
636 assert!(text.contains("feat/login"));
637 assert!(text.contains("Add login"));
638 assert!(text.contains("Summary line"));
639 assert!(text.contains("[create]"));
640 assert!(text.contains("draft [x]"));
641 assert!(text.contains("boom"));
642 assert!(text.contains("Ctrl-S"));
643 assert!(text.contains("Ctrl-A"));
645 assert!(text.contains("model:"));
646 assert!(text.contains("Sonnet 4.6")); assert!(text.contains("effort:"));
648 }
649
650 #[test]
651 fn pr_compose_shows_selected_model_and_effort() {
652 let mut a = app(&[("main", true)]);
653 a.mode = Mode::PrCompose(PrComposeState {
654 title: "T".into(),
655 branch: "feat".into(),
656 trunk: "main".into(),
657 action_label: "create".into(),
658 model: crate::agent::AgentModel::Opus,
659 effort: crate::agent::Effort::High,
660 ..Default::default()
661 });
662 let text = render_to_text(&a, 100, 30);
663 assert!(text.contains("Opus 4.8"));
664 assert!(text.contains("high"));
665 }
666
667 #[test]
668 fn pr_compose_submitting_shows_status() {
669 let mut a = app(&[("main", true)]);
670 a.mode = Mode::PrCompose(PrComposeState {
671 title: "T".into(),
672 branch: "feat".into(),
673 trunk: "main".into(),
674 action_label: "update #5".into(),
675 submitting: true,
676 ..Default::default()
677 });
678 let text = render_to_text(&a, 100, 30);
679 assert!(text.contains("working"));
680 assert!(text.contains("[update #5]"));
681 }
682
683 #[test]
684 fn pr_picker_states() {
685 let mut a = app(&[("main", true)]);
686 a.mode = Mode::PrPicker(PrPickerState {
687 loading: true,
688 ..Default::default()
689 });
690 assert!(render_to_text(&a, 100, 30).contains("loading"));
691
692 a.mode = Mode::PrPicker(PrPickerState {
693 loading: false,
694 prs: vec![
695 PrItem {
696 number: 42,
697 title: "Add login".into(),
698 author: "alice".into(),
699 state: "open".into(),
700 created_at: "2020-01-01T00:00:00Z".into(),
701 },
702 PrItem {
704 number: 7,
705 title: "No date".into(),
706 author: "bob".into(),
707 state: "open".into(),
708 created_at: String::new(),
709 },
710 ],
711 ..Default::default()
712 });
713 let text = render_to_text(&a, 100, 30);
714 assert!(text.contains("#42"));
715 assert!(text.contains("Add login"));
716 assert!(text.contains("ago"));
719
720 a.mode = Mode::PrPicker(PrPickerState {
721 error: Some("gh unavailable".into()),
722 ..Default::default()
723 });
724 assert!(render_to_text(&a, 100, 30).contains("gh auth login"));
725 }
726
727 #[test]
728 fn confirm_remove_overlay_shows_safety() {
729 let mut dirty = wt("topic", false);
730 dirty.dirty = Some(true);
731 let mut a = app(&[("main", true)]);
732 a.worktrees.push(dirty);
733 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
734 let text = render_to_text(&a, 100, 30);
735 assert!(text.contains("confirm remove"));
736 assert!(text.contains("data may be lost"));
737 assert!(text.contains("[y/N]"));
738 }
739
740 #[test]
741 fn confirm_remove_flags_no_upstream_as_unpushed() {
742 let mut clean = wt("topic", false);
745 clean.dirty = Some(false);
746 clean.has_untracked = Some(false);
747 clean.ahead = None; clean.merge_state = Some(MergeState::NoUpstreamLocal);
749 let mut a = app(&[("main", true)]);
750 a.worktrees.push(clean);
751 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
752 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
753 let text = render_to_text(&a, 100, 30);
754 assert!(text.contains("no upstream"));
755 assert!(text.contains("local-only"));
756 assert!(!text.contains("data may be lost")); }
758
759 #[test]
760 fn confirm_remove_merged_into_base_is_safe() {
761 let mut w = wt("feature/done", false);
763 w.dirty = Some(false);
764 w.has_untracked = Some(false);
765 w.ahead = None;
766 w.merge_state = Some(MergeState::Merged {
767 into: Some("main".into()),
768 });
769 let mut a = app(&[("main", true)]);
770 a.worktrees.push(w);
771 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
772 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
773 let text = render_to_text(&a, 100, 30);
774 assert!(text.contains("merged into main"));
775 assert!(text.contains("safe to delete"));
776 assert!(!text.contains("unpushed"));
779 }
780
781 #[test]
782 fn confirm_remove_merged_via_pr_is_safe() {
783 let mut w = wt("feature/squashed", false);
785 w.dirty = Some(false);
786 w.has_untracked = Some(false);
787 w.ahead = None;
788 w.merge_state = Some(MergeState::Merged { into: None });
789 let mut a = app(&[("main", true)]);
790 a.worktrees.push(w);
791 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
792 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
793 let text = render_to_text(&a, 100, 30);
794 assert!(text.contains("merged via PR"));
795 assert!(!text.contains("unpushed"));
796 }
797
798 #[test]
799 fn confirm_remove_upstream_gone_is_soft() {
800 let mut w = wt("feature/pushed", false);
802 w.dirty = Some(false);
803 w.has_untracked = Some(false);
804 w.ahead = None;
805 w.merge_state = Some(MergeState::UpstreamGone);
806 let mut a = app(&[("main", true)]);
807 a.worktrees.push(w);
808 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
809 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
810 let text = render_to_text(&a, 100, 30);
811 assert!(text.contains("upstream branch deleted"));
812 assert!(text.contains("likely merged"));
813 assert!(!text.contains("unpushed work"));
814 }
815
816 #[test]
817 fn confirm_remove_merged_but_dirty_still_warns() {
818 let mut w = wt("feature/dirty-merged", false);
821 w.dirty = Some(true);
822 w.ahead = None;
823 w.merge_state = Some(MergeState::Merged {
824 into: Some("main".into()),
825 });
826 let mut a = app(&[("main", true)]);
827 a.worktrees.push(w);
828 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
829 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
830 let text = render_to_text(&a, 100, 30);
831 assert!(text.contains("safe to delete"));
832 assert!(text.contains("data may be lost"));
833 }
834
835 #[test]
836 fn confirm_remove_tracked_ahead_still_warns() {
837 let mut w = wt("feature/ahead", false);
839 w.dirty = Some(false);
840 w.ahead = Some(2);
841 w.upstream = Some("origin/feature/ahead".into());
842 w.merge_state = Some(MergeState::Tracked);
843 let mut a = app(&[("main", true)]);
844 a.worktrees.push(w);
845 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
846 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
847 let text = render_to_text(&a, 100, 30);
848 assert!(text.contains("2 unpushed commit(s)"));
849 }
850
851 #[test]
852 fn confirm_remove_honors_remove_untracked_blocks() {
853 let mut wt_un = wt("topic", false);
856 wt_un.dirty = Some(false);
857 wt_un.has_untracked = Some(true);
858 wt_un.ahead = Some(0);
859 let mut a = app(&[("main", true)]);
860 assert!(a.show_untracked && !a.remove_untracked_blocks);
861 a.worktrees.push(wt_un);
862 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
863 assert!(!render_to_text(&a, 100, 30).contains("data may be lost"));
864 }
865
866 #[test]
867 fn confirm_remove_shows_glanceable_context() {
868 use crate::model::{Commit, Pr, PrState};
871 let mut w = wt("feature/login", false);
872 w.dirty = Some(false);
873 w.has_untracked = Some(false);
874 w.ahead = Some(0);
875 w.behind = Some(0);
876 w.upstream = Some("origin/feature/login".into());
877 w.base_ref = Some("main".into());
878 w.commit = Some(Commit {
879 hash: "abc1234".into(),
880 subject: "Add login page".into(),
881 author: "Alice".into(),
882 timestamp: "2024-01-15T10:30:00Z".into(),
883 });
884 w.pr = Some(Pr {
885 number: 42,
886 state: PrState::Merged,
887 title: "Add login page".into(),
888 });
889 let mut a = app(&[("main", true)]);
890 a.worktrees.push(w);
891 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
892 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
893 let text = render_to_text(&a, 100, 30);
894 assert!(text.contains("origin/feature/login"));
895 assert!(text.contains("base:"));
896 assert!(text.contains("abc1234"));
897 assert!(text.contains("Add login page"));
898 assert!(text.contains("#42 (merged)"));
899 assert!(text.contains("↑0"));
900 assert!(!text.contains("data may be lost"));
901 assert!(text.contains("[y/N]"));
902 }
903
904 #[test]
905 fn confirm_remove_layers_warnings_over_context() {
906 use crate::model::{Commit, Pr, PrState};
909 let mut w = wt("feature/x", false);
910 w.dirty = Some(true);
911 w.ahead = Some(2);
912 w.behind = Some(0);
913 w.upstream = Some("origin/feature/x".into());
914 w.commit = Some(Commit {
915 hash: "def5678".into(),
916 subject: "WIP work".into(),
917 author: "Bob".into(),
918 timestamp: "2024-02-20T08:00:00Z".into(),
919 });
920 w.pr = Some(Pr {
921 number: 7,
922 state: PrState::Open,
923 title: "Feature x".into(),
924 });
925 let mut a = app(&[("main", true)]);
926 a.worktrees.push(w);
927 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
928 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
929 let text = render_to_text(&a, 100, 30);
930 assert!(text.contains("def5678"));
931 assert!(text.contains("#7 (open)"));
932 assert!(text.contains("data may be lost"));
933 assert!(text.contains("2 unpushed commit(s)"));
934 }
935
936 #[test]
937 fn confirm_remove_missing_skips_status_lines() {
938 use crate::model::Commit;
942 let mut w = wt("feature/gone", false);
943 w.is_missing = true;
944 w.base_ref = Some("main".into());
945 w.commit = Some(Commit {
946 hash: "ccc9999".into(),
947 subject: "Gone branch tip".into(),
948 author: "Dan".into(),
949 timestamp: "2024-04-01T00:00:00Z".into(),
950 });
951 let mut a = app(&[("main", true)]);
952 a.worktrees.push(w);
953 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
954 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
955 let text = render_to_text(&a, 100, 30);
956 assert!(text.contains("directory already deleted"));
957 assert!(!text.contains("ccc9999"));
958 assert!(!text.contains("Gone branch tip"));
959 }
960
961 #[test]
962 fn confirm_remove_shows_spinner_until_loaded() {
963 use crate::model::Commit;
966 let mut w = wt("feature/loading", false);
967 w.commit = Some(Commit {
968 hash: "aaa0000".into(),
969 subject: "Secret subject".into(),
970 author: "Carol".into(),
971 timestamp: "2024-03-01T00:00:00Z".into(),
972 });
973 let mut a = app(&[("main", true)]);
974 a.worktrees.push(w);
975 a.mark_loading(); a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
977 let text = render_to_text(&a, 100, 30);
978 assert!(text.contains("feature/loading"));
980 assert!(!text.contains("Secret subject"));
981 assert!(!text.contains("aaa0000"));
982 }
983
984 #[test]
985 fn failed_dirty_read_shows_absent_marker_not_blank() {
986 let mut unknown = wt("topic", false);
989 unknown.dirty = None; unknown.ahead = Some(0);
991 unknown.behind = Some(0); let mut a = app(&[("main", true)]);
993 a.worktrees.push(unknown);
994 let text = render_to_text(&a, 100, 20);
995 assert!(text.contains('–'));
996 }
997
998 #[test]
999 fn status_bar_hints_are_per_mode() {
1000 let mut a = app(&[("main", true)]);
1001 a.mode = Mode::PrPicker(PrPickerState {
1002 loading: false,
1003 ..Default::default()
1004 });
1005 assert!(render_to_text(&a, 100, 30).contains("checkout"));
1007 }
1008
1009 #[test]
1010 fn detail_pane_shows_recent_commits_and_pr_url() {
1011 use crate::model::{Commit, Pr, PrState};
1012 let mut a = app(&[("main", true)]);
1013 let c = |hash: &str, subject: &str| Commit {
1014 hash: hash.into(),
1015 subject: subject.into(),
1016 author: "x".into(),
1017 timestamp: "2024-01-15T10:30:00Z".into(),
1018 };
1019 a.worktrees[0].recent_commits = vec![c("aaaaaaa", "newest"), c("bbbbbbb", "older")];
1020 a.worktrees[0].pr = Some(Pr {
1021 number: 42,
1022 state: PrState::Open,
1023 title: "Add login".into(),
1024 });
1025 a.worktrees[0].pr_url = Some("https://github.com/o/r/pull/42".into());
1026 let text = render_to_text(&a, 100, 30);
1027 assert!(text.contains("commits:"));
1028 assert!(text.contains("newest"));
1029 assert!(text.contains("older"));
1030 assert!(text.contains("pull/42"));
1031 }
1032
1033 #[test]
1034 fn detail_pane_shows_merged_note() {
1035 let mut a = app(&[("main", true)]);
1038 a.worktrees[0].merge_state = Some(MergeState::Merged {
1039 into: Some("main".into()),
1040 });
1041 a.mark_loaded(a.worktrees[0].path.clone());
1042 let text = render_to_text(&a, 100, 30);
1043 assert!(text.contains("merged into main"));
1044 }
1045
1046 #[test]
1047 fn detail_pane_omits_no_upstream_warning() {
1048 let mut a = app(&[("main", true)]);
1051 a.worktrees[0].merge_state = Some(MergeState::NoUpstreamLocal);
1052 a.mark_loaded(a.worktrees[0].path.clone());
1053 let text = render_to_text(&a, 100, 30);
1054 assert!(!text.contains("local-only"));
1055 }
1056
1057 #[test]
1058 fn status_bar_shows_mode_and_filter() {
1059 let mut a = app(&[("main", true)]);
1060 a.filter = "feat".into();
1061 a.mode = Mode::Filter;
1062 let text = render_to_text(&a, 100, 20);
1063 assert!(text.contains("FILTER"));
1064 assert!(text.contains("/feat"));
1065 }
1066
1067 #[test]
1068 fn list_markers_are_colored_and_gate_on_color_flag() {
1069 let mut a = app(&[("main", true)]);
1070 a.worktrees[0].ahead = Some(1);
1071 a.worktrees[0].behind = Some(2);
1072 a.worktrees[0].dirty = Some(true);
1073 let buf = render_to_buffer(&a, 100, 20);
1075 assert_ne!(cell_fg(&buf, "*"), Color::Reset); assert_ne!(cell_fg(&buf, "M"), Color::Reset); assert_ne!(cell_fg(&buf, "↑"), Color::Reset); assert_ne!(cell_fg(&buf, "↓"), Color::Reset); assert_ne!(cell_fg(&buf, "↑"), cell_fg(&buf, "↓")); a.color = false;
1082 let mono = render_to_buffer(&a, 100, 20);
1083 assert_eq!(cell_fg(&mono, "*"), Color::Reset);
1084 assert_eq!(cell_fg(&mono, "M"), Color::Reset);
1085 assert_eq!(cell_fg(&mono, "↑"), Color::Reset);
1086 }
1087
1088 #[test]
1089 fn custom_palette_recolors_cells() {
1090 let mut a = app(&[("main", true)]);
1091 a.palette.green = Color::Rgb(1, 2, 3);
1094 let buf = render_to_buffer(&a, 100, 20);
1095 assert_eq!(cell_fg(&buf, "*"), Color::Rgb(1, 2, 3));
1096 }
1097
1098 #[test]
1099 fn pr_state_cell_is_colored() {
1100 use crate::model::{Pr, PrState};
1101 let mut a = app(&[("main", true)]);
1102 a.worktrees[0].pr = Some(Pr {
1103 number: 7,
1104 state: PrState::Open,
1105 title: "t".into(),
1106 });
1107 let buf = render_to_buffer(&a, 120, 20);
1108 assert_ne!(cell_fg(&buf, "#"), Color::Reset);
1110 }
1111
1112 #[test]
1113 fn focused_pane_border_differs_from_unfocused() {
1114 let mut a = app(&[("main", true)]);
1115 a.focus = Pane::List;
1116 let list_focused = render_to_buffer(&a, 100, 20);
1117 a.focus = Pane::Detail;
1118 let detail_focused = render_to_buffer(&a, 100, 20);
1119 assert_ne!(list_focused[(0, 0)].fg, detail_focused[(0, 0)].fg);
1121 }
1122
1123 #[test]
1124 fn list_title_shows_count_and_sort() {
1125 let a = app(&[("main", true), ("feature/x", false)]);
1126 let text = render_to_text(&a, 100, 20);
1127 assert!(text.contains("(2)"));
1128 assert!(text.contains("branch ↑"));
1129 }
1130
1131 #[test]
1132 fn filtered_title_shows_visible_over_total() {
1133 let mut a = app(&[("alpha", true), ("beta", false)]);
1134 a.filter_push('a');
1135 a.filter_push('l');
1136 a.filter_push('p'); assert_eq!(a.visible.len(), 1);
1138 let text = render_to_text(&a, 100, 20);
1139 assert!(text.contains("(1/2)"));
1140 }
1141
1142 #[test]
1143 fn empty_filter_shows_no_matches_hint() {
1144 let mut a = app(&[("alpha", true)]);
1145 a.filter_push('z');
1146 a.filter_push('z');
1147 a.filter_push('z');
1148 assert!(a.visible.is_empty());
1149 let text = render_to_text(&a, 100, 20);
1150 assert!(text.contains("no matches for /zzz"));
1151 }
1152
1153 #[test]
1154 fn detail_scrollbar_appears_when_content_overflows() {
1155 use crate::model::Commit;
1156 let mut a = app(&[("main", true)]);
1157 a.worktrees[0].recent_commits = (0..40)
1158 .map(|i| Commit {
1159 hash: format!("h{i:05}"),
1160 subject: "s".into(),
1161 author: "a".into(),
1162 timestamp: "2024-01-15T10:30:00Z".into(),
1163 })
1164 .collect();
1165 let text = render_to_text(&a, 100, 12);
1167 assert!(text.contains('█'));
1168 }
1169
1170 #[test]
1171 fn status_message_colored_by_kind() {
1172 let mut a = app(&[("main", true)]);
1173 a.set_status("ZEBRA", StatusKind::Success);
1174 let ok = render_to_buffer(&a, 100, 20);
1175 a.set_status("ZEBRA", StatusKind::Error);
1176 let err = render_to_buffer(&a, 100, 20);
1177 a.set_status("ZEBRA", StatusKind::Info);
1178 let info = render_to_buffer(&a, 100, 20);
1179 assert_ne!(cell_fg(&ok, "Z"), Color::Reset); assert_ne!(cell_fg(&err, "Z"), Color::Reset); assert_eq!(cell_fg(&info, "Z"), Color::Reset); assert_ne!(cell_fg(&ok, "Z"), cell_fg(&err, "Z")); }
1185
1186 #[test]
1187 fn per_row_job_shows_spinner_and_label() {
1188 use crate::tui::app::JobKey;
1192 let mut a = app(&[("main", true), ("feat/foo", false)]);
1193 a.begin_job(JobKey::Path("/r/feat/foo".into()), "Removing feat/foo");
1194 let text = render_to_text(&a, 120, 20);
1195 assert!(text.contains("Removing feat/foo"));
1196 assert!(text.contains(Glyphs::new(false).spinner_frame(0)));
1198 }
1199
1200 #[test]
1201 fn status_bar_summarizes_running_jobs() {
1202 use crate::tui::app::JobKey;
1203 let mut a = app(&[("main", true)]);
1204 a.begin_job(JobKey::New("feat/a".into()), "Creating feat/a");
1205 let at0 = render_to_text(&a, 120, 20);
1206 assert!(at0.contains("Creating feat/a"));
1207 a.spinner_frame = 1;
1209 let at1 = render_to_text(&a, 120, 20);
1210 assert!(at1.contains(Glyphs::new(false).spinner_frame(1)));
1211 assert_ne!(at0, at1);
1212 }
1213
1214 #[test]
1215 fn exit_blocked_modal_lists_jobs_and_intent() {
1216 use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1217 let mut a = app(&[("main", true)]);
1218 a.begin_job(JobKey::New("feat".into()), "Creating feat");
1219 a.begin_job(
1220 JobKey::Path(std::path::PathBuf::from("/r/x")),
1221 "Initializing 2 submodule(s)",
1222 );
1223 a.mode = crate::tui::app::Mode::ExitBlocked(ExitBlockedState {
1224 intent: ExitIntent::Quit,
1225 });
1226 let text = render_to_text(&a, 100, 20);
1227 assert!(text.contains("finishing up"));
1228 assert!(text.contains("Quitting"));
1229 assert!(text.contains("2 background jobs"));
1230 assert!(text.contains("Creating feat"));
1232 assert!(text.contains("Initializing 2 submodule(s)"));
1233 assert!(text.contains("partial work"));
1235 assert!(text.contains("abandon"));
1236 assert!(text.contains("keep working"));
1237 }
1238
1239 #[test]
1240 fn exit_blocked_modal_shows_switch_destination_and_spinner() {
1241 use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1242 let mut a = app(&[("main", true)]);
1243 a.nerd_fonts = true; a.begin_job(JobKey::New("feat".into()), "Creating feat");
1245 a.spinner_frame = 2;
1246 a.mode = crate::tui::app::Mode::ExitBlocked(ExitBlockedState {
1247 intent: ExitIntent::Switch(std::path::PathBuf::from("/r/feat")),
1248 });
1249 let text = render_to_text(&a, 100, 20);
1250 assert!(text.contains("Switching into"));
1251 assert!(text.contains("/r/feat"));
1252 assert!(text.contains(Glyphs::new(true).spinner_frame(2)));
1254 }
1255
1256 #[test]
1257 fn exit_blocked_modal_caps_long_job_list() {
1258 use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1259 let mut a = app(&[("main", true)]);
1260 for i in 0..12 {
1261 a.begin_job(JobKey::New(format!("j{i}")), format!("Job {i}"));
1262 }
1263 a.mode = crate::tui::app::Mode::ExitBlocked(ExitBlockedState {
1264 intent: ExitIntent::Quit,
1265 });
1266 let text = render_to_text(&a, 100, 30);
1267 assert!(text.contains("more")); }
1269}