use std::path::PathBuf;
use ratatui::style::Color;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Wrap};
use thiserror::Error;
use tokio::sync::mpsc;
use crate::app::state::Entry;
use crate::preview::graphics::ImagePreview;
use crate::workers::WorkerMsg;
#[allow(dead_code)]
#[derive(Debug, Error)]
pub enum PreviewError {
#[error("I/O error reading {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct StyledSpan {
pub text: String,
pub fg: Option<Color>,
}
pub type HighlightedLine = Vec<StyledSpan>;
const TAB_WIDTH: usize = 4;
const CONTROL_PLACEHOLDER: char = '·';
pub fn sanitize(line: &str) -> String {
let mut out = String::with_capacity(line.len());
for ch in line.chars() {
match ch {
'\t' => (0..TAB_WIDTH).for_each(|_| out.push(' ')),
ch if ch.is_control() => out.push(CONTROL_PLACEHOLDER),
ch => out.push(ch),
}
}
out
}
#[derive(Debug, Clone, Default)]
pub enum PreviewContent {
#[default]
Empty,
Loading,
Text(Vec<String>),
Highlighted(Vec<HighlightedLine>),
Binary(Vec<String>),
Image(ImagePreview),
Directory {
file_count: usize,
dir_count: usize,
hidden_count: usize,
entries: Vec<String>,
},
}
const DIRECTORY_HEADER_LINES: usize = 2;
impl PreviewContent {
pub fn scrollable_len(&self) -> Option<usize> {
match self {
Self::Empty | Self::Loading | Self::Image(_) => None,
Self::Text(lines) | Self::Binary(lines) => Some(lines.len()),
Self::Highlighted(lines) => Some(lines.len()),
Self::Directory { entries, .. } => Some(entries.len() + DIRECTORY_HEADER_LINES),
}
}
pub fn display_rows(&self, index: usize, width: u16) -> usize {
if width == 0 {
return 1;
}
let text = match self {
Self::Text(lines) | Self::Binary(lines) => match lines.get(index) {
Some(line) => line.clone(),
None => return 1,
},
Self::Highlighted(lines) => match lines.get(index) {
Some(spans) => {
let mut text = line_number_gutter(index + 1);
text.extend(spans.iter().map(|s| s.text.as_str()));
text
}
None => return 1,
},
Self::Empty | Self::Loading | Self::Image(_) | Self::Directory { .. } => return 1,
};
Paragraph::new(Line::raw(text))
.wrap(Wrap { trim: false })
.line_count(width)
.max(1)
}
}
pub fn line_number_gutter(number: usize) -> String {
format!("{number:>4} ")
}
#[derive(Debug)]
pub enum PreviewOutcome {
Ready(PreviewContent),
Deferred,
}
#[derive(Debug)]
pub struct PreviewCtx {
pub show_hidden: bool,
pub worker_tx: mpsc::Sender<WorkerMsg>,
pub generation: u64,
#[allow(dead_code)]
pub text_sync_threshold_bytes: usize,
pub max_preview_lines: usize,
}
pub trait PreviewProvider: Send + Sync {
fn can_handle(&self, entry: &Entry) -> bool;
fn preview(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome;
}
pub struct PreviewRegistry {
providers: Vec<Box<dyn PreviewProvider>>,
}
impl PreviewRegistry {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn register(&mut self, provider: Box<dyn PreviewProvider>) {
self.providers.push(provider);
}
pub fn preview_for(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome {
for provider in &self.providers {
if provider.can_handle(entry) {
return provider.preview(entry, ctx);
}
}
PreviewOutcome::Ready(PreviewContent::Empty)
}
}
impl Default for PreviewRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_leaves_ordinary_text_alone() {
assert_eq!(sanitize("fn main() { todo!() }"), "fn main() { todo!() }");
assert_eq!(sanitize("héllo — wörld"), "héllo — wörld");
}
#[test]
fn sanitize_defuses_escape_sequences() {
let attack = "before\u{1b}[2J\u{1b}[Hafter";
let safe = sanitize(attack);
assert!(!safe.contains('\u{1b}'), "no escape may survive: {safe:?}");
assert_eq!(safe, "before·[2J·[Hafter");
}
#[test]
fn sanitize_replaces_carriage_returns_and_bells() {
assert_eq!(sanitize("a\rb\u{7}c"), "a·b·c");
}
#[test]
fn sanitize_expands_tabs_so_the_terminal_cannot_choose() {
assert_eq!(sanitize("\tindented"), " indented");
assert_eq!(sanitize("a\tb").chars().count(), 1 + TAB_WIDTH + 1);
}
}