From a3dc8196aa5199bedfd62b640c4020a92df9d9d7 Mon Sep 17 00:00:00 2001 From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com> Date: Sun, 19 Jan 2025 18:16:34 +0100 Subject: [PATCH] perf(preview): add partial preview rendering and buffer preview requests (#285) --- Cargo.lock | 51 ++-- Cargo.toml | 5 +- crates/television-channels/Cargo.toml | 2 +- crates/television-previewers/Cargo.toml | 2 +- .../television-previewers/src/previewers.rs | 87 +++++-- .../src/previewers/basic.rs | 3 +- .../src/previewers/command.rs | 61 +++-- .../src/previewers/env.rs | 19 +- .../src/previewers/files.rs | 185 +++++++++------ .../src/previewers/meta.rs | 19 +- crates/television-screen/Cargo.toml | 1 + crates/television-screen/src/cache.rs | 50 +++- crates/television-screen/src/preview.rs | 219 +++++++++++++----- crates/television-utils/Cargo.toml | 3 +- crates/television-utils/src/cache.rs | 16 ++ crates/television-utils/src/files.rs | 47 ++++ crates/television-utils/src/strings.rs | 2 +- crates/television-utils/src/syntax.rs | 169 ++++++++++++-- crates/television/television.rs | 26 ++- 19 files changed, 723 insertions(+), 244 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9a0529..a0db65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -949,6 +964,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "faster-hex" version = "0.9.0" @@ -2210,28 +2235,6 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" -[[package]] -name = "onig" -version = "6.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" -dependencies = [ - "bitflags 1.3.2", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "oorandom" version = "11.1.4" @@ -2979,10 +2982,10 @@ checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" dependencies = [ "bincode", "bitflags 1.3.2", + "fancy-regex", "flate2", "fnv", "once_cell", - "onig", "plist", "regex-syntax 0.8.5", "serde", @@ -3093,6 +3096,7 @@ name = "television-screen" version = "0.0.21" dependencies = [ "color-eyre", + "devicons", "ratatui", "rustc-hash", "serde", @@ -3115,6 +3119,7 @@ dependencies = [ "lazy_static", "rustc-hash", "syntect", + "tokio", "tracing", "unicode-width 0.2.0", "winapi-util", diff --git a/Cargo.toml b/Cargo.toml index a92627d..262d7bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,13 +56,14 @@ readme = "README.md" [workspace.dependencies] directories = "5.0.1" +devicons = "0.6.11" color-eyre = "0.6.3" lazy_static = "1.5.0" tokio = { version = "1.41.1", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } rustc-hash = "2.1.0" -syntect = "5.2.0" +syntect = {version = "5.2.0", default-features = false, features = ["default-fancy"]} unicode-width = "0.2.0" clap = { version = "4.5.20", features = ["derive", "cargo", "string"] } serde = { version = "1.0.214", features = ["derive"] } @@ -104,7 +105,7 @@ copypasta = "0.10.1" [dev-dependencies] criterion = "0.5.1" -devicons = "0.6.11" +devicons = { workspace = true } [[bin]] bench = false diff --git a/crates/television-channels/Cargo.toml b/crates/television-channels/Cargo.toml index b1f53f7..544b4b8 100644 --- a/crates/television-channels/Cargo.toml +++ b/crates/television-channels/Cargo.toml @@ -20,6 +20,7 @@ television-derive = { path = "../television-derive", version = "0.0.21" } tracing = { workspace = true } tokio = { workspace = true, features = ["rt"] } clap = { workspace = true, features = ["derive"] } +devicons = { workspace = true } directories = { workspace = true } color-eyre = { workspace = true } serde = { workspace = true } @@ -27,7 +28,6 @@ lazy_static = { workspace = true } toml = { workspace = true } rustc-hash = { workspace = true } -devicons = "0.6.11" ignore = "0.4.23" strum = { version = "0.26.3", features = ["derive"] } regex = "1.11.1" diff --git a/crates/television-previewers/Cargo.toml b/crates/television-previewers/Cargo.toml index 57657a2..7ebb37a 100644 --- a/crates/television-previewers/Cargo.toml +++ b/crates/television-previewers/Cargo.toml @@ -17,6 +17,7 @@ television-channels = { path = "../television-channels", version = "0.0.21" } television-utils = { path = "../television-utils", version = "0.0.21" } syntect = { workspace = true } +devicons = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } color-eyre = { workspace = true } @@ -24,7 +25,6 @@ lazy_static = { workspace = true } rustc-hash = { workspace = true } parking_lot = "0.12.3" -devicons = "0.6.11" regex = "1.11.1" nom = "7.1" tui = { version = "0.29", default-features = false, package = "ratatui" } diff --git a/crates/television-previewers/src/previewers.rs b/crates/television-previewers/src/previewers.rs index 3595259..7727032 100644 --- a/crates/television-previewers/src/previewers.rs +++ b/crates/television-previewers/src/previewers.rs @@ -19,23 +19,44 @@ pub use env::EnvVarPreviewer; pub use env::EnvVarPreviewerConfig; pub use files::FilePreviewer; pub use files::FilePreviewerConfig; -use syntect::highlighting::Style; +use television_utils::cache::RingSet; +use television_utils::syntax::HighlightedLines; #[derive(Clone, Debug)] pub enum PreviewContent { Empty, FileTooLarge, - SyntectHighlightedText(Vec>), + SyntectHighlightedText(HighlightedLines), Loading, + Timeout, NotSupported, PlainText(Vec), PlainTextWrapped(String), AnsiText(String), } +impl PreviewContent { + pub fn total_lines(&self) -> u16 { + match self { + PreviewContent::SyntectHighlightedText(hl_lines) => { + hl_lines.lines.len().try_into().unwrap_or(u16::MAX) + } + PreviewContent::PlainText(lines) => { + lines.len().try_into().unwrap_or(u16::MAX) + } + PreviewContent::AnsiText(text) => { + text.lines().count().try_into().unwrap_or(u16::MAX) + } + _ => 0, + } + } +} + pub const PREVIEW_NOT_SUPPORTED_MSG: &str = "Preview for this file type is not supported"; pub const FILE_TOO_LARGE_MSG: &str = "File too large"; +pub const LOADING_MSG: &str = "Loading..."; +pub const TIMEOUT_MSG: &str = "Preview timed out"; /// A preview of an entry. /// @@ -47,7 +68,10 @@ pub struct Preview { pub title: String, pub content: PreviewContent, pub icon: Option, - pub stale: bool, + /// If the preview is partial, this field contains the byte offset + /// up to which the preview holds. + pub partial_offset: Option, + pub total_lines: u16, } impl Default for Preview { @@ -56,7 +80,8 @@ impl Default for Preview { title: String::new(), content: PreviewContent::Empty, icon: None, - stale: false, + partial_offset: None, + total_lines: 0, } } } @@ -66,27 +91,22 @@ impl Preview { title: String, content: PreviewContent, icon: Option, - stale: bool, + partial_offset: Option, + total_lines: u16, ) -> Self { Preview { title, content, icon, - stale, - } - } - - pub fn stale(&self) -> Self { - Preview { - stale: true, - ..self.clone() + partial_offset, + total_lines, } } pub fn total_lines(&self) -> u16 { match &self.content { - PreviewContent::SyntectHighlightedText(lines) => { - lines.len().try_into().unwrap_or(u16::MAX) + PreviewContent::SyntectHighlightedText(hl_lines) => { + hl_lines.lines.len().try_into().unwrap_or(u16::MAX) } PreviewContent::PlainText(lines) => { lines.len().try_into().unwrap_or(u16::MAX) @@ -105,6 +125,7 @@ pub struct Previewer { file: FilePreviewer, env_var: EnvVarPreviewer, command: CommandPreviewer, + requests: RingSet, } #[derive(Debug, Default)] @@ -132,6 +153,8 @@ impl PreviewerConfig { } } +const REQUEST_STACK_SIZE: usize = 20; + impl Previewer { pub fn new(config: Option) -> Self { let config = config.unwrap_or_default(); @@ -140,17 +163,43 @@ impl Previewer { file: FilePreviewer::new(Some(config.file)), env_var: EnvVarPreviewer::new(Some(config.env_var)), command: CommandPreviewer::new(Some(config.command)), + requests: RingSet::with_capacity(REQUEST_STACK_SIZE), } } - pub fn preview(&mut self, entry: &Entry) -> Arc { + fn dispatch_request(&mut self, entry: &Entry) -> Option> { match &entry.preview_type { - PreviewType::Basic => self.basic.preview(entry), - PreviewType::EnvVar => self.env_var.preview(entry), + PreviewType::Basic => Some(self.basic.preview(entry)), + PreviewType::EnvVar => Some(self.env_var.preview(entry)), PreviewType::Files => self.file.preview(entry), PreviewType::Command(cmd) => self.command.preview(entry, cmd), - PreviewType::None => Arc::new(Preview::default()), + PreviewType::None => Some(Arc::new(Preview::default())), + } + } + + fn cached(&self, entry: &Entry) -> Option> { + match &entry.preview_type { + PreviewType::Files => self.file.cached(entry), + PreviewType::Command(_) => self.command.cached(entry), + PreviewType::Basic | PreviewType::EnvVar => None, + PreviewType::None => Some(Arc::new(Preview::default())), + } + } + + pub fn preview(&mut self, entry: &Entry) -> Option> { + // if we haven't acknowledged the request yet, acknowledge it + self.requests.push(entry.clone()); + + if let Some(preview) = self.dispatch_request(entry) { + return Some(preview); + } + // lookup request stack and return the most recent preview available + for request in self.requests.back_to_front() { + if let Some(preview) = self.cached(&request) { + return Some(preview); + } } + None } pub fn set_config(&mut self, config: PreviewerConfig) { diff --git a/crates/television-previewers/src/previewers/basic.rs b/crates/television-previewers/src/previewers/basic.rs index 3a014f7..0e8c040 100644 --- a/crates/television-previewers/src/previewers/basic.rs +++ b/crates/television-previewers/src/previewers/basic.rs @@ -23,7 +23,8 @@ impl BasicPreviewer { title: entry.name.clone(), content: PreviewContent::PlainTextWrapped(entry.name.clone()), icon: entry.icon, - ..Default::default() + partial_offset: None, + total_lines: 1, }) } } diff --git a/crates/television-previewers/src/previewers/command.rs b/crates/television-previewers/src/previewers/command.rs index 6db4197..14942bd 100644 --- a/crates/television-previewers/src/previewers/command.rs +++ b/crates/television-previewers/src/previewers/command.rs @@ -16,7 +16,6 @@ pub struct CommandPreviewer { cache: Arc>, config: CommandPreviewerConfig, concurrent_preview_tasks: Arc, - last_previewed: Arc>>, in_flight_previews: Arc>>, } @@ -53,54 +52,65 @@ impl CommandPreviewer { cache: Arc::new(Mutex::new(PreviewCache::default())), config, concurrent_preview_tasks: Arc::new(AtomicU8::new(0)), - last_previewed: Arc::new(Mutex::new(Arc::new( - Preview::default().stale(), - ))), in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())), } } + pub fn cached(&self, entry: &Entry) -> Option> { + self.cache.lock().get(&entry.name) + } + pub fn preview( &mut self, entry: &Entry, command: &PreviewCommand, - ) -> Arc { - // do we have a preview in cache for that entry? - if let Some(preview) = self.cache.lock().get(&entry.name) { - return preview.clone(); + ) -> Option> { + if let Some(preview) = self.cached(entry) { + Some(preview) + } else { + // preview is not in cache, spawn a task to compute the preview + debug!("Preview cache miss for {:?}", entry.name); + self.handle_preview_request(entry, command); + None } - debug!("Preview cache miss for {:?}", entry.name); + } - // are we already computing a preview in the background for that entry? + pub fn handle_preview_request( + &mut self, + entry: &Entry, + command: &PreviewCommand, + ) { if self.in_flight_previews.lock().contains(&entry.name) { debug!("Preview already in flight for {:?}", entry.name); - return self.last_previewed.lock().clone(); + return; } if self.concurrent_preview_tasks.load(Ordering::Relaxed) < MAX_CONCURRENT_PREVIEW_TASKS { + self.in_flight_previews.lock().insert(entry.name.clone()); self.concurrent_preview_tasks .fetch_add(1, Ordering::Relaxed); let cache = self.cache.clone(); let entry_c = entry.clone(); let concurrent_tasks = self.concurrent_preview_tasks.clone(); let command = command.clone(); - let last_previewed = self.last_previewed.clone(); + let in_flight_previews = self.in_flight_previews.clone(); tokio::spawn(async move { try_preview( &command, &entry_c, &cache, &concurrent_tasks, - &last_previewed, + &in_flight_previews, ); }); } else { - debug!("Too many concurrent preview tasks running"); + debug!( + "Too many concurrent preview tasks, skipping {:?}", + entry.name + ); } - - self.last_previewed.lock().clone() } } @@ -149,41 +159,42 @@ pub fn try_preview( entry: &Entry, cache: &Arc>, concurrent_tasks: &Arc, - last_previewed: &Arc>>, + in_flight_previews: &Arc>>, ) { debug!("Computing preview for {:?}", entry.name); let command = format_command(command, entry); debug!("Formatted preview command: {:?}", command); - let output = shell_command() + let child = shell_command() .arg(&command) .output() .expect("failed to execute process"); - if output.status.success() { - let content = String::from_utf8_lossy(&output.stdout); + if child.status.success() { + let content = String::from_utf8_lossy(&child.stdout); let preview = Arc::new(Preview::new( entry.name.clone(), PreviewContent::AnsiText(content.to_string()), None, - false, + None, + u16::try_from(content.lines().count()).unwrap_or(u16::MAX), )); cache.lock().insert(entry.name.clone(), &preview); - let mut tp = last_previewed.lock(); - *tp = preview.stale().into(); } else { - let content = String::from_utf8_lossy(&output.stderr); + let content = String::from_utf8_lossy(&child.stderr); let preview = Arc::new(Preview::new( entry.name.clone(), PreviewContent::AnsiText(content.to_string()), None, - false, + None, + u16::try_from(content.lines().count()).unwrap_or(u16::MAX), )); cache.lock().insert(entry.name.clone(), &preview); } concurrent_tasks.fetch_sub(1, Ordering::Relaxed); + in_flight_previews.lock().remove(&entry.name); } #[cfg(test)] diff --git a/crates/television-previewers/src/previewers/env.rs b/crates/television-previewers/src/previewers/env.rs index 0774b73..8264130 100644 --- a/crates/television-previewers/src/previewers/env.rs +++ b/crates/television-previewers/src/previewers/env.rs @@ -26,17 +26,22 @@ impl EnvVarPreviewer { if let Some(preview) = self.cache.get(entry) { return preview.clone(); } + let content = entry.value.as_ref().map(|preview| { + maybe_add_newline_after_colon(preview, &entry.name) + }); + let total_lines = content.as_ref().map_or_else( + || 1, + |c| u16::try_from(c.lines().count()).unwrap_or(u16::MAX), + ); let preview = Arc::new(Preview { title: entry.name.clone(), - content: if let Some(preview) = &entry.value { - PreviewContent::PlainTextWrapped( - maybe_add_newline_after_colon(preview, &entry.name), - ) - } else { - PreviewContent::Empty + content: match content { + Some(content) => PreviewContent::PlainTextWrapped(content), + None => PreviewContent::Empty, }, icon: entry.icon, - ..Default::default() + partial_offset: None, + total_lines, }); self.cache.insert(entry.clone(), preview.clone()); preview diff --git a/crates/television-previewers/src/previewers/files.rs b/crates/television-previewers/src/previewers/files.rs index f560368..cf48128 100644 --- a/crates/television-previewers/src/previewers/files.rs +++ b/crates/television-previewers/src/previewers/files.rs @@ -1,4 +1,3 @@ -use color_eyre::Result; use parking_lot::Mutex; use rustc_hash::{FxBuildHasher, FxHashSet}; use std::collections::HashSet; @@ -9,6 +8,8 @@ use std::sync::{ atomic::{AtomicU8, Ordering}, Arc, }; +use television_utils::files::{read_into_lines_capped, ReadResult}; +use television_utils::syntax::HighlightedLines; use syntect::{highlighting::Theme, parsing::SyntaxSet}; use tracing::{debug, warn}; @@ -17,7 +18,7 @@ use super::cache::PreviewCache; use crate::previewers::{meta, Preview, PreviewContent}; use television_channels::entry; use television_utils::{ - files::{get_file_size, FileType}, + files::FileType, strings::preprocess_line, syntax::{self, load_highlighting_assets, HighlightingAssetsExt}, }; @@ -28,7 +29,6 @@ pub struct FilePreviewer { pub syntax_set: Arc, pub syntax_theme: Arc, concurrent_preview_tasks: Arc, - last_previewed: Arc>>, in_flight_previews: Arc>>, } @@ -43,10 +43,6 @@ impl FilePreviewerConfig { } } -/// The maximum file size that we will try to preview. -/// 4 MB -const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024; - const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3; const BAT_THEME_ENV_VAR: &str = "BAT_THEME"; @@ -72,30 +68,41 @@ impl FilePreviewer { syntax_set: Arc::new(syntax_set), syntax_theme: Arc::new(theme), concurrent_preview_tasks: Arc::new(AtomicU8::new(0)), - last_previewed: Arc::new(Mutex::new(Arc::new( - Preview::default().stale(), - ))), in_flight_previews: Arc::new(Mutex::new(HashSet::with_hasher( FxBuildHasher, ))), } } - /// Get a preview for a file entry. - /// - /// # Panics - /// Panics if seeking to the start of the file fails. - pub fn preview(&mut self, entry: &entry::Entry) -> Arc { - // do we have a preview in cache for that entry? - if let Some(preview) = self.cache.lock().get(&entry.name) { - return preview; + pub fn cached(&self, entry: &entry::Entry) -> Option> { + self.cache.lock().get(&entry.name) + } + + pub fn preview(&mut self, entry: &entry::Entry) -> Option> { + if let Some(preview) = self.cached(entry) { + debug!("Preview cache hit for {:?}", entry.name); + if preview.partial_offset.is_some() { + // preview is partial, spawn a task to compute the next chunk + // and return the partial preview + debug!("Spawning partial preview task for {:?}", entry.name); + self.handle_preview_request(entry, Some(preview.clone())); + } + Some(preview) + } else { + // preview is not in cache, spawn a task to compute the preview + debug!("Preview cache miss for {:?}", entry.name); + self.handle_preview_request(entry, None); + None } - debug!("Preview cache miss for {:?}", entry.name); + } - // are we already computing a preview in the background for that entry? + pub fn handle_preview_request( + &mut self, + entry: &entry::Entry, + partial_preview: Option>, + ) { if self.in_flight_previews.lock().contains(&entry.name) { debug!("Preview already in flight for {:?}", entry.name); - return self.last_previewed.lock().clone(); } if self.concurrent_preview_tasks.load(Ordering::Relaxed) @@ -109,22 +116,19 @@ impl FilePreviewer { let syntax_set = self.syntax_set.clone(); let syntax_theme = self.syntax_theme.clone(); let concurrent_tasks = self.concurrent_preview_tasks.clone(); - let last_previewed = self.last_previewed.clone(); let in_flight_previews = self.in_flight_previews.clone(); tokio::spawn(async move { try_preview( &entry_c, + partial_preview, &cache, &syntax_set, &syntax_theme, &concurrent_tasks, - &last_previewed, &in_flight_previews, ); }); } - - self.last_previewed.lock().clone() } #[allow(dead_code)] @@ -133,41 +137,98 @@ impl FilePreviewer { } } +/// The size of the buffer used to read the file in bytes. +/// This ends up being the max size of partial previews. +const PARTIAL_BUFREAD_SIZE: usize = 16 * 1024; + pub fn try_preview( entry: &entry::Entry, + partial_preview: Option>, cache: &Arc>, syntax_set: &Arc, syntax_theme: &Arc, concurrent_tasks: &Arc, - last_previewed: &Arc>>, in_flight_previews: &Arc>>, ) { debug!("Computing preview for {:?}", entry.name); let path = PathBuf::from(&entry.name); - // check file size - if get_file_size(&path).map_or(false, |s| s > MAX_FILE_SIZE) { - debug!("File too large: {:?}", entry.name); - let preview = meta::file_too_large(&entry.name); - cache.lock().insert(entry.name.clone(), &preview); - } - if matches!(FileType::from(&path), FileType::Text) { + // if we're dealing with a partial preview, no need to re-check for textual content + if partial_preview.is_some() + || matches!(FileType::from(&path), FileType::Text) + { debug!("File is text-based: {:?}", entry.name); match File::open(path) { - Ok(file) => { + Ok(mut file) => { + // if we're dealing with a partial preview, seek to the provided offset + // and use the previous state to compute the next chunk of the preview + let cached_lines = if let Some(p) = partial_preview { + if let PreviewContent::SyntectHighlightedText(hl) = + &p.content + { + let _ = file.seek(std::io::SeekFrom::Start( + // this is always Some in this case + p.partial_offset.unwrap() as u64, + )); + Some(hl.clone()) + } else { + None + } + } else { + None + }; // compute the highlighted version in the background - let mut reader = BufReader::new(file); - reader.seek(std::io::SeekFrom::Start(0)).unwrap(); - let preview = compute_highlighted_text_preview( - entry, - reader, - syntax_set, - syntax_theme, - ); - cache.lock().insert(entry.name.clone(), &preview); - in_flight_previews.lock().remove(&entry.name); - let mut tp = last_previewed.lock(); - *tp = preview.stale().into(); + match read_into_lines_capped(file, PARTIAL_BUFREAD_SIZE) { + ReadResult::Full(lines) => { + if let Some(content) = compute_highlighted_text_preview( + entry, + &lines + .iter() + .map(|l| preprocess_line(l).0 + "\n") + .collect::>(), + syntax_set, + syntax_theme, + cached_lines, + ) { + let total_lines = content.total_lines(); + let preview = Arc::new(Preview::new( + entry.name.clone(), + content, + entry.icon, + None, + total_lines, + )); + cache.lock().insert(entry.name.clone(), &preview); + } + } + ReadResult::Partial(p) => { + if let Some(content) = compute_highlighted_text_preview( + entry, + &p.lines + .iter() + .map(|l| preprocess_line(l).0 + "\n") + .collect::>(), + syntax_set, + syntax_theme, + cached_lines, + ) { + let total_lines = content.total_lines(); + let preview = Arc::new(Preview::new( + entry.name.clone(), + content, + entry.icon, + Some(p.bytes_read), + total_lines, + )); + cache.lock().insert(entry.name.clone(), &preview); + } + } + ReadResult::Error(e) => { + warn!("Error reading file: {:?}", e); + let p = meta::not_supported(&entry.name); + cache.lock().insert(entry.name.clone(), &p); + } + } } Err(e) => { warn!("Error opening file: {:?}", e); @@ -181,44 +242,34 @@ pub fn try_preview( cache.lock().insert(entry.name.clone(), &preview); } concurrent_tasks.fetch_sub(1, Ordering::Relaxed); + in_flight_previews.lock().remove(&entry.name); } fn compute_highlighted_text_preview( entry: &entry::Entry, - reader: BufReader, + lines: &[String], syntax_set: &SyntaxSet, syntax_theme: &Theme, -) -> Arc { + previous_lines: Option, +) -> Option { debug!( "Computing highlights in the background for {:?}", entry.name ); - let lines: Vec = reader - .lines() - .map_while(Result::ok) - // we need to add a newline here because sublime syntaxes expect one - // to be present at the end of each line - .map(|line| preprocess_line(&line).0 + "\n") - .collect(); - - match syntax::compute_highlights_for_path( + + match syntax::compute_highlights_incremental( &PathBuf::from(&entry.name), lines, syntax_set, syntax_theme, + previous_lines, ) { Ok(highlighted_lines) => { - debug!("Successfully computed highlights for {:?}", entry.name); - Arc::new(Preview::new( - entry.name.clone(), - PreviewContent::SyntectHighlightedText(highlighted_lines), - entry.icon, - false, - )) + Some(PreviewContent::SyntectHighlightedText(highlighted_lines)) } Err(e) => { warn!("Error computing highlights: {:?}", e); - meta::not_supported(&entry.name) + None } } } @@ -244,10 +295,12 @@ fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc { break; } } + let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX); Arc::new(Preview::new( title.to_string(), PreviewContent::PlainText(lines), None, - false, + None, + total_lines, )) } diff --git a/crates/television-previewers/src/previewers/meta.rs b/crates/television-previewers/src/previewers/meta.rs index 1e0b597..9bfa858 100644 --- a/crates/television-previewers/src/previewers/meta.rs +++ b/crates/television-previewers/src/previewers/meta.rs @@ -6,7 +6,8 @@ pub fn not_supported(title: &str) -> Arc { title.to_string(), PreviewContent::NotSupported, None, - false, + None, + 1, )) } @@ -15,7 +16,8 @@ pub fn file_too_large(title: &str) -> Arc { title.to_string(), PreviewContent::FileTooLarge, None, - false, + None, + 1, )) } @@ -25,6 +27,17 @@ pub fn loading(title: &str) -> Arc { title.to_string(), PreviewContent::Loading, None, - false, + None, + 1, + )) +} + +pub fn timeout(title: &str) -> Arc { + Arc::new(Preview::new( + title.to_string(), + PreviewContent::Timeout, + None, + None, + 1, )) } diff --git a/crates/television-screen/Cargo.toml b/crates/television-screen/Cargo.toml index 9ae942e..c0488d2 100644 --- a/crates/television-screen/Cargo.toml +++ b/crates/television-screen/Cargo.toml @@ -23,6 +23,7 @@ color-eyre = { workspace = true } syntect = { workspace = true } rustc-hash = { workspace = true } tracing = { workspace = true } +devicons = { workspace = true } [lints] workspace = true diff --git a/crates/television-screen/src/cache.rs b/crates/television-screen/src/cache.rs index 526f7bf..fad4d32 100644 --- a/crates/television-screen/src/cache.rs +++ b/crates/television-screen/src/cache.rs @@ -1,15 +1,41 @@ +use devicons::FileIcon; use rustc_hash::FxHashMap; use std::sync::Arc; use ratatui::widgets::Paragraph; use television_utils::cache::RingSet; -const DEFAULT_RENDERED_PREVIEW_CACHE_SIZE: usize = 25; +const DEFAULT_RENDERED_PREVIEW_CACHE_SIZE: usize = 10; + +#[derive(Clone, Debug)] +pub struct CachedPreview<'a> { + pub key: String, + pub icon: Option, + pub title: String, + pub paragraph: Arc>, +} + +impl<'a> CachedPreview<'a> { + pub fn new( + key: String, + icon: Option, + title: String, + paragraph: Arc>, + ) -> Self { + CachedPreview { + key, + icon, + title, + paragraph, + } + } +} #[derive(Debug)] pub struct RenderedPreviewCache<'a> { - previews: FxHashMap>>, + previews: FxHashMap>, ring_set: RingSet, + pub last_preview: Option>, } impl<'a> RenderedPreviewCache<'a> { @@ -17,15 +43,29 @@ impl<'a> RenderedPreviewCache<'a> { RenderedPreviewCache { previews: FxHashMap::default(), ring_set: RingSet::with_capacity(capacity), + last_preview: None, } } - pub fn get(&self, key: &str) -> Option>> { + pub fn get(&self, key: &str) -> Option> { self.previews.get(key).cloned() } - pub fn insert(&mut self, key: String, preview: &Arc>) { - self.previews.insert(key.clone(), preview.clone()); + pub fn insert( + &mut self, + key: String, + icon: Option, + title: &str, + paragraph: &Arc>, + ) { + let cached_preview = CachedPreview::new( + key.clone(), + icon, + title.to_string(), + paragraph.clone(), + ); + self.last_preview = Some(cached_preview.clone()); + self.previews.insert(key.clone(), cached_preview); if let Some(oldest_key) = self.ring_set.push(key) { self.previews.remove(&oldest_key); } diff --git a/crates/television-screen/src/preview.rs b/crates/television-screen/src/preview.rs index b1ee114..2e75984 100644 --- a/crates/television-screen/src/preview.rs +++ b/crates/television-screen/src/preview.rs @@ -3,6 +3,7 @@ use crate::{ colors::{Colorscheme, PreviewColorscheme}, }; use color_eyre::eyre::Result; +use devicons::FileIcon; use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}; use ratatui::Frame; use ratatui::{ @@ -15,7 +16,8 @@ use television_channels::entry::Entry; use television_previewers::{ ansi::IntoText, previewers::{ - Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG, + Preview, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG, + PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG, }, }; use television_utils::strings::{ @@ -28,14 +30,20 @@ const FILL_CHAR_SLANTED: char = '╱'; const FILL_CHAR_EMPTY: char = ' '; #[allow(clippy::needless_pass_by_value)] -pub fn build_preview_paragraph( - preview_block: Block<'_>, +pub fn build_preview_paragraph<'a>( inner: Rect, preview_content: PreviewContent, target_line: Option, preview_scroll: u16, colorscheme: Colorscheme, -) -> Paragraph<'_> { +) -> Paragraph<'a> { + let preview_block = + Block::default().style(Style::default()).padding(Padding { + top: 0, + right: 1, + bottom: 0, + left: 1, + }); match preview_content { PreviewContent::AnsiText(text) => { build_ansi_text_paragraph(text, preview_block, preview_scroll) @@ -56,7 +64,7 @@ pub fn build_preview_paragraph( } PreviewContent::SyntectHighlightedText(highlighted_lines) => { build_syntect_highlighted_paragraph( - highlighted_lines, + highlighted_lines.lines, preview_block, target_line, preview_scroll, @@ -65,7 +73,7 @@ pub fn build_preview_paragraph( } // meta PreviewContent::Loading => { - build_meta_preview_paragraph(inner, "Loading...", FILL_CHAR_EMPTY) + build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY) .block(preview_block) .alignment(Alignment::Left) .style(Style::default().add_modifier(Modifier::ITALIC)) @@ -86,28 +94,48 @@ pub fn build_preview_paragraph( .block(preview_block) .alignment(Alignment::Left) .style(Style::default().add_modifier(Modifier::ITALIC)), + PreviewContent::Timeout => { + build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY) + } + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)), } } +const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10; +const ANSI_CONTEXT_SIZE: usize = 150; + #[allow(clippy::needless_pass_by_value)] fn build_ansi_text_paragraph( text: String, preview_block: Block, preview_scroll: u16, ) -> Paragraph { - let text = replace_non_printable( - text.as_bytes(), - &ReplaceNonPrintableConfig { - replace_line_feed: false, - replace_control_characters: false, - ..Default::default() - }, - ) - .0 - .into_text() - .unwrap(); - Paragraph::new(text) + let lines = text.lines(); + let skip = + preview_scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE) as usize; + let context = lines + .skip(skip) + .take(ANSI_CONTEXT_SIZE) + .collect::>() + .join("\n"); + + let mut text = "\n".repeat(skip); + text.push_str( + &replace_non_printable( + context.as_bytes(), + &ReplaceNonPrintableConfig { + replace_line_feed: false, + replace_control_characters: false, + ..Default::default() + }, + ) + .0, + ); + + Paragraph::new(text.into_text().unwrap()) .block(preview_block) .scroll((preview_scroll, 0)) } @@ -244,20 +272,18 @@ pub fn build_meta_preview_paragraph<'a>( Paragraph::new(Text::from(lines)) } -#[allow(clippy::too_many_arguments)] -pub fn draw_preview_content_block( +fn draw_content_outer_block( f: &mut Frame, rect: Rect, - entry: &Entry, - preview: &Arc, - rendered_preview_cache: &Arc>>, - preview_scroll: u16, - use_nerd_font_icons: bool, colorscheme: &Colorscheme, -) -> Result<()> { + icon: Option, + title: &str, + use_nerd_font_icons: bool, +) -> Result { let mut preview_title_spans = vec![Span::from(" ")]; - if preview.icon.is_some() && use_nerd_font_icons { - let icon = preview.icon.as_ref().unwrap(); + // optional icon + if icon.is_some() && use_nerd_font_icons { + let icon = icon.as_ref().unwrap(); preview_title_spans.push(Span::styled( { let mut icon_str = String::from(icon.icon); @@ -267,10 +293,11 @@ pub fn draw_preview_content_block( Style::default().fg(Color::from_str(icon.color)?), )); } + // preview title preview_title_spans.push(Span::styled( shrink_with_ellipsis( &replace_non_printable( - preview.title.as_bytes(), + title.as_bytes(), &ReplaceNonPrintableConfig::default(), ) .0, @@ -279,6 +306,8 @@ pub fn draw_preview_content_block( Style::default().fg(colorscheme.preview.title_fg).bold(), )); preview_title_spans.push(Span::from(" ")); + + // build the preview block let preview_outer_block = Block::default() .title_top( Line::from(preview_title_spans) @@ -294,47 +323,111 @@ pub fn draw_preview_content_block( ) .padding(Padding::new(0, 1, 1, 0)); - let preview_inner_block = - Block::default().style(Style::default()).padding(Padding { - top: 0, - right: 1, - bottom: 0, - left: 1, - }); let inner = preview_outer_block.inner(rect); f.render_widget(preview_outer_block, rect); + Ok(inner) +} - let target_line = entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)); - let cache_key = compute_cache_key(entry); +#[allow(clippy::too_many_arguments)] +pub fn draw_preview_content_block( + f: &mut Frame, + rect: Rect, + entry: &Entry, + preview: &Option>, + rendered_preview_cache: &Arc>>, + preview_scroll: u16, + use_nerd_font_icons: bool, + colorscheme: &Colorscheme, +) -> Result<()> { + if let Some(preview) = preview { + let inner = draw_content_outer_block( + f, + rect, + colorscheme, + preview.icon, + &preview.title, + use_nerd_font_icons, + )?; - // Check if the rendered preview content is already in the cache - if let Some(preview_paragraph) = - rendered_preview_cache.lock().unwrap().get(&cache_key) - { - let p = preview_paragraph.as_ref().clone(); - f.render_widget(p.scroll((preview_scroll, 0)), inner); + // check if the rendered preview content is already in the cache + let cache_key = compute_cache_key(entry); + if let Some(rp) = + rendered_preview_cache.lock().unwrap().get(&cache_key) + { + // we got a hit, render the cached preview content + let p = rp.paragraph.as_ref().clone(); + f.render_widget(p.scroll((preview_scroll, 0)), inner); + return Ok(()); + } + // render the preview content and cache it + let rp = build_preview_paragraph( + //preview_inner_block, + inner, + preview.content.clone(), + entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)), + preview_scroll, + colorscheme.clone(), + ); + // only cache the preview content if it's not a partial preview + // and the preview title matches the entry name + if preview.partial_offset.is_none() && preview.title == entry.name { + rendered_preview_cache.lock().unwrap().insert( + cache_key, + preview.icon, + &preview.title, + &Arc::new(rp.clone()), + ); + } + f.render_widget(rp.scroll((preview_scroll, 0)), inner); return Ok(()); } - // If not, render the preview content and cache it if not empty - let c_scheme = colorscheme.clone(); - let rp = build_preview_paragraph( - preview_inner_block, - inner, - preview.content.clone(), - target_line, - preview_scroll, - c_scheme, - ); - if !preview.stale { - rendered_preview_cache - .lock() - .unwrap() - .insert(cache_key, &Arc::new(rp.clone())); + // else if last_preview exists + if let Some(last_preview) = + &rendered_preview_cache.lock().unwrap().last_preview + { + let inner = draw_content_outer_block( + f, + rect, + colorscheme, + last_preview.icon, + &last_preview.title, + use_nerd_font_icons, + )?; + + f.render_widget( + last_preview + .paragraph + .as_ref() + .clone() + .scroll((preview_scroll, 0)), + inner, + ); + return Ok(()); } - f.render_widget( - Arc::new(rp).as_ref().clone().scroll((preview_scroll, 0)), - inner, - ); + // otherwise render empty preview + let inner = draw_content_outer_block( + f, + rect, + colorscheme, + None, + "", + use_nerd_font_icons, + )?; + let preview_outer_block = Block::default() + .title_top(Line::from(Span::styled( + " Preview ", + Style::default().fg(colorscheme.preview.title_fg), + ))) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(colorscheme.general.border_fg)) + .style( + Style::default() + .bg(colorscheme.general.background.unwrap_or_default()), + ) + .padding(Padding::new(0, 1, 1, 0)); + f.render_widget(preview_outer_block, inner); + Ok(()) } diff --git a/crates/television-utils/Cargo.toml b/crates/television-utils/Cargo.toml index a869f45..478e518 100644 --- a/crates/television-utils/Cargo.toml +++ b/crates/television-utils/Cargo.toml @@ -20,10 +20,11 @@ directories = { workspace = true } syntect = { workspace = true } unicode-width = { workspace = true } rustc-hash = { workspace = true } +tokio = { workspace = true } ignore = "0.4.23" bat = { version = "0.24.0", default-features = false, features = [ - "regex-onig", + "regex-fancy", ] } gag = "1.0.0" diff --git a/crates/television-utils/src/cache.rs b/crates/television-utils/src/cache.rs index 710a92e..7f157e3 100644 --- a/crates/television-utils/src/cache.rs +++ b/crates/television-utils/src/cache.rs @@ -48,6 +48,17 @@ pub struct RingSet { capacity: usize, } +const DEFAULT_CAPACITY: usize = 20; + +impl Default for RingSet +where + T: Eq + std::hash::Hash + Clone + std::fmt::Debug, +{ + fn default() -> Self { + RingSet::with_capacity(DEFAULT_CAPACITY) + } +} + impl RingSet where T: Eq + std::hash::Hash + Clone + std::fmt::Debug, @@ -97,6 +108,11 @@ where pub fn contains(&self, key: &T) -> bool { self.known_keys.contains(key) } + + /// Returns an iterator that goes from the back to the front of the buffer. + pub fn back_to_front(&self) -> impl Iterator { + self.ring_buffer.clone().into_iter().rev() + } } #[cfg(test)] diff --git a/crates/television-utils/src/files.rs b/crates/television-utils/src/files.rs index cf51987..dba455f 100644 --- a/crates/television-utils/src/files.rs +++ b/crates/television-utils/src/files.rs @@ -1,6 +1,8 @@ use rustc_hash::FxHashSet; use std::fmt::Debug; use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; use std::io::Read; use std::path::Path; use std::path::PathBuf; @@ -14,6 +16,51 @@ use crate::strings::{ }; use crate::threads::default_num_threads; +pub struct PartialReadResult { + pub lines: Vec, + pub bytes_read: usize, +} + +pub enum ReadResult { + Partial(PartialReadResult), + Full(Vec), + Error(String), +} + +pub fn read_into_lines_capped(r: R, max_bytes: usize) -> ReadResult +where + R: Read, +{ + let mut buf_reader = BufReader::new(r); + let mut line = String::new(); + let mut lines = Vec::new(); + let mut bytes_read = 0; + + loop { + line.clear(); + match buf_reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if bytes_read > max_bytes { + break; + } + lines.push(line.trim_end().to_string()); + bytes_read += line.len(); + } + Err(e) => { + warn!("Error reading file: {:?}", e); + return ReadResult::Error(format!("{e:?}")); + } + } + } + + if bytes_read > max_bytes { + ReadResult::Partial(PartialReadResult { lines, bytes_read }) + } else { + ReadResult::Full(lines) + } +} + lazy_static::lazy_static! { pub static ref DEFAULT_NUM_THREADS: usize = default_num_threads().into(); } diff --git a/crates/television-utils/src/strings.rs b/crates/television-utils/src/strings.rs index 9c44dfe..3f03222 100644 --- a/crates/television-utils/src/strings.rs +++ b/crates/television-utils/src/strings.rs @@ -224,7 +224,7 @@ pub fn replace_non_printable( input: &[u8], config: &ReplaceNonPrintableConfig, ) -> (String, Vec) { - let mut output = String::new(); + let mut output = String::with_capacity(input.len()); let mut offsets = Vec::new(); let mut cumulative_offset: i16 = 0; diff --git a/crates/television-utils/src/syntax.rs b/crates/television-utils/src/syntax.rs index ff30452..31b4ec7 100644 --- a/crates/television-utils/src/syntax.rs +++ b/crates/television-utils/src/syntax.rs @@ -1,31 +1,95 @@ use bat::assets::HighlightingAssets; +use color_eyre::Result; use gag::Gag; use std::path::{Path, PathBuf}; use syntect::easy::HighlightLines; -use syntect::highlighting::{Style, Theme}; -use syntect::parsing::SyntaxSet; +use syntect::highlighting::{ + HighlightIterator, HighlightState, Highlighter, Style, Theme, +}; +use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}; use tracing::warn; +#[derive(Debug, Clone)] +pub struct HighlightingState { + parse_state: ParseState, + highlight_state: HighlightState, +} + +impl HighlightingState { + pub fn new( + parse_state: ParseState, + highlight_state: HighlightState, + ) -> Self { + Self { + parse_state, + highlight_state, + } + } +} + +struct LineHighlighter<'a> { + highlighter: Highlighter<'a>, + pub parse_state: ParseState, + pub highlight_state: HighlightState, +} + +impl<'a> LineHighlighter<'a> { + pub fn new( + syntax: &SyntaxReference, + theme: &'a Theme, + ) -> LineHighlighter<'a> { + let highlighter = Highlighter::new(theme); + let highlight_state = + HighlightState::new(&highlighter, ScopeStack::new()); + Self { + highlighter, + parse_state: ParseState::new(syntax), + highlight_state, + } + } + + pub fn from_state( + state: HighlightingState, + theme: &'a Theme, + ) -> LineHighlighter<'a> { + Self { + highlighter: Highlighter::new(theme), + parse_state: state.parse_state, + highlight_state: state.highlight_state, + } + } + + /// Highlights a line of a file + pub fn highlight_line<'b>( + &mut self, + line: &'b str, + syntax_set: &SyntaxSet, + ) -> Result, syntect::Error> { + let ops = self.parse_state.parse_line(line, syntax_set)?; + let iter = HighlightIterator::new( + &mut self.highlight_state, + &ops[..], + line, + &self.highlighter, + ); + Ok(iter.collect()) + } +} + +#[deprecated( + note = "Use `compute_highlights_incremental` instead, which also returns the state" +)] pub fn compute_highlights_for_path( file_path: &Path, - lines: Vec, + lines: &[String], syntax_set: &SyntaxSet, syntax_theme: &Theme, -) -> color_eyre::Result>> { - let syntax = - syntax_set - .find_syntax_for_file(file_path)? - .unwrap_or_else(|| { - warn!( - "No syntax found for {:?}, defaulting to plain text", - file_path - ); - syntax_set.find_syntax_plain_text() - }); +) -> Result>> { + let syntax = set_syntax_set(syntax_set, file_path); let mut highlighter = HighlightLines::new(syntax, syntax_theme); let mut highlighted_lines = Vec::new(); for line in lines { - let hl_regions = highlighter.highlight_line(&line, syntax_set)?; + let hl_regions = highlighter.highlight_line(line, syntax_set)?; highlighted_lines.push( hl_regions .iter() @@ -36,13 +100,86 @@ pub fn compute_highlights_for_path( Ok(highlighted_lines) } +fn set_syntax_set<'a>( + syntax_set: &'a SyntaxSet, + file_path: &Path, +) -> &'a SyntaxReference { + syntax_set + .find_syntax_for_file(file_path) + .unwrap_or(None) + .unwrap_or_else(|| { + warn!( + "No syntax found for {:?}, defaulting to plain text", + file_path + ); + syntax_set.find_syntax_plain_text() + }) +} + +#[derive(Debug, Clone)] +pub struct HighlightedLines { + pub lines: Vec>, + pub state: Option, +} + +impl HighlightedLines { + pub fn new( + lines: Vec>, + state: Option, + ) -> Self { + Self { lines, state } + } +} + +pub fn compute_highlights_incremental( + file_path: &Path, + lines: &[String], + syntax_set: &SyntaxSet, + syntax_theme: &Theme, + cached_lines: Option, +) -> Result { + let mut highlighted_lines: Vec<_>; + let mut highlighter: LineHighlighter; + + if let Some(HighlightedLines { + lines: c_lines, + state: Some(s), + }) = cached_lines + { + highlighter = LineHighlighter::from_state(s, syntax_theme); + highlighted_lines = c_lines; + } else { + let syntax = set_syntax_set(syntax_set, file_path); + highlighter = LineHighlighter::new(syntax, syntax_theme); + highlighted_lines = Vec::new(); + }; + + for line in lines { + let hl_regions = highlighter.highlight_line(line, syntax_set)?; + highlighted_lines.push( + hl_regions + .iter() + .map(|(style, text)| (*style, (*text).to_string())) + .collect(), + ); + } + + Ok(HighlightedLines::new( + highlighted_lines, + Some(HighlightingState::new( + highlighter.parse_state.clone(), + highlighter.highlight_state.clone(), + )), + )) +} + #[allow(dead_code)] pub fn compute_highlights_for_line<'a>( line: &'a str, syntax_set: &SyntaxSet, syntax_theme: &Theme, file_path: &str, -) -> color_eyre::Result> { +) -> Result> { let syntax = syntax_set.find_syntax_for_file(file_path)?; match syntax { None => { diff --git a/crates/television/television.rs b/crates/television/television.rs index 890424a..45bf823 100644 --- a/crates/television/television.rs +++ b/crates/television/television.rs @@ -560,20 +560,26 @@ impl Television { && !matches!(selected_entry.preview_type, PreviewType::None) { // preview content - let preview = self.previewer.preview(&selected_entry); - self.current_preview_total_lines = preview.total_lines(); - // initialize preview scroll - self.maybe_init_preview_scroll( - selected_entry - .line_number - .map(|l| u16::try_from(l).unwrap_or(0)), - layout.preview_window.unwrap().height, - ); + let maybe_preview = self.previewer.preview(&selected_entry); + + let _ = self.previewer.preview(&selected_entry); + + if let Some(preview) = &maybe_preview { + self.current_preview_total_lines = preview.total_lines; + // initialize preview scroll + self.maybe_init_preview_scroll( + selected_entry + .line_number + .map(|l| u16::try_from(l).unwrap_or(0)), + layout.preview_window.unwrap().height, + ); + } + draw_preview_content_block( f, layout.preview_window.unwrap(), &selected_entry, - &preview, + &maybe_preview, &self.rendered_preview_cache, self.preview_scroll.unwrap_or(0), self.config.ui.use_nerd_font_icons,