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 /// Multi-host picker for snippet execution. The host aliases live
198 /// on `snippets.flow_targets`; the variant stays data-less.
199 SnippetPicker,
200 /// Edit form for a snippet. `editing` (index) and target_aliases
201 /// live on `snippets.form_editing` / `snippets.flow_targets`.
202 SnippetForm,
203 /// Output viewer after running a snippet against the flow targets.
204 /// `snippet_name` lives on `snippets.output_snippet_name`;
205 /// `target_aliases` on `snippets.flow_targets`.
206 SnippetOutput,
207 /// Param-substitution form shown before running a parametrised
208 /// snippet. The `Snippet` lives on `snippets.param_snippet` and
209 /// the target aliases on `snippets.flow_targets`.
210 SnippetParamForm,
211 ConfirmHostKeyReset {
212 alias: String,
213 hostname: String,
214 known_hosts_path: String,
215 askpass: Option<String>,
216 },
217 FileBrowser {
218 alias: String,
219 },
220 Containers {
221 alias: String,
222 },
223 /// Picker reached from the containers overview when adding a host
224 /// to the cache (`a`). Lists hosts that have no cache entry yet;
225 /// on Enter, spawns a `docker ps` listing for the chosen host and
226 /// returns to the overview.
227 ContainerHostPicker,
228 /// One-shot logs viewer for a single container. The identity
229 /// (alias / container_id / container_name), the streaming body, the
230 /// scroll position and the search state all live on
231 /// `container_state.logs_view`; this variant only tags the open
232 /// overlay so the dispatch table reads cleanly.
233 ContainerLogs,
234 /// Confirm dialog for `K` (kick). restart a single running
235 /// container. Reuses `route_confirm_key` so y/n/Esc are the only
236 /// effective inputs; stake-test footer phrases the verb on both
237 /// sides.
238 ConfirmContainerRestart {
239 alias: String,
240 container_id: String,
241 container_name: String,
242 project: Option<String>,
243 uptime: Option<String>,
244 },
245 /// Confirm dialog for `S` (stop). stop a single running container.
246 /// Same key contract as ConfirmContainerRestart.
247 ConfirmContainerStop {
248 alias: String,
249 container_id: String,
250 container_name: String,
251 project: Option<String>,
252 uptime: Option<String>,
253 },
254 /// Single-line prompt for an arbitrary command to run inside the
255 /// container via `docker exec -it`. Submit hits the existing
256 /// `pending_container_exec` flow with the typed command in place
257 /// of the default `bash || sh`.
258 ContainerExecPrompt {
259 alias: String,
260 container_id: String,
261 container_name: String,
262 query: String,
263 },
264 /// Confirm dialog for `Ctrl-K` (stack kick). Restarts every running
265 /// member of a compose stack on a single host, sequentially. The
266 /// alias/project/members payload lives on
267 /// `containers_overview.pending_bulk_confirm` so screen
268 /// transitions stay allocation-free.
269 ConfirmStackRestart,
270 /// Confirm dialog for `K` pressed on a host-divider row in the
271 /// containers overview. Restarts every running container on the
272 /// host. Payload on `containers_overview.pending_bulk_confirm`.
273 ConfirmHostRestartAll,
274 /// Confirm dialog for `S` pressed on a host-divider row. Stops
275 /// every running container on the host. Payload on
276 /// `containers_overview.pending_bulk_confirm`.
277 ConfirmHostStopAll,
278 ConfirmImport {
279 count: usize,
280 },
281 /// Confirm dialog for purging stale (provider-managed but deleted)
282 /// hosts. The alias list and provider scope live on
283 /// `providers.pending_purge` so the variant stays data-less.
284 ConfirmPurgeStale,
285 /// Confirm dialog for `V` (bulk vault sign). The precomputed
286 /// signable list lives on `vault.pending_sign` so the screen
287 /// variant stays data-less.
288 ConfirmVaultSign,
289 Welcome {
290 has_backup: bool,
291 host_count: usize,
292 known_hosts_count: usize,
293 },
294 /// Bulk tag editor: tri-state checkbox picker that edits tags across
295 /// all hosts in `multi_select` in one go. Opened via `t` when a
296 /// multi-host selection is active.
297 BulkTagEditor,
298 /// What's New overlay: shows recent changelog sections to the user
299 /// after an upgrade. Opened via the upgrade toast or `n` key.
300 WhatsNew(WhatsNewState),
301}
302
303impl Screen {
304 /// Stable short variant name used in state-transition logs.
305 /// Omits inner fields so log lines never leak host aliases, paths or
306 /// tokens.
307 pub fn variant_name(&self) -> &'static str {
308 match self {
309 Screen::HostList => "HostList",
310 Screen::AddHost => "AddHost",
311 Screen::EditHost { .. } => "EditHost",
312 Screen::ConfirmDelete { .. } => "ConfirmDelete",
313 Screen::Help { .. } => "Help",
314 Screen::KeyList => "KeyList",
315 Screen::KeyDetail { .. } => "KeyDetail",
316 Screen::KeyPushPicker { .. } => "KeyPushPicker",
317 Screen::ConfirmKeyPush { .. } => "ConfirmKeyPush",
318 Screen::HostDetail { .. } => "HostDetail",
319 Screen::TagPicker => "TagPicker",
320 Screen::ThemePicker => "ThemePicker",
321 Screen::Providers => "Providers",
322 Screen::ProviderForm { .. } => "ProviderForm",
323 Screen::ProviderLabelMigration { .. } => "ProviderLabelMigration",
324 Screen::TunnelList { .. } => "TunnelList",
325 Screen::TunnelForm { .. } => "TunnelForm",
326 Screen::TunnelHostPicker => "TunnelHostPicker",
327 Screen::SnippetPicker => "SnippetPicker",
328 Screen::SnippetForm => "SnippetForm",
329 Screen::SnippetOutput => "SnippetOutput",
330 Screen::SnippetParamForm => "SnippetParamForm",
331 Screen::ConfirmHostKeyReset { .. } => "ConfirmHostKeyReset",
332 Screen::FileBrowser { .. } => "FileBrowser",
333 Screen::Containers { .. } => "Containers",
334 Screen::ContainerHostPicker => "ContainerHostPicker",
335 Screen::ContainerLogs => "ContainerLogs",
336 Screen::ConfirmContainerRestart { .. } => "ConfirmContainerRestart",
337 Screen::ConfirmContainerStop { .. } => "ConfirmContainerStop",
338 Screen::ContainerExecPrompt { .. } => "ContainerExecPrompt",
339 Screen::ConfirmStackRestart => "ConfirmStackRestart",
340 Screen::ConfirmHostRestartAll => "ConfirmHostRestartAll",
341 Screen::ConfirmHostStopAll => "ConfirmHostStopAll",
342 Screen::ConfirmImport { .. } => "ConfirmImport",
343 Screen::ConfirmPurgeStale => "ConfirmPurgeStale",
344 Screen::ConfirmVaultSign => "ConfirmVaultSign",
345 Screen::Welcome { .. } => "Welcome",
346 Screen::BulkTagEditor => "BulkTagEditor",
347 Screen::WhatsNew(_) => "WhatsNew",
348 }
349 }
350}