raffi 0.25.0

A simple fuzzel launcher based on YAML file
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! File browser previews produced by a preview command: the bundled
//! `scripts/file-preview` script, or the user's `preview_command`.
//!
//! The command gets the selected path and prints ANSI text or a Script Filter
//! preview object. The built-in preview stays on screen until it answers.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
use std::time::{Duration, SystemTime};

use iced::Task;

use super::state::LauncherApp;
use super::state::Message;
use super::types::PreparedPreview;

/// Longest a preview command may run before it is killed.
const PREVIEW_TIMEOUT: Duration = Duration::from_secs(3);
/// Largest output accepted, enough for a base64 image.
const MAX_PREVIEW_OUTPUT: usize = 8 * 1024 * 1024;
/// Text output beyond these limits is cut, to keep the pane responsive.
const MAX_TEXT_LINES: usize = 1000;
const MAX_TEXT_BYTES: usize = 256 * 1024;
/// Wait after a selection change before running the command, so scrolling
/// through a directory does not start one process per row.
const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(80);
/// Previews kept for the session.
pub(super) const PREVIEW_CACHE_SIZE: usize = 64;
/// Monospace cell size relative to the font size, to tell the command how
/// many columns and lines fit in the pane.
const CELL_WIDTH_RATIO: f32 = 0.6;
const CELL_HEIGHT_RATIO: f32 = 1.3;
/// Horizontal padding of the text block the output is drawn in.
const TEXT_BLOCK_PADDING: f32 = 24.0;

/// Fields of a Script Filter preview object. A JSON object using only these
/// keys is a preview; any other JSON (such as a `.json` file printed as is)
/// is shown as text.
const PREVIEW_OBJECT_KEYS: &[&str] = &[
    "title", "subtitle", "badge", "icon", "text", "image", "sections", "metadata", "refresh",
    "dynamic",
];

/// The bundled previewer, run with python3 unless `preview_command` says
/// otherwise.
const BUNDLED_SCRIPT: &str = include_str!("../../../scripts/file-preview/file-preview.py");

/// `file-preview-<hash>.py`: the content hash means a raffi upgrade never
/// runs an older copy. FNV-1a because it is stable across builds, unlike the
/// std hasher.
static BUNDLED_SCRIPT_NAME: LazyLock<String> = LazyLock::new(|| {
    let hash = BUNDLED_SCRIPT
        .bytes()
        .fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
            (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
        });
    format!("file-preview-{hash:016x}.py")
});

/// Which command previews file browser entries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FilePreviewCommand {
    Bundled,
    Custom(String),
}

impl FilePreviewCommand {
    /// The `preview_command` setting: unset means the bundled script when
    /// python3 is installed, `none` turns previews off.
    pub fn resolve(config: Option<&str>, program_exists: fn(&str) -> bool) -> Option<Self> {
        match config.map(str::trim) {
            None | Some("") => program_exists("python3").then_some(Self::Bundled),
            Some("none") => None,
            Some(command) => Some(Self::Custom(command.to_string())),
        }
    }
}

/// Write the bundled script to `dir` unless it is already there, remove
/// older copies, and return its path. The file is checked on every run, so
/// deleting it is harmless.
fn install_bundled_script(dir: &Path) -> std::io::Result<PathBuf> {
    let name = BUNDLED_SCRIPT_NAME.as_str();
    let path = dir.join(name);
    if path.is_file() {
        return Ok(path);
    }
    std::fs::create_dir_all(dir)?;
    let tmp = dir.join(format!(".{name}.{}", std::process::id()));
    std::fs::write(&tmp, BUNDLED_SCRIPT)?;
    std::fs::rename(&tmp, &path)?;
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let old = entry.file_name();
            let old = old.to_string_lossy();
            if old != name && old.starts_with("file-preview-") && old.ends_with(".py") {
                let _ = std::fs::remove_file(entry.path());
            }
        }
    }
    Ok(path)
}

/// A path as it was when previewed, so edits get a fresh preview.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FilePreviewKey {
    pub path: String,
    modified: Option<SystemTime>,
    len: u64,
}

impl FilePreviewKey {
    pub fn of(path: &str) -> Self {
        let meta = std::fs::metadata(path).ok();
        Self {
            path: path.to_string(),
            modified: meta.as_ref().and_then(|meta| meta.modified().ok()),
            len: meta.map_or(0, |meta| meta.len()),
        }
    }
}

/// What the preview command printed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FilePreviewOutput {
    /// ANSI text, already cut to the display limits.
    Text(String),
    Rich(Box<PreparedPreview>),
}

/// Result of one run: `Ok(None)` when the command has no preview for the
/// path (no output or a non-zero exit) and the built-in one is kept.
pub(crate) type FilePreviewResult = Result<Option<FilePreviewOutput>, String>;

/// Read the command output: a preview object, ANSI text, or nothing.
pub(super) fn parse_output(success: bool, stdout: &[u8]) -> FilePreviewResult {
    if !success {
        return Ok(None);
    }
    let text = String::from_utf8_lossy(stdout);
    if text.trim().is_empty() {
        return Ok(None);
    }
    if let Ok(serde_json::Value::Object(object)) = serde_json::from_str(&text) {
        if !object.is_empty()
            && object
                .keys()
                .all(|key| PREVIEW_OBJECT_KEYS.contains(&key.as_str()))
        {
            let spec: super::types::RawPreviewSpec =
                serde_json::from_value(serde_json::Value::Object(object))
                    .map_err(|error| format!("Invalid preview: {error}"))?;
            return Ok(Some(FilePreviewOutput::Rich(Box::new(spec.into()))));
        }
    }
    Ok(Some(FilePreviewOutput::Text(cap_text(&text))))
}

/// Cut `text` to the display limits, noting what was left out.
fn cap_text(text: &str) -> String {
    let text = text.trim_end_matches(['\n', '\r']);
    let mut end = text.len();
    let mut cut = false;
    if let Some((index, _)) = text.match_indices('\n').nth(MAX_TEXT_LINES - 1) {
        end = index;
        cut = true;
    }
    if end > MAX_TEXT_BYTES {
        end = MAX_TEXT_BYTES;
        while !text.is_char_boundary(end) {
            end -= 1;
        }
        cut = true;
    }
    if !cut {
        return text.to_string();
    }
    // Reset styles first: the cut may fall inside a coloured run.
    format!("{}\x1b[0m\n\x1b[2m… output truncated\x1b[0m", &text[..end])
}

/// Everything needed to run the preview command once.
#[derive(Debug, Clone)]
pub(super) struct FilePreviewRequest {
    command: String,
    env: HashMap<String, String>,
}

impl FilePreviewRequest {
    /// `pane` is the preview pane size in pixels and `font_size` the size of
    /// the monospace text the output is drawn with.
    pub fn new(command: &str, path: &str, pane: (f32, f32), font_size: f32) -> Self {
        // One spare column: glyph advances vary between fonts, and a line
        // that overflows by a hair would wrap a second time in the pane.
        let columns = (pane.0 - TEXT_BLOCK_PADDING) / (font_size * CELL_WIDTH_RATIO) - 1.0;
        let lines = pane.1 / (font_size * CELL_HEIGHT_RATIO);
        let env = HashMap::from([
            ("RAFFI_PREVIEW".into(), "1".into()),
            ("RAFFI_VALUE".into(), path.to_string()),
            (
                "RAFFI_PREVIEW_WIDTH".into(),
                (pane.0.round() as u32).to_string(),
            ),
            (
                "RAFFI_PREVIEW_HEIGHT".into(),
                (pane.1.round() as u32).to_string(),
            ),
            (
                "RAFFI_PREVIEW_COLUMNS".into(),
                (columns.floor().max(20.0) as u32).to_string(),
            ),
            (
                "RAFFI_PREVIEW_LINES".into(),
                (lines.floor().max(5.0) as u32).to_string(),
            ),
        ]);
        Self {
            command: command.to_string(),
            env,
        }
    }

    /// The path is passed through the environment, never pasted into the
    /// shell line, so any file name is safe.
    fn shell_line(&self) -> String {
        format!("{} \"$RAFFI_VALUE\"", self.command)
    }

    pub fn run(self) -> FilePreviewResult {
        crate::debug_log!(
            "file_preview: command={:?} path={:?}",
            self.command,
            self.env.get("RAFFI_VALUE")
        );
        let mut command = Command::new("sh");
        command.arg("-c").arg(self.shell_line()).envs(&self.env);
        let (status, output) =
            super::actions::run_bounded_status(command, PREVIEW_TIMEOUT, MAX_PREVIEW_OUTPUT)?;
        let result = parse_output(status.success(), &output);
        if !status.success() {
            crate::debug_log!("file_preview: exited with {status}, using built-in preview");
        }
        result
    }
}

impl LauncherApp {
    /// Path of the selected file browser entry when the preview command
    /// should describe it.
    fn file_preview_target(&self) -> Option<String> {
        if !self.view.show_preview || !self.file_browser.active {
            return None;
        }
        self.file_browser.preview_command.as_ref()?;
        self.selected_file_to_inspect().map(|(path, _)| path)
    }

    /// Follow the selection: when it moved to another entry, schedule a run
    /// unless a current preview of it is cached. Called after every update.
    pub(super) fn ensure_file_browser_preview(&mut self) -> Task<Message> {
        let target = self.file_preview_target();
        let state = &mut self.file_browser;
        if target == state.preview_target {
            return Task::none();
        }
        state.preview_target = target.clone();
        state.preview_due = false;
        let Some(path) = target else {
            return Task::none();
        };
        let key = FilePreviewKey::of(&path);
        let fresh = state
            .script_previews
            .iter()
            .any(|(cached, result)| *cached == key && result.is_ok());
        if fresh {
            return Task::none();
        }
        state
            .script_previews
            .retain(|(cached, _)| cached.path != path);
        Task::perform(
            async move {
                let (sender, receiver) = iced::futures::channel::oneshot::channel();
                std::thread::spawn(move || {
                    std::thread::sleep(PREVIEW_DEBOUNCE);
                    let _ = sender.send(());
                });
                let _ = receiver.await;
                path
            },
            Message::FileBrowserPreviewDue,
        )
    }

    pub(super) fn handle_file_browser_preview_due(&mut self, path: String) -> Task<Message> {
        if self.file_browser.preview_target.as_deref() != Some(path.as_str()) {
            return Task::none();
        }
        self.file_browser.preview_due = true;
        self.start_file_browser_preview()
    }

    /// Run the command for the selected entry once its debounce is over and
    /// no other run is out.
    fn start_file_browser_preview(&mut self) -> Task<Message> {
        let state = &self.file_browser;
        if !state.preview_due || state.preview_inflight {
            return Task::none();
        }
        let (Some(path), Some(command)) =
            (state.preview_target.clone(), state.preview_command.clone())
        else {
            return Task::none();
        };
        if state.script_preview(&path).is_some() {
            return Task::none();
        }
        let pane = (
            self.preview_viewport_width(),
            self.preview_viewport_height(),
        );
        let (command, script) = match command {
            FilePreviewCommand::Custom(command) => (crate::expand_config_value(&command), None),
            FilePreviewCommand::Bundled => match install_bundled_script(&crate::icon_cache_dir()) {
                Ok(script) => (
                    "python3 \"$RAFFI_FILE_PREVIEW_SCRIPT\"".to_string(),
                    Some(script),
                ),
                Err(e) => {
                    crate::debug_log!("file_preview: cannot write bundled script: {e}");
                    self.file_browser.preview_command = None;
                    return Task::none();
                }
            },
        };
        let mut request =
            FilePreviewRequest::new(&command, &path, pane, self.view.preview_style.text);
        request.env.extend(self.view.theme.command_env());
        if let Some(script) = script {
            request.env.insert(
                "RAFFI_FILE_PREVIEW_SCRIPT".into(),
                script.to_string_lossy().into_owned(),
            );
        }
        let key = FilePreviewKey::of(&path);
        self.file_browser.preview_inflight = true;
        Task::perform(
            async move {
                let (sender, receiver) = iced::futures::channel::oneshot::channel();
                std::thread::spawn(move || {
                    let _ = sender.send(request.run());
                });
                let result = receiver
                    .await
                    .unwrap_or_else(|_| Err("preview worker stopped".into()));
                (key, result)
            },
            |(key, result)| Message::FileBrowserPreview(key, result),
        )
    }

    pub(super) fn handle_file_browser_preview(
        &mut self,
        key: FilePreviewKey,
        result: FilePreviewResult,
    ) -> Task<Message> {
        let state = &mut self.file_browser;
        state.preview_inflight = false;
        if let Err(error) = &result {
            crate::debug_log!("file_preview: {error}");
        }
        state
            .script_previews
            .retain(|(cached, _)| cached.path != key.path);
        if state.script_previews.len() >= PREVIEW_CACHE_SIZE {
            state.script_previews.drain(..1);
        }
        state.script_previews.push((key, result));
        // The selection may have moved on while this one ran.
        self.start_file_browser_preview()
    }
}

#[cfg(test)]
mod tests {
    use super::super::types::PreparedPreviewBody;
    use super::{
        install_bundled_script, parse_output, FilePreviewCommand, FilePreviewOutput,
        FilePreviewRequest, BUNDLED_SCRIPT, MAX_TEXT_LINES,
    };

    #[test]
    fn test_preview_command_defaults_to_bundled_script_with_python() {
        let resolve = FilePreviewCommand::resolve;
        assert_eq!(resolve(None, |_| true), Some(FilePreviewCommand::Bundled));
        assert_eq!(resolve(None, |_| false), None);
        assert_eq!(resolve(Some("none"), |_| true), None);
        assert_eq!(
            resolve(Some("~/bin/preview"), |_| false),
            Some(FilePreviewCommand::Custom("~/bin/preview".into()))
        );
    }

    #[test]
    fn test_bundled_script_is_installed_once_and_replaces_old_copies() {
        let dir = std::env::temp_dir().join(format!("raffi-bundled-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let old = dir.join("file-preview-0000000000000000.py");
        std::fs::write(&old, "old").unwrap();

        let path = install_bundled_script(&dir).unwrap();
        assert_eq!(std::fs::read_to_string(&path).unwrap(), BUNDLED_SCRIPT);
        assert!(!old.exists());
        assert_eq!(install_bundled_script(&dir).unwrap(), path);
        std::fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn test_parse_output_reads_text_preview_objects_and_fallbacks() {
        assert_eq!(parse_output(true, b"  \n"), Ok(None));
        assert_eq!(parse_output(false, b"No preview"), Ok(None));
        assert_eq!(
            parse_output(true, b"\x1b[31mred\x1b[0m\n\n"),
            Ok(Some(FilePreviewOutput::Text("\x1b[31mred\x1b[0m".into())))
        );

        let Ok(Some(FilePreviewOutput::Rich(preview))) = parse_output(
            true,
            br#"{"badge":"PDF","image":"/tmp/p.jpg","metadata":[["Pages","3"]]}"#,
        ) else {
            panic!("expected a preview object");
        };
        assert_eq!(preview.badge.as_deref(), Some("PDF"));
        assert_eq!(
            preview.bodies,
            vec![PreparedPreviewBody::Image("/tmp/p.jpg".into())]
        );
        assert_eq!(preview.metadata, vec![("Pages".into(), "3".into())]);

        assert!(parse_output(true, br#"{"metadata": "oops"}"#).is_err());
    }

    #[test]
    fn test_plain_json_files_are_shown_as_text() {
        let package = br#"{"name": "demo", "title": "x"}"#;
        assert_eq!(
            parse_output(true, package),
            Ok(Some(FilePreviewOutput::Text(
                String::from_utf8_lossy(package).into()
            )))
        );
        assert!(matches!(
            parse_output(true, b"{}"),
            Ok(Some(FilePreviewOutput::Text(_)))
        ));
    }

    #[test]
    fn test_long_text_is_truncated_with_a_note() {
        let long = "line\n".repeat(MAX_TEXT_LINES + 50);
        let Ok(Some(FilePreviewOutput::Text(text))) = parse_output(true, long.as_bytes()) else {
            panic!("expected text");
        };
        assert_eq!(text.matches("line").count(), MAX_TEXT_LINES);
        assert!(text.ends_with("… output truncated\x1b[0m"));
    }

    #[test]
    fn test_hostile_file_names_reach_the_command_verbatim() {
        let dir = std::env::temp_dir().join(format!("raffi-preview-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("$(touch pwned) `id` \"q\" 'a b'.txt");
        std::fs::write(&path, "").unwrap();
        let path = path.to_string_lossy().to_string();

        let request = FilePreviewRequest::new("printf '%s'", &path, (700.0, 800.0), 16.0);
        assert_eq!(request.run(), Ok(Some(FilePreviewOutput::Text(path))));
        assert!(!dir.join("pwned").exists());

        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn test_request_reports_the_pane_in_cells() {
        let request =
            FilePreviewRequest::new("env", "/tmp/x", (24.0 + 9.6 * 70.0, 20.8 * 40.0), 16.0);
        assert_eq!(request.env["RAFFI_PREVIEW_COLUMNS"], "69");
        assert_eq!(request.env["RAFFI_PREVIEW_LINES"], "40");
        assert_eq!(request.env["RAFFI_PREVIEW"], "1");
    }
}