diff --git a/crates/ely_app/src/shell/history.rs b/crates/ely_app/src/shell/history.rs index 091afa6..ce37590 100644 --- a/crates/ely_app/src/shell/history.rs +++ b/crates/ely_app/src/shell/history.rs @@ -1,13 +1,36 @@ +use ely_domain::{ProfileId, SpaceId}; use gpui::Context; use super::{ElyShell, ShellState}; +#[derive(Clone, Debug)] +pub(super) struct PendingHistoryDomainClear { + profile_id: ProfileId, + space_id: SpaceId, + host: String, +} + +impl PendingHistoryDomainClear { + fn new(profile_id: ProfileId, space_id: SpaceId, host: String) -> Self { + Self { profile_id, space_id, host } + } + + pub(super) fn host(&self) -> &str { + &self.host + } + + pub(super) fn matches_context(&self, profile_id: &ProfileId, space_id: &SpaceId) -> bool { + &self.profile_id == profile_id && &self.space_id == space_id + } +} + impl ElyShell { pub(super) fn request_clear_history_confirmation(&mut self, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state && let Ok(snapshot) = core.snapshot() { self.history_clear_confirmation = Some(snapshot.active_profile_id); + self.pending_history_domain_clear = None; cx.notify(); } } @@ -24,6 +47,44 @@ impl ElyShell { && core.clear_active_profile_history().is_ok() { self.history_clear_confirmation = None; + self.pending_history_domain_clear = None; + cx.notify(); + } + } + + pub(super) fn request_clear_history_domain_confirmation( + &mut self, + host: String, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && let Ok(snapshot) = core.snapshot() + { + self.pending_history_domain_clear = Some(PendingHistoryDomainClear::new( + snapshot.active_profile_id, + snapshot.active_space_id, + host, + )); + cx.notify(); + } + } + + pub(super) fn cancel_clear_history_domain_confirmation(&mut self, cx: &mut Context) { + self.pending_history_domain_clear = None; + cx.notify(); + } + + pub(super) fn clear_active_space_history_for_pending_domain(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_history_domain_clear.clone() else { + return; + }; + + if let ShellState::Ready(core) = &mut self.state + && let Ok(snapshot) = core.snapshot() + && pending.matches_context(&snapshot.active_profile_id, &snapshot.active_space_id) + && core.clear_active_space_history_for_host(pending.host()).is_ok() + { + self.pending_history_domain_clear = None; cx.notify(); } } diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 6791f3a..730b0fe 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 download_settings; mod downloads; mod general; +mod history; mod plugin_catalog; mod plugin_details; mod plugins; @@ -23,7 +24,7 @@ mod task_manager; use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; -use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab, HistoryEntry}; +use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, div, px, rgb, @@ -73,136 +74,6 @@ impl ElyShell { } } - fn render_history_page( - &mut self, - snapshot: &BrowserSnapshot, - cx: &mut Context, - ) -> AnyElement { - render_canvas_surface( - div() - .size_full() - .p_8() - .flex() - .flex_col() - .gap_5() - .child( - 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("History"), - ) - .child(div().text_sm().text_color(rgb(colors::MUTED)).child( - format!( - "{} / {}", - snapshot.active_profile_name, snapshot.active_space_name - ), - )), - ) - .child( - div() - .text_xs() - .text_color(rgb(colors::MUTED)) - .child(format!("{} entries", snapshot.history_entries.len())), - ), - ) - .child(self.render_history_list(snapshot, cx)), - ) - } - - fn render_history_list( - &mut self, - snapshot: &BrowserSnapshot, - cx: &mut Context, - ) -> AnyElement { - if snapshot.history_entries.is_empty() { - return div() - .flex_1() - .border_t_1() - .border_color(rgb(colors::HAIRLINE)) - .pt_5() - .text_sm() - .text_color(rgb(colors::MUTED)) - .child("History is empty for this Space and Profile.") - .into_any_element(); - } - - div() - .flex_1() - .flex() - .flex_col() - .overflow_y_scrollbar() - .border_t_1() - .border_color(rgb(colors::HAIRLINE)) - .children( - snapshot - .history_entries - .iter() - .rev() - .enumerate() - .map(|(index, entry)| self.render_history_row(index, entry, cx)), - ) - .into_any_element() - } - - fn render_history_row( - &mut self, - index: usize, - entry: &HistoryEntry, - cx: &mut Context, - ) -> AnyElement { - let url = entry.url().clone(); - - div() - .id(SharedString::from(format!("history-{index}"))) - .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() - .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.url().display_url()), - ), - ) - .child(div().text_color(rgb(colors::MUTED_SOFT)).child(IconName::ExternalLink)) - .into_any_element() - } - fn render_archive_page( &mut self, snapshot: &BrowserSnapshot, diff --git a/crates/ely_app/src/shell/internal_pages/history.rs b/crates/ely_app/src/shell/internal_pages/history.rs new file mode 100644 index 0000000..d24b807 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/history.rs @@ -0,0 +1,231 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::HistoryEntry; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, + px, rgb, +}; +use gpui_component::{ + Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::super::PendingHistoryDomainClear; +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_history_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + let pending_domain_clear = self.pending_history_domain_clear.clone().filter(|pending| { + pending.matches_context(&snapshot.active_profile_id, &snapshot.active_space_id) + }); + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_history_header(snapshot)) + .when_some(pending_domain_clear, |this, pending| { + this.child(render_domain_clear_confirmation(&pending, cx)) + }) + .child(render_history_list(snapshot, cx)), + ) + } +} + +fn render_history_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("History")) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(format!( + "{} / {}", + snapshot.active_profile_name, snapshot.active_space_name + ))), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} entries", snapshot.history_entries.len())), + ) + .into_any_element() +} + +fn render_domain_clear_confirmation( + pending: &PendingHistoryDomainClear, + cx: &mut Context, +) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::ERROR)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(format!("Confirm clearing {}", pending.host())), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("This removes matching history in the current Space."), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("cancel-clear-history-domain") + .ghost() + .xsmall() + .label("Cancel") + .on_click(cx.listener(|shell, _, _, cx| { + shell.cancel_clear_history_domain_confirmation(cx); + })), + ) + .child( + Button::new("confirm-clear-history-domain") + .danger() + .xsmall() + .label("Clear") + .on_click(cx.listener(|shell, _, _, cx| { + shell.clear_active_space_history_for_pending_domain(cx); + })), + ), + ) + .into_any_element() +} + +fn render_history_list(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { + if snapshot.history_entries.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("History is empty for this Space and 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 + .history_entries + .iter() + .rev() + .enumerate() + .map(|(index, entry)| render_history_row(index, entry, cx)), + ) + .into_any_element() +} + +fn render_history_row( + index: usize, + entry: &HistoryEntry, + cx: &mut Context, +) -> AnyElement { + let url = entry.url().clone(); + let host = entry.url().host(); + + div() + .id(SharedString::from(format!("history-{index}"))) + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .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.url().display_url()), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new(("open-history-entry", index)) + .ghost() + .xsmall() + .label("Open") + .tooltip("Open History Entry") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_url(url.clone(), window, cx); + })), + ) + .when_some(host, |this, host| { + this.child( + Button::new(("clear-history-domain", index)) + .danger() + .xsmall() + .label("Clear Domain") + .tooltip("Clear Domain History") + .on_click(cx.listener(move |shell, _, _, cx| { + shell.request_clear_history_domain_confirmation(host.clone(), cx); + })), + ) + }), + ) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index d1b2e61..4136992 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -16,6 +16,7 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio use gpui_component::input::{InputEvent, InputState, SelectAll}; use downloads::PendingDownloadFileAction; +use history::PendingHistoryDomainClear; use plugins::{PendingPluginInstall, PendingPluginUninstall}; use crate::{ @@ -38,6 +39,7 @@ pub struct ElyShell { download_clear_confirmation: bool, download_security_confirmation: Option, history_clear_confirmation: Option, + pending_history_domain_clear: Option, site_permissions_clear_confirmation: Option, plugin_install_error: Option, pending_plugin_install: Option, @@ -101,6 +103,7 @@ impl ElyShell { download_clear_confirmation: false, download_security_confirmation: None, history_clear_confirmation: None, + pending_history_domain_clear: None, site_permissions_clear_confirmation: None, plugin_install_error: None, pending_plugin_install: None, diff --git a/crates/ely_browser_core/src/state/history.rs b/crates/ely_browser_core/src/state/history.rs index bf03759..28c9ad8 100644 --- a/crates/ely_browser_core/src/state/history.rs +++ b/crates/ely_browser_core/src/state/history.rs @@ -1,6 +1,6 @@ use std::time::SystemTime; -use ely_domain::{BrowserTab, HistoryEntry, ProfileId, ProfileKind, UrlText}; +use ely_domain::{BrowserTab, HistoryEntry, ProfileId, ProfileKind, SpaceId, UrlText}; use crate::navigation::records_history; @@ -12,6 +12,15 @@ impl BrowserCore { self.clear_profile_history(&profile_id) } + pub fn clear_active_space_history_for_host( + &mut self, + host: &str, + ) -> Result { + let profile_id = self.active_profile_id.clone(); + let space_id = self.active_space_id.clone(); + self.clear_space_profile_history_for_host(&profile_id, &space_id, host) + } + pub(super) fn record_history_entry(&mut self, tab: &BrowserTab) { if !self.history_recording_policy.records_history() || !records_history(tab.url()) @@ -72,6 +81,33 @@ impl BrowserCore { Ok(original_count - self.history_entries.len()) } + fn clear_space_profile_history_for_host( + &mut self, + profile_id: &ProfileId, + space_id: &SpaceId, + host: &str, + ) -> Result { + if !self.profiles.iter().any(|profile| profile.id() == profile_id) { + return Err(crate::CoreError::ProfileNotFound { id: profile_id.clone() }); + } + if !self.spaces.iter().any(|space| space.id() == space_id) { + return Err(crate::CoreError::SpaceNotFound { id: space_id.clone() }); + } + + let normalized_host = host.trim().to_ascii_lowercase(); + if normalized_host.is_empty() { + return Ok(0); + } + + let original_count = self.history_entries.len(); + self.history_entries.retain(|entry| { + entry.profile_id() != profile_id + || entry.space_id() != space_id + || entry.url().host().as_deref() != Some(normalized_host.as_str()) + }); + Ok(original_count - self.history_entries.len()) + } + fn profile_records_history(&self, profile_id: &ProfileId) -> bool { match self.profiles.iter().find(|profile| profile.id() == profile_id) { Some(profile) => profile.kind() == &ProfileKind::Standard, diff --git a/crates/ely_browser_core/tests/history.rs b/crates/ely_browser_core/tests/history.rs index dbaa0b0..70021be 100644 --- a/crates/ely_browser_core/tests/history.rs +++ b/crates/ely_browser_core/tests/history.rs @@ -85,6 +85,58 @@ fn clear_active_profile_history_without_entries_is_empty_change() -> Result<(), Ok(()) } +#[test] +fn clear_active_space_history_for_host_stays_in_space_and_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let default_profile_id = core.snapshot()?.active_profile_id; + + core.open_tab(UrlText::parse("https://example.com/work-one")?); + core.open_tab(UrlText::parse("https://example.com/work-two")?); + core.open_tab(UrlText::parse("https://example.org/work")?); + + let research_space_id = core.create_space("Research", "R", 0xf54e00)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + + core.select_space(&work_space_id)?; + let personal_profile_id = core.create_profile("Personal", 0x26251e, ProfileKind::Standard)?; + core.open_tab(UrlText::parse("https://example.com/personal")?); + core.select_profile(&default_profile_id)?; + + let removed_count = core.clear_active_space_history_for_host("EXAMPLE.com")?; + let work_snapshot = core.snapshot()?; + assert_eq!(removed_count, 2); + assert_eq!(work_snapshot.history_entries.len(), 1); + assert_eq!(work_snapshot.history_entries[0].url().as_str(), "https://example.org/work"); + assert_eq!(work_snapshot.active_profile_history_entry_count, 2); + + core.select_space(&research_space_id)?; + let research_snapshot = core.snapshot()?; + assert_eq!(research_snapshot.history_entries.len(), 1); + assert_eq!(research_snapshot.history_entries[0].url().as_str(), "https://example.com/research"); + + core.select_space(&work_space_id)?; + core.select_profile(&personal_profile_id)?; + let personal_snapshot = core.snapshot()?; + assert_eq!(personal_snapshot.history_entries.len(), 1); + assert_eq!(personal_snapshot.history_entries[0].url().as_str(), "https://example.com/personal"); + Ok(()) +} + +#[test] +fn clear_active_space_history_for_absent_host_is_empty_change() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/work")?); + + let removed_count = core.clear_active_space_history_for_host("absent.example")?; + let snapshot = core.snapshot()?; + + assert_eq!(removed_count, 0); + assert_eq!(snapshot.history_entries.len(), 1); + assert_eq!(snapshot.active_profile_history_entry_count, 1); + 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_domain/src/url_text.rs b/crates/ely_domain/src/url_text.rs index 7d8f242..eb1dec5 100644 --- a/crates/ely_domain/src/url_text.rs +++ b/crates/ely_domain/src/url_text.rs @@ -52,6 +52,13 @@ impl UrlText { .unwrap_or_else(|| self.value.clone()) } + #[must_use] + pub fn host(&self) -> Option { + Url::parse(&self.value) + .ok() + .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase())) + } + #[must_use] pub fn display_url(&self) -> String { let Ok(url) = Url::parse(&self.value) else {