From 97a06d7b522a9be02cf56864e12a8de6cb4192ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 00:16:12 -0400 Subject: [PATCH] Add active tab reorder commands --- crates/ely_browser_core/src/state/commands.rs | 4 + .../ely_browser_core/src/state/tab_order.rs | 64 +++++++++++++++- crates/ely_browser_core/tests/tab_order.rs | 76 +++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 crates/ely_browser_core/tests/tab_order.rs diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index b9dd8aa..ce4e5bb 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -167,6 +167,10 @@ impl BrowserCore { self.open_new_tab()?; Ok(true) } + "move-tab-up" | "move tab up" | "tab-up" | "tab up" => self.move_active_tab_up(), + "move-tab-down" | "move tab down" | "tab-down" | "tab down" => { + self.move_active_tab_down() + } "split-right" | "split right" => { self.split_active_tab_right()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/tab_order.rs b/crates/ely_browser_core/src/state/tab_order.rs index a4052b9..2e2ac5f 100644 --- a/crates/ely_browser_core/src/state/tab_order.rs +++ b/crates/ely_browser_core/src/state/tab_order.rs @@ -1,8 +1,16 @@ -use ely_domain::{BrowserTab, SpaceId}; +use ely_domain::{BrowserTab, SpaceId, TabId}; use super::BrowserCore; impl BrowserCore { + pub fn move_active_tab_up(&mut self) -> Result { + self.move_active_tab_by(TabMoveDirection::Up) + } + + pub fn move_active_tab_down(&mut self) -> Result { + self.move_active_tab_by(TabMoveDirection::Down) + } + pub(super) fn next_tab_sort_key(&self, space_id: &SpaceId) -> u64 { self.tabs .iter() @@ -34,6 +42,45 @@ impl BrowserCore { self.tabs[index] = tab; } } + + fn move_active_tab_by( + &mut self, + direction: TabMoveDirection, + ) -> Result { + let active_tab = self.active_tab()?.clone(); + let space_id = active_tab.space_id().clone(); + let mut tab_ids = sorted_tabs(self.tabs.iter().filter(|tab| tab.space_id() == &space_id)) + .into_iter() + .map(|tab| tab.id().clone()) + .collect::>(); + let Some(active_index) = tab_ids.iter().position(|tab_id| tab_id == active_tab.id()) else { + return Err(crate::CoreError::MissingActiveTab); + }; + let Some(target_index) = direction.target_index(active_index, tab_ids.len()) else { + return Ok(false); + }; + + tab_ids.swap(active_index, target_index); + self.apply_tab_order(&space_id, &tab_ids)?; + Ok(true) + } + + fn apply_tab_order( + &mut self, + space_id: &SpaceId, + tab_ids: &[TabId], + ) -> Result<(), crate::CoreError> { + for (sort_key, tab_id) in tab_ids.iter().enumerate() { + let tab = self + .tabs + .iter_mut() + .find(|tab| tab.id() == tab_id && tab.space_id() == space_id) + .ok_or_else(|| crate::CoreError::TabNotFound { id: tab_id.clone() })?; + tab.set_sort_key(sort_key as u64); + } + self.sort_tabs_within_space(space_id); + Ok(()) + } } pub(super) fn sorted_tabs<'a>(tabs: impl Iterator) -> Vec { @@ -45,3 +92,18 @@ pub(super) fn sorted_tabs<'a>(tabs: impl Iterator) -> Vec fn compare_tabs(left: &BrowserTab, right: &BrowserTab) -> std::cmp::Ordering { left.sort_key().cmp(&right.sort_key()).then_with(|| left.id().cmp(right.id())) } + +#[derive(Clone, Copy)] +enum TabMoveDirection { + Up, + Down, +} + +impl TabMoveDirection { + fn target_index(self, active_index: usize, tab_count: usize) -> Option { + match self { + Self::Up => active_index.checked_sub(1), + Self::Down => (active_index + 1 < tab_count).then_some(active_index + 1), + } + } +} diff --git a/crates/ely_browser_core/tests/tab_order.rs b/crates/ely_browser_core/tests/tab_order.rs new file mode 100644 index 0000000..79e1f33 --- /dev/null +++ b/crates/ely_browser_core/tests/tab_order.rs @@ -0,0 +1,76 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, UrlText}; + +#[test] +fn move_active_tab_commands_reorder_visible_tabs() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + let second_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + let third_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + core.set_command_query(">move-tab-up"); + let up_intent = core.submit_command()?; + let up_snapshot = core.snapshot()?; + let up_order = up_snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(up_intent, Some(CommandIntent::Command("move-tab-up".to_string()))); + assert_eq!(up_order, vec![first_tab_id.clone(), third_tab_id.clone(), second_tab_id.clone()]); + assert_eq!(up_snapshot.active_tab_id, third_tab_id); + assert_eq!(up_snapshot.command_query, ""); + + core.set_command_query(">move-tab-down"); + let down_intent = core.submit_command()?; + let down_snapshot = core.snapshot()?; + let down_order = down_snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(down_intent, Some(CommandIntent::Command("move-tab-down".to_string()))); + assert_eq!(down_order, vec![first_tab_id, second_tab_id, third_tab_id]); + assert_eq!(down_snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn move_active_tab_command_preserves_query_at_boundary() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + core.open_tab(UrlText::parse("https://example.com")?); + core.select_tab(&active_tab_id)?; + + core.set_command_query(">move-tab-up"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let ordered_ids = snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(intent, Some(CommandIntent::Command("move-tab-up".to_string()))); + assert_eq!(snapshot.command_query, ">move-tab-up"); + assert_eq!(ordered_ids[0], active_tab_id); + Ok(()) +} + +#[test] +fn moving_active_tab_stays_within_active_space() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let work_tab_id = core.active_tab()?.id().clone(); + let work_example_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + let research_space_id = core.create_space("Research", "R", 0xf54e00)?; + let research_tab_id = core.active_tab()?.id().clone(); + let research_servo_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + core.move_active_tab_up()?; + let research_snapshot = core.snapshot()?; + let research_order = + research_snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(research_snapshot.active_space_id, research_space_id); + assert_eq!(research_order, vec![research_servo_tab_id, research_tab_id]); + + core.select_space(&work_space_id)?; + let work_snapshot = core.snapshot()?; + let work_order = work_snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(work_order, vec![work_tab_id, work_example_tab_id]); + Ok(()) +}