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