use std::{
io::Result,
path::{Path, PathBuf},
sync::Arc,
};
use ratatui::widgets::WidgetRef;
use crate::{Theme, input::Input, widget::Renderer};
mod builder;
mod file;
pub use builder::FileExplorerBuilder;
pub use file::File;
type Filter = dyn Fn(File) -> Option<File> + Send + Sync + 'static;
#[derive(Clone, educe::Educe)]
#[educe(Debug, PartialEq, Eq, Hash)]
pub struct FileExplorer {
cwd: PathBuf,
files: Vec<File>,
show_hidden: bool,
selected: usize,
theme: Theme,
#[educe(Debug(ignore), PartialEq(ignore), Hash(ignore))]
filter: Option<Arc<Filter>>,
}
impl FileExplorer {
pub fn new() -> Result<FileExplorer> {
let cwd = std::env::current_dir()?;
let files = Self::get_files(&cwd, false, None)?;
let file_explorer = Self {
cwd,
files,
show_hidden: false,
selected: 0,
theme: Theme::new(),
filter: None,
};
Ok(file_explorer)
}
#[inline]
#[must_use]
pub const fn widget(&self) -> impl WidgetRef + '_ {
Renderer(self)
}
pub fn handle<I: Into<Input>>(&mut self, input: I) -> Result<()> {
const SCROLL_COUNT: usize = 12;
let input = input.into();
match input {
Input::Up => {
self.selected = self.selected.wrapping_sub(1).min(self.files.len() - 1);
}
Input::Down => {
self.selected = (self.selected + 1) % self.files.len();
}
Input::Home => {
self.selected = 0;
}
Input::End => {
self.selected = self.files.len() - 1;
}
Input::PageUp => {
self.selected = self.selected.saturating_sub(SCROLL_COUNT);
}
Input::PageDown => {
self.selected = (self.selected + SCROLL_COUNT).min(self.files.len() - 1);
}
Input::Left => {
let parent = self.cwd.parent();
if let Some(parent) = parent {
let path = parent.to_path_buf();
self.set_cwd(path)?;
}
}
Input::Right => {
if self.files[self.selected].path.is_dir() {
let path = self.files.swap_remove(self.selected).path;
self.set_cwd(path)?;
}
}
Input::ToggleShowHidden => self.set_show_hidden(!self.show_hidden)?,
Input::None => (),
}
Ok(())
}
#[inline]
pub fn set_cwd<P: Into<PathBuf>>(&mut self, cwd: P) -> Result<()> {
let cwd = cwd.into();
self.files = Self::get_files(&cwd, self.show_hidden, self.filter.as_ref())?;
self.cwd = cwd;
self.selected = 0;
Ok(())
}
#[inline]
pub fn set_working_file<P: Into<PathBuf>>(&mut self, working_file: P) -> Result<()> {
let working_file = working_file.into();
let cwd = working_file
.parent()
.map(|p| p.to_owned())
.unwrap_or_else(|| working_file.clone());
self.files = Self::get_files(&cwd, self.show_hidden, self.filter.as_ref())?;
let selected_path = working_file;
let selected = self
.files
.iter()
.position(|file| file.path == selected_path)
.unwrap_or_default();
self.cwd = cwd;
self.selected = selected;
Ok(())
}
#[inline]
pub fn set_show_hidden(&mut self, show_hidden: bool) -> Result<()> {
self.show_hidden = show_hidden;
self.files = Self::get_files(&self.cwd, show_hidden, self.filter.as_ref())?;
self.selected = 0;
Ok(())
}
pub fn set_filter_map(
&mut self,
f: impl Fn(File) -> Option<File> + Send + Sync + 'static,
) -> Result<()> {
self.filter = Some(Arc::new(f));
self.files = Self::get_files(&self.cwd, self.show_hidden, self.filter.as_ref())?;
self.selected = 0;
Ok(())
}
pub fn remove_filter_map(&mut self) -> Result<Option<Arc<Filter>>> {
let filter = self.filter.take();
self.files = Self::get_files(&self.cwd, self.show_hidden, None)?;
self.selected = 0;
Ok(filter)
}
#[inline]
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
#[inline]
pub fn set_selected_idx(&mut self, selected: usize) {
assert!(selected < self.files.len());
self.selected = selected;
}
#[inline]
#[must_use]
pub fn current(&self) -> &File {
&self.files[self.selected]
}
#[inline]
#[must_use]
pub const fn cwd(&self) -> &PathBuf {
&self.cwd
}
#[inline]
#[must_use]
pub const fn show_hidden(&self) -> bool {
self.show_hidden
}
#[inline]
#[must_use]
pub const fn files(&self) -> &Vec<File> {
&self.files
}
#[inline]
#[must_use]
pub const fn selected_idx(&self) -> usize {
self.selected
}
#[inline]
#[must_use]
pub const fn theme(&self) -> &Theme {
&self.theme
}
#[allow(missing_docs)]
#[inline]
#[deprecated(
since = "0.3.0",
note = "Use `FileExplorerBuilder::build_with_theme` instead"
)]
pub fn with_theme(theme: Theme) -> Result<FileExplorer> {
FileExplorerBuilder::build_with_theme(theme)
}
fn get_files(
working_dir: &Path,
show_hidden: bool,
filter: Option<&Arc<Filter>>,
) -> Result<Vec<File>> {
let (mut dirs, mut none_dirs): (Vec<_>, Vec<_>) = std::fs::read_dir(working_dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
let metadata = path.metadata().ok();
let file_type = metadata.as_ref().map(|f| f.file_type());
let is_dir = file_type.is_some_and(|f| f.is_dir());
let name = entry.file_name().to_string_lossy().into_owned();
let name = if is_dir { format!("{name}/") } else { name };
let is_hidden = {
#[cfg(unix)]
{
name.starts_with('.')
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
metadata.is_some_and(|f| f.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0)
}
};
let file = File {
name,
path,
is_dir,
is_hidden,
file_type,
};
if !show_hidden && file.is_hidden {
None
} else if let Some(filter) = &filter {
filter(file)
} else {
Some(file)
}
})
.partition(|file| file.is_dir);
dirs.sort_unstable_by(|f1, f2| f1.name.cmp(&f2.name));
none_dirs.sort_unstable_by(|f1, f2| f1.name.cmp(&f2.name));
let files = if let Some(parent) = working_dir.parent() {
let mut files = Vec::with_capacity(1 + dirs.len() + none_dirs.len());
let parent = File {
name: "../".to_owned(),
path: parent.to_path_buf(),
is_dir: true,
is_hidden: false,
file_type: None,
};
if let Some(filter) = &filter {
if let Some(parent) = filter(parent) {
files.push(parent);
}
} else {
files.push(parent);
}
files.extend(dirs);
files.extend(none_dirs);
files
} else {
let mut files = Vec::with_capacity(dirs.len() + none_dirs.len());
files.extend(dirs);
files.extend(none_dirs);
files
};
Ok(files)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::TempDir;
fn build_tmp_file_system() -> Result<TempDir> {
let root = TempDir::new()?;
let git_path = root.path().join(".git");
let documents_path = root.path().join("Documents");
let passport_path = root.path().join("Documents/passport.png");
let resume_path = root.path().join("Documents/resume.pdf");
fs::create_dir(git_path)?;
fs::create_dir(documents_path)?;
File::create(passport_path)?;
File::create(resume_path)?;
Ok(root)
}
#[test]
fn test_thread_safe() {
fn is_sync<T: Sync>() {}
fn is_send<T: Send>() {}
is_send::<FileExplorer>();
is_sync::<FileExplorer>();
}
#[test]
fn test_set_cwd_does_not_change_displayed_path_on_failure() -> Result<()> {
let tmp_dir = TempDir::new()?;
let does_not_exist_path = tmp_dir.path().join("does_not_exist");
assert!(!does_not_exist_path.exists());
let mut explorer = FileExplorer::new()?;
let previous_cwd = explorer.cwd().clone();
let result = explorer.set_cwd(does_not_exist_path);
assert!(result.is_err());
assert_eq!(&previous_cwd, explorer.cwd());
Ok(())
}
#[cfg(unix)]
#[test]
fn test_hidden_files_are_ignored() -> Result<()> {
let root = build_tmp_file_system()?;
let mut explorer = FileExplorerBuilder::build_with_working_dir(root.path())?;
assert_eq!(explorer.files().len(), 2);
explorer.set_show_hidden(true)?;
assert_eq!(explorer.files().len(), 3);
Ok(())
}
#[test]
fn test_apply_filter_hide_files() -> Result<()> {
let root = build_tmp_file_system()?;
let documents_path = root.path().join("Documents");
let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
assert_eq!(explorer.files().len(), 3);
explorer
.set_filter_map(|file| if file.is_dir { Some(file) } else { None })
.unwrap();
assert_eq!(explorer.files().len(), 1);
Ok(())
}
#[test]
fn test_removing_filter_show_files() -> Result<()> {
let root = build_tmp_file_system()?;
let documents_path = root.path().join("Documents");
let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
assert_eq!(explorer.files().len(), 3);
explorer
.set_filter_map(|file| if file.is_dir { Some(file) } else { None })
.unwrap();
assert_eq!(explorer.files().len(), 1);
explorer.remove_filter_map()?;
assert_eq!(explorer.files().len(), 3);
Ok(())
}
#[test]
fn test_filter_is_apply_when_changing_working_dir() -> Result<()> {
let root = build_tmp_file_system()?;
let documents_path = root.path().join("Documents");
let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
explorer
.set_filter_map(|file| {
let keep = !file.name.ends_with("png");
if keep { Some(file) } else { None }
})
.unwrap();
assert_eq!(explorer.files().len(), 2);
explorer.handle(Input::Left)?;
explorer.handle(Input::Down)?;
explorer.handle(Input::Right)?;
assert_eq!(explorer.files().len(), 2);
Ok(())
}
#[test]
fn test_filter_mutate_files() -> Result<()> {
let root = build_tmp_file_system()?;
let documents_path = root.path().join("Documents");
let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
explorer
.set_filter_map(|mut file| {
let is_png = file.name.ends_with("png");
if is_png {
file.name = file.name.replace("png", "jpg");
}
Some(file)
})
.unwrap();
assert_eq!(explorer.files().len(), 3);
let names = ["../", "passport.jpg", "resume.pdf"];
for (file, name) in explorer.files().iter().zip(names.iter()) {
assert_eq!(&file.name, name)
}
Ok(())
}
#[test]
fn test_filter_operate_on_parent() -> Result<()> {
let root = build_tmp_file_system()?;
let documents_path = root.path().join("Documents");
let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
explorer
.set_filter_map(|file| if file.is_dir { None } else { Some(file) })
.unwrap();
assert_eq!(explorer.files().len(), 2);
explorer.remove_filter_map()?;
assert_eq!(explorer.files().len(), 3);
Ok(())
}
}