diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index fcd5d8c..b89439b 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -32,6 +32,10 @@ pub(crate) fn tab_matches_query(tab: &BrowserTab, normalized_query: &str) -> boo || tab.display_url().to_lowercase().contains(normalized_query) } +pub(crate) fn records_history(url: &UrlText) -> bool { + Url::parse(url.as_str()).map(|parsed_url| parsed_url.scheme() != "ely").unwrap_or(false) +} + pub(crate) fn new_space_name(command: &str) -> Option<&str> { command_argument(command, &["new-space ", "new space "]) } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index c5d3e91..803b1c4 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,13 +1,14 @@ use std::collections::BTreeMap; use ely_domain::{ - ArchivedTab, BrowserTab, DomainError, Profile, ProfileId, ProfileKind, Space, SpaceId, TabId, - UrlText, + ArchivedTab, BrowserTab, DomainError, HistoryEntry, Profile, ProfileId, ProfileKind, Space, + SpaceId, TabId, UrlText, }; use crate::CoreError; mod commands; +mod history; mod profiles; mod tabs; @@ -36,6 +37,7 @@ pub struct BrowserSnapshot { pub favorites: Vec, pub pinned_tabs: Vec, pub archived_tabs: Vec, + pub history_entries: Vec, pub spaces: Vec, pub active_tab_id: TabId, pub active_space_id: SpaceId, @@ -50,6 +52,7 @@ pub struct BrowserCore { profiles: Vec, tabs: Vec, archived_tabs: Vec, + history_entries: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, @@ -90,6 +93,7 @@ impl BrowserCore { profiles: vec![profile], tabs: vec![tab], archived_tabs: Vec::new(), + history_entries: Vec::new(), command_query: String::new(), new_tab_url, }) @@ -175,6 +179,7 @@ impl BrowserCore { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_tabs.clone(), + history_entries: self.visible_history(), spaces: self.spaces.clone(), tabs: self.visible_tabs(), active_tab_id: self.active_tab_id.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 20d7875..52028d2 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -44,6 +44,12 @@ impl BrowserCore { self.command_query.clear(); } } + CommandIntent::ScopedSearch { scope: CommandScope::History, query } => { + if let Some(url) = self.find_history_match(query) { + self.open_tab(url); + self.command_query.clear(); + } + } CommandIntent::ScopedSearch { scope: CommandScope::Settings, query } => { if let Some(url) = settings_page_url(query)? { self.open_tab(url); diff --git a/crates/ely_browser_core/src/state/history.rs b/crates/ely_browser_core/src/state/history.rs new file mode 100644 index 0000000..d4cde72 --- /dev/null +++ b/crates/ely_browser_core/src/state/history.rs @@ -0,0 +1,55 @@ +use std::time::SystemTime; + +use ely_domain::{BrowserTab, HistoryEntry, UrlText}; + +use crate::navigation::records_history; + +use super::BrowserCore; + +impl BrowserCore { + pub(super) fn record_history_entry(&mut self, tab: &BrowserTab) { + if !records_history(tab.url()) { + return; + } + + self.history_entries.push(HistoryEntry::new( + tab.profile_id().clone(), + tab.space_id().clone(), + tab.title(), + tab.url().clone(), + SystemTime::now(), + )); + } + + pub(super) fn find_history_match(&self, query: &str) -> Option { + let normalized_query = query.trim().to_lowercase(); + if normalized_query.is_empty() { + return None; + } + + self.history_entries + .iter() + .rev() + .find(|entry| { + entry.profile_id() == &self.active_profile_id + && entry.space_id() == &self.active_space_id + && history_entry_matches_query(entry, &normalized_query) + }) + .map(|entry| entry.url().clone()) + } + + pub(super) fn visible_history(&self) -> Vec { + self.history_entries + .iter() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .filter(|entry| entry.space_id() == &self.active_space_id) + .cloned() + .collect() + } +} + +fn history_entry_matches_query(entry: &HistoryEntry, normalized_query: &str) -> bool { + entry.title().to_lowercase().contains(normalized_query) + || entry.url().as_str().to_lowercase().contains(normalized_query) + || entry.url().display_url().to_lowercase().contains(normalized_query) +} diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index dd3925f..d72468e 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -18,6 +18,7 @@ impl BrowserCore { .iter() .position(|existing| existing.id() == &self.active_tab_id) .map_or(self.tabs.len(), |index| index + 1); + self.record_history_entry(&tab); 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()); diff --git a/crates/ely_browser_core/tests/history.rs b/crates/ely_browser_core/tests/history.rs new file mode 100644 index 0000000..66007dc --- /dev/null +++ b/crates/ely_browser_core/tests/history.rs @@ -0,0 +1,101 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; + +#[test] +fn navigation_records_profile_and_space_history() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_profile_id = core.active_tab()?.profile_id().clone(); + let active_space_id = core.snapshot()?.active_space_id; + + core.open_tab(UrlText::parse("https://example.com/research")?); + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.history_entries.len(), 1); + assert_eq!(snapshot.history_entries[0].profile_id(), &active_profile_id); + assert_eq!(snapshot.history_entries[0].space_id(), &active_space_id); + assert_eq!(snapshot.history_entries[0].title(), "example.com"); + assert_eq!(snapshot.history_entries[0].url().as_str(), "https://example.com/research"); + Ok(()) +} + +#[test] +fn internal_pages_are_omitted_from_history() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.open_tab(UrlText::parse("ely://history")?); + + assert!(core.snapshot()?.history_entries.is_empty()); + Ok(()) +} + +#[test] +fn history_scoped_search_opens_recent_matching_entry() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + + core.set_command_query("@history example"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::History, + query: "example".to_string() + }) + ); + assert_eq!(active_tab.url().as_str(), "https://example.com/research"); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.history_entries.len(), 2); + Ok(()) +} + +#[test] +fn history_scoped_search_preserves_query_without_match() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.open_tab(UrlText::parse("https://example.com/research")?); + + core.set_command_query("@history absent"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::History, + query: "absent".to_string() + }) + ); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.command_query, "@history absent"); + Ok(()) +} + +#[test] +fn history_scoped_search_stays_with_active_profile() -> 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)?; + + core.open_tab(UrlText::parse("https://example.com/personal")?); + core.select_profile(&default_profile_id)?; + + core.set_command_query("@history personal"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::History, + query: "personal".to_string() + }) + ); + assert_eq!(core.active_tab()?.profile_id(), &default_profile_id); + assert_ne!(core.active_tab()?.profile_id(), &personal_profile_id); + assert_eq!(snapshot.command_query, "@history personal"); + Ok(()) +} diff --git a/crates/ely_domain/src/history.rs b/crates/ely_domain/src/history.rs new file mode 100644 index 0000000..6903c83 --- /dev/null +++ b/crates/ely_domain/src/history.rs @@ -0,0 +1,50 @@ +use std::time::SystemTime; + +use crate::{ProfileId, SpaceId, UrlText}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HistoryEntry { + profile_id: ProfileId, + space_id: SpaceId, + title: String, + url: UrlText, + visited_at: SystemTime, +} + +impl HistoryEntry { + #[must_use] + pub fn new( + profile_id: ProfileId, + space_id: SpaceId, + title: impl Into, + url: UrlText, + visited_at: SystemTime, + ) -> Self { + Self { profile_id, space_id, title: title.into(), url, visited_at } + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub fn space_id(&self) -> &SpaceId { + &self.space_id + } + + #[must_use] + pub fn title(&self) -> &str { + &self.title + } + + #[must_use] + pub fn url(&self) -> &UrlText { + &self.url + } + + #[must_use] + pub fn visited_at(&self) -> SystemTime { + self.visited_at + } +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index bb506ae..63e5766 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -1,6 +1,7 @@ mod archive; mod command; mod error; +mod history; mod identifiers; mod profile; mod space; @@ -11,6 +12,7 @@ mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; pub use command::{CommandIntent, CommandScope}; pub use error::DomainError; +pub use history::HistoryEntry; pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId}; pub use profile::{Profile, ProfileKind}; pub use space::{ArchivePolicy, Space};