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

@@ -7,6 +7,7 @@ mod folder_picker;
#[cfg(target_os = "macos")]
mod native_menu;
mod navigation;
mod palette;
use std::{
path::PathBuf,
@@ -43,6 +44,7 @@ use ironstorage::{
write::WriteOutcome,
};
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
use palette::CommandPalette;
use zeroize::Zeroizing;
use action::{ActionContext, MenuGroup, UiAction};
@@ -58,6 +60,9 @@ enum Message {
Action(UiAction),
FieldAction(EntryFieldId, UiAction),
ToggleMenu(MenuGroup),
PaletteQueryChanged(String),
PaletteCancel,
PaletteInvoke(UiAction),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>),
@@ -186,6 +191,7 @@ struct App {
status: String,
open_menu: Option<MenuGroup>,
utility: Option<UtilityView>,
palette: CommandPalette,
#[cfg(target_os = "macos")]
native_menu: Option<NativeMenu>,
}
@@ -292,6 +298,7 @@ impl App {
status: "Loading shared configuration…".to_owned(),
open_menu: None,
utility: None,
palette: CommandPalette::default(),
#[cfg(target_os = "macos")]
native_menu: None,
};
@@ -310,7 +317,16 @@ impl App {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Action(action) => return self.invoke_action(action),
Message::Action(UiAction::CommandPalette) => return self.toggle_palette(),
Message::Action(action) => {
if self.palette.is_open() {
self.palette.close();
let restore = self.restore_focus();
let action = self.invoke_action(action);
return Task::batch([restore, action]);
}
return self.invoke_action(action);
}
Message::FieldAction(id, action) => {
if let Some(editor) = self.editor.as_mut() {
editor.select(id);
@@ -320,6 +336,21 @@ impl App {
Message::ToggleMenu(group) => {
self.open_menu = (self.open_menu != Some(group)).then_some(group);
}
Message::PaletteQueryChanged(query) => {
self.palette.update_query(query);
return iced::widget::operation::snap_to(
command_palette_scroll_id(),
scrollable::RelativeOffset::START,
);
}
Message::PaletteCancel => {
self.touch_user_activity();
if self.palette.is_open() {
self.palette.close();
return self.restore_focus();
}
}
Message::PaletteInvoke(action) => return self.invoke_palette_action(action),
Message::DismissUtility => self.utility = None,
Message::WindowResolved(action, id) => {
let Some(id) = id else {
@@ -436,6 +467,33 @@ impl App {
}
Message::SidebarNavigate(key) => {
self.touch_user_activity();
if self.palette.is_open() {
return match key {
NavigationKey::Previous => {
self.palette
.move_selection(false, self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Next => {
self.palette
.move_selection(true, self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Activate => self
.palette
.selected_action()
.map_or_else(Task::none, |action| self.invoke_palette_action(action)),
NavigationKey::First => {
self.palette.select_first();
self.scroll_palette_selection()
}
NavigationKey::Last => {
self.palette.select_last(self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Collapse | NavigationKey::Expand => Task::none(),
};
}
match self.pane_focus {
PaneFocus::Sidebar => {
let intent = self.navigation.navigate(key);
@@ -708,6 +766,7 @@ impl App {
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving,
switching_vault: self.switching_vault,
modal_open: self.confirmation.is_some() || self.generate_confirmation.is_some(),
focused_field: focused.is_some(),
focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -740,6 +799,7 @@ impl App {
UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::CommandPalette => return self.toggle_palette(),
UiAction::OpenFolder => {
let initial = self
.storage
@@ -778,6 +838,62 @@ impl App {
Task::none()
}
fn toggle_palette(&mut self) -> Task<Message> {
if self.palette.is_open() {
self.palette.close();
return self.restore_focus();
}
let context = self.action_context();
if let Some(reason) = action::disabled_reason(UiAction::CommandPalette, context) {
self.status = reason.to_owned();
return Task::none();
}
self.open_menu = None;
self.utility = None;
self.palette.open();
iced::widget::operation::focus(command_palette_input_id())
}
fn invoke_palette_action(&mut self, selected: UiAction) -> Task<Message> {
if !self.palette.is_open() {
return Task::none();
}
let context = self.action_context();
if let Some(reason) = action::disabled_reason(selected, context) {
self.status = format!("{}: {reason}.", action::spec_for(selected).label);
return Task::none();
}
self.palette.close();
let restore = self.restore_focus();
let action = self.invoke_action(selected);
Task::batch([restore, action])
}
fn scroll_palette_selection(&self) -> Task<Message> {
let result_count = self.palette.results().len();
let y = if result_count <= 1 {
0.0
} else {
self.palette.selected() as f32 / (result_count - 1) as f32
};
iced::widget::operation::snap_to(
command_palette_scroll_id(),
scrollable::RelativeOffset { x: 0.0, y },
)
}
fn restore_focus(&self) -> Task<Message> {
match self.pane_focus {
PaneFocus::Sidebar => iced::widget::operation::focus_next(),
PaneFocus::Content => iced::widget::operation::focus(
self.editor
.as_ref()
.and_then(EntryEditor::focused)
.map_or_else(content_focus_id, editor_field_input_id),
),
}
}
fn begin_tree_refresh(&mut self) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
@@ -1088,31 +1204,46 @@ impl App {
.min_size(220)
.on_resize(8, Message::PaneResized);
container(
column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
platform_menu_bar(self),
panes,
]
.height(Length::Fill),
let shortcut = if cfg!(target_os = "macos") {
"⌘K"
} else {
"Ctrl+K"
};
let command_input = text_input(
&format!("Search commands ({shortcut})"),
self.palette.query(),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
.id(command_palette_input_id())
.on_input(Message::PaletteQueryChanged)
.width(Length::Fixed(280.0));
let mut chrome = column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
command_input,
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
platform_menu_bar(self),
];
if self.palette.is_open() {
chrome = chrome.push(command_palette_results(self));
}
chrome = chrome.push(panes);
container(chrome.height(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
@@ -1124,6 +1255,66 @@ fn viewer_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-viewer")
}
fn command_palette_input_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-command-palette")
}
fn command_palette_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-command-palette-results")
}
fn content_focus_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-path")
}
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
format!("desktop-entry-field-{}", id.value()).into()
}
fn command_palette_results(app: &App) -> Element<'_, Message> {
let context = app.action_context();
let results = app.palette.results();
if results.is_empty() {
return container(text("No matching commands"))
.padding([8, 12])
.width(Length::Fill)
.into();
}
let mut rows = column![].spacing(2).padding([4, 8]);
for (index, action) in results.into_iter().enumerate() {
let spec = action::spec_for(action);
let shortcut = action::shortcut_label(action).unwrap_or_default();
let reason = action::disabled_reason(action, context);
let availability = reason.unwrap_or("Available");
let content = row![
text(spec.label).width(Length::Fill),
text(shortcut).size(13),
text(availability).size(13).width(Length::Fixed(250.0)),
]
.spacing(12);
let item = button(content)
.width(Length::Fill)
.style(if index == app.palette.selected() {
button::primary
} else {
button::text
});
rows = rows.push(if reason.is_none() {
item.on_press(Message::PaletteInvoke(action))
} else {
item
});
}
container(
scrollable(rows)
.id(command_palette_scroll_id())
.height(Length::Fixed(240.0)),
)
.width(Length::Fill)
.into()
}
fn platform_menu_bar(app: &App) -> Element<'_, Message> {
if cfg!(target_os = "macos") {
return container(row![]).height(Length::Fixed(0.0)).into();
@@ -1318,6 +1509,7 @@ fn content_view(app: &App) -> Element<'_, Message> {
})
.size(20),
text_input("Entry path", &app.entry_path)
.id(content_focus_id())
.on_input(Message::EntryPathChanged)
.on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
@@ -1549,6 +1741,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
},
value.unwrap_or(""),
)
.id(editor_field_input_id(id))
.secure(sensitive && !editor.is_revealed(id))
.on_input_maybe(
value
@@ -1696,6 +1889,9 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
fn event_message(event: &Event) -> Option<Message> {
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) {
return Some(Message::PaletteCancel);
}
if let Some(action) = action::shortcut_action(key, *modifiers) {
return Some(Message::Action(action));
}
@@ -1940,6 +2136,7 @@ mod tests {
status: String::new(),
open_menu: None,
utility: None,
palette: CommandPalette::default(),
#[cfg(target_os = "macos")]
native_menu: None,
}
@@ -2195,6 +2392,64 @@ mod tests {
assert!(app.status.contains("Wait for vault validation"));
}
#[test]
fn command_palette_focus_matching_execution_cancellation_and_availability_are_shared() {
let mut app = test_app(None);
app.pane_focus = PaneFocus::Content;
let _focus = app.update(Message::Action(UiAction::CommandPalette));
assert!(app.palette.is_open());
assert_eq!(app.palette.selected(), 0);
let _task = app.update(Message::SidebarNavigate(NavigationKey::Next));
assert_eq!(app.palette.selected(), 1);
let _task = app.update(Message::SidebarNavigate(NavigationKey::Previous));
assert_eq!(app.palette.selected(), 0);
let _task = app.update(Message::PaletteQueryChanged("about".to_owned()));
assert_eq!(app.palette.selected_action(), Some(UiAction::About));
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
assert!(!app.palette.is_open());
assert_eq!(app.utility, Some(UtilityView::About));
assert_eq!(app.pane_focus, PaneFocus::Content);
app.utility = None;
let _task = app.update(Message::Action(UiAction::CommandPalette));
let _task = app.update(Message::PaletteQueryChanged("lock".to_owned()));
assert_eq!(app.palette.selected_action(), Some(UiAction::Lock));
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
assert!(app.palette.is_open());
assert!(app.status.contains("already locked"));
let _restore = app.update(Message::PaletteCancel);
assert!(!app.palette.is_open());
assert!(app.palette.query().is_empty());
assert_eq!(app.pane_focus, PaneFocus::Content);
app.confirmation = Some(PendingAction::CloseWindow(window::Id::unique()));
let _task = app.update(Message::Action(UiAction::CommandPalette));
assert!(!app.palette.is_open());
assert!(app.status.contains("Finish the current confirmation"));
app.confirmation = None;
let _task = app.update(Message::Action(UiAction::CommandPalette));
let _task = app.update(Message::PaletteInvoke(UiAction::Settings));
assert!(!app.palette.is_open());
assert_eq!(app.utility, Some(UtilityView::Settings));
let escape = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::NONE,
text: None,
repeat: false,
});
assert!(matches!(
event_message(&escape),
Some(Message::PaletteCancel)
));
}
#[test]
fn vault_switch_result_replaces_state_only_after_storage_success() {
let (_temporary, storage) = fixture_storage();