1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//! Tracks when each note was last opened.
//!
//! The note picker lists notes most-recently-opened first and lets a double
//! `Cmd-O`/`Ctrl-O` jump straight back to the previous note. Both need to know
//! *when* notes were last opened, which the in-session navigation [`History`]
//! does not record. This is persisted as TOML next to the window-state file so
//! the ordering survives restarts — otherwise "sort by last open date" would be
//! empty on every launch. The file is scoped per wiki directory (see
//! [`crate::window_state::recent_notes_file`]) so different wikis keep separate
//! histories.
//!
//! [`History`]: crate::history::History
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentNotes {
/// Note name -> last-opened time in milliseconds since the Unix epoch.
#[serde(default)]
opened: HashMap<String, i64>,
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
impl RecentNotes {
/// Load from `path`, returning an empty store if it is missing or corrupt.
pub fn load(path: &Path) -> Self {
fs::read_to_string(path)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
}
/// Persist to `path`, creating parent directories as needed.
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let toml = toml::to_string_pretty(self)
.map_err(|e| io::Error::other(format!("toml serialization error: {e}")))?;
fs::write(path, toml)
}
/// Record that `note` was opened just now.
pub fn mark_opened(&mut self, note: &str) {
self.opened.insert(note.to_string(), now_millis());
}
/// The last-opened time (ms since epoch) for `note`, if it has been opened.
pub fn last_opened(&self, note: &str) -> Option<i64> {
self.opened.get(note).copied()
}
/// Move `old`'s recency entry to `new` (used when a note is renamed) so the
/// renamed note keeps its place in the picker's ordering and no stale entry
/// for the vanished name lingers. No-op if `old` was never opened.
pub fn rename(&mut self, old: &str, new: &str) {
if let Some(time) = self.opened.remove(old) {
self.opened.insert(new.to_string(), time);
}
}
/// Forget `note`'s recency entry (used when a note is deleted) so it no
/// longer appears in the picker's ordering. No-op if it was never opened.
pub fn remove(&mut self, note: &str) {
self.opened.remove(note);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mark_opened_then_last_opened_is_set() {
let mut r = RecentNotes::default();
assert_eq!(r.last_opened("x"), None);
r.mark_opened("x");
assert!(r.last_opened("x").is_some());
}
#[test]
fn rename_moves_entry_preserving_time() {
let mut r = RecentNotes::default();
r.mark_opened("old");
let t = r.last_opened("old").unwrap();
r.rename("old", "new");
assert_eq!(r.last_opened("old"), None);
assert_eq!(r.last_opened("new"), Some(t));
}
#[test]
fn rename_unknown_note_is_noop() {
let mut r = RecentNotes::default();
r.rename("missing", "new");
assert_eq!(r.last_opened("new"), None);
}
#[test]
fn remove_forgets_entry() {
let mut r = RecentNotes::default();
r.mark_opened("gone");
assert!(r.last_opened("gone").is_some());
r.remove("gone");
assert_eq!(r.last_opened("gone"), None);
// Removing an unknown note is a no-op.
r.remove("never");
}
#[test]
fn roundtrips_names_with_slashes() {
let mut r = RecentNotes::default();
r.opened.insert("project-a/standup".into(), 42);
let toml = toml::to_string_pretty(&r).unwrap();
let back: RecentNotes = toml::from_str(&toml).unwrap();
assert_eq!(back.last_opened("project-a/standup"), Some(42));
}
}