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.default_hosts != b.default_hosts
250            }
251            None => false,
252        }
253    }
254
255    pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
256        self.form_baseline = baseline;
257    }
258
259    /// Construct with snippet store loaded from disk.
260    pub fn with_store_loaded(paths: Option<&crate::runtime::env::Paths>) -> Self {
261        Self {
262            store: crate::snippet::SnippetStore::load(paths),
263            runs: crate::snippet_runs::SnippetRunLog::load(paths),
264            ..Self::default()
265        }
266    }
267
268    pub fn runs(&self) -> &crate::snippet_runs::SnippetRunLog {
269        &self.runs
270    }
271
272    pub fn runs_mut(&mut self) -> &mut crate::snippet_runs::SnippetRunLog {
273        &mut self.runs
274    }
275
276    /// Open a delete confirmation for the snippet at `idx`. The renderer
277    /// reads `pending_delete` to draw the confirm overlay.
278    pub fn request_delete(&mut self, idx: usize) {
279        self.pending_delete = Some(idx);
280    }
281
282    /// Dismiss a pending delete confirmation. Idempotent.
283    pub fn cancel_delete(&mut self) {
284        self.pending_delete = None;
285    }
286
287    /// Close the parameter substitution form. Clears the form state and
288    /// the terminal-submit flag that decide whether the next Enter sends
289    /// the resolved command to the foreground terminal or to background
290    /// output capture. Idempotent.
291    pub fn close_param_form(&mut self) {
292        self.param_form = None;
293        self.pending_terminal = false;
294    }
295
296    pub fn list_state(&self) -> &ListState {
297        &self.list_state
298    }
299
300    pub fn list_state_mut(&mut self) -> &mut ListState {
301        &mut self.list_state
302    }
303
304    pub fn view_mode(&self) -> crate::app::ViewMode {
305        self.view_mode
306    }
307
308    pub fn toggle_view_mode(&mut self) {
309        self.view_mode = match self.view_mode {
310            crate::app::ViewMode::Detailed => crate::app::ViewMode::Compact,
311            crate::app::ViewMode::Compact => crate::app::ViewMode::Detailed,
312        };
313    }
314
315    pub fn host_pick(&self) -> &SnippetHostPick {
316        &self.host_pick
317    }
318
319    pub fn host_pick_mut(&mut self) -> &mut SnippetHostPick {
320        &mut self.host_pick
321    }
322
323    pub fn reset_host_pick(&mut self) {
324        self.host_pick.reset();
325    }
326
327    pub fn flow_snippet(&self) -> Option<&Snippet> {
328        self.flow_snippet.as_ref()
329    }
330
331    pub fn set_flow_snippet(&mut self, snippet: Option<Snippet>) {
332        self.flow_snippet = snippet;
333    }
334
335    pub fn take_flow_snippet(&mut self) -> Option<Snippet> {
336        self.flow_snippet.take()
337    }
338
339    pub fn flow_terminal(&self) -> bool {
340        self.flow_terminal
341    }
342
343    pub fn set_flow_terminal(&mut self, value: bool) {
344        self.flow_terminal = value;
345    }
346
347    pub fn form_return_to_tab(&self) -> bool {
348        self.form_return_to_tab
349    }
350
351    pub fn set_form_return_to_tab(&mut self, value: bool) {
352        self.form_return_to_tab = value;
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn default_is_empty() {
362        let s = SnippetState::default();
363        assert!(s.pending.is_none());
364        assert!(s.output.is_none());
365        assert!(s.param_form.is_none());
366        assert!(!s.pending_terminal);
367        assert!(s.form_baseline.is_none());
368        assert!(s.pending_delete.is_none());
369    }
370
371    #[test]
372    fn request_delete_sets_pending_delete_to_some_idx() {
373        let mut s = SnippetState::default();
374        s.request_delete(3);
375        assert_eq!(s.pending_delete, Some(3));
376    }
377
378    #[test]
379    fn cancel_delete_clears_pending_delete() {
380        let mut s = SnippetState {
381            pending_delete: Some(2),
382            ..Default::default()
383        };
384        s.cancel_delete();
385        assert!(s.pending_delete.is_none());
386    }
387
388    #[test]
389    fn request_delete_overwrites_existing_pending() {
390        let mut s = SnippetState {
391            pending_delete: Some(1),
392            ..Default::default()
393        };
394        s.request_delete(7);
395        assert_eq!(s.pending_delete, Some(7));
396    }
397
398    #[test]
399    fn close_param_form_clears_param_form_and_pending_terminal() {
400        let mut s = SnippetState {
401            param_form: Some(SnippetParamFormState::new(&[])),
402            pending_terminal: true,
403            ..Default::default()
404        };
405        s.close_param_form();
406        assert!(s.param_form.is_none());
407        assert!(!s.pending_terminal);
408    }
409
410    #[test]
411    fn close_param_form_preserves_pending_output_and_store() {
412        use crate::snippet::Snippet;
413        let mut s = SnippetState {
414            param_form: Some(SnippetParamFormState::new(&[])),
415            pending_terminal: true,
416            pending: Some((
417                Snippet {
418                    name: "ls".into(),
419                    command: "ls -la".into(),
420                    description: String::new(),
421                },
422                vec!["host-a".into()],
423            )),
424            ..Default::default()
425        };
426
427        s.close_param_form();
428
429        assert!(
430            s.pending.is_some(),
431            "pending stays for the consumer to read"
432        );
433        assert!(s.pending_delete.is_none());
434    }
435
436    #[test]
437    fn close_param_form_is_idempotent_when_already_none() {
438        let mut s = SnippetState::default();
439        s.close_param_form();
440        s.close_param_form();
441        assert!(s.param_form.is_none());
442        assert!(!s.pending_terminal);
443    }
444
445    fn state_matching_baseline() -> SnippetState {
446        let mut s = SnippetState::default();
447        s.form.name = "deploy".into();
448        s.form.command = "make deploy".into();
449        s.form.description = "ship it".into();
450        s.set_form_baseline(Some(SnippetFormBaseline {
451            name: "deploy".into(),
452            command: "make deploy".into(),
453            description: "ship it".into(),
454            default_hosts: Vec::new(),
455        }));
456        s
457    }
458
459    #[test]
460    fn form_is_dirty_is_false_without_a_baseline() {
461        let mut s = SnippetState::default();
462        s.form.name = "edited".into();
463        assert!(!s.form_is_dirty());
464    }
465
466    #[test]
467    fn form_is_dirty_is_false_when_form_equals_baseline() {
468        assert!(!state_matching_baseline().form_is_dirty());
469    }
470
471    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
472        let mut s = state_matching_baseline();
473        mutate(&mut s.form);
474        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
475    }
476
477    #[test]
478    fn form_is_dirty_detects_a_change_in_each_field() {
479        assert_field_change_is_dirty("name", |f| f.name.push('x'));
480        assert_field_change_is_dirty("command", |f| f.command.push('x'));
481        assert_field_change_is_dirty("description", |f| f.description.push('x'));
482    }
483
484    #[test]
485    fn default_view_mode_is_detailed() {
486        let s = SnippetState::default();
487        assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
488    }
489
490    #[test]
491    fn toggle_view_mode_flips_between_detailed_and_compact() {
492        let mut s = SnippetState::default();
493        s.toggle_view_mode();
494        assert_eq!(s.view_mode(), crate::app::ViewMode::Compact);
495        s.toggle_view_mode();
496        assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
497    }
498
499    #[test]
500    fn reset_host_pick_clears_selection_and_resets_cursor() {
501        let mut s = SnippetState::default();
502        s.host_pick_mut().selected.insert("h1".into());
503        s.host_pick_mut().list_state.select(Some(4));
504        s.reset_host_pick();
505        assert!(s.host_pick().selected.is_empty());
506        assert_eq!(s.host_pick().list_state.selected(), Some(0));
507    }
508
509    #[test]
510    fn flow_snippet_set_and_take_round_trips() {
511        let mut s = SnippetState::default();
512        assert!(s.flow_snippet().is_none());
513        s.set_flow_snippet(Some(Snippet {
514            name: "deploy".into(),
515            command: "make deploy".into(),
516            description: String::new(),
517        }));
518        assert_eq!(s.flow_snippet().map(|s| s.name.as_str()), Some("deploy"));
519        let taken = s.take_flow_snippet();
520        assert_eq!(taken.map(|s| s.name), Some("deploy".to_string()));
521        assert!(s.flow_snippet().is_none());
522    }
523
524    #[test]
525    fn flow_terminal_defaults_false_and_sets() {
526        let mut s = SnippetState::default();
527        assert!(!s.flow_terminal());
528        s.set_flow_terminal(true);
529        assert!(s.flow_terminal());
530    }
531}