Skip to main content

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}