diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 94c1282..5f649ad 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -11,6 +11,19 @@ pub enum Action { Command, Cancel, Lock, + Refresh, + Next, + Previous, + PageDown, + PageUp, + First, + Last, + Parent, + Child, + Activate, + Filter, + NextMatch, + PreviousMatch, FocusNext, FocusPrevious, } @@ -27,7 +40,7 @@ pub struct ActionSpec { pub action: Action, pub label: &'static str, pub command: &'static str, - pub binding: KeyBinding, + pub bindings: &'static [KeyBinding], modes: &'static [Mode], } @@ -51,82 +64,163 @@ const ALL: &[Mode] = &[ Mode::Locked, ]; +macro_rules! keys { + ($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => { + &[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+] + }; +} + pub static ACTIONS: &[ActionSpec] = &[ ActionSpec { action: Action::Quit, label: "quit", command: "quit", - binding: KeyBinding { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - display: "q", - }, + bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::Help, label: "help", command: "help", - binding: KeyBinding { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - display: "?", - }, + bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")), modes: UNLOCKED, }, ActionSpec { action: Action::Command, label: "command", command: "command", - binding: KeyBinding { - code: KeyCode::Char(':'), - modifiers: KeyModifiers::NONE, - display: ":", - }, + bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")), modes: UNLOCKED, }, ActionSpec { action: Action::Cancel, label: "back", command: "cancel", - binding: KeyBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - display: "Esc", - }, + bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), modes: OVERLAYS, }, ActionSpec { action: Action::Lock, label: "lock", command: "lock", - binding: KeyBinding { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::CONTROL, - display: "C-l", - }, + bindings: keys!((KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l")), modes: UNLOCKED, }, + ActionSpec { + action: Action::Refresh, + label: "refresh", + command: "refresh", + bindings: keys!((KeyCode::Char('r'), KeyModifiers::NONE, "r")), + modes: BROWSER_LIKE, + }, + ActionSpec { + action: Action::Next, + label: "next", + command: "next", + bindings: keys!( + (KeyCode::Char('j'), KeyModifiers::NONE, "j"), + (KeyCode::Down, KeyModifiers::NONE, "↓"), + ), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Previous, + label: "previous", + command: "previous", + bindings: keys!( + (KeyCode::Char('k'), KeyModifiers::NONE, "k"), + (KeyCode::Up, KeyModifiers::NONE, "↑"), + ), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::PageDown, + label: "page down", + command: "page-down", + bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::PageUp, + label: "page up", + command: "page-up", + bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::First, + label: "first", + command: "first", + bindings: keys!((KeyCode::Home, KeyModifiers::NONE, "Home")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Last, + label: "last", + command: "last", + bindings: keys!((KeyCode::End, KeyModifiers::NONE, "End")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Parent, + label: "parent", + command: "parent", + bindings: keys!( + (KeyCode::Char('h'), KeyModifiers::NONE, "h"), + (KeyCode::Left, KeyModifiers::NONE, "←"), + ), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Child, + label: "child", + command: "child", + bindings: keys!( + (KeyCode::Char('l'), KeyModifiers::NONE, "l"), + (KeyCode::Right, KeyModifiers::NONE, "→"), + ), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Activate, + label: "open/toggle", + command: "open", + bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Filter, + label: "filter", + command: "filter", + bindings: keys!((KeyCode::Char('/'), KeyModifiers::NONE, "/")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::NextMatch, + label: "next match", + command: "next-match", + bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::PreviousMatch, + label: "previous match", + command: "previous-match", + bindings: keys!((KeyCode::Char('N'), KeyModifiers::SHIFT, "N")), + modes: &[Mode::Browser], + }, ActionSpec { action: Action::FocusNext, label: "next pane", command: "focus-next", - binding: KeyBinding { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - display: "Tab", - }, + bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")), modes: UNLOCKED, }, ActionSpec { action: Action::FocusPrevious, label: "previous pane", command: "focus-previous", - binding: KeyBinding { - code: KeyCode::BackTab, - modifiers: KeyModifiers::SHIFT, - display: "S-Tab", - }, + bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")), modes: UNLOCKED, }, ]; @@ -136,8 +230,10 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option .iter() .find(|spec| { spec.is_available(mode) - && spec.binding.code == code - && spec.binding.modifiers == modifiers + && spec + .bindings + .iter() + .any(|binding| binding.code == code && binding.modifiers == modifiers) }) .map(|spec| spec.action) } @@ -160,7 +256,11 @@ mod tests { assert_ne!(action.command, other.command); for mode in ALL { if action.is_available(*mode) && other.is_available(*mode) { - assert_ne!(action.binding, other.binding); + for binding in action.bindings { + for other_binding in other.bindings { + assert_ne!(binding, other_binding); + } + } } } } @@ -171,10 +271,12 @@ mod tests { fn resolution_and_help_consume_the_same_registry() { for mode in ALL { for spec in available_actions(*mode) { - assert_eq!( - resolve_key(*mode, spec.binding.code, spec.binding.modifiers), - Some(spec.action) - ); + for binding in spec.bindings { + assert_eq!( + resolve_key(*mode, binding.code, binding.modifiers), + Some(spec.action) + ); + } } } assert_eq!( diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 7ee8b3d..7860d29 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -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, focus: PaneFocus, status: String, - startup: Option, + config: Option, + sidebar: Sidebar, + selected_entry: Option, 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] diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index b19a18a..56244da 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -6,6 +6,7 @@ pub mod action; pub mod app; pub mod runtime; +pub mod sidebar; pub mod terminal; pub mod ui; @@ -16,7 +17,7 @@ use ratatui::DefaultTerminal; use crate::{ action::resolve_key, - app::{App, AsyncPayload, StartupSummary}, + app::{App, AppEffect, AsyncPayload, StartupData}, runtime::AsyncExecutor, }; @@ -27,23 +28,16 @@ const TICK_INTERVAL: Duration = Duration::from_millis(250); pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut app = App::new(); let executor = AsyncExecutor::new(); - let startup = app.begin_request(); - executor.submit(startup, || { - ironstorage::config::Config::load(None) - .map(|config| { - AsyncPayload::Startup(StartupSummary { - configuration: config.source().display().to_string(), - vault: config.vault().display().to_string(), - }) - }) - .map_err(|error| error.to_string()) - }); + let startup = app.begin_latest_request(); + executor.submit(startup, || load_startup().map(AsyncPayload::Startup)); while !app.should_quit() { for result in executor.drain() { app.apply_result(result); } + let size = terminal.size()?; + app.resize(size.width, size.height); terminal.draw(|frame| ui::draw(frame, &app))?; if !event::poll(TICK_INTERVAL)? { app.tick(); @@ -52,8 +46,18 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { - if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { - app.dispatch(action); + if app.sidebar().is_editing_filter() { + if handle_filter_key(&mut app, key.code) { + submit_filter(&mut app, &executor); + } + } else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) + && app.dispatch(action) == AppEffect::RefreshTree + && let Some(config) = app.config().cloned() + { + let token = app.begin_latest_request(); + executor.submit(token, move || { + load_tree(&config).map(AsyncPayload::Refreshed) + }); } } Event::Resize(width, height) => app.resize(width, height), @@ -62,3 +66,58 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { } Ok(()) } + +fn load_startup() -> Result { + let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; + let tree = load_tree(&config)?; + Ok(StartupData { config, tree }) +} + +fn load_tree(config: &ironstorage::config::Config) -> Result { + let repository = ironstorage::repository::Repository::open(config.vault()) + .map_err(|error| error.to_string())?; + let keys = ironstorage::crypto::KeyStore::load(config.key_material()) + .map_err(|error| error.to_string())?; + ironstorage::read::VaultReader::new(&repository, &keys) + .list(&ironstorage::repository::DirectoryPath::root()) + .map_err(|error| error.to_string()) +} + +fn submit_filter(app: &mut App, executor: &AsyncExecutor) { + let query = app.sidebar().filter_query().to_owned(); + if query.is_empty() { + app.sidebar_mut().reset_filter_results(); + return; + } + let Some(config) = app.config().cloned() else { + return; + }; + let token = app.begin_latest_request(); + executor.submit(token, move || { + let repository = ironstorage::repository::Repository::open(config.vault()) + .map_err(|error| error.to_string())?; + let keys = ironstorage::crypto::KeyStore::load(config.key_material()) + .map_err(|error| error.to_string())?; + let results = ironstorage::read::VaultReader::new(&repository, &keys) + .find(std::slice::from_ref(&query)) + .map_err(|error| error.to_string())?; + Ok(AsyncPayload::Filtered { query, results }) + }); +} + +fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool { + use crossterm::event::KeyCode; + match code { + KeyCode::Esc => { + app.sidebar_mut().clear_filter_results(); + false + } + KeyCode::Enter => { + app.sidebar_mut().finish_filter(); + false + } + KeyCode::Backspace => app.sidebar_mut().pop_filter_character(), + KeyCode::Char(character) => app.sidebar_mut().push_filter_character(character), + _ => false, + } +} diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 51fce84..3206150 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -54,7 +54,7 @@ mod tests { let executor = AsyncExecutor::new(); let mut app = App::new(); let token = app.begin_request(); - executor.submit(token, || Ok(AsyncPayload::Refreshed)); + executor.submit(token, || Err("test result".to_owned())); let result = executor .receiver diff --git a/apps/tui/src/sidebar.rs b/apps/tui/src/sidebar.rs new file mode 100644 index 0000000..e25900b --- /dev/null +++ b/apps/tui/src/sidebar.rs @@ -0,0 +1,611 @@ +//! Presentation state for a storage-provided password-store tree. + +use std::collections::BTreeSet; + +use ironstorage::read::{FindResults, TreeModel, TreeNode, TreeNodeIndicators, TreeNodeKind}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct NodeId { + path: String, + directory: bool, +} + +impl NodeId { + pub fn path(&self) -> &str { + &self.path + } + + pub fn is_directory(&self) -> bool { + self.directory + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SidebarNode { + id: NodeId, + name: String, + indicators: TreeNodeIndicators, + children: Vec, +} + +impl SidebarNode { + fn from_storage(node: &TreeNode) -> Self { + Self { + id: NodeId { + path: node.path().to_owned(), + directory: node.kind() == TreeNodeKind::Directory, + }, + name: node.name().to_owned(), + indicators: node.indicators(), + children: node.children().iter().map(Self::from_storage).collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VisibleRow { + pub id: NodeId, + pub name: String, + pub depth: usize, + pub indicators: TreeNodeIndicators, + pub expanded: bool, + pub has_children: bool, + parent: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SidebarIntent { + None, + OpenEntry(String), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Sidebar { + full: Vec, + filtered: Option>, + filtered_matches: BTreeSet, + expanded: BTreeSet, + selected: Option, + scroll: usize, + viewport_height: usize, + filter_query: String, + editing_filter: bool, +} + +impl Sidebar { + pub fn replace_tree(&mut self, model: &TreeModel) { + let previous_index = self.selected_index().unwrap_or(0); + self.full = model + .children() + .iter() + .map(SidebarNode::from_storage) + .collect(); + self.filtered = None; + self.filtered_matches.clear(); + self.filter_query.clear(); + self.editing_filter = false; + self.retain_valid_state(previous_index); + } + + pub fn apply_filter_results(&mut self, query: &str, results: &FindResults) -> bool { + if query != self.filter_query { + return false; + } + let previous_index = self.selected_index().unwrap_or(0); + self.filtered = Some( + results + .tree() + .children() + .iter() + .map(SidebarNode::from_storage) + .collect(), + ); + self.filtered_matches = results + .matches() + .iter() + .map(|matched| NodeId { + path: matched.path().to_owned(), + directory: matched.kind() == TreeNodeKind::Directory, + }) + .collect(); + self.retain_valid_state(previous_index); + true + } + + pub fn clear_filter_results(&mut self) { + let previous_index = self.selected_index().unwrap_or(0); + self.filter_query.clear(); + self.filtered = None; + self.filtered_matches.clear(); + self.editing_filter = false; + self.retain_valid_state(previous_index); + } + + pub fn reset_filter_results(&mut self) { + let previous_index = self.selected_index().unwrap_or(0); + self.filtered = None; + self.filtered_matches.clear(); + self.retain_valid_state(previous_index); + } + + pub fn begin_filter(&mut self) { + self.editing_filter = true; + } + + pub fn finish_filter(&mut self) { + self.editing_filter = false; + } + + pub fn is_editing_filter(&self) -> bool { + self.editing_filter + } + + pub fn filter_query(&self) -> &str { + &self.filter_query + } + + pub fn push_filter_character(&mut self, character: char) -> bool { + if character.is_control() { + return false; + } + self.filter_query.push(character); + true + } + + pub fn pop_filter_character(&mut self) -> bool { + self.filter_query.pop().is_some() + } + + pub fn selected(&self) -> Option<&NodeId> { + self.selected.as_ref() + } + + pub fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height.max(1); + self.ensure_selected_visible(); + } + + pub fn visible_rows(&self) -> Vec { + let mut rows = Vec::new(); + let filtering = !self.filter_query.is_empty(); + flatten( + self.active_nodes(), + 0, + None, + &self.expanded, + filtering, + &mut rows, + ); + rows + } + + pub fn visible_window(&self) -> Vec { + self.visible_rows() + .into_iter() + .skip(self.scroll) + .take(self.viewport_height.max(1)) + .collect() + } + + pub fn move_next(&mut self) { + self.move_by(1); + } + + pub fn move_previous(&mut self) { + self.move_by(-1); + } + + pub fn page_down(&mut self) { + self.move_by(self.viewport_height.max(1) as isize); + } + + pub fn page_up(&mut self) { + self.move_by(-(self.viewport_height.max(1) as isize)); + } + + pub fn move_first(&mut self) { + self.select_index(0); + } + + pub fn move_last(&mut self) { + let rows = self.visible_rows(); + if !rows.is_empty() { + self.select_index(rows.len() - 1); + } + } + + pub fn move_parent(&mut self) { + let rows = self.visible_rows(); + let Some(index) = selected_index_in(&rows, self.selected.as_ref()) else { + return; + }; + if let Some(parent) = &rows[index].parent { + self.selected = Some(parent.clone()); + self.ensure_selected_visible(); + } + } + + pub fn move_child(&mut self) { + let rows = self.visible_rows(); + let Some(index) = selected_index_in(&rows, self.selected.as_ref()) else { + return; + }; + let row = &rows[index]; + if !row.id.is_directory() || !row.has_children { + return; + } + self.expanded.insert(row.id.clone()); + let rows = self.visible_rows(); + if let Some(child) = rows.get(index + 1).filter(|child| child.depth > row.depth) { + self.selected = Some(child.id.clone()); + self.ensure_selected_visible(); + } + } + + pub fn collapse_or_parent(&mut self) { + let Some(selected) = self.selected.clone() else { + return; + }; + if selected.is_directory() && self.expanded.remove(&selected) { + self.ensure_selected_visible(); + } else { + self.move_parent(); + } + } + + pub fn activate(&mut self) -> SidebarIntent { + let Some(selected) = self.selected.clone() else { + return SidebarIntent::None; + }; + if selected.is_directory() { + if !self.expanded.remove(&selected) { + self.expanded.insert(selected); + } + self.ensure_selected_visible(); + SidebarIntent::None + } else { + SidebarIntent::OpenEntry(selected.path) + } + } + + pub fn next_match(&mut self) { + if self.filter_query.is_empty() { + return; + } + let matches = self.match_rows(); + if matches.is_empty() { + return; + } + let next = matches + .iter() + .position(|row| Some(&row.id) == self.selected.as_ref()) + .map_or(0, |index| (index + 1) % matches.len()); + self.selected = Some(matches[next].id.clone()); + self.ensure_selected_visible(); + } + + pub fn previous_match(&mut self) { + if self.filter_query.is_empty() { + return; + } + let matches = self.match_rows(); + if matches.is_empty() { + return; + } + let previous = matches + .iter() + .position(|row| Some(&row.id) == self.selected.as_ref()) + .map_or(0, |index| index.checked_sub(1).unwrap_or(matches.len() - 1)); + self.selected = Some(matches[previous].id.clone()); + self.ensure_selected_visible(); + } + + fn active_nodes(&self) -> &[SidebarNode] { + self.filtered.as_deref().unwrap_or(&self.full) + } + + fn selected_index(&self) -> Option { + selected_index_in(&self.visible_rows(), self.selected.as_ref()) + } + + fn match_rows(&self) -> Vec { + self.visible_rows() + .into_iter() + .filter(|row| self.filtered_matches.contains(&row.id)) + .collect() + } + + fn move_by(&mut self, amount: isize) { + let rows = self.visible_rows(); + if rows.is_empty() { + self.selected = None; + self.scroll = 0; + return; + } + let current = selected_index_in(&rows, self.selected.as_ref()).unwrap_or(0); + let destination = current.saturating_add_signed(amount).min(rows.len() - 1); + self.select_index(destination); + } + + fn select_index(&mut self, index: usize) { + if let Some(row) = self.visible_rows().get(index) { + self.selected = Some(row.id.clone()); + self.ensure_selected_visible(); + } + } + + fn ensure_selected_visible(&mut self) { + let rows = self.visible_rows(); + let Some(index) = selected_index_in(&rows, self.selected.as_ref()) else { + self.scroll = self.scroll.min(rows.len().saturating_sub(1)); + return; + }; + if index < self.scroll { + self.scroll = index; + } else if index >= self.scroll + self.viewport_height.max(1) { + self.scroll = index + 1 - self.viewport_height.max(1); + } + } + + fn retain_valid_state(&mut self, previous_index: usize) { + let all_ids = collect_ids(&self.full); + self.expanded.retain(|id| all_ids.contains(id)); + let rows = self.visible_rows(); + if !rows + .iter() + .any(|row| Some(&row.id) == self.selected.as_ref()) + { + self.selected = rows + .get(previous_index.min(rows.len().saturating_sub(1))) + .map(|row| row.id.clone()); + } + self.ensure_selected_visible(); + } + + #[cfg(test)] + pub(crate) fn replace_test_tree(&mut self, nodes: Vec) { + self.full = nodes.into_iter().map(TestTreeNode::into_sidebar).collect(); + self.filtered = None; + self.filtered_matches.clear(); + self.retain_valid_state(0); + } +} + +#[cfg(test)] +pub(crate) struct TestTreeNode { + pub path: String, + pub name: String, + pub directory: bool, + pub indicators: TreeNodeIndicators, + pub children: Vec, +} + +#[cfg(test)] +impl TestTreeNode { + fn into_sidebar(self) -> SidebarNode { + SidebarNode { + id: NodeId { + path: self.path, + directory: self.directory, + }, + name: self.name, + indicators: self.indicators, + children: self.children.into_iter().map(Self::into_sidebar).collect(), + } + } +} + +fn flatten( + nodes: &[SidebarNode], + depth: usize, + parent: Option<&NodeId>, + expanded: &BTreeSet, + force_expanded: bool, + rows: &mut Vec, +) { + for node in nodes { + let is_expanded = force_expanded || expanded.contains(&node.id); + rows.push(VisibleRow { + id: node.id.clone(), + name: node.name.clone(), + depth, + indicators: node.indicators, + expanded: is_expanded, + has_children: !node.children.is_empty(), + parent: parent.cloned(), + }); + if node.id.is_directory() && is_expanded { + flatten( + &node.children, + depth + 1, + Some(&node.id), + expanded, + force_expanded, + rows, + ); + } + } +} + +fn selected_index_in(rows: &[VisibleRow], selected: Option<&NodeId>) -> Option { + let selected = selected?; + rows.iter().position(|row| &row.id == selected) +} + +fn collect_ids(nodes: &[SidebarNode]) -> BTreeSet { + fn visit(nodes: &[SidebarNode], ids: &mut BTreeSet) { + for node in nodes { + ids.insert(node.id.clone()); + visit(&node.children, ids); + } + } + let mut ids = BTreeSet::new(); + visit(nodes, &mut ids); + ids +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(path: &str, directory: bool, children: Vec) -> SidebarNode { + SidebarNode { + id: NodeId { + path: path.to_owned(), + directory, + }, + name: path.rsplit('/').next().unwrap_or(path).to_owned(), + indicators: TreeNodeIndicators::default(), + children, + } + } + + fn sidebar() -> Sidebar { + let mut sidebar = Sidebar { + full: vec![ + node( + "personal", + true, + vec![ + node("personal/email", false, vec![]), + node("personal/咖啡", false, vec![]), + ], + ), + node("work", true, vec![node("work/server", false, vec![])]), + ], + viewport_height: 3, + ..Sidebar::default() + }; + sidebar.retain_valid_state(0); + sidebar + } + + #[test] + fn navigation_is_deterministic_and_directories_are_collapsible() { + let mut sidebar = sidebar(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal")); + sidebar.move_child(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/email")); + sidebar.move_next(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + sidebar.move_parent(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal")); + sidebar.collapse_or_parent(); + assert_eq!(sidebar.visible_rows().len(), 2); + sidebar.move_last(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("work")); + sidebar.move_first(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal")); + } + + #[test] + fn paging_clamps_and_scrolls_large_trees() { + let mut sidebar = Sidebar { + full: (0..1000) + .map(|index| node(&format!("entry-{index:04}"), false, vec![])) + .collect(), + viewport_height: 5, + ..Sidebar::default() + }; + sidebar.retain_valid_state(0); + sidebar.page_down(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("entry-0005")); + assert_eq!(sidebar.scroll, 1); + sidebar.move_last(); + sidebar.page_down(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("entry-0999")); + assert_eq!(sidebar.visible_window().len(), 5); + sidebar.page_up(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("entry-0994")); + } + + #[test] + fn refresh_preserves_valid_state_and_repairs_deleted_selection() { + let mut sidebar = sidebar(); + sidebar.move_child(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/email")); + sidebar.full[0].children.remove(0); + sidebar.retain_valid_state(1); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + assert!( + sidebar + .visible_rows() + .iter() + .any(|row| Some(&row.id) == sidebar.selected()) + ); + } + + #[test] + fn filtering_uses_supplied_results_and_cycles_matches() { + let mut sidebar = sidebar(); + sidebar.begin_filter(); + assert!(sidebar.push_filter_character('咖')); + sidebar.filtered = Some(vec![node( + "personal", + true, + vec![node("personal/咖啡", false, vec![])], + )]); + sidebar.filtered_matches = [NodeId { + path: "personal/咖啡".to_owned(), + directory: false, + }] + .into_iter() + .collect(); + sidebar.retain_valid_state(0); + assert_eq!(sidebar.visible_rows().len(), 2); + sidebar.next_match(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + sidebar.next_match(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + sidebar.previous_match(); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + sidebar.clear_filter_results(); + assert_eq!(sidebar.visible_rows().len(), 2); + } + + #[test] + fn activation_never_opens_a_directory_as_an_entry() { + let mut sidebar = sidebar(); + assert_eq!(sidebar.activate(), SidebarIntent::None); + sidebar.move_next(); + assert_eq!( + sidebar.activate(), + SidebarIntent::OpenEntry("personal/email".to_owned()) + ); + } + + #[test] + fn empty_tree_has_no_stale_selection() { + let mut sidebar = Sidebar::default(); + sidebar.retain_valid_state(0); + sidebar.move_next(); + sidebar.page_down(); + assert_eq!(sidebar.selected(), None); + assert!(sidebar.visible_window().is_empty()); + } + + #[test] + fn deep_trees_and_entry_directory_name_collisions_keep_stable_identities() { + let mut nested = node("depth-128/entry", false, vec![]); + for depth in (0..128).rev() { + nested = node(&format!("depth-{depth}"), true, vec![nested]); + } + let collision_entry = node("shared", false, vec![]); + let collision_directory = node("shared", true, vec![node("shared/child", false, vec![])]); + let mut sidebar = Sidebar { + full: vec![nested, collision_directory, collision_entry], + viewport_height: 10, + filter_query: "force expansion".to_owned(), + ..Sidebar::default() + }; + sidebar.retain_valid_state(0); + let rows = sidebar.visible_rows(); + assert_eq!(rows.iter().map(|row| row.depth).max(), Some(128)); + let collisions = rows + .iter() + .filter(|row| row.id.path() == "shared") + .collect::>(); + assert_eq!(collisions.len(), 2); + assert_ne!(collisions[0].id, collisions[1].id); + } +} diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 681cd0d..0472ce3 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -76,14 +76,21 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { } if app.mode() == Mode::Help { - let lines = crate::action::ACTIONS.iter().map(|spec| { - Line::from(vec![ - Span::styled( - format!("{:>7}", spec.binding.display), + let available_rows = usize::from(area.height.saturating_sub(2)).max(1); + let lines = (0..available_rows).map(|row| { + let mut spans = Vec::new(); + for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) { + let spec = &crate::action::ACTIONS[index]; + spans.push(Span::styled( + format!("{:>6}", spec.bindings[0].display), Style::default().fg(Color::Cyan), - ), - Span::raw(format!(" {:<18} :{}", spec.label, spec.command)), - ]) + )); + spans.push(Span::raw(format!( + " {:<16} :{:<16}", + spec.label, spec.command + ))); + } + Line::from(spans) }); frame.render_widget( Paragraph::new(lines.collect::>()) @@ -112,9 +119,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { }; let panes = Layout::new(direction, constraints).split(area); frame.render_widget( - Paragraph::new(sidebar_text(app)) + Paragraph::new(sidebar_lines(app)) .block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar)) - .wrap(Wrap { trim: true }), + .wrap(Wrap { trim: false }), panes[0], ); frame.render_widget( @@ -142,24 +149,62 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> { .border_style(style) } -fn sidebar_text(app: &App) -> String { - if app.is_busy() { - "Loading password-store tree…".to_owned() - } else if let Some(startup) = app.startup() { - format!("Vault\n{}", startup.vault) - } else { - "No password store is open.".to_owned() +fn sidebar_lines(app: &App) -> Vec> { + let selected = app.sidebar().selected(); + let rows = app.sidebar().visible_window(); + if rows.is_empty() { + if app.is_busy() { + return vec![Line::from("Loading password-store tree…")]; + } + return vec![Line::from("No password entries.")]; } + rows.into_iter() + .map(|row| { + let marker = if row.id.is_directory() { + if row.expanded { "▼" } else { "▶" } + } else { + "•" + }; + let mut indicators = String::new(); + if row.indicators.is_locked() { + indicators.push_str(" L"); + } + if row.indicators.is_changed() { + indicators.push_str(" *"); + } + if row.indicators.has_conflict() { + indicators.push_str(" !"); + } + let style = if selected == Some(&row.id) { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default() + }; + Line::styled( + format!( + "{}{} {}{}", + " ".repeat(row.depth), + marker, + row.name, + indicators + ), + style, + ) + }) + .collect() } -fn main_text(app: &App) -> &'static str { +fn main_text(app: &App) -> String { match app.mode() { - Mode::Browser => "Select an entry from the sidebar.", - Mode::Viewer => "Structured entry viewer", - Mode::Editor => "Structured entry editor", - Mode::Dialog => "Complete or cancel the active dialog.", - Mode::Command => "Enter a command on the bottom line.", - Mode::Help | Mode::Locked => "", + Mode::Browser => "Select an entry from the sidebar.".to_owned(), + Mode::Viewer => app.selected_entry().map_or_else( + || "Structured entry viewer".to_owned(), + |path| format!("Opening {path}…"), + ), + Mode::Editor => "Structured entry editor".to_owned(), + Mode::Dialog => "Complete or cancel the active dialog.".to_owned(), + Mode::Command => "Enter a command on the bottom line.".to_owned(), + Mode::Help | Mode::Locked => String::new(), } } @@ -188,7 +233,7 @@ fn status_line(app: &App) -> Paragraph<'_> { fn context_line(app: &App) -> Paragraph<'static> { let text = available_actions(app.mode()) - .map(|spec| format!("{} {}", spec.binding.display, spec.label)) + .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label)) .collect::>() .join(" "); Paragraph::new(text) @@ -198,6 +243,10 @@ fn prompt_line(app: &App) -> Paragraph<'static> { match app.mode() { Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)), Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)), + _ if app.sidebar().is_editing_filter() => { + Paragraph::new(format!("/{}", app.sidebar().filter_query())) + .style(Style::default().fg(Color::Yellow)) + } _ => Paragraph::new(""), } } @@ -208,6 +257,7 @@ mod tests { use super::*; use crate::app::Transition; + use crate::sidebar::TestTreeNode; fn render(width: u16, height: u16, app: &App) -> String { let backend = TestBackend::new(width, height); @@ -268,4 +318,56 @@ mod tests { assert!(output.contains(spec.command)); } } + + #[test] + fn hierarchy_selection_and_storage_indicators_have_stable_rendering() { + let mut app = App::new(); + app.resize(100, 20); + app.sidebar_mut().replace_test_tree(vec![TestTreeNode { + path: "personal".to_owned(), + name: "personal".to_owned(), + directory: true, + indicators: ironstorage::read::TreeNodeIndicators::default(), + children: vec![TestTreeNode { + path: "personal/咖啡".to_owned(), + name: "咖啡".to_owned(), + directory: false, + indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true), + children: vec![], + }], + }]); + app.sidebar_mut().move_child(); + let output = render(100, 20, &app); + assert!(output.contains("▼ personal")); + assert!(output.contains('咖')); + assert!(output.contains("L * !")); + } + + #[test] + fn scrolling_focus_filter_prompt_and_resize_are_rendered() { + let mut app = App::new(); + app.resize(60, 10); + app.sidebar_mut().replace_test_tree( + (0..20) + .map(|index| TestTreeNode { + path: format!("entry-{index:02}"), + name: format!("entry-{index:02}"), + directory: false, + indicators: ironstorage::read::TreeNodeIndicators::default(), + children: vec![], + }) + .collect(), + ); + app.sidebar_mut().move_last(); + app.sidebar_mut().begin_filter(); + app.sidebar_mut().push_filter_character('咖'); + let narrow = render(60, 10, &app); + assert!(narrow.contains("entry-19")); + assert!(narrow.contains("/咖")); + assert!(!narrow.contains("entry-00")); + + app.dispatch(crate::action::Action::FocusNext); + let wide = render(140, 20, &app); + assert!(wide.contains("Browser")); + } } diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 098819e..102aa40 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -21,11 +21,43 @@ pub enum TreeNodeKind { Entry, } +/// Presentation-safe state calculated by storage services for a tree object. +/// Frontends render these flags and never infer them from names or paths. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TreeNodeIndicators { + locked: bool, + changed: bool, + conflict: bool, +} + +impl TreeNodeIndicators { + pub const fn new(locked: bool, changed: bool, conflict: bool) -> Self { + Self { + locked, + changed, + conflict, + } + } + + pub const fn is_locked(self) -> bool { + self.locked + } + + pub const fn is_changed(self) -> bool { + self.changed + } + + pub const fn has_conflict(self) -> bool { + self.conflict + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct TreeNode { name: String, path: String, kind: TreeNodeKind, + indicators: TreeNodeIndicators, children: Vec, } @@ -42,6 +74,10 @@ impl TreeNode { self.kind } + pub fn indicators(&self) -> TreeNodeIndicators { + self.indicators + } + pub fn children(&self) -> &[TreeNode] { &self.children } @@ -592,6 +628,7 @@ fn finalize_children(node: MutableNode, parent: &Path) -> Result, name, path: path_text(&path)?, kind, + indicators: TreeNodeIndicators::default(), children, }) })