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