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

611
apps/tui/src/sidebar.rs Normal file
View File

@@ -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<SidebarNode>,
}
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<NodeId>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SidebarIntent {
None,
OpenEntry(String),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Sidebar {
full: Vec<SidebarNode>,
filtered: Option<Vec<SidebarNode>>,
filtered_matches: BTreeSet<NodeId>,
expanded: BTreeSet<NodeId>,
selected: Option<NodeId>,
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<VisibleRow> {
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<VisibleRow> {
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<usize> {
selected_index_in(&self.visible_rows(), self.selected.as_ref())
}
fn match_rows(&self) -> Vec<VisibleRow> {
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<TestTreeNode>) {
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<TestTreeNode>,
}
#[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<NodeId>,
force_expanded: bool,
rows: &mut Vec<VisibleRow>,
) {
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<usize> {
let selected = selected?;
rows.iter().position(|row| &row.id == selected)
}
fn collect_ids(nodes: &[SidebarNode]) -> BTreeSet<NodeId> {
fn visit(nodes: &[SidebarNode], ids: &mut BTreeSet<NodeId>) {
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 {
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::<Vec<_>>();
assert_eq!(collisions.len(), 2);
assert_ne!(collisions[0].id, collisions[1].id);
}
}