1use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14pub const CAP: usize = 6;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Recent {
20 pub label: String,
21 pub locator: String,
23}
24
25pub struct Recents {
28 path: PathBuf,
29 entries: Vec<Recent>,
30}
31
32impl Recents {
33 pub fn load(path: impl Into<PathBuf>) -> Self {
35 let path = path.into();
36 let entries = std::fs::read_to_string(&path)
37 .ok()
38 .and_then(|text| serde_json::from_str(&text).ok())
39 .unwrap_or_default();
40 Self { path, entries }
41 }
42
43 pub fn default_path() -> PathBuf {
45 std::env::var_os("XDG_CONFIG_HOME")
46 .map(PathBuf::from)
47 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
48 .unwrap_or_else(|| PathBuf::from("."))
49 .join("infinite")
50 .join("recents.json")
51 }
52
53 pub fn entries(&self) -> &[Recent] {
55 &self.entries
56 }
57
58 pub fn visit(&mut self, label: &str, locator: &str) -> bool {
62 let locator = locator
63 .split(['#', '@'])
64 .next()
65 .unwrap_or(locator)
66 .trim_end_matches('/');
67 if locator.is_empty() || crate::home::is_home(locator) {
68 return false;
69 }
70 let label = if label.trim().is_empty() {
71 locator
72 } else {
73 label.trim()
74 };
75 if self
76 .entries
77 .first()
78 .is_some_and(|r| r.locator == locator && r.label == label)
79 {
80 return false; }
82 self.entries.retain(|r| r.locator != locator);
83 self.entries.insert(
84 0,
85 Recent {
86 label: label.to_string(),
87 locator: locator.to_string(),
88 },
89 );
90 self.entries.truncate(CAP);
91 self.save();
92 true
93 }
94
95 fn save(&self) {
96 if let Some(parent) = self.path.parent() {
97 let _ = std::fs::create_dir_all(parent);
98 }
99 match serde_json::to_string_pretty(&self.entries) {
100 Ok(json) => {
101 if let Err(e) = std::fs::write(&self.path, json) {
102 tracing::warn!("recents: cannot write {}: {e}", self.path.display());
103 }
104 }
105 Err(e) => tracing::warn!("recents: serialize failed: {e}"),
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn temp_store(tag: &str) -> PathBuf {
115 let p =
116 std::env::temp_dir().join(format!("loom-recents-{}-{tag}.json", std::process::id()));
117 let _ = std::fs::remove_file(&p);
118 p
119 }
120
121 #[test]
122 fn visits_dedupe_reorder_cap_and_persist() {
123 let path = temp_store("basic");
124 let mut r = Recents::load(&path);
125 assert!(r.visit("The Nexus", "thread://pixygon.io#entry"));
126 assert!(r.visit("The Commons", "thread://commons.pixygon.io"));
127 assert!(r.visit("The Nexus", "thread://pixygon.io"));
129 assert_eq!(r.entries().len(), 2);
130 assert_eq!(r.entries()[0].locator, "thread://pixygon.io");
131 assert!(!r.visit("The Nexus", "thread://pixygon.io"));
133 assert!(!r.visit("Home", crate::home::HOME_LOCATOR));
135 for i in 0..10 {
137 r.visit(&format!("W{i}"), &format!("thread://w{i}.example"));
138 }
139 assert_eq!(r.entries().len(), CAP);
140 assert_eq!(r.entries()[0].label, "W9");
141 let reloaded = Recents::load(&path);
143 assert_eq!(reloaded.entries(), r.entries());
144 let _ = std::fs::remove_file(&path);
145 }
146}