Add tab group sidebar support

This commit is contained in:
2026-05-08 07:16:17 -04:00
parent cbd8d7a091
commit adf5b27207
12 changed files with 572 additions and 31 deletions
+163 -21
View File
@@ -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<Self>,
) -> Vec<AnyElement> {
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;
};
if row_state.rendered_group_ids.insert(group_id.clone()) {
self.push_tab_group_rows(&mut rows, snapshot, group, &mut row_state, cx);
}
}
None
})
.collect()
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<AnyElement>,
snapshot: &BrowserSnapshot,
group: &TabGroup,
row_state: &mut SidebarRowState,
cx: &mut Context<Self>,
) {
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<AnyElement>,
snapshot: &BrowserSnapshot,
tab: &BrowserTab,
row_state: &mut SidebarRowState,
cx: &mut Context<Self>,
) {
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<Self>,
) -> 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<SplitId> {
.and_then(|tab| tab.split_id().cloned())
}
fn active_group_id(snapshot: &BrowserSnapshot) -> Option<TabGroupId> {
snapshot
.tabs
.iter()
.find(|tab| tab.id() == &snapshot.active_tab_id)
.and_then(|tab| tab.group_id().cloned())
}
struct SidebarRowState {
active_group_id: Option<TabGroupId>,
active_split_id: Option<SplitId>,
rendered_group_ids: BTreeSet<TabGroupId>,
rendered_split_ids: BTreeSet<SplitId>,
}
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
}
+4 -1
View File
@@ -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 },
@@ -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 "])
}
+7 -2
View File
@@ -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<DownloadEntry>,
pub history_entries: Vec<HistoryEntry>,
pub active_profile_history_entry_count: usize,
pub tab_groups: Vec<TabGroup>,
pub split_layouts: Vec<SplitLayout>,
pub installed_plugins: Vec<InstalledPlugin>,
pub plugin_audit_events: Vec<PluginAuditEvent>,
@@ -92,6 +94,7 @@ pub struct BrowserCore {
site_permission_audit_events: Vec<SitePermissionAuditEvent>,
download_entries: Vec<DownloadEntry>,
history_entries: Vec<HistoryEntry>,
tab_groups: Vec<TabGroup>,
split_layouts: Vec<SplitLayout>,
archived_split_layouts: Vec<SplitLayout>,
installed_plugins: Vec<InstalledPlugin>,
@@ -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(),
@@ -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);
@@ -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<String>) -> Result<TabGroupId, CoreError> {
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<TabGroup> {
let mut groups = self
.tab_groups
.iter()
.filter(|group| group.space_id() == &self.active_space_id)
.cloned()
.collect::<Vec<_>>();
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<String>,
) -> Result<TabGroupId, CoreError> {
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<TabGroupId> {
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))
}
}
+2 -2
View File
@@ -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<TabId, CoreError> {
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());
+104
View File
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
+1
View File
@@ -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");
+4 -1
View File
@@ -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;
+16 -1
View File
@@ -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<TabId>,
state: TabState,
flags: TabFlags,
group_id: Option<TabGroupId>,
split_id: Option<SplitId>,
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()
+160
View File
@@ -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<String>,
color_hex: u32,
sort_key: u64,
) -> Result<Self, DomainError> {
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<String>) -> 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<String>) -> Result<String, DomainError> {
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<dyn std::error::Error>> {
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(())
}
}