Implement the desktop command palette

This commit is contained in:
2026-08-10 17:39:27 +02:00
parent 491d7b1557
commit 44e1b86f47
4 changed files with 595 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ pub enum UiAction {
CopyEditedField,
Paste,
Find,
CommandPalette,
TogglePaneFocus,
Refresh,
ReloadEntry,
@@ -47,6 +48,7 @@ impl UiAction {
Self::CopyEditedField => "copy-edited-field",
Self::Paste => "paste",
Self::Find => "find",
Self::CommandPalette => "command-palette",
Self::TogglePaneFocus => "toggle-pane-focus",
Self::Refresh => "refresh",
Self::ReloadEntry => "reload-entry",
@@ -107,6 +109,7 @@ pub struct ActionContext {
pub dirty: bool,
pub saving: bool,
pub switching_vault: bool,
pub modal_open: bool,
pub focused_field: bool,
pub focused_sensitive: bool,
pub entry_path: bool,
@@ -156,6 +159,12 @@ pub const ACTIONS: &[ActionSpec] = &[
),
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
spec(
UiAction::CommandPalette,
MenuGroup::View,
"Command Palette…",
Some("⌘K"),
),
spec(
UiAction::TogglePaneFocus,
MenuGroup::View,
@@ -228,6 +237,7 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
match action {
UiAction::About | UiAction::Settings | UiAction::Help => true,
UiAction::CommandPalette => !context.modal_open,
// Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false,
UiAction::OpenFolder => {
@@ -282,6 +292,95 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
}
}
pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'static str> {
if enabled(action, context) {
return None;
}
Some(match action {
UiAction::NewEntry => "Entry creation is not available yet",
UiAction::OpenFolder if !context.storage_ready => "Shared configuration is unavailable",
UiAction::OpenFolder if context.saving => "Wait for the active save",
UiAction::OpenFolder => "Wait for vault validation",
UiAction::OpenEntry if !context.storage_ready => "Shared configuration is unavailable",
UiAction::OpenEntry if !context.entry_path => "Enter or select an entry path",
UiAction::OpenEntry if context.saving => "Wait for the active save",
UiAction::OpenEntry => "Wait for vault validation",
UiAction::Save if !context.unlocked => "Unlock the current entry first",
UiAction::Save if !context.editing => "Open an entry editor first",
UiAction::Save if !context.dirty => "The editor has no changes",
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 => {
"Use the focused native text field"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.unlocked => {
"Unlock an entry first"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.document_open => {
"Open an entry first"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.focused_field => {
"Select a field first"
}
UiAction::CopyField | UiAction::CopyEditedField if context.switching_vault => {
"Wait for vault validation"
}
UiAction::CopyField => "Use Copy Edited Field while editing",
UiAction::CopyEditedField => "Open the entry editor first",
UiAction::Refresh if !context.storage_ready => "Shared configuration is unavailable",
UiAction::Refresh if context.tree_loading => "A refresh is already running",
UiAction::Refresh => "Wait for vault validation",
UiAction::ReloadEntry if !context.unlocked => "Unlock an entry first",
UiAction::ReloadEntry if !context.document_open => "Open an entry first",
UiAction::ReloadEntry if context.saving => "Wait for the active save",
UiAction::ReloadEntry => "Wait for vault validation",
UiAction::EditEntry if !context.unlocked => "Unlock an entry first",
UiAction::EditEntry if !context.document_open => "Open an entry first",
UiAction::EditEntry if context.editing => "The entry editor is already open",
UiAction::EditEntry => "Wait for vault validation",
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",
UiAction::ToggleReveal => "Wait for vault validation",
UiAction::Lock => "The password store is already locked",
UiAction::CommandPalette => "Finish the current confirmation first",
UiAction::About
| UiAction::Settings
| UiAction::TogglePaneFocus
| UiAction::Minimize
| UiAction::Help => "Action is unavailable",
})
}
pub const fn aliases(action: UiAction) -> &'static [&'static str] {
match action {
UiAction::About => &["version", "license", "credits"],
UiAction::Settings => &["preferences", "configuration", "config"],
UiAction::NewEntry => &["insert", "add password", "create entry"],
UiAction::OpenFolder => &["open vault", "open store", "choose folder"],
UiAction::OpenEntry => &["show entry", "view password"],
UiAction::Save => &["write", "save entry"],
UiAction::CloseWindow => &["close"],
UiAction::Quit => &["exit"],
UiAction::Undo => &["revert edit"],
UiAction::Redo => &["repeat edit"],
UiAction::Cut => &["remove selection"],
UiAction::CopyField | UiAction::CopyEditedField => &["copy value", "clipboard"],
UiAction::Paste => &["insert clipboard"],
UiAction::Find => &["search text"],
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::ToggleReveal => &["show password", "hide password", "reveal field"],
UiAction::Lock => &["secure", "log out", "relock"],
UiAction::Minimize => &["hide window"],
UiAction::Help => &["shortcuts", "documentation"],
}
}
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
return Some(UiAction::TogglePaneFocus);
@@ -305,6 +404,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette),
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
@@ -328,6 +428,7 @@ mod tests {
dirty: true,
saving: false,
switching_vault: false,
modal_open: false,
focused_field: true,
focused_sensitive: true,
entry_path: true,
@@ -417,6 +518,32 @@ mod tests {
] {
assert!(!enabled(action, switching), "{action:?}");
}
for spec in ACTIONS {
if !enabled(spec.action, ready) {
assert!(
disabled_reason(spec.action, ready).is_some(),
"{:?}",
spec.action
);
}
}
assert!(!enabled(
UiAction::CommandPalette,
ActionContext {
modal_open: true,
..ready
}
));
assert_eq!(
disabled_reason(
UiAction::CommandPalette,
ActionContext {
modal_open: true,
..ready
}
),
Some("Finish the current confirmation first")
);
}
#[test]
@@ -426,6 +553,7 @@ mod tests {
("o", UiAction::OpenFolder),
("s", UiAction::Save),
("f", UiAction::Find),
("k", UiAction::CommandPalette),
("n", UiAction::NewEntry),
("w", UiAction::CloseWindow),
("q", UiAction::Quit),