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(¤t);
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}