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, InitSubmodulesState, Mode, Pane,
23 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::ConfirmQuit { jobs } => modals::render_confirm_quit(app, *jobs, 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::ConfirmQuit { .. } => "QUIT",
248 Mode::Help => "HELP",
249 }
250}
251
252const LIST_BAR: [KeyAction; 8] = [
257 KeyAction::Switch,
258 KeyAction::New,
259 KeyAction::Remove,
260 KeyAction::PrCheckout,
261 KeyAction::Checkout,
262 KeyAction::Filter,
263 KeyAction::Help,
264 KeyAction::Quit,
265];
266
267fn mode_hints(app: &App) -> Vec<(String, String)> {
271 match &app.mode {
272 Mode::List => LIST_BAR
273 .iter()
274 .filter_map(|&action| {
275 app.keymap
276 .display_for(action)
277 .map(|keys| (keys, action.label().to_string()))
278 })
279 .collect(),
280 Mode::Filter => hint_pairs(hints::filter_hints()),
281 Mode::Create(_) => hint_pairs(hints::create_hints()),
282 Mode::PrPicker(_) => hint_pairs(hints::pr_picker_hints()),
283 Mode::PrCompose(_) => hint_pairs(hints::compose_edit_hints()),
284 Mode::Checkout(_) => hint_pairs(hints::checkout_hints()),
285 Mode::ConfirmRemove(_) => hint_pairs(hints::confirm_hints()),
286 Mode::ConfirmCreate(_) => hint_pairs(hints::confirm_create_hints()),
287 Mode::ConfirmDeleteBranch { .. } => hint_pairs(hints::confirm_delete_branch_hints()),
288 Mode::ConfirmStaleBase(_) => hint_pairs(hints::confirm_stale_base_hints()),
289 Mode::ConfirmInitSubmodules(_) => hint_pairs(hints::confirm_init_submodules_hints()),
290 Mode::ConfirmQuit { .. } => hint_pairs(hints::confirm_quit_hints()),
291 Mode::Help => hint_pairs(hints::help_hints()),
292 }
293}
294
295fn hint_pairs(table: &[Hint]) -> Vec<(String, String)> {
297 table
298 .iter()
299 .map(|h| (h.key.to_string(), h.label.to_string()))
300 .collect()
301}
302
303fn centered(area: Rect, width: u16, height: u16) -> Rect {
305 let w = width.min(area.width);
306 let h = height.min(area.height);
307 Rect {
308 x: area.x + (area.width.saturating_sub(w)) / 2,
309 y: area.y + (area.height.saturating_sub(h)) / 2,
310 width: w,
311 height: h,
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::keys::KeyChord;
319 use crate::tui::app::testutil::{app, wt};
320 use crate::tui::app::{
321 ComposeField, CreateState, PrComposeState, PrItem, PrPickerState, StatusKind,
322 };
323 use crossterm::event::KeyCode;
324 use ratatui::Terminal;
325 use ratatui::backend::TestBackend;
326 use ratatui::buffer::Buffer;
327 use ratatui::style::Color;
328
329 fn render_to_text(app: &App, w: u16, h: u16) -> String {
331 buffer_text(&render_to_buffer(app, w, h))
332 }
333
334 fn render_to_buffer(app: &App, w: u16, h: u16) -> Buffer {
336 let backend = TestBackend::new(w, h);
337 let mut terminal = Terminal::new(backend).unwrap();
338 terminal.draw(|f| render(app, f)).unwrap();
339 terminal.backend().buffer().clone()
340 }
341
342 fn cell_fg(buffer: &Buffer, symbol: &str) -> Color {
344 let area = buffer.area;
345 for y in 0..area.height {
346 for x in 0..area.width {
347 if buffer[(x, y)].symbol() == symbol {
348 return buffer[(x, y)].fg;
349 }
350 }
351 }
352 panic!("symbol {symbol:?} not found");
353 }
354
355 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
356 let area = buffer.area;
357 let mut out = String::new();
358 for y in 0..area.height {
359 for x in 0..area.width {
360 out.push_str(buffer[(x, y)].symbol());
361 }
362 out.push('\n');
363 }
364 out
365 }
366
367 #[test]
368 fn renders_list_and_detail() {
369 let a = app(&[("main", true), ("feature/x", false)]);
370 let text = render_to_text(&a, 100, 20);
371 assert!(text.contains("worktrees"));
372 assert!(text.contains("detail"));
373 assert!(text.contains("main"));
374 assert!(text.contains("feature/x"));
375 assert!(text.contains('*'));
377 }
378
379 #[test]
380 fn list_shows_branch_rows_with_marker_and_count() {
381 use crate::tui::app::testutil::branch_row;
382 let mut a = app(&[("main", true)]);
383 let mut br = branch_row("feature/lonely");
384 br.ahead = Some(3);
385 br.behind = Some(1);
386 a.worktrees.push(br);
387 a.apply_filter(String::new());
388 a.mark_loaded(a.worktrees[1].path.clone());
389 let text = render_to_text(&a, 100, 20);
390 assert!(text.contains("feature/lonely"));
391 assert!(text.contains('○')); assert!(text.contains("↑3"));
393 assert!(text.contains("↓1"));
394 assert!(text.contains("branches"));
396 }
397
398 #[test]
399 fn detail_pane_for_branch_row_is_pathless() {
400 use crate::tui::app::testutil::branch_row;
401 let mut a = app(&[("main", true)]);
402 let mut br = branch_row("topic");
403 br.ahead = Some(2);
404 br.behind = Some(0);
405 br.base_ref = Some("main".into());
406 a.worktrees.push(br);
407 a.apply_filter(String::new());
408 a.mark_loaded(a.worktrees[1].path.clone());
409 a.selected = 1; let text = render_to_text(&a, 100, 20);
411 assert!(text.contains("no worktree"));
412 assert!(text.contains("vs base"));
413 assert!(text.contains("base:"));
414 assert!(!text.contains("branch://"));
416 }
417
418 #[test]
419 fn confirm_create_dialog_renders() {
420 use crate::tui::app::testutil::branch_row;
421 let mut a = app(&[("main", true)]);
422 let mut br = branch_row("topic");
423 br.base_ref = Some("main".into());
424 br.ahead = Some(2);
425 br.behind = Some(0);
426 a.worktrees.push(br);
427 a.apply_filter(String::new());
428 a.mark_loaded(a.worktrees[1].path.clone());
429 a.mode = Mode::ConfirmCreate(1);
430 let text = render_to_text(&a, 100, 30);
431 assert!(text.contains("create worktree"));
432 assert!(text.contains("topic"));
433 assert!(text.contains("switch into it"));
434 assert!(text.contains("[y/N]"));
435 }
436
437 #[test]
438 fn renders_confirm_init_submodules_modal() {
439 let mut a = app(&[("main", true)]);
440 a.mode = Mode::ConfirmInitSubmodules(InitSubmodulesState {
441 dir: std::path::PathBuf::from("/wt/feature"),
442 branch: "feature".into(),
443 count: 3,
444 });
445 let text = render_to_text(&a, 100, 30);
446 assert!(text.contains("initialize submodules"));
447 assert!(text.contains("feature"));
448 assert!(text.contains("3 uninitialized"));
449 assert!(text.contains("[Y/n]"));
451 }
452
453 #[test]
454 fn narrow_terminal_hides_detail() {
455 let a = app(&[("main", true)]);
456 let mut a = a;
457 a.size = (50, 20);
458 let text = render_to_text(&a, 50, 20);
459 assert!(text.contains("worktrees"));
460 assert!(!text.contains("detail")); }
462
463 #[test]
464 fn pending_rows_show_spinner() {
465 let mut a = app(&[("main", true)]);
466 a.mark_loading(); let text = render_to_text(&a, 100, 20);
468 assert!(text.contains('…'));
469 }
470
471 #[test]
472 fn loaded_no_upstream_shows_absent_marker() {
473 let a = app(&[("main", true)]);
475 let text = render_to_text(&a, 100, 20);
476 assert!(text.contains('–'));
477 }
478
479 #[test]
480 fn help_overlay_renders() {
481 let mut a = app(&[("main", true)]);
482 a.mode = Mode::Help;
483 let text = render_to_text(&a, 100, 40);
484 assert!(text.contains("help"));
485 assert!(text.contains("navigate"));
486 assert!(text.contains("quit"));
487 assert!(text.contains("checkout"));
489 }
490
491 #[test]
492 fn help_overlay_documents_every_action() {
493 let mut a = app(&[("main", true)]);
496 a.mode = Mode::Help;
497 let text = render_to_text(&a, 100, 40);
498 for action in KeyAction::ALL {
499 assert!(
500 text.contains(action.label()),
501 "help overlay missing label for {action:?}: {:?}",
502 action.label()
503 );
504 }
505 }
506
507 #[test]
508 fn list_bar_includes_checkout() {
509 let a = app(&[("main", true)]);
512 let text = render_to_text(&a, 100, 30);
513 assert!(text.contains("checkout"));
514 assert!(text.contains(" c "));
515 }
516
517 #[test]
518 fn list_bar_follows_rebind() {
519 let mut a = app(&[("main", true)]);
522 a.keymap
523 .rebind(KeyAction::Checkout, KeyChord::key(KeyCode::Char('x')));
524 let text = render_to_text(&a, 100, 30);
525 assert!(text.contains(" x "));
526 }
527
528 #[test]
529 fn create_overlay_shows_fields_and_error() {
530 let mut a = app(&[("main", true)]);
531 a.mode = Mode::Create(CreateState {
532 branch: "feat".into(),
533 error: Some("branch name is required".into()),
534 ..Default::default()
535 });
536 let text = render_to_text(&a, 100, 30);
537 assert!(text.contains("new worktree"));
538 assert!(text.contains("feat"));
539 assert!(text.contains("required"));
540 }
541
542 #[test]
543 fn create_overlay_shows_open_branch_options() {
544 use crate::tui::options::OptionList;
545 let mut a = app(&[("main", true)]);
546 let mut options = OptionList::new(vec![
547 "main".into(),
548 "origin/main".into(),
549 "origin/dev".into(),
550 ]);
551 options.open();
552 a.mode = Mode::Create(CreateState {
553 options,
554 ..Default::default()
555 });
556 let text = render_to_text(&a, 100, 30);
557 assert!(text.contains("origin/main"));
559 assert!(text.contains("origin/dev"));
560 assert!(text.contains('▌')); assert!(text.contains("options")); }
563
564 #[test]
565 fn checkout_overlay_renders_branches_and_target() {
566 use crate::tui::app::CheckoutState;
567 use crate::tui::options::OptionList;
568 let mut a = app(&[("main", true), ("feature/x", false)]);
569 let mut options = OptionList::new(vec!["main".into(), "feature/x".into()]);
570 options.open();
571 a.mode = Mode::Checkout(CheckoutState {
572 worktree_index: 0,
573 query: "feat".into(),
574 options,
575 ..Default::default()
576 });
577 let text = render_to_text(&a, 100, 30);
578 assert!(text.contains("checkout branch"));
579 assert!(text.contains("feature/x"));
580 assert!(text.contains("branches")); }
582
583 #[test]
584 fn pr_compose_model_field_shows_options_dropdown() {
585 let mut a = app(&[("main", true)]);
586 a.mode = Mode::PrCompose(PrComposeState {
587 field: ComposeField::Model,
588 branch: "feat".into(),
589 trunk: "main".into(),
590 action_label: "create".into(),
591 ..Default::default()
592 });
593 let text = render_to_text(&a, 100, 30);
594 assert!(text.contains("Opus 4.8"));
596 assert!(text.contains("Sonnet 4.6"));
597 assert!(text.contains("Haiku 4.5"));
598 assert!(text.contains("> model:"));
599 }
600
601 #[test]
602 fn pr_compose_effort_field_shows_options_dropdown() {
603 let mut a = app(&[("main", true)]);
604 a.mode = Mode::PrCompose(PrComposeState {
605 field: ComposeField::Effort,
606 branch: "feat".into(),
607 trunk: "main".into(),
608 action_label: "create".into(),
609 ..Default::default()
610 });
611 let text = render_to_text(&a, 100, 30);
612 assert!(text.contains("low"));
613 assert!(text.contains("medium"));
614 assert!(text.contains("high"));
615 }
616
617 #[test]
618 fn pr_compose_overlay_shows_header_fields_and_hints() {
619 let mut a = app(&[("main", true)]);
620 a.mode = Mode::PrCompose(PrComposeState {
621 field: ComposeField::Body,
622 title: "Add login".into(),
623 body: "Summary line".into(),
624 draft: true,
625 branch: "feat/login".into(),
626 trunk: "main".into(),
627 action_label: "create".into(),
628 error: Some("boom".into()),
629 ..Default::default()
630 });
631 let text = render_to_text(&a, 100, 30);
632 assert!(text.contains("open pull request"));
633 assert!(text.contains("feat/login"));
634 assert!(text.contains("Add login"));
635 assert!(text.contains("Summary line"));
636 assert!(text.contains("[create]"));
637 assert!(text.contains("draft [x]"));
638 assert!(text.contains("boom"));
639 assert!(text.contains("Ctrl-S"));
640 assert!(text.contains("Ctrl-A"));
642 assert!(text.contains("model:"));
643 assert!(text.contains("Sonnet 4.6")); assert!(text.contains("effort:"));
645 }
646
647 #[test]
648 fn pr_compose_shows_selected_model_and_effort() {
649 let mut a = app(&[("main", true)]);
650 a.mode = Mode::PrCompose(PrComposeState {
651 title: "T".into(),
652 branch: "feat".into(),
653 trunk: "main".into(),
654 action_label: "create".into(),
655 model: crate::agent::AgentModel::Opus,
656 effort: crate::agent::Effort::High,
657 ..Default::default()
658 });
659 let text = render_to_text(&a, 100, 30);
660 assert!(text.contains("Opus 4.8"));
661 assert!(text.contains("high"));
662 }
663
664 #[test]
665 fn pr_compose_submitting_shows_status() {
666 let mut a = app(&[("main", true)]);
667 a.mode = Mode::PrCompose(PrComposeState {
668 title: "T".into(),
669 branch: "feat".into(),
670 trunk: "main".into(),
671 action_label: "update #5".into(),
672 submitting: true,
673 ..Default::default()
674 });
675 let text = render_to_text(&a, 100, 30);
676 assert!(text.contains("working"));
677 assert!(text.contains("[update #5]"));
678 }
679
680 #[test]
681 fn pr_picker_states() {
682 let mut a = app(&[("main", true)]);
683 a.mode = Mode::PrPicker(PrPickerState {
684 loading: true,
685 ..Default::default()
686 });
687 assert!(render_to_text(&a, 100, 30).contains("loading"));
688
689 a.mode = Mode::PrPicker(PrPickerState {
690 loading: false,
691 prs: vec![
692 PrItem {
693 number: 42,
694 title: "Add login".into(),
695 author: "alice".into(),
696 state: "open".into(),
697 created_at: "2020-01-01T00:00:00Z".into(),
698 },
699 PrItem {
701 number: 7,
702 title: "No date".into(),
703 author: "bob".into(),
704 state: "open".into(),
705 created_at: String::new(),
706 },
707 ],
708 ..Default::default()
709 });
710 let text = render_to_text(&a, 100, 30);
711 assert!(text.contains("#42"));
712 assert!(text.contains("Add login"));
713 assert!(text.contains("ago"));
716
717 a.mode = Mode::PrPicker(PrPickerState {
718 error: Some("gh unavailable".into()),
719 ..Default::default()
720 });
721 assert!(render_to_text(&a, 100, 30).contains("gh auth login"));
722 }
723
724 #[test]
725 fn confirm_remove_overlay_shows_safety() {
726 let mut dirty = wt("topic", false);
727 dirty.dirty = Some(true);
728 let mut a = app(&[("main", true)]);
729 a.worktrees.push(dirty);
730 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
731 let text = render_to_text(&a, 100, 30);
732 assert!(text.contains("confirm remove"));
733 assert!(text.contains("data may be lost"));
734 assert!(text.contains("[y/N]"));
735 }
736
737 #[test]
738 fn confirm_remove_flags_no_upstream_as_unpushed() {
739 let mut clean = wt("topic", false);
742 clean.dirty = Some(false);
743 clean.has_untracked = Some(false);
744 clean.ahead = None; clean.merge_state = Some(MergeState::NoUpstreamLocal);
746 let mut a = app(&[("main", true)]);
747 a.worktrees.push(clean);
748 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
749 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
750 let text = render_to_text(&a, 100, 30);
751 assert!(text.contains("no upstream"));
752 assert!(text.contains("local-only"));
753 assert!(!text.contains("data may be lost")); }
755
756 #[test]
757 fn confirm_remove_merged_into_base_is_safe() {
758 let mut w = wt("feature/done", false);
760 w.dirty = Some(false);
761 w.has_untracked = Some(false);
762 w.ahead = None;
763 w.merge_state = Some(MergeState::Merged {
764 into: Some("main".into()),
765 });
766 let mut a = app(&[("main", true)]);
767 a.worktrees.push(w);
768 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
769 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
770 let text = render_to_text(&a, 100, 30);
771 assert!(text.contains("merged into main"));
772 assert!(text.contains("safe to delete"));
773 assert!(!text.contains("unpushed"));
776 }
777
778 #[test]
779 fn confirm_remove_merged_via_pr_is_safe() {
780 let mut w = wt("feature/squashed", false);
782 w.dirty = Some(false);
783 w.has_untracked = Some(false);
784 w.ahead = None;
785 w.merge_state = Some(MergeState::Merged { into: None });
786 let mut a = app(&[("main", true)]);
787 a.worktrees.push(w);
788 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
789 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
790 let text = render_to_text(&a, 100, 30);
791 assert!(text.contains("merged via PR"));
792 assert!(!text.contains("unpushed"));
793 }
794
795 #[test]
796 fn confirm_remove_upstream_gone_is_soft() {
797 let mut w = wt("feature/pushed", false);
799 w.dirty = Some(false);
800 w.has_untracked = Some(false);
801 w.ahead = None;
802 w.merge_state = Some(MergeState::UpstreamGone);
803 let mut a = app(&[("main", true)]);
804 a.worktrees.push(w);
805 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
806 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
807 let text = render_to_text(&a, 100, 30);
808 assert!(text.contains("upstream branch deleted"));
809 assert!(text.contains("likely merged"));
810 assert!(!text.contains("unpushed work"));
811 }
812
813 #[test]
814 fn confirm_remove_merged_but_dirty_still_warns() {
815 let mut w = wt("feature/dirty-merged", false);
818 w.dirty = Some(true);
819 w.ahead = None;
820 w.merge_state = Some(MergeState::Merged {
821 into: Some("main".into()),
822 });
823 let mut a = app(&[("main", true)]);
824 a.worktrees.push(w);
825 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
826 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
827 let text = render_to_text(&a, 100, 30);
828 assert!(text.contains("safe to delete"));
829 assert!(text.contains("data may be lost"));
830 }
831
832 #[test]
833 fn confirm_remove_tracked_ahead_still_warns() {
834 let mut w = wt("feature/ahead", false);
836 w.dirty = Some(false);
837 w.ahead = Some(2);
838 w.upstream = Some("origin/feature/ahead".into());
839 w.merge_state = Some(MergeState::Tracked);
840 let mut a = app(&[("main", true)]);
841 a.worktrees.push(w);
842 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
843 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
844 let text = render_to_text(&a, 100, 30);
845 assert!(text.contains("2 unpushed commit(s)"));
846 }
847
848 #[test]
849 fn confirm_remove_honors_remove_untracked_blocks() {
850 let mut wt_un = wt("topic", false);
853 wt_un.dirty = Some(false);
854 wt_un.has_untracked = Some(true);
855 wt_un.ahead = Some(0);
856 let mut a = app(&[("main", true)]);
857 assert!(a.show_untracked && !a.remove_untracked_blocks);
858 a.worktrees.push(wt_un);
859 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
860 assert!(!render_to_text(&a, 100, 30).contains("data may be lost"));
861 }
862
863 #[test]
864 fn confirm_remove_shows_glanceable_context() {
865 use crate::model::{Commit, Pr, PrState};
868 let mut w = wt("feature/login", false);
869 w.dirty = Some(false);
870 w.has_untracked = Some(false);
871 w.ahead = Some(0);
872 w.behind = Some(0);
873 w.upstream = Some("origin/feature/login".into());
874 w.base_ref = Some("main".into());
875 w.commit = Some(Commit {
876 hash: "abc1234".into(),
877 subject: "Add login page".into(),
878 author: "Alice".into(),
879 timestamp: "2024-01-15T10:30:00Z".into(),
880 });
881 w.pr = Some(Pr {
882 number: 42,
883 state: PrState::Merged,
884 title: "Add login page".into(),
885 });
886 let mut a = app(&[("main", true)]);
887 a.worktrees.push(w);
888 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
889 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
890 let text = render_to_text(&a, 100, 30);
891 assert!(text.contains("origin/feature/login"));
892 assert!(text.contains("base:"));
893 assert!(text.contains("abc1234"));
894 assert!(text.contains("Add login page"));
895 assert!(text.contains("#42 (merged)"));
896 assert!(text.contains("↑0"));
897 assert!(!text.contains("data may be lost"));
898 assert!(text.contains("[y/N]"));
899 }
900
901 #[test]
902 fn confirm_remove_layers_warnings_over_context() {
903 use crate::model::{Commit, Pr, PrState};
906 let mut w = wt("feature/x", false);
907 w.dirty = Some(true);
908 w.ahead = Some(2);
909 w.behind = Some(0);
910 w.upstream = Some("origin/feature/x".into());
911 w.commit = Some(Commit {
912 hash: "def5678".into(),
913 subject: "WIP work".into(),
914 author: "Bob".into(),
915 timestamp: "2024-02-20T08:00:00Z".into(),
916 });
917 w.pr = Some(Pr {
918 number: 7,
919 state: PrState::Open,
920 title: "Feature x".into(),
921 });
922 let mut a = app(&[("main", true)]);
923 a.worktrees.push(w);
924 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
925 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
926 let text = render_to_text(&a, 100, 30);
927 assert!(text.contains("def5678"));
928 assert!(text.contains("#7 (open)"));
929 assert!(text.contains("data may be lost"));
930 assert!(text.contains("2 unpushed commit(s)"));
931 }
932
933 #[test]
934 fn confirm_remove_missing_skips_status_lines() {
935 use crate::model::Commit;
939 let mut w = wt("feature/gone", false);
940 w.is_missing = true;
941 w.base_ref = Some("main".into());
942 w.commit = Some(Commit {
943 hash: "ccc9999".into(),
944 subject: "Gone branch tip".into(),
945 author: "Dan".into(),
946 timestamp: "2024-04-01T00:00:00Z".into(),
947 });
948 let mut a = app(&[("main", true)]);
949 a.worktrees.push(w);
950 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
951 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
952 let text = render_to_text(&a, 100, 30);
953 assert!(text.contains("directory already deleted"));
954 assert!(!text.contains("ccc9999"));
955 assert!(!text.contains("Gone branch tip"));
956 }
957
958 #[test]
959 fn confirm_remove_shows_spinner_until_loaded() {
960 use crate::model::Commit;
963 let mut w = wt("feature/loading", false);
964 w.commit = Some(Commit {
965 hash: "aaa0000".into(),
966 subject: "Secret subject".into(),
967 author: "Carol".into(),
968 timestamp: "2024-03-01T00:00:00Z".into(),
969 });
970 let mut a = app(&[("main", true)]);
971 a.worktrees.push(w);
972 a.mark_loading(); a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
974 let text = render_to_text(&a, 100, 30);
975 assert!(text.contains("feature/loading"));
977 assert!(!text.contains("Secret subject"));
978 assert!(!text.contains("aaa0000"));
979 }
980
981 #[test]
982 fn failed_dirty_read_shows_absent_marker_not_blank() {
983 let mut unknown = wt("topic", false);
986 unknown.dirty = None; unknown.ahead = Some(0);
988 unknown.behind = Some(0); let mut a = app(&[("main", true)]);
990 a.worktrees.push(unknown);
991 let text = render_to_text(&a, 100, 20);
992 assert!(text.contains('–'));
993 }
994
995 #[test]
996 fn status_bar_hints_are_per_mode() {
997 let mut a = app(&[("main", true)]);
998 a.mode = Mode::PrPicker(PrPickerState {
999 loading: false,
1000 ..Default::default()
1001 });
1002 assert!(render_to_text(&a, 100, 30).contains("checkout"));
1004 }
1005
1006 #[test]
1007 fn detail_pane_shows_recent_commits_and_pr_url() {
1008 use crate::model::{Commit, Pr, PrState};
1009 let mut a = app(&[("main", true)]);
1010 let c = |hash: &str, subject: &str| Commit {
1011 hash: hash.into(),
1012 subject: subject.into(),
1013 author: "x".into(),
1014 timestamp: "2024-01-15T10:30:00Z".into(),
1015 };
1016 a.worktrees[0].recent_commits = vec![c("aaaaaaa", "newest"), c("bbbbbbb", "older")];
1017 a.worktrees[0].pr = Some(Pr {
1018 number: 42,
1019 state: PrState::Open,
1020 title: "Add login".into(),
1021 });
1022 a.worktrees[0].pr_url = Some("https://github.com/o/r/pull/42".into());
1023 let text = render_to_text(&a, 100, 30);
1024 assert!(text.contains("commits:"));
1025 assert!(text.contains("newest"));
1026 assert!(text.contains("older"));
1027 assert!(text.contains("pull/42"));
1028 }
1029
1030 #[test]
1031 fn detail_pane_shows_merged_note() {
1032 let mut a = app(&[("main", true)]);
1035 a.worktrees[0].merge_state = Some(MergeState::Merged {
1036 into: Some("main".into()),
1037 });
1038 a.mark_loaded(a.worktrees[0].path.clone());
1039 let text = render_to_text(&a, 100, 30);
1040 assert!(text.contains("merged into main"));
1041 }
1042
1043 #[test]
1044 fn detail_pane_omits_no_upstream_warning() {
1045 let mut a = app(&[("main", true)]);
1048 a.worktrees[0].merge_state = Some(MergeState::NoUpstreamLocal);
1049 a.mark_loaded(a.worktrees[0].path.clone());
1050 let text = render_to_text(&a, 100, 30);
1051 assert!(!text.contains("local-only"));
1052 }
1053
1054 #[test]
1055 fn status_bar_shows_mode_and_filter() {
1056 let mut a = app(&[("main", true)]);
1057 a.filter = "feat".into();
1058 a.mode = Mode::Filter;
1059 let text = render_to_text(&a, 100, 20);
1060 assert!(text.contains("FILTER"));
1061 assert!(text.contains("/feat"));
1062 }
1063
1064 #[test]
1065 fn list_markers_are_colored_and_gate_on_color_flag() {
1066 let mut a = app(&[("main", true)]);
1067 a.worktrees[0].ahead = Some(1);
1068 a.worktrees[0].behind = Some(2);
1069 a.worktrees[0].dirty = Some(true);
1070 let buf = render_to_buffer(&a, 100, 20);
1072 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;
1079 let mono = render_to_buffer(&a, 100, 20);
1080 assert_eq!(cell_fg(&mono, "*"), Color::Reset);
1081 assert_eq!(cell_fg(&mono, "M"), Color::Reset);
1082 assert_eq!(cell_fg(&mono, "↑"), Color::Reset);
1083 }
1084
1085 #[test]
1086 fn custom_palette_recolors_cells() {
1087 let mut a = app(&[("main", true)]);
1088 a.palette.green = Color::Rgb(1, 2, 3);
1091 let buf = render_to_buffer(&a, 100, 20);
1092 assert_eq!(cell_fg(&buf, "*"), Color::Rgb(1, 2, 3));
1093 }
1094
1095 #[test]
1096 fn pr_state_cell_is_colored() {
1097 use crate::model::{Pr, PrState};
1098 let mut a = app(&[("main", true)]);
1099 a.worktrees[0].pr = Some(Pr {
1100 number: 7,
1101 state: PrState::Open,
1102 title: "t".into(),
1103 });
1104 let buf = render_to_buffer(&a, 120, 20);
1105 assert_ne!(cell_fg(&buf, "#"), Color::Reset);
1107 }
1108
1109 #[test]
1110 fn focused_pane_border_differs_from_unfocused() {
1111 let mut a = app(&[("main", true)]);
1112 a.focus = Pane::List;
1113 let list_focused = render_to_buffer(&a, 100, 20);
1114 a.focus = Pane::Detail;
1115 let detail_focused = render_to_buffer(&a, 100, 20);
1116 assert_ne!(list_focused[(0, 0)].fg, detail_focused[(0, 0)].fg);
1118 }
1119
1120 #[test]
1121 fn list_title_shows_count_and_sort() {
1122 let a = app(&[("main", true), ("feature/x", false)]);
1123 let text = render_to_text(&a, 100, 20);
1124 assert!(text.contains("(2)"));
1125 assert!(text.contains("branch ↑"));
1126 }
1127
1128 #[test]
1129 fn filtered_title_shows_visible_over_total() {
1130 let mut a = app(&[("alpha", true), ("beta", false)]);
1131 a.filter_push('a');
1132 a.filter_push('l');
1133 a.filter_push('p'); assert_eq!(a.visible.len(), 1);
1135 let text = render_to_text(&a, 100, 20);
1136 assert!(text.contains("(1/2)"));
1137 }
1138
1139 #[test]
1140 fn empty_filter_shows_no_matches_hint() {
1141 let mut a = app(&[("alpha", true)]);
1142 a.filter_push('z');
1143 a.filter_push('z');
1144 a.filter_push('z');
1145 assert!(a.visible.is_empty());
1146 let text = render_to_text(&a, 100, 20);
1147 assert!(text.contains("no matches for /zzz"));
1148 }
1149
1150 #[test]
1151 fn detail_scrollbar_appears_when_content_overflows() {
1152 use crate::model::Commit;
1153 let mut a = app(&[("main", true)]);
1154 a.worktrees[0].recent_commits = (0..40)
1155 .map(|i| Commit {
1156 hash: format!("h{i:05}"),
1157 subject: "s".into(),
1158 author: "a".into(),
1159 timestamp: "2024-01-15T10:30:00Z".into(),
1160 })
1161 .collect();
1162 let text = render_to_text(&a, 100, 12);
1164 assert!(text.contains('█'));
1165 }
1166
1167 #[test]
1168 fn status_message_colored_by_kind() {
1169 let mut a = app(&[("main", true)]);
1170 a.set_status("ZEBRA", StatusKind::Success);
1171 let ok = render_to_buffer(&a, 100, 20);
1172 a.set_status("ZEBRA", StatusKind::Error);
1173 let err = render_to_buffer(&a, 100, 20);
1174 a.set_status("ZEBRA", StatusKind::Info);
1175 let info = render_to_buffer(&a, 100, 20);
1176 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")); }
1182
1183 #[test]
1184 fn per_row_job_shows_spinner_and_label() {
1185 use crate::tui::app::JobKey;
1189 let mut a = app(&[("main", true), ("feat/foo", false)]);
1190 a.begin_job(JobKey::Path("/r/feat/foo".into()), "Removing feat/foo");
1191 let text = render_to_text(&a, 120, 20);
1192 assert!(text.contains("Removing feat/foo"));
1193 assert!(text.contains(Glyphs::new(false).spinner_frame(0)));
1195 }
1196
1197 #[test]
1198 fn status_bar_summarizes_running_jobs() {
1199 use crate::tui::app::JobKey;
1200 let mut a = app(&[("main", true)]);
1201 a.begin_job(JobKey::New("feat/a".into()), "Creating feat/a");
1202 let at0 = render_to_text(&a, 120, 20);
1203 assert!(at0.contains("Creating feat/a"));
1204 a.spinner_frame = 1;
1206 let at1 = render_to_text(&a, 120, 20);
1207 assert!(at1.contains(Glyphs::new(false).spinner_frame(1)));
1208 assert_ne!(at0, at1);
1209 }
1210
1211 #[test]
1212 fn confirm_quit_modal_renders_over_mode() {
1213 let mut a = app(&[("main", true)]);
1214 a.mode = crate::tui::app::Mode::ConfirmQuit { jobs: 2 };
1215 let text = render_to_text(&a, 100, 20);
1216 assert!(text.contains("confirm quit"));
1217 assert!(text.contains("2 background jobs"));
1218 assert!(text.contains("Quit anyway?"));
1219 }
1220}