Add desktop search and entry mutation workflows
This commit is contained in:
@@ -21,12 +21,16 @@ pub enum UiAction {
|
|||||||
CopyEditedField,
|
CopyEditedField,
|
||||||
Paste,
|
Paste,
|
||||||
Find,
|
Find,
|
||||||
|
SearchContents,
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
TogglePaneFocus,
|
TogglePaneFocus,
|
||||||
Refresh,
|
Refresh,
|
||||||
ReloadEntry,
|
ReloadEntry,
|
||||||
EditEntry,
|
EditEntry,
|
||||||
GeneratePassword,
|
GeneratePassword,
|
||||||
|
MoveEntry,
|
||||||
|
CopyEntry,
|
||||||
|
DeleteEntry,
|
||||||
ToggleReveal,
|
ToggleReveal,
|
||||||
Lock,
|
Lock,
|
||||||
Minimize,
|
Minimize,
|
||||||
@@ -53,12 +57,16 @@ impl UiAction {
|
|||||||
Self::CopyEditedField => "copy-edited-field",
|
Self::CopyEditedField => "copy-edited-field",
|
||||||
Self::Paste => "paste",
|
Self::Paste => "paste",
|
||||||
Self::Find => "find",
|
Self::Find => "find",
|
||||||
|
Self::SearchContents => "search-contents",
|
||||||
Self::CommandPalette => "command-palette",
|
Self::CommandPalette => "command-palette",
|
||||||
Self::TogglePaneFocus => "toggle-pane-focus",
|
Self::TogglePaneFocus => "toggle-pane-focus",
|
||||||
Self::Refresh => "refresh",
|
Self::Refresh => "refresh",
|
||||||
Self::ReloadEntry => "reload-entry",
|
Self::ReloadEntry => "reload-entry",
|
||||||
Self::EditEntry => "edit-entry",
|
Self::EditEntry => "edit-entry",
|
||||||
Self::GeneratePassword => "generate-password",
|
Self::GeneratePassword => "generate-password",
|
||||||
|
Self::MoveEntry => "move-entry",
|
||||||
|
Self::CopyEntry => "copy-entry",
|
||||||
|
Self::DeleteEntry => "delete-entry",
|
||||||
Self::ToggleReveal => "toggle-reveal",
|
Self::ToggleReveal => "toggle-reveal",
|
||||||
Self::Lock => "lock",
|
Self::Lock => "lock",
|
||||||
Self::Minimize => "minimize",
|
Self::Minimize => "minimize",
|
||||||
@@ -120,6 +128,7 @@ pub struct ActionContext {
|
|||||||
pub focused_sensitive: bool,
|
pub focused_sensitive: bool,
|
||||||
pub focused_generatable: bool,
|
pub focused_generatable: bool,
|
||||||
pub entry_path: bool,
|
pub entry_path: bool,
|
||||||
|
pub selected_object: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -173,6 +182,12 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
),
|
),
|
||||||
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
||||||
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
||||||
|
spec(
|
||||||
|
UiAction::SearchContents,
|
||||||
|
MenuGroup::Edit,
|
||||||
|
"Search Decrypted Contents…",
|
||||||
|
Some("⇧⌘F"),
|
||||||
|
),
|
||||||
spec(
|
spec(
|
||||||
UiAction::CommandPalette,
|
UiAction::CommandPalette,
|
||||||
MenuGroup::View,
|
MenuGroup::View,
|
||||||
@@ -199,6 +214,14 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
"Generate Password…",
|
"Generate Password…",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
spec(
|
||||||
|
UiAction::MoveEntry,
|
||||||
|
MenuGroup::Entry,
|
||||||
|
"Move or Rename…",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
spec(UiAction::CopyEntry, MenuGroup::Entry, "Copy Entry…", None),
|
||||||
|
spec(UiAction::DeleteEntry, MenuGroup::Entry, "Delete…", None),
|
||||||
spec(
|
spec(
|
||||||
UiAction::ToggleReveal,
|
UiAction::ToggleReveal,
|
||||||
MenuGroup::Entry,
|
MenuGroup::Entry,
|
||||||
@@ -289,7 +312,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|||||||
}
|
}
|
||||||
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
|
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
|
||||||
UiAction::Minimize => true,
|
UiAction::Minimize => true,
|
||||||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
|
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => false,
|
||||||
|
UiAction::Find | UiAction::SearchContents => {
|
||||||
|
context.storage_ready
|
||||||
|
&& !context.saving
|
||||||
|
&& !context.switching_vault
|
||||||
|
&& !context.modal_open
|
||||||
|
}
|
||||||
UiAction::CopyField => {
|
UiAction::CopyField => {
|
||||||
context.unlocked
|
context.unlocked
|
||||||
&& context.document_open
|
&& context.document_open
|
||||||
@@ -320,6 +349,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|||||||
&& !context.switching_vault
|
&& !context.switching_vault
|
||||||
&& !context.modal_open
|
&& !context.modal_open
|
||||||
}
|
}
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => {
|
||||||
|
context.storage_ready
|
||||||
|
&& context.selected_object
|
||||||
|
&& !context.saving
|
||||||
|
&& !context.switching_vault
|
||||||
|
&& !context.modal_open
|
||||||
|
}
|
||||||
UiAction::ToggleReveal => {
|
UiAction::ToggleReveal => {
|
||||||
context.unlocked
|
context.unlocked
|
||||||
&& context.document_open
|
&& context.document_open
|
||||||
@@ -367,9 +403,17 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
|||||||
UiAction::Save if context.saving => "A save is already running",
|
UiAction::Save if context.saving => "A save is already running",
|
||||||
UiAction::Save => "Wait for vault validation",
|
UiAction::Save => "Wait for vault validation",
|
||||||
UiAction::CloseWindow | UiAction::Quit => "Wait for vault validation",
|
UiAction::CloseWindow | UiAction::Quit => "Wait for vault validation",
|
||||||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => {
|
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {
|
||||||
"Use the focused native text field"
|
"Use the focused native text field"
|
||||||
}
|
}
|
||||||
|
UiAction::Find | UiAction::SearchContents if !context.storage_ready => {
|
||||||
|
"Shared configuration is unavailable"
|
||||||
|
}
|
||||||
|
UiAction::Find | UiAction::SearchContents if context.saving => "Wait for the active save",
|
||||||
|
UiAction::Find | UiAction::SearchContents if context.switching_vault => {
|
||||||
|
"Wait for vault validation"
|
||||||
|
}
|
||||||
|
UiAction::Find | UiAction::SearchContents => "Close the current screen first",
|
||||||
UiAction::CopyField | UiAction::CopyEditedField if !context.unlocked => {
|
UiAction::CopyField | UiAction::CopyEditedField if !context.unlocked => {
|
||||||
"Unlock an entry first"
|
"Unlock an entry first"
|
||||||
}
|
}
|
||||||
@@ -402,6 +446,27 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
|||||||
}
|
}
|
||||||
UiAction::GeneratePassword if context.switching_vault => "Wait for vault validation",
|
UiAction::GeneratePassword if context.switching_vault => "Wait for vault validation",
|
||||||
UiAction::GeneratePassword => "Close the current screen first",
|
UiAction::GeneratePassword => "Close the current screen first",
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry
|
||||||
|
if !context.storage_ready =>
|
||||||
|
{
|
||||||
|
"Shared configuration is unavailable"
|
||||||
|
}
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry
|
||||||
|
if !context.selected_object =>
|
||||||
|
{
|
||||||
|
"Select an entry or folder first"
|
||||||
|
}
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry if context.saving => {
|
||||||
|
"Wait for the active save"
|
||||||
|
}
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry
|
||||||
|
if context.switching_vault =>
|
||||||
|
{
|
||||||
|
"Wait for vault validation"
|
||||||
|
}
|
||||||
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => {
|
||||||
|
"Close the current screen first"
|
||||||
|
}
|
||||||
UiAction::ToggleReveal if !context.unlocked => "Unlock an entry first",
|
UiAction::ToggleReveal if !context.unlocked => "Unlock an entry first",
|
||||||
UiAction::ToggleReveal if !context.document_open => "Open an entry first",
|
UiAction::ToggleReveal if !context.document_open => "Open an entry first",
|
||||||
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
|
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
|
||||||
@@ -436,12 +501,16 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
|
|||||||
UiAction::CopyField | UiAction::CopyEditedField => &["copy value", "clipboard"],
|
UiAction::CopyField | UiAction::CopyEditedField => &["copy value", "clipboard"],
|
||||||
UiAction::Paste => &["insert clipboard"],
|
UiAction::Paste => &["insert clipboard"],
|
||||||
UiAction::Find => &["search text"],
|
UiAction::Find => &["search text"],
|
||||||
|
UiAction::SearchContents => &["grep", "decrypted search", "search passwords"],
|
||||||
UiAction::CommandPalette => &["commands", "actions", "search commands"],
|
UiAction::CommandPalette => &["commands", "actions", "search commands"],
|
||||||
UiAction::TogglePaneFocus => &["next pane", "switch pane", "focus"],
|
UiAction::TogglePaneFocus => &["next pane", "switch pane", "focus"],
|
||||||
UiAction::Refresh => &["reload vault", "refresh tree"],
|
UiAction::Refresh => &["reload vault", "refresh tree"],
|
||||||
UiAction::ReloadEntry => &["revert entry", "refresh entry"],
|
UiAction::ReloadEntry => &["revert entry", "refresh entry"],
|
||||||
UiAction::EditEntry => &["modify entry"],
|
UiAction::EditEntry => &["modify entry"],
|
||||||
UiAction::GeneratePassword => &["random password", "replace password", "generate"],
|
UiAction::GeneratePassword => &["random password", "replace password", "generate"],
|
||||||
|
UiAction::MoveEntry => &["rename", "mv", "move folder"],
|
||||||
|
UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
|
||||||
|
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
|
||||||
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
|
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
|
||||||
UiAction::Lock => &["secure", "log out", "relock"],
|
UiAction::Lock => &["secure", "log out", "relock"],
|
||||||
UiAction::Minimize => &["hide window"],
|
UiAction::Minimize => &["hide window"],
|
||||||
@@ -471,6 +540,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
|
|||||||
keyboard::Key::Character("x" | "X") => Some(UiAction::Cut),
|
keyboard::Key::Character("x" | "X") => Some(UiAction::Cut),
|
||||||
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
|
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
|
||||||
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
|
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
|
||||||
|
keyboard::Key::Character("f" | "F") if modifiers.shift() => Some(UiAction::SearchContents),
|
||||||
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
||||||
keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette),
|
keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette),
|
||||||
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
|
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
|
||||||
@@ -501,6 +571,7 @@ mod tests {
|
|||||||
focused_sensitive: true,
|
focused_sensitive: true,
|
||||||
focused_generatable: true,
|
focused_generatable: true,
|
||||||
entry_path: true,
|
entry_path: true,
|
||||||
|
selected_object: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +625,18 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
assert!(!enabled(action, locked));
|
assert!(!enabled(action, locked));
|
||||||
}
|
}
|
||||||
|
for action in [
|
||||||
|
UiAction::Find,
|
||||||
|
UiAction::SearchContents,
|
||||||
|
UiAction::MoveEntry,
|
||||||
|
UiAction::CopyEntry,
|
||||||
|
UiAction::DeleteEntry,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
enabled(action, locked),
|
||||||
|
"{action:?} can initiate authentication"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(!enabled(
|
assert!(!enabled(
|
||||||
UiAction::Refresh,
|
UiAction::Refresh,
|
||||||
ActionContext {
|
ActionContext {
|
||||||
@@ -591,6 +674,11 @@ mod tests {
|
|||||||
UiAction::EditEntry,
|
UiAction::EditEntry,
|
||||||
UiAction::GeneratePassword,
|
UiAction::GeneratePassword,
|
||||||
UiAction::ToggleReveal,
|
UiAction::ToggleReveal,
|
||||||
|
UiAction::Find,
|
||||||
|
UiAction::SearchContents,
|
||||||
|
UiAction::MoveEntry,
|
||||||
|
UiAction::CopyEntry,
|
||||||
|
UiAction::DeleteEntry,
|
||||||
] {
|
] {
|
||||||
assert!(!enabled(action, switching), "{action:?}");
|
assert!(!enabled(action, switching), "{action:?}");
|
||||||
}
|
}
|
||||||
@@ -662,6 +750,13 @@ mod tests {
|
|||||||
Some(expected)
|
Some(expected)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert_eq!(
|
||||||
|
shortcut_action(
|
||||||
|
&keyboard::Key::Character("f".into()),
|
||||||
|
primary | keyboard::Modifiers::SHIFT,
|
||||||
|
),
|
||||||
|
Some(UiAction::SearchContents)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE),
|
shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE),
|
||||||
Some(UiAction::Help)
|
Some(UiAction::Help)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ impl NativeMenu {
|
|||||||
submenu.append(&PredefinedMenuItem::select_all(None))?;
|
submenu.append(&PredefinedMenuItem::select_all(None))?;
|
||||||
submenu.append(&PredefinedMenuItem::separator())?;
|
submenu.append(&PredefinedMenuItem::separator())?;
|
||||||
append_action(&submenu, UiAction::Find, context, &mut items)?;
|
append_action(&submenu, UiAction::Find, context, &mut items)?;
|
||||||
|
append_action(&submenu, UiAction::SearchContents, context, &mut items)?;
|
||||||
}
|
}
|
||||||
_ => append_actions(&submenu, group, context, &mut items)?,
|
_ => append_actions(&submenu, group, context, &mut items)?,
|
||||||
}
|
}
|
||||||
@@ -125,6 +126,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
UiAction::Cut => (command, Code::KeyX),
|
UiAction::Cut => (command, Code::KeyX),
|
||||||
UiAction::Paste => (command, Code::KeyV),
|
UiAction::Paste => (command, Code::KeyV),
|
||||||
UiAction::Find => (command, Code::KeyF),
|
UiAction::Find => (command, Code::KeyF),
|
||||||
|
UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF),
|
||||||
UiAction::CommandPalette => (command, Code::KeyK),
|
UiAction::CommandPalette => (command, Code::KeyK),
|
||||||
UiAction::Refresh => (command, Code::KeyR),
|
UiAction::Refresh => (command, Code::KeyR),
|
||||||
UiAction::Lock => (command, Code::KeyL),
|
UiAction::Lock => (command, Code::KeyL),
|
||||||
@@ -139,6 +141,9 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
| UiAction::ReloadEntry
|
| UiAction::ReloadEntry
|
||||||
| UiAction::EditEntry
|
| UiAction::EditEntry
|
||||||
| UiAction::GeneratePassword
|
| UiAction::GeneratePassword
|
||||||
|
| UiAction::MoveEntry
|
||||||
|
| UiAction::CopyEntry
|
||||||
|
| UiAction::DeleteEntry
|
||||||
| UiAction::ToggleReveal
|
| UiAction::ToggleReveal
|
||||||
| UiAction::Minimize => return None,
|
| UiAction::Minimize => return None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use std::{collections::BTreeSet, path::Path};
|
use std::{collections::BTreeSet, path::Path};
|
||||||
|
|
||||||
use ironstorage::read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind};
|
use ironstorage::{
|
||||||
|
read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind},
|
||||||
|
repository::DirectoryPath,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum NavigationKey {
|
pub enum NavigationKey {
|
||||||
@@ -86,6 +89,20 @@ impl NavigationTree {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn directories(&self) -> Vec<TreeNodeId> {
|
||||||
|
fn visit(nodes: &[NavigationNode], directories: &mut Vec<TreeNodeId>) {
|
||||||
|
for node in nodes {
|
||||||
|
if node.id.is_directory() {
|
||||||
|
directories.push(node.id.clone());
|
||||||
|
}
|
||||||
|
visit(&node.children, directories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut directories = vec![TreeNodeId::Directory(DirectoryPath::root())];
|
||||||
|
visit(&self.nodes, &mut directories);
|
||||||
|
directories
|
||||||
|
}
|
||||||
|
|
||||||
pub fn selected_ratio(&self) -> f32 {
|
pub fn selected_ratio(&self) -> f32 {
|
||||||
let rows = self.rows();
|
let rows = self.rows();
|
||||||
let Some(index) = selected_index(&rows, self.selected.as_ref()) else {
|
let Some(index) = selected_index(&rows, self.selected.as_ref()) else {
|
||||||
@@ -131,6 +148,19 @@ impl NavigationTree {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn select_id(&mut self, id: &TreeNodeId) -> bool {
|
||||||
|
let mut lineage = Vec::new();
|
||||||
|
if !find_id_lineage(&self.nodes, id, &mut lineage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(selected) = lineage.pop() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.expanded.extend(lineage);
|
||||||
|
self.selected = Some(selected);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn activate_selected(&mut self) -> NavigationIntent {
|
fn activate_selected(&mut self) -> NavigationIntent {
|
||||||
let Some(selected) = self.selected.clone() else {
|
let Some(selected) = self.selected.clone() else {
|
||||||
return NavigationIntent::None;
|
return NavigationIntent::None;
|
||||||
@@ -274,6 +304,21 @@ fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec<TreeNode
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_id_lineage(
|
||||||
|
nodes: &[NavigationNode],
|
||||||
|
target: &TreeNodeId,
|
||||||
|
lineage: &mut Vec<TreeNodeId>,
|
||||||
|
) -> bool {
|
||||||
|
for node in nodes {
|
||||||
|
lineage.push(node.id.clone());
|
||||||
|
if &node.id == target || find_id_lineage(&node.children, target, lineage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
lineage.pop();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) struct TestNode {
|
pub(crate) struct TestNode {
|
||||||
pub id: TreeNodeId,
|
pub id: TreeNodeId,
|
||||||
@@ -365,6 +410,24 @@ mod tests {
|
|||||||
assert_eq!(tree.rows().len(), 2);
|
assert_eq!(tree.rows().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_search_and_mutation_identities_expand_and_select_hidden_nodes() {
|
||||||
|
let mut tree = NavigationTree::default();
|
||||||
|
tree.replace_test_nodes(populated());
|
||||||
|
let hidden = TreeNodeId::Entry(EntryPath::parse("personal/email").expect("entry"));
|
||||||
|
assert!(tree.select_id(&hidden));
|
||||||
|
assert_eq!(tree.selected(), Some(&hidden));
|
||||||
|
assert!(tree.rows().iter().any(|row| row.id == hidden));
|
||||||
|
assert_eq!(
|
||||||
|
tree.directories(),
|
||||||
|
vec![
|
||||||
|
TreeNodeId::Directory(DirectoryPath::root()),
|
||||||
|
TreeNodeId::Directory(DirectoryPath::parse("personal").expect("personal")),
|
||||||
|
TreeNodeId::Directory(DirectoryPath::parse("work").expect("work")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refresh_preserves_valid_typed_identity_and_clears_removed_state() {
|
fn refresh_preserves_valid_typed_identity_and_clears_removed_state() {
|
||||||
let mut tree = NavigationTree::default();
|
let mut tree = NavigationTree::default();
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ mod tests {
|
|||||||
matches("generate").first(),
|
matches("generate").first(),
|
||||||
Some(&UiAction::GeneratePassword)
|
Some(&UiAction::GeneratePassword)
|
||||||
);
|
);
|
||||||
|
assert_eq!(matches("grep").first(), Some(&UiAction::SearchContents));
|
||||||
|
assert_eq!(matches("rename").first(), Some(&UiAction::MoveEntry));
|
||||||
|
assert_eq!(
|
||||||
|
matches("duplicate entry").first(),
|
||||||
|
Some(&UiAction::CopyEntry)
|
||||||
|
);
|
||||||
|
assert_eq!(matches("rm").first(), Some(&UiAction::DeleteEntry));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
matches(""),
|
matches(""),
|
||||||
action::ACTIONS
|
action::ACTIONS
|
||||||
|
|||||||
@@ -6,20 +6,21 @@ use crate::{
|
|||||||
authentication::{
|
authentication::{
|
||||||
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
|
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||||
},
|
},
|
||||||
command::InitRequest,
|
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
|
||||||
config::{Config, ConfigSettings, EditorCommand},
|
config::{Config, ConfigSettings, EditorCommand},
|
||||||
crypto::{KeyInfo, KeyStore, SecretProvider},
|
crypto::{KeyInfo, KeyStore, SecretProvider},
|
||||||
document::{DocumentError, EntryDocument, EntryDocumentService},
|
document::{DocumentError, EntryDocument, EntryDocumentService},
|
||||||
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity},
|
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity},
|
||||||
|
mutation::{MutationOutcome, TreeMutator},
|
||||||
presentation::ClipboardTimeout,
|
presentation::ClipboardTimeout,
|
||||||
read::{TreeModel, VaultReader},
|
read::{FindResults, GrepResults, TreeModel, VaultReader},
|
||||||
recipient::{
|
recipient::{
|
||||||
PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
|
PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
|
||||||
RecipientPolicyOutcome,
|
RecipientPolicyOutcome,
|
||||||
},
|
},
|
||||||
repository::{DirectoryPath, Repository},
|
repository::{DirectoryPath, Repository},
|
||||||
secret_store::SecretProtectionPolicy,
|
secret_store::SecretProtectionPolicy,
|
||||||
write::{VaultWriter, WriteError, WriteOutcome},
|
write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -35,6 +36,7 @@ pub enum DesktopErrorKind {
|
|||||||
Unchanged,
|
Unchanged,
|
||||||
MissingDefaultKey,
|
MissingDefaultKey,
|
||||||
EntryExists,
|
EntryExists,
|
||||||
|
Mutation,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -80,6 +82,23 @@ pub struct DesktopStorage {
|
|||||||
config: Config,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DesktopMutationRequest {
|
||||||
|
Remove(RemoveRequest),
|
||||||
|
Move(MoveRequest),
|
||||||
|
Copy(CopyRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopMutationRequest {
|
||||||
|
pub fn source(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Remove(request) => &request.entry,
|
||||||
|
Self::Move(request) => &request.source,
|
||||||
|
Self::Copy(request) => &request.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DesktopStorage {
|
impl DesktopStorage {
|
||||||
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
|
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
|
||||||
Config::load(explicit)
|
Config::load(explicit)
|
||||||
@@ -199,6 +218,80 @@ impl DesktopStorage {
|
|||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||||
|
let repository = self.repository()?;
|
||||||
|
let keys = self.keys()?;
|
||||||
|
VaultReader::new(&repository, &keys)
|
||||||
|
.find(&request.terms)
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grep_active(
|
||||||
|
&self,
|
||||||
|
handle: &NativeAuthenticationHandle,
|
||||||
|
request: &GrepRequest,
|
||||||
|
) -> Result<GrepResults, DesktopError> {
|
||||||
|
handle
|
||||||
|
.ensure_active()
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
|
||||||
|
let mut provider = handle.clone();
|
||||||
|
self.grep(request, &mut provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grep(
|
||||||
|
&self,
|
||||||
|
request: &GrepRequest,
|
||||||
|
provider: &mut impl SecretProvider,
|
||||||
|
) -> Result<GrepResults, DesktopError> {
|
||||||
|
let repository = self.repository()?;
|
||||||
|
let keys = self.keys()?;
|
||||||
|
VaultReader::new(&repository, &keys)
|
||||||
|
.grep(request, provider)
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mutate_active(
|
||||||
|
&self,
|
||||||
|
handle: &NativeAuthenticationHandle,
|
||||||
|
request: &DesktopMutationRequest,
|
||||||
|
overwrite: OverwriteDecision,
|
||||||
|
) -> Result<MutationOutcome, DesktopError> {
|
||||||
|
handle
|
||||||
|
.ensure_active()
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
|
||||||
|
let mut provider = handle.clone();
|
||||||
|
self.mutate(request, overwrite, &mut provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mutate(
|
||||||
|
&self,
|
||||||
|
request: &DesktopMutationRequest,
|
||||||
|
overwrite: OverwriteDecision,
|
||||||
|
provider: &mut impl SecretProvider,
|
||||||
|
) -> Result<MutationOutcome, DesktopError> {
|
||||||
|
let repository = self.repository()?;
|
||||||
|
let keys = self.keys()?;
|
||||||
|
let mut committer = AutomaticTreeCommitter::for_source(
|
||||||
|
&repository,
|
||||||
|
request.source(),
|
||||||
|
GitIdentity::ironstorage(),
|
||||||
|
)
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
|
||||||
|
let mutator = TreeMutator::new(&repository, &keys);
|
||||||
|
match request {
|
||||||
|
DesktopMutationRequest::Remove(request) => {
|
||||||
|
mutator.remove(request, overwrite, &mut committer)
|
||||||
|
}
|
||||||
|
DesktopMutationRequest::Move(request) => {
|
||||||
|
mutator.move_tree(request, overwrite, None, provider, &mut committer)
|
||||||
|
}
|
||||||
|
DesktopMutationRequest::Copy(request) => {
|
||||||
|
mutator.copy(request, overwrite, None, provider, &mut committer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Mutation, error))
|
||||||
|
}
|
||||||
|
|
||||||
/// Enumerate storage-validated encryption keys for recipient selection.
|
/// Enumerate storage-validated encryption keys for recipient selection.
|
||||||
pub fn key_infos(&self) -> Result<Vec<KeyInfo>, DesktopError> {
|
pub fn key_infos(&self) -> Result<Vec<KeyInfo>, DesktopError> {
|
||||||
Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect())
|
Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect())
|
||||||
|
|||||||
@@ -229,17 +229,23 @@ impl fmt::Debug for ShowResult {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct NameMatch {
|
pub struct NameMatch {
|
||||||
path: String,
|
id: TreeNodeId,
|
||||||
kind: TreeNodeKind,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NameMatch {
|
impl NameMatch {
|
||||||
pub fn path(&self) -> &str {
|
pub fn path(&self) -> &str {
|
||||||
&self.path
|
self.id
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.expect("search construction rejects non-UTF-8 paths")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> TreeNodeKind {
|
pub fn kind(&self) -> TreeNodeKind {
|
||||||
self.kind
|
self.id.kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &TreeNodeId {
|
||||||
|
&self.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,8 +495,7 @@ impl<'a> VaultReader<'a> {
|
|||||||
let name = display_name(directory.path().as_path())?;
|
let name = display_name(directory.path().as_path())?;
|
||||||
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
|
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
|
||||||
matches.push(NameMatch {
|
matches.push(NameMatch {
|
||||||
path: path_text(directory.path().as_path())?,
|
id: TreeNodeId::Directory(directory.path().clone()),
|
||||||
kind: TreeNodeKind::Directory,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,16 +506,12 @@ impl<'a> VaultReader<'a> {
|
|||||||
let name = display_name(entry.path().as_path())?;
|
let name = display_name(entry.path().as_path())?;
|
||||||
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
|
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
|
||||||
matches.push(NameMatch {
|
matches.push(NameMatch {
|
||||||
path: path_text(entry.path().as_path())?,
|
id: TreeNodeId::Entry(entry.path().clone()),
|
||||||
kind: TreeNodeKind::Entry,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
matches.sort_by(|left, right| left.path.cmp(&right.path));
|
matches.sort_by(|left, right| left.path().cmp(right.path()));
|
||||||
let included = matches
|
let included = matches.iter().map(NameMatch::path).collect::<Vec<_>>();
|
||||||
.iter()
|
|
||||||
.map(|matched| matched.path.as_str())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let tree = build_tree(&snapshot, &DirectoryPath::root(), Some(included.as_slice()))?;
|
let tree = build_tree(&snapshot, &DirectoryPath::root(), Some(included.as_slice()))?;
|
||||||
Ok(FindResults {
|
Ok(FindResults {
|
||||||
terms: terms.to_vec(),
|
terms: terms.to_vec(),
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ fn find_matches_entry_and_directory_names_case_insensitively() -> TestResult {
|
|||||||
);
|
);
|
||||||
let personal = reader.find(&["PERSONAL".to_owned()])?;
|
let personal = reader.find(&["PERSONAL".to_owned()])?;
|
||||||
assert_eq!(personal.matches()[0].path(), "email/personal");
|
assert_eq!(personal.matches()[0].path(), "email/personal");
|
||||||
|
assert_eq!(
|
||||||
|
personal.matches()[0].id(),
|
||||||
|
&ironstorage::read::TreeNodeId::Entry(EntryPath::parse("email/personal")?)
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
reader.find(&[]),
|
reader.find(&[]),
|
||||||
Err(ReadError::MissingSearchTerms)
|
Err(ReadError::MissingSearchTerms)
|
||||||
|
|||||||
Reference in New Issue
Block a user