diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index cfcf3c2..ab1bc8b 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -176,6 +176,17 @@ impl BrowserCore { "detach-split-pane" | "detach split pane" | "detach-pane" | "detach pane" => { self.detach_active_split_pane() } + "swap-split-pane" | "swap split pane" | "swap-pane" | "swap pane" => { + self.swap_active_split_pane() + } + "swap-split-pane-left" + | "swap split pane left" + | "swap-pane-left" + | "swap pane left" => self.swap_active_split_pane_previous(), + "swap-split-pane-right" + | "swap split pane right" + | "swap-pane-right" + | "swap pane right" => self.swap_active_split_pane_next(), "duplicate-split-pane" | "duplicate split pane" | "duplicate-pane" diff --git a/crates/ely_browser_core/src/state/splits.rs b/crates/ely_browser_core/src/state/splits.rs index 2064c35..679e0cb 100644 --- a/crates/ely_browser_core/src/state/splits.rs +++ b/crates/ely_browser_core/src/state/splits.rs @@ -130,6 +130,20 @@ impl BrowserCore { Ok(Some(duplicated_tab_id)) } + pub fn swap_active_split_pane(&mut self) -> Result { + self.swap_active_split_pane_by(|layout, tab_id| { + layout.swap_tab_with_next(tab_id) || layout.swap_tab_with_previous(tab_id) + }) + } + + pub fn swap_active_split_pane_previous(&mut self) -> Result { + self.swap_active_split_pane_by(SplitLayout::swap_tab_with_previous) + } + + pub fn swap_active_split_pane_next(&mut self) -> Result { + self.swap_active_split_pane_by(SplitLayout::swap_tab_with_next) + } + 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(); @@ -156,6 +170,35 @@ impl BrowserCore { Ok(split_id) } + fn swap_active_split_pane_by( + &mut self, + swap_pane: impl FnOnce(&mut SplitLayout, &TabId) -> bool, + ) -> 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); + }; + + let swapped = { + 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 }); + } + + swap_pane(layout, &active_tab_id) + }; + if swapped { + self.refresh_saved_split_title(&split_id)?; + } + + Ok(swapped) + } + pub(super) fn detach_tab_from_split(&mut self, tab_id: &TabId) { for tab in self.tabs.iter_mut().filter(|tab| tab.id() == tab_id) { tab.clear_split_id(); diff --git a/crates/ely_browser_core/tests/split_swap.rs b/crates/ely_browser_core/tests/split_swap.rs new file mode 100644 index 0000000..841a926 --- /dev/null +++ b/crates/ely_browser_core/tests/split_swap.rs @@ -0,0 +1,124 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, SplitId, TabId, UrlText}; + +#[test] +fn swap_split_pane_command_swaps_last_pane_with_previous() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let (split_id, first_tab_id, second_tab_id, third_tab_id) = create_three_pane_split(&mut core)?; + + core.set_command_query(">swap-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 pane_ids = layout.panes().iter().map(|pane| pane.tab_id()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("swap-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, third_tab_id); + assert_eq!(pane_ids, vec![&first_tab_id, &third_tab_id, &second_tab_id]); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn swap_split_pane_right_command_moves_active_pane_forward() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let (split_id, first_tab_id, second_tab_id, third_tab_id) = create_three_pane_split(&mut core)?; + core.select_tab(&first_tab_id)?; + + core.set_command_query(">swap-split-pane-right"); + 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 pane_ids = layout.panes().iter().map(|pane| pane.tab_id()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("swap-split-pane-right".to_string()))); + assert_eq!(snapshot.active_tab_id, first_tab_id); + assert_eq!(pane_ids, vec![&second_tab_id, &first_tab_id, &third_tab_id]); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn swap_split_pane_left_command_moves_active_pane_backward() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let (split_id, first_tab_id, second_tab_id, third_tab_id) = create_three_pane_split(&mut core)?; + core.select_tab(&second_tab_id)?; + + core.set_command_query(">swap-split-pane-left"); + 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 pane_ids = layout.panes().iter().map(|pane| pane.tab_id()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("swap-split-pane-left".to_string()))); + assert_eq!(snapshot.active_tab_id, second_tab_id); + assert_eq!(pane_ids, vec![&second_tab_id, &first_tab_id, &third_tab_id]); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn swap_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(">swap-split-pane"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("swap-split-pane".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.command_query, ">swap-split-pane"); + Ok(()) +} + +#[test] +fn swap_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")?; + 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.save_active_split_view()?; + + core.swap_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: servo.org + example.com"); + assert_eq!(layout.pane_count(), 2); + Ok(()) +} + +fn create_three_pane_split( + core: &mut BrowserCore, +) -> Result<(SplitId, TabId, TabId, TabId), Box> { + let first_tab_id = core.active_tab()?.id().clone(); + let split_id = core.split_active_tab_right()?; + let second_tab_id = core.active_tab()?.id().clone(); + core.split_active_tab_right()?; + let third_tab_id = core.active_tab()?.id().clone(); + + Ok((split_id, first_tab_id, second_tab_id, third_tab_id)) +} diff --git a/crates/ely_domain/src/split.rs b/crates/ely_domain/src/split.rs index cd5c3bf..2463ef0 100644 --- a/crates/ely_domain/src/split.rs +++ b/crates/ely_domain/src/split.rs @@ -109,6 +109,30 @@ impl SplitLayout { true } + pub fn swap_tab_with_previous(&mut self, tab_id: &TabId) -> bool { + let Some(index) = self.pane_index(tab_id) else { + return false; + }; + if index == 0 { + return false; + } + + self.panes.swap(index - 1, index); + true + } + + pub fn swap_tab_with_next(&mut self, tab_id: &TabId) -> bool { + let Some(index) = self.pane_index(tab_id) else { + return false; + }; + if index + 1 >= self.panes.len() { + return false; + } + + self.panes.swap(index, index + 1); + 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); @@ -119,4 +143,8 @@ impl SplitLayout { self.title = title.into(); self.saved = true; } + + fn pane_index(&self, tab_id: &TabId) -> Option { + self.panes.iter().position(|pane| pane.tab_id() == tab_id) + } }