gwm/tui/state/exec_picker.rs
1//! Exec profile picker state (issue #325).
2//!
3//! Pure state — no `App` / `Config` / PTY dependency beyond the sorted
4//! profile-name list handed in at open time. The `App` orchestrator owns
5//! resolving the highlighted profile to an argv (via
6//! [`crate::exec::resolve_exec_command`]); the run loop owns spawning the
7//! PTY overlay with that argv in the selected worktree's directory.
8//!
9//! The picker is deliberately tiny: a sorted list of `[exec.profiles.*]`
10//! names, a wrapping highlight, and the worktree path it was opened for.
11//! `Enter` accepts the highlight, `Esc` cancels — the `j`/`k`/arrow
12//! navigation lives in [`crate::tui::modal_keymap`] under
13//! `KeyContext::ExecPicker`.
14
15use std::path::{Path, PathBuf};
16
17/// Pure state for the exec profile picker. `Default` is an empty, closed
18/// picker (no profiles, no target, highlight at 0).
19#[derive(Debug, Default)]
20pub struct ExecPicker {
21 /// Exec profile names from `[exec.profiles.*]`, in the sorted order the
22 /// orchestrator hands them over (a `BTreeMap` key walk).
23 profiles: Vec<String>,
24 /// Highlighted row index. Always a valid index into `profiles` while
25 /// non-empty; the `next`/`prev` wrappers keep it in range.
26 selected: usize,
27 /// The worktree directory the picker was opened for. Captured at open so
28 /// `Enter` runs the profile in *that* worktree even if an auto-refresh
29 /// drifts the live selection while the overlay sits open (Codex #333).
30 cwd: Option<PathBuf>,
31}
32
33impl ExecPicker {
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 /// (Re)populate the picker from a list of profile names + the worktree it
39 /// targets, resetting the highlight to the top. Called by the
40 /// orchestrator's `enter_exec_picker` each time the overlay opens.
41 pub fn open(&mut self, profiles: Vec<String>, cwd: PathBuf) {
42 self.profiles = profiles;
43 self.selected = 0;
44 self.cwd = Some(cwd);
45 }
46
47 /// The worktree directory the picker was opened for, if any.
48 pub fn cwd(&self) -> Option<&Path> {
49 self.cwd.as_deref()
50 }
51
52 /// The profile names, in display order.
53 pub fn profiles(&self) -> &[String] {
54 &self.profiles
55 }
56
57 /// `true` when no profiles are loaded (the orchestrator refuses to open
58 /// the overlay in this state, but the picker stays well-defined).
59 pub fn is_empty(&self) -> bool {
60 self.profiles.is_empty()
61 }
62
63 /// The highlighted row index (clamped to `profiles`).
64 pub fn selected_index(&self) -> usize {
65 self.selected
66 }
67
68 /// The highlighted profile name, or `None` when the picker is empty.
69 pub fn selected_profile(&self) -> Option<&str> {
70 self.profiles.get(self.selected).map(String::as_str)
71 }
72
73 /// Move the highlight down one row, wrapping to the top. No-op when
74 /// empty.
75 pub fn next(&mut self) {
76 if self.profiles.is_empty() {
77 return;
78 }
79 self.selected = (self.selected + 1) % self.profiles.len();
80 }
81
82 /// Move the highlight up one row, wrapping to the bottom. No-op when
83 /// empty.
84 pub fn prev(&mut self) {
85 if self.profiles.is_empty() {
86 return;
87 }
88 self.selected = (self.selected + self.profiles.len() - 1) % self.profiles.len();
89 }
90}