diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 510401e..2b48000 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,7 +1,7 @@ mod render; 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_component::input::{InputEvent, InputState, SelectAll}; @@ -11,7 +11,7 @@ use crate::{ }; enum ShellState { - Ready(BrowserCore), + Ready(Box), StartupError(String), } @@ -66,7 +66,7 @@ impl ElyShell { _ => ely_domain::DomainError::InvalidCommand, }) }) { - Ok(core) => ShellState::Ready(core), + Ok(core) => ShellState::Ready(Box::new(core)), 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) { + 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) { if let ShellState::Ready(core) = &mut self.state && core.select_next_tab().is_ok() diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index b782212..8f571b0 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -1,6 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::{ELY_THEME, colors, spacing}; -use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab}; +use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab, Space}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, px, rgb, @@ -157,16 +157,9 @@ impl ElyShell { }), ) .child(section_label("Space")) - .child( - div() - .rounded_md() - .bg(rgb(colors::SURFACE_CARD)) - .border_1() - .border_color(rgb(colors::HAIRLINE)) - .px_3() - .py_2() - .child(snapshot.active_space_name.clone()), - ) + .children(snapshot.spaces.iter().map(|space| { + self.render_space_row(space, space.id() == &snapshot.active_space_id, cx) + })) .child(section_label("Tabs")) .children( snapshot @@ -195,6 +188,44 @@ impl ElyShell { .into_any_element() } + fn render_space_row( + &mut self, + space: &Space, + active: bool, + cx: &mut Context, + ) -> 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( &mut self, tab: &BrowserTab, diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index 981059c..ae58c2c 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -1,4 +1,4 @@ -use ely_domain::{DomainError, TabId}; +use ely_domain::{DomainError, SpaceId, TabId}; use thiserror::Error; #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -9,6 +9,9 @@ pub enum CoreError { #[error("tab not found: {id}")] TabNotFound { id: TabId }, + #[error("space not found: {id}")] + SpaceNotFound { id: SpaceId }, + #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index 3fdcdc1..0bd1845 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -1,4 +1,5 @@ mod error; +mod navigation; mod state; pub use error::CoreError; diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs new file mode 100644 index 0000000..b3a1d86 --- /dev/null +++ b/crates/ely_browser_core/src/navigation.rs @@ -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 { + 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) +} diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 29daad3..d6ccac0 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,12 +1,15 @@ +use std::collections::BTreeMap; + use ely_domain::{ ArchiveSource, ArchivedTab, BrowserTab, CommandIntent, CommandScope, DomainError, Profile, 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; #[derive(Clone, Debug)] @@ -34,7 +37,9 @@ pub struct BrowserSnapshot { pub favorites: Vec, pub pinned_tabs: Vec, pub archived_tabs: Vec, + pub spaces: Vec, pub active_tab_id: TabId, + pub active_space_id: SpaceId, pub active_space_name: String, pub active_profile_name: String, pub command_query: String, @@ -49,6 +54,7 @@ pub struct BrowserCore { active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, + active_tabs_by_space: BTreeMap, command_query: String, new_tab_url: UrlText, } @@ -58,18 +64,24 @@ impl BrowserCore { let space = Space::new(config.space_name, config.space_icon, 0xf54e00); let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard); 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( TabId::new(), - space.id().clone(), - profile.id().clone(), + active_space_id.clone(), + active_profile_id.clone(), "New Tab", 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 { - active_space_id: space.id().clone(), - active_profile_id: profile.id().clone(), - active_tab_id: tab.id().clone(), + active_space_id, + active_profile_id, + active_tab_id, + active_tabs_by_space, spaces: vec![space], profiles: vec![profile], tabs: vec![tab], @@ -89,9 +101,65 @@ impl BrowserCore { .map_or(self.tabs.len(), |index| index + 1); self.tabs.insert(insert_index, tab); self.active_tab_id = tab_id.clone(); + self.active_tabs_by_space.insert(self.active_space_id.clone(), tab_id.clone()); tab_id } + pub fn create_space( + &mut self, + name: impl Into, + icon: impl Into, + accent_hex: u32, + ) -> Result { + 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 { + 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 { let tab_id = self.active_tab_id.clone(); self.close_tab(&tab_id) @@ -106,20 +174,33 @@ impl BrowserCore { let was_active = &self.active_tab_id == tab_id; 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)); - if self.tabs.is_empty() { - let tab = self.build_tab(self.new_tab_url.clone()); - let replacement_id = tab.id().clone(); - self.tabs.push(tab); - self.active_tab_id = replacement_id.clone(); - return Ok(replacement_id); + if let Some(next_tab_id) = self.nearest_tab_in_space(&closed_space_id, close_index) { + if was_space_active_tab { + self.active_tabs_by_space.insert(closed_space_id, next_tab_id.clone()); + } + if was_active { + 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 { - let next_index = close_index.min(self.tabs.len() - 1); - let next_tab_id = self.tabs[next_index].id().clone(); - self.select_tab(&next_tab_id)?; + self.select_tab(&replacement_id)?; + return Ok(replacement_id); } Ok(self.active_tab_id.clone()) @@ -180,10 +261,14 @@ impl BrowserCore { .iter() .find(|tab| tab.id() == tab_id) .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_space_id = tab.space_id().clone(); - self.active_profile_id = tab.profile_id().clone(); + self.active_tab_id = active_tab_id.clone(); + self.active_space_id = active_space_id.clone(); + self.active_profile_id = active_profile_id; + self.active_tabs_by_space.insert(active_space_id, active_tab_id); Ok(()) } @@ -280,8 +365,10 @@ impl BrowserCore { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), 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_space_id: self.active_space_id.clone(), active_space_name: active_space.name().to_string(), active_profile_name: active_profile.name().to_string(), command_query: self.command_query.clone(), @@ -296,16 +383,31 @@ impl BrowserCore { } fn select_tab_by_offset(&mut self, offset: isize) -> Result { - let active_index = self.active_tab_index()?; - let tab_count = self.tabs.len() as isize; + let visible_tab_ids = self + .tabs + .iter() + .filter(|tab| tab.space_id() == &self.active_space_id) + .map(|tab| tab.id().clone()) + .collect::>(); + 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_tab_id = self.tabs[next_index].id().clone(); + let next_tab_id = visible_tab_ids[next_index].clone(); self.select_tab(&next_tab_id)?; Ok(next_tab_id) } fn submit_named_command(&mut self, command: &str) -> Result { - 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" => { self.open_tab(self.new_tab_url.clone()); Ok(true) @@ -344,11 +446,16 @@ impl BrowserCore { fn pinned_tabs(&self) -> Vec { self.tabs .iter() + .filter(|tab| tab.space_id() == &self.active_space_id) .filter(|tab| tab.flags().pinned && !tab.flags().favorite) .cloned() .collect() } + fn visible_tabs(&self) -> Vec { + self.tabs.iter().filter(|tab| tab.space_id() == &self.active_space_id).cloned().collect() + } + fn find_tab_match(&self, query: &str) -> Option { let normalized_query = query.trim().to_lowercase(); self.tabs @@ -358,34 +465,24 @@ impl BrowserCore { } 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); - BrowserTab::new( - TabId::new(), - self.active_space_id.clone(), - self.active_profile_id.clone(), - title, - url, - ) - } -} - -fn tab_title(url: &UrlText) -> String { - if url.as_str() == "ely://new-tab" { - return "New Tab".to_string(); + BrowserTab::new(TabId::new(), space_id, profile_id, title, url) } - url.display_host() -} + fn nearest_tab_in_space(&self, space_id: &SpaceId, start_index: usize) -> Option { + self.tabs + .iter() + .skip(start_index) + .chain(self.tabs.iter().take(start_index)) + .find(|tab| tab.space_id() == space_id) + .map(|tab| tab.id().clone()) + } -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 { - 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) + 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) + } } diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index d05411d..c992a59 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -49,6 +49,25 @@ fn new_tab_command_opens_new_tab() -> Result<(), Box> { Ok(()) } +#[test] +fn new_space_command_creates_and_selects_named_space() -> Result<(), Box> { + 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] fn close_tab_command_closes_active_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index f385970..bf170c6 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -244,6 +244,56 @@ fn search_command_opens_default_search_url() -> Result<(), Box> { Ok(()) } +#[test] +fn switching_spaces_restores_each_space_active_tab() -> Result<(), Box> { + 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::>(); + + 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> { + 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] fn toggles_active_tab_favorite() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 97299c4..5a80efb 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -9,10 +9,11 @@ │ Pinned │ │ │ [pin] New Tab │ │ │ Space │ ┌─────────────────────────────────────────┐ │ -│ Work │ │ New Tab │ │ -│ │ │ Clean browser surface for the current │ │ -│ Tabs │ │ Space and Profile. │ │ -│ ● New Tab │ └─────────────────────────────────────────┘ │ +│ W Work │ │ New Tab │ │ +│ R Research │ │ Clean browser surface for the current │ │ +│ │ │ Space and Profile. │ │ +│ Tabs │ └─────────────────────────────────────────┘ │ +│ ● New Tab │ │ │ Archive │ │ │ ↶ servo.org │ │ │ │ │ @@ -21,6 +22,6 @@ └──────────────────────────────┴───────────────────────────────────────────────┘ ``` -Motion register: productive. Command, tab, and archive restore interactions use immediate state -changes with hover/press feedback through GPUI styles; future pane transitions should use +Motion register: productive. Command, space, tab, and archive restore interactions use immediate +state changes with hover/press feedback through GPUI styles; future pane transitions should use transform/opacity and respect reduced-motion settings.