1use 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
85pub trait MruEntry: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static {
89 fn is_pinned(&self) -> bool {
92 false
93 }
94
95 fn set_pinned(&mut self, _pinned: bool) {}
97
98 fn touch(&mut self) {}
102}
103
104pub 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 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 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 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 pub fn model(&self) -> &ListModel<T> {
172 self.persisted.model()
173 }
174
175 pub fn max_items(&self) -> usize {
180 self.max_items
181 }
182
183 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 self.persisted.upsert_front(entry);
203 self.cap_to_max();
204 }
205
206 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 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 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 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 pub fn clear(&self) {
297 self.persisted.clear();
298 }
299
300 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
305 self.persisted.flush_now()
306 }
307
308 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 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")); 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")); 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 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 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 #[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 #[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}