television_previewers/previewers/
files.rs

1use parking_lot::Mutex;
2use rustc_hash::{FxBuildHasher, FxHashSet};
3use std::collections::HashSet;
4use std::fs::File;
5use std::io::{BufRead, BufReader, Seek};
6use std::path::PathBuf;
7use std::sync::{
8    atomic::{AtomicU8, Ordering},
9    Arc,
10};
11use television_utils::files::{read_into_lines_capped, ReadResult};
12use television_utils::syntax::HighlightedLines;
13
14use syntect::{highlighting::Theme, parsing::SyntaxSet};
15use tracing::{debug, warn};
16
17use super::cache::PreviewCache;
18use crate::previewers::{meta, Preview, PreviewContent};
19use television_channels::entry;
20use television_utils::{
21    files::FileType,
22    strings::preprocess_line,
23    syntax::{self, load_highlighting_assets, HighlightingAssetsExt},
24};
25
26#[derive(Debug, Default)]
27pub struct FilePreviewer {
28    cache: Arc<Mutex<PreviewCache>>,
29    pub syntax_set: Arc<SyntaxSet>,
30    pub syntax_theme: Arc<Theme>,
31    concurrent_preview_tasks: Arc<AtomicU8>,
32    in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct FilePreviewerConfig {
37    pub theme: String,
38}
39
40impl FilePreviewerConfig {
41    pub fn new(theme: String) -> Self {
42        FilePreviewerConfig { theme }
43    }
44}
45
46const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
47
48const BAT_THEME_ENV_VAR: &str = "BAT_THEME";
49
50impl FilePreviewer {
51    pub fn new(config: Option<FilePreviewerConfig>) -> Self {
52        let hl_assets = load_highlighting_assets();
53        let syntax_set = hl_assets.get_syntax_set().unwrap().clone();
54
55        let theme_name = match std::env::var(BAT_THEME_ENV_VAR) {
56            Ok(t) => t,
57            Err(_) => match config {
58                Some(c) => c.theme,
59                // this will error and default back nicely
60                None => "unknown".to_string(),
61            },
62        };
63
64        let theme = hl_assets.get_theme_no_output(&theme_name).clone();
65
66        FilePreviewer {
67            cache: Arc::new(Mutex::new(PreviewCache::default())),
68            syntax_set: Arc::new(syntax_set),
69            syntax_theme: Arc::new(theme),
70            concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
71            in_flight_previews: Arc::new(Mutex::new(HashSet::with_hasher(
72                FxBuildHasher,
73            ))),
74        }
75    }
76
77    pub fn cached(&self, entry: &entry::Entry) -> Option<Arc<Preview>> {
78        self.cache.lock().get(&entry.name)
79    }
80
81    pub fn preview(&mut self, entry: &entry::Entry) -> Option<Arc<Preview>> {
82        if let Some(preview) = self.cached(entry) {
83            debug!("Preview cache hit for {:?}", entry.name);
84            if preview.partial_offset.is_some() {
85                // preview is partial, spawn a task to compute the next chunk
86                // and return the partial preview
87                debug!("Spawning partial preview task for {:?}", entry.name);
88                self.handle_preview_request(entry, Some(preview.clone()));
89            }
90            Some(preview)
91        } else {
92            // preview is not in cache, spawn a task to compute the preview
93            debug!("Preview cache miss for {:?}", entry.name);
94            self.handle_preview_request(entry, None);
95            None
96        }
97    }
98
99    pub fn handle_preview_request(
100        &mut self,
101        entry: &entry::Entry,
102        partial_preview: Option<Arc<Preview>>,
103    ) {
104        if self.in_flight_previews.lock().contains(&entry.name) {
105            debug!("Preview already in flight for {:?}", entry.name);
106        }
107
108        if self.concurrent_preview_tasks.load(Ordering::Relaxed)
109            < MAX_CONCURRENT_PREVIEW_TASKS
110        {
111            self.in_flight_previews.lock().insert(entry.name.clone());
112            self.concurrent_preview_tasks
113                .fetch_add(1, Ordering::Relaxed);
114            let cache = self.cache.clone();
115            let entry_c = entry.clone();
116            let syntax_set = self.syntax_set.clone();
117            let syntax_theme = self.syntax_theme.clone();
118            let concurrent_tasks = self.concurrent_preview_tasks.clone();
119            let in_flight_previews = self.in_flight_previews.clone();
120            tokio::spawn(async move {
121                try_preview(
122                    &entry_c,
123                    partial_preview,
124                    &cache,
125                    &syntax_set,
126                    &syntax_theme,
127                    &concurrent_tasks,
128                    &in_flight_previews,
129                );
130            });
131        }
132    }
133
134    #[allow(dead_code)]
135    fn cache_preview(&mut self, key: String, preview: &Arc<Preview>) {
136        self.cache.lock().insert(key, preview);
137    }
138}
139
140/// The size of the buffer used to read the file in bytes.
141/// This ends up being the max size of partial previews.
142const PARTIAL_BUFREAD_SIZE: usize = 64 * 1024;
143
144pub fn try_preview(
145    entry: &entry::Entry,
146    partial_preview: Option<Arc<Preview>>,
147    cache: &Arc<Mutex<PreviewCache>>,
148    syntax_set: &Arc<SyntaxSet>,
149    syntax_theme: &Arc<Theme>,
150    concurrent_tasks: &Arc<AtomicU8>,
151    in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
152) {
153    debug!("Computing preview for {:?}", entry.name);
154    let path = PathBuf::from(&entry.name);
155
156    // if we're dealing with a partial preview, no need to re-check for textual content
157    if partial_preview.is_some()
158        || matches!(FileType::from(&path), FileType::Text)
159    {
160        debug!("File is text-based: {:?}", entry.name);
161        match File::open(path) {
162            Ok(mut file) => {
163                // if we're dealing with a partial preview, seek to the provided offset
164                // and use the previous state to compute the next chunk of the preview
165                let cached_lines = if let Some(p) = partial_preview {
166                    if let PreviewContent::SyntectHighlightedText(hl) =
167                        &p.content
168                    {
169                        let _ = file.seek(std::io::SeekFrom::Start(
170                            // this is always Some in this case
171                            p.partial_offset.unwrap() as u64,
172                        ));
173                        Some(hl.clone())
174                    } else {
175                        None
176                    }
177                } else {
178                    None
179                };
180                // compute the highlighted version in the background
181                match read_into_lines_capped(file, PARTIAL_BUFREAD_SIZE) {
182                    ReadResult::Full(lines) => {
183                        if let Some(content) = compute_highlighted_text_preview(
184                            entry,
185                            &lines
186                                .iter()
187                                .map(|l| preprocess_line(l).0 + "\n")
188                                .collect::<Vec<_>>(),
189                            syntax_set,
190                            syntax_theme,
191                            &cached_lines,
192                        ) {
193                            let total_lines = content.total_lines();
194                            let preview = Arc::new(Preview::new(
195                                entry.name.clone(),
196                                content,
197                                entry.icon,
198                                None,
199                                total_lines,
200                            ));
201                            cache.lock().insert(entry.name.clone(), &preview);
202                        }
203                    }
204                    ReadResult::Partial(p) => {
205                        if let Some(content) = compute_highlighted_text_preview(
206                            entry,
207                            &p.lines
208                                .iter()
209                                .map(|l| preprocess_line(l).0 + "\n")
210                                .collect::<Vec<_>>(),
211                            syntax_set,
212                            syntax_theme,
213                            &cached_lines,
214                        ) {
215                            let total_lines = content.total_lines();
216                            let preview = Arc::new(Preview::new(
217                                entry.name.clone(),
218                                content,
219                                entry.icon,
220                                Some(p.bytes_read),
221                                total_lines,
222                            ));
223                            cache.lock().insert(entry.name.clone(), &preview);
224                        }
225                    }
226                    ReadResult::Error(e) => {
227                        warn!("Error reading file: {:?}", e);
228                        let p = meta::not_supported(&entry.name);
229                        cache.lock().insert(entry.name.clone(), &p);
230                    }
231                }
232            }
233            Err(e) => {
234                warn!("Error opening file: {:?}", e);
235                let p = meta::not_supported(&entry.name);
236                cache.lock().insert(entry.name.clone(), &p);
237            }
238        }
239    } else {
240        debug!("File isn't text-based: {:?}", entry.name);
241        let preview = meta::not_supported(&entry.name);
242        cache.lock().insert(entry.name.clone(), &preview);
243    }
244    concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
245    in_flight_previews.lock().remove(&entry.name);
246}
247
248fn compute_highlighted_text_preview(
249    entry: &entry::Entry,
250    lines: &[String],
251    syntax_set: &SyntaxSet,
252    syntax_theme: &Theme,
253    previous_lines: &Option<HighlightedLines>,
254) -> Option<PreviewContent> {
255    debug!(
256        "Computing highlights in the background for {:?}",
257        entry.name
258    );
259
260    match syntax::compute_highlights_incremental(
261        &PathBuf::from(&entry.name),
262        lines,
263        syntax_set,
264        syntax_theme,
265        previous_lines,
266    ) {
267        Ok(highlighted_lines) => {
268            Some(PreviewContent::SyntectHighlightedText(highlighted_lines))
269        }
270        Err(e) => {
271            warn!("Error computing highlights: {:?}", e);
272            None
273        }
274    }
275}
276
277/// This should be enough for most terminal sizes
278const TEMP_PLAIN_TEXT_PREVIEW_HEIGHT: usize = 200;
279
280#[allow(dead_code)]
281fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc<Preview> {
282    debug!("Creating plain text preview for {:?}", title);
283    let mut lines = Vec::with_capacity(TEMP_PLAIN_TEXT_PREVIEW_HEIGHT);
284    // PERF: instead of using lines(), maybe check for the length of the first line instead and
285    // truncate accordingly (since this is just a temp preview)
286    for maybe_line in reader.lines() {
287        match maybe_line {
288            Ok(line) => lines.push(preprocess_line(&line).0),
289            Err(e) => {
290                warn!("Error reading file: {:?}", e);
291                return meta::not_supported(title);
292            }
293        }
294        if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT {
295            break;
296        }
297    }
298    let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
299    Arc::new(Preview::new(
300        title.to_string(),
301        PreviewContent::PlainText(lines),
302        None,
303        None,
304        total_lines,
305    ))
306}