Archive saved split views as groups

This commit is contained in:
2026-05-08 02:00:00 -04:00
parent 0da8a3531b
commit 9163d8b514
4 changed files with 241 additions and 4 deletions
@@ -182,6 +182,9 @@ impl BrowserCore {
self.close_active_tab()?; self.close_active_tab()?;
Ok(true) Ok(true)
} }
"close-split-view" | "close split view" => {
Ok(self.close_active_saved_split_view()?.is_some())
}
"favorite" | "toggle-favorite" => { "favorite" | "toggle-favorite" => {
self.toggle_active_tab_favorite()?; self.toggle_active_tab_favorite()?;
Ok(true) Ok(true)
+147 -1
View File
@@ -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 crate::CoreError;
use super::BrowserCore; use super::BrowserCore;
impl BrowserCore { impl BrowserCore {
pub fn close_active_saved_split_view(&mut self) -> Result<Option<TabId>, 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<TabId, CoreError> {
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<Option<SplitId>, CoreError> { pub fn save_active_split_view(&mut self) -> Result<Option<SplitId>, CoreError> {
let Some(split_id) = self.active_tab()?.split_id().cloned() else { let Some(split_id) = self.active_tab()?.split_id().cloned() else {
return Ok(None); return Ok(None);
@@ -137,4 +183,104 @@ impl BrowserCore {
[first, second, ..] => format!("Split View: {first} + {second}"), [first, second, ..] => format!("Split View: {first} + {second}"),
}) })
} }
pub(super) fn saved_split_id_for_tab(&self, tab_id: &TabId) -> Option<SplitId> {
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<Vec<TabId>, 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<usize, CoreError> {
self.tabs
.iter()
.position(|tab| pane_ids.contains(tab.id()))
.ok_or(CoreError::MissingActiveTab)
}
fn remove_split_tabs(&mut self, pane_ids: &[TabId]) -> Result<Vec<ArchivedTab>, 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<TabId, CoreError> {
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())
}
} }
+14 -2
View File
@@ -72,11 +72,19 @@ impl BrowserCore {
} }
pub fn close_active_tab(&mut self) -> Result<TabId, CoreError> { pub fn close_active_tab(&mut self) -> Result<TabId, CoreError> {
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(); let tab_id = self.active_tab_id.clone();
self.close_tab(&tab_id) self.close_tab(&tab_id)
} }
pub fn close_tab(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> { pub fn close_tab(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> {
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 let close_index = self
.tabs .tabs
.iter() .iter()
@@ -285,7 +293,11 @@ impl BrowserCore {
self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url) 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<TabId> { pub(super) fn nearest_tab_in_space(
&self,
space_id: &SpaceId,
start_index: usize,
) -> Option<TabId> {
self.tabs self.tabs
.iter() .iter()
.skip(start_index) .skip(start_index)
@@ -294,7 +306,7 @@ impl BrowserCore {
.map(|tab| tab.id().clone()) .map(|tab| tab.id().clone())
} }
fn nearest_tab_in_space_profile( pub(super) fn nearest_tab_in_space_profile(
&self, &self,
space_id: &SpaceId, space_id: &SpaceId,
profile_id: &ProfileId, profile_id: &ProfileId,
+77 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{CommandIntent, SplitAxis}; use ely_domain::{CommandIntent, SplitAxis, UrlText};
#[test] #[test]
fn split_right_creates_two_pane_layout() -> Result<(), Box<dyn Error>> { fn split_right_creates_two_pane_layout() -> Result<(), Box<dyn Error>> {
@@ -75,6 +75,82 @@ fn save_split_view_command_preserves_query_without_active_split() -> Result<(),
Ok(()) Ok(())
} }
#[test]
fn close_active_tab_archives_saved_split_view() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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] #[test]
fn closing_split_pane_dissolves_two_pane_layout() -> Result<(), Box<dyn Error>> { fn closing_split_pane_dissolves_two_pane_layout() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;