Add desktop search and entry mutation workflows

This commit is contained in:
2026-08-10 19:19:46 +02:00
parent bb650c2b5c
commit de5dea28ef
8 changed files with 1292 additions and 30 deletions

View File

@@ -21,12 +21,16 @@ pub enum UiAction {
CopyEditedField,
Paste,
Find,
SearchContents,
CommandPalette,
TogglePaneFocus,
Refresh,
ReloadEntry,
EditEntry,
GeneratePassword,
MoveEntry,
CopyEntry,
DeleteEntry,
ToggleReveal,
Lock,
Minimize,
@@ -53,12 +57,16 @@ impl UiAction {
Self::CopyEditedField => "copy-edited-field",
Self::Paste => "paste",
Self::Find => "find",
Self::SearchContents => "search-contents",
Self::CommandPalette => "command-palette",
Self::TogglePaneFocus => "toggle-pane-focus",
Self::Refresh => "refresh",
Self::ReloadEntry => "reload-entry",
Self::EditEntry => "edit-entry",
Self::GeneratePassword => "generate-password",
Self::MoveEntry => "move-entry",
Self::CopyEntry => "copy-entry",
Self::DeleteEntry => "delete-entry",
Self::ToggleReveal => "toggle-reveal",
Self::Lock => "lock",
Self::Minimize => "minimize",
@@ -120,6 +128,7 @@ pub struct ActionContext {
pub focused_sensitive: bool,
pub focused_generatable: bool,
pub entry_path: bool,
pub selected_object: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -173,6 +182,12 @@ pub const ACTIONS: &[ActionSpec] = &[
),
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
spec(
UiAction::SearchContents,
MenuGroup::Edit,
"Search Decrypted Contents…",
Some("⇧⌘F"),
),
spec(
UiAction::CommandPalette,
MenuGroup::View,
@@ -199,6 +214,14 @@ pub const ACTIONS: &[ActionSpec] = &[
"Generate Password…",
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(
UiAction::ToggleReveal,
MenuGroup::Entry,
@@ -289,7 +312,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
}
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
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 => {
context.unlocked
&& context.document_open
@@ -320,6 +349,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
&& !context.switching_vault
&& !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 => {
context.unlocked
&& 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 => "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"
}
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 => {
"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 => "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.document_open => "Open an entry 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::Paste => &["insert clipboard"],
UiAction::Find => &["search text"],
UiAction::SearchContents => &["grep", "decrypted search", "search passwords"],
UiAction::CommandPalette => &["commands", "actions", "search commands"],
UiAction::TogglePaneFocus => &["next pane", "switch pane", "focus"],
UiAction::Refresh => &["reload vault", "refresh tree"],
UiAction::ReloadEntry => &["revert entry", "refresh entry"],
UiAction::EditEntry => &["modify entry"],
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::Lock => &["secure", "log out", "relock"],
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("c" | "C") => Some(UiAction::CopyField),
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("k" | "K") => Some(UiAction::CommandPalette),
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
@@ -501,6 +571,7 @@ mod tests {
focused_sensitive: true,
focused_generatable: true,
entry_path: true,
selected_object: true,
}
}
@@ -554,6 +625,18 @@ mod tests {
] {
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(
UiAction::Refresh,
ActionContext {
@@ -591,6 +674,11 @@ mod tests {
UiAction::EditEntry,
UiAction::GeneratePassword,
UiAction::ToggleReveal,
UiAction::Find,
UiAction::SearchContents,
UiAction::MoveEntry,
UiAction::CopyEntry,
UiAction::DeleteEntry,
] {
assert!(!enabled(action, switching), "{action:?}");
}
@@ -662,6 +750,13 @@ mod tests {
Some(expected)
);
}
assert_eq!(
shortcut_action(
&keyboard::Key::Character("f".into()),
primary | keyboard::Modifiers::SHIFT,
),
Some(UiAction::SearchContents)
);
assert_eq!(
shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE),
Some(UiAction::Help)

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@ impl NativeMenu {
submenu.append(&PredefinedMenuItem::select_all(None))?;
submenu.append(&PredefinedMenuItem::separator())?;
append_action(&submenu, UiAction::Find, context, &mut items)?;
append_action(&submenu, UiAction::SearchContents, 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::Paste => (command, Code::KeyV),
UiAction::Find => (command, Code::KeyF),
UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF),
UiAction::CommandPalette => (command, Code::KeyK),
UiAction::Refresh => (command, Code::KeyR),
UiAction::Lock => (command, Code::KeyL),
@@ -139,6 +141,9 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::ReloadEntry
| UiAction::EditEntry
| UiAction::GeneratePassword
| UiAction::MoveEntry
| UiAction::CopyEntry
| UiAction::DeleteEntry
| UiAction::ToggleReveal
| UiAction::Minimize => return None,
};

View File

@@ -2,7 +2,10 @@
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)]
pub enum NavigationKey {
@@ -86,6 +89,20 @@ impl NavigationTree {
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 {
let rows = self.rows();
let Some(index) = selected_index(&rows, self.selected.as_ref()) else {
@@ -131,6 +148,19 @@ impl NavigationTree {
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 {
let Some(selected) = self.selected.clone() else {
return NavigationIntent::None;
@@ -274,6 +304,21 @@ fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec<TreeNode
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)]
pub(crate) struct TestNode {
pub id: TreeNodeId,
@@ -365,6 +410,24 @@ mod tests {
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]
fn refresh_preserves_valid_typed_identity_and_clears_removed_state() {
let mut tree = NavigationTree::default();

View File

@@ -166,6 +166,13 @@ mod tests {
matches("generate").first(),
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!(
matches(""),
action::ACTIONS