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    SnippetPicker {
198        target_aliases: Vec<String>,
199    },
200    SnippetForm {
201        target_aliases: Vec<String>,
202        editing: Option<usize>,
203    },
204    SnippetOutput {
205        snippet_name: String,
206        target_aliases: Vec<String>,
207    },
208    SnippetParamForm {
209        snippet: crate::snippet::Snippet,
210        target_aliases: Vec<String>,
211    },
212    ConfirmHostKeyReset {
213        alias: String,
214        hostname: String,
215        known_hosts_path: String,
216        askpass: Option<String>,
217    },
218    FileBrowser {
219        alias: String,
220    },
221    Containers {
222        alias: String,
223    },
224    /// Picker reached from the containers overview when adding a host
225    /// to the cache (`a`). Lists hosts that have no cache entry yet;
226    /// on Enter, spawns a `docker ps` listing for the chosen host and
227    /// returns to the overview.
228    ContainerHostPicker,
229    /// One-shot logs viewer for a single container. `body` is empty
230    /// while the SSH `docker logs --tail 200` call is in flight; once
231    /// the result lands the lines populate it. `scroll` is line-based,
232    /// `0` is the top. `last_render_height` is written by the renderer
233    /// each frame so the logs-arrival path and `G` can compute the
234    /// tail-anchored scroll without guessing the visible-area size.
235    ContainerLogs {
236        alias: String,
237        container_id: String,
238        container_name: String,
239        body: Vec<String>,
240        fetched_at: u64,
241        error: Option<String>,
242        scroll: u16,
243        last_render_height: u16,
244        /// `/` search state. `None` when no search is active.
245        search: Option<ContainerLogsSearch>,
246    },
247    /// Confirm dialog for `K` (kick). restart a single running
248    /// container. Reuses `route_confirm_key` so y/n/Esc are the only
249    /// effective inputs; stake-test footer phrases the verb on both
250    /// sides.
251    ConfirmContainerRestart {
252        alias: String,
253        container_id: String,
254        container_name: String,
255        project: Option<String>,
256        uptime: Option<String>,
257    },
258    /// Confirm dialog for `S` (stop). stop a single running container.
259    /// Same key contract as ConfirmContainerRestart.
260    ConfirmContainerStop {
261        alias: String,
262        container_id: String,
263        container_name: String,
264        project: Option<String>,
265        uptime: Option<String>,
266    },
267    /// Single-line prompt for an arbitrary command to run inside the
268    /// container via `docker exec -it`. Submit hits the existing
269    /// `pending_container_exec` flow with the typed command in place
270    /// of the default `bash || sh`.
271    ContainerExecPrompt {
272        alias: String,
273        container_id: String,
274        container_name: String,
275        query: String,
276    },
277    /// Confirm dialog for `Ctrl-K` (stack kick). Restarts every running
278    /// member of a compose stack on a single host, sequentially. The
279    /// drain queue + 30s cache TTL pace the work; we accept the trivial
280    /// per-tick interleaving.
281    ConfirmStackRestart {
282        alias: String,
283        project: String,
284        members: Vec<StackMember>,
285    },
286    /// Confirm dialog for `K` pressed on a host-divider row in the
287    /// containers overview. Restarts every running container on the
288    /// host, ignoring compose-project boundaries. Shares the bulk
289    /// confirm shape with `ConfirmStackRestart`.
290    ConfirmHostRestartAll {
291        alias: String,
292        members: Vec<StackMember>,
293    },
294    /// Confirm dialog for `S` pressed on a host-divider row. Stops
295    /// every running container on the host, sequentially.
296    ConfirmHostStopAll {
297        alias: String,
298        members: Vec<StackMember>,
299    },
300    ConfirmImport {
301        count: usize,
302    },
303    ConfirmPurgeStale {
304        aliases: Vec<String>,
305        provider: Option<String>,
306    },
307    ConfirmVaultSign {
308        /// Precomputed list of hosts that resolve to a Vault SSH role. Computed
309        /// when the user presses `V`. `certificate_file` is the host's existing
310        /// `CertificateFile` directive (empty when unset) so the background
311        /// worker checks renewal against the configured cert path.
312        signable: Vec<crate::vault_ssh::VaultSignTarget>,
313    },
314    Welcome {
315        has_backup: bool,
316        host_count: usize,
317        known_hosts_count: usize,
318    },
319    /// Bulk tag editor: tri-state checkbox picker that edits tags across
320    /// all hosts in `multi_select` in one go. Opened via `t` when a
321    /// multi-host selection is active.
322    BulkTagEditor,
323    /// What's New overlay: shows recent changelog sections to the user
324    /// after an upgrade. Opened via the upgrade toast or `n` key.
325    WhatsNew(WhatsNewState),
326}
327
328impl Screen {
329    /// Stable short variant name used in state-transition logs.
330    /// Omits inner fields so log lines never leak host aliases, paths or
331    /// tokens.
332    pub fn variant_name(&self) -> &'static str {
333        match self {
334            Screen::HostList => "HostList",
335            Screen::AddHost => "AddHost",
336            Screen::EditHost { .. } => "EditHost",
337            Screen::ConfirmDelete { .. } => "ConfirmDelete",
338            Screen::Help { .. } => "Help",
339            Screen::KeyList => "KeyList",
340            Screen::KeyDetail { .. } => "KeyDetail",
341            Screen::KeyPushPicker { .. } => "KeyPushPicker",
342            Screen::ConfirmKeyPush { .. } => "ConfirmKeyPush",
343            Screen::HostDetail { .. } => "HostDetail",
344            Screen::TagPicker => "TagPicker",
345            Screen::ThemePicker => "ThemePicker",
346            Screen::Providers => "Providers",
347            Screen::ProviderForm { .. } => "ProviderForm",
348            Screen::ProviderLabelMigration { .. } => "ProviderLabelMigration",
349            Screen::TunnelList { .. } => "TunnelList",
350            Screen::TunnelForm { .. } => "TunnelForm",
351            Screen::TunnelHostPicker => "TunnelHostPicker",
352            Screen::SnippetPicker { .. } => "SnippetPicker",
353            Screen::SnippetForm { .. } => "SnippetForm",
354            Screen::SnippetOutput { .. } => "SnippetOutput",
355            Screen::SnippetParamForm { .. } => "SnippetParamForm",
356            Screen::ConfirmHostKeyReset { .. } => "ConfirmHostKeyReset",
357            Screen::FileBrowser { .. } => "FileBrowser",
358            Screen::Containers { .. } => "Containers",
359            Screen::ContainerHostPicker => "ContainerHostPicker",
360            Screen::ContainerLogs { .. } => "ContainerLogs",
361            Screen::ConfirmContainerRestart { .. } => "ConfirmContainerRestart",
362            Screen::ConfirmContainerStop { .. } => "ConfirmContainerStop",
363            Screen::ContainerExecPrompt { .. } => "ContainerExecPrompt",
364            Screen::ConfirmStackRestart { .. } => "ConfirmStackRestart",
365            Screen::ConfirmHostRestartAll { .. } => "ConfirmHostRestartAll",
366            Screen::ConfirmHostStopAll { .. } => "ConfirmHostStopAll",
367            Screen::ConfirmImport { .. } => "ConfirmImport",
368            Screen::ConfirmPurgeStale { .. } => "ConfirmPurgeStale",
369            Screen::ConfirmVaultSign { .. } => "ConfirmVaultSign",
370            Screen::Welcome { .. } => "Welcome",
371            Screen::BulkTagEditor => "BulkTagEditor",
372            Screen::WhatsNew(_) => "WhatsNew",
373        }
374    }
375}