From 7f479ff9e1de643335cf1657c7546c1c6548ae11 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 02:27:29 -0400 Subject: [PATCH] Add idle tab auto archive policy --- crates/ely_browser_core/src/navigation.rs | 5 ++ crates/ely_browser_core/src/state.rs | 37 ++++++++- crates/ely_browser_core/src/state/commands.rs | 21 +++-- crates/ely_browser_core/src/state/tabs.rs | 82 ++++++++++++++++++- .../ely_browser_core/tests/archive_policy.rs | 69 ++++++++++++++++ crates/ely_domain/src/space.rs | 4 + crates/ely_domain/src/tab.rs | 21 +++++ 7 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 crates/ely_browser_core/tests/archive_policy.rs diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index dc7f89f..848679b 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -56,6 +56,11 @@ pub(crate) fn move_tab_space_name(command: &str) -> Option<&str> { ) } +pub(crate) fn archive_idle_days(command: &str) -> Option { + command_argument(command, &["archive-idle-tabs ", "archive idle tabs "]) + .and_then(|value| value.parse().ok()) +} + pub(crate) fn new_profile_name(command: &str) -> Option<&str> { command_argument(command, &["new-profile ", "new profile "]) } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index ee80989..41188b8 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,9 +1,10 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ - ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, - HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SitePermissionAuditEvent, - SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, + ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, + DownloadPolicy, HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, + SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, + UrlText, }; use crate::CoreError; @@ -194,6 +195,28 @@ impl BrowserCore { Ok(tab_id) } + pub fn set_active_space_archive_policy( + &mut self, + archive_policy: ArchivePolicy, + ) -> Result<(), CoreError> { + let active_space_id = self.active_space_id.clone(); + self.set_space_archive_policy(&active_space_id, archive_policy) + } + + pub fn set_space_archive_policy( + &mut self, + space_id: &SpaceId, + archive_policy: ArchivePolicy, + ) -> Result<(), CoreError> { + let space = self + .spaces + .iter_mut() + .find(|space| space.id() == space_id) + .ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() })?; + space.set_archive_policy(archive_policy); + Ok(()) + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -264,4 +287,10 @@ impl BrowserCore { fn visible_tabs(&self) -> Vec { self.tabs.iter().filter(|tab| tab.space_id() == &self.active_space_id).cloned().collect() } + + fn record_tab_activity(&mut self, tab_id: &TabId, active_at: SystemTime) { + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id() == tab_id) { + tab.record_activity(active_at); + } + } } diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 2a00f76..245e3f1 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -1,12 +1,14 @@ -use ely_domain::{CommandIntent, CommandScope, ProfileId, ProfileKind, SpaceId}; +use std::time::SystemTime; + +use ely_domain::{ArchivePolicy, CommandIntent, CommandScope, ProfileId, ProfileKind, SpaceId}; use crate::{ CoreError, navigation::{ - about_url, archive_url, bookmarks_url, downloads_url, history_url, move_tab_space_name, - new_private_profile_name, new_profile_name, new_space_name, plugin_detail_url, plugins_url, - reading_list_url, search_url, settings_page_url, settings_url, space_icon, - switch_profile_name, sync_status_url, task_manager_url, + about_url, archive_idle_days, archive_url, bookmarks_url, downloads_url, history_url, + move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, + plugin_detail_url, plugins_url, reading_list_url, search_url, settings_page_url, + settings_url, space_icon, switch_profile_name, sync_status_url, task_manager_url, }, }; @@ -115,6 +117,11 @@ impl BrowserCore { self.select_profile(&profile_id)?; return Ok(true); } + if let Some(idle_days) = archive_idle_days(command) { + self.set_active_space_archive_policy(ArchivePolicy::IdleDays(idle_days))?; + self.archive_idle_tabs(SystemTime::now())?; + return Ok(true); + } match command.to_ascii_lowercase().as_str() { "new-tab" => { @@ -185,6 +192,10 @@ impl BrowserCore { "close-split-view" | "close split view" => { Ok(self.close_active_saved_split_view()?.is_some()) } + "archive-idle-tabs" | "archive idle tabs" => { + self.archive_idle_tabs(SystemTime::now())?; + Ok(true) + } "favorite" | "toggle-favorite" => { self.toggle_active_tab_favorite()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index f29d9da..f8c8149 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -1,4 +1,8 @@ -use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab, ProfileId, SpaceId, TabId, UrlText}; +use std::time::{Duration, SystemTime}; + +use ely_domain::{ + ArchivePolicy, ArchiveSource, ArchivedTab, BrowserTab, ProfileId, SpaceId, TabId, UrlText, +}; use crate::{ CoreError, @@ -168,6 +172,23 @@ impl BrowserCore { self.restore_archived_tab_at_index(index).map(Some) } + pub fn archive_idle_tabs(&mut self, now: SystemTime) -> Result { + let ArchivePolicy::IdleDays(idle_days) = self.active_space()?.archive_policy() else { + return Ok(0); + }; + + let active_space_id = self.active_space_id.clone(); + let idle_after = Duration::from_secs(u64::from(*idle_days) * 86_400); + let tab_ids = self.idle_archive_candidates(&active_space_id, now, idle_after); + let archived_count = tab_ids.len(); + + for tab_id in tab_ids { + self.archive_idle_tab(&tab_id)?; + } + + Ok(archived_count) + } + pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { let tab = self .tabs @@ -184,6 +205,7 @@ impl BrowserCore { self.active_tabs_by_space_profile .insert((active_space_id.clone(), active_profile_id), active_tab_id.clone()); self.active_tabs_by_space.insert(active_space_id, active_tab_id); + self.record_tab_activity(tab_id, SystemTime::now()); Ok(()) } @@ -348,4 +370,62 @@ impl BrowserCore { }, ) } + + fn idle_archive_candidates( + &self, + space_id: &SpaceId, + now: SystemTime, + idle_after: Duration, + ) -> Vec { + self.tabs + .iter() + .filter(|tab| tab.space_id() == space_id) + .filter(|tab| tab.id() != &self.active_tab_id) + .filter(|tab| !tab.flags().favorite && !tab.flags().pinned) + .filter(|tab| tab.split_id().is_none()) + .filter(|tab| tab_is_idle(tab, now, idle_after)) + .map(|tab| tab.id().clone()) + .collect() + } + + fn archive_idle_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { + let tab_index = self + .tabs + .iter() + .position(|tab| tab.id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + let mut tab = self.tabs.remove(tab_index); + let space_id = tab.space_id().clone(); + let profile_id = tab.profile_id().clone(); + + tab.clear_split_id(); + self.archived_tabs.push(ArchivedTab::new(tab, ArchiveSource::AutoArchive)); + self.refresh_space_profile_active_tab(&space_id, &profile_id); + Ok(()) + } + + fn refresh_space_profile_active_tab(&mut self, space_id: &SpaceId, profile_id: &ProfileId) { + let key = (space_id.clone(), profile_id.clone()); + if self.active_tabs_by_space_profile.get(&key).is_some_and(|tab_id| { + self.tabs.iter().any(|tab| { + tab.id() == tab_id && tab.space_id() == space_id && tab.profile_id() == profile_id + }) + }) { + return; + } + + self.active_tabs_by_space_profile.remove(&key); + if let Some(tab_id) = self + .tabs + .iter() + .find(|tab| tab.space_id() == space_id && tab.profile_id() == profile_id) + .map(|tab| tab.id().clone()) + { + self.active_tabs_by_space_profile.insert(key, tab_id); + } + } +} + +fn tab_is_idle(tab: &BrowserTab, now: SystemTime, idle_after: Duration) -> bool { + now.duration_since(tab.last_active_at()).is_ok_and(|idle_time| idle_time >= idle_after) } diff --git a/crates/ely_browser_core/tests/archive_policy.rs b/crates/ely_browser_core/tests/archive_policy.rs new file mode 100644 index 0000000..6ce328a --- /dev/null +++ b/crates/ely_browser_core/tests/archive_policy.rs @@ -0,0 +1,69 @@ +use std::{ + error::Error, + time::{Duration, SystemTime}, +}; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ArchivePolicy, ArchiveSource, CommandIntent, UrlText}; + +#[test] +fn archive_idle_tabs_uses_active_space_policy() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + let idle_tab_id = core.open_tab(UrlText::parse("https://example.com/idle")?); + let pinned_tab_id = core.open_tab(UrlText::parse("https://example.com/pinned")?); + core.toggle_active_tab_pinned()?; + core.select_tab(&active_tab_id)?; + core.set_active_space_archive_policy(ArchivePolicy::IdleDays(1))?; + + let archived_count = core.archive_idle_tabs(days_from_now(2))?; + let snapshot = core.snapshot()?; + + assert_eq!(archived_count, 1); + assert!(snapshot.tabs.iter().any(|tab| tab.id() == &active_tab_id)); + assert!(snapshot.tabs.iter().any(|tab| tab.id() == &pinned_tab_id)); + assert_eq!(snapshot.archived_tabs.len(), 1); + assert_eq!(snapshot.archived_tabs[0].tab().id(), &idle_tab_id); + assert_eq!(snapshot.archived_tabs[0].source(), &ArchiveSource::AutoArchive); + Ok(()) +} + +#[test] +fn archive_idle_tabs_manual_policy_leaves_tabs_open() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + let idle_tab_id = core.open_tab(UrlText::parse("https://example.com/idle")?); + core.select_tab(&active_tab_id)?; + + let archived_count = core.archive_idle_tabs(days_from_now(30))?; + let snapshot = core.snapshot()?; + + assert_eq!(archived_count, 0); + assert!(snapshot.tabs.iter().any(|tab| tab.id() == &idle_tab_id)); + assert!(snapshot.archived_tabs.is_empty()); + Ok(()) +} + +#[test] +fn archive_idle_tabs_command_sets_days_policy() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + let archived_tab_id = core.open_tab(UrlText::parse("https://example.com/command")?); + core.select_tab(&active_tab_id)?; + + core.set_command_query(">archive-idle-tabs 0"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("archive-idle-tabs 0".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.archived_tabs.len(), 1); + assert_eq!(snapshot.archived_tabs[0].tab().id(), &archived_tab_id); + assert_eq!(snapshot.archived_tabs[0].source(), &ArchiveSource::AutoArchive); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +fn days_from_now(days: u64) -> SystemTime { + SystemTime::now() + Duration::from_secs(days * 86_400) +} diff --git a/crates/ely_domain/src/space.rs b/crates/ely_domain/src/space.rs index 403f7d8..810c8f5 100644 --- a/crates/ely_domain/src/space.rs +++ b/crates/ely_domain/src/space.rs @@ -51,4 +51,8 @@ impl Space { pub fn archive_policy(&self) -> &ArchivePolicy { &self.archive_policy } + + pub fn set_archive_policy(&mut self, archive_policy: ArchivePolicy) { + self.archive_policy = archive_policy; + } } diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 4818de9..04223b8 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -1,3 +1,5 @@ +use std::time::SystemTime; + use crate::{ProfileId, SpaceId, SplitId, TabId, UrlText}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -27,6 +29,8 @@ pub struct BrowserTab { state: TabState, flags: TabFlags, split_id: Option, + created_at: SystemTime, + last_active_at: SystemTime, } impl BrowserTab { @@ -38,6 +42,7 @@ impl BrowserTab { title: impl Into, url: UrlText, ) -> Self { + let created_at = SystemTime::now(); Self { id, space_id, @@ -47,6 +52,8 @@ impl BrowserTab { state: TabState::Ready, flags: TabFlags::default(), split_id: None, + created_at, + last_active_at: created_at, } } @@ -85,6 +92,20 @@ impl BrowserTab { &self.state } + #[must_use] + pub fn created_at(&self) -> SystemTime { + self.created_at + } + + #[must_use] + pub fn last_active_at(&self) -> SystemTime { + self.last_active_at + } + + pub fn record_activity(&mut self, active_at: SystemTime) { + self.last_active_at = active_at; + } + pub fn mark_archived(&mut self) { self.state = TabState::Archived; }