television_previewers/previewers/
files.rs1use 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 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 debug!("Spawning partial preview task for {:?}", entry.name);
88 self.handle_preview_request(entry, Some(preview.clone()));
89 }
90 Some(preview)
91 } else {
92 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
140const 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 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 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 p.partial_offset.unwrap() as u64,
172 ));
173 Some(hl.clone())
174 } else {
175 None
176 }
177 } else {
178 None
179 };
180 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
277const 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 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}