diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 0cc5318..2a00f76 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -182,6 +182,9 @@ impl BrowserCore { self.close_active_tab()?; Ok(true) } + "close-split-view" | "close split view" => { + Ok(self.close_active_saved_split_view()?.is_some()) + } "favorite" | "toggle-favorite" => { self.toggle_active_tab_favorite()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/splits.rs b/crates/ely_browser_core/src/state/splits.rs index e6a8eb7..3a7dd48 100644 --- a/crates/ely_browser_core/src/state/splits.rs +++ b/crates/ely_browser_core/src/state/splits.rs @@ -1,10 +1,56 @@ -use ely_domain::{MAX_SPLIT_PANES, SplitAxis, SplitId, SplitLayout, SplitPane, TabId}; +use ely_domain::{ + ArchiveSource, ArchivedTab, MAX_SPLIT_PANES, ProfileId, SpaceId, SplitAxis, SplitId, + SplitLayout, SplitPane, TabId, +}; use crate::CoreError; use super::BrowserCore; impl BrowserCore { + pub fn close_active_saved_split_view(&mut self) -> Result, CoreError> { + let Some(split_id) = self.active_tab()?.split_id().cloned() else { + return Ok(None); + }; + if !self.saved_split_exists(&split_id) { + return Ok(None); + } + + self.close_saved_split_view(&split_id).map(Some) + } + + pub fn close_saved_split_view(&mut self, split_id: &SplitId) -> Result { + let pane_ids = self.saved_split_pane_ids(split_id)?; + self.validate_split_panes_are_open(&pane_ids)?; + + let first_close_index = self.first_split_tab_index(&pane_ids)?; + let first_tab = self.tabs.get(first_close_index).ok_or(CoreError::MissingActiveTab)?; + let closed_space_id = first_tab.space_id().clone(); + let closed_profile_id = first_tab.profile_id().clone(); + let was_active = pane_ids.contains(&self.active_tab_id); + let was_space_active = self + .active_tabs_by_space + .get(&closed_space_id) + .is_some_and(|tab_id| pane_ids.contains(tab_id)); + let was_profile_active = self + .active_tabs_by_space_profile + .get(&(closed_space_id.clone(), closed_profile_id.clone())) + .is_some_and(|tab_id| pane_ids.contains(tab_id)); + + let archived_tabs = self.remove_split_tabs(&pane_ids)?; + self.archived_tabs.extend(archived_tabs); + self.split_layouts.retain(|layout| layout.id() != split_id); + + self.reselect_after_split_close( + closed_space_id, + closed_profile_id, + first_close_index, + was_active, + was_space_active, + was_profile_active, + ) + } + pub fn save_active_split_view(&mut self) -> Result, CoreError> { let Some(split_id) = self.active_tab()?.split_id().cloned() else { return Ok(None); @@ -137,4 +183,104 @@ impl BrowserCore { [first, second, ..] => format!("Split View: {first} + {second}"), }) } + + 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())?; + self.saved_split_exists(split_id).then(|| split_id.clone()) + } + + fn saved_split_exists(&self, split_id: &SplitId) -> bool { + self.split_layouts.iter().any(|layout| layout.id() == split_id && layout.saved()) + } + + fn saved_split_pane_ids(&self, split_id: &SplitId) -> Result, CoreError> { + self.split_layouts + .iter() + .find(|layout| layout.id() == split_id && layout.saved()) + .map(|layout| layout.panes().iter().map(|pane| pane.tab_id().clone()).collect()) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() }) + } + + fn validate_split_panes_are_open(&self, pane_ids: &[TabId]) -> Result<(), CoreError> { + if let Some(missing_tab_id) = + pane_ids.iter().find(|tab_id| !self.tabs.iter().any(|tab| tab.id() == *tab_id)) + { + return Err(CoreError::TabNotFound { id: missing_tab_id.clone() }); + } + + Ok(()) + } + + fn first_split_tab_index(&self, pane_ids: &[TabId]) -> Result { + self.tabs + .iter() + .position(|tab| pane_ids.contains(tab.id())) + .ok_or(CoreError::MissingActiveTab) + } + + fn remove_split_tabs(&mut self, pane_ids: &[TabId]) -> Result, CoreError> { + let mut archived_tabs = Vec::with_capacity(pane_ids.len()); + for pane_id in pane_ids { + let tab_index = self + .tabs + .iter() + .position(|tab| tab.id() == pane_id) + .ok_or_else(|| CoreError::TabNotFound { id: pane_id.clone() })?; + let mut tab = self.tabs.remove(tab_index); + tab.clear_split_id(); + archived_tabs.push(ArchivedTab::new(tab, ArchiveSource::ManualClose)); + } + + Ok(archived_tabs) + } + + fn reselect_after_split_close( + &mut self, + closed_space_id: SpaceId, + closed_profile_id: ProfileId, + start_index: usize, + was_active: bool, + was_space_active: bool, + was_profile_active: bool, + ) -> Result { + if let Some(next_tab_id) = self.nearest_tab_in_space(&closed_space_id, start_index) { + if was_space_active { + self.active_tabs_by_space.insert(closed_space_id.clone(), next_tab_id.clone()); + } + if was_profile_active { + self.active_tabs_by_space_profile + .remove(&(closed_space_id.clone(), closed_profile_id.clone())); + if let Some(next_profile_tab_id) = self.nearest_tab_in_space_profile( + &closed_space_id, + &closed_profile_id, + start_index, + ) { + self.active_tabs_by_space_profile + .insert((closed_space_id, closed_profile_id), next_profile_tab_id); + } + } + if was_active { + self.select_tab(&next_tab_id)?; + } + return Ok(self.active_tab_id.clone()); + } + + let replacement = self.build_tab_for( + closed_space_id.clone(), + closed_profile_id.clone(), + self.new_tab_url.clone(), + ); + let replacement_id = replacement.id().clone(); + self.tabs.insert(start_index.min(self.tabs.len()), replacement); + self.active_tabs_by_space.insert(closed_space_id.clone(), replacement_id.clone()); + self.active_tabs_by_space_profile + .insert((closed_space_id, closed_profile_id), replacement_id.clone()); + + if was_active { + self.select_tab(&replacement_id)?; + } + + Ok(self.active_tab_id.clone()) + } } diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index 0850b5c..72135e0 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -72,11 +72,19 @@ impl BrowserCore { } pub fn close_active_tab(&mut self) -> Result { + if let Some(active_tab_id) = self.close_active_saved_split_view()? { + return Ok(active_tab_id); + } + let tab_id = self.active_tab_id.clone(); self.close_tab(&tab_id) } pub fn close_tab(&mut self, tab_id: &TabId) -> Result { + if let Some(split_id) = self.saved_split_id_for_tab(tab_id) { + return self.close_saved_split_view(&split_id); + } + let close_index = self .tabs .iter() @@ -285,7 +293,11 @@ impl BrowserCore { self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url) } - fn nearest_tab_in_space(&self, space_id: &SpaceId, start_index: usize) -> Option { + pub(super) fn nearest_tab_in_space( + &self, + space_id: &SpaceId, + start_index: usize, + ) -> Option { self.tabs .iter() .skip(start_index) @@ -294,7 +306,7 @@ impl BrowserCore { .map(|tab| tab.id().clone()) } - fn nearest_tab_in_space_profile( + pub(super) fn nearest_tab_in_space_profile( &self, space_id: &SpaceId, profile_id: &ProfileId, diff --git a/crates/ely_browser_core/tests/splits.rs b/crates/ely_browser_core/tests/splits.rs index d880155..ef4b5e3 100644 --- a/crates/ely_browser_core/tests/splits.rs +++ b/crates/ely_browser_core/tests/splits.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, SplitAxis}; +use ely_domain::{CommandIntent, SplitAxis, UrlText}; #[test] fn split_right_creates_two_pane_layout() -> Result<(), Box> { @@ -75,6 +75,82 @@ fn save_split_view_command_preserves_query_without_active_split() -> Result<(), Ok(()) } +#[test] +fn close_active_tab_archives_saved_split_view() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let remaining_tab_id = core.active_tab()?.id().clone(); + core.open_tab(UrlText::parse("https://example.com/saved-split")?); + core.split_active_tab_right()?; + core.save_active_split_view()?; + + let active_tab_id = core.close_active_tab()?; + let snapshot = core.snapshot()?; + + assert_eq!(active_tab_id, remaining_tab_id); + assert_eq!(snapshot.active_tab_id, remaining_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.archived_tabs.len(), 2); + assert!(snapshot.archived_tabs.iter().all(|archived| archived.tab().split_id().is_none())); + Ok(()) +} + +#[test] +fn close_saved_split_member_archives_entire_saved_split_view() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let remaining_tab_id = core.active_tab()?.id().clone(); + core.open_tab(UrlText::parse("https://example.com/saved-split")?); + core.split_active_tab_right()?; + core.save_active_split_view()?; + let left_pane_id = core.snapshot()?.split_layouts[0].panes()[0].tab_id().clone(); + + let active_tab_id = core.close_tab(&left_pane_id)?; + let snapshot = core.snapshot()?; + + assert_eq!(active_tab_id, remaining_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.archived_tabs.len(), 2); + Ok(()) +} + +#[test] +fn close_split_view_command_archives_active_saved_split_view() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let remaining_tab_id = core.active_tab()?.id().clone(); + core.open_tab(UrlText::parse("https://example.com/saved-split")?); + core.split_active_tab_right()?; + core.save_active_split_view()?; + + core.set_command_query(">close-split-view"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("close-split-view".to_string()))); + assert_eq!(snapshot.active_tab_id, remaining_tab_id); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.archived_tabs.len(), 2); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn close_split_view_command_preserves_query_without_saved_split() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.split_active_tab_right()?; + let active_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">close-split-view"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("close-split-view".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.split_layouts.len(), 1); + assert_eq!(snapshot.command_query, ">close-split-view"); + Ok(()) +} + #[test] fn closing_split_pane_dissolves_two_pane_layout() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;