libsurfer/
file_history.rs1use camino::Utf8PathBuf;
2#[cfg(all(not(target_arch = "wasm32"), not(test)))]
3use serde::{Deserialize, Serialize};
4
5#[cfg(all(not(target_arch = "wasm32"), not(test)))]
6const FILE_HISTORY_FILE: &str = "file_history.ron";
7
8#[cfg(all(not(target_arch = "wasm32"), not(test)))]
9#[derive(Debug, Default, Serialize, Deserialize)]
10struct StoredFileHistory {
11 files: Vec<String>,
12}
13
14#[derive(Debug, Default)]
15pub struct FileHistory {
16 files: Vec<Utf8PathBuf>,
17 max_entries: usize,
18}
19
20impl FileHistory {
21 #[must_use]
22 pub fn load(max_entries: usize) -> Self {
23 let mut history = Self {
24 files: Vec::new(),
25 max_entries,
26 };
27 history.load_from_disk();
28 history.truncate_to_limit();
29 history
30 }
31
32 #[must_use]
33 pub fn files(&self) -> &[Utf8PathBuf] {
34 &self.files
35 }
36
37 #[must_use]
38 pub fn display_labels(&self) -> Vec<String> {
39 disambiguated_labels(&self.files)
40 }
41
42 pub fn add(&mut self, file: Utf8PathBuf) {
43 if self.max_entries == 0 || is_connection_entry(&file) {
44 return;
45 }
46
47 self.files.retain(|path| path != &file);
48 self.files.insert(0, file.clone());
49 self.truncate_to_limit();
50 self.save_to_disk();
51 }
52
53 fn truncate_to_limit(&mut self) {
54 self.files.truncate(self.max_entries);
55 }
56
57 #[cfg(all(not(target_arch = "wasm32"), not(test)))]
58 fn load_from_disk(&mut self) {
59 let Some(path) = storage_path() else {
60 return;
61 };
62
63 let Ok(content) = std::fs::read_to_string(path) else {
64 return;
65 };
66
67 let Ok(stored) = ron::from_str::<StoredFileHistory>(&content) else {
68 return;
69 };
70
71 self.files = stored
72 .files
73 .into_iter()
74 .map(Utf8PathBuf::from)
75 .collect::<Vec<_>>();
76 }
77
78 #[cfg(any(target_arch = "wasm32", test))]
79 fn load_from_disk(&mut self) {}
80
81 #[cfg(all(not(target_arch = "wasm32"), not(test)))]
82 fn save_to_disk(&self) {
83 let Some(path) = storage_path() else {
84 return;
85 };
86 let Some(parent) = path.parent() else {
87 return;
88 };
89
90 if std::fs::create_dir_all(parent).is_err() {
91 return;
92 }
93
94 let stored = StoredFileHistory {
95 files: self
96 .files
97 .iter()
98 .map(std::string::ToString::to_string)
99 .collect(),
100 };
101 let Ok(ron) =
102 ron::Options::default().to_string_pretty(&stored, ron::ser::PrettyConfig::default())
103 else {
104 return;
105 };
106
107 let _ = std::fs::write(path, ron);
108 }
109
110 #[cfg(any(target_arch = "wasm32", test))]
111 fn save_to_disk(&self) {}
112}
113
114#[cfg(all(not(target_arch = "wasm32"), not(test)))]
115fn storage_path() -> Option<std::path::PathBuf> {
116 crate::config::PROJECT_DIR
117 .as_ref()
118 .map(|dirs| dirs.data_local_dir().join(FILE_HISTORY_FILE))
119}
120
121fn is_connection_entry(path: &Utf8PathBuf) -> bool {
122 let value = path.as_str();
123 value.starts_with("http://")
124 || value.starts_with("https://")
125 || value.starts_with("ws://")
126 || value.starts_with("wss://")
127 || value.starts_with("cxxrtl+tcp://")
128}
129
130fn disambiguated_labels(paths: &[Utf8PathBuf]) -> Vec<String> {
131 let segments: Vec<Vec<String>> = paths
132 .iter()
133 .map(|path| {
134 path.as_str()
135 .split(['/', '\\'])
136 .filter(|segment| !segment.is_empty())
137 .map(str::to_string)
138 .collect::<Vec<_>>()
139 })
140 .collect();
141
142 let mut labels = segments
143 .iter()
144 .map(|parts| {
145 parts
146 .last()
147 .cloned()
148 .unwrap_or_else(|| String::from("<unknown>"))
149 })
150 .collect::<Vec<_>>();
151
152 let mut groups = std::collections::HashMap::<String, Vec<usize>>::new();
153 for (idx, parts) in segments.iter().enumerate() {
154 if let Some(name) = parts.last() {
155 groups.entry(name.clone()).or_default().push(idx);
156 }
157 }
158
159 for indexes in groups.values() {
160 if indexes.len() <= 1 {
161 continue;
162 }
163
164 let max_depth = indexes
165 .iter()
166 .map(|idx| segments[*idx].len())
167 .max()
168 .unwrap_or(1);
169
170 let mut found_unique_depth = None;
171 for depth in 2..=max_depth {
172 let mut seen = std::collections::HashSet::new();
173 let all_unique = indexes.iter().all(|idx| {
174 let candidate = label_for_depth(&segments[*idx], depth);
175 seen.insert(candidate)
176 });
177 if all_unique {
178 found_unique_depth = Some(depth);
179 break;
180 }
181 }
182
183 if let Some(depth) = found_unique_depth {
184 for idx in indexes {
185 labels[*idx] = label_for_depth(&segments[*idx], depth);
186 }
187 } else {
188 for idx in indexes {
189 labels[*idx] = paths[*idx].to_string();
190 }
191 }
192 }
193
194 labels
195}
196
197fn label_for_depth(parts: &[String], depth: usize) -> String {
198 let start = parts.len().saturating_sub(depth);
199 parts[start..].join("/")
200}
201
202#[cfg(test)]
203mod tests {
204 use super::FileHistory;
205 use super::disambiguated_labels;
206 use camino::Utf8PathBuf;
207
208 #[test]
209 fn keeps_most_recent_on_top() {
210 let mut history = FileHistory::load(3);
211
212 history.add(Utf8PathBuf::from("a.vcd"));
213 history.add(Utf8PathBuf::from("b.vcd"));
214 history.add(Utf8PathBuf::from("a.vcd"));
215
216 assert_eq!(
217 history.files(),
218 [Utf8PathBuf::from("a.vcd"), Utf8PathBuf::from("b.vcd")]
219 );
220 }
221
222 #[test]
223 fn respects_max_entries() {
224 let mut history = FileHistory::load(2);
225
226 history.add(Utf8PathBuf::from("a.vcd"));
227 history.add(Utf8PathBuf::from("b.vcd"));
228 history.add(Utf8PathBuf::from("c.vcd"));
229
230 assert_eq!(
231 history.files(),
232 [Utf8PathBuf::from("c.vcd"), Utf8PathBuf::from("b.vcd")]
233 );
234 }
235
236 #[test]
237 fn ignores_connection_entries() {
238 let mut history = FileHistory::load(5);
239
240 history.add(Utf8PathBuf::from("https://surver.example/status"));
241 history.add(Utf8PathBuf::from("ws://surver.example/socket"));
242 history.add(Utf8PathBuf::from("wave.vcd"));
243
244 assert_eq!(history.files(), [Utf8PathBuf::from("wave.vcd")]);
245 }
246
247 #[test]
248 fn disambiguates_duplicate_file_names() {
249 let paths = vec![
250 Utf8PathBuf::from("C:/work/a/top.vcd"),
251 Utf8PathBuf::from("C:/work/b/top.vcd"),
252 Utf8PathBuf::from("C:/work/c/other.vcd"),
253 ];
254
255 let labels = disambiguated_labels(&paths);
256
257 assert_eq!(labels[0], "a/top.vcd");
258 assert_eq!(labels[1], "b/top.vcd");
259 assert_eq!(labels[2], "other.vcd");
260 }
261}