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, BusyState, CheckoutState, ComposeField, CreateState, CreateStep, InitSubmodulesState,
23 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 _ => {}
68 }
69
70 if let Some(busy) = &app.busy {
73 render_busy(app, busy, frame, area);
74 }
75}
76
77fn render_busy(app: &App, busy: &BusyState, frame: &mut Frame, area: Rect) {
81 let theme = Theme::with_palette(app.color, app.palette);
82 let glyphs = Glyphs::new(app.nerd_fonts);
83 let line = Line::from(vec![
84 Span::styled(
85 glyphs.spinner_frame(busy.frame).to_string(),
86 theme.spinner(),
87 ),
88 Span::raw(" "),
89 Span::styled(format!("{}…", busy.label), theme.label()),
90 ]);
91 let width = (busy.label.chars().count() as u16 + 8).clamp(20, area.width);
94 let rect = centered(area, width, 3);
95 frame.render_widget(Clear, rect);
96 frame.render_widget(
97 Paragraph::new(line)
98 .block(Block::bordered().title(Span::styled("working", theme.title(true)))),
99 rect,
100 );
101}
102
103fn ahead_behind_spans(
106 worktree: &Worktree,
107 theme: &Theme,
108 loaded: bool,
109 glyphs: &Glyphs,
110) -> Vec<Span<'static>> {
111 if !loaded {
112 return vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())];
113 }
114 match (worktree.ahead, worktree.behind) {
115 (Some(ahead), Some(behind)) => vec![
116 Span::styled(format!("↑{ahead}"), theme.ahead(ahead)),
117 Span::raw(" "),
118 Span::styled(format!("↓{behind}"), theme.behind(behind)),
119 ],
120 _ => vec![Span::styled(glyphs.absent().to_string(), theme.absent())],
121 }
122}
123
124fn commit_spans(
126 worktree: &Worktree,
127 theme: &Theme,
128 loaded: bool,
129 glyphs: &Glyphs,
130 now: i64,
131) -> Vec<Span<'static>> {
132 match (&worktree.commit, loaded) {
133 (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
134 (Some(c), true) => {
135 let rel = parse_iso8601(&c.timestamp)
136 .map(|u| relative(now, u))
137 .unwrap_or_default();
138 vec![
139 Span::styled(c.hash.clone(), theme.commit_hash()),
140 Span::raw(" "),
141 Span::raw(c.subject.clone()),
142 Span::raw(" "),
143 Span::styled(format!("({rel})"), theme.time()),
144 ]
145 }
146 (None, true) if !worktree.is_missing => {
148 vec![Span::styled(glyphs.absent().to_string(), theme.absent())]
149 }
150 (None, true) => Vec::new(),
151 }
152}
153
154fn pr_spans(
156 worktree: &Worktree,
157 theme: &Theme,
158 loaded: bool,
159 glyphs: &Glyphs,
160) -> Vec<Span<'static>> {
161 match (&worktree.pr, loaded) {
162 (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
163 (Some(pr), true) => vec![Span::styled(
164 format!("#{} ({})", pr.number, pr.state.as_str()),
165 theme.pr_state(pr.state),
166 )],
167 (None, true) => Vec::new(),
168 }
169}
170
171fn dirty_label_span(worktree: &Worktree, theme: &Theme) -> Span<'static> {
173 match (worktree.dirty, worktree.has_untracked) {
174 (Some(true), _) => Span::styled("modified", theme.dirty()),
175 (_, Some(true)) => Span::styled("untracked", theme.untracked()),
176 (Some(false), _) => Span::styled("clean", theme.hint_label()),
177 _ => Span::raw(""),
178 }
179}
180
181fn merge_state_note(
189 worktree: &Worktree,
190 theme: &Theme,
191 include_warnings: bool,
192) -> Option<Line<'static>> {
193 match &worktree.merge_state {
194 Some(MergeState::Merged { into: Some(base) }) => Some(Line::from(Span::styled(
195 format!("(merged into {base} — safe to delete)"),
196 theme.success(),
197 ))),
198 Some(MergeState::Merged { into: None }) => Some(Line::from(Span::styled(
199 "(merged via PR — safe to delete)",
200 theme.success(),
201 ))),
202 Some(MergeState::UpstreamGone) => Some(Line::from(Span::styled(
203 "(upstream branch deleted — likely merged)",
204 theme.label(),
205 ))),
206 Some(MergeState::NoUpstreamLocal) if include_warnings => Some(Line::from(Span::styled(
207 "(no upstream — local-only, unpushed work)",
208 theme.warning(),
209 ))),
210 Some(MergeState::Tracked) | None if include_warnings => match worktree.ahead {
211 Some(ahead) if ahead > 0 => Some(Line::from(Span::styled(
212 format!("({ahead} unpushed commit(s))"),
213 theme.warning(),
214 ))),
215 _ => None,
216 },
217 _ => None,
218 }
219}
220
221fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) {
223 let theme = Theme::with_palette(app.color, app.palette);
224 let mut spans = vec![Span::styled(
225 format!(" {} ", mode_label(&app.mode)),
226 theme.mode_chip(&app.mode),
227 )];
228 if !app.filter.is_empty() {
229 spans.push(Span::raw(" "));
230 spans.push(Span::styled(format!("/{}", app.filter), theme.accent()));
231 }
232 spans.push(Span::raw(" "));
233 if let Some(message) = &app.status_message {
234 spans.push(Span::styled(message.clone(), theme.status(app.status_kind)));
235 } else {
236 for (i, (key, label)) in mode_hints(app).into_iter().enumerate() {
237 if i > 0 {
238 spans.push(Span::raw(" "));
239 }
240 spans.push(Span::styled(key, theme.hint_key()));
241 spans.push(Span::raw(" "));
242 spans.push(Span::styled(label, theme.hint_label()));
243 }
244 }
245 frame.render_widget(Paragraph::new(Line::from(spans)), area);
246}
247
248fn mode_label(mode: &Mode) -> &'static str {
250 match mode {
251 Mode::List => "LIST",
252 Mode::Filter => "FILTER",
253 Mode::Create(_) => "CREATE",
254 Mode::PrPicker(_) => "PR",
255 Mode::PrCompose(_) => "COMPOSE",
256 Mode::Checkout(_) => "CHECKOUT",
257 Mode::ConfirmRemove(_) => "REMOVE",
258 Mode::ConfirmCreate(_) => "CREATE",
259 Mode::ConfirmDeleteBranch { .. } => "DELETE",
260 Mode::ConfirmStaleBase(_) => "CREATE",
261 Mode::ConfirmInitSubmodules(_) => "SUBMODULES",
262 Mode::Help => "HELP",
263 }
264}
265
266const LIST_BAR: [KeyAction; 8] = [
271 KeyAction::Switch,
272 KeyAction::New,
273 KeyAction::Remove,
274 KeyAction::PrCheckout,
275 KeyAction::Checkout,
276 KeyAction::Filter,
277 KeyAction::Help,
278 KeyAction::Quit,
279];
280
281fn mode_hints(app: &App) -> Vec<(String, String)> {
285 match &app.mode {
286 Mode::List => LIST_BAR
287 .iter()
288 .filter_map(|&action| {
289 app.keymap
290 .display_for(action)
291 .map(|keys| (keys, action.label().to_string()))
292 })
293 .collect(),
294 Mode::Filter => hint_pairs(hints::filter_hints()),
295 Mode::Create(_) => hint_pairs(hints::create_hints()),
296 Mode::PrPicker(_) => hint_pairs(hints::pr_picker_hints()),
297 Mode::PrCompose(_) => hint_pairs(hints::compose_edit_hints()),
298 Mode::Checkout(_) => hint_pairs(hints::checkout_hints()),
299 Mode::ConfirmRemove(_) => hint_pairs(hints::confirm_hints()),
300 Mode::ConfirmCreate(_) => hint_pairs(hints::confirm_create_hints()),
301 Mode::ConfirmDeleteBranch { .. } => hint_pairs(hints::confirm_delete_branch_hints()),
302 Mode::ConfirmStaleBase(_) => hint_pairs(hints::confirm_stale_base_hints()),
303 Mode::ConfirmInitSubmodules(_) => hint_pairs(hints::confirm_init_submodules_hints()),
304 Mode::Help => hint_pairs(hints::help_hints()),
305 }
306}
307
308fn hint_pairs(table: &[Hint]) -> Vec<(String, String)> {
310 table
311 .iter()
312 .map(|h| (h.key.to_string(), h.label.to_string()))
313 .collect()
314}
315
316fn centered(area: Rect, width: u16, height: u16) -> Rect {
318 let w = width.min(area.width);
319 let h = height.min(area.height);
320 Rect {
321 x: area.x + (area.width.saturating_sub(w)) / 2,
322 y: area.y + (area.height.saturating_sub(h)) / 2,
323 width: w,
324 height: h,
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::keys::KeyChord;
332 use crate::tui::app::testutil::{app, wt};
333 use crate::tui::app::{
334 ComposeField, CreateState, PrComposeState, PrItem, PrPickerState, StatusKind,
335 };
336 use crossterm::event::KeyCode;
337 use ratatui::Terminal;
338 use ratatui::backend::TestBackend;
339 use ratatui::buffer::Buffer;
340 use ratatui::style::Color;
341
342 fn render_to_text(app: &App, w: u16, h: u16) -> String {
344 buffer_text(&render_to_buffer(app, w, h))
345 }
346
347 fn render_to_buffer(app: &App, w: u16, h: u16) -> Buffer {
349 let backend = TestBackend::new(w, h);
350 let mut terminal = Terminal::new(backend).unwrap();
351 terminal.draw(|f| render(app, f)).unwrap();
352 terminal.backend().buffer().clone()
353 }
354
355 fn cell_fg(buffer: &Buffer, symbol: &str) -> Color {
357 let area = buffer.area;
358 for y in 0..area.height {
359 for x in 0..area.width {
360 if buffer[(x, y)].symbol() == symbol {
361 return buffer[(x, y)].fg;
362 }
363 }
364 }
365 panic!("symbol {symbol:?} not found");
366 }
367
368 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
369 let area = buffer.area;
370 let mut out = String::new();
371 for y in 0..area.height {
372 for x in 0..area.width {
373 out.push_str(buffer[(x, y)].symbol());
374 }
375 out.push('\n');
376 }
377 out
378 }
379
380 #[test]
381 fn renders_list_and_detail() {
382 let a = app(&[("main", true), ("feature/x", false)]);
383 let text = render_to_text(&a, 100, 20);
384 assert!(text.contains("worktrees"));
385 assert!(text.contains("detail"));
386 assert!(text.contains("main"));
387 assert!(text.contains("feature/x"));
388 assert!(text.contains('*'));
390 }
391
392 #[test]
393 fn list_shows_branch_rows_with_marker_and_count() {
394 use crate::tui::app::testutil::branch_row;
395 let mut a = app(&[("main", true)]);
396 let mut br = branch_row("feature/lonely");
397 br.ahead = Some(3);
398 br.behind = Some(1);
399 a.worktrees.push(br);
400 a.apply_filter(String::new());
401 a.mark_loaded(a.worktrees[1].path.clone());
402 let text = render_to_text(&a, 100, 20);
403 assert!(text.contains("feature/lonely"));
404 assert!(text.contains('○')); assert!(text.contains("↑3"));
406 assert!(text.contains("↓1"));
407 assert!(text.contains("branches"));
409 }
410
411 #[test]
412 fn detail_pane_for_branch_row_is_pathless() {
413 use crate::tui::app::testutil::branch_row;
414 let mut a = app(&[("main", true)]);
415 let mut br = branch_row("topic");
416 br.ahead = Some(2);
417 br.behind = Some(0);
418 br.base_ref = Some("main".into());
419 a.worktrees.push(br);
420 a.apply_filter(String::new());
421 a.mark_loaded(a.worktrees[1].path.clone());
422 a.selected = 1; let text = render_to_text(&a, 100, 20);
424 assert!(text.contains("no worktree"));
425 assert!(text.contains("vs base"));
426 assert!(text.contains("base:"));
427 assert!(!text.contains("branch://"));
429 }
430
431 #[test]
432 fn confirm_create_dialog_renders() {
433 use crate::tui::app::testutil::branch_row;
434 let mut a = app(&[("main", true)]);
435 let mut br = branch_row("topic");
436 br.base_ref = Some("main".into());
437 br.ahead = Some(2);
438 br.behind = Some(0);
439 a.worktrees.push(br);
440 a.apply_filter(String::new());
441 a.mark_loaded(a.worktrees[1].path.clone());
442 a.mode = Mode::ConfirmCreate(1);
443 let text = render_to_text(&a, 100, 30);
444 assert!(text.contains("create worktree"));
445 assert!(text.contains("topic"));
446 assert!(text.contains("switch into it"));
447 assert!(text.contains("[y/N]"));
448 }
449
450 #[test]
451 fn renders_confirm_init_submodules_modal() {
452 let mut a = app(&[("main", true)]);
453 a.mode = Mode::ConfirmInitSubmodules(InitSubmodulesState {
454 dir: std::path::PathBuf::from("/wt/feature"),
455 branch: "feature".into(),
456 count: 3,
457 });
458 let text = render_to_text(&a, 100, 30);
459 assert!(text.contains("initialize submodules"));
460 assert!(text.contains("feature"));
461 assert!(text.contains("3 uninitialized"));
462 assert!(text.contains("[Y/n]"));
464 }
465
466 #[test]
467 fn narrow_terminal_hides_detail() {
468 let a = app(&[("main", true)]);
469 let mut a = a;
470 a.size = (50, 20);
471 let text = render_to_text(&a, 50, 20);
472 assert!(text.contains("worktrees"));
473 assert!(!text.contains("detail")); }
475
476 #[test]
477 fn pending_rows_show_spinner() {
478 let mut a = app(&[("main", true)]);
479 a.mark_loading(); let text = render_to_text(&a, 100, 20);
481 assert!(text.contains('…'));
482 }
483
484 #[test]
485 fn loaded_no_upstream_shows_absent_marker() {
486 let a = app(&[("main", true)]);
488 let text = render_to_text(&a, 100, 20);
489 assert!(text.contains('–'));
490 }
491
492 #[test]
493 fn help_overlay_renders() {
494 let mut a = app(&[("main", true)]);
495 a.mode = Mode::Help;
496 let text = render_to_text(&a, 100, 40);
497 assert!(text.contains("help"));
498 assert!(text.contains("navigate"));
499 assert!(text.contains("quit"));
500 assert!(text.contains("checkout"));
502 }
503
504 #[test]
505 fn help_overlay_documents_every_action() {
506 let mut a = app(&[("main", true)]);
509 a.mode = Mode::Help;
510 let text = render_to_text(&a, 100, 40);
511 for action in KeyAction::ALL {
512 assert!(
513 text.contains(action.label()),
514 "help overlay missing label for {action:?}: {:?}",
515 action.label()
516 );
517 }
518 }
519
520 #[test]
521 fn list_bar_includes_checkout() {
522 let a = app(&[("main", true)]);
525 let text = render_to_text(&a, 100, 30);
526 assert!(text.contains("checkout"));
527 assert!(text.contains(" c "));
528 }
529
530 #[test]
531 fn list_bar_follows_rebind() {
532 let mut a = app(&[("main", true)]);
535 a.keymap
536 .rebind(KeyAction::Checkout, KeyChord::key(KeyCode::Char('x')));
537 let text = render_to_text(&a, 100, 30);
538 assert!(text.contains(" x "));
539 }
540
541 #[test]
542 fn create_overlay_shows_fields_and_error() {
543 let mut a = app(&[("main", true)]);
544 a.mode = Mode::Create(CreateState {
545 branch: "feat".into(),
546 error: Some("branch name is required".into()),
547 ..Default::default()
548 });
549 let text = render_to_text(&a, 100, 30);
550 assert!(text.contains("new worktree"));
551 assert!(text.contains("feat"));
552 assert!(text.contains("required"));
553 }
554
555 #[test]
556 fn create_overlay_shows_open_branch_options() {
557 use crate::tui::options::OptionList;
558 let mut a = app(&[("main", true)]);
559 let mut options = OptionList::new(vec![
560 "main".into(),
561 "origin/main".into(),
562 "origin/dev".into(),
563 ]);
564 options.open();
565 a.mode = Mode::Create(CreateState {
566 options,
567 ..Default::default()
568 });
569 let text = render_to_text(&a, 100, 30);
570 assert!(text.contains("origin/main"));
572 assert!(text.contains("origin/dev"));
573 assert!(text.contains('▌')); assert!(text.contains("options")); }
576
577 #[test]
578 fn checkout_overlay_renders_branches_and_target() {
579 use crate::tui::app::CheckoutState;
580 use crate::tui::options::OptionList;
581 let mut a = app(&[("main", true), ("feature/x", false)]);
582 let mut options = OptionList::new(vec!["main".into(), "feature/x".into()]);
583 options.open();
584 a.mode = Mode::Checkout(CheckoutState {
585 worktree_index: 0,
586 query: "feat".into(),
587 options,
588 ..Default::default()
589 });
590 let text = render_to_text(&a, 100, 30);
591 assert!(text.contains("checkout branch"));
592 assert!(text.contains("feature/x"));
593 assert!(text.contains("branches")); }
595
596 #[test]
597 fn pr_compose_model_field_shows_options_dropdown() {
598 let mut a = app(&[("main", true)]);
599 a.mode = Mode::PrCompose(PrComposeState {
600 field: ComposeField::Model,
601 branch: "feat".into(),
602 trunk: "main".into(),
603 action_label: "create".into(),
604 ..Default::default()
605 });
606 let text = render_to_text(&a, 100, 30);
607 assert!(text.contains("Opus 4.8"));
609 assert!(text.contains("Sonnet 4.6"));
610 assert!(text.contains("Haiku 4.5"));
611 assert!(text.contains("> model:"));
612 }
613
614 #[test]
615 fn pr_compose_effort_field_shows_options_dropdown() {
616 let mut a = app(&[("main", true)]);
617 a.mode = Mode::PrCompose(PrComposeState {
618 field: ComposeField::Effort,
619 branch: "feat".into(),
620 trunk: "main".into(),
621 action_label: "create".into(),
622 ..Default::default()
623 });
624 let text = render_to_text(&a, 100, 30);
625 assert!(text.contains("low"));
626 assert!(text.contains("medium"));
627 assert!(text.contains("high"));
628 }
629
630 #[test]
631 fn pr_compose_overlay_shows_header_fields_and_hints() {
632 let mut a = app(&[("main", true)]);
633 a.mode = Mode::PrCompose(PrComposeState {
634 field: ComposeField::Body,
635 title: "Add login".into(),
636 body: "Summary line".into(),
637 draft: true,
638 branch: "feat/login".into(),
639 trunk: "main".into(),
640 action_label: "create".into(),
641 error: Some("boom".into()),
642 ..Default::default()
643 });
644 let text = render_to_text(&a, 100, 30);
645 assert!(text.contains("open pull request"));
646 assert!(text.contains("feat/login"));
647 assert!(text.contains("Add login"));
648 assert!(text.contains("Summary line"));
649 assert!(text.contains("[create]"));
650 assert!(text.contains("draft [x]"));
651 assert!(text.contains("boom"));
652 assert!(text.contains("Ctrl-S"));
653 assert!(text.contains("Ctrl-A"));
655 assert!(text.contains("model:"));
656 assert!(text.contains("Sonnet 4.6")); assert!(text.contains("effort:"));
658 }
659
660 #[test]
661 fn pr_compose_shows_selected_model_and_effort() {
662 let mut a = app(&[("main", true)]);
663 a.mode = Mode::PrCompose(PrComposeState {
664 title: "T".into(),
665 branch: "feat".into(),
666 trunk: "main".into(),
667 action_label: "create".into(),
668 model: crate::agent::AgentModel::Opus,
669 effort: crate::agent::Effort::High,
670 ..Default::default()
671 });
672 let text = render_to_text(&a, 100, 30);
673 assert!(text.contains("Opus 4.8"));
674 assert!(text.contains("high"));
675 }
676
677 #[test]
678 fn pr_compose_submitting_shows_status() {
679 let mut a = app(&[("main", true)]);
680 a.mode = Mode::PrCompose(PrComposeState {
681 title: "T".into(),
682 branch: "feat".into(),
683 trunk: "main".into(),
684 action_label: "update #5".into(),
685 submitting: true,
686 ..Default::default()
687 });
688 let text = render_to_text(&a, 100, 30);
689 assert!(text.contains("working"));
690 assert!(text.contains("[update #5]"));
691 }
692
693 #[test]
694 fn pr_picker_states() {
695 let mut a = app(&[("main", true)]);
696 a.mode = Mode::PrPicker(PrPickerState {
697 loading: true,
698 ..Default::default()
699 });
700 assert!(render_to_text(&a, 100, 30).contains("loading"));
701
702 a.mode = Mode::PrPicker(PrPickerState {
703 loading: false,
704 prs: vec![
705 PrItem {
706 number: 42,
707 title: "Add login".into(),
708 author: "alice".into(),
709 state: "open".into(),
710 created_at: "2020-01-01T00:00:00Z".into(),
711 },
712 PrItem {
714 number: 7,
715 title: "No date".into(),
716 author: "bob".into(),
717 state: "open".into(),
718 created_at: String::new(),
719 },
720 ],
721 ..Default::default()
722 });
723 let text = render_to_text(&a, 100, 30);
724 assert!(text.contains("#42"));
725 assert!(text.contains("Add login"));
726 assert!(text.contains("ago"));
729
730 a.mode = Mode::PrPicker(PrPickerState {
731 error: Some("gh unavailable".into()),
732 ..Default::default()
733 });
734 assert!(render_to_text(&a, 100, 30).contains("gh auth login"));
735 }
736
737 #[test]
738 fn confirm_remove_overlay_shows_safety() {
739 let mut dirty = wt("topic", false);
740 dirty.dirty = Some(true);
741 let mut a = app(&[("main", true)]);
742 a.worktrees.push(dirty);
743 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
744 let text = render_to_text(&a, 100, 30);
745 assert!(text.contains("confirm remove"));
746 assert!(text.contains("data may be lost"));
747 assert!(text.contains("[y/N]"));
748 }
749
750 #[test]
751 fn confirm_remove_flags_no_upstream_as_unpushed() {
752 let mut clean = wt("topic", false);
755 clean.dirty = Some(false);
756 clean.has_untracked = Some(false);
757 clean.ahead = None; clean.merge_state = Some(MergeState::NoUpstreamLocal);
759 let mut a = app(&[("main", true)]);
760 a.worktrees.push(clean);
761 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
762 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
763 let text = render_to_text(&a, 100, 30);
764 assert!(text.contains("no upstream"));
765 assert!(text.contains("local-only"));
766 assert!(!text.contains("data may be lost")); }
768
769 #[test]
770 fn confirm_remove_merged_into_base_is_safe() {
771 let mut w = wt("feature/done", false);
773 w.dirty = Some(false);
774 w.has_untracked = Some(false);
775 w.ahead = None;
776 w.merge_state = Some(MergeState::Merged {
777 into: Some("main".into()),
778 });
779 let mut a = app(&[("main", true)]);
780 a.worktrees.push(w);
781 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
782 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
783 let text = render_to_text(&a, 100, 30);
784 assert!(text.contains("merged into main"));
785 assert!(text.contains("safe to delete"));
786 assert!(!text.contains("unpushed"));
789 }
790
791 #[test]
792 fn confirm_remove_merged_via_pr_is_safe() {
793 let mut w = wt("feature/squashed", false);
795 w.dirty = Some(false);
796 w.has_untracked = Some(false);
797 w.ahead = None;
798 w.merge_state = Some(MergeState::Merged { into: None });
799 let mut a = app(&[("main", true)]);
800 a.worktrees.push(w);
801 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
802 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
803 let text = render_to_text(&a, 100, 30);
804 assert!(text.contains("merged via PR"));
805 assert!(!text.contains("unpushed"));
806 }
807
808 #[test]
809 fn confirm_remove_upstream_gone_is_soft() {
810 let mut w = wt("feature/pushed", false);
812 w.dirty = Some(false);
813 w.has_untracked = Some(false);
814 w.ahead = None;
815 w.merge_state = Some(MergeState::UpstreamGone);
816 let mut a = app(&[("main", true)]);
817 a.worktrees.push(w);
818 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
819 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
820 let text = render_to_text(&a, 100, 30);
821 assert!(text.contains("upstream branch deleted"));
822 assert!(text.contains("likely merged"));
823 assert!(!text.contains("unpushed work"));
824 }
825
826 #[test]
827 fn confirm_remove_merged_but_dirty_still_warns() {
828 let mut w = wt("feature/dirty-merged", false);
831 w.dirty = Some(true);
832 w.ahead = None;
833 w.merge_state = Some(MergeState::Merged {
834 into: Some("main".into()),
835 });
836 let mut a = app(&[("main", true)]);
837 a.worktrees.push(w);
838 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
839 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
840 let text = render_to_text(&a, 100, 30);
841 assert!(text.contains("safe to delete"));
842 assert!(text.contains("data may be lost"));
843 }
844
845 #[test]
846 fn confirm_remove_tracked_ahead_still_warns() {
847 let mut w = wt("feature/ahead", false);
849 w.dirty = Some(false);
850 w.ahead = Some(2);
851 w.upstream = Some("origin/feature/ahead".into());
852 w.merge_state = Some(MergeState::Tracked);
853 let mut a = app(&[("main", true)]);
854 a.worktrees.push(w);
855 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
856 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
857 let text = render_to_text(&a, 100, 30);
858 assert!(text.contains("2 unpushed commit(s)"));
859 }
860
861 #[test]
862 fn confirm_remove_honors_remove_untracked_blocks() {
863 let mut wt_un = wt("topic", false);
866 wt_un.dirty = Some(false);
867 wt_un.has_untracked = Some(true);
868 wt_un.ahead = Some(0);
869 let mut a = app(&[("main", true)]);
870 assert!(a.show_untracked && !a.remove_untracked_blocks);
871 a.worktrees.push(wt_un);
872 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
873 assert!(!render_to_text(&a, 100, 30).contains("data may be lost"));
874 }
875
876 #[test]
877 fn confirm_remove_shows_glanceable_context() {
878 use crate::model::{Commit, Pr, PrState};
881 let mut w = wt("feature/login", false);
882 w.dirty = Some(false);
883 w.has_untracked = Some(false);
884 w.ahead = Some(0);
885 w.behind = Some(0);
886 w.upstream = Some("origin/feature/login".into());
887 w.base_ref = Some("main".into());
888 w.commit = Some(Commit {
889 hash: "abc1234".into(),
890 subject: "Add login page".into(),
891 author: "Alice".into(),
892 timestamp: "2024-01-15T10:30:00Z".into(),
893 });
894 w.pr = Some(Pr {
895 number: 42,
896 state: PrState::Merged,
897 title: "Add login page".into(),
898 });
899 let mut a = app(&[("main", true)]);
900 a.worktrees.push(w);
901 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
902 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
903 let text = render_to_text(&a, 100, 30);
904 assert!(text.contains("origin/feature/login"));
905 assert!(text.contains("base:"));
906 assert!(text.contains("abc1234"));
907 assert!(text.contains("Add login page"));
908 assert!(text.contains("#42 (merged)"));
909 assert!(text.contains("↑0"));
910 assert!(!text.contains("data may be lost"));
911 assert!(text.contains("[y/N]"));
912 }
913
914 #[test]
915 fn confirm_remove_layers_warnings_over_context() {
916 use crate::model::{Commit, Pr, PrState};
919 let mut w = wt("feature/x", false);
920 w.dirty = Some(true);
921 w.ahead = Some(2);
922 w.behind = Some(0);
923 w.upstream = Some("origin/feature/x".into());
924 w.commit = Some(Commit {
925 hash: "def5678".into(),
926 subject: "WIP work".into(),
927 author: "Bob".into(),
928 timestamp: "2024-02-20T08:00:00Z".into(),
929 });
930 w.pr = Some(Pr {
931 number: 7,
932 state: PrState::Open,
933 title: "Feature x".into(),
934 });
935 let mut a = app(&[("main", true)]);
936 a.worktrees.push(w);
937 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
938 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
939 let text = render_to_text(&a, 100, 30);
940 assert!(text.contains("def5678"));
941 assert!(text.contains("#7 (open)"));
942 assert!(text.contains("data may be lost"));
943 assert!(text.contains("2 unpushed commit(s)"));
944 }
945
946 #[test]
947 fn confirm_remove_missing_skips_status_lines() {
948 use crate::model::Commit;
952 let mut w = wt("feature/gone", false);
953 w.is_missing = true;
954 w.base_ref = Some("main".into());
955 w.commit = Some(Commit {
956 hash: "ccc9999".into(),
957 subject: "Gone branch tip".into(),
958 author: "Dan".into(),
959 timestamp: "2024-04-01T00:00:00Z".into(),
960 });
961 let mut a = app(&[("main", true)]);
962 a.worktrees.push(w);
963 a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
964 a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
965 let text = render_to_text(&a, 100, 30);
966 assert!(text.contains("directory already deleted"));
967 assert!(!text.contains("ccc9999"));
968 assert!(!text.contains("Gone branch tip"));
969 }
970
971 #[test]
972 fn confirm_remove_shows_spinner_until_loaded() {
973 use crate::model::Commit;
976 let mut w = wt("feature/loading", false);
977 w.commit = Some(Commit {
978 hash: "aaa0000".into(),
979 subject: "Secret subject".into(),
980 author: "Carol".into(),
981 timestamp: "2024-03-01T00:00:00Z".into(),
982 });
983 let mut a = app(&[("main", true)]);
984 a.worktrees.push(w);
985 a.mark_loading(); a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
987 let text = render_to_text(&a, 100, 30);
988 assert!(text.contains("feature/loading"));
990 assert!(!text.contains("Secret subject"));
991 assert!(!text.contains("aaa0000"));
992 }
993
994 #[test]
995 fn failed_dirty_read_shows_absent_marker_not_blank() {
996 let mut unknown = wt("topic", false);
999 unknown.dirty = None; unknown.ahead = Some(0);
1001 unknown.behind = Some(0); let mut a = app(&[("main", true)]);
1003 a.worktrees.push(unknown);
1004 let text = render_to_text(&a, 100, 20);
1005 assert!(text.contains('–'));
1006 }
1007
1008 #[test]
1009 fn status_bar_hints_are_per_mode() {
1010 let mut a = app(&[("main", true)]);
1011 a.mode = Mode::PrPicker(PrPickerState {
1012 loading: false,
1013 ..Default::default()
1014 });
1015 assert!(render_to_text(&a, 100, 30).contains("checkout"));
1017 }
1018
1019 #[test]
1020 fn detail_pane_shows_recent_commits_and_pr_url() {
1021 use crate::model::{Commit, Pr, PrState};
1022 let mut a = app(&[("main", true)]);
1023 let c = |hash: &str, subject: &str| Commit {
1024 hash: hash.into(),
1025 subject: subject.into(),
1026 author: "x".into(),
1027 timestamp: "2024-01-15T10:30:00Z".into(),
1028 };
1029 a.worktrees[0].recent_commits = vec![c("aaaaaaa", "newest"), c("bbbbbbb", "older")];
1030 a.worktrees[0].pr = Some(Pr {
1031 number: 42,
1032 state: PrState::Open,
1033 title: "Add login".into(),
1034 });
1035 a.worktrees[0].pr_url = Some("https://github.com/o/r/pull/42".into());
1036 let text = render_to_text(&a, 100, 30);
1037 assert!(text.contains("commits:"));
1038 assert!(text.contains("newest"));
1039 assert!(text.contains("older"));
1040 assert!(text.contains("pull/42"));
1041 }
1042
1043 #[test]
1044 fn detail_pane_shows_merged_note() {
1045 let mut a = app(&[("main", true)]);
1048 a.worktrees[0].merge_state = Some(MergeState::Merged {
1049 into: Some("main".into()),
1050 });
1051 a.mark_loaded(a.worktrees[0].path.clone());
1052 let text = render_to_text(&a, 100, 30);
1053 assert!(text.contains("merged into main"));
1054 }
1055
1056 #[test]
1057 fn detail_pane_omits_no_upstream_warning() {
1058 let mut a = app(&[("main", true)]);
1061 a.worktrees[0].merge_state = Some(MergeState::NoUpstreamLocal);
1062 a.mark_loaded(a.worktrees[0].path.clone());
1063 let text = render_to_text(&a, 100, 30);
1064 assert!(!text.contains("local-only"));
1065 }
1066
1067 #[test]
1068 fn status_bar_shows_mode_and_filter() {
1069 let mut a = app(&[("main", true)]);
1070 a.filter = "feat".into();
1071 a.mode = Mode::Filter;
1072 let text = render_to_text(&a, 100, 20);
1073 assert!(text.contains("FILTER"));
1074 assert!(text.contains("/feat"));
1075 }
1076
1077 #[test]
1078 fn list_markers_are_colored_and_gate_on_color_flag() {
1079 let mut a = app(&[("main", true)]);
1080 a.worktrees[0].ahead = Some(1);
1081 a.worktrees[0].behind = Some(2);
1082 a.worktrees[0].dirty = Some(true);
1083 let buf = render_to_buffer(&a, 100, 20);
1085 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;
1092 let mono = render_to_buffer(&a, 100, 20);
1093 assert_eq!(cell_fg(&mono, "*"), Color::Reset);
1094 assert_eq!(cell_fg(&mono, "M"), Color::Reset);
1095 assert_eq!(cell_fg(&mono, "↑"), Color::Reset);
1096 }
1097
1098 #[test]
1099 fn custom_palette_recolors_cells() {
1100 let mut a = app(&[("main", true)]);
1101 a.palette.green = Color::Rgb(1, 2, 3);
1104 let buf = render_to_buffer(&a, 100, 20);
1105 assert_eq!(cell_fg(&buf, "*"), Color::Rgb(1, 2, 3));
1106 }
1107
1108 #[test]
1109 fn pr_state_cell_is_colored() {
1110 use crate::model::{Pr, PrState};
1111 let mut a = app(&[("main", true)]);
1112 a.worktrees[0].pr = Some(Pr {
1113 number: 7,
1114 state: PrState::Open,
1115 title: "t".into(),
1116 });
1117 let buf = render_to_buffer(&a, 120, 20);
1118 assert_ne!(cell_fg(&buf, "#"), Color::Reset);
1120 }
1121
1122 #[test]
1123 fn focused_pane_border_differs_from_unfocused() {
1124 let mut a = app(&[("main", true)]);
1125 a.focus = Pane::List;
1126 let list_focused = render_to_buffer(&a, 100, 20);
1127 a.focus = Pane::Detail;
1128 let detail_focused = render_to_buffer(&a, 100, 20);
1129 assert_ne!(list_focused[(0, 0)].fg, detail_focused[(0, 0)].fg);
1131 }
1132
1133 #[test]
1134 fn list_title_shows_count_and_sort() {
1135 let a = app(&[("main", true), ("feature/x", false)]);
1136 let text = render_to_text(&a, 100, 20);
1137 assert!(text.contains("(2)"));
1138 assert!(text.contains("branch ↑"));
1139 }
1140
1141 #[test]
1142 fn filtered_title_shows_visible_over_total() {
1143 let mut a = app(&[("alpha", true), ("beta", false)]);
1144 a.filter_push('a');
1145 a.filter_push('l');
1146 a.filter_push('p'); assert_eq!(a.visible.len(), 1);
1148 let text = render_to_text(&a, 100, 20);
1149 assert!(text.contains("(1/2)"));
1150 }
1151
1152 #[test]
1153 fn empty_filter_shows_no_matches_hint() {
1154 let mut a = app(&[("alpha", true)]);
1155 a.filter_push('z');
1156 a.filter_push('z');
1157 a.filter_push('z');
1158 assert!(a.visible.is_empty());
1159 let text = render_to_text(&a, 100, 20);
1160 assert!(text.contains("no matches for /zzz"));
1161 }
1162
1163 #[test]
1164 fn detail_scrollbar_appears_when_content_overflows() {
1165 use crate::model::Commit;
1166 let mut a = app(&[("main", true)]);
1167 a.worktrees[0].recent_commits = (0..40)
1168 .map(|i| Commit {
1169 hash: format!("h{i:05}"),
1170 subject: "s".into(),
1171 author: "a".into(),
1172 timestamp: "2024-01-15T10:30:00Z".into(),
1173 })
1174 .collect();
1175 let text = render_to_text(&a, 100, 12);
1177 assert!(text.contains('█'));
1178 }
1179
1180 #[test]
1181 fn status_message_colored_by_kind() {
1182 let mut a = app(&[("main", true)]);
1183 a.set_status("ZEBRA", StatusKind::Success);
1184 let ok = render_to_buffer(&a, 100, 20);
1185 a.set_status("ZEBRA", StatusKind::Error);
1186 let err = render_to_buffer(&a, 100, 20);
1187 a.set_status("ZEBRA", StatusKind::Info);
1188 let info = render_to_buffer(&a, 100, 20);
1189 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")); }
1195
1196 #[test]
1197 fn busy_overlay_renders_label_and_spinner() {
1198 let mut a = app(&[("main", true)]);
1199 a.begin_busy("Removing feat/foo");
1200 let text = render_to_text(&a, 100, 20);
1201 assert!(text.contains("working"));
1202 assert!(text.contains("Removing feat/foo"));
1203 assert!(text.contains('…'));
1204 let frame0 = Glyphs::new(false).spinner_frame(0);
1206 assert!(text.contains(frame0));
1207 }
1208
1209 #[test]
1210 fn busy_overlay_animates_with_frame() {
1211 let mut a = app(&[("main", true)]);
1212 a.begin_busy("Working");
1213 let glyphs = Glyphs::new(false);
1214 let at0 = render_to_text(&a, 100, 20);
1215 a.tick_busy();
1216 let at1 = render_to_text(&a, 100, 20);
1217 assert_ne!(glyphs.spinner_frame(0), glyphs.spinner_frame(1));
1219 assert!(at1.contains(glyphs.spinner_frame(1)));
1220 assert_ne!(at0, at1);
1221 }
1222
1223 #[test]
1224 fn busy_overlay_sits_over_mode() {
1225 let mut a = app(&[("main", true)]);
1226 a.mode = crate::tui::app::Mode::ConfirmRemove(0);
1227 a.begin_busy("Removing main");
1228 let text = render_to_text(&a, 100, 20);
1229 assert!(text.contains("working"));
1231 assert!(text.contains("Removing main"));
1232 }
1233}