feat: implement recents.rs - recent folders management

This commit is contained in:
Your Name
2026-02-19 01:50:28 +02:00
parent b95094c50f
commit 3280d60f71

222
src-tauri/src/recents.rs Normal file
View File

@@ -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<String> {
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<String> = 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<String> = 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<String> = 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());
}
}