Skip to main content

libsurfer/
file_history.rs

1use 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}