Convert split views to tab groups

This commit is contained in:
2026-05-08 07:41:26 -04:00
parent 2b8d9051df
commit 499bcc1e5a
6 changed files with 183 additions and 6 deletions
+3
View File
@@ -21,6 +21,9 @@ pub enum CoreError {
#[error("split not found: {id}")] #[error("split not found: {id}")]
SplitNotFound { id: SplitId }, SplitNotFound { id: SplitId },
#[error("split pane belongs to a different space: {id}")]
SplitPaneSpaceMismatch { id: SplitId },
#[error("tab group not found: {id}")] #[error("tab group not found: {id}")]
TabGroupNotFound { id: TabGroupId }, TabGroupNotFound { id: TabGroupId },
@@ -104,6 +104,13 @@ pub(crate) fn tab_group_name(command: &str) -> Option<&str> {
command_argument(command, &["group-tab ", "group tab ", "tab-group ", "tab group "]) command_argument(command, &["group-tab ", "group tab ", "tab-group ", "tab group "])
} }
pub(crate) fn split_group_name(command: &str) -> Option<&str> {
command_argument(
command,
&["group-split-view ", "group split view ", "split-view-to-group ", "split view to group "],
)
}
pub(crate) fn new_profile_name(command: &str) -> Option<&str> { pub(crate) fn new_profile_name(command: &str) -> Option<&str> {
command_argument(command, &["new-profile ", "new profile "]) command_argument(command, &["new-profile ", "new profile "])
} }
+12 -1
View File
@@ -9,7 +9,8 @@ use crate::{
move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, note_body, move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, note_body,
notes_url, plugin_detail_url, plugins_url, reading_list_url, reading_progress_percent, notes_url, plugin_detail_url, plugins_url, reading_list_url, reading_progress_percent,
search_url, settings_page_url, settings_url, shortcut_settings_url, space_icon, search_url, settings_page_url, settings_url, shortcut_settings_url, space_icon,
switch_profile_name, sync_status_url, tab_group_name, tab_note_body, task_manager_url, split_group_name, switch_profile_name, sync_status_url, tab_group_name, tab_note_body,
task_manager_url,
}, },
}; };
@@ -133,6 +134,12 @@ impl BrowserCore {
self.set_active_tab_reading_progress(percent)?; self.set_active_tab_reading_progress(percent)?;
return Ok(true); return Ok(true);
} }
if command.eq_ignore_ascii_case("tab group to split") {
return Ok(self.split_active_tab_group()?.is_some());
}
if let Some(name) = split_group_name(command) {
return Ok(self.group_active_split_view(Some(name))?.is_some());
}
if let Some(name) = tab_group_name(command) { if let Some(name) = tab_group_name(command) {
self.group_active_tab(name)?; self.group_active_tab(name)?;
return Ok(true); return Ok(true);
@@ -159,6 +166,10 @@ impl BrowserCore {
"split-tab-group" | "split tab group" | "tab-group-to-split" | "tab group to split" => { "split-tab-group" | "split tab group" | "tab-group-to-split" | "tab group to split" => {
Ok(self.split_active_tab_group()?.is_some()) Ok(self.split_active_tab_group()?.is_some())
} }
"group-split-view"
| "group split view"
| "split-view-to-group"
| "split view to group" => Ok(self.group_active_split_view(None)?.is_some()),
"toggle-tab-group" | "toggle tab group" => { "toggle-tab-group" | "toggle tab group" => {
Ok(self.toggle_active_tab_group_collapsed()?.is_some()) Ok(self.toggle_active_tab_group_collapsed()?.is_some())
} }
@@ -1,5 +1,6 @@
use ely_domain::{ use ely_domain::{
MAX_SPLIT_PANES, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup, TabGroupId, TabId, BrowserTab, MAX_SPLIT_PANES, SpaceId, SplitAxis, SplitId, SplitLayout, SplitPane, TabGroup,
TabGroupId, TabId,
}; };
use crate::CoreError; use crate::CoreError;
@@ -89,6 +90,49 @@ impl BrowserCore {
Ok(Some(split_id)) Ok(Some(split_id))
} }
pub fn group_active_split_view(
&mut self,
name: Option<&str>,
) -> Result<Option<TabGroupId>, CoreError> {
let Some(split_id) = self.active_tab()?.split_id().cloned() else {
return Ok(None);
};
let layout_index = self
.split_layouts
.iter()
.position(|layout| layout.id() == &split_id)
.ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?;
let layout = self.split_layouts[layout_index].clone();
if layout.pane_count() < 2 {
return Ok(None);
}
let pane_ids = layout.panes().iter().map(|pane| pane.tab_id().clone()).collect::<Vec<_>>();
let space_id = self.split_pane_space_id(&split_id, &pane_ids)?;
let group_name = name
.map(str::trim)
.filter(|value| !value.is_empty())
.map_or_else(|| layout.title().to_string(), ToString::to_string);
let color_hex = self
.spaces
.iter()
.find(|space| space.id() == &space_id)
.map(|space| space.accent_hex())
.ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() })?;
let sort_key = self.next_tab_group_sort_key_for_space(&space_id);
let group = TabGroup::new(space_id, group_name, color_hex, sort_key)?;
let group_id = group.id().clone();
for tab in self.tabs.iter_mut().filter(|tab| pane_ids.contains(tab.id())) {
tab.clear_split_id();
tab.set_group_id(group_id.clone());
}
self.split_layouts.remove(layout_index);
self.tab_groups.push(group);
Ok(Some(group_id))
}
pub fn toggle_active_tab_group_collapsed(&mut self) -> Result<Option<bool>, CoreError> { pub fn toggle_active_tab_group_collapsed(&mut self) -> Result<Option<bool>, CoreError> {
let Some(group_id) = self.active_tab_group_id()? else { let Some(group_id) = self.active_tab_group_id()? else {
return Ok(None); return Ok(None);
@@ -148,7 +192,7 @@ impl BrowserCore {
let active_space_id = self.active_space_id.clone(); let active_space_id = self.active_space_id.clone();
let color_hex = self.active_space()?.accent_hex(); let color_hex = self.active_space()?.accent_hex();
let sort_key = self.next_tab_group_sort_key(); let sort_key = self.next_tab_group_sort_key_for_space(&active_space_id);
let group = TabGroup::new(active_space_id, name, color_hex, sort_key)?; let group = TabGroup::new(active_space_id, name, color_hex, sort_key)?;
let group_id = group.id().clone(); let group_id = group.id().clone();
self.tab_groups.push(group); self.tab_groups.push(group);
@@ -166,16 +210,16 @@ impl BrowserCore {
.map(|group| group.id().clone()) .map(|group| group.id().clone())
} }
fn next_tab_group_sort_key(&self) -> u64 { fn next_tab_group_sort_key_for_space(&self, space_id: &SpaceId) -> u64 {
self.tab_groups self.tab_groups
.iter() .iter()
.filter(|group| group.space_id() == &self.active_space_id) .filter(|group| group.space_id() == space_id)
.map(TabGroup::sort_key) .map(TabGroup::sort_key)
.max() .max()
.map_or(0, |sort_key| sort_key.saturating_add(1)) .map_or(0, |sort_key| sort_key.saturating_add(1))
} }
fn active_space_group_tabs(&self, group_id: &TabGroupId) -> Vec<ely_domain::BrowserTab> { fn active_space_group_tabs(&self, group_id: &TabGroupId) -> Vec<BrowserTab> {
tab_order::sorted_tabs( tab_order::sorted_tabs(
self.tabs self.tabs
.iter() .iter()
@@ -184,6 +228,34 @@ impl BrowserCore {
) )
} }
fn split_pane_space_id(
&self,
split_id: &SplitId,
pane_ids: &[TabId],
) -> Result<SpaceId, CoreError> {
let first_pane_id =
pane_ids.first().ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?;
let first_tab = self
.tabs
.iter()
.find(|tab| tab.id() == first_pane_id)
.ok_or_else(|| CoreError::TabNotFound { id: first_pane_id.clone() })?;
let space_id = first_tab.space_id().clone();
for pane_id in pane_ids {
let tab = self
.tabs
.iter()
.find(|tab| tab.id() == pane_id)
.ok_or_else(|| CoreError::TabNotFound { id: pane_id.clone() })?;
if tab.space_id() != &space_id {
return Err(CoreError::SplitPaneSpaceMismatch { id: split_id.clone() });
}
}
Ok(space_id)
}
fn active_tab_group_id(&self) -> Result<Option<TabGroupId>, CoreError> { fn active_tab_group_id(&self) -> Result<Option<TabGroupId>, CoreError> {
let tab = self.active_tab()?; let tab = self.active_tab()?;
let Some(group_id) = tab.group_id().cloned() else { let Some(group_id) = tab.group_id().cloned() else {
+66
View File
@@ -158,6 +158,72 @@ fn close_split_view_command_preserves_query_without_saved_split() -> Result<(),
Ok(()) Ok(())
} }
#[test]
fn split_view_to_group_command_converts_active_split() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let first_tab_id = core.active_tab()?.id().clone();
core.split_active_tab_right()?;
let second_tab_id = core.active_tab()?.id().clone();
core.save_active_split_view()?;
core.set_command_query(">split-view-to-group Research");
let intent = core.submit_command()?;
let snapshot = core.snapshot()?;
let group = snapshot.tab_groups.first().ok_or("missing tab group")?;
let grouped_tab_ids = snapshot
.tabs
.iter()
.filter(|tab| tab.group_id() == Some(group.id()))
.map(|tab| tab.id().clone())
.collect::<Vec<_>>();
assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group Research".to_string())));
assert_eq!(snapshot.active_tab_id, second_tab_id);
assert!(snapshot.split_layouts.is_empty());
assert_eq!(snapshot.tab_groups.len(), 1);
assert_eq!(group.name(), "Research");
assert_eq!(grouped_tab_ids, vec![first_tab_id, second_tab_id]);
assert!(snapshot.tabs.iter().all(|tab| tab.split_id().is_none()));
assert_eq!(snapshot.command_query, "");
Ok(())
}
#[test]
fn split_view_to_group_command_uses_saved_split_title_without_name() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
core.split_active_tab_right()?;
core.save_active_split_view()?;
core.set_command_query(">split-view-to-group");
let intent = core.submit_command()?;
let snapshot = core.snapshot()?;
let group = snapshot.tab_groups.first().ok_or("missing tab group")?;
assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group".to_string())));
assert!(snapshot.split_layouts.is_empty());
assert_eq!(group.name(), "Split View: New Tab + New Tab");
assert_eq!(snapshot.command_query, "");
Ok(())
}
#[test]
fn split_view_to_group_command_preserves_query_without_active_split() -> Result<(), Box<dyn Error>>
{
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let active_tab_id = core.active_tab()?.id().clone();
core.set_command_query(">split-view-to-group Research");
let intent = core.submit_command()?;
let snapshot = core.snapshot()?;
assert_eq!(intent, Some(CommandIntent::Command("split-view-to-group Research".to_string())));
assert_eq!(snapshot.active_tab_id, active_tab_id);
assert!(snapshot.tab_groups.is_empty());
assert!(snapshot.split_layouts.is_empty());
assert_eq!(snapshot.command_query, ">split-view-to-group Research");
Ok(())
}
#[test] #[test]
fn restore_last_archived_tab_restores_saved_split_view() -> Result<(), Box<dyn Error>> { fn restore_last_archived_tab_restores_saved_split_view() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
@@ -210,6 +210,24 @@ fn split_tab_group_command_converts_active_group_to_split_view() -> Result<(), B
Ok(()) Ok(())
} }
#[test]
fn tab_group_to_split_alias_converts_active_group_to_split_view() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
core.group_active_tab("Research")?;
core.open_tab(UrlText::parse("https://example.com")?);
core.group_active_tab("Research")?;
core.set_command_query(">tab group to split");
let intent = core.submit_command()?;
let snapshot = core.snapshot()?;
assert_eq!(intent, Some(CommandIntent::Command("tab group to split".to_string())));
assert!(snapshot.tab_groups.is_empty());
assert_eq!(snapshot.split_layouts.len(), 1);
assert_eq!(snapshot.command_query, "");
Ok(())
}
#[test] #[test]
fn split_tab_group_command_preserves_query_for_single_tab_group() -> Result<(), Box<dyn Error>> { fn split_tab_group_command_preserves_query_for_single_tab_group() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;