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