Skip to main content

teksilo_settings/
mru.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Most-recently-used list — a generic, persisted reactive collection
5//! with dedupe, pinning, and LRU-style cap eviction.
6//!
7//! Apps define their own item type by implementing [`Keyed`] (a stable
8//! identity) and [`MruEntry`] (pin / touch semantics). The framework
9//! handles dedupe-on-add, pin-aware cap eviction, and cross-process-safe
10//! persistence via [`PersistedListModel`]; the app owns the item schema.
11//!
12//! ## When to use
13//!
14//! Use [`MruList`] for any "recently opened / recently used" feature:
15//! recent files, recent projects, recently visited locations, recently used
16//! palette entries, etc. The backing [`ListModel<T>`] is the same reactive
17//! handle you bind to a [`ListView`](teksilo_data::ListModel) or iterate in
18//! a menu — no separate notification plumbing is required.
19//!
20//! ## Persistence
21//!
22//! [`MruList::open`] reads `<config_dir>/<name>.toml` on first access
23//! (cross-process safe: the read is lock-protected, and every subsequent
24//! mutation merges by key against the document on disk, never overwriting
25//! the whole thing). Pass [`Duration::ZERO`] in tests to flush
26//! synchronously, or call [`MruList::flush_now`] explicitly.
27//!
28//! ```ignore
29//! use std::path::PathBuf;
30//! use std::time::Duration;
31//! use teksilo_settings::{AppPaths, Keyed, MruEntry, MruList};
32//! use serde::{Serialize, Deserialize};
33//!
34//! #[derive(Serialize, Deserialize, Clone)]
35//! struct RecentProject {
36//!     path: PathBuf,
37//!     display_name: String,
38//!     last_opened: u64,
39//!     pinned: bool,
40//! }
41//!
42//! impl Keyed for RecentProject {
43//!     type Key = PathBuf;
44//!     fn key(&self) -> PathBuf { self.path.clone() }
45//! }
46//!
47//! impl MruEntry for RecentProject {
48//!     fn is_pinned(&self) -> bool { self.pinned }
49//!     fn set_pinned(&mut self, p: bool) { self.pinned = p; }
50//!     fn touch(&mut self) { self.last_opened += 1; }
51//! }
52//!
53//! // In tests: AppPaths::for_testing(tmp.path()) + Duration::ZERO.
54//! // In production: AppPaths::new(qualifier, org, app).
55//! let tmp = tempfile::tempdir().unwrap();
56//! let paths = AppPaths::for_testing(tmp.path());
57//! let recents: MruList<RecentProject> =
58//!     MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
59//!
60//! recents.add(RecentProject {
61//!     path: "/projects/foo".into(),
62//!     display_name: "Foo".into(),
63//!     last_opened: 0,
64//!     pinned: false,
65//! });
66//! assert_eq!(recents.model().len(), 1);
67//! ```
68
69use std::borrow::Borrow;
70use std::path::{Path, PathBuf};
71use std::rc::Rc;
72use std::time::Duration;
73
74use serde::Serialize;
75use serde::de::DeserializeOwned;
76use teksilo_data::ListModel;
77
78use crate::collection::list::{Keyed, PersistedListModel};
79use crate::file::SettingsFileError;
80use crate::migration::Migrator;
81use crate::path::AppPaths;
82use crate::reload::Reloadable;
83use crate::store::DEFAULT_DEBOUNCE;
84
85/// An item that can live in an [`MruList`]. Requires [`Keyed`] for its
86/// stable merge identity; adds the pin / touch vocabulary an MRU list
87/// specifically needs on top.
88pub trait MruEntry: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static {
89    /// Whether this entry should resist eviction by `cap_to_max`.
90    /// Default: never pinned.
91    fn is_pinned(&self) -> bool {
92        false
93    }
94
95    /// Set the pinned flag. Default: ignore.
96    fn set_pinned(&mut self, _pinned: bool) {}
97
98    /// Hook called by [`MruList::add`] and [`MruList::touch`] to mark
99    /// this entry as freshly used. Apps that track a `last_opened`
100    /// timestamp update it here. Default: no-op.
101    fn touch(&mut self) {}
102}
103
104/// A persisted MRU list backed by [`PersistedListModel<T>`].
105///
106/// Cheap to clone (`Rc`-shared internally). The reactive
107/// [`ListModel<T>`](teksilo_data::ListModel) returned by [`model()`](Self::model) is the same
108/// handle this list's own mutators update — nothing observes it for
109/// persistence, so only those mutators reach the disk.
110pub struct MruList<T: MruEntry> {
111    persisted: Rc<PersistedListModel<T>>,
112    max_items: usize,
113}
114
115impl<T: MruEntry> Clone for MruList<T> {
116    fn clone(&self) -> Self {
117        Self {
118            persisted: Rc::clone(&self.persisted),
119            max_items: self.max_items,
120        }
121    }
122}
123
124impl<T: MruEntry> MruList<T> {
125    /// Open at `<paths.config_dir()>/<name>.toml` with the default debounce window.
126    ///
127    /// Creates the file (and any missing parent directories) if it does not
128    /// yet exist. Use [`open_with_delay`](Self::open_with_delay) to override
129    /// the debounce in tests.
130    pub fn open(paths: &AppPaths, name: &str, max_items: usize) -> Result<Self, SettingsFileError> {
131        Self::open_with_delay(paths, name, max_items, DEFAULT_DEBOUNCE)
132    }
133
134    /// Open at `<paths.config_dir()>/<name>.toml` with a custom debounce window.
135    ///
136    /// Pass [`Duration::ZERO`](std::time::Duration::ZERO) in tests to flush
137    /// every mutation synchronously.
138    pub fn open_with_delay(
139        paths: &AppPaths,
140        name: &str,
141        max_items: usize,
142        delay: Duration,
143    ) -> Result<Self, SettingsFileError> {
144        Self::open_at(paths.config_file(name), max_items, delay)
145    }
146
147    /// Open at an explicit path with the given debounce window.
148    ///
149    /// Lower-level alternative to [`open`](Self::open) when the caller
150    /// already has a resolved [`PathBuf`] (e.g. from a custom directory layout).
151    pub fn open_at(
152        path: PathBuf,
153        max_items: usize,
154        delay: Duration,
155    ) -> Result<Self, SettingsFileError> {
156        let persisted = PersistedListModel::open(path, delay, Migrator::new())?;
157        Ok(Self {
158            persisted: Rc::new(persisted),
159            max_items,
160        })
161    }
162
163    /// The underlying reactive list; bind to UI widgets via clones of this handle.
164    ///
165    /// **Read-only for mutation purposes.** Use [`add`](Self::add),
166    /// [`remove`](Self::remove), [`touch`](Self::touch),
167    /// [`set_pinned`](Self::set_pinned), [`clear`](Self::clear) to mutate —
168    /// those are what enqueue the matching persisted op. Mutating the
169    /// returned `ListModel` directly updates what's on screen but is
170    /// never written to disk.
171    pub fn model(&self) -> &ListModel<T> {
172        self.persisted.model()
173    }
174
175    /// Returns the maximum number of unpinned entries kept in the list.
176    ///
177    /// Pinned entries do not count toward this cap and are never evicted
178    /// automatically.
179    pub fn max_items(&self) -> usize {
180        self.max_items
181    }
182
183    /// Insert `entry` at the front, deduping by `entry.key()`.
184    /// `T::touch` is invoked before insertion, so the freshly-added
185    /// entry reflects "now". If a previously-pinned entry is re-added
186    /// without `pinned`, the pin state is preserved.
187    pub fn add(&self, mut entry: T) {
188        let key = entry.key();
189        let was_pinned = self.find_index(&key).is_some_and(|idx| {
190            self.persisted
191                .model()
192                .with_item(idx, |t| t.is_pinned())
193                .unwrap_or(false)
194        });
195        if was_pinned && !entry.is_pinned() {
196            entry.set_pinned(true);
197        }
198        entry.touch();
199        // `upsert_front` handles the dedupe-then-insert-at-front as one
200        // op — the exact "most recently used" semantics, and the same
201        // one a peer's disk-side merge re-runs at flush time.
202        self.persisted.upsert_front(entry);
203        self.cap_to_max();
204    }
205
206    /// Remove the entry whose key matches, then schedule a debounced flush.
207    ///
208    /// No-op when no entry with that key is present. Generic over `Q` so
209    /// callers can pass a borrowed form of the key (e.g. `&Path` when
210    /// `T::Key = PathBuf`, `&str` when `T::Key = String`) without having
211    /// to allocate an owned key just to look one up.
212    pub fn remove<Q>(&self, key: &Q)
213    where
214        T::Key: Borrow<Q>,
215        Q: Eq + ?Sized,
216    {
217        if let Some(idx) = self.find_index_by(key) {
218            let owned_key = self
219                .persisted
220                .model()
221                .with_item(idx, |t| t.key())
222                .expect("index was just found to be valid");
223            self.persisted.remove(&owned_key);
224        }
225    }
226
227    /// Mark the entry whose key matches as freshly used by calling
228    /// [`MruEntry::touch`] on a clone of it, then write it back and
229    /// schedule a debounced flush. No-op when no entry matches.
230    pub fn touch<Q>(&self, key: &Q)
231    where
232        T::Key: Borrow<Q>,
233        Q: Eq + ?Sized,
234    {
235        if let Some(idx) = self.find_index_by(key) {
236            let model = self.persisted.model();
237            let mut updated = match model.with_item(idx, |t| t.clone()) {
238                Some(v) => v,
239                None => return,
240            };
241            updated.touch();
242            self.persisted.update_in_place(updated);
243        }
244    }
245
246    /// Set the pin flag of the entry whose key matches to exactly
247    /// `pinned` (idempotent — unlike a toggle, replaying this against an
248    /// already-applied peer change does not flip it back). No-op when no
249    /// entry matches.
250    pub fn set_pinned<Q>(&self, key: &Q, pinned: bool)
251    where
252        T::Key: Borrow<Q>,
253        Q: Eq + ?Sized,
254    {
255        if let Some(idx) = self.find_index_by(key) {
256            let model = self.persisted.model();
257            let mut updated = match model.with_item(idx, |t| t.clone()) {
258                Some(v) => v,
259                None => return,
260            };
261            updated.set_pinned(pinned);
262            self.persisted.update_in_place(updated);
263        }
264    }
265
266    /// Is the entry with this key currently pinned? `false` when no entry
267    /// matches.
268    ///
269    /// The counterpart [`set_pinned`](Self::set_pinned) deliberately takes the
270    /// *desired* value rather than toggling, because a toggle is not idempotent:
271    /// replayed against a peer process's already-applied toggle it would flip
272    /// the value straight back, inverting their change. A pin **button** still
273    /// needs to toggle, though — so read the current value here and pass its
274    /// negation:
275    ///
276    /// ```ignore
277    /// let pinned = mru.is_pinned(path);
278    /// mru.set_pinned(path, !pinned);
279    /// ```
280    pub fn is_pinned<Q>(&self, key: &Q) -> bool
281    where
282        T::Key: Borrow<Q>,
283        Q: Eq + ?Sized,
284    {
285        match self.find_index_by(key) {
286            Some(idx) => self
287                .persisted
288                .model()
289                .with_item(idx, |t| t.is_pinned())
290                .unwrap_or(false),
291            None => false,
292        }
293    }
294
295    /// Drop every entry (pinned or not) and schedule a debounced flush.
296    pub fn clear(&self) {
297        self.persisted.clear();
298    }
299
300    /// Write the list to disk synchronously, bypassing the debounce window.
301    ///
302    /// Useful at app shutdown or at the end of a test to guarantee the
303    /// file reflects the in-memory state before the process exits.
304    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
305        self.persisted.flush_now()
306    }
307
308    /// The TOML file path this list reads from and writes to.
309    pub fn path(&self) -> &Path {
310        self.persisted.path()
311    }
312
313    fn find_index(&self, key: &T::Key) -> Option<usize> {
314        self.find_index_by(key)
315    }
316
317    fn find_index_by<Q>(&self, key: &Q) -> Option<usize>
318    where
319        T::Key: Borrow<Q>,
320        Q: Eq + ?Sized,
321    {
322        let model = self.persisted.model();
323        (0..model.len()).find(|&i| {
324            model
325                .with_item(i, |t| t.key().borrow() == key)
326                .unwrap_or(false)
327        })
328    }
329
330    fn cap_to_max(&self) {
331        let model = self.persisted.model();
332        let len = model.len();
333
334        let mut unpinned = 0usize;
335        for i in 0..len {
336            if model.with_item(i, |t| !t.is_pinned()).unwrap_or(false) {
337                unpinned += 1;
338            }
339        }
340        if unpinned <= self.max_items {
341            return;
342        }
343        let mut to_drop = unpinned - self.max_items;
344
345        let mut i = len;
346        while i > 0 && to_drop > 0 {
347            i -= 1;
348            let evict = model.with_item(i, |t| (!t.is_pinned()).then(|| t.key()));
349            if let Some(Some(key)) = evict {
350                self.persisted.remove(&key);
351                to_drop -= 1;
352            }
353        }
354    }
355}
356
357impl<T: MruEntry> Reloadable for MruList<T>
358where
359    T: PartialEq,
360{
361    fn path(&self) -> &Path {
362        MruList::path(self)
363    }
364
365    fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
366        self.persisted.reload_from_disk()
367    }
368}
369
370impl<T: MruEntry> std::fmt::Debug for MruList<T> {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        f.debug_struct("MruList")
373            .field("len", &self.persisted.model().len())
374            .field("max_items", &self.max_items)
375            .field("path", &self.persisted.path())
376            .field("entry_type", &std::any::type_name::<T>())
377            .finish()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use serde::{Deserialize, Serialize};
385    use tempfile::tempdir;
386
387    #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
388    struct DemoItem {
389        path: PathBuf,
390        name: String,
391        opened_at: u64,
392        pinned: bool,
393    }
394
395    impl DemoItem {
396        fn new(path: &str, name: &str) -> Self {
397            Self {
398                path: path.into(),
399                name: name.into(),
400                opened_at: 0,
401                pinned: false,
402            }
403        }
404        fn pinned(mut self) -> Self {
405            self.pinned = true;
406            self
407        }
408    }
409
410    impl Keyed for DemoItem {
411        type Key = PathBuf;
412        fn key(&self) -> PathBuf {
413            self.path.clone()
414        }
415    }
416
417    impl MruEntry for DemoItem {
418        fn is_pinned(&self) -> bool {
419            self.pinned
420        }
421        fn set_pinned(&mut self, p: bool) {
422            self.pinned = p;
423        }
424        fn touch(&mut self) {
425            self.opened_at += 1;
426        }
427    }
428
429    fn open(dir: &Path, max: usize) -> MruList<DemoItem> {
430        let paths = AppPaths::for_testing(dir);
431        MruList::open_with_delay(&paths, "mru", max, Duration::ZERO).unwrap()
432    }
433
434    #[test]
435    fn add_pushes_to_front() {
436        let dir = tempdir().unwrap();
437        let mru = open(dir.path(), 5);
438        mru.add(DemoItem::new("/a", "A"));
439        mru.add(DemoItem::new("/b", "B"));
440        assert_eq!(mru.model().len(), 2);
441        assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "B");
442    }
443
444    #[test]
445    fn add_dedupes_by_key() {
446        let dir = tempdir().unwrap();
447        let mru = open(dir.path(), 5);
448        mru.add(DemoItem::new("/a", "A"));
449        mru.add(DemoItem::new("/b", "B"));
450        mru.add(DemoItem::new("/a", "A again"));
451        // Two entries: A-renamed at front, B after.
452        assert_eq!(mru.model().len(), 2);
453        assert_eq!(
454            mru.model().with_item(0, |i| i.name.clone()).unwrap(),
455            "A again"
456        );
457        assert_eq!(mru.model().with_item(1, |i| i.name.clone()).unwrap(), "B");
458    }
459
460    #[test]
461    fn add_preserves_pin_on_dedupe() {
462        let dir = tempdir().unwrap();
463        let mru = open(dir.path(), 5);
464        mru.add(DemoItem::new("/a", "A").pinned());
465        mru.add(DemoItem::new("/a", "A renamed")); // not pinned in arg
466        assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
467    }
468
469    #[test]
470    fn touch_invokes_entry_hook() {
471        let dir = tempdir().unwrap();
472        let mru = open(dir.path(), 5);
473        mru.add(DemoItem::new("/a", "A")); // touch -> opened_at = 1
474        let before = mru.model().with_item(0, |i| i.opened_at).unwrap();
475        mru.touch(Path::new("/a"));
476        let after = mru.model().with_item(0, |i| i.opened_at).unwrap();
477        assert!(after > before);
478    }
479
480    #[test]
481    fn cap_drops_oldest_unpinned() {
482        let dir = tempdir().unwrap();
483        let mru = open(dir.path(), 2);
484        mru.add(DemoItem::new("/a", "A"));
485        mru.add(DemoItem::new("/b", "B"));
486        mru.add(DemoItem::new("/c", "C"));
487        // Should keep the 2 most recent: C, B.
488        let names: Vec<String> = (0..mru.model().len())
489            .map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
490            .collect();
491        assert_eq!(names, vec!["C", "B"]);
492    }
493
494    #[test]
495    fn pinned_survives_cap() {
496        let dir = tempdir().unwrap();
497        let mru = open(dir.path(), 2);
498        mru.add(DemoItem::new("/a", "A").pinned());
499        mru.add(DemoItem::new("/b", "B"));
500        mru.add(DemoItem::new("/c", "C"));
501        mru.add(DemoItem::new("/d", "D"));
502        let mut names: Vec<String> = (0..mru.model().len())
503            .map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
504            .collect();
505        names.sort();
506        assert_eq!(names, vec!["A", "C", "D"]);
507    }
508
509    #[test]
510    fn set_pinned_sets_state_and_is_idempotent() {
511        let dir = tempdir().unwrap();
512        let mru = open(dir.path(), 5);
513        mru.add(DemoItem::new("/a", "A"));
514        assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
515
516        mru.set_pinned(Path::new("/a"), true);
517        assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
518
519        // Replaying the same desired value is a no-op, unlike a toggle
520        // (which would flip it back) — this is exactly what makes it
521        // safe to replay against a merged list.
522        mru.set_pinned(Path::new("/a"), true);
523        assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
524
525        mru.set_pinned(Path::new("/a"), false);
526        assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
527    }
528
529    #[test]
530    fn remove_drops_entry() {
531        let dir = tempdir().unwrap();
532        let mru = open(dir.path(), 5);
533        mru.add(DemoItem::new("/a", "A"));
534        mru.add(DemoItem::new("/b", "B"));
535        mru.remove(Path::new("/a"));
536        assert_eq!(mru.model().len(), 1);
537    }
538
539    #[test]
540    fn persists_across_reopen() {
541        let dir = tempdir().unwrap();
542        {
543            let mru = open(dir.path(), 5);
544            mru.add(DemoItem::new("/foo", "Foo"));
545            mru.add(DemoItem::new("/bar", "Bar"));
546            mru.flush_now().unwrap();
547        }
548        let mru = open(dir.path(), 5);
549        assert_eq!(mru.model().len(), 2);
550        assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "Bar");
551    }
552
553    /// A second item type with a `String` key — proves the trait
554    /// works for arbitrary owned key types, not just `PathBuf`.
555    #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
556    struct StringItem {
557        token: String,
558        count: u32,
559    }
560    impl Keyed for StringItem {
561        type Key = String;
562        fn key(&self) -> String {
563            self.token.clone()
564        }
565    }
566    impl MruEntry for StringItem {}
567
568    #[test]
569    fn works_with_string_keyed_entries() {
570        let dir = tempdir().unwrap();
571        let paths = AppPaths::for_testing(dir.path());
572        let mru: MruList<StringItem> =
573            MruList::open_with_delay(&paths, "tokens", 3, Duration::ZERO).unwrap();
574        mru.add(StringItem {
575            token: "alpha".into(),
576            count: 1,
577        });
578        mru.add(StringItem {
579            token: "beta".into(),
580            count: 2,
581        });
582        mru.add(StringItem {
583            token: "alpha".into(),
584            count: 99,
585        });
586        assert_eq!(mru.model().len(), 2);
587        mru.remove("beta");
588        assert_eq!(mru.model().len(), 1);
589    }
590
591    // -----------------------------------------------------------------
592    // THE REPORTED BUG, fixed: two peers each adding a different recent.
593    // -----------------------------------------------------------------
594
595    /// Two `MruList` handles over one file each `add` a *different*
596    /// recent, with no coordination between them. A third, fresh handle
597    /// must see **both** — before the op-merge design, one was silently
598    /// lost because the old design re-derived and overwrote the whole
599    /// file from an increasingly stale in-memory snapshot on every
600    /// mutation.
601    #[test]
602    fn two_peers_each_adding_a_different_recent_both_survive() {
603        let dir = tempdir().unwrap();
604        let paths = AppPaths::for_testing(dir.path());
605
606        let a: MruList<DemoItem> =
607            MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
608        let b: MruList<DemoItem> =
609            MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
610
611        a.add(DemoItem::new("/proj/a", "Project A"));
612        a.flush_now().unwrap();
613        b.add(DemoItem::new("/proj/b", "Project B"));
614        b.flush_now().unwrap();
615
616        let c: MruList<DemoItem> =
617            MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
618        let mut names: Vec<String> = (0..c.model().len())
619            .map(|i| c.model().with_item(i, |x| x.name.clone()).unwrap())
620            .collect();
621        names.sort();
622        assert_eq!(
623            names,
624            vec!["Project A".to_string(), "Project B".to_string()],
625            "both peers' additions must survive — neither is silently lost"
626        );
627    }
628
629    #[test]
630    fn reload_from_disk_picks_up_a_peers_addition() {
631        let dir = tempdir().unwrap();
632        let paths = AppPaths::for_testing(dir.path());
633
634        let a: MruList<DemoItem> =
635            MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
636        let b: MruList<DemoItem> =
637            MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
638
639        a.add(DemoItem::new("/proj/a", "Project A"));
640        a.flush_now().unwrap();
641
642        assert!(Reloadable::reload_from_disk(&b).unwrap());
643        assert_eq!(b.model().len(), 1);
644    }
645}