From 3a1c415572090f2f9f71554ef97b711d07d3cea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 02:55:37 -0400 Subject: [PATCH] Add spaces settings page --- crates/ely_app/src/shell/internal_pages.rs | 2 + .../src/shell/internal_pages/settings.rs | 6 + .../src/shell/internal_pages/spaces.rs | 251 ++++++++++++++++++ crates/ely_browser_core/src/navigation.rs | 2 + .../ely_browser_core/tests/settings_routes.rs | 21 ++ 5 files changed, 282 insertions(+) create mode 100644 crates/ely_app/src/shell/internal_pages/spaces.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 432edf6..2d3ab45 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -12,6 +12,7 @@ mod settings; mod shortcuts; mod sidebar_tabs; mod site_settings; +mod spaces; mod sync; mod task_manager; @@ -50,6 +51,7 @@ impl ElyShell { "ely://about" => self.render_about_page(snapshot), "ely://settings" => self.render_settings_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), + "ely://settings/spaces" => self.render_spaces_page(snapshot, cx), "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index 7ad2f40..1a8aa39 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -24,6 +24,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "Vertical tabs, pinned area, and auto archive policy.", route: "ely://settings/sidebar-tabs", }, + SettingsRoute { + icon: IconName::GalleryVerticalEnd, + title: "Spaces", + detail: "Space identity, accent color, and active context.", + route: "ely://settings/spaces", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", diff --git a/crates/ely_app/src/shell/internal_pages/spaces.rs b/crates/ely_app/src/shell/internal_pages/spaces.rs new file mode 100644 index 0000000..9b3d6fc --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/spaces.rs @@ -0,0 +1,251 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{ArchivePolicy, Space, SpaceId}; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, + px, rgb, +}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_spaces_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_spaces_header(snapshot)) + .child(render_active_space_summary(snapshot)) + .child(render_spaces_list(snapshot, cx)), + ) + } +} + +fn render_spaces_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Spaces")) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .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_active_space_summary(snapshot: &BrowserSnapshot) -> AnyElement { + let Some(active_space) = + snapshot.spaces.iter().find(|space| space.id() == &snapshot.active_space_id) + else { + return div() + .rounded_md() + .border_1() + .border_color(rgb(colors::ERROR)) + .px_4() + .py_3() + .text_sm() + .text_color(rgb(colors::ERROR)) + .child("Active Space is unavailable.") + .into_any_element(); + }; + + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div().min_w_0().flex().items_center().gap_3().child(space_avatar(active_space)).child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(active_space.name().to_string()), + ) + .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child( + format!( + "{} - {}", + accent_label(active_space.accent_hex()), + archive_policy_label(active_space.archive_policy()) + ), + )), + ), + ) + .child(div().text_xs().font_semibold().text_color(rgb(colors::SUCCESS)).child("Active")) + .into_any_element() +} + +fn render_spaces_list(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children(snapshot.spaces.iter().enumerate().map(|(index, space)| { + render_space_row(index, space, space.id() == &snapshot.active_space_id, cx) + })) + .into_any_element() +} + +fn render_space_row( + index: usize, + space: &Space, + active: bool, + cx: &mut Context, +) -> AnyElement { + let space_id = space.id().clone(); + + div() + .id(SharedString::from(format!("settings-space-{}", space.id().as_str()))) + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div().min_w_0().flex().items_center().gap_3().child(space_avatar(space)).child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(space.name().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(space_detail_label(space)), + ), + ), + ) + .child(render_space_action(index, space_id, active, cx)) + .into_any_element() +} + +fn render_space_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 space_avatar(space: &Space) -> AnyElement { + div() + .w(px(28.0)) + .h(px(28.0)) + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE_STRONG)) + .bg(rgb(space.accent_hex())) + .flex() + .items_center() + .justify_center() + .text_xs() + .font_semibold() + .text_color(rgb(colors::CANVAS)) + .child(space.icon().to_string()) + .into_any_element() +} + +fn space_detail_label(space: &Space) -> String { + format!( + "{} - {}", + accent_label(space.accent_hex()), + archive_policy_label(space.archive_policy()) + ) +} + +fn accent_label(accent_hex: u32) -> String { + format!("#{accent_hex:06X}") +} + +fn archive_policy_label(policy: &ArchivePolicy) -> &'static str { + match policy { + ArchivePolicy::Manual => "Manual archive", + ArchivePolicy::IdleDays(0) => "Archive today", + ArchivePolicy::IdleDays(1) => "Archive after 1 day", + ArchivePolicy::IdleDays(7) => "Archive after 7 days", + ArchivePolicy::IdleDays(30) => "Archive after 30 days", + ArchivePolicy::IdleDays(_) => "Custom archive policy", + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 1b86fac..0aad91a 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -28,6 +28,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), + "ely://settings/spaces" => Some("Space Settings"), "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), "ely://settings/profiles" => Some("Profile Settings"), @@ -162,6 +163,7 @@ fn settings_page_route(query: &str) -> Option<&'static str> { "sidebar" | "tabs" | "sidebar tabs" | "sidebar & tabs" => { Some("ely://settings/sidebar-tabs") } + "space" | "spaces" | "space settings" | "spaces settings" => Some("ely://settings/spaces"), "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { Some("ely://settings/shortcuts") } diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index 33a50ef..f0432f0 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -44,3 +44,24 @@ fn settings_scoped_search_opens_shortcuts_page() -> Result<(), Box> { assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn settings_scoped_search_opens_spaces_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings spaces"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "spaces".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(()) +}