Implement the password-store tree sidebar

This commit is contained in:
Hermes Agent
2026-08-10 06:07:05 +00:00
parent 42e6a82b2d
commit 91f58bae3a
7 changed files with 1097 additions and 114 deletions

View File

@@ -2,7 +2,15 @@
use std::collections::BTreeSet;
use crate::action::Action;
use ironstorage::{
config::Config,
read::{FindResults, TreeModel},
};
use crate::{
action::Action,
sidebar::{Sidebar, SidebarIntent},
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Mode {
@@ -41,15 +49,16 @@ pub struct RequestToken {
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupSummary {
pub configuration: String,
pub vault: String,
pub struct StartupData {
pub config: Config,
pub tree: TreeModel,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AsyncPayload {
Startup(StartupSummary),
Refreshed,
Startup(StartupData),
Refreshed(TreeModel),
Filtered { query: String, results: FindResults },
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -64,13 +73,21 @@ pub enum ResultDisposition {
Stale,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppEffect {
None,
RefreshTree,
}
#[derive(Debug)]
pub struct App {
mode: Mode,
suspended_mode: Option<Mode>,
focus: PaneFocus,
status: String,
startup: Option<StartupSummary>,
config: Option<Config>,
sidebar: Sidebar,
selected_entry: Option<String>,
terminal_size: (u16, u16),
ticks: u64,
should_quit: bool,
@@ -92,7 +109,9 @@ impl App {
suspended_mode: None,
focus: PaneFocus::Sidebar,
status: "Starting…".to_owned(),
startup: None,
config: None,
sidebar: Sidebar::default(),
selected_entry: None,
terminal_size: (0, 0),
ticks: 0,
should_quit: false,
@@ -114,8 +133,20 @@ impl App {
&self.status
}
pub fn startup(&self) -> Option<&StartupSummary> {
self.startup.as_ref()
pub fn config(&self) -> Option<&Config> {
self.config.as_ref()
}
pub fn sidebar(&self) -> &Sidebar {
&self.sidebar
}
pub fn sidebar_mut(&mut self) -> &mut Sidebar {
&mut self.sidebar
}
pub fn selected_entry(&self) -> Option<&str> {
self.selected_entry.as_deref()
}
pub fn terminal_size(&self) -> (u16, u16) {
@@ -136,6 +167,14 @@ impl App {
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height);
let content_height = height.saturating_sub(3) as usize;
let sidebar_height = if width < 80 {
content_height.saturating_mul(40) / 100
} else {
content_height
};
self.sidebar
.set_viewport_height(sidebar_height.saturating_sub(2));
}
pub fn tick(&mut self) {
@@ -152,22 +191,37 @@ impl App {
token
}
pub fn begin_latest_request(&mut self) -> RequestToken {
self.invalidate_requests();
self.begin_request()
}
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
return ResultDisposition::Stale;
}
match result.payload {
Ok(AsyncPayload::Startup(summary)) => {
self.status = format!("Vault: {}", summary.vault);
self.startup = Some(summary);
Ok(AsyncPayload::Startup(startup)) => {
self.status = format!("Vault: {}", startup.config.vault().display());
self.sidebar.replace_tree(&startup.tree);
self.config = Some(startup.config);
}
Ok(AsyncPayload::Refreshed(tree)) => {
self.sidebar.replace_tree(&tree);
self.status = "Password store refreshed".to_owned();
}
Ok(AsyncPayload::Filtered { query, results }) => {
if !self.sidebar.apply_filter_results(&query, &results) {
return ResultDisposition::Stale;
}
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
}
Ok(AsyncPayload::Refreshed) => self.status = "Password store refreshed".to_owned(),
Err(error) => self.status = error,
}
ResultDisposition::Applied
}
pub fn dispatch(&mut self, action: Action) {
pub fn dispatch(&mut self, action: Action) -> AppEffect {
match action {
Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => {
self.should_quit = true;
@@ -184,6 +238,24 @@ impl App {
Action::Lock => {
self.transition(Transition::Lock);
}
Action::Refresh => return AppEffect::RefreshTree,
Action::Next => self.sidebar.move_next(),
Action::Previous => self.sidebar.move_previous(),
Action::PageDown => self.sidebar.page_down(),
Action::PageUp => self.sidebar.page_up(),
Action::First => self.sidebar.move_first(),
Action::Last => self.sidebar.move_last(),
Action::Parent => self.sidebar.collapse_or_parent(),
Action::Child => self.sidebar.move_child(),
Action::Activate => {
if let SidebarIntent::OpenEntry(path) = self.sidebar.activate() {
self.selected_entry = Some(path);
self.transition(Transition::OpenEntry);
}
}
Action::Filter => self.sidebar.begin_filter(),
Action::NextMatch => self.sidebar.next_match(),
Action::PreviousMatch => self.sidebar.previous_match(),
Action::FocusNext | Action::FocusPrevious => {
self.focus = match self.focus {
PaneFocus::Sidebar => PaneFocus::Main,
@@ -192,6 +264,7 @@ impl App {
}
Action::Quit => {}
}
AppEffect::None
}
pub fn transition(&mut self, transition: Transition) -> bool {
@@ -220,11 +293,13 @@ impl App {
};
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
self.suspended_mode = Some(current);
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
self.selected_entry = None;
} else if destination == Mode::Locked {
self.suspended_mode = None;
self.focus = PaneFocus::Sidebar;
self.invalidate_requests();
self.startup = None;
self.selected_entry = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
@@ -243,13 +318,6 @@ impl App {
mod tests {
use super::*;
fn success(token: RequestToken, payload: AsyncPayload) -> AsyncResult {
AsyncResult {
token,
payload: Ok(payload),
}
}
#[test]
fn every_legal_transition_reaches_its_destination() {
let cases = [
@@ -299,14 +367,20 @@ mod tests {
fn stale_and_duplicate_async_results_are_rejected() {
let mut app = App::new();
let token = app.begin_request();
let result = success(token, AsyncPayload::Refreshed);
let result = AsyncResult {
token,
payload: Err("completed".to_owned()),
};
assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied);
assert_eq!(app.apply_result(result), ResultDisposition::Stale);
let stale = app.begin_request();
assert!(app.transition(Transition::Lock));
assert_eq!(
app.apply_result(success(stale, AsyncPayload::Refreshed)),
app.apply_result(AsyncResult {
token: stale,
payload: Err("stale".to_owned()),
}),
ResultDisposition::Stale
);
assert_eq!(app.status(), "Locked");
@@ -316,16 +390,14 @@ mod tests {
fn storage_results_are_applied_without_domain_inference() {
let mut app = App::new();
let token = app.begin_request();
let summary = StartupSummary {
configuration: "/config.toml".to_owned(),
vault: "/vault".to_owned(),
};
assert_eq!(
app.apply_result(success(token, AsyncPayload::Startup(summary.clone()))),
app.apply_result(AsyncResult {
token,
payload: Err("typed storage failure".to_owned()),
}),
ResultDisposition::Applied
);
assert_eq!(app.startup(), Some(&summary));
assert_eq!(app.status(), "Vault: /vault");
assert_eq!(app.status(), "typed storage failure");
}
#[test]