Implement the password-store tree sidebar
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user