From 349597d2839040ad77a5a3f92c789ef37fc0ca27 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 04:31:19 -0400 Subject: [PATCH] Add recent history clear confirmation --- crates/ely_app/src/shell/history.rs | 73 +++++++++++++ .../src/shell/internal_pages/history.rs | 100 ++++++++++++++++-- crates/ely_app/src/shell/mod.rs | 4 +- crates/ely_browser_core/src/state/history.rs | 31 ++++++ crates/ely_browser_core/tests/history.rs | 57 +++++++++- 5 files changed, 257 insertions(+), 8 deletions(-) diff --git a/crates/ely_app/src/shell/history.rs b/crates/ely_app/src/shell/history.rs index ce37590..6025843 100644 --- a/crates/ely_app/src/shell/history.rs +++ b/crates/ely_app/src/shell/history.rs @@ -1,8 +1,12 @@ +use std::time::{Duration, SystemTime}; + use ely_domain::{ProfileId, SpaceId}; use gpui::Context; use super::{ElyShell, ShellState}; +const RECENT_HISTORY_CLEAR_WINDOW: Duration = Duration::from_secs(3_600); + #[derive(Clone, Debug)] pub(super) struct PendingHistoryDomainClear { profile_id: ProfileId, @@ -24,6 +28,37 @@ impl PendingHistoryDomainClear { } } +#[derive(Clone, Debug)] +pub(super) struct PendingHistoryTimeClear { + profile_id: ProfileId, + space_id: SpaceId, + cutoff: SystemTime, + label: &'static str, +} + +impl PendingHistoryTimeClear { + fn recent_hour(profile_id: ProfileId, space_id: SpaceId) -> Self { + Self { + profile_id, + space_id, + cutoff: SystemTime::now() - RECENT_HISTORY_CLEAR_WINDOW, + label: "last hour", + } + } + + pub(super) fn cutoff(&self) -> SystemTime { + self.cutoff + } + + pub(super) fn label(&self) -> &'static str { + self.label + } + + 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 @@ -31,6 +66,7 @@ impl ElyShell { { self.history_clear_confirmation = Some(snapshot.active_profile_id); self.pending_history_domain_clear = None; + self.pending_history_time_clear = None; cx.notify(); } } @@ -48,6 +84,7 @@ impl ElyShell { { self.history_clear_confirmation = None; self.pending_history_domain_clear = None; + self.pending_history_time_clear = None; cx.notify(); } } @@ -60,6 +97,8 @@ impl ElyShell { if let ShellState::Ready(core) = &mut self.state && let Ok(snapshot) = core.snapshot() { + self.history_clear_confirmation = None; + self.pending_history_time_clear = None; self.pending_history_domain_clear = Some(PendingHistoryDomainClear::new( snapshot.active_profile_id, snapshot.active_space_id, @@ -88,4 +127,38 @@ impl ElyShell { cx.notify(); } } + + pub(super) fn request_clear_recent_history_confirmation(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && let Ok(snapshot) = core.snapshot() + { + self.history_clear_confirmation = None; + self.pending_history_domain_clear = None; + self.pending_history_time_clear = Some(PendingHistoryTimeClear::recent_hour( + snapshot.active_profile_id, + snapshot.active_space_id, + )); + cx.notify(); + } + } + + pub(super) fn cancel_clear_recent_history_confirmation(&mut self, cx: &mut Context) { + self.pending_history_time_clear = None; + cx.notify(); + } + + pub(super) fn clear_active_space_history_for_pending_time(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_history_time_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_since(pending.cutoff()).is_ok() + { + self.pending_history_time_clear = None; + cx.notify(); + } + } } diff --git a/crates/ely_app/src/shell/internal_pages/history.rs b/crates/ely_app/src/shell/internal_pages/history.rs index d24b807..3230d6f 100644 --- a/crates/ely_app/src/shell/internal_pages/history.rs +++ b/crates/ely_app/src/shell/internal_pages/history.rs @@ -12,7 +12,7 @@ use gpui_component::{ scroll::ScrollableElement, }; -use super::super::PendingHistoryDomainClear; +use super::super::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use super::{ElyShell, render_canvas_surface}; impl ElyShell { @@ -24,6 +24,9 @@ impl ElyShell { let pending_domain_clear = self.pending_history_domain_clear.clone().filter(|pending| { pending.matches_context(&snapshot.active_profile_id, &snapshot.active_space_id) }); + let pending_time_clear = self.pending_history_time_clear.clone().filter(|pending| { + pending.matches_context(&snapshot.active_profile_id, &snapshot.active_space_id) + }); render_canvas_surface( div() @@ -32,7 +35,10 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_history_header(snapshot)) + .child(render_history_header(snapshot, cx)) + .when_some(pending_time_clear, |this, pending| { + this.child(render_time_clear_confirmation(&pending, cx)) + }) .when_some(pending_domain_clear, |this, pending| { this.child(render_domain_clear_confirmation(&pending, cx)) }) @@ -41,13 +47,15 @@ impl ElyShell { } } -fn render_history_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_history_header(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { div() .flex() .items_end() .justify_between() + .gap_4() .child( div() + .min_w_0() .flex() .flex_col() .gap_2() @@ -59,9 +67,89 @@ fn render_history_header(snapshot: &BrowserSnapshot) -> AnyElement { ) .child( div() - .text_xs() - .text_color(rgb(colors::MUTED)) - .child(format!("{} entries", snapshot.history_entries.len())), + .flex() + .items_center() + .gap_3() + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} entries", snapshot.history_entries.len())), + ) + .when(!snapshot.history_entries.is_empty(), |this| { + this.child( + Button::new("request-clear-recent-history") + .danger() + .xsmall() + .label("Clear Last Hour") + .tooltip("Clear Recent History") + .on_click(cx.listener(|shell, _, _, cx| { + shell.request_clear_recent_history_confirmation(cx); + })), + ) + }), + ) + .into_any_element() +} + +fn render_time_clear_confirmation( + pending: &PendingHistoryTimeClear, + 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.label())), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("This removes recent history in the current Space."), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("cancel-clear-recent-history") + .ghost() + .xsmall() + .label("Cancel") + .on_click(cx.listener(|shell, _, _, cx| { + shell.cancel_clear_recent_history_confirmation(cx); + })), + ) + .child( + Button::new("confirm-clear-recent-history") + .danger() + .xsmall() + .label("Clear") + .on_click(cx.listener(|shell, _, _, cx| { + shell.clear_active_space_history_for_pending_time(cx); + })), + ), ) .into_any_element() } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 4136992..244a5be 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -16,7 +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 history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use plugins::{PendingPluginInstall, PendingPluginUninstall}; use crate::{ @@ -40,6 +40,7 @@ pub struct ElyShell { download_security_confirmation: Option, history_clear_confirmation: Option, pending_history_domain_clear: Option, + pending_history_time_clear: Option, site_permissions_clear_confirmation: Option, plugin_install_error: Option, pending_plugin_install: Option, @@ -104,6 +105,7 @@ impl ElyShell { download_security_confirmation: None, history_clear_confirmation: None, pending_history_domain_clear: None, + pending_history_time_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 28c9ad8..a3192ea 100644 --- a/crates/ely_browser_core/src/state/history.rs +++ b/crates/ely_browser_core/src/state/history.rs @@ -21,6 +21,15 @@ impl BrowserCore { self.clear_space_profile_history_for_host(&profile_id, &space_id, host) } + pub fn clear_active_space_history_since( + &mut self, + cutoff: SystemTime, + ) -> Result { + let profile_id = self.active_profile_id.clone(); + let space_id = self.active_space_id.clone(); + self.clear_space_profile_history_since(&profile_id, &space_id, cutoff) + } + pub(super) fn record_history_entry(&mut self, tab: &BrowserTab) { if !self.history_recording_policy.records_history() || !records_history(tab.url()) @@ -108,6 +117,28 @@ impl BrowserCore { Ok(original_count - self.history_entries.len()) } + fn clear_space_profile_history_since( + &mut self, + profile_id: &ProfileId, + space_id: &SpaceId, + cutoff: SystemTime, + ) -> 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 original_count = self.history_entries.len(); + self.history_entries.retain(|entry| { + entry.profile_id() != profile_id + || entry.space_id() != space_id + || entry.visited_at() < cutoff + }); + 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 70021be..ed3e39e 100644 --- a/crates/ely_browser_core/tests/history.rs +++ b/crates/ely_browser_core/tests/history.rs @@ -1,4 +1,7 @@ -use std::error::Error; +use std::{ + error::Error, + time::{Duration, SystemTime}, +}; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{CommandIntent, CommandScope, HistoryRecordingPolicy, ProfileKind, UrlText}; @@ -137,6 +140,58 @@ fn clear_active_space_history_for_absent_host_is_empty_change() -> Result<(), Bo Ok(()) } +#[test] +fn clear_active_space_history_since_stays_in_space_and_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let cutoff = SystemTime::now() - Duration::from_secs(3_600); + 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.org/work-two")?); + + 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_since(cutoff)?; + let work_snapshot = core.snapshot()?; + assert_eq!(removed_count, 2); + assert!(work_snapshot.history_entries.is_empty()); + assert_eq!(work_snapshot.active_profile_history_entry_count, 1); + + 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_since_future_cutoff_is_empty_change() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/work")?); + + let cutoff = SystemTime::now() + Duration::from_secs(60); + let removed_count = core.clear_active_space_history_since(cutoff)?; + 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()?)?;