mirror of
https://github.com/Dummi26/tuifile.git
synced 2025-04-28 09:16:03 +02:00
444 lines
14 KiB
Rust
Executable File
444 lines
14 KiB
Rust
Executable File
mod run;
|
|
mod tasks;
|
|
mod updates;
|
|
|
|
use std::{
|
|
fs::{self, Metadata},
|
|
io::{self, StdoutLock},
|
|
path::PathBuf,
|
|
sync::{Arc, Mutex},
|
|
thread::JoinHandle,
|
|
};
|
|
|
|
use clap::{command, Parser};
|
|
use crossterm::terminal;
|
|
use regex::Regex;
|
|
use updates::Updates;
|
|
|
|
const EXIT_NO_ABSOLUTE_PATH: i32 = 1;
|
|
|
|
fn main() -> io::Result<()> {
|
|
let args = Args::parse();
|
|
let current_dir = match args.dir {
|
|
Some(dir) => {
|
|
if args.dir_relative || dir.is_absolute() {
|
|
dir
|
|
} else {
|
|
match fs::canonicalize(dir) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Error getting absolute path: {e}.");
|
|
std::process::exit(EXIT_NO_ABSOLUTE_PATH);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None => std::env::current_dir().unwrap_or(PathBuf::from("/")),
|
|
};
|
|
let mut share = Share {
|
|
status: String::new(),
|
|
tasks: vec![],
|
|
active_instance: 0,
|
|
total_instances: 1,
|
|
stdout: io::stdout().lock(),
|
|
size: terminal::size()?,
|
|
shell_command: std::env::var("SHELL").unwrap_or("sh".to_string()),
|
|
editor_command: std::env::var("EDITOR").unwrap_or("nano".to_string()),
|
|
live_search: !args.no_live_search,
|
|
info_what: vec![0, 1],
|
|
};
|
|
if args.check {
|
|
eprintln!("Shell: {}", share.shell_command);
|
|
eprintln!("Editor: {}", share.editor_command);
|
|
return Ok(());
|
|
}
|
|
let mut instances = vec![TuiFile::new(current_dir)?];
|
|
TuiFile::term_setup_no_redraw(&mut share)?;
|
|
let mut redraw = true;
|
|
loop {
|
|
if instances.is_empty() {
|
|
break;
|
|
}
|
|
if share.active_instance >= instances.len() {
|
|
share.active_instance = instances.len() - 1;
|
|
}
|
|
share.total_instances = instances.len();
|
|
let instance = &mut instances[share.active_instance];
|
|
if redraw {
|
|
instance.updates.request_clear();
|
|
instance.updates.request_redraw();
|
|
if instance.active {
|
|
share.status = format!("{}", share.active_instance);
|
|
}
|
|
}
|
|
let cmd = instance.run(&mut share)?;
|
|
redraw = match cmd {
|
|
AppCmd::Quit => break,
|
|
AppCmd::CloseInstance => {
|
|
instances.remove(share.active_instance);
|
|
if share.active_instance > 0 {
|
|
share.active_instance -= 1;
|
|
}
|
|
true
|
|
}
|
|
AppCmd::NextInstance => {
|
|
if share.active_instance + 1 < instances.len() {
|
|
share.active_instance += 1;
|
|
}
|
|
true
|
|
}
|
|
AppCmd::PrevInstance => {
|
|
if share.active_instance > 0 {
|
|
share.active_instance -= 1;
|
|
}
|
|
true
|
|
}
|
|
AppCmd::AddInstance(new) => {
|
|
share.active_instance += 1;
|
|
instances.insert(share.active_instance, new);
|
|
true
|
|
}
|
|
AppCmd::CopyTo(destination) => {
|
|
instance.updates.request_redraw_infobar();
|
|
let src = instances
|
|
.iter()
|
|
.filter(|v| v.active)
|
|
.map(|v| {
|
|
(
|
|
v.current_dir.clone(),
|
|
v.dir_content
|
|
.iter()
|
|
.filter(|e| e.selected)
|
|
.filter_map(|e| {
|
|
Some((
|
|
e.path.strip_prefix(&v.current_dir).ok()?.to_owned(),
|
|
e.rel_depth == v.scan_files_max_depth,
|
|
))
|
|
})
|
|
.collect(),
|
|
)
|
|
})
|
|
.collect();
|
|
tasks::task_copy(src, destination, &mut share);
|
|
false
|
|
}
|
|
AppCmd::RescanFiles => {
|
|
for i in &mut instances {
|
|
i.updates.request_rescan_files();
|
|
}
|
|
false
|
|
}
|
|
};
|
|
}
|
|
TuiFile::term_reset(&mut share)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// TUI file explorer. Long Help is available with --help.
|
|
///
|
|
/// Controls:
|
|
/// - Ctrl+Up/K => previous
|
|
/// - Ctrl+Down/J => next
|
|
/// - Ctrl+Left/H => close
|
|
/// - Ctrl+Right/L => duplicate
|
|
/// Files:
|
|
/// - Up/K or Down/J => move selection
|
|
/// - Left/H => go to parent directory
|
|
/// - Right/L => go into selected entry
|
|
/// - A => Alternate selection (toggle All)
|
|
/// - S => Select or toggle current
|
|
/// - D => Deselect all
|
|
/// - F or / => focus Find/Filter bar
|
|
/// - M => set Mode based on Find/Filter bar ((t/b)[seconds])
|
|
/// - N => New directory from search text
|
|
/// - C => Copy selected files to this directory.
|
|
/// - R => Remove selected files and directories non-recursively
|
|
/// - P -> set Permissions (mode taken as base-8 number from find/filter bar text)
|
|
/// - O -> set Owner (and group - TODO!)
|
|
/// - 1-9 or 0 => set recursive depth limit (0 = infinite)
|
|
/// - Q => query files again if they have changes
|
|
/// - W => open terminal here ($SHELL)
|
|
/// - E => open in editor ($EDITOR <file/dir>)
|
|
/// Find/Filter Bar:
|
|
/// - Esc: back and discard
|
|
/// - Enter: back and apply
|
|
/// - Backspace: delete
|
|
/// - type to enter search regex
|
|
#[derive(Parser, Debug)]
|
|
#[command(version, verbatim_doc_comment)]
|
|
struct Args {
|
|
/// the directory you want to view.
|
|
dir: Option<PathBuf>,
|
|
/// skips converting the 'dir' argument to an absolute path.
|
|
/// this causes issues when trying to view parent directories
|
|
/// but may be necessary if tuifile doesn't start.
|
|
#[arg(long)]
|
|
dir_relative: bool,
|
|
/// performs some checks and prints results.
|
|
#[arg(long)]
|
|
check: bool,
|
|
/// disables live search, only filtering the file list when enter is pressed.
|
|
#[arg(long)]
|
|
no_live_search: bool,
|
|
}
|
|
|
|
struct Share {
|
|
status: String,
|
|
tasks: Vec<BackgroundTask>,
|
|
active_instance: usize,
|
|
total_instances: usize,
|
|
size: (u16, u16),
|
|
stdout: StdoutLock<'static>,
|
|
//
|
|
live_search: bool,
|
|
shell_command: String,
|
|
editor_command: String,
|
|
/// 0: size
|
|
/// 1: mode (permissions)
|
|
info_what: Vec<u32>,
|
|
}
|
|
impl Share {
|
|
/// returns Some if any task has finished.
|
|
/// returns Some(true) if at least one of these tasks may have altered files.
|
|
/// (this should trigger a rescan)
|
|
fn check_bgtasks(&mut self) -> Option<bool> {
|
|
let mut finished = false;
|
|
let mut rescan = false;
|
|
for i in self
|
|
.tasks
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|v| v.1.thread.is_finished())
|
|
.map(|v| v.0)
|
|
.collect::<Vec<_>>()
|
|
{
|
|
let task = self.tasks.remove(i);
|
|
finished = true;
|
|
rescan |= task.rescan_after;
|
|
}
|
|
if finished {
|
|
Some(rescan)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
struct BackgroundTask {
|
|
status: Arc<Mutex<String>>,
|
|
thread: JoinHandle<Result<(), String>>,
|
|
rescan_after: bool,
|
|
}
|
|
impl BackgroundTask {
|
|
pub fn new(
|
|
text: String,
|
|
func: impl FnOnce(Arc<Mutex<String>>) -> Result<(), String> + Send + 'static,
|
|
rescan_after: bool,
|
|
) -> Self {
|
|
let status = Arc::new(Mutex::new(text));
|
|
Self {
|
|
status: Arc::clone(&status),
|
|
thread: std::thread::spawn(move || func(status)),
|
|
rescan_after,
|
|
}
|
|
}
|
|
}
|
|
struct TuiFile {
|
|
active: bool,
|
|
updates: u32,
|
|
current_dir: PathBuf,
|
|
dir_content: Vec<DirContent>,
|
|
dir_content_len: usize,
|
|
dir_content_builder_task: Option<Arc<Mutex<Option<Result<Vec<DirContent>, String>>>>>,
|
|
scroll: usize,
|
|
current_index: usize,
|
|
focus: Focus,
|
|
scan_files_max_depth: usize,
|
|
files_status_is_special: bool,
|
|
files_status: String,
|
|
search_text: String,
|
|
search_regex: Option<Regex>,
|
|
last_drawn_files_height: usize,
|
|
last_drawn_files_count: usize,
|
|
last_files_max_scroll: usize,
|
|
after_rescanning_files: Vec<Box<dyn FnOnce(&mut Self)>>,
|
|
scan_files_mode: ScanFilesMode,
|
|
}
|
|
#[derive(Clone)]
|
|
enum ScanFilesMode {
|
|
/// file-scanning blocks the main thread.
|
|
/// prevents flickering.
|
|
Blocking,
|
|
/// file-scanning doesn't block the main thread.
|
|
/// leads to flickering as the file list appears empty until the thread finishes.
|
|
Threaded,
|
|
/// file-scanning blocks the main thread for up to _ seconds.
|
|
/// after the timeout is reached, file scanning is stopped.
|
|
/// can lead to incomplete file lists.
|
|
Timeout(f32),
|
|
/// file-scanning blocks the main thread for up to _ seconds.
|
|
/// after the timeout is reached, file-scanning will restart on a thread.
|
|
/// prevents flickering but will scan the first files twice if the timeout is reached.
|
|
TimeoutThenThreaded(f32),
|
|
}
|
|
impl Default for ScanFilesMode {
|
|
fn default() -> Self {
|
|
Self::Blocking
|
|
}
|
|
}
|
|
#[derive(Clone)]
|
|
struct DirContent {
|
|
path: PathBuf,
|
|
name: String,
|
|
name_charlen: usize,
|
|
rel_depth: usize,
|
|
passes_filter: bool,
|
|
selected: bool,
|
|
info: String,
|
|
more: DirContentType,
|
|
}
|
|
#[derive(Clone)]
|
|
enum DirContentType {
|
|
/// Couldn't get more info on this entry
|
|
Err(String),
|
|
Dir {
|
|
metadata: Metadata,
|
|
},
|
|
File {
|
|
metadata: Metadata,
|
|
},
|
|
Symlink {
|
|
metadata: Metadata,
|
|
},
|
|
}
|
|
#[derive(Clone)]
|
|
enum Focus {
|
|
Files,
|
|
SearchBar,
|
|
}
|
|
enum AppCmd {
|
|
Quit,
|
|
CloseInstance,
|
|
NextInstance,
|
|
PrevInstance,
|
|
AddInstance(TuiFile),
|
|
CopyTo(PathBuf),
|
|
RescanFiles,
|
|
}
|
|
impl TuiFile {
|
|
pub fn clone(&self) -> Self {
|
|
Self {
|
|
active: self.active,
|
|
updates: 0,
|
|
current_dir: self.current_dir.clone(),
|
|
dir_content: self.dir_content.clone(),
|
|
dir_content_len: self.dir_content_len,
|
|
dir_content_builder_task: None,
|
|
scroll: self.scroll,
|
|
current_index: self.current_index,
|
|
focus: self.focus.clone(),
|
|
scan_files_max_depth: self.scan_files_max_depth,
|
|
files_status_is_special: self.files_status_is_special,
|
|
files_status: self.files_status.clone(),
|
|
search_text: self.search_text.clone(),
|
|
search_regex: self.search_regex.clone(),
|
|
last_drawn_files_height: self.last_drawn_files_height,
|
|
last_drawn_files_count: self.last_drawn_files_count,
|
|
last_files_max_scroll: self.last_files_max_scroll,
|
|
after_rescanning_files: vec![],
|
|
scan_files_mode: self.scan_files_mode.clone(),
|
|
}
|
|
}
|
|
pub fn new(current_dir: PathBuf) -> io::Result<Self> {
|
|
// state
|
|
let (_width, _height) = terminal::size()?;
|
|
let updates = u32::MAX;
|
|
Ok(Self {
|
|
active: true,
|
|
updates,
|
|
current_dir,
|
|
dir_content: vec![],
|
|
dir_content_len: 0,
|
|
dir_content_builder_task: None,
|
|
scroll: 0,
|
|
current_index: 0,
|
|
focus: Focus::Files,
|
|
scan_files_max_depth: 0,
|
|
files_status_is_special: false,
|
|
files_status: String::new(),
|
|
search_text: String::new(),
|
|
search_regex: None,
|
|
last_drawn_files_height: 0,
|
|
last_drawn_files_count: 0,
|
|
last_files_max_scroll: 0,
|
|
after_rescanning_files: vec![],
|
|
scan_files_mode: ScanFilesMode::default(),
|
|
})
|
|
}
|
|
fn set_current_index(&mut self, mut i: usize) {
|
|
if i >= self.dir_content.len() {
|
|
i = self.dir_content.len().saturating_sub(1);
|
|
}
|
|
if i == self.current_index {
|
|
return;
|
|
}
|
|
if i < self.scroll {
|
|
self.scroll = i;
|
|
self.updates.request_redraw_filelist();
|
|
}
|
|
if i >= self.scroll + self.last_drawn_files_height && self.last_drawn_files_height > 0 {
|
|
self.scroll = 1 + i - self.last_drawn_files_height;
|
|
self.updates.request_redraw_filelist();
|
|
}
|
|
self.updates.request_move_cursor();
|
|
// self.updates.request_redraw_filelist();
|
|
self.current_index = i;
|
|
}
|
|
/// starting from `start`, checks all indices until it finds a visible entry or there are no more entries.
|
|
/// If an entry was found, the current_index will be set to that entry.
|
|
fn set_current_index_to_visible(&mut self, start: usize, inc: bool) {
|
|
let mut i = start;
|
|
loop {
|
|
if let Some(e) = self.dir_content.get(i) {
|
|
if e.passes_filter {
|
|
self.set_current_index(i);
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
if inc {
|
|
i += 1;
|
|
} else if i > 0 {
|
|
i -= 1;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
fn request_rescan_files_then_select(
|
|
&mut self,
|
|
find_by: impl FnMut(&DirContent) -> bool + 'static,
|
|
) {
|
|
self.updates.request_rescan_files();
|
|
self.after_rescanning_files.push(Box::new(move |s| {
|
|
if let Some(i) = s.dir_content.iter().position(find_by) {
|
|
s.set_current_index(i);
|
|
} else {
|
|
s.updates.request_reset_current_index();
|
|
}
|
|
}));
|
|
}
|
|
fn request_rescan_files_then_select_by_name(&mut self, name: String) {
|
|
self.request_rescan_files_then_select(move |e| {
|
|
e.name == name || e.name.ends_with('/') && e.name[..e.name.len() - 1] == name
|
|
});
|
|
}
|
|
fn request_rescan_files_then_select_current_again(&mut self) {
|
|
if let Some(c) = self.dir_content.get(self.current_index) {
|
|
self.request_rescan_files_then_select_by_name(c.name.clone());
|
|
} else {
|
|
self.updates.request_rescan_files();
|
|
}
|
|
}
|
|
}
|