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#[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" => {
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#[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 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#[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#[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 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 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#[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#[derive(Clone)]
416pub struct Opener {
417 pub association: Association,
418}
419
420impl Default for Opener {
421 fn default() -> Self {
424 Self {
425 association: Association::default().with_config(OPENER_PATH),
426 }
427 }
428}
429
430impl Opener {
431 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 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 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 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 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 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}