Skip to main content

wt/tui/
app.rs

1//! TUI view-model: the [`App`] state and the modal substates (spec §10).
2//!
3//! All state lives here; [`crate::tui::event`] drives transitions purely (no
4//! terminal I/O), which is what makes the TUI testable.
5
6use std::path::PathBuf;
7
8use crate::agent::{AgentModel, Effort};
9use crate::keys::Keymap;
10use crate::model::{Column, SortKey, SortSpec, Worktree};
11use crate::tui::options::OptionList;
12use crate::tui::theme::Palette;
13use crate::util::fuzzy;
14
15/// The narrowest terminal width at which the detail pane is shown (spec §10).
16pub const MIN_DETAIL_WIDTH: u16 = 60;
17/// The terminal height below which the TUI exits cleanly (spec §10).
18pub const MIN_HEIGHT: u16 = 5;
19
20/// The interaction mode (spec §10 "View modes").
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Mode {
23    /// The default worktree list.
24    List,
25    /// Fuzzy-filter overlay.
26    Filter,
27    /// Create-worktree prompt.
28    Create(CreateState),
29    /// PR picker overlay.
30    PrPicker(PrPickerState),
31    /// PR compose form (`wt pr open`): edit a title + body, then submit.
32    PrCompose(PrComposeState),
33    /// Branch picker for checking out a branch in the selected worktree.
34    Checkout(CheckoutState),
35    /// Confirm-remove dialog (the worktree index).
36    ConfirmRemove(usize),
37    /// Confirm creating a worktree for the worktree-less branch row at the given
38    /// index, then switching into it (issue #47).
39    ConfirmCreate(usize),
40    /// Confirm-delete dialog for the worktree-less branch row at `index` (issue
41    /// #53). A branch row has no worktree to remove, so Remove deletes its local
42    /// branch instead. `force` is set on the second prompt, after a safe
43    /// `git branch -d` refused an unmerged branch, to offer a `git branch -D`.
44    ConfirmDeleteBranch {
45        /// Index into [`App::worktrees`] of the branch row to delete.
46        index: usize,
47        /// Whether this is the force-delete (`-D`) re-prompt for an unmerged branch.
48        force: bool,
49    },
50    /// Confirm dialog shown when the base a new worktree would fork from is behind
51    /// its origin counterpart (issue #56): update the base, proceed as-is, or cancel.
52    ConfirmStaleBase(StaleBaseState),
53    /// Confirm dialog shown after a worktree is created with uninitialized
54    /// submodules and the `[submodules] init` policy is left at its `prompt`
55    /// default (issue #50): initialize them recursively, or leave them. Defaults
56    /// to yes.
57    ConfirmInitSubmodules(InitSubmodulesState),
58    /// Help overlay.
59    Help,
60}
61
62/// Which pane has focus.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Pane {
65    /// The worktree list (left).
66    List,
67    /// The detail pane (right).
68    Detail,
69}
70
71/// The severity of a transient status-bar message, used to color it.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub enum StatusKind {
74    /// A neutral, uncolored message.
75    #[default]
76    Info,
77    /// A successful action (e.g. "created feature/x").
78    Success,
79    /// A failed action (e.g. a git error).
80    Error,
81}
82
83/// An in-flight background action (issue #46): the label to display and the
84/// animation frame. While set, the TUI shows a centered spinner overlay and
85/// ignores input until the action completes.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct BusyState {
88    /// The human-facing label, e.g. `Removing feat/foo` or `Creating feat/bar`.
89    pub label: String,
90    /// The monotonic spinner frame counter, advanced on each animation tick.
91    pub frame: usize,
92}
93
94/// The create-worktree prompt state.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct CreateState {
97    /// Which field is being edited.
98    pub step: CreateStep,
99    /// The entered branch name.
100    pub branch: String,
101    /// The entered base ref.
102    pub base: String,
103    /// An inline error from a failed submission.
104    pub error: Option<String>,
105    /// The inline branch-options dropdown for the active field (issue #25):
106    /// existing local + remote branches to fork from or check out.
107    pub options: OptionList,
108}
109
110/// The stale-base confirm state (issue #56): the base a new worktree would fork
111/// from is behind its upstream. Carries the pending create's inputs so the
112/// user's choice (update / proceed) can re-issue it.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct StaleBaseState {
115    /// The new branch name being created.
116    pub branch: String,
117    /// The base ref the user entered (or `None` for the default).
118    pub base: Option<String>,
119    /// How many commits the base is behind its upstream.
120    pub behind: u32,
121    /// The upstream display name, e.g. `origin/main`.
122    pub upstream_display: String,
123    /// Whether the base can be fast-forwarded (no local-only commits); when
124    /// false, updating will fail and only proceed/cancel make sense.
125    pub can_fast_forward: bool,
126}
127
128/// The submodule-init confirm state (issue #50): a freshly created worktree has
129/// uninitialized submodules and the policy is left at its `prompt` default.
130/// Carries the new worktree directory (where the init runs) and what to say.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct InitSubmodulesState {
133    /// The new worktree directory whose submodules would be initialized.
134    pub dir: PathBuf,
135    /// The branch the worktree was created for (for the status text).
136    pub branch: String,
137    /// How many uninitialized submodules were detected.
138    pub count: usize,
139}
140
141/// Which create-prompt field is active.
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143pub enum CreateStep {
144    /// Editing the branch name.
145    #[default]
146    Branch,
147    /// Editing the base ref.
148    Base,
149}
150
151/// The checkout-branch picker state: a type-ahead branch list plus the target
152/// worktree to switch in place.
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
154pub struct CheckoutState {
155    /// Index into [`App::worktrees`] of the target worktree (the selected row).
156    pub worktree_index: usize,
157    /// The type-ahead query (the branch the user is filtering/typing).
158    pub query: String,
159    /// The inline branch-options dropdown (local + remote branches to check out).
160    pub options: OptionList,
161    /// An inline error from a failed checkout (e.g. a dirty worktree).
162    pub error: Option<String>,
163    /// Whether a checkout is in flight (input is ignored while set).
164    pub submitting: bool,
165}
166
167/// One PR shown in the picker.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct PrItem {
170    /// PR number.
171    pub number: u64,
172    /// PR title.
173    pub title: String,
174    /// PR author login.
175    pub author: String,
176    /// PR state label.
177    pub state: String,
178    /// ISO-8601 creation time, used to render a relative age.
179    pub created_at: String,
180}
181
182/// Which PR-compose field is active.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
184pub enum ComposeField {
185    /// Editing the single-line title.
186    #[default]
187    Title,
188    /// Editing the multi-line body.
189    Body,
190    /// Selecting the AI auto-fill model from its options dropdown (issue #25).
191    Model,
192    /// Selecting the AI auto-fill effort from its options dropdown (issue #25).
193    Effort,
194}
195
196/// The `wt pr open` compose-form state: a title and (multi-line) body the user
197/// edits before submitting, plus the precomputed header context.
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct PrComposeState {
200    /// Which field is being edited.
201    pub field: ComposeField,
202    /// The PR title (single line).
203    pub title: String,
204    /// The PR body (may contain newlines).
205    pub body: String,
206    /// Whether to open the PR as a draft (create only).
207    pub draft: bool,
208    /// The current branch (for the header).
209    pub branch: String,
210    /// The base/trunk branch (for the header).
211    pub trunk: String,
212    /// Precomputed action label, e.g. `create` or `update #12`.
213    pub action_label: String,
214    /// The model used for AI auto-fill (`Ctrl-A`), cycled with `Ctrl-M`.
215    pub model: AgentModel,
216    /// The effort used for AI auto-fill, cycled with `Ctrl-E`.
217    pub effort: Effort,
218    /// Whether a submit/draft operation is in flight (shown as a hint).
219    pub submitting: bool,
220    /// An inline error from a failed draft or submission.
221    pub error: Option<String>,
222}
223
224/// The PR-picker state.
225#[derive(Debug, Clone, Default, PartialEq, Eq)]
226pub struct PrPickerState {
227    /// Whether PRs are still loading.
228    pub loading: bool,
229    /// The loaded PRs.
230    pub prs: Vec<PrItem>,
231    /// The selected PR index.
232    pub selected: usize,
233    /// An error (e.g. gh unavailable).
234    pub error: Option<String>,
235}
236
237/// The TUI application state.
238pub struct App {
239    /// All worktrees (sorted).
240    pub worktrees: Vec<Worktree>,
241    /// Indices into `worktrees` currently visible (after filtering).
242    pub visible: Vec<usize>,
243    /// Selected index into `visible`.
244    pub selected: usize,
245    /// The current mode.
246    pub mode: Mode,
247    /// The active filter string.
248    pub filter: String,
249    /// Which pane has focus.
250    pub focus: Pane,
251    /// Whether the list (sidebar) pane is shown.
252    pub show_sidebar: bool,
253    /// The list pane width.
254    pub sidebar_width: u16,
255    /// The current sort.
256    pub sort: SortSpec,
257    /// Scroll offset of the detail pane.
258    pub detail_scroll: u16,
259    /// Terminal size (cols, rows).
260    pub size: (u16, u16),
261    /// The key bindings.
262    pub keymap: Keymap,
263    /// Columns to render in the list.
264    pub columns: Vec<Column>,
265    /// Whether untracked files show `?`.
266    pub show_untracked: bool,
267    /// Whether untracked-only files count as "dirty" for the remove guard
268    /// (the confirm dialog mirrors `remove.untracked_blocks`, not `show_untracked`).
269    pub remove_untracked_blocks: bool,
270    /// Whether Nerd Font glyphs are enabled.
271    pub nerd_fonts: bool,
272    /// Whether mouse support is enabled.
273    pub mouse: bool,
274    /// Whether color output is enabled (spec §11 precedence, resolved once).
275    pub color: bool,
276    /// The resolved color palette (preset + `[ui.theme]` overrides).
277    pub palette: Palette,
278    /// Set when the user quits without switching.
279    pub quit: bool,
280    /// Set to the chosen path when the user switches (Enter).
281    pub chosen: Option<PathBuf>,
282    /// When set, checking out a PR sets `chosen` and exits the loop (switching
283    /// into the new worktree) instead of returning to the list. Used by the
284    /// `wt pr` no-argument picker entry; the `p`-key picker leaves it `false`.
285    pub exit_on_pr_checkout: bool,
286    /// Worktree paths whose async fields have loaded; rows not in this set show
287    /// the per-row spinner (spec §10). Keyed by path so it survives re-sorting.
288    loaded_paths: std::collections::HashSet<PathBuf>,
289    /// A transient status/error line shown in the status bar.
290    pub status_message: Option<String>,
291    /// The severity of `status_message`, used to color it.
292    pub status_kind: StatusKind,
293    /// Set when the terminal became too small to continue (spec §10).
294    pub too_small: bool,
295    /// An in-flight background action (issue #46): `Some` while a shell-based
296    /// action runs on a background task, driving the spinner overlay and gating
297    /// input. `None` when idle.
298    pub busy: Option<BusyState>,
299    /// Local + remote-tracking branch names offered in the create-prompt
300    /// options dropdown and used to tab-complete the base ref (best-effort;
301    /// empty when enumeration fails).
302    pub branches: Vec<String>,
303    /// The remote-tracking default branch (e.g. `origin/main`) a new worktree
304    /// forks from by default, pre-filled into the create-prompt base field
305    /// (issue #70). `None` when there is no confident remote default (no
306    /// `origin/HEAD`), in which case the base starts empty.
307    pub default_base: Option<String>,
308}
309
310/// Display/config inputs for the TUI (the parts of [`crate::config::Config`]
311/// the view needs), bundled to keep [`App::new`] tidy.
312pub struct AppConfig {
313    /// The effective key bindings.
314    pub keymap: Keymap,
315    /// The initial sort.
316    pub sort: SortSpec,
317    /// Columns to render in the list.
318    pub columns: Vec<Column>,
319    /// Whether untracked files show `?`.
320    pub show_untracked: bool,
321    /// Whether untracked-only files count as "dirty" for the remove guard.
322    pub remove_untracked_blocks: bool,
323    /// Whether Nerd Font glyphs are enabled.
324    pub nerd_fonts: bool,
325    /// Whether mouse support is enabled.
326    pub mouse: bool,
327    /// Whether color output is enabled (spec §11 precedence, resolved once).
328    pub color: bool,
329    /// The resolved color palette (preset + `[ui.theme]` overrides).
330    pub palette: Palette,
331}
332
333impl App {
334    /// Builds an app over the given worktrees, selecting the current one. All
335    /// rows start marked loaded; the runtime marks them loading before async
336    /// enrichment.
337    pub fn new(worktrees: Vec<Worktree>, config: AppConfig, size: (u16, u16)) -> App {
338        let visible = (0..worktrees.len()).collect();
339        let selected = worktrees.iter().position(|w| w.is_current).unwrap_or(0);
340        let loaded_paths = worktrees.iter().map(|w| w.path.clone()).collect();
341        App {
342            loaded_paths,
343            status_message: None,
344            status_kind: StatusKind::Info,
345            too_small: false,
346            busy: None,
347            branches: Vec::new(),
348            default_base: None,
349            worktrees,
350            visible,
351            selected,
352            mode: Mode::List,
353            filter: String::new(),
354            focus: Pane::List,
355            show_sidebar: true,
356            sidebar_width: 40,
357            sort: config.sort,
358            detail_scroll: 0,
359            size,
360            keymap: config.keymap,
361            columns: config.columns,
362            show_untracked: config.show_untracked,
363            remove_untracked_blocks: config.remove_untracked_blocks,
364            nerd_fonts: config.nerd_fonts,
365            mouse: config.mouse,
366            color: config.color,
367            palette: config.palette,
368            quit: false,
369            chosen: None,
370            exit_on_pr_checkout: false,
371        }
372    }
373
374    /// Sets the transient status-bar message and its severity (for coloring).
375    pub fn set_status(&mut self, message: impl Into<String>, kind: StatusKind) {
376        self.status_message = Some(message.into());
377        self.status_kind = kind;
378    }
379
380    /// Marks a background action in flight with the given label (issue #46),
381    /// resetting the spinner to its first frame.
382    pub fn begin_busy(&mut self, label: impl Into<String>) {
383        self.busy = Some(BusyState {
384            label: label.into(),
385            frame: 0,
386        });
387    }
388
389    /// Advances the busy spinner one frame (called on each animation tick); a
390    /// no-op when no action is in flight.
391    pub fn tick_busy(&mut self) {
392        if let Some(busy) = &mut self.busy {
393            busy.frame = busy.frame.wrapping_add(1);
394        }
395    }
396
397    /// Clears the busy state once the background action completes.
398    pub fn end_busy(&mut self) {
399        self.busy = None;
400    }
401
402    /// Whether a background action is in flight (input is gated while set).
403    pub fn is_busy(&self) -> bool {
404        self.busy.is_some()
405    }
406
407    /// The currently selected worktree, if any.
408    pub fn selected_worktree(&self) -> Option<&Worktree> {
409        self.visible
410            .get(self.selected)
411            .and_then(|&i| self.worktrees.get(i))
412    }
413
414    /// Whether a worktree's async fields have loaded (else it shows a spinner).
415    pub fn is_loaded(&self, worktree: &Worktree) -> bool {
416        self.loaded_paths.contains(&worktree.path)
417    }
418
419    /// Marks all rows as loading (clears the loaded set), for the initial render.
420    pub fn mark_loading(&mut self) {
421        self.loaded_paths.clear();
422    }
423
424    /// Marks a worktree's path as loaded.
425    pub fn mark_loaded(&mut self, path: PathBuf) {
426        self.loaded_paths.insert(path);
427    }
428
429    /// Whether the detail pane is visible at the current size.
430    pub fn detail_visible(&self) -> bool {
431        !self.show_sidebar || self.size.0 >= MIN_DETAIL_WIDTH
432    }
433
434    /// Replaces the worktrees (e.g. after a refresh), preserving the selection by
435    /// path and re-applying the sort and filter.
436    pub fn set_worktrees(&mut self, worktrees: Vec<Worktree>) {
437        let selected_path = self.selected_worktree().map(|w| w.path.clone());
438        self.worktrees = worktrees;
439        self.apply_sort();
440        self.recompute_visible();
441        if let Some(path) = selected_path {
442            self.select_path(&path);
443        }
444    }
445
446    /// Moves the selection by `delta`, clamped to the visible range. Changing
447    /// the selection resets the detail-pane scroll.
448    pub fn move_selection(&mut self, delta: isize) {
449        if self.visible.is_empty() {
450            return;
451        }
452        let max = self.visible.len() as isize - 1;
453        let next = (self.selected as isize + delta).clamp(0, max);
454        self.selected = next as usize;
455        self.detail_scroll = 0;
456    }
457
458    /// Selects the first / last visible row.
459    pub fn select_edge(&mut self, last: bool) {
460        if self.visible.is_empty() {
461            return;
462        }
463        self.selected = if last { self.visible.len() - 1 } else { 0 };
464        self.detail_scroll = 0;
465    }
466
467    /// Selects the visible row at display position `row`, if any.
468    pub fn select_row(&mut self, row: usize) {
469        if row < self.visible.len() {
470            self.selected = row;
471            self.detail_scroll = 0;
472        }
473    }
474
475    /// Scrolls the detail pane by `delta` lines (spec §10), clamped to roughly
476    /// the selected worktree's detail content so it cannot scroll into the void.
477    pub fn scroll_detail(&mut self, delta: isize) {
478        let max = self.selected_worktree().map_or(0, |w| {
479            // path/branch/base/status + the commit block + the PR block.
480            (w.recent_commits.len() + 10) as isize
481        });
482        let next = (self.detail_scroll as isize + delta).clamp(0, max.max(0));
483        self.detail_scroll = next as u16;
484    }
485
486    /// Cycles the sort field (spec §10 sort-cycle).
487    pub fn cycle_sort(&mut self) {
488        const ORDER: [SortKey; 6] = [
489            SortKey::Branch,
490            SortKey::Dirty,
491            SortKey::Ahead,
492            SortKey::Behind,
493            SortKey::Activity,
494            SortKey::Path,
495        ];
496        let current = ORDER.iter().position(|k| *k == self.sort.key).unwrap_or(0);
497        self.sort.key = ORDER[(current + 1) % ORDER.len()];
498        self.resort_preserving_selection();
499    }
500
501    /// Toggles the sort direction (spec §10 sort-reverse).
502    pub fn reverse_sort(&mut self) {
503        self.sort.descending = !self.sort.descending;
504        self.resort_preserving_selection();
505    }
506
507    /// Appends a character to the filter and recomputes the visible set.
508    pub fn filter_push(&mut self, c: char) {
509        self.filter.push(c);
510        self.recompute_visible();
511    }
512
513    /// Removes the last filter character.
514    pub fn filter_pop(&mut self) {
515        self.filter.pop();
516        self.recompute_visible();
517    }
518
519    /// Clears the filter.
520    pub fn clear_filter(&mut self) {
521        self.filter.clear();
522        self.recompute_visible();
523    }
524
525    /// Replaces the filter wholesale and recomputes the visible set, resetting
526    /// the selection to the first match. Used to seed the picker with a query
527    /// (e.g. the ambiguous-query fallback opens pre-filtered to that query).
528    pub(crate) fn apply_filter(&mut self, filter: String) {
529        self.filter = filter;
530        self.selected = 0;
531        self.recompute_visible();
532    }
533
534    /// Re-sorts worktrees and rebuilds the visible set, keeping the selection.
535    fn resort_preserving_selection(&mut self) {
536        let selected_path = self.selected_worktree().map(|w| w.path.clone());
537        self.apply_sort();
538        self.recompute_visible();
539        if let Some(path) = selected_path {
540            self.select_path(&path);
541        }
542    }
543
544    /// Sorts `worktrees` by the current spec, keeping the base (primary)
545    /// worktree pinned first (issue #4).
546    fn apply_sort(&mut self) {
547        crate::worktree_service::sort_worktrees_base_first(&mut self.worktrees, self.sort);
548    }
549
550    /// Recomputes `visible` from the filter, clamping the selection.
551    fn recompute_visible(&mut self) {
552        if self.filter.is_empty() {
553            self.visible = (0..self.worktrees.len()).collect();
554        } else {
555            let haystacks: Vec<String> = self.worktrees.iter().map(haystack).collect();
556            let matched = fuzzy::filter_indices(&haystacks, &self.filter);
557            // Keep worktree order rather than fuzzy-score order for stability.
558            let keep: std::collections::HashSet<usize> = matched.into_iter().collect();
559            self.visible = (0..self.worktrees.len())
560                .filter(|i| keep.contains(i))
561                .collect();
562        }
563        if self.selected >= self.visible.len() {
564            self.selected = self.visible.len().saturating_sub(1);
565        }
566    }
567
568    /// Selects the visible row whose worktree path matches `path`.
569    fn select_path(&mut self, path: &std::path::Path) {
570        if let Some(pos) = self
571            .visible
572            .iter()
573            .position(|&i| self.worktrees[i].path == path)
574        {
575            self.selected = pos;
576        }
577    }
578
579    /// Selects the visible row for the real worktree on `branch`, if present.
580    /// Returns whether a matching visible row was found — `false` when the row is
581    /// filtered out or absent, leaving the selection unchanged. Used to focus a
582    /// freshly created worktree (issue #52).
583    pub fn select_branch(&mut self, branch: &str) -> bool {
584        let Some(pos) = self.visible.iter().position(|&i| {
585            let w = &self.worktrees[i];
586            w.has_worktree && w.branch.as_deref() == Some(branch)
587        }) else {
588            return false;
589        };
590        self.selected = pos;
591        self.detail_scroll = 0;
592        true
593    }
594}
595
596/// The fuzzy-filter haystack for a worktree: branch + slug + path. A
597/// worktree-less branch row has only a virtual path (issue #47), so it matches on
598/// branch + slug alone.
599fn haystack(worktree: &Worktree) -> String {
600    let path = if worktree.has_worktree {
601        worktree.path.display().to_string()
602    } else {
603        String::new()
604    };
605    format!(
606        "{} {} {}",
607        worktree.branch.as_deref().unwrap_or(""),
608        worktree.slug.as_deref().unwrap_or(""),
609        path
610    )
611}
612
613#[cfg(test)]
614pub(crate) mod testutil {
615    use super::*;
616    use std::path::PathBuf;
617
618    /// Builds a worktree with a branch for tests.
619    pub(crate) fn wt(branch: &str, current: bool) -> Worktree {
620        let mut w = Worktree::new(PathBuf::from(format!("/r/{branch}")));
621        w.branch = Some(branch.to_string());
622        w.slug = Some(branch.replace('/', "-"));
623        w.is_current = current;
624        w
625    }
626
627    /// Builds a worktree-less branch row for tests (issue #47).
628    pub(crate) fn branch_row(branch: &str) -> Worktree {
629        let mut w = Worktree::new(PathBuf::from(format!("branch://{branch}")));
630        w.branch = Some(branch.to_string());
631        w.slug = Some(branch.replace('/', "-"));
632        w.has_worktree = false;
633        w
634    }
635
636    /// Builds an app over the given branches.
637    pub(crate) fn app(branches: &[(&str, bool)]) -> App {
638        let worktrees: Vec<Worktree> = branches.iter().map(|(b, c)| wt(b, *c)).collect();
639        App::new(
640            worktrees,
641            AppConfig {
642                keymap: Keymap::defaults(),
643                sort: SortSpec::default(),
644                columns: Column::ALL.to_vec(),
645                show_untracked: true,
646                remove_untracked_blocks: false,
647                nerd_fonts: false,
648                mouse: true,
649                color: true,
650                palette: Palette::one_dark(),
651            },
652            (100, 30),
653        )
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::testutil::app;
660    use super::*;
661
662    #[test]
663    fn selects_current_worktree_initially() {
664        let a = app(&[("main", false), ("feature", true)]);
665        assert_eq!(
666            a.selected_worktree().unwrap().branch.as_deref(),
667            Some("feature")
668        );
669    }
670
671    #[test]
672    fn navigation_clamps() {
673        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
674        a.selected = 0;
675        a.move_selection(-1);
676        assert_eq!(a.selected, 0);
677        a.move_selection(5);
678        assert_eq!(a.selected, 2);
679        a.select_edge(false);
680        assert_eq!(a.selected, 0);
681        a.select_edge(true);
682        assert_eq!(a.selected, 2);
683    }
684
685    #[test]
686    fn filter_narrows_and_clamps_selection() {
687        let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
688        a.selected = 2;
689        a.filter_push('a');
690        a.filter_push('l');
691        a.filter_push('p');
692        // Only alpha + alphabet match.
693        assert_eq!(a.visible.len(), 2);
694        assert!(a.selected < a.visible.len());
695        a.clear_filter();
696        assert_eq!(a.visible.len(), 3);
697    }
698
699    #[test]
700    fn apply_filter_seeds_filter_and_resets_selection() {
701        let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
702        a.selected = 2;
703        a.apply_filter("alph".to_string());
704        assert_eq!(a.filter, "alph");
705        // Only alpha + alphabet match; selection resets to the first match.
706        assert_eq!(a.visible.len(), 2);
707        assert_eq!(a.selected, 0);
708    }
709
710    #[test]
711    fn sort_preserves_selection_by_path() {
712        let mut a = app(&[("zebra", false), ("alpha", true), ("mango", false)]);
713        // Sort by branch ascending.
714        a.sort = SortSpec {
715            key: SortKey::Branch,
716            descending: false,
717        };
718        a.resort_preserving_selection();
719        // The current worktree (alpha) is still selected.
720        assert_eq!(
721            a.selected_worktree().unwrap().branch.as_deref(),
722            Some("alpha")
723        );
724    }
725
726    #[test]
727    fn base_worktree_stays_first_after_sort() {
728        let mut a = app(&[("zebra", false), ("main", true), ("alpha", false)]);
729        // Mark "main" as the primary (base) worktree.
730        let base = a
731            .worktrees
732            .iter()
733            .position(|w| w.branch.as_deref() == Some("main"))
734            .unwrap();
735        a.worktrees[base].is_main = true;
736        a.sort = SortSpec {
737            key: SortKey::Branch,
738            descending: false,
739        };
740        a.resort_preserving_selection();
741        // The base is pinned first; the rest follow in sorted order.
742        let order: Vec<&str> = a
743            .visible
744            .iter()
745            .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
746            .collect();
747        assert_eq!(order, vec!["main", "alpha", "zebra"]);
748        // The current worktree (main) remains selected after the resort.
749        assert_eq!(
750            a.selected_worktree().unwrap().branch.as_deref(),
751            Some("main")
752        );
753    }
754
755    #[test]
756    fn cycle_sort_advances_field() {
757        let mut a = app(&[("a", true)]);
758        assert_eq!(a.sort.key, SortKey::Branch);
759        a.cycle_sort();
760        assert_eq!(a.sort.key, SortKey::Dirty);
761        a.reverse_sort();
762        assert!(a.sort.descending);
763    }
764
765    #[test]
766    fn detail_visible_respects_width() {
767        let mut a = app(&[("a", true)]);
768        a.size = (100, 30);
769        assert!(a.detail_visible());
770        a.size = (50, 30); // < 60 cols
771        assert!(!a.detail_visible());
772        a.show_sidebar = false; // full-screen detail
773        assert!(a.detail_visible());
774    }
775
776    #[test]
777    fn branch_rows_sort_below_worktrees_and_filter_by_name() {
778        use super::testutil::branch_row;
779        let mut a = app(&[("main", true), ("zebra", false)]);
780        a.worktrees.push(branch_row("feature/lonely"));
781        // A resort groups branch rows below the worktrees (issue #47).
782        a.resort_preserving_selection();
783        let order: Vec<&str> = a
784            .visible
785            .iter()
786            .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
787            .collect();
788        assert_eq!(order, vec!["main", "zebra", "feature/lonely"]);
789        // The branch row matches on its name even though its path is virtual.
790        a.apply_filter("lonely".into());
791        assert_eq!(a.visible.len(), 1);
792        assert_eq!(
793            a.selected_worktree().unwrap().branch.as_deref(),
794            Some("feature/lonely")
795        );
796    }
797
798    #[test]
799    fn select_row_within_bounds() {
800        let mut a = app(&[("a", true), ("b", false)]);
801        a.select_row(1);
802        assert_eq!(a.selected, 1);
803        a.select_row(99); // out of bounds -> no change
804        assert_eq!(a.selected, 1);
805    }
806
807    #[test]
808    fn select_branch_focuses_match() {
809        let mut a = app(&[("main", true), ("feature/x", false), ("other", false)]);
810        a.selected = 0;
811        assert!(a.select_branch("feature/x"));
812        assert_eq!(
813            a.selected_worktree().unwrap().branch.as_deref(),
814            Some("feature/x")
815        );
816    }
817
818    #[test]
819    fn select_branch_misses_leave_selection_unchanged() {
820        let mut a = app(&[("alpha", true), ("beta", false)]);
821        a.selected = 1;
822        // A branch that exists but is filtered out of the visible set.
823        a.apply_filter("alph".into());
824        a.selected = 0;
825        assert!(!a.select_branch("beta"));
826        assert_eq!(a.selected, 0);
827        // A branch that is not present at all.
828        assert!(!a.select_branch("ghost"));
829        assert_eq!(a.selected, 0);
830    }
831
832    #[test]
833    fn select_branch_ignores_worktree_less_branch_rows() {
834        use super::testutil::branch_row;
835        let mut a = app(&[("main", true)]);
836        a.worktrees.push(branch_row("topic"));
837        a.apply_filter(String::new()); // include the branch row in `visible`
838        a.selected = 0;
839        // A worktree-less branch row is not a created worktree to focus.
840        assert!(!a.select_branch("topic"));
841    }
842
843    #[test]
844    fn busy_lifecycle_begin_tick_end() {
845        let mut a = app(&[("a", true)]);
846        assert!(!a.is_busy());
847        a.begin_busy("Removing feat/foo");
848        assert!(a.is_busy());
849        assert_eq!(a.busy.as_ref().unwrap().frame, 0);
850        assert_eq!(a.busy.as_ref().unwrap().label, "Removing feat/foo");
851        a.tick_busy();
852        a.tick_busy();
853        assert_eq!(a.busy.as_ref().unwrap().frame, 2);
854        a.end_busy();
855        assert!(!a.is_busy());
856    }
857
858    #[test]
859    fn tick_busy_is_noop_when_idle() {
860        let mut a = app(&[("a", true)]);
861        a.tick_busy();
862        assert!(!a.is_busy());
863    }
864}