684 lines
20 KiB
Rust
684 lines
20 KiB
Rust
//! 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 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<String> {
|
|
fn visit(nodes: &[SidebarNode], paths: &mut Vec<String>) {
|
|
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<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 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::<Vec<_>>();
|
|
assert_eq!(collisions.len(), 2);
|
|
assert_ne!(collisions[0].id, collisions[1].id);
|
|
}
|
|
}
|