diff --git a/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs b/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs index d9a98ce..91059e0 100644 --- a/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs +++ b/crates/ely_app/src/shell/internal_pages/sidebar_tabs.rs @@ -1,6 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{ArchivePolicy, Space}; +use ely_domain::{ArchivePolicy, FavoriteLimit, Space}; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ IconName, Selectable, Sizable, StyledExt, @@ -65,7 +65,7 @@ impl ElyShell { .flex_col() .gap_5() .child(render_sidebar_tabs_header(snapshot, active_space)) - .child(render_archive_policy_panel(active_space, cx)), + .child(render_sidebar_tabs_settings(snapshot, active_space, cx)), ) } } @@ -102,12 +102,20 @@ fn render_sidebar_tabs_header(snapshot: &BrowserSnapshot, active_space: &Space) .font_semibold() .text_color(rgb(colors::MUTED)) .child(IconName::LayoutDashboard) - .child(archive_policy_label(active_space.archive_policy())), + .child(format!( + "{} / {}", + archive_policy_label(active_space.archive_policy()), + snapshot.favorite_limit.label() + )), ) .into_any_element() } -fn render_archive_policy_panel(active_space: &Space, cx: &mut Context) -> AnyElement { +fn render_sidebar_tabs_settings( + snapshot: &BrowserSnapshot, + active_space: &Space, + cx: &mut Context, +) -> AnyElement { div() .flex_1() .min_h_0() @@ -118,6 +126,16 @@ fn render_archive_policy_panel(active_space: &Space, cx: &mut Context) .flex() .flex_col() .gap_4() + .child(render_archive_policy_section(active_space, cx)) + .child(render_favorite_limit_section(snapshot.favorite_limit, cx)) + .into_any_element() +} + +fn render_archive_policy_section(active_space: &Space, cx: &mut Context) -> AnyElement { + div() + .flex() + .flex_col() + .gap_3() .child( div() .flex() @@ -164,6 +182,57 @@ fn render_archive_policy_panel(active_space: &Space, cx: &mut Context) .into_any_element() } +fn render_favorite_limit_section( + favorite_limit: FavoriteLimit, + cx: &mut Context, +) -> AnyElement { + div() + .flex() + .flex_col() + .gap_3() + .pt_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("Favorite Limit"), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("Maximum cross-space Favorites visible in the sidebar."), + ), + ) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(favorite_limit.label()), + ), + ) + .children( + FavoriteLimit::ALL.iter().copied().enumerate().map(|(index, limit)| { + render_favorite_limit_option(index, limit, favorite_limit, cx) + }), + ) + .into_any_element() +} + fn render_archive_policy_option( index: usize, option: &'static ArchivePolicyOption, @@ -230,6 +299,71 @@ fn render_archive_policy_option( .into_any_element() } +fn render_favorite_limit_option( + index: usize, + limit: FavoriteLimit, + active_limit: FavoriteLimit, + cx: &mut Context, +) -> AnyElement { + let selected = limit == active_limit; + 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(limit.label()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(limit.detail()), + ), + ), + ) + .child( + Button::new(("favorite-limit-option", index)) + .ghost() + .xsmall() + .selected(selected) + .label("Select") + .tooltip(limit.label()) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_favorite_limit(limit, cx); + })), + ) + .into_any_element() +} + fn archive_policy_label(policy: &ArchivePolicy) -> &'static str { match policy { ArchivePolicy::Manual => "Manual", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index ab107c2..a03a802 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -7,8 +7,8 @@ mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ - ArchivePolicy, CommandIntent, DownloadPolicy, HistoryRecordingPolicy, NewTabDestination, - ProfileId, SearchEngine, SpaceId, TabId, UrlText, + ArchivePolicy, CommandIntent, DownloadPolicy, FavoriteLimit, HistoryRecordingPolicy, + NewTabDestination, ProfileId, SearchEngine, SpaceId, TabId, UrlText, }; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; @@ -310,6 +310,13 @@ impl ElyShell { } } + fn set_favorite_limit(&mut self, favorite_limit: FavoriteLimit, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + core.set_favorite_limit(favorite_limit); + 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() diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 9705053..80b7b82 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,9 +2,9 @@ use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, - DownloadPolicy, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, Profile, ProfileId, - ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, SitePermissionEntry, - Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, + DownloadPolicy, FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, + Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, + SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, }; use crate::{CoreError, navigation::tab_title}; @@ -69,6 +69,7 @@ pub struct BrowserSnapshot { pub search_engine: SearchEngine, pub new_tab_destination: NewTabDestination, pub history_recording_policy: HistoryRecordingPolicy, + pub favorite_limit: FavoriteLimit, pub command_query: String, } @@ -96,6 +97,7 @@ pub struct BrowserCore { search_engine: SearchEngine, new_tab_destination: NewTabDestination, history_recording_policy: HistoryRecordingPolicy, + favorite_limit: FavoriteLimit, command_query: String, } @@ -131,6 +133,7 @@ impl BrowserCore { search_engine: SearchEngine::default(), new_tab_destination, history_recording_policy: HistoryRecordingPolicy::default(), + favorite_limit: FavoriteLimit::default(), spaces: vec![space], profiles: vec![profile], tabs: vec![tab], @@ -253,6 +256,15 @@ impl BrowserCore { self.history_recording_policy } + pub fn set_favorite_limit(&mut self, favorite_limit: FavoriteLimit) { + self.favorite_limit = favorite_limit; + } + + #[must_use] + pub fn favorite_limit(&self) -> FavoriteLimit { + self.favorite_limit + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -292,6 +304,7 @@ impl BrowserCore { search_engine: self.search_engine, new_tab_destination: self.new_tab_destination, history_recording_policy: self.history_recording_policy, + favorite_limit: self.favorite_limit, command_query: self.command_query.clone(), }) } diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index 085c455..096e73f 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -11,8 +11,6 @@ use crate::{ use super::BrowserCore; -const DEFAULT_FAVORITE_LIMIT: usize = 12; - impl BrowserCore { pub fn open_new_tab(&mut self) -> Result { let url = self.new_tab_url()?; @@ -227,9 +225,10 @@ impl BrowserCore { let favorite_count = self.tabs.iter().filter(|tab| tab.flags().favorite).count(); let active_tab = self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)?; let next_favorite = !active_tab.flags().favorite; + let favorite_limit = self.favorite_limit.value(); - if next_favorite && favorite_count >= DEFAULT_FAVORITE_LIMIT { - return Err(CoreError::FavoriteLimitReached { limit: DEFAULT_FAVORITE_LIMIT }); + if next_favorite && favorite_count >= favorite_limit { + return Err(CoreError::FavoriteLimitReached { limit: favorite_limit }); } active_tab.set_favorite(next_favorite); diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index f17155e..9a1174d 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -1,7 +1,9 @@ use std::error::Error; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, NewTabDestination, SearchEngine, TabState, UrlText}; +use ely_domain::{ + CommandIntent, CommandScope, FavoriteLimit, NewTabDestination, SearchEngine, TabState, UrlText, +}; #[test] fn opens_new_tab_below_active_tab() -> Result<(), Box> { @@ -398,3 +400,27 @@ fn enforces_default_favorite_limit() -> Result<(), Box> { assert_eq!(core.snapshot()?.favorites.len(), 12); Ok(()) } + +#[test] +fn enforces_configured_favorite_limit() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.set_favorite_limit(FavoriteLimit::Six); + + core.toggle_active_tab_favorite()?; + for index in 1..6 { + core.open_tab(UrlText::parse(format!("https://example.com/{index}"))?); + core.toggle_active_tab_favorite()?; + } + + core.open_tab(UrlText::parse("https://example.com/overflow")?); + let error = match core.toggle_active_tab_favorite() { + Err(error) => error, + Ok(_) => return Err("configured favorite limit should apply".into()), + }; + + let snapshot = core.snapshot()?; + assert_eq!(error, CoreError::FavoriteLimitReached { limit: 6 }); + assert_eq!(snapshot.favorite_limit, FavoriteLimit::Six); + assert_eq!(snapshot.favorites.len(), 6); + Ok(()) +} diff --git a/crates/ely_domain/src/favorite.rs b/crates/ely_domain/src/favorite.rs new file mode 100644 index 0000000..aa9036e --- /dev/null +++ b/crates/ely_domain/src/favorite.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FavoriteLimit { + Six, + #[default] + Twelve, + TwentyFour, +} + +impl FavoriteLimit { + pub const ALL: &[Self] = &[Self::Six, Self::Twelve, Self::TwentyFour]; + + #[must_use] + pub fn value(self) -> usize { + match self { + Self::Six => 6, + Self::Twelve => 12, + Self::TwentyFour => 24, + } + } + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Six => "6 Favorites", + Self::Twelve => "12 Favorites", + Self::TwentyFour => "24 Favorites", + } + } + + #[must_use] + pub fn detail(self) -> &'static str { + match self { + Self::Six => "Keep the Favorites shelf compact.", + Self::Twelve => "Default Favorites shelf capacity.", + Self::TwentyFour => "Allow a larger cross-space Favorites shelf.", + } + } +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 510a50e..e492f5b 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -3,6 +3,7 @@ mod bookmark; mod command; mod download; mod error; +mod favorite; mod history; mod identifiers; mod new_tab; @@ -26,6 +27,7 @@ pub use download::{ DownloadPolicy, DownloadSecurity, DownloadState, }; pub use error::DomainError; +pub use favorite::FavoriteLimit; pub use history::HistoryEntry; pub use identifiers::{ BookmarkId, DownloadId, ProfileId, ReadingListId, SpaceId, SplitId, TabId, WebViewId,