//! 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 apply_find_results(&mut self, query: &str, results: &FindResults) { self.filter_query = query.to_owned(); self.editing_filter = false; let _applied = self.apply_filter_results(query, results); } 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 select_path(&mut self, path: &str, directory: bool) -> bool { let target = collect_ids(&self.full) .into_iter() .find(|id| id.path() == path && id.is_directory() == directory); let Some(target) = target else { return false; }; if !path.is_empty() { let mut prefix = String::new(); for component in path .split('/') .take(path.split('/').count().saturating_sub(1)) { if !prefix.is_empty() { prefix.push('/'); } prefix.push_str(component); self.expanded.insert(NodeId { path: prefix.clone(), directory: true, }); } } self.selected = Some(target); self.ensure_selected_visible(); true } pub fn completion_paths(&self) -> Vec { fn visit(nodes: &[SidebarNode], paths: &mut Vec) { for node in nodes { paths.push(node.id.path.clone()); visit(&node.children, paths); } } let mut paths = Vec::new(); visit(&self.full, &mut paths); paths } 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 command_selection_expands_ancestors_and_completion_uses_the_full_tree() { let mut sidebar = sidebar(); assert!(sidebar.select_path("personal", true)); assert!(sidebar.select_path("personal/咖啡", false)); assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); assert!( sidebar .visible_rows() .iter() .any(|row| row.id.path() == "personal/咖啡") ); assert_eq!( sidebar.completion_paths(), vec![ "personal", "personal/email", "personal/咖啡", "work", "work/server" ] ); assert!(!sidebar.select_path("personal/咖啡", true)); } #[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); } }