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}