diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index a9581ea..a1a7773 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,4 +1,5 @@ mod about; +mod bookmarks; mod download_actions; mod download_labels; mod downloads; @@ -25,6 +26,7 @@ impl ElyShell { cx: &mut Context, ) -> AnyElement { match tab.url().as_str() { + "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), "ely://downloads" => self.render_downloads_page(snapshot, cx), "ely://history" => self.render_history_page(snapshot, cx), "ely://archive" => self.render_archive_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/bookmarks.rs b/crates/ely_app/src/shell/internal_pages/bookmarks.rs new file mode 100644 index 0000000..ab0aa48 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/bookmarks.rs @@ -0,0 +1,162 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::BookmarkEntry; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, +}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_bookmarks_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_bookmarks_header(snapshot)) + .child(self.render_bookmark_list(snapshot, cx)), + ) + } + + fn render_bookmark_list( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + if snapshot.bookmarks.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("No bookmarks in this Profile.") + .into_any_element(); + } + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + snapshot + .bookmarks + .iter() + .rev() + .map(|bookmark| self.render_bookmark_row(bookmark, cx)), + ) + .into_any_element() + } + + fn render_bookmark_row( + &mut self, + bookmark: &BookmarkEntry, + cx: &mut Context, + ) -> AnyElement { + let url = bookmark.url().clone(); + + div() + .id(SharedString::from(format!("bookmark-{}", bookmark.id().as_str()))) + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::CANVAS_SOFT))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_url(url.clone(), window, cx); + })) + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::MUTED_SOFT)).child(IconName::BookOpen)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(bookmark.title().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(bookmark.display_url()), + ), + ), + ) + .child( + div() + .max_w(px(180.0)) + .truncate() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(bookmark.collection_name().to_string()), + ) + .into_any_element() + } +} + +fn render_bookmarks_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Bookmarks")) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(bookmark_count_label(snapshot.bookmarks.len())), + ) + .into_any_element() +} + +fn bookmark_count_label(count: usize) -> String { + match count { + 1 => "1 bookmark".to_string(), + count => format!("{count} bookmarks"), + } +} diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index d778fc8..8767cc1 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -151,6 +151,7 @@ fn sync_object_kind_label(kind: &SyncObjectKind) -> &'static str { match kind { SyncObjectKind::Spaces => "Spaces", SyncObjectKind::Tabs => "Tabs", + SyncObjectKind::Bookmarks => "Bookmarks", SyncObjectKind::Profiles => "Profiles", SyncObjectKind::History => "History", SyncObjectKind::PluginSettings => "Plugin settings", diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 35ca599..4b664ae 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -16,6 +16,7 @@ pub(crate) fn tab_title(url: &UrlText) -> String { fn internal_page_title(url: &str) -> Option<&'static str> { match url { "ely://new-tab" => Some("New Tab"), + "ely://bookmarks" => Some("Bookmarks"), "ely://downloads" => Some("Downloads"), "ely://history" => Some("History"), "ely://archive" => Some("Archived Tabs"), @@ -84,6 +85,10 @@ pub(crate) fn downloads_url() -> Result { internal_page_url("ely://downloads") } +pub(crate) fn bookmarks_url() -> Result { + internal_page_url("ely://bookmarks") +} + pub(crate) fn history_url() -> Result { internal_page_url("ely://history") } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 31f05ca..5aeeef5 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,12 +1,13 @@ use std::collections::BTreeMap; use ely_domain::{ - ArchivedTab, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, HistoryEntry, Profile, - ProfileId, ProfileKind, Space, SpaceId, SyncStatus, TabId, UrlText, + ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, + HistoryEntry, Profile, ProfileId, ProfileKind, Space, SpaceId, SyncStatus, TabId, UrlText, }; use crate::CoreError; +mod bookmarks; mod commands; mod downloads; mod history; @@ -42,6 +43,7 @@ pub struct BrowserSnapshot { pub favorites: Vec, pub pinned_tabs: Vec, pub archived_tabs: Vec, + pub bookmarks: Vec, pub download_entries: Vec, pub history_entries: Vec, pub installed_plugins: Vec, @@ -64,6 +66,7 @@ pub struct BrowserCore { profiles: Vec, tabs: Vec, archived_tabs: Vec, + bookmarks: Vec, download_entries: Vec, history_entries: Vec, installed_plugins: Vec, @@ -108,6 +111,7 @@ impl BrowserCore { profiles: vec![profile], tabs: vec![tab], archived_tabs: Vec::new(), + bookmarks: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), installed_plugins: Vec::new(), @@ -182,21 +186,14 @@ impl BrowserCore { } 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)?; + let active_space = self.active_space()?; + let active_profile = self.active_profile()?; Ok(BrowserSnapshot { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_tabs.clone(), + bookmarks: self.visible_bookmarks(), download_entries: self.visible_downloads(), history_entries: self.visible_history(), installed_plugins: self.installed_plugins.clone(), @@ -219,7 +216,14 @@ impl BrowserCore { self.profiles .iter() .find(|profile| profile.id() == &self.active_profile_id) - .ok_or(CoreError::MissingActiveTab) + .ok_or_else(|| CoreError::ProfileNotFound { id: self.active_profile_id.clone() }) + } + + fn active_space(&self) -> Result<&Space, CoreError> { + self.spaces + .iter() + .find(|space| space.id() == &self.active_space_id) + .ok_or_else(|| CoreError::SpaceNotFound { id: self.active_space_id.clone() }) } fn favorites(&self) -> Vec { diff --git a/crates/ely_browser_core/src/state/bookmarks.rs b/crates/ely_browser_core/src/state/bookmarks.rs new file mode 100644 index 0000000..e0c9843 --- /dev/null +++ b/crates/ely_browser_core/src/state/bookmarks.rs @@ -0,0 +1,64 @@ +use std::time::SystemTime; + +use ely_domain::{BookmarkEntry, BookmarkId, UrlText}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn bookmark_active_tab(&mut self) -> Result { + let active_tab = self.active_tab()?.clone(); + if let Some(bookmark) = self.bookmarks.iter().find(|bookmark| { + bookmark.profile_id() == active_tab.profile_id() + && bookmark.space_id() == active_tab.space_id() + && bookmark.url() == active_tab.url() + }) { + return Ok(bookmark.id().clone()); + } + + let collection_name = self.active_space()?.name().to_string(); + let bookmark = BookmarkEntry::new( + active_tab.profile_id().clone(), + active_tab.space_id().clone(), + collection_name, + active_tab.title(), + active_tab.url().clone(), + SystemTime::now(), + )?; + let bookmark_id = bookmark.id().clone(); + self.bookmarks.push(bookmark); + Ok(bookmark_id) + } + + pub(super) fn find_bookmark_match(&self, query: &str) -> Option { + let normalized_query = query.trim().to_lowercase(); + if normalized_query.is_empty() { + return None; + } + + self.bookmarks + .iter() + .rev() + .filter(|bookmark| bookmark.profile_id() == &self.active_profile_id) + .find(|bookmark| bookmark_matches_query(bookmark, &normalized_query)) + .map(|bookmark| bookmark.url().clone()) + } + + pub(super) fn visible_bookmarks(&self) -> Vec { + self.bookmarks + .iter() + .filter(|bookmark| bookmark.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } +} + +fn bookmark_matches_query(bookmark: &BookmarkEntry, normalized_query: &str) -> bool { + bookmark.title().to_lowercase().contains(normalized_query) + || bookmark.url().as_str().to_lowercase().contains(normalized_query) + || bookmark.display_url().to_lowercase().contains(normalized_query) + || bookmark.collection_name().to_lowercase().contains(normalized_query) + || bookmark.tags().iter().any(|tag| tag.to_lowercase().contains(normalized_query)) + || bookmark.note().is_some_and(|note| note.to_lowercase().contains(normalized_query)) +} diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index f8a3d79..15c483b 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -3,8 +3,8 @@ use ely_domain::{CommandIntent, CommandScope, ProfileId, ProfileKind, SpaceId}; use crate::{ CoreError, navigation::{ - about_url, downloads_url, history_url, move_tab_space_name, new_profile_name, - new_space_name, search_url, settings_page_url, settings_url, space_icon, + about_url, bookmarks_url, downloads_url, history_url, move_tab_space_name, + new_profile_name, new_space_name, search_url, settings_page_url, settings_url, space_icon, switch_profile_name, sync_status_url, }, }; @@ -51,6 +51,12 @@ impl BrowserCore { self.command_query.clear(); } } + CommandIntent::ScopedSearch { scope: CommandScope::Bookmarks, query } => { + if let Some(url) = self.find_bookmark_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); @@ -102,6 +108,10 @@ impl BrowserCore { self.open_tab(downloads_url()?); Ok(true) } + "bookmarks" | "open-bookmarks" | "open bookmarks" => { + self.open_tab(bookmarks_url()?); + Ok(true) + } "history" | "open-history" | "open history" => { self.open_tab(history_url()?); Ok(true) @@ -126,6 +136,10 @@ impl BrowserCore { self.toggle_active_tab_favorite()?; Ok(true) } + "bookmark" | "add-bookmark" | "add bookmark" => { + self.bookmark_active_tab()?; + Ok(true) + } "pin" | "pin-tab" | "toggle-pin" => { self.toggle_active_tab_pinned()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 271433a..4fa8fe8 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -15,6 +15,11 @@ impl BrowserCore { self.tabs.len(), SyncObjectState::LocalOnly, ), + SyncObjectStatus::new( + SyncObjectKind::Bookmarks, + self.bookmarks.len(), + SyncObjectState::LocalOnly, + ), SyncObjectStatus::new( SyncObjectKind::Profiles, self.profiles.len(), diff --git a/crates/ely_browser_core/tests/bookmarks.rs b/crates/ely_browser_core/tests/bookmarks.rs new file mode 100644 index 0000000..f8c96b9 --- /dev/null +++ b/crates/ely_browser_core/tests/bookmarks.rs @@ -0,0 +1,108 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; + +#[test] +fn bookmark_active_tab_records_current_context() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/research")?); + let active_profile_id = core.active_tab()?.profile_id().clone(); + let active_space_id = core.active_tab()?.space_id().clone(); + + let bookmark_id = core.bookmark_active_tab()?; + let snapshot = core.snapshot()?; + let [bookmark] = snapshot.bookmarks.as_slice() else { + return Err(format!("expected 1 bookmark, got {}", snapshot.bookmarks.len()).into()); + }; + + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(bookmark.id(), &bookmark_id); + assert_eq!(bookmark.profile_id(), &active_profile_id); + assert_eq!(bookmark.space_id(), &active_space_id); + assert_eq!(bookmark.collection_name(), "Work"); + assert_eq!(bookmark.title(), "example.com"); + assert_eq!(bookmark.url().as_str(), "https://example.com/research"); + assert!(bookmark.tags().is_empty()); + assert_eq!(bookmark.note(), None); + Ok(()) +} + +#[test] +fn bookmark_active_tab_reuses_existing_bookmark() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + + let first_id = core.bookmark_active_tab()?; + let second_id = core.bookmark_active_tab()?; + + assert_eq!(first_id, second_id); + assert_eq!(core.snapshot()?.bookmarks.len(), 1); + Ok(()) +} + +#[test] +fn bookmarks_scoped_search_opens_matching_bookmark() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + core.bookmark_active_tab()?; + + core.set_command_query("@bookmarks research"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Bookmarks, + query: "research".to_string() + }) + ); + assert_eq!(active_tab.url().as_str(), "https://example.com/research"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn bookmarks_stay_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.bookmark_active_tab()?; + core.select_profile(&default_profile_id)?; + + core.set_command_query("@bookmarks personal"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Bookmarks, + 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!(snapshot.bookmarks.is_empty()); + assert_eq!(snapshot.command_query, "@bookmarks personal"); + Ok(()) +} + +#[test] +fn open_bookmarks_command_opens_bookmarks_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-bookmarks"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-bookmarks".to_string()))); + assert_eq!(active_tab.title(), "Bookmarks"); + assert_eq!(active_tab.url().as_str(), "ely://bookmarks"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index 25027eb..ef34f23 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -8,6 +8,7 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box Result<(), Box, + note: Option, + added_at: SystemTime, +} + +impl BookmarkEntry { + pub fn new( + profile_id: ProfileId, + space_id: SpaceId, + collection_name: impl Into, + title: impl Into, + url: UrlText, + added_at: SystemTime, + ) -> Result { + let collection_name = non_empty_text("bookmark collection", collection_name.into())?; + let title = non_empty_text("bookmark title", title.into())?; + + Ok(Self { + id: BookmarkId::new(), + profile_id, + space_id, + collection_name, + title, + url, + tags: Vec::new(), + note: None, + added_at, + }) + } + + #[must_use] + pub fn id(&self) -> &BookmarkId { + &self.id + } + + #[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 collection_name(&self) -> &str { + &self.collection_name + } + + #[must_use] + pub fn title(&self) -> &str { + &self.title + } + + #[must_use] + pub fn url(&self) -> &UrlText { + &self.url + } + + #[must_use] + pub fn display_url(&self) -> String { + self.url.display_url() + } + + #[must_use] + pub fn tags(&self) -> &[String] { + &self.tags + } + + #[must_use] + pub fn note(&self) -> Option<&str> { + self.note.as_deref() + } + + #[must_use] + pub fn added_at(&self) -> SystemTime { + self.added_at + } +} + +fn non_empty_text(field: &'static str, value: String) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyField { field }); + } + Ok(trimmed.to_string()) +} diff --git a/crates/ely_domain/src/identifiers.rs b/crates/ely_domain/src/identifiers.rs index 9d84395..95706a4 100644 --- a/crates/ely_domain/src/identifiers.rs +++ b/crates/ely_domain/src/identifiers.rs @@ -39,3 +39,4 @@ entity_id!(ProfileId, "profile"); entity_id!(SplitId, "split"); entity_id!(WebViewId, "webview"); entity_id!(DownloadId, "download"); +entity_id!(BookmarkId, "bookmark"); diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 6d26e0f..fb8effc 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -1,4 +1,5 @@ mod archive; +mod bookmark; mod command; mod download; mod error; @@ -13,6 +14,7 @@ mod tab; mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; +pub use bookmark::BookmarkEntry; pub use command::{CommandIntent, CommandScope}; pub use download::{ DownloadChecksum, DownloadChecksumAlgorithm, DownloadDestination, DownloadEntry, @@ -20,7 +22,7 @@ pub use download::{ }; pub use error::DomainError; pub use history::HistoryEntry; -pub use identifiers::{DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId}; +pub use identifiers::{BookmarkId, DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId}; pub use plugin::{ PluginContributionPoint, PluginId, PluginManifest, PluginPermission, PluginPermissionRisk, PluginSignature, PluginSignatureAlgorithm, diff --git a/crates/ely_domain/src/sync.rs b/crates/ely_domain/src/sync.rs index 7197118..2cc143d 100644 --- a/crates/ely_domain/src/sync.rs +++ b/crates/ely_domain/src/sync.rs @@ -7,6 +7,7 @@ pub enum SyncConnectionState { pub enum SyncObjectKind { Spaces, Tabs, + Bookmarks, Profiles, History, PluginSettings,