Skip to main content

thread_engine/
recents.rs

1//! Recents — the threads a traveler last walked.
2//!
3//! The web's history bar, kept the Thread's way: a small, durable,
4//! most-recent-first list of `(label, locator)` pairs. The home-space renders
5//! it as an arc of veils ([`crate::home::sync_recents`]) so "where was I?"
6//! is answered by looking around your own home, not by opening a menu.
7//! Storage is one human-readable JSON file beside the bookmarks; a missing or
8//! corrupt file degrades to an empty list, never an error.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14/// How many recent places the list keeps (and the home renders).
15pub const CAP: usize = 6;
16
17/// One recently visited place.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Recent {
20    pub label: String,
21    /// A `thread://` Locator.
22    pub locator: String,
23}
24
25/// A persisted recent-places list, most recent first. Every mutation saves
26/// immediately (the file is tiny).
27pub struct Recents {
28    path: PathBuf,
29    entries: Vec<Recent>,
30}
31
32impl Recents {
33    /// Load from `path` (missing or unreadable → empty list, never an error).
34    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    /// The default per-user store: `~/.config/infinite/recents.json`.
44    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    /// Most recent first.
54    pub fn entries(&self) -> &[Recent] {
55        &self.entries
56    }
57
58    /// Record a visit: moves the place to the front (refreshing its label),
59    /// trims to [`CAP`]. Home itself is never recorded — you don't "recently
60    /// visit" your own hearth. Returns whether the list changed.
61    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; // already freshest — no rewrite
81        }
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        // Re-visiting moves to the front and strips the anchor; no duplicate.
128        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        // Freshest repeat is a no-op (no file churn).
132        assert!(!r.visit("The Nexus", "thread://pixygon.io"));
133        // Home is never recorded.
134        assert!(!r.visit("Home", crate::home::HOME_LOCATOR));
135        // Cap holds.
136        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        // And it round-trips from disk.
142        let reloaded = Recents::load(&path);
143        assert_eq!(reloaded.entries(), r.entries());
144        let _ = std::fs::remove_file(&path);
145    }
146}