Add recent history clear confirmation

This commit is contained in:
2026-05-08 04:31:19 -04:00
parent 71750114a6
commit 349597d283
5 changed files with 257 additions and 8 deletions
+73
View File
@@ -1,8 +1,12 @@
use std::time::{Duration, SystemTime};
use ely_domain::{ProfileId, SpaceId}; use ely_domain::{ProfileId, SpaceId};
use gpui::Context; use gpui::Context;
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
const RECENT_HISTORY_CLEAR_WINDOW: Duration = Duration::from_secs(3_600);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(super) struct PendingHistoryDomainClear { pub(super) struct PendingHistoryDomainClear {
profile_id: ProfileId, 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 { impl ElyShell {
pub(super) fn request_clear_history_confirmation(&mut self, cx: &mut Context<Self>) { pub(super) fn request_clear_history_confirmation(&mut self, cx: &mut Context<Self>) {
if let ShellState::Ready(core) = &mut self.state if let ShellState::Ready(core) = &mut self.state
@@ -31,6 +66,7 @@ impl ElyShell {
{ {
self.history_clear_confirmation = Some(snapshot.active_profile_id); self.history_clear_confirmation = Some(snapshot.active_profile_id);
self.pending_history_domain_clear = None; self.pending_history_domain_clear = None;
self.pending_history_time_clear = None;
cx.notify(); cx.notify();
} }
} }
@@ -48,6 +84,7 @@ impl ElyShell {
{ {
self.history_clear_confirmation = None; self.history_clear_confirmation = None;
self.pending_history_domain_clear = None; self.pending_history_domain_clear = None;
self.pending_history_time_clear = None;
cx.notify(); cx.notify();
} }
} }
@@ -60,6 +97,8 @@ impl ElyShell {
if let ShellState::Ready(core) = &mut self.state if let ShellState::Ready(core) = &mut self.state
&& let Ok(snapshot) = core.snapshot() && let Ok(snapshot) = core.snapshot()
{ {
self.history_clear_confirmation = None;
self.pending_history_time_clear = None;
self.pending_history_domain_clear = Some(PendingHistoryDomainClear::new( self.pending_history_domain_clear = Some(PendingHistoryDomainClear::new(
snapshot.active_profile_id, snapshot.active_profile_id,
snapshot.active_space_id, snapshot.active_space_id,
@@ -88,4 +127,38 @@ impl ElyShell {
cx.notify(); cx.notify();
} }
} }
pub(super) fn request_clear_recent_history_confirmation(&mut self, cx: &mut Context<Self>) {
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>) {
self.pending_history_time_clear = None;
cx.notify();
}
pub(super) fn clear_active_space_history_for_pending_time(&mut self, cx: &mut Context<Self>) {
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();
}
}
} }
@@ -12,7 +12,7 @@ use gpui_component::{
scroll::ScrollableElement, scroll::ScrollableElement,
}; };
use super::super::PendingHistoryDomainClear; use super::super::{PendingHistoryDomainClear, PendingHistoryTimeClear};
use super::{ElyShell, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
impl ElyShell { impl ElyShell {
@@ -24,6 +24,9 @@ impl ElyShell {
let pending_domain_clear = self.pending_history_domain_clear.clone().filter(|pending| { let pending_domain_clear = self.pending_history_domain_clear.clone().filter(|pending| {
pending.matches_context(&snapshot.active_profile_id, &snapshot.active_space_id) 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( render_canvas_surface(
div() div()
@@ -32,7 +35,10 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .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| { .when_some(pending_domain_clear, |this, pending| {
this.child(render_domain_clear_confirmation(&pending, cx)) 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<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_end()
.justify_between() .justify_between()
.gap_4()
.child( .child(
div() div()
.min_w_0()
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
@@ -59,9 +67,89 @@ fn render_history_header(snapshot: &BrowserSnapshot) -> AnyElement {
) )
.child( .child(
div() div()
.text_xs() .flex()
.text_color(rgb(colors::MUTED)) .items_center()
.child(format!("{} entries", snapshot.history_entries.len())), .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<ElyShell>,
) -> 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() .into_any_element()
} }
+3 -1
View File
@@ -16,7 +16,7 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio
use gpui_component::input::{InputEvent, InputState, SelectAll}; use gpui_component::input::{InputEvent, InputState, SelectAll};
use downloads::PendingDownloadFileAction; use downloads::PendingDownloadFileAction;
use history::PendingHistoryDomainClear; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
use plugins::{PendingPluginInstall, PendingPluginUninstall}; use plugins::{PendingPluginInstall, PendingPluginUninstall};
use crate::{ use crate::{
@@ -40,6 +40,7 @@ pub struct ElyShell {
download_security_confirmation: Option<PendingDownloadFileAction>, download_security_confirmation: Option<PendingDownloadFileAction>,
history_clear_confirmation: Option<ProfileId>, history_clear_confirmation: Option<ProfileId>,
pending_history_domain_clear: Option<PendingHistoryDomainClear>, pending_history_domain_clear: Option<PendingHistoryDomainClear>,
pending_history_time_clear: Option<PendingHistoryTimeClear>,
site_permissions_clear_confirmation: Option<ProfileId>, site_permissions_clear_confirmation: Option<ProfileId>,
plugin_install_error: Option<String>, plugin_install_error: Option<String>,
pending_plugin_install: Option<PendingPluginInstall>, pending_plugin_install: Option<PendingPluginInstall>,
@@ -104,6 +105,7 @@ impl ElyShell {
download_security_confirmation: None, download_security_confirmation: None,
history_clear_confirmation: None, history_clear_confirmation: None,
pending_history_domain_clear: None, pending_history_domain_clear: None,
pending_history_time_clear: None,
site_permissions_clear_confirmation: None, site_permissions_clear_confirmation: None,
plugin_install_error: None, plugin_install_error: None,
pending_plugin_install: None, pending_plugin_install: None,
@@ -21,6 +21,15 @@ impl BrowserCore {
self.clear_space_profile_history_for_host(&profile_id, &space_id, host) self.clear_space_profile_history_for_host(&profile_id, &space_id, host)
} }
pub fn clear_active_space_history_since(
&mut self,
cutoff: SystemTime,
) -> Result<usize, crate::CoreError> {
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) { pub(super) fn record_history_entry(&mut self, tab: &BrowserTab) {
if !self.history_recording_policy.records_history() if !self.history_recording_policy.records_history()
|| !records_history(tab.url()) || !records_history(tab.url())
@@ -108,6 +117,28 @@ impl BrowserCore {
Ok(original_count - self.history_entries.len()) Ok(original_count - self.history_entries.len())
} }
fn clear_space_profile_history_since(
&mut self,
profile_id: &ProfileId,
space_id: &SpaceId,
cutoff: SystemTime,
) -> Result<usize, crate::CoreError> {
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 { fn profile_records_history(&self, profile_id: &ProfileId) -> bool {
match self.profiles.iter().find(|profile| profile.id() == profile_id) { match self.profiles.iter().find(|profile| profile.id() == profile_id) {
Some(profile) => profile.kind() == &ProfileKind::Standard, Some(profile) => profile.kind() == &ProfileKind::Standard,
+56 -1
View File
@@ -1,4 +1,7 @@
use std::error::Error; use std::{
error::Error,
time::{Duration, SystemTime},
};
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{CommandIntent, CommandScope, HistoryRecordingPolicy, ProfileKind, UrlText}; 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(()) Ok(())
} }
#[test]
fn clear_active_space_history_since_stays_in_space_and_profile() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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] #[test]
fn history_scoped_search_opens_recent_matching_entry() -> Result<(), Box<dyn Error>> { fn history_scoped_search_opens_recent_matching_entry() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;