Skip to main content

purple_ssh/app/
screen.rs

1//! Screen enum: tags the currently-displayed overlay or view.
2
3/// Top-level page selected via the top navigation bar.
4///
5/// Orthogonal to [`Screen`]. `Screen` tracks overlays and modal forms,
6/// `TopPage` tracks which base view (hosts, tunnels, containers, keys)
7/// renders behind them. Tab/Shift+Tab cycles through the variants when
8/// no overlay is active.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum TopPage {
11    #[default]
12    Hosts,
13    Tunnels,
14    Containers,
15    Keys,
16}
17
18impl TopPage {
19    /// Cycle to the next page
20    /// (Hosts -> Tunnels -> Containers -> Keys -> Hosts).
21    pub fn next(self) -> Self {
22        match self {
23            TopPage::Hosts => TopPage::Tunnels,
24            TopPage::Tunnels => TopPage::Containers,
25            TopPage::Containers => TopPage::Keys,
26            TopPage::Keys => TopPage::Hosts,
27        }
28    }
29
30    /// Cycle to the previous page
31    /// (Hosts -> Keys -> Containers -> Tunnels -> Hosts).
32    pub fn prev(self) -> Self {
33        match self {
34            TopPage::Hosts => TopPage::Keys,
35            TopPage::Tunnels => TopPage::Hosts,
36            TopPage::Containers => TopPage::Tunnels,
37            TopPage::Keys => TopPage::Containers,
38        }
39    }
40}
41
42/// State for the What's New overlay.
43#[derive(Debug, Default, Clone, PartialEq)]
44pub struct WhatsNewState {
45    pub scroll: u16,
46}
47
48/// Search state for the container logs viewer. `None` on
49/// `Screen::ContainerLogs.search` means no search is active.
50///
51/// Modeless: while the struct is `Some`, every keystroke either edits
52/// the query (chars / cursor / delete) or navigates matches
53/// (Tab / Shift+Tab). There is no "confirm" step: matches are
54/// recomputed live and `Esc` exits search outright.
55///
56/// `matches` are line indices into the rendered body; `current`
57/// indexes into `matches`. Smart case is decided by the query at
58/// match time: any uppercase rune flips to case-sensitive (vim's
59/// `'smartcase'`).
60///
61/// `cursor_pos` is a char index into `query` (0..=chars().count()).
62/// Mirrors the host_form pattern so Left/Right/Home/End/Delete edit
63/// mid-string instead of forcing append-only behaviour.
64#[derive(Debug, Default, Clone, PartialEq)]
65pub struct ContainerLogsSearch {
66    pub query: String,
67    pub matches: Vec<usize>,
68    pub current: usize,
69    pub cursor_pos: usize,
70}
71
72impl ContainerLogsSearch {
73    pub fn insert_char(&mut self, c: char) {
74        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
75        self.query.insert(byte_pos, c);
76        self.cursor_pos += 1;
77    }
78
79    pub fn delete_char_before_cursor(&mut self) {
80        if self.cursor_pos == 0 {
81            return;
82        }
83        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
84        let prev = super::forms::char_to_byte_pos(&self.query, self.cursor_pos - 1);
85        self.query.drain(prev..byte_pos);
86        self.cursor_pos -= 1;
87    }
88
89    pub fn delete_char_at_cursor(&mut self) {
90        let len = self.query.chars().count();
91        if self.cursor_pos >= len {
92            return;
93        }
94        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
95        let next = super::forms::char_to_byte_pos(&self.query, self.cursor_pos + 1);
96        self.query.drain(byte_pos..next);
97    }
98
99    pub fn move_left(&mut self) {
100        if self.cursor_pos > 0 {
101            self.cursor_pos -= 1;
102        }
103    }
104
105    pub fn move_right(&mut self) {
106        let len = self.query.chars().count();
107        if self.cursor_pos < len {
108            self.cursor_pos += 1;
109        }
110    }
111
112    pub fn move_home(&mut self) {
113        self.cursor_pos = 0;
114    }
115
116    pub fn move_end(&mut self) {
117        self.cursor_pos = self.query.chars().count();
118    }
119}
120
121/// One running compose-stack member surfaced in the stack-restart
122/// confirm dialog. Carried so the confirm body can list every
123/// container that will be cycled, identity-and-state-clear.
124#[derive(Debug, Clone, PartialEq)]
125pub struct StackMember {
126    pub container_id: String,
127    pub container_name: String,
128    pub uptime: Option<String>,
129}
130
131/// Which screen is currently displayed.
132#[derive(Debug, Clone, PartialEq)]
133pub enum Screen {
134    HostList,
135    AddHost,
136    EditHost {
137        alias: String,
138    },
139    ConfirmDelete {
140        alias: String,
141    },
142    Help {
143        return_screen: Box<Screen>,
144    },
145    KeyList,
146    KeyDetail {
147        index: usize,
148    },
149    /// Multi-host picker reached from the Keys tab by pressing `p`.
150    /// `key_index` points into `app.keys.list` for the key to push. The picker
151    /// shows hosts with checkbox selection; hosts whose `vault_ssh` role
152    /// is configured are dimmed and not selectable (Vault SSH workflow
153    /// uses signed certs, not authorized_keys appends).
154    KeyPushPicker {
155        key_index: usize,
156    },
157    /// Destructive confirm shown after the picker commits. Footer renders
158    /// action verbs both sides via `design::confirm_footer_destructive`.
159    /// On y the worker thread is spawned and the screen returns to
160    /// HostList; on n/Esc returns to the picker with selection intact.
161    ///
162    /// The frozen alias list lives on `app.keys.push.committed` instead
163    /// of inside the Screen variant: keeping the vec out of the enum
164    /// prevents per-frame clones during overlay redraws and keeps the
165    /// `Screen` payload uniformly small.
166    ConfirmKeyPush {
167        key_index: usize,
168    },
169    HostDetail {
170        index: usize,
171    },
172    TagPicker,
173    ThemePicker,
174    Providers,
175    ProviderForm {
176        id: crate::providers::config::ProviderConfigId,
177    },
178    /// Step 1 of the lazy add-second-config flow: ask the user to pick a
179    /// label for the existing (bare) config of `provider` before opening
180    /// the new-config form. The chosen label lives on
181    /// `app.providers.pending_label_migration` until step 2 saves both
182    /// configs together.
183    ProviderLabelMigration {
184        provider: String,
185    },
186    TunnelList {
187        alias: String,
188    },
189    TunnelForm {
190        alias: String,
191        editing: Option<usize>,
192    },
193    /// Host picker reached from the Tunnels overview when adding a new
194    /// tunnel: the user must choose a host before the tunnel form opens.
195    /// On confirm, transitions to `TunnelForm { alias, editing: None }`.
196    TunnelHostPicker,
197    /// Multi-host picker for snippet execution. The host aliases live
198    /// on `snippets.flow_targets`; the variant stays data-less.
199    SnippetPicker,
200    /// Edit form for a snippet. `editing` (index) and target_aliases
201    /// live on `snippets.form_editing` / `snippets.flow_targets`.
202    SnippetForm,
203    /// Output viewer after running a snippet against the flow targets.
204    /// `snippet_name` lives on `snippets.output_snippet_name`;
205    /// `target_aliases` on `snippets.flow_targets`.
206    SnippetOutput,
207    /// Param-substitution form shown before running a parametrised
208    /// snippet. The `Snippet` lives on `snippets.param_snippet` and
209    /// the target aliases on `snippets.flow_targets`.
210    SnippetParamForm,
211    ConfirmHostKeyReset {
212        alias: String,
213        hostname: String,
214        known_hosts_path: String,
215        askpass: Option<String>,
216    },
217    FileBrowser {
218        alias: String,
219    },
220    Containers {
221        alias: String,
222    },
223    /// Picker reached from the containers overview when adding a host
224    /// to the cache (`a`). Lists hosts that have no cache entry yet;
225    /// on Enter, spawns a `docker ps` listing for the chosen host and
226    /// returns to the overview.
227    ContainerHostPicker,
228    /// One-shot logs viewer for a single container. The identity
229    /// (alias / container_id / container_name), the streaming body, the
230    /// scroll position and the search state all live on
231    /// `container_state.logs_view`; this variant only tags the open
232    /// overlay so the dispatch table reads cleanly.
233    ContainerLogs,
234    /// Confirm dialog for `K` (kick). restart a single running
235    /// container. Reuses `route_confirm_key` so y/n/Esc are the only
236    /// effective inputs; stake-test footer phrases the verb on both
237    /// sides.
238    ConfirmContainerRestart {
239        alias: String,
240        container_id: String,
241        container_name: String,
242        project: Option<String>,
243        uptime: Option<String>,
244    },
245    /// Confirm dialog for `S` (stop). stop a single running container.
246    /// Same key contract as ConfirmContainerRestart.
247    ConfirmContainerStop {
248        alias: String,
249        container_id: String,
250        container_name: String,
251        project: Option<String>,
252        uptime: Option<String>,
253    },
254    /// Single-line prompt for an arbitrary command to run inside the
255    /// container via `docker exec -it`. Submit hits the existing
256    /// `pending_container_exec` flow with the typed command in place
257    /// of the default `bash || sh`.
258    ContainerExecPrompt {
259        alias: String,
260        container_id: String,
261        container_name: String,
262        query: String,
263    },
264    /// Confirm dialog for `Ctrl-K` (stack kick). Restarts every running
265    /// member of a compose stack on a single host, sequentially. The
266    /// alias/project/members payload lives on
267    /// `containers_overview.pending_bulk_confirm` so screen
268    /// transitions stay allocation-free.
269    ConfirmStackRestart,
270    /// Confirm dialog for `K` pressed on a host-divider row in the
271    /// containers overview. Restarts every running container on the
272    /// host. Payload on `containers_overview.pending_bulk_confirm`.
273    ConfirmHostRestartAll,
274    /// Confirm dialog for `S` pressed on a host-divider row. Stops
275    /// every running container on the host. Payload on
276    /// `containers_overview.pending_bulk_confirm`.
277    ConfirmHostStopAll,
278    ConfirmImport {
279        count: usize,
280    },
281    /// Confirm dialog for purging stale (provider-managed but deleted)
282    /// hosts. The alias list and provider scope live on
283    /// `providers.pending_purge` so the variant stays data-less.
284    ConfirmPurgeStale,
285    /// Confirm dialog for `V` (bulk vault sign). The precomputed
286    /// signable list lives on `vault.pending_sign` so the screen
287    /// variant stays data-less.
288    ConfirmVaultSign,
289    Welcome {
290        has_backup: bool,
291        host_count: usize,
292        known_hosts_count: usize,
293    },
294    /// Bulk tag editor: tri-state checkbox picker that edits tags across
295    /// all hosts in `multi_select` in one go. Opened via `t` when a
296    /// multi-host selection is active.
297    BulkTagEditor,
298    /// What's New overlay: shows recent changelog sections to the user
299    /// after an upgrade. Opened via the upgrade toast or `n` key.
300    WhatsNew(WhatsNewState),
301}
302
303impl Screen {
304    /// Stable short variant name used in state-transition logs.
305    /// Omits inner fields so log lines never leak host aliases, paths or
306    /// tokens.
307    pub fn variant_name(&self) -> &'static str {
308        match self {
309            Screen::HostList => "HostList",
310            Screen::AddHost => "AddHost",
311            Screen::EditHost { .. } => "EditHost",
312            Screen::ConfirmDelete { .. } => "ConfirmDelete",
313            Screen::Help { .. } => "Help",
314            Screen::KeyList => "KeyList",
315            Screen::KeyDetail { .. } => "KeyDetail",
316            Screen::KeyPushPicker { .. } => "KeyPushPicker",
317            Screen::ConfirmKeyPush { .. } => "ConfirmKeyPush",
318            Screen::HostDetail { .. } => "HostDetail",
319            Screen::TagPicker => "TagPicker",
320            Screen::ThemePicker => "ThemePicker",
321            Screen::Providers => "Providers",
322            Screen::ProviderForm { .. } => "ProviderForm",
323            Screen::ProviderLabelMigration { .. } => "ProviderLabelMigration",
324            Screen::TunnelList { .. } => "TunnelList",
325            Screen::TunnelForm { .. } => "TunnelForm",
326            Screen::TunnelHostPicker => "TunnelHostPicker",
327            Screen::SnippetPicker => "SnippetPicker",
328            Screen::SnippetForm => "SnippetForm",
329            Screen::SnippetOutput => "SnippetOutput",
330            Screen::SnippetParamForm => "SnippetParamForm",
331            Screen::ConfirmHostKeyReset { .. } => "ConfirmHostKeyReset",
332            Screen::FileBrowser { .. } => "FileBrowser",
333            Screen::Containers { .. } => "Containers",
334            Screen::ContainerHostPicker => "ContainerHostPicker",
335            Screen::ContainerLogs => "ContainerLogs",
336            Screen::ConfirmContainerRestart { .. } => "ConfirmContainerRestart",
337            Screen::ConfirmContainerStop { .. } => "ConfirmContainerStop",
338            Screen::ContainerExecPrompt { .. } => "ContainerExecPrompt",
339            Screen::ConfirmStackRestart => "ConfirmStackRestart",
340            Screen::ConfirmHostRestartAll => "ConfirmHostRestartAll",
341            Screen::ConfirmHostStopAll => "ConfirmHostStopAll",
342            Screen::ConfirmImport { .. } => "ConfirmImport",
343            Screen::ConfirmPurgeStale => "ConfirmPurgeStale",
344            Screen::ConfirmVaultSign => "ConfirmVaultSign",
345            Screen::Welcome { .. } => "Welcome",
346            Screen::BulkTagEditor => "BulkTagEditor",
347            Screen::WhatsNew(_) => "WhatsNew",
348        }
349    }
350}