Add tab group sidebar support
This commit is contained in:
@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
|
|||||||
|
|
||||||
use ely_browser_core::BrowserSnapshot;
|
use ely_browser_core::BrowserSnapshot;
|
||||||
use ely_design_system::{colors, spacing};
|
use ely_design_system::{colors, spacing};
|
||||||
use ely_domain::{BrowserTab, SplitAxis, SplitId, SplitLayout};
|
use ely_domain::{BrowserTab, SplitAxis, SplitId, SplitLayout, TabGroup, TabGroupId};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString,
|
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||||
StatefulInteractiveElement, Styled, Window, div, px, rgb,
|
StatefulInteractiveElement, Styled, Window, div, px, rgb,
|
||||||
@@ -18,30 +18,27 @@ impl ElyShell {
|
|||||||
snapshot: &BrowserSnapshot,
|
snapshot: &BrowserSnapshot,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Vec<AnyElement> {
|
) -> Vec<AnyElement> {
|
||||||
let mut rendered_split_ids = BTreeSet::new();
|
let mut rows = Vec::new();
|
||||||
let active_split_id = active_split_id(snapshot);
|
let mut row_state = SidebarRowState::new(snapshot);
|
||||||
|
|
||||||
snapshot
|
for tab in snapshot.tabs.iter().filter(|tab| sidebar_tab_is_visible(tab)) {
|
||||||
.tabs
|
let Some(group_id) = tab.group_id() else {
|
||||||
.iter()
|
self.push_sidebar_tab_row(&mut rows, snapshot, tab, &mut row_state, cx);
|
||||||
.filter(|tab| !tab.flags().favorite)
|
continue;
|
||||||
.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));
|
|
||||||
};
|
|
||||||
|
|
||||||
if rendered_split_ids.insert(split_id.clone()) {
|
let Some(group) = snapshot.tab_groups.iter().find(|group| group.id() == group_id)
|
||||||
let active = active_split_id.as_ref() == Some(split_id);
|
else {
|
||||||
return self.render_saved_split_row(layout, active, cx);
|
self.push_sidebar_tab_row(&mut rows, snapshot, tab, &mut row_state, cx);
|
||||||
}
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
None
|
if row_state.rendered_group_ids.insert(group_id.clone()) {
|
||||||
})
|
self.push_tab_group_rows(&mut rows, snapshot, group, &mut row_state, cx);
|
||||||
.collect()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn render_content_area(
|
pub(super) fn render_content_area(
|
||||||
@@ -190,7 +187,113 @@ impl ElyShell {
|
|||||||
.into_any_element()
|
.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,
|
&mut self,
|
||||||
layout: &SplitLayout,
|
layout: &SplitLayout,
|
||||||
active: bool,
|
active: bool,
|
||||||
@@ -261,9 +364,48 @@ fn active_split_id(snapshot: &BrowserSnapshot) -> Option<SplitId> {
|
|||||||
.and_then(|tab| tab.split_id().cloned())
|
.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>(
|
fn saved_split_layout<'a>(
|
||||||
snapshot: &'a BrowserSnapshot,
|
snapshot: &'a BrowserSnapshot,
|
||||||
split_id: &SplitId,
|
split_id: &SplitId,
|
||||||
) -> Option<&'a SplitLayout> {
|
) -> Option<&'a SplitLayout> {
|
||||||
snapshot.split_layouts.iter().find(|layout| layout.id() == split_id && layout.saved())
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use ely_domain::{
|
use ely_domain::{
|
||||||
BookmarkId, DomainError, DownloadId, NoteId, PluginId, ProfileId, ReadingListId, SpaceId,
|
BookmarkId, DomainError, DownloadId, NoteId, PluginId, ProfileId, ReadingListId, SpaceId,
|
||||||
SplitId, TabId,
|
SplitId, TabGroupId, TabId,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -21,6 +21,9 @@ pub enum CoreError {
|
|||||||
#[error("split not found: {id}")]
|
#[error("split not found: {id}")]
|
||||||
SplitNotFound { id: SplitId },
|
SplitNotFound { id: SplitId },
|
||||||
|
|
||||||
|
#[error("tab group not found: {id}")]
|
||||||
|
TabGroupNotFound { id: TabGroupId },
|
||||||
|
|
||||||
#[error("profile not found: {id}")]
|
#[error("profile not found: {id}")]
|
||||||
ProfileNotFound { id: ProfileId },
|
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 "])
|
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> {
|
pub(crate) fn new_profile_name(command: &str) -> Option<&str> {
|
||||||
command_argument(command, &["new-profile ", "new profile "])
|
command_argument(command, &["new-profile ", "new profile "])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use ely_domain::{
|
|||||||
ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry,
|
ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry,
|
||||||
DownloadPolicy, FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination,
|
DownloadPolicy, FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination,
|
||||||
NoteEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine,
|
NoteEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine,
|
||||||
SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId,
|
SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus,
|
||||||
UrlText,
|
TabGroup, TabId, UrlText,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{CoreError, navigation::tab_title};
|
use crate::{CoreError, navigation::tab_title};
|
||||||
@@ -22,6 +22,7 @@ mod reading_list;
|
|||||||
mod site_permissions;
|
mod site_permissions;
|
||||||
mod splits;
|
mod splits;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod tab_groups;
|
||||||
mod tab_order;
|
mod tab_order;
|
||||||
mod tabs;
|
mod tabs;
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ pub struct BrowserSnapshot {
|
|||||||
pub download_entries: Vec<DownloadEntry>,
|
pub download_entries: Vec<DownloadEntry>,
|
||||||
pub history_entries: Vec<HistoryEntry>,
|
pub history_entries: Vec<HistoryEntry>,
|
||||||
pub active_profile_history_entry_count: usize,
|
pub active_profile_history_entry_count: usize,
|
||||||
|
pub tab_groups: Vec<TabGroup>,
|
||||||
pub split_layouts: Vec<SplitLayout>,
|
pub split_layouts: Vec<SplitLayout>,
|
||||||
pub installed_plugins: Vec<InstalledPlugin>,
|
pub installed_plugins: Vec<InstalledPlugin>,
|
||||||
pub plugin_audit_events: Vec<PluginAuditEvent>,
|
pub plugin_audit_events: Vec<PluginAuditEvent>,
|
||||||
@@ -92,6 +94,7 @@ pub struct BrowserCore {
|
|||||||
site_permission_audit_events: Vec<SitePermissionAuditEvent>,
|
site_permission_audit_events: Vec<SitePermissionAuditEvent>,
|
||||||
download_entries: Vec<DownloadEntry>,
|
download_entries: Vec<DownloadEntry>,
|
||||||
history_entries: Vec<HistoryEntry>,
|
history_entries: Vec<HistoryEntry>,
|
||||||
|
tab_groups: Vec<TabGroup>,
|
||||||
split_layouts: Vec<SplitLayout>,
|
split_layouts: Vec<SplitLayout>,
|
||||||
archived_split_layouts: Vec<SplitLayout>,
|
archived_split_layouts: Vec<SplitLayout>,
|
||||||
installed_plugins: Vec<InstalledPlugin>,
|
installed_plugins: Vec<InstalledPlugin>,
|
||||||
@@ -161,6 +164,7 @@ impl BrowserCore {
|
|||||||
site_permission_audit_events: Vec::new(),
|
site_permission_audit_events: Vec::new(),
|
||||||
download_entries: Vec::new(),
|
download_entries: Vec::new(),
|
||||||
history_entries: Vec::new(),
|
history_entries: Vec::new(),
|
||||||
|
tab_groups: Vec::new(),
|
||||||
split_layouts: Vec::new(),
|
split_layouts: Vec::new(),
|
||||||
archived_split_layouts: Vec::new(),
|
archived_split_layouts: Vec::new(),
|
||||||
installed_plugins: Vec::new(),
|
installed_plugins: Vec::new(),
|
||||||
@@ -354,6 +358,7 @@ impl BrowserCore {
|
|||||||
download_entries: self.visible_downloads(),
|
download_entries: self.visible_downloads(),
|
||||||
history_entries: self.visible_history(),
|
history_entries: self.visible_history(),
|
||||||
active_profile_history_entry_count: self.active_profile_history_count(),
|
active_profile_history_entry_count: self.active_profile_history_count(),
|
||||||
|
tab_groups: self.visible_tab_groups(),
|
||||||
split_layouts: self.visible_split_layouts(),
|
split_layouts: self.visible_split_layouts(),
|
||||||
installed_plugins: self.installed_plugins.clone(),
|
installed_plugins: self.installed_plugins.clone(),
|
||||||
plugin_audit_events: self.plugin_audit_events.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,
|
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,
|
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,
|
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)?;
|
self.set_active_tab_reading_progress(percent)?;
|
||||||
return Ok(true);
|
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) {
|
if let Some(body) = tab_note_body(command) {
|
||||||
self.save_active_tab_note(body)?;
|
self.save_active_tab_note(body)?;
|
||||||
return Ok(true);
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,12 @@ use ely_domain::{
|
|||||||
ArchivePolicy, ArchiveSource, ArchivedTab, BrowserTab, ProfileId, SpaceId, TabId, UrlText,
|
ArchivePolicy, ArchiveSource, ArchivedTab, BrowserTab, ProfileId, SpaceId, TabId, UrlText,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::BrowserCore;
|
||||||
use crate::{
|
use crate::{
|
||||||
CoreError,
|
CoreError,
|
||||||
navigation::{tab_matches_query, tab_title},
|
navigation::{tab_matches_query, tab_title},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::BrowserCore;
|
|
||||||
|
|
||||||
impl BrowserCore {
|
impl BrowserCore {
|
||||||
pub fn open_new_tab(&mut self) -> Result<TabId, CoreError> {
|
pub fn open_new_tab(&mut self) -> Result<TabId, CoreError> {
|
||||||
let url = self.new_tab_url()?;
|
let url = self.new_tab_url()?;
|
||||||
@@ -53,6 +52,7 @@ impl BrowserCore {
|
|||||||
let tab_index = self.active_tab_index()?;
|
let tab_index = self.active_tab_index()?;
|
||||||
let target_sort_key = self.next_tab_sort_key(space_id);
|
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].move_to_space(space_id.clone());
|
||||||
|
self.tabs[tab_index].clear_group_id();
|
||||||
self.tabs[tab_index].set_sort_key(target_sort_key);
|
self.tabs[tab_index].set_sort_key(target_sort_key);
|
||||||
self.sort_tabs_within_space(space_id);
|
self.sort_tabs_within_space(space_id);
|
||||||
self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone());
|
self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone());
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ macro_rules! entity_id {
|
|||||||
entity_id!(TabId, "tab");
|
entity_id!(TabId, "tab");
|
||||||
entity_id!(SpaceId, "space");
|
entity_id!(SpaceId, "space");
|
||||||
entity_id!(ProfileId, "profile");
|
entity_id!(ProfileId, "profile");
|
||||||
|
entity_id!(TabGroupId, "tab_group");
|
||||||
entity_id!(SplitId, "split");
|
entity_id!(SplitId, "split");
|
||||||
entity_id!(WebViewId, "webview");
|
entity_id!(WebViewId, "webview");
|
||||||
entity_id!(DownloadId, "download");
|
entity_id!(DownloadId, "download");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod space;
|
|||||||
mod split;
|
mod split;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod tab;
|
mod tab;
|
||||||
|
mod tab_group;
|
||||||
mod url_text;
|
mod url_text;
|
||||||
|
|
||||||
pub use archive::{ArchiveSource, ArchivedTab};
|
pub use archive::{ArchiveSource, ArchivedTab};
|
||||||
@@ -31,7 +32,8 @@ pub use error::DomainError;
|
|||||||
pub use favorite::FavoriteLimit;
|
pub use favorite::FavoriteLimit;
|
||||||
pub use history::HistoryEntry;
|
pub use history::HistoryEntry;
|
||||||
pub use identifiers::{
|
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 new_tab::NewTabDestination;
|
||||||
pub use note::{NoteEntry, NoteTarget};
|
pub use note::{NoteEntry, NoteTarget};
|
||||||
@@ -54,4 +56,5 @@ pub use sync::{
|
|||||||
SyncStatus,
|
SyncStatus,
|
||||||
};
|
};
|
||||||
pub use tab::{BrowserTab, TabFlags, TabState};
|
pub use tab::{BrowserTab, TabFlags, TabState};
|
||||||
|
pub use tab_group::TabGroup;
|
||||||
pub use url_text::UrlText;
|
pub use url_text::UrlText;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::time::SystemTime;
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum TabState {
|
pub enum TabState {
|
||||||
@@ -30,6 +30,7 @@ pub struct BrowserTab {
|
|||||||
parent_tab_id: Option<TabId>,
|
parent_tab_id: Option<TabId>,
|
||||||
state: TabState,
|
state: TabState,
|
||||||
flags: TabFlags,
|
flags: TabFlags,
|
||||||
|
group_id: Option<TabGroupId>,
|
||||||
split_id: Option<SplitId>,
|
split_id: Option<SplitId>,
|
||||||
sort_key: u64,
|
sort_key: u64,
|
||||||
sync_enabled: bool,
|
sync_enabled: bool,
|
||||||
@@ -57,6 +58,7 @@ impl BrowserTab {
|
|||||||
parent_tab_id: None,
|
parent_tab_id: None,
|
||||||
state: TabState::Ready,
|
state: TabState::Ready,
|
||||||
flags: TabFlags::default(),
|
flags: TabFlags::default(),
|
||||||
|
group_id: None,
|
||||||
split_id: None,
|
split_id: None,
|
||||||
sort_key: 0,
|
sort_key: 0,
|
||||||
sync_enabled: true,
|
sync_enabled: true,
|
||||||
@@ -176,6 +178,19 @@ impl BrowserTab {
|
|||||||
self.space_id = space_id;
|
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]
|
#[must_use]
|
||||||
pub fn split_id(&self) -> Option<&SplitId> {
|
pub fn split_id(&self) -> Option<&SplitId> {
|
||||||
self.split_id.as_ref()
|
self.split_id.as_ref()
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user