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}