Add basic space switching
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||||
use ely_domain::{CommandIntent, TabId, UrlText};
|
use ely_domain::{CommandIntent, SpaceId, TabId, UrlText};
|
||||||
use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window};
|
use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window};
|
||||||
use gpui_component::input::{InputEvent, InputState, SelectAll};
|
use gpui_component::input::{InputEvent, InputState, SelectAll};
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
enum ShellState {
|
enum ShellState {
|
||||||
Ready(BrowserCore),
|
Ready(Box<BrowserCore>),
|
||||||
StartupError(String),
|
StartupError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ impl ElyShell {
|
|||||||
_ => ely_domain::DomainError::InvalidCommand,
|
_ => ely_domain::DomainError::InvalidCommand,
|
||||||
})
|
})
|
||||||
}) {
|
}) {
|
||||||
Ok(core) => ShellState::Ready(core),
|
Ok(core) => ShellState::Ready(Box::new(core)),
|
||||||
Err(error) => ShellState::StartupError(error.to_string()),
|
Err(error) => ShellState::StartupError(error.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +106,15 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn select_space(&mut self, space_id: &SpaceId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let ShellState::Ready(core) = &mut self.state
|
||||||
|
&& core.select_space(space_id).is_ok()
|
||||||
|
{
|
||||||
|
self.sync_address_input(window, cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn select_next_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn select_next_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let ShellState::Ready(core) = &mut self.state
|
if let ShellState::Ready(core) = &mut self.state
|
||||||
&& core.select_next_tab().is_ok()
|
&& core.select_next_tab().is_ok()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use ely_browser_core::BrowserSnapshot;
|
use ely_browser_core::BrowserSnapshot;
|
||||||
use ely_design_system::{ELY_THEME, colors, spacing};
|
use ely_design_system::{ELY_THEME, colors, spacing};
|
||||||
use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab};
|
use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab, Space};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||||
StatefulInteractiveElement, Styled, Window, div, px, rgb,
|
StatefulInteractiveElement, Styled, Window, div, px, rgb,
|
||||||
@@ -157,16 +157,9 @@ impl ElyShell {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(section_label("Space"))
|
.child(section_label("Space"))
|
||||||
.child(
|
.children(snapshot.spaces.iter().map(|space| {
|
||||||
div()
|
self.render_space_row(space, space.id() == &snapshot.active_space_id, cx)
|
||||||
.rounded_md()
|
}))
|
||||||
.bg(rgb(colors::SURFACE_CARD))
|
|
||||||
.border_1()
|
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
|
||||||
.px_3()
|
|
||||||
.py_2()
|
|
||||||
.child(snapshot.active_space_name.clone()),
|
|
||||||
)
|
|
||||||
.child(section_label("Tabs"))
|
.child(section_label("Tabs"))
|
||||||
.children(
|
.children(
|
||||||
snapshot
|
snapshot
|
||||||
@@ -195,6 +188,44 @@ impl ElyShell {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_space_row(
|
||||||
|
&mut self,
|
||||||
|
space: &Space,
|
||||||
|
active: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let space_id = space.id().clone();
|
||||||
|
let background = if active { colors::SURFACE_CARD } else { colors::CANVAS };
|
||||||
|
let border = if active { colors::HAIRLINE_STRONG } else { colors::HAIRLINE };
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!("space-{}", space.id().as_str())))
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(border))
|
||||||
|
.bg(rgb(background))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.gap_2()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|style| style.bg(rgb(colors::SURFACE_CARD)))
|
||||||
|
.active(|style| style.opacity(0.82))
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
shell.select_space(&space_id, window, cx);
|
||||||
|
}))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(rgb(colors::PRIMARY))
|
||||||
|
.child(space.icon().to_string()),
|
||||||
|
)
|
||||||
|
.child(div().text_sm().font_semibold().child(space.name().to_string()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_favorite_row(
|
fn render_favorite_row(
|
||||||
&mut self,
|
&mut self,
|
||||||
tab: &BrowserTab,
|
tab: &BrowserTab,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use ely_domain::{DomainError, TabId};
|
use ely_domain::{DomainError, SpaceId, TabId};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||||
@@ -9,6 +9,9 @@ pub enum CoreError {
|
|||||||
#[error("tab not found: {id}")]
|
#[error("tab not found: {id}")]
|
||||||
TabNotFound { id: TabId },
|
TabNotFound { id: TabId },
|
||||||
|
|
||||||
|
#[error("space not found: {id}")]
|
||||||
|
SpaceNotFound { id: SpaceId },
|
||||||
|
|
||||||
#[error("favorite limit reached: {limit}")]
|
#[error("favorite limit reached: {limit}")]
|
||||||
FavoriteLimitReached { limit: usize },
|
FavoriteLimitReached { limit: usize },
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod error;
|
mod error;
|
||||||
|
mod navigation;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use error::CoreError;
|
pub use error::CoreError;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use ely_domain::{BrowserTab, DomainError, UrlText};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::CoreError;
|
||||||
|
|
||||||
|
const DEFAULT_SEARCH_URL: &str = "https://duckduckgo.com/";
|
||||||
|
|
||||||
|
pub(crate) fn tab_title(url: &UrlText) -> String {
|
||||||
|
if url.as_str() == "ely://new-tab" {
|
||||||
|
return "New Tab".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
url.display_host()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tab_matches_query(tab: &BrowserTab, normalized_query: &str) -> bool {
|
||||||
|
tab.title().to_lowercase().contains(normalized_query)
|
||||||
|
|| tab.url().as_str().to_lowercase().contains(normalized_query)
|
||||||
|
|| tab.display_url().to_lowercase().contains(normalized_query)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_space_name(command: &str) -> Option<&str> {
|
||||||
|
let normalized_command = command.to_ascii_lowercase();
|
||||||
|
for prefix in ["new-space ", "new space "] {
|
||||||
|
if normalized_command.starts_with(prefix) {
|
||||||
|
let name = command[prefix.len()..].trim();
|
||||||
|
return (!name.is_empty()).then_some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn space_icon(name: &str) -> String {
|
||||||
|
name.chars().next().map_or_else(String::new, |value| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search_url(query: &str) -> Result<UrlText, CoreError> {
|
||||||
|
let mut url = Url::parse(DEFAULT_SEARCH_URL)
|
||||||
|
.map_err(|_| DomainError::InvalidUrl { value: DEFAULT_SEARCH_URL.to_string() })?;
|
||||||
|
url.query_pairs_mut().append_pair("q", query);
|
||||||
|
UrlText::parse(url.to_string()).map_err(CoreError::from)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use ely_domain::{
|
use ely_domain::{
|
||||||
ArchiveSource, ArchivedTab, BrowserTab, CommandIntent, CommandScope, DomainError, Profile,
|
ArchiveSource, ArchivedTab, BrowserTab, CommandIntent, CommandScope, DomainError, Profile,
|
||||||
ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText,
|
ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText,
|
||||||
};
|
};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::CoreError;
|
use crate::{
|
||||||
|
CoreError,
|
||||||
|
navigation::{new_space_name, search_url, space_icon, tab_matches_query, tab_title},
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_SEARCH_URL: &str = "https://duckduckgo.com/";
|
|
||||||
const DEFAULT_FAVORITE_LIMIT: usize = 12;
|
const DEFAULT_FAVORITE_LIMIT: usize = 12;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -34,7 +37,9 @@ pub struct BrowserSnapshot {
|
|||||||
pub favorites: Vec<BrowserTab>,
|
pub favorites: Vec<BrowserTab>,
|
||||||
pub pinned_tabs: Vec<BrowserTab>,
|
pub pinned_tabs: Vec<BrowserTab>,
|
||||||
pub archived_tabs: Vec<ArchivedTab>,
|
pub archived_tabs: Vec<ArchivedTab>,
|
||||||
|
pub spaces: Vec<Space>,
|
||||||
pub active_tab_id: TabId,
|
pub active_tab_id: TabId,
|
||||||
|
pub active_space_id: SpaceId,
|
||||||
pub active_space_name: String,
|
pub active_space_name: String,
|
||||||
pub active_profile_name: String,
|
pub active_profile_name: String,
|
||||||
pub command_query: String,
|
pub command_query: String,
|
||||||
@@ -49,6 +54,7 @@ pub struct BrowserCore {
|
|||||||
active_space_id: SpaceId,
|
active_space_id: SpaceId,
|
||||||
active_profile_id: ProfileId,
|
active_profile_id: ProfileId,
|
||||||
active_tab_id: TabId,
|
active_tab_id: TabId,
|
||||||
|
active_tabs_by_space: BTreeMap<SpaceId, TabId>,
|
||||||
command_query: String,
|
command_query: String,
|
||||||
new_tab_url: UrlText,
|
new_tab_url: UrlText,
|
||||||
}
|
}
|
||||||
@@ -58,18 +64,24 @@ impl BrowserCore {
|
|||||||
let space = Space::new(config.space_name, config.space_icon, 0xf54e00);
|
let space = Space::new(config.space_name, config.space_icon, 0xf54e00);
|
||||||
let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard);
|
let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard);
|
||||||
let new_tab_url = config.initial_url;
|
let new_tab_url = config.initial_url;
|
||||||
|
let active_space_id = space.id().clone();
|
||||||
|
let active_profile_id = profile.id().clone();
|
||||||
let tab = BrowserTab::new(
|
let tab = BrowserTab::new(
|
||||||
TabId::new(),
|
TabId::new(),
|
||||||
space.id().clone(),
|
active_space_id.clone(),
|
||||||
profile.id().clone(),
|
active_profile_id.clone(),
|
||||||
"New Tab",
|
"New Tab",
|
||||||
new_tab_url.clone(),
|
new_tab_url.clone(),
|
||||||
);
|
);
|
||||||
|
let active_tab_id = tab.id().clone();
|
||||||
|
let mut active_tabs_by_space = BTreeMap::new();
|
||||||
|
active_tabs_by_space.insert(active_space_id.clone(), active_tab_id.clone());
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
active_space_id: space.id().clone(),
|
active_space_id,
|
||||||
active_profile_id: profile.id().clone(),
|
active_profile_id,
|
||||||
active_tab_id: tab.id().clone(),
|
active_tab_id,
|
||||||
|
active_tabs_by_space,
|
||||||
spaces: vec![space],
|
spaces: vec![space],
|
||||||
profiles: vec![profile],
|
profiles: vec![profile],
|
||||||
tabs: vec![tab],
|
tabs: vec![tab],
|
||||||
@@ -89,9 +101,65 @@ impl BrowserCore {
|
|||||||
.map_or(self.tabs.len(), |index| index + 1);
|
.map_or(self.tabs.len(), |index| index + 1);
|
||||||
self.tabs.insert(insert_index, tab);
|
self.tabs.insert(insert_index, tab);
|
||||||
self.active_tab_id = tab_id.clone();
|
self.active_tab_id = tab_id.clone();
|
||||||
|
self.active_tabs_by_space.insert(self.active_space_id.clone(), tab_id.clone());
|
||||||
tab_id
|
tab_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_space(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
icon: impl Into<String>,
|
||||||
|
accent_hex: u32,
|
||||||
|
) -> Result<SpaceId, CoreError> {
|
||||||
|
let space = Space::new(name, icon, accent_hex);
|
||||||
|
let space_id = space.id().clone();
|
||||||
|
let tab = self.build_tab_for(
|
||||||
|
space_id.clone(),
|
||||||
|
self.active_profile_id.clone(),
|
||||||
|
self.new_tab_url.clone(),
|
||||||
|
);
|
||||||
|
let tab_id = tab.id().clone();
|
||||||
|
|
||||||
|
self.spaces.push(space);
|
||||||
|
self.tabs.push(tab);
|
||||||
|
self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone());
|
||||||
|
self.select_tab(&tab_id)?;
|
||||||
|
Ok(space_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_space(&mut self, space_id: &SpaceId) -> Result<TabId, CoreError> {
|
||||||
|
if !self.spaces.iter().any(|space| space.id() == space_id) {
|
||||||
|
return Err(CoreError::SpaceNotFound { id: space_id.clone() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tab_id) = self
|
||||||
|
.active_tabs_by_space
|
||||||
|
.get(space_id)
|
||||||
|
.filter(|tab_id| self.tab_belongs_to_space(tab_id, space_id))
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
self.select_tab(&tab_id)?;
|
||||||
|
return Ok(tab_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tab_id) =
|
||||||
|
self.tabs.iter().find(|tab| tab.space_id() == space_id).map(|tab| tab.id().clone())
|
||||||
|
{
|
||||||
|
self.select_tab(&tab_id)?;
|
||||||
|
return Ok(tab_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab = self.build_tab_for(
|
||||||
|
space_id.clone(),
|
||||||
|
self.active_profile_id.clone(),
|
||||||
|
self.new_tab_url.clone(),
|
||||||
|
);
|
||||||
|
let tab_id = tab.id().clone();
|
||||||
|
self.tabs.push(tab);
|
||||||
|
self.select_tab(&tab_id)?;
|
||||||
|
Ok(tab_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn close_active_tab(&mut self) -> Result<TabId, CoreError> {
|
pub fn close_active_tab(&mut self) -> Result<TabId, CoreError> {
|
||||||
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)
|
||||||
@@ -106,20 +174,33 @@ impl BrowserCore {
|
|||||||
let was_active = &self.active_tab_id == tab_id;
|
let was_active = &self.active_tab_id == tab_id;
|
||||||
|
|
||||||
let closed_tab = self.tabs.remove(close_index);
|
let closed_tab = self.tabs.remove(close_index);
|
||||||
|
let closed_space_id = closed_tab.space_id().clone();
|
||||||
|
let closed_profile_id = closed_tab.profile_id().clone();
|
||||||
|
let was_space_active_tab = self.active_tabs_by_space.get(&closed_space_id) == Some(tab_id);
|
||||||
self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose));
|
self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose));
|
||||||
|
|
||||||
if self.tabs.is_empty() {
|
if let Some(next_tab_id) = self.nearest_tab_in_space(&closed_space_id, close_index) {
|
||||||
let tab = self.build_tab(self.new_tab_url.clone());
|
if was_space_active_tab {
|
||||||
let replacement_id = tab.id().clone();
|
self.active_tabs_by_space.insert(closed_space_id, next_tab_id.clone());
|
||||||
self.tabs.push(tab);
|
}
|
||||||
self.active_tab_id = replacement_id.clone();
|
if was_active {
|
||||||
return Ok(replacement_id);
|
self.select_tab(&next_tab_id)?;
|
||||||
|
}
|
||||||
|
return Ok(self.active_tab_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tab = self.build_tab_for(
|
||||||
|
closed_space_id.clone(),
|
||||||
|
closed_profile_id,
|
||||||
|
self.new_tab_url.clone(),
|
||||||
|
);
|
||||||
|
let replacement_id = tab.id().clone();
|
||||||
|
self.tabs.insert(close_index.min(self.tabs.len()), tab);
|
||||||
|
self.active_tabs_by_space.insert(closed_space_id, replacement_id.clone());
|
||||||
|
|
||||||
if was_active {
|
if was_active {
|
||||||
let next_index = close_index.min(self.tabs.len() - 1);
|
self.select_tab(&replacement_id)?;
|
||||||
let next_tab_id = self.tabs[next_index].id().clone();
|
return Ok(replacement_id);
|
||||||
self.select_tab(&next_tab_id)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(self.active_tab_id.clone())
|
Ok(self.active_tab_id.clone())
|
||||||
@@ -180,10 +261,14 @@ impl BrowserCore {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|tab| tab.id() == tab_id)
|
.find(|tab| tab.id() == tab_id)
|
||||||
.ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?;
|
.ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?;
|
||||||
|
let active_space_id = tab.space_id().clone();
|
||||||
|
let active_profile_id = tab.profile_id().clone();
|
||||||
|
let active_tab_id = tab.id().clone();
|
||||||
|
|
||||||
self.active_tab_id = tab.id().clone();
|
self.active_tab_id = active_tab_id.clone();
|
||||||
self.active_space_id = tab.space_id().clone();
|
self.active_space_id = active_space_id.clone();
|
||||||
self.active_profile_id = tab.profile_id().clone();
|
self.active_profile_id = active_profile_id;
|
||||||
|
self.active_tabs_by_space.insert(active_space_id, active_tab_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +365,10 @@ impl BrowserCore {
|
|||||||
favorites: self.favorites(),
|
favorites: self.favorites(),
|
||||||
pinned_tabs: self.pinned_tabs(),
|
pinned_tabs: self.pinned_tabs(),
|
||||||
archived_tabs: self.archived_tabs.clone(),
|
archived_tabs: self.archived_tabs.clone(),
|
||||||
tabs: self.tabs.clone(),
|
spaces: self.spaces.clone(),
|
||||||
|
tabs: self.visible_tabs(),
|
||||||
active_tab_id: self.active_tab_id.clone(),
|
active_tab_id: self.active_tab_id.clone(),
|
||||||
|
active_space_id: self.active_space_id.clone(),
|
||||||
active_space_name: active_space.name().to_string(),
|
active_space_name: active_space.name().to_string(),
|
||||||
active_profile_name: active_profile.name().to_string(),
|
active_profile_name: active_profile.name().to_string(),
|
||||||
command_query: self.command_query.clone(),
|
command_query: self.command_query.clone(),
|
||||||
@@ -296,16 +383,31 @@ impl BrowserCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn select_tab_by_offset(&mut self, offset: isize) -> Result<TabId, CoreError> {
|
fn select_tab_by_offset(&mut self, offset: isize) -> Result<TabId, CoreError> {
|
||||||
let active_index = self.active_tab_index()?;
|
let visible_tab_ids = self
|
||||||
let tab_count = self.tabs.len() as isize;
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.filter(|tab| tab.space_id() == &self.active_space_id)
|
||||||
|
.map(|tab| tab.id().clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let active_index = visible_tab_ids
|
||||||
|
.iter()
|
||||||
|
.position(|tab_id| tab_id == &self.active_tab_id)
|
||||||
|
.ok_or(CoreError::MissingActiveTab)?;
|
||||||
|
let tab_count = visible_tab_ids.len() as isize;
|
||||||
let next_index = (active_index as isize + offset).rem_euclid(tab_count) as usize;
|
let next_index = (active_index as isize + offset).rem_euclid(tab_count) as usize;
|
||||||
let next_tab_id = self.tabs[next_index].id().clone();
|
let next_tab_id = visible_tab_ids[next_index].clone();
|
||||||
self.select_tab(&next_tab_id)?;
|
self.select_tab(&next_tab_id)?;
|
||||||
Ok(next_tab_id)
|
Ok(next_tab_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_named_command(&mut self, command: &str) -> Result<bool, CoreError> {
|
fn submit_named_command(&mut self, command: &str) -> Result<bool, CoreError> {
|
||||||
match command.trim().to_ascii_lowercase().as_str() {
|
let command = command.trim();
|
||||||
|
if let Some(name) = new_space_name(command) {
|
||||||
|
self.create_space(name.to_string(), space_icon(name), 0xf54e00)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
match command.to_ascii_lowercase().as_str() {
|
||||||
"new-tab" => {
|
"new-tab" => {
|
||||||
self.open_tab(self.new_tab_url.clone());
|
self.open_tab(self.new_tab_url.clone());
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -344,11 +446,16 @@ impl BrowserCore {
|
|||||||
fn pinned_tabs(&self) -> Vec<BrowserTab> {
|
fn pinned_tabs(&self) -> Vec<BrowserTab> {
|
||||||
self.tabs
|
self.tabs
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|tab| tab.space_id() == &self.active_space_id)
|
||||||
.filter(|tab| tab.flags().pinned && !tab.flags().favorite)
|
.filter(|tab| tab.flags().pinned && !tab.flags().favorite)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn visible_tabs(&self) -> Vec<BrowserTab> {
|
||||||
|
self.tabs.iter().filter(|tab| tab.space_id() == &self.active_space_id).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn find_tab_match(&self, query: &str) -> Option<TabId> {
|
fn find_tab_match(&self, query: &str) -> Option<TabId> {
|
||||||
let normalized_query = query.trim().to_lowercase();
|
let normalized_query = query.trim().to_lowercase();
|
||||||
self.tabs
|
self.tabs
|
||||||
@@ -358,34 +465,24 @@ impl BrowserCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_tab(&self, url: UrlText) -> BrowserTab {
|
fn build_tab(&self, url: UrlText) -> BrowserTab {
|
||||||
|
self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tab_for(&self, space_id: SpaceId, profile_id: ProfileId, url: UrlText) -> BrowserTab {
|
||||||
let title = tab_title(&url);
|
let title = tab_title(&url);
|
||||||
BrowserTab::new(
|
BrowserTab::new(TabId::new(), space_id, profile_id, title, url)
|
||||||
TabId::new(),
|
|
||||||
self.active_space_id.clone(),
|
|
||||||
self.active_profile_id.clone(),
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tab_title(url: &UrlText) -> String {
|
fn nearest_tab_in_space(&self, space_id: &SpaceId, start_index: usize) -> Option<TabId> {
|
||||||
if url.as_str() == "ely://new-tab" {
|
self.tabs
|
||||||
return "New Tab".to_string();
|
.iter()
|
||||||
|
.skip(start_index)
|
||||||
|
.chain(self.tabs.iter().take(start_index))
|
||||||
|
.find(|tab| tab.space_id() == space_id)
|
||||||
|
.map(|tab| tab.id().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
url.display_host()
|
fn tab_belongs_to_space(&self, tab_id: &TabId, space_id: &SpaceId) -> bool {
|
||||||
|
self.tabs.iter().any(|tab| tab.id() == tab_id && tab.space_id() == space_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tab_matches_query(tab: &BrowserTab, normalized_query: &str) -> bool {
|
|
||||||
tab.title().to_lowercase().contains(normalized_query)
|
|
||||||
|| tab.url().as_str().to_lowercase().contains(normalized_query)
|
|
||||||
|| tab.display_url().to_lowercase().contains(normalized_query)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_url(query: &str) -> Result<UrlText, CoreError> {
|
|
||||||
let mut url = Url::parse(DEFAULT_SEARCH_URL)
|
|
||||||
.map_err(|_| DomainError::InvalidUrl { value: DEFAULT_SEARCH_URL.to_string() })?;
|
|
||||||
url.query_pairs_mut().append_pair("q", query);
|
|
||||||
UrlText::parse(url.to_string()).map_err(CoreError::from)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ fn new_tab_command_opens_new_tab() -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_space_command_creates_and_selects_named_space() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|
||||||
|
core.set_command_query(">new-space Research");
|
||||||
|
let intent = core.submit_command()?;
|
||||||
|
let snapshot = core.snapshot()?;
|
||||||
|
let active_tab = core.active_tab()?;
|
||||||
|
|
||||||
|
assert_eq!(intent, Some(CommandIntent::Command("new-space Research".to_string())));
|
||||||
|
assert_eq!(snapshot.spaces.len(), 2);
|
||||||
|
assert_eq!(snapshot.active_space_name, "Research");
|
||||||
|
assert_eq!(snapshot.tabs.len(), 1);
|
||||||
|
assert_eq!(snapshot.tabs[0].space_id(), &snapshot.active_space_id);
|
||||||
|
assert_eq!(active_tab.url().as_str(), "ely://new-tab");
|
||||||
|
assert_eq!(snapshot.command_query, "");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_tab_command_closes_active_tab() -> Result<(), Box<dyn Error>> {
|
fn close_tab_command_closes_active_tab() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -244,6 +244,56 @@ fn search_command_opens_default_search_url() -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_spaces_restores_each_space_active_tab() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
let work_snapshot = core.snapshot()?;
|
||||||
|
let work_space_id = work_snapshot.active_space_id;
|
||||||
|
let work_tab_id = work_snapshot.active_tab_id;
|
||||||
|
|
||||||
|
let research_space_id = core.create_space("Research", "R", 0xf54e00)?;
|
||||||
|
let research_new_tab_id = core.active_tab()?.id().clone();
|
||||||
|
let servo_tab_id = core.open_tab(UrlText::parse("https://servo.org")?);
|
||||||
|
|
||||||
|
core.select_space(&work_space_id)?;
|
||||||
|
let work_snapshot = core.snapshot()?;
|
||||||
|
|
||||||
|
assert_eq!(work_snapshot.active_tab_id, work_tab_id);
|
||||||
|
assert_eq!(work_snapshot.active_space_id, work_space_id);
|
||||||
|
assert_eq!(work_snapshot.tabs.len(), 1);
|
||||||
|
assert_eq!(work_snapshot.tabs[0].id(), &work_tab_id);
|
||||||
|
|
||||||
|
core.select_space(&research_space_id)?;
|
||||||
|
let research_snapshot = core.snapshot()?;
|
||||||
|
let research_ids =
|
||||||
|
research_snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(research_snapshot.active_tab_id, servo_tab_id);
|
||||||
|
assert_eq!(research_snapshot.active_space_id, research_space_id);
|
||||||
|
assert_eq!(research_ids, vec![research_new_tab_id, servo_tab_id]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_active_tab_selects_neighbor_in_same_space() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
let work_tab_id = core.active_tab()?.id().clone();
|
||||||
|
core.create_space("Research", "R", 0xf54e00)?;
|
||||||
|
let research_tab_id = core.active_tab()?.id().clone();
|
||||||
|
let servo_tab_id = core.open_tab(UrlText::parse("https://servo.org")?);
|
||||||
|
|
||||||
|
let active_tab_id = core.close_active_tab()?;
|
||||||
|
let snapshot = core.snapshot()?;
|
||||||
|
|
||||||
|
assert_eq!(active_tab_id, research_tab_id);
|
||||||
|
assert_eq!(snapshot.active_tab_id, research_tab_id);
|
||||||
|
assert_eq!(snapshot.tabs.len(), 1);
|
||||||
|
assert_eq!(snapshot.tabs[0].id(), &research_tab_id);
|
||||||
|
assert!(snapshot.tabs.iter().all(|tab| tab.id() != &work_tab_id));
|
||||||
|
assert_eq!(snapshot.archived_tabs[0].tab().id(), &servo_tab_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggles_active_tab_favorite() -> Result<(), Box<dyn Error>> {
|
fn toggles_active_tab_favorite() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
+7
-6
@@ -9,10 +9,11 @@
|
|||||||
│ Pinned │ │
|
│ Pinned │ │
|
||||||
│ [pin] New Tab │ │
|
│ [pin] New Tab │ │
|
||||||
│ Space │ ┌─────────────────────────────────────────┐ │
|
│ Space │ ┌─────────────────────────────────────────┐ │
|
||||||
│ Work │ │ New Tab │ │
|
│ W Work │ │ New Tab │ │
|
||||||
│ │ │ Clean browser surface for the current │ │
|
│ R Research │ │ Clean browser surface for the current │ │
|
||||||
│ Tabs │ │ Space and Profile. │ │
|
│ │ │ Space and Profile. │ │
|
||||||
│ ● New Tab │ └─────────────────────────────────────────┘ │
|
│ Tabs │ └─────────────────────────────────────────┘ │
|
||||||
|
│ ● New Tab │ │
|
||||||
│ Archive │ │
|
│ Archive │ │
|
||||||
│ ↶ servo.org │ │
|
│ ↶ servo.org │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -21,6 +22,6 @@
|
|||||||
└──────────────────────────────┴───────────────────────────────────────────────┘
|
└──────────────────────────────┴───────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Motion register: productive. Command, tab, and archive restore interactions use immediate state
|
Motion register: productive. Command, space, tab, and archive restore interactions use immediate
|
||||||
changes with hover/press feedback through GPUI styles; future pane transitions should use
|
state changes with hover/press feedback through GPUI styles; future pane transitions should use
|
||||||
transform/opacity and respect reduced-motion settings.
|
transform/opacity and respect reduced-motion settings.
|
||||||
|
|||||||
Reference in New Issue
Block a user