Skip to main content

rich_interact/components/
file.rs

1//! A file picker (#493, `rich file`): browse directories from a root.
2//!
3//! The listing is a [`Select`], so typing filters it fuzzily and the
4//! focused entry previews beside it: a text file's first lines,
5//! highlighted by its extension, or a directory's entries. Enter opens a
6//! directory (or picks it, when directories may be picked) and picks a
7//! file; Right opens, Left and Backspace on an empty filter go up to `..`,
8//! and Ctrl+T shows or hides hidden files. Actions on entries are
9//! [`TargetKind::File`] actions.
10//!
11//! With a root jail ([`FilePicker::jail`]), nothing outside the root is
12//! listed, opened or previewed: `..` stops at the root, and a symbolic link
13//! whose target is outside it is left out. Names that are not valid UTF-8
14//! show with each such byte as `\xNN`, as the `rich` command spells such
15//! paths; the path returned is the real one.
16
17use std::ffi::{OsStr, OsString};
18use std::io::Read;
19use std::path::{Component as PathPart, Path, PathBuf};
20use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
21use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
22use std::sync::{Arc, Mutex, OnceLock};
23use std::time::Duration;
24
25use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
26
27use crate::component::{Component, Context, Flow, View};
28use crate::components::{PreviewLayout, Select, Theme, text};
29use crate::event::{Event, Key};
30use crate::item::{Actions, Item, Preview, TargetKind};
31use crate::keymap::{Keymap, keys};
32use crate::policy::{LineIo, NotInteractive};
33
34/// The actions the picker handles itself, in context `file`.
35const FILE_ACTIONS: [&str; 3] = ["open", "up", "hidden"];
36
37/// The most entries listed from one directory.
38const MAX_ENTRIES: usize = 50_000;
39/// The most bytes of a file read for its preview.
40const PREVIEW_BYTES: u64 = 64 * 1024;
41/// The most lines of a preview.
42const PREVIEW_LINES: usize = 200;
43/// How long drawing a preview waits for its read, on a thread of its own:
44/// a read that blocks (a stalled network mount, `/proc/kmsg`) must not
45/// hold up the keys.
46const PREVIEW_WAIT: Duration = Duration::from_millis(250);
47
48/// What may be picked.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum FileMode {
51    /// Files; directories are only opened (the default).
52    #[default]
53    File,
54    /// Directories only; files are not listed.
55    Directory,
56    /// Either: Enter picks a directory, Right opens it.
57    Both,
58}
59
60/// One listed entry: a name in the current directory, or `..`.
61#[derive(Clone, Debug, PartialEq, Eq)]
62struct Entry {
63    /// `None` for `..`.
64    name: Option<OsString>,
65    dir: bool,
66}
67
68/// `name` as text: itself when it is valid UTF-8, else with each byte
69/// that is not as `\xNN` (and `\` as `\x5C`, so no two names share a
70/// spelling), the way the `rich` command spells such paths.
71pub fn display_name(name: &OsStr) -> String {
72    if let Some(text) = name.to_str() {
73        return text.to_string();
74    }
75    #[cfg(unix)]
76    {
77        use std::os::unix::ffi::OsStrExt;
78        let mut out = String::new();
79        for chunk in name.as_bytes().utf8_chunks() {
80            out.push_str(&chunk.valid().replace('\\', "\\x5C"));
81            for byte in chunk.invalid() {
82                out.push_str(&format!("\\x{byte:02X}"));
83            }
84        }
85        out
86    }
87    #[cfg(not(unix))]
88    name.to_string_lossy().into_owned()
89}
90
91/// A path as text, component by component: see [`display_name`].
92pub fn display_path(path: &Path) -> String {
93    match path.to_str() {
94        Some(text) => text.to_string(),
95        None => display_name(path.as_os_str()),
96    }
97}
98
99fn hidden_name(name: &OsStr) -> bool {
100    name.as_encoded_bytes().first() == Some(&b'.')
101}
102
103/// Browse from a root and pick a path. Returns the path, joined to the
104/// root as given (a root of `.` gives paths relative to it).
105#[must_use = "a component does nothing until it is run or mounted"]
106pub struct FilePicker {
107    root: PathBuf,
108    /// The canonical root, when jailed.
109    jail: Option<PathBuf>,
110    /// The directory shown, relative to the root.
111    rel: PathBuf,
112    mode: FileMode,
113    extensions: Vec<String>,
114    show_hidden: bool,
115    select: Select<Entry>,
116    default: Option<PathBuf>,
117    /// Why the directory could not be read, or that it was cut short.
118    note: Option<String>,
119    theme: Theme,
120    /// Preview reads still going, or finished and not yet drawn.
121    wake: Arc<PreviewWake>,
122    /// Keys rebound on this picker's own actions ([`FilePicker::rebind`]).
123    rebound: Keymap,
124}
125
126/// Shared by a picker and its previews: a read that outlasts
127/// [`PREVIEW_WAIT`] finishes on its own thread, and the picker ticks until
128/// its result has been drawn, so it shows without a key press.
129#[derive(Default)]
130struct PreviewWake {
131    running: AtomicUsize,
132    ready: AtomicBool,
133}
134
135impl PreviewWake {
136    fn wants_tick(&self) -> bool {
137        self.running.load(Ordering::SeqCst) > 0 || self.ready.load(Ordering::SeqCst)
138    }
139}
140
141impl FilePicker {
142    pub fn new(prompt: impl Into<String>, root: impl Into<PathBuf>) -> FilePicker {
143        let root = root.into();
144        let root = if root.as_os_str().is_empty() {
145            PathBuf::from(".")
146        } else {
147            root
148        };
149        let mut select = Select::new(prompt, Vec::<Item<Entry>>::new());
150        select.set_kind(TargetKind::File);
151        // One height for every directory, so the view does not jump.
152        select.steady = true;
153        let mut picker = FilePicker {
154            root,
155            jail: None,
156            rel: PathBuf::new(),
157            mode: FileMode::File,
158            extensions: Vec::new(),
159            show_hidden: false,
160            select,
161            default: None,
162            note: None,
163            theme: Theme::default(),
164            wake: Arc::default(),
165            rebound: Keymap::new("file"),
166        };
167        picker.load();
168        picker
169    }
170
171    /// What may be picked (default: files).
172    pub fn mode(mut self, mode: FileMode) -> Self {
173        self.mode = mode;
174        self.load();
175        self
176    }
177
178    /// List only files with one of these extensions (without the dot, any
179    /// case); directories are still listed, to open.
180    pub fn extensions<I: IntoIterator<Item = S>, S: AsRef<str>>(mut self, extensions: I) -> Self {
181        self.extensions = extensions
182            .into_iter()
183            .map(|e| e.as_ref().trim_start_matches('.').to_lowercase())
184            .filter(|e| !e.is_empty())
185            .collect();
186        self.load();
187        self
188    }
189
190    /// Show hidden files (names starting with `.`) to begin with; Ctrl+T
191    /// toggles them.
192    pub fn show_hidden(mut self, on: bool) -> Self {
193        self.show_hidden = on;
194        self.load();
195        self
196    }
197
198    /// Keep to the root: nothing outside it is listed, opened or read, and
199    /// symbolic links out of it are left out. A root that cannot be
200    /// resolved lists nothing.
201    pub fn jail(mut self, on: bool) -> Self {
202        self.jail = if on {
203            Some(
204                self.root
205                    .canonicalize()
206                    .unwrap_or_else(|_| PathBuf::from("\0")),
207            )
208        } else {
209            None
210        };
211        self.load();
212        self
213    }
214
215    /// The path returned without a terminal when the policy asks for
216    /// defaults.
217    pub fn default(mut self, path: impl Into<PathBuf>) -> Self {
218        self.default = Some(path.into());
219        self
220    }
221
222    /// Show at most `rows` entries at once (default 10).
223    pub fn height(mut self, rows: usize) -> Self {
224        self.select = self.select.height(rows);
225        self
226    }
227
228    pub fn preview(mut self, layout: PreviewLayout) -> Self {
229        self.select = self.select.preview(layout);
230        self
231    }
232
233    /// Start with this filter text.
234    pub fn query(mut self, query: impl Into<String>) -> Self {
235        self.select = self.select.query(query);
236        self
237    }
238
239    /// Make `keys` do `action` on this picker only; no keys unbinds it.
240    /// The actions are the picker's own (`open`, `up`, `hidden`, in context
241    /// `file`) and its list's (see [`select_keymap`](crate::components::select_keymap)).
242    /// A name both have means the picker's: `up` is going to the parent
243    /// directory, not the cursor. Write `select.up` for the list's (and
244    /// `file.up`, if you like, for the picker's).
245    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
246        let keys: Vec<Key> = keys.into_iter().collect();
247        if let Some(action) = action.strip_prefix("select.") {
248            self.select = self.select.rebind(action, keys);
249            return self;
250        }
251        let action = action.strip_prefix("file.").unwrap_or(action);
252        if FILE_ACTIONS.contains(&action) {
253            self.rebound.rebind(action, keys);
254        } else {
255            self.select = self.select.rebind(action, keys);
256        }
257        self
258    }
259
260    /// The picker's own keys, without its list's (their actions are
261    /// [`FILE_ACTIONS`]).
262    fn file_keymap(&self) -> Keymap {
263        let mut keymap = Keymap::new("file")
264            .bind("open", keys("right"), "open the directory")
265            .bind("up", keys("left"), "go up (Backspace with no filter)")
266            .bind("hidden", keys("ctrl+t"), "show or hide hidden files");
267        keymap.extend(self.rebound.clone());
268        keymap
269    }
270
271    pub fn theme(mut self, theme: Theme) -> Self {
272        self.theme = theme.clone();
273        self.select = self.select.theme(theme);
274        self.load();
275        self
276    }
277
278    /// Actions offered on every entry (#491).
279    pub fn actions(mut self, actions: Actions) -> Self {
280        self.select = self.select.actions(actions);
281        self
282    }
283
284    /// The key that opens the action menu (default Ctrl+K).
285    pub fn menu_key(mut self, key: Key) -> Self {
286        self.select = self.select.menu_key(key);
287        self
288    }
289
290    /// Report the mouse: a click focuses, a second click opens or picks,
291    /// and the border beside the preview drags.
292    pub fn with_mouse(mut self, on: bool) -> Self {
293        self.select = self.select.with_mouse(on);
294        self
295    }
296
297    /// The id of the action that picked the entry, if one did.
298    pub fn action(&self) -> Option<&str> {
299        self.select.action()
300    }
301
302    /// The directory shown.
303    pub fn directory(&self) -> PathBuf {
304        self.path_of(None)
305    }
306
307    /// The labels listed, in order (`..` first when it is there).
308    pub fn labels(&self) -> Vec<String> {
309        self.select
310            .items()
311            .iter()
312            .map(|item| item.label.clone())
313            .collect()
314    }
315
316    /// The path of `name` in the directory shown, or of the directory.
317    fn path_of(&self, name: Option<&OsStr>) -> PathBuf {
318        let mut rel = self.rel.clone();
319        if let Some(name) = name {
320            rel.push(name);
321        }
322        if self.root == Path::new(".") {
323            if rel.as_os_str().is_empty() {
324                PathBuf::from(".")
325            } else {
326                rel
327            }
328        } else {
329            self.root.join(rel)
330        }
331    }
332
333    /// Whether `path` is inside the jail (always, without one).
334    fn allowed(&self, path: &Path) -> bool {
335        match &self.jail {
336            None => true,
337            Some(jail) => path.canonicalize().is_ok_and(|real| real.starts_with(jail)),
338        }
339    }
340
341    fn at_root(&self) -> bool {
342        self.rel.as_os_str().is_empty()
343    }
344
345    /// Read the directory shown into the list.
346    fn load(&mut self) {
347        let dir = self.root.join(&self.rel);
348        self.note = None;
349        let mut entries: Vec<(Entry, String)> = Vec::new();
350        if !self.allowed(&dir) {
351            self.note = Some("outside the root".into());
352        } else {
353            match std::fs::read_dir(&dir) {
354                Err(error) => self.note = Some(format!("cannot read: {error}")),
355                Ok(listing) => {
356                    for entry in listing.flatten() {
357                        if entries.len() >= MAX_ENTRIES {
358                            self.note = Some(format!("first {MAX_ENTRIES} entries"));
359                            break;
360                        }
361                        let name = entry.file_name();
362                        if hidden_name(&name) && !self.show_hidden {
363                            continue;
364                        }
365                        let path = entry.path();
366                        let Ok(kind) = entry.file_type() else {
367                            continue;
368                        };
369                        // A link counts as what it points to, and only when
370                        // that is allowed.
371                        let dir = if kind.is_symlink() {
372                            if !self.allowed(&path) {
373                                continue;
374                            }
375                            path.is_dir()
376                        } else {
377                            kind.is_dir()
378                        };
379                        if !dir && !self.file_listed(&name) {
380                            continue;
381                        }
382                        let label = display_name(&name);
383                        entries.push((
384                            Entry {
385                                name: Some(name),
386                                dir,
387                            },
388                            label,
389                        ));
390                    }
391                }
392            }
393        }
394        entries.sort_by(|(a, x), (b, y)| {
395            b.dir
396                .cmp(&a.dir)
397                .then_with(|| x.to_lowercase().cmp(&y.to_lowercase()))
398                .then_with(|| x.cmp(y))
399        });
400        let up = if self.jail.is_some() {
401            !self.at_root()
402        } else {
403            true
404        };
405        let mut items = Vec::with_capacity(entries.len() + 1);
406        let mut values = Vec::with_capacity(entries.len() + 1);
407        if up {
408            items.push(Item::new(
409                Entry {
410                    name: None,
411                    dir: true,
412                },
413                "..",
414            ));
415            values.push(display_path(&self.path_of(Some(OsStr::new("..")))));
416        }
417        for (entry, label) in entries {
418            let path = self.path_of(entry.name.as_deref());
419            values.push(display_path(&path));
420            let full = self
421                .root
422                .join(&self.rel)
423                .join(entry.name.as_deref().unwrap_or_default());
424            let preview = FilePreview::new(full, self.jail.clone(), Arc::clone(&self.wake));
425            let preview = Preview::Renderable(Arc::new(preview));
426            let item = if entry.dir {
427                Item::new(entry, format!("{label}/")).preview(preview)
428            } else {
429                Item::new(entry, label).preview(preview)
430            };
431            items.push(item);
432        }
433        let entries = items.len();
434        self.select.replace_items(items);
435        self.select.set_values(values);
436        // The first entry, rather than `..`, is focused in a directory.
437        if up && entries > 1 {
438            self.select.focus_item(1);
439        }
440        self.select.heading = Some(vec![text(
441            display_path(&self.directory()),
442            &self.theme.hint,
443        )]);
444        let mut hints = String::from("→ open · ← up · ctrl+t hidden");
445        if let Some(note) = &self.note {
446            hints.push_str(&format!(" · {note}"));
447        }
448        self.select.hints = Some(hints);
449    }
450
451    fn file_listed(&self, name: &OsStr) -> bool {
452        if self.mode == FileMode::Directory {
453            return false;
454        }
455        if self.extensions.is_empty() {
456            return true;
457        }
458        // The name's end rather than its last extension, so `tar.gz`
459        // matches `a.tar.gz`; a name that is only the suffix does not.
460        let name = name.to_string_lossy().to_lowercase();
461        self.extensions
462            .iter()
463            .any(|ext| name.len() > ext.len() + 1 && name.ends_with(&format!(".{ext}")))
464    }
465
466    fn open(&mut self, name: &OsStr) {
467        let target = self.root.join(&self.rel).join(name);
468        if self.allowed(&target) {
469            self.rel.push(name);
470            self.load();
471        }
472    }
473
474    fn up(&mut self) {
475        if self.jail.is_some() && self.at_root() {
476            return;
477        }
478        let from = self.rel.file_name().map(OsStr::to_os_string);
479        match self.rel.components().next_back() {
480            Some(PathPart::Normal(_)) => {
481                self.rel.pop();
482            }
483            _ => self.rel.push(".."),
484        }
485        self.load();
486        // Focus the directory just left.
487        if let Some(from) = from {
488            let index = self
489                .select
490                .items()
491                .iter()
492                .position(|item| item.value.name.as_deref() == Some(from.as_os_str()));
493            if let Some(index) = index {
494                self.select.focus_item(index);
495            }
496        }
497    }
498
499    fn entry(&self, index: usize) -> Entry {
500        self.select.items()[index].value.clone()
501    }
502
503    /// Show the list again after the picker finished or was cancelled, in
504    /// the directory it was in: for a container that opens one picker more
505    /// than once (a [`Form`](crate::Form)'s file field).
506    pub fn reopen(&mut self) {
507        self.select.reopen();
508    }
509
510    fn finish(&mut self, path: PathBuf) -> Flow<PathBuf> {
511        self.select.set_answer(Some(display_path(&path)));
512        Flow::Done(path)
513    }
514
515    /// Enter (or a second click) on entry `index`.
516    fn activate(&mut self, index: usize) -> Flow<PathBuf> {
517        let entry = self.entry(index);
518        self.select.reopen();
519        match entry.name {
520            None => self.up(),
521            Some(name) if entry.dir => {
522                if self.mode == FileMode::File {
523                    self.open(&name);
524                } else {
525                    let path = self.path_of(Some(&name));
526                    return self.finish(path);
527                }
528            }
529            Some(name) => {
530                if self.mode != FileMode::Directory {
531                    let path = self.path_of(Some(&name));
532                    return self.finish(path);
533                }
534            }
535        }
536        Flow::Continue
537    }
538
539    fn key(&mut self, key: Key) -> bool {
540        let focused = self.select.focused().map(|index| self.entry(index));
541        let action = self
542            .file_keymap()
543            .action(key)
544            .map(str::to_string)
545            .or_else(|| {
546                // The list's `delete` (Backspace) with no filter to delete from.
547                (self.select.query_text().is_empty()
548                    && self.select.select_keymap().action(key) == Some("delete"))
549                .then(|| "up".to_string())
550            });
551        match action.as_deref() {
552            Some("open") => {
553                if let Some(Entry {
554                    name: Some(name),
555                    dir: true,
556                }) = focused
557                {
558                    self.open(&name);
559                } else if let Some(Entry { name: None, .. }) = focused {
560                    self.up();
561                }
562            }
563            Some("up") => self.up(),
564            Some("hidden") => {
565                self.show_hidden = !self.show_hidden;
566                let focused = focused.and_then(|entry| entry.name);
567                self.load();
568                if let Some(name) = focused {
569                    let index = self
570                        .select
571                        .items()
572                        .iter()
573                        .position(|item| item.value.name.as_ref() == Some(&name));
574                    if let Some(index) = index {
575                        self.select.focus_item(index);
576                    }
577                }
578            }
579            _ => return false,
580        }
581        true
582    }
583}
584
585impl Component for FilePicker {
586    type Output = PathBuf;
587
588    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<PathBuf> {
589        if let Some(key) = event.key()
590            && !self.select.menu_open()
591            && self.key(key)
592        {
593            return Flow::Continue;
594        }
595        match self.select.event(event, context) {
596            Some(Flow::Done(indices)) => {
597                let index = indices[0];
598                if self.select.action().is_some() {
599                    let entry = self.entry(index);
600                    let path = match entry.name {
601                        Some(name) => self.path_of(Some(&name)),
602                        None => self.path_of(Some(OsStr::new(".."))),
603                    };
604                    return self.finish(path);
605                }
606                self.activate(index)
607            }
608            Some(Flow::Cancel) => Flow::Cancel,
609            Some(Flow::Ignored) => Flow::Ignored,
610            _ => Flow::Continue,
611        }
612    }
613
614    fn keymap(&self) -> Keymap {
615        let mut keymap = self.file_keymap();
616        keymap.extend(self.select.visible_keymap());
617        keymap
618    }
619
620    fn render(&self, context: &Context<'_>) -> View {
621        // Drawing takes any preview that has finished since.
622        self.wake.ready.store(false, Ordering::SeqCst);
623        self.select.render(context)
624    }
625
626    fn mouse(&self) -> bool {
627        Component::mouse(&self.select)
628    }
629
630    fn tick(&self) -> Option<Duration> {
631        self.wake.wants_tick().then_some(PREVIEW_WAIT)
632    }
633
634    fn default_value(&self) -> Option<PathBuf> {
635        self.default.clone()
636    }
637
638    fn prompt(&mut self, _: &mut dyn LineIo) -> Result<Option<PathBuf>, NotInteractive> {
639        Err(NotInteractive::NoPrompt)
640    }
641}
642
643/// What a preview shows, read once.
644enum Shown {
645    Code(String, String),
646    Lines(Vec<String>),
647    Note(String),
648}
649
650/// The preview of an entry: a text file's first lines, highlighted by its
651/// extension; a directory's entries; or why there is nothing to show. Read
652/// on a thread the first time it is drawn, waiting at most
653/// [`PREVIEW_WAIT`]; a read still going shows as unavailable until it
654/// finishes. Only a regular file is read (a FIFO would block), opened
655/// without blocking and checked once open, within the jail.
656struct FilePreview {
657    path: PathBuf,
658    jail: Option<PathBuf>,
659    shown: OnceLock<Shown>,
660    /// The read under way, once started.
661    pending: Mutex<Option<Receiver<Shown>>>,
662    /// [`read`], or a stand-in in tests.
663    reader: fn(&Path, Option<&Path>) -> Shown,
664    wake: Arc<PreviewWake>,
665}
666
667impl FilePreview {
668    fn new(path: PathBuf, jail: Option<PathBuf>, wake: Arc<PreviewWake>) -> FilePreview {
669        FilePreview {
670            path,
671            jail,
672            shown: OnceLock::new(),
673            pending: Mutex::new(None),
674            reader: read,
675            wake,
676        }
677    }
678
679    /// What to show: the read, or `None` while it is still going.
680    fn shown(&self) -> Option<&Shown> {
681        if let Some(shown) = self.shown.get() {
682            return Some(shown);
683        }
684        let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
685        let wait = if pending.is_some() {
686            Duration::ZERO
687        } else {
688            let (sender, receiver) = mpsc::channel();
689            let (path, jail, reader) = (self.path.clone(), self.jail.clone(), self.reader);
690            let wake = Arc::clone(&self.wake);
691            wake.running.fetch_add(1, Ordering::SeqCst);
692            let spawned = std::thread::Builder::new()
693                .name("rich-file-preview".into())
694                .spawn(move || {
695                    let _ = sender.send(reader(&path, jail.as_deref()));
696                    // Ready before no longer running, so the picker never
697                    // sees neither while the result waits to be drawn.
698                    wake.ready.store(true, Ordering::SeqCst);
699                    wake.running.fetch_sub(1, Ordering::SeqCst);
700                });
701            if let Err(error) = spawned {
702                self.wake.running.fetch_sub(1, Ordering::SeqCst);
703                return Some(
704                    self.shown
705                        .get_or_init(|| Shown::Note(format!("preview unavailable: {error}"))),
706                );
707            }
708            *pending = Some(receiver);
709            PREVIEW_WAIT
710        };
711        let receiver = pending.as_ref()?;
712        let shown = match receiver.recv_timeout(wait) {
713            Ok(shown) => shown,
714            Err(RecvTimeoutError::Timeout) => return None,
715            Err(RecvTimeoutError::Disconnected) => Shown::Note("cannot read".into()),
716        };
717        *pending = None;
718        Some(self.shown.get_or_init(|| shown))
719    }
720}
721
722/// `path` opened for reading without blocking: a FIFO swapped in after
723/// the listing opens at once instead of waiting for a writer, and a
724/// device such as `/proc/kmsg` reads nothing rather than waiting.
725fn open_nonblocking(path: &Path) -> std::io::Result<std::fs::File> {
726    let mut options = std::fs::OpenOptions::new();
727    options.read(true);
728    #[cfg(unix)]
729    {
730        use std::os::unix::fs::OpenOptionsExt;
731        options.custom_flags(libc::O_NONBLOCK);
732    }
733    options.open(path)
734}
735
736/// Read what [`FilePreview`] shows for `path`.
737fn read(path: &Path, jail: Option<&Path>) -> Shown {
738    if let Some(jail) = jail
739        && !path.canonicalize().is_ok_and(|real| real.starts_with(jail))
740    {
741        return Shown::Note("outside the root".into());
742    }
743    let metadata = match std::fs::metadata(path) {
744        Ok(metadata) => metadata,
745        Err(error) => return Shown::Note(format!("cannot read: {error}")),
746    };
747    if metadata.is_dir() {
748        let Ok(listing) = std::fs::read_dir(path) else {
749            return Shown::Note("cannot read the directory".into());
750        };
751        let mut names: Vec<String> = listing
752            .flatten()
753            .take(MAX_ENTRIES)
754            .map(|entry| {
755                let dir = entry.file_type().is_ok_and(|kind| kind.is_dir());
756                let name = display_name(&entry.file_name());
757                if dir { format!("{name}/") } else { name }
758            })
759            .collect();
760        names.sort_by_key(|name| name.to_lowercase());
761        names.truncate(PREVIEW_LINES);
762        if names.is_empty() {
763            return Shown::Note("empty directory".into());
764        }
765        return Shown::Lines(names);
766    }
767    if !metadata.is_file() {
768        return Shown::Note("not a regular file".into());
769    }
770    // What was opened is checked, not what the path named a moment ago.
771    let file = open_nonblocking(path).and_then(|file| {
772        let metadata = file.metadata()?;
773        Ok((file, metadata))
774    });
775    let (file, metadata) = match file {
776        Ok(opened) => opened,
777        Err(error) => return Shown::Note(format!("cannot read: {error}")),
778    };
779    if !metadata.is_file() {
780        return Shown::Note("not a regular file".into());
781    }
782    let mut bytes = Vec::new();
783    if let Err(error) = file.take(PREVIEW_BYTES).read_to_end(&mut bytes) {
784        return Shown::Note(format!("cannot read: {error}"));
785    }
786    if bytes.contains(&0) {
787        return Shown::Note(format!(
788            "binary file, {}",
789            rich::filesize::decimal(metadata.len())
790        ));
791    }
792    let text = String::from_utf8_lossy(&bytes);
793    let head: Vec<&str> = text.lines().take(PREVIEW_LINES).collect();
794    let language = path
795        .extension()
796        .and_then(OsStr::to_str)
797        .unwrap_or("text")
798        .to_lowercase();
799    Shown::Code(head.join("\n"), language)
800}
801
802impl Renderable for FilePreview {
803    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
804        let waiting = Shown::Note("preview unavailable: the read has not finished".into());
805        match self.shown().unwrap_or(&waiting) {
806            #[cfg(feature = "syntax")]
807            Shown::Code(code, language) => {
808                rich::syntax::Syntax::new(code.clone(), language.clone())
809                    .rich_render(console, options)
810            }
811            // Without syntect (the `syntax` feature off): the code as it is.
812            #[cfg(not(feature = "syntax"))]
813            Shown::Code(code, language) => {
814                let _ = language;
815                Text::new(code.clone()).rich_render(console, options)
816            }
817            Shown::Lines(lines) => Text::new(lines.join("\n")).rich_render(console, options),
818            Shown::Note(note) => {
819                let style = rich::Style::parse("dim italic").expect("style");
820                Text::styled(note.clone(), style).rich_render(console, options)
821            }
822        }
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    fn note(preview: &FilePreview) -> Option<String> {
831        match preview.shown()? {
832            Shown::Note(note) => Some(note.clone()),
833            Shown::Code(..) | Shown::Lines(_) => Some(String::new()),
834        }
835    }
836
837    /// Drawing a preview whose read never ends comes back, with nothing to
838    /// show yet; once the read ends, the next drawing shows it.
839    #[test]
840    fn a_read_that_blocks_does_not_hold_up_drawing() {
841        let wake = Arc::new(PreviewWake::default());
842        let mut preview = FilePreview::new(PathBuf::from("slow"), None, Arc::clone(&wake));
843        preview.reader = |_, _| {
844            std::thread::sleep(Duration::from_millis(600));
845            Shown::Note("done".into())
846        };
847        assert!(!wake.wants_tick());
848        let started = std::time::Instant::now();
849        assert_eq!(note(&preview), None);
850        assert!(started.elapsed() < Duration::from_millis(500));
851        // The picker ticks while the read runs, and after it finishes until
852        // a drawing has shown it: no key press is needed.
853        assert!(wake.wants_tick());
854        std::thread::sleep(Duration::from_millis(800));
855        assert!(wake.wants_tick());
856        assert_eq!(note(&preview).as_deref(), Some("done"));
857    }
858
859    /// A picker whose preview read outlasts the wait asks for ticks, and
860    /// stops once a drawing has taken the result.
861    #[test]
862    fn a_picker_ticks_until_a_slow_preview_is_drawn() {
863        let dir = std::env::temp_dir().join(format!("rich-file-wake-{}", std::process::id()));
864        std::fs::create_dir_all(&dir).unwrap();
865        let picker = FilePicker::new("Open", &dir);
866        assert_eq!(picker.tick(), None);
867        picker.wake.running.fetch_add(1, Ordering::SeqCst);
868        assert_eq!(picker.tick(), Some(PREVIEW_WAIT));
869        picker.wake.ready.store(true, Ordering::SeqCst);
870        picker.wake.running.fetch_sub(1, Ordering::SeqCst);
871        assert_eq!(picker.tick(), Some(PREVIEW_WAIT));
872        let console = rich::Console::builder().width(40).build();
873        let context = Context {
874            console: &console,
875            width: 40,
876            height: 10,
877        };
878        let _ = picker.render(&context);
879        assert_eq!(picker.tick(), None);
880        let _ = std::fs::remove_dir_all(&dir);
881    }
882
883    /// `/proc/kmsg` is a regular file whose read waits for the kernel's
884    /// next message: opened without blocking, it reads nothing at once.
885    #[test]
886    fn a_file_whose_read_waits_is_not_waited_for() {
887        let path = Path::new("/proc/kmsg");
888        if !path.exists() {
889            return;
890        }
891        let (sender, receiver) = mpsc::channel();
892        std::thread::spawn(move || {
893            read(path, None);
894            let _ = sender.send(());
895        });
896        assert_eq!(receiver.recv_timeout(Duration::from_secs(5)), Ok(()));
897    }
898}