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> {
110 persisted: Rc<PersistedListModel<T>>,
111 max_items: usize,
112}
113
114impl<T: MruEntry> Clone for MruList<T> {
115 fn clone(&self) -> Self {
116 Self {
117 persisted: Rc::clone(&self.persisted),
118 max_items: self.max_items,
119 }
120 }
121}
122
123impl<T: MruEntry> MruList<T> {
124 pub fn open(paths: &AppPaths, name: &str, max_items: usize) -> Result<Self, SettingsFileError> {
130 Self::open_with_delay(paths, name, max_items, DEFAULT_DEBOUNCE)
131 }
132
133 pub fn open_with_delay(
138 paths: &AppPaths,
139 name: &str,
140 max_items: usize,
141 delay: Duration,
142 ) -> Result<Self, SettingsFileError> {
143 Self::open_at(paths.config_file(name), max_items, delay)
144 }
145
146 pub fn open_at(
151 path: PathBuf,
152 max_items: usize,
153 delay: Duration,
154 ) -> Result<Self, SettingsFileError> {
155 let persisted = PersistedListModel::open(path, delay, Migrator::new())?;
156 Ok(Self {
157 persisted: Rc::new(persisted),
158 max_items,
159 })
160 }
161
162 pub fn model(&self) -> &ListModel<T> {
171 self.persisted.model()
172 }
173
174 pub fn max_items(&self) -> usize {
179 self.max_items
180 }
181
182 pub fn add(&self, mut entry: T) {
187 let key = entry.key();
188 let was_pinned = self.find_index(&key).is_some_and(|idx| {
189 self.persisted
190 .model()
191 .with_item(idx, |t| t.is_pinned())
192 .unwrap_or(false)
193 });
194 if was_pinned && !entry.is_pinned() {
195 entry.set_pinned(true);
196 }
197 entry.touch();
198 self.persisted.upsert_front(entry);
202 self.cap_to_max();
203 }
204
205 pub fn remove<Q>(&self, key: &Q)
212 where
213 T::Key: Borrow<Q>,
214 Q: Eq + ?Sized,
215 {
216 if let Some(idx) = self.find_index_by(key) {
217 let owned_key = self
218 .persisted
219 .model()
220 .with_item(idx, |t| t.key())
221 .expect("index was just found to be valid");
222 self.persisted.remove(&owned_key);
223 }
224 }
225
226 pub fn touch<Q>(&self, key: &Q)
230 where
231 T::Key: Borrow<Q>,
232 Q: Eq + ?Sized,
233 {
234 if let Some(idx) = self.find_index_by(key) {
235 let model = self.persisted.model();
236 let mut updated = match model.with_item(idx, |t| t.clone()) {
237 Some(v) => v,
238 None => return,
239 };
240 updated.touch();
241 self.persisted.update_in_place(updated);
242 }
243 }
244
245 pub fn set_pinned<Q>(&self, key: &Q, pinned: bool)
250 where
251 T::Key: Borrow<Q>,
252 Q: Eq + ?Sized,
253 {
254 if let Some(idx) = self.find_index_by(key) {
255 let model = self.persisted.model();
256 let mut updated = match model.with_item(idx, |t| t.clone()) {
257 Some(v) => v,
258 None => return,
259 };
260 updated.set_pinned(pinned);
261 self.persisted.update_in_place(updated);
262 }
263 }
264
265 pub fn is_pinned<Q>(&self, key: &Q) -> bool
280 where
281 T::Key: Borrow<Q>,
282 Q: Eq + ?Sized,
283 {
284 match self.find_index_by(key) {
285 Some(idx) => self
286 .persisted
287 .model()
288 .with_item(idx, |t| t.is_pinned())
289 .unwrap_or(false),
290 None => false,
291 }
292 }
293
294 pub fn clear(&self) {
296 self.persisted.clear();
297 }
298
299 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
304 self.persisted.flush_now()
305 }
306
307 pub fn path(&self) -> &Path {
309 self.persisted.path()
310 }
311
312 fn find_index(&self, key: &T::Key) -> Option<usize> {
313 self.find_index_by(key)
314 }
315
316 fn find_index_by<Q>(&self, key: &Q) -> Option<usize>
317 where
318 T::Key: Borrow<Q>,
319 Q: Eq + ?Sized,
320 {
321 let model = self.persisted.model();
322 (0..model.len()).find(|&i| {
323 model
324 .with_item(i, |t| t.key().borrow() == key)
325 .unwrap_or(false)
326 })
327 }
328
329 fn cap_to_max(&self) {
330 let model = self.persisted.model();
331 let len = model.len();
332
333 let mut unpinned = 0usize;
334 for i in 0..len {
335 if model.with_item(i, |t| !t.is_pinned()).unwrap_or(false) {
336 unpinned += 1;
337 }
338 }
339 if unpinned <= self.max_items {
340 return;
341 }
342 let mut to_drop = unpinned - self.max_items;
343
344 let mut i = len;
345 while i > 0 && to_drop > 0 {
346 i -= 1;
347 let evict = model.with_item(i, |t| (!t.is_pinned()).then(|| t.key()));
348 if let Some(Some(key)) = evict {
349 self.persisted.remove(&key);
350 to_drop -= 1;
351 }
352 }
353 }
354}
355
356impl<T: MruEntry> Reloadable for MruList<T>
357where
358 T: PartialEq,
359{
360 fn path(&self) -> &Path {
361 MruList::path(self)
362 }
363
364 fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
365 self.persisted.reload_from_disk()
366 }
367}
368
369impl<T: MruEntry> std::fmt::Debug for MruList<T> {
370 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371 f.debug_struct("MruList")
372 .field("len", &self.persisted.model().len())
373 .field("max_items", &self.max_items)
374 .field("path", &self.persisted.path())
375 .field("entry_type", &std::any::type_name::<T>())
376 .finish()
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use serde::{Deserialize, Serialize};
384 use tempfile::tempdir;
385
386 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
387 struct DemoItem {
388 path: PathBuf,
389 name: String,
390 opened_at: u64,
391 pinned: bool,
392 }
393
394 impl DemoItem {
395 fn new(path: &str, name: &str) -> Self {
396 Self {
397 path: path.into(),
398 name: name.into(),
399 opened_at: 0,
400 pinned: false,
401 }
402 }
403 fn pinned(mut self) -> Self {
404 self.pinned = true;
405 self
406 }
407 }
408
409 impl Keyed for DemoItem {
410 type Key = PathBuf;
411 fn key(&self) -> PathBuf {
412 self.path.clone()
413 }
414 }
415
416 impl MruEntry for DemoItem {
417 fn is_pinned(&self) -> bool {
418 self.pinned
419 }
420 fn set_pinned(&mut self, p: bool) {
421 self.pinned = p;
422 }
423 fn touch(&mut self) {
424 self.opened_at += 1;
425 }
426 }
427
428 fn open(dir: &Path, max: usize) -> MruList<DemoItem> {
429 let paths = AppPaths::for_testing(dir);
430 MruList::open_with_delay(&paths, "mru", max, Duration::ZERO).unwrap()
431 }
432
433 #[test]
434 fn add_pushes_to_front() {
435 let dir = tempdir().unwrap();
436 let mru = open(dir.path(), 5);
437 mru.add(DemoItem::new("/a", "A"));
438 mru.add(DemoItem::new("/b", "B"));
439 assert_eq!(mru.model().len(), 2);
440 assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "B");
441 }
442
443 #[test]
444 fn add_dedupes_by_key() {
445 let dir = tempdir().unwrap();
446 let mru = open(dir.path(), 5);
447 mru.add(DemoItem::new("/a", "A"));
448 mru.add(DemoItem::new("/b", "B"));
449 mru.add(DemoItem::new("/a", "A again"));
450 assert_eq!(mru.model().len(), 2);
452 assert_eq!(
453 mru.model().with_item(0, |i| i.name.clone()).unwrap(),
454 "A again"
455 );
456 assert_eq!(mru.model().with_item(1, |i| i.name.clone()).unwrap(), "B");
457 }
458
459 #[test]
460 fn add_preserves_pin_on_dedupe() {
461 let dir = tempdir().unwrap();
462 let mru = open(dir.path(), 5);
463 mru.add(DemoItem::new("/a", "A").pinned());
464 mru.add(DemoItem::new("/a", "A renamed")); assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
466 }
467
468 #[test]
469 fn touch_invokes_entry_hook() {
470 let dir = tempdir().unwrap();
471 let mru = open(dir.path(), 5);
472 mru.add(DemoItem::new("/a", "A")); let before = mru.model().with_item(0, |i| i.opened_at).unwrap();
474 mru.touch(Path::new("/a"));
475 let after = mru.model().with_item(0, |i| i.opened_at).unwrap();
476 assert!(after > before);
477 }
478
479 #[test]
480 fn cap_drops_oldest_unpinned() {
481 let dir = tempdir().unwrap();
482 let mru = open(dir.path(), 2);
483 mru.add(DemoItem::new("/a", "A"));
484 mru.add(DemoItem::new("/b", "B"));
485 mru.add(DemoItem::new("/c", "C"));
486 let names: Vec<String> = (0..mru.model().len())
488 .map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
489 .collect();
490 assert_eq!(names, vec!["C", "B"]);
491 }
492
493 #[test]
494 fn pinned_survives_cap() {
495 let dir = tempdir().unwrap();
496 let mru = open(dir.path(), 2);
497 mru.add(DemoItem::new("/a", "A").pinned());
498 mru.add(DemoItem::new("/b", "B"));
499 mru.add(DemoItem::new("/c", "C"));
500 mru.add(DemoItem::new("/d", "D"));
501 let mut names: Vec<String> = (0..mru.model().len())
502 .map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
503 .collect();
504 names.sort();
505 assert_eq!(names, vec!["A", "C", "D"]);
506 }
507
508 #[test]
509 fn set_pinned_sets_state_and_is_idempotent() {
510 let dir = tempdir().unwrap();
511 let mru = open(dir.path(), 5);
512 mru.add(DemoItem::new("/a", "A"));
513 assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
514
515 mru.set_pinned(Path::new("/a"), true);
516 assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
517
518 mru.set_pinned(Path::new("/a"), true);
522 assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
523
524 mru.set_pinned(Path::new("/a"), false);
525 assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
526 }
527
528 #[test]
529 fn remove_drops_entry() {
530 let dir = tempdir().unwrap();
531 let mru = open(dir.path(), 5);
532 mru.add(DemoItem::new("/a", "A"));
533 mru.add(DemoItem::new("/b", "B"));
534 mru.remove(Path::new("/a"));
535 assert_eq!(mru.model().len(), 1);
536 }
537
538 #[test]
539 fn persists_across_reopen() {
540 let dir = tempdir().unwrap();
541 {
542 let mru = open(dir.path(), 5);
543 mru.add(DemoItem::new("/foo", "Foo"));
544 mru.add(DemoItem::new("/bar", "Bar"));
545 mru.flush_now().unwrap();
546 }
547 let mru = open(dir.path(), 5);
548 assert_eq!(mru.model().len(), 2);
549 assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "Bar");
550 }
551
552 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
555 struct StringItem {
556 token: String,
557 count: u32,
558 }
559 impl Keyed for StringItem {
560 type Key = String;
561 fn key(&self) -> String {
562 self.token.clone()
563 }
564 }
565 impl MruEntry for StringItem {}
566
567 #[test]
568 fn works_with_string_keyed_entries() {
569 let dir = tempdir().unwrap();
570 let paths = AppPaths::for_testing(dir.path());
571 let mru: MruList<StringItem> =
572 MruList::open_with_delay(&paths, "tokens", 3, Duration::ZERO).unwrap();
573 mru.add(StringItem {
574 token: "alpha".into(),
575 count: 1,
576 });
577 mru.add(StringItem {
578 token: "beta".into(),
579 count: 2,
580 });
581 mru.add(StringItem {
582 token: "alpha".into(),
583 count: 99,
584 });
585 assert_eq!(mru.model().len(), 2);
586 mru.remove("beta");
587 assert_eq!(mru.model().len(), 1);
588 }
589
590 #[test]
600 fn two_peers_each_adding_a_different_recent_both_survive() {
601 let dir = tempdir().unwrap();
602 let paths = AppPaths::for_testing(dir.path());
603
604 let a: MruList<DemoItem> =
605 MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
606 let b: MruList<DemoItem> =
607 MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
608
609 a.add(DemoItem::new("/proj/a", "Project A"));
610 a.flush_now().unwrap();
611 b.add(DemoItem::new("/proj/b", "Project B"));
612 b.flush_now().unwrap();
613
614 let c: MruList<DemoItem> =
615 MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
616 let mut names: Vec<String> = (0..c.model().len())
617 .map(|i| c.model().with_item(i, |x| x.name.clone()).unwrap())
618 .collect();
619 names.sort();
620 assert_eq!(
621 names,
622 vec!["Project A".to_string(), "Project B".to_string()],
623 "both peers' additions must survive — neither is silently lost"
624 );
625 }
626
627 #[test]
628 fn reload_from_disk_picks_up_a_peers_addition() {
629 let dir = tempdir().unwrap();
630 let paths = AppPaths::for_testing(dir.path());
631
632 let a: MruList<DemoItem> =
633 MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
634 let b: MruList<DemoItem> =
635 MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
636
637 a.add(DemoItem::new("/proj/a", "Project A"));
638 a.flush_now().unwrap();
639
640 assert!(Reloadable::reload_from_disk(&b).unwrap());
641 assert_eq!(b.model().len(), 1);
642 }
643}