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}