1use 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
34const FILE_ACTIONS: [&str; 3] = ["open", "up", "hidden"];
36
37const MAX_ENTRIES: usize = 50_000;
39const PREVIEW_BYTES: u64 = 64 * 1024;
41const PREVIEW_LINES: usize = 200;
43const PREVIEW_WAIT: Duration = Duration::from_millis(250);
47
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum FileMode {
51 #[default]
53 File,
54 Directory,
56 Both,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
62struct Entry {
63 name: Option<OsString>,
65 dir: bool,
66}
67
68pub 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
91pub 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#[must_use = "a component does nothing until it is run or mounted"]
106pub struct FilePicker {
107 root: PathBuf,
108 jail: Option<PathBuf>,
110 rel: PathBuf,
112 mode: FileMode,
113 extensions: Vec<String>,
114 show_hidden: bool,
115 select: Select<Entry>,
116 default: Option<PathBuf>,
117 note: Option<String>,
119 theme: Theme,
120 wake: Arc<PreviewWake>,
122 rebound: Keymap,
124}
125
126#[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 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 pub fn mode(mut self, mode: FileMode) -> Self {
173 self.mode = mode;
174 self.load();
175 self
176 }
177
178 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 pub fn show_hidden(mut self, on: bool) -> Self {
193 self.show_hidden = on;
194 self.load();
195 self
196 }
197
198 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 pub fn default(mut self, path: impl Into<PathBuf>) -> Self {
218 self.default = Some(path.into());
219 self
220 }
221
222 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 pub fn query(mut self, query: impl Into<String>) -> Self {
235 self.select = self.select.query(query);
236 self
237 }
238
239 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 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 pub fn actions(mut self, actions: Actions) -> Self {
280 self.select = self.select.actions(actions);
281 self
282 }
283
284 pub fn menu_key(mut self, key: Key) -> Self {
286 self.select = self.select.menu_key(key);
287 self
288 }
289
290 pub fn with_mouse(mut self, on: bool) -> Self {
293 self.select = self.select.with_mouse(on);
294 self
295 }
296
297 pub fn action(&self) -> Option<&str> {
299 self.select.action()
300 }
301
302 pub fn directory(&self) -> PathBuf {
304 self.path_of(None)
305 }
306
307 pub fn labels(&self) -> Vec<String> {
309 self.select
310 .items()
311 .iter()
312 .map(|item| item.label.clone())
313 .collect()
314 }
315
316 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 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 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 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 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 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 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 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 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 (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 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
643enum Shown {
645 Code(String, String),
646 Lines(Vec<String>),
647 Note(String),
648}
649
650struct FilePreview {
657 path: PathBuf,
658 jail: Option<PathBuf>,
659 shown: OnceLock<Shown>,
660 pending: Mutex<Option<Receiver<Shown>>>,
662 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 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 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
722fn 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
736fn 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 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 #[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 #[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 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 #[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 #[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}