Implement the password-store tree sidebar
This commit is contained in:
@@ -11,6 +11,19 @@ pub enum Action {
|
||||
Command,
|
||||
Cancel,
|
||||
Lock,
|
||||
Refresh,
|
||||
Next,
|
||||
Previous,
|
||||
PageDown,
|
||||
PageUp,
|
||||
First,
|
||||
Last,
|
||||
Parent,
|
||||
Child,
|
||||
Activate,
|
||||
Filter,
|
||||
NextMatch,
|
||||
PreviousMatch,
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
}
|
||||
@@ -27,7 +40,7 @@ pub struct ActionSpec {
|
||||
pub action: Action,
|
||||
pub label: &'static str,
|
||||
pub command: &'static str,
|
||||
pub binding: KeyBinding,
|
||||
pub bindings: &'static [KeyBinding],
|
||||
modes: &'static [Mode],
|
||||
}
|
||||
|
||||
@@ -51,82 +64,163 @@ const ALL: &[Mode] = &[
|
||||
Mode::Locked,
|
||||
];
|
||||
|
||||
macro_rules! keys {
|
||||
($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => {
|
||||
&[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+]
|
||||
};
|
||||
}
|
||||
|
||||
pub static ACTIONS: &[ActionSpec] = &[
|
||||
ActionSpec {
|
||||
action: Action::Quit,
|
||||
label: "quit",
|
||||
command: "quit",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "q",
|
||||
},
|
||||
bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")),
|
||||
modes: BROWSER_LIKE,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Help,
|
||||
label: "help",
|
||||
command: "help",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('?'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "?",
|
||||
},
|
||||
bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")),
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Command,
|
||||
label: "command",
|
||||
command: "command",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char(':'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: ":",
|
||||
},
|
||||
bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")),
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Cancel,
|
||||
label: "back",
|
||||
command: "cancel",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "Esc",
|
||||
},
|
||||
bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
|
||||
modes: OVERLAYS,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Lock,
|
||||
label: "lock",
|
||||
command: "lock",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: "C-l",
|
||||
},
|
||||
bindings: keys!((KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l")),
|
||||
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 {
|
||||
action: Action::FocusNext,
|
||||
label: "next pane",
|
||||
command: "focus-next",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "Tab",
|
||||
},
|
||||
bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")),
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::FocusPrevious,
|
||||
label: "previous pane",
|
||||
command: "focus-previous",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::BackTab,
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
display: "S-Tab",
|
||||
},
|
||||
bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
];
|
||||
@@ -136,8 +230,10 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option
|
||||
.iter()
|
||||
.find(|spec| {
|
||||
spec.is_available(mode)
|
||||
&& spec.binding.code == code
|
||||
&& spec.binding.modifiers == modifiers
|
||||
&& spec
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.code == code && binding.modifiers == modifiers)
|
||||
})
|
||||
.map(|spec| spec.action)
|
||||
}
|
||||
@@ -160,7 +256,11 @@ mod tests {
|
||||
assert_ne!(action.command, other.command);
|
||||
for mode in ALL {
|
||||
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() {
|
||||
for mode in ALL {
|
||||
for spec in available_actions(*mode) {
|
||||
for binding in spec.bindings {
|
||||
assert_eq!(
|
||||
resolve_key(*mode, spec.binding.code, spec.binding.modifiers),
|
||||
resolve_key(*mode, binding.code, binding.modifiers),
|
||||
Some(spec.action)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
None
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
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)]
|
||||
pub enum Mode {
|
||||
@@ -41,15 +49,16 @@ pub struct RequestToken {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StartupSummary {
|
||||
pub configuration: String,
|
||||
pub vault: String,
|
||||
pub struct StartupData {
|
||||
pub config: Config,
|
||||
pub tree: TreeModel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AsyncPayload {
|
||||
Startup(StartupSummary),
|
||||
Refreshed,
|
||||
Startup(StartupData),
|
||||
Refreshed(TreeModel),
|
||||
Filtered { query: String, results: FindResults },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -64,13 +73,21 @@ pub enum ResultDisposition {
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AppEffect {
|
||||
None,
|
||||
RefreshTree,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
mode: Mode,
|
||||
suspended_mode: Option<Mode>,
|
||||
focus: PaneFocus,
|
||||
status: String,
|
||||
startup: Option<StartupSummary>,
|
||||
config: Option<Config>,
|
||||
sidebar: Sidebar,
|
||||
selected_entry: Option<String>,
|
||||
terminal_size: (u16, u16),
|
||||
ticks: u64,
|
||||
should_quit: bool,
|
||||
@@ -92,7 +109,9 @@ impl App {
|
||||
suspended_mode: None,
|
||||
focus: PaneFocus::Sidebar,
|
||||
status: "Starting…".to_owned(),
|
||||
startup: None,
|
||||
config: None,
|
||||
sidebar: Sidebar::default(),
|
||||
selected_entry: None,
|
||||
terminal_size: (0, 0),
|
||||
ticks: 0,
|
||||
should_quit: false,
|
||||
@@ -114,8 +133,20 @@ impl App {
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn startup(&self) -> Option<&StartupSummary> {
|
||||
self.startup.as_ref()
|
||||
pub fn config(&self) -> Option<&Config> {
|
||||
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) {
|
||||
@@ -136,6 +167,14 @@ impl App {
|
||||
|
||||
pub fn resize(&mut self, width: u16, height: u16) {
|
||||
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) {
|
||||
@@ -152,22 +191,37 @@ impl App {
|
||||
token
|
||||
}
|
||||
|
||||
pub fn begin_latest_request(&mut self) -> RequestToken {
|
||||
self.invalidate_requests();
|
||||
self.begin_request()
|
||||
}
|
||||
|
||||
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
|
||||
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
|
||||
return ResultDisposition::Stale;
|
||||
}
|
||||
match result.payload {
|
||||
Ok(AsyncPayload::Startup(summary)) => {
|
||||
self.status = format!("Vault: {}", summary.vault);
|
||||
self.startup = Some(summary);
|
||||
Ok(AsyncPayload::Startup(startup)) => {
|
||||
self.status = format!("Vault: {}", startup.config.vault().display());
|
||||
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,
|
||||
}
|
||||
ResultDisposition::Applied
|
||||
}
|
||||
|
||||
pub fn dispatch(&mut self, action: Action) {
|
||||
pub fn dispatch(&mut self, action: Action) -> AppEffect {
|
||||
match action {
|
||||
Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => {
|
||||
self.should_quit = true;
|
||||
@@ -184,6 +238,24 @@ impl App {
|
||||
Action::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 => {
|
||||
self.focus = match self.focus {
|
||||
PaneFocus::Sidebar => PaneFocus::Main,
|
||||
@@ -192,6 +264,7 @@ impl App {
|
||||
}
|
||||
Action::Quit => {}
|
||||
}
|
||||
AppEffect::None
|
||||
}
|
||||
|
||||
pub fn transition(&mut self, transition: Transition) -> bool {
|
||||
@@ -220,11 +293,13 @@ impl App {
|
||||
};
|
||||
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
||||
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 {
|
||||
self.suspended_mode = None;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
self.invalidate_requests();
|
||||
self.startup = None;
|
||||
self.selected_entry = None;
|
||||
self.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -243,13 +318,6 @@ impl App {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn success(token: RequestToken, payload: AsyncPayload) -> AsyncResult {
|
||||
AsyncResult {
|
||||
token,
|
||||
payload: Ok(payload),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_legal_transition_reaches_its_destination() {
|
||||
let cases = [
|
||||
@@ -299,14 +367,20 @@ mod tests {
|
||||
fn stale_and_duplicate_async_results_are_rejected() {
|
||||
let mut app = App::new();
|
||||
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), ResultDisposition::Stale);
|
||||
|
||||
let stale = app.begin_request();
|
||||
assert!(app.transition(Transition::Lock));
|
||||
assert_eq!(
|
||||
app.apply_result(success(stale, AsyncPayload::Refreshed)),
|
||||
app.apply_result(AsyncResult {
|
||||
token: stale,
|
||||
payload: Err("stale".to_owned()),
|
||||
}),
|
||||
ResultDisposition::Stale
|
||||
);
|
||||
assert_eq!(app.status(), "Locked");
|
||||
@@ -316,16 +390,14 @@ mod tests {
|
||||
fn storage_results_are_applied_without_domain_inference() {
|
||||
let mut app = App::new();
|
||||
let token = app.begin_request();
|
||||
let summary = StartupSummary {
|
||||
configuration: "/config.toml".to_owned(),
|
||||
vault: "/vault".to_owned(),
|
||||
};
|
||||
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
|
||||
);
|
||||
assert_eq!(app.startup(), Some(&summary));
|
||||
assert_eq!(app.status(), "Vault: /vault");
|
||||
assert_eq!(app.status(), "typed storage failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod action;
|
||||
pub mod app;
|
||||
pub mod runtime;
|
||||
pub mod sidebar;
|
||||
pub mod terminal;
|
||||
pub mod ui;
|
||||
|
||||
@@ -16,7 +17,7 @@ use ratatui::DefaultTerminal;
|
||||
|
||||
use crate::{
|
||||
action::resolve_key,
|
||||
app::{App, AsyncPayload, StartupSummary},
|
||||
app::{App, AppEffect, AsyncPayload, StartupData},
|
||||
runtime::AsyncExecutor,
|
||||
};
|
||||
|
||||
@@ -27,23 +28,16 @@ const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
let mut app = App::new();
|
||||
let executor = AsyncExecutor::new();
|
||||
let startup = app.begin_request();
|
||||
executor.submit(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())
|
||||
});
|
||||
let startup = app.begin_latest_request();
|
||||
executor.submit(startup, || load_startup().map(AsyncPayload::Startup));
|
||||
|
||||
while !app.should_quit() {
|
||||
for result in executor.drain() {
|
||||
app.apply_result(result);
|
||||
}
|
||||
|
||||
let size = terminal.size()?;
|
||||
app.resize(size.width, size.height);
|
||||
terminal.draw(|frame| ui::draw(frame, &app))?;
|
||||
if !event::poll(TICK_INTERVAL)? {
|
||||
app.tick();
|
||||
@@ -52,8 +46,18 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) {
|
||||
app.dispatch(action);
|
||||
if app.sidebar().is_editing_filter() {
|
||||
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),
|
||||
@@ -62,3 +66,58 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ mod tests {
|
||||
let executor = AsyncExecutor::new();
|
||||
let mut app = App::new();
|
||||
let token = app.begin_request();
|
||||
executor.submit(token, || Ok(AsyncPayload::Refreshed));
|
||||
executor.submit(token, || Err("test result".to_owned()));
|
||||
|
||||
let result = executor
|
||||
.receiver
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,21 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
if app.mode() == Mode::Help {
|
||||
let lines = crate::action::ACTIONS.iter().map(|spec| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:>7}", spec.binding.display),
|
||||
let available_rows = usize::from(area.height.saturating_sub(2)).max(1);
|
||||
let lines = (0..available_rows).map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
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),
|
||||
),
|
||||
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(
|
||||
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);
|
||||
frame.render_widget(
|
||||
Paragraph::new(sidebar_text(app))
|
||||
Paragraph::new(sidebar_lines(app))
|
||||
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
|
||||
.wrap(Wrap { trim: true }),
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
@@ -142,24 +149,62 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||
.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() {
|
||||
"Loading password-store tree…".to_owned()
|
||||
} else if let Some(startup) = app.startup() {
|
||||
format!("Vault\n{}", startup.vault)
|
||||
} else {
|
||||
"No password store is open.".to_owned()
|
||||
return vec![Line::from("Loading password-store tree…")];
|
||||
}
|
||||
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() {
|
||||
Mode::Browser => "Select an entry from the sidebar.",
|
||||
Mode::Viewer => "Structured entry viewer",
|
||||
Mode::Editor => "Structured entry editor",
|
||||
Mode::Dialog => "Complete or cancel the active dialog.",
|
||||
Mode::Command => "Enter a command on the bottom line.",
|
||||
Mode::Help | Mode::Locked => "",
|
||||
Mode::Browser => "Select an entry from the sidebar.".to_owned(),
|
||||
Mode::Viewer => app.selected_entry().map_or_else(
|
||||
|| "Structured entry viewer".to_owned(),
|
||||
|path| format!("Opening {path}…"),
|
||||
),
|
||||
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> {
|
||||
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<_>>()
|
||||
.join(" ");
|
||||
Paragraph::new(text)
|
||||
@@ -198,6 +243,10 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||
match app.mode() {
|
||||
Mode::Command => Paragraph::new(":").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(""),
|
||||
}
|
||||
}
|
||||
@@ -208,6 +257,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::app::Transition;
|
||||
use crate::sidebar::TestTreeNode;
|
||||
|
||||
fn render(width: u16, height: u16, app: &App) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
@@ -268,4 +318,56 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,43 @@ pub enum TreeNodeKind {
|
||||
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)]
|
||||
pub struct TreeNode {
|
||||
name: String,
|
||||
path: String,
|
||||
kind: TreeNodeKind,
|
||||
indicators: TreeNodeIndicators,
|
||||
children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
@@ -42,6 +74,10 @@ impl TreeNode {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn indicators(&self) -> TreeNodeIndicators {
|
||||
self.indicators
|
||||
}
|
||||
|
||||
pub fn children(&self) -> &[TreeNode] {
|
||||
&self.children
|
||||
}
|
||||
@@ -592,6 +628,7 @@ fn finalize_children(node: MutableNode, parent: &Path) -> Result<Vec<TreeNode>,
|
||||
name,
|
||||
path: path_text(&path)?,
|
||||
kind,
|
||||
indicators: TreeNodeIndicators::default(),
|
||||
children,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user