diff --git a/crates/ely_app/src/shell/command_actions.rs b/crates/ely_app/src/shell/command_actions.rs index 8b7a1bd..9f9a0ce 100644 --- a/crates/ely_app/src/shell/command_actions.rs +++ b/crates/ely_app/src/shell/command_actions.rs @@ -11,6 +11,12 @@ enum SpaceFileCommand { ImportPreservingProfiles, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutFileCommand { + Export, + Import, +} + impl ElyShell { pub(super) fn handle_shell_command_intent( &mut self, @@ -36,6 +42,12 @@ impl ElyShell { } None => {} } + + match shortcut_file_command(command) { + Some(ShortcutFileCommand::Export) => self.export_shortcuts(window, cx), + Some(ShortcutFileCommand::Import) => self.choose_shortcut_import(window, cx), + None => {} + } } } @@ -66,9 +78,24 @@ fn space_file_command(command: &str) -> Option { } } +fn shortcut_file_command(command: &str) -> Option { + match command.trim().to_ascii_lowercase().as_str() { + "export-shortcuts" | "export shortcuts" | "export-keybindings" | "export keybindings" => { + Some(ShortcutFileCommand::Export) + } + "import-shortcuts" | "import shortcuts" | "import-keybindings" | "import keybindings" => { + Some(ShortcutFileCommand::Import) + } + _ => None, + } +} + #[cfg(test)] mod tests { - use super::{SpaceFileCommand, install_plugin_from_file_command, space_file_command}; + use super::{ + ShortcutFileCommand, SpaceFileCommand, install_plugin_from_file_command, + shortcut_file_command, space_file_command, + }; #[test] fn install_plugin_from_file_command_matches_prd_aliases() { @@ -101,4 +128,10 @@ mod tests { assert_eq!(space_file_command("new-space Research"), None); assert_eq!(space_file_command("spaces"), None); } + + #[test] + fn shortcut_file_command_matches_export_and_import_aliases() { + assert_eq!(shortcut_file_command("export-shortcuts"), Some(ShortcutFileCommand::Export)); + assert_eq!(shortcut_file_command("import keybindings"), Some(ShortcutFileCommand::Import)); + } } diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 970e060..c212357 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -88,7 +88,7 @@ impl ElyShell { "ely://settings/site-permissions" => { self.render_site_permissions_settings_page(snapshot, cx) } - "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), + "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot, cx), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), "ely://settings/sync" => self.render_sync_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/shortcuts.rs b/crates/ely_app/src/shell/internal_pages/shortcuts.rs index 738d4b3..71e3522 100644 --- a/crates/ely_app/src/shell/internal_pages/shortcuts.rs +++ b/crates/ely_app/src/shell/internal_pages/shortcuts.rs @@ -1,11 +1,14 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; -use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; use crate::shortcuts::{ - SHORTCUT_ACTIONS, ShortcutAction, ShortcutConflict, ShortcutPlatform, bindings_for_action, - shortcut_conflicts, + SHORTCUT_ACTIONS, ShortcutAction, ShortcutConflict, ShortcutPlatform, ShortcutProfile, }; use super::{ElyShell, render_canvas_surface}; @@ -13,8 +16,12 @@ use super::{ElyShell, render_canvas_surface}; const SHORTCUT_CATEGORIES: &[&str] = &["Command", "Tabs", "Library", "System", "Application"]; impl ElyShell { - pub(super) fn render_shortcuts_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { - let conflicts = shortcut_conflicts(); + pub(super) fn render_shortcuts_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + let conflicts = self.shortcut_profile.conflicts(); render_canvas_surface( div() @@ -23,14 +30,22 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_shortcuts_header(snapshot, conflicts.len())) + .child(render_shortcuts_header(snapshot, conflicts.len(), cx)) + .child(render_shortcut_file_message( + self.shortcut_file_notice.as_deref(), + self.shortcut_file_error.as_deref(), + )) .child(render_conflict_panel(&conflicts)) - .child(render_shortcut_categories(&conflicts)), + .child(render_shortcut_categories(&self.shortcut_profile, &conflicts)), ) } } -fn render_shortcuts_header(snapshot: &BrowserSnapshot, conflict_count: usize) -> AnyElement { +fn render_shortcuts_header( + snapshot: &BrowserSnapshot, + conflict_count: usize, + cx: &mut Context, +) -> AnyElement { let status = if conflict_count == 0 { "Ready".to_string() } else { @@ -62,15 +77,69 @@ fn render_shortcuts_header(snapshot: &BrowserSnapshot, conflict_count: usize) -> .flex() .items_center() .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(shortcut_status_color(conflict_count))) - .child(shortcut_status_icon(conflict_count)) - .child(status), + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(shortcut_status_color(conflict_count))) + .child(shortcut_status_icon(conflict_count)) + .child(status), + ) + .child( + Button::new("export-shortcuts") + .ghost() + .xsmall() + .icon(IconName::ArrowUp) + .label("Export") + .tooltip("Export Shortcuts") + .on_click(cx.listener(|shell, _, window, cx| { + shell.export_shortcuts(window, cx); + })), + ) + .child( + Button::new("import-shortcuts") + .ghost() + .xsmall() + .icon(IconName::ArrowDown) + .label("Import") + .tooltip("Import Shortcuts") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_shortcut_import(window, cx); + })), + ), ) .into_any_element() } +fn render_shortcut_file_message(notice: Option<&str>, error: Option<&str>) -> AnyElement { + let Some((message, color, icon)) = error + .map(|message| (message, colors::ERROR, IconName::TriangleAlert)) + .or_else(|| notice.map(|message| (message, colors::SUCCESS, IconName::CircleCheck))) + else { + return div().hidden().into_any_element(); + }; + + div() + .rounded_md() + .border_1() + .border_color(rgb(color)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_2() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(color)) + .child(icon) + .child(message.to_string()) + .into_any_element() +} + fn render_conflict_panel(conflicts: &[ShortcutConflict]) -> AnyElement { let (icon, title, detail, color) = if conflicts.is_empty() { ( @@ -132,7 +201,10 @@ fn render_conflict_panel(conflicts: &[ShortcutConflict]) -> AnyElement { .into_any_element() } -fn render_shortcut_categories(conflicts: &[ShortcutConflict]) -> AnyElement { +fn render_shortcut_categories( + profile: &ShortcutProfile, + conflicts: &[ShortcutConflict], +) -> AnyElement { div() .flex_1() .min_h_0() @@ -144,12 +216,16 @@ fn render_shortcut_categories(conflicts: &[ShortcutConflict]) -> AnyElement { .children( SHORTCUT_CATEGORIES .iter() - .map(|category| render_shortcut_category(category, conflicts)), + .map(|category| render_shortcut_category(profile, category, conflicts)), ) .into_any_element() } -fn render_shortcut_category(category: &'static str, conflicts: &[ShortcutConflict]) -> AnyElement { +fn render_shortcut_category( + profile: &ShortcutProfile, + category: &'static str, + conflicts: &[ShortcutConflict], +) -> AnyElement { div() .flex() .flex_col() @@ -159,7 +235,7 @@ fn render_shortcut_category(category: &'static str, conflicts: &[ShortcutConflic .iter() .copied() .filter(move |action| action.category() == category) - .map(|action| render_shortcut_row(action, conflicts)), + .map(|action| render_shortcut_row(profile, action, conflicts)), ) .into_any_element() } @@ -175,7 +251,11 @@ fn render_category_header(category: &'static str) -> AnyElement { .into_any_element() } -fn render_shortcut_row(action: ShortcutAction, conflicts: &[ShortcutConflict]) -> AnyElement { +fn render_shortcut_row( + profile: &ShortcutProfile, + action: ShortcutAction, + conflicts: &[ShortcutConflict], +) -> AnyElement { let has_conflict = conflicts .iter() .any(|conflict| conflict.actions.iter().any(|conflict_action| conflict_action == &action)); @@ -229,14 +309,18 @@ fn render_shortcut_row(action: ShortcutAction, conflicts: &[ShortcutConflict]) - .justify_end() .gap_3() .text_xs() - .child(shortcut_platform_label(action, ShortcutPlatform::Macos)) - .child(shortcut_platform_label(action, ShortcutPlatform::WindowsLinux)) + .child(shortcut_platform_label(profile, action, ShortcutPlatform::Macos)) + .child(shortcut_platform_label(profile, action, ShortcutPlatform::WindowsLinux)) .child(shortcut_row_status(has_conflict)), ) .into_any_element() } -fn shortcut_platform_label(action: ShortcutAction, platform: ShortcutPlatform) -> AnyElement { +fn shortcut_platform_label( + profile: &ShortcutProfile, + action: ShortcutAction, + platform: ShortcutPlatform, +) -> AnyElement { div() .min_w(px(170.0)) .flex() @@ -247,7 +331,7 @@ fn shortcut_platform_label(action: ShortcutAction, platform: ShortcutPlatform) - div() .font_semibold() .text_color(rgb(colors::INK)) - .child(shortcut_keys_label(action, platform)), + .child(profile.display_bindings_for_action(action, platform)), ) .into_any_element() } @@ -259,18 +343,6 @@ fn shortcut_row_status(has_conflict: bool) -> AnyElement { div().min_w(px(72.0)).font_semibold().text_color(rgb(color)).child(label).into_any_element() } -fn shortcut_keys_label(action: ShortcutAction, platform: ShortcutPlatform) -> String { - let bindings = bindings_for_action(action, platform) - .map(|binding| binding.display_keystroke()) - .collect::>(); - - if bindings.is_empty() { - return "Unassigned".to_string(); - } - - bindings.join(" / ") -} - fn shortcut_status_color(conflict_count: usize) -> u32 { if conflict_count == 0 { colors::SUCCESS } else { colors::ERROR } } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 15a042f..1d0e5cc 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -11,6 +11,7 @@ mod plugins; mod reading_list; mod render; mod settings_actions; +mod shortcut_files; mod sidebar; mod site_permissions; mod space_files; @@ -33,6 +34,7 @@ use ely_domain::{ProfileId, SpaceId, TabId}; use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window}; use gpui_component::input::{InputEvent, InputState}; +use crate::shortcuts::ShortcutProfile; use bookmarks::PendingBookmarkEdit; use downloads::PendingDownloadFileAction; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; @@ -64,6 +66,9 @@ pub struct ElyShell { pending_space_trash: Option, space_file_error: Option, space_file_notice: Option, + shortcut_file_error: Option, + shortcut_file_notice: Option, + shortcut_profile: ShortcutProfile, pending_bookmark_edit: Option, bookmark_edit_error: Option, plugin_install_error: Option, @@ -146,6 +151,9 @@ impl ElyShell { pending_space_trash: None, space_file_error: None, space_file_notice: None, + shortcut_file_error: None, + shortcut_file_notice: None, + shortcut_profile: ShortcutProfile::default_profile(), pending_bookmark_edit: None, bookmark_edit_error: None, plugin_install_error: None, diff --git a/crates/ely_app/src/shell/shortcut_files.rs b/crates/ely_app/src/shell/shortcut_files.rs new file mode 100644 index 0000000..3047ac6 --- /dev/null +++ b/crates/ely_app/src/shell/shortcut_files.rs @@ -0,0 +1,235 @@ +use std::{ + borrow::BorrowMut, + fs, + path::{Path, PathBuf}, +}; + +use directories::UserDirs; +use gpui::{App, Context, PathPromptOptions, Window}; + +use crate::shortcuts::{ + ELYKEYS_FILE_EXTENSION, ShortcutProfile, parse_shortcut_profile_json, + shortcut_rebinding_key_bindings, +}; + +use super::ElyShell; + +const SHORTCUT_SETTINGS_URL: &str = "ely://settings/shortcuts"; + +impl ElyShell { + pub(super) fn export_shortcuts(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_shortcut_settings_surface(window, cx); + self.clear_shortcut_file_message(); + + let profile_json = match self.shortcut_profile.to_json() { + Ok(profile_json) => profile_json, + Err(error) => { + self.set_shortcut_file_error(error.to_string(), cx); + return; + } + }; + let directory = match default_export_directory() { + Ok(directory) => directory, + Err(error) => { + self.set_shortcut_file_error(error, cx); + return; + } + }; + let prompt = cx.prompt_for_new_path(&directory, Some("ELY Shortcuts.elykeys")); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(path)) => path, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_shortcut_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_shortcut_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { write_shortcut_profile(path, profile_json) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_shortcut_export_result(result, cx); + }); + }) + .detach(); + } + + pub(super) fn choose_shortcut_import(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_shortcut_settings_surface(window, cx); + self.clear_shortcut_file_message(); + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select .elykeys file".into()), + }); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(Some(paths))) => paths.into_iter().next(), + Ok(Ok(None)) => None, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_shortcut_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_shortcut_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { read_shortcut_profile(path) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_shortcut_import_result(result, cx); + }); + }) + .detach(); + } + + fn ensure_shortcut_settings_surface(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_matches_url(SHORTCUT_SETTINGS_URL) { + return; + } + + self.open_internal_tab(SHORTCUT_SETTINGS_URL, window, cx); + } + + fn clear_shortcut_file_message(&mut self) { + self.shortcut_file_error = None; + self.shortcut_file_notice = None; + } + + fn set_shortcut_file_error(&mut self, message: String, cx: &mut Context) { + self.shortcut_file_error = Some(message); + self.shortcut_file_notice = None; + cx.notify(); + } + + fn set_shortcut_file_notice(&mut self, message: String, cx: &mut Context) { + self.shortcut_file_notice = Some(message); + self.shortcut_file_error = None; + cx.notify(); + } + + fn handle_shortcut_export_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + match result { + Ok(path) => self.set_shortcut_file_notice(format!("Exported {}", path.display()), cx), + Err(error) => self.set_shortcut_file_error(error, cx), + } + } + + fn handle_shortcut_import_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + let profile = match result { + Ok(profile) => profile, + Err(error) => { + self.set_shortcut_file_error(error, cx); + return; + } + }; + + let previous_profile = self.shortcut_profile.clone(); + let app: &mut App = cx.borrow_mut(); + app.bind_keys(shortcut_rebinding_key_bindings(&previous_profile, &profile)); + self.shortcut_profile = profile; + self.set_shortcut_file_notice("Imported shortcut profile".to_string(), cx); + } +} + +fn default_export_directory() -> Result { + UserDirs::new() + .and_then(|dirs| dirs.document_dir().map(Path::to_path_buf)) + .ok_or_else(|| "Documents directory is unavailable.".to_string()) +} + +fn write_shortcut_profile(path: PathBuf, profile_json: String) -> Result { + let path = normalize_export_path(path)?; + fs::write(&path, profile_json) + .map_err(|error| format!("Unable to write {}: {error}", path.display()))?; + Ok(path) +} + +fn read_shortcut_profile(path: PathBuf) -> Result { + if !path_has_elykeys_extension(&path) { + return Err("Selected file must use .elykeys extension.".to_string()); + } + + let profile_json = fs::read_to_string(&path) + .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; + parse_shortcut_profile_json(&profile_json).map_err(|error| error.to_string()) +} + +fn normalize_export_path(mut path: PathBuf) -> Result { + if path.extension().is_none() { + path.set_extension(ELYKEYS_FILE_EXTENSION); + return Ok(path); + } + + if path_has_elykeys_extension(&path) { + Ok(path) + } else { + Err("Export path must use .elykeys extension.".to_string()) + } +} + +fn path_has_elykeys_extension(path: &Path) -> bool { + path.extension().is_some_and(|extension| { + extension.to_string_lossy().eq_ignore_ascii_case(ELYKEYS_FILE_EXTENSION) + }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::normalize_export_path; + + #[test] + fn normalize_export_path_adds_elykeys_extension() -> Result<(), String> { + let path = normalize_export_path(PathBuf::from("ELY Shortcuts"))?; + + assert_eq!(path, PathBuf::from("ELY Shortcuts.elykeys")); + Ok(()) + } + + #[test] + fn normalize_export_path_rejects_other_extensions() { + let error = normalize_export_path(PathBuf::from("shortcuts.json")); + + assert_eq!(error, Err("Export path must use .elykeys extension.".to_string())); + } +} diff --git a/crates/ely_app/src/shortcuts.rs b/crates/ely_app/src/shortcuts.rs index 8fa1e95..171e939 100644 --- a/crates/ely_app/src/shortcuts.rs +++ b/crates/ely_app/src/shortcuts.rs @@ -1,6 +1,12 @@ -use std::collections::BTreeMap; - use gpui::{App, KeyBinding}; +use serde::{Deserialize, Serialize}; + +mod profile; + +pub(crate) use profile::{ + ELYKEYS_FILE_EXTENSION, ShortcutProfile, parse_shortcut_profile_json, + shortcut_rebinding_key_bindings, +}; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, @@ -9,7 +15,8 @@ use crate::{ ToggleSidebar, }; -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] pub(crate) enum ShortcutPlatform { Macos, WindowsLinux, @@ -24,7 +31,8 @@ impl ShortcutPlatform { } } -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] pub(crate) enum ShortcutAction { FocusAddressBar, FocusCommandMode, @@ -122,15 +130,23 @@ pub(crate) struct ShortcutBinding { } impl ShortcutBinding { - pub(crate) fn display_keystroke(self) -> String { - display_keystroke(self.keystroke, self.platform) + pub(crate) fn action(self) -> ShortcutAction { + self.action + } + + pub(crate) fn platform(self) -> ShortcutPlatform { + self.platform + } + + pub(crate) fn keystroke(self) -> &'static str { + self.keystroke } } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ShortcutConflict { pub(crate) platform: ShortcutPlatform, - pub(crate) keystroke: &'static str, + pub(crate) keystroke: String, pub(crate) actions: Vec, } @@ -209,81 +225,38 @@ pub(crate) fn bind_shortcuts(cx: &mut App) { cx.bind_keys(SHORTCUT_BINDINGS.iter().map(|binding| binding.key_binding())); } -pub(crate) fn bindings_for_action( - action: ShortcutAction, - platform: ShortcutPlatform, -) -> impl Iterator { - SHORTCUT_BINDINGS - .iter() - .copied() - .filter(move |binding| binding.action == action && binding.platform == platform) -} - -pub(crate) fn shortcut_conflicts() -> Vec { - let mut bindings_by_key: BTreeMap<(ShortcutPlatform, &'static str), Vec> = - BTreeMap::new(); - - for binding in SHORTCUT_BINDINGS { - bindings_by_key - .entry((binding.platform, binding.keystroke)) - .or_default() - .push(binding.action); - } - - bindings_by_key - .into_iter() - .filter_map(|((platform, keystroke), actions)| { - (actions.len() > 1).then_some(ShortcutConflict { platform, keystroke, actions }) - }) - .collect() -} - impl ShortcutBinding { fn key_binding(self) -> KeyBinding { - match self.action { - ShortcutAction::CloseCurrentTab => { - KeyBinding::new(self.keystroke, CloseCurrentTab, None) - } - ShortcutAction::FocusAddressBar => { - KeyBinding::new(self.keystroke, FocusAddressBar, None) - } - ShortcutAction::FocusCommandMode => { - KeyBinding::new(self.keystroke, FocusCommandMode, None) - } - ShortcutAction::OpenDownloads => KeyBinding::new(self.keystroke, OpenDownloads, None), - ShortcutAction::OpenHistory => KeyBinding::new(self.keystroke, OpenHistory, None), - ShortcutAction::OpenNewTab => KeyBinding::new(self.keystroke, OpenNewTab, None), - ShortcutAction::OpenPrivateWindow => { - KeyBinding::new(self.keystroke, OpenPrivateWindow, None) - } - ShortcutAction::OpenSettings => KeyBinding::new(self.keystroke, OpenSettings, None), - ShortcutAction::OpenTaskManager => { - KeyBinding::new(self.keystroke, OpenTaskManager, None) - } - ShortcutAction::Quit => KeyBinding::new(self.keystroke, Quit, None), - ShortcutAction::RestoreClosedTab => { - KeyBinding::new(self.keystroke, RestoreClosedTab, None) - } - ShortcutAction::SelectNextSpace => { - KeyBinding::new(self.keystroke, SelectNextSpace, None) - } - ShortcutAction::SelectNextTab => KeyBinding::new(self.keystroke, SelectNextTab, None), - ShortcutAction::SelectPreviousSpace => { - KeyBinding::new(self.keystroke, SelectPreviousSpace, None) - } - ShortcutAction::SelectPreviousTab => { - KeyBinding::new(self.keystroke, SelectPreviousTab, None) - } - ShortcutAction::SplitRight => KeyBinding::new(self.keystroke, SplitRight, None), - ShortcutAction::ToggleFavoriteTab => { - KeyBinding::new(self.keystroke, ToggleFavoriteTab, None) - } - ShortcutAction::ToggleSidebar => KeyBinding::new(self.keystroke, ToggleSidebar, None), - } + key_binding_for_action(self.action, self.keystroke) } } -fn display_keystroke(keystroke: &str, platform: ShortcutPlatform) -> String { +pub(crate) fn key_binding_for_action(action: ShortcutAction, keystroke: &str) -> KeyBinding { + match action { + ShortcutAction::CloseCurrentTab => KeyBinding::new(keystroke, CloseCurrentTab, None), + ShortcutAction::FocusAddressBar => KeyBinding::new(keystroke, FocusAddressBar, None), + ShortcutAction::FocusCommandMode => KeyBinding::new(keystroke, FocusCommandMode, None), + ShortcutAction::OpenDownloads => KeyBinding::new(keystroke, OpenDownloads, None), + ShortcutAction::OpenHistory => KeyBinding::new(keystroke, OpenHistory, None), + ShortcutAction::OpenNewTab => KeyBinding::new(keystroke, OpenNewTab, None), + ShortcutAction::OpenPrivateWindow => KeyBinding::new(keystroke, OpenPrivateWindow, None), + ShortcutAction::OpenSettings => KeyBinding::new(keystroke, OpenSettings, None), + ShortcutAction::OpenTaskManager => KeyBinding::new(keystroke, OpenTaskManager, None), + ShortcutAction::Quit => KeyBinding::new(keystroke, Quit, None), + ShortcutAction::RestoreClosedTab => KeyBinding::new(keystroke, RestoreClosedTab, None), + ShortcutAction::SelectNextSpace => KeyBinding::new(keystroke, SelectNextSpace, None), + ShortcutAction::SelectNextTab => KeyBinding::new(keystroke, SelectNextTab, None), + ShortcutAction::SelectPreviousSpace => { + KeyBinding::new(keystroke, SelectPreviousSpace, None) + } + ShortcutAction::SelectPreviousTab => KeyBinding::new(keystroke, SelectPreviousTab, None), + ShortcutAction::SplitRight => KeyBinding::new(keystroke, SplitRight, None), + ShortcutAction::ToggleFavoriteTab => KeyBinding::new(keystroke, ToggleFavoriteTab, None), + ShortcutAction::ToggleSidebar => KeyBinding::new(keystroke, ToggleSidebar, None), + } +} + +pub(crate) fn display_keystroke(keystroke: &str, platform: ShortcutPlatform) -> String { keystroke .split('-') .map(|part| display_key_part(part, platform)) @@ -306,73 +279,39 @@ fn display_key_part(part: &str, platform: ShortcutPlatform) -> String { #[cfg(test)] mod tests { use super::{ - SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutPlatform, bindings_for_action, - shortcut_conflicts, + SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutPlatform, ShortcutProfile, }; #[test] fn registered_shortcuts_have_no_conflicts() { - assert_eq!(shortcut_conflicts(), Vec::new()); + assert_eq!(ShortcutProfile::default_profile().conflicts(), Vec::new()); } #[test] fn open_settings_shortcut_has_platform_bindings() { - let bindings = bindings_for_action(ShortcutAction::OpenSettings, ShortcutPlatform::Macos) - .chain(bindings_for_action( - ShortcutAction::OpenSettings, - ShortcutPlatform::WindowsLinux, - )) - .map(|binding| binding.display_keystroke()) - .collect::>(); + let bindings = platform_labels(ShortcutAction::OpenSettings); assert_eq!(bindings, vec!["Cmd + ,".to_string(), "Ctrl + ,".to_string()]); } #[test] fn private_window_shortcut_has_platform_bindings() { - let bindings = - bindings_for_action(ShortcutAction::OpenPrivateWindow, ShortcutPlatform::Macos) - .chain(bindings_for_action( - ShortcutAction::OpenPrivateWindow, - ShortcutPlatform::WindowsLinux, - )) - .map(|binding| binding.display_keystroke()) - .collect::>(); + let bindings = platform_labels(ShortcutAction::OpenPrivateWindow); assert_eq!(bindings, vec!["Cmd + Shift + N".to_string(), "Ctrl + Shift + N".to_string()]); } #[test] fn toggle_sidebar_shortcut_has_platform_bindings() { - let bindings = bindings_for_action(ShortcutAction::ToggleSidebar, ShortcutPlatform::Macos) - .chain(bindings_for_action( - ShortcutAction::ToggleSidebar, - ShortcutPlatform::WindowsLinux, - )) - .map(|binding| binding.display_keystroke()) - .collect::>(); + let bindings = platform_labels(ShortcutAction::ToggleSidebar); assert_eq!(bindings, vec!["Cmd + B".to_string(), "Ctrl + B".to_string()]); } #[test] fn space_switch_shortcuts_have_platform_bindings() { - let next_bindings = - bindings_for_action(ShortcutAction::SelectNextSpace, ShortcutPlatform::Macos) - .chain(bindings_for_action( - ShortcutAction::SelectNextSpace, - ShortcutPlatform::WindowsLinux, - )) - .map(|binding| binding.display_keystroke()) - .collect::>(); - let previous_bindings = - bindings_for_action(ShortcutAction::SelectPreviousSpace, ShortcutPlatform::Macos) - .chain(bindings_for_action( - ShortcutAction::SelectPreviousSpace, - ShortcutPlatform::WindowsLinux, - )) - .map(|binding| binding.display_keystroke()) - .collect::>(); + let next_bindings = platform_labels(ShortcutAction::SelectNextSpace); + let previous_bindings = platform_labels(ShortcutAction::SelectPreviousSpace); assert_eq!( next_bindings, @@ -387,7 +326,15 @@ mod tests { #[test] fn every_declared_action_has_a_binding() { for action in SHORTCUT_ACTIONS { - assert!(SHORTCUT_BINDINGS.iter().any(|binding| binding.action == *action)); + assert!(SHORTCUT_BINDINGS.iter().any(|binding| binding.action() == *action)); } } + + fn platform_labels(action: ShortcutAction) -> Vec { + let profile = ShortcutProfile::default_profile(); + [ShortcutPlatform::Macos, ShortcutPlatform::WindowsLinux] + .into_iter() + .map(|platform| profile.display_bindings_for_action(action, platform)) + .collect() + } } diff --git a/crates/ely_app/src/shortcuts/profile.rs b/crates/ely_app/src/shortcuts/profile.rs new file mode 100644 index 0000000..02bf2d3 --- /dev/null +++ b/crates/ely_app/src/shortcuts/profile.rs @@ -0,0 +1,308 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gpui::{KeyBinding, Keystroke, NoAction}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutConflict, ShortcutPlatform, + display_keystroke, key_binding_for_action, +}; + +pub(crate) const ELYKEYS_FILE_EXTENSION: &str = "elykeys"; +const SHORTCUT_PROFILE_SCHEMA_VERSION: u16 = 1; +const SHORTCUT_PLATFORMS: &[ShortcutPlatform] = + &[ShortcutPlatform::Macos, ShortcutPlatform::WindowsLinux]; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ShortcutProfile { + schema_version: u16, + bindings: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ShortcutProfileBinding { + action: ShortcutAction, + platform: ShortcutPlatform, + keystrokes: Vec, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub(crate) enum ShortcutProfileError { + #[error("Shortcut profile schema version {actual} is unsupported.")] + UnsupportedSchemaVersion { actual: u16 }, + #[error("Shortcut profile has duplicate entry for {platform} {action}.")] + DuplicateEntry { action: &'static str, platform: &'static str }, + #[error("Shortcut profile is missing entry for {platform} {action}.")] + MissingEntry { action: &'static str, platform: &'static str }, + #[error("Shortcut profile has empty key for {platform} {action}.")] + EmptyKeystroke { action: &'static str, platform: &'static str }, + #[error("Shortcut profile key `{keystroke}` for {platform} {action} is invalid.")] + InvalidKeystroke { action: &'static str, platform: &'static str, keystroke: String }, + #[error("Shortcut profile repeats `{keystroke}` for {platform} {action}.")] + DuplicateKeystroke { action: &'static str, platform: &'static str, keystroke: String }, + #[error("Shortcut profile maps `{keystroke}` on {platform} to multiple actions.")] + ConflictingKeystroke { platform: &'static str, keystroke: String }, + #[error("Shortcut profile JSON is invalid: {0}")] + InvalidJson(String), +} + +impl Default for ShortcutProfile { + fn default() -> Self { + Self::default_profile() + } +} + +impl ShortcutProfile { + pub(crate) fn default_profile() -> Self { + let mut bindings = Vec::new(); + + for action in SHORTCUT_ACTIONS { + for platform in SHORTCUT_PLATFORMS { + bindings.push(ShortcutProfileBinding { + action: *action, + platform: *platform, + keystrokes: default_keystrokes(*action, *platform), + }); + } + } + + Self { schema_version: SHORTCUT_PROFILE_SCHEMA_VERSION, bindings } + } + + pub(crate) fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + .map_err(|error| ShortcutProfileError::InvalidJson(error.to_string())) + } + + pub(crate) fn bindings_for_action( + &self, + action: ShortcutAction, + platform: ShortcutPlatform, + ) -> &[String] { + self.bindings + .iter() + .find(|binding| binding.action == action && binding.platform == platform) + .map_or(&[], |binding| binding.keystrokes.as_slice()) + } + + pub(crate) fn display_bindings_for_action( + &self, + action: ShortcutAction, + platform: ShortcutPlatform, + ) -> String { + let bindings = self + .bindings_for_action(action, platform) + .iter() + .map(|keystroke| display_keystroke(keystroke, platform)) + .collect::>(); + + if bindings.is_empty() { "Unassigned".to_string() } else { bindings.join(" / ") } + } + + pub(crate) fn conflicts(&self) -> Vec { + let mut bindings_by_key: BTreeMap<(ShortcutPlatform, &str), Vec> = + BTreeMap::new(); + + for binding in &self.bindings { + for keystroke in &binding.keystrokes { + bindings_by_key + .entry((binding.platform, keystroke.as_str())) + .or_default() + .push(binding.action); + } + } + + bindings_by_key + .into_iter() + .filter_map(|((platform, keystroke), actions)| { + (actions.len() > 1).then_some(ShortcutConflict { + platform, + keystroke: keystroke.to_string(), + actions, + }) + }) + .collect() + } + + fn validate(mut self) -> Result { + if self.schema_version != SHORTCUT_PROFILE_SCHEMA_VERSION { + return Err(ShortcutProfileError::UnsupportedSchemaVersion { + actual: self.schema_version, + }); + } + + let mut seen_entries = BTreeSet::new(); + let mut bindings_by_key = BTreeMap::<(ShortcutPlatform, String), ShortcutAction>::new(); + + for binding in &mut self.bindings { + if !seen_entries.insert((binding.action, binding.platform)) { + return Err(ShortcutProfileError::DuplicateEntry { + action: binding.action.label(), + platform: binding.platform.label(), + }); + } + + normalize_keystrokes(binding)?; + for keystroke in &binding.keystrokes { + if let Some(existing_action) = + bindings_by_key.insert((binding.platform, keystroke.clone()), binding.action) + && existing_action != binding.action + { + return Err(ShortcutProfileError::ConflictingKeystroke { + platform: binding.platform.label(), + keystroke: keystroke.clone(), + }); + } + } + } + + for action in SHORTCUT_ACTIONS { + for platform in SHORTCUT_PLATFORMS { + if !seen_entries.contains(&(*action, *platform)) { + return Err(ShortcutProfileError::MissingEntry { + action: action.label(), + platform: platform.label(), + }); + } + } + } + + self.bindings.sort_by_key(|binding| (binding.action, binding.platform)); + Ok(self) + } +} + +pub(crate) fn parse_shortcut_profile_json( + profile_json: &str, +) -> Result { + serde_json::from_str::(profile_json) + .map_err(|error| ShortcutProfileError::InvalidJson(error.to_string()))? + .validate() +} + +pub(crate) fn key_bindings_for_profile(profile: &ShortcutProfile) -> Vec { + profile + .bindings + .iter() + .flat_map(|binding| { + binding + .keystrokes + .iter() + .map(move |keystroke| key_binding_for_action(binding.action, keystroke)) + }) + .collect() +} + +pub(crate) fn shortcut_rebinding_key_bindings( + previous: &ShortcutProfile, + next: &ShortcutProfile, +) -> Vec { + let mut removed_keys = BTreeSet::new(); + + for binding in SHORTCUT_BINDINGS { + removed_keys.insert(binding.keystroke().to_string()); + } + for binding in &previous.bindings { + for keystroke in &binding.keystrokes { + removed_keys.insert(keystroke.clone()); + } + } + + let mut key_bindings = removed_keys + .into_iter() + .map(|keystroke| KeyBinding::new(&keystroke, NoAction {}, None)) + .collect::>(); + key_bindings.extend(key_bindings_for_profile(next)); + key_bindings +} + +fn default_keystrokes(action: ShortcutAction, platform: ShortcutPlatform) -> Vec { + let mut keystrokes = SHORTCUT_BINDINGS + .iter() + .filter(|binding| binding.action() == action && binding.platform() == platform) + .map(|binding| binding.keystroke().to_string()) + .collect::>(); + keystrokes.sort(); + keystrokes +} + +fn normalize_keystrokes(binding: &mut ShortcutProfileBinding) -> Result<(), ShortcutProfileError> { + let mut seen_keystrokes = BTreeSet::new(); + + for keystroke in &mut binding.keystrokes { + let normalized = keystroke.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(ShortcutProfileError::EmptyKeystroke { + action: binding.action.label(), + platform: binding.platform.label(), + }); + } + if Keystroke::parse(&normalized).is_err() { + return Err(ShortcutProfileError::InvalidKeystroke { + action: binding.action.label(), + platform: binding.platform.label(), + keystroke: keystroke.clone(), + }); + } + if !seen_keystrokes.insert(normalized.clone()) { + return Err(ShortcutProfileError::DuplicateKeystroke { + action: binding.action.label(), + platform: binding.platform.label(), + keystroke: normalized, + }); + } + *keystroke = normalized; + } + + binding.keystrokes.sort(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ShortcutProfile, ShortcutProfileError, parse_shortcut_profile_json}; + use serde_json::Value; + + #[test] + fn default_shortcut_profile_round_trips_as_json() -> Result<(), ShortcutProfileError> { + let profile = ShortcutProfile::default_profile(); + let json = profile.to_json()?; + let parsed = parse_shortcut_profile_json(&json)?; + + assert_eq!(parsed, profile); + Ok(()) + } + + #[test] + fn shortcut_profile_rejects_conflicting_keys() -> Result<(), ShortcutProfileError> { + let profile = ShortcutProfile::default_profile(); + let json = profile.to_json()?.replace("\"ctrl-h\"", "\"ctrl-shift-j\""); + + let error = parse_shortcut_profile_json(&json); + + assert!(matches!(error, Err(ShortcutProfileError::ConflictingKeystroke { .. }))); + Ok(()) + } + + #[test] + fn shortcut_profile_rejects_missing_entries() -> Result<(), ShortcutProfileError> { + let profile = ShortcutProfile::default_profile(); + let mut value = serde_json::from_str::(&profile.to_json()?) + .map_err(|error| ShortcutProfileError::InvalidJson(error.to_string()))?; + let bindings = value + .get_mut("bindings") + .and_then(Value::as_array_mut) + .ok_or_else(|| ShortcutProfileError::InvalidJson("missing bindings".to_string()))?; + bindings.pop(); + let json = serde_json::to_string(&value) + .map_err(|error| ShortcutProfileError::InvalidJson(error.to_string()))?; + + let error = parse_shortcut_profile_json(&json); + + assert!(matches!(error, Err(ShortcutProfileError::MissingEntry { .. }))); + Ok(()) + } +}