Skip to main content

purple_ssh/app/
snippet_state.rs

1use std::collections::HashSet;
2
3use ratatui::widgets::ListState;
4
5use crate::app::SnippetFormBaseline;
6use crate::app::forms::{SnippetForm, SnippetOutputState, SnippetParamFormState};
7use crate::snippet::{Snippet, SnippetStore};
8
9/// Why the snippet host picker is open: to run the snippet now, or to pick the
10/// default target hosts for the open edit form. Decides where Enter/Esc return
11/// and whether a run is launched.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SnippetHostPickPurpose {
14    /// Select hosts then run (-> `Screen::ConfirmRunSnippet`).
15    #[default]
16    Run,
17    /// Select hosts then write them to the edit form's `default_hosts`
18    /// (-> `Screen::SnippetForm`). An empty selection clears the defaults.
19    EditDefault,
20}
21
22/// Picker half for the snippet -> hosts run flow. Mirrors the picker fields of
23/// `KeyPushState`; snippets carry no worker state of their own because
24/// execution reuses the existing snippet output machinery.
25#[derive(Default)]
26pub struct SnippetHostPick {
27    /// Why the picker is open (run now vs set the edit form's default hosts).
28    pub purpose: SnippetHostPickPurpose,
29    /// Aliases toggled in `Screen::SnippetHostPicker`. Frozen into
30    /// `flow_targets` on Enter.
31    pub selected: HashSet<String>,
32    /// Cursor in the picker's host list.
33    pub list_state: ListState,
34    /// Applied type-to-filter query. Empty means no filter (grouped browse).
35    pub query: String,
36    /// Whether keystrokes currently edit `query` (filter mode entered via `/`)
37    /// rather than acting as selection commands.
38    pub filtering: bool,
39}
40
41impl SnippetHostPick {
42    /// Reset selection, filter and cursor. Called when the picker opens for a
43    /// fresh snippet; pre-selection of saved targets happens after this.
44    pub fn reset(&mut self) {
45        self.selected.clear();
46        self.query.clear();
47        self.filtering = false;
48        self.list_state.select(Some(0));
49        self.purpose = SnippetHostPickPurpose::Run;
50    }
51}
52
53/// Snippet-owned state grouped off the `App` god-struct. Holds the on-disk
54/// snippet store, the edit form, the pending execution payload, the output
55/// screen state, the param form, the terminal-submit flag, the dirty-check
56/// baseline and the pending-delete index. Pure state container.
57pub struct SnippetState {
58    pub(in crate::app) store: SnippetStore,
59    pub(in crate::app) form: SnippetForm,
60    pub(in crate::app) pending: Option<(Snippet, Vec<String>)>,
61    pub(in crate::app) output: Option<SnippetOutputState>,
62    pub(in crate::app) param_form: Option<SnippetParamFormState>,
63    pub(in crate::app) pending_terminal: bool,
64    pub(in crate::app) form_baseline: Option<SnippetFormBaseline>,
65    pub(in crate::app) pending_delete: Option<usize>,
66    /// Target host aliases for the currently-open snippet flow
67    /// (`Screen::SnippetPicker`, `SnippetForm`, `SnippetOutput`,
68    /// `SnippetParamForm`). The Screen variants are data-less; the
69    /// alias list lives here so screen transitions never clone it.
70    pub(in crate::app) flow_targets: Vec<String>,
71    /// `editing` payload for an open `Screen::SnippetForm`. `None`
72    /// when adding a new snippet, `Some(index)` when editing an
73    /// existing one. Set alongside the screen transition.
74    pub(in crate::app) form_editing: Option<usize>,
75    /// Snippet currently displayed by `Screen::SnippetParamForm`.
76    pub(in crate::app) param_snippet: Option<Snippet>,
77    /// Snippet name shown by `Screen::SnippetOutput` in its title.
78    pub(in crate::app) output_snippet_name: Option<String>,
79    /// Snippet-list cursor for the Snippets tab. Indexes the filtered list
80    /// when `app.search` is active; the handler translates back to a store
81    /// index before any data access.
82    pub(in crate::app) list_state: ListState,
83    /// Detail-panel visibility for the Snippets tab, toggled by `v`. Drives
84    /// the shared detail animation exactly as the Containers tab does.
85    pub(in crate::app) view_mode: crate::app::ViewMode,
86    /// Host picker selection for the snippet -> hosts run flow.
87    pub(in crate::app) host_pick: SnippetHostPick,
88    /// Snippet chosen on the tab, cloned when the host picker opens and read by
89    /// the confirm body and the run step. Cleared when the flow ends.
90    pub(in crate::app) flow_snippet: Option<Snippet>,
91    /// Whether the pending run uses terminal (foreground) mode. Set from the
92    /// host picker (`!` vs Enter), read by the run step.
93    pub(in crate::app) flow_terminal: bool,
94    /// Whether the current snippet form / param form was opened from the
95    /// Snippets tab (vs the host-list snippet picker). Decides where closing
96    /// the form returns and which base page renders behind it.
97    pub(in crate::app) form_return_to_tab: bool,
98    /// Per-snippet run ledger, read by the detail TRACK RECORD verdict and
99    /// trend chart, appended on every run completion.
100    pub(in crate::app) runs: crate::snippet_runs::SnippetRunLog,
101}
102
103impl Default for SnippetState {
104    fn default() -> Self {
105        Self {
106            store: SnippetStore::default(),
107            form: SnippetForm::new(),
108            pending: None,
109            output: None,
110            param_form: None,
111            pending_terminal: false,
112            form_baseline: None,
113            pending_delete: None,
114            flow_targets: Vec::new(),
115            form_editing: None,
116            param_snippet: None,
117            output_snippet_name: None,
118            list_state: ListState::default(),
119            view_mode: crate::app::ViewMode::Detailed,
120            host_pick: SnippetHostPick::default(),
121            flow_snippet: None,
122            flow_terminal: false,
123            form_return_to_tab: false,
124            runs: crate::snippet_runs::SnippetRunLog::default(),
125        }
126    }
127}
128
129impl SnippetState {
130    pub fn store(&self) -> &SnippetStore {
131        &self.store
132    }
133
134    pub fn store_mut(&mut self) -> &mut SnippetStore {
135        &mut self.store
136    }
137
138    pub fn form(&self) -> &SnippetForm {
139        &self.form
140    }
141
142    pub fn form_mut(&mut self) -> &mut SnippetForm {
143        &mut self.form
144    }
145
146    pub fn output(&self) -> Option<&SnippetOutputState> {
147        self.output.as_ref()
148    }
149
150    pub fn output_mut(&mut self) -> Option<&mut SnippetOutputState> {
151        self.output.as_mut()
152    }
153
154    pub fn set_output(&mut self, output: Option<SnippetOutputState>) {
155        self.output = output;
156    }
157
158    pub fn take_output(&mut self) -> Option<SnippetOutputState> {
159        self.output.take()
160    }
161
162    pub fn param_form(&self) -> Option<&SnippetParamFormState> {
163        self.param_form.as_ref()
164    }
165
166    pub fn param_form_mut(&mut self) -> Option<&mut SnippetParamFormState> {
167        self.param_form.as_mut()
168    }
169
170    pub fn set_param_form(&mut self, param_form: Option<SnippetParamFormState>) {
171        self.param_form = param_form;
172    }
173
174    pub fn pending_delete(&self) -> Option<usize> {
175        self.pending_delete
176    }
177
178    pub fn take_pending_delete(&mut self) -> Option<usize> {
179        self.pending_delete.take()
180    }
181
182    pub fn pending(&self) -> Option<&(Snippet, Vec<String>)> {
183        self.pending.as_ref()
184    }
185
186    pub fn take_pending(&mut self) -> Option<(Snippet, Vec<String>)> {
187        self.pending.take()
188    }
189
190    pub fn set_pending(&mut self, value: Option<(Snippet, Vec<String>)>) {
191        self.pending = value;
192    }
193
194    pub fn flow_targets(&self) -> &[String] {
195        &self.flow_targets
196    }
197
198    pub fn set_flow_targets(&mut self, targets: Vec<String>) {
199        self.flow_targets = targets;
200    }
201
202    pub fn clear_flow_targets(&mut self) {
203        self.flow_targets.clear();
204    }
205
206    pub fn form_editing(&self) -> Option<usize> {
207        self.form_editing
208    }
209
210    pub fn set_form_editing(&mut self, editing: Option<usize>) {
211        self.form_editing = editing;
212    }
213
214    pub fn param_snippet(&self) -> Option<&Snippet> {
215        self.param_snippet.as_ref()
216    }
217
218    pub fn set_param_snippet(&mut self, snippet: Option<Snippet>) {
219        self.param_snippet = snippet;
220    }
221
222    pub fn output_snippet_name(&self) -> Option<&str> {
223        self.output_snippet_name.as_deref()
224    }
225
226    pub fn set_output_snippet_name(&mut self, name: Option<String>) {
227        self.output_snippet_name = name;
228    }
229
230    pub fn pending_terminal(&self) -> bool {
231        self.pending_terminal
232    }
233
234    pub fn set_pending_terminal(&mut self, value: bool) {
235        self.pending_terminal = value;
236    }
237
238    pub fn form_baseline(&self) -> Option<&SnippetFormBaseline> {
239        self.form_baseline.as_ref()
240    }
241
242    /// True if the snippet form differs from its captured baseline.
243    pub fn form_is_dirty(&self) -> bool {
244        match &self.form_baseline {
245            Some(b) => {
246                self.form.name != b.name
247                    || self.form.command != b.command
248                    || self.form.description != b.description
249                    || self.form.interactive != b.interactive
250                    || self.form.default_hosts != b.default_hosts
251            }
252            None => false,
253        }
254    }
255
256    pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
257        self.form_baseline = baseline;
258    }
259
260    /// Construct with snippet store loaded from disk.
261    pub fn with_store_loaded(paths: Option<&crate::runtime::env::Paths>) -> Self {
262        Self {
263            store: crate::snippet::SnippetStore::load(paths),
264            runs: crate::snippet_runs::SnippetRunLog::load(paths),
265            ..Self::default()
266        }
267    }
268
269    pub fn runs(&self) -> &crate::snippet_runs::SnippetRunLog {
270        &self.runs
271    }
272
273    pub fn runs_mut(&mut self) -> &mut crate::snippet_runs::SnippetRunLog {
274        &mut self.runs
275    }
276
277    /// Open a delete confirmation for the snippet at `idx`. The renderer
278    /// reads `pending_delete` to draw the confirm overlay.
279    pub fn request_delete(&mut self, idx: usize) {
280        self.pending_delete = Some(idx);
281    }
282
283    /// Dismiss a pending delete confirmation. Idempotent.
284    pub fn cancel_delete(&mut self) {
285        self.pending_delete = None;
286    }
287
288    /// Close the parameter substitution form. Clears the form state and
289    /// the terminal-submit flag that decide whether the next Enter sends
290    /// the resolved command to the foreground terminal or to background
291    /// output capture. Idempotent.
292    pub fn close_param_form(&mut self) {
293        self.param_form = None;
294        self.pending_terminal = false;
295    }
296
297    pub fn list_state(&self) -> &ListState {
298        &self.list_state
299    }
300
301    pub fn list_state_mut(&mut self) -> &mut ListState {
302        &mut self.list_state
303    }
304
305    pub fn view_mode(&self) -> crate::app::ViewMode {
306        self.view_mode
307    }
308
309    pub fn toggle_view_mode(&mut self) {
310        self.view_mode = match self.view_mode {
311            crate::app::ViewMode::Detailed => crate::app::ViewMode::Compact,
312            crate::app::ViewMode::Compact => crate::app::ViewMode::Detailed,
313        };
314    }
315
316    pub fn host_pick(&self) -> &SnippetHostPick {
317        &self.host_pick
318    }
319
320    pub fn host_pick_mut(&mut self) -> &mut SnippetHostPick {
321        &mut self.host_pick
322    }
323
324    pub fn reset_host_pick(&mut self) {
325        self.host_pick.reset();
326    }
327
328    pub fn flow_snippet(&self) -> Option<&Snippet> {
329        self.flow_snippet.as_ref()
330    }
331
332    pub fn set_flow_snippet(&mut self, snippet: Option<Snippet>) {
333        self.flow_snippet = snippet;
334    }
335
336    pub fn take_flow_snippet(&mut self) -> Option<Snippet> {
337        self.flow_snippet.take()
338    }
339
340    pub fn flow_terminal(&self) -> bool {
341        self.flow_terminal
342    }
343
344    pub fn set_flow_terminal(&mut self, value: bool) {
345        self.flow_terminal = value;
346    }
347
348    pub fn form_return_to_tab(&self) -> bool {
349        self.form_return_to_tab
350    }
351
352    pub fn set_form_return_to_tab(&mut self, value: bool) {
353        self.form_return_to_tab = value;
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn default_is_empty() {
363        let s = SnippetState::default();
364        assert!(s.pending.is_none());
365        assert!(s.output.is_none());
366        assert!(s.param_form.is_none());
367        assert!(!s.pending_terminal);
368        assert!(s.form_baseline.is_none());
369        assert!(s.pending_delete.is_none());
370    }
371
372    #[test]
373    fn request_delete_sets_pending_delete_to_some_idx() {
374        let mut s = SnippetState::default();
375        s.request_delete(3);
376        assert_eq!(s.pending_delete, Some(3));
377    }
378
379    #[test]
380    fn cancel_delete_clears_pending_delete() {
381        let mut s = SnippetState {
382            pending_delete: Some(2),
383            ..Default::default()
384        };
385        s.cancel_delete();
386        assert!(s.pending_delete.is_none());
387    }
388
389    #[test]
390    fn request_delete_overwrites_existing_pending() {
391        let mut s = SnippetState {
392            pending_delete: Some(1),
393            ..Default::default()
394        };
395        s.request_delete(7);
396        assert_eq!(s.pending_delete, Some(7));
397    }
398
399    #[test]
400    fn close_param_form_clears_param_form_and_pending_terminal() {
401        let mut s = SnippetState {
402            param_form: Some(SnippetParamFormState::new(&[])),
403            pending_terminal: true,
404            ..Default::default()
405        };
406        s.close_param_form();
407        assert!(s.param_form.is_none());
408        assert!(!s.pending_terminal);
409    }
410
411    #[test]
412    fn close_param_form_preserves_pending_output_and_store() {
413        use crate::snippet::Snippet;
414        let mut s = SnippetState {
415            param_form: Some(SnippetParamFormState::new(&[])),
416            pending_terminal: true,
417            pending: Some((
418                Snippet {
419                    name: "ls".into(),
420                    command: "ls -la".into(),
421                    description: String::new(),
422                    interactive: false,
423                },
424                vec!["host-a".into()],
425            )),
426            ..Default::default()
427        };
428
429        s.close_param_form();
430
431        assert!(
432            s.pending.is_some(),
433            "pending stays for the consumer to read"
434        );
435        assert!(s.pending_delete.is_none());
436    }
437
438    #[test]
439    fn close_param_form_is_idempotent_when_already_none() {
440        let mut s = SnippetState::default();
441        s.close_param_form();
442        s.close_param_form();
443        assert!(s.param_form.is_none());
444        assert!(!s.pending_terminal);
445    }
446
447    fn state_matching_baseline() -> SnippetState {
448        let mut s = SnippetState::default();
449        s.form.name = "deploy".into();
450        s.form.command = "make deploy".into();
451        s.form.description = "ship it".into();
452        s.set_form_baseline(Some(SnippetFormBaseline {
453            name: "deploy".into(),
454            command: "make deploy".into(),
455            description: "ship it".into(),
456            interactive: false,
457            default_hosts: Vec::new(),
458        }));
459        s
460    }
461
462    #[test]
463    fn form_is_dirty_is_false_without_a_baseline() {
464        let mut s = SnippetState::default();
465        s.form.name = "edited".into();
466        assert!(!s.form_is_dirty());
467    }
468
469    #[test]
470    fn form_is_dirty_is_false_when_form_equals_baseline() {
471        assert!(!state_matching_baseline().form_is_dirty());
472    }
473
474    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
475        let mut s = state_matching_baseline();
476        mutate(&mut s.form);
477        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
478    }
479
480    #[test]
481    fn form_is_dirty_detects_a_change_in_each_field() {
482        assert_field_change_is_dirty("name", |f| f.name.push('x'));
483        assert_field_change_is_dirty("command", |f| f.command.push('x'));
484        assert_field_change_is_dirty("description", |f| f.description.push('x'));
485        assert_field_change_is_dirty("interactive", |f| f.interactive = !f.interactive);
486    }
487
488    #[test]
489    fn default_view_mode_is_detailed() {
490        let s = SnippetState::default();
491        assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
492    }
493
494    #[test]
495    fn toggle_view_mode_flips_between_detailed_and_compact() {
496        let mut s = SnippetState::default();
497        s.toggle_view_mode();
498        assert_eq!(s.view_mode(), crate::app::ViewMode::Compact);
499        s.toggle_view_mode();
500        assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
501    }
502
503    #[test]
504    fn reset_host_pick_clears_selection_and_resets_cursor() {
505        let mut s = SnippetState::default();
506        s.host_pick_mut().selected.insert("h1".into());
507        s.host_pick_mut().list_state.select(Some(4));
508        s.reset_host_pick();
509        assert!(s.host_pick().selected.is_empty());
510        assert_eq!(s.host_pick().list_state.selected(), Some(0));
511    }
512
513    #[test]
514    fn flow_snippet_set_and_take_round_trips() {
515        let mut s = SnippetState::default();
516        assert!(s.flow_snippet().is_none());
517        s.set_flow_snippet(Some(Snippet {
518            name: "deploy".into(),
519            command: "make deploy".into(),
520            description: String::new(),
521            interactive: false,
522        }));
523        assert_eq!(s.flow_snippet().map(|s| s.name.as_str()), Some("deploy"));
524        let taken = s.take_flow_snippet();
525        assert_eq!(taken.map(|s| s.name), Some("deploy".to_string()));
526        assert!(s.flow_snippet().is_none());
527    }
528
529    #[test]
530    fn flow_terminal_defaults_false_and_sets() {
531        let mut s = SnippetState::default();
532        assert!(!s.flow_terminal());
533        s.set_flow_terminal(true);
534        assert!(s.flow_terminal());
535    }
536}