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

View File

@@ -11,6 +11,19 @@ pub enum Action {
Command, Command,
Cancel, Cancel,
Lock, Lock,
Refresh,
Next,
Previous,
PageDown,
PageUp,
First,
Last,
Parent,
Child,
Activate,
Filter,
NextMatch,
PreviousMatch,
FocusNext, FocusNext,
FocusPrevious, FocusPrevious,
} }
@@ -27,7 +40,7 @@ pub struct ActionSpec {
pub action: Action, pub action: Action,
pub label: &'static str, pub label: &'static str,
pub command: &'static str, pub command: &'static str,
pub binding: KeyBinding, pub bindings: &'static [KeyBinding],
modes: &'static [Mode], modes: &'static [Mode],
} }
@@ -51,82 +64,163 @@ const ALL: &[Mode] = &[
Mode::Locked, Mode::Locked,
]; ];
macro_rules! keys {
($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => {
&[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+]
};
}
pub static ACTIONS: &[ActionSpec] = &[ pub static ACTIONS: &[ActionSpec] = &[
ActionSpec { ActionSpec {
action: Action::Quit, action: Action::Quit,
label: "quit", label: "quit",
command: "quit", command: "quit",
binding: KeyBinding { bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")),
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
display: "q",
},
modes: BROWSER_LIKE, modes: BROWSER_LIKE,
}, },
ActionSpec { ActionSpec {
action: Action::Help, action: Action::Help,
label: "help", label: "help",
command: "help", command: "help",
binding: KeyBinding { bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")),
code: KeyCode::Char('?'),
modifiers: KeyModifiers::NONE,
display: "?",
},
modes: UNLOCKED, modes: UNLOCKED,
}, },
ActionSpec { ActionSpec {
action: Action::Command, action: Action::Command,
label: "command", label: "command",
command: "command", command: "command",
binding: KeyBinding { bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")),
code: KeyCode::Char(':'),
modifiers: KeyModifiers::NONE,
display: ":",
},
modes: UNLOCKED, modes: UNLOCKED,
}, },
ActionSpec { ActionSpec {
action: Action::Cancel, action: Action::Cancel,
label: "back", label: "back",
command: "cancel", command: "cancel",
binding: KeyBinding { bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
display: "Esc",
},
modes: OVERLAYS, modes: OVERLAYS,
}, },
ActionSpec { ActionSpec {
action: Action::Lock, action: Action::Lock,
label: "lock", label: "lock",
command: "lock", command: "lock",
binding: KeyBinding { bindings: keys!((KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l")),
code: KeyCode::Char('l'),
modifiers: KeyModifiers::CONTROL,
display: "C-l",
},
modes: UNLOCKED, 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 { ActionSpec {
action: Action::FocusNext, action: Action::FocusNext,
label: "next pane", label: "next pane",
command: "focus-next", command: "focus-next",
binding: KeyBinding { bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")),
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
display: "Tab",
},
modes: UNLOCKED, modes: UNLOCKED,
}, },
ActionSpec { ActionSpec {
action: Action::FocusPrevious, action: Action::FocusPrevious,
label: "previous pane", label: "previous pane",
command: "focus-previous", command: "focus-previous",
binding: KeyBinding { bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
code: KeyCode::BackTab,
modifiers: KeyModifiers::SHIFT,
display: "S-Tab",
},
modes: UNLOCKED, modes: UNLOCKED,
}, },
]; ];
@@ -136,8 +230,10 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option
.iter() .iter()
.find(|spec| { .find(|spec| {
spec.is_available(mode) spec.is_available(mode)
&& spec.binding.code == code && spec
&& spec.binding.modifiers == modifiers .bindings
.iter()
.any(|binding| binding.code == code && binding.modifiers == modifiers)
}) })
.map(|spec| spec.action) .map(|spec| spec.action)
} }
@@ -160,7 +256,11 @@ mod tests {
assert_ne!(action.command, other.command); assert_ne!(action.command, other.command);
for mode in ALL { for mode in ALL {
if action.is_available(*mode) && other.is_available(*mode) { 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,12 +271,14 @@ mod tests {
fn resolution_and_help_consume_the_same_registry() { fn resolution_and_help_consume_the_same_registry() {
for mode in ALL { for mode in ALL {
for spec in available_actions(*mode) { for spec in available_actions(*mode) {
for binding in spec.bindings {
assert_eq!( assert_eq!(
resolve_key(*mode, spec.binding.code, spec.binding.modifiers), resolve_key(*mode, binding.code, binding.modifiers),
Some(spec.action) Some(spec.action)
); );
} }
} }
}
assert_eq!( assert_eq!(
resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
None None

View File

@@ -2,7 +2,15 @@
use std::collections::BTreeSet; 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)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Mode { pub enum Mode {
@@ -41,15 +49,16 @@ pub struct RequestToken {
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupSummary { pub struct StartupData {
pub configuration: String, pub config: Config,
pub vault: String, pub tree: TreeModel,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum AsyncPayload { pub enum AsyncPayload {
Startup(StartupSummary), Startup(StartupData),
Refreshed, Refreshed(TreeModel),
Filtered { query: String, results: FindResults },
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -64,13 +73,21 @@ pub enum ResultDisposition {
Stale, Stale,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppEffect {
None,
RefreshTree,
}
#[derive(Debug)] #[derive(Debug)]
pub struct App { pub struct App {
mode: Mode, mode: Mode,
suspended_mode: Option<Mode>, suspended_mode: Option<Mode>,
focus: PaneFocus, focus: PaneFocus,
status: String, status: String,
startup: Option<StartupSummary>, config: Option<Config>,
sidebar: Sidebar,
selected_entry: Option<String>,
terminal_size: (u16, u16), terminal_size: (u16, u16),
ticks: u64, ticks: u64,
should_quit: bool, should_quit: bool,
@@ -92,7 +109,9 @@ impl App {
suspended_mode: None, suspended_mode: None,
focus: PaneFocus::Sidebar, focus: PaneFocus::Sidebar,
status: "Starting…".to_owned(), status: "Starting…".to_owned(),
startup: None, config: None,
sidebar: Sidebar::default(),
selected_entry: None,
terminal_size: (0, 0), terminal_size: (0, 0),
ticks: 0, ticks: 0,
should_quit: false, should_quit: false,
@@ -114,8 +133,20 @@ impl App {
&self.status &self.status
} }
pub fn startup(&self) -> Option<&StartupSummary> { pub fn config(&self) -> Option<&Config> {
self.startup.as_ref() 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) { pub fn terminal_size(&self) -> (u16, u16) {
@@ -136,6 +167,14 @@ impl App {
pub fn resize(&mut self, width: u16, height: u16) { pub fn resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height); 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) { pub fn tick(&mut self) {
@@ -152,22 +191,37 @@ impl App {
token token
} }
pub fn begin_latest_request(&mut self) -> RequestToken {
self.invalidate_requests();
self.begin_request()
}
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition { pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) { if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
return ResultDisposition::Stale; return ResultDisposition::Stale;
} }
match result.payload { match result.payload {
Ok(AsyncPayload::Startup(summary)) => { Ok(AsyncPayload::Startup(startup)) => {
self.status = format!("Vault: {}", summary.vault); self.status = format!("Vault: {}", startup.config.vault().display());
self.startup = Some(summary); 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, Err(error) => self.status = error,
} }
ResultDisposition::Applied ResultDisposition::Applied
} }
pub fn dispatch(&mut self, action: Action) { pub fn dispatch(&mut self, action: Action) -> AppEffect {
match action { match action {
Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => { Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => {
self.should_quit = true; self.should_quit = true;
@@ -184,6 +238,24 @@ impl App {
Action::Lock => { Action::Lock => {
self.transition(Transition::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 => { Action::FocusNext | Action::FocusPrevious => {
self.focus = match self.focus { self.focus = match self.focus {
PaneFocus::Sidebar => PaneFocus::Main, PaneFocus::Sidebar => PaneFocus::Main,
@@ -192,6 +264,7 @@ impl App {
} }
Action::Quit => {} Action::Quit => {}
} }
AppEffect::None
} }
pub fn transition(&mut self, transition: Transition) -> bool { pub fn transition(&mut self, transition: Transition) -> bool {
@@ -220,11 +293,13 @@ impl App {
}; };
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) { if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
self.suspended_mode = Some(current); 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 { } else if destination == Mode::Locked {
self.suspended_mode = None; self.suspended_mode = None;
self.focus = PaneFocus::Sidebar; self.focus = PaneFocus::Sidebar;
self.invalidate_requests(); self.invalidate_requests();
self.startup = None; self.selected_entry = None;
self.status = "Locked".to_owned(); self.status = "Locked".to_owned();
} else if current == Mode::Locked { } else if current == Mode::Locked {
self.status = "Authentication required".to_owned(); self.status = "Authentication required".to_owned();
@@ -243,13 +318,6 @@ impl App {
mod tests { mod tests {
use super::*; use super::*;
fn success(token: RequestToken, payload: AsyncPayload) -> AsyncResult {
AsyncResult {
token,
payload: Ok(payload),
}
}
#[test] #[test]
fn every_legal_transition_reaches_its_destination() { fn every_legal_transition_reaches_its_destination() {
let cases = [ let cases = [
@@ -299,14 +367,20 @@ mod tests {
fn stale_and_duplicate_async_results_are_rejected() { fn stale_and_duplicate_async_results_are_rejected() {
let mut app = App::new(); let mut app = App::new();
let token = app.begin_request(); 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.clone()), ResultDisposition::Applied);
assert_eq!(app.apply_result(result), ResultDisposition::Stale); assert_eq!(app.apply_result(result), ResultDisposition::Stale);
let stale = app.begin_request(); let stale = app.begin_request();
assert!(app.transition(Transition::Lock)); assert!(app.transition(Transition::Lock));
assert_eq!( assert_eq!(
app.apply_result(success(stale, AsyncPayload::Refreshed)), app.apply_result(AsyncResult {
token: stale,
payload: Err("stale".to_owned()),
}),
ResultDisposition::Stale ResultDisposition::Stale
); );
assert_eq!(app.status(), "Locked"); assert_eq!(app.status(), "Locked");
@@ -316,16 +390,14 @@ mod tests {
fn storage_results_are_applied_without_domain_inference() { fn storage_results_are_applied_without_domain_inference() {
let mut app = App::new(); let mut app = App::new();
let token = app.begin_request(); let token = app.begin_request();
let summary = StartupSummary {
configuration: "/config.toml".to_owned(),
vault: "/vault".to_owned(),
};
assert_eq!( 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 ResultDisposition::Applied
); );
assert_eq!(app.startup(), Some(&summary)); assert_eq!(app.status(), "typed storage failure");
assert_eq!(app.status(), "Vault: /vault");
} }
#[test] #[test]

View File

@@ -6,6 +6,7 @@
pub mod action; pub mod action;
pub mod app; pub mod app;
pub mod runtime; pub mod runtime;
pub mod sidebar;
pub mod terminal; pub mod terminal;
pub mod ui; pub mod ui;
@@ -16,7 +17,7 @@ use ratatui::DefaultTerminal;
use crate::{ use crate::{
action::resolve_key, action::resolve_key,
app::{App, AsyncPayload, StartupSummary}, app::{App, AppEffect, AsyncPayload, StartupData},
runtime::AsyncExecutor, runtime::AsyncExecutor,
}; };
@@ -27,23 +28,16 @@ const TICK_INTERVAL: Duration = Duration::from_millis(250);
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut app = App::new(); let mut app = App::new();
let executor = AsyncExecutor::new(); let executor = AsyncExecutor::new();
let startup = app.begin_request(); let startup = app.begin_latest_request();
executor.submit(startup, || { executor.submit(startup, || load_startup().map(AsyncPayload::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())
});
while !app.should_quit() { while !app.should_quit() {
for result in executor.drain() { for result in executor.drain() {
app.apply_result(result); app.apply_result(result);
} }
let size = terminal.size()?;
app.resize(size.width, size.height);
terminal.draw(|frame| ui::draw(frame, &app))?; terminal.draw(|frame| ui::draw(frame, &app))?;
if !event::poll(TICK_INTERVAL)? { if !event::poll(TICK_INTERVAL)? {
app.tick(); app.tick();
@@ -52,8 +46,18 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
match event::read()? { match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => { Event::Key(key) if key.kind == KeyEventKind::Press => {
if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { if app.sidebar().is_editing_filter() {
app.dispatch(action); 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), Event::Resize(width, height) => app.resize(width, height),
@@ -62,3 +66,58 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
} }
Ok(()) Ok(())
} }
fn load_startup() -> Result<StartupData, String> {
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<ironstorage::read::TreeModel, String> {
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,
}
}

View File

@@ -54,7 +54,7 @@ mod tests {
let executor = AsyncExecutor::new(); let executor = AsyncExecutor::new();
let mut app = App::new(); let mut app = App::new();
let token = app.begin_request(); let token = app.begin_request();
executor.submit(token, || Ok(AsyncPayload::Refreshed)); executor.submit(token, || Err("test result".to_owned()));
let result = executor let result = executor
.receiver .receiver

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);
}
}

View File

@@ -76,14 +76,21 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
} }
if app.mode() == Mode::Help { if app.mode() == Mode::Help {
let lines = crate::action::ACTIONS.iter().map(|spec| { let available_rows = usize::from(area.height.saturating_sub(2)).max(1);
Line::from(vec![ let lines = (0..available_rows).map(|row| {
Span::styled( let mut spans = Vec::new();
format!("{:>7}", spec.binding.display), 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), 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( frame.render_widget(
Paragraph::new(lines.collect::<Vec<_>>()) Paragraph::new(lines.collect::<Vec<_>>())
@@ -112,9 +119,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
}; };
let panes = Layout::new(direction, constraints).split(area); let panes = Layout::new(direction, constraints).split(area);
frame.render_widget( frame.render_widget(
Paragraph::new(sidebar_text(app)) Paragraph::new(sidebar_lines(app))
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar)) .block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
.wrap(Wrap { trim: true }), .wrap(Wrap { trim: false }),
panes[0], panes[0],
); );
frame.render_widget( frame.render_widget(
@@ -142,24 +149,62 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
.border_style(style) .border_style(style)
} }
fn sidebar_text(app: &App) -> String { fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
let selected = app.sidebar().selected();
let rows = app.sidebar().visible_window();
if rows.is_empty() {
if app.is_busy() { if app.is_busy() {
"Loading password-store tree…".to_owned() return vec![Line::from("Loading password-store tree…")];
} else if let Some(startup) = app.startup() {
format!("Vault\n{}", startup.vault)
} else {
"No password store is open.".to_owned()
} }
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() { match app.mode() {
Mode::Browser => "Select an entry from the sidebar.", Mode::Browser => "Select an entry from the sidebar.".to_owned(),
Mode::Viewer => "Structured entry viewer", Mode::Viewer => app.selected_entry().map_or_else(
Mode::Editor => "Structured entry editor", || "Structured entry viewer".to_owned(),
Mode::Dialog => "Complete or cancel the active dialog.", |path| format!("Opening {path}"),
Mode::Command => "Enter a command on the bottom line.", ),
Mode::Help | Mode::Locked => "", 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> { fn context_line(app: &App) -> Paragraph<'static> {
let text = available_actions(app.mode()) let text = available_actions(app.mode())
.map(|spec| format!("{} {}", spec.binding.display, spec.label)) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
Paragraph::new(text) Paragraph::new(text)
@@ -198,6 +243,10 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
match app.mode() { match app.mode() {
Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)), Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)),
Mode::Dialog => Paragraph::new("dialog> ").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(""), _ => Paragraph::new(""),
} }
} }
@@ -208,6 +257,7 @@ mod tests {
use super::*; use super::*;
use crate::app::Transition; use crate::app::Transition;
use crate::sidebar::TestTreeNode;
fn render(width: u16, height: u16, app: &App) -> String { fn render(width: u16, height: u16, app: &App) -> String {
let backend = TestBackend::new(width, height); let backend = TestBackend::new(width, height);
@@ -268,4 +318,56 @@ mod tests {
assert!(output.contains(spec.command)); 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"));
}
} }

View File

@@ -21,11 +21,43 @@ pub enum TreeNodeKind {
Entry, 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeNode { pub struct TreeNode {
name: String, name: String,
path: String, path: String,
kind: TreeNodeKind, kind: TreeNodeKind,
indicators: TreeNodeIndicators,
children: Vec<TreeNode>, children: Vec<TreeNode>,
} }
@@ -42,6 +74,10 @@ impl TreeNode {
self.kind self.kind
} }
pub fn indicators(&self) -> TreeNodeIndicators {
self.indicators
}
pub fn children(&self) -> &[TreeNode] { pub fn children(&self) -> &[TreeNode] {
&self.children &self.children
} }
@@ -592,6 +628,7 @@ fn finalize_children(node: MutableNode, parent: &Path) -> Result<Vec<TreeNode>,
name, name,
path: path_text(&path)?, path: path_text(&path)?,
kind, kind,
indicators: TreeNodeIndicators::default(),
children, children,
}) })
}) })