From 1186509e26a79e782530f08ee86485e88abc5b31 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 08:03:15 -0400 Subject: [PATCH] Duplicate active split panes --- crates/ely_browser_core/src/state/commands.rs | 4 + crates/ely_browser_core/src/state/splits.rs | 33 +++++++ crates/ely_browser_core/src/state/tabs.rs | 2 +- .../ely_browser_core/tests/split_duplicate.rs | 98 +++++++++++++++++++ crates/ely_domain/src/split.rs | 14 +++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 crates/ely_browser_core/tests/split_duplicate.rs diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 3f2ea9e..cfcf3c2 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -176,6 +176,10 @@ impl BrowserCore { "detach-split-pane" | "detach split pane" | "detach-pane" | "detach pane" => { self.detach_active_split_pane() } + "duplicate-split-pane" + | "duplicate split pane" + | "duplicate-pane" + | "duplicate pane" => Ok(self.duplicate_active_split_pane()?.is_some()), "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 1fa3b1a..2064c35 100644 --- a/crates/ely_browser_core/src/state/splits.rs +++ b/crates/ely_browser_core/src/state/splits.rs @@ -97,6 +97,39 @@ impl BrowserCore { Ok(true) } + pub fn duplicate_active_split_pane(&mut self) -> Result, CoreError> { + let active_tab = self.active_tab()?; + let active_tab_id = active_tab.id().clone(); + let duplicate_url = active_tab.url().clone(); + let Some(split_id) = active_tab.split_id().cloned() else { + return Ok(None); + }; + + let mut duplicated_tab = self.build_tab(duplicate_url); + let duplicated_tab_id = duplicated_tab.id().clone(); + duplicated_tab.set_split_id(split_id.clone()); + + let layout = self + .split_layouts + .iter_mut() + .find(|layout| layout.id() == &split_id) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + if !layout.contains_tab(&active_tab_id) { + return Err(CoreError::TabNotFound { id: active_tab_id }); + } + if !layout.add_pane_after_tab(&active_tab_id, SplitPane::new(duplicated_tab_id.clone(), 1)) + { + return Err(CoreError::SplitPaneLimitReached { limit: MAX_SPLIT_PANES }); + } + + let insert_index = self.active_tab_index()? + 1; + self.record_history_entry(&duplicated_tab); + self.tabs.insert(insert_index, duplicated_tab); + self.select_tab(&duplicated_tab_id)?; + self.refresh_saved_split_title(&split_id)?; + Ok(Some(duplicated_tab_id)) + } + 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(); diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index 7144c20..e261268 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -383,7 +383,7 @@ impl BrowserCore { .ok_or(CoreError::MissingActiveTab) } - fn build_tab(&self, url: UrlText) -> BrowserTab { + pub(super) fn build_tab(&self, url: UrlText) -> BrowserTab { self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url) .with_parent_tab_id(self.active_tab_id.clone()) } diff --git a/crates/ely_browser_core/tests/split_duplicate.rs b/crates/ely_browser_core/tests/split_duplicate.rs new file mode 100644 index 0000000..0ec8f98 --- /dev/null +++ b/crates/ely_browser_core/tests/split_duplicate.rs @@ -0,0 +1,98 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; +use ely_domain::{CommandIntent, MAX_SPLIT_PANES, UrlText}; + +#[test] +fn duplicate_split_pane_command_copies_active_pane_after_source() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_tab_id = core.open_tab(UrlText::parse("https://example.com/docs")?); + let split_id = core.split_active_tab_right()?; + let trailing_tab_id = core.active_tab()?.id().clone(); + core.select_tab(&source_tab_id)?; + + core.set_command_query(">duplicate-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let layout = snapshot + .split_layouts + .iter() + .find(|layout| layout.id() == &split_id) + .ok_or("missing split layout")?; + let duplicated_tab = snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .ok_or("missing duplicated tab")?; + let pane_ids = layout.panes().iter().map(|pane| pane.tab_id()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("duplicate-split-pane".to_string()))); + assert_eq!(snapshot.tabs.iter().filter(|tab| tab.split_id() == Some(&split_id)).count(), 3); + assert_eq!(layout.pane_count(), 3); + assert_eq!(pane_ids, vec![&source_tab_id, &snapshot.active_tab_id, &trailing_tab_id]); + assert_eq!(duplicated_tab.url().as_str(), "https://example.com/docs"); + assert_eq!(duplicated_tab.parent_tab_id(), Some(&source_tab_id)); + assert_eq!(duplicated_tab.split_id(), Some(&split_id)); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn duplicate_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(">duplicate-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("duplicate-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.command_query, ">duplicate-split-pane"); + Ok(()) +} + +#[test] +fn duplicate_split_pane_rejects_full_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + for _ in 1..MAX_SPLIT_PANES { + core.split_active_tab_right()?; + } + let before = core.snapshot()?; + + let error = match core.duplicate_active_split_pane() { + Err(error) => error, + Ok(_) => return Err("full split layout accepted another pane".into()), + }; + let after = core.snapshot()?; + + assert_eq!(error, CoreError::SplitPaneLimitReached { limit: MAX_SPLIT_PANES }); + assert_eq!(after.tabs.len(), before.tabs.len()); + assert_eq!(after.active_tab_id, before.active_tab_id); + assert_eq!(after.split_layouts[0].pane_count(), MAX_SPLIT_PANES); + Ok(()) +} + +#[test] +fn duplicate_split_pane_refreshes_saved_split_title() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_tab_id = core.open_tab(UrlText::parse("https://example.com/docs")?); + let split_id = core.split_active_tab_right()?; + core.save_active_split_view()?; + core.select_tab(&source_tab_id)?; + + core.duplicate_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")?; + + assert!(layout.saved()); + assert_eq!(layout.title(), "Split View: example.com + example.com"); + assert_eq!(layout.pane_count(), 3); + Ok(()) +} diff --git a/crates/ely_domain/src/split.rs b/crates/ely_domain/src/split.rs index 13e5b6e..cd5c3bf 100644 --- a/crates/ely_domain/src/split.rs +++ b/crates/ely_domain/src/split.rs @@ -95,6 +95,20 @@ impl SplitLayout { true } + pub fn add_pane_after_tab(&mut self, after_tab_id: &TabId, pane: SplitPane) -> bool { + if self.panes.len() >= MAX_SPLIT_PANES || self.contains_tab(pane.tab_id()) { + return false; + } + + let Some(index) = self.panes.iter().position(|existing| existing.tab_id() == after_tab_id) + else { + return false; + }; + + self.panes.insert(index + 1, pane); + true + } + pub fn remove_tab(&mut self, tab_id: &TabId) -> bool { let original_len = self.panes.len(); self.panes.retain(|pane| pane.tab_id() != tab_id);