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}