Skip to main content

evault_tui/
app.rs

1//! Deterministic state machine driving the dashboard.
2
3use evault_core::model::{Group, VarId, VarKind};
4use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
5use ratatui::widgets::TableState;
6use secrecy::SecretString;
7
8use crate::event::Action;
9use crate::filter::FilterState;
10use crate::provider::{ProviderError, VarDraft, VarProvider, VarSummary};
11
12/// In-session toast displayed at the bottom of the screen.
13///
14/// `Toast` is part of the *crate*-internal API. External callers
15/// inspect toast state via [`AppState::toast_text`] /
16/// [`AppState::toast_is_error`] and cannot construct or pattern-match
17/// on the inner kind directly. Keeping these types private lets the
18/// toast model evolve (e.g. severity levels, timeouts) without an API
19/// break.
20#[allow(clippy::redundant_pub_crate)]
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) struct Toast {
23    pub(crate) text: String,
24    pub(crate) kind: ToastKind,
25}
26
27/// Whether a toast represents a user-recoverable info message or an
28/// error worth keeping on-screen until explicitly dismissed.
29///
30/// See [`Toast`] — crate-internal.
31#[allow(clippy::redundant_pub_crate)]
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum ToastKind {
34    /// Neutral, informational message. Auto-dismissed on the next
35    /// non-`Noop` interaction so it does not pile up in front of the
36    /// user.
37    Info,
38    /// Failure surfaced from the provider or another subsystem.
39    /// Sticky: only dismissed by an explicit `Action::Dismiss` or
40    /// `Action::Refresh` so the user has time to read the message
41    /// even if they keep typing.
42    Error,
43}
44
45/// Overlay currently on top of the main view.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Overlay {
48    None,
49    Help,
50}
51
52/// Top-level view currently displayed by the runtime.
53///
54/// Views are mutually exclusive: at any moment the dashboard is
55/// either showing the table or the per-variable detail screen, not
56/// both. Overlays (help, modals) layer on top of *either* view.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum View {
59    /// Variable list — the default screen.
60    Dashboard,
61    /// Read-only inspection of the row that was selected when the
62    /// user pressed Enter.
63    Detail,
64}
65
66/// Dashboard state.
67///
68/// `AppState` is a pure value: every public method is a function of
69/// `(state, input) -> state'`, so the whole UI can be exercised in
70/// unit tests without ever touching a terminal. The runtime
71/// ([`crate::run_tui`]) is the only piece that performs I/O.
72#[derive(Debug)]
73pub struct AppState {
74    rows: Vec<VarSummary>,
75    table_state: TableState,
76    overlay: Overlay,
77    toast: Option<Toast>,
78    secrets_visible: bool,
79    quit: bool,
80    filter: Option<FilterState>,
81    view: View,
82    /// `Some(id)` while `view == Detail`. Tracking the inspected
83    /// variable by id (rather than by selection index) keeps the
84    /// Detail screen from silently re-pointing at a different row
85    /// when an external mutation reshuffles the row buffer. Cleared
86    /// on every return to the dashboard.
87    detail_target: Option<VarId>,
88    /// Modal confirmation request currently focused. When `Some`,
89    /// [`Self::dispatch_key`] routes all keys to the confirm-modal
90    /// handler instead of the normal Action / filter paths.
91    confirm: Option<ConfirmRequest>,
92    /// Editor form (modal popup) currently focused. When `Some`,
93    /// [`Self::dispatch_key`] routes typed characters and editing
94    /// keys to this form and bypasses the Action / filter / modal
95    /// paths.
96    form: Option<EditorForm>,
97    /// Link-to-project form (modal popup) currently focused. Same
98    /// focus-stealing semantics as `form` above.
99    link_form: Option<LinkForm>,
100    /// Run-in-project form (modal popup) currently focused. Captures
101    /// the project path, the profile, and the command line to spawn.
102    run_form: Option<RunForm>,
103    /// Read-only view-value modal currently focused. Shows a
104    /// variable's decrypted value; closed by Esc.
105    view_value: Option<ViewValueModal>,
106    /// Error modal currently focused. Surfaces an action failure
107    /// with a contextual hint. Dismissed by Esc / Enter.
108    error_modal: Option<ErrorModal>,
109}
110
111/// Outcome of [`AppState::dispatch_key`]: signals whether the caller
112/// (the runtime) should perform an I/O side effect after the state
113/// has been updated.
114#[derive(Debug, Clone)]
115pub enum DispatchOutcome {
116    /// Continue the event loop without further side effects.
117    Continue,
118    /// Re-fetch rows from the provider; the dispatch translated a
119    /// `Refresh` intent. The runtime owns the provider and is
120    /// responsible for the actual call.
121    RefreshRequested,
122    /// The user confirmed deletion of the variable identified by
123    /// `id`; the runtime should call
124    /// [`VarMutator::delete`](crate::VarMutator::delete) and then
125    /// refresh. `name` is carried along for the post-delete success
126    /// toast so the runtime does not have to look it up after the row
127    /// is gone.
128    DeleteRequested {
129        /// Variable identifier the user confirmed deleting.
130        id: VarId,
131        /// Human-readable name, for use in the post-delete toast.
132        name: String,
133    },
134    /// The user submitted the new-var prompt; the runtime should call
135    /// [`crate::VarMutator::create`] and refresh on success.
136    CreateRequested(VarDraft),
137    /// The user submitted the edit-value prompt; the runtime should
138    /// call [`crate::VarMutator::update_value`] and refresh on success.
139    /// `name` is carried for the post-update toast.
140    UpdateValueRequested {
141        /// Variable to update.
142        id: VarId,
143        /// New value.
144        value: SecretString,
145        /// Human-readable name for the success toast.
146        name: String,
147    },
148    /// The user submitted the link form; the runtime should call
149    /// [`crate::VarMutator::link_to_project`] and refresh on success.
150    LinkRequested {
151        /// Variable being linked.
152        id: VarId,
153        /// Variable's display name (for the success toast).
154        name: String,
155        /// Project path the user typed.
156        project_path: std::path::PathBuf,
157        /// Profile name to use for the binding.
158        profile: String,
159        /// Whether to also materialize `.env` after linking.
160        materialize: bool,
161    },
162    /// The user asked to view the value of a variable. The runtime
163    /// should fetch via [`crate::VarProvider::get_value`] and then
164    /// call [`AppState::show_value_modal`] with the result.
165    ViewValueRequested {
166        /// Variable id whose value to fetch.
167        id: VarId,
168        /// Display name for the modal title.
169        name: String,
170    },
171    /// The user submitted the run-in-project form. The runtime should
172    /// restore the terminal, call
173    /// [`crate::VarMutator::run_in_project`], and re-init the TUI
174    /// afterwards.
175    RunRequested {
176        /// Project path to load the manifest from.
177        project_path: std::path::PathBuf,
178        /// Profile to resolve bindings under.
179        profile: String,
180        /// Program to spawn.
181        program: String,
182        /// Arguments forwarded to the program.
183        args: Vec<String>,
184    },
185}
186
187/// Active editor form — modal popup for the `n` (new var) and `e`
188/// (edit value) flows. Replaces the bottom-strip prompt with a
189/// centered window that has multiple fields (name / group / kind /
190/// value).
191#[allow(clippy::redundant_pub_crate)]
192#[derive(Debug, Clone)]
193pub(crate) struct EditorForm {
194    /// What the form is collecting.
195    pub(crate) mode: EditorMode,
196    /// Variable name. Editable only in `NewVar` mode.
197    pub(crate) name: String,
198    /// Variable value (the secret material). Always editable.
199    pub(crate) value: String,
200    /// Index into [`GROUP_CYCLE`].
201    pub(crate) group_idx: usize,
202    /// Index into [`KIND_CYCLE`].
203    pub(crate) kind_idx: usize,
204    /// Currently-focused field — receives typing / arrow input.
205    pub(crate) focus: FormField,
206    /// Whether the value field should be rendered verbatim (true)
207    /// or as `*` characters (false). Defaults to false for Secret
208    /// kind, true for Plain.
209    pub(crate) show_value: bool,
210}
211
212/// What the editor form is collecting.
213#[allow(clippy::redundant_pub_crate)]
214#[derive(Debug, Clone)]
215pub(crate) enum EditorMode {
216    /// Creating a new variable. All four fields are editable.
217    NewVar,
218    /// Replacing the value of an existing variable. The name /
219    /// group / kind are display-only; only the value field accepts
220    /// input.
221    EditValue {
222        /// Target variable id (snapshotted at form-open time).
223        id: VarId,
224        /// Original variable name, shown read-only.
225        original_name: String,
226    },
227}
228
229/// Field currently focused inside [`EditorForm`].
230#[allow(clippy::redundant_pub_crate)]
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub(crate) enum FormField {
233    Name,
234    Group,
235    Kind,
236    Value,
237}
238
239/// Groups exposed by the TUI cycler. Custom groups remain available
240/// via the CLI's `--group` flag.
241#[allow(clippy::redundant_pub_crate)]
242pub(crate) const GROUP_CYCLE: &[Group] = &[Group::User, Group::System, Group::Project];
243
244/// Kinds exposed by the TUI cycler.
245#[allow(clippy::redundant_pub_crate)]
246pub(crate) const KIND_CYCLE: &[VarKind] = &[VarKind::Secret, VarKind::Plain];
247
248/// Link-to-project form — modal popup for the `l` flow.
249///
250/// Captures the project path, the profile name, and whether to
251/// materialise the project's `.env` immediately after linking.
252#[allow(clippy::redundant_pub_crate)]
253#[derive(Debug, Clone)]
254pub(crate) struct LinkForm {
255    /// Variable being linked (id snapshotted at form-open time).
256    pub(crate) var_id: VarId,
257    /// Display name for the form title + success toast.
258    pub(crate) var_name: String,
259    /// Filesystem path the user has typed.
260    pub(crate) path: String,
261    /// Profile name (defaults to `default`).
262    pub(crate) profile: String,
263    /// Whether to materialise `.env` right after linking.
264    pub(crate) materialize: bool,
265    /// Field currently focused.
266    pub(crate) focus: LinkField,
267}
268
269/// Field currently focused inside [`LinkForm`].
270#[allow(clippy::redundant_pub_crate)]
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub(crate) enum LinkField {
273    Path,
274    Profile,
275    Materialize,
276}
277
278/// Run-in-project form — modal popup for the `R` flow.
279///
280/// Captures the project path, the profile name, and the command line
281/// (program + args) to spawn with the project's resolved environment
282/// overlay injected.
283#[allow(clippy::redundant_pub_crate)]
284#[derive(Debug, Clone)]
285pub(crate) struct RunForm {
286    /// Filesystem path the user has typed.
287    pub(crate) path: String,
288    /// Profile name (defaults to `default`).
289    pub(crate) profile: String,
290    /// Raw command line as typed by the user.
291    ///
292    /// Tokenised by whitespace at submit time. Quoted arguments are
293    /// NOT supported — users with complex shell quoting needs should
294    /// fall back to the `evault run` CLI command.
295    pub(crate) command: String,
296    /// Field currently focused.
297    pub(crate) focus: RunField,
298}
299
300/// Field currently focused inside [`RunForm`].
301#[allow(clippy::redundant_pub_crate)]
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub(crate) enum RunField {
304    Path,
305    Profile,
306    Command,
307}
308
309/// Error modal — focused popup that surfaces an action failure
310/// (failed create / edit / delete / link) with an explanatory hint.
311///
312/// Replaces a sticky error toast for cases where the user needs to
313/// actively acknowledge the failure (the toast is too easy to miss
314/// when an action they just initiated fails).
315#[allow(clippy::redundant_pub_crate)]
316#[derive(Debug, Clone)]
317pub(crate) struct ErrorModal {
318    /// Short title, e.g. `"create failed"` or `"link failed"`.
319    pub(crate) title: String,
320    /// The raw error message from the backend.
321    pub(crate) message: String,
322    /// Optional contextual hint explaining the failure and how to
323    /// fix it (e.g. naming rules when the create rejected the name).
324    pub(crate) hint: Option<String>,
325}
326
327/// View-value modal — popup showing a variable's decrypted value
328/// after the user pressed `v`. The runtime fetches the value and
329/// calls [`AppState::show_value_modal`] which inserts this struct.
330#[allow(clippy::redundant_pub_crate)]
331#[derive(Debug)]
332pub(crate) struct ViewValueModal {
333    /// Variable name for the modal title.
334    pub(crate) name: String,
335    /// The decrypted value. Held in `SecretString` so it gets
336    /// zeroized on drop.
337    pub(crate) value: SecretString,
338    /// Whether the value is currently rendered verbatim (`true`)
339    /// or as `*` characters. Toggle with `Ctrl+S` while open.
340    pub(crate) show: bool,
341}
342
343/// Modal confirmation request — internal state for the y/n overlay.
344///
345/// Crate-private: external callers don't construct these; they are
346/// raised by [`AppState`] in response to user input and rendered by
347/// the views layer.
348#[allow(clippy::redundant_pub_crate)]
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub(crate) struct ConfirmRequest {
351    pub(crate) title: String,
352    pub(crate) body: String,
353    pub(crate) action: PendingAction,
354}
355
356/// Action to perform when a [`ConfirmRequest`] is accepted.
357#[allow(clippy::redundant_pub_crate)]
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub(crate) enum PendingAction {
360    /// Delete the named variable; the runtime resolves via
361    /// [`VarMutator::delete`](crate::VarMutator::delete).
362    DeleteVar { id: VarId, name: String },
363}
364
365impl Default for AppState {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371impl AppState {
372    /// Construct an empty state with no rows loaded yet.
373    ///
374    /// Call [`Self::refresh`] before rendering for the first time to
375    /// populate the dashboard. Until then [`Self::selected_index`]
376    /// returns `None` (rather than `Some(0)` pointing at non-existent
377    /// row 0) so callers cannot accidentally index into an empty
378    /// buffer.
379    #[must_use]
380    pub fn new() -> Self {
381        // Selection begins at `None`. `clamp_selection` (run from
382        // `refresh`) re-anchors to row 0 once rows are loaded.
383        Self {
384            rows: Vec::new(),
385            table_state: TableState::default(),
386            overlay: Overlay::None,
387            toast: None,
388            secrets_visible: false,
389            quit: false,
390            filter: None,
391            view: View::Dashboard,
392            detail_target: None,
393            confirm: None,
394            form: None,
395            link_form: None,
396            run_form: None,
397            view_value: None,
398            error_modal: None,
399        }
400    }
401
402    /// Re-read rows from `provider` and re-anchor the selection.
403    ///
404    /// On success the dashboard's row buffer is replaced. The cursor
405    /// is clamped to `[0, rows.len())`: if the previously-selected
406    /// index is still in range it survives; if the new row count is
407    /// smaller the cursor pins to the last surviving row; if the
408    /// dashboard is now empty the cursor is cleared to `None`.
409    ///
410    /// On failure the previous rows are preserved and the error is
411    /// returned unchanged — the runtime decides whether to display it
412    /// as a toast.
413    ///
414    /// Note: re-anchoring is *by index*, not by [`evault_core::model::VarId`].
415    /// In phase 1 the dashboard is read-only, so external mutation
416    /// during a refresh can silently shift the user's selection by one
417    /// row. Phase 2 will track the selected `VarId` and re-anchor by
418    /// identity once CRUD is wired.
419    ///
420    /// # Errors
421    /// Propagates whatever [`ProviderError`] the provider returns.
422    pub fn refresh<P: VarProvider + ?Sized>(&mut self, provider: &P) -> Result<(), ProviderError> {
423        let rows = provider.list()?;
424        self.rows = rows;
425        self.rebuild_filter();
426        self.clamp_selection();
427        // Detail-target validation. If the user is inspecting a
428        // variable that just disappeared (external delete, profile
429        // switch, etc.) auto-return to the dashboard with a loud
430        // error toast. Without this check the Detail pane would
431        // silently re-anchor by index and start showing a
432        // *different* variable under the same screen header — the
433        // exact silent-failure mode the audit charter forbids.
434        if matches!(self.view, View::Detail) && !self.detail_target_is_present() {
435            self.view = View::Dashboard;
436            self.detail_target = None;
437            self.set_error_toast("variable removed elsewhere \u{2014} returned to dashboard");
438        }
439        Ok(())
440    }
441
442    fn detail_target_is_present(&self) -> bool {
443        let Some(target) = self.detail_target else {
444            return false;
445        };
446        self.rows.iter().any(|v| v.id == target)
447    }
448
449    /// Dispatch a raw key event.
450    ///
451    /// When the filter input is active, characters and Backspace edit
452    /// the needle, Enter accepts (filter stays applied but the input
453    /// is closed), and Esc cancels the filter entirely. Navigation
454    /// keys (Up / Down / `PageUp` / `PageDown`) and Ctrl-C remain bound
455    /// so the user can scroll through results and quit even while
456    /// typing.
457    ///
458    /// When the filter input is **not** active, the key is translated
459    /// via [`Action::from_key`] and dispatched to [`Self::apply`].
460    ///
461    /// Returns [`DispatchOutcome::RefreshRequested`] when the user
462    /// pressed `r` (or otherwise triggered `Action::Refresh`); the
463    /// runtime is responsible for the actual provider call.
464    pub fn dispatch_key(&mut self, key: KeyEvent) -> DispatchOutcome {
465        if key.kind != KeyEventKind::Press {
466            return DispatchOutcome::Continue;
467        }
468        // Error modal: takes priority over everything else so the
469        // user has to acknowledge an action failure before continuing.
470        if self.error_modal.is_some() {
471            return self.dispatch_error_modal_key(key);
472        }
473        // Modal confirm steals focus from everything else: when the
474        // user is being asked "are you sure?", any other action would
475        // be ambiguous.
476        if self.confirm.is_some() {
477            return self.dispatch_confirm_key(key);
478        }
479        // View-value modal: read-only popup, only Esc / Ctrl+S / Ctrl+C
480        // make sense while it's focused.
481        if self.view_value.is_some() {
482            return self.dispatch_view_value_key(key);
483        }
484        // Link form: modal popup capturing path + profile + materialize.
485        if self.link_form.is_some() {
486            return self.dispatch_link_form_key(key);
487        }
488        // Run form: modal popup capturing path + profile + command line.
489        if self.run_form.is_some() {
490            return self.dispatch_run_form_key(key);
491        }
492        // Editor form: typed characters go to its fields.
493        if self.form.is_some() {
494            return self.dispatch_form_key(key);
495        }
496        if self.is_filter_input_active() {
497            return self.dispatch_filter_input_key(key);
498        }
499        let action = Action::from_key(key);
500        // `ViewValue` is special: it needs to emit a runtime request
501        // carrying the selected row's id, which `apply` cannot
502        // express. We intercept it here.
503        if matches!(action, Action::ViewValue) {
504            if let Some((id, name)) = self.request_view_value() {
505                return DispatchOutcome::ViewValueRequested { id, name };
506            }
507            return DispatchOutcome::Continue;
508        }
509        self.apply(action);
510        if matches!(action, Action::Refresh) {
511            DispatchOutcome::RefreshRequested
512        } else {
513            DispatchOutcome::Continue
514        }
515    }
516
517    /// Handle a key while the editor form modal is focused.
518    ///
519    /// - `Tab` / `Shift+Tab` — cycle focus across the four fields.
520    /// - `Enter` — submit (validates; on failure leaves the form
521    ///   open and surfaces a sticky error toast).
522    /// - `Esc` — cancel.
523    /// - `Ctrl+C` — quit (escape hatch).
524    /// - On `Name` / `Value` focus: typed characters append, Backspace
525    ///   pops.
526    /// - On `Group` / `Kind` focus: Left / Right arrows + Space cycle
527    ///   the option.
528    /// - `s` on `Value` focus toggles secret-value masking (kept as
529    ///   typed but rendered with `*`).
530    fn dispatch_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
531        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
532        if matches!(key.code, KeyCode::Char('c')) && ctrl {
533            self.quit = true;
534            return DispatchOutcome::Continue;
535        }
536        match key.code {
537            KeyCode::Esc => {
538                self.form = None;
539                DispatchOutcome::Continue
540            }
541            KeyCode::Enter => self.submit_form(),
542            KeyCode::Tab => {
543                if let Some(form) = self.form.as_mut() {
544                    form.focus = next_focus(form.focus, &form.mode);
545                }
546                DispatchOutcome::Continue
547            }
548            KeyCode::BackTab => {
549                if let Some(form) = self.form.as_mut() {
550                    form.focus = prev_focus(form.focus, &form.mode);
551                }
552                DispatchOutcome::Continue
553            }
554            _ => {
555                if let Some(form) = self.form.as_mut() {
556                    handle_field_key(form, key);
557                }
558                DispatchOutcome::Continue
559            }
560        }
561    }
562
563    /// Validate the editor form's current contents and emit the
564    /// matching outcome. On validation failure, surfaces a sticky
565    /// error toast and leaves the form open with the entered data
566    /// preserved.
567    fn submit_form(&mut self) -> DispatchOutcome {
568        let Some(form) = self.form.take() else {
569            return DispatchOutcome::Continue;
570        };
571        // Reach into the cycle slices to recover the typed options.
572        // Indices are bounded at construction + key handling, so
573        // out-of-range here is unreachable; we clamp defensively.
574        let group = GROUP_CYCLE
575            .get(form.group_idx.min(GROUP_CYCLE.len() - 1))
576            .cloned()
577            .unwrap_or(Group::User);
578        let kind = *KIND_CYCLE
579            .get(form.kind_idx.min(KIND_CYCLE.len() - 1))
580            .unwrap_or(&VarKind::Secret);
581
582        match form.mode.clone() {
583            EditorMode::NewVar => {
584                if form.name.trim().is_empty() {
585                    self.set_error_toast("name must be non-empty (Esc to cancel)");
586                    self.form = Some(EditorForm {
587                        focus: FormField::Name,
588                        ..form
589                    });
590                    return DispatchOutcome::Continue;
591                }
592                if form.value.is_empty() {
593                    self.set_error_toast("value must be non-empty (Esc to cancel)");
594                    self.form = Some(EditorForm {
595                        focus: FormField::Value,
596                        ..form
597                    });
598                    return DispatchOutcome::Continue;
599                }
600                DispatchOutcome::CreateRequested(VarDraft {
601                    name: form.name.trim().to_owned(),
602                    group,
603                    kind,
604                    value: SecretString::new(form.value.into()),
605                })
606            }
607            EditorMode::EditValue { id, original_name } => {
608                if form.value.is_empty() {
609                    self.set_error_toast("value must be non-empty (Esc to cancel)");
610                    self.form = Some(EditorForm {
611                        focus: FormField::Value,
612                        ..form
613                    });
614                    return DispatchOutcome::Continue;
615                }
616                DispatchOutcome::UpdateValueRequested {
617                    id,
618                    value: SecretString::new(form.value.into()),
619                    name: original_name,
620                }
621            }
622        }
623    }
624
625    /// Handle a key while the view-value modal is focused.
626    /// `Esc` closes; `Ctrl+S` toggles masking; `Ctrl+C` quits.
627    fn dispatch_view_value_key(&mut self, key: KeyEvent) -> DispatchOutcome {
628        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
629        if matches!(key.code, KeyCode::Char('c')) && ctrl {
630            self.quit = true;
631            return DispatchOutcome::Continue;
632        }
633        match key.code {
634            KeyCode::Esc | KeyCode::Enter => {
635                self.view_value = None;
636            }
637            KeyCode::Char('s') if ctrl => {
638                if let Some(modal) = self.view_value.as_mut() {
639                    modal.show = !modal.show;
640                }
641            }
642            _ => {}
643        }
644        DispatchOutcome::Continue
645    }
646
647    /// Handle a key while the link form modal is focused.
648    fn dispatch_link_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
649        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
650        if matches!(key.code, KeyCode::Char('c')) && ctrl {
651            self.quit = true;
652            return DispatchOutcome::Continue;
653        }
654        match key.code {
655            KeyCode::Esc => {
656                self.link_form = None;
657                DispatchOutcome::Continue
658            }
659            KeyCode::Enter => self.submit_link_form(),
660            KeyCode::Tab => {
661                if let Some(form) = self.link_form.as_mut() {
662                    form.focus = match form.focus {
663                        LinkField::Path => LinkField::Profile,
664                        LinkField::Profile => LinkField::Materialize,
665                        LinkField::Materialize => LinkField::Path,
666                    };
667                }
668                DispatchOutcome::Continue
669            }
670            KeyCode::BackTab => {
671                if let Some(form) = self.link_form.as_mut() {
672                    form.focus = match form.focus {
673                        LinkField::Path => LinkField::Materialize,
674                        LinkField::Profile => LinkField::Path,
675                        LinkField::Materialize => LinkField::Profile,
676                    };
677                }
678                DispatchOutcome::Continue
679            }
680            _ => {
681                if let Some(form) = self.link_form.as_mut() {
682                    handle_link_field_key(form, key);
683                }
684                DispatchOutcome::Continue
685            }
686        }
687    }
688
689    fn submit_link_form(&mut self) -> DispatchOutcome {
690        let Some(form) = self.link_form.take() else {
691            return DispatchOutcome::Continue;
692        };
693        let path = form.path.trim();
694        if path.is_empty() {
695            self.set_error_toast("project path must be non-empty (Esc to cancel)");
696            self.link_form = Some(LinkForm {
697                focus: LinkField::Path,
698                ..form
699            });
700            return DispatchOutcome::Continue;
701        }
702        let profile = if form.profile.trim().is_empty() {
703            "default".to_owned()
704        } else {
705            form.profile.trim().to_owned()
706        };
707        DispatchOutcome::LinkRequested {
708            id: form.var_id,
709            name: form.var_name,
710            project_path: std::path::PathBuf::from(path),
711            profile,
712            materialize: form.materialize,
713        }
714    }
715
716    /// Handle a key while the run-in-project form modal is focused.
717    fn dispatch_run_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
718        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
719        if matches!(key.code, KeyCode::Char('c')) && ctrl {
720            self.quit = true;
721            return DispatchOutcome::Continue;
722        }
723        match key.code {
724            KeyCode::Esc => {
725                self.run_form = None;
726                DispatchOutcome::Continue
727            }
728            KeyCode::Enter => self.submit_run_form(),
729            KeyCode::Tab => {
730                if let Some(form) = self.run_form.as_mut() {
731                    form.focus = match form.focus {
732                        RunField::Path => RunField::Profile,
733                        RunField::Profile => RunField::Command,
734                        RunField::Command => RunField::Path,
735                    };
736                }
737                DispatchOutcome::Continue
738            }
739            KeyCode::BackTab => {
740                if let Some(form) = self.run_form.as_mut() {
741                    form.focus = match form.focus {
742                        RunField::Path => RunField::Command,
743                        RunField::Profile => RunField::Path,
744                        RunField::Command => RunField::Profile,
745                    };
746                }
747                DispatchOutcome::Continue
748            }
749            _ => {
750                if let Some(form) = self.run_form.as_mut() {
751                    handle_run_field_key(form, key);
752                }
753                DispatchOutcome::Continue
754            }
755        }
756    }
757
758    /// Validate the form's contents and emit a [`DispatchOutcome::RunRequested`]
759    /// or — if validation fails — re-open the form with an info toast.
760    fn submit_run_form(&mut self) -> DispatchOutcome {
761        let Some(form) = self.run_form.take() else {
762            return DispatchOutcome::Continue;
763        };
764        let path = form.path.trim();
765        if path.is_empty() {
766            self.set_error_toast("project path must be non-empty (Esc to cancel)");
767            self.run_form = Some(RunForm {
768                focus: RunField::Path,
769                ..form
770            });
771            return DispatchOutcome::Continue;
772        }
773        let command_trim = form.command.trim();
774        if command_trim.is_empty() {
775            self.set_error_toast("command line must be non-empty (Esc to cancel)");
776            self.run_form = Some(RunForm {
777                focus: RunField::Command,
778                ..form
779            });
780            return DispatchOutcome::Continue;
781        }
782        let mut tokens = command_trim.split_whitespace();
783        // `command_trim` is non-empty, so the first token exists.
784        let program = tokens.next().unwrap_or("").to_owned();
785        let args: Vec<String> = tokens.map(str::to_owned).collect();
786        let profile = if form.profile.trim().is_empty() {
787            "default".to_owned()
788        } else {
789            form.profile.trim().to_owned()
790        };
791        DispatchOutcome::RunRequested {
792            project_path: std::path::PathBuf::from(path),
793            profile,
794            program,
795            args,
796        }
797    }
798
799    /// Open the run-in-project form modal. Unlike the link form, the
800    /// run form is per-project rather than per-var, so no row needs to
801    /// be selected — the user types the project path explicitly.
802    fn open_run_form(&mut self) {
803        self.run_form = Some(RunForm {
804            path: String::new(),
805            profile: "default".to_owned(),
806            command: String::new(),
807            focus: RunField::Path,
808        });
809    }
810
811    /// Whether the run-in-project form modal is currently focused.
812    #[must_use]
813    pub const fn is_run_form_visible(&self) -> bool {
814        self.run_form.is_some()
815    }
816
817    /// Read-only access to the focused run-form (for the views layer).
818    pub(crate) const fn current_run_form(&self) -> Option<&RunForm> {
819        self.run_form.as_ref()
820    }
821
822    /// Open the link-form modal for the currently-targeted variable.
823    /// No-op (with info toast) if there is no row selected.
824    fn open_link_form(&mut self) {
825        let target = match self.view {
826            View::Dashboard => self.selected_row(),
827            View::Detail => self.detail_row(),
828        };
829        let Some(var) = target else {
830            if !self.toast_is_error() {
831                self.set_info_toast("no row selected");
832            }
833            return;
834        };
835        self.link_form = Some(LinkForm {
836            var_id: var.id,
837            var_name: var.name.clone(),
838            path: String::new(),
839            profile: "default".to_owned(),
840            materialize: false,
841            focus: LinkField::Path,
842        });
843    }
844
845    /// Trigger a value-view request for the currently-targeted row.
846    /// Returns the outcome the runtime should act on; called from
847    /// `apply(Action::ViewValue)` via the special path.
848    fn request_view_value(&mut self) -> Option<(VarId, String)> {
849        let target = match self.view {
850            View::Dashboard => self.selected_row(),
851            View::Detail => self.detail_row(),
852        };
853        let Some(var) = target else {
854            if !self.toast_is_error() {
855                self.set_info_toast("no row selected");
856            }
857            return None;
858        };
859        Some((var.id, var.name.clone()))
860    }
861
862    /// Show the value modal. Runtime calls this after fetching the
863    /// secret material via [`crate::VarProvider::get_value`].
864    pub fn show_value_modal(&mut self, name: String, value: SecretString) {
865        self.view_value = Some(ViewValueModal {
866            name,
867            value,
868            show: false,
869        });
870    }
871
872    /// Raise a focused error modal so the user has to acknowledge
873    /// an action failure before continuing. Preferred over
874    /// [`Self::set_error_toast`] when the user just initiated the
875    /// failing action (create / edit / delete / link) — the toast
876    /// is too easy to miss.
877    pub fn show_error_modal(
878        &mut self,
879        title: impl Into<String>,
880        message: impl Into<String>,
881        hint: Option<String>,
882    ) {
883        self.error_modal = Some(ErrorModal {
884            title: title.into(),
885            message: message.into(),
886            hint,
887        });
888    }
889
890    /// Whether the error modal is currently focused.
891    #[must_use]
892    pub const fn is_error_modal_visible(&self) -> bool {
893        self.error_modal.is_some()
894    }
895
896    /// Read-only access to the focused error modal (for the views layer).
897    pub(crate) const fn current_error_modal(&self) -> Option<&ErrorModal> {
898        self.error_modal.as_ref()
899    }
900
901    /// Handle a key while the error modal is focused. Only `Esc`,
902    /// `Enter`, and `Ctrl+C` are recognised; the modal swallows
903    /// everything else so the user can't accidentally trigger a new
904    /// action while still reading the error.
905    fn dispatch_error_modal_key(&mut self, key: KeyEvent) -> DispatchOutcome {
906        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
907        if matches!(key.code, KeyCode::Char('c')) && ctrl {
908            self.quit = true;
909            return DispatchOutcome::Continue;
910        }
911        if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ')) {
912            self.error_modal = None;
913        }
914        DispatchOutcome::Continue
915    }
916
917    /// Whether the link form modal is currently focused.
918    #[must_use]
919    pub const fn is_link_form_visible(&self) -> bool {
920        self.link_form.is_some()
921    }
922
923    /// Read-only access to the link form for the views layer.
924    pub(crate) const fn current_link_form(&self) -> Option<&LinkForm> {
925        self.link_form.as_ref()
926    }
927
928    /// Whether the view-value modal is currently focused.
929    #[must_use]
930    pub const fn is_view_value_visible(&self) -> bool {
931        self.view_value.is_some()
932    }
933
934    /// Read-only access to the view-value modal for the views layer.
935    pub(crate) const fn current_view_value(&self) -> Option<&ViewValueModal> {
936        self.view_value.as_ref()
937    }
938
939    /// Handle a key while a confirmation modal is focused.
940    ///
941    /// Recognised keys:
942    /// - `y` / `Y` / `Enter` — accept; consume the pending action
943    ///   and surface it as a [`DispatchOutcome`] for the runtime.
944    /// - `n` / `N` / `Esc` — cancel; clear the modal.
945    /// - `Ctrl+C` — quit (overrides the modal so the user can always
946    ///   escape).
947    /// - Any other key is ignored and the modal stays focused.
948    fn dispatch_confirm_key(&mut self, key: KeyEvent) -> DispatchOutcome {
949        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
950        if matches!(key.code, KeyCode::Char('c')) && ctrl {
951            self.quit = true;
952            return DispatchOutcome::Continue;
953        }
954        let accept = matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter);
955        let reject = matches!(key.code, KeyCode::Char('n' | 'N') | KeyCode::Esc);
956        if !accept && !reject {
957            return DispatchOutcome::Continue;
958        }
959        let Some(req) = self.confirm.take() else {
960            return DispatchOutcome::Continue;
961        };
962        if reject {
963            // Cascade the dismissal: if the user opened help and then
964            // raised a modal, an Esc press should back them out of
965            // *both* layers in one go rather than leaving help still
966            // visible. The user opened help BEFORE the modal (modals
967            // steal focus so `?` cannot reach the Action path), so
968            // closing it here matches the "Esc means back to the
969            // dashboard root" invariant.
970            if matches!(self.overlay, Overlay::Help) {
971                self.overlay = Overlay::None;
972            }
973            return DispatchOutcome::Continue;
974        }
975        match req.action {
976            PendingAction::DeleteVar { id, name } => DispatchOutcome::DeleteRequested { id, name },
977        }
978    }
979
980    fn dispatch_filter_input_key(&mut self, key: KeyEvent) -> DispatchOutcome {
981        // Mirror `apply()`'s contract: any active interaction in the
982        // filter input clears a stale info toast so the user never
983        // sees a "refreshed (5 vars)" sitting on screen while they
984        // type a needle that contradicts it. Error toasts remain
985        // sticky and survive — same policy as the Action path.
986        if !self.toast_is_error() {
987            self.toast = None;
988        }
989        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
990        match key.code {
991            // Universal exit gesture remains bound.
992            KeyCode::Char('c') if ctrl => {
993                self.quit = true;
994            }
995            // Cancel — clear the filter, restore full row set.
996            KeyCode::Esc => self.close_filter(),
997            // Accept — keep filter applied, hide the input box.
998            KeyCode::Enter => {
999                if let Some(filter) = self.filter.as_mut() {
1000                    filter.commit();
1001                }
1002            }
1003            // Edit the needle.
1004            KeyCode::Backspace => self.filter_pop(),
1005            KeyCode::Char(c) if !ctrl => self.filter_push(c),
1006            // Navigation through filtered results — arrow / page keys
1007            // only; j/k are valid needle characters and must not steal
1008            // the keystroke while the input is active.
1009            KeyCode::Up => self.select_prev(),
1010            KeyCode::Down => self.select_next(),
1011            KeyCode::PageUp => self.page(false),
1012            KeyCode::PageDown => self.page(true),
1013            _ => {}
1014        }
1015        DispatchOutcome::Continue
1016    }
1017
1018    /// Apply one [`Action`] to the state.
1019    ///
1020    /// Side-effect-free: only mutates `self`. The runtime drives this
1021    /// in a tight loop with each translated key event.
1022    ///
1023    /// Toast lifecycle: *info* toasts auto-dismiss on any non-`Noop`
1024    /// interaction so they do not pile up; *error* toasts are sticky
1025    /// and only cleared by [`Action::Dismiss`] or [`Action::Refresh`]
1026    /// so a user typing fast cannot lose a failure notice they never
1027    /// had a chance to read.
1028    pub fn apply(&mut self, action: Action) {
1029        // Auto-dismiss INFO toasts on any non-Noop action *before*
1030        // dispatch so the current action can set its own toast which
1031        // will survive. Error toasts persist until explicitly handled.
1032        if !matches!(action, Action::Noop) && !self.toast_is_error() {
1033            self.toast = None;
1034        }
1035        match action {
1036            Action::Quit => self.quit = true,
1037            Action::Dismiss => self.dismiss(),
1038            Action::MoveDown => self.select_next(),
1039            Action::MoveUp => self.select_prev(),
1040            Action::MoveTop => self.select_first(),
1041            Action::MoveBottom => self.select_last(),
1042            Action::PageDown => self.page(true),
1043            Action::PageUp => self.page(false),
1044            Action::ToggleHelp => self.toggle_help(),
1045            Action::ToggleSecretVisibility => {
1046                self.secrets_visible = !self.secrets_visible;
1047            }
1048            Action::Refresh => {
1049                // The runtime owns the refresh side-effect (it owns
1050                // the provider). Here we just clear any toast so the
1051                // runtime's post-refresh toast — whether the success
1052                // confirmation or an error from the provider — is the
1053                // only message visible afterwards.
1054                self.toast = None;
1055            }
1056            Action::StartFuzzy => self.open_filter(),
1057            Action::OpenDetail => self.open_detail(),
1058            Action::DeleteVar => self.request_delete_confirmation(),
1059            Action::NewVar => self.open_new_var_prompt(),
1060            Action::EditVar => self.open_edit_value_prompt(),
1061            Action::LinkVar => self.open_link_form(),
1062            Action::RunInProject => self.open_run_form(),
1063            // `ViewValue` is handled in `dispatch_key` directly
1064            // because its outcome needs to leave the apply path. We
1065            // accept it here as a no-op for tests that bypass
1066            // `dispatch_key`. Same for `Noop`.
1067            Action::ViewValue | Action::Noop => {}
1068            // Remaining stubs — copy / profile / next-view.
1069            Action::CopyValue | Action::SwitchProfile | Action::NextView => {
1070                self.set_info_toast("not implemented yet (use CLI for now)");
1071            }
1072        }
1073    }
1074
1075    /// Open the fuzzy filter overlay. If a filter is already active
1076    /// the input box is re-opened so the user can keep typing without
1077    /// losing the existing needle.
1078    /// Open the fuzzy filter overlay.
1079    ///
1080    /// If a filter is already applied, the input box is re-opened so
1081    /// the user can continue editing the existing needle. The cursor
1082    /// position is **preserved** in both first-open and re-open paths
1083    /// so the user's mental "what is selected" pointer survives a
1084    /// roundtrip through the input.
1085    pub fn open_filter(&mut self) {
1086        if let Some(filter) = self.filter.as_mut() {
1087            filter.reopen_input();
1088            return;
1089        }
1090        self.filter = Some(FilterState::new(self.rows.len()));
1091        // The fresh `FilterState` already matches all rows in original
1092        // order, and the visible row count is unchanged, so the
1093        // existing selection index remains valid. Clamp defensively
1094        // in case the dashboard was empty.
1095        self.clamp_selection();
1096    }
1097
1098    /// Clear the filter and restore the full row set.
1099    pub fn close_filter(&mut self) {
1100        self.filter = None;
1101        self.clamp_selection();
1102    }
1103
1104    /// Switch to [`View::Detail`] for the currently-selected row.
1105    ///
1106    /// The selected row's `VarId` is snapshotted into
1107    /// [`detail_target`](Self::detail_row) so the Detail screen looks
1108    /// up its data by identity rather than index — this prevents the
1109    /// pane from silently re-pointing at a different row when a
1110    /// concurrent refresh reshuffles the row buffer.
1111    ///
1112    /// If no row is selected, the call is a no-op aside from a brief
1113    /// `"no row selected"` info toast. A pre-existing error toast is
1114    /// **never** clobbered (errors are sticky for a reason; an
1115    /// accidental Enter must not erase a refresh-failure notice the
1116    /// user has not yet read).
1117    pub fn open_detail(&mut self) {
1118        if let Some(var) = self.selected_row() {
1119            self.detail_target = Some(var.id);
1120            self.view = View::Detail;
1121            return;
1122        }
1123        // Preserve sticky error toasts; only show the info hint when
1124        // the toast slot is empty or already an info toast (which
1125        // `apply` already cleared before dispatch in the normal path).
1126        if !self.toast_is_error() {
1127            self.set_info_toast("no row selected");
1128        }
1129    }
1130
1131    /// Return to the dashboard from any other view. Clears the
1132    /// Detail target so a subsequent re-entry re-snapshots fresh.
1133    pub const fn return_to_dashboard(&mut self) {
1134        self.view = View::Dashboard;
1135        self.detail_target = None;
1136    }
1137
1138    /// Splice the given variable id out of the row buffer locally
1139    /// without going through the provider.
1140    ///
1141    /// Used by the runtime when a `delete` succeeded but the
1142    /// subsequent `refresh` failed: keeping the deleted row visible
1143    /// would let the user press `d` a second time on a ghost entry,
1144    /// producing a confusing `NotFound` error or a "deleted twice"
1145    /// success. Splicing locally restores the user's mental model.
1146    ///
1147    /// No-op if the id is not in the buffer. Re-ranks any active
1148    /// filter and clamps the selection cursor.
1149    pub fn splice_out_row(&mut self, id: VarId) {
1150        let before = self.rows.len();
1151        self.rows.retain(|v| v.id != id);
1152        if self.rows.len() == before {
1153            return;
1154        }
1155        self.rebuild_filter();
1156        self.clamp_selection();
1157        // If the user was inspecting the just-spliced variable,
1158        // return to the dashboard so the detail target does not
1159        // dangle.
1160        if self.detail_target == Some(id) {
1161            self.return_to_dashboard();
1162        }
1163    }
1164
1165    /// Open the editor form modal for creating a new variable.
1166    /// Fields default to: empty name + empty value, group=user,
1167    /// kind=secret (matches the most common case).
1168    fn open_new_var_prompt(&mut self) {
1169        self.form = Some(EditorForm {
1170            mode: EditorMode::NewVar,
1171            name: String::new(),
1172            value: String::new(),
1173            group_idx: 0,
1174            kind_idx: 0,
1175            focus: FormField::Name,
1176            show_value: false,
1177        });
1178    }
1179
1180    /// Open the editor form modal for editing the value of the
1181    /// currently-targeted variable. The name / group / kind fields
1182    /// are displayed but read-only; only the value is editable.
1183    fn open_edit_value_prompt(&mut self) {
1184        let target = match self.view {
1185            View::Dashboard => self.selected_row(),
1186            View::Detail => self.detail_row(),
1187        };
1188        let Some(var) = target else {
1189            if !self.toast_is_error() {
1190                self.set_info_toast("no row selected");
1191            }
1192            return;
1193        };
1194        let group_idx = GROUP_CYCLE
1195            .iter()
1196            .position(|g| g == &var.group)
1197            .unwrap_or(0);
1198        let kind_idx = KIND_CYCLE.iter().position(|k| *k == var.kind).unwrap_or(0);
1199        self.form = Some(EditorForm {
1200            mode: EditorMode::EditValue {
1201                id: var.id,
1202                original_name: var.name.clone(),
1203            },
1204            name: var.name.clone(),
1205            value: String::new(),
1206            group_idx,
1207            kind_idx,
1208            focus: FormField::Value,
1209            show_value: !matches!(var.kind, VarKind::Secret),
1210        });
1211    }
1212
1213    /// Whether the editor form modal is currently focused.
1214    #[must_use]
1215    pub const fn is_form_visible(&self) -> bool {
1216        self.form.is_some()
1217    }
1218
1219    /// Read-only access to the focused editor form (for the views layer).
1220    pub(crate) const fn current_form(&self) -> Option<&EditorForm> {
1221        self.form.as_ref()
1222    }
1223
1224    /// Raise a confirmation modal for deleting the currently-targeted
1225    /// variable (the selected row on the Dashboard, or
1226    /// [`Self::detail_row`] when on Detail). Surfaces an info toast
1227    /// instead if there is no target — without clobbering a sticky
1228    /// error toast.
1229    fn request_delete_confirmation(&mut self) {
1230        debug_assert!(
1231            self.confirm.is_none(),
1232            "request_delete_confirmation called with a focused modal — \
1233             dispatch_key routes confirm-mode keys to dispatch_confirm_key \
1234             before any Action::DeleteVar can reach here"
1235        );
1236        let target = match self.view {
1237            View::Dashboard => self.selected_row(),
1238            View::Detail => self.detail_row(),
1239        };
1240        let Some(var) = target else {
1241            if !self.toast_is_error() {
1242                self.set_info_toast("no row selected");
1243            }
1244            return;
1245        };
1246        let kind = match var.kind {
1247            evault_core::model::VarKind::Secret => "secret",
1248            evault_core::model::VarKind::Plain => "plain",
1249        };
1250        self.confirm = Some(ConfirmRequest {
1251            title: "delete variable".to_owned(),
1252            body: format!("Delete `{}` ({kind})?\nThis cannot be undone.", var.name),
1253            action: PendingAction::DeleteVar {
1254                id: var.id,
1255                name: var.name.clone(),
1256            },
1257        });
1258    }
1259
1260    /// Whether a confirmation modal is currently focused.
1261    #[must_use]
1262    pub const fn is_confirm_visible(&self) -> bool {
1263        self.confirm.is_some()
1264    }
1265
1266    /// Read-only access to the focused confirm request (for the
1267    /// views layer). Crate-private so external callers stay on the
1268    /// observation-only API ([`Self::is_confirm_visible`] et al.).
1269    pub(crate) const fn current_confirm(&self) -> Option<&ConfirmRequest> {
1270        self.confirm.as_ref()
1271    }
1272
1273    /// Programmatic dismissal of a focused modal. Returns `true` if a
1274    /// modal was actually cleared. Used by the runtime to flush state
1275    /// after the user-initiated delete it triggered has completed.
1276    pub fn dismiss_confirm(&mut self) -> bool {
1277        let was_set = self.confirm.is_some();
1278        self.confirm = None;
1279        was_set
1280    }
1281
1282    /// The variable currently displayed by the Detail view, looked
1283    /// up by identity rather than by selection index.
1284    ///
1285    /// Returns `None` when no Detail view is active, or when the
1286    /// inspected variable has been removed from the row buffer
1287    /// between Detail entry and the current frame.
1288    #[must_use]
1289    pub fn detail_row(&self) -> Option<&VarSummary> {
1290        let id = self.detail_target?;
1291        self.rows.iter().find(|v| v.id == id)
1292    }
1293
1294    fn filter_push(&mut self, c: char) {
1295        let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
1296        if let Some(filter) = self.filter.as_mut() {
1297            filter.push(c, &haystacks);
1298        }
1299        self.clamp_selection();
1300    }
1301
1302    fn filter_pop(&mut self) {
1303        let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
1304        if let Some(filter) = self.filter.as_mut() {
1305            filter.pop(&haystacks);
1306        }
1307        self.clamp_selection();
1308    }
1309
1310    /// Re-rank the existing filter against the (possibly changed) row
1311    /// buffer. Called from [`Self::refresh`]; no-op when no filter is
1312    /// active.
1313    ///
1314    /// `FilterState::rerank` is pure in `(needle, haystacks)`, so
1315    /// replaying the needle char-by-char against the new haystacks
1316    /// produces the same ranking that live typing would. We rebuild
1317    /// rather than expose a public rerank to keep `FilterState`'s
1318    /// surface minimal.
1319    fn rebuild_filter(&mut self) {
1320        let Some(filter) = self.filter.as_mut() else {
1321            return;
1322        };
1323        let needle = filter.needle().to_owned();
1324        let input_active = filter.input_active();
1325        let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
1326        let mut fresh = FilterState::new(self.rows.len());
1327        for c in needle.chars() {
1328            fresh.push(c, &haystacks);
1329        }
1330        if !input_active {
1331            fresh.commit();
1332        }
1333        *filter = fresh;
1334    }
1335
1336    /// Set an informational toast (auto-dismissed on the next action).
1337    pub fn set_info_toast(&mut self, msg: impl Into<String>) {
1338        self.toast = Some(Toast {
1339            text: msg.into(),
1340            kind: ToastKind::Info,
1341        });
1342    }
1343
1344    /// Set an error toast (rendered in the `error` palette).
1345    pub fn set_error_toast(&mut self, msg: impl Into<String>) {
1346        self.toast = Some(Toast {
1347            text: msg.into(),
1348            kind: ToastKind::Error,
1349        });
1350    }
1351
1352    /// `true` if the runtime should exit after the current frame.
1353    #[must_use]
1354    pub const fn quit_requested(&self) -> bool {
1355        self.quit
1356    }
1357
1358    /// Read-only access to the full row buffer (filter-independent).
1359    /// Use [`Self::visible_row_indices`] / [`Self::visible_rows`] when
1360    /// you need the rows currently rendered by the dashboard.
1361    #[must_use]
1362    pub fn rows(&self) -> &[VarSummary] {
1363        &self.rows
1364    }
1365
1366    /// Indices into [`Self::rows`] of the rows currently rendered.
1367    ///
1368    /// When a filter is applied the indices are in match-score order
1369    /// (best score first). Without a filter they are simply
1370    /// `0..rows.len()`.
1371    #[must_use]
1372    pub fn visible_row_indices(&self) -> Vec<usize> {
1373        self.filter.as_ref().map_or_else(
1374            || (0..self.rows.len()).collect(),
1375            |f| f.visible_indices().to_vec(),
1376        )
1377    }
1378
1379    /// Iterator over the rows currently rendered by the dashboard.
1380    pub fn visible_rows(&self) -> impl Iterator<Item = &VarSummary> {
1381        self.visible_row_indices()
1382            .into_iter()
1383            .filter_map(move |i| self.rows.get(i))
1384    }
1385
1386    /// Whether the fuzzy-filter input box is currently capturing
1387    /// keystrokes. While `true` characters edit the needle instead of
1388    /// firing actions.
1389    #[must_use]
1390    pub fn is_filter_input_active(&self) -> bool {
1391        self.filter.as_ref().is_some_and(FilterState::input_active)
1392    }
1393
1394    /// Whether a filter is currently applied (regardless of whether
1395    /// the input box is still open).
1396    #[must_use]
1397    pub const fn is_filter_active(&self) -> bool {
1398        self.filter.is_some()
1399    }
1400
1401    /// The current filter needle, if any. Empty string when the user
1402    /// has opened the filter but not typed anything yet.
1403    #[must_use]
1404    pub fn filter_needle(&self) -> Option<&str> {
1405        self.filter.as_ref().map(FilterState::needle)
1406    }
1407
1408    /// Visible-row index of the currently-selected row, if any. This
1409    /// is the index inside [`Self::visible_rows`], not into
1410    /// [`Self::rows`]. Use [`Self::selected_row`] to dereference to
1411    /// the underlying [`VarSummary`].
1412    #[must_use]
1413    pub const fn selected_index(&self) -> Option<usize> {
1414        self.table_state.selected()
1415    }
1416
1417    /// The currently selected [`VarSummary`], if any, resolved through
1418    /// the active filter.
1419    #[must_use]
1420    pub fn selected_row(&self) -> Option<&VarSummary> {
1421        let visible_idx = self.table_state.selected()?;
1422        let absolute_idx = match self.filter.as_ref() {
1423            Some(f) => *f.visible_indices().get(visible_idx)?,
1424            None => visible_idx,
1425        };
1426        self.rows.get(absolute_idx)
1427    }
1428
1429    /// Read-only access to the [`TableState`]. Useful for tests that
1430    /// want to inspect the cursor without rendering.
1431    #[must_use]
1432    pub const fn table_state(&self) -> &TableState {
1433        &self.table_state
1434    }
1435
1436    /// Mutable access to the [`TableState`]. The dashboard view
1437    /// uses this when calling `render_stateful_widget`.
1438    pub const fn table_state_mut(&mut self) -> &mut TableState {
1439        &mut self.table_state
1440    }
1441
1442    /// Whether the help overlay is currently visible.
1443    #[must_use]
1444    pub const fn help_visible(&self) -> bool {
1445        matches!(self.overlay, Overlay::Help)
1446    }
1447
1448    /// Whether secret values should be rendered (otherwise masked).
1449    #[must_use]
1450    pub const fn secrets_visible(&self) -> bool {
1451        self.secrets_visible
1452    }
1453
1454    /// Top-level view currently displayed.
1455    #[must_use]
1456    pub const fn current_view(&self) -> View {
1457        self.view
1458    }
1459
1460    /// The currently-displayed toast text, if any.
1461    #[must_use]
1462    pub fn toast_text(&self) -> Option<&str> {
1463        self.toast.as_ref().map(|t| t.text.as_str())
1464    }
1465
1466    /// Whether the current toast is an error (vs informational).
1467    #[must_use]
1468    pub fn toast_is_error(&self) -> bool {
1469        matches!(self.toast.as_ref().map(|t| t.kind), Some(ToastKind::Error))
1470    }
1471
1472    pub(crate) const fn current_toast(&self) -> Option<&Toast> {
1473        self.toast.as_ref()
1474    }
1475
1476    fn dismiss(&mut self) {
1477        // Cascade: toast → filter → secondary view → overlay → quit.
1478        // Each level is dismissed in turn so the user has a
1479        // predictable Esc path back to the dashboard root before the
1480        // app exits. Modals and similar focus-stealing overlays will
1481        // be inserted ahead of `toast` in subsequent phases.
1482        if self.toast.is_some() {
1483            self.toast = None;
1484            return;
1485        }
1486        if self.is_filter_active() {
1487            self.close_filter();
1488            return;
1489        }
1490        if !matches!(self.view, View::Dashboard) {
1491            self.view = View::Dashboard;
1492            self.detail_target = None;
1493            return;
1494        }
1495        if matches!(self.overlay, Overlay::Help) {
1496            self.overlay = Overlay::None;
1497            return;
1498        }
1499        self.quit = true;
1500    }
1501
1502    const fn toggle_help(&mut self) {
1503        self.overlay = match self.overlay {
1504            Overlay::Help => Overlay::None,
1505            Overlay::None => Overlay::Help,
1506        };
1507    }
1508
1509    /// Visible-row count: the rendered length of the dashboard.
1510    /// When a filter is applied this is the count of *matching* rows;
1511    /// otherwise it equals `rows.len()`. Navigation operates in this
1512    /// space so the cursor never lands on a hidden row.
1513    fn visible_len(&self) -> usize {
1514        self.filter
1515            .as_ref()
1516            .map_or_else(|| self.rows.len(), |f| f.visible_indices().len())
1517    }
1518
1519    fn clamp_selection(&mut self) {
1520        let len = self.visible_len();
1521        if len == 0 {
1522            self.table_state.select(None);
1523            return;
1524        }
1525        let max = len - 1;
1526        let cur = self.table_state.selected().unwrap_or(0).min(max);
1527        self.table_state.select(Some(cur));
1528    }
1529
1530    fn select_next(&mut self) {
1531        let len = self.visible_len();
1532        if len == 0 {
1533            return;
1534        }
1535        let next = self.table_state.selected().map_or(0, |i| (i + 1) % len);
1536        self.table_state.select(Some(next));
1537    }
1538
1539    fn select_prev(&mut self) {
1540        let len = self.visible_len();
1541        if len == 0 {
1542            return;
1543        }
1544        let prev = self
1545            .table_state
1546            .selected()
1547            .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
1548        self.table_state.select(Some(prev));
1549    }
1550
1551    #[allow(clippy::missing_const_for_fn)]
1552    fn select_first(&mut self) {
1553        if self.visible_len() > 0 {
1554            self.table_state.select(Some(0));
1555        }
1556    }
1557
1558    #[allow(clippy::missing_const_for_fn)]
1559    fn select_last(&mut self) {
1560        if let Some(last) = self.visible_len().checked_sub(1) {
1561            self.table_state.select(Some(last));
1562        }
1563    }
1564
1565    fn page(&mut self, down: bool) {
1566        // A "page" is intentionally a fixed stride; the runtime does
1567        // not know the viewport size at action-translation time. Ten
1568        // rows is a sensible compromise that works on small and large
1569        // terminals alike.
1570        const STRIDE: usize = 10;
1571        let len = self.visible_len();
1572        if len == 0 {
1573            return;
1574        }
1575        let cur = self.table_state.selected().unwrap_or(0);
1576        let new = if down {
1577            cur.saturating_add(STRIDE).min(len - 1)
1578        } else {
1579            cur.saturating_sub(STRIDE)
1580        };
1581        self.table_state.select(Some(new));
1582    }
1583}
1584
1585/// Return the next field in the editor form's focus cycle. In
1586/// `EditValue` mode the Name / Group / Kind fields are display-only
1587/// so focus stays on `Value`.
1588const fn next_focus(current: FormField, mode: &EditorMode) -> FormField {
1589    if matches!(mode, EditorMode::EditValue { .. }) {
1590        return FormField::Value;
1591    }
1592    match current {
1593        FormField::Name => FormField::Group,
1594        FormField::Group => FormField::Kind,
1595        FormField::Kind => FormField::Value,
1596        FormField::Value => FormField::Name,
1597    }
1598}
1599
1600/// Return the previous field in the editor form's focus cycle.
1601const fn prev_focus(current: FormField, mode: &EditorMode) -> FormField {
1602    if matches!(mode, EditorMode::EditValue { .. }) {
1603        return FormField::Value;
1604    }
1605    match current {
1606        FormField::Name => FormField::Value,
1607        FormField::Group => FormField::Name,
1608        FormField::Kind => FormField::Group,
1609        FormField::Value => FormField::Kind,
1610    }
1611}
1612
1613/// Apply a non-Tab / non-Esc / non-Enter / non-Ctrl-C key to the
1614/// currently-focused field of the editor form.
1615fn handle_field_key(form: &mut EditorForm, key: KeyEvent) {
1616    let read_only_metadata = matches!(form.mode, EditorMode::EditValue { .. });
1617    match form.focus {
1618        FormField::Name => {
1619            if read_only_metadata {
1620                return;
1621            }
1622            match key.code {
1623                KeyCode::Backspace => {
1624                    form.name.pop();
1625                }
1626                KeyCode::Char(c) if is_text_input(key) => form.name.push(c),
1627                _ => {}
1628            }
1629        }
1630        FormField::Group => {
1631            if read_only_metadata {
1632                return;
1633            }
1634            match key.code {
1635                KeyCode::Left => {
1636                    form.group_idx = (form.group_idx + GROUP_CYCLE.len() - 1) % GROUP_CYCLE.len();
1637                }
1638                KeyCode::Right | KeyCode::Char(' ') => {
1639                    form.group_idx = (form.group_idx + 1) % GROUP_CYCLE.len();
1640                }
1641                _ => {}
1642            }
1643        }
1644        FormField::Kind => {
1645            if read_only_metadata {
1646                return;
1647            }
1648            match key.code {
1649                KeyCode::Left => {
1650                    form.kind_idx = (form.kind_idx + KIND_CYCLE.len() - 1) % KIND_CYCLE.len();
1651                }
1652                KeyCode::Right | KeyCode::Char(' ') => {
1653                    form.kind_idx = (form.kind_idx + 1) % KIND_CYCLE.len();
1654                }
1655                _ => {}
1656            }
1657        }
1658        FormField::Value => match key.code {
1659            KeyCode::Backspace => {
1660                form.value.pop();
1661            }
1662            // `Ctrl+S` toggles "show value" so the user can verify what
1663            // they typed.
1664            KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1665                form.show_value = !form.show_value;
1666            }
1667            KeyCode::Char(c) if is_text_input(key) => form.value.push(c),
1668            _ => {}
1669        },
1670    }
1671}
1672
1673/// Apply a key to the currently-focused field of the link form.
1674fn handle_link_field_key(form: &mut LinkForm, key: KeyEvent) {
1675    match form.focus {
1676        LinkField::Path => match key.code {
1677            KeyCode::Backspace => {
1678                form.path.pop();
1679            }
1680            KeyCode::Char(c) if is_text_input(key) => form.path.push(c),
1681            _ => {}
1682        },
1683        LinkField::Profile => match key.code {
1684            KeyCode::Backspace => {
1685                form.profile.pop();
1686            }
1687            KeyCode::Char(c) if is_text_input(key) => form.profile.push(c),
1688            _ => {}
1689        },
1690        LinkField::Materialize => match key.code {
1691            KeyCode::Left | KeyCode::Right | KeyCode::Char(' ' | 'y' | 'Y' | 'n' | 'N') => {
1692                form.materialize = !form.materialize;
1693            }
1694            _ => {}
1695        },
1696    }
1697}
1698
1699/// Apply a key to the currently-focused field of the run form.
1700fn handle_run_field_key(form: &mut RunForm, key: KeyEvent) {
1701    let target = match form.focus {
1702        RunField::Path => &mut form.path,
1703        RunField::Profile => &mut form.profile,
1704        RunField::Command => &mut form.command,
1705    };
1706    match key.code {
1707        KeyCode::Backspace => {
1708            target.pop();
1709        }
1710        KeyCode::Char(c) if is_text_input(key) => target.push(c),
1711        _ => {}
1712    }
1713}
1714
1715/// Whether a key event represents a typeable character (no Ctrl /
1716/// Alt modifier; Shift is OK).
1717const fn is_text_input(key: KeyEvent) -> bool {
1718    !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT)
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724    use evault_core::model::{Group, VarId, VarKind};
1725    use time::OffsetDateTime;
1726
1727    struct StaticProvider(Vec<VarSummary>);
1728    impl VarProvider for StaticProvider {
1729        fn list(&self) -> Result<Vec<VarSummary>, ProviderError> {
1730            Ok(self.0.clone())
1731        }
1732        fn get_value(&self, _: VarId) -> Result<Option<SecretString>, ProviderError> {
1733            Ok(None)
1734        }
1735    }
1736
1737    struct FailingProvider;
1738    impl VarProvider for FailingProvider {
1739        fn list(&self) -> Result<Vec<VarSummary>, ProviderError> {
1740            Err(ProviderError::Backend("synthetic".into()))
1741        }
1742        fn get_value(&self, _: VarId) -> Result<Option<SecretString>, ProviderError> {
1743            Err(ProviderError::Backend("synthetic".into()))
1744        }
1745    }
1746
1747    fn summary(name: &str) -> VarSummary {
1748        VarSummary {
1749            id: VarId::new_v4(),
1750            name: name.into(),
1751            group: Group::User,
1752            kind: VarKind::Plain,
1753            value_len: name.len(),
1754            linked_projects: 0,
1755            updated_at: OffsetDateTime::now_utc(),
1756        }
1757    }
1758
1759    fn three_rows() -> StaticProvider {
1760        StaticProvider(vec![summary("ALPHA"), summary("BETA"), summary("GAMMA")])
1761    }
1762
1763    #[test]
1764    fn refresh_populates_rows() {
1765        let mut app = AppState::new();
1766        app.refresh(&three_rows()).unwrap();
1767        assert_eq!(app.rows().len(), 3);
1768        assert_eq!(app.selected_index(), Some(0));
1769    }
1770
1771    #[test]
1772    fn selection_is_none_before_first_refresh() {
1773        // Invariant: an `AppState` that has never been refreshed must
1774        // not advertise a selection (would point at non-existent row 0).
1775        let app = AppState::new();
1776        assert!(app.rows().is_empty());
1777        assert_eq!(app.selected_index(), None);
1778    }
1779
1780    #[test]
1781    fn refresh_with_empty_provider_clears_selection() {
1782        let mut app = AppState::new();
1783        app.refresh(&three_rows()).unwrap();
1784        app.refresh(&StaticProvider(Vec::new())).unwrap();
1785        assert!(app.rows().is_empty());
1786        assert_eq!(app.selected_index(), None);
1787    }
1788
1789    #[test]
1790    fn refresh_propagates_provider_error() {
1791        let mut app = AppState::new();
1792        let err = app.refresh(&FailingProvider).unwrap_err();
1793        assert!(matches!(err, ProviderError::Backend(_)));
1794    }
1795
1796    #[test]
1797    fn selection_wraps_around() {
1798        let mut app = AppState::new();
1799        app.refresh(&three_rows()).unwrap();
1800        app.apply(Action::MoveUp); // wrap from 0 to last
1801        assert_eq!(app.selected_index(), Some(2));
1802        app.apply(Action::MoveDown); // wrap from last back to 0
1803        assert_eq!(app.selected_index(), Some(0));
1804        app.apply(Action::MoveBottom);
1805        assert_eq!(app.selected_index(), Some(2));
1806        app.apply(Action::MoveTop);
1807        assert_eq!(app.selected_index(), Some(0));
1808    }
1809
1810    #[test]
1811    fn navigation_on_empty_rows_does_nothing() {
1812        let mut app = AppState::new();
1813        app.refresh(&StaticProvider(Vec::new())).unwrap();
1814        app.apply(Action::MoveDown);
1815        app.apply(Action::MoveUp);
1816        app.apply(Action::MoveTop);
1817        app.apply(Action::MoveBottom);
1818        assert_eq!(app.selected_index(), None);
1819    }
1820
1821    #[test]
1822    fn quit_action_sets_quit_flag() {
1823        let mut app = AppState::new();
1824        app.apply(Action::Quit);
1825        assert!(app.quit_requested());
1826    }
1827
1828    #[test]
1829    fn dismiss_closes_help_overlay_first_then_quits() {
1830        let mut app = AppState::new();
1831        app.apply(Action::ToggleHelp);
1832        assert!(app.help_visible());
1833        app.apply(Action::Dismiss);
1834        assert!(!app.help_visible());
1835        assert!(!app.quit_requested());
1836        app.apply(Action::Dismiss);
1837        assert!(app.quit_requested());
1838    }
1839
1840    #[test]
1841    fn toggle_secret_visibility_round_trips() {
1842        let mut app = AppState::new();
1843        assert!(!app.secrets_visible());
1844        app.apply(Action::ToggleSecretVisibility);
1845        assert!(app.secrets_visible());
1846        app.apply(Action::ToggleSecretVisibility);
1847        assert!(!app.secrets_visible());
1848    }
1849
1850    #[test]
1851    fn toasts_distinguish_info_and_error() {
1852        let mut app = AppState::new();
1853        app.set_info_toast("hello");
1854        assert_eq!(app.toast_text(), Some("hello"));
1855        assert!(!app.toast_is_error());
1856        app.set_error_toast("boom");
1857        assert_eq!(app.toast_text(), Some("boom"));
1858        assert!(app.toast_is_error());
1859    }
1860
1861    #[test]
1862    fn info_toast_dismissed_on_next_interaction() {
1863        let mut app = AppState::new();
1864        app.refresh(&three_rows()).unwrap();
1865        app.set_info_toast("hi");
1866        app.apply(Action::MoveDown);
1867        assert!(app.toast_text().is_none());
1868    }
1869
1870    /// Error toasts must survive navigation so a user typing fast
1871    /// (e.g. holding `j` to scroll) cannot lose a failure notice
1872    /// before reading it. Only explicit `Dismiss` / `Refresh` clears
1873    /// an error.
1874    #[test]
1875    fn error_toast_survives_navigation_and_help_toggle() {
1876        let mut app = AppState::new();
1877        app.refresh(&three_rows()).unwrap();
1878        app.set_error_toast("backend exploded");
1879        app.apply(Action::MoveDown);
1880        assert_eq!(app.toast_text(), Some("backend exploded"));
1881        app.apply(Action::ToggleHelp);
1882        assert_eq!(app.toast_text(), Some("backend exploded"));
1883        // Explicit dismiss clears it. Because the toast is present,
1884        // `Dismiss` consumes it instead of closing the help overlay,
1885        // so help stays visible.
1886        app.apply(Action::Dismiss);
1887        assert!(app.toast_text().is_none());
1888        assert!(app.help_visible());
1889    }
1890
1891    /// `Action::Refresh` is *not* a stub: it MUST clear any toast so
1892    /// the runtime's post-refresh success/failure message is the only
1893    /// thing visible afterwards. Previously this action set a
1894    /// "not implemented" info toast that lingered on every successful
1895    /// runtime refresh.
1896    #[test]
1897    fn refresh_action_clears_pre_existing_toast() {
1898        let mut app = AppState::new();
1899        app.set_info_toast("stale info");
1900        app.apply(Action::Refresh);
1901        assert!(app.toast_text().is_none());
1902
1903        app.set_error_toast("stale error");
1904        app.apply(Action::Refresh);
1905        assert!(
1906            app.toast_text().is_none(),
1907            "Refresh must also clear sticky error toasts"
1908        );
1909    }
1910
1911    #[test]
1912    fn noop_action_preserves_toast() {
1913        let mut app = AppState::new();
1914        app.set_info_toast("hi");
1915        app.apply(Action::Noop);
1916        assert_eq!(app.toast_text(), Some("hi"));
1917    }
1918
1919    #[test]
1920    fn page_navigation_is_bounded() {
1921        let mut app = AppState::new();
1922        app.refresh(&three_rows()).unwrap();
1923        app.apply(Action::PageDown);
1924        // Only 3 rows; PageDown should pin at the last.
1925        assert_eq!(app.selected_index(), Some(2));
1926        app.apply(Action::PageUp);
1927        assert_eq!(app.selected_index(), Some(0));
1928    }
1929
1930    // ─── Phase 2a: fuzzy filter ───────────────────────────────────
1931
1932    fn press(code: KeyCode) -> KeyEvent {
1933        KeyEvent::new(code, KeyModifiers::NONE)
1934    }
1935
1936    fn five_rows() -> StaticProvider {
1937        StaticProvider(vec![
1938            summary("DATABASE_URL"),
1939            summary("API_KEY"),
1940            summary("DB_HOST"),
1941            summary("NODE_ENV"),
1942            summary("PORT"),
1943        ])
1944    }
1945
1946    #[test]
1947    fn start_fuzzy_opens_filter_with_empty_needle() {
1948        let mut app = AppState::new();
1949        app.refresh(&five_rows()).unwrap();
1950        app.apply(Action::StartFuzzy);
1951        assert!(app.is_filter_active());
1952        assert!(app.is_filter_input_active());
1953        assert_eq!(app.filter_needle(), Some(""));
1954        // Empty needle shows every row.
1955        assert_eq!(app.visible_rows().count(), 5);
1956    }
1957
1958    #[test]
1959    fn typing_filter_chars_narrows_visible_rows() {
1960        let mut app = AppState::new();
1961        app.refresh(&five_rows()).unwrap();
1962        app.apply(Action::StartFuzzy);
1963        // Dispatch each char through dispatch_key so the filter-input
1964        // routing path is exercised.
1965        app.dispatch_key(press(KeyCode::Char('d')));
1966        app.dispatch_key(press(KeyCode::Char('b')));
1967        let visible: Vec<_> = app.visible_rows().map(|v| v.name.clone()).collect();
1968        assert!(visible.contains(&"DATABASE_URL".to_string()));
1969        assert!(visible.contains(&"DB_HOST".to_string()));
1970        assert!(!visible.contains(&"API_KEY".to_string()));
1971        assert!(!visible.contains(&"PORT".to_string()));
1972        assert_eq!(app.filter_needle(), Some("db"));
1973    }
1974
1975    #[test]
1976    fn backspace_pops_needle_and_widens_visible_set() {
1977        let mut app = AppState::new();
1978        app.refresh(&five_rows()).unwrap();
1979        app.apply(Action::StartFuzzy);
1980        app.dispatch_key(press(KeyCode::Char('x')));
1981        assert_eq!(app.visible_rows().count(), 0);
1982        app.dispatch_key(press(KeyCode::Backspace));
1983        assert_eq!(app.filter_needle(), Some(""));
1984        assert_eq!(app.visible_rows().count(), 5);
1985    }
1986
1987    #[test]
1988    fn enter_commits_filter_input_but_keeps_filter_applied() {
1989        let mut app = AppState::new();
1990        app.refresh(&five_rows()).unwrap();
1991        app.apply(Action::StartFuzzy);
1992        app.dispatch_key(press(KeyCode::Char('p')));
1993        app.dispatch_key(press(KeyCode::Enter));
1994        assert!(app.is_filter_active());
1995        assert!(!app.is_filter_input_active());
1996        // The filter is still narrowing the view.
1997        assert!(app.visible_rows().count() < 5);
1998        // Char keys now go through the Action path again.
1999        app.dispatch_key(press(KeyCode::Char('s')));
2000        assert!(app.secrets_visible());
2001    }
2002
2003    #[test]
2004    fn esc_clears_the_filter_entirely() {
2005        let mut app = AppState::new();
2006        app.refresh(&five_rows()).unwrap();
2007        app.apply(Action::StartFuzzy);
2008        app.dispatch_key(press(KeyCode::Char('p')));
2009        app.dispatch_key(press(KeyCode::Esc));
2010        assert!(!app.is_filter_active());
2011        assert_eq!(app.visible_rows().count(), 5);
2012    }
2013
2014    #[test]
2015    fn selection_clamps_to_visible_count_on_narrow() {
2016        let mut app = AppState::new();
2017        app.refresh(&five_rows()).unwrap();
2018        app.apply(Action::MoveBottom); // select row 4 (PORT)
2019        assert_eq!(app.selected_index(), Some(4));
2020        app.apply(Action::StartFuzzy);
2021        // Type something that filters down to only a handful of rows.
2022        app.dispatch_key(press(KeyCode::Char('d')));
2023        app.dispatch_key(press(KeyCode::Char('b')));
2024        // Visible count is 2; selection must clamp.
2025        let visible = app.visible_rows().count();
2026        assert!(visible <= 2);
2027        assert!(app.selected_index().is_some_and(|i| i < visible));
2028    }
2029
2030    #[test]
2031    fn selected_row_resolves_through_filter() {
2032        let mut app = AppState::new();
2033        app.refresh(&five_rows()).unwrap();
2034        app.apply(Action::StartFuzzy);
2035        // Filter narrows to rows containing "API".
2036        app.dispatch_key(press(KeyCode::Char('a')));
2037        app.dispatch_key(press(KeyCode::Char('p')));
2038        // The selected row should now be API_KEY (the best match for
2039        // "ap") rather than whatever absolute index 0 points to.
2040        let selected = app.selected_row().expect("a row should be selected");
2041        assert_eq!(selected.name, "API_KEY");
2042    }
2043
2044    #[test]
2045    fn ctrl_c_still_quits_while_filter_input_active() {
2046        let mut app = AppState::new();
2047        app.refresh(&five_rows()).unwrap();
2048        app.apply(Action::StartFuzzy);
2049        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2050        app.dispatch_key(ctrl_c);
2051        assert!(app.quit_requested());
2052    }
2053
2054    #[test]
2055    fn refresh_request_is_signalled_when_filter_is_off() {
2056        let mut app = AppState::new();
2057        app.refresh(&five_rows()).unwrap();
2058        let outcome = app.dispatch_key(press(KeyCode::Char('r')));
2059        assert!(matches!(outcome, DispatchOutcome::RefreshRequested));
2060    }
2061
2062    #[test]
2063    fn dismiss_closes_an_active_filter_before_overlay_or_quit() {
2064        let mut app = AppState::new();
2065        app.refresh(&five_rows()).unwrap();
2066        // Open + commit the filter so input is no longer active.
2067        app.apply(Action::StartFuzzy);
2068        app.dispatch_key(press(KeyCode::Char('d')));
2069        app.dispatch_key(press(KeyCode::Enter));
2070        assert!(app.is_filter_active());
2071        // Esc with a committed filter must close the filter, NOT quit.
2072        app.apply(Action::Dismiss);
2073        assert!(!app.is_filter_active());
2074        assert!(!app.quit_requested());
2075        // A second Esc with no overlay/filter quits.
2076        app.apply(Action::Dismiss);
2077        assert!(app.quit_requested());
2078    }
2079
2080    #[test]
2081    fn typing_in_filter_input_clears_pre_existing_info_toast() {
2082        // Regression: previously the filter-input path never touched
2083        // the toast, so a stale "refreshed (5 vars)" sat on screen
2084        // while the user typed a needle that contradicted it.
2085        let mut app = AppState::new();
2086        app.refresh(&five_rows()).unwrap();
2087        app.set_info_toast("refreshed (5 vars)");
2088        app.apply(Action::StartFuzzy);
2089        app.dispatch_key(press(KeyCode::Char('d')));
2090        assert!(app.toast_text().is_none());
2091    }
2092
2093    #[test]
2094    fn typing_in_filter_input_preserves_error_toasts() {
2095        let mut app = AppState::new();
2096        app.refresh(&five_rows()).unwrap();
2097        app.set_error_toast("backend failure");
2098        app.apply(Action::StartFuzzy);
2099        app.dispatch_key(press(KeyCode::Char('d')));
2100        assert_eq!(app.toast_text(), Some("backend failure"));
2101    }
2102
2103    // ─── Phase 2b1: detail view ───────────────────────────────────
2104
2105    #[test]
2106    fn open_detail_switches_view_when_a_row_is_selected() {
2107        let mut app = AppState::new();
2108        app.refresh(&five_rows()).unwrap();
2109        assert_eq!(app.current_view(), View::Dashboard);
2110        app.apply(Action::OpenDetail);
2111        assert_eq!(app.current_view(), View::Detail);
2112    }
2113
2114    #[test]
2115    fn open_detail_on_empty_dashboard_keeps_view_and_toasts() {
2116        let mut app = AppState::new();
2117        app.refresh(&StaticProvider(Vec::new())).unwrap();
2118        app.apply(Action::OpenDetail);
2119        assert_eq!(app.current_view(), View::Dashboard);
2120        assert_eq!(app.toast_text(), Some("no row selected"));
2121    }
2122
2123    #[test]
2124    fn dismiss_returns_from_detail_to_dashboard() {
2125        let mut app = AppState::new();
2126        app.refresh(&five_rows()).unwrap();
2127        app.apply(Action::OpenDetail);
2128        assert_eq!(app.current_view(), View::Detail);
2129        app.apply(Action::Dismiss);
2130        assert_eq!(app.current_view(), View::Dashboard);
2131        assert!(!app.quit_requested());
2132    }
2133
2134    #[test]
2135    fn detail_view_survives_secret_toggle_and_help_open() {
2136        let mut app = AppState::new();
2137        app.refresh(&five_rows()).unwrap();
2138        app.apply(Action::OpenDetail);
2139        // Action applied while on Detail view should not auto-return.
2140        app.apply(Action::ToggleSecretVisibility);
2141        assert_eq!(app.current_view(), View::Detail);
2142        assert!(app.secrets_visible());
2143        app.apply(Action::ToggleHelp);
2144        assert!(app.help_visible());
2145        assert_eq!(app.current_view(), View::Detail);
2146    }
2147
2148    #[test]
2149    fn detail_row_resolves_by_identity_after_row_reorder() {
2150        let mut app = AppState::new();
2151        app.refresh(&five_rows()).unwrap();
2152        // Select API_KEY (index 1) and open detail.
2153        app.apply(Action::MoveDown);
2154        let target_id = app.selected_row().expect("selection").id;
2155        app.apply(Action::OpenDetail);
2156        assert_eq!(app.current_view(), View::Detail);
2157        assert_eq!(
2158            app.detail_row().map(|v| v.id),
2159            Some(target_id),
2160            "Detail must resolve to the originally inspected var"
2161        );
2162
2163        // Now refresh with the SAME rows but in reverse order. By
2164        // index alone the Detail pane would silently jump to a
2165        // different variable.
2166        let reversed_rows: Vec<VarSummary> = {
2167            let mut tmp = app.rows().to_vec();
2168            tmp.reverse();
2169            tmp
2170        };
2171        app.refresh(&StaticProvider(reversed_rows)).unwrap();
2172        assert_eq!(app.current_view(), View::Detail);
2173        assert_eq!(
2174            app.detail_row().map(|v| v.id),
2175            Some(target_id),
2176            "Detail target must follow identity through a row reorder"
2177        );
2178    }
2179
2180    #[test]
2181    fn refresh_returns_from_detail_when_inspected_var_is_gone() {
2182        let mut app = AppState::new();
2183        app.refresh(&five_rows()).unwrap();
2184        app.apply(Action::MoveDown); // select API_KEY
2185        let target_id = app.selected_row().expect("selection").id;
2186        app.apply(Action::OpenDetail);
2187        assert_eq!(app.current_view(), View::Detail);
2188
2189        // External delete: rebuild rows without the inspected target.
2190        let surviving: Vec<VarSummary> = app
2191            .rows()
2192            .iter()
2193            .filter(|v| v.id != target_id)
2194            .cloned()
2195            .collect();
2196        app.refresh(&StaticProvider(surviving)).unwrap();
2197
2198        assert_eq!(
2199            app.current_view(),
2200            View::Dashboard,
2201            "must auto-return to dashboard when the inspected var disappears"
2202        );
2203        assert!(
2204            app.toast_text()
2205                .is_some_and(|t| t.contains("removed elsewhere")),
2206            "must surface a loud error toast"
2207        );
2208        assert!(app.toast_is_error());
2209    }
2210
2211    #[test]
2212    fn open_detail_does_not_clobber_sticky_error_toast() {
2213        let mut app = AppState::new();
2214        app.refresh(&StaticProvider(Vec::new())).unwrap();
2215        app.set_error_toast("backend failure");
2216        // Apply via `apply` so the pre-dispatch info-clear runs:
2217        // error toasts must survive that step AND the open_detail
2218        // empty-selection branch.
2219        app.apply(Action::OpenDetail);
2220        assert_eq!(app.toast_text(), Some("backend failure"));
2221        assert!(app.toast_is_error());
2222        assert_eq!(app.current_view(), View::Dashboard);
2223    }
2224
2225    #[test]
2226    fn return_to_dashboard_clears_detail_target() {
2227        let mut app = AppState::new();
2228        app.refresh(&five_rows()).unwrap();
2229        app.apply(Action::OpenDetail);
2230        assert!(app.detail_row().is_some());
2231        app.apply(Action::Dismiss);
2232        assert_eq!(app.current_view(), View::Dashboard);
2233        // Internal invariant: re-opening Detail must re-snapshot
2234        // against the current selection, not retain the prior target.
2235        app.apply(Action::MoveDown);
2236        let new_target = app.selected_row().expect("selection").id;
2237        app.apply(Action::OpenDetail);
2238        assert_eq!(app.detail_row().map(|v| v.id), Some(new_target));
2239    }
2240
2241    #[test]
2242    fn dismiss_cascade_priority_is_toast_filter_view_overlay() {
2243        let mut app = AppState::new();
2244        app.refresh(&five_rows()).unwrap();
2245        // Build a stacked context: filter committed, view = Detail,
2246        // overlay = Help, plus an ERROR toast on top (info toasts
2247        // would auto-clear before the dismiss cascade and merge two
2248        // steps into one — error toasts are sticky and exercise the
2249        // explicit-toast-dismiss step on its own).
2250        app.apply(Action::ToggleHelp);
2251        app.apply(Action::StartFuzzy);
2252        app.dispatch_key(press(KeyCode::Char('a')));
2253        app.dispatch_key(press(KeyCode::Enter));
2254        app.apply(Action::OpenDetail);
2255        app.set_error_toast("scratch");
2256        // 1) toast first
2257        app.apply(Action::Dismiss);
2258        assert!(app.toast_text().is_none());
2259        assert!(app.is_filter_active());
2260        // 2) filter
2261        app.apply(Action::Dismiss);
2262        assert!(!app.is_filter_active());
2263        assert_eq!(app.current_view(), View::Detail);
2264        // 3) view (back to dashboard)
2265        app.apply(Action::Dismiss);
2266        assert_eq!(app.current_view(), View::Dashboard);
2267        assert!(app.help_visible());
2268        // 4) overlay (help)
2269        app.apply(Action::Dismiss);
2270        assert!(!app.help_visible());
2271        // 5) finally quit
2272        app.apply(Action::Dismiss);
2273        assert!(app.quit_requested());
2274    }
2275
2276    // ─── Phase 2b2: confirm modal + delete flow ───────────────────
2277
2278    #[test]
2279    fn delete_action_opens_confirm_modal_for_selected_row() {
2280        let mut app = AppState::new();
2281        app.refresh(&five_rows()).unwrap();
2282        assert!(!app.is_confirm_visible());
2283        app.apply(Action::MoveDown); // select index 1 (API_KEY)
2284        let target_id = app.selected_row().expect("selection").id;
2285        app.apply(Action::DeleteVar);
2286        assert!(app.is_confirm_visible());
2287        let req = app.current_confirm().expect("confirm set");
2288        assert!(req.body.contains("API_KEY"));
2289        match &req.action {
2290            PendingAction::DeleteVar { id, name } => {
2291                assert_eq!(*id, target_id);
2292                assert_eq!(name, "API_KEY");
2293            }
2294        }
2295    }
2296
2297    #[test]
2298    fn delete_action_on_empty_dashboard_does_not_open_modal() {
2299        let mut app = AppState::new();
2300        app.refresh(&StaticProvider(Vec::new())).unwrap();
2301        app.apply(Action::DeleteVar);
2302        assert!(!app.is_confirm_visible());
2303        assert_eq!(app.toast_text(), Some("no row selected"));
2304    }
2305
2306    #[test]
2307    fn delete_action_on_detail_view_targets_inspected_var() {
2308        let mut app = AppState::new();
2309        app.refresh(&five_rows()).unwrap();
2310        app.apply(Action::MoveDown);
2311        let target_id = app.selected_row().expect("selection").id;
2312        app.apply(Action::OpenDetail);
2313        app.apply(Action::DeleteVar);
2314        assert!(app.is_confirm_visible());
2315        let req = app.current_confirm().expect("confirm set");
2316        match &req.action {
2317            PendingAction::DeleteVar { id, .. } => assert_eq!(*id, target_id),
2318        }
2319    }
2320
2321    #[test]
2322    fn confirm_modal_steals_focus_from_filter_and_actions() {
2323        let mut app = AppState::new();
2324        app.refresh(&five_rows()).unwrap();
2325        app.apply(Action::DeleteVar);
2326        assert!(app.is_confirm_visible());
2327
2328        // Char keys like 's' that normally fire ToggleSecretVisibility
2329        // must NOT take effect while a confirm is focused.
2330        let s = press(KeyCode::Char('s'));
2331        let outcome = app.dispatch_key(s);
2332        assert!(matches!(outcome, DispatchOutcome::Continue));
2333        assert!(!app.secrets_visible(), "modal must steal focus from `s`");
2334        assert!(app.is_confirm_visible());
2335
2336        // Arrow keys must not navigate either.
2337        let down = press(KeyCode::Down);
2338        app.dispatch_key(down);
2339        assert!(app.is_confirm_visible());
2340    }
2341
2342    #[test]
2343    fn modal_n_or_esc_cancels_without_side_effects() {
2344        let mut app = AppState::new();
2345        app.refresh(&five_rows()).unwrap();
2346        app.apply(Action::DeleteVar);
2347        let outcome = app.dispatch_key(press(KeyCode::Char('n')));
2348        assert!(matches!(outcome, DispatchOutcome::Continue));
2349        assert!(!app.is_confirm_visible());
2350
2351        app.apply(Action::DeleteVar);
2352        let outcome = app.dispatch_key(press(KeyCode::Esc));
2353        assert!(matches!(outcome, DispatchOutcome::Continue));
2354        assert!(!app.is_confirm_visible());
2355    }
2356
2357    #[test]
2358    fn modal_y_emits_delete_requested_with_id_and_name() {
2359        let mut app = AppState::new();
2360        app.refresh(&five_rows()).unwrap();
2361        app.apply(Action::MoveDown); // API_KEY
2362        let target_id = app.selected_row().expect("selection").id;
2363        app.apply(Action::DeleteVar);
2364        let outcome = app.dispatch_key(press(KeyCode::Char('y')));
2365        assert!(!app.is_confirm_visible(), "modal must clear after accept");
2366        match outcome {
2367            DispatchOutcome::DeleteRequested { id, name } => {
2368                assert_eq!(id, target_id);
2369                assert_eq!(name, "API_KEY");
2370            }
2371            other => panic!("expected DeleteRequested, got {other:?}"),
2372        }
2373    }
2374
2375    #[test]
2376    fn modal_enter_also_accepts() {
2377        let mut app = AppState::new();
2378        app.refresh(&five_rows()).unwrap();
2379        app.apply(Action::DeleteVar);
2380        let outcome = app.dispatch_key(press(KeyCode::Enter));
2381        assert!(!app.is_confirm_visible());
2382        assert!(matches!(outcome, DispatchOutcome::DeleteRequested { .. }));
2383    }
2384
2385    #[test]
2386    fn ctrl_c_quits_even_with_modal_focused() {
2387        let mut app = AppState::new();
2388        app.refresh(&five_rows()).unwrap();
2389        app.apply(Action::DeleteVar);
2390        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2391        app.dispatch_key(ctrl_c);
2392        assert!(app.quit_requested());
2393    }
2394
2395    #[test]
2396    fn modal_plain_c_does_not_quit_or_dismiss() {
2397        // Regression: only Ctrl-C exits the modal; a plain `c`
2398        // keystroke is an unrecognised input that must leave the
2399        // modal focused.
2400        let mut app = AppState::new();
2401        app.refresh(&five_rows()).unwrap();
2402        app.apply(Action::DeleteVar);
2403        app.dispatch_key(press(KeyCode::Char('c')));
2404        assert!(app.is_confirm_visible());
2405        assert!(!app.quit_requested());
2406    }
2407
2408    #[test]
2409    fn modal_reject_also_closes_help_overlay() {
2410        // The modal steals focus from `?`, so help can only be open
2411        // BEFORE the modal is raised. Rejecting the modal with Esc
2412        // should cascade and close help too — otherwise the user
2413        // sees nothing visible change when they hit Esc the first
2414        // time (modal disappears but help still covers everything).
2415        let mut app = AppState::new();
2416        app.refresh(&five_rows()).unwrap();
2417        app.apply(Action::ToggleHelp);
2418        assert!(app.help_visible());
2419        app.apply(Action::DeleteVar);
2420        assert!(app.is_confirm_visible());
2421        app.dispatch_key(press(KeyCode::Esc));
2422        assert!(!app.is_confirm_visible());
2423        assert!(!app.help_visible(), "Esc cascade must close help too");
2424    }
2425
2426    #[test]
2427    fn splice_out_row_removes_local_entry_and_rebuilds_filter() {
2428        let mut app = AppState::new();
2429        app.refresh(&five_rows()).unwrap();
2430        // Snapshot the API_KEY id.
2431        app.apply(Action::MoveDown);
2432        let target_id = app.selected_row().expect("selection").id;
2433
2434        // Apply a filter that matches `target_id` so we can confirm
2435        // the filter buffer is also re-ranked after the splice.
2436        app.apply(Action::StartFuzzy);
2437        app.dispatch_key(press(KeyCode::Char('a')));
2438        let before = app.visible_rows().count();
2439        assert!(before >= 1);
2440
2441        app.splice_out_row(target_id);
2442        assert!(app.rows().iter().all(|v| v.id != target_id));
2443        assert!(app.visible_rows().count() < before);
2444    }
2445
2446    #[test]
2447    fn splice_out_row_on_inspected_var_returns_to_dashboard() {
2448        let mut app = AppState::new();
2449        app.refresh(&five_rows()).unwrap();
2450        app.apply(Action::MoveDown);
2451        let target_id = app.selected_row().expect("selection").id;
2452        app.apply(Action::OpenDetail);
2453        assert_eq!(app.current_view(), View::Detail);
2454
2455        app.splice_out_row(target_id);
2456        assert_eq!(
2457            app.current_view(),
2458            View::Dashboard,
2459            "splice of the inspected var must return to dashboard"
2460        );
2461        assert!(app.detail_row().is_none());
2462    }
2463
2464    #[test]
2465    fn splice_out_row_on_unknown_id_is_a_noop() {
2466        let mut app = AppState::new();
2467        app.refresh(&five_rows()).unwrap();
2468        let before = app.rows().len();
2469        // Random VarId — must not be one of the five we just loaded.
2470        let bogus = VarId::new_v4();
2471        app.splice_out_row(bogus);
2472        assert_eq!(app.rows().len(), before);
2473    }
2474
2475    #[test]
2476    fn unknown_keys_keep_modal_focused() {
2477        let mut app = AppState::new();
2478        app.refresh(&five_rows()).unwrap();
2479        app.apply(Action::DeleteVar);
2480        // Letter neither y/Y/n/N nor Enter/Esc — must not dismiss.
2481        app.dispatch_key(press(KeyCode::Char('q')));
2482        assert!(app.is_confirm_visible());
2483        assert!(!app.quit_requested());
2484    }
2485
2486    #[test]
2487    fn refresh_rebuilds_filter_against_new_rows() {
2488        let mut app = AppState::new();
2489        app.refresh(&five_rows()).unwrap();
2490        app.apply(Action::StartFuzzy);
2491        app.dispatch_key(press(KeyCode::Char('d')));
2492        let before = app.visible_rows().count();
2493        // Shrink the underlying data and refresh.
2494        let shrunk = StaticProvider(vec![summary("DATABASE_URL")]);
2495        app.refresh(&shrunk).unwrap();
2496        // Filter must still be applied and re-ranked against the new rows.
2497        assert!(app.is_filter_active());
2498        assert_eq!(app.filter_needle(), Some("d"));
2499        let after = app.visible_rows().count();
2500        assert!(after <= before);
2501    }
2502}