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::event::Effect;
12use crate::tui::options::OptionList;
13use crate::tui::theme::Palette;
14use crate::util::fuzzy;
15
16/// The narrowest terminal width at which the detail pane is shown (spec §10).
17pub const MIN_DETAIL_WIDTH: u16 = 60;
18/// The terminal height below which the TUI exits cleanly (spec §10).
19pub const MIN_HEIGHT: u16 = 5;
20
21/// The interaction mode (spec §10 "View modes").
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Mode {
24    /// The default worktree list.
25    List,
26    /// Fuzzy-filter overlay.
27    Filter,
28    /// Create-worktree prompt.
29    Create(CreateState),
30    /// PR picker overlay.
31    PrPicker(PrPickerState),
32    /// PR compose form (`wt pr open`): edit a title + body, then submit.
33    PrCompose(PrComposeState),
34    /// Branch picker for checking out a branch in the selected worktree.
35    Checkout(CheckoutState),
36    /// Confirm-remove dialog (the worktree index).
37    ConfirmRemove(usize),
38    /// Confirm creating a worktree for the worktree-less branch row at the given
39    /// index, then switching into it (issue #47).
40    ConfirmCreate(usize),
41    /// Confirm-delete dialog for the worktree-less branch row at `index` (issue
42    /// #53). A branch row has no worktree to remove, so Remove deletes its local
43    /// branch instead. `force` is set on the second prompt, after a safe
44    /// `git branch -d` refused an unmerged branch, to offer a `git branch -D`.
45    ConfirmDeleteBranch {
46        /// Index into [`App::worktrees`] of the branch row to delete.
47        index: usize,
48        /// Whether this is the force-delete (`-D`) re-prompt for an unmerged branch.
49        force: bool,
50    },
51    /// Confirm dialog shown when the base a new worktree would fork from is behind
52    /// its origin counterpart (issue #56): update the base, proceed as-is, or cancel.
53    ConfirmStaleBase(StaleBaseState),
54    /// Confirm dialog shown after a worktree is created with uninitialized
55    /// submodules and the `[submodules] init` policy is left at its `prompt`
56    /// default (issue #50): initialize them recursively, or leave them. Defaults
57    /// to yes.
58    ConfirmInitSubmodules(InitSubmodulesState),
59    /// The single blocking overlay shown when an exit is requested (quit or
60    /// switch) while background jobs are still running (issue #46 overhaul). It
61    /// blocks the TUI and richly explains why: the pending [`ExitIntent`] is held
62    /// so the loop can complete it once the jobs drain, while the user can abandon
63    /// them (killing in-flight git subprocesses) or keep working.
64    ExitBlocked(ExitBlockedState),
65    /// Help overlay.
66    Help,
67}
68
69/// Why the TUI is trying to leave (issue #46 overhaul): the exit a user — or,
70/// defensively, a finished job — requested, held on [`Mode::ExitBlocked`] so a
71/// blocked exit can be replayed once the background jobs drain, or forced
72/// immediately if the user abandons them.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ExitIntent {
75    /// Quit without switching (the `q` path).
76    Quit,
77    /// Switch into the worktree at this path (the Enter path).
78    Switch(PathBuf),
79}
80
81/// The state behind [`Mode::ExitBlocked`]: the pending exit to complete once the
82/// in-flight background jobs finish. Rendered as a blocking overlay listing the
83/// running jobs and the cost of abandoning them.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ExitBlockedState {
86    /// What the user is trying to do, replayed when the jobs drain or forced when
87    /// the user abandons them.
88    pub intent: ExitIntent,
89}
90
91/// Which pane has focus.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Pane {
94    /// The worktree list (left).
95    List,
96    /// The detail pane (right).
97    Detail,
98}
99
100/// The severity of a transient status-bar message, used to color it.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102pub enum StatusKind {
103    /// A neutral, uncolored message.
104    #[default]
105    Info,
106    /// A successful action (e.g. "created feature/x").
107    Success,
108    /// A failed action (e.g. a git error).
109    Error,
110}
111
112/// Identifies the target of a background job so its per-row spinner can be found
113/// and so a second action on the same target can be refused (issue #46 overhaul).
114/// Keyed by the row's stable identity (path or branch name) so it survives a
115/// re-sort/refresh, mirroring [`App::loaded_paths`].
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum JobKey {
118    /// A job targeting the worktree at this path (remove, sync, checkout, submodule
119    /// init).
120    Path(PathBuf),
121    /// A job targeting the worktree-less branch row with this name (delete branch,
122    /// materialize, branch-row sync).
123    Branch(String),
124    /// A job with no existing row yet (creating a brand-new worktree, or checking
125    /// out a PR into a new branch): it has nothing to attach a per-row spinner to,
126    /// so it shows only in the status-bar summary.
127    New(String),
128}
129
130/// An in-flight background action (issue #46 overhaul). Multiple jobs run
131/// concurrently, each attached to its target row via [`JobKey`]; the shared
132/// [`App::spinner_frame`] animates every row spinner in sync.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ActiveJob {
135    /// The target this job acts on.
136    pub key: JobKey,
137    /// The human-facing label, e.g. `Removing feat/foo` or `Initializing submodules`.
138    pub label: String,
139}
140
141/// The interaction context a finished job is allowed to drive a mode change from,
142/// so a background job never clobbers an unrelated modal the user opened while it
143/// ran (issue #46 overhaul). A job may transition the mode only when the user is
144/// idle (List/Filter) or still in the job's own single-instance modal.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum JobHome {
147    /// The job's confirm dialog already closed to the list before it began, so it
148    /// may act only when the user is idle.
149    List,
150    /// The create modal stays open (submitting) during a create job.
151    Create,
152    /// The checkout picker stays open during an in-place checkout.
153    Checkout,
154    /// The PR picker stays open during a PR checkout.
155    PrPicker,
156}
157
158/// The create-worktree prompt state.
159#[derive(Debug, Clone, Default, PartialEq, Eq)]
160pub struct CreateState {
161    /// Which field is being edited.
162    pub step: CreateStep,
163    /// The entered branch name.
164    pub branch: String,
165    /// The entered base ref.
166    pub base: String,
167    /// An inline error from a failed submission.
168    pub error: Option<String>,
169    /// The inline branch-options dropdown for the active field (issue #25):
170    /// existing local + remote branches to fork from or check out.
171    pub options: OptionList,
172}
173
174/// The stale-base confirm state (issue #56): the base a new worktree would fork
175/// from is behind its upstream. Carries the pending create's inputs so the
176/// user's choice (update / proceed) can re-issue it.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct StaleBaseState {
179    /// The new branch name being created.
180    pub branch: String,
181    /// The base ref the user entered (or `None` for the default).
182    pub base: Option<String>,
183    /// How many commits the base is behind its upstream.
184    pub behind: u32,
185    /// The upstream display name, e.g. `origin/main`.
186    pub upstream_display: String,
187    /// Whether the base can be fast-forwarded (no local-only commits); when
188    /// false, updating will fail and only proceed/cancel make sense.
189    pub can_fast_forward: bool,
190}
191
192/// The submodule-init confirm state (issue #50): a freshly created worktree has
193/// uninitialized submodules and the policy is left at its `prompt` default.
194/// Carries the new worktree directory (where the init runs) and what to say.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct InitSubmodulesState {
197    /// The new worktree directory whose submodules would be initialized.
198    pub dir: PathBuf,
199    /// The branch the worktree was created for (for the status text).
200    pub branch: String,
201    /// How many uninitialized submodules were detected.
202    pub count: usize,
203}
204
205/// Which create-prompt field is active.
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
207pub enum CreateStep {
208    /// Editing the branch name.
209    #[default]
210    Branch,
211    /// Editing the base ref.
212    Base,
213}
214
215/// The checkout-branch picker state: a type-ahead branch list plus the target
216/// worktree to switch in place.
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub struct CheckoutState {
219    /// Index into [`App::worktrees`] of the target worktree (the selected row).
220    pub worktree_index: usize,
221    /// The type-ahead query (the branch the user is filtering/typing).
222    pub query: String,
223    /// The inline branch-options dropdown (local + remote branches to check out).
224    pub options: OptionList,
225    /// An inline error from a failed checkout (e.g. a dirty worktree).
226    pub error: Option<String>,
227    /// Whether a checkout is in flight (input is ignored while set).
228    pub submitting: bool,
229}
230
231/// One PR shown in the picker.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct PrItem {
234    /// PR number.
235    pub number: u64,
236    /// PR title.
237    pub title: String,
238    /// PR author login.
239    pub author: String,
240    /// PR state label.
241    pub state: String,
242    /// ISO-8601 creation time, used to render a relative age.
243    pub created_at: String,
244}
245
246/// Which PR-compose field is active.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
248pub enum ComposeField {
249    /// Editing the single-line title.
250    #[default]
251    Title,
252    /// Editing the multi-line body.
253    Body,
254    /// Selecting the AI auto-fill model from its options dropdown (issue #25).
255    Model,
256    /// Selecting the AI auto-fill effort from its options dropdown (issue #25).
257    Effort,
258}
259
260/// The `wt pr open` compose-form state: a title and (multi-line) body the user
261/// edits before submitting, plus the precomputed header context.
262#[derive(Debug, Clone, Default, PartialEq, Eq)]
263pub struct PrComposeState {
264    /// Which field is being edited.
265    pub field: ComposeField,
266    /// The PR title (single line).
267    pub title: String,
268    /// The PR body (may contain newlines).
269    pub body: String,
270    /// Whether to open the PR as a draft (create only).
271    pub draft: bool,
272    /// The current branch (for the header).
273    pub branch: String,
274    /// The base/trunk branch (for the header).
275    pub trunk: String,
276    /// Precomputed action label, e.g. `create` or `update #12`.
277    pub action_label: String,
278    /// The model used for AI auto-fill (`Ctrl-A`), cycled with `Ctrl-M`.
279    pub model: AgentModel,
280    /// The effort used for AI auto-fill, cycled with `Ctrl-E`.
281    pub effort: Effort,
282    /// Whether a submit/draft operation is in flight (shown as a hint).
283    pub submitting: bool,
284    /// An inline error from a failed draft or submission.
285    pub error: Option<String>,
286}
287
288/// The PR-picker state.
289#[derive(Debug, Clone, Default, PartialEq, Eq)]
290pub struct PrPickerState {
291    /// Whether PRs are still loading.
292    pub loading: bool,
293    /// The loaded PRs.
294    pub prs: Vec<PrItem>,
295    /// The selected PR index.
296    pub selected: usize,
297    /// An error (e.g. gh unavailable).
298    pub error: Option<String>,
299}
300
301/// The TUI application state.
302pub struct App {
303    /// All worktrees (sorted).
304    pub worktrees: Vec<Worktree>,
305    /// Indices into `worktrees` currently visible (after filtering).
306    pub visible: Vec<usize>,
307    /// Selected index into `visible`.
308    pub selected: usize,
309    /// The current mode.
310    pub mode: Mode,
311    /// The active filter string.
312    pub filter: String,
313    /// Which pane has focus.
314    pub focus: Pane,
315    /// Whether the list (sidebar) pane is shown.
316    pub show_sidebar: bool,
317    /// The list pane width.
318    pub sidebar_width: u16,
319    /// The current sort.
320    pub sort: SortSpec,
321    /// Scroll offset of the detail pane.
322    pub detail_scroll: u16,
323    /// Terminal size (cols, rows).
324    pub size: (u16, u16),
325    /// The key bindings.
326    pub keymap: Keymap,
327    /// Columns to render in the list.
328    pub columns: Vec<Column>,
329    /// Whether untracked files show `?`.
330    pub show_untracked: bool,
331    /// Whether untracked-only files count as "dirty" for the remove guard
332    /// (the confirm dialog mirrors `remove.untracked_blocks`, not `show_untracked`).
333    pub remove_untracked_blocks: bool,
334    /// Whether Nerd Font glyphs are enabled.
335    pub nerd_fonts: bool,
336    /// Whether mouse support is enabled.
337    pub mouse: bool,
338    /// Whether color output is enabled (spec §11 precedence, resolved once).
339    pub color: bool,
340    /// The resolved color palette (preset + `[ui.theme]` overrides).
341    pub palette: Palette,
342    /// Set when the user quits without switching.
343    pub quit: bool,
344    /// Set to the chosen path when the user switches (Enter).
345    pub chosen: Option<PathBuf>,
346    /// Worktree paths whose async fields have loaded; rows not in this set show
347    /// the per-row spinner (spec §10). Keyed by path so it survives re-sorting.
348    loaded_paths: std::collections::HashSet<PathBuf>,
349    /// A transient status/error line shown in the status bar.
350    pub status_message: Option<String>,
351    /// The severity of `status_message`, used to color it.
352    pub status_kind: StatusKind,
353    /// Set when the terminal became too small to continue (spec §10).
354    pub too_small: bool,
355    /// The in-flight background actions (issue #46 overhaul): each runs on its own
356    /// task and shows a per-row spinner; input is never gated. Empty when idle.
357    pub jobs: Vec<ActiveJob>,
358    /// The shared spinner animation frame, advanced on each tick while any job is
359    /// in flight; every per-row job spinner reads it so they animate in sync.
360    pub spinner_frame: usize,
361    /// Follow-up background actions queued by a just-applied job outcome (e.g. a
362    /// created worktree with uninitialized submodules under the `always` policy),
363    /// drained by the event loop after `apply_outcome` and spawned as their own
364    /// jobs. Kept off the render path.
365    pub pending_jobs: Vec<Effect>,
366    /// Local + remote-tracking branch names offered in the create-prompt
367    /// options dropdown and used to tab-complete the base ref (best-effort;
368    /// empty when enumeration fails).
369    pub branches: Vec<String>,
370    /// The remote-tracking default branch (e.g. `origin/main`) a new worktree
371    /// forks from by default, pre-filled into the create-prompt base field
372    /// (issue #70). `None` when there is no confident remote default (no
373    /// `origin/HEAD`), in which case the base starts empty.
374    pub default_base: Option<String>,
375}
376
377/// Display/config inputs for the TUI (the parts of [`crate::config::Config`]
378/// the view needs), bundled to keep [`App::new`] tidy.
379pub struct AppConfig {
380    /// The effective key bindings.
381    pub keymap: Keymap,
382    /// The initial sort.
383    pub sort: SortSpec,
384    /// Columns to render in the list.
385    pub columns: Vec<Column>,
386    /// Whether untracked files show `?`.
387    pub show_untracked: bool,
388    /// Whether untracked-only files count as "dirty" for the remove guard.
389    pub remove_untracked_blocks: bool,
390    /// Whether Nerd Font glyphs are enabled.
391    pub nerd_fonts: bool,
392    /// Whether mouse support is enabled.
393    pub mouse: bool,
394    /// Whether color output is enabled (spec §11 precedence, resolved once).
395    pub color: bool,
396    /// The resolved color palette (preset + `[ui.theme]` overrides).
397    pub palette: Palette,
398}
399
400impl App {
401    /// Builds an app over the given worktrees, selecting the current one. All
402    /// rows start marked loaded; the runtime marks them loading before async
403    /// enrichment.
404    pub fn new(worktrees: Vec<Worktree>, config: AppConfig, size: (u16, u16)) -> App {
405        let visible = (0..worktrees.len()).collect();
406        let selected = worktrees.iter().position(|w| w.is_current).unwrap_or(0);
407        let loaded_paths = worktrees.iter().map(|w| w.path.clone()).collect();
408        App {
409            loaded_paths,
410            status_message: None,
411            status_kind: StatusKind::Info,
412            too_small: false,
413            jobs: Vec::new(),
414            spinner_frame: 0,
415            pending_jobs: Vec::new(),
416            branches: Vec::new(),
417            default_base: None,
418            worktrees,
419            visible,
420            selected,
421            mode: Mode::List,
422            filter: String::new(),
423            focus: Pane::List,
424            show_sidebar: true,
425            sidebar_width: 40,
426            sort: config.sort,
427            detail_scroll: 0,
428            size,
429            keymap: config.keymap,
430            columns: config.columns,
431            show_untracked: config.show_untracked,
432            remove_untracked_blocks: config.remove_untracked_blocks,
433            nerd_fonts: config.nerd_fonts,
434            mouse: config.mouse,
435            color: config.color,
436            palette: config.palette,
437            quit: false,
438            chosen: None,
439        }
440    }
441
442    /// Sets the transient status-bar message and its severity (for coloring).
443    pub fn set_status(&mut self, message: impl Into<String>, kind: StatusKind) {
444        self.status_message = Some(message.into());
445        self.status_kind = kind;
446    }
447
448    /// Registers a background job on `key` with a display label (issue #46
449    /// overhaul). If a job already targets `key` it is replaced (the caller
450    /// guards against conflicts first via [`App::has_job`]).
451    pub fn begin_job(&mut self, key: JobKey, label: impl Into<String>) {
452        self.jobs.retain(|j| j.key != key);
453        self.jobs.push(ActiveJob {
454            key,
455            label: label.into(),
456        });
457    }
458
459    /// Removes the job targeting `key` once it completes; a no-op if absent.
460    pub fn finish_job(&mut self, key: &JobKey) {
461        self.jobs.retain(|j| &j.key != key);
462    }
463
464    /// Whether a background job already targets `key` (used to refuse a second,
465    /// conflicting action on the same row).
466    pub fn has_job(&self, key: &JobKey) -> bool {
467        self.jobs.iter().any(|j| &j.key == key)
468    }
469
470    /// The active job attached to `worktree`'s row, if any, so the list can render
471    /// its per-row spinner and label. Matches a `Path` job by path and a `Branch`
472    /// job by the branch-row's name; `New` jobs attach to no row.
473    pub fn job_for(&self, worktree: &Worktree) -> Option<&ActiveJob> {
474        self.jobs.iter().find(|j| match &j.key {
475            JobKey::Path(p) => worktree.has_worktree && &worktree.path == p,
476            JobKey::Branch(b) => {
477                !worktree.has_worktree && worktree.branch.as_deref() == Some(b.as_str())
478            }
479            JobKey::New(_) => false,
480        })
481    }
482
483    /// Advances the shared spinner one frame (called on each animation tick); a
484    /// no-op when no job is in flight.
485    pub fn tick_spinner(&mut self) {
486        if !self.jobs.is_empty() {
487            self.spinner_frame = self.spinner_frame.wrapping_add(1);
488        }
489    }
490
491    /// Whether any background job is in flight (keeps the animation ticker awake).
492    pub fn any_jobs(&self) -> bool {
493        !self.jobs.is_empty()
494    }
495
496    /// The single exit gate every voluntary-exit path funnels through (issue #46
497    /// overhaul): pressing `q`, or Enter to switch into a worktree. With no
498    /// background jobs it commits `intent` immediately; with jobs in flight it
499    /// opens the blocking [`Mode::ExitBlocked`] overlay instead and returns
500    /// [`Effect::None`], so the loop keeps running (and finishing its jobs) until
501    /// they drain or the user decides.
502    pub fn request_exit(&mut self, intent: ExitIntent) -> Effect {
503        if self.any_jobs() {
504            self.mode = Mode::ExitBlocked(ExitBlockedState { intent });
505            Effect::None
506        } else {
507            self.commit_exit(intent)
508        }
509    }
510
511    /// Records a committed exit on the app and returns its exit [`Effect`]: a quit,
512    /// or a switch into the chosen worktree. Shared by the immediate path (no jobs)
513    /// and the deferred/abandon paths out of [`Mode::ExitBlocked`].
514    pub(crate) fn commit_exit(&mut self, intent: ExitIntent) -> Effect {
515        match intent {
516            ExitIntent::Quit => {
517                self.quit = true;
518                Effect::Quit
519            }
520            ExitIntent::Switch(path) => {
521                self.chosen = Some(path.clone());
522                Effect::Switch(path)
523            }
524        }
525    }
526
527    /// Whether the event loop should exit after a background job just finished —
528    /// the single break predicate the loop consults (issue #46 overhaul). A blocked
529    /// exit that has been waiting for its jobs completes once the last one drains;
530    /// otherwise a job that recorded a path to switch into still exits (defensive —
531    /// no job does today).
532    pub fn exit_now(&mut self) -> bool {
533        if let Mode::ExitBlocked(state) = &self.mode
534            && !self.any_jobs()
535        {
536            let intent = state.intent.clone();
537            self.commit_exit(intent);
538            return true;
539        }
540        self.chosen.is_some()
541    }
542
543    /// A compact status-bar summary of the in-flight jobs — the count and the
544    /// first job's label — so background work stays visible even when its row is
545    /// scrolled off. `None` when idle.
546    pub fn job_summary(&self) -> Option<String> {
547        let first = self.jobs.first()?;
548        Some(if self.jobs.len() == 1 {
549            format!("{}…", first.label)
550        } else {
551            format!("{} (+{} more)…", first.label, self.jobs.len() - 1)
552        })
553    }
554
555    /// Whether a finished job in `home` context may drive a mode change without
556    /// clobbering an unrelated modal the user opened while it ran (issue #46
557    /// overhaul): true when the user is idle or still in the job's own modal.
558    pub fn may_apply_mode(&self, home: JobHome) -> bool {
559        matches!(self.mode, Mode::List | Mode::Filter)
560            || match home {
561                JobHome::List => false,
562                JobHome::Create => matches!(self.mode, Mode::Create(_)),
563                JobHome::Checkout => matches!(self.mode, Mode::Checkout(_)),
564                JobHome::PrPicker => matches!(self.mode, Mode::PrPicker(_)),
565            }
566    }
567
568    /// Queues a follow-up background action for the loop to spawn after the
569    /// current outcome is applied (e.g. submodule init after a create).
570    pub fn queue_job(&mut self, effect: Effect) {
571        self.pending_jobs.push(effect);
572    }
573
574    /// Drains the queued follow-up actions (issue #46 overhaul).
575    pub fn take_pending_jobs(&mut self) -> Vec<Effect> {
576        std::mem::take(&mut self.pending_jobs)
577    }
578
579    /// The currently selected worktree, if any.
580    pub fn selected_worktree(&self) -> Option<&Worktree> {
581        self.visible
582            .get(self.selected)
583            .and_then(|&i| self.worktrees.get(i))
584    }
585
586    /// Whether a worktree's async fields have loaded (else it shows a spinner).
587    pub fn is_loaded(&self, worktree: &Worktree) -> bool {
588        self.loaded_paths.contains(&worktree.path)
589    }
590
591    /// Marks all rows as loading (clears the loaded set), for the initial render.
592    pub fn mark_loading(&mut self) {
593        self.loaded_paths.clear();
594    }
595
596    /// Marks a worktree's path as loaded.
597    pub fn mark_loaded(&mut self, path: PathBuf) {
598        self.loaded_paths.insert(path);
599    }
600
601    /// Whether the detail pane is visible at the current size.
602    pub fn detail_visible(&self) -> bool {
603        !self.show_sidebar || self.size.0 >= MIN_DETAIL_WIDTH
604    }
605
606    /// Replaces the worktrees (e.g. after a refresh), preserving the selection by
607    /// path and re-applying the sort and filter.
608    pub fn set_worktrees(&mut self, worktrees: Vec<Worktree>) {
609        let selected_path = self.selected_worktree().map(|w| w.path.clone());
610        self.worktrees = worktrees;
611        self.apply_sort();
612        self.recompute_visible();
613        if let Some(path) = selected_path {
614            self.select_path(&path);
615        }
616    }
617
618    /// Moves the selection by `delta`, clamped to the visible range. Changing
619    /// the selection resets the detail-pane scroll.
620    pub fn move_selection(&mut self, delta: isize) {
621        if self.visible.is_empty() {
622            return;
623        }
624        let max = self.visible.len() as isize - 1;
625        let next = (self.selected as isize + delta).clamp(0, max);
626        self.selected = next as usize;
627        self.detail_scroll = 0;
628    }
629
630    /// Selects the first / last visible row.
631    pub fn select_edge(&mut self, last: bool) {
632        if self.visible.is_empty() {
633            return;
634        }
635        self.selected = if last { self.visible.len() - 1 } else { 0 };
636        self.detail_scroll = 0;
637    }
638
639    /// Selects the visible row at display position `row`, if any.
640    pub fn select_row(&mut self, row: usize) {
641        if row < self.visible.len() {
642            self.selected = row;
643            self.detail_scroll = 0;
644        }
645    }
646
647    /// Scrolls the detail pane by `delta` lines (spec §10), clamped to roughly
648    /// the selected worktree's detail content so it cannot scroll into the void.
649    pub fn scroll_detail(&mut self, delta: isize) {
650        let max = self.selected_worktree().map_or(0, |w| {
651            // path/branch/base/status + the commit block + the PR block.
652            (w.recent_commits.len() + 10) as isize
653        });
654        let next = (self.detail_scroll as isize + delta).clamp(0, max.max(0));
655        self.detail_scroll = next as u16;
656    }
657
658    /// Cycles the sort field (spec §10 sort-cycle).
659    pub fn cycle_sort(&mut self) {
660        const ORDER: [SortKey; 6] = [
661            SortKey::Branch,
662            SortKey::Dirty,
663            SortKey::Ahead,
664            SortKey::Behind,
665            SortKey::Activity,
666            SortKey::Path,
667        ];
668        let current = ORDER.iter().position(|k| *k == self.sort.key).unwrap_or(0);
669        self.sort.key = ORDER[(current + 1) % ORDER.len()];
670        self.resort_preserving_selection();
671    }
672
673    /// Toggles the sort direction (spec §10 sort-reverse).
674    pub fn reverse_sort(&mut self) {
675        self.sort.descending = !self.sort.descending;
676        self.resort_preserving_selection();
677    }
678
679    /// Appends a character to the filter and recomputes the visible set.
680    pub fn filter_push(&mut self, c: char) {
681        self.filter.push(c);
682        self.recompute_visible();
683    }
684
685    /// Removes the last filter character.
686    pub fn filter_pop(&mut self) {
687        self.filter.pop();
688        self.recompute_visible();
689    }
690
691    /// Clears the filter.
692    pub fn clear_filter(&mut self) {
693        self.filter.clear();
694        self.recompute_visible();
695    }
696
697    /// Replaces the filter wholesale and recomputes the visible set, resetting
698    /// the selection to the first match. Used to seed the picker with a query
699    /// (e.g. the ambiguous-query fallback opens pre-filtered to that query).
700    pub(crate) fn apply_filter(&mut self, filter: String) {
701        self.filter = filter;
702        self.selected = 0;
703        self.recompute_visible();
704    }
705
706    /// Re-sorts worktrees and rebuilds the visible set, keeping the selection.
707    fn resort_preserving_selection(&mut self) {
708        let selected_path = self.selected_worktree().map(|w| w.path.clone());
709        self.apply_sort();
710        self.recompute_visible();
711        if let Some(path) = selected_path {
712            self.select_path(&path);
713        }
714    }
715
716    /// Sorts `worktrees` by the current spec, keeping the base (primary)
717    /// worktree pinned first (issue #4).
718    fn apply_sort(&mut self) {
719        crate::worktree_service::sort_worktrees_base_first(&mut self.worktrees, self.sort);
720    }
721
722    /// Recomputes `visible` from the filter, clamping the selection.
723    fn recompute_visible(&mut self) {
724        if self.filter.is_empty() {
725            self.visible = (0..self.worktrees.len()).collect();
726        } else {
727            let haystacks: Vec<String> = self.worktrees.iter().map(haystack).collect();
728            let matched = fuzzy::filter_indices(&haystacks, &self.filter);
729            // Keep worktree order rather than fuzzy-score order for stability.
730            let keep: std::collections::HashSet<usize> = matched.into_iter().collect();
731            self.visible = (0..self.worktrees.len())
732                .filter(|i| keep.contains(i))
733                .collect();
734        }
735        if self.selected >= self.visible.len() {
736            self.selected = self.visible.len().saturating_sub(1);
737        }
738    }
739
740    /// Selects the visible row whose worktree path matches `path`.
741    pub fn select_path(&mut self, path: &std::path::Path) {
742        if let Some(pos) = self
743            .visible
744            .iter()
745            .position(|&i| self.worktrees[i].path == path)
746        {
747            self.selected = pos;
748        }
749    }
750
751    /// Selects the visible row for the real worktree on `branch`, if present.
752    /// Returns whether a matching visible row was found — `false` when the row is
753    /// filtered out or absent, leaving the selection unchanged. Used to focus a
754    /// freshly created worktree (issue #52).
755    pub fn select_branch(&mut self, branch: &str) -> bool {
756        let Some(pos) = self.visible.iter().position(|&i| {
757            let w = &self.worktrees[i];
758            w.has_worktree && w.branch.as_deref() == Some(branch)
759        }) else {
760            return false;
761        };
762        self.selected = pos;
763        self.detail_scroll = 0;
764        true
765    }
766}
767
768/// The fuzzy-filter haystack for a worktree: branch + slug + path. A
769/// worktree-less branch row has only a virtual path (issue #47), so it matches on
770/// branch + slug alone.
771fn haystack(worktree: &Worktree) -> String {
772    let path = if worktree.has_worktree {
773        worktree.path.display().to_string()
774    } else {
775        String::new()
776    };
777    format!(
778        "{} {} {}",
779        worktree.branch.as_deref().unwrap_or(""),
780        worktree.slug.as_deref().unwrap_or(""),
781        path
782    )
783}
784
785#[cfg(test)]
786pub(crate) mod testutil {
787    use super::*;
788    use std::path::PathBuf;
789
790    /// Builds a worktree with a branch for tests.
791    pub(crate) fn wt(branch: &str, current: bool) -> Worktree {
792        let mut w = Worktree::new(PathBuf::from(format!("/r/{branch}")));
793        w.branch = Some(branch.to_string());
794        w.slug = Some(branch.replace('/', "-"));
795        w.is_current = current;
796        w
797    }
798
799    /// Builds a worktree-less branch row for tests (issue #47).
800    pub(crate) fn branch_row(branch: &str) -> Worktree {
801        let mut w = Worktree::new(PathBuf::from(format!("branch://{branch}")));
802        w.branch = Some(branch.to_string());
803        w.slug = Some(branch.replace('/', "-"));
804        w.has_worktree = false;
805        w
806    }
807
808    /// Builds an app over the given branches.
809    pub(crate) fn app(branches: &[(&str, bool)]) -> App {
810        let worktrees: Vec<Worktree> = branches.iter().map(|(b, c)| wt(b, *c)).collect();
811        App::new(
812            worktrees,
813            AppConfig {
814                keymap: Keymap::defaults(),
815                sort: SortSpec::default(),
816                columns: Column::ALL.to_vec(),
817                show_untracked: true,
818                remove_untracked_blocks: false,
819                nerd_fonts: false,
820                mouse: true,
821                color: true,
822                palette: Palette::one_dark(),
823            },
824            (100, 30),
825        )
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::testutil::app;
832    use super::*;
833
834    #[test]
835    fn selects_current_worktree_initially() {
836        let a = app(&[("main", false), ("feature", true)]);
837        assert_eq!(
838            a.selected_worktree().unwrap().branch.as_deref(),
839            Some("feature")
840        );
841    }
842
843    #[test]
844    fn navigation_clamps() {
845        let mut a = app(&[("a", true), ("b", false), ("c", false)]);
846        a.selected = 0;
847        a.move_selection(-1);
848        assert_eq!(a.selected, 0);
849        a.move_selection(5);
850        assert_eq!(a.selected, 2);
851        a.select_edge(false);
852        assert_eq!(a.selected, 0);
853        a.select_edge(true);
854        assert_eq!(a.selected, 2);
855    }
856
857    #[test]
858    fn filter_narrows_and_clamps_selection() {
859        let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
860        a.selected = 2;
861        a.filter_push('a');
862        a.filter_push('l');
863        a.filter_push('p');
864        // Only alpha + alphabet match.
865        assert_eq!(a.visible.len(), 2);
866        assert!(a.selected < a.visible.len());
867        a.clear_filter();
868        assert_eq!(a.visible.len(), 3);
869    }
870
871    #[test]
872    fn apply_filter_seeds_filter_and_resets_selection() {
873        let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
874        a.selected = 2;
875        a.apply_filter("alph".to_string());
876        assert_eq!(a.filter, "alph");
877        // Only alpha + alphabet match; selection resets to the first match.
878        assert_eq!(a.visible.len(), 2);
879        assert_eq!(a.selected, 0);
880    }
881
882    #[test]
883    fn sort_preserves_selection_by_path() {
884        let mut a = app(&[("zebra", false), ("alpha", true), ("mango", false)]);
885        // Sort by branch ascending.
886        a.sort = SortSpec {
887            key: SortKey::Branch,
888            descending: false,
889        };
890        a.resort_preserving_selection();
891        // The current worktree (alpha) is still selected.
892        assert_eq!(
893            a.selected_worktree().unwrap().branch.as_deref(),
894            Some("alpha")
895        );
896    }
897
898    #[test]
899    fn base_worktree_stays_first_after_sort() {
900        let mut a = app(&[("zebra", false), ("main", true), ("alpha", false)]);
901        // Mark "main" as the primary (base) worktree.
902        let base = a
903            .worktrees
904            .iter()
905            .position(|w| w.branch.as_deref() == Some("main"))
906            .unwrap();
907        a.worktrees[base].is_main = true;
908        a.sort = SortSpec {
909            key: SortKey::Branch,
910            descending: false,
911        };
912        a.resort_preserving_selection();
913        // The base is pinned first; the rest follow in sorted order.
914        let order: Vec<&str> = a
915            .visible
916            .iter()
917            .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
918            .collect();
919        assert_eq!(order, vec!["main", "alpha", "zebra"]);
920        // The current worktree (main) remains selected after the resort.
921        assert_eq!(
922            a.selected_worktree().unwrap().branch.as_deref(),
923            Some("main")
924        );
925    }
926
927    #[test]
928    fn cycle_sort_advances_field() {
929        let mut a = app(&[("a", true)]);
930        assert_eq!(a.sort.key, SortKey::Branch);
931        a.cycle_sort();
932        assert_eq!(a.sort.key, SortKey::Dirty);
933        a.reverse_sort();
934        assert!(a.sort.descending);
935    }
936
937    #[test]
938    fn detail_visible_respects_width() {
939        let mut a = app(&[("a", true)]);
940        a.size = (100, 30);
941        assert!(a.detail_visible());
942        a.size = (50, 30); // < 60 cols
943        assert!(!a.detail_visible());
944        a.show_sidebar = false; // full-screen detail
945        assert!(a.detail_visible());
946    }
947
948    #[test]
949    fn branch_rows_sort_below_worktrees_and_filter_by_name() {
950        use super::testutil::branch_row;
951        let mut a = app(&[("main", true), ("zebra", false)]);
952        a.worktrees.push(branch_row("feature/lonely"));
953        // A resort groups branch rows below the worktrees (issue #47).
954        a.resort_preserving_selection();
955        let order: Vec<&str> = a
956            .visible
957            .iter()
958            .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
959            .collect();
960        assert_eq!(order, vec!["main", "zebra", "feature/lonely"]);
961        // The branch row matches on its name even though its path is virtual.
962        a.apply_filter("lonely".into());
963        assert_eq!(a.visible.len(), 1);
964        assert_eq!(
965            a.selected_worktree().unwrap().branch.as_deref(),
966            Some("feature/lonely")
967        );
968    }
969
970    #[test]
971    fn select_row_within_bounds() {
972        let mut a = app(&[("a", true), ("b", false)]);
973        a.select_row(1);
974        assert_eq!(a.selected, 1);
975        a.select_row(99); // out of bounds -> no change
976        assert_eq!(a.selected, 1);
977    }
978
979    #[test]
980    fn select_branch_focuses_match() {
981        let mut a = app(&[("main", true), ("feature/x", false), ("other", false)]);
982        a.selected = 0;
983        assert!(a.select_branch("feature/x"));
984        assert_eq!(
985            a.selected_worktree().unwrap().branch.as_deref(),
986            Some("feature/x")
987        );
988    }
989
990    #[test]
991    fn select_branch_misses_leave_selection_unchanged() {
992        let mut a = app(&[("alpha", true), ("beta", false)]);
993        a.selected = 1;
994        // A branch that exists but is filtered out of the visible set.
995        a.apply_filter("alph".into());
996        a.selected = 0;
997        assert!(!a.select_branch("beta"));
998        assert_eq!(a.selected, 0);
999        // A branch that is not present at all.
1000        assert!(!a.select_branch("ghost"));
1001        assert_eq!(a.selected, 0);
1002    }
1003
1004    #[test]
1005    fn select_branch_ignores_worktree_less_branch_rows() {
1006        use super::testutil::branch_row;
1007        let mut a = app(&[("main", true)]);
1008        a.worktrees.push(branch_row("topic"));
1009        a.apply_filter(String::new()); // include the branch row in `visible`
1010        a.selected = 0;
1011        // A worktree-less branch row is not a created worktree to focus.
1012        assert!(!a.select_branch("topic"));
1013    }
1014
1015    #[test]
1016    fn job_registry_begin_finish_and_query() {
1017        let mut a = app(&[("main", true), ("feat", false)]);
1018        assert!(!a.any_jobs());
1019        let key = JobKey::Path(PathBuf::from("/r/feat"));
1020        a.begin_job(key.clone(), "Removing feat");
1021        assert!(a.any_jobs());
1022        assert!(a.has_job(&key));
1023        assert_eq!(a.job_summary().as_deref(), Some("Removing feat…"));
1024        // The job attaches to the matching worktree row (by path).
1025        let feat = a
1026            .worktrees
1027            .iter()
1028            .find(|w| w.branch.as_deref() == Some("feat"));
1029        assert_eq!(a.job_for(feat.unwrap()).unwrap().label, "Removing feat");
1030        // Re-registering the same key replaces rather than duplicates.
1031        a.begin_job(key.clone(), "Removing feat again");
1032        assert_eq!(a.jobs.len(), 1);
1033        a.finish_job(&key);
1034        assert!(!a.any_jobs());
1035        assert!(a.job_summary().is_none());
1036    }
1037
1038    #[test]
1039    fn job_summary_counts_multiple() {
1040        let mut a = app(&[("main", true)]);
1041        a.begin_job(JobKey::New("feat/a".into()), "Creating feat/a");
1042        a.begin_job(JobKey::Branch("feat/b".into()), "Deleting branch feat/b");
1043        let summary = a.job_summary().unwrap();
1044        assert!(summary.contains("+1 more"));
1045    }
1046
1047    #[test]
1048    fn branch_job_attaches_to_branch_row_only() {
1049        use super::testutil::branch_row;
1050        let mut a = app(&[("main", true)]);
1051        a.worktrees.push(branch_row("topic"));
1052        a.begin_job(JobKey::Branch("topic".into()), "Deleting branch topic");
1053        let row = a
1054            .worktrees
1055            .iter()
1056            .find(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1057            .unwrap();
1058        assert!(a.job_for(row).is_some());
1059        // A `New` job attaches to no existing row (status-bar only).
1060        a.begin_job(JobKey::New("brand-new".into()), "Creating brand-new");
1061        assert!(a.job_for(&a.worktrees[0]).is_none());
1062    }
1063
1064    #[test]
1065    fn tick_spinner_advances_only_with_jobs() {
1066        let mut a = app(&[("a", true)]);
1067        a.tick_spinner();
1068        assert_eq!(a.spinner_frame, 0); // idle: no advance
1069        a.begin_job(JobKey::New("x".into()), "Creating x");
1070        a.tick_spinner();
1071        a.tick_spinner();
1072        assert_eq!(a.spinner_frame, 2);
1073    }
1074
1075    #[test]
1076    fn may_apply_mode_guards_against_unrelated_modals() {
1077        let mut a = app(&[("a", true)]);
1078        // Idle: any job may transition.
1079        assert!(a.may_apply_mode(JobHome::List));
1080        assert!(a.may_apply_mode(JobHome::Create));
1081        // In an unrelated confirm modal, a List-home job must not touch the mode.
1082        a.mode = Mode::ConfirmRemove(0);
1083        assert!(!a.may_apply_mode(JobHome::List));
1084        // A checkout job may still finish into its own open picker.
1085        a.mode = Mode::Checkout(Default::default());
1086        assert!(a.may_apply_mode(JobHome::Checkout));
1087        assert!(!a.may_apply_mode(JobHome::Create));
1088    }
1089
1090    #[test]
1091    fn may_apply_mode_is_false_while_exit_blocked() {
1092        // Load-bearing invariant: `ExitBlocked` is NOT an idle mode, so a finished
1093        // create job's follow-up (e.g. submodule init) is *queued* rather than
1094        // opening a `ConfirmInitSubmodules` modal over the blocking overlay — which
1095        // is what lets the loop keep waiting for that follow-up to drain instead of
1096        // clobbering the overlay or exiting early (issue #46 overhaul).
1097        let mut a = app(&[("a", true)]);
1098        a.mode = Mode::ExitBlocked(ExitBlockedState {
1099            intent: ExitIntent::Quit,
1100        });
1101        assert!(!a.may_apply_mode(JobHome::List));
1102        assert!(!a.may_apply_mode(JobHome::Create));
1103        assert!(!a.may_apply_mode(JobHome::Checkout));
1104        assert!(!a.may_apply_mode(JobHome::PrPicker));
1105    }
1106
1107    #[test]
1108    fn request_exit_commits_immediately_when_idle() {
1109        // No jobs: the gate commits the intent right away (the common case).
1110        let mut a = app(&[("a", true)]);
1111        assert_eq!(a.request_exit(ExitIntent::Quit), Effect::Quit);
1112        assert!(a.quit);
1113
1114        let mut b = app(&[("a", true)]);
1115        let path = PathBuf::from("/r/feat");
1116        assert_eq!(
1117            b.request_exit(ExitIntent::Switch(path.clone())),
1118            Effect::Switch(path.clone())
1119        );
1120        assert_eq!(b.chosen, Some(path));
1121    }
1122
1123    #[test]
1124    fn request_exit_blocks_and_exit_now_drains() {
1125        // Jobs in flight: the gate blocks (no commit) and holds the intent; the
1126        // loop's `exit_now` predicate stays false until the last job drains, then
1127        // commits the held intent exactly once.
1128        let mut a = app(&[("a", true)]);
1129        let key = JobKey::New("x".into());
1130        a.begin_job(key.clone(), "Creating x");
1131        assert_eq!(a.request_exit(ExitIntent::Quit), Effect::None);
1132        assert!(matches!(a.mode, Mode::ExitBlocked(_)));
1133        assert!(!a.quit);
1134        assert!(!a.exit_now()); // still waiting
1135        a.finish_job(&key);
1136        assert!(a.exit_now()); // drained → commit
1137        assert!(a.quit);
1138    }
1139
1140    #[test]
1141    fn pending_jobs_queue_and_drain() {
1142        let mut a = app(&[("a", true)]);
1143        assert!(a.take_pending_jobs().is_empty());
1144        a.queue_job(Effect::InitSubmodules {
1145            dir: PathBuf::from("/wt/x"),
1146            count: 2,
1147        });
1148        let drained = a.take_pending_jobs();
1149        assert_eq!(drained.len(), 1);
1150        assert!(a.take_pending_jobs().is_empty());
1151    }
1152}