From 3280d60f71cc347364fcaa2a65b53cf7f0168d50 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 01:50:28 +0200 Subject: [PATCH] feat: implement recents.rs - recent folders management --- src-tauri/src/recents.rs | 222 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src-tauri/src/recents.rs diff --git a/src-tauri/src/recents.rs b/src-tauri/src/recents.rs new file mode 100644 index 0000000..689700e --- /dev/null +++ b/src-tauri/src/recents.rs @@ -0,0 +1,222 @@ +use serde_json::json; +use std::path::Path; +use std::time::SystemTime; + +use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; +use crate::utils::deduplicate_list; + +/// Maximum number of recent folders to keep. +pub const RECENTS_MAX: usize = 50; + +/// Name of the recents file within the state directory. +const RECENTS_FILENAME: &str = "recent_folders.json"; + +/// Load the list of recently opened folders. +/// +/// Reads from `{state_dir}/recent_folders.json`. Returns an empty vec if the +/// file is missing, corrupt, or has an unexpected shape. +pub fn load_recents(state_dir: &Path) -> Vec { + let path = state_dir.join(RECENTS_FILENAME); + let data = match load_json_with_fallbacks(&path, BACKUP_COUNT) { + Some(v) => v, + None => return Vec::new(), + }; + + // Top-level must be an object + let obj = match data.as_object() { + Some(o) => o, + None => return Vec::new(), + }; + + // "items" must be an array + let items_val = match obj.get("items") { + Some(v) => v, + None => return Vec::new(), + }; + let arr = match items_val.as_array() { + Some(a) => a, + None => return Vec::new(), + }; + + // Collect only string elements + let strings: Vec = arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let deduped = deduplicate_list(&strings); + deduped.into_iter().take(RECENTS_MAX).collect() +} + +/// Save the list of recently opened folders. +/// +/// Deduplicates and truncates to `RECENTS_MAX`, then writes JSON with version +/// and timestamp metadata. +pub fn save_recents(state_dir: &Path, paths: &[String]) { + let cleaned: Vec = deduplicate_list(paths) + .into_iter() + .take(RECENTS_MAX) + .collect(); + + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let data = json!({ + "version": 2, + "updated_at": timestamp, + "items": cleaned, + }); + + let path = state_dir.join(RECENTS_FILENAME); + atomic_write_json(&path, &data, BACKUP_COUNT); +} + +/// Add a folder to the top of the recent folders list. +/// +/// If the path already exists in the list it is moved to the front rather than +/// duplicated. +pub fn push_recent(state_dir: &Path, path_str: &str) { + let mut paths = load_recents(state_dir); + let trimmed = path_str.trim().to_string(); + paths.retain(|p| p != &trimmed); + paths.insert(0, trimmed); + save_recents(state_dir, &paths); +} + +/// Remove a specific path from the recent folders list and save. +pub fn remove_recent(state_dir: &Path, path_str: &str) { + let mut paths = load_recents(state_dir); + let trimmed = path_str.trim().to_string(); + paths.retain(|p| p != &trimmed); + save_recents(state_dir, &paths); +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_load_empty_returns_empty_vec() { + let dir = TempDir::new().unwrap(); + let result = load_recents(dir.path()); + assert!(result.is_empty()); + } + + #[test] + fn test_push_adds_to_front() { + let dir = TempDir::new().unwrap(); + push_recent(dir.path(), "/folder/a"); + push_recent(dir.path(), "/folder/b"); + push_recent(dir.path(), "/folder/c"); + + let recents = load_recents(dir.path()); + assert_eq!(recents[0], "/folder/c"); + assert_eq!(recents[1], "/folder/b"); + assert_eq!(recents[2], "/folder/a"); + } + + #[test] + fn test_push_deduplicates() { + let dir = TempDir::new().unwrap(); + push_recent(dir.path(), "/folder/a"); + push_recent(dir.path(), "/folder/b"); + push_recent(dir.path(), "/folder/a"); // duplicate — should move to front + + let recents = load_recents(dir.path()); + assert_eq!(recents.len(), 2); + assert_eq!(recents[0], "/folder/a"); + assert_eq!(recents[1], "/folder/b"); + } + + #[test] + fn test_remove_works() { + let dir = TempDir::new().unwrap(); + push_recent(dir.path(), "/folder/a"); + push_recent(dir.path(), "/folder/b"); + push_recent(dir.path(), "/folder/c"); + + remove_recent(dir.path(), "/folder/b"); + + let recents = load_recents(dir.path()); + assert_eq!(recents.len(), 2); + assert_eq!(recents[0], "/folder/c"); + assert_eq!(recents[1], "/folder/a"); + } + + #[test] + fn test_remove_nonexistent_is_noop() { + let dir = TempDir::new().unwrap(); + push_recent(dir.path(), "/folder/a"); + remove_recent(dir.path(), "/folder/zzz"); + + let recents = load_recents(dir.path()); + assert_eq!(recents.len(), 1); + assert_eq!(recents[0], "/folder/a"); + } + + #[test] + fn test_max_50_limit() { + let dir = TempDir::new().unwrap(); + + // Push 55 items + for i in 0..55 { + push_recent(dir.path(), &format!("/folder/{}", i)); + } + + let recents = load_recents(dir.path()); + assert_eq!(recents.len(), RECENTS_MAX); + // Most recent should be at front + assert_eq!(recents[0], "/folder/54"); + } + + #[test] + fn test_save_and_load_round_trip() { + let dir = TempDir::new().unwrap(); + let paths: Vec = vec![ + "/home/user/videos".to_string(), + "/home/user/tutorials".to_string(), + ]; + save_recents(dir.path(), &paths); + + let loaded = load_recents(dir.path()); + assert_eq!(loaded, paths); + } + + #[test] + fn test_load_non_object_returns_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(RECENTS_FILENAME); + // Write a JSON array instead of an object + std::fs::write(&path, "[1, 2, 3]").unwrap(); + + let result = load_recents(dir.path()); + assert!(result.is_empty()); + } + + #[test] + fn test_load_missing_items_key_returns_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(RECENTS_FILENAME); + std::fs::write(&path, r#"{"version": 2}"#).unwrap(); + + let result = load_recents(dir.path()); + assert!(result.is_empty()); + } + + #[test] + fn test_load_items_not_array_returns_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(RECENTS_FILENAME); + std::fs::write(&path, r#"{"items": "not an array"}"#).unwrap(); + + let result = load_recents(dir.path()); + assert!(result.is_empty()); + } +}