use std::collections::BTreeMap; use ely_domain::{ ArchiveSource, ArchivedTab, BrowserTab, DomainError, Profile, ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText, }; use crate::{ CoreError, navigation::{tab_matches_query, tab_title}, }; mod commands; const DEFAULT_FAVORITE_LIMIT: usize = 12; #[derive(Clone, Debug)] pub struct InitialBrowserConfig { pub space_name: String, pub space_icon: String, pub profile_name: String, pub initial_url: UrlText, } impl InitialBrowserConfig { pub fn ely_defaults() -> Result { Ok(Self { space_name: "Work".to_string(), space_icon: "W".to_string(), profile_name: "Default".to_string(), initial_url: UrlText::parse("ely://new-tab")?, }) } } #[derive(Clone, Debug)] pub struct BrowserSnapshot { pub tabs: Vec, 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, } #[derive(Debug)] pub struct BrowserCore { spaces: Vec, profiles: Vec, tabs: Vec, archived_tabs: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, active_tabs_by_space: BTreeMap, command_query: String, new_tab_url: UrlText, } impl BrowserCore { pub fn new(config: InitialBrowserConfig) -> Result { 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(), 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, active_profile_id, active_tab_id, active_tabs_by_space, spaces: vec![space], profiles: vec![profile], tabs: vec![tab], archived_tabs: Vec::new(), command_query: String::new(), new_tab_url, }) } pub fn open_tab(&mut self, url: UrlText) -> TabId { let tab = self.build_tab(url); 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.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) } pub fn close_tab(&mut self, tab_id: &TabId) -> Result { let close_index = self .tabs .iter() .position(|tab| tab.id() == tab_id) .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; 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 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 { self.select_tab(&replacement_id)?; return Ok(replacement_id); } Ok(self.active_tab_id.clone()) } pub fn restore_last_archived_tab(&mut self) -> Result { let archived_tab = self.archived_tabs.pop().ok_or(CoreError::NoArchivedTabs)?; let tab = archived_tab.into_tab(); self.restore_tab(tab) } pub fn restore_archived_tab(&mut self, tab_id: &TabId) -> Result { let index = self .archived_tabs .iter() .position(|archived| archived.tab().id() == tab_id) .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; let archived_tab = self.archived_tabs.remove(index); let tab = archived_tab.into_tab(); self.restore_tab(tab) } pub fn restore_archived_tab_match(&mut self, query: &str) -> Result, CoreError> { let normalized_query = query.trim().to_lowercase(); if normalized_query.is_empty() { return Ok(None); } let Some(index) = self .archived_tabs .iter() .rposition(|archived| tab_matches_query(archived.tab(), &normalized_query)) else { return Ok(None); }; let archived_tab = self.archived_tabs.remove(index); let tab = archived_tab.into_tab(); self.restore_tab(tab).map(Some) } fn restore_tab(&mut self, tab: BrowserTab) -> Result { 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) } pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { let tab = self .tabs .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 = 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(()) } pub fn select_next_tab(&mut self) -> Result { self.select_tab_by_offset(1) } pub fn select_previous_tab(&mut self) -> Result { self.select_tab_by_offset(-1) } pub fn toggle_active_tab_favorite(&mut self) -> Result { let active_index = self.active_tab_index()?; let favorite_count = self.tabs.iter().filter(|tab| tab.flags().favorite).count(); let active_tab = self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)?; let next_favorite = !active_tab.flags().favorite; if next_favorite && favorite_count >= DEFAULT_FAVORITE_LIMIT { return Err(CoreError::FavoriteLimitReached { limit: DEFAULT_FAVORITE_LIMIT }); } active_tab.set_favorite(next_favorite); Ok(next_favorite) } pub fn toggle_active_tab_pinned(&mut self) -> Result { let active_index = self.active_tab_index()?; let active_tab = self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)?; let next_pinned = !active_tab.flags().pinned; active_tab.set_pinned(next_pinned); Ok(next_pinned) } pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } #[must_use] pub fn command_query(&self) -> &str { &self.command_query } pub fn snapshot(&self) -> Result { let active_space = self .spaces .iter() .find(|space| space.id() == &self.active_space_id) .ok_or(CoreError::MissingActiveTab)?; let active_profile = self .profiles .iter() .find(|profile| profile.id() == &self.active_profile_id) .ok_or(CoreError::MissingActiveTab)?; Ok(BrowserSnapshot { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_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(), }) } pub fn active_tab(&self) -> Result<&BrowserTab, CoreError> { self.tabs .iter() .find(|tab| tab.id() == &self.active_tab_id) .ok_or(CoreError::MissingActiveTab) } fn select_tab_by_offset(&mut self, offset: isize) -> Result { 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 = visible_tab_ids[next_index].clone(); self.select_tab(&next_tab_id)?; Ok(next_tab_id) } fn active_tab_index(&self) -> Result { self.tabs .iter() .position(|tab| tab.id() == &self.active_tab_id) .ok_or(CoreError::MissingActiveTab) } fn favorites(&self) -> Vec { self.tabs.iter().filter(|tab| tab.flags().favorite).cloned().collect() } 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 .iter() .find(|tab| tab_matches_query(tab, &normalized_query)) .map(|tab| tab.id().clone()) } 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(), space_id, profile_id, title, url) } 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_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) } }