1use 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#[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#[allow(clippy::redundant_pub_crate)]
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum ToastKind {
34 Info,
38 Error,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Overlay {
48 None,
49 Help,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum View {
59 Dashboard,
61 Detail,
64}
65
66#[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 detail_target: Option<VarId>,
88 confirm: Option<ConfirmRequest>,
92 form: Option<EditorForm>,
97 link_form: Option<LinkForm>,
100 run_form: Option<RunForm>,
103 view_value: Option<ViewValueModal>,
106 error_modal: Option<ErrorModal>,
109}
110
111#[derive(Debug, Clone)]
115pub enum DispatchOutcome {
116 Continue,
118 RefreshRequested,
122 DeleteRequested {
129 id: VarId,
131 name: String,
133 },
134 CreateRequested(VarDraft),
137 UpdateValueRequested {
141 id: VarId,
143 value: SecretString,
145 name: String,
147 },
148 LinkRequested {
151 id: VarId,
153 name: String,
155 project_path: std::path::PathBuf,
157 profile: String,
159 materialize: bool,
161 },
162 ViewValueRequested {
166 id: VarId,
168 name: String,
170 },
171 RunRequested {
176 project_path: std::path::PathBuf,
178 profile: String,
180 program: String,
182 args: Vec<String>,
184 },
185}
186
187#[allow(clippy::redundant_pub_crate)]
192#[derive(Debug, Clone)]
193pub(crate) struct EditorForm {
194 pub(crate) mode: EditorMode,
196 pub(crate) name: String,
198 pub(crate) value: String,
200 pub(crate) group_idx: usize,
202 pub(crate) kind_idx: usize,
204 pub(crate) focus: FormField,
206 pub(crate) show_value: bool,
210}
211
212#[allow(clippy::redundant_pub_crate)]
214#[derive(Debug, Clone)]
215pub(crate) enum EditorMode {
216 NewVar,
218 EditValue {
222 id: VarId,
224 original_name: String,
226 },
227}
228
229#[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#[allow(clippy::redundant_pub_crate)]
242pub(crate) const GROUP_CYCLE: &[Group] = &[Group::User, Group::System, Group::Project];
243
244#[allow(clippy::redundant_pub_crate)]
246pub(crate) const KIND_CYCLE: &[VarKind] = &[VarKind::Secret, VarKind::Plain];
247
248#[allow(clippy::redundant_pub_crate)]
253#[derive(Debug, Clone)]
254pub(crate) struct LinkForm {
255 pub(crate) var_id: VarId,
257 pub(crate) var_name: String,
259 pub(crate) path: String,
261 pub(crate) profile: String,
263 pub(crate) materialize: bool,
265 pub(crate) focus: LinkField,
267}
268
269#[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#[allow(clippy::redundant_pub_crate)]
284#[derive(Debug, Clone)]
285pub(crate) struct RunForm {
286 pub(crate) path: String,
288 pub(crate) profile: String,
290 pub(crate) command: String,
296 pub(crate) focus: RunField,
298}
299
300#[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#[allow(clippy::redundant_pub_crate)]
316#[derive(Debug, Clone)]
317pub(crate) struct ErrorModal {
318 pub(crate) title: String,
320 pub(crate) message: String,
322 pub(crate) hint: Option<String>,
325}
326
327#[allow(clippy::redundant_pub_crate)]
331#[derive(Debug)]
332pub(crate) struct ViewValueModal {
333 pub(crate) name: String,
335 pub(crate) value: SecretString,
338 pub(crate) show: bool,
341}
342
343#[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#[allow(clippy::redundant_pub_crate)]
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub(crate) enum PendingAction {
360 DeleteVar { id: VarId, name: String },
363}
364
365impl Default for AppState {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371impl AppState {
372 #[must_use]
380 pub fn new() -> Self {
381 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 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 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 pub fn dispatch_key(&mut self, key: KeyEvent) -> DispatchOutcome {
465 if key.kind != KeyEventKind::Press {
466 return DispatchOutcome::Continue;
467 }
468 if self.error_modal.is_some() {
471 return self.dispatch_error_modal_key(key);
472 }
473 if self.confirm.is_some() {
477 return self.dispatch_confirm_key(key);
478 }
479 if self.view_value.is_some() {
482 return self.dispatch_view_value_key(key);
483 }
484 if self.link_form.is_some() {
486 return self.dispatch_link_form_key(key);
487 }
488 if self.run_form.is_some() {
490 return self.dispatch_run_form_key(key);
491 }
492 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 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 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 fn submit_form(&mut self) -> DispatchOutcome {
568 let Some(form) = self.form.take() else {
569 return DispatchOutcome::Continue;
570 };
571 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 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 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 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 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 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 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 #[must_use]
813 pub const fn is_run_form_visible(&self) -> bool {
814 self.run_form.is_some()
815 }
816
817 pub(crate) const fn current_run_form(&self) -> Option<&RunForm> {
819 self.run_form.as_ref()
820 }
821
822 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 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 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 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 #[must_use]
892 pub const fn is_error_modal_visible(&self) -> bool {
893 self.error_modal.is_some()
894 }
895
896 pub(crate) const fn current_error_modal(&self) -> Option<&ErrorModal> {
898 self.error_modal.as_ref()
899 }
900
901 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 #[must_use]
919 pub const fn is_link_form_visible(&self) -> bool {
920 self.link_form.is_some()
921 }
922
923 pub(crate) const fn current_link_form(&self) -> Option<&LinkForm> {
925 self.link_form.as_ref()
926 }
927
928 #[must_use]
930 pub const fn is_view_value_visible(&self) -> bool {
931 self.view_value.is_some()
932 }
933
934 pub(crate) const fn current_view_value(&self) -> Option<&ViewValueModal> {
936 self.view_value.as_ref()
937 }
938
939 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 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 if !self.toast_is_error() {
987 self.toast = None;
988 }
989 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
990 match key.code {
991 KeyCode::Char('c') if ctrl => {
993 self.quit = true;
994 }
995 KeyCode::Esc => self.close_filter(),
997 KeyCode::Enter => {
999 if let Some(filter) = self.filter.as_mut() {
1000 filter.commit();
1001 }
1002 }
1003 KeyCode::Backspace => self.filter_pop(),
1005 KeyCode::Char(c) if !ctrl => self.filter_push(c),
1006 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 pub fn apply(&mut self, action: Action) {
1029 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 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 Action::ViewValue | Action::Noop => {}
1068 Action::CopyValue | Action::SwitchProfile | Action::NextView => {
1070 self.set_info_toast("not implemented yet (use CLI for now)");
1071 }
1072 }
1073 }
1074
1075 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 self.clamp_selection();
1096 }
1097
1098 pub fn close_filter(&mut self) {
1100 self.filter = None;
1101 self.clamp_selection();
1102 }
1103
1104 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 if !self.toast_is_error() {
1127 self.set_info_toast("no row selected");
1128 }
1129 }
1130
1131 pub const fn return_to_dashboard(&mut self) {
1134 self.view = View::Dashboard;
1135 self.detail_target = None;
1136 }
1137
1138 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 self.detail_target == Some(id) {
1161 self.return_to_dashboard();
1162 }
1163 }
1164
1165 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 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 #[must_use]
1215 pub const fn is_form_visible(&self) -> bool {
1216 self.form.is_some()
1217 }
1218
1219 pub(crate) const fn current_form(&self) -> Option<&EditorForm> {
1221 self.form.as_ref()
1222 }
1223
1224 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 #[must_use]
1262 pub const fn is_confirm_visible(&self) -> bool {
1263 self.confirm.is_some()
1264 }
1265
1266 pub(crate) const fn current_confirm(&self) -> Option<&ConfirmRequest> {
1270 self.confirm.as_ref()
1271 }
1272
1273 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 #[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 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 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 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 #[must_use]
1354 pub const fn quit_requested(&self) -> bool {
1355 self.quit
1356 }
1357
1358 #[must_use]
1362 pub fn rows(&self) -> &[VarSummary] {
1363 &self.rows
1364 }
1365
1366 #[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 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 #[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 #[must_use]
1397 pub const fn is_filter_active(&self) -> bool {
1398 self.filter.is_some()
1399 }
1400
1401 #[must_use]
1404 pub fn filter_needle(&self) -> Option<&str> {
1405 self.filter.as_ref().map(FilterState::needle)
1406 }
1407
1408 #[must_use]
1413 pub const fn selected_index(&self) -> Option<usize> {
1414 self.table_state.selected()
1415 }
1416
1417 #[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 #[must_use]
1432 pub const fn table_state(&self) -> &TableState {
1433 &self.table_state
1434 }
1435
1436 pub const fn table_state_mut(&mut self) -> &mut TableState {
1439 &mut self.table_state
1440 }
1441
1442 #[must_use]
1444 pub const fn help_visible(&self) -> bool {
1445 matches!(self.overlay, Overlay::Help)
1446 }
1447
1448 #[must_use]
1450 pub const fn secrets_visible(&self) -> bool {
1451 self.secrets_visible
1452 }
1453
1454 #[must_use]
1456 pub const fn current_view(&self) -> View {
1457 self.view
1458 }
1459
1460 #[must_use]
1462 pub fn toast_text(&self) -> Option<&str> {
1463 self.toast.as_ref().map(|t| t.text.as_str())
1464 }
1465
1466 #[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 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 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 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
1585const 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
1600const 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
1613fn 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 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
1673fn 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
1699fn 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
1715const 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 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); assert_eq!(app.selected_index(), Some(2));
1802 app.apply(Action::MoveDown); 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 #[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 app.apply(Action::Dismiss);
1887 assert!(app.toast_text().is_none());
1888 assert!(app.help_visible());
1889 }
1890
1891 #[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 assert_eq!(app.selected_index(), Some(2));
1926 app.apply(Action::PageUp);
1927 assert_eq!(app.selected_index(), Some(0));
1928 }
1929
1930 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 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 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 assert!(app.visible_rows().count() < 5);
1998 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); assert_eq!(app.selected_index(), Some(4));
2020 app.apply(Action::StartFuzzy);
2021 app.dispatch_key(press(KeyCode::Char('d')));
2023 app.dispatch_key(press(KeyCode::Char('b')));
2024 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 app.dispatch_key(press(KeyCode::Char('a')));
2037 app.dispatch_key(press(KeyCode::Char('p')));
2038 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 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 app.apply(Action::Dismiss);
2073 assert!(!app.is_filter_active());
2074 assert!(!app.quit_requested());
2075 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 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 #[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 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 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 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); let target_id = app.selected_row().expect("selection").id;
2186 app.apply(Action::OpenDetail);
2187 assert_eq!(app.current_view(), View::Detail);
2188
2189 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 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 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 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 app.apply(Action::Dismiss);
2258 assert!(app.toast_text().is_none());
2259 assert!(app.is_filter_active());
2260 app.apply(Action::Dismiss);
2262 assert!(!app.is_filter_active());
2263 assert_eq!(app.current_view(), View::Detail);
2264 app.apply(Action::Dismiss);
2266 assert_eq!(app.current_view(), View::Dashboard);
2267 assert!(app.help_visible());
2268 app.apply(Action::Dismiss);
2270 assert!(!app.help_visible());
2271 app.apply(Action::Dismiss);
2273 assert!(app.quit_requested());
2274 }
2275
2276 #[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); 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 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 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); 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 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 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 app.apply(Action::MoveDown);
2432 let target_id = app.selected_row().expect("selection").id;
2433
2434 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 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 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 let shrunk = StaticProvider(vec![summary("DATABASE_URL")]);
2495 app.refresh(&shrunk).unwrap();
2496 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}