From adf5b27207fb22209d6ef13420aa055546d59c72 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 07:16:17 -0400 Subject: [PATCH] Add tab group sidebar support --- crates/ely_app/src/shell/splits.rs | 188 +++++++++++++++--- crates/ely_browser_core/src/error.rs | 5 +- crates/ely_browser_core/src/navigation.rs | 4 + crates/ely_browser_core/src/state.rs | 9 +- crates/ely_browser_core/src/state/commands.rs | 6 +- .../ely_browser_core/src/state/tab_groups.rs | 100 ++++++++++ crates/ely_browser_core/src/state/tabs.rs | 4 +- crates/ely_browser_core/tests/tab_groups.rs | 104 ++++++++++ crates/ely_domain/src/identifiers.rs | 1 + crates/ely_domain/src/lib.rs | 5 +- crates/ely_domain/src/tab.rs | 17 +- crates/ely_domain/src/tab_group.rs | 160 +++++++++++++++ 12 files changed, 572 insertions(+), 31 deletions(-) create mode 100644 crates/ely_browser_core/src/state/tab_groups.rs create mode 100644 crates/ely_browser_core/tests/tab_groups.rs create mode 100644 crates/ely_domain/src/tab_group.rs diff --git a/crates/ely_app/src/shell/splits.rs b/crates/ely_app/src/shell/splits.rs index 830fa89..02113df 100644 --- a/crates/ely_app/src/shell/splits.rs +++ b/crates/ely_app/src/shell/splits.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; -use ely_domain::{BrowserTab, SplitAxis, SplitId, SplitLayout}; +use ely_domain::{BrowserTab, SplitAxis, SplitId, SplitLayout, TabGroup, TabGroupId}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, px, rgb, @@ -18,30 +18,27 @@ impl ElyShell { snapshot: &BrowserSnapshot, cx: &mut Context, ) -> Vec { - let mut rendered_split_ids = BTreeSet::new(); - let active_split_id = active_split_id(snapshot); + let mut rows = Vec::new(); + let mut row_state = SidebarRowState::new(snapshot); - snapshot - .tabs - .iter() - .filter(|tab| !tab.flags().favorite) - .filter(|tab| !tab.flags().pinned) - .filter_map(|tab| { - let Some(split_id) = tab.split_id() else { - return Some(self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)); - }; - let Some(layout) = saved_split_layout(snapshot, split_id) else { - return Some(self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)); - }; + for tab in snapshot.tabs.iter().filter(|tab| sidebar_tab_is_visible(tab)) { + let Some(group_id) = tab.group_id() else { + self.push_sidebar_tab_row(&mut rows, snapshot, tab, &mut row_state, cx); + continue; + }; - if rendered_split_ids.insert(split_id.clone()) { - let active = active_split_id.as_ref() == Some(split_id); - return self.render_saved_split_row(layout, active, cx); - } + let Some(group) = snapshot.tab_groups.iter().find(|group| group.id() == group_id) + else { + self.push_sidebar_tab_row(&mut rows, snapshot, tab, &mut row_state, cx); + continue; + }; - None - }) - .collect() + if row_state.rendered_group_ids.insert(group_id.clone()) { + self.push_tab_group_rows(&mut rows, snapshot, group, &mut row_state, cx); + } + } + + rows } pub(super) fn render_content_area( @@ -190,7 +187,113 @@ impl ElyShell { .into_any_element() } - fn render_saved_split_row( + fn push_tab_group_rows( + &mut self, + rows: &mut Vec, + snapshot: &BrowserSnapshot, + group: &TabGroup, + row_state: &mut SidebarRowState, + cx: &mut Context, + ) { + let group_tabs = group_tabs(snapshot, group); + let Some(first_tab_id) = group_tabs.first().map(|tab| tab.id().clone()) else { + return; + }; + + rows.push(self.render_tab_group_row( + group, + group_tabs.len(), + first_tab_id, + row_state.active_group_id.as_ref() == Some(group.id()), + cx, + )); + + if group.collapsed() { + return; + } + + for tab in group_tabs { + self.push_sidebar_tab_row(rows, snapshot, tab, row_state, cx); + } + } + + fn push_sidebar_tab_row( + &mut self, + rows: &mut Vec, + snapshot: &BrowserSnapshot, + tab: &BrowserTab, + row_state: &mut SidebarRowState, + cx: &mut Context, + ) { + if let Some(split_id) = tab.split_id() + && let Some(layout) = saved_split_layout(snapshot, split_id) + { + if row_state.rendered_split_ids.insert(split_id.clone()) { + let active = row_state.active_split_id.as_ref() == Some(split_id); + if let Some(row) = self.render_saved_split_row(layout, active, cx) { + rows.push(row); + } + } + return; + } + + rows.push(self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)); + } + + fn render_tab_group_row( + &mut self, + group: &TabGroup, + tab_count: usize, + first_tab_id: ely_domain::TabId, + active: bool, + cx: &mut Context, + ) -> AnyElement { + let background = if active { colors::SURFACE_CARD } else { colors::CANVAS }; + let border = if active { colors::PRIMARY } else { colors::HAIRLINE }; + + div() + .id(SharedString::from(format!("tab-group-{}", group.id().as_str()))) + .rounded_md() + .border_1() + .border_color(rgb(border)) + .bg(rgb(background)) + .px_3() + .py_2() + .gap_2() + .flex() + .items_center() + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::SURFACE_CARD))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_tab(&first_tab_id, window, cx); + })) + .child(div().text_color(rgb(group.color_hex())).child(IconName::Folder)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(group.name().to_string()), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{tab_count} tabs")), + ), + ) + .into_any_element() + } + + pub(super) fn render_saved_split_row( &mut self, layout: &SplitLayout, active: bool, @@ -261,9 +364,48 @@ fn active_split_id(snapshot: &BrowserSnapshot) -> Option { .and_then(|tab| tab.split_id().cloned()) } +fn active_group_id(snapshot: &BrowserSnapshot) -> Option { + snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .and_then(|tab| tab.group_id().cloned()) +} + +struct SidebarRowState { + active_group_id: Option, + active_split_id: Option, + rendered_group_ids: BTreeSet, + rendered_split_ids: BTreeSet, +} + +impl SidebarRowState { + fn new(snapshot: &BrowserSnapshot) -> Self { + Self { + active_group_id: active_group_id(snapshot), + active_split_id: active_split_id(snapshot), + rendered_group_ids: BTreeSet::new(), + rendered_split_ids: BTreeSet::new(), + } + } +} + fn saved_split_layout<'a>( snapshot: &'a BrowserSnapshot, split_id: &SplitId, ) -> Option<&'a SplitLayout> { snapshot.split_layouts.iter().find(|layout| layout.id() == split_id && layout.saved()) } + +fn group_tabs<'a>(snapshot: &'a BrowserSnapshot, group: &TabGroup) -> Vec<&'a BrowserTab> { + snapshot + .tabs + .iter() + .filter(|tab| sidebar_tab_is_visible(tab)) + .filter(|tab| tab.group_id() == Some(group.id())) + .collect() +} + +fn sidebar_tab_is_visible(tab: &BrowserTab) -> bool { + !tab.flags().favorite && !tab.flags().pinned +} diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index dea5975..ec9c4f9 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -1,6 +1,6 @@ use ely_domain::{ BookmarkId, DomainError, DownloadId, NoteId, PluginId, ProfileId, ReadingListId, SpaceId, - SplitId, TabId, + SplitId, TabGroupId, TabId, }; use thiserror::Error; @@ -21,6 +21,9 @@ pub enum CoreError { #[error("split not found: {id}")] SplitNotFound { id: SplitId }, + #[error("tab group not found: {id}")] + TabGroupNotFound { id: TabGroupId }, + #[error("profile not found: {id}")] ProfileNotFound { id: ProfileId }, diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index fca5d7c..5b0edb1 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -100,6 +100,10 @@ pub(crate) fn tab_note_body(command: &str) -> Option<&str> { command_argument(command, &["tab-note ", "tab note ", "note-tab ", "note tab "]) } +pub(crate) fn tab_group_name(command: &str) -> Option<&str> { + command_argument(command, &["group-tab ", "group tab ", "tab-group ", "tab group "]) +} + pub(crate) fn new_profile_name(command: &str) -> Option<&str> { command_argument(command, &["new-profile ", "new profile "]) } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 81e051b..d4e2154 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -4,8 +4,8 @@ use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, NoteEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine, - SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, - UrlText, + SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, + TabGroup, TabId, UrlText, }; use crate::{CoreError, navigation::tab_title}; @@ -22,6 +22,7 @@ mod reading_list; mod site_permissions; mod splits; mod sync; +mod tab_groups; mod tab_order; mod tabs; @@ -60,6 +61,7 @@ pub struct BrowserSnapshot { pub download_entries: Vec, pub history_entries: Vec, pub active_profile_history_entry_count: usize, + pub tab_groups: Vec, pub split_layouts: Vec, pub installed_plugins: Vec, pub plugin_audit_events: Vec, @@ -92,6 +94,7 @@ pub struct BrowserCore { site_permission_audit_events: Vec, download_entries: Vec, history_entries: Vec, + tab_groups: Vec, split_layouts: Vec, archived_split_layouts: Vec, installed_plugins: Vec, @@ -161,6 +164,7 @@ impl BrowserCore { site_permission_audit_events: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), + tab_groups: Vec::new(), split_layouts: Vec::new(), archived_split_layouts: Vec::new(), installed_plugins: Vec::new(), @@ -354,6 +358,7 @@ impl BrowserCore { download_entries: self.visible_downloads(), history_entries: self.visible_history(), active_profile_history_entry_count: self.active_profile_history_count(), + tab_groups: self.visible_tab_groups(), split_layouts: self.visible_split_layouts(), installed_plugins: self.installed_plugins.clone(), plugin_audit_events: self.plugin_audit_events.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 982accf..cdb0cf0 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -9,7 +9,7 @@ use crate::{ move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, note_body, notes_url, plugin_detail_url, plugins_url, reading_list_url, reading_progress_percent, search_url, settings_page_url, settings_url, shortcut_settings_url, space_icon, - switch_profile_name, sync_status_url, tab_note_body, task_manager_url, + switch_profile_name, sync_status_url, tab_group_name, tab_note_body, task_manager_url, }, }; @@ -133,6 +133,10 @@ impl BrowserCore { self.set_active_tab_reading_progress(percent)?; return Ok(true); } + if let Some(name) = tab_group_name(command) { + self.group_active_tab(name)?; + return Ok(true); + } if let Some(body) = tab_note_body(command) { self.save_active_tab_note(body)?; return Ok(true); diff --git a/crates/ely_browser_core/src/state/tab_groups.rs b/crates/ely_browser_core/src/state/tab_groups.rs new file mode 100644 index 0000000..81c5dd0 --- /dev/null +++ b/crates/ely_browser_core/src/state/tab_groups.rs @@ -0,0 +1,100 @@ +use ely_domain::{TabGroup, TabGroupId, TabId}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn group_active_tab(&mut self, name: impl Into) -> Result { + let group_id = self.find_or_create_active_space_tab_group(name)?; + let active_tab_id = self.active_tab_id.clone(); + self.assign_tab_to_group(&active_tab_id, &group_id)?; + Ok(group_id) + } + + pub fn assign_tab_to_group( + &mut self, + tab_id: &TabId, + group_id: &TabGroupId, + ) -> Result<(), CoreError> { + let group_space_id = self + .tab_groups + .iter() + .find(|group| group.id() == group_id) + .map(|group| group.space_id().clone()) + .ok_or_else(|| CoreError::TabGroupNotFound { id: group_id.clone() })?; + let tab = self + .tabs + .iter_mut() + .find(|tab| tab.id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + + if tab.space_id() != &group_space_id { + return Err(CoreError::TabGroupNotFound { id: group_id.clone() }); + } + + tab.set_group_id(group_id.clone()); + Ok(()) + } + + pub fn clear_tab_group(&mut self, tab_id: &TabId) -> Result<(), CoreError> { + let tab = self + .tabs + .iter_mut() + .find(|tab| tab.id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + tab.clear_group_id(); + Ok(()) + } + + pub(super) fn visible_tab_groups(&self) -> Vec { + let mut groups = self + .tab_groups + .iter() + .filter(|group| group.space_id() == &self.active_space_id) + .cloned() + .collect::>(); + groups.sort_by(|left, right| { + left.sort_key().cmp(&right.sort_key()).then_with(|| left.id().cmp(right.id())) + }); + groups + } + + fn find_or_create_active_space_tab_group( + &mut self, + name: impl Into, + ) -> Result { + let name = name.into(); + if let Some(group_id) = self.find_active_space_group_id(&name) { + return Ok(group_id); + } + + let active_space_id = self.active_space_id.clone(); + let color_hex = self.active_space()?.accent_hex(); + let sort_key = self.next_tab_group_sort_key(); + let group = TabGroup::new(active_space_id, name, color_hex, sort_key)?; + let group_id = group.id().clone(); + self.tab_groups.push(group); + Ok(group_id) + } + + fn find_active_space_group_id(&self, name: &str) -> Option { + let normalized_name = name.trim().to_lowercase(); + self.tab_groups + .iter() + .find(|group| { + group.space_id() == &self.active_space_id + && group.name().to_lowercase() == normalized_name + }) + .map(|group| group.id().clone()) + } + + fn next_tab_group_sort_key(&self) -> u64 { + self.tab_groups + .iter() + .filter(|group| group.space_id() == &self.active_space_id) + .map(TabGroup::sort_key) + .max() + .map_or(0, |sort_key| sort_key.saturating_add(1)) + } +} diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index 697b924..7144c20 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -4,13 +4,12 @@ use ely_domain::{ ArchivePolicy, ArchiveSource, ArchivedTab, BrowserTab, ProfileId, SpaceId, TabId, UrlText, }; +use super::BrowserCore; use crate::{ CoreError, navigation::{tab_matches_query, tab_title}, }; -use super::BrowserCore; - impl BrowserCore { pub fn open_new_tab(&mut self) -> Result { let url = self.new_tab_url()?; @@ -53,6 +52,7 @@ impl BrowserCore { let tab_index = self.active_tab_index()?; let target_sort_key = self.next_tab_sort_key(space_id); self.tabs[tab_index].move_to_space(space_id.clone()); + self.tabs[tab_index].clear_group_id(); self.tabs[tab_index].set_sort_key(target_sort_key); self.sort_tabs_within_space(space_id); self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone()); diff --git a/crates/ely_browser_core/tests/tab_groups.rs b/crates/ely_browser_core/tests/tab_groups.rs new file mode 100644 index 0000000..6251f1c --- /dev/null +++ b/crates/ely_browser_core/tests/tab_groups.rs @@ -0,0 +1,104 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; +use ely_domain::{CommandIntent, UrlText}; + +#[test] +fn group_active_tab_creates_visible_space_group() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let group_id = core.group_active_tab("Research")?; + let snapshot = core.snapshot()?; + let active_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .ok_or(CoreError::MissingActiveTab)?; + + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(snapshot.tab_groups[0].id(), &group_id); + assert_eq!(snapshot.tab_groups[0].name(), "Research"); + assert_eq!(snapshot.tab_groups[0].space_id(), &snapshot.active_space_id); + assert_eq!(active_tab.group_id(), Some(&group_id)); + Ok(()) +} + +#[test] +fn group_active_tab_reuses_matching_space_group() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_group_id = core.group_active_tab("Research")?; + + let second_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + let second_group_id = core.group_active_tab("research")?; + let snapshot = core.snapshot()?; + let grouped_count = + snapshot.tabs.iter().filter(|tab| tab.group_id() == Some(&first_group_id)).count(); + + assert_eq!(first_group_id, second_group_id); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(grouped_count, 2); + assert_eq!(snapshot.active_tab_id, second_tab_id); + Ok(()) +} + +#[test] +fn tab_groups_stay_with_active_space() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let group_id = core.group_active_tab("Research")?; + let research_space_id = core.create_space("Research", "R", 0xf54e00)?; + + let research_snapshot = core.snapshot()?; + assert_eq!(research_snapshot.active_space_id, research_space_id); + assert!(research_snapshot.tab_groups.is_empty()); + + core.select_space(&work_space_id)?; + let work_snapshot = core.snapshot()?; + + assert_eq!(work_snapshot.tab_groups.len(), 1); + assert_eq!(work_snapshot.tab_groups[0].id(), &group_id); + Ok(()) +} + +#[test] +fn moving_grouped_tab_to_another_space_clears_group() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let research_space_id = core.create_space("Research", "R", 0xf54e00)?; + core.select_space(&work_space_id)?; + let moved_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + core.group_active_tab("Docs")?; + + core.move_active_tab_to_space(&research_space_id)?; + let snapshot = core.snapshot()?; + let moved_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &moved_tab_id) + .ok_or(CoreError::MissingActiveTab)?; + + assert_eq!(snapshot.active_space_id, research_space_id); + assert_eq!(moved_tab.group_id(), None); + Ok(()) +} + +#[test] +fn group_tab_command_groups_active_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">group-tab Research"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .ok_or(CoreError::MissingActiveTab)?; + + assert_eq!(intent, Some(CommandIntent::Command("group-tab Research".to_string()))); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(snapshot.tab_groups[0].name(), "Research"); + assert_eq!(active_tab.group_id(), Some(snapshot.tab_groups[0].id())); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} diff --git a/crates/ely_domain/src/identifiers.rs b/crates/ely_domain/src/identifiers.rs index a3351fa..f88d2dd 100644 --- a/crates/ely_domain/src/identifiers.rs +++ b/crates/ely_domain/src/identifiers.rs @@ -36,6 +36,7 @@ macro_rules! entity_id { entity_id!(TabId, "tab"); entity_id!(SpaceId, "space"); entity_id!(ProfileId, "profile"); +entity_id!(TabGroupId, "tab_group"); entity_id!(SplitId, "split"); entity_id!(WebViewId, "webview"); entity_id!(DownloadId, "download"); diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index b69e06a..5a7ce01 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -18,6 +18,7 @@ mod space; mod split; mod sync; mod tab; +mod tab_group; mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; @@ -31,7 +32,8 @@ pub use error::DomainError; pub use favorite::FavoriteLimit; pub use history::HistoryEntry; pub use identifiers::{ - BookmarkId, DownloadId, NoteId, ProfileId, ReadingListId, SpaceId, SplitId, TabId, WebViewId, + BookmarkId, DownloadId, NoteId, ProfileId, ReadingListId, SpaceId, SplitId, TabGroupId, TabId, + WebViewId, }; pub use new_tab::NewTabDestination; pub use note::{NoteEntry, NoteTarget}; @@ -54,4 +56,5 @@ pub use sync::{ SyncStatus, }; pub use tab::{BrowserTab, TabFlags, TabState}; +pub use tab_group::TabGroup; pub use url_text::UrlText; diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 52b8d7b..aa64d5c 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -1,6 +1,6 @@ use std::time::SystemTime; -use crate::{DomainError, ProfileId, SpaceId, SplitId, TabId, UrlText}; +use crate::{DomainError, ProfileId, SpaceId, SplitId, TabGroupId, TabId, UrlText}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum TabState { @@ -30,6 +30,7 @@ pub struct BrowserTab { parent_tab_id: Option, state: TabState, flags: TabFlags, + group_id: Option, split_id: Option, sort_key: u64, sync_enabled: bool, @@ -57,6 +58,7 @@ impl BrowserTab { parent_tab_id: None, state: TabState::Ready, flags: TabFlags::default(), + group_id: None, split_id: None, sort_key: 0, sync_enabled: true, @@ -176,6 +178,19 @@ impl BrowserTab { self.space_id = space_id; } + #[must_use] + pub fn group_id(&self) -> Option<&TabGroupId> { + self.group_id.as_ref() + } + + pub fn set_group_id(&mut self, group_id: TabGroupId) { + self.group_id = Some(group_id); + } + + pub fn clear_group_id(&mut self) { + self.group_id = None; + } + #[must_use] pub fn split_id(&self) -> Option<&SplitId> { self.split_id.as_ref() diff --git a/crates/ely_domain/src/tab_group.rs b/crates/ely_domain/src/tab_group.rs new file mode 100644 index 0000000..9e28cc2 --- /dev/null +++ b/crates/ely_domain/src/tab_group.rs @@ -0,0 +1,160 @@ +use std::time::{Duration, SystemTime}; + +use crate::{DomainError, SpaceId, TabGroupId}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TabGroup { + id: TabGroupId, + space_id: SpaceId, + name: String, + color_hex: u32, + collapsed: bool, + sort_key: u64, + created_at: SystemTime, + updated_at: SystemTime, +} + +impl TabGroup { + pub fn new( + space_id: SpaceId, + name: impl Into, + color_hex: u32, + sort_key: u64, + ) -> Result { + let name = normalized_name(name)?; + let created_at = SystemTime::now(); + Ok(Self { + id: TabGroupId::new(), + space_id, + name, + color_hex, + collapsed: false, + sort_key, + created_at, + updated_at: created_at, + }) + } + + #[must_use] + pub fn id(&self) -> &TabGroupId { + &self.id + } + + #[must_use] + pub fn space_id(&self) -> &SpaceId { + &self.space_id + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + pub fn rename(&mut self, name: impl Into) -> Result<(), DomainError> { + self.name = normalized_name(name)?; + self.record_update(); + Ok(()) + } + + #[must_use] + pub fn color_hex(&self) -> u32 { + self.color_hex + } + + pub fn set_color_hex(&mut self, color_hex: u32) { + self.color_hex = color_hex; + self.record_update(); + } + + #[must_use] + pub fn collapsed(&self) -> bool { + self.collapsed + } + + pub fn set_collapsed(&mut self, collapsed: bool) { + self.collapsed = collapsed; + self.record_update(); + } + + #[must_use] + pub fn sort_key(&self) -> u64 { + self.sort_key + } + + pub fn set_sort_key(&mut self, sort_key: u64) { + self.sort_key = sort_key; + self.record_update(); + } + + #[must_use] + pub fn created_at(&self) -> SystemTime { + self.created_at + } + + #[must_use] + pub fn updated_at(&self) -> SystemTime { + self.updated_at + } + + fn record_update(&mut self) { + let now = SystemTime::now(); + self.updated_at = + if now > self.updated_at { now } else { self.updated_at + Duration::from_nanos(1) }; + } +} + +fn normalized_name(name: impl Into) -> Result { + let name = name.into(); + let name = name.trim(); + if name.is_empty() { + return Err(DomainError::EmptyField { field: "tab_group_name" }); + } + + Ok(name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::TabGroup; + use crate::{DomainError, SpaceId}; + + #[test] + fn creates_tab_group_with_trimmed_name() -> Result<(), DomainError> { + let space_id = SpaceId::new(); + let group = TabGroup::new(space_id.clone(), " Research ", 0xf54e00, 7)?; + + assert_eq!(group.space_id(), &space_id); + assert_eq!(group.name(), "Research"); + assert_eq!(group.color_hex(), 0xf54e00); + assert_eq!(group.sort_key(), 7); + assert!(!group.collapsed()); + Ok(()) + } + + #[test] + fn rejects_empty_tab_group_name() -> Result<(), Box> { + let error = match TabGroup::new(SpaceId::new(), " ", 0xf54e00, 0) { + Err(error) => error, + Ok(_) => return Err("empty tab group name was accepted".into()), + }; + + assert_eq!(error, DomainError::EmptyField { field: "tab_group_name" }); + Ok(()) + } + + #[test] + fn updates_mutable_tab_group_fields() -> Result<(), DomainError> { + let mut group = TabGroup::new(SpaceId::new(), "Research", 0xf54e00, 0)?; + + group.rename("Docs")?; + group.set_color_hex(0x2f6fed); + group.set_collapsed(true); + group.set_sort_key(9); + + assert_eq!(group.name(), "Docs"); + assert_eq!(group.color_hex(), 0x2f6fed); + assert!(group.collapsed()); + assert_eq!(group.sort_key(), 9); + Ok(()) + } +}