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, Mode};
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, WorktreeName};
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 /// Generic detail overlay (issue #408). A centred row-list modal — its
116 /// first consumer is the agent-session view (`a` on the worktree list);
117 /// the content contract is deliberately generic so the planned rich
118 /// PR/Issue view reuses it. State lives on [`App::detail_overlay`]; keys
119 /// resolve through [`crate::tui::modal_keymap::KeyContext::Detail`].
120 DetailOverlay,
121}
122
123/// What the run loop must do after [`App::handle_exec_picker_key`]
124/// processes a key in the exec picker overlay (issue #325). Mirrors
125/// [`CreateKey`] / [`LinkPromptKey`]: the testable handler owns the
126/// highlight movement, the loop owns the two side effects (resolve the
127/// argv + spawn the PTY overlay, or close back to the list).
128#[derive(Debug, PartialEq, Eq, Clone, Copy)]
129pub enum ExecPickerKey {
130 /// The key moved the highlight (or was ignored); stay in the picker.
131 Handled,
132 /// `Enter` — the loop should resolve the highlighted profile and spawn
133 /// the PTY overlay.
134 Submit,
135 /// `Esc` — the loop should close the picker back to the list.
136 Cancel,
137}
138
139/// What the run loop must do after [`App::handle_create_key`] processes a
140/// key in the create overlay (issue #217). Keeps the side effects
141/// (worktree creation, view transition) in the loop while the form
142/// mutations stay in the testable handler.
143#[derive(Debug, PartialEq, Eq, Clone, Copy)]
144pub enum CreateKey {
145 /// The key mutated form state (or was ignored); stay in the overlay.
146 Handled,
147 /// `Enter` on the description field — the loop should run `submit_create`.
148 Submit,
149 /// `Esc` — the loop should close the overlay back to the list.
150 Cancel,
151}
152
153/// What the run loop must do after [`App::handle_link_prompt_key`] processes
154/// a key in the link prompt (issue #217). Mirrors [`CreateKey`]: the testable
155/// handler owns the picker / digit-buffer mutations, the loop owns the two
156/// side effects (the `github::link_*` shell-out, the view transition).
157#[derive(Debug, PartialEq, Eq, Clone, Copy)]
158pub enum LinkPromptKey {
159 /// The key moved the highlight, committed a target, or edited the number
160 /// buffer (or was ignored); stay in the prompt.
161 Handled,
162 /// `Enter` on the number field — the loop should run `link_prompt_submit`.
163 Submit,
164 /// The resolved `fetch_github` key — the loop should refresh status.
165 Refresh,
166 /// `Esc` — the loop should close the prompt back to the list.
167 Cancel,
168}
169
170/// Target of an open / link action. Canonical definition lives in
171/// `crate::cli::LinkTarget` (it carries the `clap::ValueEnum` derive
172/// for the CLI surface); the TUI re-exports the same type so a value
173/// crossing the cli/tui boundary doesn't need a manual conversion
174/// (issue #106).
175pub use crate::cli::LinkTarget;
176
177/// Dispatch target for the `o` key (issue #73). Resolved by
178/// [`App::resolve_open_target`] from the current selection + the
179/// `[tui.open]` config so the event loop can hand off to the right
180/// runner (shell suspend, editor suspend, OS file manager) without
181/// re-reading the config or `$SHELL` / `$EDITOR` itself.
182#[derive(Debug, PartialEq, Eq, Clone)]
183pub enum OpenTarget {
184 /// Spawn `command` with `cwd = path`. Caller suspends the TUI and
185 /// restores it on the child's exit (same lifecycle as `l: lazygit`).
186 Shell { path: PathBuf, command: String },
187 /// Spawn `command <path>` and wait. Same suspend/restore lifecycle
188 /// as `Shell`.
189 Editor { path: PathBuf, command: String },
190 /// Hand off to the OS opener (`open` / `xdg-open` / `explorer`).
191 /// Doesn't suspend the TUI — the opener detaches.
192 Finder { path: PathBuf },
193}
194
195/// Stage of the two-step link prompt. Re-export from the extracted
196/// `LinkPrompt` sub-struct (issue #126) so the existing public surface
197/// (`gwm::tui::LinkPromptStage`) keeps compiling without callers
198/// learning the new module path.
199pub use super::state::link_prompt::LinkPromptStage;
200
201/// One repo's session-stable metadata in workspace mode (issue #36). The live
202/// `git2::Repository` is *not* stored here (it isn't `Send`/`Clone` and would
203/// duplicate `App.repo`); it is re-opened from `workdir` when this repo
204/// becomes the active one. `config` is cloned into `App.config` on activation
205/// so per-row actions (`create`, bootstrap, hooks) read the right repo's
206/// `.gwm.toml` — matching the issue's "each row inherits its own repo's
207/// config" contract. Keymap/theme stay session-level (resolved once from the
208/// first repo), the same "resolved once, relaunch to change" contract as
209/// single-repo mode.
210#[derive(Debug, Clone)]
211pub struct RepoMeta {
212 pub name: String,
213 pub workdir: PathBuf,
214 pub config: Config,
215}
216
217/// Pins per worktree path, read from each row's owning repo (its branch
218/// config). Runs in the detection worker on the periodic path (round P) and
219/// synchronously on user-action paths; repos are opened at most once per
220/// distinct workdir. Pub: the state tests pin the owning-repo contract
221/// through it without spawning the worker thread.
222pub fn read_pins_from_sources(
223 sources: &[(String, String, PathBuf)],
224) -> std::collections::BTreeMap<String, Vec<String>> {
225 let mut repos: std::collections::BTreeMap<&PathBuf, Option<Repository>> = std::collections::BTreeMap::new();
226 let mut out = std::collections::BTreeMap::new();
227 for (path, branch, repo_dir) in sources {
228 let repo = repos.entry(repo_dir).or_insert_with(|| Repository::open(repo_dir).ok());
229 let Some(repo) = repo.as_ref() else {
230 continue;
231 };
232 let pins = crate::github::agent_pins(repo, branch).unwrap_or_default();
233 if !pins.is_empty() {
234 out.insert(path.clone(), pins);
235 }
236 }
237 out
238}
239
240/// Workspace-mode state (issue #36). `None` in single-repo mode (the default).
241/// The *active* repo lives in `App`'s core fields (`repo`/`repo_name`/
242/// `workdir`/`config`); this holds everything needed to swap a different repo
243/// into those fields as the selection moves between repos.
244#[derive(Debug, Clone)]
245pub struct WorkspaceState {
246 /// The root `--workspace` pointed at.
247 pub root: PathBuf,
248 /// Session-stable repo metadata, in discovery (alphabetical) order.
249 pub repos: Vec<RepoMeta>,
250 /// The owning repo index for each `App.worktrees[i]` row, parallel to that
251 /// vec. Rebuilt by every workspace refresh so it never drifts.
252 pub row_repo: Vec<usize>,
253 /// Index into `repos` of the currently active repo (mirrors `App.repo*`).
254 pub active: usize,
255}
256
257pub struct App {
258 pub repo: Repository,
259 /// The name `{repo}` expands to, in `branch_pattern`, `path_pattern` and
260 /// `[worktree].base` alike: the repo directory's basename
261 /// ([`worktree::repo_name`]), which is also the name every `gwm create` from
262 /// the CLI uses and the one the parser side reads back
263 /// ([`crate::naming::BranchParser::for_repo`], `github::read_link`,
264 /// `lifecycle`).
265 ///
266 /// Issue #480: deliberately **not** the workspace display label. That label
267 /// is derived from the workspace's current membership (a second `api` becomes
268 /// `api-2`, #304), so it changes when a sibling repo moves while the branches
269 /// already written keep saying `api-2`. A name persisted in git cannot depend
270 /// on what else happens to sit next to it on disk.
271 pub repo_name: String,
272 /// The name shown to the user for the active repo: the workspace display
273 /// label when there is one, otherwise identical to [`Self::repo_name`]. Only
274 /// the header reads it; nothing that writes a branch or a path does.
275 pub display_repo_name: String,
276 pub workdir: PathBuf,
277 pub config: Config,
278 /// Workspace-mode state (issue #36); `None` in single-repo mode.
279 pub workspace: Option<WorkspaceState>,
280 /// Set when the selected row's repo could not be activated in workspace mode
281 /// (moved / deleted / corrupt since listing). While true, `repo`/`workdir`/
282 /// `config` still point at the previously active repo, so repo-mutating
283 /// actions are blocked to avoid a wrong-target write (#304). Always `false`
284 /// in single-repo mode and once a selection activates cleanly.
285 pub workspace_active_stale: bool,
286 pub worktrees: Vec<WorktreeInfo>,
287 pub list_state: TableState,
288 pub view: View,
289 pub status: String,
290 pub delete_branch_on_remove: bool,
291 pub open_menu_selected: LinkTarget,
292
293 // Create form state
294 /// Create-worktree overlay state (extracted per #123). Holds field
295 /// focus, type index, and the issue/slug input buffers.
296 pub create_form: CreateForm,
297 /// Last asynchronous create failure shown inside the Create modal.
298 pub create_failure: Option<String>,
299 /// Branch types displayed in the create-form picker. Resolved once at
300 /// startup from [`Config::resolved_branch_types`] so the picker
301 /// honours any `[[branch_types]]` override in `.gwm.toml` without
302 /// re-reading the file on every key event.
303 pub branch_types: Vec<BranchType>,
304
305 // Bootstrap report
306 pub report: Option<BootstrapReport>,
307
308 /// Keybindings (help) overlay scroll offset, in rows. Reset to 0 every
309 /// time the overlay opens; clamped to `help_max_scroll` (#217).
310 pub help_scroll: u16,
311 /// Keybindings (help) overlay horizontal scroll offset, in columns (#222).
312 pub help_x_scroll: u16,
313 /// Maximum help scroll offset, republished by [`super::ui::draw_help`]
314 /// each frame as `content_rows.saturating_sub(viewport_rows)` so the
315 /// offset can never scroll past the last line into the void.
316 pub help_max_scroll: u16,
317 /// Maximum horizontal help scroll offset, republished by the renderer.
318 pub help_max_x_scroll: u16,
319
320 /// Sidebar (git preview) panel state (extracted per #127). Owns the
321 /// visibility / focus flags, the scroll offset + max bound, and the
322 /// cached pre-rendered sections keyed by the selected worktree's
323 /// path. The cache prevents re-shelling `git log` / `git status` on
324 /// every TUI redraw — they only run when the selection actually
325 /// changes (via [`SidebarState::on_navigation`]) or on explicit
326 /// refresh ([`SidebarState::invalidate`]). The renderer publishes
327 /// `sidebar.max_scroll` every frame against the actual rendered
328 /// Recent Commits height; [`SidebarState::scroll_down`] clamps
329 /// against it.
330 pub sidebar: SidebarState,
331
332 /// Last completed agent-session snapshot, keyed by worktree path string
333 /// (issue #408). `None` until the first detection lands — the table then
334 /// renders without agent cells, no placeholder noise. Replaced atomically
335 /// by [`Self::apply_agent_snapshot`]; the render path only reads it.
336 pub agent_snapshot: Option<std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>>,
337 /// When the current snapshot was taken — drives the periodic re-detection
338 /// in [`Self::maybe_refresh_agent_sessions`] so freshness colours do not
339 /// fossilise at their startup value.
340 pub agent_snapshot_at: Option<std::time::Instant>,
341 /// Every session the last detection saw, matched or not — the candidate
342 /// pool of the overlay's attach-by-id prompt (user feedback 2026-07-22).
343 pub agent_all_sessions: Vec<crate::agent_sessions::AgentSession>,
344 /// Pinned session ids per worktree path — the sidebar Agents pane shows
345 /// ONLY these (user feedback 2026-07-22), and the render path must not
346 /// read git config, so the map is refreshed off-render (each detection
347 /// cycle + immediately after attach/detach). Empty in workspace mode
348 /// (same single-repo ceiling as the pins themselves).
349 pub agent_pins: std::collections::BTreeMap<String, Vec<String>>,
350 /// A full pool scan was requested while a detection run was in flight —
351 /// it chains after that run lands instead of walking the store
352 /// concurrently (Codex review round R).
353 agent_pool_wanted: bool,
354 /// A pin changed while a detection run was in flight — the re-scan (and
355 /// the pins refresh) chains after that run lands instead of racing a
356 /// second walk against it (Codex review round U).
357 agent_redetect_wanted: bool,
358
359 // Vim motion buffer: armed by first `g`, completed by the second.
360 // **Kept for backward compatibility** with pre-#87 tests that read
361 // it directly. Now a *mirror* of [`Self::pending_chord`] —
362 // [`Self::dispatch_key`] keeps the two synchronised via
363 // [`Self::sync_legacy_pending`]. New code should consume
364 // [`Self::pending_chord_is_empty`] instead.
365 pub pending_g: bool,
366
367 /// Generic pending-keys buffer for the configurable keymap
368 /// (issue #87). Empty most of the time; populated with the
369 /// strokes seen so far whenever the user is partway through a
370 /// chord that is a prefix of a bound binding (e.g. after the
371 /// first `g` of the default `g g → Top`).
372 pub pending_chord: Vec<KeyStroke>,
373
374 /// Resolved keymap for this TUI session. Built from
375 /// [`Config::tui.keys`] at construction time and never mutated
376 /// thereafter — the user has to relaunch gwm to pick up a config
377 /// change, mirroring how every other knob in `[tui]` behaves.
378 pub keymap: Keymap,
379
380 /// Resolved contextual keymap for modals / overlays (issue #219).
381 /// Built from the `[tui.keys.modal.<context>]` sub-tables at construction
382 /// time alongside [`Self::keymap`]; consulted by the modal routing in
383 /// `src/tui/mod.rs` to turn a keystroke into a typed [`ModalAction`].
384 pub modal_keymap: ModalKeymap,
385
386 /// Resolved colour theme for this TUI session (issue #33). Built
387 /// from `[theme]` in `.gwm.toml` at construction time. Threaded
388 /// through `draw_*` calls so user overrides reach every visual
389 /// signal. Same hot-reload-on-relaunch contract as the keymap.
390 pub theme: Theme,
391
392 // Inline fuzzy filter on the worktree list (issue #21, extracted per
393 // #124 with memoisation closing #104). The sub-struct owns the buffer
394 // (`query`), the typing-bar flag (`active`), and a cached indices vec
395 // so the 3–5 `tui/ui.rs` call sites per render frame don't each rerun
396 // the `nucleo_matcher` pass. `App::refresh` calls
397 // `self.filter.invalidate()` to drop the cache when `worktrees`
398 // changes; a worktrees-length mismatch auto-invalidates too.
399 pub filter: FilterState,
400
401 // Picker mode (issue #22): `gwm switch` runs the TUI as a stripped-down
402 // picker. Create / delete / bootstrap keys are inert; Enter records the
403 // highlighted worktree path into `picker_result` and the event loop quits
404 // so the CLI caller can print the path on stdout for `cd "$(gwm switch)"`.
405 pub picker_mode: bool,
406 pub picker_result: Option<PathBuf>,
407 /// Event-loop exit signal for picker mode. Driven by `picker_confirm`
408 /// (only when a worktree is actually selected) and `picker_cancel` (Esc
409 /// from inside the filter bar, where a blanket `break` would clash with
410 /// the regular TUI's clear-filter behaviour). Keeps the loop running on
411 /// Enter-with-no-match so the user can back-space and refine the filter
412 /// instead of being kicked out with exit code 1.
413 pub picker_should_exit: bool,
414
415 /// Event-loop exit signal for `Action::Quit` fired from a path
416 /// that cannot itself `break` the loop (issue #32: the command
417 /// palette routes accepted actions through `run_action`, which
418 /// returns `Result<()>` and has no `break` channel). Set by
419 /// `run_action` when it sees `Action::Quit`; checked at the top
420 /// of every event-loop iteration alongside `picker_should_exit`.
421 pub should_quit: bool,
422
423 /// Safety countdown state for the confirm overlay (issue #30, extracted
424 /// per #125). Holds the timer anchor and exposes the pure state-machine
425 /// API; this `App` keeps the side-effecting wrappers below that compose
426 /// the status messages and call `worktree::remove`.
427 pub confirm: ConfirmModal,
428
429 /// Last delete-worktree failure shown inside the confirm modal (issue
430 /// #257). Kept on `App`, not `ConfirmModal`, because it is the outcome of
431 /// the async worktree deletion side effect rather than countdown state.
432 pub delete_failure: Option<String>,
433
434 /// Animated loader for overlays (issue #187). Advanced by the event
435 /// loop's 200ms poll tick while the confirm countdown is armed and
436 /// read by the renderer; pure state lives in
437 /// [`super::state::spinner::Spinner`].
438 pub spinner: Spinner,
439
440 // ---- Issue/PR linking (issue #67) -------------------------------------
441 /// GitHub fetch state slice — owns the cached link for the currently
442 /// selected worktree's branch, the repo slug parsed from `origin`,
443 /// and the per-target `gh issue view` / `gh pr view` fetch state
444 /// (extracted per #128, part 6/6 of the `App` god-struct
445 /// decomposition #102). The orchestrator methods below
446 /// (`refresh_link`, `refresh_github_status`,
447 /// `apply_issue_fetch_result`, `apply_pr_fetch_result`) are thin
448 /// wrappers that compose the status-bar copy + drive the actual
449 /// `gh` shell-outs; the pure state machine lives on
450 /// `GitHubFetch`.
451 pub github: GitHubFetch,
452 /// Two-stage issue/PR link prompt state (extracted per #126). Owns
453 /// the stage + target + digit buffer; the orchestrator wraps the
454 /// transitions to update the status bar and shell out to
455 /// `github::link_{issue,pr}` on submit.
456 link_prompt: LinkPrompt,
457
458 /// Command palette overlay state (issue #32). Opened by
459 /// `Action::CommandPalette` (default `:` binding). The pure state
460 /// machine — buffer, fuzzy-matched candidates, highlight cursor —
461 /// lives on `PaletteState`; this `App` owns the view transition
462 /// and routes the accepted `Action` back through the normal
463 /// dispatcher so palette and keymap fire identical side effects.
464 pub palette: PaletteState,
465
466 /// TOFU trust mode for this TUI session (issue #95). Resolved at
467 /// the CLI entrypoint from `--allow-bootstrap` / `--deny-bootstrap`
468 /// / `GWM_ALLOW_BOOTSTRAP=1` and threaded down via `tui::run(mode)`.
469 /// Used by `check_trust_for_bootstrap` to gate `submit_create` and
470 /// `bootstrap_selected` — same security policy as the CLI, no
471 /// bypass via the TUI. Default `Prompt` (preserves the safe
472 /// default when callers construct `App` directly, e.g. tests that
473 /// don't care about the gate).
474 pub trust_mode: crate::trust::TrustMode,
475
476 /// Generic off-thread task spine (issue #231; GitHub fetch folded in by
477 /// #255): coalescing + per-key generation late-drop for slow one-shot
478 /// ops — the worktree list refresh and the `gh issue/pr view` fetches.
479 /// Public for the same reason `github` is — the state-machine tests
480 /// claim a generation directly without spawning an OS thread.
481 pub tasks: TaskRunner,
482 /// Last point at which the periodic TUI worktree refresh was armed.
483 /// Tests set this directly to simulate elapsed time without sleeping.
484 pub last_auto_refresh_at: Instant,
485 /// Sender cloned into each background task worker (issue #231; carries the
486 /// GitHub fetch results too since #255).
487 task_tx: mpsc::Sender<TaskMsg>,
488 /// Receiver drained by [`Self::drain_task_results`] each event-loop tick.
489 /// A worker whose `App` has dropped simply fails its `send` and is ignored.
490 task_rx: mpsc::Receiver<TaskMsg>,
491
492 /// Command Logs overlay state (issue #226): the scroll cursor plus an
493 /// owned snapshot of the [`crate::command_log`] global, so the modal
494 /// renders off `App` state rather than locking the global mid-frame.
495 pub command_logs: CommandLogs,
496
497 /// Configuration panel overlay state (issue #232): the scroll cursor
498 /// plus the resolved-row snapshot, filled by [`Self::enter_config_panel`].
499 pub config_panel: ConfigPanel,
500
501 /// The user-level global config path this `App` was constructed with
502 /// (issue #232). Stored so [`Self::enter_config_panel`] resolves the
503 /// panel's source attribution against the *same* layers the running
504 /// config was loaded from — `None` in tests / sandboxed runs with no
505 /// global file, matching [`Config::load_layered`]'s injection point.
506 global_path: Option<PathBuf>,
507
508 /// Live PTY overlay state (issue #35). `Some` while a lazygit or native
509 /// terminal PTY session is open; `None` at all other times.
510 /// Managed by [`Self::open_pty_overlay`] / [`Self::close_pty_overlay`].
511 pub pty_overlay: Option<PtyOverlay>,
512
513 /// Exec profile picker overlay state (issue #325). Populated by
514 /// [`Self::enter_exec_picker`] from `[exec.profiles.*]`; on `Enter` the
515 /// run loop resolves the highlight to an argv and spawns a PTY overlay
516 /// ([`PtyKind::Exec`]) in the selected worktree's directory.
517 pub exec_picker: ExecPicker,
518
519 /// The `[exec]` config captured when the exec picker opened (issue #325).
520 /// In workspace mode `sync_active_repo` can swap `self.config` to another
521 /// repo while the overlay is open, so `Enter` resolves the argv against
522 /// this snapshot — the active repo's `[exec]` at open time — not the live
523 /// config (Codex #333 review).
524 exec_picker_cfg: ExecConfig,
525
526 /// Clean overlay state (issue #325). Holds the gated reclaim scan of the
527 /// selected worktree, the `[clean.profiles.*]` picker, and a dedicated
528 /// safety countdown. Filled by [`Self::enter_clean_overlay`]; the run loop
529 /// fires [`crate::clean::delete_reclaim`] when the countdown elapses.
530 pub clean_overlay: CleanOverlay,
531
532 /// The `[clean]` config captured when the clean overlay opened (issue
533 /// #325) — every re-scan and the delete resolve their dir-set against this
534 /// snapshot, not the live `self.config.clean`, which a workspace
535 /// auto-refresh could swap to another repo's (Codex #333 review).
536 clean_overlay_cfg: CleanConfig,
537
538 /// The safety-countdown duration (seconds) captured when the clean overlay
539 /// opened (issue #325). Pinned alongside [`Self::clean_overlay_cfg`] so a
540 /// workspace config swap can't shorten — or clear to `0` — the delay
541 /// before an armed reclaim fires (Codex #333 review).
542 clean_overlay_countdown_secs: u32,
543
544 /// Generic detail overlay content (issue #408) — filled by
545 /// [`Self::open_agent_overlay`] while [`View::DetailOverlay`] is up.
546 pub detail_overlay: crate::tui::state::detail_overlay::DetailOverlay,
547
548 /// The worktree the open detail overlay was built for — `(path, branch)`
549 /// captured at open so attach/detach pin against it even if an
550 /// auto-refresh drifts the live selection (clean-overlay pattern).
551 detail_overlay_target: Option<(PathBuf, Option<String>)>,
552
553 /// CI-consumer counterpart of `detail_overlay_target` (Codex review
554 /// #455): the `(remote slug, PR number)` the open CI checks overlay was
555 /// built for, captured by [`Self::enter_ci_checks`]. Any link mutation
556 /// that disagrees — the PR changed, disappeared, or (workspace mode)
557 /// the slug moved to another repo whose PR happens to share the
558 /// number — closes the overlay up front via
559 /// [`Self::close_ci_overlay_if_link_disagrees`]; otherwise the stale
560 /// checks stay up through the new fetch, and forever if it fails, with
561 /// `Enter` opening an old PR's check URL.
562 detail_overlay_pr: Option<(Option<String>, u64)>,
563
564 /// The `PrCheck`s the open CI overlay renders (Codex review #455): the
565 /// duration tick used to read them back from the PR fetch cache, so an
566 /// invalidation while the overlay was up — a workspace `refresh_link`
567 /// with no bulk refetch, a failed manual refresh — silently killed the
568 /// clock of a Running check. The overlay owns its checks instead;
569 /// populated at open and on every landing, cleared on close.
570 ci_overlay_checks: Vec<github::PrCheck>,
571
572 /// Set by `Action::ExitToWorktree` (#290): the path the main loop
573 /// should print to stdout just before quitting so the shell wrapper
574 /// (`cd "$(gwm)"`) can change directory. `None` → plain quit.
575 pub should_exit_to: Option<PathBuf>,
576
577 /// The selected worktree's branch name captured when the rename modal
578 /// (`View::Edit`, #290) opens — the `<old>` in `git branch -m <old> <new>`.
579 /// `None` while the modal is closed.
580 pub edit_original_branch: Option<String>,
581
582 /// The selected worktree's on-disk path captured when the rename modal
583 /// opens — the source for `git worktree move <old_path> <new_path>`.
584 pub edit_original_path: Option<PathBuf>,
585
586 /// Last rename failure, surfaced inside the Edit modal (mirrors
587 /// [`Self::create_failure`]) so the user can correct and retry without
588 /// losing the form. Cleared when the modal reopens.
589 pub edit_failure: Option<String>,
590}
591
592impl App {
593 pub fn new() -> Result<Self> {
594 Self::new_at(None)
595 }
596
597 pub fn new_at(start: Option<&Path>) -> Result<Self> {
598 Self::new_at_layered(start, crate::config::global_config_path().as_deref())
599 }
600
601 /// Injectable variant of [`Self::new_at`] (issue #194): `global_path`
602 /// is the user-level global config layered under the repo's `.gwm.toml`
603 /// (`None` = repo-only, no environment read). Tests pass `None` so `App`
604 /// construction never depends on the runner's real
605 /// `~/.config/gwm/config.toml`. `new_at` delegates with the real
606 /// `global_config_path()`, so runtime behaviour is unchanged.
607 pub fn new_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
608 let repo = worktree::discover_repo(start)?;
609 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
610 let repo_name = worktree::repo_name(&repo);
611 let config = Config::load_layered(&workdir, global_path)?;
612 let branch_types = config.resolved_branch_types().types;
613 // Resolve the keymap once at construction. Config::load_for_repo
614 // already validated the overrides, so this should not surface a
615 // fresh error — but we re-`?` it rather than `.expect()` so a
616 // future hot-reload path could exercise the same call.
617 let keymap = config.tui.keys.resolved_keymap()?;
618 // Issue #219: resolve the contextual modal keymap once, same lifecycle
619 // as the global keymap above. Pre-validated by `Config::load_for_repo`.
620 let modal_keymap = config.tui.keys.resolved_modal_keymap()?;
621 // Issue #33: resolve the colour theme once at construction.
622 // Validated by `Config::load_for_repo` already, so this can
623 // only surface a fresh error if the loader pre-validation is
624 // bypassed (e.g. a future hot-reload path) — `?` is still the
625 // right propagation policy.
626 let theme = config.theme.resolve()?;
627 let worktrees = worktree::list(&repo)?;
628 let mut state = TableState::default();
629 if !worktrees.is_empty() {
630 state.select(Some(0));
631 }
632 let (task_tx, task_rx) = mpsc::channel();
633 let mut out = Self {
634 repo,
635 display_repo_name: repo_name.clone(),
636 repo_name,
637 workdir,
638 config,
639 workspace: None,
640 workspace_active_stale: false,
641 worktrees,
642 list_state: state,
643 view: View::List,
644 status: String::from("press ? for help"),
645 delete_branch_on_remove: false,
646 open_menu_selected: LinkTarget::Issue,
647 create_form: CreateForm::new(),
648 create_failure: None,
649 branch_types,
650 report: None,
651 help_scroll: 0,
652 help_x_scroll: 0,
653 help_max_scroll: 0,
654 help_max_x_scroll: 0,
655 sidebar: SidebarState::new(),
656 agent_snapshot: None,
657 agent_snapshot_at: None,
658 agent_all_sessions: Vec::new(),
659 agent_pins: std::collections::BTreeMap::new(),
660 agent_pool_wanted: false,
661 agent_redetect_wanted: false,
662 pending_g: false,
663 pending_chord: Vec::new(),
664 keymap,
665 modal_keymap,
666 theme,
667 filter: FilterState::new(),
668 picker_mode: false,
669 picker_result: None,
670 picker_should_exit: false,
671 should_quit: false,
672 confirm: ConfirmModal::new(),
673 delete_failure: None,
674 spinner: Spinner::new(),
675 github: GitHubFetch::new(),
676 link_prompt: LinkPrompt::new(),
677 palette: PaletteState::new(),
678 trust_mode: crate::trust::TrustMode::Prompt,
679 tasks: TaskRunner::new(),
680 last_auto_refresh_at: Instant::now(),
681 task_tx,
682 task_rx,
683 command_logs: CommandLogs::new(),
684 config_panel: ConfigPanel::new(),
685 global_path: global_path.map(Path::to_path_buf),
686 pty_overlay: None,
687 exec_picker: ExecPicker::new(),
688 exec_picker_cfg: ExecConfig::default(),
689 clean_overlay: CleanOverlay::new(),
690 clean_overlay_cfg: CleanConfig::default(),
691 clean_overlay_countdown_secs: 0,
692 detail_overlay: crate::tui::state::detail_overlay::DetailOverlay::default(),
693 detail_overlay_target: None,
694 detail_overlay_pr: None,
695 ci_overlay_checks: Vec::new(),
696 should_exit_to: None,
697 edit_original_branch: None,
698 edit_original_path: None,
699 edit_failure: None,
700 };
701 out.apply_sidebar_config();
702 out.apply_create_form_fields();
703 out.refresh_link();
704 let spawned = out.refresh_linked_github_statuses_for_worktrees();
705 if spawned > 0 {
706 out.status = String::from("fetching GitHub status…");
707 }
708 Ok(out)
709 }
710
711 /// Workspace-mode constructor (issue #36): open the TUI over every git repo
712 /// one level below `root`, merging their worktree listings into one
713 /// repo-tagged table. Anchors the session on the first repo (alphabetical)
714 /// for keymap/theme resolution and the event-loop channels, then swaps the
715 /// merged list and per-row repo map in. Errors with [`GwmError::EmptyWorkspace`]
716 /// when no repo sits directly under `root`.
717 pub fn new_workspace_at_layered(root: &Path, global_path: Option<&Path>) -> Result<Self> {
718 let ws = crate::workspace::discover(root)?;
719 if ws.is_empty() {
720 return Err(GwmError::EmptyWorkspace {
721 root: root.display().to_string(),
722 });
723 }
724
725 // Load each repo's `.gwm.toml` once — session-stable metadata swapped into
726 // the active slot on navigation.
727 let mut repos: Vec<RepoMeta> = Vec::with_capacity(ws.repos.len());
728 for r in &ws.repos {
729 let config = Config::load_layered(&r.path, global_path)?;
730 repos.push(RepoMeta {
731 name: r.name.clone(),
732 workdir: r.path.clone(),
733 config,
734 });
735 }
736
737 // Anchor the session on the first repo: this resolves the keymap, theme,
738 // branch types, and sets up the task channels exactly as single-repo mode.
739 let mut app = Self::new_at_layered(Some(&repos[0].workdir), global_path)?;
740
741 // Replace the single-repo list with the merged, repo-tagged one. Map each
742 // row to its repo by the repo's *workdir path*, not its display name —
743 // names can collide (a linked worktree resolving to an owner outside the
744 // root, symlinks), and a name-keyed map would then point rows at the wrong
745 // repo handle/config (Codex review #303 round-2 P2).
746 let path_to_idx: HashMap<&Path, usize> = repos
747 .iter()
748 .enumerate()
749 .map(|(i, m)| (m.workdir.as_path(), i))
750 .collect();
751 let rows = crate::workspace::merge_worktrees(&ws)?;
752 let mut worktrees = Vec::with_capacity(rows.len());
753 let mut row_repo = Vec::with_capacity(rows.len());
754 for row in &rows {
755 let idx = path_to_idx.get(row.repo_path.as_path()).copied().unwrap_or(0);
756 worktrees.push(row.info.clone());
757 row_repo.push(idx);
758 }
759
760 let repo_count = repos.len();
761 let wt_count = worktrees.len();
762 app.worktrees = worktrees;
763 app.workspace = Some(WorkspaceState {
764 root: root.to_path_buf(),
765 repos,
766 row_repo,
767 active: 0,
768 });
769 app.filter.invalidate();
770 app.list_state.select(if wt_count == 0 { None } else { Some(0) });
771 // Resolve the initially-selected row's GitHub link/slug against its own
772 // repo (the anchor). Workspace mode fetches GitHub state per-selection, not
773 // in one cross-repo bulk pass — see `refresh_linked_github_statuses_for_worktrees`.
774 app.refresh_link();
775 app.status = format!(
776 "workspace {} — {} repo(s), {} worktree(s) · press ? for help",
777 root.display(),
778 repo_count,
779 wt_count
780 );
781 Ok(app)
782 }
783
784 /// True when the TUI is in workspace mode (issue #36).
785 pub fn is_workspace(&self) -> bool {
786 self.workspace.is_some()
787 }
788
789 /// Display name of the repo owning raw worktree row `raw_index` (the index
790 /// into [`Self::worktrees`], not the filtered view). `None` in single-repo
791 /// mode or for an out-of-range index. Drives the TUI `REPO` column.
792 pub fn row_repo_name(&self, raw_index: usize) -> Option<&str> {
793 let ws = self.workspace.as_ref()?;
794 let idx = *ws.row_repo.get(raw_index)?;
795 ws.repos.get(idx).map(|m| m.name.as_str())
796 }
797
798 /// Raw `worktrees` index of the current selection, hopping through the fuzzy
799 /// filter map (the selection indexes the filtered view, not the raw vec).
800 fn selected_raw_index(&self) -> Option<usize> {
801 let i = self.list_state.selected()?;
802 let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
803 filtered.get(i).copied()
804 }
805
806 /// Align the active repo (`repo`/`repo_name`/`workdir`/`config`) with the
807 /// selected worktree's repo (issue #36). A no-op in single-repo mode and
808 /// when the selection still belongs to the active repo, so the event loop
809 /// can call it every frame cheaply. On the repo actually changing it
810 /// re-opens the `git2::Repository` from the target workdir and invalidates
811 /// the sidebar preview; an open failure keeps the current repo and reports
812 /// on the status bar rather than panicking mid-render.
813 /// Apply the `[tui]` sidebar knobs from the live config onto the sidebar
814 /// state. Called from every point where `self.config` becomes authoritative:
815 /// construction, the Settings-panel reload, and the workspace repo swap
816 /// (`sync_active_repo`). Kept as one call rather than open-coded assignments
817 /// so a future knob can't be wired into two of the three and silently drift —
818 /// which is precisely how the repo-swap path came to ignore
819 /// `sidebar_position` (Codex review #366 P2).
820 fn apply_sidebar_config(&mut self) {
821 self.sidebar.position = self.config.tui.sidebar_position;
822 self.sidebar.orientation = self.config.tui.sidebar_orientation;
823 }
824
825 /// Point the create form at the fields the active repo's patterns ask for
826 /// (issue #418). Same three call sites and same reasoning as
827 /// [`Self::apply_sidebar_config`]: one call rather than open-coded
828 /// assignments, so the repo swap cannot come to ignore it.
829 ///
830 /// The union covers `base` as well as the two patterns, because
831 /// [`crate::naming::BranchSpec::worktree_path`] expands the triple in it too:
832 /// a segment only `base` carries still names a directory on disk.
833 /// Public for the same reason the rest of the lib surface is (#342, the
834 /// internal test seam): state-machine tests set `config.worktree.*` directly
835 /// rather than round-tripping a `.gwm.toml`, and that shortcut has to be able
836 /// to re-derive what construction derives.
837 pub fn apply_create_form_fields(&mut self) {
838 self.create_form.set_fields(super::state::create_form::fields_for(&[
839 &self.config.worktree.branch_pattern,
840 &self.config.worktree.path_pattern,
841 &self.config.worktree.base,
842 ]));
843 }
844
845 /// The name of the field Enter submits from, for the status line (#418).
846 /// Read from the same `last_field` the key handler gates on, so the hint and
847 /// the behaviour cannot disagree.
848 fn submit_field_label(&self) -> &'static str {
849 match self.create_form.last_field() {
850 Field::Type => "type",
851 Field::Issue => "issue",
852 Field::Name => "name",
853 Field::Desc => "desc",
854 }
855 }
856
857 /// The verbs the structured form's status line advertises (#418), naming the
858 /// real submit field and dropping the field-switching half when there is
859 /// nothing to switch between. Same rule the hint row follows, and the same
860 /// reason: `next_field` rotates within a one-element list, so telling the
861 /// user to press Tab names a key that does nothing.
862 fn structured_form_instruction(&self) -> String {
863 match self.create_form.fields().len() {
864 // A pattern set with no editable token at all presents no field, so
865 // there is none to name — `last_field` falls back to `Type`, which the
866 // renderer is not drawing (Codex review on PR #492, fifth pass).
867 0 => "enter: submit".into(),
868 1 => format!("enter on {}: submit", self.submit_field_label()),
869 _ => format!(
870 "tab/shift-tab: switch field — enter on {}: submit",
871 self.submit_field_label()
872 ),
873 }
874 }
875
876 /// How the free-form status line names the mode it toggles back to (#418):
877 /// the fields that mode actually presents, rather than the canonical triple.
878 /// On a `{type}/{desc}` repo "back to type/issue/desc" promised an Issue
879 /// field the structured form hides.
880 fn structured_mode_label(&self) -> String {
881 let names: Vec<&str> = self
882 .create_form
883 .fields()
884 .iter()
885 .map(|f| match f {
886 Field::Type => "type",
887 Field::Issue => "issue",
888 Field::Desc => "desc",
889 Field::Name => "name",
890 })
891 .collect();
892 if names.is_empty() {
893 "the structured form".into()
894 } else {
895 names.join("/")
896 }
897 }
898
899 /// The segments the active repo's patterns ask the user to supply — what
900 /// [`crate::naming::BranchSpec::new_with_required`] validates against, so the
901 /// form never refuses a submission over a value the patterns discard (#418).
902 fn required_segments(&self) -> Vec<&'static str> {
903 crate::naming::editable_segments(&[
904 &self.config.worktree.branch_pattern,
905 &self.config.worktree.path_pattern,
906 &self.config.worktree.base,
907 ])
908 }
909
910 /// Mark the workspace selection stale AND close the open CI checks
911 /// overlay (Codex review #455): its rows belong to the previously
912 /// active repo, so every verb — `Enter` opening a check URL included —
913 /// would act on the wrong repo. One funnel so a new stale site cannot
914 /// forget the close.
915 fn mark_workspace_stale(&mut self) {
916 self.workspace_active_stale = true;
917 if self.view == View::DetailOverlay
918 && self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks
919 {
920 self.close_detail_overlay();
921 }
922 }
923
924 pub fn sync_active_repo(&mut self) {
925 let Some(ws) = self.workspace.as_ref() else {
926 return;
927 };
928 let Some(raw) = self.selected_raw_index() else {
929 // No visible/selected row (e.g. the filter hides everything): there is no
930 // active repo the selection points at, so writes must not fall through to
931 // the previously active repo — mark stale to block them (#304). Reached
932 // only in workspace mode (the `ws` guard above returns in single-repo).
933 self.mark_workspace_stale();
934 return;
935 };
936 let Some(&target) = ws.row_repo.get(raw) else {
937 self.mark_workspace_stale();
938 return;
939 };
940 if target == ws.active {
941 // The selection is on the live, already-activated repo — clear any stale
942 // flag left over from a previous unreachable selection.
943 self.workspace_active_stale = false;
944 return;
945 }
946 let Some(meta) = ws.repos.get(target).cloned() else {
947 return;
948 };
949 match Repository::open(&meta.workdir) {
950 Ok(repo) => {
951 // Issue #480: the naming name comes from the freshly-opened repo's own
952 // directory, never from the workspace label — see `App::repo_name`.
953 self.repo_name = worktree::repo_name(&repo);
954 self.repo = repo;
955 self.display_repo_name = meta.name;
956 self.workdir = meta.workdir;
957 self.config = meta.config;
958 self.workspace_active_stale = false;
959 // The branch types drive the create form; re-resolve them from the
960 // newly-active repo's config so a per-repo `[[branch_types]]` override
961 // applies to the row being acted on (Codex review #303 P2).
962 self.branch_types = self.config.resolved_branch_types().types;
963 // Same reasoning for the form's field set (#418): the swap replaced
964 // `self.config` wholesale, so the newly-active repo's patterns decide
965 // which fields the form presents.
966 self.apply_create_form_fields();
967 // Same reasoning for the sidebar layout: the swap replaced `self.config`
968 // wholesale, so a per-repo `[tui]` sidebar override would otherwise be
969 // ignored until a reload (Codex review #366 P2).
970 self.apply_sidebar_config();
971 if let Some(ws) = self.workspace.as_mut() {
972 ws.active = target;
973 }
974 self.invalidate_sidebar_cache();
975 // Re-resolve the GitHub link + slug against the now-active repo so the
976 // Issue/PR panel and the `F` refresh act on the selected row's own
977 // repo, not the previously-active one (Codex review #303 P2). The
978 // per-repo nav hook (`on_navigation`) ran `refresh_link` *before* this
979 // swap, while `self.repo` still pointed at the old repo.
980 self.refresh_link();
981 }
982 Err(e) => {
983 // Keep the previously active repo live but mark the selection stale so
984 // repo-mutating actions are blocked until the user moves to a
985 // reachable row (or a refresh drops the dead repo) — #304.
986 self.mark_workspace_stale();
987 self.status = format!(
988 "workspace: repo '{}' is unavailable ({}) — press r to refresh",
989 meta.name, e
990 );
991 }
992 }
993 }
994
995 /// Set the active config, keeping the workspace cache coherent. In
996 /// workspace mode the per-repo `RepoMeta.config` is the source of truth that
997 /// `sync_active_repo` restores on activation, so a settings/keymap reload
998 /// that only updated `self.config` would be reverted the next time the user
999 /// navigated away and back (Codex review #303 P3). Write the reloaded config
1000 /// through to the active repo's cached meta too.
1001 fn set_active_config(&mut self, cfg: Config) {
1002 self.config = cfg;
1003 if let Some(ws) = self.workspace.as_mut() {
1004 if let Some(meta) = ws.repos.get_mut(ws.active) {
1005 meta.config = self.config.clone();
1006 }
1007 }
1008 }
1009
1010 /// Reload every workspace repo's cached config from disk (issue #36). Called
1011 /// after a Global-layer settings edit, which changes the deep-merged config
1012 /// for *all* repos — without this, navigating to a non-active repo would
1013 /// restore the config it was loaded with at startup, reverting the edit for
1014 /// that repo until relaunch (Codex review #303 P2). The active repo's live
1015 /// `self.config` is already current (set by `set_active_config`); this
1016 /// re-syncs its cached meta too, so it stays the single source of truth.
1017 fn reload_workspace_repo_configs(&mut self) {
1018 let Some(ws) = self.workspace.as_ref() else {
1019 return;
1020 };
1021 let global = self.global_path.clone();
1022 let targets: Vec<(usize, PathBuf)> = ws
1023 .repos
1024 .iter()
1025 .enumerate()
1026 .map(|(i, m)| (i, m.workdir.clone()))
1027 .collect();
1028 for (i, workdir) in targets {
1029 if let Ok(cfg) = Config::load_layered(&workdir, global.as_deref()) {
1030 if let Some(ws) = self.workspace.as_mut() {
1031 if let Some(meta) = ws.repos.get_mut(i) {
1032 meta.config = cfg;
1033 }
1034 }
1035 }
1036 }
1037 }
1038
1039 /// Per-row mask of whether each `worktrees` row belongs to the currently
1040 /// active repo. `None` in single-repo mode (every row qualifies). Issue/PR
1041 /// numbers are only unique *within* a repo, so the number-keyed GitHub state
1042 /// stamping must be scoped to the active repo's rows in workspace mode —
1043 /// otherwise a fetch for repo A's `#1` would stamp (and persist to the wrong
1044 /// repo) every other repo's `#1` row (Codex review #303 P2).
1045 fn active_repo_row_mask(&self) -> Option<Vec<bool>> {
1046 let ws = self.workspace.as_ref()?;
1047 Some(ws.row_repo.iter().map(|&r| r == ws.active).collect())
1048 }
1049
1050 /// Re-list every repo in the workspace and rebuild the merged table +
1051 /// row→repo map (issue #36). The single-repo async refresh would clobber the
1052 /// merged list with one repo's worktrees, so workspace refresh runs
1053 /// synchronously across all repos instead. Repos are fixed for the session
1054 /// (a new repo under the root needs a relaunch, matching the config "resolved
1055 /// once" contract), so this re-lists the stored metas rather than re-walking
1056 /// the root.
1057 fn refresh_workspace(&mut self) {
1058 let targets = self.workspace_refresh_targets();
1059 let rows = Self::list_workspace(&targets);
1060 self.apply_workspace_worktrees(rows);
1061 }
1062
1063 /// The `(repo_index, workdir)` targets a workspace re-list walks — every
1064 /// repo's stored `workdir` (repos are fixed for the session). Owned `Send`
1065 /// data, so the async worker ([`Self::spawn_refresh_workspace`], issue #343)
1066 /// can move it across the thread boundary; the synchronous path uses it too.
1067 fn workspace_refresh_targets(&self) -> Vec<(usize, PathBuf)> {
1068 self
1069 .workspace
1070 .as_ref()
1071 .map(|ws| {
1072 ws.repos
1073 .iter()
1074 .enumerate()
1075 .map(|(i, m)| (i, m.workdir.clone()))
1076 .collect()
1077 })
1078 .unwrap_or_default()
1079 }
1080
1081 /// Open + list every workspace target into merged `(worktree, repo_index)`
1082 /// rows (issue #343 / #36). A static fn taking owned targets so it runs
1083 /// unchanged on the async worker thread or the synchronous path. Per-repo
1084 /// open / list errors are swallowed — a broken repo drops its rows, the rest
1085 /// still list — matching the pre-#343 synchronous behaviour.
1086 fn list_workspace(targets: &[(usize, PathBuf)]) -> Vec<(WorktreeInfo, usize)> {
1087 let mut rows = Vec::new();
1088 for (idx, workdir) in targets {
1089 if let Ok(repo) = Repository::open(workdir) {
1090 if let Ok(trees) = worktree::list(&repo) {
1091 for t in trees {
1092 rows.push((t, *idx));
1093 }
1094 }
1095 }
1096 }
1097 rows
1098 }
1099
1100 /// Apply merged workspace rows: rebuild the row→repo map, swap in the merged
1101 /// worktree list, and re-align the active repo (issue #343 / #36). Shared by
1102 /// the synchronous [`Self::refresh_workspace`] and the async
1103 /// `RefreshWorkspace` drain so the two can never drift.
1104 fn apply_workspace_worktrees(&mut self, rows: Vec<(WorktreeInfo, usize)>) {
1105 let mut worktrees = Vec::with_capacity(rows.len());
1106 let mut row_repo = Vec::with_capacity(rows.len());
1107 for (t, idx) in rows {
1108 worktrees.push(t);
1109 row_repo.push(idx);
1110 }
1111 if let Some(ws) = self.workspace.as_mut() {
1112 ws.row_repo = row_repo;
1113 }
1114 self.apply_refreshed_worktrees(worktrees);
1115 // The selection may now land on a different repo's row — re-align the
1116 // active repo. `sync_active_repo` only refreshes the link when the repo
1117 // actually changes, so re-resolve the selected row's link/slug here too
1118 // (the bulk prefetch is a no-op in workspace mode).
1119 self.sync_active_repo();
1120 self.refresh_link();
1121 }
1122
1123 /// Builder-style setter for `trust_mode`. The TUI entrypoint
1124 /// (`tui::run`) calls this after construction to thread through
1125 /// the CLI flags / env resolution; tests can use it directly to
1126 /// exercise each variant of the gate.
1127 pub fn with_trust_mode(mut self, mode: crate::trust::TrustMode) -> Self {
1128 self.trust_mode = mode;
1129 self
1130 }
1131
1132 /// Silent TOFU gate for the TUI's bootstrap call sites
1133 /// (`submit_create`, `bootstrap_selected`). Returns:
1134 ///
1135 /// * `Ok(None)` — caller is cleared to invoke `bootstrap::run`.
1136 /// * `Ok(Some(msg))` — caller MUST NOT run bootstrap; show `msg`
1137 /// to the user (e.g. assign to `self.status`). Untrusted
1138 /// configs and `TrustMode::Deny` both land here — the TUI
1139 /// alternate-screen can't host a stdin prompt today, so we
1140 /// refuse with a hint pointing the user at the CLI gate
1141 /// (`gwm bootstrap` from another terminal).
1142 /// * `Err(e)` — ledger I/O / config read error propagated verbatim.
1143 pub fn check_trust_for_bootstrap(&self) -> Result<Option<String>> {
1144 use crate::trust::{self, TrustOutcome};
1145
1146 let origin = trust::origin_key_for_repo(&self.repo, &self.workdir);
1147
1148 match trust::evaluate(&self.workdir, &origin, self.trust_mode)? {
1149 TrustOutcome::Proceed => Ok(None),
1150 TrustOutcome::Refuse { message } => Ok(Some(message)),
1151 TrustOutcome::Prompt { cfg_path, sha, .. } => {
1152 let short_sha: String = sha.chars().take(12).collect();
1153 Ok(Some(format!(
1154 ".gwm.toml at {} not in trust ledger (hash {}) — \
1155 run `gwm bootstrap` from a CLI in another terminal to approve, \
1156 or relaunch with GWM_ALLOW_BOOTSTRAP=1 / --allow-bootstrap",
1157 cfg_path.display(),
1158 short_sha
1159 )))
1160 }
1161 }
1162 }
1163
1164 /// Constructor for `gwm switch`: same App, but picker mode is on and the
1165 /// fuzzy filter bar is open from the first frame so the user can start
1166 /// narrowing right away. Everything else (worktree list, sidebar, vim
1167 /// motions) behaves identically; only the event-loop interpretation of
1168 /// Enter / n / d / b changes.
1169 pub fn new_picker_at(start: Option<&Path>) -> Result<Self> {
1170 Self::new_picker_at_layered(start, crate::config::global_config_path().as_deref())
1171 }
1172
1173 /// Injectable variant of [`Self::new_picker_at`] (issue #196): mirrors
1174 /// [`Self::new_at_layered`] so picker-mode tests never read the runner's
1175 /// real `~/.config/gwm/config.toml`. `new_picker_at` delegates with the
1176 /// real `global_config_path()`.
1177 pub fn new_picker_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
1178 let mut app = Self::new_at_layered(start, global_path)?;
1179 app.picker_mode = true;
1180 app.filter.open();
1181 app.status = "switch picker — type to filter · enter selects · esc cancels".into();
1182 Ok(app)
1183 }
1184
1185 /// Synchronous worktree list refresh. Kept for internal post-mutation
1186 /// callers (create / delete / report-close) that need the list fresh
1187 /// *before* the next render; the user-initiated `f` / `r` key path goes
1188 /// through the off-thread [`Self::request_refresh`] instead (issue
1189 /// #231). Both converge on [`Self::apply_refreshed_worktrees`] so the
1190 /// two paths can never drift on the post-list bookkeeping.
1191 pub fn refresh(&mut self) -> Result<()> {
1192 // A synchronous re-list (create / delete / report-close) produces
1193 // authoritative fresh state, so any older async refresh still in flight
1194 // is by definition stale — bump its generation so `drain_task_results`
1195 // drops the late result instead of clobbering this post-mutation list
1196 // with a pre-mutation snapshot (issue #231, the #138 race class). A
1197 // harmless counter bump when no task is running. Lives here and not in
1198 // `apply_refreshed_worktrees` so the async drain, which shares that
1199 // tail, does not re-invalidate the run it just applied.
1200 self.tasks.invalidate(TaskKind::RefreshWorktrees);
1201 // Same for an in-flight async workspace re-list (issue #343): this
1202 // synchronous path produces authoritative post-mutation state, so drop the
1203 // stale run's generation. (The in-flight *sidebar* rebuild is dropped by
1204 // `apply_refreshed_worktrees`, the tail every refresh path shares.)
1205 self.tasks.invalidate(TaskKind::RefreshWorkspace);
1206 if self.is_workspace() {
1207 // Workspace mode re-lists every repo, not just the active one (#36).
1208 self.refresh_workspace();
1209 return Ok(());
1210 }
1211 let worktrees = worktree::list(&self.repo)?;
1212 self.apply_refreshed_worktrees(worktrees);
1213 Ok(())
1214 }
1215
1216 /// Swap in a freshly-listed worktree vec and run the bookkeeping every
1217 /// refresh path shares: drop the cached fuzzy-match indices (they point
1218 /// at the previous vec — a length change auto-invalidates, but a
1219 /// same-length list with different contents would not, so the explicit
1220 /// flush is the safe play), re-clamp the selection (which re-resolves
1221 /// the link cache), refresh every Issue/PR status linked by the listed
1222 /// rows, invalidate the sidebar preview, and report the count. Called by
1223 /// the synchronous [`Self::refresh`] and by the off-thread drain in
1224 /// [`Self::drain_task_results`].
1225 fn apply_refreshed_worktrees(&mut self, mut worktrees: Vec<WorktreeInfo>) {
1226 let old_keys: std::collections::BTreeSet<(PathBuf, Option<String>)> = self
1227 .worktrees
1228 .iter()
1229 .map(|w| (w.path.clone(), w.branch.clone()))
1230 .collect();
1231 // The carry-over preserves this session's in-memory fetched issue/PR state
1232 // across a re-list, keyed by number. In workspace mode that key collides
1233 // across repos (two repos can both own `#1`), so skip it: the freshly
1234 // listed rows already carry each repo's own *persisted* state from
1235 // `read_link`, which is per-repo-correct (Codex review #303 P2).
1236 if !self.is_workspace() {
1237 let issue_states: HashMap<u64, IssueState> = self
1238 .worktrees
1239 .iter()
1240 .filter_map(|w| Some((w.link.issue?, w.issue_state?)))
1241 .collect();
1242 let pr_states = self
1243 .worktrees
1244 .iter()
1245 .filter_map(|w| Some((w.link.pr?, w.pr_state?)))
1246 .collect::<HashMap<_, _>>();
1247
1248 for w in &mut worktrees {
1249 if let Some(issue) = w.link.issue {
1250 if let Some(state) = issue_states.get(&issue).copied() {
1251 w.issue_state = Some(state);
1252 }
1253 }
1254 if let Some(pr) = w.link.pr {
1255 if let Some(state) = pr_states.get(&pr).copied() {
1256 w.pr_state = Some(state);
1257 }
1258 }
1259 }
1260 }
1261
1262 self.worktrees = worktrees;
1263 self.filter.invalidate();
1264 self.clamp_selection_to_filter();
1265 let spawned = self.refresh_linked_github_statuses_for_worktrees();
1266 self.invalidate_sidebar_cache();
1267 // The re-list re-read git state, so any in-flight sidebar rebuild is now
1268 // reading *pre-refresh* data — bump its generation so a late result is
1269 // dropped by the drain instead of stored under the current key and rendered
1270 // as fresh until the next navigation (issue #343). Lives here, in the tail
1271 // every refresh path shares, so the OFF-thread drains (`RefreshWorktrees` /
1272 // `RefreshWorkspace`) get it too, not just the synchronous `refresh`.
1273 self.tasks.invalidate(TaskKind::Sidebar);
1274 // Agent staleness is keyed to the SET of (path, branch) pairs, not to
1275 // the refresh itself (Codex review rounds P + Q): invalidating
1276 // unconditionally freed the in-flight slot while its scan thread kept
1277 // running, so an auto-refresh faster than the scan piled up concurrent
1278 // scans whose results were each dropped as stale — no snapshot ever
1279 // landed. The branch is part of the key because pins live in BRANCH
1280 // config: a same-path checkout that switched branch must drop the old
1281 // branch's pins instead of showing them for up to 30 s. A same-keys
1282 // refresh keeps the in-flight run (the 30 s TTL owns freshness).
1283 let new_keys: std::collections::BTreeSet<(PathBuf, Option<String>)> = self
1284 .worktrees
1285 .iter()
1286 .map(|w| (w.path.clone(), w.branch.clone()))
1287 .collect();
1288 if old_keys != new_keys {
1289 self.tasks.invalidate(TaskKind::AgentSessions);
1290 self.agent_snapshot_at = None;
1291 }
1292 self.status = if spawned > 0 {
1293 format!(
1294 "refreshed — {} worktree(s); fetching GitHub status…",
1295 self.worktrees.len()
1296 )
1297 } else {
1298 format!("refreshed — {} worktree(s)", self.worktrees.len())
1299 };
1300 }
1301
1302 /// Off-thread worktree list refresh for the `f` / `r` key (issue #231):
1303 /// spawn a worker that re-lists the worktrees and posts the result back
1304 /// to the event loop, so a large repo / slow filesystem no longer
1305 /// freezes the TUI. Coalesces onto an in-flight run (a second press
1306 /// while loading is a no-op) and seeds the loader label + spinner. The
1307 /// result is applied by [`Self::drain_task_results`].
1308 pub fn request_refresh(&mut self) {
1309 if self.is_workspace() {
1310 // Workspace mode re-lists every repo off-thread on its own slot (issue
1311 // #343): the single-repo worker can't be reused (it would clobber the
1312 // merged list with one repo's worktrees, #36), so route through
1313 // `RefreshWorkspace` instead of the pre-#343 synchronous `refresh()`.
1314 let Some(generation) = self.tasks.request(TaskKind::RefreshWorkspace) else {
1315 return;
1316 };
1317 self.spinner.reset();
1318 self.status = TaskKind::RefreshWorkspace.loading_label().into();
1319 self.spawn_refresh_workspace(generation);
1320 return;
1321 }
1322 let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1323 // A refresh is already in flight — coalesce onto it.
1324 return;
1325 };
1326 // Start the loader from a deterministic frame and surface the label.
1327 self.spinner.reset();
1328 self.status = TaskKind::RefreshWorktrees.loading_label().into();
1329 self.spawn_refresh(generation);
1330 }
1331
1332 /// Periodic worktree-list refresh for the TUI event loop. Returns `true`
1333 /// only when a new async refresh task was actually started. `0` disables
1334 /// the feature, and an in-flight refresh coalesces so the renderer is never
1335 /// blocked by repeated relist attempts.
1336 pub fn maybe_auto_refresh(&mut self, now: Instant) -> bool {
1337 let secs = self.config.tui.auto_refresh_secs;
1338 if secs == 0 {
1339 return false;
1340 }
1341 if now.saturating_duration_since(self.last_auto_refresh_at) < Duration::from_secs(secs) {
1342 return false;
1343 }
1344 self.last_auto_refresh_at = now;
1345 if self.is_workspace() {
1346 // Off-thread merged refresh in workspace mode (issue #343 / #36): the
1347 // per-repo `Repository::open` + `worktree::list` loop no longer freezes
1348 // the event loop on a many-repo workspace. Coalesces onto an in-flight
1349 // run so a slow relist never stacks.
1350 let Some(generation) = self.tasks.request(TaskKind::RefreshWorkspace) else {
1351 return false;
1352 };
1353 self.spinner.reset();
1354 self.status = "auto-refreshing worktrees…".into();
1355 self.spawn_refresh_workspace(generation);
1356 return true;
1357 }
1358 let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1359 return false;
1360 };
1361 self.spinner.reset();
1362 self.status = "auto-refreshing worktrees…".into();
1363 self.spawn_refresh(generation);
1364 true
1365 }
1366
1367 /// Spawn one background worktree-list worker tagged with `generation`
1368 /// (issue #231). A thin shell, mirroring [`Self::spawn_github_fetch`]:
1369 /// it owns only the off-thread dispatch + send, no state logic (the
1370 /// coalescing / late-drop contract lives in [`TaskRunner`], tested in
1371 /// `tui_state_async_task_tests.rs`). `git2::Repository` is not `Send`,
1372 /// so the worker opens its *own* repo from the owned `workdir` path
1373 /// rather than borrowing `self.repo` — the same "only owned `Send` data
1374 /// crosses the boundary" discipline as the GitHub worker. A `send`
1375 /// failure (the `App`/receiver dropped) is ignored.
1376 fn spawn_refresh(&self, generation: u64) {
1377 let tx = self.task_tx.clone();
1378 let workdir = self.workdir.clone();
1379 std::thread::spawn(move || {
1380 let result = worktree::discover_repo(Some(&workdir))
1381 .and_then(|repo| worktree::list(&repo))
1382 .map_err(|e| e.to_string());
1383 let _ = tx.send(TaskMsg::RefreshWorktrees(generation, result));
1384 });
1385 }
1386
1387 /// Spawn one background workspace re-list worker tagged with `generation`
1388 /// (issue #343 / #36). Mirrors [`Self::spawn_refresh`] for workspace mode:
1389 /// the owned `(repo_index, workdir)` targets are the only data crossing the
1390 /// boundary, and [`Self::list_workspace`] opens each repo + lists off-thread.
1391 /// The drain applies the merged rows via [`Self::apply_workspace_worktrees`].
1392 fn spawn_refresh_workspace(&self, generation: u64) {
1393 let tx = self.task_tx.clone();
1394 let targets = self.workspace_refresh_targets();
1395 std::thread::spawn(move || {
1396 let rows = Self::list_workspace(&targets);
1397 let _ = tx.send(TaskMsg::RefreshWorkspace(generation, rows));
1398 });
1399 }
1400
1401 /// Keep the details sidebar's git-backed preview off the render path (issue
1402 /// #343). Called once per event-loop tick (after `sync_active_repo`, so the
1403 /// active repo's `doctor.trunks` are correct in workspace mode). When the
1404 /// cached payload was NOT built for the current selection + mode — a cold
1405 /// cache, a navigation (`on_navigation` nulled it), a mode toggle, or a
1406 /// post-mutation `invalidate` — this spawns one worker to rebuild it, keyed
1407 /// to the *currently selected* worktree.
1408 ///
1409 /// Pure navigation deliberately does NOT [`TaskRunner::invalidate`] the
1410 /// `Sidebar` slot, so a held `j` coalesces onto the single in-flight worker
1411 /// instead of spawning a thread per row: the render shows the placeholder
1412 /// while scrolling and the settled selection is fetched once the burst ends.
1413 /// That coalescing IS the debounce — no timer needed. A worker whose
1414 /// selection has since moved stores a payload the render key-check ignores;
1415 /// the next tick requests the settled one.
1416 pub fn maybe_refresh_sidebar(&mut self) {
1417 // A hidden sidebar is not drawn (`draw_body` skips `draw_sidebar`), so
1418 // rebuilding its preview would run git work for an invisible panel —
1419 // restoring the pre-#343 behaviour where hiding the sidebar (`v`) did no
1420 // preview work at all. Opening it (`v`) re-arms the fetch on the next tick.
1421 if !self.sidebar.open {
1422 return;
1423 }
1424 let Some(w) = self.selected().cloned() else {
1425 return;
1426 };
1427 let mode = self.sidebar.mode;
1428 // Already authoritative for this selection + mode → nothing to rebuild.
1429 if matches!(&self.sidebar.cache, Some(((p, m), _)) if *p == w.path && *m == mode) {
1430 return;
1431 }
1432 let Some(generation) = self.tasks.request(TaskKind::Sidebar) else {
1433 // A rebuild is already in flight — coalesce onto it (the debounce).
1434 return;
1435 };
1436 let trunks = self.config.doctor.trunks.clone();
1437 let theme = self.theme;
1438 self.spawn_sidebar(generation, w, mode, trunks, theme);
1439 }
1440
1441 /// Spawn one background sidebar-rebuild worker tagged with `generation`
1442 /// (issue #343). Mirrors [`Self::spawn_refresh`]: only owned `Send` data
1443 /// crosses the boundary (the [`WorktreeInfo`], mode, the active repo's
1444 /// `trunks`, and the `Copy` [`Theme`]), and the worker runs
1445 /// [`crate::tui::ui::build_sidebar_payload`], which fires every sidebar git
1446 /// subprocess off-thread. A `send` failure (the `App`/receiver dropped) is
1447 /// ignored.
1448 fn spawn_sidebar(
1449 &self,
1450 generation: u64,
1451 w: WorktreeInfo,
1452 mode: crate::tui::state::sidebar::SidebarMode,
1453 trunks: Vec<String>,
1454 theme: crate::tui::theme::Theme,
1455 ) {
1456 let tx = self.task_tx.clone();
1457 std::thread::spawn(move || {
1458 let path = w.path.clone();
1459 let sections = crate::tui::ui::build_sidebar_payload(&w, mode, &trunks, &theme);
1460 let _ = tx.send(TaskMsg::Sidebar(generation, path, mode, sections));
1461 });
1462 }
1463
1464 /// Keep agent-session detection off the render path (issue #408). Called
1465 /// once per event-loop tick, next to [`Self::maybe_refresh_sidebar`]: a
1466 /// cold snapshot (startup, or nulled by a refresh) or one older than the
1467 /// re-detection period spawns one worker; a tick that finds a run already
1468 /// in flight coalesces onto it — same no-timer debounce as the sidebar.
1469 pub fn maybe_refresh_agent_sessions(&mut self) {
1470 const REDETECT_PERIOD: std::time::Duration = std::time::Duration::from_secs(30);
1471 let fresh = self.agent_snapshot_at.is_some_and(|at| at.elapsed() < REDETECT_PERIOD);
1472 if fresh {
1473 return;
1474 }
1475 let Some(generation) = self.tasks.request(TaskKind::AgentSessions) else {
1476 return; // detection already in flight — coalesce
1477 };
1478 let rows: Vec<(String, PathBuf)> = self
1479 .worktrees
1480 .iter()
1481 .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1482 .collect();
1483 // Pin reads are branch-config I/O — in workspace mode one repo open
1484 // per row. That happens in the WORKER, not here (Codex review round P:
1485 // the event loop must not touch the disk on the periodic path); the
1486 // main thread only assembles (path, branch, owning repo dir) triples,
1487 // resolved via `row_repo` so each row reads its own repo (round I).
1488 let pin_sources = self.agent_pin_sources();
1489 let tx = self.task_tx.clone();
1490 std::thread::spawn(move || {
1491 let pins_map = read_pins_from_sources(&pin_sources);
1492 let pins: Vec<(String, String)> = pins_map
1493 .iter()
1494 .flat_map(|(path, sids)| sids.iter().map(move |sid| (path.clone(), sid.clone())))
1495 .collect();
1496 // Summary-only: the matched-per-worktree scan, NOT the full
1497 // foreign-dir sweep — that one is linear in the whole artefact
1498 // history and runs only when the attach prompt opens (round Q).
1499 let map = match crate::agent_sessions::agents_home() {
1500 Some(home) => crate::agent_sessions::detect_all(&home, &rows, &pins, std::time::SystemTime::now()),
1501 None => Default::default(), // no home: detection degrades to empty (FR-009)
1502 };
1503 let _ = tx.send(TaskMsg::AgentSessions(generation, map, None, pins_map));
1504 });
1505 }
1506
1507 /// Spawn the FULL detection — foreign-dir sweep included — to feed the
1508 /// attach prompt's candidate pool. Prompt-open only (round Q): the sweep
1509 /// costs a bounded read of every recent foreign artefact and must not
1510 /// ride the 30 s periodic tick. Drops a coalescing in-flight periodic
1511 /// run: this result supersedes it anyway.
1512 fn refresh_agent_pool(&mut self) {
1513 // A run in flight keeps walking the store even after `invalidate`
1514 // frees its slot — starting the full scan NOW would double the I/O.
1515 // Queue it instead; `apply_agent_snapshot` chains it on landing
1516 // (round R).
1517 if self.tasks.is_loading(TaskKind::AgentSessions) {
1518 self.agent_pool_wanted = true;
1519 return;
1520 }
1521 let Some(generation) = self.tasks.request(TaskKind::AgentSessions) else {
1522 return;
1523 };
1524 let rows: Vec<(String, PathBuf)> = self
1525 .worktrees
1526 .iter()
1527 .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1528 .collect();
1529 let pin_sources = self.agent_pin_sources();
1530 let tx = self.task_tx.clone();
1531 std::thread::spawn(move || {
1532 let pins_map = read_pins_from_sources(&pin_sources);
1533 let pins: Vec<(String, String)> = pins_map
1534 .iter()
1535 .flat_map(|(path, sids)| sids.iter().map(move |sid| (path.clone(), sid.clone())))
1536 .collect();
1537 let (map, all) = match crate::agent_sessions::agents_home() {
1538 Some(home) => crate::agent_sessions::detect_with_sessions(&home, &rows, &pins, std::time::SystemTime::now()),
1539 None => Default::default(), // no home: detection degrades to empty (FR-009)
1540 };
1541 let _ = tx.send(TaskMsg::AgentSessions(generation, map, Some(all), pins_map));
1542 });
1543 }
1544
1545 /// The (worktree path, branch, owning repo workdir) triples the detection
1546 /// worker reads pins from — assembled here without touching the disk. In
1547 /// workspace mode the owner comes from the `row_repo` mapping; in
1548 /// single-repo mode every row belongs to the active repo.
1549 pub fn agent_pin_sources(&self) -> Vec<(String, String, PathBuf)> {
1550 self
1551 .worktrees
1552 .iter()
1553 .enumerate()
1554 .filter_map(|(i, w)| {
1555 let branch = crate::github::pinnable_branch(w.branch.as_deref())?;
1556 let repo_dir = if let Some(ws) = &self.workspace {
1557 ws.repos.get(*ws.row_repo.get(i)?)?.workdir.clone()
1558 } else {
1559 self.workdir.clone()
1560 };
1561 Some((
1562 crate::agent_sessions::path_display_key(&w.path),
1563 branch.to_string(),
1564 repo_dir,
1565 ))
1566 })
1567 .collect()
1568 }
1569
1570 /// Store a completed detection snapshot if its generation is still
1571 /// authoritative (issue #408). Returns `true` when applied; a late result
1572 /// superseded by [`TaskRunner::invalidate`] is dropped and the previous
1573 /// snapshot survives. Extracted from the drain so the state contract is
1574 /// pinned ratatui-free by `tests/tui_app_tests.rs`.
1575 pub fn apply_agent_snapshot(
1576 &mut self,
1577 generation: u64,
1578 map: std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
1579 all: Option<Vec<crate::agent_sessions::AgentSession>>,
1580 pins: std::collections::BTreeMap<String, Vec<String>>,
1581 ) -> bool {
1582 if !self.tasks.complete(TaskKind::AgentSessions, generation) {
1583 return false;
1584 }
1585 self.agent_snapshot = Some(map);
1586 self.agent_snapshot_at = Some(std::time::Instant::now());
1587 // `None` = summary-only run: the previous pool survives so an open
1588 // attach prompt keeps its candidates (round Q).
1589 let landed_pool = all.is_some();
1590 if let Some(all) = all {
1591 self.agent_all_sessions = all;
1592 }
1593 // A pool scan queued while this run was in flight chains now that the
1594 // slot is free (round R); a landing that already carried the pool
1595 // satisfies the request outright, and a prompt closed in the meantime
1596 // abandons it — nobody would consume the sweep (round T).
1597 if self.agent_pool_wanted {
1598 self.agent_pool_wanted = false;
1599 let prompt_open = self.view == View::DetailOverlay
1600 && self.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::Input;
1601 if !landed_pool && prompt_open {
1602 self.refresh_agent_pool();
1603 }
1604 }
1605 // The worker read the pins from each row's owning repo (round P);
1606 // store them before the overlay rebuild below reads the map — UNLESS
1607 // a pin changed while this run was in flight: its map predates the
1608 // change, so the fresh event-path read stands and a re-detection is
1609 // chained by clearing the snapshot timestamp (round U).
1610 if self.agent_redetect_wanted {
1611 self.agent_redetect_wanted = false;
1612 self.agent_snapshot_at = None;
1613 } else {
1614 self.agent_pins = pins;
1615 }
1616 // A landing detection refreshes the open overlay in place (user
1617 // feedback: attach/detach used to leave stale rows until reopened).
1618 // Gated on the AGENTS consumer (Codex review #455): a stale target
1619 // left by an interrupted agents overlay must never rebuild the CI
1620 // checks rows into session rows under an unchanged CiChecks kind.
1621 if self.view == View::DetailOverlay
1622 && self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::Agents
1623 {
1624 if let Some((path, _)) = self.detail_overlay_target.clone() {
1625 if let Some(w) = self.worktrees.iter().find(|w| w.path == path).cloned() {
1626 let rows = self.build_agent_rows(&w);
1627 self.detail_overlay.set_rows(rows);
1628 }
1629 }
1630 }
1631 true
1632 }
1633
1634 /// The agent sessions matched to `w`, if a snapshot has landed and holds
1635 /// any (issue #408). Pure lookup — the render path's only entry point.
1636 pub fn agents_for(&self, w: &crate::worktree::WorktreeInfo) -> Option<&crate::agent_sessions::WorktreeAgents> {
1637 self
1638 .agent_snapshot
1639 .as_ref()
1640 .and_then(|map| map.get(&crate::agent_sessions::path_display_key(&w.path)))
1641 }
1642
1643 /// Any session in the landed snapshot at all? Drives the table's AGENT
1644 /// column visibility (Codex review round D): with no agent tooling the
1645 /// table must stay visually pre-#408, not carry an empty 8-cell column
1646 /// squeezing NAME/BRANCH/PATH on narrow terminals. Keyed to the whole
1647 /// snapshot — not the visible rows — so filtering/scrolling never makes
1648 /// the column flicker.
1649 pub fn any_agent_sessions(&self) -> bool {
1650 self
1651 .agent_snapshot
1652 .as_ref()
1653 .is_some_and(|map| map.values().any(|a| !a.sessions.is_empty()))
1654 }
1655
1656 /// Off-thread `gwm sync` of the selected worktree for the `S` key (issue
1657 /// #258): fetch + rebase its branch onto upstream on a worker thread, so a
1658 /// slow network fetch / rebase does not freeze the event loop. Coalesces
1659 /// onto an in-flight sync (a second `S` while one runs is a no-op, so two
1660 /// rebases never race). The outcome is applied by
1661 /// [`Self::drain_task_results`], which reports it and refreshes the list so
1662 /// the new ahead/behind state shows. Default strategy is rebase (the repo
1663 /// convention); a `--merge` variant is deferred (see #258).
1664 pub fn request_sync(&mut self) {
1665 let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
1666 self.status = "no worktree selected to sync".into();
1667 return;
1668 };
1669 if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Sync) {
1670 self.status = self.busy_mutation_status("syncing");
1671 return;
1672 }
1673 let Some(generation) = self.tasks.request(TaskKind::Sync) else {
1674 // A sync is already in flight — coalesce onto it.
1675 return;
1676 };
1677 self.spinner.reset();
1678 self.status = TaskKind::Sync.loading_label().into();
1679 self.spawn_sync(generation, path, name);
1680 }
1681
1682 /// Spawn one background `gwm sync` worker tagged with `generation` (issue
1683 /// #258). Mirrors [`Self::spawn_refresh`]: it moves only owned `Send` data
1684 /// (the worktree `path` + `name`) across the boundary and runs the existing
1685 /// [`crate::sync::sync`] logic, which discovers its own repo from `path` and
1686 /// shells out to `git` for fetch/rebase. A `send` failure (the `App`/receiver
1687 /// dropped) is ignored.
1688 fn spawn_sync(&self, generation: u64, path: PathBuf, name: String) {
1689 let tx = self.task_tx.clone();
1690 std::thread::spawn(move || {
1691 let result = crate::sync::sync(&path, crate::sync::SyncStrategy::Rebase).map_err(|e| e.to_string());
1692 let _ = tx.send(TaskMsg::Sync(generation, name, result));
1693 });
1694 }
1695
1696 /// Apply every background task result that has arrived since the last
1697 /// call (issue #231; GitHub fetch results folded in by #255), draining
1698 /// the channel without blocking. Each result goes through
1699 /// [`TaskRunner::complete`], so a result whose per-key generation was
1700 /// bumped mid-flight is dropped (#138 guard, generalised) — this is what
1701 /// makes a stale GitHub worker lose to a fresh one in the retry race.
1702 ///
1703 /// A failed refresh surfaces on the status bar and leaves the list
1704 /// intact — what used to be a fatal `refresh()?` that tore down the
1705 /// event loop is now a graceful message. A GitHub result is stamped into
1706 /// the per-key cache via `complete_{issue,pr}` (pure writes now that the
1707 /// drop decision lives on the spine); once nothing GitHub-side is left
1708 /// loading, the aggregate outcome is re-reported on the status bar — the
1709 /// same end state `drain_github_results` produced pre-#255. Returns `true`
1710 /// if at least one result was applied, so the loop can force a redraw.
1711 pub fn drain_task_results(&mut self) -> bool {
1712 let mut applied = false;
1713 let mut github_applied = false;
1714 let mut refresh_applied = false;
1715 while let Ok(msg) = self.task_rx.try_recv() {
1716 match msg {
1717 TaskMsg::CreateWorktree(generation, result) => {
1718 if !self.tasks.complete(TaskKind::CreateWorktree, generation) {
1719 // Late result — a newer run (or an invalidate) superseded it.
1720 continue;
1721 }
1722 match result {
1723 Ok(result) => {
1724 self.create_failure = None;
1725 self.report = Some(result.report);
1726 self.view = View::Report;
1727 let refresh_result = self.refresh();
1728 self.status = match refresh_result {
1729 Ok(()) => format!("created {} @ {}", result.branch, result.created.display()),
1730 Err(e) => format!(
1731 "created {} @ {}; refresh failed: {}",
1732 result.branch,
1733 result.created.display(),
1734 e
1735 ),
1736 };
1737 }
1738 Err(e) => {
1739 self.create_failure = Some(e.clone());
1740 self.view = View::Create;
1741 self.status = format!("create failed: {}", e);
1742 }
1743 }
1744 applied = true;
1745 // Create owns the status line this tick.
1746 refresh_applied = true;
1747 }
1748 TaskMsg::RefreshWorktrees(generation, result) => {
1749 if !self.tasks.complete(TaskKind::RefreshWorktrees, generation) {
1750 // Late result — a newer run (or an invalidate) superseded it.
1751 continue;
1752 }
1753 match result {
1754 Ok(worktrees) => self.apply_refreshed_worktrees(worktrees),
1755 Err(e) => self.status = format!("refresh failed: {}", e),
1756 }
1757 applied = true;
1758 refresh_applied = true;
1759 }
1760 TaskMsg::RefreshWorkspace(generation, rows) => {
1761 if !self.tasks.complete(TaskKind::RefreshWorkspace, generation) {
1762 // Late result — a newer run (or a synchronous `refresh`) superseded it.
1763 continue;
1764 }
1765 self.apply_workspace_worktrees(rows);
1766 applied = true;
1767 refresh_applied = true;
1768 }
1769 TaskMsg::GithubIssue(generation, number, result) => {
1770 // Generation guard: a stale worker whose slot was bumped by an
1771 // intervening invalidate/re-request is dropped here, before it can
1772 // stamp the cache (the Codex-flagged race, fixed by the spine).
1773 if !self.tasks.complete(TaskKind::GithubIssue(number), generation) {
1774 continue;
1775 }
1776 if let Ok(status) = &result {
1777 self.persist_loaded_issue_title(status);
1778 }
1779 self.github.complete_issue(number, result);
1780 applied = true;
1781 github_applied = true;
1782 }
1783 TaskMsg::GithubPr(generation, number, result) => {
1784 if !self.tasks.complete(TaskKind::GithubPr(number), generation) {
1785 continue;
1786 }
1787 if let Ok(status) = &result {
1788 self.persist_loaded_pr_title(status);
1789 if self.refresh_ci_overlay_on_pr_landing(status) {
1790 // The overlay-close message owns the status line this tick.
1791 refresh_applied = true;
1792 }
1793 }
1794 self.github.complete_pr(number, result);
1795 applied = true;
1796 github_applied = true;
1797 }
1798 TaskMsg::Sync(generation, name, result) => {
1799 if !self.tasks.complete(TaskKind::Sync, generation) {
1800 // Late result — a newer sync (or an invalidate) superseded it.
1801 continue;
1802 }
1803 match result {
1804 Ok(report) => {
1805 // Re-list so the new ahead/behind state shows (this also bumps
1806 // the refresh generation — the #138 race guard). The worker
1807 // mutated refs in a subprocess, but a libgit2 read re-reads them
1808 // from disk, so the synchronous `self.refresh()` (`self.repo`)
1809 // sees the rebased state — verified end-to-end by the
1810 // ahead/behind assertion in `sync_tests::
1811 // tui_sync_action_relists_to_the_rebased_state_from_disk`.
1812 // `refresh` sets its own "refreshed — N" status, so overwrite it
1813 // with the sync outcome afterwards — the user pressed `S`, the
1814 // sync result is what they want to read.
1815 let _ = self.refresh();
1816 self.status = crate::cli::format_sync_report(&name, &report).trim_end().to_string();
1817 }
1818 Err(e) => self.status = format!("sync failed: {}", e),
1819 }
1820 applied = true;
1821 // The sync owns the status line this tick — keep the post-loop
1822 // GitHub report from overwriting it (same guard the refresh uses).
1823 refresh_applied = true;
1824 }
1825 TaskMsg::Bootstrap(generation, result) => {
1826 if !self.tasks.complete(TaskKind::Bootstrap, generation) {
1827 // Late result — a newer run (or an invalidate) superseded it, so
1828 // it must not flip the view to a stale report.
1829 continue;
1830 }
1831 match result {
1832 Ok(report) => {
1833 // Same outcome as the old synchronous path (issue #256): show
1834 // the report and surface whether any step failed.
1835 let any_failed = report.steps.iter().any(|s| s.status == StepStatus::Failed);
1836 self.report = Some(report);
1837 self.view = View::Report;
1838 self.status = if any_failed {
1839 "bootstrap had failures".into()
1840 } else {
1841 "bootstrap ok".into()
1842 };
1843 }
1844 Err(e) => self.status = format!("bootstrap error: {}", e),
1845 }
1846 applied = true;
1847 // The bootstrap owns the status line (and the view) this tick — keep
1848 // the post-loop GitHub report from overwriting it (same guard the
1849 // refresh / sync arms use).
1850 refresh_applied = true;
1851 }
1852 TaskMsg::DeleteWorktree(generation, name, label, result) => {
1853 if !self.tasks.complete(TaskKind::DeleteWorktree, generation) {
1854 // Late result — a newer run (or an invalidate) superseded it.
1855 continue;
1856 }
1857 match result {
1858 Ok(()) => {
1859 self.delete_failure = None;
1860 self.view = View::List;
1861 self.confirm.reset();
1862 let refresh_result = self.refresh();
1863 self.status = match refresh_result {
1864 Ok(()) => format!("removed {} ({})", name, label),
1865 Err(e) => format!("removed {} ({}); refresh failed: {}", name, label, e),
1866 };
1867 }
1868 Err(e) => {
1869 self.delete_failure = Some(e.clone());
1870 self.view = View::Confirm;
1871 self.status = format!("delete failed: {}", e);
1872 }
1873 }
1874 applied = true;
1875 // Delete owns the status line this tick.
1876 refresh_applied = true;
1877 }
1878 TaskMsg::Pull(generation, name, result) => {
1879 if !self.tasks.complete(TaskKind::Pull, generation) {
1880 continue;
1881 }
1882 // Refresh on both arms: a failed pull can still mutate the tree (a
1883 // merge/rebase conflict leaves it dirty / mid-rebase), so the table
1884 // must not keep showing the pre-pull clean state (Codex review #292).
1885 let _ = self.refresh();
1886 match result {
1887 Ok(msg) => self.status = format!("pulled {}: {}", name, msg),
1888 Err(e) => self.status = format!("pull failed: {}", e),
1889 }
1890 applied = true;
1891 refresh_applied = true;
1892 }
1893 TaskMsg::Push(generation, name, result) => {
1894 if !self.tasks.complete(TaskKind::Push, generation) {
1895 continue;
1896 }
1897 match result {
1898 Ok(msg) => {
1899 // Pushing updates the remote-tracking ref + ahead/behind, so
1900 // refresh the table before overwriting the status, mirroring
1901 // the pull/sync path (Codex review on PR #292).
1902 let _ = self.refresh();
1903 self.status = format!("pushed {}: {}", name, msg);
1904 }
1905 Err(e) => self.status = format!("push failed: {}", e),
1906 }
1907 applied = true;
1908 refresh_applied = true;
1909 }
1910 TaskMsg::EditWorktree(generation, result) => {
1911 if !self.tasks.complete(TaskKind::EditWorktree, generation) {
1912 continue;
1913 }
1914 match result {
1915 Ok(res) => {
1916 let _ = self.refresh();
1917 self.status = if res.remote_renamed {
1918 format!("renamed to {} (local + remote)", res.new_branch)
1919 } else {
1920 format!("renamed to {} (local only)", res.new_branch)
1921 };
1922 // Re-select the renamed worktree by its new path so the cursor
1923 // stays on the row the user just edited (mapped through the
1924 // filter — Codex review on PR #292).
1925 self.reselect_by_path(&res.new_path);
1926 self.edit_original_branch = None;
1927 self.edit_original_path = None;
1928 self.edit_failure = None;
1929 self.create_form.reset();
1930 self.view = View::List;
1931 }
1932 // Keep the modal open so the user can fix the form and retry, and
1933 // replace the "renaming worktree…" loading status so the bar no
1934 // longer reads as in-progress (Codex review on PR #292, P3).
1935 Err(e) => {
1936 self.status = format!("rename failed: {}", e);
1937 self.edit_failure = Some(e);
1938 }
1939 }
1940 applied = true;
1941 refresh_applied = true;
1942 }
1943 TaskMsg::Sidebar(generation, path, mode, sections) => {
1944 // Late result — the selection moved and `refresh` bumped the slot's
1945 // generation (a mutation invalidated a pre-mutation rebuild), so this
1946 // payload is stale. Drop it; the next tick requests the current one.
1947 if !self.tasks.complete(TaskKind::Sidebar, generation) {
1948 continue;
1949 }
1950 // Store keyed by the worktree + mode it was built for. If the
1951 // selection has since moved this key won't match the current one, so
1952 // the render shows the placeholder and `maybe_refresh_sidebar` fetches
1953 // the settled selection next tick — no stale worktree's git preview
1954 // is ever shown under the live header.
1955 self.sidebar.cache = Some(((path, mode), sections));
1956 applied = true;
1957 }
1958 TaskMsg::AgentSessions(generation, map, all, pins) => {
1959 // Late-drop + store live in `apply_agent_snapshot` so the state
1960 // contract is pinned ratatui-free (issue #408). Deliberately does
1961 // NOT set `applied`: agent detection reads no git state, so there
1962 // is nothing for the post-drain refresh bookkeeping to do.
1963 self.apply_agent_snapshot(generation, map, all, pins);
1964 }
1965 }
1966 }
1967 // Once nothing GitHub-side is left loading, swap the "fetching…"
1968 // placeholder for the real outcome (refreshed / partial failure /
1969 // failure) — only when a GitHub result actually applied, so a dropped
1970 // stale result never overwrites the current status (issue #217 review P2).
1971 //
1972 // Skip it when a worktree refresh also landed this tick: pre-#255 the
1973 // event loop drained the GitHub channel *before* the task channel, so a
1974 // simultaneous completion left `apply_refreshed_worktrees`' "refreshed —
1975 // N" message standing last. The `!refresh_applied` guard preserves that
1976 // ordering now that both drain in one pass.
1977 if github_applied && !refresh_applied && !self.is_github_loading() {
1978 self.report_github_refresh_status();
1979 }
1980 applied
1981 }
1982
1983 /// `true` while any background task is in flight (issue #231) — drives
1984 /// the statusbar spinner alongside [`Self::is_github_loading`].
1985 pub fn is_task_loading(&self) -> bool {
1986 self.tasks.is_any_loading()
1987 }
1988
1989 /// `true` while the create-worktree worker is in flight (issue #276).
1990 pub fn is_create_worktree_loading(&self) -> bool {
1991 self.tasks.is_loading(TaskKind::CreateWorktree)
1992 }
1993
1994 /// `true` while the delete-worktree worker is in flight (issue #257).
1995 pub fn is_delete_worktree_loading(&self) -> bool {
1996 self.tasks.is_loading(TaskKind::DeleteWorktree)
1997 }
1998
1999 /// `true` when a requested quit can safely leave the event loop now.
2000 /// Mutating spine workers keep running until their result is drained so
2001 /// `sync` / `bootstrap` / delete-worktree are not abandoned mid-operation.
2002 pub fn can_quit_now(&self) -> bool {
2003 !self.should_quit || !self.tasks.has_mutating_task_in_flight()
2004 }
2005
2006 /// Surface why a requested quit is being held. The event loop keeps
2007 /// ticking/draining while this status is visible.
2008 pub fn defer_quit_for_mutating_task(&mut self) {
2009 if let Some(label) = self.tasks.mutating_loading_label() {
2010 self.status = format!("finishing {} before quit…", label.trim_end_matches('…'));
2011 } else {
2012 self.status = "finishing task before quit…".into();
2013 }
2014 }
2015
2016 /// A clone of the task channel sender background workers report over
2017 /// (issue #231; GitHub fetch workers too since #255). Exposed so the
2018 /// async-apply path ([`Self::drain_task_results`]) can be driven
2019 /// deterministically in tests — inject a [`TaskMsg`] exactly as a worker
2020 /// would, then drain — without spawning an OS thread or a real `gh`.
2021 pub fn task_result_sender(&self) -> mpsc::Sender<TaskMsg> {
2022 self.task_tx.clone()
2023 }
2024
2025 /// Drop the cached sidebar content. Call on any change that may have altered
2026 /// what the sidebar shows: worktree list refresh, filter narrowing, etc.
2027 /// Pure delegate over [`SidebarState::invalidate`]; navigation-driven
2028 /// invalidation goes through [`Self::on_navigation`] which also resets
2029 /// the scroll offset.
2030 pub fn invalidate_sidebar_cache(&mut self) {
2031 self.sidebar.invalidate();
2032 }
2033
2034 /// Selection-change reaction: drop the sidebar's scroll back to the
2035 /// top, invalidate its cached preview, and resolve the link cache
2036 /// against the freshly selected worktree. Collapses the verbatim
2037 /// `sidebar.scroll = 0; invalidate_sidebar_cache(); refresh_link();`
2038 /// triple that was repeated across `next`, `prev`, `first`, `last`
2039 /// pre-extraction (issue #127, part of #102). The first two pieces
2040 /// live on [`SidebarState::on_navigation`]; the link refresh is
2041 /// orchestrator-shaped (it touches `self.link` / `self.link_slug` /
2042 /// `self.issue_state` / `self.pr_state` via [`Self::refresh_link`])
2043 /// so it stays here. Every navigation entry point now goes through
2044 /// this single call so the triple cannot drift back into duplicated
2045 /// literals.
2046 pub fn on_navigation(&mut self) {
2047 self.sidebar.on_navigation();
2048 self.refresh_link();
2049 }
2050
2051 pub fn next(&mut self) {
2052 // Route navigation to the sidebar when it's focused; otherwise move the list.
2053 if self.sidebar.open && self.sidebar.focused {
2054 self.sidebar_scroll_down();
2055 return;
2056 }
2057 let len = self.filtered_indices().len();
2058 if len == 0 {
2059 return;
2060 }
2061 let i = match self.list_state.selected() {
2062 Some(i) => (i + 1) % len,
2063 None => 0,
2064 };
2065 self.list_state.select(Some(i));
2066 self.on_navigation();
2067 }
2068
2069 pub fn prev(&mut self) {
2070 if self.sidebar.open && self.sidebar.focused {
2071 self.sidebar_scroll_up();
2072 return;
2073 }
2074 let len = self.filtered_indices().len();
2075 if len == 0 {
2076 return;
2077 }
2078 let i = match self.list_state.selected() {
2079 Some(0) | None => len - 1,
2080 Some(i) => i - 1,
2081 };
2082 self.list_state.select(Some(i));
2083 self.on_navigation();
2084 }
2085
2086 // ---- Vim-style motions / list jumps -------------------------------------
2087
2088 pub fn first(&mut self) {
2089 let len = self.filtered_indices().len();
2090 if len > 0 {
2091 self.list_state.select(Some(0));
2092 self.on_navigation();
2093 }
2094 }
2095
2096 pub fn last(&mut self) {
2097 let len = self.filtered_indices().len();
2098 if len > 0 {
2099 self.list_state.select(Some(len - 1));
2100 self.on_navigation();
2101 }
2102 }
2103
2104 /// Drive the two-keystroke `gg` motion. First press arms it, second jumps to top.
2105 ///
2106 /// **Compatibility shim** — kept so the existing tests in
2107 /// `tests/tui_app_tests.rs::handle_g_motion_tracks_pending_then_jumps_to_first`
2108 /// and the not-yet-migrated event-loop branch keep working
2109 /// verbatim. The implementation routes through
2110 /// [`Self::dispatch_key`] so the legacy and generic paths cannot
2111 /// drift on the chord semantics.
2112 pub fn handle_g(&mut self) {
2113 let ev = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty());
2114 if let Some(Action::Top) = self.dispatch_key(ev) {
2115 self.first();
2116 }
2117 }
2118
2119 /// Drop any in-flight chord prefix. Called by the legacy event-loop
2120 /// branch on any non-`g` keystroke (pre-#87 contract). New call
2121 /// sites that route through [`Self::dispatch_key`] don't need it —
2122 /// `dispatch_key` already clears the buffer on `NoMatch`.
2123 pub fn cancel_pending_motion(&mut self) {
2124 self.pending_chord.clear();
2125 self.sync_legacy_pending_flag();
2126 }
2127
2128 /// True iff no chord prefix is currently armed. Surface for tests
2129 /// and for the help / status-bar code that may want to show a
2130 /// "waiting for next key" hint once chord support is wired up.
2131 pub fn pending_chord_is_empty(&self) -> bool {
2132 self.pending_chord.is_empty()
2133 }
2134
2135 /// Drive a raw `KeyEvent` through the keymap.
2136 ///
2137 /// Returns `Some(action)` when the buffer (current pending chord +
2138 /// this stroke) matches a binding — caller fires the action and the
2139 /// buffer is left cleared. Returns `None` when the buffer is now a
2140 /// strict prefix of a longer binding (caller waits for the next
2141 /// keystroke) **or** when the stroke matches nothing at all
2142 /// (caller drops it).
2143 ///
2144 /// Vim-style fallback: if appending the stroke to a non-empty
2145 /// buffer produces a `NoMatch`, the buffer is cleared and the
2146 /// stroke is re-tried on its own. This mirrors the historical
2147 /// `g j` behaviour where the stray `g` is forgotten and `j`
2148 /// still navigates down.
2149 pub fn dispatch_key(&mut self, key: KeyEvent) -> Option<Action> {
2150 let stroke = KeyStroke::from_event(&key);
2151 let mut tentative = self.pending_chord.clone();
2152 tentative.push(stroke.clone());
2153
2154 let outcome = match self.keymap.lookup(&tentative) {
2155 ChordResolution::Matched(action) => {
2156 self.pending_chord.clear();
2157 Some(action)
2158 }
2159 ChordResolution::PendingPrefix => {
2160 self.pending_chord = tentative;
2161 None
2162 }
2163 ChordResolution::NoMatch if self.pending_chord.is_empty() => {
2164 // Single stroke, no binding. Nothing to retry.
2165 None
2166 }
2167 ChordResolution::NoMatch => {
2168 // Mismatched continuation. Drop the in-flight prefix and
2169 // retry the new stroke on its own so the user's keypress
2170 // is not silently swallowed when it has a single-key
2171 // binding (the `g j` case).
2172 self.pending_chord.clear();
2173 let single = vec![stroke];
2174 match self.keymap.lookup(&single) {
2175 ChordResolution::Matched(action) => Some(action),
2176 ChordResolution::PendingPrefix => {
2177 self.pending_chord = single;
2178 None
2179 }
2180 ChordResolution::NoMatch => None,
2181 }
2182 }
2183 };
2184
2185 self.sync_legacy_pending_flag();
2186 outcome
2187 }
2188
2189 pub fn key_matches_action(&self, key: KeyEvent, action: Action) -> bool {
2190 matches!(
2191 self.keymap.lookup(&[KeyStroke::from_event(&key)]),
2192 ChordResolution::Matched(found) if found == action
2193 )
2194 }
2195
2196 /// Resolve a keystroke against the contextual modal keymap (issue #219).
2197 /// Returns the [`ModalAction`] bound to `key` in `ctx`, or `None` when
2198 /// nothing in that context binds it — the modal routing then applies its
2199 /// text-input / default fallback (digits, free-text, sub-state guards).
2200 pub fn resolve_modal(&self, ctx: KeyContext, key: KeyEvent) -> Option<ModalAction> {
2201 self.modal_keymap.resolve(ctx, &KeyStroke::from_event(&key))
2202 }
2203
2204 /// Mirror the new `pending_chord` buffer into the legacy
2205 /// `pending_g` boolean so pre-#87 tests that read it as a field
2206 /// stay green. Removed when those tests migrate to
2207 /// [`Self::pending_chord_is_empty`].
2208 fn sync_legacy_pending_flag(&mut self) {
2209 let g = KeyStroke::new(KeyCode::Char('g'), KeyModifiers::empty());
2210 self.pending_g = self.pending_chord.len() == 1 && self.pending_chord[0] == g;
2211 }
2212
2213 // ---- Command palette (issue #32) ----------------------------------------
2214
2215 /// Open the command palette overlay. Transitions the active view
2216 /// to `View::CommandPalette` and arms the pure state machine on
2217 /// `self.palette` with a fresh empty buffer. Status bar shows a
2218 /// short hint so the user knows what to type.
2219 /// Typing route of the palette, resolved BEFORE the modal context
2220 /// (Codex review #456): during text input the printable keys and
2221 /// Backspace are reserved for typing — a rebind like
2222 /// `palette.close = ["x"]` must not close the palette mid-word. Returns
2223 /// `true` when the key was consumed as input; charset keys type, other
2224 /// printable characters are swallowed (no palette entry could match
2225 /// them), Ctrl-modified keys fall through to the modal resolution.
2226 pub fn palette_input_key(&mut self, key: KeyEvent) -> bool {
2227 use crossterm::event::KeyModifiers as Mods;
2228 // Ctrl/Alt-modified strokes are bindable and must stay reachable
2229 // (Codex review #456) — only unmodified legitimate input is reserved.
2230 if key.modifiers.intersects(Mods::CONTROL | Mods::ALT) {
2231 return false;
2232 }
2233 // Kitty-style terminals report an uppercase as Char + SHIFT (the case
2234 // KeyStroke::from_event normalises): that is an uppercase letter, not
2235 // the palette's lowercase input — a binding on "X" must stay
2236 // reachable on every terminal encoding (Codex review #456, it. 10).
2237 if key.modifiers.contains(Mods::SHIFT) && matches!(key.code, KeyCode::Char(c) if c.is_ascii_alphabetic()) {
2238 return false;
2239 }
2240 if key.code == KeyCode::Backspace {
2241 self.palette_pop_char();
2242 return true;
2243 }
2244 match key.code {
2245 KeyCode::Char(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' => {
2246 self.palette_push_char(c);
2247 true
2248 }
2249 // Characters outside the filter charset fall through to the modal
2250 // resolution — a rebind like `close = ["?"]` stays honoured (#293
2251 // contract); unresolved ones are dropped by the dispatch fallback.
2252 _ => false,
2253 }
2254 }
2255
2256 /// Fallback after an EMPTY modal resolution in the palette (Codex
2257 /// review #456, iterations 10/14): an unresolved modified charset
2258 /// printable is still typing (AltGr/Option characters arrive as
2259 /// Char + ALT on some keyboards), and an unresolved modified Backspace
2260 /// still erases (parity with the pre-#456 routing).
2261 pub fn palette_unresolved_fallback(&mut self, key: KeyEvent) {
2262 match key.code {
2263 KeyCode::Char(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' => {
2264 self.palette_push_char(c);
2265 }
2266 KeyCode::Backspace => self.palette_pop_char(),
2267 _ => {}
2268 }
2269 }
2270
2271 /// Typing route of the Settings value editor — same reserved-typing
2272 /// contract as [`Self::palette_input_key`] (Codex review #456). A no-op
2273 /// (`false`) when no edit is live.
2274 pub fn settings_edit_input_key(&mut self, key: KeyEvent) -> bool {
2275 use crossterm::event::KeyModifiers as Mods;
2276 if self.config_panel.editing.is_none() {
2277 return false;
2278 }
2279 if key.modifiers.intersects(Mods::CONTROL | Mods::ALT) {
2280 return false;
2281 }
2282 if key.code == KeyCode::Backspace {
2283 self.config_panel.pop_edit_char();
2284 return true;
2285 }
2286 match key.code {
2287 // A character the field refuses (a non-digit on a numeric field) is
2288 // NOT typing — it falls through so a rebound verb on it still fires
2289 // (Codex review #456, iteration 11).
2290 KeyCode::Char(c) => self.config_panel.push_edit_char(c),
2291 _ => false,
2292 }
2293 }
2294
2295 /// The whole Settings-editor key route (Codex review #456, iteration
2296 /// 10) — one testable method: reserved typing first, then the modal
2297 /// resolution, and an unresolved printable is REINJECTED as typing
2298 /// (AltGr/Option characters arrive Char + ALT on some keyboards; they
2299 /// are not reserved so a bound Alt+x stays reachable, but when nothing
2300 /// resolves they are what the user typed).
2301 pub fn handle_settings_edit_key(&mut self, key: KeyEvent) {
2302 if self.settings_edit_input_key(key) {
2303 return;
2304 }
2305 match self.resolve_modal(KeyContext::ConfigEdit, key) {
2306 Some(ModalAction::ConfigEditSubmit) => self.commit_settings_edit(),
2307 Some(ModalAction::ConfigEditCancel) => self.config_panel.cancel_edit(),
2308 _ => match key.code {
2309 // Best-effort reinjection; a character the field refuses (an
2310 // AltGr symbol on a numeric field) is simply dropped.
2311 KeyCode::Char(c) => {
2312 let _ = self.config_panel.push_edit_char(c);
2313 }
2314 // An UNBOUND Alt/Ctrl+Backspace still erases (iteration 14 —
2315 // parity with the pre-#456 routing, which erased on every
2316 // KeyCode::Backspace).
2317 KeyCode::Backspace => self.config_panel.pop_edit_char(),
2318 _ => {}
2319 },
2320 }
2321 }
2322
2323 pub fn open_command_palette(&mut self) {
2324 self.palette.open();
2325 self.view = View::CommandPalette;
2326 self.status = "command palette — type, Enter to run, Esc to cancel".into();
2327 }
2328
2329 /// Close the palette without firing anything. Called on `Esc` from
2330 /// inside the overlay. Returns the view to `View::List` and drops
2331 /// the buffer.
2332 pub fn close_command_palette(&mut self) {
2333 self.palette.close();
2334 self.view = View::List;
2335 self.status = "palette cancelled".into();
2336 }
2337
2338 /// Append a character to the palette input buffer. The pure state
2339 /// machine re-runs its fuzzy match and resets the highlight to 0.
2340 pub fn palette_push_char(&mut self, c: char) {
2341 self.palette.push_char(c);
2342 }
2343
2344 /// Remove the trailing character from the palette input buffer.
2345 pub fn palette_pop_char(&mut self) {
2346 self.palette.pop_char();
2347 }
2348
2349 /// Move the palette highlight one row down (wraps at the end).
2350 pub fn palette_cycle_down(&mut self) {
2351 self.palette.cycle_highlight_down();
2352 }
2353
2354 /// Move the palette highlight one row up (wraps at the start).
2355 pub fn palette_cycle_up(&mut self) {
2356 self.palette.cycle_highlight_up();
2357 }
2358
2359 /// Accept the highlighted entry. Returns the resolved `Action` and
2360 /// drops the palette overlay; the caller (event loop) routes the
2361 /// action through the same dispatcher branch as a keystroke so
2362 /// palette + key fire identical side effects.
2363 ///
2364 /// When the input buffer matches nothing the palette stays open
2365 /// and `None` is returned — the user can backspace and retry
2366 /// without losing context.
2367 pub fn accept_command_palette(&mut self) -> Option<Action> {
2368 let action = self.palette.accept()?;
2369 self.view = View::List;
2370 self.status = format!("palette: {}", action.slug());
2371 Some(action)
2372 }
2373
2374 // ---- Sidebar ------------------------------------------------------------
2375
2376 pub fn toggle_sidebar(&mut self) {
2377 self.sidebar.toggle_open();
2378 self.status = if self.sidebar.open {
2379 "sidebar shown".into()
2380 } else {
2381 "sidebar hidden".into()
2382 };
2383 }
2384
2385 /// Cycle the sidebar preview mode between Commits and Stashes
2386 /// (issue #34). Drives the pure-state cycle on `SidebarState`
2387 /// plus the status-bar copy: orchestrator-shaped because the
2388 /// status bar is owned by `App`, not by the sub-struct.
2389 pub fn cycle_sidebar_mode(&mut self) {
2390 self.sidebar.cycle_mode();
2391 self.status = format!("sidebar: {}", self.sidebar.mode.label());
2392 }
2393
2394 /// Cycle the sidebar orientation `auto → side-by-side → stacked`
2395 /// (issue #188). Orchestrator-shaped for the status-bar copy, like
2396 /// [`Self::cycle_sidebar_mode`].
2397 pub fn cycle_sidebar_layout(&mut self) {
2398 self.sidebar.cycle_orientation();
2399 self.status = format!("sidebar layout: {}", self.sidebar.orientation.label());
2400 }
2401
2402 /// Flip the side-by-side sidebar position left ↔ right (issue #188).
2403 pub fn toggle_sidebar_position(&mut self) {
2404 self.sidebar.toggle_position();
2405 self.status = format!("sidebar position: {}", self.sidebar.position.label());
2406 }
2407
2408 pub fn toggle_focus(&mut self) {
2409 self.sidebar.toggle_focus();
2410 }
2411
2412 /// Direct-focus the worktree table (issue #217, `1`). Orchestrator-shaped
2413 /// for the status-bar copy, like the sidebar toggles.
2414 pub fn focus_worktrees(&mut self) {
2415 self.sidebar.focus_table();
2416 self.status = "focus: worktrees".into();
2417 }
2418
2419 /// Direct-focus the status (sidebar) pane (issue #217, `2`). Opens the
2420 /// sidebar if needed and moves focus onto it.
2421 pub fn focus_status(&mut self) {
2422 self.sidebar.focus_panel();
2423 self.status = "focus: status".into();
2424 }
2425
2426 /// The live UI context driving the statusbar chip + help subtitle (issue
2427 /// #217). An open modal / overlay wins over the pane focus (issue #217
2428 /// review P2): when the create form is up, the statusbar must advertise
2429 /// the form's keys, not the worktrees pane's `n new` — pressing `n` there
2430 /// types text. Only `View::List` falls through to the pane context
2431 /// (`Picker` in `gwm switch`, `Status` when the sidebar holds focus, else
2432 /// `Worktrees`).
2433 pub fn hint_context(&self) -> super::ui::HintContext {
2434 use super::ui::HintContext;
2435 match self.view {
2436 View::Create => self.create_hint_context(),
2437 View::Confirm => HintContext::Confirm,
2438 View::OpenMenu => HintContext::OpenMenu,
2439 // #219: the two link-prompt stages advertise different keys — the
2440 // choose-target picker vs the number-input submit/cancel — so the
2441 // statusbar tracks whichever stage is live.
2442 View::LinkPrompt => {
2443 if self.link_prompt_stage() == crate::tui::state::link_prompt::LinkPromptStage::InputNumber {
2444 HintContext::LinkInputNumber
2445 } else {
2446 HintContext::LinkPrompt
2447 }
2448 }
2449 View::CommandPalette => HintContext::CommandPalette,
2450 View::Report => HintContext::Report,
2451 View::Help => HintContext::Help,
2452 // The Command Logs overlay (issue #226) is a ~90% fullscreen modal;
2453 // the statusbar behind it shows the underlying pane's context, as the
2454 // List view does.
2455 View::CommandLogs => self.pane_hint_context(),
2456 // The Configuration panel (issue #232) is likewise a ~90% fullscreen
2457 // modal; the statusbar behind it keeps the underlying pane context.
2458 View::Config => self.pane_hint_context(),
2459 View::Pty => super::ui::HintContext::Pty,
2460 View::ExecPicker => HintContext::ExecPicker,
2461 View::CleanReport => HintContext::Clean,
2462 View::Edit => self.rename_hint_context(),
2463 // Issue #408: the detail overlay advertises its close/scroll keys.
2464 View::DetailOverlay => {
2465 if self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
2466 HintContext::CiChecks
2467 } else {
2468 HintContext::Detail
2469 }
2470 }
2471 View::List => self.pane_hint_context(),
2472 }
2473 }
2474
2475 /// Which hint row the create overlay advertises (issue #416). The two
2476 /// modes present different inputs, so they advertise different verbs:
2477 /// free-form has one field and no type selector, and `toggle_mode` is the
2478 /// only way between them — a verb a user cannot guess from the visible
2479 /// inputs, unlike Tab or the arrows. Both the statusbar and the overlay's
2480 /// own footer read this, so the two can never disagree.
2481 pub fn create_hint_context(&self) -> super::ui::HintContext {
2482 use super::ui::HintContext;
2483 match self.create_form.mode {
2484 Mode::Freeform => HintContext::CreateFreeform,
2485 Mode::Structured => HintContext::Create,
2486 }
2487 }
2488
2489 /// Which hint row the rename overlay advertises (issue #479). Same shape and
2490 /// same reason as [`Self::create_hint_context`]: free-form has one field and
2491 /// no type selector, so advertising `field` and `type` there would name keys
2492 /// that do nothing. Both the statusbar and the modal's own footer read this,
2493 /// so the two can never disagree — which they did when only the footer knew
2494 /// about the mode (Codex review on PR #485).
2495 pub fn rename_hint_context(&self) -> super::ui::HintContext {
2496 use super::ui::HintContext;
2497 match self.create_form.mode {
2498 Mode::Freeform => HintContext::RenameFreeform,
2499 Mode::Structured => HintContext::Rename,
2500 }
2501 }
2502
2503 /// The underlying list-view pane context (issue #217), ignoring any open
2504 /// overlay. Drives the help overlay's subtitle + picker-section gating:
2505 /// `?` documents the keys for the pane you were on, so it must NOT collapse
2506 /// to the `Help` context that [`Self::hint_context`] returns while the
2507 /// overlay is up.
2508 pub fn pane_hint_context(&self) -> super::ui::HintContext {
2509 use super::ui::HintContext;
2510 if self.picker_mode {
2511 HintContext::Picker
2512 } else if self.sidebar.open && self.sidebar.focused {
2513 HintContext::Status
2514 } else {
2515 HintContext::Worktrees
2516 }
2517 }
2518
2519 /// `true` while a GitHub issue / PR fetch for the current link is inflight
2520 /// (issue #217) — drives the statusbar loading spinner.
2521 pub fn is_github_loading(&self) -> bool {
2522 matches!(self.issue_fetch_state(), GitHubFetchState::Loading)
2523 || matches!(self.pr_fetch_state(), GitHubFetchState::Loading)
2524 }
2525
2526 pub fn sidebar_scroll_down(&mut self) {
2527 self.sidebar.scroll_down();
2528 }
2529
2530 pub fn sidebar_scroll_up(&mut self) {
2531 self.sidebar.scroll_up();
2532 }
2533
2534 /// Scroll the Working Tree pane down (issue #437, `J`). Gated on the
2535 /// status pane holding the navigation focus — the same condition that
2536 /// routes `j` / `k` to the sidebar in [`Self::next`] / [`Self::prev`] —
2537 /// so the keys stay inert (and reusable) in the worktrees context.
2538 pub fn wt_scroll_down(&mut self) {
2539 if self.sidebar.open && self.sidebar.focused {
2540 self.sidebar.wt_scroll_down();
2541 }
2542 }
2543
2544 /// Scroll the Working Tree pane up (issue #437, `K`). Same focus gate
2545 /// as [`Self::wt_scroll_down`].
2546 pub fn wt_scroll_up(&mut self) {
2547 if self.sidebar.open && self.sidebar.focused {
2548 self.sidebar.wt_scroll_up();
2549 }
2550 }
2551
2552 /// Open the Keybindings (help) overlay from the top (#217). Resetting
2553 /// the scroll offset here keeps re-opens predictable.
2554 pub fn enter_help(&mut self) {
2555 self.view = View::Help;
2556 self.help_scroll = 0;
2557 self.help_x_scroll = 0;
2558 }
2559
2560 /// Open the Command Logs overlay (issue #226). Snapshots the global
2561 /// command log into owned state and resets the scroll cursor so a
2562 /// previously-scrolled session starts fresh at the top. The renderer
2563 /// republishes `max_scroll` against the live viewport.
2564 pub fn enter_command_logs(&mut self) {
2565 self.command_logs.sync();
2566 self.command_logs.reset();
2567 self.view = View::CommandLogs;
2568 }
2569
2570 /// Open the Configuration panel (issue #232). Resolves the effective
2571 /// config — the user-level global deep-merged under the repo `.gwm.toml`,
2572 /// with per-row source attribution — into owned state, then resets the
2573 /// scroll cursor so a re-open starts fresh at the top. The reads are
2574 /// cheap local TOML parses; on failure the panel still opens (empty)
2575 /// with the error on the statusbar rather than refusing to open.
2576 pub fn enter_config_panel(&mut self) {
2577 match crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
2578 Ok(rows) => self.config_panel.rows = rows,
2579 Err(e) => {
2580 self.config_panel.rows = Vec::new();
2581 self.status = format!("error: {}", e);
2582 }
2583 }
2584 self.refresh_key_rows();
2585 self.config_panel.reset();
2586 self.view = View::Config;
2587 }
2588
2589 /// Rebuild the Keys-tab rows (issue #294) from the live keymaps, attributing
2590 /// each binding's source via the resolved-row snapshot (the same layer
2591 /// attribution the `All` tab shows). Called on panel open and after a
2592 /// successful rebind so the displayed key(s) + badge track the edit.
2593 fn refresh_key_rows(&mut self) {
2594 let rows = self.config_panel.rows.clone();
2595 let key_rows = super::state::config_panel::build_key_rows(&self.keymap, &self.modal_keymap, |key| {
2596 rows
2597 .iter()
2598 .find(|r| r.key == key)
2599 .map(|r| r.source)
2600 .unwrap_or(crate::config::ConfigSource::Default)
2601 });
2602 self.config_panel.key_rows = key_rows;
2603 }
2604
2605 /// Feed a raw key event into the in-progress Keys-tab capture (issue #294),
2606 /// normalising it to a [`KeyStroke`] first. No-op when no capture is armed.
2607 pub fn push_key_capture(&mut self, key: KeyEvent) {
2608 self.config_panel.capture_push(KeyStroke::from_event(&key));
2609 }
2610
2611 /// Drive a key through an armed Keys-tab capture (issue #294). The event loop
2612 /// owns no logic — it just routes here when a capture is armed, mirroring
2613 /// `handle_create_key` / `handle_link_prompt_key`. Controls (resolved through
2614 /// the `config.edit` context so a rebind shows through):
2615 ///
2616 /// - `cancel` (def Esc) aborts the capture;
2617 /// - `submit` (def Enter) commits a **multi-stroke global chord**;
2618 /// - `Backspace` drops the last stroke of a global chord;
2619 /// - any other key is captured — a **single-stroke modal** verb auto-commits
2620 /// on the first one, a global chord accumulates until `submit`.
2621 ///
2622 /// `Esc` / `Enter` / `Backspace` stay reserved controls in **both** modes and
2623 /// are never themselves captured (a modal verb can't be bound to them via the
2624 /// UI — hand-edit `.gwm.toml`), matching the documented capture controls and
2625 /// the hard-coded escape-hatch policy.
2626 pub fn handle_capture_key(&mut self, key: KeyEvent) {
2627 let single = self
2628 .config_panel
2629 .capture
2630 .as_ref()
2631 .map(|c| c.single_only)
2632 .unwrap_or(false);
2633 // Reserved capture controls. The *physical* Esc / Enter / Backspace are
2634 // always controls (never captured) regardless of any `config.edit` rebind,
2635 // so a custom `submit = ["Ctrl+s"]` can't make Enter assignable (Codex #297
2636 // review). The resolved `config.edit` verbs are honoured *in addition*, so a
2637 // custom key also cancels / commits.
2638 let resolved = self.resolve_modal(KeyContext::ConfigEdit, key);
2639 let is_cancel = key.code == KeyCode::Esc || resolved == Some(ModalAction::ConfigEditCancel);
2640 let is_submit = key.code == KeyCode::Enter || resolved == Some(ModalAction::ConfigEditSubmit);
2641 if is_cancel {
2642 self.config_panel.cancel_capture();
2643 } else if is_submit {
2644 // Enter commits an accumulated global chord; a reserved control (ignored)
2645 // for a single-stroke modal capture.
2646 if !single {
2647 self.commit_key_capture();
2648 }
2649 } else if key.code == KeyCode::Backspace {
2650 // Backspace edits a global chord; reserved (ignored) for a modal capture.
2651 if !single {
2652 self.config_panel.capture_pop();
2653 }
2654 } else {
2655 self.push_key_capture(key);
2656 if single {
2657 self.commit_key_capture();
2658 }
2659 }
2660 }
2661
2662 /// Commit the in-progress Keys-tab capture (issue #294): write the captured
2663 /// chord as a TOML array to the selected target's `[tui.keys]` /
2664 /// `[tui.keys.modal.<context>]` key in the active layer, then reload the
2665 /// config + both keymaps so the rebind is live immediately. An empty capture
2666 /// writes `[]` (unbind). Validation (conflict / prefix-collision) happens in
2667 /// the writer's validate-before-write gate; on failure the file and the live
2668 /// keymaps are left untouched and the error is surfaced on the statusbar.
2669 pub fn commit_key_capture(&mut self) {
2670 let Some(cap) = self.config_panel.take_capture() else {
2671 return;
2672 };
2673 let target = match self.config_panel.key_rows.get(cap.row) {
2674 Some(row) => row.target,
2675 None => return,
2676 };
2677 let config_key = target.config_key();
2678 let items = cap.as_config_items();
2679
2680 // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
2681 // with a stale selection that path is the *previously* active repo, so
2682 // refuse rather than rebind keys in the wrong repo (#304).
2683 if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
2684 self.status = "workspace: selected repo is unavailable — can't edit its project keymap".into();
2685 return;
2686 }
2687 let path = match self.config_panel.layer {
2688 SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
2689 SettingsLayer::Global => match self.global_path.clone() {
2690 Some(p) => p,
2691 None => {
2692 self.status = "keys: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
2693 return;
2694 }
2695 },
2696 };
2697
2698 // Snapshot the target file first: `set_array_at` only validates the file
2699 // it writes, not the layered merge, so a rebind that is valid in this file
2700 // alone but collides with the *other* layer once merged (e.g. a prefix
2701 // collision the global layer reveals) would slip past and brick the
2702 // config for the next launch. Keep the prior bytes so we can roll back
2703 // (Codex #297 review P2).
2704 let prior = std::fs::read(&path).ok();
2705
2706 if let Err(e) = crate::config_cli::set_array_at(&path, &config_key, &items) {
2707 // `write_and_validate` writes the edit *before* erroring when the file
2708 // was already invalid on its own (the recovery path for #281 — here the
2709 // target value can be shadowed by another layer so the app still
2710 // loaded). Roll back so a rebind reported as failed never persists or
2711 // takes effect on the next launch (Codex #297 review P2).
2712 Self::restore_file(&path, prior);
2713 self.status = format!("keys: {}", e);
2714 return;
2715 }
2716
2717 // Strip any pre-#290 alias of this action from the same file: a legacy
2718 // config that still carries e.g. `tui.keys.open_menu` would, on reload,
2719 // re-apply the alias after the canonical `browse_links` in the sorted
2720 // override walk and silently shadow the new binding (Codex #297 review).
2721 // Best-effort: the canonical key is already written, so a cleanup error
2722 // is surfaced but does not abort the rebind.
2723 for alias_key in target.compat_alias_keys() {
2724 if let Err(e) = crate::config_cli::unset_at(&path, &alias_key) {
2725 self.status = format!("keys: {}", e);
2726 }
2727 }
2728
2729 // Reload the merged config and rebuild both keymaps so the new binding
2730 // fires without a restart.
2731 match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
2732 Ok(cfg) => self.set_active_config(cfg),
2733 Err(e) => {
2734 // The single-file write validated but the layered merge is invalid —
2735 // roll the file back to its prior state so the config is never left
2736 // broken on disk, and keep the previous live keymaps.
2737 Self::restore_file(&path, prior);
2738 self.status = format!("keys: rebind rejected — would break the merged config: {}", e);
2739 return;
2740 }
2741 }
2742 match self.config.tui.keys.resolved_keymap() {
2743 Ok(km) => self.keymap = km,
2744 Err(e) => {
2745 self.status = format!("keys: {}", e);
2746 return;
2747 }
2748 }
2749 match self.config.tui.keys.resolved_modal_keymap() {
2750 Ok(mk) => self.modal_keymap = mk,
2751 Err(e) => {
2752 self.status = format!("keys: {}", e);
2753 return;
2754 }
2755 }
2756 if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
2757 self.config_panel.rows = rows;
2758 }
2759 self.refresh_key_rows();
2760
2761 let desc = if items.is_empty() {
2762 "unbound".to_string()
2763 } else {
2764 items.join(" ")
2765 };
2766 let mut status = format!("set {} = {} ({})", config_key, desc, self.config_panel.layer.label());
2767 // Verify the capture actually took effect in the *merged* keymap: a
2768 // higher-precedence layer, or a pre-#290 alias still declared in another
2769 // layer (which we deliberately don't edit), can shadow the write so the new
2770 // key never fires — or, for an unbind, keeps the action bound — even though
2771 // it persisted. Warn instead of reporting a clean success (Codex #297
2772 // review).
2773 if !self.capture_took_effect(target, &cap.pending) {
2774 status.push_str(" — shadowed (a higher layer or legacy alias still binds it)");
2775 }
2776 self.status = status;
2777 }
2778
2779 /// Restore a config file to a snapshot taken before a rebind write: rewrite
2780 /// the prior bytes, or remove the file if it did not exist before. Used to
2781 /// roll back a failed / merge-invalid Keys-tab write (issue #294).
2782 fn restore_file(path: &std::path::Path, prior: Option<Vec<u8>>) {
2783 match prior {
2784 Some(bytes) => {
2785 let _ = std::fs::write(path, bytes);
2786 }
2787 None => {
2788 let _ = std::fs::remove_file(path);
2789 }
2790 }
2791 }
2792
2793 /// Whether the just-committed capture is the *effective* state in the live
2794 /// (merged) keymap, i.e. not shadowed by another layer / a lingering legacy
2795 /// alias. For a rebind (`strokes` non-empty) the captured chord must resolve
2796 /// to the target's action; for an unbind (`strokes` empty) the action must
2797 /// have no remaining binding. Issue #294 (Codex #297 review).
2798 fn capture_took_effect(&self, target: KeyTarget, strokes: &[KeyStroke]) -> bool {
2799 match target {
2800 KeyTarget::Global(action) => {
2801 if strokes.is_empty() {
2802 self.keymap.keys_display(action).is_empty()
2803 } else {
2804 matches!(self.keymap.lookup(strokes), ChordResolution::Matched(a) if a == action)
2805 }
2806 }
2807 KeyTarget::Modal(action) => {
2808 if strokes.is_empty() {
2809 self.modal_keymap.keys_display(action).is_empty()
2810 } else {
2811 strokes
2812 .first()
2813 .map(|s| self.modal_keymap.resolve(action.context(), s) == Some(action))
2814 .unwrap_or(false)
2815 }
2816 }
2817 }
2818 }
2819
2820 // ── PTY overlay (issue #35) ────────────────────────────────────────────
2821
2822 /// Open the PTY overlay: store `pty` and switch to [`View::Pty`].
2823 pub fn open_pty_overlay(&mut self, pty: super::state::pty_overlay::PtyOverlay) {
2824 self.pty_overlay = Some(pty);
2825 self.view = View::Pty;
2826 }
2827
2828 /// Close the PTY overlay: kill the child process, drop the state, and
2829 /// return to [`View::List`]. Safe to call when no overlay is open.
2830 pub fn close_pty_overlay(&mut self) {
2831 if let Some(ref mut pty) = self.pty_overlay {
2832 pty.kill();
2833 }
2834 self.pty_overlay = None;
2835 if self.view == View::Pty {
2836 self.view = View::List;
2837 }
2838 }
2839
2840 // ── Exec picker overlay (issue #325) ───────────────────────────────────
2841
2842 /// `true` while a destructive overlay — the exec picker or the clean
2843 /// report — is open (issue #325). The run loop suspends `maybe_auto_refresh`
2844 /// and `sync_active_repo` while one is up, so the worktree list (and thus
2845 /// the live selection / active repo) cannot reshuffle under an armed reclaim
2846 /// or a pending exec run. This closes the drift class at its source (Codex
2847 /// #333 review); the per-overlay open-time snapshots stay as defence in
2848 /// depth against an already-in-flight refresh landing its result.
2849 pub fn destructive_overlay_open(&self) -> bool {
2850 matches!(self.view, View::ExecPicker | View::CleanReport)
2851 }
2852
2853 /// Open the exec profile picker (issue #325). Populates it from
2854 /// `[exec.profiles.*]` and switches to [`View::ExecPicker`]. Refuses
2855 /// (status-bar message, no transition) when nothing is selected or no
2856 /// exec profiles are configured — there is nothing to pick.
2857 pub fn enter_exec_picker(&mut self) {
2858 let Some(cwd) = self.selected().map(|wt| wt.path.clone()) else {
2859 self.status = "nothing selected".into();
2860 return;
2861 };
2862 let names: Vec<String> = self.config.exec.profiles.keys().cloned().collect();
2863 if names.is_empty() {
2864 self.status = "no [exec.profiles] configured — add one to .gwm.toml".into();
2865 return;
2866 }
2867 // Capture the target worktree path AND the active repo's `[exec]` config
2868 // now: an auto-refresh can drift the live selection (and, in workspace
2869 // mode, the active repo) while the picker is open, so `Enter` must run in
2870 // *this* worktree against *this* config — not whatever is live later
2871 // (Codex #333 review).
2872 self.exec_picker_cfg = self.config.exec.clone();
2873 self.exec_picker.open(names, cwd);
2874 self.view = View::ExecPicker;
2875 }
2876
2877 /// Handle a key inside the exec picker overlay (issue #325). The
2878 /// testable handler owns the highlight movement; the run loop owns the
2879 /// two side effects (resolve + spawn, or close). Keys resolve through
2880 /// [`KeyContext::ExecPicker`] so they honour `[tui.keys.modal.exec]`.
2881 pub fn handle_exec_picker_key(&mut self, key: KeyEvent) -> ExecPickerKey {
2882 match self.resolve_modal(KeyContext::ExecPicker, key) {
2883 Some(ModalAction::ExecPickerCancel) => ExecPickerKey::Cancel,
2884 Some(ModalAction::ExecPickerAccept) => ExecPickerKey::Submit,
2885 Some(ModalAction::ExecPickerNext) => {
2886 self.exec_picker.next();
2887 ExecPickerKey::Handled
2888 }
2889 Some(ModalAction::ExecPickerPrev) => {
2890 self.exec_picker.prev();
2891 ExecPickerKey::Handled
2892 }
2893 _ => ExecPickerKey::Handled,
2894 }
2895 }
2896
2897 /// Resolve the highlighted exec profile to an `(argv, cwd)` pair for the
2898 /// run loop to spawn in a PTY overlay (issue #325). `None` (with a
2899 /// status-bar message) when nothing is selected or the profile fails to
2900 /// resolve — e.g. an empty `command` array. The argv is the frozen
2901 /// `[exec.profiles.<name>].command` verbatim (no shell), matching the
2902 /// 1.0 exec contract; the run loop spawns `argv[0]` directly.
2903 pub fn exec_picker_resolve(&mut self) -> Option<(Vec<String>, PathBuf)> {
2904 let profile = self.exec_picker.selected_profile()?.to_string();
2905 // Resolve against the worktree captured when the picker opened, NOT the
2906 // live selection (which an auto-refresh may have drifted) — #333 review.
2907 let Some(cwd) = self.exec_picker.cwd().map(Path::to_path_buf) else {
2908 self.status = "nothing selected".into();
2909 return None;
2910 };
2911 // Resolve against the `[exec]` config captured at open, not the live one.
2912 match crate::exec::resolve_exec_command(Some(&profile), &[], &self.exec_picker_cfg) {
2913 Ok(mut argv) => {
2914 // Pin a worktree-relative executable (`./run.sh`, `scripts/build`) to
2915 // the captured worktree, exactly like the CLI exec path — otherwise
2916 // `argv[0]` would resolve against gwm's own cwd (Codex #333 review).
2917 // A bare command (`cargo`) or an absolute path is returned unchanged
2918 // (PATH lookup / as-is).
2919 if let Some(first) = argv.first_mut() {
2920 *first = crate::exec::resolve_program(&cwd, first).to_string_lossy().into_owned();
2921 }
2922 Some((argv, cwd))
2923 }
2924 Err(e) => {
2925 self.status = format!("exec profile {profile:?}: {e}");
2926 None
2927 }
2928 }
2929 }
2930
2931 /// Close the exec picker without running anything (issue #325). Returns
2932 /// to [`View::List`].
2933 pub fn close_exec_picker(&mut self) {
2934 if self.view == View::ExecPicker {
2935 self.view = View::List;
2936 }
2937 }
2938
2939 // ── Clean overlay (issue #325) ─────────────────────────────────────────
2940
2941 /// Open the clean overlay (issue #325). Populates the `[clean.profiles]`
2942 /// picker, scans the selected worktree through the safety gate
2943 /// ([`crate::clean::scan_worktree_safe`]), and switches to
2944 /// [`View::CleanReport`]. Refuses (status-bar message, no transition) when
2945 /// nothing is selected. A scan that finds nothing safe still opens — the
2946 /// report says so.
2947 /// Open the agent-session detail overlay for the selected worktree
2948 /// (issue #408, `a`). Rows come from the pure
2949 /// [`crate::tui::state::detail_overlay::agent_detail_rows`] mapping over
2950 /// the last completed snapshot — a session-less worktree opens with an
2951 /// explicit "no agent session found" row, never blank.
2952 pub fn open_agent_overlay(&mut self) {
2953 let Some(sel) = self.selected().cloned() else {
2954 self.status = "nothing selected".into();
2955 return;
2956 };
2957 // Capture the target now (clean-overlay pattern, Codex #333): an
2958 // auto-refresh can drift the live selection while the overlay is open,
2959 // and attach/detach must pin against THIS worktree's branch.
2960 self.detail_overlay_target = Some((
2961 sel.path.clone(),
2962 crate::github::pinnable_branch(sel.branch.as_deref()).map(str::to_string),
2963 ));
2964 let rows = self.build_agent_rows(&sel);
2965 self.detail_overlay.open(
2966 crate::tui::state::detail_overlay::DetailKind::Agents,
2967 "Agent Sessions".into(),
2968 rows,
2969 );
2970 self.view = View::DetailOverlay;
2971 }
2972
2973 /// Open the CI checks overlay (issue #436): one row per classified
2974 /// `statusCheckRollup` entry of the linked PR, in rollup order. With no
2975 /// linked PR or an empty rollup the overlay would be a bordered void —
2976 /// explain on the status bar instead.
2977 pub fn enter_ci_checks(&mut self) {
2978 // Workspace mode: a failed `Repository::open` for the selected row
2979 // leaves `github.link` and its cache on the previously active repo —
2980 // opening now would show (and `Enter` would browse) the OLD repo's
2981 // checks. Refuse, the same contract as the project-layer keymap
2982 // editor (#304 / Codex review #455).
2983 if self.workspace_active_stale {
2984 self.status = "workspace: selected repo is unavailable — can't open its CI checks".into();
2985 return;
2986 }
2987 let checks = match self.pr_fetch_state() {
2988 GitHubFetchState::Loaded(pr) if !pr.checks.is_empty() => pr.checks.clone(),
2989 _ => {
2990 // Resolve the active fetch binding instead of hard-coding `F`
2991 // (Codex review #455); an unbound action drops the parenthetical.
2992 self.status = match self.keymap.primary_chord(Action::FetchGithub) {
2993 Some(key) => format!("no CI checks to show — link a PR and fetch ({key}) first"),
2994 None => "no CI checks to show — link a PR and fetch first".into(),
2995 };
2996 return;
2997 }
2998 };
2999 let rows = crate::tui::state::detail_overlay::ci_check_rows(&checks, std::time::SystemTime::now());
3000 // Drop any stale agents target (an interrupted agents overlay leaves
3001 // one behind) — it belongs to the agents consumer only (Codex #455).
3002 self.detail_overlay_target = None;
3003 // Pin the overlay to the PR it renders, so a link mutation that
3004 // disagrees can close it (Codex review #455). The checks themselves
3005 // are kept too — the duration tick's cache-independent source.
3006 self.detail_overlay_pr = self.github.link.pr.map(|n| (self.github.link_slug.clone(), n));
3007 self.ci_overlay_checks = checks;
3008 self.detail_overlay.open(
3009 crate::tui::state::detail_overlay::DetailKind::CiChecks,
3010 "CI Checks".into(),
3011 rows,
3012 );
3013 self.view = View::DetailOverlay;
3014 }
3015
3016 /// Contextual KEY routing (issue #436) — same mechanism that turns
3017 /// `j` / `k` into sidebar scroll: while the status pane holds the
3018 /// focus, the `c` keystroke (EditWorktree) opens the CI checks
3019 /// overlay instead of the rename modal. Applied by the event loop on
3020 /// the **key path only** (Codex review #455): the command palette
3021 /// dispatches actions by their NAME, so its `edit-worktree` entry
3022 /// must stay a rename in every context (a dedicated `ci-checks`
3023 /// entry already exists there). Pure, so the contract is pinned
3024 /// without an event loop.
3025 pub fn resolve_contextual_action(&self, action: Action) -> Action {
3026 if action == Action::EditWorktree && self.sidebar.open && self.sidebar.focused {
3027 Action::CiChecks
3028 } else {
3029 action
3030 }
3031 }
3032
3033 // ---- CI checks overlay `f` filter (issue #436) ---------------------------
3034 // Same shell machinery as the agent attach prompt right above (mode +
3035 // input buffer + candidate cursor), filtering the overlay's own rows.
3036
3037 /// `f` inside the CI checks overlay — re-fetch the PR; the landing
3038 /// refreshes the rows in place (`refresh_ci_overlay_on_pr_landing`).
3039 /// The modal dispatch bypasses run_action's workspace guard, so it is
3040 /// re-applied here (Codex review #455, P1): a selection gone stale
3041 /// AFTER the overlay opened must not fetch — and persist PR metadata —
3042 /// through the previously active repo's slug and handle. The overlay
3043 /// closes, since its rows belong to that previous repo anyway.
3044 pub fn ci_checks_refresh(&mut self) {
3045 if self.workspace_active_stale {
3046 self.close_detail_overlay();
3047 self.status = "workspace: selected repo is unavailable — CI checks closed".into();
3048 return;
3049 }
3050 self.refresh_github_status();
3051 }
3052
3053 /// Poll-cadence tick (Codex review #455): a Running check's `extra`
3054 /// column carries an elapsed duration formatted when the rows were
3055 /// built, which otherwise freezes until the next `f`. Rebuild the rows
3056 /// from the cached PR state while at least one check is still running —
3057 /// pure in-memory formatting, no I/O — and stay a no-op once every
3058 /// check is terminal so idle frames do no churn. `set_rows` keeps the
3059 /// selection and the filter cursor clamped.
3060 pub fn tick_ci_overlay_durations(&mut self) {
3061 if self.view != View::DetailOverlay
3062 || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
3063 {
3064 return;
3065 }
3066 // The overlay's OWN checks, not the fetch cache (Codex review #455):
3067 // an invalidation while the overlay is up — a workspace refresh_link
3068 // with no bulk refetch, a failed manual refresh — would empty the
3069 // cache and silently kill the clock of a still-Running check.
3070 if !self
3071 .ci_overlay_checks
3072 .iter()
3073 .any(|c| matches!(c.outcome, github::CheckOutcome::Running))
3074 {
3075 return;
3076 }
3077 let rows = crate::tui::state::detail_overlay::ci_check_rows(&self.ci_overlay_checks, std::time::SystemTime::now());
3078 self.detail_overlay.set_rows(rows);
3079 }
3080
3081 pub fn ci_input_open(&mut self) {
3082 self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::Input;
3083 self.detail_overlay.input.clear();
3084 self.detail_overlay.input_selected = 0;
3085 }
3086
3087 pub fn ci_input_push(&mut self, c: char) {
3088 self.detail_overlay.input.push(c);
3089 self.detail_overlay.input_selected = 0;
3090 }
3091
3092 pub fn ci_input_pop(&mut self) {
3093 self.detail_overlay.input.pop();
3094 self.detail_overlay.input_selected = 0;
3095 }
3096
3097 /// Indices of the rows matching the live query, in row order.
3098 pub fn ci_input_matches(&self) -> Vec<usize> {
3099 crate::tui::state::detail_overlay::filter_rows(&self.detail_overlay.rows, &self.detail_overlay.input)
3100 }
3101
3102 pub fn ci_input_next(&mut self) {
3103 let len = self.ci_input_matches().len();
3104 self.detail_overlay.input_selected = (self.detail_overlay.input_selected + 1).min(len.saturating_sub(1));
3105 }
3106
3107 pub fn ci_input_prev(&mut self) {
3108 self.detail_overlay.input_selected = self.detail_overlay.input_selected.saturating_sub(1);
3109 }
3110
3111 pub fn ci_input_cancel(&mut self) {
3112 self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3113 self.detail_overlay.input.clear();
3114 }
3115
3116 /// The details URL of the highlighted filtered row (Enter inside the
3117 /// filter). Pure so the event loop owns the actual browser spawn; also
3118 /// re-anchors the List selection on the picked row and leaves the
3119 /// filter, so Esc-free flows land where the user expects.
3120 pub fn ci_input_selected_url(&mut self) -> Option<String> {
3121 let matches = self.ci_input_matches();
3122 let row_idx = matches.get(self.detail_overlay.input_selected).copied()?;
3123 self.detail_overlay.selected = row_idx;
3124 self.ci_input_cancel();
3125 self.detail_overlay.rows.get(row_idx).and_then(|r| r.meta.clone())
3126 }
3127
3128 /// The details URL of the selected row in List mode (Enter). `None` when
3129 /// the check carries no URL — the caller reports on the status bar.
3130 pub fn ci_selected_url(&self) -> Option<String> {
3131 self
3132 .detail_overlay
3133 .rows
3134 .get(self.detail_overlay.selected)
3135 .and_then(|r| r.meta.clone())
3136 }
3137
3138 /// Rows for the captured worktree: sessions from the snapshot, the manual
3139 /// pins marked (issue #408 US4 + user feedback 2026-07-22 — multi-pin).
3140 fn build_agent_rows(&self, w: &crate::worktree::WorktreeInfo) -> Vec<crate::tui::state::detail_overlay::DetailRow> {
3141 // Pins come from the per-path map — built per OWNING repo, so a
3142 // workspace active-repo swap under the open overlay cannot yield
3143 // absent or wrong markers (round N), and the snapshot-landing rebuild
3144 // does no branch-config I/O on the event loop (round P): the map is
3145 // refreshed by the landing itself and by every attach/detach.
3146 let pinned = self
3147 .agent_pins
3148 .get(&crate::agent_sessions::path_display_key(&w.path))
3149 .cloned()
3150 .unwrap_or_default();
3151 crate::tui::state::detail_overlay::agent_detail_rows(self.agents_for(w), &pinned, std::time::SystemTime::now())
3152 }
3153
3154 /// Fresh pins per worktree path from branch config — the synchronous
3155 /// read for USER-ACTION paths (attach/detach refresh); the periodic
3156 /// detection reads the same sources in its worker instead (round P).
3157 /// Each row reads from its OWNING repo via [`Self::agent_pin_sources`]
3158 /// (rounds A + I: a same-named branch elsewhere cannot leak its pins).
3159 fn read_agent_pins(&self) -> std::collections::BTreeMap<String, Vec<String>> {
3160 read_pins_from_sources(&self.agent_pin_sources())
3161 }
3162
3163 /// The current pinnable branch of the worktree at `path`, freshly read
3164 /// from the listed rows (which every refresh re-lists) — never the
3165 /// branch captured when an overlay opened (Codex review round U).
3166 fn current_branch_of(&self, path: &Path) -> Option<String> {
3167 let w = self.worktrees.iter().find(|w| w.path == path)?;
3168 crate::github::pinnable_branch(w.branch.as_deref()).map(str::to_string)
3169 }
3170
3171 /// Pin the selected overlay row's session to the overlay's target worktree
3172 /// (`a` inside the modal). Auto-detection stays the default; the pin only
3173 /// adds (issue #408 US4).
3174 pub fn attach_selected_agent(&mut self) {
3175 let Some(sid) = self.detail_overlay.selected_meta().map(str::to_string) else {
3176 // Only the "no agent session found" placeholder carries no id: with
3177 // nothing to select, `a` falls through to the attach-by-id prompt
3178 // instead of dead-ending (user feedback 2026-07-22).
3179 self.open_agent_input();
3180 return;
3181 };
3182 self.attach_agent_by_id(&sid);
3183 }
3184
3185 /// Pin `sid` to the overlay's target worktree — shared by the row action
3186 /// and the attach-by-id prompt. Returns `true` when the pin was written.
3187 fn attach_agent_by_id(&mut self, sid: &str) -> bool {
3188 if self.is_workspace() {
3189 // Pins are single-repo (same ceiling as the CLI surfaces): in
3190 // workspace mode `sync_active_repo` may swap `self.repo` under the
3191 // open overlay, which would write the pin into the wrong repo's
3192 // config (Codex review round B).
3193 self.status = "agent pins are per-repo — not available in workspace mode".into();
3194 return false;
3195 }
3196 let Some((path, _)) = self.detail_overlay_target.clone() else {
3197 self.status = "cannot pin: no worktree captured".into();
3198 return false;
3199 };
3200 // The CURRENT branch, not the one captured at overlay open: a branch
3201 // flipped externally while the overlay stayed open would otherwise
3202 // receive the pin under `branch.<old>.` (Codex review round U).
3203 let Some(branch) = self.current_branch_of(&path) else {
3204 self.status = "cannot pin: worktree has no branch (detached HEAD)".into();
3205 return false;
3206 };
3207 if let Err(e) = crate::github::add_agent_pin(&self.repo, &branch, sid) {
3208 self.status = format!("pin failed: {e}");
3209 return false;
3210 }
3211 self.status = format!("pinned {sid}");
3212 self.refresh_agent_overlay_rows(&path);
3213 true
3214 }
3215
3216 /// Enter the attach-by-id prompt (`i` in the overlay): palette-style
3217 /// query over EVERY detected session — a session matched to no worktree
3218 /// is exactly the one worth pinning manually.
3219 pub fn open_agent_input(&mut self) {
3220 self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::Input;
3221 self.detail_overlay.input.clear();
3222 self.detail_overlay.input_selected = 0;
3223 // The candidate pool needs the full sweep — refreshed on open, not on
3224 // the periodic tick (round Q); until it lands the prompt filters the
3225 // last landed pool.
3226 self.refresh_agent_pool();
3227 }
3228
3229 pub fn agent_input_push(&mut self, c: char) {
3230 self.detail_overlay.input.push(c);
3231 self.detail_overlay.input_selected = 0;
3232 }
3233
3234 pub fn agent_input_pop(&mut self) {
3235 self.detail_overlay.input.pop();
3236 self.detail_overlay.input_selected = 0;
3237 }
3238
3239 pub fn agent_input_next(&mut self) {
3240 let len = self.agent_input_candidates().len();
3241 self.detail_overlay.input_selected = (self.detail_overlay.input_selected + 1).min(len.saturating_sub(1));
3242 }
3243
3244 pub fn agent_input_prev(&mut self) {
3245 self.detail_overlay.input_selected = self.detail_overlay.input_selected.saturating_sub(1);
3246 }
3247
3248 pub fn agent_input_cancel(&mut self) {
3249 self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3250 self.detail_overlay.input.clear();
3251 }
3252
3253 /// The prompt's filtered candidate pool (owned clones — the borrow of
3254 /// `agent_all_sessions` must not outlive `&mut self` call sites).
3255 pub fn agent_input_candidates(&self) -> Vec<crate::agent_sessions::AgentSession> {
3256 crate::tui::state::detail_overlay::filter_sessions(&self.agent_all_sessions, &self.detail_overlay.input)
3257 .into_iter()
3258 .cloned()
3259 .collect()
3260 }
3261
3262 /// Attach the highlighted candidate (or the literal query when nothing
3263 /// matches a known session — validated before persisting). Unknown id
3264 /// keeps the prompt open for correction.
3265 pub fn agent_input_submit(&mut self) {
3266 let candidates = self.agent_input_candidates();
3267 let sid = candidates
3268 .get(self.detail_overlay.input_selected)
3269 .map(|s| s.id.clone())
3270 .unwrap_or_else(|| self.detail_overlay.input.trim().to_string());
3271 let known = candidates.iter().any(|s| s.id == sid);
3272 if sid.is_empty() || !known {
3273 self.status = format!("no agent session matching '{sid}' — run gwm agents for ids");
3274 return;
3275 }
3276 if self.attach_agent_by_id(&sid) {
3277 self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3278 self.detail_overlay.input.clear();
3279 }
3280 }
3281
3282 /// Unpin the SELECTED session (`d` inside the modal). Pins are
3283 /// multi-valued (user feedback 2026-07-22): only the highlighted
3284 /// session's pin is removed, the others stay.
3285 pub fn detach_selected_agent(&mut self) {
3286 if self.is_workspace() {
3287 self.status = "agent pins are per-repo — not available in workspace mode".into();
3288 return;
3289 }
3290 let Some(sid) = self.detail_overlay.selected_meta().map(str::to_string) else {
3291 self.status = "no session selected to unpin".into();
3292 return;
3293 };
3294 let Some((path, _)) = self.detail_overlay_target.clone() else {
3295 self.status = "cannot detach: no worktree captured".into();
3296 return;
3297 };
3298 // Same round-U rule as attach: unpin from the CURRENT branch.
3299 let Some(branch) = self.current_branch_of(&path) else {
3300 self.status = "cannot detach: worktree has no branch (detached HEAD)".into();
3301 return;
3302 };
3303 match crate::github::remove_agent_pin(&self.repo, &branch, &sid) {
3304 Ok(true) => self.status = format!("unpinned {sid}"),
3305 Ok(false) => {
3306 self.status = "session is not pinned".into();
3307 return;
3308 }
3309 Err(e) => {
3310 self.status = format!("detach failed: {e}");
3311 return;
3312 }
3313 }
3314 self.refresh_agent_overlay_rows(&path);
3315 }
3316
3317 /// Rebuild the open overlay's rows after a pin change, refresh the
3318 /// render-side pins copy (the Agents pane shows pinned-only), and push
3319 /// the new pin state to every other surface (snapshot re-detection).
3320 fn refresh_agent_overlay_rows(&mut self, path: &Path) {
3321 // Map first: `build_agent_rows` reads the [`Self::agent_pins`] copy
3322 // (round P), so the fresh read must land before the rows rebuild.
3323 self.agent_pins = self.read_agent_pins();
3324 if let Some(w) = self.worktrees.iter().find(|w| w.path == path).cloned() {
3325 let rows = self.build_agent_rows(&w);
3326 self.detail_overlay.set_rows(rows);
3327 }
3328 if self.tasks.is_loading(TaskKind::AgentSessions) {
3329 // The in-flight thread keeps walking the store even if its slot is
3330 // dropped — invalidating here raced a second scan against it
3331 // (round U, same hazard as rounds P/R). Let it land and chain the
3332 // re-detection; its pre-change pins are skipped on landing.
3333 self.agent_redetect_wanted = true;
3334 } else {
3335 self.tasks.invalidate(TaskKind::AgentSessions);
3336 self.agent_snapshot_at = None;
3337 }
3338 }
3339
3340 /// Close the detail overlay back to the list, leaving list state as it was.
3341 pub fn close_detail_overlay(&mut self) {
3342 self.detail_overlay_target = None;
3343 self.detail_overlay_pr = None;
3344 self.ci_overlay_checks.clear();
3345 self.view = View::List;
3346 }
3347
3348 pub fn enter_clean_overlay(&mut self) {
3349 let Some(sel) = self.selected() else {
3350 self.status = "nothing selected".into();
3351 return;
3352 };
3353 // Capture the target worktree AND the active repo's `[clean]` config now:
3354 // an auto-refresh can drift the live selection (and, in workspace mode,
3355 // the active repo) while the overlay is open / armed, so every re-scan
3356 // and the delete must pin to *this* worktree against *this* config
3357 // (Codex #333 review).
3358 let name = sel.name.clone();
3359 let path = sel.path.clone();
3360 self.clean_overlay_cfg = self.config.clean.clone();
3361 self.clean_overlay_countdown_secs = self.config.tui.effective_confirm_countdown_secs();
3362 let names: Vec<String> = self.clean_overlay_cfg.profiles.keys().cloned().collect();
3363 self.clean_overlay.open(names, name, path);
3364 if let Err(e) = self.clean_overlay_rescan() {
3365 self.status = format!("clean: {e}");
3366 return;
3367 }
3368 self.view = View::CleanReport;
3369 }
3370
3371 /// Re-resolve the highlighted profile's dirs and re-scan the *captured*
3372 /// target worktree (not the live selection), storing the gated snapshot.
3373 /// Surfaces a profile-resolution error (e.g. an invalid `[clean.profiles]`
3374 /// dir) to the caller.
3375 fn clean_overlay_rescan(&mut self) -> Result<()> {
3376 let Some((name, path)) = self
3377 .clean_overlay
3378 .target()
3379 .map(|(n, p)| (n.to_string(), p.to_path_buf()))
3380 else {
3381 return Ok(());
3382 };
3383 let profile = self.clean_overlay.selected_profile().map(str::to_string);
3384 let dirs = crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg)?;
3385 let (reclaim, skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
3386 self.clean_overlay.set_scan(reclaim, skipped);
3387 Ok(())
3388 }
3389
3390 /// Cycle the clean profile picker forward and re-scan, but ONLY when the
3391 /// highlight actually moved (issue #325 / Codex #333). A no-op move (only
3392 /// the `(default)` choice) must not re-scan — that would reset the
3393 /// `ConfirmModal` and silently disarm a pending reclaim while the status
3394 /// bar still reads `armed`.
3395 pub fn clean_overlay_next(&mut self) {
3396 if self.clean_overlay.select_next() {
3397 if let Err(e) = self.clean_overlay_rescan() {
3398 self.status = format!("clean: {e}");
3399 }
3400 }
3401 }
3402
3403 /// Cycle the clean profile picker backward and re-scan, only when the
3404 /// highlight actually moved (issue #325 / Codex #333).
3405 pub fn clean_overlay_prev(&mut self) {
3406 if self.clean_overlay.select_prev() {
3407 if let Err(e) = self.clean_overlay_rescan() {
3408 self.status = format!("clean: {e}");
3409 }
3410 }
3411 }
3412
3413 /// Total duration of the clean safety countdown. Unlike the delete-confirm
3414 /// modal, clean has no `delete_branch_on_remove` gate — it reads
3415 /// `[tui] confirm_countdown_secs` directly. `Duration::ZERO` ⇒ classic
3416 /// single-keystroke confirm.
3417 pub fn clean_countdown_total(&self) -> Duration {
3418 // The value captured at open (Codex #333) — never the live config, which a
3419 // workspace refresh could swap (e.g. to `0`, erasing the safety delay).
3420 Duration::from_secs(u64::from(self.clean_overlay_countdown_secs))
3421 }
3422
3423 /// Handle the clean confirm key. Arms / disarms / fires the countdown via
3424 /// the dedicated [`CleanOverlay`] modal. Nothing-to-reclaim is a no-op
3425 /// guard so the user cannot arm a delete that would free zero bytes.
3426 pub fn clean_confirm_press(&mut self, now: Instant) -> ConfirmKeyAction {
3427 if self.clean_overlay.is_empty_scan() {
3428 self.status = "nothing to reclaim".into();
3429 return ConfirmKeyAction::Disarmed;
3430 }
3431 let total = self.clean_countdown_total();
3432 let action = self.clean_overlay.confirm.press_y(now, total);
3433 match action {
3434 ConfirmKeyAction::Armed => {
3435 self.status = format!(
3436 "armed — reclaiming {} in {}s",
3437 crate::clean::human_size(self.clean_overlay.total_bytes()),
3438 total.as_secs()
3439 );
3440 }
3441 ConfirmKeyAction::Disarmed => self.status = "clean cancelled".into(),
3442 ConfirmKeyAction::FireNow => {}
3443 }
3444 action
3445 }
3446
3447 /// Tick the clean safety countdown. Called from the event loop on every
3448 /// poll-timeout iteration while the overlay is open.
3449 pub fn tick_clean_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
3450 self.clean_overlay.confirm.tick(now, self.clean_countdown_total())
3451 }
3452
3453 /// Clean countdown progress in `[0.0, 1.0]` for the UI gauge.
3454 pub fn clean_countdown_progress(&self, now: Instant) -> f64 {
3455 self.clean_overlay.confirm.progress(now, self.clean_countdown_total())
3456 }
3457
3458 /// Seconds remaining (rounded up) on the clean countdown, for the UI label.
3459 pub fn clean_countdown_remaining_secs(&self, now: Instant) -> u64 {
3460 self
3461 .clean_overlay
3462 .confirm
3463 .remaining_secs(now, self.clean_countdown_total())
3464 }
3465
3466 /// Delete the gated reclaim of the current clean snapshot (issue #325) and
3467 /// return to the list. The snapshot was already filtered to the
3468 /// git-ignored, untracked artifacts by [`crate::clean::scan_worktree_safe`],
3469 /// so this only removes what the CLI `gwm clean --yes` would. Reports the
3470 /// freed size (or the failure) on the status bar.
3471 pub fn clean_overlay_delete(&mut self) {
3472 // Re-scan + re-gate IMMEDIATELY before deleting rather than trusting the
3473 // snapshot shown in the overlay (Codex #333 review). That snapshot can be
3474 // seconds old — the safety countdown, or just the overlay sitting open —
3475 // and a directory may have turned unsafe meanwhile (e.g. `git add -f
3476 // target/file` under an ignored `target/`). Deleting a freshly gated
3477 // reclaim closes that TOCTOU window, matching the CLI's scan-then-delete.
3478 // Pin to the CAPTURED target worktree, not the live selection (an
3479 // auto-refresh may have drifted it while the countdown ran) — #333.
3480 let Some((name, path)) = self
3481 .clean_overlay
3482 .target()
3483 .map(|(n, p)| (n.to_string(), p.to_path_buf()))
3484 else {
3485 self.close_clean_overlay();
3486 return;
3487 };
3488 let profile = self.clean_overlay.selected_profile().map(str::to_string);
3489 let dirs = match crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg) {
3490 Ok(d) => d,
3491 Err(e) => {
3492 self.status = format!("clean: {e}");
3493 self.close_clean_overlay();
3494 return;
3495 }
3496 };
3497 let (reclaim, _skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
3498 if reclaim.artifacts.is_empty() {
3499 self.status = "nothing to reclaim".into();
3500 self.close_clean_overlay();
3501 return;
3502 }
3503 match crate::clean::delete_reclaim(&reclaim) {
3504 Ok(freed) => {
3505 self.status = format!("reclaimed {} from {}", crate::clean::human_size(freed), reclaim.name);
3506 }
3507 Err(e) => self.status = format!("clean failed: {e}"),
3508 }
3509 self.close_clean_overlay();
3510 }
3511
3512 /// Close the clean overlay, disarming the countdown, and return to
3513 /// [`View::List`] (issue #325).
3514 pub fn close_clean_overlay(&mut self) {
3515 self.clean_overlay.confirm.dismiss();
3516 if self.view == View::CleanReport {
3517 self.view = View::List;
3518 }
3519 }
3520
3521 /// Activate the selected Settings field (issue #279): cycle a choice field
3522 /// to its next value (writing + applying live), or arm the numeric input
3523 /// buffer for a `Uint` field. No-op on the read-only `All` tab.
3524 pub fn activate_selected_setting(&mut self) {
3525 let Some(field) = self.config_panel.selected_field() else {
3526 return;
3527 };
3528 match field.kind() {
3529 FieldKind::Choice => {
3530 if let Some(next) = field.next_choice(&self.config) {
3531 self.apply_setting(field, &next);
3532 }
3533 }
3534 FieldKind::Uint | FieldKind::Text => {
3535 let current = field.current(&self.config);
3536 self.config_panel.begin_edit(¤t);
3537 }
3538 }
3539 }
3540
3541 /// Commit the in-progress numeric edit (issue #279): write the buffered
3542 /// value to the selected field and apply it live. Clearing the buffer
3543 /// reads as `0` (see [`ConfigPanel::take_edit`]).
3544 pub fn commit_settings_edit(&mut self) {
3545 let Some(field) = self.config_panel.selected_field() else {
3546 self.config_panel.cancel_edit();
3547 return;
3548 };
3549 if let Some(value) = self.config_panel.take_edit() {
3550 // A cleared numeric input is a valid zero; a cleared text input is a
3551 // legitimate empty / unset value.
3552 let value = if field.kind() == FieldKind::Uint && value.is_empty() {
3553 "0".to_string()
3554 } else {
3555 value
3556 };
3557 self.apply_setting(field, &value);
3558 }
3559 }
3560
3561 /// Persist `field = value` into the active layer's TOML file and apply the
3562 /// change live (issue #279). The write targets the per-project `.gwm.toml`
3563 /// or the user-global `config.toml` per the panel's layer selector; on
3564 /// success the config is reloaded, the theme re-resolved, the sidebar
3565 /// position re-seeded and the resolved-rows snapshot refreshed so the
3566 /// `All` tab and the source attribution track the edit. Every fallible
3567 /// step routes its error to the status line — no `unwrap` on this path.
3568 pub fn apply_setting(&mut self, field: SettingField, value: &str) {
3569 // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
3570 // with a stale selection that path is the *previously* active repo, so
3571 // refuse rather than write settings into the wrong repo (#304). Global-layer
3572 // edits are repo-independent and stay allowed.
3573 if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
3574 self.status = "workspace: selected repo is unavailable — can't edit its project config".into();
3575 return;
3576 }
3577 let path = match self.config_panel.layer {
3578 SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
3579 SettingsLayer::Global => match self.global_path.clone() {
3580 Some(p) => p,
3581 None => {
3582 self.status = "settings: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
3583 return;
3584 }
3585 },
3586 };
3587
3588 // Numeric fields write a TOML integer; choices and free text write a
3589 // TOML string, so a value like `123` / `true` in a shell command or
3590 // worktree pattern is preserved as text rather than coerced (review P2).
3591 let write = match field.kind() {
3592 FieldKind::Uint => crate::config_cli::set_value_at(&path, field.key_path(), value),
3593 FieldKind::Choice | FieldKind::Text => crate::config_cli::set_string_at(&path, field.key_path(), value),
3594 };
3595 if let Err(e) = write {
3596 self.status = format!("settings: {}", e);
3597 return;
3598 }
3599
3600 // Reload the merged config so every live read (open mode, confirm
3601 // countdown) and the re-seeded state below reflect the edit.
3602 match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
3603 Ok(cfg) => self.set_active_config(cfg),
3604 Err(e) => {
3605 self.status = format!("settings saved, but reload failed: {}", e);
3606 return;
3607 }
3608 }
3609 // A Global-layer edit changes config for *every* repo, not just the active
3610 // one — refresh each cached `RepoMeta.config` so navigating to another repo
3611 // doesn't restore the pre-edit global value (Codex review #303 P2). A
3612 // Project-layer edit only touched the active repo's `.gwm.toml`, already
3613 // handled by `set_active_config`.
3614 if self.config_panel.layer == SettingsLayer::Global {
3615 self.reload_workspace_repo_configs();
3616 }
3617 match self.config.theme.resolve() {
3618 Ok(theme) => self.theme = theme,
3619 Err(e) => self.status = format!("theme: {}", e),
3620 }
3621 self.apply_sidebar_config();
3622 // A Settings edit can rewrite the patterns themselves, and the field set is
3623 // derived from them (#418) — refresh it here too, or the form keeps asking
3624 // for a token the pattern no longer carries until the next launch.
3625 self.apply_create_form_fields();
3626 if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
3627 self.config_panel.rows = rows;
3628 }
3629
3630 let mut status = format!(
3631 "set {} = {} ({})",
3632 field.key_path(),
3633 value,
3634 self.config_panel.layer.label()
3635 );
3636 // Surface a shadowed edit: writing global for a key the repo overrides
3637 // leaves the effective value unchanged (repo wins).
3638 if self.config_panel.layer == SettingsLayer::Global
3639 && self.config_panel.field_source(field) == Some(crate::config::ConfigSource::Repo)
3640 {
3641 status.push_str(" — shadowed by .gwm.toml");
3642 }
3643 self.status = status;
3644 }
3645
3646 /// Render the Command Logs transcript as plain text for the clipboard
3647 /// (issue #279, `y`): newest-first, mirroring the overlay's layout
3648 /// (`$ argv`, the outcome line, then the full captured output — not the
3649 /// tail-capped view), entries separated by a blank line. Pure + owned so
3650 /// the format is unit-testable without a clipboard. Empty when no commands
3651 /// have run.
3652 pub fn command_logs_transcript(&self) -> String {
3653 use crate::command_log::CommandStatus;
3654 let mut out = String::new();
3655 for entry in self.command_logs.entries.iter().rev() {
3656 out.push_str(&format!("$ {}\n", entry.command));
3657 let detail = match &entry.status {
3658 CommandStatus::Exited(Some(0)) => format!("→ exit 0 ({} ms)", entry.duration.as_millis()),
3659 CommandStatus::Exited(Some(code)) => format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
3660 CommandStatus::Exited(None) => format!("→ terminated ({} ms)", entry.duration.as_millis()),
3661 CommandStatus::Spawn => "✗ failed to spawn".to_string(),
3662 };
3663 out.push_str(&format!(" {}\n", detail));
3664 for line in entry.output.lines() {
3665 out.push_str(&format!(" {}\n", line));
3666 }
3667 out.push('\n');
3668 }
3669 out.trim_end().to_string()
3670 }
3671
3672 /// Scroll the help overlay down one row, clamped to the renderer-published
3673 /// `help_max_scroll` so it never scrolls past the last line.
3674 pub fn help_scroll_down(&mut self) {
3675 self.help_scroll = (self.help_scroll + 1).min(self.help_max_scroll);
3676 }
3677
3678 /// Scroll the help overlay up one row, clamped at the top.
3679 pub fn help_scroll_up(&mut self) {
3680 self.help_scroll = self.help_scroll.saturating_sub(1);
3681 }
3682
3683 pub fn help_scroll_right(&mut self) {
3684 self.help_x_scroll = (self.help_x_scroll + 1).min(self.help_max_x_scroll);
3685 }
3686
3687 pub fn help_scroll_left(&mut self) {
3688 self.help_x_scroll = self.help_x_scroll.saturating_sub(1);
3689 }
3690
3691 /// Path to launch lazygit on, or `None` if nothing selected or lazygit is missing.
3692 /// The caller drives the actual TUI suspension/restoration around the spawn.
3693 ///
3694 /// Retained for callers that still want the legacy "lazygit only"
3695 /// path; new code should go through [`Self::prepare_git_tui`], which
3696 /// honours the configurable `[git_tui]` block (issue #75).
3697 pub fn launch_lazygit(&mut self) -> Option<PathBuf> {
3698 let path = self.selected()?.path.clone();
3699 if which::which("lazygit").is_err() {
3700 self.status = "lazygit not found in PATH".into();
3701 return None;
3702 }
3703 Some(path)
3704 }
3705
3706 // ---- Configurable launchers (issue #75) ---------------------------------
3707
3708 /// Build the [`LauncherPlan`] for the `l` keybinding. Reads
3709 /// `[git_tui]` from `.gwm.toml` (default `lazygit -p {path}`
3710 /// fullscreen=true) and expands the `{path}` placeholder against
3711 /// the selected worktree. Returns `None` (and sets a status hint)
3712 /// when nothing is selected or the template is malformed.
3713 pub fn prepare_git_tui(&mut self) -> Option<LauncherPlan> {
3714 let Some(wt) = self.selected().cloned() else {
3715 self.status = "nothing selected".into();
3716 return None;
3717 };
3718 let resolved = self.config.git_tui.resolved();
3719 let ctx = LauncherContext {
3720 worktree_path: &wt.path,
3721 base: None,
3722 head: None,
3723 repo_workdir: Some(&self.workdir),
3724 };
3725 match launcher::expand_command(&resolved.command, &ctx) {
3726 Ok(expanded) => Some(LauncherPlan {
3727 expanded,
3728 cwd: wt.path,
3729 fullscreen: resolved.fullscreen,
3730 base: None,
3731 }),
3732 Err(e) => {
3733 self.status = format!("git_tui template error: {}", e);
3734 None
3735 }
3736 }
3737 }
3738
3739 /// Build the [`LauncherPlan`] for the `R` keybinding. Implements the
3740 /// full review contract from issue #75:
3741 ///
3742 /// 1. `[review]` must resolve to a concrete launcher (`command`
3743 /// set, or `tool = "<preset>"` matched).
3744 /// 2. The selected worktree must carry a branch name.
3745 /// 3. The review base is resolved via the documented chain (upstream
3746 /// → `gwm-base` → `[review].default_base` → `"dev"` → `"main"`).
3747 /// 4. When `skip_when_no_changes` is on (default), a zero
3748 /// `git rev-list --count {base}..HEAD` short-circuits with a
3749 /// status-bar hint naming the base.
3750 /// 5. The template is expanded; `{diff}` lazily materialises a
3751 /// tempfile so unused placeholders never spawn `git diff`.
3752 pub fn prepare_review(&mut self) -> Option<LauncherPlan> {
3753 let resolved = match self.config.review.resolved() {
3754 Some(r) => r,
3755 None => {
3756 self.status = "review tool not configured — set [review] in .gwm.toml".into();
3757 return None;
3758 }
3759 };
3760 let Some(wt) = self.selected().cloned() else {
3761 self.status = "nothing selected".into();
3762 return None;
3763 };
3764 let Some(head) = wt.branch.clone() else {
3765 self.status = "selected worktree has no branch — cannot review".into();
3766 return None;
3767 };
3768
3769 let base = launcher::resolve_review_base(&self.repo, &head, self.config.review.default_base.as_deref());
3770
3771 if self.config.review.skip_when_no_changes {
3772 let n = launcher::count_commits_ahead(&wt.path, &base, "HEAD");
3773 if n == 0 {
3774 self.status = format!("no changes to review (already at {})", base);
3775 return None;
3776 }
3777 }
3778
3779 let ctx = LauncherContext {
3780 worktree_path: &wt.path,
3781 base: Some(&base),
3782 head: Some(&head),
3783 repo_workdir: Some(&self.workdir),
3784 };
3785 match launcher::expand_command(&resolved.command, &ctx) {
3786 Ok(expanded) => {
3787 if self.config.review.has_shadowed_tool() {
3788 self.status = format!("review: command overrides tool — running {}", base);
3789 } else {
3790 self.status = format!("review: {} vs {}", head, base);
3791 }
3792 Some(LauncherPlan {
3793 expanded,
3794 cwd: wt.path,
3795 fullscreen: resolved.fullscreen,
3796 base: Some(base),
3797 })
3798 }
3799 Err(e) => {
3800 self.status = format!("review template error: {}", e);
3801 None
3802 }
3803 }
3804 }
3805
3806 pub fn selected(&self) -> Option<&WorktreeInfo> {
3807 // The visible list is the filtered subset, so the table state's index is
3808 // into `filtered_indices()`, not the raw `worktrees` vec. Resolving the
3809 // selection means hopping through the filter map.
3810 //
3811 // `selected` keeps its `&self` signature so callers holding a
3812 // shared borrow (e.g. `ui.rs` render path, `copy_path_to_status`)
3813 // don't have to upgrade. `snapshot_indices` reads the cache when
3814 // it's warm (which the per-frame render path guarantees, since
3815 // the table renderer calls `filtered_indices` first) and falls
3816 // back to a fresh compute when it isn't.
3817 let i = self.list_state.selected()?;
3818 let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
3819 let original = *filtered.get(i)?;
3820 self.worktrees.get(original)
3821 }
3822
3823 pub fn copy_path_to_status(&mut self) {
3824 if let Some(w) = self.selected() {
3825 self.status = format!("path: {}", w.path.display());
3826 }
3827 }
3828
3829 /// Reveal the selected worktree's directory in the OS file manager.
3830 /// macOS: `open`, Linux: `xdg-open`, Windows: `explorer`. Used by
3831 /// `resolve_open_target` when the config picks `mode = "finder"`,
3832 /// and by the event loop directly to spawn the opener.
3833 pub fn open_selected_in_finder(&mut self) {
3834 let path = match self.selected() {
3835 Some(w) => w.path.clone(),
3836 None => {
3837 self.status = "nothing selected".into();
3838 return;
3839 }
3840 };
3841 let opener = if cfg!(target_os = "macos") {
3842 "open"
3843 } else if cfg!(target_os = "windows") {
3844 "explorer"
3845 } else {
3846 "xdg-open"
3847 };
3848 match std::process::Command::new(opener).arg(&path).spawn() {
3849 Ok(_) => self.status = format!("opened {} in {}", path.display(), opener),
3850 Err(e) => self.status = format!("failed to open {}: {}", path.display(), e),
3851 }
3852 }
3853
3854 /// Return the path that the `Y: yank-path` key should push into the
3855 /// system clipboard, or `None` when nothing is selected. Pure — the
3856 /// shell-out is handled by the event loop.
3857 pub fn yank_selected_path(&self) -> Option<PathBuf> {
3858 self.selected().map(|w| w.path.clone())
3859 }
3860
3861 /// Return the branch name for the `y: yank-branch-name` key (#290).
3862 pub fn yank_selected_branch(&self) -> Option<String> {
3863 self.selected()?.branch.clone()
3864 }
3865
3866 /// Return the worktree slug/name for the `w: yank-worktree-name` key (#290).
3867 pub fn yank_selected_worktree_name(&self) -> Option<String> {
3868 self.selected().map(|w| w.name.clone())
3869 }
3870
3871 /// Signal the event loop to print the selected worktree path to stdout
3872 /// before quitting (`e: exit-to-worktree`, #290). The loop checks
3873 /// `should_exit_to` after `can_quit_now` to emit the path.
3874 pub fn exit_to_worktree(&mut self) {
3875 let Some(path) = self.selected().map(|w| w.path.clone()) else {
3876 self.status = "no worktree selected".into();
3877 return;
3878 };
3879 self.should_exit_to = Some(path);
3880 self.should_quit = true;
3881 }
3882
3883 /// Request an off-thread `git pull` of the selected worktree's branch
3884 /// (#290). Coalesces if a pull is already in flight, and refuses to start
3885 /// while a *different* mutating task (sync / bootstrap / push / rename /
3886 /// create / delete) runs in the same worktree (Codex review on PR #292).
3887 pub fn request_pull(&mut self) {
3888 let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
3889 self.status = "no worktree selected".into();
3890 return;
3891 };
3892 if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Pull) {
3893 self.status = self.busy_mutation_status("pulling");
3894 return;
3895 }
3896 let Some(generation) = self.tasks.request(TaskKind::Pull) else {
3897 return;
3898 };
3899 self.spinner.reset();
3900 self.status = TaskKind::Pull.loading_label().into();
3901 self.spawn_pull(generation, path, name);
3902 }
3903
3904 /// Status line shown when a mutating verb is pressed while another mutating
3905 /// task is in flight. `action` is the gerund of the blocked verb
3906 /// (e.g. "pulling", "pushing").
3907 fn busy_mutation_status(&self, action: &str) -> String {
3908 match self.tasks.mutating_loading_label() {
3909 Some(label) => format!("finish {} before {}", label.trim_end_matches('…'), action),
3910 None => format!("finish current task before {}", action),
3911 }
3912 }
3913
3914 fn spawn_pull(&self, generation: u64, path: PathBuf, name: String) {
3915 let tx = self.task_tx.clone();
3916 std::thread::spawn(move || {
3917 let mut cmd = std::process::Command::new("git");
3918 cmd.args(["pull"]).current_dir(&path);
3919 // Route through the command-log chokepoint so `git pull` lands in the
3920 // Command Logs modal (#290) — a user-triggered mutating op the user
3921 // expects to find in the transcript.
3922 let result = crate::command_log::run_logged(&mut cmd, "git pull".to_string())
3923 .map_err(|e| e.to_string())
3924 .and_then(|out| {
3925 if out.status.success() {
3926 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
3927 } else {
3928 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
3929 }
3930 });
3931 let _ = tx.send(TaskMsg::Pull(generation, name, result));
3932 });
3933 }
3934
3935 /// Request an off-thread `git push` of the selected worktree's branch
3936 /// (#290). Coalesces if a push is already in flight, and refuses to start
3937 /// while a *different* mutating task runs in the same worktree (Codex review
3938 /// on PR #292).
3939 pub fn request_push(&mut self) {
3940 let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
3941 self.status = "no worktree selected".into();
3942 return;
3943 };
3944 if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Push) {
3945 self.status = self.busy_mutation_status("pushing");
3946 return;
3947 }
3948 let Some(generation) = self.tasks.request(TaskKind::Push) else {
3949 return;
3950 };
3951 self.spinner.reset();
3952 self.status = TaskKind::Push.loading_label().into();
3953 self.spawn_push(generation, path, name);
3954 }
3955
3956 fn spawn_push(&self, generation: u64, path: PathBuf, name: String) {
3957 let tx = self.task_tx.clone();
3958 std::thread::spawn(move || {
3959 let mut cmd = std::process::Command::new("git");
3960 cmd.args(["push"]).current_dir(&path);
3961 // Route through the command-log chokepoint so `git push` lands in the
3962 // Command Logs modal (#290). git writes its progress to stderr, so the
3963 // status line still reads stderr on success.
3964 let result = crate::command_log::run_logged(&mut cmd, "git push".to_string())
3965 .map_err(|e| e.to_string())
3966 .and_then(|out| {
3967 if out.status.success() {
3968 Ok(String::from_utf8_lossy(&out.stderr).trim().to_string())
3969 } else {
3970 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
3971 }
3972 });
3973 let _ = tx.send(TaskMsg::Push(generation, name, result));
3974 });
3975 }
3976
3977 /// Open the rename modal for the selected worktree (`c`, #290). Reuses the
3978 /// Create form (Type / Issue / Desc) pre-filled by parsing the current
3979 /// branch name, so renaming is symmetric with creating. A branch that does
3980 /// not match the `<type>/#<issue>-<desc>` pattern can't be decomposed into
3981 /// the form, so the modal refuses to open and explains why.
3982 pub fn enter_edit_worktree(&mut self) {
3983 // Issue #479 replaces an accidental protection with a deliberate one. The
3984 // main checkout's branch is normally unparseable (`main`, `dev`), so the
3985 // refusal below used to turn this modal away from it — for the wrong
3986 // reason, but with the right result. Free-form mode parses nothing, so that
3987 // side effect is gone: state the guard, the same shape
3988 // `enter_confirm_delete` already uses. Renaming the main worktree means
3989 // renaming the repo's default branch, and `git worktree move` cannot move
3990 // the main checkout anyway.
3991 if self.selected().is_some_and(|w| w.is_main) {
3992 self.status = "cannot rename the main worktree".into();
3993 return;
3994 }
3995 let Some((branch, path)) = self
3996 .selected()
3997 .and_then(|w| w.branch.clone().map(|b| (b, w.path.clone())))
3998 else {
3999 self.status = "no branch to rename (detached HEAD or nothing selected)".into();
4000 return;
4001 };
4002 // Issue #417: the form rebuilds the triple this repo's own
4003 // `worktree.branch_pattern` writes, so the branch is read back with it.
4004 // `self.repo_name`, not the workdir basename: in a workspace, two repos
4005 // sharing a basename are disambiguated for display (#304) and every
4006 // formatter call in this flow expands `{repo}` with that name. Parser and
4007 // formatter agreeing is the whole of #417, so they read the same name.
4008 //
4009 // Issue #478: the directory is read too. A segment `branch_pattern`
4010 // freezes is not in the branch, and `path_pattern` may still carry what
4011 // `gwm create` was given — rebuilding from the branch alone renamed the
4012 // directory's own components on every edit.
4013 let Some(spec) = crate::naming::worktree_spec(
4014 &self.config,
4015 &self.repo_name,
4016 &branch,
4017 path.file_name().and_then(|name| name.to_str()),
4018 ) else {
4019 // Issue #416 refused here: a name the user chose on purpose has none of
4020 // the `<type>/#<issue>-<desc>` triple the form rebuilds, so the form said
4021 // it did not apply. Issue #479 supplies the missing mode instead — the
4022 // form now collects a free-form name too, which is exactly the shape this
4023 // worktree already has, so there is nothing left to turn away.
4024 //
4025 // Prefilled with the current branch verbatim: the common edit is a small
4026 // one (`spike-redis` becoming `spike-valkey`), and it is also the only
4027 // non-guess available, since a free-form name carries no segments.
4028 //
4029 // Issue #417 deliberately did NOT fold "type not configured" into this
4030 // arm: `{type}` stays `[a-z]+`, so `zzz/#7-thing` still parses and the
4031 // type-index lookup below refuses it with the precise reason.
4032 self.create_form.reset();
4033 self.create_form.mode = Mode::Freeform;
4034 self.create_form.name = branch.clone();
4035 self.create_form.field = Field::Name;
4036 self.edit_original_branch = Some(branch);
4037 self.edit_original_path = Some(path);
4038 self.edit_failure = None;
4039 self.view = View::Edit;
4040 return;
4041 };
4042 // **The form must not open on a segment whose current value it cannot show.**
4043 //
4044 // Issue #418 rescoped the guard that stands here rather than removing it,
4045 // and both earlier readings of it were wrong in the same direction. The
4046 // original (Codex review on PR #476) refused whenever a segment came back
4047 // empty, which took the form away from a repo whose patterns simply do not
4048 // carry that segment: nothing writes it, nothing asks for it, its absence
4049 // is not a dead end. Deleting the guard outright then went too far the
4050 // other way (Codex review on PR #492, second pass): `worktree_spec` reads
4051 // the branch and the directory name and never parses `base`, so a segment
4052 // carried only by `base` comes back empty **while its value sits on disk**.
4053 // Under `base = ".../wt/{type}"`, a worktree at `.../wt/fix/my-desc` would
4054 // have opened with the selector defaulted to the first configured type, and
4055 // submitting without touching it would have moved the worktree to
4056 // `.../wt/feat/`. "Not recovered" is not "not there".
4057 //
4058 // So the rule is neither "empty" nor "absent from the patterns" but the
4059 // conjunction: a segment some pattern **writes** that the parse did **not**
4060 // recover. There the form would show a default it did not read and the
4061 // submit would write it over the real one.
4062 let required = self.required_segments();
4063 if let Some(missing) = required.iter().find(|segment| match **segment {
4064 "type" => spec.type_.is_empty(),
4065 "issue" => spec.issue.is_empty(),
4066 _ => spec.desc.is_empty(),
4067 }) {
4068 self.status = format!(
4069 "'{}' carries {{{}}} only where this form cannot read it back (check worktree.base), so renaming would overwrite it — rename with git, or write {{{}}} into worktree.branch_pattern or worktree.path_pattern",
4070 crate::naming::sanitise_for_terminal(&branch),
4071 missing,
4072 missing
4073 );
4074 return;
4075 }
4076 // Refuse rather than silently preselect type index 0: a branch whose
4077 // parsed type isn't configured (config change, manual branch) would
4078 // otherwise be renamed to the first configured type on Enter (Codex
4079 // review on PR #292). Only reachable when some pattern carries `{type}`,
4080 // the guard above having taken the unrecovered case; where none does, the
4081 // value is discarded by every expansion and index 0 is inert.
4082 let type_index = match self.branch_types.iter().position(|t| t.name == spec.type_) {
4083 Some(index) => index,
4084 None if !required.contains(&"type") => 0,
4085 None => {
4086 self.status = format!("branch type '{}' is not configured; can't rename here", spec.type_);
4087 return;
4088 }
4089 };
4090 self.create_form.reset();
4091 self.create_form.type_index = type_index;
4092 self.create_form.issue = spec.issue;
4093 self.create_form.desc = spec.desc;
4094 // The last field in pattern order: the usual rename edits the trailing
4095 // description, and naming `Field::Desc` here focused an input the renderer
4096 // does not draw on a pattern without one (#418).
4097 self.create_form.field = self.create_form.last_field();
4098 self.edit_original_branch = Some(branch);
4099 self.edit_original_path = Some(path);
4100 self.edit_failure = None;
4101 self.view = View::Edit;
4102 }
4103
4104 /// The [`WorktreeName`] the form currently describes, in whichever mode it is
4105 /// in (#479). The rename target is built the same way the create target is,
4106 /// so the four conversions of issue #479 are two code paths, not four:
4107 /// `WorktreeName` already knows how each shape becomes a branch and a
4108 /// directory.
4109 fn worktree_name_from_form(&self) -> std::result::Result<WorktreeName, String> {
4110 match self.create_form.mode {
4111 Mode::Freeform => WorktreeName::freeform(&self.create_form.name).map_err(|e| e.to_string()),
4112 Mode::Structured => {
4113 let type_ = self
4114 .branch_types
4115 .get(self.create_form.type_index)
4116 .map(|t| t.name.clone())
4117 .unwrap_or_default();
4118 // Validated only against the segments the patterns actually carry
4119 // (#418): a `{type}/{desc}` repo writes no issue number anywhere, so
4120 // refusing an empty one refused a form the user had filled completely.
4121 BranchSpec::new_with_required(
4122 type_,
4123 self.create_form.issue.clone(),
4124 self.create_form.desc.clone(),
4125 &self.branch_types,
4126 &self.required_segments(),
4127 )
4128 .map(WorktreeName::Structured)
4129 .map_err(|e| e.to_string())
4130 }
4131 }
4132 }
4133
4134 /// What the rename would write: `(branch, directory name)`.
4135 ///
4136 /// Public because the renderer needs it. The preview and the submit have to
4137 /// derive from **one** place or they drift, and drifting is not hypothetical:
4138 /// both live previews used to expand a hardcoded `<type>/#<issue>-<desc>`
4139 /// while the submit expanded the repo's real patterns, so the modal showed a
4140 /// branch it was not going to write (found by hand on #476). Free-form mode
4141 /// is the same trap one mode over, since there the branch is the name and no
4142 /// pattern is expanded at all.
4143 ///
4144 /// `Err` carries the reason the form cannot compose a target yet — an
4145 /// incomplete triple, a refused free-form name, or a `base` that uses a
4146 /// placeholder a free-form name has no value for.
4147 pub fn edit_target(&self) -> std::result::Result<(String, String), String> {
4148 let name = self.worktree_name_from_form()?;
4149 let branch = name
4150 .branch_name(&self.config.worktree, &self.repo_name)
4151 .map_err(|e| e.to_string())?;
4152 let dirname = name
4153 .worktree_dirname(&self.config.worktree, &self.repo_name)
4154 .map_err(|e| e.to_string())?;
4155 Ok((branch, dirname))
4156 }
4157
4158 /// Seed the mode `toggle_mode` is about to switch *into*, for the rename
4159 /// modal (#479). Called before the flip, so `self.create_form.mode` is still
4160 /// the mode being left.
4161 ///
4162 /// Only ever fills an **empty** buffer: #416 keeps both modes' buffers side
4163 /// by side precisely so a round trip loses nothing, and overwriting what the
4164 /// user already typed would defeat that.
4165 ///
4166 /// Create seeds nothing — there is no worktree to seed from.
4167 ///
4168 /// Leaving structured, the free-form name is seeded with the current branch
4169 /// verbatim: it is the only non-guess available, and the common edit is a
4170 /// small one.
4171 ///
4172 /// Leaving free-form, the description is seeded with `kebab` of the name and
4173 /// the issue is left empty, because a free-form name carries no issue number
4174 /// and inventing one would be a guess. `kebab`'s output is by construction
4175 /// either `DESC_RE`-valid or empty, so this seed can never dead-end the form
4176 /// on a value it would then refuse to submit.
4177 ///
4178 /// Truncated to `MAX_DESC_LEN` (Codex review on PR #485): a free-form name
4179 /// may run to `MAX_DIR_COMPONENT_BYTES`, `push_char` is the only other place
4180 /// that bound is applied, and `BranchSpec` has no length check to catch the
4181 /// overflow downstream — so seeding unbounded wrote a description no
4182 /// keystroke could have produced, and the cap is what keeps
4183 /// `<type>/#<issue>-<desc>` inside git's ref limit.
4184 ///
4185 /// The **type** is not seeded and is not left unset either: it stays on
4186 /// whatever the selector shows, which is the first configured type on a form
4187 /// that was just opened. That is the create form's own contract, the value is
4188 /// on screen in the selector, and the preview spells out the branch it
4189 /// produces — so a promotion into the pattern is stated rather than silent.
4190 fn seed_toggled_mode(&mut self) {
4191 if self.view != View::Edit {
4192 return;
4193 }
4194 match self.create_form.mode {
4195 Mode::Structured if self.create_form.name.is_empty() => {
4196 if let Some(branch) = self.edit_original_branch.clone() {
4197 self.create_form.name = branch;
4198 }
4199 }
4200 Mode::Freeform if self.create_form.desc.is_empty() => {
4201 self.create_form.desc = crate::naming::kebab(&self.create_form.name)
4202 .chars()
4203 .take(crate::tui::state::create_form::MAX_DESC_LEN)
4204 .collect();
4205 }
4206 _ => {}
4207 }
4208 }
4209
4210 /// `true` while the async rename worker is in flight (#290). The run loop
4211 /// swallows input in `View::Edit` while this holds, mirroring create.
4212 pub fn is_edit_worktree_loading(&self) -> bool {
4213 self.tasks.is_loading(TaskKind::EditWorktree)
4214 }
4215
4216 /// Cancel the rename modal (`Esc`): drop the captured original branch/path
4217 /// and return to the list without touching git.
4218 pub fn cancel_edit_worktree(&mut self) {
4219 self.edit_original_branch = None;
4220 self.edit_original_path = None;
4221 self.edit_failure = None;
4222 self.create_form.reset();
4223 self.view = View::List;
4224 }
4225
4226 /// Whether this submit changes a segment **nothing writes**, setting
4227 /// `edit_failure` with the reason when it does (#417, Codex review on
4228 /// PR #476). Structured mode only: see the call site for why free-form has
4229 /// no segments to freeze.
4230 ///
4231 /// Returns its verdict rather than leaving the caller to read `edit_failure`
4232 /// back (Codex review on PR #485). That field survives a failed submit, so
4233 /// reading it as "did the guard refuse" also caught every *earlier* failure
4234 /// and stopped a submit the user had just corrected, wedging the form shut
4235 /// until it was closed and reopened.
4236 fn refuse_unwritable_segment_change(&mut self, type_: &str) -> bool {
4237 // Issue #417 / Codex review on PR #476: a segment **nothing writes** is not
4238 // editable here, because there is nowhere to put the new value. The submit
4239 // would rebuild the same branch at the same path and close the form having
4240 // changed nothing, so say no instead.
4241 //
4242 // The question is the *formatter's*, not the parser's — asking the parser
4243 // is what made the first two versions of this guard wrong. It is not "can a
4244 // new value be read back", it is "will a new value be written anywhere", and
4245 // `expand_placeholders` writes a token wherever it appears. So the test is
4246 // whether any of the three patterns it expands carries that token:
4247 //
4248 // - `branch_pattern`, the obvious one;
4249 // - `path_pattern` (Kylian, validating by hand): `feat/#{issue}-{desc}` will
4250 // say `feat` whatever the form holds, so under that config the *directory*
4251 // is where this worktree's type lives, and refusing meant a worktree
4252 // created as `fix` could never become `docs`;
4253 // - `[worktree].base` (Codex review, tenth pass): `worktree_path` feeds it
4254 // the triple too, so a `base` of `.../{type}` sorts worktrees into
4255 // per-type directories and changing the type moves the worktree between
4256 // them.
4257 //
4258 // In the last two cases the branch does not change at all, which
4259 // `rename_worktree` handles as a path-only edit — it skips every ref
4260 // mutation, local and remote — and the preview states it on screen by
4261 // showing the branch unchanged.
4262 //
4263 // Scoped to a segment the user actually changed, so `feat/#{issue}-{desc}`,
4264 // whose rename worked before #417, keeps renaming its issue and description.
4265 //
4266 // Compared against what the form was **opened with**, not against the
4267 // pattern's literal (#478): that value may have come from the worktree's
4268 // directory rather than from the pattern. And against the *form's* fields
4269 // rather than the `BranchSpec`, because a frozen description need not be
4270 // canonical — `DESC_RE` accepts `fixed-`, and `kebab` would strip that
4271 // trailing dash on the way into the spec, making every submit look like a
4272 // change and locking the form shut.
4273 // The same predicate `editable_segments` computes for the form's field set
4274 // (#418), asked once rather than open-coded twice: this guard and the field
4275 // set have to agree on "does any pattern write this", and two spellings of
4276 // one question are two things to keep in step.
4277 //
4278 // ⚠️ I wrote here that this made the guard unreachable in structured mode,
4279 // reasoning that the form presents no field for a segment nothing writes so
4280 // its value cannot differ. Wrong, and the sixth review pass found it: the
4281 // buffer behind a hidden field keeps its default, which is not the parsed
4282 // value, so the comparison below fired on a change nobody made. The skip
4283 // added there is what actually makes the two agree.
4284 let written = self.required_segments();
4285 let writes = |segment: &str| written.contains(&segment);
4286 let opened_with = self.edit_original_branch.as_deref().and_then(|branch| {
4287 crate::naming::worktree_spec(
4288 &self.config,
4289 &self.repo_name,
4290 branch,
4291 self
4292 .edit_original_path
4293 .as_ref()
4294 .and_then(|path| path.file_name())
4295 .and_then(|name| name.to_str()),
4296 )
4297 });
4298 if let Some(opened_with) = opened_with.as_ref() {
4299 for segment in ["type", "issue", "desc"] {
4300 if writes(segment) {
4301 continue;
4302 }
4303 // A segment the form does not present has no value to defend (Codex
4304 // review on PR #492, sixth pass, and it disproves the note two commits
4305 // back claiming this guard had become unreachable in structured mode).
4306 // The buffers behind a hidden field keep their defaults — `type_index`
4307 // points at the first configured type — so comparing them against what
4308 // the branch parsed reads as a change the user never made, and refuses
4309 // every structured rename on a pattern set that omits `{type}`. The
4310 // field is not on screen, so nothing clears it either.
4311 if !self.create_form.fields().contains(&match segment {
4312 "type" => Field::Type,
4313 "issue" => Field::Issue,
4314 _ => Field::Desc,
4315 }) {
4316 continue;
4317 }
4318 let (submitted, was) = match segment {
4319 "type" => (type_, opened_with.type_.as_str()),
4320 "issue" => (self.create_form.issue.as_str(), opened_with.issue.as_str()),
4321 _ => (self.create_form.desc.as_str(), opened_with.desc.as_str()),
4322 };
4323 if submitted != was {
4324 // No value in the message, on purpose: `LoaderWidget` renders one
4325 // unwrapped line, so a message whose length follows a user-supplied
4326 // value clips at an arbitrary point. This one is a fixed 36
4327 // characters whatever the branch holds, and the value it would have
4328 // quoted is on screen anyway, in the `From :` row above (found
4329 // validating by hand).
4330 let _ = was;
4331 self.edit_failure = Some(format!("branch_pattern has no {{{}}} to write", segment));
4332 return true;
4333 }
4334 }
4335 }
4336 false
4337 }
4338
4339 /// Submit the rename from the `View::Edit` modal (#290). Composes the new
4340 /// branch name + worktree path from the form, then spawns an off-thread
4341 /// worker that renames the local branch (`git branch -m`), the remote
4342 /// branch when it exists (`git push origin :<old> <new>:<new>` + re-track),
4343 /// and moves the worktree directory (`git worktree move`). A no-op rename
4344 /// (nothing changed) just closes the modal.
4345 pub fn submit_edit_worktree(&mut self) -> Result<()> {
4346 let type_ = self
4347 .branch_types
4348 .get(self.create_form.type_index)
4349 .map(|t| t.name.clone())
4350 .unwrap_or_default();
4351 // Issue #479: the frozen-segment guard below is about segments, and a
4352 // free-form name has none — the branch is the name, no pattern is expanded,
4353 // so nothing can be frozen out of reach. Skipping it is stated here rather
4354 // than left to fall out of `opened_with` being `None` for an unparseable
4355 // branch, which is how it would happen by accident.
4356 //
4357 // Converting a free-form worktree *into* the pattern therefore bypasses the
4358 // guard, and that is right: the guard refuses to change a value the form was
4359 // opened with when nothing can write it, and a worktree opened free-form was
4360 // opened with no such value to contradict.
4361 if self.create_form.mode == Mode::Structured && self.refuse_unwritable_segment_change(&type_) {
4362 return Ok(());
4363 }
4364 // Issue #479: composed through `WorktreeName`, the same seam
4365 // `submit_create` uses, so free-form and structured targets are built by
4366 // one mechanism and the preview can show exactly what this will write.
4367 let name = match self.worktree_name_from_form() {
4368 Ok(n) => n,
4369 Err(e) => {
4370 self.edit_failure = Some(e);
4371 return Ok(());
4372 }
4373 };
4374 // Reported in the form rather than returned as `Err`: these are user- and
4375 // config-driven refusals (an incomplete triple, a name git will not take, a
4376 // `base` a free-form name has no value for), and an `Err` out of here tears
4377 // down the alternate screen instead of telling the user what to fix.
4378 let (new_branch, new_name) = match self.edit_target() {
4379 Ok(target) => target,
4380 Err(e) => {
4381 self.edit_failure = Some(e);
4382 return Ok(());
4383 }
4384 };
4385 let new_path = match name.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir) {
4386 Ok(p) => p,
4387 Err(e) => {
4388 self.edit_failure = Some(e.to_string());
4389 return Ok(());
4390 }
4391 };
4392
4393 let Some(old_branch) = self.edit_original_branch.clone() else {
4394 self.cancel_edit_worktree();
4395 return Ok(());
4396 };
4397 let Some(old_path) = self.edit_original_path.clone() else {
4398 self.cancel_edit_worktree();
4399 return Ok(());
4400 };
4401
4402 // Nothing changed — close without shelling out to git.
4403 if new_branch == old_branch && new_path == old_path {
4404 self.status = "no change".into();
4405 self.cancel_edit_worktree();
4406 return Ok(());
4407 }
4408
4409 if self.tasks.has_mutating_task_in_flight() {
4410 if let Some(label) = self.tasks.mutating_loading_label() {
4411 self.status = format!("finish {} before renaming", label.trim_end_matches('…'));
4412 } else {
4413 self.status = "finish current task before renaming".into();
4414 }
4415 return Ok(());
4416 }
4417 let Some(generation) = self.tasks.request(TaskKind::EditWorktree) else {
4418 return Ok(());
4419 };
4420 self.edit_failure = None;
4421 self.spinner.reset();
4422 self.status = TaskKind::EditWorktree.loading_label().into();
4423 self.spawn_edit_worktree(
4424 generation,
4425 old_branch,
4426 old_path,
4427 new_branch,
4428 new_path,
4429 new_name,
4430 self.workdir.clone(),
4431 );
4432 Ok(())
4433 }
4434
4435 // The rename worker takes each piece of the edit as an owned, `Send`
4436 // parameter because only owned data may cross the `thread::spawn` boundary
4437 // (`self` / `git2::Repository` are not `Send`) — the same flat signature the
4438 // other `spawn_*` workers use. Bundling them into a struct would just add an
4439 // indirection between the call site and the move-closure for no gain, so the
4440 // arg count is deliberate.
4441 #[allow(clippy::too_many_arguments)]
4442 fn spawn_edit_worktree(
4443 &self,
4444 generation: u64,
4445 old_branch: String,
4446 old_path: PathBuf,
4447 new_branch: String,
4448 new_path: PathBuf,
4449 new_name: String,
4450 workdir: PathBuf,
4451 ) {
4452 let tx = self.task_tx.clone();
4453 std::thread::spawn(move || {
4454 let result = crate::worktree::rename_worktree(&workdir, &old_path, &old_branch, &new_path, &new_branch)
4455 .map(|remote_renamed| EditWorktreeResult {
4456 new_branch,
4457 new_path,
4458 new_name,
4459 remote_renamed,
4460 })
4461 .map_err(|e| e.to_string());
4462 let _ = tx.send(TaskMsg::EditWorktree(generation, result));
4463 });
4464 }
4465
4466 /// Open the selected worktree in a new multiplexer pane/tab (`t`, #290).
4467 /// Detects tmux / zellij at runtime via environment variables; prints a
4468 /// status message when no supported multiplexer is active.
4469 pub fn open_in_mux_pane(&mut self) {
4470 use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
4471 let Some(w) = self.selected() else {
4472 self.status = "no worktree selected".into();
4473 return;
4474 };
4475 let path = w.path.clone();
4476 let name = w.name.clone();
4477 // `mux_pane` promises a pane, so split the current pane (tmux
4478 // `split-window` / zellij `new-pane`) rather than opening a new
4479 // window/tab (Codex review on PR #292).
4480 let cmd = if detect_tmux(std::env::var("TMUX").ok()) {
4481 build_tmux_command(&name, &path, SpawnMode::Split)
4482 } else if detect_zellij(std::env::var("ZELLIJ").ok()) {
4483 build_zellij_command(&name, &path, SpawnMode::Split)
4484 } else {
4485 self.status = "no multiplexer detected ($TMUX / $ZELLIJ not set)".into();
4486 return;
4487 };
4488 let bin = cmd[0].as_str();
4489 match std::process::Command::new(bin).args(&cmd[1..]).spawn() {
4490 Ok(_) => self.status = format!("opened {} in new pane", name),
4491 Err(e) => self.status = format!("mux-pane failed: {}", e),
4492 }
4493 }
4494
4495 /// Resolve what the `o` key should do for the currently selected
4496 /// worktree. Returns `None` when nothing is selected (the event loop
4497 /// surfaces a status message in that case). The exact command is
4498 /// resolved once here (config override > env var > hardcoded fallback)
4499 /// so the event loop never has to reason about precedence.
4500 pub fn resolve_open_target(&self) -> Option<OpenTarget> {
4501 let path = self.selected()?.path.clone();
4502 Some(match self.config.tui.open.mode {
4503 TuiOpenMode::Shell => OpenTarget::Shell {
4504 path,
4505 command: resolve_shell_command(&self.config.tui.open),
4506 },
4507 TuiOpenMode::Editor => OpenTarget::Editor {
4508 path,
4509 command: resolve_editor_command(&self.config.tui.open),
4510 },
4511 TuiOpenMode::Finder => OpenTarget::Finder { path },
4512 })
4513 }
4514
4515 pub fn toggle_delete_branch(&mut self) {
4516 self.delete_branch_on_remove = !self.delete_branch_on_remove;
4517 self.status = format!("delete branch on remove: {}", self.delete_branch_on_remove);
4518 }
4519
4520 // ---- Create flow ---------------------------------------------------------
4521
4522 pub fn enter_create(&mut self) {
4523 self.view = View::Create;
4524 self.create_form.reset();
4525 self.create_failure = None;
4526 // Open focused on the first field the user types into rather than the
4527 // cycle-only Type field (#217 UX): the first keypress then edits text
4528 // instead of being a silent no-op on Type. The type keeps its `reset()`
4529 // default and stays reachable via Shift-Tab / the field rotation.
4530 //
4531 // Asked of the form rather than named here (#418): on a pattern that
4532 // writes no issue number, `Field::Issue` focused an input the renderer
4533 // does not draw, so the first keypress went nowhere at all.
4534 self.create_form.field = self.create_form.entry_field();
4535 self.status = format!("{} — esc: cancel", self.structured_form_instruction());
4536 }
4537
4538 pub fn create_next_field(&mut self) {
4539 self.create_form.next_field();
4540 }
4541
4542 pub fn create_prev_field(&mut self) {
4543 self.create_form.prev_field();
4544 }
4545
4546 pub fn create_next_type(&mut self) {
4547 self.create_form.next_type(self.branch_types.len());
4548 }
4549
4550 pub fn create_prev_type(&mut self) {
4551 self.create_form.prev_type(self.branch_types.len());
4552 }
4553
4554 pub fn create_push_char(&mut self, c: char) {
4555 self.create_form.push_char(c);
4556 }
4557
4558 pub fn create_pop_char(&mut self) {
4559 self.create_form.pop_char();
4560 }
4561
4562 /// Handle one key in the create overlay and report what the run loop must
4563 /// do next. Extracted from the inline `View::Create` match (issue #217)
4564 /// so the input path — typing, type cycling, submit/cancel — is
4565 /// unit-testable rather than only reachable through a live terminal.
4566 ///
4567 /// `h` / `l` mirror the `←` / `→` horizontal type selector, but **only**
4568 /// when the Type field is focused; on a text field they are literal input
4569 /// so the letters are never swallowed.
4570 pub fn handle_create_key(&mut self, key: KeyEvent) -> CreateKey {
4571 if self.is_create_worktree_loading() {
4572 return CreateKey::Handled;
4573 }
4574 let on_type = self.create_form.field == Field::Type;
4575 // Typing is RESERVED on the text fields (Codex review #456): a modal
4576 // rebind like `cancel = ["Backspace"]` (or `= ["q"]`) resolved before
4577 // the typing fallback, so it stole the eraser / typed characters.
4578 // Printable keys and Backspace route to the field first — the palette
4579 // convention; modal verbs act through non-printable keys (Ctrl-
4580 // modified chars still fall through to the modal resolution).
4581 if !on_type
4582 && !key
4583 .modifiers
4584 .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT)
4585 {
4586 match key.code {
4587 KeyCode::Backspace => {
4588 self.create_pop_char();
4589 return CreateKey::Handled;
4590 }
4591 // Only the field's LEGITIMATE input is reserved: free text on
4592 // description, digits on issue. A non-digit on the issue field
4593 // still reaches the modal resolution (a printable rebind stays
4594 // honoured, the #293 contract), with the digits-only status hint
4595 // as the unresolved fallback below.
4596 KeyCode::Char(c) if self.create_form.field != Field::Issue || c.is_ascii_digit() => {
4597 self.create_push_char(c);
4598 return CreateKey::Handled;
4599 }
4600 _ => {}
4601 }
4602 }
4603 // #219: verbs resolve through the `create` context. The type-cycling
4604 // verbs (`prev_type` / `next_type`, def arrows + h/l) only fire on the
4605 // Type field; on a text field their keys fall through to literal input
4606 // so `h` / `l` are never swallowed while typing a description.
4607 match self.resolve_modal(KeyContext::Create, key) {
4608 Some(ModalAction::CreateCancel) => return CreateKey::Cancel,
4609 Some(ModalAction::CreateNextField) => self.create_next_field(),
4610 Some(ModalAction::CreatePrevField) => self.create_prev_field(),
4611 // Both modal views that collect a name: create (#416) and rename (#479).
4612 // #474 scoped this to create alone because `draw_edit_worktree` did not
4613 // render the `Name` field and `submit_edit_worktree` did not read it, so
4614 // toggling there sent keystrokes into an invisible buffer; #479 supplies
4615 // both halves, so the verb is no longer suppressed.
4616 // Swallowed outside those modals — an explicit empty arm, not a
4617 // guard on the arm below, because a failing guard would fall through
4618 // to the literal-input fallback and type `t` into the description.
4619 Some(ModalAction::CreateToggleMode) if !matches!(self.view, View::Create | View::Edit) => {}
4620 Some(ModalAction::CreateToggleMode) => {
4621 self.seed_toggled_mode();
4622 self.create_form.toggle_mode();
4623 // Name the LIVE binding, and drop the clause when the verb is
4624 // unbound — same contract as the confirm countdown above: never
4625 // advertise a key that does nothing (Codex review on PR #474).
4626 let back = self
4627 .modal_keymap
4628 .primary_key(ModalAction::CreateToggleMode)
4629 .map(|k| format!(" — {k}: "))
4630 .unwrap_or_default();
4631 self.status = match self.create_form.mode {
4632 Mode::Freeform if back.is_empty() => "free-form: name the worktree anything git accepts".into(),
4633 Mode::Freeform => format!(
4634 "free-form: name the worktree anything git accepts{back}back to {}",
4635 self.structured_mode_label()
4636 ),
4637 // The submit field is named from the patterns, not hardcoded to
4638 // `desc` (#418): a pattern without one told the user to press enter
4639 // on a field the form does not present.
4640 Mode::Structured if back.is_empty() => self.structured_form_instruction(),
4641 Mode::Structured => format!("{}{back}free-form", self.structured_form_instruction()),
4642 };
4643 }
4644 Some(ModalAction::CreateSubmit) => {
4645 // The submit field is the last one of the active mode: `Name` in
4646 // free-form (its only field), and in structured mode the last field
4647 // the *patterns* present (#418) rather than `Desc` by name. Naming
4648 // `Desc` made `{type}/#{issue}` a form that could not be submitted at
4649 // all, since Enter then only ever rotated.
4650 let submit_field = match self.create_form.mode {
4651 Mode::Freeform => Field::Name,
4652 Mode::Structured => self.create_form.last_field(),
4653 };
4654 if self.create_form.field == submit_field {
4655 return CreateKey::Submit;
4656 }
4657 self.create_next_field();
4658 }
4659 Some(ModalAction::CreatePrevType) if on_type => self.create_prev_type(),
4660 Some(ModalAction::CreateNextType) if on_type => self.create_next_type(),
4661 _ => match key.code {
4662 KeyCode::Char(c) if self.create_form.field == Field::Issue && !c.is_ascii_digit() => {
4663 self.status = "issue accepts digits only".into();
4664 }
4665 KeyCode::Char(c) if !on_type => self.create_push_char(c),
4666 KeyCode::Backspace if !on_type => self.create_pop_char(),
4667 _ => {}
4668 },
4669 }
4670 CreateKey::Handled
4671 }
4672
4673 pub fn submit_create(&mut self) -> Result<()> {
4674 // Issue #416: free-form validation lands here rather than per keystroke,
4675 // so the user can type through an intermediate state. A rejected name
4676 // keeps the form open with the reason in the status bar — same shape as
4677 // the trust refusal below, and for the same reason (an `Err` would tear
4678 // down the alternate screen).
4679 //
4680 // Composed through the same `worktree_name_from_form` the rename uses
4681 // (#418). It used to build its own `BranchSpec` here, so relaxing
4682 // validation for the patterns' actual segments in one composer left the
4683 // other still demanding a value it would discard — two composers of one
4684 // value drift, and this is the second time on this form (the previews did
4685 // it in #476).
4686 let wt_name = match self.worktree_name_from_form() {
4687 Ok(n) => n,
4688 Err(e) => {
4689 self.status = e;
4690 return Ok(());
4691 }
4692 };
4693 let branch = wt_name.branch_name(&self.config.worktree, &self.repo_name)?;
4694 let dirname = wt_name.worktree_dirname(&self.config.worktree, &self.repo_name)?;
4695 let target = wt_name.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir)?;
4696
4697 // Gate the bootstrap RCE primitive on the TOFU ledger BEFORE
4698 // creating the worktree on disk (issue #95). A refusal here
4699 // leaves the user's disk state untouched — no orphaned
4700 // worktree to clean up. Mirrors `cmd_create` in src/cli.rs.
4701 if let Some(msg) = self.check_trust_for_bootstrap()? {
4702 self.status = msg;
4703 // Stay in the create form so the user can retry after
4704 // approving the config via the CLI gate. Returning Ok here
4705 // (rather than Err) keeps the event loop alive — an Err
4706 // would print to stderr and tear down the alternate screen.
4707 return Ok(());
4708 }
4709
4710 if self.tasks.has_mutating_task_in_flight() {
4711 if let Some(label) = self.tasks.mutating_loading_label() {
4712 self.status = format!("finish {} before creating worktree", label.trim_end_matches('…'));
4713 } else {
4714 self.status = "finish current task before creating worktree".into();
4715 }
4716 return Ok(());
4717 }
4718 let Some(generation) = self.tasks.request(TaskKind::CreateWorktree) else {
4719 return Ok(());
4720 };
4721 self.create_failure = None;
4722 self.spinner.reset();
4723 self.status = TaskKind::CreateWorktree.loading_label().into();
4724 self.spawn_create_worktree(
4725 generation,
4726 dirname,
4727 target,
4728 branch,
4729 self.workdir.clone(),
4730 self.config.clone(),
4731 );
4732 Ok(())
4733 }
4734
4735 fn spawn_create_worktree(
4736 &self,
4737 generation: u64,
4738 dirname: String,
4739 target: PathBuf,
4740 branch: String,
4741 workdir: PathBuf,
4742 config: Config,
4743 ) {
4744 let tx = self.task_tx.clone();
4745 std::thread::spawn(move || {
4746 let result = (|| -> Result<CreateWorktreeResult> {
4747 let repo = worktree::discover_repo(Some(&workdir))?;
4748 let created = worktree::add(&repo, &dirname, &target, &branch, false)?;
4749 let ctx = BootstrapCtx {
4750 main_repo: &workdir,
4751 worktree: &created,
4752 config: &config,
4753 };
4754 let report = bootstrap::run(&ctx)?;
4755 Ok(CreateWorktreeResult {
4756 branch,
4757 created,
4758 report,
4759 })
4760 })()
4761 .map_err(|e| e.to_string());
4762 let _ = tx.send(TaskMsg::CreateWorktree(generation, result));
4763 });
4764 }
4765
4766 // ---- Delete flow ---------------------------------------------------------
4767
4768 pub fn enter_confirm_delete(&mut self) {
4769 let Some(sel) = self.selected() else {
4770 self.status = "nothing selected".into();
4771 return;
4772 };
4773 if sel.is_main {
4774 self.status = "cannot remove the main worktree".into();
4775 return;
4776 }
4777 self.view = View::Confirm;
4778 self.confirm.reset();
4779 self.delete_failure = None;
4780 // Start the loader animation from a deterministic frame each time
4781 // the modal opens (#187).
4782 self.spinner.reset();
4783 }
4784
4785 pub fn confirm_delete(&mut self) -> Result<()> {
4786 // `worktree::remove` resolves by the internal git id, which can diverge
4787 // from the display name after a rename (#290), so pass `id` here.
4788 let (id, label) = match self.selected() {
4789 Some(s) => (s.id.clone(), s.path.display().to_string()),
4790 None => return Ok(()),
4791 };
4792 if self.is_delete_worktree_loading() {
4793 return Ok(());
4794 }
4795 if self.tasks.has_mutating_task_in_flight() {
4796 if let Some(label) = self.tasks.mutating_loading_label() {
4797 self.status = format!("finish {} before deleting worktree", label.trim_end_matches('…'));
4798 } else {
4799 self.status = "finish current task before deleting worktree".into();
4800 }
4801 return Ok(());
4802 }
4803 let Some(generation) = self.tasks.request(TaskKind::DeleteWorktree) else {
4804 return Ok(());
4805 };
4806 let delete_branch = self.delete_branch_on_remove;
4807 self.delete_failure = None;
4808 self.confirm.dismiss();
4809 self.spinner.reset();
4810 self.status = TaskKind::DeleteWorktree.loading_label().into();
4811 self.spawn_delete_worktree(generation, id, label, delete_branch);
4812 Ok(())
4813 }
4814
4815 fn spawn_delete_worktree(&self, generation: u64, id: String, label: String, delete_branch: bool) {
4816 let tx = self.task_tx.clone();
4817 let workdir = self.workdir.clone();
4818 std::thread::spawn(move || {
4819 let result = worktree::discover_repo(Some(&workdir))
4820 .and_then(|repo| worktree::remove(&repo, &id, delete_branch))
4821 .map_err(|e| e.to_string());
4822 let _ = tx.send(TaskMsg::DeleteWorktree(generation, id, label, result));
4823 });
4824 }
4825
4826 // ---- Confirm-overlay safety countdown (issue #30, extracted per #125) ---
4827 //
4828 // The countdown only applies when `delete_branch_on_remove` is ON AND the
4829 // configured `confirm_countdown_secs` is non-zero. The pure state lives
4830 // on `self.confirm` (see `src/tui/state/confirm.rs`); the wrappers below
4831 // own the side effects (status messages, view transitions).
4832
4833 /// Total duration of the safety countdown for the current modal state.
4834 /// `Duration::ZERO` means "no countdown — classic modal".
4835 pub fn confirm_countdown_total(&self) -> Duration {
4836 if self.delete_branch_on_remove {
4837 Duration::from_secs(u64::from(self.config.tui.effective_confirm_countdown_secs()))
4838 } else {
4839 Duration::ZERO
4840 }
4841 }
4842
4843 /// True when the confirm overlay should render the countdown variant
4844 /// (progress bar + footer "y arm / y again to cancel"). False for the
4845 /// classic single-keystroke confirm.
4846 pub fn confirm_is_countdown_mode(&self) -> bool {
4847 self.confirm_countdown_total() > Duration::ZERO
4848 }
4849
4850 /// Handle a `y` / Enter press inside the confirm overlay. Delegates to
4851 /// `ConfirmModal::press_y` and composes the status-bar message based on
4852 /// the returned action.
4853 pub fn confirm_press_y(&mut self, now: Instant) -> ConfirmKeyAction {
4854 let total = self.confirm_countdown_total();
4855 let action = self.confirm.press_y(now, total);
4856 match action {
4857 ConfirmKeyAction::FireNow => {}
4858 ConfirmKeyAction::Disarmed => {
4859 let secs = total.as_secs();
4860 // #219 review: name the live confirm key, and drop the clause entirely
4861 // when it is unbound — never advertise a key that no longer re-arms.
4862 self.status = match self.modal_keymap.primary_key(ModalAction::ConfirmConfirm) {
4863 Some(c) => format!("countdown cancelled — press {c} to re-arm ({secs}s safety delay)"),
4864 None => format!("countdown cancelled ({secs}s safety delay)"),
4865 };
4866 }
4867 ConfirmKeyAction::Armed => {
4868 let secs = total.as_secs();
4869 // #219 review: name the live confirm / cancel keys (rebindable via
4870 // `[tui.keys.modal.confirm]`), dropping either clause when its verb is
4871 // unbound rather than advertising a phantom key while the timer runs.
4872 let confirm = self.modal_keymap.primary_key(ModalAction::ConfirmConfirm);
4873 let cancel = self.modal_keymap.primary_key(ModalAction::ConfirmCancel);
4874 let tail = match (confirm, cancel) {
4875 (Some(c), Some(x)) => format!(" · press {c} again or {x} to cancel"),
4876 (Some(c), None) => format!(" · press {c} again to disarm"),
4877 (None, Some(x)) => format!(" · press {x} to cancel"),
4878 (None, None) => String::new(),
4879 };
4880 self.status = format!("armed — auto-fires in {secs}s{tail}");
4881 }
4882 }
4883 action
4884 }
4885
4886 /// Handle the dismissal keys (`n` / `Esc`) inside the confirm overlay.
4887 /// Always disarms the countdown and returns to the list.
4888 pub fn confirm_dismiss(&mut self) {
4889 if self.is_delete_worktree_loading() {
4890 self.status = TaskKind::DeleteWorktree.loading_label().into();
4891 return;
4892 }
4893 self.confirm.dismiss();
4894 self.delete_failure = None;
4895 self.view = View::List;
4896 }
4897
4898 /// Tick the countdown forward. Called from the event loop on every
4899 /// poll-timeout iteration (every 200ms).
4900 pub fn tick_confirm_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
4901 self.confirm.tick(now, self.confirm_countdown_total())
4902 }
4903
4904 /// Countdown progress in `[0.0, 1.0]`. `0.0` when not armed, `1.0` once
4905 /// elapsed. Used by the UI to draw the gauge.
4906 pub fn confirm_countdown_progress(&self, now: Instant) -> f64 {
4907 self.confirm.progress(now, self.confirm_countdown_total())
4908 }
4909
4910 /// Seconds remaining (rounded up to the next whole second) for the UI
4911 /// label. `0` when not armed or when the countdown has elapsed.
4912 pub fn confirm_countdown_remaining_secs(&self, now: Instant) -> u64 {
4913 self.confirm.remaining_secs(now, self.confirm_countdown_total())
4914 }
4915
4916 // ---- Fuzzy filter (issue #21) -------------------------------------------
4917
4918 /// Open the inline filter bar. The existing query is preserved so the user
4919 /// can refine an already-sticky filter; `Esc` is the way to start fresh.
4920 /// Disarms any pending `gg` motion so `/g` doesn't half-trigger it.
4921 ///
4922 /// Forces focus back onto the list: opening `/` is an intent to narrow the
4923 /// list, and the post-`Enter` contract is "navigation returns to the
4924 /// table". Leaving the sidebar focused would make `j` / `k` scroll it
4925 /// instead of walking the filtered worktrees after the filter sticks.
4926 pub fn enter_filter(&mut self) {
4927 self.filter.open();
4928 self.sidebar.focused = false;
4929 self.cancel_pending_motion();
4930 self.status = "/ filter — type to narrow · enter confirms · esc clears".into();
4931 }
4932
4933 /// Close the filter bar but keep the query: `Enter` confirms the current
4934 /// match set and returns the cursor to list navigation.
4935 pub fn exit_filter_keep(&mut self) {
4936 self.filter.close_keep();
4937 self.status = if self.filter.query().is_empty() {
4938 "press ? for help".into()
4939 } else {
4940 format!("filter sticky: {}", self.filter.query())
4941 };
4942 }
4943
4944 /// Close the filter bar and clear the query: `Esc` returns to the full list.
4945 pub fn exit_filter_cancel(&mut self) {
4946 let had_query = !self.filter.query().is_empty();
4947 self.filter.close_cancel();
4948 self.clamp_selection_to_filter();
4949 self.invalidate_sidebar_cache();
4950 self.status = if had_query {
4951 "filter cleared".into()
4952 } else {
4953 "press ? for help".into()
4954 };
4955 }
4956
4957 pub fn filter_push_char(&mut self, c: char) {
4958 self.filter.push_char(c);
4959 self.clamp_selection_to_filter();
4960 self.invalidate_sidebar_cache();
4961 }
4962
4963 pub fn filter_pop_char(&mut self) {
4964 let before = self.filter.query().len();
4965 self.filter.pop_char();
4966 if self.filter.query().len() != before {
4967 self.clamp_selection_to_filter();
4968 self.invalidate_sidebar_cache();
4969 }
4970 }
4971
4972 /// Indices into `self.worktrees`, in display order:
4973 /// - empty query: identity (every worktree in source order).
4974 /// - non-empty: only worktrees whose name matches the query via
4975 /// `nucleo_matcher`, ranked by descending score (nucleo intrinsically
4976 /// ranks exact/substring/prefix matches above subsequence matches).
4977 ///
4978 /// Score ties are broken by original index so output is stable.
4979 ///
4980 /// Memoised on `FilterState` since #124 / #104: the per-frame render
4981 /// path calls this 3–5× (table height, visible rows, title hint,
4982 /// footer counter, selection resolver), but the result only changes
4983 /// when the query OR the worktrees vec changes. The cache holds the
4984 /// previous result and the worktrees length it was computed against;
4985 /// any buffer mutation (`push_char` / `pop_char` / `set_query` /
4986 /// `clear`), an explicit `filter.invalidate()`, or a length change
4987 /// invalidates it. `App::refresh` calls `invalidate` after replacing
4988 /// `worktrees` so a same-length-different-contents refresh is also
4989 /// caught.
4990 pub fn filtered_indices(&mut self) -> &[usize] {
4991 self.filter.filtered_indices(&self.worktrees, fuzzy_match_indices)
4992 }
4993
4994 /// Reposition the selection so it stays inside the current filtered subset.
4995 /// Called whenever the filter mutates (`/`-mode typing, `Esc`-clear) or the
4996 /// worktree list itself changes (`refresh`). Also re-resolves the issue/PR
4997 /// link cache so the right-panel block tracks the new selection — PR #68
4998 /// Copilot review caught that selection changes were leaving the cache
4999 /// pointing at the previously selected worktree.
5000 fn clamp_selection_to_filter(&mut self) {
5001 let len = self.filtered_indices().len();
5002 if len == 0 {
5003 self.list_state.select(None);
5004 self.refresh_link();
5005 return;
5006 }
5007 match self.list_state.selected() {
5008 Some(i) if i >= len => self.list_state.select(Some(len - 1)),
5009 Some(_) => {}
5010 None => self.list_state.select(Some(0)),
5011 }
5012 self.refresh_link();
5013 }
5014
5015 /// Move the cursor onto the worktree at `path`, mapping its raw index in
5016 /// `self.worktrees` to its slot in the *filtered* list — `list_state`
5017 /// indexes `filtered_indices()`, not the raw vec, so selecting a raw index
5018 /// under an active filter lands on the wrong visible row or none (Codex
5019 /// review on PR #292). A no-op when the path is filtered out.
5020 /// The chord that opens the issue/PR link prompt (`i` by default since
5021 /// #290), resolved from the live keymap so "press X to link" status hints
5022 /// track the binding and any `[tui.keys]` override (Codex review on PR
5023 /// #292, P3).
5024 fn link_prompt_chord(&self) -> String {
5025 self
5026 .keymap
5027 .primary_chord(Action::LinkPrompt)
5028 .unwrap_or_else(|| "i".into())
5029 }
5030
5031 pub fn reselect_by_path(&mut self, path: &Path) {
5032 let Some(raw) = self.worktrees.iter().position(|w| w.path == path) else {
5033 return;
5034 };
5035 let pos = self.filtered_indices().iter().position(|&idx| idx == raw);
5036 if let Some(pos) = pos {
5037 self.list_state.select(Some(pos));
5038 }
5039 }
5040
5041 // ---- Bootstrap flow ------------------------------------------------------
5042
5043 // ---- Picker mode (issue #22) --------------------------------------------
5044
5045 /// Commit the highlighted worktree as the picker's result. The event loop
5046 /// breaks once `picker_should_exit` flips so `run_picker` can surface the
5047 /// path to the CLI caller, which prints it on stdout for `cd "$(gwm
5048 /// switch)"`.
5049 ///
5050 /// Outside picker mode the call is inert. When picker mode is on but
5051 /// nothing is selected (e.g. the filter narrowed the list to zero
5052 /// matches), the loop stays open and a status hint asks the user to
5053 /// refine — addresses Copilot's PR #53 review: Enter on an empty match
5054 /// set used to break with `None`, which read as "cancel" instead of
5055 /// "nothing to pick".
5056 pub fn picker_confirm(&mut self) {
5057 if !self.picker_mode {
5058 return;
5059 }
5060 match self.selected() {
5061 Some(w) => {
5062 self.picker_result = Some(w.path.clone());
5063 self.picker_should_exit = true;
5064 }
5065 None => {
5066 self.status = "no worktree selected — adjust the filter and try again".into();
5067 }
5068 }
5069 }
5070
5071 /// Esc-equivalent for picker mode: leave without recording a path. The
5072 /// regular TUI uses Esc to clear an active filter, which conflicts with
5073 /// the picker footer's `esc:cancel` contract; this method exists so the
5074 /// event loop can route Esc-during-filter to a clean picker cancel.
5075 pub fn picker_cancel(&mut self) {
5076 if !self.picker_mode {
5077 return;
5078 }
5079 self.picker_should_exit = true;
5080 }
5081
5082 pub fn bootstrap_selected(&mut self) {
5083 let path = match self.selected() {
5084 Some(s) => s.path.clone(),
5085 None => {
5086 self.status = "nothing selected".into();
5087 return;
5088 }
5089 };
5090
5091 // Same TOFU gate as `submit_create` — pressing `b` to re-run
5092 // bootstrap on an existing worktree is just as much an RCE
5093 // primitive as creating a new one. Issue #95.
5094 match self.check_trust_for_bootstrap() {
5095 Ok(None) => {}
5096 Ok(Some(msg)) => {
5097 self.status = msg;
5098 return;
5099 }
5100 Err(e) => {
5101 self.status = format!("trust gate error: {}", e);
5102 return;
5103 }
5104 }
5105
5106 // Run off-thread on the async-task spine (issue #256): `bootstrap::run`
5107 // (file copies, guards, command hooks) used to block the event loop. The
5108 // TOFU gate above stays synchronous on the main thread; only the run
5109 // itself moves to a worker, with the `View::Report` transition deferred
5110 // to `drain_task_results`. A second `b` press while one is in flight
5111 // coalesces (no `Some(generation)`), so two bootstraps never race.
5112 if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Bootstrap) {
5113 self.status = self.busy_mutation_status("bootstrapping");
5114 return;
5115 }
5116 let Some(generation) = self.tasks.request(TaskKind::Bootstrap) else {
5117 return;
5118 };
5119 self.spinner.reset();
5120 self.status = TaskKind::Bootstrap.loading_label().into();
5121 self.spawn_bootstrap(generation, self.workdir.clone(), path, self.config.clone());
5122 }
5123
5124 /// Spawn the off-thread bootstrap worker (issue #256). Only owned, `Send`
5125 /// data crosses the thread boundary — the `main_repo` / `worktree` paths
5126 /// and a clone of the resolved `Config` — so the worker rebuilds its own
5127 /// `BootstrapCtx` rather than borrowing `self`. The result is posted back
5128 /// over the task channel for `drain_task_results` to apply.
5129 fn spawn_bootstrap(&self, generation: u64, main_repo: PathBuf, worktree: PathBuf, config: Config) {
5130 let tx = self.task_tx.clone();
5131 std::thread::spawn(move || {
5132 let ctx = BootstrapCtx {
5133 main_repo: &main_repo,
5134 worktree: &worktree,
5135 config: &config,
5136 };
5137 let result = bootstrap::run(&ctx).map_err(|e| e.to_string());
5138 let _ = tx.send(TaskMsg::Bootstrap(generation, result));
5139 });
5140 }
5141
5142 // ---- Issue/PR linking (issue #67) -------------------------------------
5143
5144 /// Re-read the link for the currently selected worktree's branch. Also
5145 /// re-resolves the repo slug from the origin remote, and resets any
5146 /// previously cached GitHub fetch state since it would refer to a
5147 /// different (issue, pr) tuple now. Delegates to
5148 /// [`GitHubFetch::refresh_link`] for the pure state mutation; the
5149 /// branch resolution still lives here because it depends on
5150 /// `App`'s `selected()` + `repo.head()` fallback.
5151 pub fn refresh_link(&mut self) {
5152 let branch = self.selected_branch_name();
5153 self.github.refresh_link(&self.repo, branch.as_deref(), &self.config);
5154 // Navigation invariant (issue #255): the cache clear above must be paired
5155 // with a spine generation-bump so any in-flight `gh` worker for the
5156 // previous worktree's link is dropped instead of stamping the now-active
5157 // worktree's cache. `refresh_link` no longer holds the old issue/PR
5158 // numbers, so invalidate by predicate.
5159 self.tasks.invalidate_matching(TaskKind::is_github);
5160 // Every link mutation funnels through here or through the
5161 // `refresh_github_status` re-probe — both revalidate the CI overlay's
5162 // pinned identity (Codex review #455): an auto-refresh relist can move
5163 // the selection (the current worktree disappeared) while the overlay
5164 // is up, and its checks must not survive their PR.
5165 self.close_ci_overlay_if_link_disagrees();
5166 }
5167
5168 /// Close the open CI checks overlay when the link no longer matches the
5169 /// `(slug, PR)` it was built for — see `detail_overlay_pr`.
5170 fn close_ci_overlay_if_link_disagrees(&mut self) {
5171 if self.view != View::DetailOverlay
5172 || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
5173 {
5174 return;
5175 }
5176 let current = self.github.link.pr.map(|n| (self.github.link_slug.clone(), n));
5177 if current != self.detail_overlay_pr {
5178 self.close_detail_overlay();
5179 }
5180 }
5181
5182 fn selected_branch_name(&self) -> Option<String> {
5183 self.selected().and_then(|w| w.branch.clone()).or_else(|| {
5184 self
5185 .repo
5186 .head()
5187 .ok()
5188 .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
5189 })
5190 }
5191
5192 pub fn current_link(&self) -> &BranchLink {
5193 &self.github.link
5194 }
5195
5196 /// Mirror the live resolved `github.link` onto the selected worktree's
5197 /// snapshot (issue #283 / Codex review #284). The table renders the PR/
5198 /// issue pastilles from `self.worktrees[*].link`, captured at list time,
5199 /// so a freshly persisted auto-detection would otherwise stay invisible on
5200 /// the selected row until a full relist. Resolves the selection through
5201 /// the same filter map as [`Self::selected`].
5202 fn sync_selected_link_into_table(&mut self) {
5203 let Some(i) = self.list_state.selected() else {
5204 return;
5205 };
5206 let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
5207 let Some(&original) = filtered.get(i) else {
5208 return;
5209 };
5210 let link = self.github.link.clone();
5211 if let Some(w) = self.worktrees.get_mut(original) {
5212 if w.link.issue != link.issue {
5213 w.issue_state = None;
5214 }
5215 if w.link.pr != link.pr {
5216 w.pr_state = None;
5217 }
5218 w.link = link;
5219 }
5220 }
5221
5222 fn sync_issue_status_into_table(&mut self, status: &IssueStatus) {
5223 if self.github.link.issue == Some(status.number) {
5224 self.github.link.issue_title = Some(status.title.clone());
5225 self.github.link.issue_state = Some(status.state);
5226 if let Some(branch) = self.selected_branch_name() {
5227 let _ = github::persist_issue_title(&self.repo, &branch, &status.title);
5228 let _ = github::persist_issue_state(&self.repo, &branch, status.state);
5229 }
5230 }
5231 // In workspace mode the fetch was for the active repo's selected issue, so
5232 // only stamp/persist rows belonging to that repo — a number-only match
5233 // would otherwise carry repo A's state onto repo B's same-numbered row and
5234 // persist it through the wrong repo handle (Codex review #303 P2).
5235 let mask = self.active_repo_row_mask();
5236 for (i, w) in self.worktrees.iter_mut().enumerate() {
5237 if mask.as_ref().is_some_and(|m| !m[i]) {
5238 continue;
5239 }
5240 if w.link.issue != Some(status.number) {
5241 continue;
5242 }
5243 w.issue_state = Some(status.state);
5244 w.link.issue_title = Some(status.title.clone());
5245 w.link.issue_state = Some(status.state);
5246 if let Some(branch) = w.branch.as_deref() {
5247 let _ = github::persist_issue_title(&self.repo, branch, &status.title);
5248 let _ = github::persist_issue_state(&self.repo, branch, status.state);
5249 }
5250 }
5251 }
5252
5253 fn sync_pr_status_into_table(&mut self, status: &PrStatus) {
5254 if self.github.link.pr == Some(status.number) {
5255 self.github.link.pr_title = Some(status.title.clone());
5256 self.github.link.pr_state = Some(status.state);
5257 if let Some(branch) = self.selected_branch_name() {
5258 let _ = match self.github.link.pr_source {
5259 github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, &branch, &status.title)
5260 .and_then(|()| github::persist_detected_pr_state(&self.repo, &branch, status.state)),
5261 github::LinkSource::Explicit => github::persist_pr_title(&self.repo, &branch, &status.title)
5262 .and_then(|()| github::persist_pr_state(&self.repo, &branch, status.state)),
5263 github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
5264 };
5265 }
5266 }
5267 // Scope to the active repo's rows in workspace mode — see the matching
5268 // note in `sync_issue_status_into_table` (Codex review #303 P2).
5269 let mask = self.active_repo_row_mask();
5270 for (i, w) in self.worktrees.iter_mut().enumerate() {
5271 if mask.as_ref().is_some_and(|m| !m[i]) {
5272 continue;
5273 }
5274 if w.link.pr != Some(status.number) {
5275 continue;
5276 }
5277 w.pr_state = Some(status.state);
5278 w.link.pr_title = Some(status.title.clone());
5279 w.link.pr_state = Some(status.state);
5280 if let Some(branch) = w.branch.as_deref() {
5281 let _ = match w.link.pr_source {
5282 github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, branch, &status.title)
5283 .and_then(|()| github::persist_detected_pr_state(&self.repo, branch, status.state)),
5284 github::LinkSource::Explicit => github::persist_pr_title(&self.repo, branch, &status.title)
5285 .and_then(|()| github::persist_pr_state(&self.repo, branch, status.state)),
5286 github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
5287 };
5288 }
5289 }
5290 }
5291
5292 pub fn current_slug(&self) -> Option<&str> {
5293 self.github.link_slug.as_deref()
5294 }
5295
5296 /// Read the cached issue fetch state for the *currently-linked*
5297 /// issue. Returns `&GitHubFetchState::Idle` when no issue is linked
5298 /// (or when the linked issue has never been fetched) — the cache is
5299 /// per-number (post-#138), so reading "the" state means resolving
5300 /// via `self.github.link.issue` first.
5301 pub fn issue_fetch_state(&self) -> &GitHubFetchState<IssueStatus> {
5302 match self.github.link.issue {
5303 Some(n) => self.github.issue_fetch_state(n),
5304 None => &GitHubFetchState::Idle,
5305 }
5306 }
5307
5308 /// PR-side counterpart to [`Self::issue_fetch_state`].
5309 pub fn pr_fetch_state(&self) -> &GitHubFetchState<PrStatus> {
5310 match self.github.link.pr {
5311 Some(n) => self.github.pr_fetch_state(n),
5312 None => &GitHubFetchState::Idle,
5313 }
5314 }
5315
5316 /// Kick off the issue/PR fetch. Called from the event loop when the
5317 /// user presses `F` (refresh GitHub status). Each `gh issue view` /
5318 /// `gh pr view` shell-out runs **off-thread** on the shared async-task
5319 /// spine (issue #255, migrated from #217's dedicated channel): the `App`
5320 /// checks the per-key cache, claims a generation from
5321 /// [`TaskRunner::request`], marks the cache `Loading`, and spawns a
5322 /// worker tagged with that generation. The worker reports a
5323 /// `TaskMsg::Github{Issue,Pr}` back; [`Self::drain_task_results`] applies
5324 /// it only if the generation is still authoritative — so a stale worker
5325 /// from a previous fetch loses the retry race to a fresh one.
5326 ///
5327 /// The PR auto-detection (`gh pr list`, issue #181) stays synchronous:
5328 /// it mutates `link` which the very next render needs, and it is a single
5329 /// cheap call rather than the two `view` shell-outs the spinner is for.
5330 ///
5331 /// This call path is the explicit user-initiated refresh, so it flushes
5332 /// the cache + drops any in-flight worker via [`Self::invalidate_github`]
5333 /// first — the user just asked for fresh data, a cache short-circuit here
5334 /// would be a bug.
5335 pub fn refresh_github_status(&mut self) {
5336 let slug = self.github.link_slug.clone();
5337
5338 // Re-resolve a non-explicit PR live on `F` (issue #181/#283): only an
5339 // explicit `gwm link --pr` pins the PR; a branch-name / none / persisted-
5340 // detected (#283) PR is re-probed so a number that changed since the last
5341 // detection is refreshed. The in-memory detection is dropped *only* once
5342 // we have a fresh successful result (the `Ok` arm), so a refresh that
5343 // cannot probe — no origin slug, no resolvable branch, or a failed `gh`
5344 // call — keeps the persisted detection visible instead of blanking the
5345 // pane/table (Codex review #284). `apply_detected_pr` only fills an empty
5346 // slot, hence the clear-then-apply to replace a stale detection.
5347 if self.github.link.pr_source != github::LinkSource::Explicit {
5348 if let (Some(forge), Some(branch)) = (self.github.forge.clone(), self.selected_branch_name()) {
5349 if let Ok(detected) = forge.find_pr_for_branch(&branch) {
5350 self.github.clear_detected_pr();
5351 self.github.apply_detected_pr(detected);
5352 // Persist the detection (issue #283) so the no-fetch table read
5353 // path colours the PR pastille on every row, not just the selected
5354 // one. Only a successful probe is authoritative: store a hit, clear
5355 // the key on a proven `Ok(None)`. Best-effort write — a git-config
5356 // failure must not break the refresh, so the result is discarded.
5357 let _ = match detected {
5358 Some(n) => github::persist_detected_pr(&self.repo, &branch, n),
5359 None => github::clear_persisted_detected_pr(&self.repo, &branch),
5360 };
5361 }
5362 // On a `gh` failure (Err) nothing was cleared, so the link keeps
5363 // whatever `read_link` resolved (possibly a persisted detection).
5364 //
5365 // Mirror the resolved link onto the selected row's snapshot so the
5366 // table pastille reflects the detection immediately, without waiting
5367 // for a separate relist (Codex review #284). The table renders from
5368 // `self.worktrees[*].link`, not the live `github.link`.
5369 self.sync_selected_link_into_table();
5370 }
5371 }
5372
5373 // The re-probe can CHANGE the PR identity — a persisted detection
5374 // coming back None, or re-detecting a different number (#61 → #62).
5375 // The flow below owns the status line ("nothing linked" / "fetching…").
5376 self.close_ci_overlay_if_link_disagrees();
5377
5378 if self.github.link.issue.is_none() && self.github.link.pr.is_none() {
5379 self.status = format!(
5380 "nothing linked — press {} to link an issue or PR",
5381 self.link_prompt_chord()
5382 );
5383 return;
5384 }
5385 let Some(slug) = slug else {
5386 self.status = "no GitHub remote — cannot fetch status".into();
5387 return;
5388 };
5389 // Explicit user-initiated refresh: flush the cache (so the cold-cache
5390 // branch fires instead of a hit) and drop any in-flight worker on the
5391 // spine, so previously-loaded keys re-fetch.
5392 self.invalidate_github();
5393 let mut spawned = 0u32;
5394 if let Some(n) = self.github.link.issue {
5395 if self.spawn_github_issue(n, &slug) {
5396 spawned += 1;
5397 }
5398 }
5399 if let Some(n) = self.github.link.pr {
5400 if self.spawn_github_pr(n, &slug) {
5401 spawned += 1;
5402 }
5403 }
5404 if spawned > 0 {
5405 // Loading state is live; the spinner animates until `drain` applies
5406 // the results and re-reports the outcome.
5407 self.spinner.reset();
5408 self.status = "fetching GitHub status…".into();
5409 } else {
5410 // Nothing actually spawned (all keys already terminal in cache) —
5411 // report the current outcome immediately.
5412 self.report_github_refresh_status();
5413 }
5414 }
5415
5416 fn refresh_linked_github_statuses_for_worktrees(&mut self) -> u32 {
5417 // Workspace mode (#36): this bulk prefetch resolves every merged row's
5418 // issue/PR against a single repo's slug (`self.github.link_slug`), which
5419 // mis-attributes numbers across child repos with different remotes (Codex
5420 // review #303 P2). In workspace mode GitHub state is fetched per-selection
5421 // instead — `sync_active_repo`/`on_navigation` call `refresh_link`, which
5422 // re-resolves the slug from the selected row's own repo. So skip the bulk
5423 // cross-repo prefetch here.
5424 if self.is_workspace() {
5425 return 0;
5426 }
5427 let Some(slug) = self.github.link_slug.clone() else {
5428 return 0;
5429 };
5430 let issues = self
5431 .worktrees
5432 .iter()
5433 .filter_map(|w| w.link.issue)
5434 .collect::<BTreeSet<_>>()
5435 .into_iter()
5436 .collect::<Vec<_>>();
5437 let prs = self
5438 .worktrees
5439 .iter()
5440 .filter_map(|w| w.link.pr)
5441 .collect::<BTreeSet<_>>()
5442 .into_iter()
5443 .collect::<Vec<_>>();
5444 if issues.is_empty() && prs.is_empty() {
5445 return 0;
5446 }
5447
5448 self.invalidate_github();
5449 let mut spawned = 0u32;
5450 for n in issues {
5451 if self.spawn_github_issue(n, &slug) {
5452 spawned += 1;
5453 }
5454 }
5455 for n in prs {
5456 if self.spawn_github_pr(n, &slug) {
5457 spawned += 1;
5458 }
5459 }
5460 if spawned > 0 {
5461 self.spinner.reset();
5462 }
5463 spawned
5464 }
5465
5466 /// Flush the GitHub result cache **and** drop any in-flight GitHub worker
5467 /// on the spine (issue #255). The navigation invariant: the cache clear
5468 /// and the spine generation-bump must always move together, or a stale
5469 /// worker's late result could outlive the cache flush. Routed through one
5470 /// helper so the pairing can't desync — `refresh_github_status` and
5471 /// (via the predicate) `refresh_link` are the only callers.
5472 fn invalidate_github(&mut self) {
5473 self.github.invalidate();
5474 self.tasks.invalidate_matching(TaskKind::is_github);
5475 }
5476
5477 /// Claim a spine generation for `Issue(n)` and spawn its `gh issue view`
5478 /// worker (issue #255), returning `true` when a worker was actually
5479 /// started. A terminal cache hit (the explicit refresh flushed the cache
5480 /// first, so this only fires on a redundant call) or a coalesced spine
5481 /// slot (a worker for this key is already in flight) returns `false`
5482 /// without spawning a second subprocess.
5483 fn spawn_github_issue(&mut self, n: u64, slug: &str) -> bool {
5484 let key = FetchKey::Issue(n);
5485 if self.github.is_cached(key) {
5486 return false;
5487 }
5488 let Some(generation) = self.tasks.request(TaskKind::GithubIssue(n)) else {
5489 return false;
5490 };
5491 self.github.mark_loading(key);
5492 self.spawn_github_fetch(key, slug.to_string(), generation);
5493 true
5494 }
5495
5496 /// PR-side counterpart to [`Self::spawn_github_issue`] (issue #255).
5497 fn spawn_github_pr(&mut self, n: u64, slug: &str) -> bool {
5498 let key = FetchKey::Pr(n);
5499 if self.github.is_cached(key) {
5500 return false;
5501 }
5502 let Some(generation) = self.tasks.request(TaskKind::GithubPr(n)) else {
5503 return false;
5504 };
5505 self.github.mark_loading(key);
5506 self.spawn_github_fetch(key, slug.to_string(), generation);
5507 true
5508 }
5509
5510 /// Spawn one background `gh` shell-out for `key` tagged with `generation`
5511 /// and wire its result back over the shared task channel (issue #255,
5512 /// migrated from #217's dedicated channel). Deliberately a thin shell: it
5513 /// owns only the off-thread dispatch + send, no state logic — the
5514 /// coalescing / late-drop contract lives on the [`TaskRunner`] spine. A
5515 /// `send` failure (the `App`/receiver was dropped) is ignored: there is
5516 /// no longer anyone to apply the result.
5517 fn spawn_github_fetch(&self, key: FetchKey, _slug: String, generation: u64) {
5518 let tx = self.task_tx.clone();
5519 // Clone the resolved forge on THIS (main) thread and hand it to the
5520 // worker. The backend captured `$GWM_GH` / `$GWM_GLAB` when it was
5521 // built (also on the main thread), so the worker never reads the
5522 // process environment concurrently with env-mutating code elsewhere —
5523 // the `env_lock` unsoundness it would otherwise reintroduce (#217).
5524 let Some(forge) = self.github.forge.clone() else {
5525 return;
5526 };
5527 std::thread::spawn(move || {
5528 let msg = match key {
5529 FetchKey::Issue(n) => TaskMsg::GithubIssue(generation, n, forge.fetch_issue(n).map_err(|e| e.to_string())),
5530 FetchKey::Pr(n) => TaskMsg::GithubPr(generation, n, forge.fetch_pr(n).map_err(|e| e.to_string())),
5531 };
5532 let _ = tx.send(msg);
5533 });
5534 }
5535
5536 /// Compute the post-refresh status line message based on the actual
5537 /// outcome of the issue / PR fetches. PR #68 Copilot review caught
5538 /// that always printing "refreshed" misled users when one of the
5539 /// fetches had failed.
5540 pub fn report_github_refresh_status(&mut self) {
5541 let issue_err = matches!(self.issue_fetch_state(), GitHubFetchState::Error(_));
5542 let pr_err = matches!(self.pr_fetch_state(), GitHubFetchState::Error(_));
5543 self.status = match (issue_err, pr_err) {
5544 (false, false) => "github status refreshed".into(),
5545 (true, false) => format!(
5546 "issue fetch failed: {}",
5547 self.issue_error_message().unwrap_or("?".into())
5548 ),
5549 (false, true) => format!("pr fetch failed: {}", self.pr_error_message().unwrap_or("?".into())),
5550 (true, true) => format!(
5551 "issue + pr fetch failed — issue: {} · pr: {}",
5552 self.issue_error_message().unwrap_or("?".into()),
5553 self.pr_error_message().unwrap_or("?".into())
5554 ),
5555 };
5556 }
5557
5558 fn issue_error_message(&self) -> Option<String> {
5559 match self.issue_fetch_state() {
5560 GitHubFetchState::Error(e) => Some(e.clone()),
5561 _ => None,
5562 }
5563 }
5564
5565 fn pr_error_message(&self) -> Option<String> {
5566 match self.pr_fetch_state() {
5567 GitHubFetchState::Error(e) => Some(e.clone()),
5568 _ => None,
5569 }
5570 }
5571
5572 pub fn apply_issue_fetch_result(&mut self, r: std::result::Result<IssueStatus, String>) {
5573 if let Ok(status) = &r {
5574 self.persist_loaded_issue_title(status);
5575 }
5576 self.github.apply_issue_result(r);
5577 }
5578
5579 pub fn apply_pr_fetch_result(&mut self, r: std::result::Result<PrStatus, String>) {
5580 if let Ok(status) = &r {
5581 self.persist_loaded_pr_title(status);
5582 self.refresh_ci_overlay_on_pr_landing(status);
5583 }
5584 self.github.apply_pr_result(r);
5585 }
5586
5587 /// Rebuild the open CI checks overlay from a landed PR fetch (validation
5588 /// feedback on PR #455, `f` = refresh inside the overlay) — same
5589 /// convention as the agents landing. Gated on the CI consumer AND on the
5590 /// linked PR: the worktree-wide bulk prefetch lands other PRs' results
5591 /// through the same drain arm, and those must not clobber the rows.
5592 /// `set_rows` clamps the selection to the new count. Called from both
5593 /// landing paths — the drain (`TaskMsg::GithubPr`, the real worker path)
5594 /// and the `apply_pr_fetch_result` test seam — so they cannot desync
5595 /// again (the first cut lived only in the seam, so the running TUI never
5596 /// refreshed the overlay).
5597 ///
5598 /// Returns `true` when the landing closed the overlay and claimed the
5599 /// status line (empty rollup) so the drain suppresses its end-of-drain
5600 /// `report_github_refresh_status` — which otherwise overwrote the close
5601 /// message with "github status refreshed" (Codex review #455); same
5602 /// guard the sync arm uses.
5603 fn refresh_ci_overlay_on_pr_landing(&mut self, status: &PrStatus) -> bool {
5604 if self.view != View::DetailOverlay
5605 || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
5606 || self.github.link.pr != Some(status.number)
5607 {
5608 return false;
5609 }
5610 // An empty rollup (a fresh commit whose workflows have not started
5611 // yet) would blank the rows while leaving the overlay open — exactly
5612 // the empty overlay `enter_ci_checks` refuses to open (Codex review
5613 // #455). Close it and say why instead.
5614 if status.checks.is_empty() {
5615 self.close_detail_overlay();
5616 self.status = "no CI checks reported by the refreshed PR".into();
5617 return true;
5618 }
5619 let rows = crate::tui::state::detail_overlay::ci_check_rows(&status.checks, std::time::SystemTime::now());
5620 self.ci_overlay_checks = status.checks.clone();
5621 self.detail_overlay.set_rows(rows);
5622 false
5623 }
5624
5625 fn persist_loaded_issue_title(&mut self, status: &IssueStatus) {
5626 self.sync_issue_status_into_table(status);
5627 }
5628
5629 fn persist_loaded_pr_title(&mut self, status: &PrStatus) {
5630 self.sync_pr_status_into_table(status);
5631 }
5632
5633 // ---- Open menu ----------------------------------------------------------
5634
5635 pub fn enter_open_menu(&mut self) {
5636 // Re-resolve link + slug in case the user just linked something
5637 // (`gwm link …` from a parallel terminal) or moved the origin remote.
5638 //
5639 // Deliberately the non-invalidating variant: `refresh_link` clears
5640 // the fetch caches, and those hold the server-reported `web_url`
5641 // this menu is about to prefer over a locally built one. Clearing
5642 // here meant that URL was never once used (Codex review #458). Safe
5643 // because the caches are keyed by number, so a link that really
5644 // changed simply misses.
5645 let branch = self.selected_branch_name();
5646 if self.github.reread_link(&self.repo, branch.as_deref(), &self.config) {
5647 // The identity moved, so `reread_link` dropped the caches — the
5648 // navigation invariant says the spine generation moves with them,
5649 // or an in-flight worker for the previous instance repopulates
5650 // what was just cleared (issue #255, Codex review #458).
5651 self.tasks.invalidate_matching(TaskKind::is_github);
5652 }
5653 self.open_menu_selected = LinkTarget::Issue;
5654 self.view = View::OpenMenu;
5655 }
5656
5657 pub fn exit_open_menu(&mut self) {
5658 self.view = View::List;
5659 }
5660
5661 pub fn open_menu_toggle_selection(&mut self) {
5662 self.open_menu_selected = match self.open_menu_selected {
5663 LinkTarget::Issue => LinkTarget::Pr,
5664 LinkTarget::Pr => LinkTarget::Issue,
5665 };
5666 }
5667
5668 /// The issue's URL as the forge itself reported it, when a fetch has
5669 /// already landed (Codex review #458).
5670 ///
5671 /// For a guessed (SSH) origin the locally constructed URL is only
5672 /// `https://<ssh-host>/…`, which is wrong whenever the SSH hostname is
5673 /// not the web hostname or the web UI runs on HTTP / a non-standard
5674 /// port. The cached status carries the server's own `web_url`, so it is
5675 /// preferred whenever it is there — and unlike the CLI path this costs
5676 /// no request, which matters on the render thread.
5677 fn cached_issue_url(&self, number: u64) -> Option<String> {
5678 match self.github.issue_fetch_state(number) {
5679 GitHubFetchState::Loaded(s) if !s.url.is_empty() => Some(s.url.clone()),
5680 _ => None,
5681 }
5682 }
5683
5684 /// PR-side counterpart to [`Self::cached_issue_url`].
5685 fn cached_pr_url(&self, number: u64) -> Option<String> {
5686 match self.github.pr_fetch_state(number) {
5687 GitHubFetchState::Loaded(s) if !s.url.is_empty() => Some(s.url.clone()),
5688 _ => None,
5689 }
5690 }
5691
5692 /// Pick a target from the open menu. Returns the URL to open, or `None`
5693 /// when the link is missing (the status bar carries the explanation).
5694 pub fn open_menu_pick(&mut self, target: LinkTarget) -> Option<String> {
5695 self.view = View::List;
5696 let Some(forge) = self.github.forge.clone() else {
5697 self.status = "no forge remote — cannot build URL".into();
5698 return None;
5699 };
5700 // Whether the URL was built locally rather than read off the server.
5701 // On a guessed SSH origin the local build uses the SSH hostname, and
5702 // the cache is empty until a fetch lands — which on an unreachable
5703 // instance is never (Codex review #458). Opening a best guess still
5704 // beats a dead menu entry; saying so beats a silent wrong tab.
5705 let mut inferred = false;
5706 let url = match target {
5707 LinkTarget::Issue => match self.github.link.issue {
5708 Some(n) => self.cached_issue_url(n).unwrap_or_else(|| {
5709 inferred = true;
5710 forge.issue_url(n)
5711 }),
5712 None => {
5713 self.status = format!("no issue linked — press {} to link one", self.link_prompt_chord());
5714 return None;
5715 }
5716 },
5717 LinkTarget::Pr => match self.github.link.pr {
5718 Some(n) => self.cached_pr_url(n).unwrap_or_else(|| {
5719 inferred = true;
5720 forge.pr_url(n)
5721 }),
5722 None => {
5723 self.status = format!(
5724 "no {} linked — press {} to link one",
5725 forge.pr_noun(),
5726 self.link_prompt_chord()
5727 );
5728 return None;
5729 }
5730 },
5731 };
5732 if inferred && !forge.origin_is_authoritative() {
5733 self.status = format!("opening a guessed URL — the SSH origin names no web host: {url}");
5734 }
5735 Some(url)
5736 }
5737
5738 // ---- Link prompt --------------------------------------------------------
5739 //
5740 // Pure state lives in `self.link_prompt` (`tui::state::link_prompt`,
5741 // extracted per #126). The methods below are thin orchestrator
5742 // wrappers: they update `self.view` / `self.status` / drive the
5743 // `github::link_{issue,pr}` shell-out on submit, then delegate the
5744 // buffer / stage transitions to `LinkPrompt`.
5745
5746 pub fn enter_link_prompt(&mut self) {
5747 self.view = View::LinkPrompt;
5748 self.link_prompt.reset();
5749 self.status = "pick".into();
5750 }
5751
5752 /// Highlighted row in the `ChooseTarget` picker (for the renderer).
5753 pub fn link_prompt_selected(&self) -> LinkTarget {
5754 self.link_prompt.selected
5755 }
5756
5757 /// Testable key handler for the link prompt (issue #217), mirroring
5758 /// [`App::handle_create_key`]. The picker / digit-buffer mutations and
5759 /// the per-stage status copy stay here; the loop only acts on the
5760 /// returned [`LinkPromptKey`] for the two genuine side effects
5761 /// (submit shell-out, view transition).
5762 pub fn handle_link_prompt_key(&mut self, key: KeyEvent) -> LinkPromptKey {
5763 use crate::tui::state::link_prompt::LinkPromptStage;
5764 // #219: each stage is its own modal context. ChooseTarget is a vertical
5765 // two-row picker — `next` / `prev` both flip the highlight (a single
5766 // flip serves j/k/Up/Down alike), while `issue` / `pr` are direct picks.
5767 // InputNumber routes `submit` / `cancel` through the context and treats
5768 // everything else as digit input. The global `fetch_github` key is a
5769 // FALLBACK after the stage context, so a contextual binding on that key
5770 // (e.g. `submit = ["F"]`) wins over the fetch shortcut (#293 review).
5771 match self.link_prompt.stage {
5772 LinkPromptStage::ChooseTarget => match self.resolve_modal(KeyContext::LinkChooseTarget, key) {
5773 Some(ModalAction::LinkChooseCancel) => return LinkPromptKey::Cancel,
5774 Some(ModalAction::LinkChooseNext) | Some(ModalAction::LinkChoosePrev) => self.link_prompt.toggle_selection(),
5775 Some(ModalAction::LinkChooseIssue) => self.link_prompt_choose(LinkTarget::Issue),
5776 Some(ModalAction::LinkChoosePr) => self.link_prompt_choose(LinkTarget::Pr),
5777 Some(ModalAction::LinkChooseAccept) => {
5778 let target = self.link_prompt.selected;
5779 self.link_prompt_choose(target);
5780 }
5781 _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
5782 _ => {}
5783 },
5784 // Typing stays reserved here too (Codex review #456) — digits and
5785 // Backspace route to the number before the stage context so a
5786 // modal rebind cannot swallow them. Same contract as the create
5787 // form; Ctrl-modified chars still reach the modal resolution.
5788 LinkPromptStage::InputNumber
5789 if key.code == KeyCode::Backspace
5790 && !key
5791 .modifiers
5792 .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
5793 {
5794 self.link_prompt_pop_char()
5795 }
5796 LinkPromptStage::InputNumber
5797 if matches!(key.code, KeyCode::Char(c) if c.is_ascii_digit())
5798 && !key
5799 .modifiers
5800 .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
5801 {
5802 if let KeyCode::Char(c) = key.code {
5803 self.link_prompt_push_char(c);
5804 }
5805 }
5806 LinkPromptStage::InputNumber => match self.resolve_modal(KeyContext::LinkInputNumber, key) {
5807 Some(ModalAction::LinkInputCancel) => return LinkPromptKey::Cancel,
5808 Some(ModalAction::LinkInputSubmit) => return LinkPromptKey::Submit,
5809 _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
5810 _ => match key.code {
5811 KeyCode::Char(c) => self.link_prompt_push_char(c),
5812 KeyCode::Backspace => self.link_prompt_pop_char(),
5813 _ => {}
5814 },
5815 },
5816 }
5817 LinkPromptKey::Handled
5818 }
5819
5820 pub fn link_prompt_cancel(&mut self) {
5821 self.view = View::List;
5822 self.link_prompt.reset();
5823 }
5824
5825 pub fn link_prompt_stage(&self) -> LinkPromptStage {
5826 self.link_prompt.stage
5827 }
5828
5829 pub fn link_prompt_number_input(&self) -> &str {
5830 &self.link_prompt.number
5831 }
5832
5833 pub fn link_prompt_target(&self) -> Option<LinkTarget> {
5834 self.link_prompt.target
5835 }
5836
5837 pub fn link_prompt_choose(&mut self, target: LinkTarget) {
5838 self.link_prompt.commit_target(target);
5839 self.status = match target {
5840 LinkTarget::Issue | LinkTarget::Pr => "num".into(),
5841 };
5842 }
5843
5844 pub fn link_prompt_push_char(&mut self, c: char) {
5845 self.link_prompt.push_char(c);
5846 }
5847
5848 pub fn link_prompt_pop_char(&mut self) {
5849 self.link_prompt.pop_char();
5850 }
5851
5852 pub fn link_prompt_submit(&mut self) -> Result<()> {
5853 let Some(target) = self.link_prompt.target else {
5854 self.status = "no target chosen".into();
5855 return Ok(());
5856 };
5857 let n: u64 = self
5858 .link_prompt
5859 .number
5860 .parse()
5861 .map_err(|_| GwmError::Other("number is empty or invalid".into()))?;
5862 let branch = self
5863 .selected()
5864 .and_then(|w| w.branch.clone())
5865 .or_else(|| {
5866 self
5867 .repo
5868 .head()
5869 .ok()
5870 .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
5871 })
5872 .ok_or_else(|| GwmError::Other("no branch resolved for selected worktree".into()))?;
5873 match target {
5874 LinkTarget::Issue => github::link_issue(&self.repo, &branch, n)?,
5875 LinkTarget::Pr => github::link_pr(&self.repo, &branch, n)?,
5876 }
5877 self.status = match target {
5878 LinkTarget::Issue => format!("linked issue #{} to {}", n, branch),
5879 LinkTarget::Pr => format!("linked PR #{} to {}", n, branch),
5880 };
5881 self.view = View::List;
5882 self.link_prompt.reset();
5883 self.refresh_link();
5884 Ok(())
5885 }
5886}
5887
5888/// Resolve the shell command for `mode = "shell"`. Precedence:
5889/// `shell_cmd` in `.gwm.toml` → `$SHELL` env var → `/bin/sh`. The
5890/// hardcoded fallback exists for the (rare) case where neither is set —
5891/// the TUI's spawn-and-restore loop assumes a non-empty command string.
5892fn resolve_shell_command(cfg: &TuiOpenConfig) -> String {
5893 cfg
5894 .shell_cmd
5895 .clone()
5896 .or_else(|| std::env::var("SHELL").ok())
5897 .unwrap_or_else(|| "/bin/sh".into())
5898}
5899
5900/// Resolve the editor command for `mode = "editor"`. Precedence:
5901/// `editor_cmd` in `.gwm.toml` → `$EDITOR` env var → `vi` (POSIX
5902/// baseline). Mirrors `resolve_shell_command` so the two flows share
5903/// the same precedence story.
5904fn resolve_editor_command(cfg: &TuiOpenConfig) -> String {
5905 cfg
5906 .editor_cmd
5907 .clone()
5908 .or_else(|| std::env::var("EDITOR").ok())
5909 .unwrap_or_else(|| "vi".into())
5910}