feat: implement recents.rs — recent folders management
This commit is contained in:
222
src-tauri/src/recents.rs
Normal file
222
src-tauri/src/recents.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user