From 5179a1adf703233ff1ceb4b89702fd1b7ba0dafb 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:55:15 -0400 Subject: [PATCH] Detach active split panes --- crates/ely_browser_core/src/state/commands.rs | 3 + crates/ely_browser_core/src/state/splits.rs | 30 ++++++ crates/ely_browser_core/tests/splits.rs | 93 +++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index f34e311..3f2ea9e 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -173,6 +173,9 @@ impl BrowserCore { "split-grid" | "split grid" => { Ok(self.set_active_split_axis(SplitAxis::Grid)?.is_some()) } + "detach-split-pane" | "detach split pane" | "detach-pane" | "detach pane" => { + self.detach_active_split_pane() + } "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()) diff --git a/crates/ely_browser_core/src/state/splits.rs b/crates/ely_browser_core/src/state/splits.rs index 89e5bc5..1fa3b1a 100644 --- a/crates/ely_browser_core/src/state/splits.rs +++ b/crates/ely_browser_core/src/state/splits.rs @@ -82,6 +82,21 @@ impl BrowserCore { Ok(Some(split_id)) } + pub fn detach_active_split_pane(&mut self) -> Result { + let active_tab = self.active_tab()?; + let active_tab_id = active_tab.id().clone(); + let Some(split_id) = active_tab.split_id().cloned() else { + return Ok(false); + }; + if !self.split_layouts.iter().any(|layout| layout.id() == &split_id) { + return Err(CoreError::SplitNotFound { id: split_id }); + } + + self.detach_tab_from_split(&active_tab_id); + self.refresh_saved_split_title(&split_id)?; + Ok(true) + } + pub fn split_active_tab_right(&mut self) -> Result { let active_index = self.active_tab_index()?; let active_tab_id = self.tabs[active_index].id().clone(); @@ -200,6 +215,21 @@ impl BrowserCore { }) } + fn refresh_saved_split_title(&mut self, split_id: &SplitId) -> Result<(), CoreError> { + let Some(layout_index) = + self.split_layouts.iter().position(|layout| layout.id() == split_id) + else { + return Ok(()); + }; + if !self.split_layouts[layout_index].saved() { + return Ok(()); + } + + let title = self.active_split_title(split_id)?; + self.split_layouts[layout_index].save(title); + Ok(()) + } + pub(super) fn saved_split_id_for_tab(&self, tab_id: &TabId) -> Option { let split_id = self.tabs.iter().find(|tab| tab.id() == tab_id).and_then(|tab| tab.split_id())?; diff --git a/crates/ely_browser_core/tests/splits.rs b/crates/ely_browser_core/tests/splits.rs index 5812e25..1e9c869 100644 --- a/crates/ely_browser_core/tests/splits.rs +++ b/crates/ely_browser_core/tests/splits.rs @@ -146,6 +146,99 @@ fn split_grid_command_keeps_four_pane_layout() -> Result<(), Box> { Ok(()) } +#[test] +fn detach_split_pane_command_removes_active_pane_from_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let split_id = core.split_active_tab_right()?; + core.split_active_tab_right()?; + let detached_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">detach-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let layout = snapshot.split_layouts.first().ok_or("missing split layout")?; + let detached_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &detached_tab_id) + .ok_or("missing detached tab")?; + + assert_eq!(intent, Some(CommandIntent::Command("detach-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, detached_tab_id); + assert_eq!(detached_tab.split_id(), None); + assert_eq!(layout.id(), &split_id); + assert_eq!(layout.pane_count(), 2); + assert!(!layout.panes().iter().any(|pane| pane.tab_id() == &detached_tab_id)); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn detach_split_pane_command_dissolves_two_pane_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.split_active_tab_right()?; + let detached_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">detach-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("detach-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, detached_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert!(snapshot.tabs.iter().all(|tab| tab.split_id().is_none())); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn detach_split_pane_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(">detach-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("detach-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.command_query, ">detach-split-pane"); + Ok(()) +} + +#[test] +fn detach_split_pane_refreshes_saved_split_title() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/docs")?); + core.group_active_tab("Research")?; + let detached_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + core.group_active_tab("Research")?; + let split_id = core.split_active_tab_group()?.ok_or("missing split id")?; + core.split_active_tab_right()?; + core.save_active_split_view()?; + + core.select_tab(&detached_tab_id)?; + core.detach_active_split_pane()?; + let snapshot = core.snapshot()?; + let layout = snapshot + .split_layouts + .iter() + .find(|layout| layout.id() == &split_id) + .ok_or("missing saved split layout")?; + let detached_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &detached_tab_id) + .ok_or("missing detached tab")?; + + assert_eq!(detached_tab.split_id(), None); + assert!(layout.saved()); + assert_eq!(layout.title(), "Split View: example.com + New Tab"); + assert_eq!(layout.pane_count(), 2); + Ok(()) +} + #[test] fn close_active_tab_archives_saved_split_view() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;