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