Add shortcut profile import export

This commit is contained in:
2026-05-09 01:30:20 -04:00
parent d193f5a400
commit fb6677aec2
7 changed files with 759 additions and 156 deletions
+34 -1
View File
@@ -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<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)]
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));
}
}
+1 -1
View File
@@ -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),
@@ -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<Self>,
) -> 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<ElyShell>,
) -> 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::<Vec<_>>();
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 }
}
+8
View File
@@ -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<SpaceId>,
space_file_error: 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>,
bookmark_edit_error: Option<String>,
plugin_install_error: Option<String>,
@@ -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,
+235
View File
@@ -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()));
}
}