fm/io/
opener.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::io::stdout;
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use crossterm::{
8    event::{DisableMouseCapture, EnableMouseCapture},
9    execute,
10    terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
11};
12use serde_yaml_ng::from_reader;
13use serde_yaml_ng::Value;
14use strum::IntoEnumIterator;
15use strum_macros::{Display, EnumIter, EnumString};
16
17use crate::common::{
18    is_in_path, tilde, OPENER_AUDIO, OPENER_DEFAULT, OPENER_IMAGE, OPENER_OFFICE, OPENER_PATH,
19    OPENER_READABLE, OPENER_TEXT, OPENER_VECT, OPENER_VIDEO,
20};
21use crate::io::{execute, execute_in_shell};
22use crate::log_info;
23use crate::modes::{
24    decompress_7z, decompress_gz, decompress_xz, decompress_zip, extract_extension, Quote,
25};
26
27/// Different kind of extensions for default openers.
28#[derive(Clone, Hash, Eq, PartialEq, Debug, Display, Default, EnumString, EnumIter)]
29pub enum Extension {
30    #[default]
31    Audio,
32    Bitmap,
33    Office,
34    Readable,
35    Text,
36    Vectorial,
37    Video,
38    Zip,
39    Sevenz,
40    Gz,
41    Xz,
42    Iso,
43    Default,
44}
45
46impl Extension {
47    pub fn matcher(ext: &str) -> Self {
48        match ext {
49            "avif" | "bmp" | "gif" | "png" | "jpg" | "jpeg" | "pgm" | "ppm" | "webp" | "tiff" => {
50                Self::Bitmap
51            }
52
53            "svg" => Self::Vectorial,
54
55            "flac" | "m4a" | "wav" | "mp3" | "ogg" | "opus" => Self::Audio,
56
57            "avi" | "mkv" | "av1" | "m4v" | "ts" | "webm" | "mov" | "wmv" => Self::Video,
58
59            "build" | "c" | "cmake" | "conf" | "cpp" | "css" | "csv" | "cu" | "ebuild" | "eex"
60            | "env" | "ex" | "exs" | "go" | "h" | "hpp" | "hs" | "html" | "ini" | "java" | "js"
61            | "json" | "kt" | "lua" | "lock" | "log" | "md" | "micro" | "ninja" | "py" | "rkt"
62            | "rs" | "scss" | "sh" | "srt" | "svelte" | "tex" | "toml" | "tsx" | "txt" | "vim"
63            | "xml" | "yaml" | "yml" => Self::Text,
64
65            "odt" | "odf" | "ods" | "odp" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" => {
66                Self::Office
67            }
68
69            "pdf" | "epub" => Self::Readable,
70
71            "zip" => Self::Zip,
72
73            "xz" => Self::Xz,
74
75            "7z" | "7za" => Self::Sevenz,
76
77            "lzip" | "lzma" | "rar" | "tgz" | "gz" | "bzip2" => Self::Gz,
78            // iso files can't be mounted without more information than we hold in this enum :
79            // we need to be able to change the status of the application to ask for a sudo password.
80            // we can't use the "basic" opener to mount them.
81            // ATM this is the only extension we can't open, it may change in the future.
82            "iso" => {
83                log_info!("extension kind iso");
84                Self::Iso
85            }
86            _ => Self::Default,
87        }
88    }
89
90    pub fn icon(&self) -> &'static str {
91        match self {
92            Self::Zip | Self::Xz | Self::Gz => "󰗄 ",
93            Self::Readable => " ",
94            Self::Iso => " ",
95            Self::Text => " ",
96            Self::Audio => " ",
97            Self::Office => "󰈙 ",
98            Self::Bitmap => " ",
99            Self::Vectorial => "󰫨 ",
100            Self::Video => " ",
101
102            _ => " ",
103        }
104    }
105}
106
107macro_rules! open_file_with {
108    ($self:ident, $key:expr, $variant:ident, $yaml:ident) => {
109        if let Some(opener) = Kind::from_yaml(&$yaml[$key]) {
110            $self
111                .association
112                .entry(Extension::$variant)
113                .and_modify(|entry| *entry = opener);
114        }
115    };
116}
117
118/// Holds an association map between `Extension` and `Info`.
119/// It's used to know how to open a kind of file.
120#[derive(Clone)]
121pub struct Association {
122    association: HashMap<Extension, Kind>,
123}
124
125impl Default for Association {
126    fn default() -> Self {
127        Self {
128            #[rustfmt::skip]
129            association: HashMap::from([
130                (Extension::Default,    Kind::external(OPENER_DEFAULT)),
131                (Extension::Audio,      Kind::external(OPENER_AUDIO)),
132                (Extension::Bitmap,     Kind::external(OPENER_IMAGE)),
133                (Extension::Office,     Kind::external(OPENER_OFFICE)),
134                (Extension::Readable,   Kind::external(OPENER_READABLE)),
135                (Extension::Text,       Kind::external(OPENER_TEXT)),
136                (Extension::Vectorial,  Kind::external(OPENER_VECT)),
137                (Extension::Video,      Kind::external(OPENER_VIDEO)),
138                (Extension::Sevenz,     Kind::Internal(Internal::Sevenz)),
139                (Extension::Gz,         Kind::Internal(Internal::Gz)),
140                (Extension::Xz,         Kind::Internal(Internal::Xz)),
141                (Extension::Zip,        Kind::Internal(Internal::Zip)),
142                (Extension::Iso,        Kind::Internal(Internal::NotSupported)),
143            ]),
144        }
145    }
146}
147
148impl Association {
149    fn with_config(mut self, path: &str) -> Self {
150        let Some(yaml) = Self::parse_yaml_file(path) else {
151            return self;
152        };
153        self.update(yaml);
154        self.validate();
155        log_info!("updated opener from {path}");
156        self
157    }
158
159    fn parse_yaml_file(path: &str) -> Option<Value> {
160        let Ok(file) = std::fs::File::open(std::path::Path::new(&tilde(path).to_string())) else {
161            eprintln!("Couldn't find opener file at {path}. Using default.");
162            log_info!("Unable to open {path}. Using default opener");
163            return None;
164        };
165        let Ok(yaml) = from_reader::<std::fs::File, Value>(file) else {
166            eprintln!("Couldn't read the opener config file at {path}.
167See https://raw.githubusercontent.com/qkzk/fm/master/config_files/fm/opener.yaml for an example. Using default.");
168            log_info!("Unable to parse openers from {path}. Using default opener");
169            return None;
170        };
171        Some(yaml)
172    }
173
174    fn update(&mut self, yaml: Value) {
175        open_file_with!(self, "audio", Audio, yaml);
176        open_file_with!(self, "bitmap_image", Bitmap, yaml);
177        open_file_with!(self, "libreoffice", Office, yaml);
178        open_file_with!(self, "readable", Readable, yaml);
179        open_file_with!(self, "text", Text, yaml);
180        open_file_with!(self, "default", Default, yaml);
181        open_file_with!(self, "vectorial_image", Vectorial, yaml);
182        open_file_with!(self, "video", Video, yaml);
183    }
184
185    fn validate(&mut self) {
186        self.association.retain(|_, info| info.is_valid());
187    }
188
189    /// Converts itself into an hashmap of strings.
190    /// Used to include openers in the help
191    pub fn as_map_of_strings(&self) -> HashMap<String, String> {
192        let mut associations: HashMap<String, String> = self
193            .association
194            .iter()
195            .map(|(k, v)| (k.to_string(), v.to_string()))
196            .collect();
197
198        for s in Extension::iter() {
199            let s = s.to_string();
200            associations.entry(s).or_insert_with(|| "".to_owned());
201        }
202        associations
203    }
204
205    fn associate(&self, ext: &str) -> Option<&Kind> {
206        self.association
207            .get(&Extension::matcher(&ext.to_lowercase()))
208    }
209}
210
211/// Some kind of files are "opened" using internal methods.
212/// ATM only one kind of files is supported, compressed ones, which use
213/// libarchive internally.
214#[derive(Clone, Hash, PartialEq, Eq, Debug, Default)]
215pub enum Internal {
216    #[default]
217    Zip,
218    Xz,
219    Gz,
220    Sevenz,
221    NotSupported,
222}
223
224impl Internal {
225    fn open(&self, path: &Path) -> Result<()> {
226        match self {
227            Self::Sevenz => decompress_7z(path),
228            Self::Zip => decompress_zip(path),
229            Self::Xz => decompress_xz(path),
230            Self::Gz => decompress_gz(path),
231            Self::NotSupported => Err(anyhow!("Can't be opened directly")),
232        }
233    }
234}
235
236/// Used to open file externally (with other programs).
237/// Most of the files are "opened" this way, only archives which could be
238/// decompressed interally aren't.
239///
240/// It holds a path to the file (as a string, for convernience) and a
241/// flag set to true if the file is opened in a terminal.
242/// - without a terminal, the file is opened by its application,
243/// - with a terminal, it starts a new terminal (from configuration) and then the program.
244#[derive(Clone, Hash, PartialEq, Eq, Debug)]
245pub struct External(String, bool);
246
247impl External {
248    fn new(opener_pair: (&str, bool)) -> Self {
249        Self(opener_pair.0.to_owned(), opener_pair.1)
250    }
251
252    fn program(&self) -> &str {
253        self.0.as_str()
254    }
255
256    pub fn use_term(&self) -> bool {
257        self.1
258    }
259
260    fn open(&self, paths: &[&str]) -> Result<()> {
261        let mut args: Vec<&str> = vec![self.program()];
262        args.extend(paths);
263        Self::without_term(args)?;
264        Ok(())
265    }
266
267    fn open_in_window<'a>(&'a self, path: &'a str) -> Result<()> {
268        let arg = format!(
269            "{program} {path}",
270            program = self.program(),
271            path = path.quote()?
272        );
273        Self::open_command_in_window(&[&arg])
274    }
275
276    fn open_multiple_in_window(&self, paths: &[PathBuf]) -> Result<()> {
277        let arg = paths
278            .iter()
279            .filter_map(|p| p.to_str().and_then(|s| s.quote().ok()))
280            .collect::<Vec<_>>()
281            .join(" ");
282        Self::open_command_in_window(&[&format!("{program} {arg}", program = self.program())])
283    }
284
285    fn without_term(mut args: Vec<&str>) -> Result<std::process::Child> {
286        if args.is_empty() {
287            return Err(anyhow!("args shouldn't be empty"));
288        }
289        let executable = args.remove(0);
290        execute(executable, &args)
291    }
292
293    /// Open a new shell in current window.
294    /// Disable raw mode, clear the screen, start a new shell ($SHELL, default to bash).
295    /// Wait...
296    /// Once the shell exits,
297    /// Clear the screen and renable raw mode.
298    ///
299    /// It's the responsability of the caller to ensure displayer doesn't try to override the display.
300    pub fn open_shell_in_window() -> Result<()> {
301        Self::open_command_in_window(&[])?;
302        Ok(())
303    }
304
305    pub fn open_command_in_window(args: &[&str]) -> Result<()> {
306        disable_raw_mode()?;
307        execute!(stdout(), DisableMouseCapture, Clear(ClearType::All))?;
308        execute_in_shell(args)?;
309        enable_raw_mode()?;
310        execute!(std::io::stdout(), EnableMouseCapture, Clear(ClearType::All))?;
311        Ok(())
312    }
313}
314
315/// A way to open one kind of files.
316/// It's either an internal method or an external program.
317#[derive(Clone, Debug, Hash, Eq, PartialEq)]
318pub enum Kind {
319    Internal(Internal),
320    External(External),
321}
322
323impl Default for Kind {
324    fn default() -> Self {
325        Self::external(OPENER_DEFAULT)
326    }
327}
328
329impl Kind {
330    fn external(opener_pair: (&str, bool)) -> Self {
331        Self::External(External::new(opener_pair))
332    }
333
334    fn from_yaml(yaml: &Value) -> Option<Self> {
335        Some(Self::external((
336            yaml.get("opener")?.as_str()?,
337            yaml.get("use_term")?.as_bool()?,
338        )))
339    }
340
341    fn is_external(&self) -> bool {
342        matches!(self, Self::External(_))
343    }
344
345    fn is_valid(&self) -> bool {
346        !self.is_external() || is_in_path(self.external_program().unwrap_or_default().0)
347    }
348
349    fn external_program(&self) -> Result<(&str, bool)> {
350        let Self::External(External(program, use_term)) = self else {
351            return Err(anyhow!("not an external opener"));
352        };
353        Ok((program, *use_term))
354    }
355}
356
357impl fmt::Display for Kind {
358    fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result {
359        let s = if let Self::External(External(program, _)) = &self {
360            program
361        } else {
362            "internal"
363        };
364        write!(f, "{s}")
365    }
366}
367
368/// Basic file opener.
369///
370/// Holds the associations between different kind of files and opener method
371/// as well as the name of the terminal configured by the user.
372/// It's also responsible for "opening" most kind of files.
373/// There's two exceptions :
374/// - iso files, which are mounted. It requires a sudo password.
375/// - neovim filepicking. It uses a socket to send RPC command.
376///
377/// It may open a single or multiple files, trying to regroup them by opener.
378#[derive(Clone)]
379pub struct Opener {
380    pub association: Association,
381}
382
383impl Default for Opener {
384    /// Creates a new opener instance.
385    /// Use the configured values from [`crate::common::OPENER_PATH`] if it can be parsed.
386    fn default() -> Self {
387        Self {
388            association: Association::default().with_config(OPENER_PATH),
389        }
390    }
391}
392
393impl Opener {
394    /// Returns the open info about this file.
395    /// It's used to check if the file can be opened without specific actions or not.
396    /// This opener can't mutate the status and can't ask for a sudo password.
397    /// Some files requires root to be opened (ie. ISO files which are mounted).
398    pub fn kind(&self, path: &Path) -> Option<&Kind> {
399        if path.is_dir() {
400            return None;
401        }
402        self.association.associate(extract_extension(path))
403    }
404
405    /// Does this extension requires a terminal ?
406    pub fn extension_use_term(&self, extension: &str) -> bool {
407        if let Some(Kind::External(external)) = self.association.associate(extension) {
408            external.use_term()
409        } else {
410            false
411        }
412    }
413
414    pub fn use_term(&self, path: &Path) -> bool {
415        match self.kind(path) {
416            None => false,
417            Some(Kind::Internal(_)) => false,
418            Some(Kind::External(external)) => external.use_term(),
419        }
420    }
421
422    /// Open a file, using the configured method.
423    /// It may fail if the program changed after reading the config file.
424    /// It may also fail if the program can't handle this kind of files.
425    /// This is quite a tricky method, there's many possible failures.
426    pub fn open_single(&self, path: &Path) -> Result<()> {
427        match self.kind(path) {
428            Some(Kind::External(external)) => {
429                external.open(&[path.to_str().context("couldn't")?])
430            }
431            Some(Kind::Internal(internal)) => internal.open(path),
432            None => Err(anyhow!("{p} can't be opened", p = path.display())),
433        }
434    }
435
436    /// Open multiple files.
437    /// Files sharing an opener are opened in a single command ie.: `nvim a.txt b.rs c.py`.
438    /// Only files opened with an external opener are supported.
439    pub fn open_multiple(&self, openers: HashMap<External, Vec<PathBuf>>) -> Result<()> {
440        for (external, grouped_paths) in openers.iter() {
441            let _ = external.open(&Self::collect_paths_as_str(grouped_paths));
442        }
443        Ok(())
444    }
445
446    /// Create an hashmap of openers -> `[files]`.
447    /// Each file in the collection share the same opener.
448    pub fn regroup_per_opener(&self, paths: &[PathBuf]) -> HashMap<External, Vec<PathBuf>> {
449        let mut openers: HashMap<External, Vec<PathBuf>> = HashMap::new();
450        for path in paths {
451            let Some(Kind::External(pair)) = self.kind(path) else {
452                continue;
453            };
454            openers
455                .entry(External(pair.0.to_owned(), pair.1).to_owned())
456                .and_modify(|files| files.push((*path).to_owned()))
457                .or_insert(vec![(*path).to_owned()]);
458        }
459        openers
460    }
461
462    /// Convert a slice of `PathBuf` into their string representation.
463    /// Files which are directory are skipped.
464    fn collect_paths_as_str(paths: &[PathBuf]) -> Vec<&str> {
465        paths
466            .iter()
467            .filter(|fp| !fp.is_dir())
468            .filter_map(|fp| fp.to_str())
469            .collect()
470    }
471
472    pub fn open_in_window(&self, path: &Path) {
473        let Some(Kind::External(external)) = self.kind(path) else {
474            return;
475        };
476        if !external.use_term() {
477            return;
478        };
479        let _ = external.open_in_window(path.to_string_lossy().as_ref());
480    }
481
482    pub fn open_multiple_in_window(&self, openers: HashMap<External, Vec<PathBuf>>) -> Result<()> {
483        let (external, paths) = openers.iter().next().unwrap();
484        external.open_multiple_in_window(paths)
485    }
486}