Implement the password-store tree sidebar
This commit is contained in:
611
apps/tui/src/sidebar.rs
Normal file
611
apps/tui/src/sidebar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user