Skip to main content

purple_ssh/app/
baselines.rs

1//! Form baselines and dirty-state detection. Implements `impl App` continuation
2//! with capture/compare logic for every form kind (host, tunnel, snippet,
3//! provider) plus the mtime helpers that detect external config changes.
4
5use crate::app::App;
6use crate::app::Screen;
7use crate::app::reload_state::{
8    config_changed, get_mtime, snapshot_include_dir_mtimes, snapshot_include_mtimes,
9};
10use crate::app::{HostForm, SnippetForm, TunnelForm};
11use crate::snippet::Snippet;
12use crate::ssh_config::model::PatternEntry;
13use crate::tunnel::TunnelRule;
14
15/// Baseline snapshot of host form content for dirty-check on Esc.
16#[derive(Clone)]
17pub struct FormBaseline {
18    pub alias: String,
19    pub hostname: String,
20    pub user: String,
21    pub port: String,
22    pub identity_file: String,
23    pub proxy_jump: String,
24    pub askpass: String,
25    pub vault_ssh: String,
26    pub vault_addr: String,
27    pub tags: String,
28}
29
30/// Baseline snapshot of tunnel form content for dirty-check on Esc.
31#[derive(Clone)]
32pub struct TunnelFormBaseline {
33    pub tunnel_type: crate::tunnel::TunnelType,
34    pub bind_port: String,
35    pub remote_host: String,
36    pub remote_port: String,
37    pub bind_address: String,
38}
39
40/// Baseline snapshot of snippet form content for dirty-check on Esc.
41#[derive(Clone)]
42pub struct SnippetFormBaseline {
43    pub name: String,
44    pub command: String,
45    pub description: String,
46}
47
48/// Baseline snapshot of provider form content for dirty-check on Esc.
49#[derive(Clone)]
50pub struct ProviderFormBaseline {
51    pub url: String,
52    pub token: String,
53    pub profile: String,
54    pub project: String,
55    pub compartment: String,
56    pub regions: String,
57    pub alias_prefix: String,
58    pub user: String,
59    pub identity_file: String,
60    pub verify_tls: bool,
61    pub auto_sync: bool,
62    pub vault_role: String,
63    pub vault_addr: String,
64}
65
66impl App {
67    /// Clear form mtime state (call on form cancel or successful submit).
68    pub fn clear_form_mtime(&mut self) {
69        self.conflict.clear_form_mtimes();
70    }
71
72    /// Capture config and Include file mtimes when opening a host form.
73    pub fn capture_form_mtime(&mut self) {
74        self.conflict.form_mtime = get_mtime(&self.reload.config_path);
75        self.conflict.form_include_mtimes = snapshot_include_mtimes(&self.hosts_state.ssh_config);
76        self.conflict.form_include_dir_mtimes =
77            snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
78    }
79
80    /// Capture ~/.purple/providers mtime when opening a provider form.
81    pub fn capture_provider_form_mtime(&mut self) {
82        let path = dirs::home_dir().map(|h| h.join(".purple/providers"));
83        self.conflict.provider_form_mtime = path.as_ref().and_then(|p| get_mtime(p));
84    }
85
86    /// Capture a baseline snapshot of the host form for dirty-check on Esc.
87    pub fn capture_form_baseline(&mut self) {
88        self.forms.host_baseline = Some(FormBaseline {
89            alias: self.forms.host.alias.clone(),
90            hostname: self.forms.host.hostname.clone(),
91            user: self.forms.host.user.clone(),
92            port: self.forms.host.port.clone(),
93            identity_file: self.forms.host.identity_file.clone(),
94            proxy_jump: self.forms.host.proxy_jump.clone(),
95            askpass: self.forms.host.askpass.clone(),
96            vault_ssh: self.forms.host.vault_ssh.clone(),
97            vault_addr: self.forms.host.vault_addr.clone(),
98            tags: self.forms.host.tags.clone(),
99        });
100    }
101
102    /// Check if the host form has been modified since baseline was captured.
103    pub fn host_form_is_dirty(&self) -> bool {
104        self.forms.host_form_is_dirty()
105    }
106
107    /// Tear down host form state and return to the host list. Flush runs
108    /// last because `flush_pending_vault_write` no-ops while a form is open.
109    pub fn close_host_form(&mut self) {
110        self.close_host_form_inner(None);
111    }
112
113    /// Close the host form and select the just-saved host. Use after a
114    /// successful submit.
115    pub fn close_host_form_after_save(&mut self, target_alias: &str) {
116        self.close_host_form_inner(Some(target_alias));
117    }
118
119    fn close_host_form_inner(&mut self, select: Option<&str>) {
120        log::debug!("[purple] close_host_form select={:?}", select);
121        self.clear_form_mtime();
122        self.forms.host_baseline = None;
123        self.set_screen(Screen::HostList);
124        if let Some(alias) = select {
125            self.select_host_by_alias(alias);
126        }
127        self.flush_pending_vault_write();
128    }
129
130    /// Tear down provider form state and return to the providers list. Same
131    /// shape as `close_host_form`; provider forms have no per-save selection.
132    pub fn close_provider_form(&mut self) {
133        log::debug!("[purple] close_provider_form");
134        self.clear_form_mtime();
135        self.providers.form_baseline = None;
136        self.set_screen(Screen::Providers);
137        self.flush_pending_vault_write();
138    }
139
140    /// Tear down tunnel form state and return to the caller's screen. The
141    /// return target varies (host detail overlay, tunnels overview, picker),
142    /// so the caller passes it.
143    pub fn close_tunnel_form(&mut self, return_to: Screen) {
144        log::debug!(
145            "[purple] close_tunnel_form return_to={:?}",
146            std::mem::discriminant(&return_to)
147        );
148        self.clear_form_mtime();
149        self.tunnels.form_baseline = None;
150        self.set_screen(return_to);
151    }
152
153    /// Tear down snippet form state and return to the snippet picker for the
154    /// given targets. Snippet forms intentionally skip clear_form_mtime; no
155    /// mtime is captured on snippet form open.
156    pub fn close_snippet_form(&mut self, target_aliases: Vec<String>) {
157        log::debug!(
158            "[purple] close_snippet_form aliases={}",
159            target_aliases.len()
160        );
161        self.snippets.form_baseline = None;
162        self.set_screen(Screen::SnippetPicker { target_aliases });
163    }
164
165    /// Open a blank host add form. Mirror is `close_host_form`.
166    pub fn open_host_add_form(&mut self) {
167        log::debug!("[purple] open_host_add_form");
168        self.forms.host = HostForm::new();
169        self.set_screen(Screen::AddHost);
170        self.capture_form_mtime();
171        self.capture_form_baseline();
172    }
173
174    /// Open a blank pattern add form. Shares Screen::AddHost; the form
175    /// constructor distinguishes pattern vs host entries internally.
176    pub fn open_host_pattern_add_form(&mut self) {
177        log::debug!("[purple] open_host_pattern_add_form");
178        self.forms.host = HostForm::new_pattern();
179        self.set_screen(Screen::AddHost);
180        self.capture_form_mtime();
181        self.capture_form_baseline();
182    }
183
184    /// Open the host edit form for `host`. Returns false (without changing
185    /// screen) if the host lives in an Include file or its raw entry cannot
186    /// be located. The caller computes `stale_hint` because it is derived
187    /// from handler-local provider-display logic.
188    pub fn open_host_edit_form(
189        &mut self,
190        host: crate::ssh_config::model::HostEntry,
191        stale_hint: Option<String>,
192    ) -> bool {
193        if let Some(ref source) = host.source_file {
194            self.notify_error(crate::messages::included_host_lives_in(
195                &host.alias,
196                &source.display(),
197            ));
198            return false;
199        }
200        // Load raw entry (no pattern inheritance) so inherited values do not
201        // appear as editable own values.
202        let raw = match self.hosts_state.ssh_config.raw_host_entry(&host.alias) {
203            Some(entry) => entry,
204            None => {
205                self.notify_warning(crate::messages::HOST_NOT_FOUND_IN_CONFIG);
206                return false;
207            }
208        };
209        let inherited = self.hosts_state.ssh_config.inherited_hints(&host.alias);
210        log::debug!("[purple] open_host_edit_form alias={}", host.alias);
211        self.forms.host = HostForm::from_entry(&raw, inherited);
212        if let Some(hint) = stale_hint {
213            self.notify_warning(crate::messages::stale_host(&hint));
214        }
215        self.set_screen(Screen::EditHost { alias: host.alias });
216        self.capture_form_mtime();
217        self.capture_form_baseline();
218        true
219    }
220
221    /// Open an edit form for an existing pattern entry.
222    pub fn open_host_pattern_edit_form(&mut self, pattern: &PatternEntry) {
223        log::debug!(
224            "[purple] open_host_pattern_edit_form pattern={}",
225            pattern.pattern
226        );
227        self.forms.host = HostForm::from_pattern_entry(pattern);
228        self.set_screen(Screen::EditHost {
229            alias: pattern.pattern.clone(),
230        });
231        self.capture_form_mtime();
232        self.capture_form_baseline();
233    }
234
235    /// Open a blank tunnel add form scoped to `alias`. The alias is set on
236    /// the screen variant so submit/cancel return to the right host context.
237    pub fn open_tunnel_add_form(&mut self, alias: String) {
238        log::debug!("[purple] open_tunnel_add_form alias={}", alias);
239        self.tunnels.form = TunnelForm::new();
240        self.set_screen(Screen::TunnelForm {
241            alias,
242            editing: None,
243        });
244        self.capture_form_mtime();
245        self.capture_tunnel_form_baseline();
246    }
247
248    /// Open an edit form for an existing tunnel rule. `editing` is the index
249    /// into `tunnels.list` that the save path mutates.
250    pub fn open_tunnel_edit_form(&mut self, alias: String, rule: &TunnelRule, editing: usize) {
251        log::debug!(
252            "[purple] open_tunnel_edit_form alias={} editing={}",
253            alias,
254            editing
255        );
256        self.tunnels.form = TunnelForm::from_rule(rule);
257        self.set_screen(Screen::TunnelForm {
258            alias,
259            editing: Some(editing),
260        });
261        self.capture_form_mtime();
262        self.capture_tunnel_form_baseline();
263    }
264
265    /// Open a blank snippet add form scoped to the given target aliases.
266    /// No mtime capture (snippet forms have no mtime tracking).
267    pub fn open_snippet_add_form(&mut self, target_aliases: Vec<String>) {
268        log::debug!(
269            "[purple] open_snippet_add_form aliases={}",
270            target_aliases.len()
271        );
272        self.snippets.form = SnippetForm::new();
273        self.set_screen(Screen::SnippetForm {
274            target_aliases,
275            editing: None,
276        });
277        self.capture_snippet_form_baseline();
278    }
279
280    /// Open an edit form for an existing snippet. `editing` is the index
281    /// into the snippet store that the save path mutates.
282    pub fn open_snippet_edit_form(
283        &mut self,
284        snippet: &Snippet,
285        target_aliases: Vec<String>,
286        editing: usize,
287    ) {
288        log::debug!(
289            "[purple] open_snippet_edit_form name={} editing={}",
290            snippet.name,
291            editing
292        );
293        self.snippets.form = SnippetForm::from_snippet(snippet);
294        self.set_screen(Screen::SnippetForm {
295            target_aliases,
296            editing: Some(editing),
297        });
298        self.capture_snippet_form_baseline();
299    }
300
301    /// Open a provider form for `id`, populating defaults for new configs
302    /// or existing data for edits. When `id.label` is `Some("")` the form
303    /// opens in label-entry mode so the user types the label first.
304    pub fn open_provider_form(&mut self, id: crate::providers::config::ProviderConfigId) {
305        let provider_impl = crate::providers::get_provider(id.provider.as_str());
306        let short_label = provider_impl
307            .as_ref()
308            .map(|p| p.short_label().to_string())
309            .unwrap_or_else(|| id.provider.clone());
310        let existing_section = self.providers.config.section_by_id(&id).cloned();
311        let label_entry = existing_section.is_none() && id.label.as_deref() == Some("");
312        let provider_first_field =
313            crate::app::ProviderFormField::fields_for(id.provider.as_str())[0];
314        let first_field = if label_entry {
315            crate::app::ProviderFormField::Label
316        } else {
317            provider_first_field
318        };
319        log::debug!(
320            "[purple] open_provider_form provider={} label_entry={}",
321            id.provider,
322            label_entry
323        );
324
325        self.providers.form = if let Some(section) = existing_section {
326            let cursor_pos = match first_field {
327                crate::app::ProviderFormField::Url => section.url.chars().count(),
328                crate::app::ProviderFormField::Token => section.token.chars().count(),
329                _ => 0,
330            };
331            crate::app::ProviderFormFields {
332                label: String::new(),
333                label_entry: false,
334                url: section.url.clone(),
335                token: section.token.clone(),
336                profile: section.profile.clone(),
337                project: section.project.clone(),
338                compartment: section.compartment.clone(),
339                regions: section.regions.clone(),
340                alias_prefix: section.alias_prefix.clone(),
341                user: section.user.clone(),
342                identity_file: section.identity_file.clone(),
343                verify_tls: section.verify_tls,
344                auto_sync: section.auto_sync,
345                vault_role: section.vault_role.clone(),
346                vault_addr: section.vault_addr.clone(),
347                focused_field: first_field,
348                cursor_pos,
349                expanded: true,
350            }
351        } else {
352            // New config: derive a sensible default alias_prefix. For a labeled
353            // config with a known label, suggest `<short>-<label>` (e.g. `do-work`);
354            // when the label is still empty (label-entry mode), fall back to the
355            // bare short prefix so the field has a stable value the user can edit.
356            let default_prefix = match id.label.as_deref() {
357                Some("") | None => short_label.clone(),
358                Some(l) => format!("{}-{}", short_label, l),
359            };
360            crate::app::ProviderFormFields {
361                label: String::new(),
362                label_entry,
363                url: String::new(),
364                token: String::new(),
365                profile: String::new(),
366                project: String::new(),
367                compartment: String::new(),
368                regions: String::new(),
369                alias_prefix: default_prefix,
370                user: "root".to_string(),
371                identity_file: String::new(),
372                verify_tls: true,
373                auto_sync: id
374                    .kind()
375                    .is_none_or(crate::providers::ProviderKind::default_auto_sync),
376                vault_role: String::new(),
377                vault_addr: String::new(),
378                focused_field: first_field,
379                cursor_pos: 0,
380                expanded: false,
381            }
382        };
383        self.set_screen(Screen::ProviderForm { id });
384        self.capture_provider_form_mtime();
385        self.capture_provider_form_baseline();
386    }
387
388    /// Capture a baseline snapshot of the tunnel form for dirty-check on Esc.
389    pub fn capture_tunnel_form_baseline(&mut self) {
390        self.tunnels.form_baseline = Some(TunnelFormBaseline {
391            tunnel_type: self.tunnels.form.tunnel_type,
392            bind_port: self.tunnels.form.bind_port.clone(),
393            remote_host: self.tunnels.form.remote_host.clone(),
394            remote_port: self.tunnels.form.remote_port.clone(),
395            bind_address: self.tunnels.form.bind_address.clone(),
396        });
397    }
398
399    /// Check if the tunnel form has been modified since baseline was captured.
400    pub fn tunnel_form_is_dirty(&self) -> bool {
401        self.tunnels.form_is_dirty()
402    }
403
404    /// Capture a baseline snapshot of the snippet form for dirty-check on Esc.
405    pub fn capture_snippet_form_baseline(&mut self) {
406        self.snippets.form_baseline = Some(SnippetFormBaseline {
407            name: self.snippets.form.name.clone(),
408            command: self.snippets.form.command.clone(),
409            description: self.snippets.form.description.clone(),
410        });
411    }
412
413    /// Check if the snippet form has been modified since baseline was captured.
414    pub fn snippet_form_is_dirty(&self) -> bool {
415        self.snippets.form_is_dirty()
416    }
417
418    /// Capture a baseline snapshot of the provider form for dirty-check on Esc.
419    pub fn capture_provider_form_baseline(&mut self) {
420        self.providers.form_baseline = Some(ProviderFormBaseline {
421            url: self.providers.form.url.clone(),
422            token: self.providers.form.token.clone(),
423            profile: self.providers.form.profile.clone(),
424            project: self.providers.form.project.clone(),
425            compartment: self.providers.form.compartment.clone(),
426            regions: self.providers.form.regions.clone(),
427            alias_prefix: self.providers.form.alias_prefix.clone(),
428            user: self.providers.form.user.clone(),
429            identity_file: self.providers.form.identity_file.clone(),
430            verify_tls: self.providers.form.verify_tls,
431            auto_sync: self.providers.form.auto_sync,
432            vault_role: self.providers.form.vault_role.clone(),
433            vault_addr: self.providers.form.vault_addr.clone(),
434        });
435    }
436
437    /// Check if the provider form has been modified since baseline was captured.
438    pub fn provider_form_is_dirty(&self) -> bool {
439        self.providers.form_is_dirty()
440    }
441
442    /// Check if config or any Include file/directory has changed since the form was opened.
443    pub fn config_changed_since_form_open(&self) -> bool {
444        config_changed(&self.conflict, &self.reload.config_path)
445    }
446
447    /// Check if ~/.purple/providers has changed since the provider form was opened.
448    pub fn provider_config_changed_since_form_open(&self) -> bool {
449        let path = dirs::home_dir().map(|h| h.join(".purple/providers"));
450        let current_mtime = path.as_ref().and_then(|p| get_mtime(p));
451        self.conflict.provider_form_mtime != current_mtime
452    }
453}