diff --git a/crates/ely_app/src/shell/internal_pages/spaces.rs b/crates/ely_app/src/shell/internal_pages/spaces.rs index 18f20e2..4861dfc 100644 --- a/crates/ely_app/src/shell/internal_pages/spaces.rs +++ b/crates/ely_app/src/shell/internal_pages/spaces.rs @@ -1,4 +1,4 @@ -use ely_browser_core::BrowserSnapshot; +use ely_browser_core::{BrowserSnapshot, TrashedSpace}; use ely_design_system::colors; use ely_domain::{ArchivePolicy, Profile, Space, SpaceId}; use gpui::{ @@ -28,7 +28,8 @@ impl ElyShell { .gap_5() .child(render_spaces_header(snapshot)) .child(render_active_space_summary(snapshot)) - .child(render_spaces_list(snapshot, cx)), + .child(render_spaces_list(snapshot, self.pending_space_trash.as_ref(), cx)) + .child(render_trashed_spaces_list(snapshot, cx)), ) } } @@ -122,7 +123,11 @@ fn render_active_space_summary(snapshot: &BrowserSnapshot) -> AnyElement { .into_any_element() } -fn render_spaces_list(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { +fn render_spaces_list( + snapshot: &BrowserSnapshot, + pending_space_trash: Option<&SpaceId>, + cx: &mut Context, +) -> AnyElement { div() .flex_1() .min_h_0() @@ -138,6 +143,7 @@ fn render_spaces_list(snapshot: &BrowserSnapshot, cx: &mut Context) -> space, snapshot, space.id() == &snapshot.active_space_id, + pending_space_trash == Some(space.id()), cx, ) })) @@ -150,6 +156,7 @@ fn render_space_row( space: &Space, snapshot: &BrowserSnapshot, active: bool, + confirming_trash: bool, cx: &mut Context, ) -> AnyElement { let space_id = space.id().clone(); @@ -187,7 +194,7 @@ fn render_space_row( ), ), ) - .child(render_space_actions(index, space_count, space_id, active, cx)) + .child(render_space_actions(index, space_count, space_id, active, confirming_trash, cx)) .into_any_element() } @@ -196,8 +203,13 @@ fn render_space_actions( space_count: usize, space_id: SpaceId, active: bool, + confirming_trash: bool, cx: &mut Context, ) -> AnyElement { + if confirming_trash { + return render_trash_confirmation(cx); + } + let can_move_up = index > 0; let can_move_down = index + 1 < space_count; @@ -223,7 +235,8 @@ fn render_space_actions( false, cx, )) - .child(render_space_switch_action(index, space_id, active, cx)) + .child(render_space_switch_action(index, space_id.clone(), active, cx)) + .child(render_request_trash_button(index, space_id, space_count, cx)) .into_any_element() } @@ -252,6 +265,48 @@ fn render_space_order_button( .into_any_element() } +fn render_request_trash_button( + index: usize, + space_id: SpaceId, + space_count: usize, + cx: &mut Context, +) -> AnyElement { + Button::new(("trash-space", index)) + .small() + .ghost() + .icon(IconName::Delete) + .tooltip("Move Space to Trash") + .disabled(space_count <= 1) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.request_space_trash(space_id.clone(), cx); + })) + .into_any_element() +} + +fn render_trash_confirmation(cx: &mut Context) -> AnyElement { + div() + .flex() + .items_center() + .gap_2() + .child(Button::new("cancel-space-trash").small().ghost().label("Cancel").on_click( + cx.listener(|shell, _, _, cx| { + shell.cancel_space_trash(cx); + }), + )) + .child( + Button::new("confirm-space-trash") + .small() + .danger() + .icon(IconName::Delete) + .label("Trash") + .tooltip("Move Space to Trash") + .on_click(cx.listener(|shell, _, window, cx| { + shell.trash_pending_space(window, cx); + })), + ) + .into_any_element() +} + fn render_space_switch_action( index: usize, space_id: SpaceId, @@ -279,6 +334,94 @@ fn render_space_switch_action( .into_any_element() } +fn render_trashed_spaces_list( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + if snapshot.trashed_spaces.is_empty() { + return div().into_any_element(); + } + + div() + .flex() + .flex_col() + .gap_2() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_3() + .child( + div() + .text_xs() + .font_semibold() + .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)), + ) + .into_any_element() +} + +fn render_trashed_space_row( + index: usize, + trashed_space: &TrashedSpace, + cx: &mut Context, +) -> AnyElement { + let space_id = trashed_space.space().id().clone(); + div() + .py_2() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(space_avatar(trashed_space.space())) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(trashed_space.space().name().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(trashed_space_detail_label(trashed_space)), + ), + ), + ) + .child( + Button::new(("restore-space", index)) + .small() + .primary() + .icon(IconName::Undo) + .label("Restore") + .tooltip("Restore Space") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.restore_trashed_space(&space_id, window, cx); + })), + ) + .into_any_element() +} + fn space_avatar(space: &Space) -> AnyElement { div() .w(px(28.0)) @@ -297,6 +440,14 @@ fn space_avatar(space: &Space) -> AnyElement { .into_any_element() } +fn trashed_space_detail_label(trashed_space: &TrashedSpace) -> String { + format!( + "{} open tabs - {} archived tabs - retained 30 days", + trashed_space.tabs().len(), + trashed_space.archived_tabs().len() + ) +} + fn space_detail_label(space: &Space, profiles: &[Profile]) -> String { format!( "{} - {} - {}", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index f807c2d..eef5b3c 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -49,6 +49,7 @@ pub struct ElyShell { pending_history_domain_clear: Option, pending_history_time_clear: Option, site_permissions_clear_confirmation: Option, + pending_space_trash: Option, pending_bookmark_edit: Option, bookmark_edit_error: Option, plugin_install_error: Option, @@ -116,6 +117,7 @@ impl ElyShell { pending_history_domain_clear: None, pending_history_time_clear: None, site_permissions_clear_confirmation: None, + pending_space_trash: None, pending_bookmark_edit: None, bookmark_edit_error: None, plugin_install_error: None, diff --git a/crates/ely_app/src/shell/spaces.rs b/crates/ely_app/src/shell/spaces.rs index 5b57250..21bc3f5 100644 --- a/crates/ely_app/src/shell/spaces.rs +++ b/crates/ely_app/src/shell/spaces.rs @@ -21,6 +21,44 @@ impl ElyShell { } } + pub(super) fn request_space_trash(&mut self, space_id: SpaceId, cx: &mut Context) { + self.pending_space_trash = Some(space_id); + cx.notify(); + } + + pub(super) fn cancel_space_trash(&mut self, cx: &mut Context) { + self.pending_space_trash = None; + cx.notify(); + } + + pub(super) fn trash_pending_space(&mut self, window: &mut Window, cx: &mut Context) { + let Some(space_id) = self.pending_space_trash.clone() else { + return; + }; + + if let ShellState::Ready(core) = &mut self.state + && core.trash_space(&space_id, std::time::SystemTime::now()).is_ok() + { + self.pending_space_trash = None; + self.sync_address_input(window, cx); + cx.notify(); + } + } + + pub(super) fn restore_trashed_space( + &mut self, + space_id: &SpaceId, + window: &mut Window, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.restore_trashed_space(space_id).is_ok() + { + self.sync_address_input(window, cx); + cx.notify(); + } + } + pub(super) fn on_select_next_space( &mut self, _: &SelectNextSpace, diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index ead4a19..f1fa5e2 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -18,6 +18,12 @@ pub enum CoreError { #[error("space not found: {id}")] SpaceNotFound { id: SpaceId }, + #[error("trashed space not found: {id}")] + TrashedSpaceNotFound { id: SpaceId }, + + #[error("last space cannot be moved to trash")] + LastSpaceCannotBeTrashed, + #[error("split not found: {id}")] SplitNotFound { id: SplitId }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index 74e3a6b..a8fbdcd 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -5,5 +5,5 @@ mod state; pub use error::CoreError; pub use state::{ BrowserCore, BrowserSnapshot, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, - PluginAuditEvent, + PluginAuditEvent, TrashedSpace, }; diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 916e8c4..75e3a4b 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -28,6 +28,7 @@ mod tab_order; mod tabs; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; +pub use spaces::TrashedSpace; #[derive(Clone, Debug)] pub struct InitialBrowserConfig { @@ -67,6 +68,7 @@ pub struct BrowserSnapshot { pub installed_plugins: Vec, pub plugin_audit_events: Vec, pub spaces: Vec, + pub trashed_spaces: Vec, pub profiles: Vec, pub sync_status: SyncStatus, pub active_tab_id: TabId, @@ -98,6 +100,7 @@ pub struct BrowserCore { tab_groups: Vec, split_layouts: Vec, archived_split_layouts: Vec, + trashed_spaces: Vec, installed_plugins: Vec, plugin_audit_events: Vec, active_space_id: SpaceId, @@ -168,6 +171,7 @@ impl BrowserCore { tab_groups: Vec::new(), split_layouts: Vec::new(), archived_split_layouts: Vec::new(), + trashed_spaces: Vec::new(), installed_plugins: Vec::new(), plugin_audit_events: Vec::new(), command_query: String::new(), @@ -390,6 +394,7 @@ impl BrowserCore { installed_plugins: self.installed_plugins.clone(), plugin_audit_events: self.plugin_audit_events.clone(), spaces: self.sorted_spaces(), + trashed_spaces: self.trashed_spaces.clone(), profiles: self.profiles.clone(), sync_status: self.sync_status(), tabs: self.visible_tabs(), diff --git a/crates/ely_browser_core/src/state/spaces.rs b/crates/ely_browser_core/src/state/spaces.rs index 4845e88..ca7bd11 100644 --- a/crates/ely_browser_core/src/state/spaces.rs +++ b/crates/ely_browser_core/src/state/spaces.rs @@ -1,9 +1,108 @@ -use ely_domain::SpaceId; +use std::time::{Duration, SystemTime}; + +use ely_domain::{ArchivedTab, BrowserTab, Space, SpaceId}; use super::BrowserCore; use crate::CoreError; +pub const SPACE_TRASH_RETENTION_DAYS: u64 = 30; + +#[derive(Clone, Debug)] +pub struct TrashedSpace { + space: Space, + tabs: Vec, + archived_tabs: Vec, + trashed_at: SystemTime, + purge_at: SystemTime, +} + +impl TrashedSpace { + fn new( + space: Space, + tabs: Vec, + archived_tabs: Vec, + trashed_at: SystemTime, + ) -> Self { + let purge_at = trashed_at + Duration::from_secs(SPACE_TRASH_RETENTION_DAYS * 86_400); + Self { space, tabs, archived_tabs, trashed_at, purge_at } + } + + #[must_use] + pub fn space(&self) -> &Space { + &self.space + } + + #[must_use] + pub fn tabs(&self) -> &[BrowserTab] { + &self.tabs + } + + #[must_use] + pub fn archived_tabs(&self) -> &[ArchivedTab] { + &self.archived_tabs + } + + #[must_use] + pub fn trashed_at(&self) -> SystemTime { + self.trashed_at + } + + #[must_use] + pub fn purge_at(&self) -> SystemTime { + self.purge_at + } +} + impl BrowserCore { + pub fn trash_space( + &mut self, + space_id: &SpaceId, + trashed_at: SystemTime, + ) -> Result { + if self.spaces.len() <= 1 { + return Err(CoreError::LastSpaceCannotBeTrashed); + } + + let Some(space_index) = self.spaces.iter().position(|space| space.id() == space_id) else { + return Err(CoreError::SpaceNotFound { id: space_id.clone() }); + }; + + let trashed_space = self.spaces.remove(space_index); + let tabs = self.remove_space_tabs(space_id); + let archived_tabs = self.remove_space_archived_tabs(space_id); + self.active_tabs_by_space.remove(space_id); + self.active_tabs_by_space_profile + .retain(|(mapped_space_id, _), _| mapped_space_id != space_id); + self.trashed_spaces.push(TrashedSpace::new(trashed_space, tabs, archived_tabs, trashed_at)); + + if &self.active_space_id == space_id { + let next_space_id = self + .sorted_spaces() + .first() + .map(|space| space.id().clone()) + .ok_or(CoreError::MissingActiveTab)?; + self.select_space(&next_space_id)?; + } + + Ok(true) + } + + pub fn restore_trashed_space(&mut self, space_id: &SpaceId) -> Result { + let Some(trash_index) = + self.trashed_spaces.iter().position(|entry| entry.space().id() == space_id) + else { + return Err(CoreError::TrashedSpaceNotFound { id: space_id.clone() }); + }; + + let trashed_space = self.trashed_spaces.remove(trash_index); + let restored_space_id = trashed_space.space().id().clone(); + self.spaces.push(trashed_space.space); + self.tabs.extend(trashed_space.tabs); + self.archived_tabs.extend(trashed_space.archived_tabs); + self.select_space(&restored_space_id)?; + Ok(true) + } + 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 { @@ -52,4 +151,30 @@ impl BrowserCore { Ok(()) } + + fn remove_space_tabs(&mut self, space_id: &SpaceId) -> Vec { + let mut removed_tabs = Vec::new(); + self.tabs.retain(|tab| { + if tab.space_id() == space_id { + removed_tabs.push(tab.clone()); + false + } else { + true + } + }); + removed_tabs + } + + fn remove_space_archived_tabs(&mut self, space_id: &SpaceId) -> Vec { + let mut removed_tabs = Vec::new(); + self.archived_tabs.retain(|archived| { + if archived.tab().space_id() == space_id { + removed_tabs.push(archived.clone()); + false + } else { + true + } + }); + removed_tabs + } } diff --git a/crates/ely_browser_core/tests/spaces.rs b/crates/ely_browser_core/tests/spaces.rs index 76df790..81dbdc3 100644 --- a/crates/ely_browser_core/tests/spaces.rs +++ b/crates/ely_browser_core/tests/spaces.rs @@ -1,7 +1,12 @@ -use std::error::Error; +use std::{ + error::Error, + time::{Duration, SystemTime}, +}; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; -use ely_domain::{ArchivePolicy, DEFAULT_SIDEBAR_WIDTH_PX, ProfileId, ProfileKind, SpaceId}; +use ely_domain::{ + ArchivePolicy, DEFAULT_SIDEBAR_WIDTH_PX, ProfileId, ProfileKind, SpaceId, UrlText, +}; #[test] fn created_space_binds_current_profile_as_default() -> Result<(), Box> { @@ -130,6 +135,80 @@ fn moving_boundary_or_missing_space_is_safe() -> Result<(), Box> { Ok(()) } +#[test] +fn trashing_space_removes_it_and_restores_with_tabs() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let research_space_id = core.create_space("Research", "R", 0x9fc9a2)?; + let research_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + core.select_space(&work_space_id)?; + assert!(core.trash_space(&research_space_id, SystemTime::UNIX_EPOCH)?); + let snapshot = core.snapshot()?; + assert_eq!(snapshot.active_space_id, work_space_id); + assert!(snapshot.spaces.iter().all(|space| space.id() != &research_space_id)); + assert_eq!(snapshot.trashed_spaces[0].space().id(), &research_space_id); + assert!(snapshot.trashed_spaces[0].tabs().iter().any(|tab| tab.id() == &research_tab_id)); + + assert!(core.restore_trashed_space(&research_space_id)?); + let snapshot = core.snapshot()?; + assert_eq!(snapshot.active_space_id, research_space_id); + assert!(snapshot.tabs.iter().any(|tab| tab.id() == &research_tab_id)); + assert!(snapshot.trashed_spaces.is_empty()); + Ok(()) +} + +#[test] +fn trashing_active_space_selects_remaining_space() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let research_space_id = core.create_space("Research", "R", 0x9fc9a2)?; + + assert!(core.trash_space(&research_space_id, SystemTime::UNIX_EPOCH)?); + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.active_space_id, work_space_id); + assert_eq!(snapshot.active_space_name, "Work"); + Ok(()) +} + +#[test] +fn trashed_space_keeps_thirty_day_retention() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let research_space_id = core.create_space("Research", "R", 0x9fc9a2)?; + let trashed_at = SystemTime::UNIX_EPOCH + Duration::from_secs(900); + + core.trash_space(&research_space_id, trashed_at)?; + let snapshot = core.snapshot()?; + let trashed_space = &snapshot.trashed_spaces[0]; + + assert_eq!(trashed_space.trashed_at(), trashed_at); + assert_eq!(trashed_space.purge_at(), trashed_at + Duration::from_secs(30 * 86_400)); + Ok(()) +} + +#[test] +fn trashing_last_or_missing_space_is_rejected() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let error = match core.trash_space(&work_space_id, SystemTime::UNIX_EPOCH) { + Err(error) => error, + Ok(_) => return Err("trashing the last space should fail".into()), + }; + + assert_eq!(error, CoreError::LastSpaceCannotBeTrashed); + + let missing_space_id = SpaceId::new(); + core.create_space("Research", "R", 0x9fc9a2)?; + let error = match core.trash_space(&missing_space_id, SystemTime::UNIX_EPOCH) { + Err(error) => error, + Ok(_) => return Err("trashing a missing space should fail".into()), + }; + + assert_eq!(error, CoreError::SpaceNotFound { id: missing_space_id }); + Ok(()) +} + #[test] fn space_default_profile_updates_with_profile_validation() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;