diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index ae58c2c..aceedb4 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, SpaceId, TabId}; +use ely_domain::{DomainError, ProfileId, SpaceId, TabId}; use thiserror::Error; #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -12,6 +12,9 @@ pub enum CoreError { #[error("space not found: {id}")] SpaceNotFound { id: SpaceId }, + #[error("profile not found: {id}")] + ProfileNotFound { id: ProfileId }, + #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index cfeb401..1c9904a 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -20,19 +20,27 @@ pub(crate) fn tab_matches_query(tab: &BrowserTab, normalized_query: &str) -> boo } 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 + command_argument(command, &["new-space ", "new space "]) } pub(crate) fn move_tab_space_name(command: &str) -> Option<&str> { + command_argument( + command, + &["move-tab ", "move tab ", "move-tab-to-space ", "move tab to space "], + ) +} + +pub(crate) fn new_profile_name(command: &str) -> Option<&str> { + command_argument(command, &["new-profile ", "new profile "]) +} + +pub(crate) fn switch_profile_name(command: &str) -> Option<&str> { + command_argument(command, &["switch-profile ", "switch profile "]) +} + +fn command_argument<'a>(command: &'a str, prefixes: &[&str]) -> Option<&'a str> { let normalized_command = command.to_ascii_lowercase(); - for prefix in ["move-tab ", "move tab ", "move-tab-to-space ", "move tab to space "] { + for prefix in prefixes { if normalized_command.starts_with(prefix) { let name = command[prefix.len()..].trim(); return (!name.is_empty()).then_some(name); diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 9962503..acf969a 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -11,6 +11,7 @@ use crate::{ }; mod commands; +mod profiles; const DEFAULT_FAVORITE_LIMIT: usize = 12; @@ -57,6 +58,7 @@ pub struct BrowserCore { active_profile_id: ProfileId, active_tab_id: TabId, active_tabs_by_space: BTreeMap, + active_tabs_by_space_profile: BTreeMap<(SpaceId, ProfileId), TabId>, command_query: String, new_tab_url: UrlText, } @@ -77,13 +79,17 @@ impl BrowserCore { ); let active_tab_id = tab.id().clone(); let mut active_tabs_by_space = BTreeMap::new(); + let mut active_tabs_by_space_profile = BTreeMap::new(); active_tabs_by_space.insert(active_space_id.clone(), active_tab_id.clone()); + active_tabs_by_space_profile + .insert((active_space_id.clone(), active_profile_id.clone()), active_tab_id.clone()); Ok(Self { active_space_id, active_profile_id, active_tab_id, active_tabs_by_space, + active_tabs_by_space_profile, spaces: vec![space], profiles: vec![profile], tabs: vec![tab], @@ -104,6 +110,8 @@ impl BrowserCore { 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()); + self.active_tabs_by_space_profile + .insert((self.active_space_id.clone(), self.active_profile_id.clone()), tab_id.clone()); tab_id } @@ -170,15 +178,23 @@ impl BrowserCore { let tab_index = self.active_tab_index()?; let tab_id = self.active_tab_id.clone(); let source_space_id = self.tabs[tab_index].space_id().clone(); + let profile_id = self.tabs[tab_index].profile_id().clone(); if &source_space_id == space_id { return Ok(tab_id); } self.tabs[tab_index].move_to_space(space_id.clone()); self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone()); + self.active_tabs_by_space_profile.remove(&(source_space_id.clone(), profile_id.clone())); if let Some(next_tab_id) = self.nearest_tab_in_space(&source_space_id, tab_index) { - self.active_tabs_by_space.insert(source_space_id, next_tab_id); + self.active_tabs_by_space.insert(source_space_id.clone(), next_tab_id); + if let Some(next_profile_tab_id) = + self.nearest_tab_in_space_profile(&source_space_id, &profile_id, tab_index) + { + self.active_tabs_by_space_profile + .insert((source_space_id, profile_id), next_profile_tab_id); + } } else { let tab = self.build_tab_for( source_space_id.clone(), @@ -187,6 +203,8 @@ impl BrowserCore { ); let replacement_id = tab.id().clone(); self.tabs.insert(tab_index, tab); + self.active_tabs_by_space_profile + .insert((source_space_id.clone(), profile_id), replacement_id.clone()); self.active_tabs_by_space.insert(source_space_id, replacement_id); } @@ -211,11 +229,19 @@ impl BrowserCore { 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.active_tabs_by_space_profile + .remove(&(closed_space_id.clone(), closed_profile_id.clone())); self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose)); 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()); + self.active_tabs_by_space.insert(closed_space_id.clone(), next_tab_id.clone()); + } + if let Some(next_profile_tab_id) = + self.nearest_tab_in_space_profile(&closed_space_id, &closed_profile_id, close_index) + { + self.active_tabs_by_space_profile + .insert((closed_space_id, closed_profile_id), next_profile_tab_id); } if was_active { self.select_tab(&next_tab_id)?; @@ -225,11 +251,13 @@ impl BrowserCore { let tab = self.build_tab_for( closed_space_id.clone(), - closed_profile_id, + closed_profile_id.clone(), 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_profile + .insert((closed_space_id.clone(), closed_profile_id), replacement_id.clone()); self.active_tabs_by_space.insert(closed_space_id, replacement_id.clone()); if was_active { @@ -301,7 +329,9 @@ impl BrowserCore { 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_profile_id = active_profile_id.clone(); + self.active_tabs_by_space_profile + .insert((active_space_id.clone(), active_profile_id), active_tab_id.clone()); self.active_tabs_by_space.insert(active_space_id, active_tab_id); Ok(()) } @@ -446,6 +476,20 @@ impl BrowserCore { .map(|tab| tab.id().clone()) } + fn nearest_tab_in_space_profile( + &self, + space_id: &SpaceId, + profile_id: &ProfileId, + start_index: usize, + ) -> Option { + self.tabs + .iter() + .skip(start_index) + .chain(self.tabs.iter().take(start_index)) + .find(|tab| tab.space_id() == space_id && tab.profile_id() == profile_id) + .map(|tab| tab.id().clone()) + } + 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/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index aad2a46..57e30d9 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -1,8 +1,11 @@ -use ely_domain::{CommandIntent, CommandScope, SpaceId}; +use ely_domain::{CommandIntent, CommandScope, ProfileId, ProfileKind, SpaceId}; use crate::{ CoreError, - navigation::{move_tab_space_name, new_space_name, search_url, space_icon}, + navigation::{ + move_tab_space_name, new_profile_name, new_space_name, search_url, space_icon, + switch_profile_name, + }, }; use super::BrowserCore; @@ -58,6 +61,10 @@ impl BrowserCore { self.create_space(name.to_string(), space_icon(name), 0xf54e00)?; return Ok(true); } + if let Some(name) = new_profile_name(command) { + self.create_profile(name.to_string(), 0xf54e00, ProfileKind::Standard)?; + return Ok(true); + } if let Some(name) = move_tab_space_name(command) { let Some(space_id) = self.find_space_match(name) else { return Ok(false); @@ -65,6 +72,13 @@ impl BrowserCore { self.move_active_tab_to_space(&space_id)?; return Ok(true); } + if let Some(name) = switch_profile_name(command) { + let Some(profile_id) = self.find_profile_match(name) else { + return Ok(false); + }; + self.select_profile(&profile_id)?; + return Ok(true); + } match command.to_ascii_lowercase().as_str() { "new-tab" => { @@ -99,4 +113,12 @@ impl BrowserCore { .then(|| space.id().clone()) }) } + + fn find_profile_match(&self, query: &str) -> Option { + let query = query.trim().to_lowercase(); + self.profiles + .iter() + .find(|profile| profile.name().to_lowercase().contains(&query)) + .map(|profile| profile.id().clone()) + } } diff --git a/crates/ely_browser_core/src/state/profiles.rs b/crates/ely_browser_core/src/state/profiles.rs new file mode 100644 index 0000000..a2078ef --- /dev/null +++ b/crates/ely_browser_core/src/state/profiles.rs @@ -0,0 +1,69 @@ +use ely_domain::{Profile, ProfileId, ProfileKind, TabId}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn create_profile( + &mut self, + name: impl Into, + color_hex: u32, + kind: ProfileKind, + ) -> Result { + let profile = Profile::new(name, color_hex, kind); + let profile_id = profile.id().clone(); + self.profiles.push(profile); + self.select_profile(&profile_id)?; + Ok(profile_id) + } + + pub fn select_profile(&mut self, profile_id: &ProfileId) -> Result { + if !self.profiles.iter().any(|profile| profile.id() == profile_id) { + return Err(CoreError::ProfileNotFound { id: profile_id.clone() }); + } + + let active_key = (self.active_space_id.clone(), profile_id.clone()); + if let Some(tab_id) = self + .active_tabs_by_space_profile + .get(&active_key) + .filter(|tab_id| { + self.tabs.iter().any(|tab| { + tab.id() == *tab_id + && tab.space_id() == &active_key.0 + && tab.profile_id() == &active_key.1 + }) + }) + .cloned() + { + self.select_tab(&tab_id)?; + return Ok(tab_id); + } + + if let Some(tab_id) = self + .tabs + .iter() + .rfind(|tab| tab.space_id() == &active_key.0 && tab.profile_id() == &active_key.1) + .map(|tab| tab.id().clone()) + { + self.select_tab(&tab_id)?; + return Ok(tab_id); + } + + let tab = self.build_tab_for( + self.active_space_id.clone(), + profile_id.clone(), + self.new_tab_url.clone(), + ); + let tab_id = tab.id().clone(); + let insert_index = self + .tabs + .iter() + .position(|existing| existing.id() == &self.active_tab_id) + .map_or(self.tabs.len(), |index| index + 1); + + self.tabs.insert(insert_index, tab); + self.select_tab(&tab_id)?; + Ok(tab_id) + } +} diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 6f9187d..13fd627 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, UrlText}; +use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; #[test] fn favorite_command_toggles_active_tab() -> Result<(), Box> { @@ -68,6 +68,62 @@ fn new_space_command_creates_and_selects_named_space() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.active_tab()?.profile_id().clone(); + + core.set_command_query(">new-profile Personal"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("new-profile Personal".to_string()))); + assert_eq!(snapshot.active_profile_name, "Personal"); + assert_ne!(active_tab.profile_id(), &default_profile_id); + assert_eq!(active_tab.url().as_str(), "ely://new-tab"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn switch_profile_command_selects_matching_profile_context() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.active_tab()?.profile_id().clone(); + let personal_profile_id = core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + let personal_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + core.select_profile(&default_profile_id)?; + + core.set_command_query(">switch-profile Personal"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("switch-profile Personal".to_string()))); + assert_eq!(snapshot.active_profile_name, "Personal"); + assert_eq!(snapshot.active_tab_id, personal_tab_id); + assert_eq!(active_tab.profile_id(), &personal_profile_id); + assert_eq!(active_tab.url().as_str(), "https://example.com"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn switch_profile_command_preserves_query_without_match() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">switch-profile Missing"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("switch-profile Missing".to_string()))); + assert_eq!(snapshot.active_profile_name, "Default"); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.command_query, ">switch-profile Missing"); + Ok(()) +} + #[test] fn spaces_scoped_search_selects_matching_space() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;