From 6ba7fd611c0a4ba48b2a1bb885febe2c8eee5ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 03:19:43 -0400 Subject: [PATCH] Add privacy history settings --- crates/ely_app/src/shell/internal_pages.rs | 2 + .../shell/internal_pages/privacy_security.rs | 242 ++++++++++++++++++ .../src/shell/internal_pages/settings.rs | 6 + crates/ely_app/src/shell/mod.rs | 15 +- crates/ely_browser_core/src/navigation.rs | 3 + crates/ely_browser_core/src/state.rs | 19 +- crates/ely_browser_core/src/state/history.rs | 5 +- crates/ely_browser_core/tests/history.rs | 17 +- .../ely_browser_core/tests/settings_routes.rs | 21 ++ crates/ely_domain/src/lib.rs | 2 + crates/ely_domain/src/privacy.rs | 39 +++ 11 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/privacy_security.rs create mode 100644 crates/ely_domain/src/privacy.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 4f2f637..ed3fce3 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -7,6 +7,7 @@ mod general; mod plugin_catalog; mod plugin_details; mod plugins; +mod privacy_security; mod profiles; mod reading_list; mod search; @@ -55,6 +56,7 @@ impl ElyShell { "ely://settings/general" => self.render_general_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), "ely://settings/search" => self.render_search_page(snapshot, cx), + "ely://settings/privacy-security" => self.render_privacy_security_page(snapshot, cx), "ely://settings/spaces" => self.render_spaces_page(snapshot, cx), "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/privacy_security.rs b/crates/ely_app/src/shell/internal_pages/privacy_security.rs new file mode 100644 index 0000000..1a1ce0e --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/privacy_security.rs @@ -0,0 +1,242 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::HistoryRecordingPolicy; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Selectable, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_privacy_security_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_privacy_header(snapshot)) + .child(render_history_summary(snapshot)) + .child(render_history_policy_rows(snapshot.history_recording_policy, cx)), + ) + } +} + +fn render_privacy_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child("Privacy & Security"), + ) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(privacy_icon(snapshot.history_recording_policy)) + .child(snapshot.history_recording_policy.status()), + ) + .into_any_element() +} + +fn render_history_summary(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child( + div() + .text_color(rgb(policy_color(snapshot.history_recording_policy))) + .child(privacy_icon(snapshot.history_recording_policy)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(snapshot.history_recording_policy.name()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(snapshot.history_recording_policy.detail()), + ), + ), + ) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(format!("{} visible entries", snapshot.history_entries.len())), + ) + .into_any_element() +} + +fn render_history_policy_rows( + active_policy: HistoryRecordingPolicy, + cx: &mut Context, +) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + HistoryRecordingPolicy::ALL + .iter() + .copied() + .enumerate() + .map(|(index, policy)| render_history_policy_row(index, policy, active_policy, cx)), + ) + .into_any_element() +} + +fn render_history_policy_row( + index: usize, + policy: HistoryRecordingPolicy, + active_policy: HistoryRecordingPolicy, + cx: &mut Context, +) -> AnyElement { + let selected = policy == active_policy; + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child( + div() + .text_color(rgb(history_policy_icon_color(policy, selected))) + .child(history_policy_icon(policy, selected)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(policy.name()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(policy.detail()), + ), + ), + ) + .child( + Button::new(("history-recording-policy", index)) + .ghost() + .xsmall() + .selected(selected) + .label(history_policy_button_label(policy, selected)) + .tooltip(policy.name()) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_history_recording_policy(policy, cx); + })), + ) + .into_any_element() +} + +fn privacy_icon(policy: HistoryRecordingPolicy) -> IconName { + match policy { + HistoryRecordingPolicy::Record => IconName::Eye, + HistoryRecordingPolicy::Pause => IconName::EyeOff, + } +} + +fn policy_color(policy: HistoryRecordingPolicy) -> u32 { + match policy { + HistoryRecordingPolicy::Record => colors::SUCCESS, + HistoryRecordingPolicy::Pause => colors::PRIMARY, + } +} + +fn history_policy_icon(policy: HistoryRecordingPolicy, selected: bool) -> IconName { + if selected { IconName::CircleCheck } else { privacy_icon(policy) } +} + +fn history_policy_icon_color(policy: HistoryRecordingPolicy, selected: bool) -> u32 { + if selected { policy_color(policy) } else { colors::MUTED_SOFT } +} + +fn history_policy_button_label(policy: HistoryRecordingPolicy, selected: bool) -> &'static str { + match (policy, selected) { + (HistoryRecordingPolicy::Record, true) => "Default", + (_, true) => "Active", + _ => "Select", + } +} diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index ad67272..fb6c9d0 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -42,6 +42,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "Default search engine for Command Bar queries.", route: "ely://settings/search", }, + SettingsRoute { + icon: IconName::Eye, + title: "Privacy & Security", + detail: "History recording and profile-scoped privacy controls.", + route: "ely://settings/privacy-security", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 3588e2d..34645f3 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -7,8 +7,8 @@ mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ - ArchivePolicy, CommandIntent, NewTabDestination, ProfileId, SearchEngine, SpaceId, TabId, - UrlText, + ArchivePolicy, CommandIntent, HistoryRecordingPolicy, NewTabDestination, ProfileId, + SearchEngine, SpaceId, TabId, UrlText, }; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; @@ -287,6 +287,17 @@ impl ElyShell { } } + fn set_history_recording_policy( + &mut self, + policy: HistoryRecordingPolicy, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state { + core.set_history_recording_policy(policy); + cx.notify(); + } + } + fn archive_idle_tabs_now(&mut self, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state && core.archive_idle_tabs(std::time::SystemTime::now()).is_ok() diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index aef7464..974f046 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -28,6 +28,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://settings/general" => Some("General Settings"), "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), "ely://settings/search" => Some("Search Settings"), + "ely://settings/privacy-security" => Some("Privacy & Security Settings"), "ely://settings/spaces" => Some("Space Settings"), "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), @@ -164,6 +165,8 @@ fn settings_page_route(query: &str) -> Option<&'static str> { "search" | "search engine" | "default search" | "default search engine" => { Some("ely://settings/search") } + "privacy" | "security" | "privacy security" | "privacy & security" | "history" + | "history recording" => Some("ely://settings/privacy-security"), "space" | "spaces" | "space settings" | "spaces settings" => Some("ely://settings/spaces"), "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { Some("ely://settings/shortcuts") diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 58cd8aa..9705053 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,9 +2,9 @@ use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, - DownloadPolicy, HistoryEntry, NewTabDestination, Profile, ProfileId, ProfileKind, - ReadingListEntry, SearchEngine, SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, - SplitLayout, SyncStatus, TabId, UrlText, + DownloadPolicy, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, Profile, ProfileId, + ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, SitePermissionEntry, + Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, }; use crate::{CoreError, navigation::tab_title}; @@ -68,6 +68,7 @@ pub struct BrowserSnapshot { pub active_download_policy: DownloadPolicy, pub search_engine: SearchEngine, pub new_tab_destination: NewTabDestination, + pub history_recording_policy: HistoryRecordingPolicy, pub command_query: String, } @@ -94,6 +95,7 @@ pub struct BrowserCore { active_tabs_by_space_profile: BTreeMap<(SpaceId, ProfileId), TabId>, search_engine: SearchEngine, new_tab_destination: NewTabDestination, + history_recording_policy: HistoryRecordingPolicy, command_query: String, } @@ -128,6 +130,7 @@ impl BrowserCore { active_tabs_by_space_profile, search_engine: SearchEngine::default(), new_tab_destination, + history_recording_policy: HistoryRecordingPolicy::default(), spaces: vec![space], profiles: vec![profile], tabs: vec![tab], @@ -241,6 +244,15 @@ impl BrowserCore { self.new_tab_destination } + pub fn set_history_recording_policy(&mut self, policy: HistoryRecordingPolicy) { + self.history_recording_policy = policy; + } + + #[must_use] + pub fn history_recording_policy(&self) -> HistoryRecordingPolicy { + self.history_recording_policy + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -279,6 +291,7 @@ impl BrowserCore { active_download_policy: active_profile.download_policy().clone(), search_engine: self.search_engine, new_tab_destination: self.new_tab_destination, + history_recording_policy: self.history_recording_policy, command_query: self.command_query.clone(), }) } diff --git a/crates/ely_browser_core/src/state/history.rs b/crates/ely_browser_core/src/state/history.rs index 8133a43..7e7c3a0 100644 --- a/crates/ely_browser_core/src/state/history.rs +++ b/crates/ely_browser_core/src/state/history.rs @@ -8,7 +8,10 @@ use super::BrowserCore; impl BrowserCore { pub(super) fn record_history_entry(&mut self, tab: &BrowserTab) { - if !records_history(tab.url()) || !self.profile_records_history(tab.profile_id()) { + if !self.history_recording_policy.records_history() + || !records_history(tab.url()) + || !self.profile_records_history(tab.profile_id()) + { return; } diff --git a/crates/ely_browser_core/tests/history.rs b/crates/ely_browser_core/tests/history.rs index 66007dc..0f918d7 100644 --- a/crates/ely_browser_core/tests/history.rs +++ b/crates/ely_browser_core/tests/history.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; +use ely_domain::{CommandIntent, CommandScope, HistoryRecordingPolicy, ProfileKind, UrlText}; #[test] fn navigation_records_profile_and_space_history() -> Result<(), Box> { @@ -30,6 +30,21 @@ fn internal_pages_are_omitted_from_history() -> Result<(), Box> { Ok(()) } +#[test] +fn paused_history_recording_skips_new_history_entries() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.open_tab(UrlText::parse("https://example.com/recorded")?); + core.set_history_recording_policy(HistoryRecordingPolicy::Pause); + core.open_tab(UrlText::parse("https://example.com/private")?); + + let snapshot = core.snapshot()?; + assert_eq!(snapshot.history_recording_policy, HistoryRecordingPolicy::Pause); + assert_eq!(snapshot.history_entries.len(), 1); + assert_eq!(snapshot.history_entries[0].url().as_str(), "https://example.com/recorded"); + Ok(()) +} + #[test] fn history_scoped_search_opens_recent_matching_entry() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index 22b6671..9daba13 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -107,3 +107,24 @@ fn settings_scoped_search_opens_search_page() -> Result<(), Box> { assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn settings_scoped_search_opens_privacy_security_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings privacy"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "privacy".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Privacy & Security Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/privacy-security"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index dde8152..510a50e 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -7,6 +7,7 @@ mod history; mod identifiers; mod new_tab; mod plugin; +mod privacy; mod profile; mod reading_list; mod search; @@ -34,6 +35,7 @@ pub use plugin::{ PluginContributionPoint, PluginId, PluginManifest, PluginPermission, PluginPermissionRisk, PluginSignature, PluginSignatureAlgorithm, }; +pub use privacy::HistoryRecordingPolicy; pub use profile::{Profile, ProfileKind}; pub use reading_list::{ReadingListEntry, ReadingProgress}; pub use search::SearchEngine; diff --git a/crates/ely_domain/src/privacy.rs b/crates/ely_domain/src/privacy.rs new file mode 100644 index 0000000..9b84ee6 --- /dev/null +++ b/crates/ely_domain/src/privacy.rs @@ -0,0 +1,39 @@ +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HistoryRecordingPolicy { + #[default] + Record, + Pause, +} + +impl HistoryRecordingPolicy { + pub const ALL: &[Self] = &[Self::Record, Self::Pause]; + + #[must_use] + pub fn records_history(self) -> bool { + matches!(self, Self::Record) + } + + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Record => "Record History", + Self::Pause => "Pause History", + } + } + + #[must_use] + pub fn detail(self) -> &'static str { + match self { + Self::Record => "Save new web visits for Standard Profiles.", + Self::Pause => "Skip new history entries for Standard Profiles.", + } + } + + #[must_use] + pub fn status(self) -> &'static str { + match self { + Self::Record => "History recording is on", + Self::Pause => "History recording is paused", + } + } +}