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}