Add tab group ordering commands
This commit is contained in:
@@ -23,6 +23,7 @@ mod site_permissions;
|
|||||||
mod spaces;
|
mod spaces;
|
||||||
mod splits;
|
mod splits;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod tab_group_order;
|
||||||
mod tab_groups;
|
mod tab_groups;
|
||||||
mod tab_lifecycle;
|
mod tab_lifecycle;
|
||||||
mod tab_order;
|
mod tab_order;
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ impl BrowserCore {
|
|||||||
Ok(self.set_active_tab_group_collapsed(false)?.is_some())
|
Ok(self.set_active_tab_group_collapsed(false)?.is_some())
|
||||||
}
|
}
|
||||||
"ungroup-tab" | "ungroup tab" => self.ungroup_active_tab(),
|
"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" => {
|
"sleep-tab-group" | "sleep tab group" | "discard-tab-group" | "discard tab group" => {
|
||||||
Ok(self.discard_active_tab_group()?.is_some())
|
Ok(self.discard_active_tab_group()?.is_some())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TabId> },
|
||||||
|
Tab(TabId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserCore {
|
||||||
|
pub fn move_active_tab_group_up(&mut self) -> Result<bool, CoreError> {
|
||||||
|
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<bool, CoreError> {
|
||||||
|
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<bool, CoreError> {
|
||||||
|
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<bool, CoreError> {
|
||||||
|
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<SpaceId, CoreError> {
|
||||||
|
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<TabGroupRowSegment> {
|
||||||
|
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::<BTreeSet<_>>();
|
||||||
|
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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
segments[..index]
|
||||||
|
.iter()
|
||||||
|
.rposition(|segment| matches!(segment, TabGroupRowSegment::Group { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_tab_group_segment_index(segments: &[TabGroupRowSegment], index: usize) -> Option<usize> {
|
||||||
|
segments[index.saturating_add(1)..]
|
||||||
|
.iter()
|
||||||
|
.position(|segment| matches!(segment, TabGroupRowSegment::Group { .. }))
|
||||||
|
.map(|offset| index + 1 + offset)
|
||||||
|
}
|
||||||
@@ -384,7 +384,7 @@ impl BrowserCore {
|
|||||||
Ok(space_id)
|
Ok(space_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_tab_group_id(&self) -> Result<Option<TabGroupId>, CoreError> {
|
pub(super) 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 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::error::Error;
|
use std::{collections::BTreeSet, error::Error};
|
||||||
|
|
||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||||
use ely_domain::{CommandIntent, TabGroupId, UrlText};
|
use ely_domain::{CommandIntent, TabGroupId, UrlText};
|
||||||
@@ -89,9 +89,92 @@ fn tab_group_color_command_preserves_query_for_invalid_hex() -> Result<(), Box<d
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_tab_group_commands_reorder_active_group() -> Result<(), Box<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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>(
|
fn tab_group_id<'a>(
|
||||||
snapshot: &'a ely_browser_core::BrowserSnapshot,
|
snapshot: &'a ely_browser_core::BrowserSnapshot,
|
||||||
tab_id: &ely_domain::TabId,
|
tab_id: &ely_domain::TabId,
|
||||||
) -> Option<&'a TabGroupId> {
|
) -> Option<&'a TabGroupId> {
|
||||||
snapshot.tabs.iter().find(|tab| tab.id() == tab_id).and_then(|tab| tab.group_id())
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
Group sleep applies the sleeping state to every tab in the active group:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
Reference in New Issue
Block a user