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    /// Trust-on-first-contact question for a host a background ssh met
230    /// that is not in `known_hosts` yet. `y` runs `retry` once with
231    /// `StrictHostKeyChecking=accept-new`; `n` and Esc drop it. Carries
232    /// no secret, so it lives in the variant like `ConfirmHostKeyReset`.
233    ConfirmHostKeyTrust {
234        alias: String,
235        hostname: String,
236        retry: super::password_prompt::PendingRetry,
237    },
238    /// Password prompt for a background ssh that ended in `Permission
239    /// denied`. The alias, the typed password, the remember toggle and the
240    /// retry live on `app.password_prompt`, so the secret is never cloned
241    /// per frame the way variant payloads are.
242    PasswordPrompt,
243    FileBrowser {
244        alias: String,
245    },
246    Containers {
247        alias: String,
248    },
249    /// Picker reached from the containers overview when adding a host
250    /// to the cache (`a`). Lists hosts that have no cache entry yet;
251    /// on Enter, spawns a `docker ps` listing for the chosen host and
252    /// returns to the overview.
253    ContainerHostPicker,
254    /// One-shot logs viewer for a single container. The identity
255    /// (alias / container_id / container_name), the streaming body, the
256    /// scroll position and the search state all live on
257    /// `container_state.logs_view`; this variant only tags the open
258    /// overlay so the dispatch table reads cleanly.
259    ContainerLogs,
260    /// Confirm dialog for `K` (kick). restart a single running
261    /// container. Reuses `route_confirm_key` so y/n/Esc are the only
262    /// effective inputs; stake-test footer phrases the verb on both
263    /// sides.
264    ConfirmContainerRestart {
265        alias: String,
266        container_id: String,
267        container_name: String,
268        project: Option<String>,
269        uptime: Option<String>,
270    },
271    /// Confirm dialog for `S` (stop). stop a single running container.
272    /// Same key contract as ConfirmContainerRestart.
273    ConfirmContainerStop {
274        alias: String,
275        container_id: String,
276        container_name: String,
277        project: Option<String>,
278        uptime: Option<String>,
279    },
280    /// Single-line prompt for an arbitrary command to run inside the
281    /// container via `docker exec -it`. Submit hits the existing
282    /// `pending_container_exec` flow with the typed command in place
283    /// of the default `bash || sh`.
284    ContainerExecPrompt {
285        alias: String,
286        container_id: String,
287        container_name: String,
288        query: String,
289    },
290    /// Confirm dialog for `Ctrl-K` (stack kick). Restarts every running
291    /// member of a compose stack on a single host, sequentially. The
292    /// alias/project/members payload lives on
293    /// `containers_overview.pending_bulk_confirm` so screen
294    /// transitions stay allocation-free.
295    ConfirmStackRestart,
296    /// Confirm dialog for `K` pressed on a host-divider row in the
297    /// containers overview. Restarts every running container on the
298    /// host. Payload on `containers_overview.pending_bulk_confirm`.
299    ConfirmHostRestartAll,
300    /// Confirm dialog for `S` pressed on a host-divider row. Stops
301    /// every running container on the host. Payload on
302    /// `containers_overview.pending_bulk_confirm`.
303    ConfirmHostStopAll,
304    ConfirmImport {
305        count: usize,
306    },
307    /// Confirm dialog for purging stale (provider-managed but deleted)
308    /// hosts. The alias list and provider scope live on
309    /// `providers.pending_purge` so the variant stays data-less.
310    ConfirmPurgeStale,
311    /// Confirm dialog for `V` (bulk vault sign). The precomputed
312    /// signable list lives on `vault.pending_sign` so the screen
313    /// variant stays data-less.
314    ConfirmVaultSign,
315    Welcome {
316        has_backup: bool,
317        host_count: usize,
318        known_hosts_count: usize,
319    },
320    /// Bulk tag editor: tri-state checkbox picker that edits tags across
321    /// all hosts in `multi_select` in one go. Opened via `t` when a
322    /// multi-host selection is active.
323    BulkTagEditor,
324    /// What's New overlay: shows recent changelog sections to the user
325    /// after an upgrade. Opened via the upgrade toast or `n` key.
326    WhatsNew(WhatsNewState),
327}
328
329impl Screen {
330    /// Stable short variant name used in state-transition logs.
331    /// Omits inner fields so log lines never leak host aliases, paths or
332    /// tokens.
333    pub fn variant_name(&self) -> &'static str {
334        match self {
335            Screen::HostList => "HostList",
336            Screen::AddHost => "AddHost",
337            Screen::EditHost { .. } => "EditHost",
338            Screen::ConfirmDelete { .. } => "ConfirmDelete",
339            Screen::Help { .. } => "Help",
340            Screen::KeyList => "KeyList",
341            Screen::KeyDetail { .. } => "KeyDetail",
342            Screen::KeyPushPicker { .. } => "KeyPushPicker",
343            Screen::ConfirmKeyPush { .. } => "ConfirmKeyPush",
344            Screen::HostDetail { .. } => "HostDetail",
345            Screen::TagPicker => "TagPicker",
346            Screen::ThemePicker => "ThemePicker",
347            Screen::Providers => "Providers",
348            Screen::ProviderForm { .. } => "ProviderForm",
349            Screen::ProviderLabelMigration { .. } => "ProviderLabelMigration",
350            Screen::TunnelList { .. } => "TunnelList",
351            Screen::TunnelForm { .. } => "TunnelForm",
352            Screen::TunnelHostPicker => "TunnelHostPicker",
353            Screen::SnippetPicker => "SnippetPicker",
354            Screen::SnippetForm => "SnippetForm",
355            Screen::SnippetOutput => "SnippetOutput",
356            Screen::SnippetParamForm => "SnippetParamForm",
357            Screen::SnippetHostPicker => "SnippetHostPicker",
358            Screen::ConfirmRunSnippet => "ConfirmRunSnippet",
359            Screen::ConfirmHostKeyReset { .. } => "ConfirmHostKeyReset",
360            Screen::ConfirmHostKeyTrust { .. } => "ConfirmHostKeyTrust",
361            Screen::PasswordPrompt => "PasswordPrompt",
362            Screen::FileBrowser { .. } => "FileBrowser",
363            Screen::Containers { .. } => "Containers",
364            Screen::ContainerHostPicker => "ContainerHostPicker",
365            Screen::ContainerLogs => "ContainerLogs",
366            Screen::ConfirmContainerRestart { .. } => "ConfirmContainerRestart",
367            Screen::ConfirmContainerStop { .. } => "ConfirmContainerStop",
368            Screen::ContainerExecPrompt { .. } => "ContainerExecPrompt",
369            Screen::ConfirmStackRestart => "ConfirmStackRestart",
370            Screen::ConfirmHostRestartAll => "ConfirmHostRestartAll",
371            Screen::ConfirmHostStopAll => "ConfirmHostStopAll",
372            Screen::ConfirmImport { .. } => "ConfirmImport",
373            Screen::ConfirmPurgeStale => "ConfirmPurgeStale",
374            Screen::ConfirmVaultSign => "ConfirmVaultSign",
375            Screen::Welcome { .. } => "Welcome",
376            Screen::BulkTagEditor => "BulkTagEditor",
377            Screen::WhatsNew(_) => "WhatsNew",
378        }
379    }
380}
381
382#[cfg(test)]
383mod top_page_tests {
384    use super::TopPage;
385
386    #[test]
387    fn cycle_includes_snippets_between_containers_and_keys() {
388        assert_eq!(TopPage::Containers.next(), TopPage::Snippets);
389        assert_eq!(TopPage::Snippets.next(), TopPage::Keys);
390        assert_eq!(TopPage::Keys.prev(), TopPage::Snippets);
391        assert_eq!(TopPage::Snippets.prev(), TopPage::Containers);
392    }
393
394    #[test]
395    fn full_forward_cycle_wraps() {
396        let mut p = TopPage::Hosts;
397        for _ in 0..5 {
398            p = p.next();
399        }
400        assert_eq!(p, TopPage::Hosts);
401    }
402}