trail/preview/provider.rs
1//! `PreviewProvider` trait and `PreviewRegistry`.
2//!
3//! Defines the contract that all preview providers implement, and the
4//! registry that dispatches preview requests to the first matching provider.
5//! New entry types (PDF, archive, etc.) implement `PreviewProvider` and
6//! register an instance at startup — the core loop and render code do not
7//! change.
8//!
9//! Phase 5 adds `Highlighted` and `Binary` variants to `PreviewContent` and
10//! enriches `PreviewCtx` with the worker sender and generation counter so
11//! providers can spawn async tasks.
12
13use std::path::PathBuf;
14
15use ratatui::style::Color;
16use ratatui::text::Line;
17use ratatui::widgets::{Paragraph, Wrap};
18use thiserror::Error;
19use tokio::sync::mpsc;
20
21use crate::app::state::Entry;
22use crate::preview::graphics::ImagePreview;
23use crate::workers::WorkerMsg;
24
25// ── Error ─────────────────────────────────────────────────────────────────────
26
27/// Errors from preview operations.
28///
29/// Currently unused in built-in providers (which return graceful fallbacks
30/// rather than propagating errors). Defined here for use by plugin-provided
31/// `PreviewProvider` implementations (Phase 8).
32// clippy: dead_code — reserved for plugin-provided PreviewProvider impls (Phase 8)
33#[allow(dead_code)]
34#[derive(Debug, Error)]
35pub enum PreviewError {
36 /// An I/O error occurred while reading file content.
37 #[error("I/O error reading {path}: {source}")]
38 Io {
39 path: PathBuf,
40 #[source]
41 source: std::io::Error,
42 },
43}
44
45// ── PreviewContent ────────────────────────────────────────────────────────────
46
47/// A single span of text with an optional foreground colour for highlighted
48/// preview rendering.
49///
50/// Used by `PreviewContent::Highlighted` to carry the output of `syntect`
51/// syntax highlighting. Each `StyledSpan` maps to a ratatui `Span`.
52#[derive(Debug, Clone)]
53pub struct StyledSpan {
54 /// The text content of this span.
55 pub text: String,
56 /// Optional foreground colour (RGB). `None` means "use default foreground".
57 pub fg: Option<Color>,
58}
59
60/// A single highlighted line, made up of one or more [`StyledSpan`]s.
61pub type HighlightedLine = Vec<StyledSpan>;
62
63// ── Making file content safe to draw ──────────────────────────────────────────
64
65/// Columns a tab is expanded to in preview text.
66///
67/// Not tab-stop aware: each tab becomes this many spaces wherever it appears.
68/// The alternative is tracking the column across styled spans that are built
69/// independently, for a difference nobody reading a preview would notice.
70///
71/// Named constant per coding-standard §10: no magic numbers.
72const TAB_WIDTH: usize = 4;
73
74/// Shown in place of a control character.
75const CONTROL_PLACEHOLDER: char = '·';
76
77/// Returns `line` with everything a terminal would *act on* replaced by
78/// something it will print.
79///
80/// A file reaches the text preview when `content_inspector` finds no NUL bytes
81/// in its first 8 KB, which is not the same as finding nothing dangerous in it:
82/// ESC, BEL, CR and the other control characters all survive that test. They
83/// used to be written into the render buffer verbatim and handed to the
84/// terminal, which obeyed them rather than drawing them — stray characters
85/// anywhere on screen, including over the panel borders and the listing, and
86/// gone again after any full redraw. That is the artifact this removes, and with
87/// it the reason the renderer cleared the whole screen on every frame.
88///
89/// Tabs become spaces (a terminal advances to its own next tab stop, which no
90/// pane layout can predict) and every other control character becomes a middle
91/// dot, so the line keeps its length in cells and the preview stays honest about
92/// what is in the file.
93///
94/// Bidirectional and zero-width characters are left alone: they are legitimate
95/// text content, and the terminal draws rather than executes them.
96pub fn sanitize(line: &str) -> String {
97 let mut out = String::with_capacity(line.len());
98 for ch in line.chars() {
99 match ch {
100 '\t' => (0..TAB_WIDTH).for_each(|_| out.push(' ')),
101 ch if ch.is_control() => out.push(CONTROL_PLACEHOLDER),
102 ch => out.push(ch),
103 }
104 }
105 out
106}
107
108/// The renderable content for the preview pane.
109///
110/// Each variant carries the data needed by `ui/preview_panel.rs` to draw
111/// the pane without any further I/O.
112#[derive(Debug, Clone, Default)]
113pub enum PreviewContent {
114 /// A placeholder shown before any entry is selected or while loading.
115 #[default]
116 Empty,
117
118 /// A placeholder shown while an async worker result is in flight.
119 ///
120 /// Displayed while `workers/highlight.rs`, `workers/image_decode.rs`, or
121 /// the binary metadata worker are running.
122 Loading,
123
124 /// Plain-text preview with pre-formatted lines.
125 ///
126 /// Each element is a formatted string (e.g. `" 1 first line of file"`).
127 /// Used for small text files where syntect highlighting is not available
128 /// (e.g. unknown syntax) and as the async-deferred fallback on error.
129 Text(Vec<String>),
130
131 /// Syntax-highlighted text preview (Phase 5).
132 ///
133 /// Each outer element is a line; each inner element is a styled span.
134 /// Line numbers are prepended as an unstyled span by the render code.
135 Highlighted(Vec<HighlightedLine>),
136
137 /// Binary file metadata (Phase 5).
138 ///
139 /// Each string is one line of formatted metadata (size, type, modified
140 /// timestamp, etc.).
141 Binary(Vec<String>),
142
143 /// Pixel image preview drawn through a terminal graphics protocol.
144 ///
145 /// Produced by `workers/image_decode.rs` once the image has been decoded
146 /// off-thread. Carries its own encoder state, so the render path needs no
147 /// further I/O.
148 Image(ImagePreview),
149
150 /// Directory preview: a summary block followed by entry names.
151 Directory {
152 /// Total number of files (non-directories).
153 file_count: usize,
154 /// Total number of subdirectories.
155 dir_count: usize,
156 /// Number of hidden entries.
157 hidden_count: usize,
158 /// Up to the first N entry names for quick inspection.
159 entries: Vec<String>,
160 },
161}
162
163/// Number of lines the directory preview spends on its summary header before
164/// the first entry name: the counts line and the blank line under it.
165///
166/// The header scrolls with the entry list rather than staying pinned, so it is
167/// part of the scrollable length.
168const DIRECTORY_HEADER_LINES: usize = 2;
169
170impl PreviewContent {
171 /// Number of logical lines this content occupies, or `None` when it does
172 /// not scroll.
173 ///
174 /// `None` covers the placeholders and `Image`: an image is drawn as pixels
175 /// into the whole pane, so a line offset is meaningless, and returning
176 /// `None` for `Loading` is what stops a deferred re-preview of the same
177 /// file from resetting a scroll position that is about to be valid again.
178 pub fn scrollable_len(&self) -> Option<usize> {
179 match self {
180 Self::Empty | Self::Loading | Self::Image(_) => None,
181 Self::Text(lines) | Self::Binary(lines) => Some(lines.len()),
182 Self::Highlighted(lines) => Some(lines.len()),
183 Self::Directory { entries, .. } => Some(entries.len() + DIRECTORY_HEADER_LINES),
184 }
185 }
186
187 /// Number of terminal rows logical line `index` occupies in a pane `width`
188 /// columns wide.
189 ///
190 /// The text, binary and highlighted previews word-wrap, so a line longer
191 /// than the pane spills onto further rows; this measures it with the same
192 /// wrapper the renderer draws with, which is what keeps the scroll limit
193 /// and the position footer honest. The directory listing does not wrap, so
194 /// each of its lines is one row.
195 ///
196 /// Never returns less than `1`: an empty line still takes a row. A `width`
197 /// of zero (no frame drawn yet) and an out-of-range `index` also count as
198 /// one row, so callers can treat the result as a plain line count.
199 pub fn display_rows(&self, index: usize, width: u16) -> usize {
200 if width == 0 {
201 return 1;
202 }
203 let text = match self {
204 Self::Text(lines) | Self::Binary(lines) => match lines.get(index) {
205 Some(line) => line.clone(),
206 None => return 1,
207 },
208 Self::Highlighted(lines) => match lines.get(index) {
209 Some(spans) => {
210 let mut text = line_number_gutter(index + 1);
211 text.extend(spans.iter().map(|s| s.text.as_str()));
212 text
213 }
214 None => return 1,
215 },
216 Self::Empty | Self::Loading | Self::Image(_) | Self::Directory { .. } => return 1,
217 };
218 Paragraph::new(Line::raw(text))
219 .wrap(Wrap { trim: false })
220 .line_count(width)
221 .max(1)
222 }
223}
224
225/// The line-number column drawn ahead of a highlighted line, for the 1-based
226/// line `number`.
227///
228/// Shared by the renderer and [`PreviewContent::display_rows`]: the gutter
229/// takes up width, so the two must agree on it or the wrap measurement drifts
230/// from what is drawn.
231pub fn line_number_gutter(number: usize) -> String {
232 format!("{number:>4} ")
233}
234
235// ── PreviewOutcome ────────────────────────────────────────────────────────────
236
237/// The result of calling `PreviewProvider::preview`.
238///
239/// `Ready` means the provider produced content synchronously.
240/// `Deferred` means it spawned a worker task; the UI should show
241/// `PreviewContent::Loading` until the matching `WorkerMsg` arrives.
242#[derive(Debug)]
243pub enum PreviewOutcome {
244 /// Synchronous path: content is ready immediately.
245 Ready(PreviewContent),
246 /// Asynchronous path: a worker task was spawned.
247 ///
248 /// The UI renders `PreviewContent::Loading` until the worker result
249 /// merges in via `workers::merge`.
250 Deferred,
251}
252
253// ── PreviewCtx ────────────────────────────────────────────────────────────────
254
255/// Context passed to `PreviewProvider::preview`.
256///
257/// Carries shared resources that providers may need without storing them on
258/// `AppState` directly. Phase 5 adds `worker_tx` and `generation` so async
259/// providers can tag their results with the current generation counter and
260/// send them back over the shared worker channel.
261#[derive(Debug)]
262pub struct PreviewCtx {
263 /// Whether hidden entries should appear in directory previews.
264 pub show_hidden: bool,
265 /// Sender half of the shared worker channel. Async providers use this to
266 /// send `WorkerMsg::Preview` / `WorkerMsg::ImageMeta` results back to the
267 /// UI thread.
268 pub worker_tx: mpsc::Sender<WorkerMsg>,
269 /// The generation counter at the time the preview was requested.
270 ///
271 /// Async providers tag their `WorkerMsg` with this value; `workers::merge`
272 /// drops results whose generation no longer matches `state.preview.generation`.
273 pub generation: u64,
274 /// Maximum file size to preview synchronously on the UI thread.
275 #[allow(dead_code)]
276 pub text_sync_threshold_bytes: usize,
277 /// Maximum number of lines a text preview loads, from `[preview] max_lines`.
278 ///
279 /// The preview scrolls within this window; a file with more lines is
280 /// reported as truncated rather than silently cut. Read when the preview is
281 /// requested, so a `:set` of the key takes effect from the next preview on.
282 pub max_preview_lines: usize,
283}
284
285// ── PreviewProvider trait ─────────────────────────────────────────────────────
286
287/// Contract for all preview providers.
288///
289/// A provider can handle a subset of entries (e.g. text files, images) and
290/// produces `PreviewOutcome::Ready` for synchronous content or
291/// `PreviewOutcome::Deferred` when it kicks off a worker task.
292///
293/// Implementations are `Send + Sync` so the registry can be constructed
294/// once on the UI thread and shared across its lifetime.
295pub trait PreviewProvider: Send + Sync {
296 /// Returns `true` if this provider can preview `entry`.
297 fn can_handle(&self, entry: &Entry) -> bool;
298
299 /// Produces a preview for `entry`.
300 ///
301 /// May return `PreviewOutcome::Deferred` if it spawns a worker; otherwise
302 /// returns `PreviewOutcome::Ready(content)`.
303 ///
304 /// Implementations on the synchronous path must not perform blocking I/O
305 /// beyond small/fast reads (a few hundred KB). Larger reads must go
306 /// through the worker pool.
307 fn preview(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome;
308}
309
310// ── PreviewRegistry ───────────────────────────────────────────────────────────
311
312/// Ordered list of `PreviewProvider`s; first match wins.
313///
314/// Built once at startup via `register_defaults()` (and extended by plugins
315/// in Phase 8). The main loop calls `preview_for` on every selection change.
316pub struct PreviewRegistry {
317 providers: Vec<Box<dyn PreviewProvider>>,
318}
319
320impl PreviewRegistry {
321 /// Creates an empty registry.
322 pub fn new() -> Self {
323 Self {
324 providers: Vec::new(),
325 }
326 }
327
328 /// Appends `provider` to the end of the ordered list.
329 ///
330 /// Providers registered later have lower priority (first-match wins).
331 pub fn register(&mut self, provider: Box<dyn PreviewProvider>) {
332 self.providers.push(provider);
333 }
334
335 /// Finds the first registered provider that can handle `entry` and
336 /// returns its `PreviewOutcome`.
337 ///
338 /// Returns `PreviewOutcome::Ready(PreviewContent::Empty)` if no provider
339 /// matches (e.g. an empty directory or an unknown type).
340 pub fn preview_for(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome {
341 for provider in &self.providers {
342 if provider.can_handle(entry) {
343 return provider.preview(entry, ctx);
344 }
345 }
346 PreviewOutcome::Ready(PreviewContent::Empty)
347 }
348}
349
350impl Default for PreviewRegistry {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn sanitize_leaves_ordinary_text_alone() {
362 assert_eq!(sanitize("fn main() { todo!() }"), "fn main() { todo!() }");
363 // Not control characters: the terminal draws these.
364 assert_eq!(sanitize("héllo — wörld"), "héllo — wörld");
365 }
366
367 /// The artifact this removes: an ESC in a file that `content_inspector`
368 /// calls text reached the terminal as an escape sequence and painted
369 /// wherever it liked.
370 #[test]
371 fn sanitize_defuses_escape_sequences() {
372 let attack = "before\u{1b}[2J\u{1b}[Hafter";
373 let safe = sanitize(attack);
374 assert!(!safe.contains('\u{1b}'), "no escape may survive: {safe:?}");
375 assert_eq!(safe, "before·[2J·[Hafter");
376 }
377
378 #[test]
379 fn sanitize_replaces_carriage_returns_and_bells() {
380 // A CR would send the cursor back to the start of the row mid-pane.
381 assert_eq!(sanitize("a\rb\u{7}c"), "a·b·c");
382 }
383
384 #[test]
385 fn sanitize_expands_tabs_so_the_terminal_cannot_choose() {
386 assert_eq!(sanitize("\tindented"), " indented");
387 assert_eq!(sanitize("a\tb").chars().count(), 1 + TAB_WIDTH + 1);
388 }
389}