diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 42f3c5c..fa56b90 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -9,6 +9,7 @@ mod plugins; mod profiles; mod reading_list; mod settings; +mod sidebar_tabs; mod site_settings; mod sync; mod task_manager; @@ -47,6 +48,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/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), "ely://settings/sync" => self.render_sync_page(snapshot), diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index c2a632d..2e24b7d 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -18,6 +18,12 @@ struct SettingsRoute { } const SETTINGS_ROUTES: &[SettingsRoute] = &[ + SettingsRoute { + icon: IconName::LayoutDashboard, + title: "Sidebar & Tabs", + detail: "Vertical tabs, pinned area, and auto archive policy.", + route: "ely://settings/sidebar-tabs", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", @@ -31,7 +37,7 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ route: "ely://settings/sync", }, SettingsRoute { - icon: IconName::LayoutDashboard, + icon: IconName::Asterisk, title: "Plugins", detail: "Installed plugin control and audit trail.", route: "ely://settings/plugins", diff --git a/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs b/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs new file mode 100644 index 0000000..d9a98ce --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs @@ -0,0 +1,249 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{ArchivePolicy, Space}; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Selectable, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +struct ArchivePolicyOption { + label: &'static str, + detail: &'static str, + policy: ArchivePolicy, +} + +const ARCHIVE_POLICY_OPTIONS: &[ArchivePolicyOption] = &[ + ArchivePolicyOption { + label: "Manual", + detail: "Keep unpinned tabs open until you close them.", + policy: ArchivePolicy::Manual, + }, + ArchivePolicyOption { + label: "Today", + detail: "Archive idle unpinned tabs during daily cleanup.", + policy: ArchivePolicy::IdleDays(0), + }, + ArchivePolicyOption { + label: "7 days", + detail: "Archive unpinned tabs after a week away.", + policy: ArchivePolicy::IdleDays(7), + }, + ArchivePolicyOption { + label: "30 days", + detail: "Archive unpinned tabs after a month away.", + policy: ArchivePolicy::IdleDays(30), + }, +]; + +impl ElyShell { + pub(super) fn render_sidebar_tabs_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + let Some(active_space) = + snapshot.spaces.iter().find(|space| space.id() == &snapshot.active_space_id) + else { + return render_canvas_surface( + div() + .size_full() + .p_8() + .text_color(rgb(colors::ERROR)) + .child("Active Space is unavailable."), + ); + }; + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_sidebar_tabs_header(snapshot, active_space)) + .child(render_archive_policy_panel(active_space, cx)), + ) + } +} + +fn render_sidebar_tabs_header(snapshot: &BrowserSnapshot, active_space: &Space) -> 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("Sidebar & Tabs"), + ) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Space: {}", snapshot.active_space_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::LayoutDashboard) + .child(archive_policy_label(active_space.archive_policy())), + ) + .into_any_element() +} + +fn render_archive_policy_panel(active_space: &Space, cx: &mut Context) -> AnyElement { + div() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child("Auto Archive"), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("Current Space policy for idle unpinned tabs."), + ), + ) + .child( + Button::new("archive-idle-tabs-now") + .ghost() + .xsmall() + .icon(IconName::Inbox) + .label("Run Now") + .tooltip("Archive idle tabs now") + .on_click(cx.listener(|shell, _, _, cx| { + shell.archive_idle_tabs_now(cx); + })), + ), + ) + .children( + ARCHIVE_POLICY_OPTIONS.iter().enumerate().map(|(index, option)| { + render_archive_policy_option(index, option, active_space, cx) + }), + ) + .into_any_element() +} + +fn render_archive_policy_option( + index: usize, + option: &'static ArchivePolicyOption, + active_space: &Space, + cx: &mut Context, +) -> AnyElement { + let selected = active_space.archive_policy() == &option.policy; + let policy = option.policy.clone(); + let border = if selected { colors::PRIMARY } else { colors::HAIRLINE }; + + div() + .rounded_md() + .border_1() + .border_color(rgb(border)) + .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( + div().text_color(rgb(policy_icon_color(selected))).child(policy_icon(selected)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(option.label), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(option.detail), + ), + ), + ) + .child( + Button::new(("archive-policy-option", index)) + .ghost() + .xsmall() + .selected(selected) + .label("Select") + .tooltip(option.label) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_active_space_archive_policy(policy.clone(), cx); + })), + ) + .into_any_element() +} + +fn archive_policy_label(policy: &ArchivePolicy) -> &'static str { + match policy { + ArchivePolicy::Manual => "Manual", + ArchivePolicy::IdleDays(0) => "Today", + ArchivePolicy::IdleDays(7) => "7 days", + ArchivePolicy::IdleDays(30) => "30 days", + ArchivePolicy::IdleDays(_) => "Custom", + } +} + +fn policy_icon(selected: bool) -> IconName { + if selected { IconName::CircleCheck } else { IconName::Inbox } +} + +fn policy_icon_color(selected: bool) -> u32 { + if selected { colors::PRIMARY } else { colors::MUTED } +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 51c9f21..0944e5a 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -6,7 +6,7 @@ mod site_permissions; mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, ProfileId, SpaceId, TabId, UrlText}; +use ely_domain::{ArchivePolicy, CommandIntent, ProfileId, SpaceId, TabId, UrlText}; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; @@ -252,6 +252,26 @@ impl ElyShell { } } + fn set_active_space_archive_policy( + &mut self, + archive_policy: ArchivePolicy, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.set_active_space_archive_policy(archive_policy).is_ok() + { + cx.notify(); + } + } + + fn archive_idle_tabs_now(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.archive_idle_tabs(std::time::SystemTime::now()).is_ok() + { + cx.notify(); + } + } + fn on_close_current_tab( &mut self, _: &CloseCurrentTab, diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 848679b..cb909d2 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -27,6 +27,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { url if SiteOrigin::from_site_route(url).ok().flatten().is_some() => Some("Site Settings"), "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), + "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), "ely://settings/plugins" => Some("Plugin Settings"), "ely://settings/profiles" => Some("Profile Settings"), "ely://settings/sync" => Some("Sync Settings"), @@ -153,6 +154,9 @@ fn settings_page_route(query: &str) -> Option<&'static str> { match query { "settings" | "general" | "browser" => Some("ely://settings"), "about" | "about ely browser" => Some("ely://about"), + "sidebar" | "tabs" | "sidebar tabs" | "sidebar & tabs" => { + Some("ely://settings/sidebar-tabs") + } "sync" | "sync settings" => Some("ely://settings/sync"), "profile" | "profiles" | "profile settings" | "profiles settings" => { Some("ely://settings/profiles") diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs new file mode 100644 index 0000000..7c3cec5 --- /dev/null +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -0,0 +1,25 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, CommandScope}; + +#[test] +fn settings_scoped_search_opens_sidebar_tabs_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings sidebar tabs"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "sidebar tabs".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Sidebar & Tabs Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/sidebar-tabs"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}