1#![allow(dead_code)]
8
9use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
10
11#[derive(Debug, Clone)]
17pub struct FileAssociation {
18 pub extension: String,
20 pub mime_type: String,
22 pub default_app: String,
24 pub alternatives: Vec<String>,
26}
27
28impl FileAssociation {
29 pub fn new(extension: &str, mime_type: &str, default_app: &str) -> Self {
31 Self {
32 extension: String::from(extension),
33 mime_type: String::from(mime_type),
34 default_app: String::from(default_app),
35 alternatives: Vec::new(),
36 }
37 }
38
39 pub fn add_alternative(&mut self, app: &str) {
41 let s = String::from(app);
42 if !self.alternatives.contains(&s) && s != self.default_app {
43 self.alternatives.push(s);
44 }
45 }
46
47 pub fn all_apps(&self) -> Vec<String> {
49 let mut apps = vec![self.default_app.clone()];
50 for alt in &self.alternatives {
51 apps.push(alt.clone());
52 }
53 apps
54 }
55}
56
57#[derive(Debug)]
63pub struct AssociationRegistry {
64 by_extension: BTreeMap<String, FileAssociation>,
66 by_mime: BTreeMap<String, FileAssociation>,
68}
69
70impl Default for AssociationRegistry {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl AssociationRegistry {
77 pub fn new() -> Self {
79 Self {
80 by_extension: BTreeMap::new(),
81 by_mime: BTreeMap::new(),
82 }
83 }
84
85 pub fn with_defaults() -> Self {
87 let mut reg = Self::new();
88
89 reg.register(FileAssociation::new("txt", "text/plain", "text_editor"));
91 reg.register(FileAssociation::new("md", "text/markdown", "text_editor"));
92 reg.register(FileAssociation::new("rs", "text/x-rust", "text_editor"));
93 reg.register(FileAssociation::new("c", "text/x-c", "text_editor"));
94 reg.register(FileAssociation::new("h", "text/x-c", "text_editor"));
95 reg.register(FileAssociation::new("cpp", "text/x-c++", "text_editor"));
96 reg.register(FileAssociation::new("py", "text/x-python", "text_editor"));
97 reg.register(FileAssociation::new(
98 "sh",
99 "text/x-shellscript",
100 "text_editor",
101 ));
102 reg.register(FileAssociation::new("toml", "text/x-toml", "text_editor"));
103 reg.register(FileAssociation::new(
104 "json",
105 "application/json",
106 "text_editor",
107 ));
108 reg.register(FileAssociation::new(
109 "xml",
110 "application/xml",
111 "text_editor",
112 ));
113 reg.register(FileAssociation::new("html", "text/html", "text_editor"));
114 reg.register(FileAssociation::new("css", "text/css", "text_editor"));
115
116 reg.register(FileAssociation::new("png", "image/png", "image_viewer"));
118 reg.register(FileAssociation::new("jpg", "image/jpeg", "image_viewer"));
119 reg.register(FileAssociation::new("jpeg", "image/jpeg", "image_viewer"));
120 reg.register(FileAssociation::new("bmp", "image/bmp", "image_viewer"));
121 reg.register(FileAssociation::new("tga", "image/x-tga", "image_viewer"));
122 reg.register(FileAssociation::new("qoi", "image/x-qoi", "image_viewer"));
123 reg.register(FileAssociation::new(
124 "ppm",
125 "image/x-portable-pixmap",
126 "image_viewer",
127 ));
128
129 reg.register(FileAssociation::new("pdf", "application/pdf", "pdf_viewer"));
131
132 reg.register(FileAssociation::new("wav", "audio/wav", "media_player"));
134 reg.register(FileAssociation::new("mp3", "audio/mpeg", "media_player"));
135 reg.register(FileAssociation::new("ogg", "audio/ogg", "media_player"));
136
137 reg.register(FileAssociation::new(
139 "avi",
140 "video/x-msvideo",
141 "media_player",
142 ));
143 reg.register(FileAssociation::new("mp4", "video/mp4", "media_player"));
144
145 reg
146 }
147
148 pub fn register(&mut self, assoc: FileAssociation) {
153 self.by_extension
154 .insert(assoc.extension.clone(), assoc.clone());
155 self.by_mime.insert(assoc.mime_type.clone(), assoc);
156 }
157
158 pub fn lookup_by_ext(&self, ext: &str) -> Option<&FileAssociation> {
160 let mut lower = String::new();
162 for c in ext.chars() {
163 for lc in c.to_lowercase() {
164 lower.push(lc);
165 }
166 }
167 self.by_extension.get(&lower)
168 }
169
170 pub fn lookup_by_mime(&self, mime: &str) -> Option<&FileAssociation> {
172 self.by_mime.get(mime)
173 }
174
175 pub fn set_default(&mut self, ext: &str, app: &str) -> bool {
177 let mut lower = String::new();
178 for c in ext.chars() {
179 for lc in c.to_lowercase() {
180 lower.push(lc);
181 }
182 }
183
184 if let Some(assoc) = self.by_extension.get_mut(&lower) {
185 let old = assoc.default_app.clone();
186 assoc.default_app = String::from(app);
187 if !old.is_empty() && old != app {
189 assoc.add_alternative(&old);
190 }
191 let mime = assoc.mime_type.clone();
193 if let Some(mime_assoc) = self.by_mime.get_mut(&mime) {
194 mime_assoc.default_app = String::from(app);
195 }
196 true
197 } else {
198 false
199 }
200 }
201
202 pub fn get_default(&self, ext: &str) -> Option<&str> {
204 self.lookup_by_ext(ext).map(|a| a.default_app.as_str())
205 }
206
207 pub fn extension_count(&self) -> usize {
209 self.by_extension.len()
210 }
211
212 pub fn mime_count(&self) -> usize {
214 self.by_mime.len()
215 }
216
217 pub fn get_app_for_file(&self, filename: &str) -> Option<&str> {
219 let ext = filename.rsplit('.').next()?;
220 self.get_default(ext)
221 }
222}
223
224#[derive(Debug)]
230pub struct OpenWithDialog {
231 pub extension: String,
233 pub mime_type: String,
235 pub apps: Vec<String>,
237 pub selected_index: usize,
239 pub visible: bool,
241}
242
243impl OpenWithDialog {
244 pub fn from_registry(registry: &AssociationRegistry, ext: &str) -> Self {
246 let (mime, apps) = if let Some(assoc) = registry.lookup_by_ext(ext) {
247 (assoc.mime_type.clone(), assoc.all_apps())
248 } else {
249 (String::from("application/octet-stream"), Vec::new())
250 };
251
252 Self {
253 extension: String::from(ext),
254 mime_type: mime,
255 apps,
256 selected_index: 0,
257 visible: true,
258 }
259 }
260
261 pub fn select_prev(&mut self) {
263 if self.selected_index > 0 {
264 self.selected_index -= 1;
265 }
266 }
267
268 pub fn select_next(&mut self) {
270 if !self.apps.is_empty() && self.selected_index + 1 < self.apps.len() {
271 self.selected_index += 1;
272 }
273 }
274
275 pub fn selected_app(&self) -> Option<&str> {
277 self.apps.get(self.selected_index).map(|s| s.as_str())
278 }
279
280 pub fn dismiss(&mut self) {
282 self.visible = false;
283 }
284}
285
286#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_new_association() {
296 let assoc = FileAssociation::new("txt", "text/plain", "editor");
297 assert_eq!(assoc.extension, "txt");
298 assert_eq!(assoc.default_app, "editor");
299 assert!(assoc.alternatives.is_empty());
300 }
301
302 #[test]
303 fn test_add_alternative() {
304 let mut assoc = FileAssociation::new("txt", "text/plain", "editor");
305 assoc.add_alternative("notepad");
306 assoc.add_alternative("notepad"); assert_eq!(assoc.alternatives.len(), 1);
308 assoc.add_alternative("editor"); assert_eq!(assoc.alternatives.len(), 1);
310 }
311
312 #[test]
313 fn test_registry_defaults() {
314 let reg = AssociationRegistry::with_defaults();
315 assert!(reg.extension_count() > 10);
316 let txt = reg.lookup_by_ext("txt").unwrap();
317 assert_eq!(txt.default_app, "text_editor");
318 }
319
320 #[test]
321 fn test_lookup_by_mime() {
322 let reg = AssociationRegistry::with_defaults();
323 let assoc = reg.lookup_by_mime("image/png").unwrap();
324 assert_eq!(assoc.default_app, "image_viewer");
325 }
326
327 #[test]
328 fn test_set_default() {
329 let mut reg = AssociationRegistry::with_defaults();
330 assert!(reg.set_default("txt", "vscode"));
331 let assoc = reg.lookup_by_ext("txt").unwrap();
332 assert_eq!(assoc.default_app, "vscode");
333 assert!(assoc.alternatives.contains(&String::from("text_editor")));
334 }
335
336 #[test]
337 fn test_get_app_for_file() {
338 let reg = AssociationRegistry::with_defaults();
339 assert_eq!(reg.get_app_for_file("hello.rs"), Some("text_editor"));
340 assert_eq!(reg.get_app_for_file("photo.png"), Some("image_viewer"));
341 }
342
343 #[test]
344 fn test_open_with_dialog() {
345 let reg = AssociationRegistry::with_defaults();
346 let dialog = OpenWithDialog::from_registry(®, "txt");
347 assert!(dialog.visible);
348 assert!(!dialog.apps.is_empty());
349 assert_eq!(dialog.selected_app(), Some("text_editor"));
350 }
351
352 #[test]
353 fn test_open_with_navigation() {
354 let mut reg = AssociationRegistry::with_defaults();
355 let mut assoc = FileAssociation::new("txt", "text/plain", "editor");
356 assoc.add_alternative("notepad");
357 reg.register(assoc);
358 let mut dialog = OpenWithDialog::from_registry(®, "txt");
359 assert_eq!(dialog.selected_index, 0);
360 dialog.select_next();
361 assert_eq!(dialog.selected_index, 1);
362 dialog.select_prev();
363 assert_eq!(dialog.selected_index, 0);
364 }
365}