Files
termscp/src/explorer/mod.rs
Christian Visintin c0b32a1847
Some checks are pending
Linux / build (push) Waiting to run
MacOS / build (push) Waiting to run
Windows / build (push) Waiting to run
feat: Replaced the Exec popup with a fully functional terminal emulator (#348)
* feat: Replaced the `Exec` popup with a fully functional terminal emulator

closes #340

* fix: colors and fmt for terminal

* feat: Handle exit and cd on terminal

* fix: Fmt pah
2025-06-10 13:17:20 +02:00

707 lines
24 KiB
Rust

//! ## Explorer
//!
//! `explorer` is the module which provides an Helper in handling Directory status through
// Mods
pub(crate) mod builder;
mod formatter;
// Locals
use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use formatter::Formatter;
// Ext
use remotefs::fs::File;
bitflags! {
/// ExplorerOpts are bit options which provides different behaviours to `FileExplorer`
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ExplorerOpts: u32 {
const SHOW_HIDDEN_FILES = 0b00000001;
}
}
/// FileSorting defines the criteria for sorting files
#[derive(Copy, Clone, PartialEq, Eq, std::fmt::Debug)]
pub enum FileSorting {
Name,
ModifyTime,
CreationTime,
Size,
None,
}
/// GroupDirs defines how directories should be grouped in sorting files
#[derive(PartialEq, Eq, std::fmt::Debug)]
pub enum GroupDirs {
First,
Last,
}
/// File explorer states
pub struct FileExplorer {
/// Current working directory
pub wrkdir: PathBuf,
/// Stack of visited directories
pub(crate) dirstack: VecDeque<PathBuf>,
/// Stack size
pub(crate) stack_size: usize,
/// Criteria to sort file
pub(crate) file_sorting: FileSorting,
/// defines how to group directories in the explorer
pub(crate) group_dirs: Option<GroupDirs>,
/// Explorer options
pub(crate) opts: ExplorerOpts,
/// Formatter for file entries
pub(crate) fmt: Formatter,
/// Is terminal open for this explorer?
terminal: bool,
/// Files in directory
files: Vec<File>,
/// files enqueued for transfer. Map between source and destination
transfer_queue: HashMap<PathBuf, PathBuf>, // transfer queue
}
impl Default for FileExplorer {
fn default() -> Self {
FileExplorer {
wrkdir: PathBuf::from("/"),
dirstack: VecDeque::with_capacity(16),
stack_size: 16,
file_sorting: FileSorting::Name,
group_dirs: None,
opts: ExplorerOpts::empty(),
fmt: Formatter::default(),
files: Vec::new(),
terminal: false,
transfer_queue: HashMap::new(),
}
}
}
impl FileExplorer {
/// push directory to stack
pub fn pushd(&mut self, dir: &Path) {
// Check if stack would overflow the size
while self.dirstack.len() >= self.stack_size {
self.dirstack.pop_front(); // Start cleaning events from back
}
// Eventually push front the new record
self.dirstack.push_back(PathBuf::from(dir));
}
/// Pop directory from the stack and return the directory
pub fn popd(&mut self) -> Option<PathBuf> {
self.dirstack.pop_back()
}
/// Set Explorer files
/// This method will also sort entries based on current options
/// Once all sorting have been performed, index is moved to first valid entry.
pub fn set_files(&mut self, files: Vec<File>) {
self.files = files;
// Sort
self.sort();
}
/// Delete file at provided index
pub fn del_entry(&mut self, idx: usize) {
if self.files.len() > idx {
self.files.remove(idx);
}
}
/// Iterate over files
/// Filters are applied based on current options (e.g. hidden files not returned)
pub fn iter_files(&self) -> impl Iterator<Item = &File> + '_ {
// Filter
let opts: ExplorerOpts = self.opts;
Box::new(self.files.iter().filter(move |x| {
// If true, element IS NOT filtered
let mut pass: bool = true;
// If hidden files SHOULDN'T be shown, AND pass with not hidden
if !opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES) {
pass &= !x.is_hidden();
}
pass
}))
}
/// Iterate all files; doesn't care about options
pub fn iter_files_all(&self) -> impl Iterator<Item = &File> + '_ {
Box::new(self.files.iter())
}
/// Get file at relative index
pub fn get(&self, idx: usize) -> Option<&File> {
let opts: ExplorerOpts = self.opts;
let filtered = self
.files
.iter()
.filter(move |x| {
// If true, element IS NOT filtered
let mut pass: bool = true;
// If hidden files SHOULDN'T be shown, AND pass with not hidden
if !opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES) {
pass &= !x.is_hidden();
}
pass
})
.collect::<Vec<_>>();
filtered.get(idx).copied()
}
/// Enqueue a file for transfer
pub fn enqueue(&mut self, src: &Path, dst: &Path) {
self.transfer_queue
.insert(PathBuf::from(src), PathBuf::from(dst));
}
/// Enqueue all files for transfer
pub fn enqueue_all(&mut self, dst: &Path) {
let files: Vec<_> = self.iter_files().map(|f| f.path.clone()).collect();
for file in files {
self.enqueue(&file, dst);
}
}
/// Get enqueued files
pub fn enqueued(&self) -> &HashMap<PathBuf, PathBuf> {
&self.transfer_queue
}
/// Dequeue a file
pub fn dequeue(&mut self, src: &Path) {
self.transfer_queue.remove(src);
}
/// Clear transfer queue
pub fn clear_queue(&mut self) {
self.transfer_queue.clear();
}
/// Toggle terminal state
pub fn toggle_terminal(&mut self, terminal: bool) {
self.terminal = terminal;
}
pub fn terminal_open(&self) -> bool {
self.terminal
}
// Formatting
/// Format a file entry
pub fn fmt_file(&self, entry: &File) -> String {
self.fmt.fmt(entry)
}
// Sorting
/// Choose sorting method; then sort files
pub fn sort_by(&mut self, sorting: FileSorting) {
// If method HAS ACTUALLY CHANGED, sort (performance!)
if self.file_sorting != sorting {
self.file_sorting = sorting;
self.sort();
}
}
/// Get current file sorting method
pub fn get_file_sorting(&self) -> FileSorting {
self.file_sorting
}
/// Choose group dirs method; then sort files
pub fn group_dirs_by(&mut self, group_dirs: Option<GroupDirs>) {
// If method HAS ACTUALLY CHANGED, sort (performance!)
if self.group_dirs != group_dirs {
self.group_dirs = group_dirs;
self.sort();
}
}
/// Sort files based on Explorer options.
fn sort(&mut self) {
// Choose sorting method
match &self.file_sorting {
FileSorting::Name => self.sort_files_by_name(),
FileSorting::CreationTime => self.sort_files_by_creation_time(),
FileSorting::ModifyTime => self.sort_files_by_mtime(),
FileSorting::Size => self.sort_files_by_size(),
FileSorting::None => {}
}
// Directories first (NOTE: MUST COME AFTER OTHER SORTING)
// Group directories if necessary
if let Some(group_dirs) = &self.group_dirs {
match group_dirs {
GroupDirs::First => self.sort_files_directories_first(),
GroupDirs::Last => self.sort_files_directories_last(),
}
}
}
/// Sort explorer files by their name. All names are converted to lowercase
fn sort_files_by_name(&mut self) {
self.files.sort_by_key(|x: &File| x.name().to_lowercase());
}
/// Sort files by mtime; the newest comes first
fn sort_files_by_mtime(&mut self) {
self.files
.sort_by_key(|b: &File| Reverse(b.metadata().modified));
}
/// Sort files by creation time; the newest comes first
fn sort_files_by_creation_time(&mut self) {
self.files
.sort_by_key(|b: &File| Reverse(b.metadata().created));
}
/// Sort files by size
fn sort_files_by_size(&mut self) {
self.files
.sort_by_key(|b: &File| Reverse(b.metadata().size));
}
/// Sort files; directories come first
fn sort_files_directories_first(&mut self) {
self.files.sort_by_key(|x: &File| !x.is_dir());
}
/// Sort files; directories come last
fn sort_files_directories_last(&mut self) {
self.files.sort_by_key(|x: &File| x.is_dir());
}
/// Enable/disable hidden files
pub fn toggle_hidden_files(&mut self) {
self.opts.toggle(ExplorerOpts::SHOW_HIDDEN_FILES);
}
/// Returns whether hidden files are visible
pub fn hidden_files_visible(&self) -> bool {
self.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES)
}
}
// Traits
impl std::fmt::Display for FileSorting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
FileSorting::CreationTime => "by_creation_time",
FileSorting::ModifyTime => "by_mtime",
FileSorting::Name => "by_name",
FileSorting::Size => "by_size",
FileSorting::None => "none",
}
)
}
}
impl FromStr for FileSorting {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"by_creation_time" => Ok(FileSorting::CreationTime),
"by_mtime" => Ok(FileSorting::ModifyTime),
"by_name" => Ok(FileSorting::Name),
"by_size" => Ok(FileSorting::Size),
_ => Err(()),
}
}
}
impl std::fmt::Display for GroupDirs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
GroupDirs::First => "first",
GroupDirs::Last => "last",
}
)
}
}
impl FromStr for GroupDirs {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"first" => Ok(GroupDirs::First),
"last" => Ok(GroupDirs::Last),
_ => Err(()),
}
}
}
#[cfg(test)]
mod tests {
use std::thread::sleep;
use std::time::{Duration, SystemTime};
use pretty_assertions::assert_eq;
use remotefs::fs::{File, FileType, Metadata, UnixPex};
use super::*;
use crate::utils::fmt::fmt_time;
#[test]
fn test_fs_explorer_new() {
let explorer: FileExplorer = FileExplorer::default();
// Verify
assert_eq!(explorer.dirstack.len(), 0);
assert_eq!(explorer.files.len(), 0);
assert_eq!(explorer.opts, ExplorerOpts::empty());
assert_eq!(explorer.wrkdir, PathBuf::from("/"));
assert_eq!(explorer.stack_size, 16);
assert_eq!(explorer.group_dirs, None);
assert_eq!(explorer.file_sorting, FileSorting::Name);
assert_eq!(explorer.get_file_sorting(), FileSorting::Name);
}
#[test]
fn test_fs_explorer_stack() {
let mut explorer = FileExplorer {
stack_size: 2,
dirstack: VecDeque::with_capacity(2),
..Default::default()
};
explorer.dirstack = VecDeque::with_capacity(2);
// Push dir
explorer.pushd(Path::new("/tmp"));
explorer.pushd(Path::new("/home/omar"));
// Pop
assert_eq!(explorer.popd().unwrap(), PathBuf::from("/home/omar"));
assert_eq!(explorer.dirstack.len(), 1);
assert_eq!(explorer.popd().unwrap(), PathBuf::from("/tmp"));
assert_eq!(explorer.dirstack.len(), 0);
// Dirstack is empty now
assert!(explorer.popd().is_none());
// Exceed limit
explorer.pushd(Path::new("/tmp"));
explorer.pushd(Path::new("/home/omar"));
explorer.pushd(Path::new("/dev"));
assert_eq!(explorer.dirstack.len(), 2);
assert_eq!(*explorer.dirstack.get(1).unwrap(), PathBuf::from("/dev"));
assert_eq!(
*explorer.dirstack.get(0).unwrap(),
PathBuf::from("/home/omar")
);
}
#[test]
fn test_fs_explorer_files() {
let mut explorer: FileExplorer = FileExplorer::default();
// Don't show hidden files
explorer.opts.remove(ExplorerOpts::SHOW_HIDDEN_FILES);
assert_eq!(explorer.hidden_files_visible(), false);
// Create files
explorer.set_files(vec![
make_fs_entry("README.md", false),
make_fs_entry("src", true),
make_fs_entry(".git", true),
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("codecov.yml", false),
make_fs_entry(".gitignore", false),
]);
assert!(explorer.get(0).is_some());
assert!(explorer.get(100).is_none());
//assert_eq!(explorer.count(), 6);
// Verify (files are sorted by name)
assert_eq!(explorer.files.get(0).unwrap().name(), ".git");
// Iter files (all)
assert_eq!(explorer.iter_files_all().count(), 6);
// Iter files (hidden excluded) (.git, .gitignore are hidden)
assert_eq!(explorer.iter_files().count(), 4);
// Toggle hidden
explorer.toggle_hidden_files();
assert_eq!(explorer.hidden_files_visible(), true);
assert_eq!(explorer.iter_files().count(), 6); // All files are returned now
}
#[test]
fn test_fs_explorer_sort_by_name() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry("README.md", false),
make_fs_entry("src", true),
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("CODE_OF_CONDUCT.md", false),
make_fs_entry("CHANGELOG.md", false),
make_fs_entry("LICENSE", false),
make_fs_entry("Cargo.toml", false),
make_fs_entry("Cargo.lock", false),
make_fs_entry("codecov.yml", false),
]);
explorer.sort_by(FileSorting::Name);
// First entry should be "Cargo.lock"
assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
// Last should be "src"
assert_eq!(explorer.files.get(8).unwrap().name(), "src");
}
#[test]
fn test_fs_explorer_sort_by_mtime() {
let mut explorer: FileExplorer = FileExplorer::default();
let entry1: File = make_fs_entry("README.md", false);
// Wait 1 sec
sleep(Duration::from_secs(1));
let entry2: File = make_fs_entry("CODE_OF_CONDUCT.md", false);
// Create files (files are then sorted by name)
explorer.set_files(vec![entry1, entry2]);
explorer.sort_by(FileSorting::ModifyTime);
// First entry should be "CODE_OF_CONDUCT.md"
assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
// Last should be "src"
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
}
#[test]
fn test_fs_explorer_sort_by_creation_time() {
let mut explorer: FileExplorer = FileExplorer::default();
let entry1: File = make_fs_entry("README.md", false);
// Wait 1 sec
sleep(Duration::from_secs(1));
let entry2: File = make_fs_entry("CODE_OF_CONDUCT.md", false);
// Create files (files are then sorted by name)
explorer.set_files(vec![entry1, entry2]);
explorer.sort_by(FileSorting::CreationTime);
// First entry should be "CODE_OF_CONDUCT.md"
assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
// Last should be "src"
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
}
#[test]
fn test_fs_explorer_sort_by_size() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry_with_size("README.md", false, 1024),
make_fs_entry_with_size("src", true, 4096),
make_fs_entry_with_size("CONTRIBUTING.md", false, 256),
]);
explorer.sort_by(FileSorting::Size);
// Directory has size 4096
assert_eq!(explorer.files.get(0).unwrap().name(), "src");
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
assert_eq!(explorer.files.get(2).unwrap().name(), "CONTRIBUTING.md");
}
#[test]
fn test_fs_explorer_sort_by_name_and_dirs_first() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry("README.md", false),
make_fs_entry("src", true),
make_fs_entry("docs", true),
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("CODE_OF_CONDUCT.md", false),
make_fs_entry("CHANGELOG.md", false),
make_fs_entry("LICENSE", false),
make_fs_entry("Cargo.toml", false),
make_fs_entry("Cargo.lock", false),
make_fs_entry("codecov.yml", false),
]);
explorer.sort_by(FileSorting::Name);
explorer.group_dirs_by(Some(GroupDirs::First));
// First entry should be "docs"
assert_eq!(explorer.files.get(0).unwrap().name(), "docs");
assert_eq!(explorer.files.get(1).unwrap().name(), "src");
// 3rd is file first for alphabetical order
assert_eq!(explorer.files.get(2).unwrap().name(), "Cargo.lock");
// Last should be "README.md" (last file for alphabetical ordening)
assert_eq!(explorer.files.get(9).unwrap().name(), "README.md");
}
#[test]
fn test_fs_explorer_sort_by_name_and_dirs_last() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry("README.md", false),
make_fs_entry("src", true),
make_fs_entry("docs", true),
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("CODE_OF_CONDUCT.md", false),
make_fs_entry("CHANGELOG.md", false),
make_fs_entry("LICENSE", false),
make_fs_entry("Cargo.toml", false),
make_fs_entry("Cargo.lock", false),
make_fs_entry("codecov.yml", false),
]);
explorer.sort_by(FileSorting::Name);
explorer.group_dirs_by(Some(GroupDirs::Last));
// Last entry should be "src"
assert_eq!(explorer.files.get(8).unwrap().name(), "docs");
assert_eq!(explorer.files.get(9).unwrap().name(), "src");
// first is file for alphabetical order
assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
// Last in files should be "README.md" (last file for alphabetical ordening)
assert_eq!(explorer.files.get(7).unwrap().name(), "README.md");
}
#[test]
fn test_fs_explorer_fmt() {
let explorer: FileExplorer = FileExplorer::default();
// Create fs entry
let t: SystemTime = SystemTime::now();
let entry = File {
path: PathBuf::from("/bar.txt"),
metadata: Metadata {
accessed: Some(t),
created: Some(t),
modified: Some(t),
file_type: FileType::File,
size: 8192,
symlink: None,
uid: Some(0),
gid: Some(0),
mode: Some(UnixPex::from(0o644)),
},
};
#[cfg(posix)]
assert_eq!(
explorer.fmt_file(&entry),
format!(
"bar.txt -rw-r--r-- root 8.2 kB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
#[cfg(win)]
assert_eq!(
explorer.fmt_file(&entry),
format!(
"bar.txt -rw-r--r-- 0 8.2 kB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
}
#[test]
fn test_fs_explorer_to_string_from_str_traits() {
// File Sorting
assert_eq!(FileSorting::CreationTime.to_string(), "by_creation_time");
assert_eq!(FileSorting::ModifyTime.to_string(), "by_mtime");
assert_eq!(FileSorting::Name.to_string(), "by_name");
assert_eq!(FileSorting::Size.to_string(), "by_size");
assert_eq!(
FileSorting::from_str("by_creation_time").ok().unwrap(),
FileSorting::CreationTime
);
assert_eq!(
FileSorting::from_str("by_mtime").ok().unwrap(),
FileSorting::ModifyTime
);
assert_eq!(
FileSorting::from_str("by_name").ok().unwrap(),
FileSorting::Name
);
assert_eq!(
FileSorting::from_str("by_size").ok().unwrap(),
FileSorting::Size
);
assert!(FileSorting::from_str("omar").is_err());
// Group dirs
assert_eq!(GroupDirs::First.to_string(), "first");
assert_eq!(GroupDirs::Last.to_string(), "last");
assert_eq!(GroupDirs::from_str("first").ok().unwrap(), GroupDirs::First);
assert_eq!(GroupDirs::from_str("last").ok().unwrap(), GroupDirs::Last);
assert!(GroupDirs::from_str("omar").is_err());
}
#[test]
fn test_fs_explorer_del_entry() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("docs", true),
make_fs_entry("src", true),
make_fs_entry("README.md", false),
]);
explorer.del_entry(0);
assert_eq!(explorer.files.len(), 3);
assert_eq!(explorer.files[0].name(), "docs");
explorer.del_entry(5);
assert_eq!(explorer.files.len(), 3);
}
#[test]
fn test_should_enqueue_and_dequeue_files() {
let mut explorer: FileExplorer = FileExplorer::default();
// Create files (files are then sorted by name)
explorer.set_files(vec![
make_fs_entry("CONTRIBUTING.md", false),
make_fs_entry("docs", true),
make_fs_entry("src", true),
make_fs_entry("README.md", false),
]);
// Enqueue
explorer.enqueue(Path::new("CONTRIBUTING.md"), Path::new("CONTRIBUTING.md"));
explorer.enqueue(Path::new("docs"), Path::new("docs"));
// Dequeue
explorer.dequeue(Path::new("CONTRIBUTING.md"));
assert_eq!(explorer.enqueued().len(), 1);
explorer.dequeue(Path::new("docs"));
assert_eq!(explorer.enqueued().len(), 0);
}
fn make_fs_entry(name: &str, is_dir: bool) -> File {
let t: SystemTime = SystemTime::now();
let metadata = Metadata {
accessed: Some(t),
created: Some(t),
modified: Some(t),
file_type: if is_dir {
FileType::Directory
} else {
FileType::File
},
symlink: None,
gid: Some(0),
uid: Some(0),
mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })),
size: 64,
};
File {
path: PathBuf::from(name),
metadata,
}
}
fn make_fs_entry_with_size(name: &str, is_dir: bool, size: usize) -> File {
let t: SystemTime = SystemTime::now();
let metadata = Metadata {
accessed: Some(t),
created: Some(t),
modified: Some(t),
file_type: if is_dir {
FileType::Directory
} else {
FileType::File
},
symlink: None,
gid: Some(0),
uid: Some(0),
mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })),
size: size as u64,
};
File {
path: PathBuf::from(name),
metadata,
}
}
}