From 2b8d9051df7613bea534a32bd370a2ccb90cbb41 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:32:39 -0400 Subject: [PATCH] Convert tab groups to split views --- crates/ely_browser_core/src/state/commands.rs | 3 + .../ely_browser_core/src/state/tab_groups.rs | 46 +++++++++++++- crates/ely_browser_core/tests/tab_groups.rs | 62 ++++++++++++++++++- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index ba84a79..7167d00 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -156,6 +156,9 @@ impl BrowserCore { Ok(true) } "save-split-view" | "save split view" => Ok(self.save_active_split_view()?.is_some()), + "split-tab-group" | "split tab group" | "tab-group-to-split" | "tab group to split" => { + Ok(self.split_active_tab_group()?.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 f34fa9e..c4d8709 100644 --- a/crates/ely_browser_core/src/state/tab_groups.rs +++ b/crates/ely_browser_core/src/state/tab_groups.rs @@ -1,8 +1,10 @@ -use ely_domain::{TabGroup, TabGroupId, TabId}; +use ely_domain::{ + MAX_SPLIT_PANES, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup, TabGroupId, TabId, +}; use crate::CoreError; -use super::BrowserCore; +use super::{BrowserCore, tab_order}; impl BrowserCore { pub fn group_active_tab(&mut self, name: impl Into) -> Result { @@ -56,6 +58,37 @@ impl BrowserCore { Ok(true) } + pub fn split_active_tab_group(&mut self) -> Result, CoreError> { + let Some(group_id) = self.active_tab_group_id()? else { + return Ok(None); + }; + let group_tabs = self.active_space_group_tabs(&group_id); + if group_tabs.len() < 2 { + return Ok(None); + } + if group_tabs.len() > MAX_SPLIT_PANES { + return Err(CoreError::SplitPaneLimitReached { limit: MAX_SPLIT_PANES }); + } + + let pane_ids = group_tabs.iter().map(|tab| tab.id().clone()).collect::>(); + for pane_id in &pane_ids { + self.detach_tab_from_split(pane_id); + } + + let panes = pane_ids.iter().cloned().map(|tab_id| SplitPane::new(tab_id, 1)).collect(); + let layout = SplitLayout::new(SplitAxis::Horizontal, panes); + let split_id = layout.id().clone(); + + for tab in self.tabs.iter_mut().filter(|tab| pane_ids.contains(tab.id())) { + tab.clear_group_id(); + tab.set_split_id(split_id.clone()); + } + + self.tab_groups.retain(|group| group.id() != &group_id); + self.split_layouts.push(layout); + Ok(Some(split_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); @@ -142,6 +175,15 @@ impl BrowserCore { .map_or(0, |sort_key| sort_key.saturating_add(1)) } + fn active_space_group_tabs(&self, group_id: &TabGroupId) -> Vec { + tab_order::sorted_tabs( + self.tabs + .iter() + .filter(|tab| tab.space_id() == &self.active_space_id) + .filter(|tab| tab.group_id() == Some(group_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/tab_groups.rs b/crates/ely_browser_core/tests/tab_groups.rs index 58ad59a..2abf4ff 100644 --- a/crates/ely_browser_core/tests/tab_groups.rs +++ b/crates/ely_browser_core/tests/tab_groups.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; -use ely_domain::{CommandIntent, UrlText}; +use ely_domain::{CommandIntent, MAX_SPLIT_PANES, SplitAxis, UrlText}; #[test] fn group_active_tab_creates_visible_space_group() -> Result<(), Box> { @@ -184,3 +184,63 @@ fn tab_group_command_preserves_query_without_active_group() -> Result<(), Boxtoggle-tab-group"); Ok(()) } + +#[test] +fn split_tab_group_command_converts_active_group_to_split_view() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + core.group_active_tab("Research")?; + let second_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + core.group_active_tab("research")?; + + core.set_command_query(">split-tab-group"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let layout = snapshot.split_layouts.first().ok_or("missing split layout")?; + let pane_ids = layout.panes().iter().map(|pane| pane.tab_id().clone()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("split-tab-group".to_string()))); + assert_eq!(snapshot.active_tab_id, second_tab_id); + assert_eq!(snapshot.command_query, ""); + assert!(snapshot.tab_groups.is_empty()); + assert_eq!(layout.axis(), &SplitAxis::Horizontal); + assert_eq!(pane_ids, vec![first_tab_id, second_tab_id]); + assert!(snapshot.tabs.iter().all(|tab| tab.group_id().is_none())); + assert!(snapshot.tabs.iter().all(|tab| tab.split_id() == Some(layout.id()))); + Ok(()) +} + +#[test] +fn split_tab_group_command_preserves_query_for_single_tab_group() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.group_active_tab("Research")?; + + core.set_command_query(">split-tab-group"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("split-tab-group".to_string()))); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.tab_groups.len(), 1); + assert_eq!(snapshot.command_query, ">split-tab-group"); + Ok(()) +} + +#[test] +fn split_tab_group_rejects_groups_above_pane_limit() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + for index in 0..=MAX_SPLIT_PANES { + if index > 0 { + core.open_tab(UrlText::parse(format!("https://example.com/{index}"))?); + } + core.group_active_tab("Research")?; + } + + let error = match core.split_active_tab_group() { + Err(error) => error, + Ok(_) => return Err("oversized tab group was converted to a split view".into()), + }; + + assert_eq!(error, CoreError::SplitPaneLimitReached { limit: MAX_SPLIT_PANES }); + Ok(()) +}