Skip to main content

binocular/preview/request/path/
registry.rs

1use super::fallback::build_text_or_binary_preview;
2use crate::preview::structured_log;
3use crate::preview::types::PreviewContent;
4use crate::preview::{archive, directory, image, media, pdf, sqlite, types};
5use ratatui::text::Text;
6use ratatui_image::picker::Picker;
7use std::path::Path;
8
9pub(crate) struct PreviewBuildContext<'a> {
10    pub picker: &'a Picker,
11    pub log_max_entries: usize,
12}
13
14pub(crate) trait PathPreviewer: Sync {
15    fn try_build(&self, path: &Path, ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent>;
16}
17
18struct DirectoryPreviewer;
19struct ImagePreviewer;
20struct MediaPreviewer;
21struct ArchivePreviewer;
22struct SqlitePreviewer;
23struct PdfPreviewer;
24struct StructuredLogPreviewer;
25struct TextOrBinaryPreviewer;
26
27static DIRECTORY_PREVIEWER: DirectoryPreviewer = DirectoryPreviewer;
28static IMAGE_PREVIEWER: ImagePreviewer = ImagePreviewer;
29static MEDIA_PREVIEWER: MediaPreviewer = MediaPreviewer;
30static ARCHIVE_PREVIEWER: ArchivePreviewer = ArchivePreviewer;
31static SQLITE_PREVIEWER: SqlitePreviewer = SqlitePreviewer;
32static PDF_PREVIEWER: PdfPreviewer = PdfPreviewer;
33static STRUCTURED_LOG_PREVIEWER: StructuredLogPreviewer = StructuredLogPreviewer;
34static TEXT_OR_BINARY_PREVIEWER: TextOrBinaryPreviewer = TextOrBinaryPreviewer;
35
36static PREVIEWERS: [&dyn PathPreviewer; 8] = [
37    &DIRECTORY_PREVIEWER,
38    &IMAGE_PREVIEWER,
39    &MEDIA_PREVIEWER,
40    &ARCHIVE_PREVIEWER,
41    &SQLITE_PREVIEWER,
42    &PDF_PREVIEWER,
43    &STRUCTURED_LOG_PREVIEWER,
44    &TEXT_OR_BINARY_PREVIEWER,
45];
46
47pub(crate) fn build_path_preview(
48    path_str: &str,
49    picker: &Picker,
50    log_max_entries: usize,
51) -> PreviewContent {
52    let path = Path::new(path_str);
53    if !path.exists() {
54        return PreviewContent::PlainText(Text::default());
55    }
56
57    let ctx = PreviewBuildContext {
58        picker,
59        log_max_entries,
60    };
61    for previewer in PREVIEWERS {
62        if let Some(preview) = previewer.try_build(path, &ctx) {
63            return preview;
64        }
65    }
66
67    PreviewContent::PlainText(Text::default())
68}
69
70impl PathPreviewer for DirectoryPreviewer {
71    fn try_build(&self, path: &Path, _ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
72        path.is_dir()
73            .then(|| PreviewContent::PlainText(directory::generate_preview(path)))
74    }
75}
76
77impl PathPreviewer for ImagePreviewer {
78    fn try_build(&self, path: &Path, ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
79        if !path.is_file() || !image::is_image_extension(path) {
80            return None;
81        }
82
83        let (protocol, metadata) = image::load_image(path, ctx.picker)?;
84        let metadata_line_count = metadata.lines.len();
85        Some(PreviewContent::Image(types::ImagePreview {
86            protocol,
87            metadata,
88            metadata_line_count,
89        }))
90    }
91}
92
93impl PathPreviewer for MediaPreviewer {
94    fn try_build(&self, path: &Path, ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
95        let kind = media::detect_media_kind(path)?;
96        let preview = media::generate_preview(path, kind);
97        let metadata_line_count = preview.text.lines.len();
98        let artwork = preview
99            .artwork_bytes
100            .as_deref()
101            .and_then(|bytes| ::image::load_from_memory(bytes).ok())
102            .map(|img| ctx.picker.new_resize_protocol(img));
103
104        Some(PreviewContent::Media(types::MediaPreview {
105            metadata: preview.text,
106            metadata_line_count,
107            artwork,
108        }))
109    }
110}
111
112impl PathPreviewer for ArchivePreviewer {
113    fn try_build(&self, path: &Path, _ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
114        archive::detect_archive_kind(path)
115            .map(|kind| PreviewContent::PlainText(archive::generate_preview(path, kind)))
116    }
117}
118
119impl PathPreviewer for SqlitePreviewer {
120    fn try_build(&self, path: &Path, _ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
121        sqlite::is_sqlite(path).then(|| PreviewContent::PlainText(sqlite::generate_preview(path)))
122    }
123}
124
125impl PathPreviewer for PdfPreviewer {
126    fn try_build(&self, path: &Path, _ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
127        pdf::is_pdf(path).then(|| PreviewContent::PlainText(pdf::generate_preview(path)))
128    }
129}
130
131impl PathPreviewer for StructuredLogPreviewer {
132    fn try_build(&self, path: &Path, ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
133        let format = structured_log::detect_structured_log(path)?;
134        let (entries, total_lines, all_fields) =
135            structured_log::parse_initial(path, &format, ctx.log_max_entries);
136        Some(structured_log::preview_content(
137            structured_log::StructuredLog {
138                entries,
139                total_lines,
140                all_fields,
141                format,
142            },
143        ))
144    }
145}
146
147impl PathPreviewer for TextOrBinaryPreviewer {
148    fn try_build(&self, path: &Path, _ctx: &PreviewBuildContext<'_>) -> Option<PreviewContent> {
149        if !path.is_file() {
150            return None;
151        }
152
153        Some(build_text_or_binary_preview(path))
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::path::PathBuf;
161    use std::time::{SystemTime, UNIX_EPOCH};
162
163    fn unique_temp_path(name: &str, ext: &str) -> PathBuf {
164        let nanos = SystemTime::now()
165            .duration_since(UNIX_EPOCH)
166            .unwrap_or_default()
167            .as_nanos();
168        std::env::temp_dir().join(format!("binocular-preview-{name}-{nanos}.{ext}"))
169    }
170
171    #[test]
172    fn structured_log_preview_wins_before_text_fallback() {
173        let path = unique_temp_path("structured", "log");
174        std::fs::write(&path, "{\"level\":\"info\",\"msg\":\"hello\"}\n").unwrap();
175
176        let preview =
177            build_path_preview(&path.display().to_string(), &Picker::halfblocks(), 10_000);
178
179        assert!(matches!(preview, PreviewContent::StructuredLog(_)));
180        let _ = std::fs::remove_file(path);
181    }
182
183    #[test]
184    fn text_fallback_handles_plain_text_files() {
185        let path = unique_temp_path("text", "txt");
186        std::fs::write(&path, "hello world\n").unwrap();
187
188        let preview =
189            build_path_preview(&path.display().to_string(), &Picker::halfblocks(), 10_000);
190
191        assert!(matches!(preview, PreviewContent::RichText(_)));
192        let _ = std::fs::remove_file(path);
193    }
194}