Add shortcut profile import export
This commit is contained in:
@@ -11,6 +11,12 @@ enum SpaceFileCommand {
|
|||||||
ImportPreservingProfiles,
|
ImportPreservingProfiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum ShortcutFileCommand {
|
||||||
|
Export,
|
||||||
|
Import,
|
||||||
|
}
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub(super) fn handle_shell_command_intent(
|
pub(super) fn handle_shell_command_intent(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -36,6 +42,12 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
None => {}
|
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<SpaceFileCommand> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shortcut_file_command(command: &str) -> Option<ShortcutFileCommand> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn install_plugin_from_file_command_matches_prd_aliases() {
|
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("new-space Research"), None);
|
||||||
assert_eq!(space_file_command("spaces"), 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ impl ElyShell {
|
|||||||
"ely://settings/site-permissions" => {
|
"ely://settings/site-permissions" => {
|
||||||
self.render_site_permissions_settings_page(snapshot, cx)
|
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/plugins" => self.render_plugins_page(snapshot, cx),
|
||||||
"ely://settings/profiles" => self.render_profiles_page(snapshot, cx),
|
"ely://settings/profiles" => self.render_profiles_page(snapshot, cx),
|
||||||
"ely://settings/sync" => self.render_sync_page(snapshot, cx),
|
"ely://settings/sync" => self.render_sync_page(snapshot, cx),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use ely_browser_core::BrowserSnapshot;
|
use ely_browser_core::BrowserSnapshot;
|
||||||
use ely_design_system::colors;
|
use ely_design_system::colors;
|
||||||
use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb};
|
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
|
||||||
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
|
use gpui_component::{
|
||||||
|
IconName, Sizable, StyledExt,
|
||||||
|
button::{Button, ButtonVariants},
|
||||||
|
scroll::ScrollableElement,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::shortcuts::{
|
use crate::shortcuts::{
|
||||||
SHORTCUT_ACTIONS, ShortcutAction, ShortcutConflict, ShortcutPlatform, bindings_for_action,
|
SHORTCUT_ACTIONS, ShortcutAction, ShortcutConflict, ShortcutPlatform, ShortcutProfile,
|
||||||
shortcut_conflicts,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ElyShell, render_canvas_surface};
|
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"];
|
const SHORTCUT_CATEGORIES: &[&str] = &["Command", "Tabs", "Library", "System", "Application"];
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub(super) fn render_shortcuts_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement {
|
pub(super) fn render_shortcuts_page(
|
||||||
let conflicts = shortcut_conflicts();
|
&mut self,
|
||||||
|
snapshot: &BrowserSnapshot,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let conflicts = self.shortcut_profile.conflicts();
|
||||||
|
|
||||||
render_canvas_surface(
|
render_canvas_surface(
|
||||||
div()
|
div()
|
||||||
@@ -23,14 +30,22 @@ impl ElyShell {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_5()
|
.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_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<ElyShell>,
|
||||||
|
) -> AnyElement {
|
||||||
let status = if conflict_count == 0 {
|
let status = if conflict_count == 0 {
|
||||||
"Ready".to_string()
|
"Ready".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -62,15 +77,69 @@ fn render_shortcuts_header(snapshot: &BrowserSnapshot, conflict_count: usize) ->
|
|||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.text_xs()
|
.child(
|
||||||
.font_semibold()
|
div()
|
||||||
.text_color(rgb(shortcut_status_color(conflict_count)))
|
.flex()
|
||||||
.child(shortcut_status_icon(conflict_count))
|
.items_center()
|
||||||
.child(status),
|
.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()
|
.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 {
|
fn render_conflict_panel(conflicts: &[ShortcutConflict]) -> AnyElement {
|
||||||
let (icon, title, detail, color) = if conflicts.is_empty() {
|
let (icon, title, detail, color) = if conflicts.is_empty() {
|
||||||
(
|
(
|
||||||
@@ -132,7 +201,10 @@ fn render_conflict_panel(conflicts: &[ShortcutConflict]) -> AnyElement {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_shortcut_categories(conflicts: &[ShortcutConflict]) -> AnyElement {
|
fn render_shortcut_categories(
|
||||||
|
profile: &ShortcutProfile,
|
||||||
|
conflicts: &[ShortcutConflict],
|
||||||
|
) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
@@ -144,12 +216,16 @@ fn render_shortcut_categories(conflicts: &[ShortcutConflict]) -> AnyElement {
|
|||||||
.children(
|
.children(
|
||||||
SHORTCUT_CATEGORIES
|
SHORTCUT_CATEGORIES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|category| render_shortcut_category(category, conflicts)),
|
.map(|category| render_shortcut_category(profile, category, conflicts)),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.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()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -159,7 +235,7 @@ fn render_shortcut_category(category: &'static str, conflicts: &[ShortcutConflic
|
|||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.filter(move |action| action.category() == category)
|
.filter(move |action| action.category() == category)
|
||||||
.map(|action| render_shortcut_row(action, conflicts)),
|
.map(|action| render_shortcut_row(profile, action, conflicts)),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -175,7 +251,11 @@ fn render_category_header(category: &'static str) -> AnyElement {
|
|||||||
.into_any_element()
|
.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
|
let has_conflict = conflicts
|
||||||
.iter()
|
.iter()
|
||||||
.any(|conflict| conflict.actions.iter().any(|conflict_action| conflict_action == &action));
|
.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()
|
.justify_end()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(shortcut_platform_label(action, ShortcutPlatform::Macos))
|
.child(shortcut_platform_label(profile, action, ShortcutPlatform::Macos))
|
||||||
.child(shortcut_platform_label(action, ShortcutPlatform::WindowsLinux))
|
.child(shortcut_platform_label(profile, action, ShortcutPlatform::WindowsLinux))
|
||||||
.child(shortcut_row_status(has_conflict)),
|
.child(shortcut_row_status(has_conflict)),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shortcut_platform_label(action: ShortcutAction, platform: ShortcutPlatform) -> AnyElement {
|
fn shortcut_platform_label(
|
||||||
|
profile: &ShortcutProfile,
|
||||||
|
action: ShortcutAction,
|
||||||
|
platform: ShortcutPlatform,
|
||||||
|
) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.min_w(px(170.0))
|
.min_w(px(170.0))
|
||||||
.flex()
|
.flex()
|
||||||
@@ -247,7 +331,7 @@ fn shortcut_platform_label(action: ShortcutAction, platform: ShortcutPlatform) -
|
|||||||
div()
|
div()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(rgb(colors::INK))
|
.text_color(rgb(colors::INK))
|
||||||
.child(shortcut_keys_label(action, platform)),
|
.child(profile.display_bindings_for_action(action, platform)),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.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()
|
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::<Vec<_>>();
|
|
||||||
|
|
||||||
if bindings.is_empty() {
|
|
||||||
return "Unassigned".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
bindings.join(" / ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shortcut_status_color(conflict_count: usize) -> u32 {
|
fn shortcut_status_color(conflict_count: usize) -> u32 {
|
||||||
if conflict_count == 0 { colors::SUCCESS } else { colors::ERROR }
|
if conflict_count == 0 { colors::SUCCESS } else { colors::ERROR }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod plugins;
|
|||||||
mod reading_list;
|
mod reading_list;
|
||||||
mod render;
|
mod render;
|
||||||
mod settings_actions;
|
mod settings_actions;
|
||||||
|
mod shortcut_files;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod site_permissions;
|
mod site_permissions;
|
||||||
mod space_files;
|
mod space_files;
|
||||||
@@ -33,6 +34,7 @@ use ely_domain::{ProfileId, SpaceId, TabId};
|
|||||||
use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window};
|
use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window};
|
||||||
use gpui_component::input::{InputEvent, InputState};
|
use gpui_component::input::{InputEvent, InputState};
|
||||||
|
|
||||||
|
use crate::shortcuts::ShortcutProfile;
|
||||||
use bookmarks::PendingBookmarkEdit;
|
use bookmarks::PendingBookmarkEdit;
|
||||||
use downloads::PendingDownloadFileAction;
|
use downloads::PendingDownloadFileAction;
|
||||||
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
|
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
|
||||||
@@ -64,6 +66,9 @@ pub struct ElyShell {
|
|||||||
pending_space_trash: Option<SpaceId>,
|
pending_space_trash: Option<SpaceId>,
|
||||||
space_file_error: Option<String>,
|
space_file_error: Option<String>,
|
||||||
space_file_notice: Option<String>,
|
space_file_notice: Option<String>,
|
||||||
|
shortcut_file_error: Option<String>,
|
||||||
|
shortcut_file_notice: Option<String>,
|
||||||
|
shortcut_profile: ShortcutProfile,
|
||||||
pending_bookmark_edit: Option<PendingBookmarkEdit>,
|
pending_bookmark_edit: Option<PendingBookmarkEdit>,
|
||||||
bookmark_edit_error: Option<String>,
|
bookmark_edit_error: Option<String>,
|
||||||
plugin_install_error: Option<String>,
|
plugin_install_error: Option<String>,
|
||||||
@@ -146,6 +151,9 @@ impl ElyShell {
|
|||||||
pending_space_trash: None,
|
pending_space_trash: None,
|
||||||
space_file_error: None,
|
space_file_error: None,
|
||||||
space_file_notice: None,
|
space_file_notice: None,
|
||||||
|
shortcut_file_error: None,
|
||||||
|
shortcut_file_notice: None,
|
||||||
|
shortcut_profile: ShortcutProfile::default_profile(),
|
||||||
pending_bookmark_edit: None,
|
pending_bookmark_edit: None,
|
||||||
bookmark_edit_error: None,
|
bookmark_edit_error: None,
|
||||||
plugin_install_error: None,
|
plugin_install_error: None,
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
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>) {
|
||||||
|
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<Self>) {
|
||||||
|
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>) {
|
||||||
|
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>) {
|
||||||
|
self.shortcut_file_notice = Some(message);
|
||||||
|
self.shortcut_file_error = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_shortcut_export_result(
|
||||||
|
&mut self,
|
||||||
|
result: Result<PathBuf, String>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<ShortcutProfile, String>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<ShortcutProfile, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-119
@@ -1,6 +1,12 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use gpui::{App, KeyBinding};
|
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::{
|
use crate::{
|
||||||
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
||||||
@@ -9,7 +15,8 @@ use crate::{
|
|||||||
ToggleSidebar,
|
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 {
|
pub(crate) enum ShortcutPlatform {
|
||||||
Macos,
|
Macos,
|
||||||
WindowsLinux,
|
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 {
|
pub(crate) enum ShortcutAction {
|
||||||
FocusAddressBar,
|
FocusAddressBar,
|
||||||
FocusCommandMode,
|
FocusCommandMode,
|
||||||
@@ -122,15 +130,23 @@ pub(crate) struct ShortcutBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ShortcutBinding {
|
impl ShortcutBinding {
|
||||||
pub(crate) fn display_keystroke(self) -> String {
|
pub(crate) fn action(self) -> ShortcutAction {
|
||||||
display_keystroke(self.keystroke, self.platform)
|
self.action
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn platform(self) -> ShortcutPlatform {
|
||||||
|
self.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn keystroke(self) -> &'static str {
|
||||||
|
self.keystroke
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct ShortcutConflict {
|
pub(crate) struct ShortcutConflict {
|
||||||
pub(crate) platform: ShortcutPlatform,
|
pub(crate) platform: ShortcutPlatform,
|
||||||
pub(crate) keystroke: &'static str,
|
pub(crate) keystroke: String,
|
||||||
pub(crate) actions: Vec<ShortcutAction>,
|
pub(crate) actions: Vec<ShortcutAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,81 +225,38 @@ pub(crate) fn bind_shortcuts(cx: &mut App) {
|
|||||||
cx.bind_keys(SHORTCUT_BINDINGS.iter().map(|binding| binding.key_binding()));
|
cx.bind_keys(SHORTCUT_BINDINGS.iter().map(|binding| binding.key_binding()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn bindings_for_action(
|
|
||||||
action: ShortcutAction,
|
|
||||||
platform: ShortcutPlatform,
|
|
||||||
) -> impl Iterator<Item = ShortcutBinding> {
|
|
||||||
SHORTCUT_BINDINGS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(move |binding| binding.action == action && binding.platform == platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn shortcut_conflicts() -> Vec<ShortcutConflict> {
|
|
||||||
let mut bindings_by_key: BTreeMap<(ShortcutPlatform, &'static str), Vec<ShortcutAction>> =
|
|
||||||
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 {
|
impl ShortcutBinding {
|
||||||
fn key_binding(self) -> KeyBinding {
|
fn key_binding(self) -> KeyBinding {
|
||||||
match self.action {
|
key_binding_for_action(self.action, self.keystroke)
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
keystroke
|
||||||
.split('-')
|
.split('-')
|
||||||
.map(|part| display_key_part(part, platform))
|
.map(|part| display_key_part(part, platform))
|
||||||
@@ -306,73 +279,39 @@ fn display_key_part(part: &str, platform: ShortcutPlatform) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutPlatform, bindings_for_action,
|
SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutPlatform, ShortcutProfile,
|
||||||
shortcut_conflicts,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_shortcuts_have_no_conflicts() {
|
fn registered_shortcuts_have_no_conflicts() {
|
||||||
assert_eq!(shortcut_conflicts(), Vec::new());
|
assert_eq!(ShortcutProfile::default_profile().conflicts(), Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_settings_shortcut_has_platform_bindings() {
|
fn open_settings_shortcut_has_platform_bindings() {
|
||||||
let bindings = bindings_for_action(ShortcutAction::OpenSettings, ShortcutPlatform::Macos)
|
let bindings = platform_labels(ShortcutAction::OpenSettings);
|
||||||
.chain(bindings_for_action(
|
|
||||||
ShortcutAction::OpenSettings,
|
|
||||||
ShortcutPlatform::WindowsLinux,
|
|
||||||
))
|
|
||||||
.map(|binding| binding.display_keystroke())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(bindings, vec!["Cmd + ,".to_string(), "Ctrl + ,".to_string()]);
|
assert_eq!(bindings, vec!["Cmd + ,".to_string(), "Ctrl + ,".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn private_window_shortcut_has_platform_bindings() {
|
fn private_window_shortcut_has_platform_bindings() {
|
||||||
let bindings =
|
let bindings = platform_labels(ShortcutAction::OpenPrivateWindow);
|
||||||
bindings_for_action(ShortcutAction::OpenPrivateWindow, ShortcutPlatform::Macos)
|
|
||||||
.chain(bindings_for_action(
|
|
||||||
ShortcutAction::OpenPrivateWindow,
|
|
||||||
ShortcutPlatform::WindowsLinux,
|
|
||||||
))
|
|
||||||
.map(|binding| binding.display_keystroke())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(bindings, vec!["Cmd + Shift + N".to_string(), "Ctrl + Shift + N".to_string()]);
|
assert_eq!(bindings, vec!["Cmd + Shift + N".to_string(), "Ctrl + Shift + N".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_sidebar_shortcut_has_platform_bindings() {
|
fn toggle_sidebar_shortcut_has_platform_bindings() {
|
||||||
let bindings = bindings_for_action(ShortcutAction::ToggleSidebar, ShortcutPlatform::Macos)
|
let bindings = platform_labels(ShortcutAction::ToggleSidebar);
|
||||||
.chain(bindings_for_action(
|
|
||||||
ShortcutAction::ToggleSidebar,
|
|
||||||
ShortcutPlatform::WindowsLinux,
|
|
||||||
))
|
|
||||||
.map(|binding| binding.display_keystroke())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(bindings, vec!["Cmd + B".to_string(), "Ctrl + B".to_string()]);
|
assert_eq!(bindings, vec!["Cmd + B".to_string(), "Ctrl + B".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn space_switch_shortcuts_have_platform_bindings() {
|
fn space_switch_shortcuts_have_platform_bindings() {
|
||||||
let next_bindings =
|
let next_bindings = platform_labels(ShortcutAction::SelectNextSpace);
|
||||||
bindings_for_action(ShortcutAction::SelectNextSpace, ShortcutPlatform::Macos)
|
let previous_bindings = platform_labels(ShortcutAction::SelectPreviousSpace);
|
||||||
.chain(bindings_for_action(
|
|
||||||
ShortcutAction::SelectNextSpace,
|
|
||||||
ShortcutPlatform::WindowsLinux,
|
|
||||||
))
|
|
||||||
.map(|binding| binding.display_keystroke())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let previous_bindings =
|
|
||||||
bindings_for_action(ShortcutAction::SelectPreviousSpace, ShortcutPlatform::Macos)
|
|
||||||
.chain(bindings_for_action(
|
|
||||||
ShortcutAction::SelectPreviousSpace,
|
|
||||||
ShortcutPlatform::WindowsLinux,
|
|
||||||
))
|
|
||||||
.map(|binding| binding.display_keystroke())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
next_bindings,
|
next_bindings,
|
||||||
@@ -387,7 +326,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_declared_action_has_a_binding() {
|
fn every_declared_action_has_a_binding() {
|
||||||
for action in SHORTCUT_ACTIONS {
|
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<String> {
|
||||||
|
let profile = ShortcutProfile::default_profile();
|
||||||
|
[ShortcutPlatform::Macos, ShortcutPlatform::WindowsLinux]
|
||||||
|
.into_iter()
|
||||||
|
.map(|platform| profile.display_bindings_for_action(action, platform))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ShortcutProfileBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct ShortcutProfileBinding {
|
||||||
|
action: ShortcutAction,
|
||||||
|
platform: ShortcutPlatform,
|
||||||
|
keystrokes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String, ShortcutProfileError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
if bindings.is_empty() { "Unassigned".to_string() } else { bindings.join(" / ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn conflicts(&self) -> Vec<ShortcutConflict> {
|
||||||
|
let mut bindings_by_key: BTreeMap<(ShortcutPlatform, &str), Vec<ShortcutAction>> =
|
||||||
|
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<Self, ShortcutProfileError> {
|
||||||
|
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<ShortcutProfile, ShortcutProfileError> {
|
||||||
|
serde_json::from_str::<ShortcutProfile>(profile_json)
|
||||||
|
.map_err(|error| ShortcutProfileError::InvalidJson(error.to_string()))?
|
||||||
|
.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn key_bindings_for_profile(profile: &ShortcutProfile) -> Vec<KeyBinding> {
|
||||||
|
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<KeyBinding> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
key_bindings.extend(key_bindings_for_profile(next));
|
||||||
|
key_bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keystrokes(action: ShortcutAction, platform: ShortcutPlatform) -> Vec<String> {
|
||||||
|
let mut keystrokes = SHORTCUT_BINDINGS
|
||||||
|
.iter()
|
||||||
|
.filter(|binding| binding.action() == action && binding.platform() == platform)
|
||||||
|
.map(|binding| binding.keystroke().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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::<Value>(&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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user