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<Utf8PathBuf> {
116 let dirs = crate::config::PROJECT_DIR.as_ref()?;
117 Utf8PathBuf::from_path_buf(dirs.data_local_dir().join(FILE_HISTORY_FILE)).ok()
118}
119
120fn is_connection_entry(path: &Utf8PathBuf) -> bool {
121 let value = path.as_str();
122 value.starts_with("http://")
123 || value.starts_with("https://")
124 || value.starts_with("ws://")
125 || value.starts_with("wss://")
126 || value.starts_with("cxxrtl+tcp://")
127}
128
129fn disambiguated_labels(paths: &[Utf8PathBuf]) -> Vec<String> {
130 let segments: Vec<Vec<String>> = paths
131 .iter()
132 .map(|path| {
133 path.as_str()
134 .split(['/', '\\'])
135 .filter(|segment| !segment.is_empty())
136 .map(str::to_string)
137 .collect::<Vec<_>>()
138 })
139 .collect();
140
141 let mut labels = segments
142 .iter()
143 .map(|parts| {
144 parts
145 .last()
146 .cloned()
147 .unwrap_or_else(|| String::from("<unknown>"))
148 })
149 .collect::<Vec<_>>();
150
151 let mut groups = std::collections::HashMap::<String, Vec<usize>>::new();
152 for (idx, parts) in segments.iter().enumerate() {
153 if let Some(name) = parts.last() {
154 groups.entry(name.clone()).or_default().push(idx);
155 }
156 }
157
158 for indexes in groups.values() {
159 if indexes.len() <= 1 {
160 continue;
161 }
162
163 let max_depth = indexes
164 .iter()
165 .map(|idx| segments[*idx].len())
166 .max()
167 .unwrap_or(1);
168
169 let mut found_unique_depth = None;
170 for depth in 2..=max_depth {
171 let mut seen = std::collections::HashSet::new();
172 let all_unique = indexes.iter().all(|idx| {
173 let candidate = label_for_depth(&segments[*idx], depth);
174 seen.insert(candidate)
175 });
176 if all_unique {
177 found_unique_depth = Some(depth);
178 break;
179 }
180 }
181
182 if let Some(depth) = found_unique_depth {
183 for idx in indexes {
184 labels[*idx] = label_for_depth(&segments[*idx], depth);
185 }
186 } else {
187 for idx in indexes {
188 labels[*idx] = paths[*idx].to_string();
189 }
190 }
191 }
192
193 labels
194}
195
196fn label_for_depth(parts: &[String], depth: usize) -> String {
197 let start = parts.len().saturating_sub(depth);
198 parts[start..].join("/")
199}
200
201#[cfg(test)]
202mod tests {
203 use super::FileHistory;
204 use super::disambiguated_labels;
205 use camino::Utf8PathBuf;
206
207 #[test]
208 fn keeps_most_recent_on_top() {
209 let mut history = FileHistory::load(3);
210
211 history.add(&Utf8PathBuf::from("a.vcd"));
212 history.add(&Utf8PathBuf::from("b.vcd"));
213 history.add(&Utf8PathBuf::from("a.vcd"));
214
215 assert_eq!(
216 history.files(),
217 [Utf8PathBuf::from("a.vcd"), Utf8PathBuf::from("b.vcd")]
218 );
219 }
220
221 #[test]
222 fn respects_max_entries() {
223 let mut history = FileHistory::load(2);
224
225 history.add(&Utf8PathBuf::from("a.vcd"));
226 history.add(&Utf8PathBuf::from("b.vcd"));
227 history.add(&Utf8PathBuf::from("c.vcd"));
228
229 assert_eq!(
230 history.files(),
231 [Utf8PathBuf::from("c.vcd"), Utf8PathBuf::from("b.vcd")]
232 );
233 }
234
235 #[test]
236 fn ignores_connection_entries() {
237 let mut history = FileHistory::load(5);
238
239 history.add(&Utf8PathBuf::from("https://surver.example/status"));
240 history.add(&Utf8PathBuf::from("ws://surver.example/socket"));
241 history.add(&Utf8PathBuf::from("wave.vcd"));
242
243 assert_eq!(history.files(), [Utf8PathBuf::from("wave.vcd")]);
244 }
245
246 #[test]
247 fn disambiguates_duplicate_file_names() {
248 let paths = vec![
249 Utf8PathBuf::from("C:/work/a/top.vcd"),
250 Utf8PathBuf::from("C:/work/b/top.vcd"),
251 Utf8PathBuf::from("C:/work/c/other.vcd"),
252 ];
253
254 let labels = disambiguated_labels(&paths);
255
256 assert_eq!(labels[0], "a/top.vcd");
257 assert_eq!(labels[1], "b/top.vcd");
258 assert_eq!(labels[2], "other.vcd");
259 }
260
261 #[test]
262 fn disambiguates_duplicate_file_names_linux() {
263 let paths = vec![
264 Utf8PathBuf::from("/home/user/a/top.vcd"),
265 Utf8PathBuf::from("/home/user/b/top.vcd"),
266 Utf8PathBuf::from("/home/user/c/other.vcd"),
267 ];
268
269 let labels = disambiguated_labels(&paths);
270
271 assert_eq!(labels[0], "a/top.vcd");
272 assert_eq!(labels[1], "b/top.vcd");
273 assert_eq!(labels[2], "other.vcd");
274 }
275}