From 499bcc1e5aa1ce8e5127d06e1fa7e11cf3189188 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:41:26 -0400 Subject: [PATCH] Convert split views to tab groups --- crates/ely_browser_core/src/error.rs | 3 + crates/ely_browser_core/src/navigation.rs | 7 ++ crates/ely_browser_core/src/state/commands.rs | 13 ++- .../ely_browser_core/src/state/tab_groups.rs | 82 +++++++++++++++++-- crates/ely_browser_core/tests/splits.rs | 66 +++++++++++++++ crates/ely_browser_core/tests/tab_groups.rs | 18 ++++ 6 files changed, 183 insertions(+), 6 deletions(-) diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index ec9c4f9..ead4a19 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -21,6 +21,9 @@ pub enum CoreError { #[error("split not found: {id}")] SplitNotFound { id: SplitId }, + #[error("split pane belongs to a different space: {id}")] + SplitPaneSpaceMismatch { id: SplitId }, + #[error("tab group not found: {id}")] TabGroupNotFound { id: TabGroupId }, diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 5b0edb1..9b95bea 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -104,6 +104,13 @@ pub(crate) fn tab_group_name(command: &str) -> Option<&str> { command_argument(command, &["group-tab ", "group tab ", "tab-group ", "tab group "]) } +pub(crate) fn split_group_name(command: &str) -> Option<&str> { + command_argument( + command, + &["group-split-view ", "group split view ", "split-view-to-group ", "split view to 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/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 7167d00..1ce31b5 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -9,7 +9,8 @@ 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_group_name, tab_note_body, task_manager_url, + split_group_name, switch_profile_name, sync_status_url, tab_group_name, tab_note_body, + task_manager_url, }, }; @@ -133,6 +134,12 @@ impl BrowserCore { self.set_active_tab_reading_progress(percent)?; return Ok(true); } + if command.eq_ignore_ascii_case("tab group to split") { + return Ok(self.split_active_tab_group()?.is_some()); + } + if let Some(name) = split_group_name(command) { + return Ok(self.group_active_split_view(Some(name))?.is_some()); + } if let Some(name) = tab_group_name(command) { self.group_active_tab(name)?; return Ok(true); @@ -159,6 +166,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()) } + "group-split-view" + | "group split view" + | "split-view-to-group" + | "split view to group" => Ok(self.group_active_split_view(None)?.is_some()), "toggle-tab-group" | "toggle tab group" => { Ok(self.toggle_active_tab_group_collapsed()?.is_some()) } diff --git a/crates/ely_browser_core/src/state/tab_groups.rs b/crates/ely_browser_core/src/state/tab_groups.rs index c4d8709..81ee603 100644 --- a/crates/ely_browser_core/src/state/tab_groups.rs +++ b/crates/ely_browser_core/src/state/tab_groups.rs @@ -1,5 +1,6 @@ use ely_domain::{ - MAX_SPLIT_PANES, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup, TabGroupId, TabId, + BrowserTab, MAX_SPLIT_PANES, SpaceId, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup, + TabGroupId, TabId, }; use crate::CoreError; @@ -89,6 +90,49 @@ impl BrowserCore { Ok(Some(split_id)) } + pub fn group_active_split_view( + &mut self, + name: Option<&str>, + ) -> Result, CoreError> { + let Some(split_id) = self.active_tab()?.split_id().cloned() else { + return Ok(None); + }; + let layout_index = self + .split_layouts + .iter() + .position(|layout| layout.id() == &split_id) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + let layout = self.split_layouts[layout_index].clone(); + if layout.pane_count() < 2 { + return Ok(None); + } + + let pane_ids = layout.panes().iter().map(|pane| pane.tab_id().clone()).collect::>(); + let space_id = self.split_pane_space_id(&split_id, &pane_ids)?; + let group_name = name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(|| layout.title().to_string(), ToString::to_string); + let color_hex = self + .spaces + .iter() + .find(|space| space.id() == &space_id) + .map(|space| space.accent_hex()) + .ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() })?; + let sort_key = self.next_tab_group_sort_key_for_space(&space_id); + let group = TabGroup::new(space_id, group_name, color_hex, sort_key)?; + let group_id = group.id().clone(); + + for tab in self.tabs.iter_mut().filter(|tab| pane_ids.contains(tab.id())) { + tab.clear_split_id(); + tab.set_group_id(group_id.clone()); + } + + self.split_layouts.remove(layout_index); + self.tab_groups.push(group); + Ok(Some(group_id)) + } + pub fn toggle_active_tab_group_collapsed(&mut self) -> Result, CoreError> { let Some(group_id) = self.active_tab_group_id()? else { return Ok(None); @@ -148,7 +192,7 @@ impl BrowserCore { 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 sort_key = self.next_tab_group_sort_key_for_space(&active_space_id); let group = TabGroup::new(active_space_id, name, color_hex, sort_key)?; let group_id = group.id().clone(); self.tab_groups.push(group); @@ -166,16 +210,16 @@ impl BrowserCore { .map(|group| group.id().clone()) } - fn next_tab_group_sort_key(&self) -> u64 { + fn next_tab_group_sort_key_for_space(&self, space_id: &SpaceId) -> u64 { self.tab_groups .iter() - .filter(|group| group.space_id() == &self.active_space_id) + .filter(|group| group.space_id() == space_id) .map(TabGroup::sort_key) .max() .map_or(0, |sort_key| sort_key.saturating_add(1)) } - fn active_space_group_tabs(&self, group_id: &TabGroupId) -> Vec { + fn active_space_group_tabs(&self, group_id: &TabGroupId) -> Vec { tab_order::sorted_tabs( self.tabs .iter() @@ -184,6 +228,34 @@ impl BrowserCore { ) } + fn split_pane_space_id( + &self, + split_id: &SplitId, + pane_ids: &[TabId], + ) -> Result { + let first_pane_id = + pane_ids.first().ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + let first_tab = self + .tabs + .iter() + .find(|tab| tab.id() == first_pane_id) + .ok_or_else(|| CoreError::TabNotFound { id: first_pane_id.clone() })?; + let space_id = first_tab.space_id().clone(); + + for pane_id in pane_ids { + let tab = self + .tabs + .iter() + .find(|tab| tab.id() == pane_id) + .ok_or_else(|| CoreError::TabNotFound { id: pane_id.clone() })?; + if tab.space_id() != &space_id { + return Err(CoreError::SplitPaneSpaceMismatch { id: split_id.clone() }); + } + } + + Ok(space_id) + } + fn active_tab_group_id(&self) -> Result, CoreError> { let tab = self.active_tab()?; let Some(group_id) = tab.group_id().cloned() else { diff --git a/crates/ely_browser_core/tests/splits.rs b/crates/ely_browser_core/tests/splits.rs index 3124f54..70ba2e6 100644 --- a/crates/ely_browser_core/tests/splits.rs +++ b/crates/ely_browser_core/tests/splits.rs @@ -158,6 +158,72 @@ fn close_split_view_command_preserves_query_without_saved_split() -> Result<(), Ok(()) } +#[test] +fn split_view_to_group_command_converts_active_split() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + core.split_active_tab_right()?; + let second_tab_id = core.active_tab()?.id().clone(); + core.save_active_split_view()?; + + core.set_command_query(">split-view-to-group Research"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let group = snapshot.tab_groups.first().ok_or("missing tab group")?; + let grouped_tab_ids = snapshot + .tabs + .iter() + .filter(|tab| tab.group_id() == Some(group.id())) + .map(|tab| tab.id().clone()) + .collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group Research".to_string()))); + assert_eq!(snapshot.active_tab_id, second_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(group.name(), "Research"); + assert_eq!(grouped_tab_ids, vec![first_tab_id, second_tab_id]); + assert!(snapshot.tabs.iter().all(|tab| tab.split_id().is_none())); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn split_view_to_group_command_uses_saved_split_title_without_name() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.split_active_tab_right()?; + core.save_active_split_view()?; + + core.set_command_query(">split-view-to-group"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let group = snapshot.tab_groups.first().ok_or("missing tab group")?; + + assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group".to_string()))); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(group.name(), "Split View: New Tab + New Tab"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn split_view_to_group_command_preserves_query_without_active_split() -> Result<(), Box> +{ + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">split-view-to-group Research"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group Research".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert!(snapshot.tab_groups.is_empty()); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.command_query, ">split-view-to-group Research"); + Ok(()) +} + #[test] fn restore_last_archived_tab_restores_saved_split_view() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/tab_groups.rs b/crates/ely_browser_core/tests/tab_groups.rs index 2abf4ff..d2376a1 100644 --- a/crates/ely_browser_core/tests/tab_groups.rs +++ b/crates/ely_browser_core/tests/tab_groups.rs @@ -210,6 +210,24 @@ fn split_tab_group_command_converts_active_group_to_split_view() -> Result<(), B Ok(()) } +#[test] +fn tab_group_to_split_alias_converts_active_group_to_split_view() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.group_active_tab("Research")?; + core.open_tab(UrlText::parse("https://example.com")?); + core.group_active_tab("Research")?; + + core.set_command_query(">tab group to split"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("tab group to split".to_string()))); + assert!(snapshot.tab_groups.is_empty()); + assert_eq!(snapshot.split_layouts.len(), 1); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + #[test] fn split_tab_group_command_preserves_query_for_single_tab_group() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;