diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index a1a7773..9f7d907 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -5,6 +5,7 @@ mod download_labels; mod downloads; mod plugins; mod profiles; +mod reading_list; mod sync; use ely_browser_core::BrowserSnapshot; @@ -27,6 +28,7 @@ impl ElyShell { ) -> AnyElement { match tab.url().as_str() { "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), + "ely://reading-list" => self.render_reading_list_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/reading_list.rs b/crates/ely_app/src/shell/internal_pages/reading_list.rs new file mode 100644 index 0000000..3910dc4 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/reading_list.rs @@ -0,0 +1,185 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{ReadingListEntry, ReadingProgress}; +use gpui::prelude::FluentBuilder; +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_reading_list_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_reading_list_header(snapshot)) + .child(self.render_reading_list_entries(snapshot, cx)), + ) + } + + fn render_reading_list_entries( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + if snapshot.reading_list.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Reading List is empty for 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 + .reading_list + .iter() + .rev() + .map(|entry| self.render_reading_list_row(snapshot, entry, cx)), + ) + .into_any_element() + } + + fn render_reading_list_row( + &mut self, + snapshot: &BrowserSnapshot, + entry: &ReadingListEntry, + cx: &mut Context, + ) -> AnyElement { + let url = entry.source_url().clone(); + let space_name = reading_list_space_name(snapshot, entry); + + div() + .id(SharedString::from(format!("reading-{}", entry.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::Inbox)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(entry.title().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(entry.display_url()), + ), + ), + ) + .child( + div() + .max_w(px(220.0)) + .flex() + .items_center() + .justify_end() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .when_some(space_name, |this, space_name| { + this.child(div().max_w(px(140.0)).truncate().child(space_name)) + }) + .child(progress_label(entry.progress())), + ) + .into_any_element() + } +} + +fn render_reading_list_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("Reading List")) + .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(reading_list_count_label(snapshot.reading_list.len())), + ) + .into_any_element() +} + +fn reading_list_space_name(snapshot: &BrowserSnapshot, entry: &ReadingListEntry) -> Option { + snapshot + .spaces + .iter() + .find(|space| space.id() == entry.space_id()) + .map(|space| space.name().to_string()) +} + +fn progress_label(progress: &ReadingProgress) -> &'static str { + match progress { + ReadingProgress::Unread => "Unread", + } +} + +fn reading_list_count_label(count: usize) -> String { + match count { + 1 => "1 item".to_string(), + count => format!("{count} items"), + } +} diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index 8767cc1..0575622 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -152,6 +152,7 @@ fn sync_object_kind_label(kind: &SyncObjectKind) -> &'static str { SyncObjectKind::Spaces => "Spaces", SyncObjectKind::Tabs => "Tabs", SyncObjectKind::Bookmarks => "Bookmarks", + SyncObjectKind::ReadingList => "Reading List", 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 4b664ae..0eecf86 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -17,6 +17,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { match url { "ely://new-tab" => Some("New Tab"), "ely://bookmarks" => Some("Bookmarks"), + "ely://reading-list" => Some("Reading List"), "ely://downloads" => Some("Downloads"), "ely://history" => Some("History"), "ely://archive" => Some("Archived Tabs"), @@ -89,6 +90,10 @@ pub(crate) fn bookmarks_url() -> Result { internal_page_url("ely://bookmarks") } +pub(crate) fn reading_list_url() -> Result { + internal_page_url("ely://reading-list") +} + 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 5aeeef5..603d53b 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,7 +2,8 @@ use std::collections::BTreeMap; use ely_domain::{ ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, - HistoryEntry, Profile, ProfileId, ProfileKind, Space, SpaceId, SyncStatus, TabId, UrlText, + HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, Space, SpaceId, SyncStatus, + TabId, UrlText, }; use crate::CoreError; @@ -13,6 +14,7 @@ mod downloads; mod history; mod plugins; mod profiles; +mod reading_list; mod sync; mod tabs; @@ -44,6 +46,7 @@ pub struct BrowserSnapshot { pub pinned_tabs: Vec, pub archived_tabs: Vec, pub bookmarks: Vec, + pub reading_list: Vec, pub download_entries: Vec, pub history_entries: Vec, pub installed_plugins: Vec, @@ -67,6 +70,7 @@ pub struct BrowserCore { tabs: Vec, archived_tabs: Vec, bookmarks: Vec, + reading_list: Vec, download_entries: Vec, history_entries: Vec, installed_plugins: Vec, @@ -112,6 +116,7 @@ impl BrowserCore { tabs: vec![tab], archived_tabs: Vec::new(), bookmarks: Vec::new(), + reading_list: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), installed_plugins: Vec::new(), @@ -194,6 +199,7 @@ impl BrowserCore { pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_tabs.clone(), bookmarks: self.visible_bookmarks(), + reading_list: self.visible_reading_list(), download_entries: self.visible_downloads(), history_entries: self.visible_history(), installed_plugins: self.installed_plugins.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 15c483b..72930e9 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -4,8 +4,8 @@ use crate::{ CoreError, navigation::{ 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, + new_profile_name, new_space_name, reading_list_url, search_url, settings_page_url, + settings_url, space_icon, switch_profile_name, sync_status_url, }, }; @@ -57,6 +57,12 @@ impl BrowserCore { self.command_query.clear(); } } + CommandIntent::ScopedSearch { scope: CommandScope::ReadingList, query } => { + if let Some(url) = self.find_reading_list_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); @@ -112,6 +118,10 @@ impl BrowserCore { self.open_tab(bookmarks_url()?); Ok(true) } + "reading-list" | "open-reading-list" | "open reading list" => { + self.open_tab(reading_list_url()?); + Ok(true) + } "history" | "open-history" | "open history" => { self.open_tab(history_url()?); Ok(true) @@ -140,6 +150,10 @@ impl BrowserCore { self.bookmark_active_tab()?; Ok(true) } + "save-reading-list" | "save reading list" | "read-later" | "read later" => { + self.save_active_tab_to_reading_list()?; + Ok(true) + } "pin" | "pin-tab" | "toggle-pin" => { self.toggle_active_tab_pinned()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/reading_list.rs b/crates/ely_browser_core/src/state/reading_list.rs new file mode 100644 index 0000000..8d09f21 --- /dev/null +++ b/crates/ely_browser_core/src/state/reading_list.rs @@ -0,0 +1,57 @@ +use std::time::SystemTime; + +use ely_domain::{ReadingListEntry, ReadingListId, UrlText}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn save_active_tab_to_reading_list(&mut self) -> Result { + let active_tab = self.active_tab()?.clone(); + if let Some(entry) = self.reading_list.iter().find(|entry| { + entry.profile_id() == active_tab.profile_id() && entry.source_url() == active_tab.url() + }) { + return Ok(entry.id().clone()); + } + + let entry = ReadingListEntry::new( + active_tab.profile_id().clone(), + active_tab.space_id().clone(), + active_tab.title(), + active_tab.url().clone(), + SystemTime::now(), + )?; + let entry_id = entry.id().clone(); + self.reading_list.push(entry); + Ok(entry_id) + } + + pub(super) fn find_reading_list_match(&self, query: &str) -> Option { + let normalized_query = query.trim().to_lowercase(); + if normalized_query.is_empty() { + return None; + } + + self.reading_list + .iter() + .rev() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .find(|entry| reading_list_entry_matches_query(entry, &normalized_query)) + .map(|entry| entry.source_url().clone()) + } + + pub(super) fn visible_reading_list(&self) -> Vec { + self.reading_list + .iter() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } +} + +fn reading_list_entry_matches_query(entry: &ReadingListEntry, normalized_query: &str) -> bool { + entry.title().to_lowercase().contains(normalized_query) + || entry.source_url().as_str().to_lowercase().contains(normalized_query) + || entry.display_url().to_lowercase().contains(normalized_query) +} diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 4fa8fe8..c151649 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -20,6 +20,11 @@ impl BrowserCore { self.bookmarks.len(), SyncObjectState::LocalOnly, ), + SyncObjectStatus::new( + SyncObjectKind::ReadingList, + self.reading_list.len(), + SyncObjectState::LocalOnly, + ), SyncObjectStatus::new( SyncObjectKind::Profiles, self.profiles.len(), diff --git a/crates/ely_browser_core/tests/reading_list.rs b/crates/ely_browser_core/tests/reading_list.rs new file mode 100644 index 0000000..aa5e644 --- /dev/null +++ b/crates/ely_browser_core/tests/reading_list.rs @@ -0,0 +1,108 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, CommandScope, ProfileKind, ReadingProgress, UrlText}; + +#[test] +fn save_active_tab_records_reading_list_context() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/long-read")?); + let active_profile_id = core.active_tab()?.profile_id().clone(); + let active_space_id = core.active_tab()?.space_id().clone(); + + let entry_id = core.save_active_tab_to_reading_list()?; + let snapshot = core.snapshot()?; + let [entry] = snapshot.reading_list.as_slice() else { + return Err( + format!("expected 1 reading list entry, got {}", snapshot.reading_list.len()).into() + ); + }; + + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(entry.id(), &entry_id); + assert_eq!(entry.profile_id(), &active_profile_id); + assert_eq!(entry.space_id(), &active_space_id); + assert_eq!(entry.title(), "example.com"); + assert_eq!(entry.source_url().as_str(), "https://example.com/long-read"); + assert_eq!(entry.progress(), &ReadingProgress::Unread); + Ok(()) +} + +#[test] +fn save_active_tab_reuses_existing_reading_list_entry() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/long-read")?); + + let first_id = core.save_active_tab_to_reading_list()?; + let second_id = core.save_active_tab_to_reading_list()?; + + assert_eq!(first_id, second_id); + assert_eq!(core.snapshot()?.reading_list.len(), 1); + Ok(()) +} + +#[test] +fn reading_list_scoped_search_opens_matching_entry() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/long-read")?); + core.save_active_tab_to_reading_list()?; + + core.set_command_query("@reading-list long-read"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::ReadingList, + query: "long-read".to_string() + }) + ); + assert_eq!(active_tab.url().as_str(), "https://example.com/long-read"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn reading_list_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.save_active_tab_to_reading_list()?; + core.select_profile(&default_profile_id)?; + + core.set_command_query("@reading-list personal"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::ReadingList, + 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.reading_list.is_empty()); + assert_eq!(snapshot.command_query, "@reading-list personal"); + Ok(()) +} + +#[test] +fn open_reading_list_command_opens_reading_list_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-reading-list"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-reading-list".to_string()))); + assert_eq!(active_tab.title(), "Reading List"); + assert_eq!(active_tab.url().as_str(), "ely://reading-list"); + 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 ef34f23..a0846df 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -9,6 +9,7 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box Result<(), Box Option<(CommandScope, &str)> { "@spaces" => CommandScope::Spaces, "@tabs" => CommandScope::Tabs, "@bookmarks" => CommandScope::Bookmarks, + "@reading-list" | "@reading" => CommandScope::ReadingList, "@history" => CommandScope::History, "@settings" => CommandScope::Settings, "@plugins" => CommandScope::Plugins, diff --git a/crates/ely_domain/src/identifiers.rs b/crates/ely_domain/src/identifiers.rs index 95706a4..fb29a12 100644 --- a/crates/ely_domain/src/identifiers.rs +++ b/crates/ely_domain/src/identifiers.rs @@ -40,3 +40,4 @@ entity_id!(SplitId, "split"); entity_id!(WebViewId, "webview"); entity_id!(DownloadId, "download"); entity_id!(BookmarkId, "bookmark"); +entity_id!(ReadingListId, "reading"); diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index fb8effc..4792587 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -7,6 +7,7 @@ mod history; mod identifiers; mod plugin; mod profile; +mod reading_list; mod space; mod split; mod sync; @@ -22,12 +23,15 @@ pub use download::{ }; pub use error::DomainError; pub use history::HistoryEntry; -pub use identifiers::{BookmarkId, DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId}; +pub use identifiers::{ + BookmarkId, DownloadId, ProfileId, ReadingListId, SpaceId, SplitId, TabId, WebViewId, +}; pub use plugin::{ PluginContributionPoint, PluginId, PluginManifest, PluginPermission, PluginPermissionRisk, PluginSignature, PluginSignatureAlgorithm, }; pub use profile::{Profile, ProfileKind}; +pub use reading_list::{ReadingListEntry, ReadingProgress}; pub use space::{ArchivePolicy, Space}; pub use split::{SplitAxis, SplitLayout, SplitPane}; pub use sync::{ diff --git a/crates/ely_domain/src/reading_list.rs b/crates/ely_domain/src/reading_list.rs new file mode 100644 index 0000000..76e3283 --- /dev/null +++ b/crates/ely_domain/src/reading_list.rs @@ -0,0 +1,89 @@ +use std::time::SystemTime; + +use crate::{DomainError, ProfileId, ReadingListId, SpaceId, UrlText}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReadingProgress { + Unread, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReadingListEntry { + id: ReadingListId, + profile_id: ProfileId, + space_id: SpaceId, + title: String, + source_url: UrlText, + progress: ReadingProgress, + added_at: SystemTime, +} + +impl ReadingListEntry { + pub fn new( + profile_id: ProfileId, + space_id: SpaceId, + title: impl Into, + source_url: UrlText, + added_at: SystemTime, + ) -> Result { + let title = non_empty_text("reading list title", title.into())?; + + Ok(Self { + id: ReadingListId::new(), + profile_id, + space_id, + title, + source_url, + progress: ReadingProgress::Unread, + added_at, + }) + } + + #[must_use] + pub fn id(&self) -> &ReadingListId { + &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 title(&self) -> &str { + &self.title + } + + #[must_use] + pub fn source_url(&self) -> &UrlText { + &self.source_url + } + + #[must_use] + pub fn display_url(&self) -> String { + self.source_url.display_url() + } + + #[must_use] + pub fn progress(&self) -> &ReadingProgress { + &self.progress + } + + #[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/sync.rs b/crates/ely_domain/src/sync.rs index 2cc143d..696c6c3 100644 --- a/crates/ely_domain/src/sync.rs +++ b/crates/ely_domain/src/sync.rs @@ -8,6 +8,7 @@ pub enum SyncObjectKind { Spaces, Tabs, Bookmarks, + ReadingList, Profiles, History, PluginSettings,