Skip to main content

fm/modes/display/
preview.rs

1use std::cmp::min;
2use std::convert::Into;
3use std::fmt::{Display, Write as _};
4use std::fs::symlink_metadata;
5use std::io::{BufRead, BufReader, Read};
6use std::iter::{Enumerate, Skip, Take};
7use std::path::{Path, PathBuf};
8use std::slice::Iter;
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use content_inspector::{inspect, ContentType};
13use fs_extra::file::read_to_string;
14use ratatui::style::{Color, Modifier, Style};
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use syntect::{
18    easy::HighlightLines,
19    highlighting::{FontStyle, Style as SyntectStyle},
20    parsing::{SyntaxReference, SyntaxSet},
21};
22
23use crate::app::try_build_plugin;
24use crate::common::{
25    clear_tmp_files, filename_from_path, is_in_path, path_to_string, ACTION_LOG_PATH, BSDTAR,
26    COLUMN, FFMPEG, FONTIMAGE, ISOINFO, JUPYTER, LIBREOFFICE, LSBLK, MEDIAINFO, PANDOC, PDFINFO,
27    PDFTOPPM, PDFTOTEXT, READELF, RSVG_CONVERT, SEVENZ, SS, TRANSMISSION_SHOW, UDEVADM,
28};
29use crate::config::{
30    get_prefered_imager, get_previewer_command, get_previewer_plugins, get_syntect_theme, Imagers,
31};
32use crate::io::execute_and_capture_output_without_check;
33use crate::log_info;
34use crate::modes::{
35    extract_extension, list_files_tar, list_files_zip, ContentWindow, DisplayedImage,
36    DisplayedImageBuilder, FileKind, FilterKind, Quote, TLine, Tree, TreeBuilder, TreeLines, Users,
37};
38
39fn images_are_enabled() -> bool {
40    let Some(prefered_imager) = get_prefered_imager() else {
41        return false;
42    };
43    !matches!(prefered_imager.imager, Imagers::Disabled)
44}
45
46/// Different kind of extension for grouped by previewers.
47/// Any extension we can preview should be matched here.
48#[derive(Default, Eq, PartialEq)]
49pub enum ExtensionKind {
50    Archive,
51    Audio,
52    Csv,
53    Epub,
54    Font,
55    Image,
56    Iso,
57    Notebook,
58    Office,
59    Pdf,
60    Sevenz,
61    Svg,
62    Torrent,
63    Video,
64
65    #[default]
66    Default,
67}
68
69impl ExtensionKind {
70    /// Match any known extension against an extension kind.
71    #[rustfmt::skip]
72    pub fn matcher(ext: &str) -> Self {
73        match ext {
74            "zip" | "gzip" | "bzip2" | "xz" | "lzip" | "lzma" | "tar" | "mtree" | "raw" | "gz" | "zst" | "deb" | "rpm"
75            => Self::Archive,
76            "csv"
77            => Self::Csv,
78            "7z" | "7za"
79            => Self::Sevenz,
80            "png" | "jpg" | "jpeg" | "tiff" | "heif" | "gif" | "cr2" | "nef" | "orf" | "sr2"
81            => Self::Image,
82            "ogg" | "ogm" | "riff" | "mp2" | "mp3" | "wm" | "qt" | "ac3" | "dts" | "aac" | "mac" | "flac" | "ape"
83            => Self::Audio,
84            "mkv" | "webm" | "mpeg" | "mp4" | "avi" | "flv" | "mpg" | "wmv" | "m4v" | "mov"
85            => Self::Video,
86            "ttf" | "otf" | "woff"
87            => Self::Font,
88            "svg" | "svgz"
89            => Self::Svg,
90            "pdf"
91            => Self::Pdf,
92            "iso"
93            => Self::Iso,
94            "ipynb"
95            => Self::Notebook,
96            "doc" | "docx" | "odt" | "sxw" | "xlsx" | "xls" 
97            => Self::Office,
98            "epub"
99            => Self::Epub,
100            "torrent"
101            => Self::Torrent,
102            _
103            => Self::Default,
104        }
105    }
106
107    #[rustfmt::skip]
108    fn has_programs(&self) -> bool {
109        match self {
110            Self::Archive   => is_in_path(BSDTAR),
111            Self::Csv       => is_in_path(COLUMN),
112            Self::Epub      => is_in_path(PANDOC),
113            Self::Iso       => is_in_path(ISOINFO),
114            Self::Notebook  => is_in_path(JUPYTER),
115            Self::Audio     => is_in_path(MEDIAINFO),
116            Self::Office    => is_in_path(LIBREOFFICE),
117            Self::Torrent   => is_in_path(TRANSMISSION_SHOW),
118            Self::Sevenz    => is_in_path(SEVENZ),
119            Self::Svg       => is_in_path(RSVG_CONVERT),
120            Self::Video     => is_in_path(FFMPEG),
121            Self::Font      => is_in_path(FONTIMAGE),
122            Self::Pdf       => {
123                               is_in_path(PDFINFO)
124                            && is_in_path(PDFTOPPM)
125            }
126
127            _           => true,
128        }
129    }
130
131    fn is_image_kind(&self) -> bool {
132        matches!(
133            &self,
134            ExtensionKind::Font
135                | ExtensionKind::Image
136                | ExtensionKind::Office
137                | ExtensionKind::Pdf
138                | ExtensionKind::Svg
139                | ExtensionKind::Video
140        )
141    }
142}
143
144impl std::fmt::Display for ExtensionKind {
145    #[rustfmt::skip]
146    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
147        let repr = match self {
148            Self::Archive   => "archive",
149            Self::Csv       => "csv",
150            Self::Image     => "image",
151            Self::Audio     => "audio",
152            Self::Video     => "video",
153            Self::Font      => "font",
154            Self::Sevenz    => "7zip",
155            Self::Svg       => "svg",
156            Self::Pdf       => "pdf",
157            Self::Iso       => "iso",
158            Self::Notebook  => "notebook",
159            Self::Office    => "office",
160            Self::Epub      => "epub",
161            Self::Torrent   => "torrent",
162            Self::Default   => "default",
163        };
164        write!(f, "{}", repr)
165    }
166}
167
168/// Different kind of preview used to display some informaitons
169/// About the file.
170/// We check if it's an archive first, then a pdf file, an image, a media file
171#[derive(Default)]
172pub enum Preview {
173    Syntaxed(HLContent),
174    Text(Text),
175    Binary(BinaryContent),
176    Image(DisplayedImage),
177    Tree(Tree),
178    #[default]
179    Empty,
180}
181
182impl Preview {
183    /// The size (most of the time the number of lines) of the preview.
184    /// Some preview (thumbnail, empty) can't be scrolled and their size is always 0.
185    pub fn len(&self) -> usize {
186        match self {
187            Self::Empty => 0,
188            Self::Syntaxed(preview) => preview.len(),
189            Self::Text(preview) => preview.len(),
190            Self::Binary(preview) => preview.len(),
191            Self::Image(preview) => preview.len(),
192            Self::Tree(tree) => tree.displayable().lines().len(),
193        }
194    }
195
196    pub fn kind_display(&self) -> &str {
197        match self {
198            Self::Empty => "empty",
199            Self::Syntaxed(_) => "an highlighted text",
200            Self::Text(text) => text.kind.for_first_line(),
201            Self::Binary(_) => "a binary file",
202            Self::Image(image) => image.kind.for_first_line(),
203            Self::Tree(_) => "a tree",
204        }
205    }
206
207    /// True if nothing is currently previewed.
208    pub fn is_empty(&self) -> bool {
209        matches!(self, Self::Empty)
210    }
211
212    /// Creates a new, static window used when we display a preview in the second pane
213    pub fn window_for_second_pane(&self, height: usize) -> ContentWindow {
214        ContentWindow::new(self.len(), height)
215    }
216
217    pub fn filepath(&self) -> Arc<Path> {
218        match self {
219            Self::Empty => Arc::from(Path::new("")),
220            Self::Binary(preview) => preview.filepath(),
221            Self::Image(preview) => preview.filepath(),
222            Self::Syntaxed(preview) => preview.filepath(),
223            Self::Text(preview) => preview.filepath(),
224            Self::Tree(tree) => Arc::from(tree.root_path()),
225        }
226    }
227}
228
229/// Builder of previews. It just knows what file asked a preview.
230/// Using a builder is useful since there's many kind of preview which all use a different method.
231pub struct PreviewBuilder {
232    path: PathBuf,
233}
234
235impl PreviewBuilder {
236    const CONTENT_INSPECTOR_MIN_SIZE: usize = 1024;
237
238    pub fn new(path: &Path) -> Self {
239        Self {
240            path: path.to_owned(),
241        }
242    }
243
244    /// Empty preview, holding nothing.
245    pub fn empty() -> Preview {
246        clear_tmp_files();
247        Preview::Empty
248    }
249
250    /// Creates a new preview instance based on the filekind and the extension of
251    /// the file.
252    /// Sometimes it reads the content of the file, sometimes it delegates
253    /// it to the display method.
254    /// Directories aren't handled there since we need more arguments to create
255    /// their previews.
256    pub fn build(self) -> Result<Preview> {
257        if let Some(preview) = self.command_preview() {
258            return Ok(preview);
259        }
260        if let Some(preview) = self.plugin_preview() {
261            return Ok(preview);
262        }
263        self.internal_preview()
264    }
265
266    fn plugin_preview(&self) -> Option<Preview> {
267        if let Some(plugins) = get_previewer_plugins() {
268            try_build_plugin(&self.path, plugins)
269        } else {
270            None
271        }
272    }
273
274    fn internal_preview(&self) -> Result<Preview> {
275        clear_tmp_files();
276        let file_kind = FileKind::new(&symlink_metadata(&self.path)?, &self.path);
277        match file_kind {
278            FileKind::Directory => self.directory(),
279            FileKind::NormalFile => self.normal_file(),
280            FileKind::Socket if is_in_path(SS) => self.socket(),
281            FileKind::BlockDevice if is_in_path(LSBLK) => self.block_device(),
282            FileKind::Fifo | FileKind::CharDevice if is_in_path(UDEVADM) => self.fifo_chardevice(),
283            FileKind::SymbolicLink(true) => self.valid_symlink(),
284            _ => Ok(Preview::default()),
285        }
286    }
287
288    /// Creates a new `Directory` from the self.file_info
289    /// It explores recursivelly the directory and creates a tree.
290    /// The recursive exploration is limited to depth 2.
291    fn directory(&self) -> Result<Preview> {
292        let users = Users::default();
293        Ok(Preview::Tree(
294            TreeBuilder::new(std::sync::Arc::from(self.path.as_path()), &users)
295                .with_max_depth(4)
296                .with_hidden(false)
297                .with_filter_kind(&FilterKind::All)
298                .build(),
299        ))
300    }
301
302    fn valid_symlink(&self) -> Result<Preview> {
303        Self::new(&std::fs::read_link(&self.path).unwrap_or_default()).build()
304    }
305
306    fn normal_file(&self) -> Result<Preview> {
307        let extension = extract_extension(&self.path)
308            .trim_end_matches(['~', '_'])
309            .to_lowercase();
310        let kind = ExtensionKind::matcher(&extension);
311        match kind {
312            ExtensionKind::Archive if kind.has_programs() => {
313                Ok(Preview::Text(Text::archive(&self.path, &extension)?))
314            }
315            ExtensionKind::Csv if kind.has_programs() => Ok(Preview::Text(Text::csv(&self.path)?)),
316            ExtensionKind::Sevenz if kind.has_programs() => {
317                Ok(Preview::Text(Text::sevenz(&self.path)?))
318            }
319            ExtensionKind::Iso if kind.has_programs() => Ok(Preview::Text(Text::iso(&self.path)?)),
320            ExtensionKind::Epub if kind.has_programs() => Ok(Preview::Text(
321                Text::epub(&self.path).context("Preview: Couldn't read epub")?,
322            )),
323            ExtensionKind::Torrent if kind.has_programs() => Ok(Preview::Text(
324                Text::torrent(&self.path).context("Preview: Couldn't read torrent")?,
325            )),
326            ExtensionKind::Notebook if kind.has_programs() => {
327                Ok(Self::notebook(&self.path).context("Preview: Couldn't parse notebook")?)
328            }
329            ExtensionKind::Audio if kind.has_programs() => {
330                Ok(Preview::Text(Text::media_content(&self.path)?))
331            }
332            _ if kind.is_image_kind() && kind.has_programs() && images_are_enabled() => {
333                Self::image(&self.path, kind)
334            }
335            _ if kind.is_image_kind() => Self::text_image(&self.path, kind),
336            _ => match self.syntaxed(&extension) {
337                Some(syntaxed_preview) => Ok(syntaxed_preview),
338                None => self.text_or_binary(),
339            },
340        }
341    }
342
343    fn image(path: &Path, kind: ExtensionKind) -> Result<Preview> {
344        let preview = DisplayedImageBuilder::new(path, kind.into()).build()?;
345        if preview.is_empty() {
346            Ok(Preview::Empty)
347        } else {
348            Ok(Preview::Image(preview))
349        }
350    }
351
352    fn text_image(path: &Path, kind: ExtensionKind) -> Result<Preview> {
353        let preview = match kind {
354            ExtensionKind::Image | ExtensionKind::Video if is_in_path(MEDIAINFO) => {
355                Preview::Text(Text::media_content(path)?)
356            }
357            ExtensionKind::Pdf if is_in_path(PDFTOTEXT) => Preview::Text(Text::pdf_text(path)?),
358            ExtensionKind::Office if is_in_path(LIBREOFFICE) => {
359                Preview::Text(Text::office_text(path)?)
360            }
361            ExtensionKind::Font | ExtensionKind::Svg => Preview::Binary(BinaryContent::new(path)?),
362            _ => Preview::Empty,
363        };
364        Ok(preview)
365    }
366
367    fn socket(&self) -> Result<Preview> {
368        Ok(Preview::Text(Text::socket(&self.path)?))
369    }
370
371    fn block_device(&self) -> Result<Preview> {
372        Ok(Preview::Text(Text::block_device(&self.path)?))
373    }
374
375    fn fifo_chardevice(&self) -> Result<Preview> {
376        Ok(Preview::Text(Text::fifo_chardevice(&self.path)?))
377    }
378
379    fn syntaxed(&self, ext: &str) -> Option<Preview> {
380        if symlink_metadata(&self.path).ok()?.len() > HLContent::SIZE_LIMIT as u64 {
381            return None;
382        };
383        let ss = SyntaxSet::load_defaults_nonewlines();
384        Some(Preview::Syntaxed(
385            HLContent::new(&self.path, ss.clone(), ss.find_syntax_by_extension(ext)?)
386                .unwrap_or_default(),
387        ))
388    }
389
390    fn notebook(path: &Path) -> Option<Preview> {
391        let path_str = path.to_str()?;
392        // nbconvert is bundled with jupyter, no need to check again
393        let output = execute_and_capture_output_without_check(
394            JUPYTER,
395            &["nbconvert", "--to", "markdown", path_str, "--stdout"],
396        )
397        .ok()?;
398        Self::syntaxed_from_str(output, "md")
399    }
400
401    fn syntaxed_from_str(output: String, ext: &str) -> Option<Preview> {
402        let ss = SyntaxSet::load_defaults_nonewlines();
403        Some(Preview::Syntaxed(
404            HLContent::from_str(
405                Path::new("command"),
406                &output,
407                ss.clone(),
408                ss.find_syntax_by_extension(ext)?,
409            )
410            .unwrap_or_default(),
411        ))
412    }
413
414    fn text_or_binary(&self) -> Result<Preview> {
415        if let Some(elf) = self.read_elf() {
416            Ok(Preview::Text(Text::from_readelf(&self.path, elf)?))
417        } else if self.is_binary()? {
418            Ok(Preview::Binary(BinaryContent::new(&self.path)?))
419        } else {
420            Ok(Preview::Text(Text::from_file(&self.path)?))
421        }
422    }
423
424    fn read_elf(&self) -> Option<String> {
425        let Ok(output) = execute_and_capture_output_without_check(
426            READELF,
427            &["-WCa", self.path.to_string_lossy().as_ref()],
428        ) else {
429            return None;
430        };
431        if output.is_empty() {
432            None
433        } else {
434            Some(output)
435        }
436    }
437
438    fn is_binary(&self) -> Result<bool> {
439        let mut file = std::fs::File::open(&self.path)?;
440        let mut buffer = [0; Self::CONTENT_INSPECTOR_MIN_SIZE];
441        let Ok(metadata) = self.path.metadata() else {
442            return Ok(false);
443        };
444
445        Ok(metadata.len() >= Self::CONTENT_INSPECTOR_MIN_SIZE as u64
446            && file.read_exact(&mut buffer).is_ok()
447            && inspect(&buffer) == ContentType::BINARY)
448    }
449
450    /// Creates the help preview as if it was a text file.
451    pub fn help(help: &str) -> Preview {
452        Preview::Text(Text::help(help))
453    }
454
455    pub fn log(log: Vec<String>) -> Preview {
456        Preview::Text(Text::log(log))
457    }
458
459    pub fn cli_info(output: &str, command: String) -> Preview {
460        crate::log_info!("cli_info. command {command} - output\n{output}");
461        Preview::Text(Text::command_stdout(
462            output,
463            command,
464            Arc::from(Path::new("")),
465        ))
466    }
467
468    pub fn plugin_text(text: String, name: &str, path: &Path) -> Preview {
469        Preview::Text(Text::plugin(text, name, path))
470    }
471
472    fn command_preview(&self) -> Option<Preview> {
473        let extension = self.path.extension()?.to_string_lossy().to_string();
474        let commands = get_previewer_command()?;
475        log_info!("{extension} - {commands:?}");
476        for command in commands.iter() {
477            if command.extensions.contains(&extension) {
478                return command.preview(&self.path).ok();
479            }
480        }
481
482        None
483    }
484}
485
486/// Holds info about a command used to preview.
487/// Its name, which extension it may preview and the full command.
488#[derive(Debug, PartialEq, Serialize, Deserialize)]
489pub struct PreviewerCommand {
490    name: String,
491    extensions: Vec<String>,
492    command: String,
493}
494
495impl PreviewerCommand {
496    fn preview(&self, path: &Path) -> Result<Preview> {
497        let command = self
498            .command
499            .replace("%s", &path.display().to_string().quote()?);
500        let args: Vec<_> = command.split_whitespace().collect();
501        Ok(Preview::Text(Text::from_command_output(
502            TextKind::Plugin,
503            args[0],
504            &args[1..],
505            path,
506        )?))
507    }
508}
509
510/// Reads a number of lines from a text file, _removing all ANSI control characters_.
511/// Returns a vector of strings.
512fn read_nb_lines(path: &Path, size_limit: usize) -> Result<Vec<String>> {
513    let re = Regex::new(r"[[:cntrl:]]").unwrap();
514    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
515    Ok(reader
516        .lines()
517        .take(size_limit)
518        .map(|line| line.unwrap_or_default())
519        .map(|s| re.replace_all(&s, "").to_string())
520        .collect())
521}
522
523/// Different kind of text previewed.
524/// Wether it's a text file or the output of a command.
525#[derive(Clone, Default, Debug)]
526pub enum TextKind {
527    #[default]
528    TEXTFILE,
529
530    Archive,
531    Blockdevice,
532    CommandStdout,
533    Csv,
534    Elf,
535    Epub,
536    FifoChardevice,
537    Help,
538    Iso,
539    Log,
540    Mediacontent,
541    Office,
542    Pdf,
543    Plugin,
544    Sevenz,
545    Socket,
546    Sqlite3,
547    Torrent,
548}
549
550impl TextKind {
551    /// Used to display the kind of content in this file.
552    pub fn for_first_line(&self) -> &'static str {
553        match self {
554            Self::TEXTFILE => "a textfile",
555            Self::Archive => "an archive",
556            Self::Csv => "a CSV file",
557            Self::Blockdevice => "a Blockdevice file",
558            Self::CommandStdout => "a command stdout",
559            Self::Elf => "an elf file",
560            Self::Epub => "an epub",
561            Self::FifoChardevice => "a Fifo or Chardevice file",
562            Self::Help => "Help",
563            Self::Iso => "Iso",
564            Self::Log => "Log",
565            Self::Plugin => "a text",
566            Self::Office => "a doc",
567            Self::Mediacontent => "a media content",
568            Self::Pdf => "a pdf",
569            Self::Sevenz => "a 7z archive",
570            Self::Socket => "a Socket file",
571            Self::Sqlite3 => "a Sqlite3 database file",
572            Self::Torrent => "a torrent",
573        }
574    }
575}
576
577impl Display for TextKind {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        writeln!(f, "{kind_str}", kind_str = self.for_first_line())
580    }
581}
582
583/// Holds a preview of a text content.
584/// It's a vector of strings (per line)
585#[derive(Clone, Debug)]
586pub struct Text {
587    pub kind: TextKind,
588    pub title: String,
589    filepath: Arc<Path>,
590    content: Vec<String>,
591    length: usize,
592}
593
594impl Default for Text {
595    fn default() -> Self {
596        Self {
597            kind: TextKind::default(),
598            title: String::default(),
599            filepath: Arc::from(Path::new("")),
600            content: vec![],
601            length: 0,
602        }
603    }
604}
605
606impl Text {
607    /// Only files with less than 1MiB will be read
608    const SIZE_LIMIT: usize = 1 << 20;
609
610    fn help(help: &str) -> Self {
611        let content: Vec<String> = help.lines().map(|line| line.to_owned()).collect();
612        Self {
613            title: "Help".to_string(),
614            kind: TextKind::Help,
615            filepath: Arc::from(Path::new("")),
616            length: content.len(),
617            content,
618        }
619    }
620
621    fn plugin(text: String, name: &str, path: &Path) -> Self {
622        let content: Vec<String> = text.lines().map(|line| line.to_owned()).collect();
623        Self {
624            title: name.to_string(),
625            kind: TextKind::Plugin,
626            length: content.len(),
627            filepath: Arc::from(path),
628            content,
629        }
630    }
631
632    fn log(content: Vec<String>) -> Self {
633        Self {
634            title: "Logs".to_string(),
635            kind: TextKind::Log,
636            length: content.len(),
637            filepath: Arc::from(Path::new(ACTION_LOG_PATH)),
638            content,
639        }
640    }
641
642    fn epub(path: &Path) -> Option<Self> {
643        let path_str = path.to_str()?;
644        let output = execute_and_capture_output_without_check(
645            PANDOC,
646            &["-s", "-t", "plain", "--", path_str],
647        )
648        .ok()?;
649        let content: Vec<String> = output.lines().map(|line| line.to_owned()).collect();
650        Some(Self {
651            title: "Epub".to_string(),
652            kind: TextKind::Epub,
653            length: content.len(),
654            filepath: Arc::from(path),
655            content,
656        })
657    }
658
659    fn from_file(path: &Path) -> Result<Self> {
660        let content = read_nb_lines(path, Self::SIZE_LIMIT)?;
661        Ok(Self {
662            title: filename_from_path(path).context("")?.to_owned(),
663            kind: TextKind::TEXTFILE,
664            filepath: Arc::from(path),
665            length: content.len(),
666            content,
667        })
668    }
669
670    fn from_readelf(path: &Path, elf: String) -> Result<Self> {
671        Ok(Self {
672            title: filename_from_path(path).context("")?.to_owned(),
673            kind: TextKind::Elf,
674            length: elf.len(),
675            filepath: Arc::from(path),
676            content: elf.lines().map(|line| line.to_owned()).collect(),
677        })
678    }
679
680    fn from_command_output(
681        kind: TextKind,
682        command: &str,
683        args: &[&str],
684        filepath: &Path,
685    ) -> Result<Self> {
686        let content: Vec<String> = execute_and_capture_output_without_check(command, args)?
687            .lines()
688            .map(|s| s.to_owned())
689            .collect();
690        Ok(Self {
691            title: command.to_owned(),
692            kind,
693            length: content.len(),
694            filepath: filepath.into(),
695            content,
696        })
697    }
698
699    fn media_content(path: &Path) -> Result<Self> {
700        Self::from_command_output(
701            TextKind::Mediacontent,
702            MEDIAINFO,
703            &[path_to_string(&path).as_str()],
704            path,
705        )
706    }
707
708    fn pdf_text(path: &Path) -> Result<Self> {
709        Self::from_command_output(
710            TextKind::Pdf,
711            PDFTOTEXT,
712            &[path_to_string(&path).as_str()],
713            path,
714        )
715    }
716
717    fn office_text(path: &Path) -> Result<Self> {
718        Self::from_command_output(
719            TextKind::Office,
720            LIBREOFFICE,
721            &["--cat", path_to_string(&path).as_str()],
722            path,
723        )
724    }
725
726    /// Holds a list of file of an archive as returned by
727    /// `ZipArchive::file_names` or from  a `tar tvf` command.
728    /// A generic error message prevent it from returning an error.
729    fn archive(path: &Path, ext: &str) -> Result<Self> {
730        let content = match ext {
731            "zip" => list_files_zip(path).unwrap_or(vec!["Invalid Zip content".to_owned()]),
732            "zst" | "gz" | "bz" | "xz" | "gzip" | "bzip2" | "deb" | "rpm" => {
733                list_files_tar(path).unwrap_or(vec!["Invalid Tar content".to_owned()])
734            }
735            _ => vec![format!("Unsupported format: {ext}")],
736        };
737
738        Ok(Self {
739            title: filename_from_path(path).context("")?.to_owned(),
740            kind: TextKind::Archive,
741            filepath: Arc::from(path),
742            length: content.len(),
743            content,
744        })
745    }
746
747    fn csv(path: &Path) -> Result<Self> {
748        let Ok(delimiter) = Self::snif_csv_delimiter(path) else {
749            return Self::from_file(path);
750        };
751        Self::from_command_output(
752            TextKind::Csv,
753            COLUMN,
754            &[
755                &format!("-s{delimiter}"),
756                "-t",
757                path_to_string(&path).as_str(),
758            ],
759            path,
760        )
761    }
762
763    const CSV_DELIMITERS: [char; 5] = [',', ';', '\t', ' ', ':'];
764
765    fn snif_csv_delimiter(path: &Path) -> Result<char> {
766        let content = read_to_string(path)?;
767        let mut pairs = vec![];
768        for delimiter in Self::CSV_DELIMITERS {
769            let count = content.replace(delimiter, "").len();
770            pairs.push((delimiter, count));
771        }
772        let res = pairs
773            .iter()
774            .min_by_key(|(_, count)| *count)
775            .context("Can't be empty")?
776            .0;
777        Ok(res)
778    }
779
780    fn sevenz(path: &Path) -> Result<Self> {
781        Self::from_command_output(
782            TextKind::Sevenz,
783            SEVENZ,
784            &["l", &path_to_string(&path)],
785            path,
786        )
787    }
788
789    fn iso(path: &Path) -> Result<Self> {
790        Self::from_command_output(
791            TextKind::Iso,
792            ISOINFO,
793            &["-l", "-i", &path_to_string(&path)],
794            path,
795        )
796    }
797
798    fn torrent(path: &Path) -> Result<Self> {
799        Self::from_command_output(
800            TextKind::Torrent,
801            TRANSMISSION_SHOW,
802            &[&path_to_string(&path)],
803            path,
804        )
805    }
806
807    /// New socket preview
808    /// See `man ss` for a description of the arguments.
809    fn socket(path: &Path) -> Result<Self> {
810        let mut preview = Self::from_command_output(TextKind::Socket, SS, &["-lpmepiT"], path)?;
811        preview.content = preview
812            .content
813            .iter()
814            .filter(|l| l.contains(path.file_name().unwrap().to_string_lossy().as_ref()))
815            .map(|s| s.to_owned())
816            .collect();
817        Ok(preview)
818    }
819
820    /// New blockdevice preview
821    /// See `man lsblk` for a description of the arguments.
822    fn block_device(path: &Path) -> Result<Self> {
823        Self::from_command_output(
824            TextKind::Blockdevice,
825            LSBLK,
826            &[
827                "-lfo",
828                "FSTYPE,PATH,LABEL,UUID,FSVER,MOUNTPOINT,MODEL,SIZE,FSAVAIL,FSUSE%",
829                &path_to_string(&path),
830            ],
831            path,
832        )
833    }
834
835    /// New FIFO preview
836    /// See `man udevadm` for a description of the arguments.
837    fn fifo_chardevice(path: &Path) -> Result<Self> {
838        Self::from_command_output(
839            TextKind::FifoChardevice,
840            UDEVADM,
841            &[
842                "info",
843                "-a",
844                "-n",
845                path_to_string(&path).as_str(),
846                "--no-pager",
847            ],
848            path,
849        )
850    }
851    /// Make a new previewed colored text.
852    pub fn command_stdout(output: &str, title: String, filepath: Arc<Path>) -> Self {
853        let content: Vec<String> = output.lines().map(|line| line.to_owned()).collect();
854        let length = content.len();
855        Self {
856            title,
857            kind: TextKind::CommandStdout,
858            content,
859            filepath,
860            length,
861        }
862    }
863
864    fn len(&self) -> usize {
865        self.length
866    }
867
868    fn filepath(&self) -> Arc<Path> {
869        self.filepath.clone()
870    }
871}
872
873/// Holds a preview of a code text file whose language is supported by `Syntect`.
874/// The file is colored propery and line numbers are shown.
875#[derive(Clone)]
876pub struct HLContent {
877    path: Arc<Path>,
878    content: Vec<Vec<SyntaxedString>>,
879    length: usize,
880}
881
882impl Default for HLContent {
883    fn default() -> Self {
884        Self {
885            path: Arc::from(Path::new("")),
886            content: vec![],
887            length: 0,
888        }
889    }
890}
891
892impl HLContent {
893    /// Only files with less than 32kiB will be read
894    const SIZE_LIMIT: usize = 1 << 15;
895
896    /// Creates a new displayable content of a syntect supported file.
897    /// It may fail if the file isn't properly formatted or the extension
898    /// is wrong (ie. python content with .c extension).
899    /// ATM only Monokaï (dark) theme is supported.
900    fn new(path: &Path, syntax_set: SyntaxSet, syntax_ref: &SyntaxReference) -> Result<Self> {
901        let raw_content = read_nb_lines(path, Self::SIZE_LIMIT)?;
902        Self::build(path, raw_content, syntax_set, syntax_ref)
903    }
904
905    fn from_str(
906        name: &Path,
907        text: &str,
908        syntax_set: SyntaxSet,
909        syntax_ref: &SyntaxReference,
910    ) -> Result<Self> {
911        let raw_content = text
912            .lines()
913            .take(Self::SIZE_LIMIT)
914            .map(|s| s.to_owned())
915            .collect();
916        Self::build(name, raw_content, syntax_set, syntax_ref)
917    }
918
919    fn build(
920        path: &Path,
921        raw_content: Vec<String>,
922        syntax_set: SyntaxSet,
923        syntax_ref: &SyntaxReference,
924    ) -> Result<Self> {
925        let highlighted_content = Self::parse_raw_content(raw_content, syntax_set, syntax_ref)?;
926        Ok(Self {
927            path: path.into(),
928            length: highlighted_content.len(),
929            content: highlighted_content,
930        })
931    }
932
933    fn len(&self) -> usize {
934        self.length
935    }
936
937    fn filepath(&self) -> Arc<Path> {
938        self.path.clone()
939    }
940
941    fn parse_raw_content(
942        raw_content: Vec<String>,
943        syntax_set: SyntaxSet,
944        syntax_ref: &SyntaxReference,
945    ) -> Result<Vec<Vec<SyntaxedString>>> {
946        let mut highlighted_content = vec![];
947        let syntect_theme = get_syntect_theme().context("Syntect set should be set")?;
948        let mut highlighter = HighlightLines::new(syntax_ref, syntect_theme);
949
950        for line in raw_content.iter() {
951            let mut v_line = vec![];
952            if let Ok(v) = highlighter.highlight_line(line, &syntax_set) {
953                for (style, token) in v.iter() {
954                    v_line.push(SyntaxedString::from_syntect(token, *style));
955                }
956            }
957            highlighted_content.push(v_line)
958        }
959
960        Ok(highlighted_content)
961    }
962}
963
964/// Holds a string to be displayed with given .
965/// We have to read the  from Syntect and parse it into ratatui attr
966/// This struct does the parsing.
967#[derive(Clone)]
968pub struct SyntaxedString {
969    pub content: String,
970    pub style: Style,
971}
972
973impl SyntaxedString {
974    /// Parse a content and style into a `SyntaxedString`
975    /// Only the foreground color is read, we don't the background nor
976    /// the style (bold, italic, underline) defined in Syntect.
977    fn from_syntect(content: &str, style: SyntectStyle) -> Self {
978        let content = content.to_owned();
979        let fg = style.foreground;
980        let style = Style {
981            fg: Some(Color::Rgb(fg.r, fg.g, fg.b)),
982            bg: None,
983            add_modifier: Self::font_style_to_effect(&style.font_style),
984            sub_modifier: Modifier::empty(),
985            underline_color: None,
986        };
987        Self { content, style }
988    }
989
990    fn font_style_to_effect(font_style: &FontStyle) -> Modifier {
991        let mut modifier = Modifier::empty();
992
993        // If the FontStyle has the bold bit set, add bold to the Effect
994        if font_style.contains(FontStyle::BOLD) {
995            modifier |= Modifier::BOLD;
996        }
997
998        // If the FontStyle has the underline bit set, add underline to the Modifier
999        if font_style.contains(FontStyle::UNDERLINE) {
1000            modifier |= Modifier::UNDERLINED;
1001        }
1002
1003        modifier
1004    }
1005}
1006
1007/// Holds a preview of a binary content.
1008/// It doesn't try to respect endianness.
1009/// The lines are formatted to display 16 bytes.
1010/// The number of lines is truncated to $2^20 = 1048576$.
1011#[derive(Clone)]
1012pub struct BinaryContent {
1013    pub path: Arc<Path>,
1014    length: u64,
1015    content: Vec<Line>,
1016}
1017
1018impl Default for BinaryContent {
1019    fn default() -> Self {
1020        Self {
1021            path: Arc::from(Path::new("")),
1022            length: 0,
1023            content: vec![],
1024        }
1025    }
1026}
1027
1028impl BinaryContent {
1029    const LINE_WIDTH: usize = 16;
1030    const SIZE_LIMIT: usize = 1048576;
1031
1032    fn new(path: &Path) -> Result<Self> {
1033        let Ok(metadata) = path.metadata() else {
1034            return Ok(Self::default());
1035        };
1036        let length = metadata.len() / Self::LINE_WIDTH as u64;
1037        let content = Self::read_content(path)?;
1038
1039        Ok(Self {
1040            path: path.into(),
1041            length,
1042            content,
1043        })
1044    }
1045
1046    fn read_content(path: &Path) -> Result<Vec<Line>> {
1047        let mut reader = BufReader::new(std::fs::File::open(path)?);
1048        let mut buffer = [0; Self::LINE_WIDTH];
1049        let mut content = vec![];
1050        while let Ok(nb_bytes_read) = reader.read(&mut buffer[..]) {
1051            if nb_bytes_read != Self::LINE_WIDTH {
1052                content.push(Line::new((&buffer[0..nb_bytes_read]).into()));
1053                break;
1054            }
1055            content.push(Line::new(buffer.into()));
1056            if content.len() >= Self::SIZE_LIMIT {
1057                break;
1058            }
1059        }
1060        Ok(content)
1061    }
1062
1063    /// WATCHOUT !
1064    /// Doesn't return the size of the file, like similar methods in other variants.
1065    /// It returns the number of **lines**.
1066    /// It's the size of the file divided by `BinaryContent::LINE_WIDTH` which is 16.
1067    pub fn len(&self) -> usize {
1068        self.length as usize
1069    }
1070
1071    pub fn is_empty(&self) -> bool {
1072        self.length == 0
1073    }
1074
1075    pub fn filepath(&self) -> Arc<Path> {
1076        self.path.clone()
1077    }
1078
1079    pub fn number_width_hex(&self) -> usize {
1080        format!("{:x}", self.len() * 16).len()
1081    }
1082}
1083
1084/// Holds a `Vec` of "bytes" (`u8`).
1085/// It's mostly used to implement a `print` method.
1086#[derive(Clone)]
1087pub struct Line {
1088    line: Vec<u8>,
1089}
1090
1091impl Line {
1092    fn new(line: Vec<u8>) -> Self {
1093        Self { line }
1094    }
1095
1096    /// Format a line of 16 bytes as BigEndian, separated by spaces.
1097    /// Every byte is zero filled if necessary.
1098    pub fn format_hex(&self) -> String {
1099        let mut hex_repr = String::new();
1100        for (i, byte) in self.line.iter().enumerate() {
1101            let _ = write!(hex_repr, "{byte:02x}");
1102            if i % 2 == 1 {
1103                hex_repr.push(' ');
1104            }
1105        }
1106        hex_repr
1107    }
1108
1109    /// Converts a byte into '.' if it represent a non ASCII printable char
1110    /// or it's corresponding char.
1111    fn byte_to_char(byte: &u8) -> char {
1112        let ch = *byte as char;
1113        if ch.is_ascii_graphic() {
1114            ch
1115        } else {
1116            '.'
1117        }
1118    }
1119
1120    /// Format a line of 16 bytes as an ASCII string.
1121    /// Non ASCII printable bytes are replaced by dots.
1122    pub fn format_as_ascii(&self) -> String {
1123        self.line.iter().map(Self::byte_to_char).collect()
1124    }
1125
1126    pub fn format_line_nr_hex(line_nr: usize, width: usize) -> String {
1127        format!("{line_nr:0width$x}  ")
1128    }
1129}
1130
1131/// Common trait for many preview methods which are just a bunch of lines with
1132/// no specific formatting.
1133/// Some previewing (thumbnail and syntaxed text) needs more details.
1134pub trait TakeSkip<T> {
1135    fn take_skip(&self, top: usize, bottom: usize, length: usize) -> Take<Skip<Iter<'_, T>>>;
1136}
1137
1138macro_rules! impl_take_skip {
1139    ($t:ident, $u:ident) => {
1140        impl TakeSkip<$u> for $t {
1141            fn take_skip(
1142                &self,
1143                top: usize,
1144                bottom: usize,
1145                length: usize,
1146            ) -> Take<Skip<Iter<'_, $u>>> {
1147                self.content.iter().skip(top).take(min(length, bottom + 1))
1148            }
1149        }
1150    };
1151}
1152/// Common trait for many preview methods which are just a bunch of lines with
1153/// no specific formatting.
1154/// Some previewing (thumbnail and syntaxed text) needs more details.
1155pub trait TakeSkipEnum<T> {
1156    fn take_skip_enum(
1157        &self,
1158        top: usize,
1159        bottom: usize,
1160        length: usize,
1161    ) -> Take<Skip<Enumerate<Iter<'_, T>>>>;
1162}
1163
1164macro_rules! impl_take_skip_enum {
1165    ($t:ident, $u:ident) => {
1166        impl TakeSkipEnum<$u> for $t {
1167            fn take_skip_enum(
1168                &self,
1169                top: usize,
1170                bottom: usize,
1171                length: usize,
1172            ) -> Take<Skip<Enumerate<Iter<'_, $u>>>> {
1173                self.content
1174                    .iter()
1175                    .enumerate()
1176                    .skip(top)
1177                    .take(min(length, bottom + 1))
1178            }
1179        }
1180    };
1181}
1182
1183/// A vector of highlighted strings
1184pub type VecSyntaxedString = Vec<SyntaxedString>;
1185
1186impl_take_skip_enum!(HLContent, VecSyntaxedString);
1187impl_take_skip_enum!(Text, String);
1188impl_take_skip_enum!(BinaryContent, Line);
1189impl_take_skip_enum!(TreeLines, TLine);
1190
1191impl_take_skip!(HLContent, VecSyntaxedString);
1192impl_take_skip!(Text, String);
1193impl_take_skip!(BinaryContent, Line);
1194impl_take_skip!(TreeLines, TLine);