use evault_core::model::{Group, VarId, VarKind};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::widgets::TableState;
use secrecy::SecretString;
use crate::event::Action;
use crate::filter::FilterState;
use crate::provider::{ProviderError, VarDraft, VarProvider, VarSummary};
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Toast {
pub(crate) text: String,
pub(crate) kind: ToastKind,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToastKind {
Info,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Overlay {
None,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Dashboard,
Detail,
}
#[derive(Debug)]
pub struct AppState {
rows: Vec<VarSummary>,
table_state: TableState,
overlay: Overlay,
toast: Option<Toast>,
secrets_visible: bool,
quit: bool,
filter: Option<FilterState>,
view: View,
detail_target: Option<VarId>,
confirm: Option<ConfirmRequest>,
form: Option<EditorForm>,
link_form: Option<LinkForm>,
run_form: Option<RunForm>,
view_value: Option<ViewValueModal>,
error_modal: Option<ErrorModal>,
}
#[derive(Debug, Clone)]
pub enum DispatchOutcome {
Continue,
RefreshRequested,
DeleteRequested {
id: VarId,
name: String,
},
CreateRequested(VarDraft),
UpdateValueRequested {
id: VarId,
value: SecretString,
name: String,
},
LinkRequested {
id: VarId,
name: String,
project_path: std::path::PathBuf,
profile: String,
materialize: bool,
},
ViewValueRequested {
id: VarId,
name: String,
},
RunRequested {
project_path: std::path::PathBuf,
profile: String,
program: String,
args: Vec<String>,
},
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone)]
pub(crate) struct EditorForm {
pub(crate) mode: EditorMode,
pub(crate) name: String,
pub(crate) value: String,
pub(crate) group_idx: usize,
pub(crate) kind_idx: usize,
pub(crate) focus: FormField,
pub(crate) show_value: bool,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone)]
pub(crate) enum EditorMode {
NewVar,
EditValue {
id: VarId,
original_name: String,
},
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FormField {
Name,
Group,
Kind,
Value,
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) const GROUP_CYCLE: &[Group] = &[Group::User, Group::System, Group::Project];
#[allow(clippy::redundant_pub_crate)]
pub(crate) const KIND_CYCLE: &[VarKind] = &[VarKind::Secret, VarKind::Plain];
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone)]
pub(crate) struct LinkForm {
pub(crate) var_id: VarId,
pub(crate) var_name: String,
pub(crate) path: String,
pub(crate) profile: String,
pub(crate) materialize: bool,
pub(crate) focus: LinkField,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinkField {
Path,
Profile,
Materialize,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone)]
pub(crate) struct RunForm {
pub(crate) path: String,
pub(crate) profile: String,
pub(crate) command: String,
pub(crate) focus: RunField,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RunField {
Path,
Profile,
Command,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone)]
pub(crate) struct ErrorModal {
pub(crate) title: String,
pub(crate) message: String,
pub(crate) hint: Option<String>,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug)]
pub(crate) struct ViewValueModal {
pub(crate) name: String,
pub(crate) value: SecretString,
pub(crate) show: bool,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfirmRequest {
pub(crate) title: String,
pub(crate) body: String,
pub(crate) action: PendingAction,
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PendingAction {
DeleteVar { id: VarId, name: String },
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
#[must_use]
pub fn new() -> Self {
Self {
rows: Vec::new(),
table_state: TableState::default(),
overlay: Overlay::None,
toast: None,
secrets_visible: false,
quit: false,
filter: None,
view: View::Dashboard,
detail_target: None,
confirm: None,
form: None,
link_form: None,
run_form: None,
view_value: None,
error_modal: None,
}
}
pub fn refresh<P: VarProvider + ?Sized>(&mut self, provider: &P) -> Result<(), ProviderError> {
let rows = provider.list()?;
self.rows = rows;
self.rebuild_filter();
self.clamp_selection();
if matches!(self.view, View::Detail) && !self.detail_target_is_present() {
self.view = View::Dashboard;
self.detail_target = None;
self.set_error_toast("variable removed elsewhere \u{2014} returned to dashboard");
}
Ok(())
}
fn detail_target_is_present(&self) -> bool {
let Some(target) = self.detail_target else {
return false;
};
self.rows.iter().any(|v| v.id == target)
}
pub fn dispatch_key(&mut self, key: KeyEvent) -> DispatchOutcome {
if key.kind != KeyEventKind::Press {
return DispatchOutcome::Continue;
}
if self.error_modal.is_some() {
return self.dispatch_error_modal_key(key);
}
if self.confirm.is_some() {
return self.dispatch_confirm_key(key);
}
if self.view_value.is_some() {
return self.dispatch_view_value_key(key);
}
if self.link_form.is_some() {
return self.dispatch_link_form_key(key);
}
if self.run_form.is_some() {
return self.dispatch_run_form_key(key);
}
if self.form.is_some() {
return self.dispatch_form_key(key);
}
if self.is_filter_input_active() {
return self.dispatch_filter_input_key(key);
}
let action = Action::from_key(key);
if matches!(action, Action::ViewValue) {
if let Some((id, name)) = self.request_view_value() {
return DispatchOutcome::ViewValueRequested { id, name };
}
return DispatchOutcome::Continue;
}
self.apply(action);
if matches!(action, Action::Refresh) {
DispatchOutcome::RefreshRequested
} else {
DispatchOutcome::Continue
}
}
fn dispatch_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
match key.code {
KeyCode::Esc => {
self.form = None;
DispatchOutcome::Continue
}
KeyCode::Enter => self.submit_form(),
KeyCode::Tab => {
if let Some(form) = self.form.as_mut() {
form.focus = next_focus(form.focus, &form.mode);
}
DispatchOutcome::Continue
}
KeyCode::BackTab => {
if let Some(form) = self.form.as_mut() {
form.focus = prev_focus(form.focus, &form.mode);
}
DispatchOutcome::Continue
}
_ => {
if let Some(form) = self.form.as_mut() {
handle_field_key(form, key);
}
DispatchOutcome::Continue
}
}
}
fn submit_form(&mut self) -> DispatchOutcome {
let Some(form) = self.form.take() else {
return DispatchOutcome::Continue;
};
let group = GROUP_CYCLE
.get(form.group_idx.min(GROUP_CYCLE.len() - 1))
.cloned()
.unwrap_or(Group::User);
let kind = *KIND_CYCLE
.get(form.kind_idx.min(KIND_CYCLE.len() - 1))
.unwrap_or(&VarKind::Secret);
match form.mode.clone() {
EditorMode::NewVar => {
if form.name.trim().is_empty() {
self.set_error_toast("name must be non-empty (Esc to cancel)");
self.form = Some(EditorForm {
focus: FormField::Name,
..form
});
return DispatchOutcome::Continue;
}
if form.value.is_empty() {
self.set_error_toast("value must be non-empty (Esc to cancel)");
self.form = Some(EditorForm {
focus: FormField::Value,
..form
});
return DispatchOutcome::Continue;
}
DispatchOutcome::CreateRequested(VarDraft {
name: form.name.trim().to_owned(),
group,
kind,
value: SecretString::new(form.value.into()),
})
}
EditorMode::EditValue { id, original_name } => {
if form.value.is_empty() {
self.set_error_toast("value must be non-empty (Esc to cancel)");
self.form = Some(EditorForm {
focus: FormField::Value,
..form
});
return DispatchOutcome::Continue;
}
DispatchOutcome::UpdateValueRequested {
id,
value: SecretString::new(form.value.into()),
name: original_name,
}
}
}
}
fn dispatch_view_value_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.view_value = None;
}
KeyCode::Char('s') if ctrl => {
if let Some(modal) = self.view_value.as_mut() {
modal.show = !modal.show;
}
}
_ => {}
}
DispatchOutcome::Continue
}
fn dispatch_link_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
match key.code {
KeyCode::Esc => {
self.link_form = None;
DispatchOutcome::Continue
}
KeyCode::Enter => self.submit_link_form(),
KeyCode::Tab => {
if let Some(form) = self.link_form.as_mut() {
form.focus = match form.focus {
LinkField::Path => LinkField::Profile,
LinkField::Profile => LinkField::Materialize,
LinkField::Materialize => LinkField::Path,
};
}
DispatchOutcome::Continue
}
KeyCode::BackTab => {
if let Some(form) = self.link_form.as_mut() {
form.focus = match form.focus {
LinkField::Path => LinkField::Materialize,
LinkField::Profile => LinkField::Path,
LinkField::Materialize => LinkField::Profile,
};
}
DispatchOutcome::Continue
}
_ => {
if let Some(form) = self.link_form.as_mut() {
handle_link_field_key(form, key);
}
DispatchOutcome::Continue
}
}
}
fn submit_link_form(&mut self) -> DispatchOutcome {
let Some(form) = self.link_form.take() else {
return DispatchOutcome::Continue;
};
let path = form.path.trim();
if path.is_empty() {
self.set_error_toast("project path must be non-empty (Esc to cancel)");
self.link_form = Some(LinkForm {
focus: LinkField::Path,
..form
});
return DispatchOutcome::Continue;
}
let profile = if form.profile.trim().is_empty() {
"default".to_owned()
} else {
form.profile.trim().to_owned()
};
DispatchOutcome::LinkRequested {
id: form.var_id,
name: form.var_name,
project_path: std::path::PathBuf::from(path),
profile,
materialize: form.materialize,
}
}
fn dispatch_run_form_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
match key.code {
KeyCode::Esc => {
self.run_form = None;
DispatchOutcome::Continue
}
KeyCode::Enter => self.submit_run_form(),
KeyCode::Tab => {
if let Some(form) = self.run_form.as_mut() {
form.focus = match form.focus {
RunField::Path => RunField::Profile,
RunField::Profile => RunField::Command,
RunField::Command => RunField::Path,
};
}
DispatchOutcome::Continue
}
KeyCode::BackTab => {
if let Some(form) = self.run_form.as_mut() {
form.focus = match form.focus {
RunField::Path => RunField::Command,
RunField::Profile => RunField::Path,
RunField::Command => RunField::Profile,
};
}
DispatchOutcome::Continue
}
_ => {
if let Some(form) = self.run_form.as_mut() {
handle_run_field_key(form, key);
}
DispatchOutcome::Continue
}
}
}
fn submit_run_form(&mut self) -> DispatchOutcome {
let Some(form) = self.run_form.take() else {
return DispatchOutcome::Continue;
};
let path = form.path.trim();
if path.is_empty() {
self.set_error_toast("project path must be non-empty (Esc to cancel)");
self.run_form = Some(RunForm {
focus: RunField::Path,
..form
});
return DispatchOutcome::Continue;
}
let command_trim = form.command.trim();
if command_trim.is_empty() {
self.set_error_toast("command line must be non-empty (Esc to cancel)");
self.run_form = Some(RunForm {
focus: RunField::Command,
..form
});
return DispatchOutcome::Continue;
}
let mut tokens = command_trim.split_whitespace();
let program = tokens.next().unwrap_or("").to_owned();
let args: Vec<String> = tokens.map(str::to_owned).collect();
let profile = if form.profile.trim().is_empty() {
"default".to_owned()
} else {
form.profile.trim().to_owned()
};
DispatchOutcome::RunRequested {
project_path: std::path::PathBuf::from(path),
profile,
program,
args,
}
}
fn open_run_form(&mut self) {
self.run_form = Some(RunForm {
path: String::new(),
profile: "default".to_owned(),
command: String::new(),
focus: RunField::Path,
});
}
#[must_use]
pub const fn is_run_form_visible(&self) -> bool {
self.run_form.is_some()
}
pub(crate) const fn current_run_form(&self) -> Option<&RunForm> {
self.run_form.as_ref()
}
fn open_link_form(&mut self) {
let target = match self.view {
View::Dashboard => self.selected_row(),
View::Detail => self.detail_row(),
};
let Some(var) = target else {
if !self.toast_is_error() {
self.set_info_toast("no row selected");
}
return;
};
self.link_form = Some(LinkForm {
var_id: var.id,
var_name: var.name.clone(),
path: String::new(),
profile: "default".to_owned(),
materialize: false,
focus: LinkField::Path,
});
}
fn request_view_value(&mut self) -> Option<(VarId, String)> {
let target = match self.view {
View::Dashboard => self.selected_row(),
View::Detail => self.detail_row(),
};
let Some(var) = target else {
if !self.toast_is_error() {
self.set_info_toast("no row selected");
}
return None;
};
Some((var.id, var.name.clone()))
}
pub fn show_value_modal(&mut self, name: String, value: SecretString) {
self.view_value = Some(ViewValueModal {
name,
value,
show: false,
});
}
pub fn show_error_modal(
&mut self,
title: impl Into<String>,
message: impl Into<String>,
hint: Option<String>,
) {
self.error_modal = Some(ErrorModal {
title: title.into(),
message: message.into(),
hint,
});
}
#[must_use]
pub const fn is_error_modal_visible(&self) -> bool {
self.error_modal.is_some()
}
pub(crate) const fn current_error_modal(&self) -> Option<&ErrorModal> {
self.error_modal.as_ref()
}
fn dispatch_error_modal_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ')) {
self.error_modal = None;
}
DispatchOutcome::Continue
}
#[must_use]
pub const fn is_link_form_visible(&self) -> bool {
self.link_form.is_some()
}
pub(crate) const fn current_link_form(&self) -> Option<&LinkForm> {
self.link_form.as_ref()
}
#[must_use]
pub const fn is_view_value_visible(&self) -> bool {
self.view_value.is_some()
}
pub(crate) const fn current_view_value(&self) -> Option<&ViewValueModal> {
self.view_value.as_ref()
}
fn dispatch_confirm_key(&mut self, key: KeyEvent) -> DispatchOutcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('c')) && ctrl {
self.quit = true;
return DispatchOutcome::Continue;
}
let accept = matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter);
let reject = matches!(key.code, KeyCode::Char('n' | 'N') | KeyCode::Esc);
if !accept && !reject {
return DispatchOutcome::Continue;
}
let Some(req) = self.confirm.take() else {
return DispatchOutcome::Continue;
};
if reject {
if matches!(self.overlay, Overlay::Help) {
self.overlay = Overlay::None;
}
return DispatchOutcome::Continue;
}
match req.action {
PendingAction::DeleteVar { id, name } => DispatchOutcome::DeleteRequested { id, name },
}
}
fn dispatch_filter_input_key(&mut self, key: KeyEvent) -> DispatchOutcome {
if !self.toast_is_error() {
self.toast = None;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => {
self.quit = true;
}
KeyCode::Esc => self.close_filter(),
KeyCode::Enter => {
if let Some(filter) = self.filter.as_mut() {
filter.commit();
}
}
KeyCode::Backspace => self.filter_pop(),
KeyCode::Char(c) if !ctrl => self.filter_push(c),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
KeyCode::PageUp => self.page(false),
KeyCode::PageDown => self.page(true),
_ => {}
}
DispatchOutcome::Continue
}
pub fn apply(&mut self, action: Action) {
if !matches!(action, Action::Noop) && !self.toast_is_error() {
self.toast = None;
}
match action {
Action::Quit => self.quit = true,
Action::Dismiss => self.dismiss(),
Action::MoveDown => self.select_next(),
Action::MoveUp => self.select_prev(),
Action::MoveTop => self.select_first(),
Action::MoveBottom => self.select_last(),
Action::PageDown => self.page(true),
Action::PageUp => self.page(false),
Action::ToggleHelp => self.toggle_help(),
Action::ToggleSecretVisibility => {
self.secrets_visible = !self.secrets_visible;
}
Action::Refresh => {
self.toast = None;
}
Action::StartFuzzy => self.open_filter(),
Action::OpenDetail => self.open_detail(),
Action::DeleteVar => self.request_delete_confirmation(),
Action::NewVar => self.open_new_var_prompt(),
Action::EditVar => self.open_edit_value_prompt(),
Action::LinkVar => self.open_link_form(),
Action::RunInProject => self.open_run_form(),
Action::ViewValue | Action::Noop => {}
Action::CopyValue | Action::SwitchProfile | Action::NextView => {
self.set_info_toast("not implemented yet (use CLI for now)");
}
}
}
pub fn open_filter(&mut self) {
if let Some(filter) = self.filter.as_mut() {
filter.reopen_input();
return;
}
self.filter = Some(FilterState::new(self.rows.len()));
self.clamp_selection();
}
pub fn close_filter(&mut self) {
self.filter = None;
self.clamp_selection();
}
pub fn open_detail(&mut self) {
if let Some(var) = self.selected_row() {
self.detail_target = Some(var.id);
self.view = View::Detail;
return;
}
if !self.toast_is_error() {
self.set_info_toast("no row selected");
}
}
pub const fn return_to_dashboard(&mut self) {
self.view = View::Dashboard;
self.detail_target = None;
}
pub fn splice_out_row(&mut self, id: VarId) {
let before = self.rows.len();
self.rows.retain(|v| v.id != id);
if self.rows.len() == before {
return;
}
self.rebuild_filter();
self.clamp_selection();
if self.detail_target == Some(id) {
self.return_to_dashboard();
}
}
fn open_new_var_prompt(&mut self) {
self.form = Some(EditorForm {
mode: EditorMode::NewVar,
name: String::new(),
value: String::new(),
group_idx: 0,
kind_idx: 0,
focus: FormField::Name,
show_value: false,
});
}
fn open_edit_value_prompt(&mut self) {
let target = match self.view {
View::Dashboard => self.selected_row(),
View::Detail => self.detail_row(),
};
let Some(var) = target else {
if !self.toast_is_error() {
self.set_info_toast("no row selected");
}
return;
};
let group_idx = GROUP_CYCLE
.iter()
.position(|g| g == &var.group)
.unwrap_or(0);
let kind_idx = KIND_CYCLE.iter().position(|k| *k == var.kind).unwrap_or(0);
self.form = Some(EditorForm {
mode: EditorMode::EditValue {
id: var.id,
original_name: var.name.clone(),
},
name: var.name.clone(),
value: String::new(),
group_idx,
kind_idx,
focus: FormField::Value,
show_value: !matches!(var.kind, VarKind::Secret),
});
}
#[must_use]
pub const fn is_form_visible(&self) -> bool {
self.form.is_some()
}
pub(crate) const fn current_form(&self) -> Option<&EditorForm> {
self.form.as_ref()
}
fn request_delete_confirmation(&mut self) {
debug_assert!(
self.confirm.is_none(),
"request_delete_confirmation called with a focused modal — \
dispatch_key routes confirm-mode keys to dispatch_confirm_key \
before any Action::DeleteVar can reach here"
);
let target = match self.view {
View::Dashboard => self.selected_row(),
View::Detail => self.detail_row(),
};
let Some(var) = target else {
if !self.toast_is_error() {
self.set_info_toast("no row selected");
}
return;
};
let kind = match var.kind {
evault_core::model::VarKind::Secret => "secret",
evault_core::model::VarKind::Plain => "plain",
};
self.confirm = Some(ConfirmRequest {
title: "delete variable".to_owned(),
body: format!("Delete `{}` ({kind})?\nThis cannot be undone.", var.name),
action: PendingAction::DeleteVar {
id: var.id,
name: var.name.clone(),
},
});
}
#[must_use]
pub const fn is_confirm_visible(&self) -> bool {
self.confirm.is_some()
}
pub(crate) const fn current_confirm(&self) -> Option<&ConfirmRequest> {
self.confirm.as_ref()
}
pub fn dismiss_confirm(&mut self) -> bool {
let was_set = self.confirm.is_some();
self.confirm = None;
was_set
}
#[must_use]
pub fn detail_row(&self) -> Option<&VarSummary> {
let id = self.detail_target?;
self.rows.iter().find(|v| v.id == id)
}
fn filter_push(&mut self, c: char) {
let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
if let Some(filter) = self.filter.as_mut() {
filter.push(c, &haystacks);
}
self.clamp_selection();
}
fn filter_pop(&mut self) {
let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
if let Some(filter) = self.filter.as_mut() {
filter.pop(&haystacks);
}
self.clamp_selection();
}
fn rebuild_filter(&mut self) {
let Some(filter) = self.filter.as_mut() else {
return;
};
let needle = filter.needle().to_owned();
let input_active = filter.input_active();
let haystacks: Vec<&str> = self.rows.iter().map(|v| v.name.as_str()).collect();
let mut fresh = FilterState::new(self.rows.len());
for c in needle.chars() {
fresh.push(c, &haystacks);
}
if !input_active {
fresh.commit();
}
*filter = fresh;
}
pub fn set_info_toast(&mut self, msg: impl Into<String>) {
self.toast = Some(Toast {
text: msg.into(),
kind: ToastKind::Info,
});
}
pub fn set_error_toast(&mut self, msg: impl Into<String>) {
self.toast = Some(Toast {
text: msg.into(),
kind: ToastKind::Error,
});
}
#[must_use]
pub const fn quit_requested(&self) -> bool {
self.quit
}
#[must_use]
pub fn rows(&self) -> &[VarSummary] {
&self.rows
}
#[must_use]
pub fn visible_row_indices(&self) -> Vec<usize> {
self.filter.as_ref().map_or_else(
|| (0..self.rows.len()).collect(),
|f| f.visible_indices().to_vec(),
)
}
pub fn visible_rows(&self) -> impl Iterator<Item = &VarSummary> {
self.visible_row_indices()
.into_iter()
.filter_map(move |i| self.rows.get(i))
}
#[must_use]
pub fn is_filter_input_active(&self) -> bool {
self.filter.as_ref().is_some_and(FilterState::input_active)
}
#[must_use]
pub const fn is_filter_active(&self) -> bool {
self.filter.is_some()
}
#[must_use]
pub fn filter_needle(&self) -> Option<&str> {
self.filter.as_ref().map(FilterState::needle)
}
#[must_use]
pub const fn selected_index(&self) -> Option<usize> {
self.table_state.selected()
}
#[must_use]
pub fn selected_row(&self) -> Option<&VarSummary> {
let visible_idx = self.table_state.selected()?;
let absolute_idx = match self.filter.as_ref() {
Some(f) => *f.visible_indices().get(visible_idx)?,
None => visible_idx,
};
self.rows.get(absolute_idx)
}
#[must_use]
pub const fn table_state(&self) -> &TableState {
&self.table_state
}
pub const fn table_state_mut(&mut self) -> &mut TableState {
&mut self.table_state
}
#[must_use]
pub const fn help_visible(&self) -> bool {
matches!(self.overlay, Overlay::Help)
}
#[must_use]
pub const fn secrets_visible(&self) -> bool {
self.secrets_visible
}
#[must_use]
pub const fn current_view(&self) -> View {
self.view
}
#[must_use]
pub fn toast_text(&self) -> Option<&str> {
self.toast.as_ref().map(|t| t.text.as_str())
}
#[must_use]
pub fn toast_is_error(&self) -> bool {
matches!(self.toast.as_ref().map(|t| t.kind), Some(ToastKind::Error))
}
pub(crate) const fn current_toast(&self) -> Option<&Toast> {
self.toast.as_ref()
}
fn dismiss(&mut self) {
if self.toast.is_some() {
self.toast = None;
return;
}
if self.is_filter_active() {
self.close_filter();
return;
}
if !matches!(self.view, View::Dashboard) {
self.view = View::Dashboard;
self.detail_target = None;
return;
}
if matches!(self.overlay, Overlay::Help) {
self.overlay = Overlay::None;
return;
}
self.quit = true;
}
const fn toggle_help(&mut self) {
self.overlay = match self.overlay {
Overlay::Help => Overlay::None,
Overlay::None => Overlay::Help,
};
}
fn visible_len(&self) -> usize {
self.filter
.as_ref()
.map_or_else(|| self.rows.len(), |f| f.visible_indices().len())
}
fn clamp_selection(&mut self) {
let len = self.visible_len();
if len == 0 {
self.table_state.select(None);
return;
}
let max = len - 1;
let cur = self.table_state.selected().unwrap_or(0).min(max);
self.table_state.select(Some(cur));
}
fn select_next(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
let next = self.table_state.selected().map_or(0, |i| (i + 1) % len);
self.table_state.select(Some(next));
}
fn select_prev(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
let prev = self
.table_state
.selected()
.map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
self.table_state.select(Some(prev));
}
#[allow(clippy::missing_const_for_fn)]
fn select_first(&mut self) {
if self.visible_len() > 0 {
self.table_state.select(Some(0));
}
}
#[allow(clippy::missing_const_for_fn)]
fn select_last(&mut self) {
if let Some(last) = self.visible_len().checked_sub(1) {
self.table_state.select(Some(last));
}
}
fn page(&mut self, down: bool) {
const STRIDE: usize = 10;
let len = self.visible_len();
if len == 0 {
return;
}
let cur = self.table_state.selected().unwrap_or(0);
let new = if down {
cur.saturating_add(STRIDE).min(len - 1)
} else {
cur.saturating_sub(STRIDE)
};
self.table_state.select(Some(new));
}
}
const fn next_focus(current: FormField, mode: &EditorMode) -> FormField {
if matches!(mode, EditorMode::EditValue { .. }) {
return FormField::Value;
}
match current {
FormField::Name => FormField::Group,
FormField::Group => FormField::Kind,
FormField::Kind => FormField::Value,
FormField::Value => FormField::Name,
}
}
const fn prev_focus(current: FormField, mode: &EditorMode) -> FormField {
if matches!(mode, EditorMode::EditValue { .. }) {
return FormField::Value;
}
match current {
FormField::Name => FormField::Value,
FormField::Group => FormField::Name,
FormField::Kind => FormField::Group,
FormField::Value => FormField::Kind,
}
}
fn handle_field_key(form: &mut EditorForm, key: KeyEvent) {
let read_only_metadata = matches!(form.mode, EditorMode::EditValue { .. });
match form.focus {
FormField::Name => {
if read_only_metadata {
return;
}
match key.code {
KeyCode::Backspace => {
form.name.pop();
}
KeyCode::Char(c) if is_text_input(key) => form.name.push(c),
_ => {}
}
}
FormField::Group => {
if read_only_metadata {
return;
}
match key.code {
KeyCode::Left => {
form.group_idx = (form.group_idx + GROUP_CYCLE.len() - 1) % GROUP_CYCLE.len();
}
KeyCode::Right | KeyCode::Char(' ') => {
form.group_idx = (form.group_idx + 1) % GROUP_CYCLE.len();
}
_ => {}
}
}
FormField::Kind => {
if read_only_metadata {
return;
}
match key.code {
KeyCode::Left => {
form.kind_idx = (form.kind_idx + KIND_CYCLE.len() - 1) % KIND_CYCLE.len();
}
KeyCode::Right | KeyCode::Char(' ') => {
form.kind_idx = (form.kind_idx + 1) % KIND_CYCLE.len();
}
_ => {}
}
}
FormField::Value => match key.code {
KeyCode::Backspace => {
form.value.pop();
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
form.show_value = !form.show_value;
}
KeyCode::Char(c) if is_text_input(key) => form.value.push(c),
_ => {}
},
}
}
fn handle_link_field_key(form: &mut LinkForm, key: KeyEvent) {
match form.focus {
LinkField::Path => match key.code {
KeyCode::Backspace => {
form.path.pop();
}
KeyCode::Char(c) if is_text_input(key) => form.path.push(c),
_ => {}
},
LinkField::Profile => match key.code {
KeyCode::Backspace => {
form.profile.pop();
}
KeyCode::Char(c) if is_text_input(key) => form.profile.push(c),
_ => {}
},
LinkField::Materialize => match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ' | 'y' | 'Y' | 'n' | 'N') => {
form.materialize = !form.materialize;
}
_ => {}
},
}
}
fn handle_run_field_key(form: &mut RunForm, key: KeyEvent) {
let target = match form.focus {
RunField::Path => &mut form.path,
RunField::Profile => &mut form.profile,
RunField::Command => &mut form.command,
};
match key.code {
KeyCode::Backspace => {
target.pop();
}
KeyCode::Char(c) if is_text_input(key) => target.push(c),
_ => {}
}
}
const fn is_text_input(key: KeyEvent) -> bool {
!key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT)
}
#[cfg(test)]
mod tests {
use super::*;
use evault_core::model::{Group, VarId, VarKind};
use time::OffsetDateTime;
struct StaticProvider(Vec<VarSummary>);
impl VarProvider for StaticProvider {
fn list(&self) -> Result<Vec<VarSummary>, ProviderError> {
Ok(self.0.clone())
}
fn get_value(&self, _: VarId) -> Result<Option<SecretString>, ProviderError> {
Ok(None)
}
}
struct FailingProvider;
impl VarProvider for FailingProvider {
fn list(&self) -> Result<Vec<VarSummary>, ProviderError> {
Err(ProviderError::Backend("synthetic".into()))
}
fn get_value(&self, _: VarId) -> Result<Option<SecretString>, ProviderError> {
Err(ProviderError::Backend("synthetic".into()))
}
}
fn summary(name: &str) -> VarSummary {
VarSummary {
id: VarId::new_v4(),
name: name.into(),
group: Group::User,
kind: VarKind::Plain,
value_len: name.len(),
linked_projects: 0,
updated_at: OffsetDateTime::now_utc(),
}
}
fn three_rows() -> StaticProvider {
StaticProvider(vec![summary("ALPHA"), summary("BETA"), summary("GAMMA")])
}
#[test]
fn refresh_populates_rows() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
assert_eq!(app.rows().len(), 3);
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn selection_is_none_before_first_refresh() {
let app = AppState::new();
assert!(app.rows().is_empty());
assert_eq!(app.selected_index(), None);
}
#[test]
fn refresh_with_empty_provider_clears_selection() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
app.refresh(&StaticProvider(Vec::new())).unwrap();
assert!(app.rows().is_empty());
assert_eq!(app.selected_index(), None);
}
#[test]
fn refresh_propagates_provider_error() {
let mut app = AppState::new();
let err = app.refresh(&FailingProvider).unwrap_err();
assert!(matches!(err, ProviderError::Backend(_)));
}
#[test]
fn selection_wraps_around() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
app.apply(Action::MoveUp); assert_eq!(app.selected_index(), Some(2));
app.apply(Action::MoveDown); assert_eq!(app.selected_index(), Some(0));
app.apply(Action::MoveBottom);
assert_eq!(app.selected_index(), Some(2));
app.apply(Action::MoveTop);
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn navigation_on_empty_rows_does_nothing() {
let mut app = AppState::new();
app.refresh(&StaticProvider(Vec::new())).unwrap();
app.apply(Action::MoveDown);
app.apply(Action::MoveUp);
app.apply(Action::MoveTop);
app.apply(Action::MoveBottom);
assert_eq!(app.selected_index(), None);
}
#[test]
fn quit_action_sets_quit_flag() {
let mut app = AppState::new();
app.apply(Action::Quit);
assert!(app.quit_requested());
}
#[test]
fn dismiss_closes_help_overlay_first_then_quits() {
let mut app = AppState::new();
app.apply(Action::ToggleHelp);
assert!(app.help_visible());
app.apply(Action::Dismiss);
assert!(!app.help_visible());
assert!(!app.quit_requested());
app.apply(Action::Dismiss);
assert!(app.quit_requested());
}
#[test]
fn toggle_secret_visibility_round_trips() {
let mut app = AppState::new();
assert!(!app.secrets_visible());
app.apply(Action::ToggleSecretVisibility);
assert!(app.secrets_visible());
app.apply(Action::ToggleSecretVisibility);
assert!(!app.secrets_visible());
}
#[test]
fn toasts_distinguish_info_and_error() {
let mut app = AppState::new();
app.set_info_toast("hello");
assert_eq!(app.toast_text(), Some("hello"));
assert!(!app.toast_is_error());
app.set_error_toast("boom");
assert_eq!(app.toast_text(), Some("boom"));
assert!(app.toast_is_error());
}
#[test]
fn info_toast_dismissed_on_next_interaction() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
app.set_info_toast("hi");
app.apply(Action::MoveDown);
assert!(app.toast_text().is_none());
}
#[test]
fn error_toast_survives_navigation_and_help_toggle() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
app.set_error_toast("backend exploded");
app.apply(Action::MoveDown);
assert_eq!(app.toast_text(), Some("backend exploded"));
app.apply(Action::ToggleHelp);
assert_eq!(app.toast_text(), Some("backend exploded"));
app.apply(Action::Dismiss);
assert!(app.toast_text().is_none());
assert!(app.help_visible());
}
#[test]
fn refresh_action_clears_pre_existing_toast() {
let mut app = AppState::new();
app.set_info_toast("stale info");
app.apply(Action::Refresh);
assert!(app.toast_text().is_none());
app.set_error_toast("stale error");
app.apply(Action::Refresh);
assert!(
app.toast_text().is_none(),
"Refresh must also clear sticky error toasts"
);
}
#[test]
fn noop_action_preserves_toast() {
let mut app = AppState::new();
app.set_info_toast("hi");
app.apply(Action::Noop);
assert_eq!(app.toast_text(), Some("hi"));
}
#[test]
fn page_navigation_is_bounded() {
let mut app = AppState::new();
app.refresh(&three_rows()).unwrap();
app.apply(Action::PageDown);
assert_eq!(app.selected_index(), Some(2));
app.apply(Action::PageUp);
assert_eq!(app.selected_index(), Some(0));
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn five_rows() -> StaticProvider {
StaticProvider(vec![
summary("DATABASE_URL"),
summary("API_KEY"),
summary("DB_HOST"),
summary("NODE_ENV"),
summary("PORT"),
])
}
#[test]
fn start_fuzzy_opens_filter_with_empty_needle() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
assert!(app.is_filter_active());
assert!(app.is_filter_input_active());
assert_eq!(app.filter_needle(), Some(""));
assert_eq!(app.visible_rows().count(), 5);
}
#[test]
fn typing_filter_chars_narrows_visible_rows() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
app.dispatch_key(press(KeyCode::Char('b')));
let visible: Vec<_> = app.visible_rows().map(|v| v.name.clone()).collect();
assert!(visible.contains(&"DATABASE_URL".to_string()));
assert!(visible.contains(&"DB_HOST".to_string()));
assert!(!visible.contains(&"API_KEY".to_string()));
assert!(!visible.contains(&"PORT".to_string()));
assert_eq!(app.filter_needle(), Some("db"));
}
#[test]
fn backspace_pops_needle_and_widens_visible_set() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('x')));
assert_eq!(app.visible_rows().count(), 0);
app.dispatch_key(press(KeyCode::Backspace));
assert_eq!(app.filter_needle(), Some(""));
assert_eq!(app.visible_rows().count(), 5);
}
#[test]
fn enter_commits_filter_input_but_keeps_filter_applied() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('p')));
app.dispatch_key(press(KeyCode::Enter));
assert!(app.is_filter_active());
assert!(!app.is_filter_input_active());
assert!(app.visible_rows().count() < 5);
app.dispatch_key(press(KeyCode::Char('s')));
assert!(app.secrets_visible());
}
#[test]
fn esc_clears_the_filter_entirely() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('p')));
app.dispatch_key(press(KeyCode::Esc));
assert!(!app.is_filter_active());
assert_eq!(app.visible_rows().count(), 5);
}
#[test]
fn selection_clamps_to_visible_count_on_narrow() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveBottom); assert_eq!(app.selected_index(), Some(4));
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
app.dispatch_key(press(KeyCode::Char('b')));
let visible = app.visible_rows().count();
assert!(visible <= 2);
assert!(app.selected_index().is_some_and(|i| i < visible));
}
#[test]
fn selected_row_resolves_through_filter() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('a')));
app.dispatch_key(press(KeyCode::Char('p')));
let selected = app.selected_row().expect("a row should be selected");
assert_eq!(selected.name, "API_KEY");
}
#[test]
fn ctrl_c_still_quits_while_filter_input_active() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.dispatch_key(ctrl_c);
assert!(app.quit_requested());
}
#[test]
fn refresh_request_is_signalled_when_filter_is_off() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
let outcome = app.dispatch_key(press(KeyCode::Char('r')));
assert!(matches!(outcome, DispatchOutcome::RefreshRequested));
}
#[test]
fn dismiss_closes_an_active_filter_before_overlay_or_quit() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
app.dispatch_key(press(KeyCode::Enter));
assert!(app.is_filter_active());
app.apply(Action::Dismiss);
assert!(!app.is_filter_active());
assert!(!app.quit_requested());
app.apply(Action::Dismiss);
assert!(app.quit_requested());
}
#[test]
fn typing_in_filter_input_clears_pre_existing_info_toast() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.set_info_toast("refreshed (5 vars)");
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
assert!(app.toast_text().is_none());
}
#[test]
fn typing_in_filter_input_preserves_error_toasts() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.set_error_toast("backend failure");
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
assert_eq!(app.toast_text(), Some("backend failure"));
}
#[test]
fn open_detail_switches_view_when_a_row_is_selected() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
assert_eq!(app.current_view(), View::Dashboard);
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Detail);
}
#[test]
fn open_detail_on_empty_dashboard_keeps_view_and_toasts() {
let mut app = AppState::new();
app.refresh(&StaticProvider(Vec::new())).unwrap();
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Dashboard);
assert_eq!(app.toast_text(), Some("no row selected"));
}
#[test]
fn dismiss_returns_from_detail_to_dashboard() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Detail);
app.apply(Action::Dismiss);
assert_eq!(app.current_view(), View::Dashboard);
assert!(!app.quit_requested());
}
#[test]
fn detail_view_survives_secret_toggle_and_help_open() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::OpenDetail);
app.apply(Action::ToggleSecretVisibility);
assert_eq!(app.current_view(), View::Detail);
assert!(app.secrets_visible());
app.apply(Action::ToggleHelp);
assert!(app.help_visible());
assert_eq!(app.current_view(), View::Detail);
}
#[test]
fn detail_row_resolves_by_identity_after_row_reorder() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown);
let target_id = app.selected_row().expect("selection").id;
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Detail);
assert_eq!(
app.detail_row().map(|v| v.id),
Some(target_id),
"Detail must resolve to the originally inspected var"
);
let reversed_rows: Vec<VarSummary> = {
let mut tmp = app.rows().to_vec();
tmp.reverse();
tmp
};
app.refresh(&StaticProvider(reversed_rows)).unwrap();
assert_eq!(app.current_view(), View::Detail);
assert_eq!(
app.detail_row().map(|v| v.id),
Some(target_id),
"Detail target must follow identity through a row reorder"
);
}
#[test]
fn refresh_returns_from_detail_when_inspected_var_is_gone() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown); let target_id = app.selected_row().expect("selection").id;
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Detail);
let surviving: Vec<VarSummary> = app
.rows()
.iter()
.filter(|v| v.id != target_id)
.cloned()
.collect();
app.refresh(&StaticProvider(surviving)).unwrap();
assert_eq!(
app.current_view(),
View::Dashboard,
"must auto-return to dashboard when the inspected var disappears"
);
assert!(
app.toast_text()
.is_some_and(|t| t.contains("removed elsewhere")),
"must surface a loud error toast"
);
assert!(app.toast_is_error());
}
#[test]
fn open_detail_does_not_clobber_sticky_error_toast() {
let mut app = AppState::new();
app.refresh(&StaticProvider(Vec::new())).unwrap();
app.set_error_toast("backend failure");
app.apply(Action::OpenDetail);
assert_eq!(app.toast_text(), Some("backend failure"));
assert!(app.toast_is_error());
assert_eq!(app.current_view(), View::Dashboard);
}
#[test]
fn return_to_dashboard_clears_detail_target() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::OpenDetail);
assert!(app.detail_row().is_some());
app.apply(Action::Dismiss);
assert_eq!(app.current_view(), View::Dashboard);
app.apply(Action::MoveDown);
let new_target = app.selected_row().expect("selection").id;
app.apply(Action::OpenDetail);
assert_eq!(app.detail_row().map(|v| v.id), Some(new_target));
}
#[test]
fn dismiss_cascade_priority_is_toast_filter_view_overlay() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::ToggleHelp);
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('a')));
app.dispatch_key(press(KeyCode::Enter));
app.apply(Action::OpenDetail);
app.set_error_toast("scratch");
app.apply(Action::Dismiss);
assert!(app.toast_text().is_none());
assert!(app.is_filter_active());
app.apply(Action::Dismiss);
assert!(!app.is_filter_active());
assert_eq!(app.current_view(), View::Detail);
app.apply(Action::Dismiss);
assert_eq!(app.current_view(), View::Dashboard);
assert!(app.help_visible());
app.apply(Action::Dismiss);
assert!(!app.help_visible());
app.apply(Action::Dismiss);
assert!(app.quit_requested());
}
#[test]
fn delete_action_opens_confirm_modal_for_selected_row() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
assert!(!app.is_confirm_visible());
app.apply(Action::MoveDown); let target_id = app.selected_row().expect("selection").id;
app.apply(Action::DeleteVar);
assert!(app.is_confirm_visible());
let req = app.current_confirm().expect("confirm set");
assert!(req.body.contains("API_KEY"));
match &req.action {
PendingAction::DeleteVar { id, name } => {
assert_eq!(*id, target_id);
assert_eq!(name, "API_KEY");
}
}
}
#[test]
fn delete_action_on_empty_dashboard_does_not_open_modal() {
let mut app = AppState::new();
app.refresh(&StaticProvider(Vec::new())).unwrap();
app.apply(Action::DeleteVar);
assert!(!app.is_confirm_visible());
assert_eq!(app.toast_text(), Some("no row selected"));
}
#[test]
fn delete_action_on_detail_view_targets_inspected_var() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown);
let target_id = app.selected_row().expect("selection").id;
app.apply(Action::OpenDetail);
app.apply(Action::DeleteVar);
assert!(app.is_confirm_visible());
let req = app.current_confirm().expect("confirm set");
match &req.action {
PendingAction::DeleteVar { id, .. } => assert_eq!(*id, target_id),
}
}
#[test]
fn confirm_modal_steals_focus_from_filter_and_actions() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
assert!(app.is_confirm_visible());
let s = press(KeyCode::Char('s'));
let outcome = app.dispatch_key(s);
assert!(matches!(outcome, DispatchOutcome::Continue));
assert!(!app.secrets_visible(), "modal must steal focus from `s`");
assert!(app.is_confirm_visible());
let down = press(KeyCode::Down);
app.dispatch_key(down);
assert!(app.is_confirm_visible());
}
#[test]
fn modal_n_or_esc_cancels_without_side_effects() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
let outcome = app.dispatch_key(press(KeyCode::Char('n')));
assert!(matches!(outcome, DispatchOutcome::Continue));
assert!(!app.is_confirm_visible());
app.apply(Action::DeleteVar);
let outcome = app.dispatch_key(press(KeyCode::Esc));
assert!(matches!(outcome, DispatchOutcome::Continue));
assert!(!app.is_confirm_visible());
}
#[test]
fn modal_y_emits_delete_requested_with_id_and_name() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown); let target_id = app.selected_row().expect("selection").id;
app.apply(Action::DeleteVar);
let outcome = app.dispatch_key(press(KeyCode::Char('y')));
assert!(!app.is_confirm_visible(), "modal must clear after accept");
match outcome {
DispatchOutcome::DeleteRequested { id, name } => {
assert_eq!(id, target_id);
assert_eq!(name, "API_KEY");
}
other => panic!("expected DeleteRequested, got {other:?}"),
}
}
#[test]
fn modal_enter_also_accepts() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
let outcome = app.dispatch_key(press(KeyCode::Enter));
assert!(!app.is_confirm_visible());
assert!(matches!(outcome, DispatchOutcome::DeleteRequested { .. }));
}
#[test]
fn ctrl_c_quits_even_with_modal_focused() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.dispatch_key(ctrl_c);
assert!(app.quit_requested());
}
#[test]
fn modal_plain_c_does_not_quit_or_dismiss() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
app.dispatch_key(press(KeyCode::Char('c')));
assert!(app.is_confirm_visible());
assert!(!app.quit_requested());
}
#[test]
fn modal_reject_also_closes_help_overlay() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::ToggleHelp);
assert!(app.help_visible());
app.apply(Action::DeleteVar);
assert!(app.is_confirm_visible());
app.dispatch_key(press(KeyCode::Esc));
assert!(!app.is_confirm_visible());
assert!(!app.help_visible(), "Esc cascade must close help too");
}
#[test]
fn splice_out_row_removes_local_entry_and_rebuilds_filter() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown);
let target_id = app.selected_row().expect("selection").id;
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('a')));
let before = app.visible_rows().count();
assert!(before >= 1);
app.splice_out_row(target_id);
assert!(app.rows().iter().all(|v| v.id != target_id));
assert!(app.visible_rows().count() < before);
}
#[test]
fn splice_out_row_on_inspected_var_returns_to_dashboard() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::MoveDown);
let target_id = app.selected_row().expect("selection").id;
app.apply(Action::OpenDetail);
assert_eq!(app.current_view(), View::Detail);
app.splice_out_row(target_id);
assert_eq!(
app.current_view(),
View::Dashboard,
"splice of the inspected var must return to dashboard"
);
assert!(app.detail_row().is_none());
}
#[test]
fn splice_out_row_on_unknown_id_is_a_noop() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
let before = app.rows().len();
let bogus = VarId::new_v4();
app.splice_out_row(bogus);
assert_eq!(app.rows().len(), before);
}
#[test]
fn unknown_keys_keep_modal_focused() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::DeleteVar);
app.dispatch_key(press(KeyCode::Char('q')));
assert!(app.is_confirm_visible());
assert!(!app.quit_requested());
}
#[test]
fn refresh_rebuilds_filter_against_new_rows() {
let mut app = AppState::new();
app.refresh(&five_rows()).unwrap();
app.apply(Action::StartFuzzy);
app.dispatch_key(press(KeyCode::Char('d')));
let before = app.visible_rows().count();
let shrunk = StaticProvider(vec![summary("DATABASE_URL")]);
app.refresh(&shrunk).unwrap();
assert!(app.is_filter_active());
assert_eq!(app.filter_needle(), Some("d"));
let after = app.visible_rows().count();
assert!(after <= before);
}
}