Skip to main content

gwm/tui/
app.rs

1use super::keymap::{Action, ChordResolution, KeyStroke, Keymap};
2use super::modal_keymap::{KeyContext, ModalAction, ModalKeymap};
3use super::palette::PaletteState;
4use super::state::async_task::{CreateWorktreeResult, EditWorktreeResult, TaskKind, TaskMsg, TaskRunner};
5use super::state::clean_overlay::CleanOverlay;
6use super::state::command_logs::CommandLogs;
7use super::state::config_panel::{ConfigPanel, FieldKind, KeyTarget, SettingField, SettingsLayer};
8use super::state::confirm::{ConfirmKeyAction, ConfirmModal, CountdownTickOutcome};
9use super::state::create_form::{CreateForm, Field};
10use super::state::exec_picker::ExecPicker;
11use super::state::filter::{fuzzy_match_indices, FilterState};
12use super::state::github_fetch::{FetchKey, GitHubFetch};
13use super::state::link_prompt::LinkPrompt;
14use super::state::pty_overlay::PtyOverlay;
15use super::state::sidebar::SidebarState;
16use super::state::spinner::Spinner;
17use super::theme::Theme;
18use crate::bootstrap::{self, BootstrapCtx, BootstrapReport, StepStatus};
19use crate::config::BranchType;
20use crate::config::{CleanConfig, Config, ExecConfig, TuiOpenConfig, TuiOpenMode};
21use crate::error::{GwmError, Result};
22use crate::github::{self, BranchLink, IssueState, IssueStatus, PrStatus};
23use crate::launcher::{self, ExpandedCommand, LauncherContext};
24use crate::naming::BranchSpec;
25use crate::worktree::{self, WorktreeInfo};
26use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
27use git2::Repository;
28use ratatui::widgets::TableState;
29use std::collections::{BTreeSet, HashMap};
30use std::path::{Path, PathBuf};
31use std::sync::mpsc;
32use std::time::{Duration, Instant};
33
34// Re-export the GitHub fetch state enum at its historical path
35// (`tui::app::GitHubFetchState`) so callers that imported it from
36// `tui::app` (or via `tui::GitHubFetchState` before the new
37// `state::github_fetch` re-export landed) keep compiling. The owning
38// module is now `tui::state::github_fetch` — see #128.
39pub use super::state::github_fetch::GitHubFetchState;
40
41/// Spawnable launcher plan handed to the event loop by
42/// [`App::prepare_git_tui`] / [`App::prepare_review`]. Carries the
43/// expanded argv, the cwd to set on the child, and the `fullscreen`
44/// toggle that decides whether gwm suspends its own TUI for the call.
45///
46/// The `diff_file` inside `expanded` (when set) is kept alive for the
47/// lifetime of the plan, so a `{diff}` tempfile survives until the
48/// spawned reviewer has had a chance to consume it.
49#[derive(Debug)]
50pub struct LauncherPlan {
51  pub expanded: ExpandedCommand,
52  pub cwd: std::path::PathBuf,
53  pub fullscreen: bool,
54  /// Resolved base ref, when the launcher cares about it (review).
55  /// `None` for the git_tui launcher. Surfaced so the status bar /
56  /// caller can mention which ref was used.
57  pub base: Option<String>,
58}
59
60#[derive(Debug, PartialEq, Eq, Clone, Copy)]
61pub enum View {
62  List,
63  Create,
64  Confirm,
65  Report,
66  Help,
67  /// Compact menu to pick which GitHub URL to open (issue / pr).
68  OpenMenu,
69  /// Two-stage prompt: pick the link kind, then enter the number.
70  LinkPrompt,
71  /// Command palette (issue #32). A bottom overlay where the user
72  /// types an action by name (`:create`, `:bootstrap`, …). State
73  /// lives on [`App::palette`]; orchestrator methods are
74  /// `open_command_palette` / `palette_push_char` / `palette_pop_char`
75  /// / `palette_cycle_*` / `accept_command_palette` /
76  /// `close_command_palette`.
77  CommandPalette,
78  /// Command Logs overlay (issue #226). A ~90% fullscreen modal over a
79  /// dimmed list showing the lazygit-style transcript of the external
80  /// commands gwm ran. Opened on `3`, scrolled like the help overlay;
81  /// state lives on [`App::command_logs`].
82  CommandLogs,
83  /// Configuration panel (issue #232). A ~90% fullscreen modal over a
84  /// dimmed list showing the resolved `.gwm.toml` (user-level global
85  /// deep-merged under the repo file) with a per-row source column
86  /// (repo / user / default). Opened on `4`, scrolled like the help
87  /// overlay; state lives on [`App::config_panel`].
88  Config,
89  /// Embedded PTY overlay (issue #35). A ~90% fullscreen modal that renders
90  /// a live PTY session (lazygit on `l`, native terminal on `o`) over the
91  /// worktree list. All keys are forwarded to the child process; `Esc`
92  /// kills the child and returns to the list. State lives on
93  /// [`App::pty_overlay`].
94  Pty,
95  /// Exec profile picker overlay (issue #325). A small centred modal that
96  /// lists the `[exec.profiles.*]` names; `Enter` resolves the highlight
97  /// to an argv and the run loop spawns it in a PTY overlay
98  /// ([`PtyKind::Exec`]) rooted at the selected worktree. State lives on
99  /// [`App::exec_picker`]; keys resolve through
100  /// [`crate::tui::modal_keymap::KeyContext::ExecPicker`].
101  ExecPicker,
102  /// Clean reclaim overlay (issue #325). A centred modal showing the gated
103  /// `clean::scan_worktree_safe` report for the selected worktree, an
104  /// optional `[clean.profiles.*]` picker, and a safety countdown; the run
105  /// loop fires `clean::delete_reclaim` when the countdown elapses. State
106  /// lives on [`App::clean_overlay`]; keys resolve through
107  /// [`crate::tui::modal_keymap::KeyContext::Clean`].
108  CleanReport,
109  /// Worktree-rename modal (#290). Reuses the Create form (Type / Issue /
110  /// Desc) pre-filled by parsing the current branch; submitting renames the
111  /// local + remote branch and moves the worktree directory. State lives on
112  /// [`App::create_form`] plus [`App::edit_original_branch`] /
113  /// [`App::edit_original_path`].
114  Edit,
115}
116
117/// What the run loop must do after [`App::handle_exec_picker_key`]
118/// processes a key in the exec picker overlay (issue #325). Mirrors
119/// [`CreateKey`] / [`LinkPromptKey`]: the testable handler owns the
120/// highlight movement, the loop owns the two side effects (resolve the
121/// argv + spawn the PTY overlay, or close back to the list).
122#[derive(Debug, PartialEq, Eq, Clone, Copy)]
123pub enum ExecPickerKey {
124  /// The key moved the highlight (or was ignored); stay in the picker.
125  Handled,
126  /// `Enter` — the loop should resolve the highlighted profile and spawn
127  /// the PTY overlay.
128  Submit,
129  /// `Esc` — the loop should close the picker back to the list.
130  Cancel,
131}
132
133/// What the run loop must do after [`App::handle_create_key`] processes a
134/// key in the create overlay (issue #217). Keeps the side effects
135/// (worktree creation, view transition) in the loop while the form
136/// mutations stay in the testable handler.
137#[derive(Debug, PartialEq, Eq, Clone, Copy)]
138pub enum CreateKey {
139  /// The key mutated form state (or was ignored); stay in the overlay.
140  Handled,
141  /// `Enter` on the description field — the loop should run `submit_create`.
142  Submit,
143  /// `Esc` — the loop should close the overlay back to the list.
144  Cancel,
145}
146
147/// What the run loop must do after [`App::handle_link_prompt_key`] processes
148/// a key in the link prompt (issue #217). Mirrors [`CreateKey`]: the testable
149/// handler owns the picker / digit-buffer mutations, the loop owns the two
150/// side effects (the `github::link_*` shell-out, the view transition).
151#[derive(Debug, PartialEq, Eq, Clone, Copy)]
152pub enum LinkPromptKey {
153  /// The key moved the highlight, committed a target, or edited the number
154  /// buffer (or was ignored); stay in the prompt.
155  Handled,
156  /// `Enter` on the number field — the loop should run `link_prompt_submit`.
157  Submit,
158  /// The resolved `fetch_github` key — the loop should refresh status.
159  Refresh,
160  /// `Esc` — the loop should close the prompt back to the list.
161  Cancel,
162}
163
164/// Target of an open / link action. Canonical definition lives in
165/// `crate::cli::LinkTarget` (it carries the `clap::ValueEnum` derive
166/// for the CLI surface); the TUI re-exports the same type so a value
167/// crossing the cli/tui boundary doesn't need a manual conversion
168/// (issue #106).
169pub use crate::cli::LinkTarget;
170
171/// Dispatch target for the `o` key (issue #73). Resolved by
172/// [`App::resolve_open_target`] from the current selection + the
173/// `[tui.open]` config so the event loop can hand off to the right
174/// runner (shell suspend, editor suspend, OS file manager) without
175/// re-reading the config or `$SHELL` / `$EDITOR` itself.
176#[derive(Debug, PartialEq, Eq, Clone)]
177pub enum OpenTarget {
178  /// Spawn `command` with `cwd = path`. Caller suspends the TUI and
179  /// restores it on the child's exit (same lifecycle as `l: lazygit`).
180  Shell { path: PathBuf, command: String },
181  /// Spawn `command <path>` and wait. Same suspend/restore lifecycle
182  /// as `Shell`.
183  Editor { path: PathBuf, command: String },
184  /// Hand off to the OS opener (`open` / `xdg-open` / `explorer`).
185  /// Doesn't suspend the TUI — the opener detaches.
186  Finder { path: PathBuf },
187}
188
189/// Stage of the two-step link prompt. Re-export from the extracted
190/// `LinkPrompt` sub-struct (issue #126) so the existing public surface
191/// (`gwm::tui::LinkPromptStage`) keeps compiling without callers
192/// learning the new module path.
193pub use super::state::link_prompt::LinkPromptStage;
194
195/// One repo's session-stable metadata in workspace mode (issue #36). The live
196/// `git2::Repository` is *not* stored here (it isn't `Send`/`Clone` and would
197/// duplicate `App.repo`); it is re-opened from `workdir` when this repo
198/// becomes the active one. `config` is cloned into `App.config` on activation
199/// so per-row actions (`create`, bootstrap, hooks) read the right repo's
200/// `.gwm.toml` — matching the issue's "each row inherits its own repo's
201/// config" contract. Keymap/theme stay session-level (resolved once from the
202/// first repo), the same "resolved once, relaunch to change" contract as
203/// single-repo mode.
204#[derive(Debug, Clone)]
205pub struct RepoMeta {
206  pub name: String,
207  pub workdir: PathBuf,
208  pub config: Config,
209}
210
211/// Workspace-mode state (issue #36). `None` in single-repo mode (the default).
212/// The *active* repo lives in `App`'s core fields (`repo`/`repo_name`/
213/// `workdir`/`config`); this holds everything needed to swap a different repo
214/// into those fields as the selection moves between repos.
215#[derive(Debug, Clone)]
216pub struct WorkspaceState {
217  /// The root `--workspace` pointed at.
218  pub root: PathBuf,
219  /// Session-stable repo metadata, in discovery (alphabetical) order.
220  pub repos: Vec<RepoMeta>,
221  /// The owning repo index for each `App.worktrees[i]` row, parallel to that
222  /// vec. Rebuilt by every workspace refresh so it never drifts.
223  pub row_repo: Vec<usize>,
224  /// Index into `repos` of the currently active repo (mirrors `App.repo*`).
225  pub active: usize,
226}
227
228pub struct App {
229  pub repo: Repository,
230  pub repo_name: String,
231  pub workdir: PathBuf,
232  pub config: Config,
233  /// Workspace-mode state (issue #36); `None` in single-repo mode.
234  pub workspace: Option<WorkspaceState>,
235  /// Set when the selected row's repo could not be activated in workspace mode
236  /// (moved / deleted / corrupt since listing). While true, `repo`/`workdir`/
237  /// `config` still point at the previously active repo, so repo-mutating
238  /// actions are blocked to avoid a wrong-target write (#304). Always `false`
239  /// in single-repo mode and once a selection activates cleanly.
240  pub workspace_active_stale: bool,
241  pub worktrees: Vec<WorktreeInfo>,
242  pub list_state: TableState,
243  pub view: View,
244  pub status: String,
245  pub delete_branch_on_remove: bool,
246  pub open_menu_selected: LinkTarget,
247
248  // Create form state
249  /// Create-worktree overlay state (extracted per #123). Holds field
250  /// focus, type index, and the issue/slug input buffers.
251  pub create_form: CreateForm,
252  /// Last asynchronous create failure shown inside the Create modal.
253  pub create_failure: Option<String>,
254  /// Branch types displayed in the create-form picker. Resolved once at
255  /// startup from [`Config::resolved_branch_types`] so the picker
256  /// honours any `[[branch_types]]` override in `.gwm.toml` without
257  /// re-reading the file on every key event.
258  pub branch_types: Vec<BranchType>,
259
260  // Bootstrap report
261  pub report: Option<BootstrapReport>,
262
263  /// Keybindings (help) overlay scroll offset, in rows. Reset to 0 every
264  /// time the overlay opens; clamped to `help_max_scroll` (#217).
265  pub help_scroll: u16,
266  /// Keybindings (help) overlay horizontal scroll offset, in columns (#222).
267  pub help_x_scroll: u16,
268  /// Maximum help scroll offset, republished by [`super::ui::draw_help`]
269  /// each frame as `content_rows.saturating_sub(viewport_rows)` so the
270  /// offset can never scroll past the last line into the void.
271  pub help_max_scroll: u16,
272  /// Maximum horizontal help scroll offset, republished by the renderer.
273  pub help_max_x_scroll: u16,
274
275  /// Sidebar (git preview) panel state (extracted per #127). Owns the
276  /// visibility / focus flags, the scroll offset + max bound, and the
277  /// cached pre-rendered sections keyed by the selected worktree's
278  /// path. The cache prevents re-shelling `git log` / `git status` on
279  /// every TUI redraw — they only run when the selection actually
280  /// changes (via [`SidebarState::on_navigation`]) or on explicit
281  /// refresh ([`SidebarState::invalidate`]). The renderer publishes
282  /// `sidebar.max_scroll` every frame against the actual rendered
283  /// Recent Commits height; [`SidebarState::scroll_down`] clamps
284  /// against it.
285  pub sidebar: SidebarState,
286
287  // Vim motion buffer: armed by first `g`, completed by the second.
288  // **Kept for backward compatibility** with pre-#87 tests that read
289  // it directly. Now a *mirror* of [`Self::pending_chord`] —
290  // [`Self::dispatch_key`] keeps the two synchronised via
291  // [`Self::sync_legacy_pending`]. New code should consume
292  // [`Self::pending_chord_is_empty`] instead.
293  pub pending_g: bool,
294
295  /// Generic pending-keys buffer for the configurable keymap
296  /// (issue #87). Empty most of the time; populated with the
297  /// strokes seen so far whenever the user is partway through a
298  /// chord that is a prefix of a bound binding (e.g. after the
299  /// first `g` of the default `g g → Top`).
300  pub pending_chord: Vec<KeyStroke>,
301
302  /// Resolved keymap for this TUI session. Built from
303  /// [`Config::tui.keys`] at construction time and never mutated
304  /// thereafter — the user has to relaunch gwm to pick up a config
305  /// change, mirroring how every other knob in `[tui]` behaves.
306  pub keymap: Keymap,
307
308  /// Resolved contextual keymap for modals / overlays (issue #219).
309  /// Built from the `[tui.keys.modal.<context>]` sub-tables at construction
310  /// time alongside [`Self::keymap`]; consulted by the modal routing in
311  /// `src/tui/mod.rs` to turn a keystroke into a typed [`ModalAction`].
312  pub modal_keymap: ModalKeymap,
313
314  /// Resolved colour theme for this TUI session (issue #33). Built
315  /// from `[theme]` in `.gwm.toml` at construction time. Threaded
316  /// through `draw_*` calls so user overrides reach every visual
317  /// signal. Same hot-reload-on-relaunch contract as the keymap.
318  pub theme: Theme,
319
320  // Inline fuzzy filter on the worktree list (issue #21, extracted per
321  // #124 with memoisation closing #104). The sub-struct owns the buffer
322  // (`query`), the typing-bar flag (`active`), and a cached indices vec
323  // so the 3–5 `tui/ui.rs` call sites per render frame don't each rerun
324  // the `nucleo_matcher` pass. `App::refresh` calls
325  // `self.filter.invalidate()` to drop the cache when `worktrees`
326  // changes; a worktrees-length mismatch auto-invalidates too.
327  pub filter: FilterState,
328
329  // Picker mode (issue #22): `gwm switch` runs the TUI as a stripped-down
330  // picker. Create / delete / bootstrap keys are inert; Enter records the
331  // highlighted worktree path into `picker_result` and the event loop quits
332  // so the CLI caller can print the path on stdout for `cd "$(gwm switch)"`.
333  pub picker_mode: bool,
334  pub picker_result: Option<PathBuf>,
335  /// Event-loop exit signal for picker mode. Driven by `picker_confirm`
336  /// (only when a worktree is actually selected) and `picker_cancel` (Esc
337  /// from inside the filter bar, where a blanket `break` would clash with
338  /// the regular TUI's clear-filter behaviour). Keeps the loop running on
339  /// Enter-with-no-match so the user can back-space and refine the filter
340  /// instead of being kicked out with exit code 1.
341  pub picker_should_exit: bool,
342
343  /// Event-loop exit signal for `Action::Quit` fired from a path
344  /// that cannot itself `break` the loop (issue #32: the command
345  /// palette routes accepted actions through `run_action`, which
346  /// returns `Result<()>` and has no `break` channel). Set by
347  /// `run_action` when it sees `Action::Quit`; checked at the top
348  /// of every event-loop iteration alongside `picker_should_exit`.
349  pub should_quit: bool,
350
351  /// Safety countdown state for the confirm overlay (issue #30, extracted
352  /// per #125). Holds the timer anchor and exposes the pure state-machine
353  /// API; this `App` keeps the side-effecting wrappers below that compose
354  /// the status messages and call `worktree::remove`.
355  pub confirm: ConfirmModal,
356
357  /// Last delete-worktree failure shown inside the confirm modal (issue
358  /// #257). Kept on `App`, not `ConfirmModal`, because it is the outcome of
359  /// the async worktree deletion side effect rather than countdown state.
360  pub delete_failure: Option<String>,
361
362  /// Animated loader for overlays (issue #187). Advanced by the event
363  /// loop's 200ms poll tick while the confirm countdown is armed and
364  /// read by the renderer; pure state lives in
365  /// [`super::state::spinner::Spinner`].
366  pub spinner: Spinner,
367
368  // ---- Issue/PR linking (issue #67) -------------------------------------
369  /// GitHub fetch state slice — owns the cached link for the currently
370  /// selected worktree's branch, the repo slug parsed from `origin`,
371  /// and the per-target `gh issue view` / `gh pr view` fetch state
372  /// (extracted per #128, part 6/6 of the `App` god-struct
373  /// decomposition #102). The orchestrator methods below
374  /// (`refresh_link`, `refresh_github_status`,
375  /// `apply_issue_fetch_result`, `apply_pr_fetch_result`) are thin
376  /// wrappers that compose the status-bar copy + drive the actual
377  /// `gh` shell-outs; the pure state machine lives on
378  /// `GitHubFetch`.
379  pub github: GitHubFetch,
380  /// Two-stage issue/PR link prompt state (extracted per #126). Owns
381  /// the stage + target + digit buffer; the orchestrator wraps the
382  /// transitions to update the status bar and shell out to
383  /// `github::link_{issue,pr}` on submit.
384  link_prompt: LinkPrompt,
385
386  /// Command palette overlay state (issue #32). Opened by
387  /// `Action::CommandPalette` (default `:` binding). The pure state
388  /// machine — buffer, fuzzy-matched candidates, highlight cursor —
389  /// lives on `PaletteState`; this `App` owns the view transition
390  /// and routes the accepted `Action` back through the normal
391  /// dispatcher so palette and keymap fire identical side effects.
392  pub palette: PaletteState,
393
394  /// TOFU trust mode for this TUI session (issue #95). Resolved at
395  /// the CLI entrypoint from `--allow-bootstrap` / `--deny-bootstrap`
396  /// / `GWM_ALLOW_BOOTSTRAP=1` and threaded down via `tui::run(mode)`.
397  /// Used by `check_trust_for_bootstrap` to gate `submit_create` and
398  /// `bootstrap_selected` — same security policy as the CLI, no
399  /// bypass via the TUI. Default `Prompt` (preserves the safe
400  /// default when callers construct `App` directly, e.g. tests that
401  /// don't care about the gate).
402  pub trust_mode: crate::trust::TrustMode,
403
404  /// Generic off-thread task spine (issue #231; GitHub fetch folded in by
405  /// #255): coalescing + per-key generation late-drop for slow one-shot
406  /// ops — the worktree list refresh and the `gh issue/pr view` fetches.
407  /// Public for the same reason `github` is — the state-machine tests
408  /// claim a generation directly without spawning an OS thread.
409  pub tasks: TaskRunner,
410  /// Last point at which the periodic TUI worktree refresh was armed.
411  /// Tests set this directly to simulate elapsed time without sleeping.
412  pub last_auto_refresh_at: Instant,
413  /// Sender cloned into each background task worker (issue #231; carries the
414  /// GitHub fetch results too since #255).
415  task_tx: mpsc::Sender<TaskMsg>,
416  /// Receiver drained by [`Self::drain_task_results`] each event-loop tick.
417  /// A worker whose `App` has dropped simply fails its `send` and is ignored.
418  task_rx: mpsc::Receiver<TaskMsg>,
419
420  /// Command Logs overlay state (issue #226): the scroll cursor plus an
421  /// owned snapshot of the [`crate::command_log`] global, so the modal
422  /// renders off `App` state rather than locking the global mid-frame.
423  pub command_logs: CommandLogs,
424
425  /// Configuration panel overlay state (issue #232): the scroll cursor
426  /// plus the resolved-row snapshot, filled by [`Self::enter_config_panel`].
427  pub config_panel: ConfigPanel,
428
429  /// The user-level global config path this `App` was constructed with
430  /// (issue #232). Stored so [`Self::enter_config_panel`] resolves the
431  /// panel's source attribution against the *same* layers the running
432  /// config was loaded from — `None` in tests / sandboxed runs with no
433  /// global file, matching [`Config::load_layered`]'s injection point.
434  global_path: Option<PathBuf>,
435
436  /// Live PTY overlay state (issue #35). `Some` while a lazygit or native
437  /// terminal PTY session is open; `None` at all other times.
438  /// Managed by [`Self::open_pty_overlay`] / [`Self::close_pty_overlay`].
439  pub pty_overlay: Option<PtyOverlay>,
440
441  /// Exec profile picker overlay state (issue #325). Populated by
442  /// [`Self::enter_exec_picker`] from `[exec.profiles.*]`; on `Enter` the
443  /// run loop resolves the highlight to an argv and spawns a PTY overlay
444  /// ([`PtyKind::Exec`]) in the selected worktree's directory.
445  pub exec_picker: ExecPicker,
446
447  /// The `[exec]` config captured when the exec picker opened (issue #325).
448  /// In workspace mode `sync_active_repo` can swap `self.config` to another
449  /// repo while the overlay is open, so `Enter` resolves the argv against
450  /// this snapshot — the active repo's `[exec]` at open time — not the live
451  /// config (Codex #333 review).
452  exec_picker_cfg: ExecConfig,
453
454  /// Clean overlay state (issue #325). Holds the gated reclaim scan of the
455  /// selected worktree, the `[clean.profiles.*]` picker, and a dedicated
456  /// safety countdown. Filled by [`Self::enter_clean_overlay`]; the run loop
457  /// fires [`crate::clean::delete_reclaim`] when the countdown elapses.
458  pub clean_overlay: CleanOverlay,
459
460  /// The `[clean]` config captured when the clean overlay opened (issue
461  /// #325) — every re-scan and the delete resolve their dir-set against this
462  /// snapshot, not the live `self.config.clean`, which a workspace
463  /// auto-refresh could swap to another repo's (Codex #333 review).
464  clean_overlay_cfg: CleanConfig,
465
466  /// The safety-countdown duration (seconds) captured when the clean overlay
467  /// opened (issue #325). Pinned alongside [`Self::clean_overlay_cfg`] so a
468  /// workspace config swap can't shorten — or clear to `0` — the delay
469  /// before an armed reclaim fires (Codex #333 review).
470  clean_overlay_countdown_secs: u32,
471
472  /// Set by `Action::ExitToWorktree` (#290): the path the main loop
473  /// should print to stdout just before quitting so the shell wrapper
474  /// (`cd "$(gwm)"`) can change directory. `None` → plain quit.
475  pub should_exit_to: Option<PathBuf>,
476
477  /// The selected worktree's branch name captured when the rename modal
478  /// (`View::Edit`, #290) opens — the `<old>` in `git branch -m <old> <new>`.
479  /// `None` while the modal is closed.
480  pub edit_original_branch: Option<String>,
481
482  /// The selected worktree's on-disk path captured when the rename modal
483  /// opens — the source for `git worktree move <old_path> <new_path>`.
484  pub edit_original_path: Option<PathBuf>,
485
486  /// Last rename failure, surfaced inside the Edit modal (mirrors
487  /// [`Self::create_failure`]) so the user can correct and retry without
488  /// losing the form. Cleared when the modal reopens.
489  pub edit_failure: Option<String>,
490}
491
492impl App {
493  pub fn new() -> Result<Self> {
494    Self::new_at(None)
495  }
496
497  pub fn new_at(start: Option<&Path>) -> Result<Self> {
498    Self::new_at_layered(start, crate::config::global_config_path().as_deref())
499  }
500
501  /// Injectable variant of [`Self::new_at`] (issue #194): `global_path`
502  /// is the user-level global config layered under the repo's `.gwm.toml`
503  /// (`None` = repo-only, no environment read). Tests pass `None` so `App`
504  /// construction never depends on the runner's real
505  /// `~/.config/gwm/config.toml`. `new_at` delegates with the real
506  /// `global_config_path()`, so runtime behaviour is unchanged.
507  pub fn new_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
508    let repo = worktree::discover_repo(start)?;
509    let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
510    let repo_name = worktree::repo_name(&repo);
511    let config = Config::load_layered(&workdir, global_path)?;
512    let branch_types = config.resolved_branch_types().types;
513    // Resolve the keymap once at construction. Config::load_for_repo
514    // already validated the overrides, so this should not surface a
515    // fresh error — but we re-`?` it rather than `.expect()` so a
516    // future hot-reload path could exercise the same call.
517    let keymap = config.tui.keys.resolved_keymap()?;
518    // Issue #219: resolve the contextual modal keymap once, same lifecycle
519    // as the global keymap above. Pre-validated by `Config::load_for_repo`.
520    let modal_keymap = config.tui.keys.resolved_modal_keymap()?;
521    // Issue #33: resolve the colour theme once at construction.
522    // Validated by `Config::load_for_repo` already, so this can
523    // only surface a fresh error if the loader pre-validation is
524    // bypassed (e.g. a future hot-reload path) — `?` is still the
525    // right propagation policy.
526    let theme = config.theme.resolve()?;
527    let worktrees = worktree::list(&repo)?;
528    let mut state = TableState::default();
529    if !worktrees.is_empty() {
530      state.select(Some(0));
531    }
532    let (task_tx, task_rx) = mpsc::channel();
533    let mut out = Self {
534      repo,
535      repo_name,
536      workdir,
537      config,
538      workspace: None,
539      workspace_active_stale: false,
540      worktrees,
541      list_state: state,
542      view: View::List,
543      status: String::from("press ? for help"),
544      delete_branch_on_remove: false,
545      open_menu_selected: LinkTarget::Issue,
546      create_form: CreateForm::new(),
547      create_failure: None,
548      branch_types,
549      report: None,
550      help_scroll: 0,
551      help_x_scroll: 0,
552      help_max_scroll: 0,
553      help_max_x_scroll: 0,
554      sidebar: SidebarState::new(),
555      pending_g: false,
556      pending_chord: Vec::new(),
557      keymap,
558      modal_keymap,
559      theme,
560      filter: FilterState::new(),
561      picker_mode: false,
562      picker_result: None,
563      picker_should_exit: false,
564      should_quit: false,
565      confirm: ConfirmModal::new(),
566      delete_failure: None,
567      spinner: Spinner::new(),
568      github: GitHubFetch::new(),
569      link_prompt: LinkPrompt::new(),
570      palette: PaletteState::new(),
571      trust_mode: crate::trust::TrustMode::Prompt,
572      tasks: TaskRunner::new(),
573      last_auto_refresh_at: Instant::now(),
574      task_tx,
575      task_rx,
576      command_logs: CommandLogs::new(),
577      config_panel: ConfigPanel::new(),
578      global_path: global_path.map(Path::to_path_buf),
579      pty_overlay: None,
580      exec_picker: ExecPicker::new(),
581      exec_picker_cfg: ExecConfig::default(),
582      clean_overlay: CleanOverlay::new(),
583      clean_overlay_cfg: CleanConfig::default(),
584      clean_overlay_countdown_secs: 0,
585      should_exit_to: None,
586      edit_original_branch: None,
587      edit_original_path: None,
588      edit_failure: None,
589    };
590    // Seed the sidebar position from `[tui] sidebar_position` (issue
591    // #188). Orientation stays at its `Auto` default — runtime-only.
592    out.sidebar.position = out.config.tui.sidebar_position;
593    out.refresh_link();
594    let spawned = out.refresh_linked_github_statuses_for_worktrees();
595    if spawned > 0 {
596      out.status = String::from("fetching GitHub status…");
597    }
598    Ok(out)
599  }
600
601  /// Workspace-mode constructor (issue #36): open the TUI over every git repo
602  /// one level below `root`, merging their worktree listings into one
603  /// repo-tagged table. Anchors the session on the first repo (alphabetical)
604  /// for keymap/theme resolution and the event-loop channels, then swaps the
605  /// merged list and per-row repo map in. Errors with [`GwmError::EmptyWorkspace`]
606  /// when no repo sits directly under `root`.
607  pub fn new_workspace_at_layered(root: &Path, global_path: Option<&Path>) -> Result<Self> {
608    let ws = crate::workspace::discover(root)?;
609    if ws.is_empty() {
610      return Err(GwmError::EmptyWorkspace {
611        root: root.display().to_string(),
612      });
613    }
614
615    // Load each repo's `.gwm.toml` once — session-stable metadata swapped into
616    // the active slot on navigation.
617    let mut repos: Vec<RepoMeta> = Vec::with_capacity(ws.repos.len());
618    for r in &ws.repos {
619      let config = Config::load_layered(&r.path, global_path)?;
620      repos.push(RepoMeta {
621        name: r.name.clone(),
622        workdir: r.path.clone(),
623        config,
624      });
625    }
626
627    // Anchor the session on the first repo: this resolves the keymap, theme,
628    // branch types, and sets up the task channels exactly as single-repo mode.
629    let mut app = Self::new_at_layered(Some(&repos[0].workdir), global_path)?;
630
631    // Replace the single-repo list with the merged, repo-tagged one. Map each
632    // row to its repo by the repo's *workdir path*, not its display name —
633    // names can collide (a linked worktree resolving to an owner outside the
634    // root, symlinks), and a name-keyed map would then point rows at the wrong
635    // repo handle/config (Codex review #303 round-2 P2).
636    let path_to_idx: HashMap<&Path, usize> = repos
637      .iter()
638      .enumerate()
639      .map(|(i, m)| (m.workdir.as_path(), i))
640      .collect();
641    let rows = crate::workspace::merge_worktrees(&ws)?;
642    let mut worktrees = Vec::with_capacity(rows.len());
643    let mut row_repo = Vec::with_capacity(rows.len());
644    for row in &rows {
645      let idx = path_to_idx.get(row.repo_path.as_path()).copied().unwrap_or(0);
646      worktrees.push(row.info.clone());
647      row_repo.push(idx);
648    }
649
650    let repo_count = repos.len();
651    let wt_count = worktrees.len();
652    app.worktrees = worktrees;
653    app.workspace = Some(WorkspaceState {
654      root: root.to_path_buf(),
655      repos,
656      row_repo,
657      active: 0,
658    });
659    app.filter.invalidate();
660    app.list_state.select(if wt_count == 0 { None } else { Some(0) });
661    // Resolve the initially-selected row's GitHub link/slug against its own
662    // repo (the anchor). Workspace mode fetches GitHub state per-selection, not
663    // in one cross-repo bulk pass — see `refresh_linked_github_statuses_for_worktrees`.
664    app.refresh_link();
665    app.status = format!(
666      "workspace {} — {} repo(s), {} worktree(s) · press ? for help",
667      root.display(),
668      repo_count,
669      wt_count
670    );
671    Ok(app)
672  }
673
674  /// True when the TUI is in workspace mode (issue #36).
675  pub fn is_workspace(&self) -> bool {
676    self.workspace.is_some()
677  }
678
679  /// Display name of the repo owning raw worktree row `raw_index` (the index
680  /// into [`Self::worktrees`], not the filtered view). `None` in single-repo
681  /// mode or for an out-of-range index. Drives the TUI `REPO` column.
682  pub fn row_repo_name(&self, raw_index: usize) -> Option<&str> {
683    let ws = self.workspace.as_ref()?;
684    let idx = *ws.row_repo.get(raw_index)?;
685    ws.repos.get(idx).map(|m| m.name.as_str())
686  }
687
688  /// Raw `worktrees` index of the current selection, hopping through the fuzzy
689  /// filter map (the selection indexes the filtered view, not the raw vec).
690  fn selected_raw_index(&self) -> Option<usize> {
691    let i = self.list_state.selected()?;
692    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
693    filtered.get(i).copied()
694  }
695
696  /// Align the active repo (`repo`/`repo_name`/`workdir`/`config`) with the
697  /// selected worktree's repo (issue #36). A no-op in single-repo mode and
698  /// when the selection still belongs to the active repo, so the event loop
699  /// can call it every frame cheaply. On the repo actually changing it
700  /// re-opens the `git2::Repository` from the target workdir and invalidates
701  /// the sidebar preview; an open failure keeps the current repo and reports
702  /// on the status bar rather than panicking mid-render.
703  pub fn sync_active_repo(&mut self) {
704    let Some(ws) = self.workspace.as_ref() else {
705      return;
706    };
707    let Some(raw) = self.selected_raw_index() else {
708      // No visible/selected row (e.g. the filter hides everything): there is no
709      // active repo the selection points at, so writes must not fall through to
710      // the previously active repo — mark stale to block them (#304). Reached
711      // only in workspace mode (the `ws` guard above returns in single-repo).
712      self.workspace_active_stale = true;
713      return;
714    };
715    let Some(&target) = ws.row_repo.get(raw) else {
716      self.workspace_active_stale = true;
717      return;
718    };
719    if target == ws.active {
720      // The selection is on the live, already-activated repo — clear any stale
721      // flag left over from a previous unreachable selection.
722      self.workspace_active_stale = false;
723      return;
724    }
725    let Some(meta) = ws.repos.get(target).cloned() else {
726      return;
727    };
728    match Repository::open(&meta.workdir) {
729      Ok(repo) => {
730        self.repo = repo;
731        self.repo_name = meta.name;
732        self.workdir = meta.workdir;
733        self.config = meta.config;
734        self.workspace_active_stale = false;
735        // The branch types drive the create form; re-resolve them from the
736        // newly-active repo's config so a per-repo `[[branch_types]]` override
737        // applies to the row being acted on (Codex review #303 P2).
738        self.branch_types = self.config.resolved_branch_types().types;
739        if let Some(ws) = self.workspace.as_mut() {
740          ws.active = target;
741        }
742        self.invalidate_sidebar_cache();
743        // Re-resolve the GitHub link + slug against the now-active repo so the
744        // Issue/PR panel and the `F` refresh act on the selected row's own
745        // repo, not the previously-active one (Codex review #303 P2). The
746        // per-repo nav hook (`on_navigation`) ran `refresh_link` *before* this
747        // swap, while `self.repo` still pointed at the old repo.
748        self.refresh_link();
749      }
750      Err(e) => {
751        // Keep the previously active repo live but mark the selection stale so
752        // repo-mutating actions are blocked until the user moves to a
753        // reachable row (or a refresh drops the dead repo) — #304.
754        self.workspace_active_stale = true;
755        self.status = format!(
756          "workspace: repo '{}' is unavailable ({}) — press r to refresh",
757          meta.name, e
758        );
759      }
760    }
761  }
762
763  /// Set the active config, keeping the workspace cache coherent. In
764  /// workspace mode the per-repo `RepoMeta.config` is the source of truth that
765  /// `sync_active_repo` restores on activation, so a settings/keymap reload
766  /// that only updated `self.config` would be reverted the next time the user
767  /// navigated away and back (Codex review #303 P3). Write the reloaded config
768  /// through to the active repo's cached meta too.
769  fn set_active_config(&mut self, cfg: Config) {
770    self.config = cfg;
771    if let Some(ws) = self.workspace.as_mut() {
772      if let Some(meta) = ws.repos.get_mut(ws.active) {
773        meta.config = self.config.clone();
774      }
775    }
776  }
777
778  /// Reload every workspace repo's cached config from disk (issue #36). Called
779  /// after a Global-layer settings edit, which changes the deep-merged config
780  /// for *all* repos — without this, navigating to a non-active repo would
781  /// restore the config it was loaded with at startup, reverting the edit for
782  /// that repo until relaunch (Codex review #303 P2). The active repo's live
783  /// `self.config` is already current (set by `set_active_config`); this
784  /// re-syncs its cached meta too, so it stays the single source of truth.
785  fn reload_workspace_repo_configs(&mut self) {
786    let Some(ws) = self.workspace.as_ref() else {
787      return;
788    };
789    let global = self.global_path.clone();
790    let targets: Vec<(usize, PathBuf)> = ws
791      .repos
792      .iter()
793      .enumerate()
794      .map(|(i, m)| (i, m.workdir.clone()))
795      .collect();
796    for (i, workdir) in targets {
797      if let Ok(cfg) = Config::load_layered(&workdir, global.as_deref()) {
798        if let Some(ws) = self.workspace.as_mut() {
799          if let Some(meta) = ws.repos.get_mut(i) {
800            meta.config = cfg;
801          }
802        }
803      }
804    }
805  }
806
807  /// Per-row mask of whether each `worktrees` row belongs to the currently
808  /// active repo. `None` in single-repo mode (every row qualifies). Issue/PR
809  /// numbers are only unique *within* a repo, so the number-keyed GitHub state
810  /// stamping must be scoped to the active repo's rows in workspace mode —
811  /// otherwise a fetch for repo A's `#1` would stamp (and persist to the wrong
812  /// repo) every other repo's `#1` row (Codex review #303 P2).
813  fn active_repo_row_mask(&self) -> Option<Vec<bool>> {
814    let ws = self.workspace.as_ref()?;
815    Some(ws.row_repo.iter().map(|&r| r == ws.active).collect())
816  }
817
818  /// Re-list every repo in the workspace and rebuild the merged table +
819  /// row→repo map (issue #36). The single-repo async refresh would clobber the
820  /// merged list with one repo's worktrees, so workspace refresh runs
821  /// synchronously across all repos instead. Repos are fixed for the session
822  /// (a new repo under the root needs a relaunch, matching the config "resolved
823  /// once" contract), so this re-lists the stored metas rather than re-walking
824  /// the root.
825  fn refresh_workspace(&mut self) {
826    let Some(ws) = self.workspace.as_ref() else {
827      return;
828    };
829    let targets: Vec<(usize, PathBuf)> = ws
830      .repos
831      .iter()
832      .enumerate()
833      .map(|(i, m)| (i, m.workdir.clone()))
834      .collect();
835    let mut worktrees = Vec::new();
836    let mut row_repo = Vec::new();
837    for (idx, workdir) in &targets {
838      if let Ok(repo) = Repository::open(workdir) {
839        if let Ok(trees) = worktree::list(&repo) {
840          for t in trees {
841            worktrees.push(t);
842            row_repo.push(*idx);
843          }
844        }
845      }
846    }
847    if let Some(ws) = self.workspace.as_mut() {
848      ws.row_repo = row_repo;
849    }
850    self.apply_refreshed_worktrees(worktrees);
851    // The selection may now land on a different repo's row — re-align the
852    // active repo. `sync_active_repo` only refreshes the link when the repo
853    // actually changes, so re-resolve the selected row's link/slug here too
854    // (the bulk prefetch is a no-op in workspace mode).
855    self.sync_active_repo();
856    self.refresh_link();
857  }
858
859  /// Builder-style setter for `trust_mode`. The TUI entrypoint
860  /// (`tui::run`) calls this after construction to thread through
861  /// the CLI flags / env resolution; tests can use it directly to
862  /// exercise each variant of the gate.
863  pub fn with_trust_mode(mut self, mode: crate::trust::TrustMode) -> Self {
864    self.trust_mode = mode;
865    self
866  }
867
868  /// Silent TOFU gate for the TUI's bootstrap call sites
869  /// (`submit_create`, `bootstrap_selected`). Returns:
870  ///
871  /// * `Ok(None)` — caller is cleared to invoke `bootstrap::run`.
872  /// * `Ok(Some(msg))` — caller MUST NOT run bootstrap; show `msg`
873  ///   to the user (e.g. assign to `self.status`). Untrusted
874  ///   configs and `TrustMode::Deny` both land here — the TUI
875  ///   alternate-screen can't host a stdin prompt today, so we
876  ///   refuse with a hint pointing the user at the CLI gate
877  ///   (`gwm bootstrap` from another terminal).
878  /// * `Err(e)` — ledger I/O / config read error propagated verbatim.
879  pub fn check_trust_for_bootstrap(&self) -> Result<Option<String>> {
880    use crate::trust::{self, TrustOutcome};
881
882    let origin_url = self
883      .repo
884      .find_remote("origin")
885      .ok()
886      .and_then(|r| r.url().ok().map(String::from));
887    let origin = trust::resolve_origin_key(origin_url.as_deref(), &self.workdir);
888
889    match trust::evaluate(&self.workdir, &origin, self.trust_mode)? {
890      TrustOutcome::Proceed => Ok(None),
891      TrustOutcome::Refuse { message } => Ok(Some(message)),
892      TrustOutcome::Prompt { cfg_path, sha, .. } => {
893        let short_sha: String = sha.chars().take(12).collect();
894        Ok(Some(format!(
895          ".gwm.toml at {} not in trust ledger (hash {}) — \
896           run `gwm bootstrap` from a CLI in another terminal to approve, \
897           or relaunch with GWM_ALLOW_BOOTSTRAP=1 / --allow-bootstrap",
898          cfg_path.display(),
899          short_sha
900        )))
901      }
902    }
903  }
904
905  /// Constructor for `gwm switch`: same App, but picker mode is on and the
906  /// fuzzy filter bar is open from the first frame so the user can start
907  /// narrowing right away. Everything else (worktree list, sidebar, vim
908  /// motions) behaves identically; only the event-loop interpretation of
909  /// Enter / n / d / b changes.
910  pub fn new_picker_at(start: Option<&Path>) -> Result<Self> {
911    Self::new_picker_at_layered(start, crate::config::global_config_path().as_deref())
912  }
913
914  /// Injectable variant of [`Self::new_picker_at`] (issue #196): mirrors
915  /// [`Self::new_at_layered`] so picker-mode tests never read the runner's
916  /// real `~/.config/gwm/config.toml`. `new_picker_at` delegates with the
917  /// real `global_config_path()`.
918  pub fn new_picker_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
919    let mut app = Self::new_at_layered(start, global_path)?;
920    app.picker_mode = true;
921    app.filter.open();
922    app.status = "switch picker — type to filter · enter selects · esc cancels".into();
923    Ok(app)
924  }
925
926  /// Synchronous worktree list refresh. Kept for internal post-mutation
927  /// callers (create / delete / report-close) that need the list fresh
928  /// *before* the next render; the user-initiated `f` / `r` key path goes
929  /// through the off-thread [`Self::request_refresh`] instead (issue
930  /// #231). Both converge on [`Self::apply_refreshed_worktrees`] so the
931  /// two paths can never drift on the post-list bookkeeping.
932  pub fn refresh(&mut self) -> Result<()> {
933    // A synchronous re-list (create / delete / report-close) produces
934    // authoritative fresh state, so any older async refresh still in flight
935    // is by definition stale — bump its generation so `drain_task_results`
936    // drops the late result instead of clobbering this post-mutation list
937    // with a pre-mutation snapshot (issue #231, the #138 race class). A
938    // harmless counter bump when no task is running. Lives here and not in
939    // `apply_refreshed_worktrees` so the async drain, which shares that
940    // tail, does not re-invalidate the run it just applied.
941    self.tasks.invalidate(TaskKind::RefreshWorktrees);
942    if self.is_workspace() {
943      // Workspace mode re-lists every repo, not just the active one (#36).
944      self.refresh_workspace();
945      return Ok(());
946    }
947    let worktrees = worktree::list(&self.repo)?;
948    self.apply_refreshed_worktrees(worktrees);
949    Ok(())
950  }
951
952  /// Swap in a freshly-listed worktree vec and run the bookkeeping every
953  /// refresh path shares: drop the cached fuzzy-match indices (they point
954  /// at the previous vec — a length change auto-invalidates, but a
955  /// same-length list with different contents would not, so the explicit
956  /// flush is the safe play), re-clamp the selection (which re-resolves
957  /// the link cache), refresh every Issue/PR status linked by the listed
958  /// rows, invalidate the sidebar preview, and report the count. Called by
959  /// the synchronous [`Self::refresh`] and by the off-thread drain in
960  /// [`Self::drain_task_results`].
961  fn apply_refreshed_worktrees(&mut self, mut worktrees: Vec<WorktreeInfo>) {
962    // The carry-over preserves this session's in-memory fetched issue/PR state
963    // across a re-list, keyed by number. In workspace mode that key collides
964    // across repos (two repos can both own `#1`), so skip it: the freshly
965    // listed rows already carry each repo's own *persisted* state from
966    // `read_link`, which is per-repo-correct (Codex review #303 P2).
967    if !self.is_workspace() {
968      let issue_states: HashMap<u64, IssueState> = self
969        .worktrees
970        .iter()
971        .filter_map(|w| Some((w.link.issue?, w.issue_state?)))
972        .collect();
973      let pr_states = self
974        .worktrees
975        .iter()
976        .filter_map(|w| Some((w.link.pr?, w.pr_state?)))
977        .collect::<HashMap<_, _>>();
978
979      for w in &mut worktrees {
980        if let Some(issue) = w.link.issue {
981          if let Some(state) = issue_states.get(&issue).copied() {
982            w.issue_state = Some(state);
983          }
984        }
985        if let Some(pr) = w.link.pr {
986          if let Some(state) = pr_states.get(&pr).copied() {
987            w.pr_state = Some(state);
988          }
989        }
990      }
991    }
992
993    self.worktrees = worktrees;
994    self.filter.invalidate();
995    self.clamp_selection_to_filter();
996    let spawned = self.refresh_linked_github_statuses_for_worktrees();
997    self.invalidate_sidebar_cache();
998    self.status = if spawned > 0 {
999      format!(
1000        "refreshed — {} worktree(s); fetching GitHub status…",
1001        self.worktrees.len()
1002      )
1003    } else {
1004      format!("refreshed — {} worktree(s)", self.worktrees.len())
1005    };
1006  }
1007
1008  /// Off-thread worktree list refresh for the `f` / `r` key (issue #231):
1009  /// spawn a worker that re-lists the worktrees and posts the result back
1010  /// to the event loop, so a large repo / slow filesystem no longer
1011  /// freezes the TUI. Coalesces onto an in-flight run (a second press
1012  /// while loading is a no-op) and seeds the loader label + spinner. The
1013  /// result is applied by [`Self::drain_task_results`].
1014  pub fn request_refresh(&mut self) {
1015    if self.is_workspace() {
1016      // No single-repo async worker in workspace mode — it would clobber the
1017      // merged list with one repo's worktrees (#36). Refresh synchronously.
1018      let _ = self.refresh();
1019      return;
1020    }
1021    let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1022      // A refresh is already in flight — coalesce onto it.
1023      return;
1024    };
1025    // Start the loader from a deterministic frame and surface the label.
1026    self.spinner.reset();
1027    self.status = TaskKind::RefreshWorktrees.loading_label().into();
1028    self.spawn_refresh(generation);
1029  }
1030
1031  /// Periodic worktree-list refresh for the TUI event loop. Returns `true`
1032  /// only when a new async refresh task was actually started. `0` disables
1033  /// the feature, and an in-flight refresh coalesces so the renderer is never
1034  /// blocked by repeated relist attempts.
1035  pub fn maybe_auto_refresh(&mut self, now: Instant) -> bool {
1036    let secs = self.config.tui.auto_refresh_secs;
1037    if secs == 0 {
1038      return false;
1039    }
1040    if now.saturating_duration_since(self.last_auto_refresh_at) < Duration::from_secs(secs) {
1041      return false;
1042    }
1043    self.last_auto_refresh_at = now;
1044    if self.is_workspace() {
1045      // Synchronous merged refresh in workspace mode (#36) — see `refresh`.
1046      let _ = self.refresh();
1047      return true;
1048    }
1049    let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1050      return false;
1051    };
1052    self.spinner.reset();
1053    self.status = "auto-refreshing worktrees…".into();
1054    self.spawn_refresh(generation);
1055    true
1056  }
1057
1058  /// Spawn one background worktree-list worker tagged with `generation`
1059  /// (issue #231). A thin shell, mirroring [`Self::spawn_github_fetch`]:
1060  /// it owns only the off-thread dispatch + send, no state logic (the
1061  /// coalescing / late-drop contract lives in [`TaskRunner`], tested in
1062  /// `tui_state_async_task_tests.rs`). `git2::Repository` is not `Send`,
1063  /// so the worker opens its *own* repo from the owned `workdir` path
1064  /// rather than borrowing `self.repo` — the same "only owned `Send` data
1065  /// crosses the boundary" discipline as the GitHub worker. A `send`
1066  /// failure (the `App`/receiver dropped) is ignored.
1067  fn spawn_refresh(&self, generation: u64) {
1068    let tx = self.task_tx.clone();
1069    let workdir = self.workdir.clone();
1070    std::thread::spawn(move || {
1071      let result = worktree::discover_repo(Some(&workdir))
1072        .and_then(|repo| worktree::list(&repo))
1073        .map_err(|e| e.to_string());
1074      let _ = tx.send(TaskMsg::RefreshWorktrees(generation, result));
1075    });
1076  }
1077
1078  /// Off-thread `gwm sync` of the selected worktree for the `S` key (issue
1079  /// #258): fetch + rebase its branch onto upstream on a worker thread, so a
1080  /// slow network fetch / rebase does not freeze the event loop. Coalesces
1081  /// onto an in-flight sync (a second `S` while one runs is a no-op, so two
1082  /// rebases never race). The outcome is applied by
1083  /// [`Self::drain_task_results`], which reports it and refreshes the list so
1084  /// the new ahead/behind state shows. Default strategy is rebase (the repo
1085  /// convention); a `--merge` variant is deferred (see #258).
1086  pub fn request_sync(&mut self) {
1087    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
1088      self.status = "no worktree selected to sync".into();
1089      return;
1090    };
1091    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Sync) {
1092      self.status = self.busy_mutation_status("syncing");
1093      return;
1094    }
1095    let Some(generation) = self.tasks.request(TaskKind::Sync) else {
1096      // A sync is already in flight — coalesce onto it.
1097      return;
1098    };
1099    self.spinner.reset();
1100    self.status = TaskKind::Sync.loading_label().into();
1101    self.spawn_sync(generation, path, name);
1102  }
1103
1104  /// Spawn one background `gwm sync` worker tagged with `generation` (issue
1105  /// #258). Mirrors [`Self::spawn_refresh`]: it moves only owned `Send` data
1106  /// (the worktree `path` + `name`) across the boundary and runs the existing
1107  /// [`crate::sync::sync`] logic, which discovers its own repo from `path` and
1108  /// shells out to `git` for fetch/rebase. A `send` failure (the `App`/receiver
1109  /// dropped) is ignored.
1110  fn spawn_sync(&self, generation: u64, path: PathBuf, name: String) {
1111    let tx = self.task_tx.clone();
1112    std::thread::spawn(move || {
1113      let result = crate::sync::sync(&path, crate::sync::SyncStrategy::Rebase).map_err(|e| e.to_string());
1114      let _ = tx.send(TaskMsg::Sync(generation, name, result));
1115    });
1116  }
1117
1118  /// Apply every background task result that has arrived since the last
1119  /// call (issue #231; GitHub fetch results folded in by #255), draining
1120  /// the channel without blocking. Each result goes through
1121  /// [`TaskRunner::complete`], so a result whose per-key generation was
1122  /// bumped mid-flight is dropped (#138 guard, generalised) — this is what
1123  /// makes a stale GitHub worker lose to a fresh one in the retry race.
1124  ///
1125  /// A failed refresh surfaces on the status bar and leaves the list
1126  /// intact — what used to be a fatal `refresh()?` that tore down the
1127  /// event loop is now a graceful message. A GitHub result is stamped into
1128  /// the per-key cache via `complete_{issue,pr}` (pure writes now that the
1129  /// drop decision lives on the spine); once nothing GitHub-side is left
1130  /// loading, the aggregate outcome is re-reported on the status bar — the
1131  /// same end state `drain_github_results` produced pre-#255. Returns `true`
1132  /// if at least one result was applied, so the loop can force a redraw.
1133  pub fn drain_task_results(&mut self) -> bool {
1134    let mut applied = false;
1135    let mut github_applied = false;
1136    let mut refresh_applied = false;
1137    while let Ok(msg) = self.task_rx.try_recv() {
1138      match msg {
1139        TaskMsg::CreateWorktree(generation, result) => {
1140          if !self.tasks.complete(TaskKind::CreateWorktree, generation) {
1141            // Late result — a newer run (or an invalidate) superseded it.
1142            continue;
1143          }
1144          match result {
1145            Ok(result) => {
1146              self.create_failure = None;
1147              self.report = Some(result.report);
1148              self.view = View::Report;
1149              let refresh_result = self.refresh();
1150              self.status = match refresh_result {
1151                Ok(()) => format!("created {} @ {}", result.branch, result.created.display()),
1152                Err(e) => format!(
1153                  "created {} @ {}; refresh failed: {}",
1154                  result.branch,
1155                  result.created.display(),
1156                  e
1157                ),
1158              };
1159            }
1160            Err(e) => {
1161              self.create_failure = Some(e.clone());
1162              self.view = View::Create;
1163              self.status = format!("create failed: {}", e);
1164            }
1165          }
1166          applied = true;
1167          // Create owns the status line this tick.
1168          refresh_applied = true;
1169        }
1170        TaskMsg::RefreshWorktrees(generation, result) => {
1171          if !self.tasks.complete(TaskKind::RefreshWorktrees, generation) {
1172            // Late result — a newer run (or an invalidate) superseded it.
1173            continue;
1174          }
1175          match result {
1176            Ok(worktrees) => self.apply_refreshed_worktrees(worktrees),
1177            Err(e) => self.status = format!("refresh failed: {}", e),
1178          }
1179          applied = true;
1180          refresh_applied = true;
1181        }
1182        TaskMsg::GithubIssue(generation, number, result) => {
1183          // Generation guard: a stale worker whose slot was bumped by an
1184          // intervening invalidate/re-request is dropped here, before it can
1185          // stamp the cache (the Codex-flagged race, fixed by the spine).
1186          if !self.tasks.complete(TaskKind::GithubIssue(number), generation) {
1187            continue;
1188          }
1189          if let Ok(status) = &result {
1190            self.persist_loaded_issue_title(status);
1191          }
1192          self.github.complete_issue(number, result);
1193          applied = true;
1194          github_applied = true;
1195        }
1196        TaskMsg::GithubPr(generation, number, result) => {
1197          if !self.tasks.complete(TaskKind::GithubPr(number), generation) {
1198            continue;
1199          }
1200          if let Ok(status) = &result {
1201            self.persist_loaded_pr_title(status);
1202          }
1203          self.github.complete_pr(number, result);
1204          applied = true;
1205          github_applied = true;
1206        }
1207        TaskMsg::Sync(generation, name, result) => {
1208          if !self.tasks.complete(TaskKind::Sync, generation) {
1209            // Late result — a newer sync (or an invalidate) superseded it.
1210            continue;
1211          }
1212          match result {
1213            Ok(report) => {
1214              // Re-list so the new ahead/behind state shows (this also bumps
1215              // the refresh generation — the #138 race guard). The worker
1216              // mutated refs in a subprocess, but a libgit2 read re-reads them
1217              // from disk, so the synchronous `self.refresh()` (`self.repo`)
1218              // sees the rebased state — verified end-to-end by the
1219              // ahead/behind assertion in `sync_tests::
1220              // tui_sync_action_relists_to_the_rebased_state_from_disk`.
1221              // `refresh` sets its own "refreshed — N" status, so overwrite it
1222              // with the sync outcome afterwards — the user pressed `S`, the
1223              // sync result is what they want to read.
1224              let _ = self.refresh();
1225              self.status = crate::cli::format_sync_report(&name, &report).trim_end().to_string();
1226            }
1227            Err(e) => self.status = format!("sync failed: {}", e),
1228          }
1229          applied = true;
1230          // The sync owns the status line this tick — keep the post-loop
1231          // GitHub report from overwriting it (same guard the refresh uses).
1232          refresh_applied = true;
1233        }
1234        TaskMsg::Bootstrap(generation, result) => {
1235          if !self.tasks.complete(TaskKind::Bootstrap, generation) {
1236            // Late result — a newer run (or an invalidate) superseded it, so
1237            // it must not flip the view to a stale report.
1238            continue;
1239          }
1240          match result {
1241            Ok(report) => {
1242              // Same outcome as the old synchronous path (issue #256): show
1243              // the report and surface whether any step failed.
1244              let any_failed = report.steps.iter().any(|s| s.status == StepStatus::Failed);
1245              self.report = Some(report);
1246              self.view = View::Report;
1247              self.status = if any_failed {
1248                "bootstrap had failures".into()
1249              } else {
1250                "bootstrap ok".into()
1251              };
1252            }
1253            Err(e) => self.status = format!("bootstrap error: {}", e),
1254          }
1255          applied = true;
1256          // The bootstrap owns the status line (and the view) this tick — keep
1257          // the post-loop GitHub report from overwriting it (same guard the
1258          // refresh / sync arms use).
1259          refresh_applied = true;
1260        }
1261        TaskMsg::DeleteWorktree(generation, name, label, result) => {
1262          if !self.tasks.complete(TaskKind::DeleteWorktree, generation) {
1263            // Late result — a newer run (or an invalidate) superseded it.
1264            continue;
1265          }
1266          match result {
1267            Ok(()) => {
1268              self.delete_failure = None;
1269              self.view = View::List;
1270              self.confirm.reset();
1271              let refresh_result = self.refresh();
1272              self.status = match refresh_result {
1273                Ok(()) => format!("removed {} ({})", name, label),
1274                Err(e) => format!("removed {} ({}); refresh failed: {}", name, label, e),
1275              };
1276            }
1277            Err(e) => {
1278              self.delete_failure = Some(e.clone());
1279              self.view = View::Confirm;
1280              self.status = format!("delete failed: {}", e);
1281            }
1282          }
1283          applied = true;
1284          // Delete owns the status line this tick.
1285          refresh_applied = true;
1286        }
1287        TaskMsg::Pull(generation, name, result) => {
1288          if !self.tasks.complete(TaskKind::Pull, generation) {
1289            continue;
1290          }
1291          // Refresh on both arms: a failed pull can still mutate the tree (a
1292          // merge/rebase conflict leaves it dirty / mid-rebase), so the table
1293          // must not keep showing the pre-pull clean state (Codex review #292).
1294          let _ = self.refresh();
1295          match result {
1296            Ok(msg) => self.status = format!("pulled {}: {}", name, msg),
1297            Err(e) => self.status = format!("pull failed: {}", e),
1298          }
1299          applied = true;
1300          refresh_applied = true;
1301        }
1302        TaskMsg::Push(generation, name, result) => {
1303          if !self.tasks.complete(TaskKind::Push, generation) {
1304            continue;
1305          }
1306          match result {
1307            Ok(msg) => {
1308              // Pushing updates the remote-tracking ref + ahead/behind, so
1309              // refresh the table before overwriting the status, mirroring
1310              // the pull/sync path (Codex review on PR #292).
1311              let _ = self.refresh();
1312              self.status = format!("pushed {}: {}", name, msg);
1313            }
1314            Err(e) => self.status = format!("push failed: {}", e),
1315          }
1316          applied = true;
1317          refresh_applied = true;
1318        }
1319        TaskMsg::EditWorktree(generation, result) => {
1320          if !self.tasks.complete(TaskKind::EditWorktree, generation) {
1321            continue;
1322          }
1323          match result {
1324            Ok(res) => {
1325              let _ = self.refresh();
1326              self.status = if res.remote_renamed {
1327                format!("renamed to {} (local + remote)", res.new_branch)
1328              } else {
1329                format!("renamed to {} (local only)", res.new_branch)
1330              };
1331              // Re-select the renamed worktree by its new path so the cursor
1332              // stays on the row the user just edited (mapped through the
1333              // filter — Codex review on PR #292).
1334              self.reselect_by_path(&res.new_path);
1335              self.edit_original_branch = None;
1336              self.edit_original_path = None;
1337              self.edit_failure = None;
1338              self.create_form.reset();
1339              self.view = View::List;
1340            }
1341            // Keep the modal open so the user can fix the form and retry, and
1342            // replace the "renaming worktree…" loading status so the bar no
1343            // longer reads as in-progress (Codex review on PR #292, P3).
1344            Err(e) => {
1345              self.status = format!("rename failed: {}", e);
1346              self.edit_failure = Some(e);
1347            }
1348          }
1349          applied = true;
1350          refresh_applied = true;
1351        }
1352      }
1353    }
1354    // Once nothing GitHub-side is left loading, swap the "fetching…"
1355    // placeholder for the real outcome (refreshed / partial failure /
1356    // failure) — only when a GitHub result actually applied, so a dropped
1357    // stale result never overwrites the current status (issue #217 review P2).
1358    //
1359    // Skip it when a worktree refresh also landed this tick: pre-#255 the
1360    // event loop drained the GitHub channel *before* the task channel, so a
1361    // simultaneous completion left `apply_refreshed_worktrees`' "refreshed —
1362    // N" message standing last. The `!refresh_applied` guard preserves that
1363    // ordering now that both drain in one pass.
1364    if github_applied && !refresh_applied && !self.is_github_loading() {
1365      self.report_github_refresh_status();
1366    }
1367    applied
1368  }
1369
1370  /// `true` while any background task is in flight (issue #231) — drives
1371  /// the statusbar spinner alongside [`Self::is_github_loading`].
1372  pub fn is_task_loading(&self) -> bool {
1373    self.tasks.is_any_loading()
1374  }
1375
1376  /// `true` while the create-worktree worker is in flight (issue #276).
1377  pub fn is_create_worktree_loading(&self) -> bool {
1378    self.tasks.is_loading(TaskKind::CreateWorktree)
1379  }
1380
1381  /// `true` while the delete-worktree worker is in flight (issue #257).
1382  pub fn is_delete_worktree_loading(&self) -> bool {
1383    self.tasks.is_loading(TaskKind::DeleteWorktree)
1384  }
1385
1386  /// `true` when a requested quit can safely leave the event loop now.
1387  /// Mutating spine workers keep running until their result is drained so
1388  /// `sync` / `bootstrap` / delete-worktree are not abandoned mid-operation.
1389  pub fn can_quit_now(&self) -> bool {
1390    !self.should_quit || !self.tasks.has_mutating_task_in_flight()
1391  }
1392
1393  /// Surface why a requested quit is being held. The event loop keeps
1394  /// ticking/draining while this status is visible.
1395  pub fn defer_quit_for_mutating_task(&mut self) {
1396    if let Some(label) = self.tasks.mutating_loading_label() {
1397      self.status = format!("finishing {} before quit…", label.trim_end_matches('…'));
1398    } else {
1399      self.status = "finishing task before quit…".into();
1400    }
1401  }
1402
1403  /// A clone of the task channel sender background workers report over
1404  /// (issue #231; GitHub fetch workers too since #255). Exposed so the
1405  /// async-apply path ([`Self::drain_task_results`]) can be driven
1406  /// deterministically in tests — inject a [`TaskMsg`] exactly as a worker
1407  /// would, then drain — without spawning an OS thread or a real `gh`.
1408  pub fn task_result_sender(&self) -> mpsc::Sender<TaskMsg> {
1409    self.task_tx.clone()
1410  }
1411
1412  /// Drop the cached sidebar content. Call on any change that may have altered
1413  /// what the sidebar shows: worktree list refresh, filter narrowing, etc.
1414  /// Pure delegate over [`SidebarState::invalidate`]; navigation-driven
1415  /// invalidation goes through [`Self::on_navigation`] which also resets
1416  /// the scroll offset.
1417  pub fn invalidate_sidebar_cache(&mut self) {
1418    self.sidebar.invalidate();
1419  }
1420
1421  /// Selection-change reaction: drop the sidebar's scroll back to the
1422  /// top, invalidate its cached preview, and resolve the link cache
1423  /// against the freshly selected worktree. Collapses the verbatim
1424  /// `sidebar.scroll = 0; invalidate_sidebar_cache(); refresh_link();`
1425  /// triple that was repeated across `next`, `prev`, `first`, `last`
1426  /// pre-extraction (issue #127, part of #102). The first two pieces
1427  /// live on [`SidebarState::on_navigation`]; the link refresh is
1428  /// orchestrator-shaped (it touches `self.link` / `self.link_slug` /
1429  /// `self.issue_state` / `self.pr_state` via [`Self::refresh_link`])
1430  /// so it stays here. Every navigation entry point now goes through
1431  /// this single call so the triple cannot drift back into duplicated
1432  /// literals.
1433  pub fn on_navigation(&mut self) {
1434    self.sidebar.on_navigation();
1435    self.refresh_link();
1436  }
1437
1438  pub fn next(&mut self) {
1439    // Route navigation to the sidebar when it's focused; otherwise move the list.
1440    if self.sidebar.open && self.sidebar.focused {
1441      self.sidebar_scroll_down();
1442      return;
1443    }
1444    let len = self.filtered_indices().len();
1445    if len == 0 {
1446      return;
1447    }
1448    let i = match self.list_state.selected() {
1449      Some(i) => (i + 1) % len,
1450      None => 0,
1451    };
1452    self.list_state.select(Some(i));
1453    self.on_navigation();
1454  }
1455
1456  pub fn prev(&mut self) {
1457    if self.sidebar.open && self.sidebar.focused {
1458      self.sidebar_scroll_up();
1459      return;
1460    }
1461    let len = self.filtered_indices().len();
1462    if len == 0 {
1463      return;
1464    }
1465    let i = match self.list_state.selected() {
1466      Some(0) | None => len - 1,
1467      Some(i) => i - 1,
1468    };
1469    self.list_state.select(Some(i));
1470    self.on_navigation();
1471  }
1472
1473  // ---- Vim-style motions / list jumps -------------------------------------
1474
1475  pub fn first(&mut self) {
1476    let len = self.filtered_indices().len();
1477    if len > 0 {
1478      self.list_state.select(Some(0));
1479      self.on_navigation();
1480    }
1481  }
1482
1483  pub fn last(&mut self) {
1484    let len = self.filtered_indices().len();
1485    if len > 0 {
1486      self.list_state.select(Some(len - 1));
1487      self.on_navigation();
1488    }
1489  }
1490
1491  /// Drive the two-keystroke `gg` motion. First press arms it, second jumps to top.
1492  ///
1493  /// **Compatibility shim** — kept so the existing tests in
1494  /// `tests/tui_app_tests.rs::handle_g_motion_tracks_pending_then_jumps_to_first`
1495  /// and the not-yet-migrated event-loop branch keep working
1496  /// verbatim. The implementation routes through
1497  /// [`Self::dispatch_key`] so the legacy and generic paths cannot
1498  /// drift on the chord semantics.
1499  pub fn handle_g(&mut self) {
1500    let ev = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty());
1501    if let Some(Action::Top) = self.dispatch_key(ev) {
1502      self.first();
1503    }
1504  }
1505
1506  /// Drop any in-flight chord prefix. Called by the legacy event-loop
1507  /// branch on any non-`g` keystroke (pre-#87 contract). New call
1508  /// sites that route through [`Self::dispatch_key`] don't need it —
1509  /// `dispatch_key` already clears the buffer on `NoMatch`.
1510  pub fn cancel_pending_motion(&mut self) {
1511    self.pending_chord.clear();
1512    self.sync_legacy_pending_flag();
1513  }
1514
1515  /// True iff no chord prefix is currently armed. Surface for tests
1516  /// and for the help / status-bar code that may want to show a
1517  /// "waiting for next key" hint once chord support is wired up.
1518  pub fn pending_chord_is_empty(&self) -> bool {
1519    self.pending_chord.is_empty()
1520  }
1521
1522  /// Drive a raw `KeyEvent` through the keymap.
1523  ///
1524  /// Returns `Some(action)` when the buffer (current pending chord +
1525  /// this stroke) matches a binding — caller fires the action and the
1526  /// buffer is left cleared. Returns `None` when the buffer is now a
1527  /// strict prefix of a longer binding (caller waits for the next
1528  /// keystroke) **or** when the stroke matches nothing at all
1529  /// (caller drops it).
1530  ///
1531  /// Vim-style fallback: if appending the stroke to a non-empty
1532  /// buffer produces a `NoMatch`, the buffer is cleared and the
1533  /// stroke is re-tried on its own. This mirrors the historical
1534  /// `g j` behaviour where the stray `g` is forgotten and `j`
1535  /// still navigates down.
1536  pub fn dispatch_key(&mut self, key: KeyEvent) -> Option<Action> {
1537    let stroke = KeyStroke::from_event(&key);
1538    let mut tentative = self.pending_chord.clone();
1539    tentative.push(stroke.clone());
1540
1541    let outcome = match self.keymap.lookup(&tentative) {
1542      ChordResolution::Matched(action) => {
1543        self.pending_chord.clear();
1544        Some(action)
1545      }
1546      ChordResolution::PendingPrefix => {
1547        self.pending_chord = tentative;
1548        None
1549      }
1550      ChordResolution::NoMatch if self.pending_chord.is_empty() => {
1551        // Single stroke, no binding. Nothing to retry.
1552        None
1553      }
1554      ChordResolution::NoMatch => {
1555        // Mismatched continuation. Drop the in-flight prefix and
1556        // retry the new stroke on its own so the user's keypress
1557        // is not silently swallowed when it has a single-key
1558        // binding (the `g j` case).
1559        self.pending_chord.clear();
1560        let single = vec![stroke];
1561        match self.keymap.lookup(&single) {
1562          ChordResolution::Matched(action) => Some(action),
1563          ChordResolution::PendingPrefix => {
1564            self.pending_chord = single;
1565            None
1566          }
1567          ChordResolution::NoMatch => None,
1568        }
1569      }
1570    };
1571
1572    self.sync_legacy_pending_flag();
1573    outcome
1574  }
1575
1576  pub fn key_matches_action(&self, key: KeyEvent, action: Action) -> bool {
1577    matches!(
1578      self.keymap.lookup(&[KeyStroke::from_event(&key)]),
1579      ChordResolution::Matched(found) if found == action
1580    )
1581  }
1582
1583  /// Resolve a keystroke against the contextual modal keymap (issue #219).
1584  /// Returns the [`ModalAction`] bound to `key` in `ctx`, or `None` when
1585  /// nothing in that context binds it — the modal routing then applies its
1586  /// text-input / default fallback (digits, free-text, sub-state guards).
1587  pub fn resolve_modal(&self, ctx: KeyContext, key: KeyEvent) -> Option<ModalAction> {
1588    self.modal_keymap.resolve(ctx, &KeyStroke::from_event(&key))
1589  }
1590
1591  /// Mirror the new `pending_chord` buffer into the legacy
1592  /// `pending_g` boolean so pre-#87 tests that read it as a field
1593  /// stay green. Removed when those tests migrate to
1594  /// [`Self::pending_chord_is_empty`].
1595  fn sync_legacy_pending_flag(&mut self) {
1596    let g = KeyStroke::new(KeyCode::Char('g'), KeyModifiers::empty());
1597    self.pending_g = self.pending_chord.len() == 1 && self.pending_chord[0] == g;
1598  }
1599
1600  // ---- Command palette (issue #32) ----------------------------------------
1601
1602  /// Open the command palette overlay. Transitions the active view
1603  /// to `View::CommandPalette` and arms the pure state machine on
1604  /// `self.palette` with a fresh empty buffer. Status bar shows a
1605  /// short hint so the user knows what to type.
1606  pub fn open_command_palette(&mut self) {
1607    self.palette.open();
1608    self.view = View::CommandPalette;
1609    self.status = "command palette — type, Enter to run, Esc to cancel".into();
1610  }
1611
1612  /// Close the palette without firing anything. Called on `Esc` from
1613  /// inside the overlay. Returns the view to `View::List` and drops
1614  /// the buffer.
1615  pub fn close_command_palette(&mut self) {
1616    self.palette.close();
1617    self.view = View::List;
1618    self.status = "palette cancelled".into();
1619  }
1620
1621  /// Append a character to the palette input buffer. The pure state
1622  /// machine re-runs its fuzzy match and resets the highlight to 0.
1623  pub fn palette_push_char(&mut self, c: char) {
1624    self.palette.push_char(c);
1625  }
1626
1627  /// Remove the trailing character from the palette input buffer.
1628  pub fn palette_pop_char(&mut self) {
1629    self.palette.pop_char();
1630  }
1631
1632  /// Move the palette highlight one row down (wraps at the end).
1633  pub fn palette_cycle_down(&mut self) {
1634    self.palette.cycle_highlight_down();
1635  }
1636
1637  /// Move the palette highlight one row up (wraps at the start).
1638  pub fn palette_cycle_up(&mut self) {
1639    self.palette.cycle_highlight_up();
1640  }
1641
1642  /// Accept the highlighted entry. Returns the resolved `Action` and
1643  /// drops the palette overlay; the caller (event loop) routes the
1644  /// action through the same dispatcher branch as a keystroke so
1645  /// palette + key fire identical side effects.
1646  ///
1647  /// When the input buffer matches nothing the palette stays open
1648  /// and `None` is returned — the user can backspace and retry
1649  /// without losing context.
1650  pub fn accept_command_palette(&mut self) -> Option<Action> {
1651    let action = self.palette.accept()?;
1652    self.view = View::List;
1653    self.status = format!("palette: {}", action.slug());
1654    Some(action)
1655  }
1656
1657  // ---- Sidebar ------------------------------------------------------------
1658
1659  pub fn toggle_sidebar(&mut self) {
1660    self.sidebar.toggle_open();
1661    self.status = if self.sidebar.open {
1662      "sidebar shown".into()
1663    } else {
1664      "sidebar hidden".into()
1665    };
1666  }
1667
1668  /// Cycle the sidebar preview mode between Commits and Stashes
1669  /// (issue #34). Drives the pure-state cycle on `SidebarState`
1670  /// plus the status-bar copy: orchestrator-shaped because the
1671  /// status bar is owned by `App`, not by the sub-struct.
1672  pub fn cycle_sidebar_mode(&mut self) {
1673    self.sidebar.cycle_mode();
1674    self.status = format!("sidebar: {}", self.sidebar.mode.label());
1675  }
1676
1677  /// Cycle the sidebar orientation `auto → side-by-side → stacked`
1678  /// (issue #188). Orchestrator-shaped for the status-bar copy, like
1679  /// [`Self::cycle_sidebar_mode`].
1680  pub fn cycle_sidebar_layout(&mut self) {
1681    self.sidebar.cycle_orientation();
1682    self.status = format!("sidebar layout: {}", self.sidebar.orientation.label());
1683  }
1684
1685  /// Flip the side-by-side sidebar position left ↔ right (issue #188).
1686  pub fn toggle_sidebar_position(&mut self) {
1687    self.sidebar.toggle_position();
1688    self.status = format!("sidebar position: {}", self.sidebar.position.label());
1689  }
1690
1691  pub fn toggle_focus(&mut self) {
1692    self.sidebar.toggle_focus();
1693  }
1694
1695  /// Direct-focus the worktree table (issue #217, `1`). Orchestrator-shaped
1696  /// for the status-bar copy, like the sidebar toggles.
1697  pub fn focus_worktrees(&mut self) {
1698    self.sidebar.focus_table();
1699    self.status = "focus: worktrees".into();
1700  }
1701
1702  /// Direct-focus the status (sidebar) pane (issue #217, `2`). Opens the
1703  /// sidebar if needed and moves focus onto it.
1704  pub fn focus_status(&mut self) {
1705    self.sidebar.focus_panel();
1706    self.status = "focus: status".into();
1707  }
1708
1709  /// The live UI context driving the statusbar chip + help subtitle (issue
1710  /// #217). An open modal / overlay wins over the pane focus (issue #217
1711  /// review P2): when the create form is up, the statusbar must advertise
1712  /// the form's keys, not the worktrees pane's `n new` — pressing `n` there
1713  /// types text. Only `View::List` falls through to the pane context
1714  /// (`Picker` in `gwm switch`, `Status` when the sidebar holds focus, else
1715  /// `Worktrees`).
1716  pub fn hint_context(&self) -> super::ui::HintContext {
1717    use super::ui::HintContext;
1718    match self.view {
1719      View::Create => HintContext::Create,
1720      View::Confirm => HintContext::Confirm,
1721      View::OpenMenu => HintContext::OpenMenu,
1722      // #219: the two link-prompt stages advertise different keys — the
1723      // choose-target picker vs the number-input submit/cancel — so the
1724      // statusbar tracks whichever stage is live.
1725      View::LinkPrompt => {
1726        if self.link_prompt_stage() == crate::tui::state::link_prompt::LinkPromptStage::InputNumber {
1727          HintContext::LinkInputNumber
1728        } else {
1729          HintContext::LinkPrompt
1730        }
1731      }
1732      View::CommandPalette => HintContext::CommandPalette,
1733      View::Report => HintContext::Report,
1734      View::Help => HintContext::Help,
1735      // The Command Logs overlay (issue #226) is a ~90% fullscreen modal;
1736      // the statusbar behind it shows the underlying pane's context, as the
1737      // List view does.
1738      View::CommandLogs => self.pane_hint_context(),
1739      // The Configuration panel (issue #232) is likewise a ~90% fullscreen
1740      // modal; the statusbar behind it keeps the underlying pane context.
1741      View::Config => self.pane_hint_context(),
1742      View::Pty => super::ui::HintContext::Pty,
1743      View::ExecPicker => HintContext::ExecPicker,
1744      View::CleanReport => HintContext::Clean,
1745      View::Edit => HintContext::Rename,
1746      View::List => self.pane_hint_context(),
1747    }
1748  }
1749
1750  /// The underlying list-view pane context (issue #217), ignoring any open
1751  /// overlay. Drives the help overlay's subtitle + picker-section gating:
1752  /// `?` documents the keys for the pane you were on, so it must NOT collapse
1753  /// to the `Help` context that [`Self::hint_context`] returns while the
1754  /// overlay is up.
1755  pub fn pane_hint_context(&self) -> super::ui::HintContext {
1756    use super::ui::HintContext;
1757    if self.picker_mode {
1758      HintContext::Picker
1759    } else if self.sidebar.open && self.sidebar.focused {
1760      HintContext::Status
1761    } else {
1762      HintContext::Worktrees
1763    }
1764  }
1765
1766  /// `true` while a GitHub issue / PR fetch for the current link is inflight
1767  /// (issue #217) — drives the statusbar loading spinner.
1768  pub fn is_github_loading(&self) -> bool {
1769    matches!(self.issue_fetch_state(), GitHubFetchState::Loading)
1770      || matches!(self.pr_fetch_state(), GitHubFetchState::Loading)
1771  }
1772
1773  pub fn sidebar_scroll_down(&mut self) {
1774    self.sidebar.scroll_down();
1775  }
1776
1777  pub fn sidebar_scroll_up(&mut self) {
1778    self.sidebar.scroll_up();
1779  }
1780
1781  /// Open the Keybindings (help) overlay from the top (#217). Resetting
1782  /// the scroll offset here keeps re-opens predictable.
1783  pub fn enter_help(&mut self) {
1784    self.view = View::Help;
1785    self.help_scroll = 0;
1786    self.help_x_scroll = 0;
1787  }
1788
1789  /// Open the Command Logs overlay (issue #226). Snapshots the global
1790  /// command log into owned state and resets the scroll cursor so a
1791  /// previously-scrolled session starts fresh at the top. The renderer
1792  /// republishes `max_scroll` against the live viewport.
1793  pub fn enter_command_logs(&mut self) {
1794    self.command_logs.sync();
1795    self.command_logs.reset();
1796    self.view = View::CommandLogs;
1797  }
1798
1799  /// Open the Configuration panel (issue #232). Resolves the effective
1800  /// config — the user-level global deep-merged under the repo `.gwm.toml`,
1801  /// with per-row source attribution — into owned state, then resets the
1802  /// scroll cursor so a re-open starts fresh at the top. The reads are
1803  /// cheap local TOML parses; on failure the panel still opens (empty)
1804  /// with the error on the statusbar rather than refusing to open.
1805  pub fn enter_config_panel(&mut self) {
1806    match crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
1807      Ok(rows) => self.config_panel.rows = rows,
1808      Err(e) => {
1809        self.config_panel.rows = Vec::new();
1810        self.status = format!("error: {}", e);
1811      }
1812    }
1813    self.refresh_key_rows();
1814    self.config_panel.reset();
1815    self.view = View::Config;
1816  }
1817
1818  /// Rebuild the Keys-tab rows (issue #294) from the live keymaps, attributing
1819  /// each binding's source via the resolved-row snapshot (the same layer
1820  /// attribution the `All` tab shows). Called on panel open and after a
1821  /// successful rebind so the displayed key(s) + badge track the edit.
1822  fn refresh_key_rows(&mut self) {
1823    let rows = self.config_panel.rows.clone();
1824    let key_rows = super::state::config_panel::build_key_rows(&self.keymap, &self.modal_keymap, |key| {
1825      rows
1826        .iter()
1827        .find(|r| r.key == key)
1828        .map(|r| r.source)
1829        .unwrap_or(crate::config::ConfigSource::Default)
1830    });
1831    self.config_panel.key_rows = key_rows;
1832  }
1833
1834  /// Feed a raw key event into the in-progress Keys-tab capture (issue #294),
1835  /// normalising it to a [`KeyStroke`] first. No-op when no capture is armed.
1836  pub fn push_key_capture(&mut self, key: KeyEvent) {
1837    self.config_panel.capture_push(KeyStroke::from_event(&key));
1838  }
1839
1840  /// Drive a key through an armed Keys-tab capture (issue #294). The event loop
1841  /// owns no logic — it just routes here when a capture is armed, mirroring
1842  /// `handle_create_key` / `handle_link_prompt_key`. Controls (resolved through
1843  /// the `config.edit` context so a rebind shows through):
1844  ///
1845  /// - `cancel` (def Esc) aborts the capture;
1846  /// - `submit` (def Enter) commits a **multi-stroke global chord**;
1847  /// - `Backspace` drops the last stroke of a global chord;
1848  /// - any other key is captured — a **single-stroke modal** verb auto-commits
1849  ///   on the first one, a global chord accumulates until `submit`.
1850  ///
1851  /// `Esc` / `Enter` / `Backspace` stay reserved controls in **both** modes and
1852  /// are never themselves captured (a modal verb can't be bound to them via the
1853  /// UI — hand-edit `.gwm.toml`), matching the documented capture controls and
1854  /// the hard-coded escape-hatch policy.
1855  pub fn handle_capture_key(&mut self, key: KeyEvent) {
1856    let single = self
1857      .config_panel
1858      .capture
1859      .as_ref()
1860      .map(|c| c.single_only)
1861      .unwrap_or(false);
1862    // Reserved capture controls. The *physical* Esc / Enter / Backspace are
1863    // always controls (never captured) regardless of any `config.edit` rebind,
1864    // so a custom `submit = ["Ctrl+s"]` can't make Enter assignable (Codex #297
1865    // review). The resolved `config.edit` verbs are honoured *in addition*, so a
1866    // custom key also cancels / commits.
1867    let resolved = self.resolve_modal(KeyContext::ConfigEdit, key);
1868    let is_cancel = key.code == KeyCode::Esc || resolved == Some(ModalAction::ConfigEditCancel);
1869    let is_submit = key.code == KeyCode::Enter || resolved == Some(ModalAction::ConfigEditSubmit);
1870    if is_cancel {
1871      self.config_panel.cancel_capture();
1872    } else if is_submit {
1873      // Enter commits an accumulated global chord; a reserved control (ignored)
1874      // for a single-stroke modal capture.
1875      if !single {
1876        self.commit_key_capture();
1877      }
1878    } else if key.code == KeyCode::Backspace {
1879      // Backspace edits a global chord; reserved (ignored) for a modal capture.
1880      if !single {
1881        self.config_panel.capture_pop();
1882      }
1883    } else {
1884      self.push_key_capture(key);
1885      if single {
1886        self.commit_key_capture();
1887      }
1888    }
1889  }
1890
1891  /// Commit the in-progress Keys-tab capture (issue #294): write the captured
1892  /// chord as a TOML array to the selected target's `[tui.keys]` /
1893  /// `[tui.keys.modal.<context>]` key in the active layer, then reload the
1894  /// config + both keymaps so the rebind is live immediately. An empty capture
1895  /// writes `[]` (unbind). Validation (conflict / prefix-collision) happens in
1896  /// the writer's validate-before-write gate; on failure the file and the live
1897  /// keymaps are left untouched and the error is surfaced on the statusbar.
1898  pub fn commit_key_capture(&mut self) {
1899    let Some(cap) = self.config_panel.take_capture() else {
1900      return;
1901    };
1902    let target = match self.config_panel.key_rows.get(cap.row) {
1903      Some(row) => row.target,
1904      None => return,
1905    };
1906    let config_key = target.config_key();
1907    let items = cap.as_config_items();
1908
1909    // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
1910    // with a stale selection that path is the *previously* active repo, so
1911    // refuse rather than rebind keys in the wrong repo (#304).
1912    if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
1913      self.status = "workspace: selected repo is unavailable — can't edit its project keymap".into();
1914      return;
1915    }
1916    let path = match self.config_panel.layer {
1917      SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
1918      SettingsLayer::Global => match self.global_path.clone() {
1919        Some(p) => p,
1920        None => {
1921          self.status = "keys: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
1922          return;
1923        }
1924      },
1925    };
1926
1927    // Snapshot the target file first: `set_array_at` only validates the file
1928    // it writes, not the layered merge, so a rebind that is valid in this file
1929    // alone but collides with the *other* layer once merged (e.g. a prefix
1930    // collision the global layer reveals) would slip past and brick the
1931    // config for the next launch. Keep the prior bytes so we can roll back
1932    // (Codex #297 review P2).
1933    let prior = std::fs::read(&path).ok();
1934
1935    if let Err(e) = crate::config_cli::set_array_at(&path, &config_key, &items) {
1936      // `write_and_validate` writes the edit *before* erroring when the file
1937      // was already invalid on its own (the recovery path for #281 — here the
1938      // target value can be shadowed by another layer so the app still
1939      // loaded). Roll back so a rebind reported as failed never persists or
1940      // takes effect on the next launch (Codex #297 review P2).
1941      Self::restore_file(&path, prior);
1942      self.status = format!("keys: {}", e);
1943      return;
1944    }
1945
1946    // Strip any pre-#290 alias of this action from the same file: a legacy
1947    // config that still carries e.g. `tui.keys.open_menu` would, on reload,
1948    // re-apply the alias after the canonical `browse_links` in the sorted
1949    // override walk and silently shadow the new binding (Codex #297 review).
1950    // Best-effort: the canonical key is already written, so a cleanup error
1951    // is surfaced but does not abort the rebind.
1952    for alias_key in target.compat_alias_keys() {
1953      if let Err(e) = crate::config_cli::unset_at(&path, &alias_key) {
1954        self.status = format!("keys: {}", e);
1955      }
1956    }
1957
1958    // Reload the merged config and rebuild both keymaps so the new binding
1959    // fires without a restart.
1960    match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
1961      Ok(cfg) => self.set_active_config(cfg),
1962      Err(e) => {
1963        // The single-file write validated but the layered merge is invalid —
1964        // roll the file back to its prior state so the config is never left
1965        // broken on disk, and keep the previous live keymaps.
1966        Self::restore_file(&path, prior);
1967        self.status = format!("keys: rebind rejected — would break the merged config: {}", e);
1968        return;
1969      }
1970    }
1971    match self.config.tui.keys.resolved_keymap() {
1972      Ok(km) => self.keymap = km,
1973      Err(e) => {
1974        self.status = format!("keys: {}", e);
1975        return;
1976      }
1977    }
1978    match self.config.tui.keys.resolved_modal_keymap() {
1979      Ok(mk) => self.modal_keymap = mk,
1980      Err(e) => {
1981        self.status = format!("keys: {}", e);
1982        return;
1983      }
1984    }
1985    if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
1986      self.config_panel.rows = rows;
1987    }
1988    self.refresh_key_rows();
1989
1990    let desc = if items.is_empty() {
1991      "unbound".to_string()
1992    } else {
1993      items.join(" ")
1994    };
1995    let mut status = format!("set {} = {} ({})", config_key, desc, self.config_panel.layer.label());
1996    // Verify the capture actually took effect in the *merged* keymap: a
1997    // higher-precedence layer, or a pre-#290 alias still declared in another
1998    // layer (which we deliberately don't edit), can shadow the write so the new
1999    // key never fires — or, for an unbind, keeps the action bound — even though
2000    // it persisted. Warn instead of reporting a clean success (Codex #297
2001    // review).
2002    if !self.capture_took_effect(target, &cap.pending) {
2003      status.push_str(" — shadowed (a higher layer or legacy alias still binds it)");
2004    }
2005    self.status = status;
2006  }
2007
2008  /// Restore a config file to a snapshot taken before a rebind write: rewrite
2009  /// the prior bytes, or remove the file if it did not exist before. Used to
2010  /// roll back a failed / merge-invalid Keys-tab write (issue #294).
2011  fn restore_file(path: &std::path::Path, prior: Option<Vec<u8>>) {
2012    match prior {
2013      Some(bytes) => {
2014        let _ = std::fs::write(path, bytes);
2015      }
2016      None => {
2017        let _ = std::fs::remove_file(path);
2018      }
2019    }
2020  }
2021
2022  /// Whether the just-committed capture is the *effective* state in the live
2023  /// (merged) keymap, i.e. not shadowed by another layer / a lingering legacy
2024  /// alias. For a rebind (`strokes` non-empty) the captured chord must resolve
2025  /// to the target's action; for an unbind (`strokes` empty) the action must
2026  /// have no remaining binding. Issue #294 (Codex #297 review).
2027  fn capture_took_effect(&self, target: KeyTarget, strokes: &[KeyStroke]) -> bool {
2028    match target {
2029      KeyTarget::Global(action) => {
2030        if strokes.is_empty() {
2031          self.keymap.keys_display(action).is_empty()
2032        } else {
2033          matches!(self.keymap.lookup(strokes), ChordResolution::Matched(a) if a == action)
2034        }
2035      }
2036      KeyTarget::Modal(action) => {
2037        if strokes.is_empty() {
2038          self.modal_keymap.keys_display(action).is_empty()
2039        } else {
2040          strokes
2041            .first()
2042            .map(|s| self.modal_keymap.resolve(action.context(), s) == Some(action))
2043            .unwrap_or(false)
2044        }
2045      }
2046    }
2047  }
2048
2049  // ── PTY overlay (issue #35) ────────────────────────────────────────────
2050
2051  /// Open the PTY overlay: store `pty` and switch to [`View::Pty`].
2052  pub fn open_pty_overlay(&mut self, pty: super::state::pty_overlay::PtyOverlay) {
2053    self.pty_overlay = Some(pty);
2054    self.view = View::Pty;
2055  }
2056
2057  /// Close the PTY overlay: kill the child process, drop the state, and
2058  /// return to [`View::List`]. Safe to call when no overlay is open.
2059  pub fn close_pty_overlay(&mut self) {
2060    if let Some(ref mut pty) = self.pty_overlay {
2061      pty.kill();
2062    }
2063    self.pty_overlay = None;
2064    if self.view == View::Pty {
2065      self.view = View::List;
2066    }
2067  }
2068
2069  // ── Exec picker overlay (issue #325) ───────────────────────────────────
2070
2071  /// `true` while a destructive overlay — the exec picker or the clean
2072  /// report — is open (issue #325). The run loop suspends `maybe_auto_refresh`
2073  /// and `sync_active_repo` while one is up, so the worktree list (and thus
2074  /// the live selection / active repo) cannot reshuffle under an armed reclaim
2075  /// or a pending exec run. This closes the drift class at its source (Codex
2076  /// #333 review); the per-overlay open-time snapshots stay as defence in
2077  /// depth against an already-in-flight refresh landing its result.
2078  pub fn destructive_overlay_open(&self) -> bool {
2079    matches!(self.view, View::ExecPicker | View::CleanReport)
2080  }
2081
2082  /// Open the exec profile picker (issue #325). Populates it from
2083  /// `[exec.profiles.*]` and switches to [`View::ExecPicker`]. Refuses
2084  /// (status-bar message, no transition) when nothing is selected or no
2085  /// exec profiles are configured — there is nothing to pick.
2086  pub fn enter_exec_picker(&mut self) {
2087    let Some(cwd) = self.selected().map(|wt| wt.path.clone()) else {
2088      self.status = "nothing selected".into();
2089      return;
2090    };
2091    let names: Vec<String> = self.config.exec.profiles.keys().cloned().collect();
2092    if names.is_empty() {
2093      self.status = "no [exec.profiles] configured — add one to .gwm.toml".into();
2094      return;
2095    }
2096    // Capture the target worktree path AND the active repo's `[exec]` config
2097    // now: an auto-refresh can drift the live selection (and, in workspace
2098    // mode, the active repo) while the picker is open, so `Enter` must run in
2099    // *this* worktree against *this* config — not whatever is live later
2100    // (Codex #333 review).
2101    self.exec_picker_cfg = self.config.exec.clone();
2102    self.exec_picker.open(names, cwd);
2103    self.view = View::ExecPicker;
2104  }
2105
2106  /// Handle a key inside the exec picker overlay (issue #325). The
2107  /// testable handler owns the highlight movement; the run loop owns the
2108  /// two side effects (resolve + spawn, or close). Keys resolve through
2109  /// [`KeyContext::ExecPicker`] so they honour `[tui.keys.modal.exec]`.
2110  pub fn handle_exec_picker_key(&mut self, key: KeyEvent) -> ExecPickerKey {
2111    match self.resolve_modal(KeyContext::ExecPicker, key) {
2112      Some(ModalAction::ExecPickerCancel) => ExecPickerKey::Cancel,
2113      Some(ModalAction::ExecPickerAccept) => ExecPickerKey::Submit,
2114      Some(ModalAction::ExecPickerNext) => {
2115        self.exec_picker.next();
2116        ExecPickerKey::Handled
2117      }
2118      Some(ModalAction::ExecPickerPrev) => {
2119        self.exec_picker.prev();
2120        ExecPickerKey::Handled
2121      }
2122      _ => ExecPickerKey::Handled,
2123    }
2124  }
2125
2126  /// Resolve the highlighted exec profile to an `(argv, cwd)` pair for the
2127  /// run loop to spawn in a PTY overlay (issue #325). `None` (with a
2128  /// status-bar message) when nothing is selected or the profile fails to
2129  /// resolve — e.g. an empty `command` array. The argv is the frozen
2130  /// `[exec.profiles.<name>].command` verbatim (no shell), matching the
2131  /// 1.0 exec contract; the run loop spawns `argv[0]` directly.
2132  pub fn exec_picker_resolve(&mut self) -> Option<(Vec<String>, PathBuf)> {
2133    let profile = self.exec_picker.selected_profile()?.to_string();
2134    // Resolve against the worktree captured when the picker opened, NOT the
2135    // live selection (which an auto-refresh may have drifted) — #333 review.
2136    let Some(cwd) = self.exec_picker.cwd().map(Path::to_path_buf) else {
2137      self.status = "nothing selected".into();
2138      return None;
2139    };
2140    // Resolve against the `[exec]` config captured at open, not the live one.
2141    match crate::exec::resolve_exec_command(Some(&profile), &[], &self.exec_picker_cfg) {
2142      Ok(mut argv) => {
2143        // Pin a worktree-relative executable (`./run.sh`, `scripts/build`) to
2144        // the captured worktree, exactly like the CLI exec path — otherwise
2145        // `argv[0]` would resolve against gwm's own cwd (Codex #333 review).
2146        // A bare command (`cargo`) or an absolute path is returned unchanged
2147        // (PATH lookup / as-is).
2148        if let Some(first) = argv.first_mut() {
2149          *first = crate::exec::resolve_program(&cwd, first).to_string_lossy().into_owned();
2150        }
2151        Some((argv, cwd))
2152      }
2153      Err(e) => {
2154        self.status = format!("exec profile {profile:?}: {e}");
2155        None
2156      }
2157    }
2158  }
2159
2160  /// Close the exec picker without running anything (issue #325). Returns
2161  /// to [`View::List`].
2162  pub fn close_exec_picker(&mut self) {
2163    if self.view == View::ExecPicker {
2164      self.view = View::List;
2165    }
2166  }
2167
2168  // ── Clean overlay (issue #325) ─────────────────────────────────────────
2169
2170  /// Open the clean overlay (issue #325). Populates the `[clean.profiles]`
2171  /// picker, scans the selected worktree through the safety gate
2172  /// ([`crate::clean::scan_worktree_safe`]), and switches to
2173  /// [`View::CleanReport`]. Refuses (status-bar message, no transition) when
2174  /// nothing is selected. A scan that finds nothing safe still opens — the
2175  /// report says so.
2176  pub fn enter_clean_overlay(&mut self) {
2177    let Some(sel) = self.selected() else {
2178      self.status = "nothing selected".into();
2179      return;
2180    };
2181    // Capture the target worktree AND the active repo's `[clean]` config now:
2182    // an auto-refresh can drift the live selection (and, in workspace mode,
2183    // the active repo) while the overlay is open / armed, so every re-scan
2184    // and the delete must pin to *this* worktree against *this* config
2185    // (Codex #333 review).
2186    let name = sel.name.clone();
2187    let path = sel.path.clone();
2188    self.clean_overlay_cfg = self.config.clean.clone();
2189    self.clean_overlay_countdown_secs = self.config.tui.effective_confirm_countdown_secs();
2190    let names: Vec<String> = self.clean_overlay_cfg.profiles.keys().cloned().collect();
2191    self.clean_overlay.open(names, name, path);
2192    if let Err(e) = self.clean_overlay_rescan() {
2193      self.status = format!("clean: {e}");
2194      return;
2195    }
2196    self.view = View::CleanReport;
2197  }
2198
2199  /// Re-resolve the highlighted profile's dirs and re-scan the *captured*
2200  /// target worktree (not the live selection), storing the gated snapshot.
2201  /// Surfaces a profile-resolution error (e.g. an invalid `[clean.profiles]`
2202  /// dir) to the caller.
2203  fn clean_overlay_rescan(&mut self) -> Result<()> {
2204    let Some((name, path)) = self
2205      .clean_overlay
2206      .target()
2207      .map(|(n, p)| (n.to_string(), p.to_path_buf()))
2208    else {
2209      return Ok(());
2210    };
2211    let profile = self.clean_overlay.selected_profile().map(str::to_string);
2212    let dirs = crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg)?;
2213    let (reclaim, skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
2214    self.clean_overlay.set_scan(reclaim, skipped);
2215    Ok(())
2216  }
2217
2218  /// Cycle the clean profile picker forward and re-scan, but ONLY when the
2219  /// highlight actually moved (issue #325 / Codex #333). A no-op move (only
2220  /// the `(default)` choice) must not re-scan — that would reset the
2221  /// `ConfirmModal` and silently disarm a pending reclaim while the status
2222  /// bar still reads `armed`.
2223  pub fn clean_overlay_next(&mut self) {
2224    if self.clean_overlay.select_next() {
2225      if let Err(e) = self.clean_overlay_rescan() {
2226        self.status = format!("clean: {e}");
2227      }
2228    }
2229  }
2230
2231  /// Cycle the clean profile picker backward and re-scan, only when the
2232  /// highlight actually moved (issue #325 / Codex #333).
2233  pub fn clean_overlay_prev(&mut self) {
2234    if self.clean_overlay.select_prev() {
2235      if let Err(e) = self.clean_overlay_rescan() {
2236        self.status = format!("clean: {e}");
2237      }
2238    }
2239  }
2240
2241  /// Total duration of the clean safety countdown. Unlike the delete-confirm
2242  /// modal, clean has no `delete_branch_on_remove` gate — it reads
2243  /// `[tui] confirm_countdown_secs` directly. `Duration::ZERO` ⇒ classic
2244  /// single-keystroke confirm.
2245  pub fn clean_countdown_total(&self) -> Duration {
2246    // The value captured at open (Codex #333) — never the live config, which a
2247    // workspace refresh could swap (e.g. to `0`, erasing the safety delay).
2248    Duration::from_secs(u64::from(self.clean_overlay_countdown_secs))
2249  }
2250
2251  /// Handle the clean confirm key. Arms / disarms / fires the countdown via
2252  /// the dedicated [`CleanOverlay`] modal. Nothing-to-reclaim is a no-op
2253  /// guard so the user cannot arm a delete that would free zero bytes.
2254  pub fn clean_confirm_press(&mut self, now: Instant) -> ConfirmKeyAction {
2255    if self.clean_overlay.is_empty_scan() {
2256      self.status = "nothing to reclaim".into();
2257      return ConfirmKeyAction::Disarmed;
2258    }
2259    let total = self.clean_countdown_total();
2260    let action = self.clean_overlay.confirm.press_y(now, total);
2261    match action {
2262      ConfirmKeyAction::Armed => {
2263        self.status = format!(
2264          "armed — reclaiming {} in {}s",
2265          crate::clean::human_size(self.clean_overlay.total_bytes()),
2266          total.as_secs()
2267        );
2268      }
2269      ConfirmKeyAction::Disarmed => self.status = "clean cancelled".into(),
2270      ConfirmKeyAction::FireNow => {}
2271    }
2272    action
2273  }
2274
2275  /// Tick the clean safety countdown. Called from the event loop on every
2276  /// poll-timeout iteration while the overlay is open.
2277  pub fn tick_clean_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
2278    self.clean_overlay.confirm.tick(now, self.clean_countdown_total())
2279  }
2280
2281  /// Clean countdown progress in `[0.0, 1.0]` for the UI gauge.
2282  pub fn clean_countdown_progress(&self, now: Instant) -> f64 {
2283    self.clean_overlay.confirm.progress(now, self.clean_countdown_total())
2284  }
2285
2286  /// Seconds remaining (rounded up) on the clean countdown, for the UI label.
2287  pub fn clean_countdown_remaining_secs(&self, now: Instant) -> u64 {
2288    self
2289      .clean_overlay
2290      .confirm
2291      .remaining_secs(now, self.clean_countdown_total())
2292  }
2293
2294  /// Delete the gated reclaim of the current clean snapshot (issue #325) and
2295  /// return to the list. The snapshot was already filtered to the
2296  /// git-ignored, untracked artifacts by [`crate::clean::scan_worktree_safe`],
2297  /// so this only removes what the CLI `gwm clean --yes` would. Reports the
2298  /// freed size (or the failure) on the status bar.
2299  pub fn clean_overlay_delete(&mut self) {
2300    // Re-scan + re-gate IMMEDIATELY before deleting rather than trusting the
2301    // snapshot shown in the overlay (Codex #333 review). That snapshot can be
2302    // seconds old — the safety countdown, or just the overlay sitting open —
2303    // and a directory may have turned unsafe meanwhile (e.g. `git add -f
2304    // target/file` under an ignored `target/`). Deleting a freshly gated
2305    // reclaim closes that TOCTOU window, matching the CLI's scan-then-delete.
2306    // Pin to the CAPTURED target worktree, not the live selection (an
2307    // auto-refresh may have drifted it while the countdown ran) — #333.
2308    let Some((name, path)) = self
2309      .clean_overlay
2310      .target()
2311      .map(|(n, p)| (n.to_string(), p.to_path_buf()))
2312    else {
2313      self.close_clean_overlay();
2314      return;
2315    };
2316    let profile = self.clean_overlay.selected_profile().map(str::to_string);
2317    let dirs = match crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg) {
2318      Ok(d) => d,
2319      Err(e) => {
2320        self.status = format!("clean: {e}");
2321        self.close_clean_overlay();
2322        return;
2323      }
2324    };
2325    let (reclaim, _skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
2326    if reclaim.artifacts.is_empty() {
2327      self.status = "nothing to reclaim".into();
2328      self.close_clean_overlay();
2329      return;
2330    }
2331    match crate::clean::delete_reclaim(&reclaim) {
2332      Ok(freed) => {
2333        self.status = format!("reclaimed {} from {}", crate::clean::human_size(freed), reclaim.name);
2334      }
2335      Err(e) => self.status = format!("clean failed: {e}"),
2336    }
2337    self.close_clean_overlay();
2338  }
2339
2340  /// Close the clean overlay, disarming the countdown, and return to
2341  /// [`View::List`] (issue #325).
2342  pub fn close_clean_overlay(&mut self) {
2343    self.clean_overlay.confirm.dismiss();
2344    if self.view == View::CleanReport {
2345      self.view = View::List;
2346    }
2347  }
2348
2349  /// Activate the selected Settings field (issue #279): cycle a choice field
2350  /// to its next value (writing + applying live), or arm the numeric input
2351  /// buffer for a `Uint` field. No-op on the read-only `All` tab.
2352  pub fn activate_selected_setting(&mut self) {
2353    let Some(field) = self.config_panel.selected_field() else {
2354      return;
2355    };
2356    match field.kind() {
2357      FieldKind::Choice => {
2358        if let Some(next) = field.next_choice(&self.config) {
2359          self.apply_setting(field, &next);
2360        }
2361      }
2362      FieldKind::Uint | FieldKind::Text => {
2363        let current = field.current(&self.config);
2364        self.config_panel.begin_edit(&current);
2365      }
2366    }
2367  }
2368
2369  /// Commit the in-progress numeric edit (issue #279): write the buffered
2370  /// value to the selected field and apply it live. Clearing the buffer
2371  /// reads as `0` (see [`ConfigPanel::take_edit`]).
2372  pub fn commit_settings_edit(&mut self) {
2373    let Some(field) = self.config_panel.selected_field() else {
2374      self.config_panel.cancel_edit();
2375      return;
2376    };
2377    if let Some(value) = self.config_panel.take_edit() {
2378      // A cleared numeric input is a valid zero; a cleared text input is a
2379      // legitimate empty / unset value.
2380      let value = if field.kind() == FieldKind::Uint && value.is_empty() {
2381        "0".to_string()
2382      } else {
2383        value
2384      };
2385      self.apply_setting(field, &value);
2386    }
2387  }
2388
2389  /// Persist `field = value` into the active layer's TOML file and apply the
2390  /// change live (issue #279). The write targets the per-project `.gwm.toml`
2391  /// or the user-global `config.toml` per the panel's layer selector; on
2392  /// success the config is reloaded, the theme re-resolved, the sidebar
2393  /// position re-seeded and the resolved-rows snapshot refreshed so the
2394  /// `All` tab and the source attribution track the edit. Every fallible
2395  /// step routes its error to the status line — no `unwrap` on this path.
2396  pub fn apply_setting(&mut self, field: SettingField, value: &str) {
2397    // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
2398    // with a stale selection that path is the *previously* active repo, so
2399    // refuse rather than write settings into the wrong repo (#304). Global-layer
2400    // edits are repo-independent and stay allowed.
2401    if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
2402      self.status = "workspace: selected repo is unavailable — can't edit its project config".into();
2403      return;
2404    }
2405    let path = match self.config_panel.layer {
2406      SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
2407      SettingsLayer::Global => match self.global_path.clone() {
2408        Some(p) => p,
2409        None => {
2410          self.status = "settings: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
2411          return;
2412        }
2413      },
2414    };
2415
2416    // Numeric fields write a TOML integer; choices and free text write a
2417    // TOML string, so a value like `123` / `true` in a shell command or
2418    // worktree pattern is preserved as text rather than coerced (review P2).
2419    let write = match field.kind() {
2420      FieldKind::Uint => crate::config_cli::set_value_at(&path, field.key_path(), value),
2421      FieldKind::Choice | FieldKind::Text => crate::config_cli::set_string_at(&path, field.key_path(), value),
2422    };
2423    if let Err(e) = write {
2424      self.status = format!("settings: {}", e);
2425      return;
2426    }
2427
2428    // Reload the merged config so every live read (open mode, confirm
2429    // countdown) and the re-seeded state below reflect the edit.
2430    match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
2431      Ok(cfg) => self.set_active_config(cfg),
2432      Err(e) => {
2433        self.status = format!("settings saved, but reload failed: {}", e);
2434        return;
2435      }
2436    }
2437    // A Global-layer edit changes config for *every* repo, not just the active
2438    // one — refresh each cached `RepoMeta.config` so navigating to another repo
2439    // doesn't restore the pre-edit global value (Codex review #303 P2). A
2440    // Project-layer edit only touched the active repo's `.gwm.toml`, already
2441    // handled by `set_active_config`.
2442    if self.config_panel.layer == SettingsLayer::Global {
2443      self.reload_workspace_repo_configs();
2444    }
2445    match self.config.theme.resolve() {
2446      Ok(theme) => self.theme = theme,
2447      Err(e) => self.status = format!("theme: {}", e),
2448    }
2449    self.sidebar.position = self.config.tui.sidebar_position;
2450    if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
2451      self.config_panel.rows = rows;
2452    }
2453
2454    let mut status = format!(
2455      "set {} = {} ({})",
2456      field.key_path(),
2457      value,
2458      self.config_panel.layer.label()
2459    );
2460    // Surface a shadowed edit: writing global for a key the repo overrides
2461    // leaves the effective value unchanged (repo wins).
2462    if self.config_panel.layer == SettingsLayer::Global
2463      && self.config_panel.field_source(field) == Some(crate::config::ConfigSource::Repo)
2464    {
2465      status.push_str(" — shadowed by .gwm.toml");
2466    }
2467    self.status = status;
2468  }
2469
2470  /// Render the Command Logs transcript as plain text for the clipboard
2471  /// (issue #279, `y`): newest-first, mirroring the overlay's layout
2472  /// (`$ argv`, the outcome line, then the full captured output — not the
2473  /// tail-capped view), entries separated by a blank line. Pure + owned so
2474  /// the format is unit-testable without a clipboard. Empty when no commands
2475  /// have run.
2476  pub fn command_logs_transcript(&self) -> String {
2477    use crate::command_log::CommandStatus;
2478    let mut out = String::new();
2479    for entry in self.command_logs.entries.iter().rev() {
2480      out.push_str(&format!("$ {}\n", entry.command));
2481      let detail = match &entry.status {
2482        CommandStatus::Exited(Some(0)) => format!("→ exit 0 ({} ms)", entry.duration.as_millis()),
2483        CommandStatus::Exited(Some(code)) => format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
2484        CommandStatus::Exited(None) => format!("→ terminated ({} ms)", entry.duration.as_millis()),
2485        CommandStatus::Spawn => "✗ failed to spawn".to_string(),
2486      };
2487      out.push_str(&format!("  {}\n", detail));
2488      for line in entry.output.lines() {
2489        out.push_str(&format!("    {}\n", line));
2490      }
2491      out.push('\n');
2492    }
2493    out.trim_end().to_string()
2494  }
2495
2496  /// Scroll the help overlay down one row, clamped to the renderer-published
2497  /// `help_max_scroll` so it never scrolls past the last line.
2498  pub fn help_scroll_down(&mut self) {
2499    self.help_scroll = (self.help_scroll + 1).min(self.help_max_scroll);
2500  }
2501
2502  /// Scroll the help overlay up one row, clamped at the top.
2503  pub fn help_scroll_up(&mut self) {
2504    self.help_scroll = self.help_scroll.saturating_sub(1);
2505  }
2506
2507  pub fn help_scroll_right(&mut self) {
2508    self.help_x_scroll = (self.help_x_scroll + 1).min(self.help_max_x_scroll);
2509  }
2510
2511  pub fn help_scroll_left(&mut self) {
2512    self.help_x_scroll = self.help_x_scroll.saturating_sub(1);
2513  }
2514
2515  /// Path to launch lazygit on, or `None` if nothing selected or lazygit is missing.
2516  /// The caller drives the actual TUI suspension/restoration around the spawn.
2517  ///
2518  /// Retained for callers that still want the legacy "lazygit only"
2519  /// path; new code should go through [`Self::prepare_git_tui`], which
2520  /// honours the configurable `[git_tui]` block (issue #75).
2521  pub fn launch_lazygit(&mut self) -> Option<PathBuf> {
2522    let path = self.selected()?.path.clone();
2523    if which::which("lazygit").is_err() {
2524      self.status = "lazygit not found in PATH".into();
2525      return None;
2526    }
2527    Some(path)
2528  }
2529
2530  // ---- Configurable launchers (issue #75) ---------------------------------
2531
2532  /// Build the [`LauncherPlan`] for the `l` keybinding. Reads
2533  /// `[git_tui]` from `.gwm.toml` (default `lazygit -p {path}`
2534  /// fullscreen=true) and expands the `{path}` placeholder against
2535  /// the selected worktree. Returns `None` (and sets a status hint)
2536  /// when nothing is selected or the template is malformed.
2537  pub fn prepare_git_tui(&mut self) -> Option<LauncherPlan> {
2538    let Some(wt) = self.selected().cloned() else {
2539      self.status = "nothing selected".into();
2540      return None;
2541    };
2542    let resolved = self.config.git_tui.resolved();
2543    let ctx = LauncherContext {
2544      worktree_path: &wt.path,
2545      base: None,
2546      head: None,
2547      repo_workdir: Some(&self.workdir),
2548    };
2549    match launcher::expand_command(&resolved.command, &ctx) {
2550      Ok(expanded) => Some(LauncherPlan {
2551        expanded,
2552        cwd: wt.path,
2553        fullscreen: resolved.fullscreen,
2554        base: None,
2555      }),
2556      Err(e) => {
2557        self.status = format!("git_tui template error: {}", e);
2558        None
2559      }
2560    }
2561  }
2562
2563  /// Build the [`LauncherPlan`] for the `R` keybinding. Implements the
2564  /// full review contract from issue #75:
2565  ///
2566  /// 1. `[review]` must resolve to a concrete launcher (`command`
2567  ///    set, or `tool = "<preset>"` matched).
2568  /// 2. The selected worktree must carry a branch name.
2569  /// 3. The review base is resolved via the documented chain (upstream
2570  ///    → `gwm-base` → `[review].default_base` → `"dev"` → `"main"`).
2571  /// 4. When `skip_when_no_changes` is on (default), a zero
2572  ///    `git rev-list --count {base}..HEAD` short-circuits with a
2573  ///    status-bar hint naming the base.
2574  /// 5. The template is expanded; `{diff}` lazily materialises a
2575  ///    tempfile so unused placeholders never spawn `git diff`.
2576  pub fn prepare_review(&mut self) -> Option<LauncherPlan> {
2577    let resolved = match self.config.review.resolved() {
2578      Some(r) => r,
2579      None => {
2580        self.status = "review tool not configured — set [review] in .gwm.toml".into();
2581        return None;
2582      }
2583    };
2584    let Some(wt) = self.selected().cloned() else {
2585      self.status = "nothing selected".into();
2586      return None;
2587    };
2588    let Some(head) = wt.branch.clone() else {
2589      self.status = "selected worktree has no branch — cannot review".into();
2590      return None;
2591    };
2592
2593    let base = launcher::resolve_review_base(&self.repo, &head, self.config.review.default_base.as_deref());
2594
2595    if self.config.review.skip_when_no_changes {
2596      let n = launcher::count_commits_ahead(&wt.path, &base, "HEAD");
2597      if n == 0 {
2598        self.status = format!("no changes to review (already at {})", base);
2599        return None;
2600      }
2601    }
2602
2603    let ctx = LauncherContext {
2604      worktree_path: &wt.path,
2605      base: Some(&base),
2606      head: Some(&head),
2607      repo_workdir: Some(&self.workdir),
2608    };
2609    match launcher::expand_command(&resolved.command, &ctx) {
2610      Ok(expanded) => {
2611        if self.config.review.has_shadowed_tool() {
2612          self.status = format!("review: command overrides tool — running {}", base);
2613        } else {
2614          self.status = format!("review: {} vs {}", head, base);
2615        }
2616        Some(LauncherPlan {
2617          expanded,
2618          cwd: wt.path,
2619          fullscreen: resolved.fullscreen,
2620          base: Some(base),
2621        })
2622      }
2623      Err(e) => {
2624        self.status = format!("review template error: {}", e);
2625        None
2626      }
2627    }
2628  }
2629
2630  pub fn selected(&self) -> Option<&WorktreeInfo> {
2631    // The visible list is the filtered subset, so the table state's index is
2632    // into `filtered_indices()`, not the raw `worktrees` vec. Resolving the
2633    // selection means hopping through the filter map.
2634    //
2635    // `selected` keeps its `&self` signature so callers holding a
2636    // shared borrow (e.g. `ui.rs` render path, `copy_path_to_status`)
2637    // don't have to upgrade. `snapshot_indices` reads the cache when
2638    // it's warm (which the per-frame render path guarantees, since
2639    // the table renderer calls `filtered_indices` first) and falls
2640    // back to a fresh compute when it isn't.
2641    let i = self.list_state.selected()?;
2642    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
2643    let original = *filtered.get(i)?;
2644    self.worktrees.get(original)
2645  }
2646
2647  pub fn copy_path_to_status(&mut self) {
2648    if let Some(w) = self.selected() {
2649      self.status = format!("path: {}", w.path.display());
2650    }
2651  }
2652
2653  /// Reveal the selected worktree's directory in the OS file manager.
2654  /// macOS: `open`, Linux: `xdg-open`, Windows: `explorer`. Used by
2655  /// `resolve_open_target` when the config picks `mode = "finder"`,
2656  /// and by the event loop directly to spawn the opener.
2657  pub fn open_selected_in_finder(&mut self) {
2658    let path = match self.selected() {
2659      Some(w) => w.path.clone(),
2660      None => {
2661        self.status = "nothing selected".into();
2662        return;
2663      }
2664    };
2665    let opener = if cfg!(target_os = "macos") {
2666      "open"
2667    } else if cfg!(target_os = "windows") {
2668      "explorer"
2669    } else {
2670      "xdg-open"
2671    };
2672    match std::process::Command::new(opener).arg(&path).spawn() {
2673      Ok(_) => self.status = format!("opened {} in {}", path.display(), opener),
2674      Err(e) => self.status = format!("failed to open {}: {}", path.display(), e),
2675    }
2676  }
2677
2678  /// Return the path that the `Y: yank-path` key should push into the
2679  /// system clipboard, or `None` when nothing is selected. Pure — the
2680  /// shell-out is handled by the event loop.
2681  pub fn yank_selected_path(&self) -> Option<PathBuf> {
2682    self.selected().map(|w| w.path.clone())
2683  }
2684
2685  /// Return the branch name for the `y: yank-branch-name` key (#290).
2686  pub fn yank_selected_branch(&self) -> Option<String> {
2687    self.selected()?.branch.clone()
2688  }
2689
2690  /// Return the worktree slug/name for the `w: yank-worktree-name` key (#290).
2691  pub fn yank_selected_worktree_name(&self) -> Option<String> {
2692    self.selected().map(|w| w.name.clone())
2693  }
2694
2695  /// Signal the event loop to print the selected worktree path to stdout
2696  /// before quitting (`e: exit-to-worktree`, #290). The loop checks
2697  /// `should_exit_to` after `can_quit_now` to emit the path.
2698  pub fn exit_to_worktree(&mut self) {
2699    let Some(path) = self.selected().map(|w| w.path.clone()) else {
2700      self.status = "no worktree selected".into();
2701      return;
2702    };
2703    self.should_exit_to = Some(path);
2704    self.should_quit = true;
2705  }
2706
2707  /// Request an off-thread `git pull` of the selected worktree's branch
2708  /// (#290). Coalesces if a pull is already in flight, and refuses to start
2709  /// while a *different* mutating task (sync / bootstrap / push / rename /
2710  /// create / delete) runs in the same worktree (Codex review on PR #292).
2711  pub fn request_pull(&mut self) {
2712    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
2713      self.status = "no worktree selected".into();
2714      return;
2715    };
2716    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Pull) {
2717      self.status = self.busy_mutation_status("pulling");
2718      return;
2719    }
2720    let Some(generation) = self.tasks.request(TaskKind::Pull) else {
2721      return;
2722    };
2723    self.spinner.reset();
2724    self.status = TaskKind::Pull.loading_label().into();
2725    self.spawn_pull(generation, path, name);
2726  }
2727
2728  /// Status line shown when a mutating verb is pressed while another mutating
2729  /// task is in flight. `action` is the gerund of the blocked verb
2730  /// (e.g. "pulling", "pushing").
2731  fn busy_mutation_status(&self, action: &str) -> String {
2732    match self.tasks.mutating_loading_label() {
2733      Some(label) => format!("finish {} before {}", label.trim_end_matches('…'), action),
2734      None => format!("finish current task before {}", action),
2735    }
2736  }
2737
2738  fn spawn_pull(&self, generation: u64, path: PathBuf, name: String) {
2739    let tx = self.task_tx.clone();
2740    std::thread::spawn(move || {
2741      let mut cmd = std::process::Command::new("git");
2742      cmd.args(["pull"]).current_dir(&path);
2743      // Route through the command-log chokepoint so `git pull` lands in the
2744      // Command Logs modal (#290) — a user-triggered mutating op the user
2745      // expects to find in the transcript.
2746      let result = crate::command_log::run_logged(&mut cmd, "git pull".to_string())
2747        .map_err(|e| e.to_string())
2748        .and_then(|out| {
2749          if out.status.success() {
2750            Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
2751          } else {
2752            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
2753          }
2754        });
2755      let _ = tx.send(TaskMsg::Pull(generation, name, result));
2756    });
2757  }
2758
2759  /// Request an off-thread `git push` of the selected worktree's branch
2760  /// (#290). Coalesces if a push is already in flight, and refuses to start
2761  /// while a *different* mutating task runs in the same worktree (Codex review
2762  /// on PR #292).
2763  pub fn request_push(&mut self) {
2764    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
2765      self.status = "no worktree selected".into();
2766      return;
2767    };
2768    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Push) {
2769      self.status = self.busy_mutation_status("pushing");
2770      return;
2771    }
2772    let Some(generation) = self.tasks.request(TaskKind::Push) else {
2773      return;
2774    };
2775    self.spinner.reset();
2776    self.status = TaskKind::Push.loading_label().into();
2777    self.spawn_push(generation, path, name);
2778  }
2779
2780  fn spawn_push(&self, generation: u64, path: PathBuf, name: String) {
2781    let tx = self.task_tx.clone();
2782    std::thread::spawn(move || {
2783      let mut cmd = std::process::Command::new("git");
2784      cmd.args(["push"]).current_dir(&path);
2785      // Route through the command-log chokepoint so `git push` lands in the
2786      // Command Logs modal (#290). git writes its progress to stderr, so the
2787      // status line still reads stderr on success.
2788      let result = crate::command_log::run_logged(&mut cmd, "git push".to_string())
2789        .map_err(|e| e.to_string())
2790        .and_then(|out| {
2791          if out.status.success() {
2792            Ok(String::from_utf8_lossy(&out.stderr).trim().to_string())
2793          } else {
2794            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
2795          }
2796        });
2797      let _ = tx.send(TaskMsg::Push(generation, name, result));
2798    });
2799  }
2800
2801  /// Open the rename modal for the selected worktree (`c`, #290). Reuses the
2802  /// Create form (Type / Issue / Desc) pre-filled by parsing the current
2803  /// branch name, so renaming is symmetric with creating. A branch that does
2804  /// not match the `<type>/#<issue>-<desc>` pattern can't be decomposed into
2805  /// the form, so the modal refuses to open and explains why.
2806  pub fn enter_edit_worktree(&mut self) {
2807    let Some((branch, path)) = self
2808      .selected()
2809      .and_then(|w| w.branch.clone().map(|b| (b, w.path.clone())))
2810    else {
2811      self.status = "no branch to rename (detached HEAD or nothing selected)".into();
2812      return;
2813    };
2814    let Some(spec) = crate::naming::parse_branch(&branch) else {
2815      self.status = format!(
2816        "branch '{}' doesn't match <type>/#<issue>-<desc>; can't rename here",
2817        branch
2818      );
2819      return;
2820    };
2821    // Refuse rather than silently preselect type index 0: a branch whose
2822    // parsed type isn't configured (config change, manual branch) would
2823    // otherwise be renamed to the first configured type on Enter (Codex
2824    // review on PR #292).
2825    let Some(type_index) = self.branch_types.iter().position(|t| t.name == spec.type_) else {
2826      self.status = format!("branch type '{}' is not configured; can't rename here", spec.type_);
2827      return;
2828    };
2829    self.create_form.reset();
2830    self.create_form.type_index = type_index;
2831    self.create_form.issue = spec.issue;
2832    self.create_form.desc = spec.desc;
2833    self.create_form.field = Field::Desc;
2834    self.edit_original_branch = Some(branch);
2835    self.edit_original_path = Some(path);
2836    self.edit_failure = None;
2837    self.view = View::Edit;
2838  }
2839
2840  /// `true` while the async rename worker is in flight (#290). The run loop
2841  /// swallows input in `View::Edit` while this holds, mirroring create.
2842  pub fn is_edit_worktree_loading(&self) -> bool {
2843    self.tasks.is_loading(TaskKind::EditWorktree)
2844  }
2845
2846  /// Cancel the rename modal (`Esc`): drop the captured original branch/path
2847  /// and return to the list without touching git.
2848  pub fn cancel_edit_worktree(&mut self) {
2849    self.edit_original_branch = None;
2850    self.edit_original_path = None;
2851    self.edit_failure = None;
2852    self.create_form.reset();
2853    self.view = View::List;
2854  }
2855
2856  /// Submit the rename from the `View::Edit` modal (#290). Composes the new
2857  /// branch name + worktree path from the form, then spawns an off-thread
2858  /// worker that renames the local branch (`git branch -m`), the remote
2859  /// branch when it exists (`git push origin :<old> <new>:<new>` + re-track),
2860  /// and moves the worktree directory (`git worktree move`). A no-op rename
2861  /// (nothing changed) just closes the modal.
2862  pub fn submit_edit_worktree(&mut self) -> Result<()> {
2863    let type_ = self
2864      .branch_types
2865      .get(self.create_form.type_index)
2866      .map(|t| t.name.clone())
2867      .unwrap_or_default();
2868    let spec = match BranchSpec::new_with_types(
2869      type_,
2870      self.create_form.issue.clone(),
2871      self.create_form.desc.clone(),
2872      &self.branch_types,
2873    ) {
2874      Ok(s) => s,
2875      Err(e) => {
2876        self.edit_failure = Some(e.to_string());
2877        return Ok(());
2878      }
2879    };
2880    let new_branch = spec.branch_name(&self.config.worktree, &self.repo_name)?;
2881    let new_name = spec.worktree_dirname(&self.config.worktree, &self.repo_name)?;
2882    let new_path = spec.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir)?;
2883
2884    let Some(old_branch) = self.edit_original_branch.clone() else {
2885      self.cancel_edit_worktree();
2886      return Ok(());
2887    };
2888    let Some(old_path) = self.edit_original_path.clone() else {
2889      self.cancel_edit_worktree();
2890      return Ok(());
2891    };
2892
2893    // Nothing changed — close without shelling out to git.
2894    if new_branch == old_branch && new_path == old_path {
2895      self.status = "no change".into();
2896      self.cancel_edit_worktree();
2897      return Ok(());
2898    }
2899
2900    if self.tasks.has_mutating_task_in_flight() {
2901      if let Some(label) = self.tasks.mutating_loading_label() {
2902        self.status = format!("finish {} before renaming", label.trim_end_matches('…'));
2903      } else {
2904        self.status = "finish current task before renaming".into();
2905      }
2906      return Ok(());
2907    }
2908    let Some(generation) = self.tasks.request(TaskKind::EditWorktree) else {
2909      return Ok(());
2910    };
2911    self.edit_failure = None;
2912    self.spinner.reset();
2913    self.status = TaskKind::EditWorktree.loading_label().into();
2914    self.spawn_edit_worktree(
2915      generation,
2916      old_branch,
2917      old_path,
2918      new_branch,
2919      new_path,
2920      new_name,
2921      self.workdir.clone(),
2922    );
2923    Ok(())
2924  }
2925
2926  #[allow(clippy::too_many_arguments)]
2927  fn spawn_edit_worktree(
2928    &self,
2929    generation: u64,
2930    old_branch: String,
2931    old_path: PathBuf,
2932    new_branch: String,
2933    new_path: PathBuf,
2934    new_name: String,
2935    workdir: PathBuf,
2936  ) {
2937    let tx = self.task_tx.clone();
2938    std::thread::spawn(move || {
2939      let result = crate::worktree::rename_worktree(&workdir, &old_path, &old_branch, &new_path, &new_branch)
2940        .map(|remote_renamed| EditWorktreeResult {
2941          new_branch,
2942          new_path,
2943          new_name,
2944          remote_renamed,
2945        })
2946        .map_err(|e| e.to_string());
2947      let _ = tx.send(TaskMsg::EditWorktree(generation, result));
2948    });
2949  }
2950
2951  /// Open the selected worktree in a new multiplexer pane/tab (`t`, #290).
2952  /// Detects tmux / zellij at runtime via environment variables; prints a
2953  /// status message when no supported multiplexer is active.
2954  pub fn open_in_mux_pane(&mut self) {
2955    use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
2956    let Some(w) = self.selected() else {
2957      self.status = "no worktree selected".into();
2958      return;
2959    };
2960    let path = w.path.clone();
2961    let name = w.name.clone();
2962    // `mux_pane` promises a pane, so split the current pane (tmux
2963    // `split-window` / zellij `new-pane`) rather than opening a new
2964    // window/tab (Codex review on PR #292).
2965    let cmd = if detect_tmux(std::env::var("TMUX").ok()) {
2966      build_tmux_command(&name, &path, SpawnMode::Split)
2967    } else if detect_zellij(std::env::var("ZELLIJ").ok()) {
2968      build_zellij_command(&name, &path, SpawnMode::Split)
2969    } else {
2970      self.status = "no multiplexer detected ($TMUX / $ZELLIJ not set)".into();
2971      return;
2972    };
2973    let bin = cmd[0].as_str();
2974    match std::process::Command::new(bin).args(&cmd[1..]).spawn() {
2975      Ok(_) => self.status = format!("opened {} in new pane", name),
2976      Err(e) => self.status = format!("mux-pane failed: {}", e),
2977    }
2978  }
2979
2980  /// Resolve what the `o` key should do for the currently selected
2981  /// worktree. Returns `None` when nothing is selected (the event loop
2982  /// surfaces a status message in that case). The exact command is
2983  /// resolved once here (config override > env var > hardcoded fallback)
2984  /// so the event loop never has to reason about precedence.
2985  pub fn resolve_open_target(&self) -> Option<OpenTarget> {
2986    let path = self.selected()?.path.clone();
2987    Some(match self.config.tui.open.mode {
2988      TuiOpenMode::Shell => OpenTarget::Shell {
2989        path,
2990        command: resolve_shell_command(&self.config.tui.open),
2991      },
2992      TuiOpenMode::Editor => OpenTarget::Editor {
2993        path,
2994        command: resolve_editor_command(&self.config.tui.open),
2995      },
2996      TuiOpenMode::Finder => OpenTarget::Finder { path },
2997    })
2998  }
2999
3000  pub fn toggle_delete_branch(&mut self) {
3001    self.delete_branch_on_remove = !self.delete_branch_on_remove;
3002    self.status = format!("delete branch on remove: {}", self.delete_branch_on_remove);
3003  }
3004
3005  // ---- Create flow ---------------------------------------------------------
3006
3007  pub fn enter_create(&mut self) {
3008    self.view = View::Create;
3009    self.create_form.reset();
3010    self.create_failure = None;
3011    // Open focused on Issue rather than the cycle-only Type field (#217 UX):
3012    // the first keypress then edits text instead of being a silent no-op on
3013    // Type. The type keeps its `reset()` default and stays reachable via
3014    // Shift-Tab / the field rotation.
3015    self.create_form.field = Field::Issue;
3016    self.status = "tab/shift-tab: switch field — enter on desc: submit — esc: cancel".into();
3017  }
3018
3019  pub fn create_next_field(&mut self) {
3020    self.create_form.next_field();
3021  }
3022
3023  pub fn create_prev_field(&mut self) {
3024    self.create_form.prev_field();
3025  }
3026
3027  pub fn create_next_type(&mut self) {
3028    self.create_form.next_type(self.branch_types.len());
3029  }
3030
3031  pub fn create_prev_type(&mut self) {
3032    self.create_form.prev_type(self.branch_types.len());
3033  }
3034
3035  pub fn create_push_char(&mut self, c: char) {
3036    self.create_form.push_char(c);
3037  }
3038
3039  pub fn create_pop_char(&mut self) {
3040    self.create_form.pop_char();
3041  }
3042
3043  /// Handle one key in the create overlay and report what the run loop must
3044  /// do next. Extracted from the inline `View::Create` match (issue #217)
3045  /// so the input path — typing, type cycling, submit/cancel — is
3046  /// unit-testable rather than only reachable through a live terminal.
3047  ///
3048  /// `h` / `l` mirror the `←` / `→` horizontal type selector, but **only**
3049  /// when the Type field is focused; on a text field they are literal input
3050  /// so the letters are never swallowed.
3051  pub fn handle_create_key(&mut self, key: KeyEvent) -> CreateKey {
3052    if self.is_create_worktree_loading() {
3053      return CreateKey::Handled;
3054    }
3055    let on_type = self.create_form.field == Field::Type;
3056    // #219: verbs resolve through the `create` context. The type-cycling
3057    // verbs (`prev_type` / `next_type`, def arrows + h/l) only fire on the
3058    // Type field; on a text field their keys fall through to literal input
3059    // so `h` / `l` are never swallowed while typing a description.
3060    match self.resolve_modal(KeyContext::Create, key) {
3061      Some(ModalAction::CreateCancel) => return CreateKey::Cancel,
3062      Some(ModalAction::CreateNextField) => self.create_next_field(),
3063      Some(ModalAction::CreatePrevField) => self.create_prev_field(),
3064      Some(ModalAction::CreateSubmit) => {
3065        if self.create_form.field == Field::Desc {
3066          return CreateKey::Submit;
3067        }
3068        self.create_next_field();
3069      }
3070      Some(ModalAction::CreatePrevType) if on_type => self.create_prev_type(),
3071      Some(ModalAction::CreateNextType) if on_type => self.create_next_type(),
3072      _ => match key.code {
3073        KeyCode::Char(c) if self.create_form.field == Field::Issue && !c.is_ascii_digit() => {
3074          self.status = "issue accepts digits only".into();
3075        }
3076        KeyCode::Char(c) if !on_type => self.create_push_char(c),
3077        KeyCode::Backspace if !on_type => self.create_pop_char(),
3078        _ => {}
3079      },
3080    }
3081    CreateKey::Handled
3082  }
3083
3084  pub fn submit_create(&mut self) -> Result<()> {
3085    let type_ = self
3086      .branch_types
3087      .get(self.create_form.type_index)
3088      .map(|t| t.name.clone())
3089      .unwrap_or_default();
3090    let spec = BranchSpec::new_with_types(
3091      type_,
3092      self.create_form.issue.clone(),
3093      self.create_form.desc.clone(),
3094      &self.branch_types,
3095    )?;
3096    let branch = spec.branch_name(&self.config.worktree, &self.repo_name)?;
3097    let dirname = spec.worktree_dirname(&self.config.worktree, &self.repo_name)?;
3098    let target = spec.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir)?;
3099
3100    // Gate the bootstrap RCE primitive on the TOFU ledger BEFORE
3101    // creating the worktree on disk (issue #95). A refusal here
3102    // leaves the user's disk state untouched — no orphaned
3103    // worktree to clean up. Mirrors `cmd_create` in src/cli.rs.
3104    if let Some(msg) = self.check_trust_for_bootstrap()? {
3105      self.status = msg;
3106      // Stay in the create form so the user can retry after
3107      // approving the config via the CLI gate. Returning Ok here
3108      // (rather than Err) keeps the event loop alive — an Err
3109      // would print to stderr and tear down the alternate screen.
3110      return Ok(());
3111    }
3112
3113    if self.tasks.has_mutating_task_in_flight() {
3114      if let Some(label) = self.tasks.mutating_loading_label() {
3115        self.status = format!("finish {} before creating worktree", label.trim_end_matches('…'));
3116      } else {
3117        self.status = "finish current task before creating worktree".into();
3118      }
3119      return Ok(());
3120    }
3121    let Some(generation) = self.tasks.request(TaskKind::CreateWorktree) else {
3122      return Ok(());
3123    };
3124    self.create_failure = None;
3125    self.spinner.reset();
3126    self.status = TaskKind::CreateWorktree.loading_label().into();
3127    self.spawn_create_worktree(
3128      generation,
3129      dirname,
3130      target,
3131      branch,
3132      self.workdir.clone(),
3133      self.config.clone(),
3134    );
3135    Ok(())
3136  }
3137
3138  fn spawn_create_worktree(
3139    &self,
3140    generation: u64,
3141    dirname: String,
3142    target: PathBuf,
3143    branch: String,
3144    workdir: PathBuf,
3145    config: Config,
3146  ) {
3147    let tx = self.task_tx.clone();
3148    std::thread::spawn(move || {
3149      let result = (|| -> Result<CreateWorktreeResult> {
3150        let repo = worktree::discover_repo(Some(&workdir))?;
3151        let created = worktree::add(&repo, &dirname, &target, &branch, false)?;
3152        let ctx = BootstrapCtx {
3153          main_repo: &workdir,
3154          worktree: &created,
3155          config: &config,
3156        };
3157        let report = bootstrap::run(&ctx)?;
3158        Ok(CreateWorktreeResult {
3159          branch,
3160          created,
3161          report,
3162        })
3163      })()
3164      .map_err(|e| e.to_string());
3165      let _ = tx.send(TaskMsg::CreateWorktree(generation, result));
3166    });
3167  }
3168
3169  // ---- Delete flow ---------------------------------------------------------
3170
3171  pub fn enter_confirm_delete(&mut self) {
3172    let Some(sel) = self.selected() else {
3173      self.status = "nothing selected".into();
3174      return;
3175    };
3176    if sel.is_main {
3177      self.status = "cannot remove the main worktree".into();
3178      return;
3179    }
3180    self.view = View::Confirm;
3181    self.confirm.reset();
3182    self.delete_failure = None;
3183    // Start the loader animation from a deterministic frame each time
3184    // the modal opens (#187).
3185    self.spinner.reset();
3186  }
3187
3188  pub fn confirm_delete(&mut self) -> Result<()> {
3189    // `worktree::remove` resolves by the internal git id, which can diverge
3190    // from the display name after a rename (#290), so pass `id` here.
3191    let (id, label) = match self.selected() {
3192      Some(s) => (s.id.clone(), s.path.display().to_string()),
3193      None => return Ok(()),
3194    };
3195    if self.is_delete_worktree_loading() {
3196      return Ok(());
3197    }
3198    if self.tasks.has_mutating_task_in_flight() {
3199      if let Some(label) = self.tasks.mutating_loading_label() {
3200        self.status = format!("finish {} before deleting worktree", label.trim_end_matches('…'));
3201      } else {
3202        self.status = "finish current task before deleting worktree".into();
3203      }
3204      return Ok(());
3205    }
3206    let Some(generation) = self.tasks.request(TaskKind::DeleteWorktree) else {
3207      return Ok(());
3208    };
3209    let delete_branch = self.delete_branch_on_remove;
3210    self.delete_failure = None;
3211    self.confirm.dismiss();
3212    self.spinner.reset();
3213    self.status = TaskKind::DeleteWorktree.loading_label().into();
3214    self.spawn_delete_worktree(generation, id, label, delete_branch);
3215    Ok(())
3216  }
3217
3218  fn spawn_delete_worktree(&self, generation: u64, id: String, label: String, delete_branch: bool) {
3219    let tx = self.task_tx.clone();
3220    let workdir = self.workdir.clone();
3221    std::thread::spawn(move || {
3222      let result = worktree::discover_repo(Some(&workdir))
3223        .and_then(|repo| worktree::remove(&repo, &id, delete_branch))
3224        .map_err(|e| e.to_string());
3225      let _ = tx.send(TaskMsg::DeleteWorktree(generation, id, label, result));
3226    });
3227  }
3228
3229  // ---- Confirm-overlay safety countdown (issue #30, extracted per #125) ---
3230  //
3231  // The countdown only applies when `delete_branch_on_remove` is ON AND the
3232  // configured `confirm_countdown_secs` is non-zero. The pure state lives
3233  // on `self.confirm` (see `src/tui/state/confirm.rs`); the wrappers below
3234  // own the side effects (status messages, view transitions).
3235
3236  /// Total duration of the safety countdown for the current modal state.
3237  /// `Duration::ZERO` means "no countdown — classic modal".
3238  pub fn confirm_countdown_total(&self) -> Duration {
3239    if self.delete_branch_on_remove {
3240      Duration::from_secs(u64::from(self.config.tui.effective_confirm_countdown_secs()))
3241    } else {
3242      Duration::ZERO
3243    }
3244  }
3245
3246  /// True when the confirm overlay should render the countdown variant
3247  /// (progress bar + footer "y arm / y again to cancel"). False for the
3248  /// classic single-keystroke confirm.
3249  pub fn confirm_is_countdown_mode(&self) -> bool {
3250    self.confirm_countdown_total() > Duration::ZERO
3251  }
3252
3253  /// Handle a `y` / Enter press inside the confirm overlay. Delegates to
3254  /// `ConfirmModal::press_y` and composes the status-bar message based on
3255  /// the returned action.
3256  pub fn confirm_press_y(&mut self, now: Instant) -> ConfirmKeyAction {
3257    let total = self.confirm_countdown_total();
3258    let action = self.confirm.press_y(now, total);
3259    match action {
3260      ConfirmKeyAction::FireNow => {}
3261      ConfirmKeyAction::Disarmed => {
3262        let secs = total.as_secs();
3263        // #219 review: name the live confirm key, and drop the clause entirely
3264        // when it is unbound — never advertise a key that no longer re-arms.
3265        self.status = match self.modal_keymap.primary_key(ModalAction::ConfirmConfirm) {
3266          Some(c) => format!("countdown cancelled — press {c} to re-arm ({secs}s safety delay)"),
3267          None => format!("countdown cancelled ({secs}s safety delay)"),
3268        };
3269      }
3270      ConfirmKeyAction::Armed => {
3271        let secs = total.as_secs();
3272        // #219 review: name the live confirm / cancel keys (rebindable via
3273        // `[tui.keys.modal.confirm]`), dropping either clause when its verb is
3274        // unbound rather than advertising a phantom key while the timer runs.
3275        let confirm = self.modal_keymap.primary_key(ModalAction::ConfirmConfirm);
3276        let cancel = self.modal_keymap.primary_key(ModalAction::ConfirmCancel);
3277        let tail = match (confirm, cancel) {
3278          (Some(c), Some(x)) => format!(" · press {c} again or {x} to cancel"),
3279          (Some(c), None) => format!(" · press {c} again to disarm"),
3280          (None, Some(x)) => format!(" · press {x} to cancel"),
3281          (None, None) => String::new(),
3282        };
3283        self.status = format!("armed — auto-fires in {secs}s{tail}");
3284      }
3285    }
3286    action
3287  }
3288
3289  /// Handle the dismissal keys (`n` / `Esc`) inside the confirm overlay.
3290  /// Always disarms the countdown and returns to the list.
3291  pub fn confirm_dismiss(&mut self) {
3292    if self.is_delete_worktree_loading() {
3293      self.status = TaskKind::DeleteWorktree.loading_label().into();
3294      return;
3295    }
3296    self.confirm.dismiss();
3297    self.delete_failure = None;
3298    self.view = View::List;
3299  }
3300
3301  /// Tick the countdown forward. Called from the event loop on every
3302  /// poll-timeout iteration (every 200ms).
3303  pub fn tick_confirm_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
3304    self.confirm.tick(now, self.confirm_countdown_total())
3305  }
3306
3307  /// Countdown progress in `[0.0, 1.0]`. `0.0` when not armed, `1.0` once
3308  /// elapsed. Used by the UI to draw the gauge.
3309  pub fn confirm_countdown_progress(&self, now: Instant) -> f64 {
3310    self.confirm.progress(now, self.confirm_countdown_total())
3311  }
3312
3313  /// Seconds remaining (rounded up to the next whole second) for the UI
3314  /// label. `0` when not armed or when the countdown has elapsed.
3315  pub fn confirm_countdown_remaining_secs(&self, now: Instant) -> u64 {
3316    self.confirm.remaining_secs(now, self.confirm_countdown_total())
3317  }
3318
3319  // ---- Fuzzy filter (issue #21) -------------------------------------------
3320
3321  /// Open the inline filter bar. The existing query is preserved so the user
3322  /// can refine an already-sticky filter; `Esc` is the way to start fresh.
3323  /// Disarms any pending `gg` motion so `/g` doesn't half-trigger it.
3324  ///
3325  /// Forces focus back onto the list: opening `/` is an intent to narrow the
3326  /// list, and the post-`Enter` contract is "navigation returns to the
3327  /// table". Leaving the sidebar focused would make `j` / `k` scroll it
3328  /// instead of walking the filtered worktrees after the filter sticks.
3329  pub fn enter_filter(&mut self) {
3330    self.filter.open();
3331    self.sidebar.focused = false;
3332    self.cancel_pending_motion();
3333    self.status = "/ filter — type to narrow · enter confirms · esc clears".into();
3334  }
3335
3336  /// Close the filter bar but keep the query: `Enter` confirms the current
3337  /// match set and returns the cursor to list navigation.
3338  pub fn exit_filter_keep(&mut self) {
3339    self.filter.close_keep();
3340    self.status = if self.filter.query().is_empty() {
3341      "press ? for help".into()
3342    } else {
3343      format!("filter sticky: {}", self.filter.query())
3344    };
3345  }
3346
3347  /// Close the filter bar and clear the query: `Esc` returns to the full list.
3348  pub fn exit_filter_cancel(&mut self) {
3349    let had_query = !self.filter.query().is_empty();
3350    self.filter.close_cancel();
3351    self.clamp_selection_to_filter();
3352    self.invalidate_sidebar_cache();
3353    self.status = if had_query {
3354      "filter cleared".into()
3355    } else {
3356      "press ? for help".into()
3357    };
3358  }
3359
3360  pub fn filter_push_char(&mut self, c: char) {
3361    self.filter.push_char(c);
3362    self.clamp_selection_to_filter();
3363    self.invalidate_sidebar_cache();
3364  }
3365
3366  pub fn filter_pop_char(&mut self) {
3367    let before = self.filter.query().len();
3368    self.filter.pop_char();
3369    if self.filter.query().len() != before {
3370      self.clamp_selection_to_filter();
3371      self.invalidate_sidebar_cache();
3372    }
3373  }
3374
3375  /// Indices into `self.worktrees`, in display order:
3376  /// - empty query: identity (every worktree in source order).
3377  /// - non-empty: only worktrees whose name matches the query via
3378  ///   `nucleo_matcher`, ranked by descending score (nucleo intrinsically
3379  ///   ranks exact/substring/prefix matches above subsequence matches).
3380  ///
3381  /// Score ties are broken by original index so output is stable.
3382  ///
3383  /// Memoised on `FilterState` since #124 / #104: the per-frame render
3384  /// path calls this 3–5× (table height, visible rows, title hint,
3385  /// footer counter, selection resolver), but the result only changes
3386  /// when the query OR the worktrees vec changes. The cache holds the
3387  /// previous result and the worktrees length it was computed against;
3388  /// any buffer mutation (`push_char` / `pop_char` / `set_query` /
3389  /// `clear`), an explicit `filter.invalidate()`, or a length change
3390  /// invalidates it. `App::refresh` calls `invalidate` after replacing
3391  /// `worktrees` so a same-length-different-contents refresh is also
3392  /// caught.
3393  pub fn filtered_indices(&mut self) -> &[usize] {
3394    self.filter.filtered_indices(&self.worktrees, fuzzy_match_indices)
3395  }
3396
3397  /// Reposition the selection so it stays inside the current filtered subset.
3398  /// Called whenever the filter mutates (`/`-mode typing, `Esc`-clear) or the
3399  /// worktree list itself changes (`refresh`). Also re-resolves the issue/PR
3400  /// link cache so the right-panel block tracks the new selection — PR #68
3401  /// Copilot review caught that selection changes were leaving the cache
3402  /// pointing at the previously selected worktree.
3403  fn clamp_selection_to_filter(&mut self) {
3404    let len = self.filtered_indices().len();
3405    if len == 0 {
3406      self.list_state.select(None);
3407      self.refresh_link();
3408      return;
3409    }
3410    match self.list_state.selected() {
3411      Some(i) if i >= len => self.list_state.select(Some(len - 1)),
3412      Some(_) => {}
3413      None => self.list_state.select(Some(0)),
3414    }
3415    self.refresh_link();
3416  }
3417
3418  /// Move the cursor onto the worktree at `path`, mapping its raw index in
3419  /// `self.worktrees` to its slot in the *filtered* list — `list_state`
3420  /// indexes `filtered_indices()`, not the raw vec, so selecting a raw index
3421  /// under an active filter lands on the wrong visible row or none (Codex
3422  /// review on PR #292). A no-op when the path is filtered out.
3423  /// The chord that opens the issue/PR link prompt (`i` by default since
3424  /// #290), resolved from the live keymap so "press X to link" status hints
3425  /// track the binding and any `[tui.keys]` override (Codex review on PR
3426  /// #292, P3).
3427  fn link_prompt_chord(&self) -> String {
3428    self
3429      .keymap
3430      .primary_chord(Action::LinkPrompt)
3431      .unwrap_or_else(|| "i".into())
3432  }
3433
3434  pub fn reselect_by_path(&mut self, path: &Path) {
3435    let Some(raw) = self.worktrees.iter().position(|w| w.path == path) else {
3436      return;
3437    };
3438    let pos = self.filtered_indices().iter().position(|&idx| idx == raw);
3439    if let Some(pos) = pos {
3440      self.list_state.select(Some(pos));
3441    }
3442  }
3443
3444  // ---- Bootstrap flow ------------------------------------------------------
3445
3446  // ---- Picker mode (issue #22) --------------------------------------------
3447
3448  /// Commit the highlighted worktree as the picker's result. The event loop
3449  /// breaks once `picker_should_exit` flips so `run_picker` can surface the
3450  /// path to the CLI caller, which prints it on stdout for `cd "$(gwm
3451  /// switch)"`.
3452  ///
3453  /// Outside picker mode the call is inert. When picker mode is on but
3454  /// nothing is selected (e.g. the filter narrowed the list to zero
3455  /// matches), the loop stays open and a status hint asks the user to
3456  /// refine — addresses Copilot's PR #53 review: Enter on an empty match
3457  /// set used to break with `None`, which read as "cancel" instead of
3458  /// "nothing to pick".
3459  pub fn picker_confirm(&mut self) {
3460    if !self.picker_mode {
3461      return;
3462    }
3463    match self.selected() {
3464      Some(w) => {
3465        self.picker_result = Some(w.path.clone());
3466        self.picker_should_exit = true;
3467      }
3468      None => {
3469        self.status = "no worktree selected — adjust the filter and try again".into();
3470      }
3471    }
3472  }
3473
3474  /// Esc-equivalent for picker mode: leave without recording a path. The
3475  /// regular TUI uses Esc to clear an active filter, which conflicts with
3476  /// the picker footer's `esc:cancel` contract; this method exists so the
3477  /// event loop can route Esc-during-filter to a clean picker cancel.
3478  pub fn picker_cancel(&mut self) {
3479    if !self.picker_mode {
3480      return;
3481    }
3482    self.picker_should_exit = true;
3483  }
3484
3485  pub fn bootstrap_selected(&mut self) {
3486    let path = match self.selected() {
3487      Some(s) => s.path.clone(),
3488      None => {
3489        self.status = "nothing selected".into();
3490        return;
3491      }
3492    };
3493
3494    // Same TOFU gate as `submit_create` — pressing `b` to re-run
3495    // bootstrap on an existing worktree is just as much an RCE
3496    // primitive as creating a new one. Issue #95.
3497    match self.check_trust_for_bootstrap() {
3498      Ok(None) => {}
3499      Ok(Some(msg)) => {
3500        self.status = msg;
3501        return;
3502      }
3503      Err(e) => {
3504        self.status = format!("trust gate error: {}", e);
3505        return;
3506      }
3507    }
3508
3509    // Run off-thread on the async-task spine (issue #256): `bootstrap::run`
3510    // (file copies, guards, command hooks) used to block the event loop. The
3511    // TOFU gate above stays synchronous on the main thread; only the run
3512    // itself moves to a worker, with the `View::Report` transition deferred
3513    // to `drain_task_results`. A second `b` press while one is in flight
3514    // coalesces (no `Some(generation)`), so two bootstraps never race.
3515    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Bootstrap) {
3516      self.status = self.busy_mutation_status("bootstrapping");
3517      return;
3518    }
3519    let Some(generation) = self.tasks.request(TaskKind::Bootstrap) else {
3520      return;
3521    };
3522    self.spinner.reset();
3523    self.status = TaskKind::Bootstrap.loading_label().into();
3524    self.spawn_bootstrap(generation, self.workdir.clone(), path, self.config.clone());
3525  }
3526
3527  /// Spawn the off-thread bootstrap worker (issue #256). Only owned, `Send`
3528  /// data crosses the thread boundary — the `main_repo` / `worktree` paths
3529  /// and a clone of the resolved `Config` — so the worker rebuilds its own
3530  /// `BootstrapCtx` rather than borrowing `self`. The result is posted back
3531  /// over the task channel for `drain_task_results` to apply.
3532  fn spawn_bootstrap(&self, generation: u64, main_repo: PathBuf, worktree: PathBuf, config: Config) {
3533    let tx = self.task_tx.clone();
3534    std::thread::spawn(move || {
3535      let ctx = BootstrapCtx {
3536        main_repo: &main_repo,
3537        worktree: &worktree,
3538        config: &config,
3539      };
3540      let result = bootstrap::run(&ctx).map_err(|e| e.to_string());
3541      let _ = tx.send(TaskMsg::Bootstrap(generation, result));
3542    });
3543  }
3544
3545  // ---- Issue/PR linking (issue #67) -------------------------------------
3546
3547  /// Re-read the link for the currently selected worktree's branch. Also
3548  /// re-resolves the repo slug from the origin remote, and resets any
3549  /// previously cached GitHub fetch state since it would refer to a
3550  /// different (issue, pr) tuple now. Delegates to
3551  /// [`GitHubFetch::refresh_link`] for the pure state mutation; the
3552  /// branch resolution still lives here because it depends on
3553  /// `App`'s `selected()` + `repo.head()` fallback.
3554  pub fn refresh_link(&mut self) {
3555    let branch = self.selected_branch_name();
3556    self.github.refresh_link(&self.repo, branch.as_deref());
3557    // Navigation invariant (issue #255): the cache clear above must be paired
3558    // with a spine generation-bump so any in-flight `gh` worker for the
3559    // previous worktree's link is dropped instead of stamping the now-active
3560    // worktree's cache. `refresh_link` no longer holds the old issue/PR
3561    // numbers, so invalidate by predicate.
3562    self.tasks.invalidate_matching(TaskKind::is_github);
3563  }
3564
3565  fn selected_branch_name(&self) -> Option<String> {
3566    self.selected().and_then(|w| w.branch.clone()).or_else(|| {
3567      self
3568        .repo
3569        .head()
3570        .ok()
3571        .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
3572    })
3573  }
3574
3575  pub fn current_link(&self) -> &BranchLink {
3576    &self.github.link
3577  }
3578
3579  /// Mirror the live resolved `github.link` onto the selected worktree's
3580  /// snapshot (issue #283 / Codex review #284). The table renders the PR/
3581  /// issue pastilles from `self.worktrees[*].link`, captured at list time,
3582  /// so a freshly persisted auto-detection would otherwise stay invisible on
3583  /// the selected row until a full relist. Resolves the selection through
3584  /// the same filter map as [`Self::selected`].
3585  fn sync_selected_link_into_table(&mut self) {
3586    let Some(i) = self.list_state.selected() else {
3587      return;
3588    };
3589    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
3590    let Some(&original) = filtered.get(i) else {
3591      return;
3592    };
3593    let link = self.github.link.clone();
3594    if let Some(w) = self.worktrees.get_mut(original) {
3595      if w.link.issue != link.issue {
3596        w.issue_state = None;
3597      }
3598      if w.link.pr != link.pr {
3599        w.pr_state = None;
3600      }
3601      w.link = link;
3602    }
3603  }
3604
3605  fn sync_issue_status_into_table(&mut self, status: &IssueStatus) {
3606    if self.github.link.issue == Some(status.number) {
3607      self.github.link.issue_title = Some(status.title.clone());
3608      self.github.link.issue_state = Some(status.state);
3609      if let Some(branch) = self.selected_branch_name() {
3610        let _ = github::persist_issue_title(&self.repo, &branch, &status.title);
3611        let _ = github::persist_issue_state(&self.repo, &branch, status.state);
3612      }
3613    }
3614    // In workspace mode the fetch was for the active repo's selected issue, so
3615    // only stamp/persist rows belonging to that repo — a number-only match
3616    // would otherwise carry repo A's state onto repo B's same-numbered row and
3617    // persist it through the wrong repo handle (Codex review #303 P2).
3618    let mask = self.active_repo_row_mask();
3619    for (i, w) in self.worktrees.iter_mut().enumerate() {
3620      if mask.as_ref().is_some_and(|m| !m[i]) {
3621        continue;
3622      }
3623      if w.link.issue != Some(status.number) {
3624        continue;
3625      }
3626      w.issue_state = Some(status.state);
3627      w.link.issue_title = Some(status.title.clone());
3628      w.link.issue_state = Some(status.state);
3629      if let Some(branch) = w.branch.as_deref() {
3630        let _ = github::persist_issue_title(&self.repo, branch, &status.title);
3631        let _ = github::persist_issue_state(&self.repo, branch, status.state);
3632      }
3633    }
3634  }
3635
3636  fn sync_pr_status_into_table(&mut self, status: &PrStatus) {
3637    if self.github.link.pr == Some(status.number) {
3638      self.github.link.pr_title = Some(status.title.clone());
3639      self.github.link.pr_state = Some(status.state);
3640      if let Some(branch) = self.selected_branch_name() {
3641        let _ = match self.github.link.pr_source {
3642          github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, &branch, &status.title)
3643            .and_then(|()| github::persist_detected_pr_state(&self.repo, &branch, status.state)),
3644          github::LinkSource::Explicit => github::persist_pr_title(&self.repo, &branch, &status.title)
3645            .and_then(|()| github::persist_pr_state(&self.repo, &branch, status.state)),
3646          github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
3647        };
3648      }
3649    }
3650    // Scope to the active repo's rows in workspace mode — see the matching
3651    // note in `sync_issue_status_into_table` (Codex review #303 P2).
3652    let mask = self.active_repo_row_mask();
3653    for (i, w) in self.worktrees.iter_mut().enumerate() {
3654      if mask.as_ref().is_some_and(|m| !m[i]) {
3655        continue;
3656      }
3657      if w.link.pr != Some(status.number) {
3658        continue;
3659      }
3660      w.pr_state = Some(status.state);
3661      w.link.pr_title = Some(status.title.clone());
3662      w.link.pr_state = Some(status.state);
3663      if let Some(branch) = w.branch.as_deref() {
3664        let _ = match w.link.pr_source {
3665          github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, branch, &status.title)
3666            .and_then(|()| github::persist_detected_pr_state(&self.repo, branch, status.state)),
3667          github::LinkSource::Explicit => github::persist_pr_title(&self.repo, branch, &status.title)
3668            .and_then(|()| github::persist_pr_state(&self.repo, branch, status.state)),
3669          github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
3670        };
3671      }
3672    }
3673  }
3674
3675  pub fn current_slug(&self) -> Option<&str> {
3676    self.github.link_slug.as_deref()
3677  }
3678
3679  /// Read the cached issue fetch state for the *currently-linked*
3680  /// issue. Returns `&GitHubFetchState::Idle` when no issue is linked
3681  /// (or when the linked issue has never been fetched) — the cache is
3682  /// per-number (post-#138), so reading "the" state means resolving
3683  /// via `self.github.link.issue` first.
3684  pub fn issue_fetch_state(&self) -> &GitHubFetchState<IssueStatus> {
3685    match self.github.link.issue {
3686      Some(n) => self.github.issue_fetch_state(n),
3687      None => &GitHubFetchState::Idle,
3688    }
3689  }
3690
3691  /// PR-side counterpart to [`Self::issue_fetch_state`].
3692  pub fn pr_fetch_state(&self) -> &GitHubFetchState<PrStatus> {
3693    match self.github.link.pr {
3694      Some(n) => self.github.pr_fetch_state(n),
3695      None => &GitHubFetchState::Idle,
3696    }
3697  }
3698
3699  /// Kick off the issue/PR fetch. Called from the event loop when the
3700  /// user presses `F` (refresh GitHub status). Each `gh issue view` /
3701  /// `gh pr view` shell-out runs **off-thread** on the shared async-task
3702  /// spine (issue #255, migrated from #217's dedicated channel): the `App`
3703  /// checks the per-key cache, claims a generation from
3704  /// [`TaskRunner::request`], marks the cache `Loading`, and spawns a
3705  /// worker tagged with that generation. The worker reports a
3706  /// `TaskMsg::Github{Issue,Pr}` back; [`Self::drain_task_results`] applies
3707  /// it only if the generation is still authoritative — so a stale worker
3708  /// from a previous fetch loses the retry race to a fresh one.
3709  ///
3710  /// The PR auto-detection (`gh pr list`, issue #181) stays synchronous:
3711  /// it mutates `link` which the very next render needs, and it is a single
3712  /// cheap call rather than the two `view` shell-outs the spinner is for.
3713  ///
3714  /// This call path is the explicit user-initiated refresh, so it flushes
3715  /// the cache + drops any in-flight worker via [`Self::invalidate_github`]
3716  /// first — the user just asked for fresh data, a cache short-circuit here
3717  /// would be a bug.
3718  pub fn refresh_github_status(&mut self) {
3719    let slug = self.github.link_slug.clone();
3720
3721    // Re-resolve a non-explicit PR live on `F` (issue #181/#283): only an
3722    // explicit `gwm link --pr` pins the PR; a branch-name / none / persisted-
3723    // detected (#283) PR is re-probed so a number that changed since the last
3724    // detection is refreshed. The in-memory detection is dropped *only* once
3725    // we have a fresh successful result (the `Ok` arm), so a refresh that
3726    // cannot probe — no origin slug, no resolvable branch, or a failed `gh`
3727    // call — keeps the persisted detection visible instead of blanking the
3728    // pane/table (Codex review #284). `apply_detected_pr` only fills an empty
3729    // slot, hence the clear-then-apply to replace a stale detection.
3730    if self.github.link.pr_source != github::LinkSource::Explicit {
3731      if let (Some(slug), Some(branch)) = (slug.as_deref(), self.selected_branch_name()) {
3732        if let Ok(detected) = github::find_pr_for_branch(slug, &branch) {
3733          self.github.clear_detected_pr();
3734          self.github.apply_detected_pr(detected);
3735          // Persist the detection (issue #283) so the no-fetch table read
3736          // path colours the PR pastille on every row, not just the selected
3737          // one. Only a successful probe is authoritative: store a hit, clear
3738          // the key on a proven `Ok(None)`. Best-effort write — a git-config
3739          // failure must not break the refresh, so the result is discarded.
3740          let _ = match detected {
3741            Some(n) => github::persist_detected_pr(&self.repo, &branch, n),
3742            None => github::clear_persisted_detected_pr(&self.repo, &branch),
3743          };
3744        }
3745        // On a `gh` failure (Err) nothing was cleared, so the link keeps
3746        // whatever `read_link` resolved (possibly a persisted detection).
3747        //
3748        // Mirror the resolved link onto the selected row's snapshot so the
3749        // table pastille reflects the detection immediately, without waiting
3750        // for a separate relist (Codex review #284). The table renders from
3751        // `self.worktrees[*].link`, not the live `github.link`.
3752        self.sync_selected_link_into_table();
3753      }
3754    }
3755
3756    if self.github.link.issue.is_none() && self.github.link.pr.is_none() {
3757      self.status = format!(
3758        "nothing linked — press {} to link an issue or PR",
3759        self.link_prompt_chord()
3760      );
3761      return;
3762    }
3763    let Some(slug) = slug else {
3764      self.status = "no GitHub remote — cannot fetch status".into();
3765      return;
3766    };
3767    // Explicit user-initiated refresh: flush the cache (so the cold-cache
3768    // branch fires instead of a hit) and drop any in-flight worker on the
3769    // spine, so previously-loaded keys re-fetch.
3770    self.invalidate_github();
3771    let mut spawned = 0u32;
3772    if let Some(n) = self.github.link.issue {
3773      if self.spawn_github_issue(n, &slug) {
3774        spawned += 1;
3775      }
3776    }
3777    if let Some(n) = self.github.link.pr {
3778      if self.spawn_github_pr(n, &slug) {
3779        spawned += 1;
3780      }
3781    }
3782    if spawned > 0 {
3783      // Loading state is live; the spinner animates until `drain` applies
3784      // the results and re-reports the outcome.
3785      self.spinner.reset();
3786      self.status = "fetching GitHub status…".into();
3787    } else {
3788      // Nothing actually spawned (all keys already terminal in cache) —
3789      // report the current outcome immediately.
3790      self.report_github_refresh_status();
3791    }
3792  }
3793
3794  fn refresh_linked_github_statuses_for_worktrees(&mut self) -> u32 {
3795    // Workspace mode (#36): this bulk prefetch resolves every merged row's
3796    // issue/PR against a single repo's slug (`self.github.link_slug`), which
3797    // mis-attributes numbers across child repos with different remotes (Codex
3798    // review #303 P2). In workspace mode GitHub state is fetched per-selection
3799    // instead — `sync_active_repo`/`on_navigation` call `refresh_link`, which
3800    // re-resolves the slug from the selected row's own repo. So skip the bulk
3801    // cross-repo prefetch here.
3802    if self.is_workspace() {
3803      return 0;
3804    }
3805    let Some(slug) = self.github.link_slug.clone() else {
3806      return 0;
3807    };
3808    let issues = self
3809      .worktrees
3810      .iter()
3811      .filter_map(|w| w.link.issue)
3812      .collect::<BTreeSet<_>>()
3813      .into_iter()
3814      .collect::<Vec<_>>();
3815    let prs = self
3816      .worktrees
3817      .iter()
3818      .filter_map(|w| w.link.pr)
3819      .collect::<BTreeSet<_>>()
3820      .into_iter()
3821      .collect::<Vec<_>>();
3822    if issues.is_empty() && prs.is_empty() {
3823      return 0;
3824    }
3825
3826    self.invalidate_github();
3827    let mut spawned = 0u32;
3828    for n in issues {
3829      if self.spawn_github_issue(n, &slug) {
3830        spawned += 1;
3831      }
3832    }
3833    for n in prs {
3834      if self.spawn_github_pr(n, &slug) {
3835        spawned += 1;
3836      }
3837    }
3838    if spawned > 0 {
3839      self.spinner.reset();
3840    }
3841    spawned
3842  }
3843
3844  /// Flush the GitHub result cache **and** drop any in-flight GitHub worker
3845  /// on the spine (issue #255). The navigation invariant: the cache clear
3846  /// and the spine generation-bump must always move together, or a stale
3847  /// worker's late result could outlive the cache flush. Routed through one
3848  /// helper so the pairing can't desync — `refresh_github_status` and
3849  /// (via the predicate) `refresh_link` are the only callers.
3850  fn invalidate_github(&mut self) {
3851    self.github.invalidate();
3852    self.tasks.invalidate_matching(TaskKind::is_github);
3853  }
3854
3855  /// Claim a spine generation for `Issue(n)` and spawn its `gh issue view`
3856  /// worker (issue #255), returning `true` when a worker was actually
3857  /// started. A terminal cache hit (the explicit refresh flushed the cache
3858  /// first, so this only fires on a redundant call) or a coalesced spine
3859  /// slot (a worker for this key is already in flight) returns `false`
3860  /// without spawning a second subprocess.
3861  fn spawn_github_issue(&mut self, n: u64, slug: &str) -> bool {
3862    let key = FetchKey::Issue(n);
3863    if self.github.is_cached(key) {
3864      return false;
3865    }
3866    let Some(generation) = self.tasks.request(TaskKind::GithubIssue(n)) else {
3867      return false;
3868    };
3869    self.github.mark_loading(key);
3870    self.spawn_github_fetch(key, slug.to_string(), generation);
3871    true
3872  }
3873
3874  /// PR-side counterpart to [`Self::spawn_github_issue`] (issue #255).
3875  fn spawn_github_pr(&mut self, n: u64, slug: &str) -> bool {
3876    let key = FetchKey::Pr(n);
3877    if self.github.is_cached(key) {
3878      return false;
3879    }
3880    let Some(generation) = self.tasks.request(TaskKind::GithubPr(n)) else {
3881      return false;
3882    };
3883    self.github.mark_loading(key);
3884    self.spawn_github_fetch(key, slug.to_string(), generation);
3885    true
3886  }
3887
3888  /// Spawn one background `gh` shell-out for `key` tagged with `generation`
3889  /// and wire its result back over the shared task channel (issue #255,
3890  /// migrated from #217's dedicated channel). Deliberately a thin shell: it
3891  /// owns only the off-thread dispatch + send, no state logic — the
3892  /// coalescing / late-drop contract lives on the [`TaskRunner`] spine. A
3893  /// `send` failure (the `App`/receiver was dropped) is ignored: there is
3894  /// no longer anyone to apply the result.
3895  fn spawn_github_fetch(&self, key: FetchKey, slug: String, generation: u64) {
3896    let tx = self.task_tx.clone();
3897    // Resolve the `gh` program on THIS (main) thread and hand it to the
3898    // worker, so the worker never reads `GWM_GH` / the process environment
3899    // concurrently with env-mutating code elsewhere (the `env_lock`
3900    // unsoundness the worker would otherwise reintroduce — issue #217).
3901    let program = github::gh_program();
3902    std::thread::spawn(move || {
3903      let msg = match key {
3904        FetchKey::Issue(n) => TaskMsg::GithubIssue(
3905          generation,
3906          n,
3907          github::fetch_issue_with(&program, &slug, n).map_err(|e| e.to_string()),
3908        ),
3909        FetchKey::Pr(n) => TaskMsg::GithubPr(
3910          generation,
3911          n,
3912          github::fetch_pr_with(&program, &slug, n).map_err(|e| e.to_string()),
3913        ),
3914      };
3915      let _ = tx.send(msg);
3916    });
3917  }
3918
3919  /// Compute the post-refresh status line message based on the actual
3920  /// outcome of the issue / PR fetches. PR #68 Copilot review caught
3921  /// that always printing "refreshed" misled users when one of the
3922  /// fetches had failed.
3923  pub fn report_github_refresh_status(&mut self) {
3924    let issue_err = matches!(self.issue_fetch_state(), GitHubFetchState::Error(_));
3925    let pr_err = matches!(self.pr_fetch_state(), GitHubFetchState::Error(_));
3926    self.status = match (issue_err, pr_err) {
3927      (false, false) => "github status refreshed".into(),
3928      (true, false) => format!(
3929        "issue fetch failed: {}",
3930        self.issue_error_message().unwrap_or("?".into())
3931      ),
3932      (false, true) => format!("pr fetch failed: {}", self.pr_error_message().unwrap_or("?".into())),
3933      (true, true) => format!(
3934        "issue + pr fetch failed — issue: {} · pr: {}",
3935        self.issue_error_message().unwrap_or("?".into()),
3936        self.pr_error_message().unwrap_or("?".into())
3937      ),
3938    };
3939  }
3940
3941  fn issue_error_message(&self) -> Option<String> {
3942    match self.issue_fetch_state() {
3943      GitHubFetchState::Error(e) => Some(e.clone()),
3944      _ => None,
3945    }
3946  }
3947
3948  fn pr_error_message(&self) -> Option<String> {
3949    match self.pr_fetch_state() {
3950      GitHubFetchState::Error(e) => Some(e.clone()),
3951      _ => None,
3952    }
3953  }
3954
3955  pub fn apply_issue_fetch_result(&mut self, r: std::result::Result<IssueStatus, String>) {
3956    if let Ok(status) = &r {
3957      self.persist_loaded_issue_title(status);
3958    }
3959    self.github.apply_issue_result(r);
3960  }
3961
3962  pub fn apply_pr_fetch_result(&mut self, r: std::result::Result<PrStatus, String>) {
3963    if let Ok(status) = &r {
3964      self.persist_loaded_pr_title(status);
3965    }
3966    self.github.apply_pr_result(r);
3967  }
3968
3969  fn persist_loaded_issue_title(&mut self, status: &IssueStatus) {
3970    self.sync_issue_status_into_table(status);
3971  }
3972
3973  fn persist_loaded_pr_title(&mut self, status: &PrStatus) {
3974    self.sync_pr_status_into_table(status);
3975  }
3976
3977  // ---- Open menu ----------------------------------------------------------
3978
3979  pub fn enter_open_menu(&mut self) {
3980    // Re-resolve link + slug in case the user just linked something
3981    // (`gwm link …` from a parallel terminal) or moved the origin remote.
3982    self.refresh_link();
3983    self.open_menu_selected = LinkTarget::Issue;
3984    self.view = View::OpenMenu;
3985  }
3986
3987  pub fn exit_open_menu(&mut self) {
3988    self.view = View::List;
3989  }
3990
3991  pub fn open_menu_toggle_selection(&mut self) {
3992    self.open_menu_selected = match self.open_menu_selected {
3993      LinkTarget::Issue => LinkTarget::Pr,
3994      LinkTarget::Pr => LinkTarget::Issue,
3995    };
3996  }
3997
3998  /// Pick a target from the open menu. Returns the URL to open, or `None`
3999  /// when the link is missing (the status bar carries the explanation).
4000  pub fn open_menu_pick(&mut self, target: LinkTarget) -> Option<String> {
4001    self.view = View::List;
4002    let Some(slug) = self.github.link_slug.clone() else {
4003      self.status = "no GitHub remote — cannot build URL".into();
4004      return None;
4005    };
4006    let url = match target {
4007      LinkTarget::Issue => match self.github.link.issue {
4008        Some(n) => github::issue_url(&slug, n),
4009        None => {
4010          self.status = format!("no issue linked — press {} to link one", self.link_prompt_chord());
4011          return None;
4012        }
4013      },
4014      LinkTarget::Pr => match self.github.link.pr {
4015        Some(n) => github::pr_url(&slug, n),
4016        None => {
4017          self.status = format!("no PR linked — press {} to link one", self.link_prompt_chord());
4018          return None;
4019        }
4020      },
4021    };
4022    Some(url)
4023  }
4024
4025  // ---- Link prompt --------------------------------------------------------
4026  //
4027  // Pure state lives in `self.link_prompt` (`tui::state::link_prompt`,
4028  // extracted per #126). The methods below are thin orchestrator
4029  // wrappers: they update `self.view` / `self.status` / drive the
4030  // `github::link_{issue,pr}` shell-out on submit, then delegate the
4031  // buffer / stage transitions to `LinkPrompt`.
4032
4033  pub fn enter_link_prompt(&mut self) {
4034    self.view = View::LinkPrompt;
4035    self.link_prompt.reset();
4036    self.status = "pick".into();
4037  }
4038
4039  /// Highlighted row in the `ChooseTarget` picker (for the renderer).
4040  pub fn link_prompt_selected(&self) -> LinkTarget {
4041    self.link_prompt.selected
4042  }
4043
4044  /// Testable key handler for the link prompt (issue #217), mirroring
4045  /// [`App::handle_create_key`]. The picker / digit-buffer mutations and
4046  /// the per-stage status copy stay here; the loop only acts on the
4047  /// returned [`LinkPromptKey`] for the two genuine side effects
4048  /// (submit shell-out, view transition).
4049  pub fn handle_link_prompt_key(&mut self, key: KeyEvent) -> LinkPromptKey {
4050    use crate::tui::state::link_prompt::LinkPromptStage;
4051    // #219: each stage is its own modal context. ChooseTarget is a vertical
4052    // two-row picker — `next` / `prev` both flip the highlight (a single
4053    // flip serves j/k/Up/Down alike), while `issue` / `pr` are direct picks.
4054    // InputNumber routes `submit` / `cancel` through the context and treats
4055    // everything else as digit input. The global `fetch_github` key is a
4056    // FALLBACK after the stage context, so a contextual binding on that key
4057    // (e.g. `submit = ["F"]`) wins over the fetch shortcut (#293 review).
4058    match self.link_prompt.stage {
4059      LinkPromptStage::ChooseTarget => match self.resolve_modal(KeyContext::LinkChooseTarget, key) {
4060        Some(ModalAction::LinkChooseCancel) => return LinkPromptKey::Cancel,
4061        Some(ModalAction::LinkChooseNext) | Some(ModalAction::LinkChoosePrev) => self.link_prompt.toggle_selection(),
4062        Some(ModalAction::LinkChooseIssue) => self.link_prompt_choose(LinkTarget::Issue),
4063        Some(ModalAction::LinkChoosePr) => self.link_prompt_choose(LinkTarget::Pr),
4064        Some(ModalAction::LinkChooseAccept) => {
4065          let target = self.link_prompt.selected;
4066          self.link_prompt_choose(target);
4067        }
4068        _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
4069        _ => {}
4070      },
4071      LinkPromptStage::InputNumber => match self.resolve_modal(KeyContext::LinkInputNumber, key) {
4072        Some(ModalAction::LinkInputCancel) => return LinkPromptKey::Cancel,
4073        Some(ModalAction::LinkInputSubmit) => return LinkPromptKey::Submit,
4074        _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
4075        _ => match key.code {
4076          KeyCode::Char(c) => self.link_prompt_push_char(c),
4077          KeyCode::Backspace => self.link_prompt_pop_char(),
4078          _ => {}
4079        },
4080      },
4081    }
4082    LinkPromptKey::Handled
4083  }
4084
4085  pub fn link_prompt_cancel(&mut self) {
4086    self.view = View::List;
4087    self.link_prompt.reset();
4088  }
4089
4090  pub fn link_prompt_stage(&self) -> LinkPromptStage {
4091    self.link_prompt.stage
4092  }
4093
4094  pub fn link_prompt_number_input(&self) -> &str {
4095    &self.link_prompt.number
4096  }
4097
4098  pub fn link_prompt_target(&self) -> Option<LinkTarget> {
4099    self.link_prompt.target
4100  }
4101
4102  pub fn link_prompt_choose(&mut self, target: LinkTarget) {
4103    self.link_prompt.commit_target(target);
4104    self.status = match target {
4105      LinkTarget::Issue | LinkTarget::Pr => "num".into(),
4106    };
4107  }
4108
4109  pub fn link_prompt_push_char(&mut self, c: char) {
4110    self.link_prompt.push_char(c);
4111  }
4112
4113  pub fn link_prompt_pop_char(&mut self) {
4114    self.link_prompt.pop_char();
4115  }
4116
4117  pub fn link_prompt_submit(&mut self) -> Result<()> {
4118    let Some(target) = self.link_prompt.target else {
4119      self.status = "no target chosen".into();
4120      return Ok(());
4121    };
4122    let n: u64 = self
4123      .link_prompt
4124      .number
4125      .parse()
4126      .map_err(|_| GwmError::Other("number is empty or invalid".into()))?;
4127    let branch = self
4128      .selected()
4129      .and_then(|w| w.branch.clone())
4130      .or_else(|| {
4131        self
4132          .repo
4133          .head()
4134          .ok()
4135          .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
4136      })
4137      .ok_or_else(|| GwmError::Other("no branch resolved for selected worktree".into()))?;
4138    match target {
4139      LinkTarget::Issue => github::link_issue(&self.repo, &branch, n)?,
4140      LinkTarget::Pr => github::link_pr(&self.repo, &branch, n)?,
4141    }
4142    self.status = match target {
4143      LinkTarget::Issue => format!("linked issue #{} to {}", n, branch),
4144      LinkTarget::Pr => format!("linked PR #{} to {}", n, branch),
4145    };
4146    self.view = View::List;
4147    self.link_prompt.reset();
4148    self.refresh_link();
4149    Ok(())
4150  }
4151}
4152
4153/// Resolve the shell command for `mode = "shell"`. Precedence:
4154/// `shell_cmd` in `.gwm.toml` → `$SHELL` env var → `/bin/sh`. The
4155/// hardcoded fallback exists for the (rare) case where neither is set —
4156/// the TUI's spawn-and-restore loop assumes a non-empty command string.
4157fn resolve_shell_command(cfg: &TuiOpenConfig) -> String {
4158  cfg
4159    .shell_cmd
4160    .clone()
4161    .or_else(|| std::env::var("SHELL").ok())
4162    .unwrap_or_else(|| "/bin/sh".into())
4163}
4164
4165/// Resolve the editor command for `mode = "editor"`. Precedence:
4166/// `editor_cmd` in `.gwm.toml` → `$EDITOR` env var → `vi` (POSIX
4167/// baseline). Mirrors `resolve_shell_command` so the two flows share
4168/// the same precedence story.
4169fn resolve_editor_command(cfg: &TuiOpenConfig) -> String {
4170  cfg
4171    .editor_cmd
4172    .clone()
4173    .or_else(|| std::env::var("EDITOR").ok())
4174    .unwrap_or_else(|| "vi".into())
4175}