From f167b7999b0e0004a458ef6be9d33bd81a3e8fce 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 10:32:09 -0400 Subject: [PATCH] Add domain tab auto grouping --- crates/ely_browser_core/src/state/commands.rs | 4 ++ .../ely_browser_core/src/state/tab_groups.rs | 49 +++++++++++++ crates/ely_browser_core/tests/tab_groups.rs | 71 +++++++++++++++++++ docs/ui-shell.md | 18 +++++ 4 files changed, 142 insertions(+) diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 3930124..0fb0128 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -195,6 +195,10 @@ impl BrowserCore { "split-tab-group" | "split tab group" | "tab-group-to-split" | "tab group to split" => { Ok(self.split_active_tab_group()?.is_some()) } + "auto-group-domains" + | "auto group domains" + | "auto-group-domain-tabs" + | "auto group domain tabs" => Ok(self.auto_group_active_space_tabs_by_domain()? > 0), "group-split-view" | "group split view" | "split-view-to-group" diff --git a/crates/ely_browser_core/src/state/tab_groups.rs b/crates/ely_browser_core/src/state/tab_groups.rs index 81ee603..60974af 100644 --- a/crates/ely_browser_core/src/state/tab_groups.rs +++ b/crates/ely_browser_core/src/state/tab_groups.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use ely_domain::{ BrowserTab, MAX_SPLIT_PANES, SpaceId, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup, TabGroupId, TabId, @@ -15,6 +17,25 @@ impl BrowserCore { Ok(group_id) } + pub fn auto_group_active_space_tabs_by_domain(&mut self) -> Result { + let domain_tab_ids = self.active_space_domain_tab_ids(); + let mut grouped_count = 0; + + for (domain, tab_ids) in domain_tab_ids { + if tab_ids.len() < 2 { + continue; + } + + let group_id = self.find_or_create_active_space_tab_group(domain)?; + for tab_id in tab_ids { + self.assign_tab_to_group(&tab_id, &group_id)?; + grouped_count += 1; + } + } + + Ok(grouped_count) + } + pub fn assign_tab_to_group( &mut self, tab_id: &TabId, @@ -210,6 +231,26 @@ impl BrowserCore { .map(|group| group.id().clone()) } + fn active_space_domain_tab_ids(&self) -> BTreeMap> { + let mut domain_tab_ids = BTreeMap::new(); + for tab in self.tabs.iter().filter(|tab| self.tab_is_domain_group_candidate(tab)) { + let Some(domain) = domain_group_name(tab) else { + continue; + }; + domain_tab_ids.entry(domain).or_insert_with(Vec::new).push(tab.id().clone()); + } + + domain_tab_ids + } + + fn tab_is_domain_group_candidate(&self, tab: &BrowserTab) -> bool { + tab.space_id() == &self.active_space_id + && tab.group_id().is_none() + && tab.split_id().is_none() + && !tab.flags().favorite + && !tab.flags().pinned + } + fn next_tab_group_sort_key_for_space(&self, space_id: &SpaceId) -> u64 { self.tab_groups .iter() @@ -275,3 +316,11 @@ impl BrowserCore { .ok_or_else(|| CoreError::TabGroupNotFound { id: group_id.clone() }) } } + +fn domain_group_name(tab: &BrowserTab) -> Option { + if tab.url().as_str().starts_with("ely://") { + return None; + } + + tab.url().host() +} diff --git a/crates/ely_browser_core/tests/tab_groups.rs b/crates/ely_browser_core/tests/tab_groups.rs index d2376a1..07856e8 100644 --- a/crates/ely_browser_core/tests/tab_groups.rs +++ b/crates/ely_browser_core/tests/tab_groups.rs @@ -103,6 +103,70 @@ fn group_tab_command_groups_active_tab() -> Result<(), Box> { Ok(()) } +#[test] +fn auto_group_active_space_tabs_by_domain_groups_matching_hosts() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.open_tab(UrlText::parse("https://example.com/a")?); + let second_tab_id = core.open_tab(UrlText::parse("https://example.com/b")?); + let singleton_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + let grouped_count = core.auto_group_active_space_tabs_by_domain()?; + let snapshot = core.snapshot()?; + let group = snapshot.tab_groups.first().ok_or("missing domain tab group")?; + + assert_eq!(grouped_count, 2); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(group.name(), "example.com"); + assert_eq!(group.space_id(), &snapshot.active_space_id); + assert_eq!(tab_group_id(&snapshot, &first_tab_id), Some(group.id())); + assert_eq!(tab_group_id(&snapshot, &second_tab_id), Some(group.id())); + assert_eq!(tab_group_id(&snapshot, &singleton_tab_id), None); + Ok(()) +} + +#[test] +fn auto_group_domain_command_groups_matching_hosts() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.open_tab(UrlText::parse("https://example.com/one")?); + let second_tab_id = core.open_tab(UrlText::parse("https://example.com/two")?); + + core.set_command_query(">auto-group-domains"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let group = snapshot.tab_groups.first().ok_or("missing domain tab group")?; + + assert_eq!(intent, Some(CommandIntent::Command("auto-group-domains".to_string()))); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(group.name(), "example.com"); + assert_eq!(tab_group_id(&snapshot, &first_tab_id), Some(group.id())); + assert_eq!(tab_group_id(&snapshot, &second_tab_id), Some(group.id())); + Ok(()) +} + +#[test] +fn auto_group_domains_preserves_existing_manual_groups() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let manual_tab_id = core.open_tab(UrlText::parse("https://example.com/manual")?); + let manual_group_id = core.group_active_tab("Manual")?; + let first_auto_tab_id = core.open_tab(UrlText::parse("https://example.com/auto-a")?); + let second_auto_tab_id = core.open_tab(UrlText::parse("https://example.com/auto-b")?); + + let grouped_count = core.auto_group_active_space_tabs_by_domain()?; + let snapshot = core.snapshot()?; + let domain_group = snapshot + .tab_groups + .iter() + .find(|group| group.name() == "example.com") + .ok_or("missing domain tab group")?; + + assert_eq!(grouped_count, 2); + assert_eq!(tab_group_id(&snapshot, &manual_tab_id), Some(&manual_group_id)); + assert_eq!(tab_group_id(&snapshot, &first_auto_tab_id), Some(domain_group.id())); + assert_eq!(tab_group_id(&snapshot, &second_auto_tab_id), Some(domain_group.id())); + Ok(()) +} + #[test] fn tab_group_collapse_commands_update_active_group() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -262,3 +326,10 @@ fn split_tab_group_rejects_groups_above_pane_limit() -> Result<(), Box( + snapshot: &'a ely_browser_core::BrowserSnapshot, + tab_id: &ely_domain::TabId, +) -> Option<&'a ely_domain::TabGroupId> { + snapshot.tabs.iter().find(|tab| tab.id() == tab_id).and_then(|tab| tab.group_id()) +} diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 2eb9174..f17d938 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -68,3 +68,21 @@ Sleeping tabs keep the same layout rhythm: │ │ └─────────────────────────────────────────┘ │ └──────────────────────────────┴───────────────────────────────────────────────┘ ``` + +Domain auto grouping keeps manual groups intact and groups matching ungrouped hosts: + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ELY Browser [ >auto-group-domains......................... ] [pin] [*] [+] │ +├──────────────────────────────┬───────────────────────────────────────────────┤ +│ Tabs │ example.com │ +│ [folder] example.com │ https://example.com/a │ +│ 2 tabs - Expanded │ │ +│ example.com │ │ +│ example.com │ │ +│ example.com │ │ +│ example.com │ │ +│ servo.org │ │ +│ servo.org │ │ +└──────────────────────────────┴───────────────────────────────────────────────┘ +```