From 24bdf3d2dc425f4973c8c516ed93cf6ba735bf51 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 11:31:34 -0400 Subject: [PATCH] Add tab group ordering commands --- crates/ely_browser_core/src/state.rs | 1 + crates/ely_browser_core/src/state/commands.rs | 6 + .../src/state/tab_group_order.rs | 182 ++++++++++++++++++ .../ely_browser_core/src/state/tab_groups.rs | 2 +- .../tests/tab_group_commands.rs | 85 +++++++- docs/ui-shell.md | 14 ++ 6 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 crates/ely_browser_core/src/state/tab_group_order.rs diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 3c323f8..9aac232 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -23,6 +23,7 @@ mod site_permissions; mod spaces; mod splits; mod sync; +mod tab_group_order; mod tab_groups; mod tab_lifecycle; mod tab_order; diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index e163cec..e6ce5d8 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -219,6 +219,12 @@ impl BrowserCore { Ok(self.set_active_tab_group_collapsed(false)?.is_some()) } "ungroup-tab" | "ungroup tab" => self.ungroup_active_tab(), + "move-tab-group-up" | "move tab group up" | "tab-group-up" | "tab group up" => { + self.move_active_tab_group_up() + } + "move-tab-group-down" | "move tab group down" | "tab-group-down" | "tab group down" => { + self.move_active_tab_group_down() + } "sleep-tab-group" | "sleep tab group" | "discard-tab-group" | "discard tab group" => { Ok(self.discard_active_tab_group()?.is_some()) } diff --git a/crates/ely_browser_core/src/state/tab_group_order.rs b/crates/ely_browser_core/src/state/tab_group_order.rs new file mode 100644 index 0000000..c5e98e0 --- /dev/null +++ b/crates/ely_browser_core/src/state/tab_group_order.rs @@ -0,0 +1,182 @@ +use std::collections::BTreeSet; + +use ely_domain::{SpaceId, TabGroupId, TabId}; + +use crate::CoreError; + +use super::{BrowserCore, tab_order}; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum TabGroupRowSegment { + Group { group_id: TabGroupId, tab_ids: Vec }, + Tab(TabId), +} + +impl BrowserCore { + pub fn move_active_tab_group_up(&mut self) -> Result { + let Some(group_id) = self.active_tab_group_id()? else { + return Ok(false); + }; + self.move_tab_group_up(&group_id) + } + + pub fn move_active_tab_group_down(&mut self) -> Result { + let Some(group_id) = self.active_tab_group_id()? else { + return Ok(false); + }; + self.move_tab_group_down(&group_id) + } + + pub fn move_tab_group_up(&mut self, group_id: &TabGroupId) -> Result { + let space_id = self.tab_group_space_id(group_id)?; + let mut segments = self.tab_group_row_segments(&space_id); + let Some(index) = tab_group_segment_index(&segments, group_id) else { + return Ok(false); + }; + let Some(previous_index) = previous_tab_group_segment_index(&segments, index) else { + return Ok(false); + }; + + segments.swap(index, previous_index); + self.apply_tab_group_segments(&space_id, &segments)?; + Ok(true) + } + + pub fn move_tab_group_down(&mut self, group_id: &TabGroupId) -> Result { + let space_id = self.tab_group_space_id(group_id)?; + let mut segments = self.tab_group_row_segments(&space_id); + let Some(index) = tab_group_segment_index(&segments, group_id) else { + return Ok(false); + }; + let Some(next_index) = next_tab_group_segment_index(&segments, index) else { + return Ok(false); + }; + + segments.swap(index, next_index); + self.apply_tab_group_segments(&space_id, &segments)?; + Ok(true) + } + + fn tab_group_space_id(&self, group_id: &TabGroupId) -> Result { + self.tab_groups + .iter() + .find(|group| group.id() == group_id) + .map(|group| group.space_id().clone()) + .ok_or_else(|| CoreError::TabGroupNotFound { id: group_id.clone() }) + } + + fn tab_group_row_segments(&self, space_id: &SpaceId) -> Vec { + let ordered_tabs = + tab_order::sorted_tabs(self.tabs.iter().filter(|tab| tab.space_id() == space_id)); + let valid_group_ids = self + .tab_groups + .iter() + .filter(|group| group.space_id() == space_id) + .map(|group| group.id().clone()) + .collect::>(); + let mut rendered_group_ids = BTreeSet::new(); + let mut segments = Vec::new(); + + for tab in &ordered_tabs { + let Some(group_id) = tab.group_id() else { + segments.push(TabGroupRowSegment::Tab(tab.id().clone())); + continue; + }; + + if !valid_group_ids.contains(group_id) { + segments.push(TabGroupRowSegment::Tab(tab.id().clone())); + continue; + } + + if rendered_group_ids.insert(group_id.clone()) { + let tab_ids = ordered_tabs + .iter() + .filter(|candidate| candidate.group_id() == Some(group_id)) + .map(|candidate| candidate.id().clone()) + .collect(); + segments.push(TabGroupRowSegment::Group { group_id: group_id.clone(), tab_ids }); + } + } + + segments + } + + fn apply_tab_group_segments( + &mut self, + space_id: &SpaceId, + segments: &[TabGroupRowSegment], + ) -> Result<(), CoreError> { + let mut ordered_tab_ids = Vec::new(); + let mut ordered_group_ids = Vec::new(); + + for segment in segments { + match segment { + TabGroupRowSegment::Group { group_id, tab_ids } => { + ordered_group_ids.push(group_id.clone()); + ordered_tab_ids.extend(tab_ids.iter().cloned()); + } + TabGroupRowSegment::Tab(tab_id) => ordered_tab_ids.push(tab_id.clone()), + } + } + + self.apply_space_tab_order(space_id, &ordered_tab_ids)?; + self.apply_tab_group_order(&ordered_group_ids)?; + Ok(()) + } + + fn apply_space_tab_order( + &mut self, + space_id: &SpaceId, + ordered_tab_ids: &[TabId], + ) -> Result<(), CoreError> { + for (sort_key, tab_id) in ordered_tab_ids.iter().enumerate() { + let Some(tab) = + self.tabs.iter_mut().find(|tab| tab.space_id() == space_id && tab.id() == tab_id) + else { + return Err(CoreError::TabNotFound { id: tab_id.clone() }); + }; + tab.set_sort_key(sort_key as u64); + } + + self.sort_tabs_within_space(space_id); + Ok(()) + } + + fn apply_tab_group_order(&mut self, ordered_group_ids: &[TabGroupId]) -> Result<(), CoreError> { + for (sort_key, group_id) in ordered_group_ids.iter().enumerate() { + let Some(group) = self.tab_groups.iter_mut().find(|group| group.id() == group_id) + else { + return Err(CoreError::TabGroupNotFound { id: group_id.clone() }); + }; + group.set_sort_key(sort_key as u64); + } + + Ok(()) + } +} + +fn tab_group_segment_index( + segments: &[TabGroupRowSegment], + group_id: &TabGroupId, +) -> Option { + segments.iter().position(|segment| match segment { + TabGroupRowSegment::Group { group_id: candidate, .. } => candidate == group_id, + TabGroupRowSegment::Tab(_) => false, + }) +} + +fn previous_tab_group_segment_index( + segments: &[TabGroupRowSegment], + index: usize, +) -> Option { + segments[..index] + .iter() + .rposition(|segment| matches!(segment, TabGroupRowSegment::Group { .. })) +} + +fn next_tab_group_segment_index(segments: &[TabGroupRowSegment], index: usize) -> Option { + segments[index.saturating_add(1)..] + .iter() + .position(|segment| matches!(segment, TabGroupRowSegment::Group { .. })) + .map(|offset| index + 1 + offset) +} diff --git a/crates/ely_browser_core/src/state/tab_groups.rs b/crates/ely_browser_core/src/state/tab_groups.rs index 52f30be..3235030 100644 --- a/crates/ely_browser_core/src/state/tab_groups.rs +++ b/crates/ely_browser_core/src/state/tab_groups.rs @@ -384,7 +384,7 @@ impl BrowserCore { Ok(space_id) } - fn active_tab_group_id(&self) -> Result, CoreError> { + pub(super) fn active_tab_group_id(&self) -> Result, CoreError> { let tab = self.active_tab()?; let Some(group_id) = tab.group_id().cloned() else { return Ok(None); diff --git a/crates/ely_browser_core/tests/tab_group_commands.rs b/crates/ely_browser_core/tests/tab_group_commands.rs index 268ad37..b617364 100644 --- a/crates/ely_browser_core/tests/tab_group_commands.rs +++ b/crates/ely_browser_core/tests/tab_group_commands.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{collections::BTreeSet, error::Error}; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{CommandIntent, TabGroupId, UrlText}; @@ -89,9 +89,92 @@ fn tab_group_color_command_preserves_query_for_invalid_hex() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.group_active_tab("Docs")?; + core.open_tab(UrlText::parse("https://example.com")?); + core.group_active_tab("Research")?; + + core.set_command_query(">move-tab-group-up"); + let up_intent = core.submit_command()?; + let up_snapshot = core.snapshot()?; + + assert_eq!(up_intent, Some(CommandIntent::Command("move-tab-group-up".to_string()))); + assert_eq!(up_snapshot.command_query, ""); + assert_eq!(tab_group_names(&up_snapshot), vec!["Research", "Docs"]); + assert_eq!(sidebar_group_names(&up_snapshot), vec!["Research", "Docs"]); + + core.set_command_query(">move-tab-group-down"); + let down_intent = core.submit_command()?; + let down_snapshot = core.snapshot()?; + + assert_eq!(down_intent, Some(CommandIntent::Command("move-tab-group-down".to_string()))); + assert_eq!(down_snapshot.command_query, ""); + assert_eq!(tab_group_names(&down_snapshot), vec!["Docs", "Research"]); + assert_eq!(sidebar_group_names(&down_snapshot), vec!["Docs", "Research"]); + Ok(()) +} + +#[test] +fn move_tab_group_command_preserves_query_at_boundary() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + core.group_active_tab("Docs")?; + core.open_tab(UrlText::parse("https://example.com")?); + core.group_active_tab("Research")?; + core.select_tab(&first_tab_id)?; + + core.set_command_query(">move-tab-group-up"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("move-tab-group-up".to_string()))); + assert_eq!(snapshot.command_query, ">move-tab-group-up"); + assert_eq!(tab_group_names(&snapshot), vec!["Docs", "Research"]); + Ok(()) +} + +#[test] +fn move_tab_group_command_preserves_query_without_active_group() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">move-tab-group-up"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("move-tab-group-up".to_string()))); + assert_eq!(snapshot.command_query, ">move-tab-group-up"); + assert!(snapshot.tab_groups.is_empty()); + Ok(()) +} + fn tab_group_id<'a>( snapshot: &'a ely_browser_core::BrowserSnapshot, tab_id: &ely_domain::TabId, ) -> Option<&'a TabGroupId> { snapshot.tabs.iter().find(|tab| tab.id() == tab_id).and_then(|tab| tab.group_id()) } + +fn tab_group_names(snapshot: &ely_browser_core::BrowserSnapshot) -> Vec<&str> { + snapshot.tab_groups.iter().map(|group| group.name()).collect() +} + +fn sidebar_group_names(snapshot: &ely_browser_core::BrowserSnapshot) -> Vec<&str> { + let mut rendered_group_ids = BTreeSet::new(); + let mut names = Vec::new(); + + for tab in &snapshot.tabs { + let Some(group_id) = tab.group_id() else { + continue; + }; + if !rendered_group_ids.insert(group_id.clone()) { + continue; + } + if let Some(group) = snapshot.tab_groups.iter().find(|group| group.id() == group_id) { + names.push(group.name()); + } + } + + names +} diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 7f3e64d..129ca27 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -118,6 +118,20 @@ Group color updates the active group accent: └──────────────────────────────┴───────────────────────────────────────────────┘ ``` +Group ordering moves the active group within the Space: + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ELY Browser [ >move-tab-group-up......................... ] [pin] [*] [+] │ +├──────────────────────────────┬───────────────────────────────────────────────┤ +│ Tabs │ example.com │ +│ [folder] Research │ https://example.com/b │ +│ 1 tabs - Expanded │ │ +│ [folder] Docs │ Group order: Research, Docs │ +│ 1 tabs - Expanded │ │ +└──────────────────────────────┴───────────────────────────────────────────────┘ +``` + Group sleep applies the sleeping state to every tab in the active group: ```text