Skip to main content

fm/io/
opener.rs

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