diff --git a/crates/ely_app/src/shell/command_actions.rs b/crates/ely_app/src/shell/command_actions.rs index 50028b1..8b7a1bd 100644 --- a/crates/ely_app/src/shell/command_actions.rs +++ b/crates/ely_app/src/shell/command_actions.rs @@ -2,6 +2,14 @@ use ely_domain::CommandIntent; use gpui::{Context, Window}; use super::ElyShell; +use ely_browser_core::SpaceImportProfileMapping; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SpaceFileCommand { + ExportActiveSpace, + ImportToActiveProfile, + ImportPreservingProfiles, +} impl ElyShell { pub(super) fn handle_shell_command_intent( @@ -17,6 +25,17 @@ impl ElyShell { if install_plugin_from_file_command(command) { self.choose_plugin_package(window, cx); } + + match space_file_command(command) { + Some(SpaceFileCommand::ExportActiveSpace) => self.export_active_space(window, cx), + Some(SpaceFileCommand::ImportToActiveProfile) => { + self.choose_space_import(SpaceImportProfileMapping::UseActiveProfile, window, cx); + } + Some(SpaceFileCommand::ImportPreservingProfiles) => { + self.choose_space_import(SpaceImportProfileMapping::PreserveExisting, window, cx); + } + None => {} + } } } @@ -30,9 +49,26 @@ fn install_plugin_from_file_command(command: &str) -> bool { ) } +fn space_file_command(command: &str) -> Option { + match command.trim().to_ascii_lowercase().as_str() { + "export-space" | "export space" | "export-active-space" | "export active space" => { + Some(SpaceFileCommand::ExportActiveSpace) + } + "import-space" + | "import space" + | "import-space-active-profile" + | "import space active profile" => Some(SpaceFileCommand::ImportToActiveProfile), + "import-space-with-profiles" + | "import space with profiles" + | "import-space-preserve-profiles" + | "import space preserve profiles" => Some(SpaceFileCommand::ImportPreservingProfiles), + _ => None, + } +} + #[cfg(test)] mod tests { - use super::install_plugin_from_file_command; + use super::{SpaceFileCommand, install_plugin_from_file_command, space_file_command}; #[test] fn install_plugin_from_file_command_matches_prd_aliases() { @@ -46,4 +82,23 @@ mod tests { assert!(!install_plugin_from_file_command("plugins")); assert!(!install_plugin_from_file_command("open plugins")); } + + #[test] + fn space_file_command_matches_export_and_import_aliases() { + assert_eq!(space_file_command("export-space"), Some(SpaceFileCommand::ExportActiveSpace)); + assert_eq!( + space_file_command("import space"), + Some(SpaceFileCommand::ImportToActiveProfile) + ); + assert_eq!( + space_file_command("import-space-with-profiles"), + Some(SpaceFileCommand::ImportPreservingProfiles) + ); + } + + #[test] + fn space_file_command_rejects_other_space_commands() { + assert_eq!(space_file_command("new-space Research"), None); + assert_eq!(space_file_command("spaces"), None); + } } diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 5789d1b..970e060 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -25,6 +25,7 @@ mod sidebar_tabs; mod site_permissions_settings; mod site_settings; mod sleep; +mod space_actions; mod spaces; mod sync; mod tab_context; diff --git a/crates/ely_app/src/shell/internal_pages/space_actions.rs b/crates/ely_app/src/shell/internal_pages/space_actions.rs new file mode 100644 index 0000000..cfa6cb6 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/space_actions.rs @@ -0,0 +1,163 @@ +use ely_design_system::colors; +use ely_domain::SpaceId; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, rgb}; +use gpui_component::{ + Disableable, IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, +}; + +use super::super::ElyShell; + +pub(super) fn render_space_actions( + index: usize, + space_count: usize, + space_id: SpaceId, + active: bool, + confirming_trash: bool, + cx: &mut Context, +) -> AnyElement { + if confirming_trash { + return render_trash_confirmation(cx); + } + + let can_move_up = index > 0; + let can_move_down = index + 1 < space_count; + + div() + .flex() + .items_center() + .gap_2() + .child(render_space_order_button( + ("move-space-up", index), + space_id.clone(), + IconName::ArrowUp, + "Move Space Up", + can_move_up, + true, + cx, + )) + .child(render_space_order_button( + ("move-space-down", index), + space_id.clone(), + IconName::ArrowDown, + "Move Space Down", + can_move_down, + false, + cx, + )) + .child(render_space_export_button(index, space_id.clone(), cx)) + .child(render_space_switch_action(index, space_id.clone(), active, cx)) + .child(render_request_trash_button(index, space_id, space_count, cx)) + .into_any_element() +} + +fn render_space_order_button( + id: (&'static str, usize), + space_id: SpaceId, + icon: IconName, + tooltip: &'static str, + enabled: bool, + moves_up: bool, + cx: &mut Context, +) -> AnyElement { + Button::new(id) + .small() + .ghost() + .icon(icon) + .tooltip(tooltip) + .disabled(!enabled) + .on_click(cx.listener(move |shell, _, _, cx| { + if moves_up { + shell.move_space_up(&space_id, cx); + } else { + shell.move_space_down(&space_id, cx); + } + })) + .into_any_element() +} + +fn render_space_export_button( + index: usize, + space_id: SpaceId, + cx: &mut Context, +) -> AnyElement { + Button::new(("export-space", index)) + .small() + .ghost() + .icon(IconName::File) + .label("Export") + .tooltip("Export .elyspace") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.export_space(&space_id, window, cx); + })) + .into_any_element() +} + +fn render_request_trash_button( + index: usize, + space_id: SpaceId, + space_count: usize, + cx: &mut Context, +) -> AnyElement { + Button::new(("trash-space", index)) + .small() + .ghost() + .icon(IconName::Delete) + .tooltip("Move Space to Trash") + .disabled(space_count <= 1) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.request_space_trash(space_id.clone(), cx); + })) + .into_any_element() +} + +fn render_trash_confirmation(cx: &mut Context) -> AnyElement { + div() + .flex() + .items_center() + .gap_2() + .child(Button::new("cancel-space-trash").small().ghost().label("Cancel").on_click( + cx.listener(|shell, _, _, cx| { + shell.cancel_space_trash(cx); + }), + )) + .child( + Button::new("confirm-space-trash") + .small() + .danger() + .icon(IconName::Delete) + .label("Trash") + .tooltip("Move Space to Trash") + .on_click(cx.listener(|shell, _, window, cx| { + shell.trash_pending_space(window, cx); + })), + ) + .into_any_element() +} + +fn render_space_switch_action( + index: usize, + space_id: SpaceId, + active: bool, + cx: &mut Context, +) -> AnyElement { + if active { + return div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::SUCCESS)) + .child("Active") + .into_any_element(); + } + + Button::new(("switch-space", index)) + .small() + .primary() + .icon(IconName::Check) + .label("Switch") + .tooltip("Switch Space") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_space(&space_id, window, cx); + })) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/internal_pages/spaces.rs b/crates/ely_app/src/shell/internal_pages/spaces.rs index 4861dfc..8bac935 100644 --- a/crates/ely_app/src/shell/internal_pages/spaces.rs +++ b/crates/ely_app/src/shell/internal_pages/spaces.rs @@ -1,4 +1,4 @@ -use ely_browser_core::{BrowserSnapshot, TrashedSpace}; +use ely_browser_core::{BrowserSnapshot, SpaceImportProfileMapping, TrashedSpace}; use ely_design_system::colors; use ely_domain::{ArchivePolicy, Profile, Space, SpaceId}; use gpui::{ @@ -6,12 +6,12 @@ use gpui::{ px, rgb, }; use gpui_component::{ - Disableable, IconName, Sizable, StyledExt, + IconName, Sizable, StyledExt, button::{Button, ButtonVariants}, scroll::ScrollableElement, }; -use super::{ElyShell, render_canvas_surface}; +use super::{ElyShell, render_canvas_surface, space_actions::render_space_actions}; impl ElyShell { pub(super) fn render_spaces_page( @@ -26,7 +26,11 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_spaces_header(snapshot)) + .child(render_spaces_header(snapshot, cx)) + .child(render_space_file_message( + self.space_file_notice.as_deref(), + self.space_file_error.as_deref(), + )) .child(render_active_space_summary(snapshot)) .child(render_spaces_list(snapshot, self.pending_space_trash.as_ref(), cx)) .child(render_trashed_spaces_list(snapshot, cx)), @@ -34,7 +38,7 @@ impl ElyShell { } } -fn render_spaces_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_spaces_header(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { div() .flex() .items_end() @@ -60,15 +64,76 @@ fn render_spaces_header(snapshot: &BrowserSnapshot) -> AnyElement { .flex() .items_center() .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::MUTED)) - .child(IconName::GalleryVerticalEnd) - .child(format!("{} spaces", snapshot.spaces.len())), + .child( + Button::new("import-space-active-profile") + .small() + .primary() + .icon(IconName::FolderOpen) + .label("Import") + .tooltip("Import .elyspace to Active Profile") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_space_import( + SpaceImportProfileMapping::UseActiveProfile, + window, + cx, + ); + })), + ) + .child( + Button::new("import-space-preserve-profiles") + .small() + .ghost() + .icon(IconName::User) + .label("Import Profiles") + .tooltip("Import .elyspace with Existing Profiles") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_space_import( + SpaceImportProfileMapping::PreserveExisting, + window, + cx, + ); + })), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::GalleryVerticalEnd) + .child(format!("{} spaces", snapshot.spaces.len())), + ), ) .into_any_element() } +fn render_space_file_message(notice: Option<&str>, error: Option<&str>) -> AnyElement { + let (message, color, icon) = if let Some(error) = error { + (error, colors::ERROR, IconName::TriangleAlert) + } else if let Some(notice) = notice { + (notice, colors::SUCCESS, IconName::CircleCheck) + } else { + return div().into_any_element(); + }; + + div() + .rounded_md() + .border_1() + .border_color(rgb(color)) + .px_4() + .py_3() + .flex() + .items_center() + .gap_2() + .text_sm() + .text_color(rgb(color)) + .child(icon) + .child(message.to_string()) + .into_any_element() +} + fn render_active_space_summary(snapshot: &BrowserSnapshot) -> AnyElement { let Some(active_space) = snapshot.spaces.iter().find(|space| space.id() == &snapshot.active_space_id) @@ -198,142 +263,6 @@ fn render_space_row( .into_any_element() } -fn render_space_actions( - index: usize, - space_count: usize, - space_id: SpaceId, - active: bool, - confirming_trash: bool, - cx: &mut Context, -) -> AnyElement { - if confirming_trash { - return render_trash_confirmation(cx); - } - - let can_move_up = index > 0; - let can_move_down = index + 1 < space_count; - - div() - .flex() - .items_center() - .gap_2() - .child(render_space_order_button( - ("move-space-up", index), - space_id.clone(), - IconName::ArrowUp, - "Move Space Up", - can_move_up, - true, - cx, - )) - .child(render_space_order_button( - ("move-space-down", index), - space_id.clone(), - IconName::ArrowDown, - "Move Space Down", - can_move_down, - false, - cx, - )) - .child(render_space_switch_action(index, space_id.clone(), active, cx)) - .child(render_request_trash_button(index, space_id, space_count, cx)) - .into_any_element() -} - -fn render_space_order_button( - id: (&'static str, usize), - space_id: SpaceId, - icon: IconName, - tooltip: &'static str, - enabled: bool, - moves_up: bool, - cx: &mut Context, -) -> AnyElement { - Button::new(id) - .small() - .ghost() - .icon(icon) - .tooltip(tooltip) - .disabled(!enabled) - .on_click(cx.listener(move |shell, _, _, cx| { - if moves_up { - shell.move_space_up(&space_id, cx); - } else { - shell.move_space_down(&space_id, cx); - } - })) - .into_any_element() -} - -fn render_request_trash_button( - index: usize, - space_id: SpaceId, - space_count: usize, - cx: &mut Context, -) -> AnyElement { - Button::new(("trash-space", index)) - .small() - .ghost() - .icon(IconName::Delete) - .tooltip("Move Space to Trash") - .disabled(space_count <= 1) - .on_click(cx.listener(move |shell, _, _, cx| { - shell.request_space_trash(space_id.clone(), cx); - })) - .into_any_element() -} - -fn render_trash_confirmation(cx: &mut Context) -> AnyElement { - div() - .flex() - .items_center() - .gap_2() - .child(Button::new("cancel-space-trash").small().ghost().label("Cancel").on_click( - cx.listener(|shell, _, _, cx| { - shell.cancel_space_trash(cx); - }), - )) - .child( - Button::new("confirm-space-trash") - .small() - .danger() - .icon(IconName::Delete) - .label("Trash") - .tooltip("Move Space to Trash") - .on_click(cx.listener(|shell, _, window, cx| { - shell.trash_pending_space(window, cx); - })), - ) - .into_any_element() -} - -fn render_space_switch_action( - index: usize, - space_id: SpaceId, - active: bool, - cx: &mut Context, -) -> AnyElement { - if active { - return div() - .text_xs() - .font_semibold() - .text_color(rgb(colors::SUCCESS)) - .child("Active") - .into_any_element(); - } - - Button::new(("switch-space", index)) - .small() - .primary() - .icon(IconName::Check) - .label("Switch") - .tooltip("Switch Space") - .on_click(cx.listener(move |shell, _, window, cx| { - shell.select_space(&space_id, window, cx); - })) - .into_any_element() -} - fn render_trashed_spaces_list( snapshot: &BrowserSnapshot, cx: &mut Context, diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 34ef4aa..5b4cdb8 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -5,12 +5,14 @@ mod downloads; mod focus; mod history; mod internal_pages; +mod navigation; mod notes; mod plugins; mod reading_list; mod render; mod sidebar; mod site_permissions; +mod space_files; mod spaces; mod splits; mod tab_groups; @@ -28,10 +30,9 @@ use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ ArchivePolicy, DownloadPolicy, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, ProfileId, ProfileSyncPolicy, SearchEngine, SpaceId, SyncObjectKind, SyncObjectPolicy, TabId, - UrlText, }; use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window}; -use gpui_component::input::{InputEvent, InputState, SelectAll}; +use gpui_component::input::{InputEvent, InputState}; use bookmarks::PendingBookmarkEdit; use downloads::PendingDownloadFileAction; @@ -62,6 +63,8 @@ pub struct ElyShell { pending_history_time_clear: Option, site_permissions_clear_confirmation: Option, pending_space_trash: Option, + space_file_error: Option, + space_file_notice: Option, pending_bookmark_edit: Option, bookmark_edit_error: Option, plugin_install_error: Option, @@ -130,6 +133,8 @@ impl ElyShell { pending_history_time_clear: None, site_permissions_clear_confirmation: None, pending_space_trash: None, + space_file_error: None, + space_file_notice: None, pending_bookmark_edit: None, bookmark_edit_error: None, plugin_install_error: None, @@ -140,54 +145,6 @@ impl ElyShell { } } - fn open_new_tab(&mut self, window: &mut Window, cx: &mut Context) { - if let ShellState::Ready(core) = &mut self.state - && core.open_new_tab().is_ok() - { - self.sync_address_input(window, cx); - self.focus_address_bar(window, cx); - cx.notify(); - } - } - - fn open_downloads(&mut self, window: &mut Window, cx: &mut Context) { - self.open_internal_tab("ely://downloads", window, cx); - } - - fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - self.open_internal_tab("ely://history", window, cx); - } - - fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { - self.open_internal_tab("ely://settings", window, cx); - } - - fn open_task_manager(&mut self, window: &mut Window, cx: &mut Context) { - self.open_internal_tab("ely://task-manager", window, cx); - } - - fn open_internal_tab(&mut self, url_text: &str, window: &mut Window, cx: &mut Context) { - if let Ok(url) = UrlText::parse(url_text) { - self.open_url(url, window, cx); - } - } - - fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context) { - if let ShellState::Ready(core) = &mut self.state { - core.open_tab(url); - self.sync_address_input(window, cx); - self.focus_address_bar(window, cx); - cx.notify(); - } - } - - fn focus_address_bar(&mut self, window: &mut Window, cx: &mut Context) { - self.command_input.update(cx, |input, cx| { - input.focus(window, cx); - }); - window.dispatch_action(Box::new(SelectAll), cx); - } - fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state { core.set_command_query(">"); diff --git a/crates/ely_app/src/shell/navigation.rs b/crates/ely_app/src/shell/navigation.rs new file mode 100644 index 0000000..a8d7559 --- /dev/null +++ b/crates/ely_app/src/shell/navigation.rs @@ -0,0 +1,67 @@ +use ely_domain::UrlText; +use gpui::{Context, Window}; +use gpui_component::input::SelectAll; + +use super::{ElyShell, ShellState}; + +impl ElyShell { + pub(super) fn active_tab_matches_url(&self, url: &str) -> bool { + match &self.state { + ShellState::Ready(core) => core.active_tab().is_ok_and(|tab| tab.url().as_str() == url), + ShellState::StartupError(_) => false, + } + } + + pub(super) fn open_new_tab(&mut self, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.open_new_tab().is_ok() + { + self.sync_address_input(window, cx); + self.focus_address_bar(window, cx); + cx.notify(); + } + } + + pub(super) fn open_downloads(&mut self, window: &mut Window, cx: &mut Context) { + self.open_internal_tab("ely://downloads", window, cx); + } + + pub(super) fn open_history(&mut self, window: &mut Window, cx: &mut Context) { + self.open_internal_tab("ely://history", window, cx); + } + + pub(super) fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { + self.open_internal_tab("ely://settings", window, cx); + } + + pub(super) fn open_task_manager(&mut self, window: &mut Window, cx: &mut Context) { + self.open_internal_tab("ely://task-manager", window, cx); + } + + pub(super) fn open_internal_tab( + &mut self, + url_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + if let Ok(url) = UrlText::parse(url_text) { + self.open_url(url, window, cx); + } + } + + pub(super) fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + core.open_tab(url); + self.sync_address_input(window, cx); + self.focus_address_bar(window, cx); + cx.notify(); + } + } + + pub(super) fn focus_address_bar(&mut self, window: &mut Window, cx: &mut Context) { + self.command_input.update(cx, |input, cx| { + input.focus(window, cx); + }); + window.dispatch_action(Box::new(SelectAll), cx); + } +} diff --git a/crates/ely_app/src/shell/plugins.rs b/crates/ely_app/src/shell/plugins.rs index e41b606..105145b 100644 --- a/crates/ely_app/src/shell/plugins.rs +++ b/crates/ely_app/src/shell/plugins.rs @@ -108,13 +108,6 @@ impl ElyShell { self.open_internal_tab(PLUGIN_SETTINGS_URL, window, cx); } - fn active_tab_matches_url(&self, url: &str) -> bool { - match &self.state { - ShellState::Ready(core) => core.active_tab().is_ok_and(|tab| tab.url().as_str() == url), - ShellState::StartupError(_) => false, - } - } - pub(super) fn confirm_plugin_install(&mut self, cx: &mut Context) { let Some(pending) = self.pending_plugin_install.take() else { cx.notify(); diff --git a/crates/ely_app/src/shell/space_files.rs b/crates/ely_app/src/shell/space_files.rs new file mode 100644 index 0000000..0d47bd2 --- /dev/null +++ b/crates/ely_app/src/shell/space_files.rs @@ -0,0 +1,322 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use directories::UserDirs; +use ely_browser_core::{ELYSPACE_FILE_EXTENSION, SpaceImportProfileMapping}; +use ely_domain::SpaceId; +use gpui::{Context, PathPromptOptions, Window}; + +use super::{ElyShell, ShellState}; + +const SPACE_SETTINGS_URL: &str = "ely://settings/spaces"; + +impl ElyShell { + pub(super) fn export_active_space(&mut self, window: &mut Window, cx: &mut Context) { + let space_id = match &self.state { + ShellState::Ready(core) => match core.snapshot() { + Ok(snapshot) => snapshot.active_space_id, + Err(error) => { + self.set_space_file_error(error.to_string(), cx); + return; + } + }, + ShellState::StartupError(message) => { + self.set_space_file_error(message.clone(), cx); + return; + } + }; + + self.export_space(&space_id, window, cx); + } + + pub(super) fn export_space( + &mut self, + space_id: &SpaceId, + window: &mut Window, + cx: &mut Context, + ) { + self.ensure_space_settings_surface(window, cx); + self.clear_space_file_message(); + + let export = match &mut self.state { + ShellState::Ready(core) => { + let package_json = match core.export_space_package_json(space_id) { + Ok(package_json) => package_json, + Err(error) => { + self.set_space_file_error(error.to_string(), cx); + return; + } + }; + let package = match core.export_space_package(space_id) { + Ok(package) => package, + Err(error) => { + self.set_space_file_error(error.to_string(), cx); + return; + } + }; + Ok((package.space_name().to_string(), package_json)) + } + ShellState::StartupError(message) => Err(message.clone()), + }; + + let (space_name, package_json) = match export { + Ok(export) => export, + Err(error) => { + self.set_space_file_error(error, cx); + return; + } + }; + let directory = match default_export_directory() { + Ok(directory) => directory, + Err(error) => { + self.set_space_file_error(error, cx); + return; + } + }; + let suggested_name = space_export_filename(&space_name); + let prompt = cx.prompt_for_new_path(&directory, Some(&suggested_name)); + + 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_space_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_space_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { write_space_package(path, package_json) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_space_export_result(result, cx); + }); + }) + .detach(); + } + + pub(super) fn choose_space_import( + &mut self, + profile_mapping: SpaceImportProfileMapping, + window: &mut Window, + cx: &mut Context, + ) { + self.ensure_space_settings_surface(window, cx); + self.clear_space_file_message(); + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select .elyspace 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_space_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_space_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = + window.background_executor().spawn(async move { read_space_package(path) }).await; + _ = shell.update_in(window, |shell, window, cx| { + shell.handle_space_import_result(result, profile_mapping, window, cx); + }); + }) + .detach(); + } + + fn ensure_space_settings_surface(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_matches_url(SPACE_SETTINGS_URL) { + return; + } + + self.open_internal_tab(SPACE_SETTINGS_URL, window, cx); + } + + fn clear_space_file_message(&mut self) { + self.space_file_error = None; + self.space_file_notice = None; + } + + fn set_space_file_error(&mut self, message: String, cx: &mut Context) { + self.space_file_error = Some(message); + self.space_file_notice = None; + cx.notify(); + } + + fn set_space_file_notice(&mut self, message: String, cx: &mut Context) { + self.space_file_notice = Some(message); + self.space_file_error = None; + cx.notify(); + } + + fn handle_space_export_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + match result { + Ok(path) => self.set_space_file_notice(format!("Exported {}", path.display()), cx), + Err(error) => self.set_space_file_error(error, cx), + } + } + + fn handle_space_import_result( + &mut self, + result: Result, + profile_mapping: SpaceImportProfileMapping, + window: &mut Window, + cx: &mut Context, + ) { + let package_json = match result { + Ok(package_json) => package_json, + Err(error) => { + self.set_space_file_error(error, cx); + return; + } + }; + + let import_result = match &mut self.state { + ShellState::Ready(core) => core + .import_space_package_json(&package_json, profile_mapping) + .and_then(|space_id| { + core.snapshot().map(|snapshot| { + snapshot.spaces.iter().find(|space| space.id() == &space_id).map_or_else( + || "Imported Space".to_string(), + |space| format!("Imported {}", space.name()), + ) + }) + }) + .map_err(|error| error.to_string()), + ShellState::StartupError(message) => Err(message.clone()), + }; + + match import_result { + Ok(message) => { + self.sync_address_input(window, cx); + self.set_space_file_notice(message, cx); + } + Err(error) => self.set_space_file_error(error, 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_space_package(path: PathBuf, package_json: String) -> Result { + let path = normalize_export_path(path)?; + fs::write(&path, package_json) + .map_err(|error| format!("Unable to write {}: {error}", path.display()))?; + Ok(path) +} + +fn read_space_package(path: PathBuf) -> Result { + if !path_has_elyspace_extension(&path) { + return Err("Selected file must use .elyspace extension.".to_string()); + } + + fs::read_to_string(&path).map_err(|error| format!("Unable to read {}: {error}", path.display())) +} + +fn normalize_export_path(mut path: PathBuf) -> Result { + if path.extension().is_none() { + path.set_extension(ELYSPACE_FILE_EXTENSION); + return Ok(path); + } + + if path_has_elyspace_extension(&path) { + Ok(path) + } else { + Err("Export path must use .elyspace extension.".to_string()) + } +} + +fn path_has_elyspace_extension(path: &Path) -> bool { + path.extension().is_some_and(|extension| { + extension.to_string_lossy().eq_ignore_ascii_case(ELYSPACE_FILE_EXTENSION) + }) +} + +fn space_export_filename(space_name: &str) -> String { + let mut stem = String::new(); + let mut previous_separator = false; + + for ch in space_name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + stem.push(ch); + previous_separator = false; + } else if !previous_separator { + stem.push('-'); + previous_separator = true; + } + } + + let stem = stem.trim_matches('-'); + let stem = if stem.is_empty() { "space" } else { stem }; + format!("{stem}.{ELYSPACE_FILE_EXTENSION}") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{normalize_export_path, space_export_filename}; + + #[test] + fn space_export_filename_sanitizes_names() { + assert_eq!(space_export_filename("Work"), "Work.elyspace"); + assert_eq!(space_export_filename("Client / Research"), "Client-Research.elyspace"); + assert_eq!(space_export_filename(" "), "space.elyspace"); + } + + #[test] + fn normalize_export_path_adds_missing_extension() -> Result<(), String> { + let path = normalize_export_path(PathBuf::from("Work"))?; + + assert_eq!(path, PathBuf::from("Work.elyspace")); + Ok(()) + } + + #[test] + fn normalize_export_path_rejects_other_extensions() { + let error = normalize_export_path(PathBuf::from("Work.json")); + + assert_eq!(error, Err("Export path must use .elyspace extension.".to_string())); + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 45b48e9..3d7cea7 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -206,6 +206,10 @@ pub(crate) fn plugin_settings_url() -> Result { internal_page_url("ely://settings/plugins") } +pub(crate) fn space_settings_url() -> Result { + internal_page_url("ely://settings/spaces") +} + pub(crate) fn plugin_detail_url(plugin_id: &PluginId) -> Result { let route = format!("ely://plugin/{}", plugin_id.as_str()); internal_page_url(&route) diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index a0e535c..b9dd8aa 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -11,8 +11,9 @@ use crate::{ move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, note_body, notes_url, plugin_detail_url, plugin_settings_url, plugins_url, reading_list_url, reading_progress_percent, rename_tab_group_name, search_url, settings_page_url, - settings_url, shortcut_settings_url, space_icon, split_group_name, switch_profile_name, - sync_status_url, tab_group_color_hex, tab_group_name, tab_note_body, task_manager_url, + settings_url, shortcut_settings_url, space_icon, space_settings_url, split_group_name, + switch_profile_name, sync_status_url, tab_group_color_hex, tab_group_name, tab_note_body, + task_manager_url, }, }; @@ -278,6 +279,21 @@ impl BrowserCore { self.open_tab(plugin_settings_url()?); Ok(true) } + "export-space" | "export space" | "export-active-space" | "export active space" => { + self.open_tab(space_settings_url()?); + Ok(true) + } + "import-space" + | "import space" + | "import-space-active-profile" + | "import space active profile" + | "import-space-with-profiles" + | "import space with profiles" + | "import-space-preserve-profiles" + | "import space preserve profiles" => { + self.open_tab(space_settings_url()?); + Ok(true) + } "site-settings" | "open-site-settings" | "open site settings" => { let Some(url) = self.active_tab_site_settings_url()? else { return Ok(false); diff --git a/crates/ely_browser_core/tests/space_file_commands.rs b/crates/ely_browser_core/tests/space_file_commands.rs new file mode 100644 index 0000000..6d16dd8 --- /dev/null +++ b/crates/ely_browser_core/tests/space_file_commands.rs @@ -0,0 +1,34 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::CommandIntent; + +#[test] +fn export_space_command_opens_space_settings_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">export-space"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("export-space".to_string()))); + assert_eq!(active_tab.title(), "Space Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/spaces"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + +#[test] +fn import_space_command_opens_space_settings_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">import-space-with-profiles"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("import-space-with-profiles".to_string()))); + assert_eq!(active_tab.title(), "Space Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/spaces"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}