From a1aa38dbb2ecd4c7af81b98b6ac5be607ba6de4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 11:38:45 -0400 Subject: [PATCH] Add expired space trash purge --- .../src/shell/internal_pages/spaces.rs | 77 ++++++++++++++++--- crates/ely_app/src/shell/spaces.rs | 10 +++ crates/ely_browser_core/src/state/commands.rs | 7 ++ crates/ely_browser_core/src/state/spaces.rs | 11 +++ crates/ely_browser_core/tests/spaces.rs | 53 +++++++++++++ 5 files changed, 147 insertions(+), 11 deletions(-) diff --git a/crates/ely_app/src/shell/internal_pages/spaces.rs b/crates/ely_app/src/shell/internal_pages/spaces.rs index 8bac935..ce33917 100644 --- a/crates/ely_app/src/shell/internal_pages/spaces.rs +++ b/crates/ely_app/src/shell/internal_pages/spaces.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, SystemTime}; + use ely_browser_core::{BrowserSnapshot, SpaceImportProfileMapping, TrashedSpace}; use ely_design_system::colors; use ely_domain::{ArchivePolicy, Profile, Space, SpaceId}; @@ -271,6 +273,10 @@ fn render_trashed_spaces_list( return div().into_any_element(); } + let now = SystemTime::now(); + let expired_count = + snapshot.trashed_spaces.iter().filter(|space| space.is_expired(now)).count(); + div() .flex() .flex_col() @@ -278,6 +284,41 @@ fn render_trashed_spaces_list( .border_t_1() .border_color(rgb(colors::HAIRLINE)) .pt_3() + .child(render_trashed_spaces_header(expired_count, cx)) + .children( + snapshot.trashed_spaces.iter().enumerate().map(|(index, trashed_space)| { + render_trashed_space_row(index, trashed_space, now, cx) + }), + ) + .into_any_element() +} + +fn render_trashed_spaces_header(expired_count: usize, cx: &mut Context) -> AnyElement { + let action = if expired_count == 0 { + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED_SOFT)) + .child("30 day retention") + .into_any_element() + } else { + Button::new("purge-expired-spaces") + .small() + .danger() + .icon(IconName::Delete) + .label("Purge Expired") + .tooltip("Purge Expired Spaces") + .on_click(cx.listener(|shell, _, _, cx| { + shell.purge_expired_trashed_spaces(cx); + })) + .into_any_element() + }; + + div() + .flex() + .items_center() + .justify_between() + .gap_3() .child( div() .text_xs() @@ -285,19 +326,14 @@ fn render_trashed_spaces_list( .text_color(rgb(colors::MUTED)) .child("Recently Trashed"), ) - .children( - snapshot - .trashed_spaces - .iter() - .enumerate() - .map(|(index, trashed_space)| render_trashed_space_row(index, trashed_space, cx)), - ) + .child(action) .into_any_element() } fn render_trashed_space_row( index: usize, trashed_space: &TrashedSpace, + now: SystemTime, cx: &mut Context, ) -> AnyElement { let space_id = trashed_space.space().id().clone(); @@ -333,7 +369,7 @@ fn render_trashed_space_row( .text_xs() .truncate() .text_color(rgb(colors::MUTED)) - .child(trashed_space_detail_label(trashed_space)), + .child(trashed_space_detail_label(trashed_space, now)), ), ), ) @@ -369,14 +405,33 @@ fn space_avatar(space: &Space) -> AnyElement { .into_any_element() } -fn trashed_space_detail_label(trashed_space: &TrashedSpace) -> String { +fn trashed_space_detail_label(trashed_space: &TrashedSpace, now: SystemTime) -> String { format!( - "{} open tabs - {} archived tabs - retained 30 days", + "{} open tabs - {} archived tabs - {}", trashed_space.tabs().len(), - trashed_space.archived_tabs().len() + trashed_space.archived_tabs().len(), + trash_retention_label(trashed_space, now) ) } +fn trash_retention_label(trashed_space: &TrashedSpace, now: SystemTime) -> String { + if trashed_space.is_expired(now) { + return "eligible for permanent purge".to_string(); + } + + format!("purges in {}", time_until_purge(trashed_space.purge_at(), now)) +} + +fn time_until_purge(purge_at: SystemTime, now: SystemTime) -> String { + let remaining = purge_at.duration_since(now).unwrap_or(Duration::ZERO); + if remaining < Duration::from_secs(3_600) { + return "under 1 hour".to_string(); + } + + let days = remaining.as_secs().div_ceil(86_400); + if days == 1 { "1 day".to_string() } else { format!("{days} days") } +} + fn space_detail_label(space: &Space, profiles: &[Profile]) -> String { format!( "{} - {} - {}", diff --git a/crates/ely_app/src/shell/spaces.rs b/crates/ely_app/src/shell/spaces.rs index 55bce4d..821eda3 100644 --- a/crates/ely_app/src/shell/spaces.rs +++ b/crates/ely_app/src/shell/spaces.rs @@ -1,3 +1,5 @@ +use std::time::SystemTime; + use ely_domain::{ProfileId, SpaceId}; use gpui::{Context, Window}; @@ -71,6 +73,14 @@ impl ElyShell { } } + pub(super) fn purge_expired_trashed_spaces(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.purge_expired_trashed_spaces(SystemTime::now()) > 0 + { + cx.notify(); + } + } + pub(super) fn on_select_next_space( &mut self, _: &SelectNextSpace, diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index df8f408..09854a7 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -381,6 +381,13 @@ impl BrowserCore { self.archive_idle_tabs(SystemTime::now())?; Ok(true) } + "purge-expired-spaces" + | "purge expired spaces" + | "purge-space-trash" + | "purge space trash" => { + self.purge_expired_trashed_spaces(SystemTime::now()); + Ok(true) + } "favorite" | "toggle-favorite" => { self.toggle_active_tab_favorite()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/spaces.rs b/crates/ely_browser_core/src/state/spaces.rs index 69d0875..c1a6aa5 100644 --- a/crates/ely_browser_core/src/state/spaces.rs +++ b/crates/ely_browser_core/src/state/spaces.rs @@ -51,6 +51,11 @@ impl TrashedSpace { pub fn purge_at(&self) -> SystemTime { self.purge_at } + + #[must_use] + pub fn is_expired(&self, now: SystemTime) -> bool { + now >= self.purge_at + } } impl BrowserCore { @@ -190,6 +195,12 @@ impl BrowserCore { Ok(true) } + pub fn purge_expired_trashed_spaces(&mut self, now: SystemTime) -> usize { + let previous_count = self.trashed_spaces.len(); + self.trashed_spaces.retain(|space| !space.is_expired(now)); + previous_count - self.trashed_spaces.len() + } + pub fn move_space_up(&mut self, space_id: &SpaceId) -> Result { let mut ordered_ids = self.sorted_space_ids(); let Some(index) = ordered_ids.iter().position(|id| id == space_id) else { diff --git a/crates/ely_browser_core/tests/spaces.rs b/crates/ely_browser_core/tests/spaces.rs index 79f8cb7..e486ea8 100644 --- a/crates/ely_browser_core/tests/spaces.rs +++ b/crates/ely_browser_core/tests/spaces.rs @@ -354,6 +354,59 @@ fn selecting_adjacent_spaces_uses_sort_order_with_wraparound() -> Result<(), Box Ok(()) } +#[test] +fn purging_expired_trashed_spaces_keeps_entries_inside_retention() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(40 * 86_400); + let stale_space_id = core.create_space("Stale", "S", 0x807d72)?; + let fresh_space_id = core.create_space("Fresh", "F", 0x9fc9a2)?; + + core.trash_space(&stale_space_id, now - Duration::from_secs(31 * 86_400))?; + core.trash_space(&fresh_space_id, now - Duration::from_secs(10 * 86_400))?; + + assert_eq!(core.purge_expired_trashed_spaces(now), 1); + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.trashed_spaces.len(), 1); + assert_eq!(snapshot.trashed_spaces[0].space().id(), &fresh_space_id); + Ok(()) +} + +#[test] +fn trashed_space_expires_at_retention_boundary() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let trashed_at = SystemTime::UNIX_EPOCH + Duration::from_secs(900); + let purge_at = trashed_at + Duration::from_secs(30 * 86_400); + let research_space_id = core.create_space("Research", "R", 0x9fc9a2)?; + + core.trash_space(&research_space_id, trashed_at)?; + assert_eq!(core.purge_expired_trashed_spaces(purge_at - Duration::from_secs(1)), 0); + assert_eq!(core.snapshot()?.trashed_spaces.len(), 1); + + assert_eq!(core.purge_expired_trashed_spaces(purge_at), 1); + assert!(core.snapshot()?.trashed_spaces.is_empty()); + Ok(()) +} + +#[test] +fn purge_expired_spaces_command_clears_expired_trash() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let stale_space_id = core.create_space("Stale", "S", 0x807d72)?; + + core.trash_space(&stale_space_id, SystemTime::now() - Duration::from_secs(31 * 86_400))?; + core.set_command_query(">purge-expired-spaces"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(ely_domain::CommandIntent::Command("purge-expired-spaces".to_string())) + ); + assert!(snapshot.trashed_spaces.is_empty()); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + fn active_space_updated_at( core: &BrowserCore, space_id: &ely_domain::SpaceId,