1052 lines
36 KiB
Rust
1052 lines
36 KiB
Rust
//! One desktop action registry shared by every presentation entry point.
|
|
|
|
use iced::keyboard::{self, key::Named};
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum DesktopPlatform {
|
|
MacOs,
|
|
Linux,
|
|
Windows,
|
|
}
|
|
|
|
impl DesktopPlatform {
|
|
pub const fn current() -> Self {
|
|
if cfg!(target_os = "macos") {
|
|
Self::MacOs
|
|
} else if cfg!(target_os = "windows") {
|
|
Self::Windows
|
|
} else {
|
|
Self::Linux
|
|
}
|
|
}
|
|
|
|
const fn primary_modifier(self) -> keyboard::Modifiers {
|
|
match self {
|
|
Self::MacOs => keyboard::Modifiers::LOGO,
|
|
Self::Linux | Self::Windows => keyboard::Modifiers::CTRL,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
pub enum UiAction {
|
|
About,
|
|
Settings,
|
|
InitializeStore,
|
|
NewFolder,
|
|
NewEntry,
|
|
OpenFolder,
|
|
OpenEntry,
|
|
Save,
|
|
CloseWindow,
|
|
Quit,
|
|
Undo,
|
|
Redo,
|
|
Cut,
|
|
CopyField,
|
|
CopyEditedField,
|
|
Paste,
|
|
Find,
|
|
SearchContents,
|
|
CommandPalette,
|
|
TogglePaneFocus,
|
|
Refresh,
|
|
ReloadEntry,
|
|
EditEntry,
|
|
GeneratePassword,
|
|
MoveEntry,
|
|
CopyEntry,
|
|
DeleteEntry,
|
|
GenerateOtp,
|
|
CopyOtp,
|
|
ImportOtp,
|
|
ImportKdbx,
|
|
ShowOtpUri,
|
|
CopyOtpUri,
|
|
ShowOtpQr,
|
|
RemoveOtp,
|
|
GitStatus,
|
|
GitPull,
|
|
GitPush,
|
|
GitSync,
|
|
Lock,
|
|
Minimize,
|
|
Help,
|
|
}
|
|
|
|
impl UiAction {
|
|
pub const fn id(self) -> &'static str {
|
|
match self {
|
|
Self::About => "about",
|
|
Self::Settings => "settings",
|
|
Self::InitializeStore => "initialize-store",
|
|
Self::NewFolder => "new-folder",
|
|
Self::NewEntry => "new-entry",
|
|
Self::OpenFolder => "open-folder",
|
|
Self::OpenEntry => "open-entry",
|
|
Self::Save => "save",
|
|
Self::CloseWindow => "close-window",
|
|
Self::Quit => "quit",
|
|
Self::Undo => "undo",
|
|
Self::Redo => "redo",
|
|
Self::Cut => "cut",
|
|
Self::CopyField => "copy-field",
|
|
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::GenerateOtp => "generate-otp",
|
|
Self::CopyOtp => "copy-otp",
|
|
Self::ImportOtp => "import-otp",
|
|
Self::ImportKdbx => "import-kdbx",
|
|
Self::ShowOtpUri => "show-otp-uri",
|
|
Self::CopyOtpUri => "copy-otp-uri",
|
|
Self::ShowOtpQr => "show-otp-qr",
|
|
Self::RemoveOtp => "remove-otp",
|
|
Self::GitStatus => "git-status",
|
|
Self::GitPull => "git-pull",
|
|
Self::GitPush => "git-push",
|
|
Self::GitSync => "git-sync",
|
|
Self::Lock => "lock",
|
|
Self::Minimize => "minimize",
|
|
Self::Help => "help",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
pub enum MenuGroup {
|
|
App,
|
|
File,
|
|
Edit,
|
|
View,
|
|
Entry,
|
|
Tools,
|
|
Window,
|
|
Help,
|
|
}
|
|
|
|
impl MenuGroup {
|
|
pub const ALL: [Self; 8] = [
|
|
Self::App,
|
|
Self::File,
|
|
Self::Edit,
|
|
Self::View,
|
|
Self::Entry,
|
|
Self::Tools,
|
|
Self::Window,
|
|
Self::Help,
|
|
];
|
|
|
|
pub const fn label(self) -> &'static str {
|
|
match self {
|
|
Self::App => "IronStorage",
|
|
Self::File => "File",
|
|
Self::Edit => "Edit",
|
|
Self::View => "View",
|
|
Self::Entry => "Entry",
|
|
Self::Tools => "Tools",
|
|
Self::Window => "Window",
|
|
Self::Help => "Help",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct ActionContext {
|
|
pub storage_ready: bool,
|
|
pub tree_loading: bool,
|
|
pub unlocked: bool,
|
|
pub document_open: bool,
|
|
pub editing: bool,
|
|
pub dirty: bool,
|
|
pub saving: bool,
|
|
pub switching_vault: bool,
|
|
pub modal_open: bool,
|
|
pub focused_field: bool,
|
|
pub focused_generatable: bool,
|
|
pub focused_otp: bool,
|
|
pub entry_path: bool,
|
|
pub selected_object: bool,
|
|
pub git_running: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct ActionSpec {
|
|
pub action: UiAction,
|
|
pub group: MenuGroup,
|
|
pub label: &'static str,
|
|
pub shortcut: Option<&'static str>,
|
|
}
|
|
|
|
pub const ACTIONS: &[ActionSpec] = &[
|
|
spec(UiAction::About, MenuGroup::App, "About IronStorage", None),
|
|
spec(UiAction::Settings, MenuGroup::App, "Settings…", Some("⌘,")),
|
|
spec(
|
|
UiAction::InitializeStore,
|
|
MenuGroup::File,
|
|
"Initialize Store…",
|
|
None,
|
|
),
|
|
spec(
|
|
UiAction::NewFolder,
|
|
MenuGroup::File,
|
|
"New Folder…",
|
|
Some("⇧⌘N"),
|
|
),
|
|
spec(
|
|
UiAction::Quit,
|
|
MenuGroup::App,
|
|
"Quit IronStorage",
|
|
Some("⌘Q"),
|
|
),
|
|
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
|
|
spec(
|
|
UiAction::OpenFolder,
|
|
MenuGroup::File,
|
|
"Open Folder…",
|
|
Some("⌘O"),
|
|
),
|
|
spec(UiAction::OpenEntry, MenuGroup::Entry, "Open Entry", None),
|
|
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
|
|
spec(
|
|
UiAction::CloseWindow,
|
|
MenuGroup::File,
|
|
"Close Window",
|
|
Some("⌘W"),
|
|
),
|
|
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
|
|
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
|
|
spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")),
|
|
spec(
|
|
UiAction::CopyField,
|
|
MenuGroup::Entry,
|
|
"Copy Field",
|
|
Some("⌘C"),
|
|
),
|
|
spec(
|
|
UiAction::CopyEditedField,
|
|
MenuGroup::Entry,
|
|
"Copy Edited Field",
|
|
Some("⌘C"),
|
|
),
|
|
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,
|
|
"Command Palette…",
|
|
Some("⌘K"),
|
|
),
|
|
spec(
|
|
UiAction::TogglePaneFocus,
|
|
MenuGroup::View,
|
|
"Next Pane",
|
|
Some("Tab"),
|
|
),
|
|
spec(UiAction::Refresh, MenuGroup::View, "Refresh", Some("⌘R")),
|
|
spec(
|
|
UiAction::ReloadEntry,
|
|
MenuGroup::Entry,
|
|
"Reload Entry",
|
|
Some("⇧⌘R"),
|
|
),
|
|
spec(
|
|
UiAction::EditEntry,
|
|
MenuGroup::Entry,
|
|
"Edit Entry",
|
|
Some("⌘E"),
|
|
),
|
|
spec(
|
|
UiAction::GeneratePassword,
|
|
MenuGroup::Entry,
|
|
"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::GenerateOtp,
|
|
MenuGroup::Entry,
|
|
"Generate OTP Code",
|
|
None,
|
|
),
|
|
spec(UiAction::CopyOtp, MenuGroup::Entry, "Copy OTP Code", None),
|
|
spec(
|
|
UiAction::ImportOtp,
|
|
MenuGroup::Entry,
|
|
"Import OTP URI or QR…",
|
|
None,
|
|
),
|
|
spec(UiAction::ShowOtpUri, MenuGroup::Entry, "Show OTP URI", None),
|
|
spec(UiAction::CopyOtpUri, MenuGroup::Entry, "Copy OTP URI", None),
|
|
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
|
|
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
|
|
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None),
|
|
spec(
|
|
UiAction::ImportKdbx,
|
|
MenuGroup::Tools,
|
|
"Import KeePass Database…",
|
|
None,
|
|
),
|
|
spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None),
|
|
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
|
|
spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None),
|
|
spec(UiAction::Lock, MenuGroup::Tools, "Lock", Some("⌘L")),
|
|
spec(
|
|
UiAction::Minimize,
|
|
MenuGroup::Window,
|
|
"Minimize",
|
|
Some("⌘M"),
|
|
),
|
|
spec(
|
|
UiAction::Help,
|
|
MenuGroup::Help,
|
|
"IronStorage Help",
|
|
Some("F1"),
|
|
),
|
|
];
|
|
|
|
const fn spec(
|
|
action: UiAction,
|
|
group: MenuGroup,
|
|
label: &'static str,
|
|
shortcut: Option<&'static str>,
|
|
) -> ActionSpec {
|
|
ActionSpec {
|
|
action,
|
|
group,
|
|
label,
|
|
shortcut,
|
|
}
|
|
}
|
|
|
|
pub fn spec_for(action: UiAction) -> &'static ActionSpec {
|
|
ACTIONS
|
|
.iter()
|
|
.find(|spec| spec.action == action)
|
|
.expect("every UI action is registered")
|
|
}
|
|
|
|
pub fn actions_in(group: MenuGroup) -> impl Iterator<Item = &'static ActionSpec> {
|
|
ACTIONS.iter().filter(move |spec| spec.group == group)
|
|
}
|
|
|
|
pub fn shortcut_label(action: UiAction) -> Option<String> {
|
|
shortcut_label_for(action, DesktopPlatform::current())
|
|
}
|
|
|
|
pub fn shortcut_label_for(action: UiAction, platform: DesktopPlatform) -> Option<String> {
|
|
let shortcut = spec_for(action).shortcut?;
|
|
if platform == DesktopPlatform::MacOs {
|
|
Some(shortcut.to_owned())
|
|
} else {
|
|
Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+"))
|
|
}
|
|
}
|
|
|
|
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|
if context.git_running && !matches!(action, UiAction::Lock | UiAction::Minimize) {
|
|
return false;
|
|
}
|
|
match action {
|
|
UiAction::About | UiAction::Help => !context.modal_open,
|
|
UiAction::Settings => {
|
|
context.storage_ready
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
&& !context.modal_open
|
|
}
|
|
UiAction::CommandPalette => !context.modal_open,
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => {
|
|
context.storage_ready
|
|
&& !context.dirty
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
&& !context.modal_open
|
|
}
|
|
UiAction::OpenFolder => {
|
|
context.storage_ready && !context.saving && !context.switching_vault
|
|
}
|
|
UiAction::OpenEntry => {
|
|
context.storage_ready
|
|
&& context.entry_path
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
}
|
|
UiAction::Save => {
|
|
context.unlocked
|
|
&& context.editing
|
|
&& context.dirty
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
}
|
|
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
|
|
UiAction::Minimize => true,
|
|
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
|
|
&& !context.editing
|
|
&& !context.switching_vault
|
|
&& context.focused_field
|
|
}
|
|
UiAction::CopyEditedField => {
|
|
context.unlocked && context.editing && !context.switching_vault && context.focused_field
|
|
}
|
|
UiAction::TogglePaneFocus => !context.modal_open,
|
|
UiAction::Refresh => {
|
|
context.storage_ready && !context.tree_loading && !context.switching_vault
|
|
}
|
|
UiAction::ReloadEntry => {
|
|
context.unlocked && context.document_open && !context.saving && !context.switching_vault
|
|
}
|
|
UiAction::EditEntry => {
|
|
context.unlocked
|
|
&& context.document_open
|
|
&& !context.editing
|
|
&& !context.switching_vault
|
|
}
|
|
UiAction::GeneratePassword => {
|
|
context.unlocked
|
|
&& context.editing
|
|
&& context.focused_generatable
|
|
&& !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::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp => {
|
|
context.unlocked
|
|
&& context.document_open
|
|
&& !context.editing
|
|
&& context.focused_otp
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
&& !context.modal_open
|
|
}
|
|
UiAction::ImportOtp | UiAction::ImportKdbx => {
|
|
context.storage_ready
|
|
&& context.unlocked
|
|
&& !context.dirty
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
&& !context.modal_open
|
|
}
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
|
|
context.storage_ready
|
|
&& !context.saving
|
|
&& !context.switching_vault
|
|
&& !context.git_running
|
|
&& !context.modal_open
|
|
}
|
|
UiAction::Lock => context.unlocked,
|
|
}
|
|
}
|
|
|
|
pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'static str> {
|
|
if enabled(action, context) {
|
|
return None;
|
|
}
|
|
if context.git_running && !matches!(action, UiAction::Lock | UiAction::Minimize) {
|
|
return Some("Cancel or wait for the active Git operation");
|
|
}
|
|
Some(match action {
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry
|
|
if !context.storage_ready =>
|
|
{
|
|
"Shared configuration is unavailable"
|
|
}
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.dirty => {
|
|
"Save or discard the current draft first"
|
|
}
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.saving => {
|
|
"Wait for the active save"
|
|
}
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry
|
|
if context.switching_vault =>
|
|
{
|
|
"Wait for vault validation"
|
|
}
|
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => {
|
|
"Close the current screen first"
|
|
}
|
|
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 => {
|
|
"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"
|
|
}
|
|
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::GeneratePassword if !context.unlocked => "Unlock an entry first",
|
|
UiAction::GeneratePassword if !context.editing => "Open the entry editor first",
|
|
UiAction::GeneratePassword if !context.focused_generatable => {
|
|
"Select a password or other generatable secret field first"
|
|
}
|
|
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::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp
|
|
if !context.unlocked =>
|
|
{
|
|
"Unlock an entry first"
|
|
}
|
|
UiAction::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp
|
|
if !context.document_open =>
|
|
{
|
|
"Open an entry first"
|
|
}
|
|
UiAction::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp
|
|
if !context.focused_otp =>
|
|
{
|
|
"Select an OTP URI field first"
|
|
}
|
|
UiAction::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp
|
|
if context.editing =>
|
|
{
|
|
"Finish editing the entry first"
|
|
}
|
|
UiAction::GenerateOtp
|
|
| UiAction::CopyOtp
|
|
| UiAction::ShowOtpUri
|
|
| UiAction::CopyOtpUri
|
|
| UiAction::ShowOtpQr
|
|
| UiAction::RemoveOtp => "Close the current screen first",
|
|
UiAction::ImportOtp | UiAction::ImportKdbx if !context.storage_ready => {
|
|
"Shared configuration is unavailable"
|
|
}
|
|
UiAction::ImportOtp | UiAction::ImportKdbx if !context.unlocked => {
|
|
"Unlock the password store first"
|
|
}
|
|
UiAction::ImportOtp | UiAction::ImportKdbx if context.dirty => {
|
|
"Save or discard the current draft first"
|
|
}
|
|
UiAction::ImportOtp | UiAction::ImportKdbx if context.saving => "Wait for the active save",
|
|
UiAction::ImportOtp | UiAction::ImportKdbx if context.switching_vault => {
|
|
"Wait for vault validation"
|
|
}
|
|
UiAction::ImportOtp | UiAction::ImportKdbx => "Close the current screen first",
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
|
if !context.storage_ready =>
|
|
{
|
|
"Shared configuration is unavailable"
|
|
}
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
|
if context.saving =>
|
|
{
|
|
"Wait for the active save"
|
|
}
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
|
if context.switching_vault =>
|
|
{
|
|
"Wait for vault validation"
|
|
}
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
|
if context.git_running =>
|
|
{
|
|
"A Git operation is already running"
|
|
}
|
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
|
|
"Close the current screen first"
|
|
}
|
|
UiAction::Lock => "The password store is already locked",
|
|
UiAction::CommandPalette => "Finish the current confirmation first",
|
|
UiAction::Settings if !context.storage_ready => "Shared configuration is unavailable",
|
|
UiAction::Settings if context.saving => "Wait for the active save",
|
|
UiAction::Settings if context.switching_vault => "Wait for vault validation",
|
|
UiAction::About | UiAction::Settings | UiAction::TogglePaneFocus | UiAction::Help => {
|
|
"Close the current screen first"
|
|
}
|
|
UiAction::Minimize => "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::InitializeStore => &["pass init", "recipients", "gpg id"],
|
|
UiAction::NewFolder => &["create folder", "directory", "nested recipients"],
|
|
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::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::GenerateOtp => &["totp", "hotp", "one time password"],
|
|
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
|
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
|
UiAction::ImportKdbx => &["keepass", "kdbx", "import database"],
|
|
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
|
|
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
|
|
UiAction::ShowOtpQr => &["otp qr", "export otp"],
|
|
UiAction::RemoveOtp => &["delete otp", "remove otpauth"],
|
|
UiAction::GitStatus => &["repository status", "git history"],
|
|
UiAction::GitPull => &["fetch", "download changes"],
|
|
UiAction::GitPush => &["upload changes"],
|
|
UiAction::GitSync => &["synchronize", "pull and push"],
|
|
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> {
|
|
shortcut_action_for(DesktopPlatform::current(), key, modifiers)
|
|
}
|
|
|
|
pub fn shortcut_action_for(
|
|
platform: DesktopPlatform,
|
|
key: &keyboard::Key,
|
|
modifiers: keyboard::Modifiers,
|
|
) -> Option<UiAction> {
|
|
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
|
|
return Some(UiAction::TogglePaneFocus);
|
|
}
|
|
if key.as_ref() == keyboard::Key::Named(Named::F1) {
|
|
return Some(UiAction::Help);
|
|
}
|
|
if !modifiers.contains(platform.primary_modifier()) {
|
|
return None;
|
|
}
|
|
match key.as_ref() {
|
|
keyboard::Key::Character(",") => Some(UiAction::Settings),
|
|
keyboard::Key::Character("n" | "N") if modifiers.shift() => Some(UiAction::NewFolder),
|
|
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
|
|
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder),
|
|
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
|
|
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
|
|
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
|
|
keyboard::Key::Character("z" | "Z") if modifiers.shift() => Some(UiAction::Redo),
|
|
keyboard::Key::Character("z" | "Z") => Some(UiAction::Undo),
|
|
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") if modifiers.shift() => Some(UiAction::ReloadEntry),
|
|
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
|
|
keyboard::Key::Character("e" | "E") => Some(UiAction::EditEntry),
|
|
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
|
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeSet;
|
|
|
|
use super::*;
|
|
|
|
fn context() -> ActionContext {
|
|
ActionContext {
|
|
storage_ready: true,
|
|
tree_loading: false,
|
|
unlocked: true,
|
|
document_open: true,
|
|
editing: true,
|
|
dirty: true,
|
|
saving: false,
|
|
switching_vault: false,
|
|
modal_open: false,
|
|
focused_field: true,
|
|
focused_generatable: true,
|
|
focused_otp: true,
|
|
entry_path: true,
|
|
selected_object: true,
|
|
git_running: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registry_is_complete_unique_and_grouped_for_every_platform_menu() {
|
|
let actions = ACTIONS
|
|
.iter()
|
|
.map(|spec| spec.action)
|
|
.collect::<BTreeSet<_>>();
|
|
assert_eq!(actions.len(), ACTIONS.len());
|
|
for group in MenuGroup::ALL {
|
|
assert!(actions_in(group).next().is_some(), "missing {group:?} menu");
|
|
}
|
|
for action in actions {
|
|
assert_eq!(spec_for(action).action, action);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn enablement_closes_authentication_dirty_and_loading_bypasses() {
|
|
let ready = context();
|
|
assert!(!enabled(UiAction::NewEntry, ready));
|
|
assert!(enabled(UiAction::Save, ready));
|
|
assert!(enabled(UiAction::CopyEditedField, ready));
|
|
assert!(enabled(UiAction::GeneratePassword, ready));
|
|
assert!(!enabled(UiAction::CopyField, ready));
|
|
for action in [
|
|
UiAction::GitStatus,
|
|
UiAction::GitPull,
|
|
UiAction::GitPush,
|
|
UiAction::GitSync,
|
|
] {
|
|
assert!(enabled(action, ready));
|
|
}
|
|
|
|
let viewing = ActionContext {
|
|
editing: false,
|
|
dirty: false,
|
|
..ready
|
|
};
|
|
assert!(enabled(UiAction::CopyField, viewing));
|
|
assert!(!enabled(UiAction::CopyEditedField, viewing));
|
|
assert!(enabled(UiAction::InitializeStore, viewing));
|
|
assert!(enabled(UiAction::NewFolder, viewing));
|
|
assert!(enabled(UiAction::NewEntry, viewing));
|
|
for action in [
|
|
UiAction::GenerateOtp,
|
|
UiAction::CopyOtp,
|
|
UiAction::ShowOtpUri,
|
|
UiAction::CopyOtpUri,
|
|
UiAction::ShowOtpQr,
|
|
UiAction::RemoveOtp,
|
|
] {
|
|
assert!(enabled(action, viewing), "{action:?}");
|
|
}
|
|
assert!(enabled(UiAction::ImportOtp, viewing));
|
|
|
|
let locked = ActionContext {
|
|
unlocked: false,
|
|
..ready
|
|
};
|
|
for action in [
|
|
UiAction::Save,
|
|
UiAction::CopyField,
|
|
UiAction::CopyEditedField,
|
|
UiAction::GeneratePassword,
|
|
UiAction::GenerateOtp,
|
|
UiAction::CopyOtp,
|
|
UiAction::ImportOtp,
|
|
UiAction::ShowOtpUri,
|
|
UiAction::CopyOtpUri,
|
|
UiAction::ShowOtpQr,
|
|
UiAction::RemoveOtp,
|
|
UiAction::Lock,
|
|
] {
|
|
assert!(!enabled(action, locked));
|
|
}
|
|
for action in [
|
|
UiAction::Find,
|
|
UiAction::SearchContents,
|
|
UiAction::MoveEntry,
|
|
UiAction::CopyEntry,
|
|
UiAction::DeleteEntry,
|
|
UiAction::GitPull,
|
|
UiAction::GitPush,
|
|
UiAction::GitSync,
|
|
] {
|
|
assert!(
|
|
enabled(action, locked),
|
|
"{action:?} can initiate authentication"
|
|
);
|
|
}
|
|
assert!(!enabled(
|
|
UiAction::Refresh,
|
|
ActionContext {
|
|
tree_loading: true,
|
|
..ready
|
|
}
|
|
));
|
|
assert!(!enabled(
|
|
UiAction::Save,
|
|
ActionContext {
|
|
dirty: false,
|
|
..ready
|
|
}
|
|
));
|
|
assert!(!enabled(
|
|
UiAction::OpenFolder,
|
|
ActionContext {
|
|
switching_vault: true,
|
|
..ready
|
|
}
|
|
));
|
|
let switching = ActionContext {
|
|
switching_vault: true,
|
|
..ready
|
|
};
|
|
for action in [
|
|
UiAction::Settings,
|
|
UiAction::Save,
|
|
UiAction::CloseWindow,
|
|
UiAction::Quit,
|
|
UiAction::CopyField,
|
|
UiAction::CopyEditedField,
|
|
UiAction::Refresh,
|
|
UiAction::ReloadEntry,
|
|
UiAction::EditEntry,
|
|
UiAction::GeneratePassword,
|
|
UiAction::Find,
|
|
UiAction::SearchContents,
|
|
UiAction::MoveEntry,
|
|
UiAction::CopyEntry,
|
|
UiAction::DeleteEntry,
|
|
] {
|
|
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")
|
|
);
|
|
|
|
let git_running = ActionContext {
|
|
git_running: true,
|
|
..ready
|
|
};
|
|
assert!(enabled(UiAction::Lock, git_running));
|
|
assert!(enabled(UiAction::Minimize, git_running));
|
|
assert!(!enabled(UiAction::GitStatus, git_running));
|
|
assert!(!enabled(UiAction::CloseWindow, git_running));
|
|
assert_eq!(
|
|
disabled_reason(UiAction::CloseWindow, git_running),
|
|
Some("Cancel or wait for the active Git operation")
|
|
);
|
|
let modal = ActionContext {
|
|
modal_open: true,
|
|
..ready
|
|
};
|
|
for action in [
|
|
UiAction::About,
|
|
UiAction::Settings,
|
|
UiAction::TogglePaneFocus,
|
|
UiAction::Help,
|
|
] {
|
|
assert!(!enabled(action, modal), "{action:?}");
|
|
}
|
|
assert!(!enabled(
|
|
UiAction::Settings,
|
|
ActionContext {
|
|
storage_ready: false,
|
|
..ready
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn conventional_shortcuts_resolve_on_every_supported_platform() {
|
|
for (platform, primary, label) in [
|
|
(DesktopPlatform::MacOs, keyboard::Modifiers::LOGO, "⌘S"),
|
|
(DesktopPlatform::Linux, keyboard::Modifiers::CTRL, "Ctrl+S"),
|
|
(
|
|
DesktopPlatform::Windows,
|
|
keyboard::Modifiers::CTRL,
|
|
"Ctrl+S",
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
shortcut_label_for(UiAction::Save, platform).as_deref(),
|
|
Some(label)
|
|
);
|
|
for (key, expected) in [
|
|
("o", UiAction::OpenFolder),
|
|
("s", UiAction::Save),
|
|
("f", UiAction::Find),
|
|
("k", UiAction::CommandPalette),
|
|
("n", UiAction::NewEntry),
|
|
("w", UiAction::CloseWindow),
|
|
("q", UiAction::Quit),
|
|
("x", UiAction::Cut),
|
|
("c", UiAction::CopyField),
|
|
("v", UiAction::Paste),
|
|
(",", UiAction::Settings),
|
|
] {
|
|
assert_eq!(
|
|
shortcut_action_for(platform, &keyboard::Key::Character(key.into()), primary,),
|
|
Some(expected)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
shortcut_action_for(
|
|
platform,
|
|
&keyboard::Key::Character("f".into()),
|
|
primary | keyboard::Modifiers::SHIFT,
|
|
),
|
|
Some(UiAction::SearchContents)
|
|
);
|
|
assert_eq!(
|
|
shortcut_action_for(
|
|
platform,
|
|
&keyboard::Key::Named(Named::F1),
|
|
keyboard::Modifiers::NONE,
|
|
),
|
|
Some(UiAction::Help)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
shortcut_action_for(
|
|
DesktopPlatform::MacOs,
|
|
&keyboard::Key::Character("s".into()),
|
|
keyboard::Modifiers::CTRL,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1"));
|
|
}
|
|
}
|