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#[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" => {
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#[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 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#[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#[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 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#[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#[derive(Clone)]
379pub struct Opener {
380 pub association: Association,
381}
382
383impl Default for Opener {
384 fn default() -> Self {
387 Self {
388 association: Association::default().with_config(OPENER_PATH),
389 }
390 }
391}
392
393impl Opener {
394 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 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 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 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 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 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}