use super::action::Action;
use super::action_result::{ActionResult, InputRequest, PendingOp};
use super::browser_config::BrowserConfig;
use super::cls_history::History;
use super::cls_key_map::KeyMap;
use super::cls_selection::Selection;
use super::fnc_browser_actions as actions;
use super::fnc_browser_flat::load_flat_entries;
use super::fnc_browser_nav as nav;
use super::fnc_browser_virtual_root::{
build_virtual_root_entries, is_virtual_root_path, virtual_root_path,
};
use super::fnc_file_ops;
use super::fnc_glob_match::glob_match;
use super::fnc_validate::validate_name;
use super::key_input::KeyInput;
use super::nav_error::NavError;
use crate::{read_dir, FileEntry, FileList, GitignoreMatcher, SortBy};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub type CustomFilter = Arc<dyn Fn(&FileEntry) -> bool + Send + Sync>;
#[derive(Debug, Clone)]
enum PendingState {
Confirmation(PendingOp),
Input(InputRequest),
}
#[derive(Debug, Clone)]
struct FilterState {
pattern: String,
is_glob: bool,
}
pub struct Browser {
files: FileList,
all_entries: Vec<FileEntry>,
cursor: usize,
scroll_offset: usize,
viewport_height: usize,
current_path: PathBuf,
selection: Selection,
history: History,
keymap: KeyMap,
filter: Option<FilterState>,
pending: Option<PendingState>,
config: BrowserConfig,
#[allow(dead_code)] gitignore: Option<GitignoreMatcher>,
flat_mode: Option<Option<usize>>,
custom_filter: Option<CustomFilter>,
roots: Vec<PathBuf>,
virtual_root: bool,
}
impl Browser {
pub async fn new(config: BrowserConfig) -> Result<Self, NavError> {
let path = config
.initial_path
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
Self::at_path(path, config).await
}
pub async fn at_path(path: impl AsRef<Path>, config: BrowserConfig) -> Result<Self, NavError> {
let path = path.as_ref().to_path_buf();
if !path.exists() {
return Err(NavError::NotFound(path));
}
if !path.is_dir() {
return Err(NavError::NotADirectory(path));
}
let gitignore = if config.respect_ignore_files {
GitignoreMatcher::from_path(&path).ok()
} else {
None
};
let mut browser = Self {
files: FileList::new(),
all_entries: Vec::new(),
cursor: 0,
scroll_offset: 0,
viewport_height: 20, current_path: path,
selection: Selection::new(),
history: History::new(config.history_limit),
keymap: config.keymap.clone(),
filter: None,
pending: None,
config,
gitignore,
flat_mode: None,
custom_filter: None,
roots: Vec::new(),
virtual_root: false,
};
browser.load_directory().await?;
Ok(browser)
}
pub async fn with_roots(
config: BrowserConfig,
roots: Vec<PathBuf>,
) -> Result<Self, NavError> {
if roots.is_empty() {
return Err(NavError::NotFound(PathBuf::from(
"with_roots requires at least one root",
)));
}
for root in &roots {
if !root.exists() {
return Err(NavError::NotFound(root.clone()));
}
if !root.is_dir() {
return Err(NavError::NotADirectory(root.clone()));
}
}
let mut browser = Self {
files: FileList::new(),
all_entries: Vec::new(),
cursor: 0,
scroll_offset: 0,
viewport_height: 20,
current_path: virtual_root_path(),
selection: Selection::new(),
history: History::new(config.history_limit),
keymap: config.keymap.clone(),
filter: None,
pending: None,
config,
gitignore: None,
flat_mode: None,
custom_filter: None,
roots,
virtual_root: true,
};
browser.load_directory().await?;
Ok(browser)
}
pub fn roots_list(&self) -> &[PathBuf] {
&self.roots
}
pub fn is_virtual_root(&self) -> bool {
self.virtual_root
}
pub fn files(&self) -> &FileList {
&self.files
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn current_entry(&self) -> Option<&FileEntry> {
self.files.get(self.cursor)
}
pub fn current_path(&self) -> &Path {
&self.current_path
}
pub fn selection(&self) -> &Selection {
&self.selection
}
pub fn is_readonly(&self) -> bool {
self.config.readonly
}
pub fn config(&self) -> &BrowserConfig {
&self.config
}
pub fn set_viewport_height(&mut self, height: usize) {
self.viewport_height = height.max(1);
self.update_scroll();
}
pub fn viewport_height(&self) -> usize {
self.viewport_height
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_cursor(&mut self, index: usize) {
let len = self.files.len();
if len == 0 {
self.cursor = 0;
return;
}
self.cursor = index.min(len - 1);
self.update_scroll();
}
pub fn move_up(&mut self) {
self.selection.clear_anchor();
self.move_cursor(-1);
}
pub fn move_down(&mut self) {
self.selection.clear_anchor();
self.move_cursor(1);
}
pub fn visible_range(&self, viewport_height: usize) -> Range<usize> {
let start = self.scroll_offset;
let end = (start + viewport_height).min(self.files.len());
start..end
}
pub fn selected_paths(&self) -> impl Iterator<Item = &Path> {
self.selection.iter()
}
pub fn selected_or_current(&self) -> Vec<&Path> {
if self.selection.is_empty() {
self.current_entry()
.map(|e| e.path.as_path())
.into_iter()
.collect()
} else {
self.selection.paths()
}
}
pub fn set_filter(&mut self, pattern: &str) {
let is_glob = pattern.contains('*') || pattern.contains('?');
self.filter = Some(FilterState {
pattern: pattern.to_string(),
is_glob,
});
self.apply_filter();
}
pub fn clear_filter(&mut self) {
self.filter = None;
self.apply_filter();
}
pub fn filter(&self) -> Option<&str> {
self.filter.as_ref().map(|f| f.pattern.as_str())
}
pub fn total_count(&self) -> usize {
self.all_entries.len()
}
pub fn filtered_count(&self) -> usize {
self.files.len()
}
pub fn set_flat_mode(&mut self, depth: Option<usize>) {
self.flat_mode = Some(depth);
}
pub fn set_flat_mode_off(&mut self) {
self.flat_mode = None;
}
pub fn is_flat_mode(&self) -> bool {
self.flat_mode.is_some()
}
pub fn flat_mode_depth(&self) -> Option<Option<usize>> {
self.flat_mode
}
pub fn set_custom_filter<F>(&mut self, predicate: F)
where
F: Fn(&FileEntry) -> bool + Send + Sync + 'static,
{
self.custom_filter = Some(Arc::new(predicate));
self.apply_filter();
}
pub fn clear_custom_filter(&mut self) {
let was_set = self.custom_filter.is_some();
self.custom_filter = None;
if was_set {
self.apply_filter();
}
}
pub fn has_custom_filter(&self) -> bool {
self.custom_filter.is_some()
}
pub fn breadcrumbs(&self) -> Vec<(String, PathBuf)> {
actions::build_breadcrumbs(&self.current_path)
}
pub async fn go_to_breadcrumb(&mut self, index: usize) -> Result<ActionResult, NavError> {
let crumbs = self.breadcrumbs();
if index >= crumbs.len() {
return Ok(ActionResult::Done);
}
self.navigate_to(&crumbs[index].1).await
}
pub async fn navigate_to(&mut self, path: impl AsRef<Path>) -> Result<ActionResult, NavError> {
let path = if path.as_ref().is_relative() {
self.current_path.join(path)
} else {
path.as_ref().to_path_buf()
};
self.navigate_internal(path).await
}
pub fn has_parent_entry(&self) -> bool {
if self.virtual_root {
return false;
}
self.config.show_parent_entry && self.current_path.parent().is_some()
}
pub fn roots() -> Vec<PathBuf> {
#[cfg(unix)]
{
vec![PathBuf::from("/")]
}
#[cfg(windows)]
{
let mut roots = Vec::new();
for letter in b'A'..=b'Z' {
let path = PathBuf::from(format!("{}:\\", letter as char));
if path.exists() {
roots.push(path);
}
}
roots
}
#[cfg(not(any(unix, windows)))]
{
vec![PathBuf::from("/")]
}
}
pub async fn handle_key(&mut self, key: KeyInput) -> ActionResult {
if let Some(action) = self.keymap.get(&key) {
self.execute(action).await
} else {
ActionResult::Unhandled
}
}
pub async fn execute(&mut self, action: Action) -> ActionResult {
if self.config.readonly && action.is_mutation() {
return ActionResult::Done;
}
match action {
Action::MoveUp => {
self.selection.clear_anchor(); self.move_cursor(-1);
ActionResult::Done
}
Action::MoveDown => {
self.selection.clear_anchor(); self.move_cursor(1);
ActionResult::Done
}
Action::MoveToTop => {
self.selection.clear_anchor(); self.cursor = 0;
self.update_scroll();
ActionResult::Done
}
Action::MoveToBottom => {
self.selection.clear_anchor(); self.cursor = self.files.len().saturating_sub(1);
self.update_scroll();
ActionResult::Done
}
Action::PageUp | Action::PageDown => ActionResult::Done, Action::Enter => self.action_enter().await,
Action::GoParent => self.action_go_parent().await,
Action::GoBack => self.action_go_back().await,
Action::GoForward => self.action_go_forward().await,
Action::ToggleSelect => {
self.toggle_current_selection();
ActionResult::Done
}
Action::SelectAll => {
self.select_all_visible();
ActionResult::Done
}
Action::ClearSelection => {
self.selection.clear();
self.selection.clear_anchor();
ActionResult::Done
}
Action::MoveUpExtend => {
self.move_cursor_extend(-1);
ActionResult::Done
}
Action::MoveDownExtend => {
self.move_cursor_extend(1);
ActionResult::Done
}
Action::Cut => self.action_cut(),
Action::Copy => self.action_copy(),
Action::Delete => self.action_delete().await,
Action::Rename => self.action_rename(),
Action::CreateDir => self.action_create_dir(),
Action::CreateFile => self.action_create_file(),
Action::ToggleHidden => {
self.toggle_hidden();
ActionResult::Done
}
Action::CycleSort => {
self.cycle_sort();
ActionResult::Done
}
Action::Refresh => {
let _ = self.refresh().await;
ActionResult::Done
}
Action::StartFilter => self.action_start_filter(),
Action::ClearFilter => {
self.clear_filter();
ActionResult::Done
}
Action::StartPathInput => self.action_start_path_input(),
}
}
pub fn jump_to_char(&mut self, c: char) -> bool {
if let Some(idx) = nav::find_char_match(&self.files, self.cursor, c) {
self.cursor = idx;
self.update_scroll();
true
} else {
false
}
}
pub fn jump_to_substring(&mut self, query: &str) -> bool {
if let Some(idx) = nav::find_substring_match(&self.files, self.cursor, query) {
self.cursor = idx;
self.update_scroll();
true
} else {
false
}
}
pub fn page_up(&mut self, viewport_height: usize) {
if self.files.is_empty() {
return;
}
self.cursor = nav::page_up_cursor(self.cursor, viewport_height);
self.update_scroll();
}
pub fn page_down(&mut self, viewport_height: usize) {
if self.files.is_empty() {
return;
}
self.cursor = nav::page_down_cursor(self.cursor, viewport_height, self.files.len());
self.update_scroll();
}
pub async fn refresh(&mut self) -> Result<(), NavError> {
let cursor_name = self.current_entry().map(|e| e.name.clone());
self.load_directory().await?;
if let Some(name) = cursor_name {
if let Some(idx) = self.files.iter().position(|e| e.name == name) {
self.cursor = idx;
}
}
self.update_scroll();
Ok(())
}
pub async fn resolve_confirmation(
&mut self,
confirmed: bool,
) -> Result<ActionResult, NavError> {
let pending = self.pending.take().ok_or(NavError::NoPendingOperation)?;
if let PendingState::Confirmation(op) = pending {
if confirmed {
self.execute_pending_op(&op)?;
self.refresh().await?;
}
Ok(ActionResult::Done)
} else {
self.pending = Some(pending);
Err(NavError::NoPendingOperation)
}
}
pub async fn complete_input(&mut self, value: &str) -> Result<ActionResult, NavError> {
let pending = self.pending.take().ok_or(NavError::NoPendingOperation)?;
if let PendingState::Input(req) = pending {
match req {
InputRequest::Filter { .. } => {
self.set_filter(value);
Ok(ActionResult::Done)
}
InputRequest::Path { .. } => self.navigate_to(value).await,
InputRequest::Rename { .. } => self.complete_rename(value).await,
InputRequest::NewDirectory => self.complete_create_dir(value).await,
InputRequest::NewFile => self.complete_create_file(value).await,
}
} else {
self.pending = Some(pending);
Err(NavError::NoPendingOperation)
}
}
pub fn cancel_input(&mut self) {
self.pending = None;
}
pub fn pending_operation(&self) -> Option<&PendingOp> {
match &self.pending {
Some(PendingState::Confirmation(op)) => Some(op),
_ => None,
}
}
async fn load_directory(&mut self) -> Result<(), NavError> {
if self.virtual_root {
self.all_entries = build_virtual_root_entries(&self.roots);
} else if let Some(depth) = self.flat_mode {
self.all_entries = load_flat_entries(
&self.current_path,
depth,
self.config.show_hidden,
self.config.respect_ignore_files,
)
.await?;
} else {
self.all_entries = read_dir(&self.current_path)
.await
.map_err(|e| NavError::from_error_with_path(e, &self.current_path))?;
}
self.files.set_show_hidden(self.config.show_hidden);
self.files.set_sort(self.config.sort_by);
self.apply_filter();
self.cursor = self.cursor.min(self.files.len().saturating_sub(1));
self.update_scroll();
Ok(())
}
fn apply_filter(&mut self) {
let built_in_pass = |e: &FileEntry| -> bool {
if let Some(filter) = &self.filter {
if filter.is_glob {
glob_match(&filter.pattern, &e.name)
} else {
e.name
.to_lowercase()
.contains(&filter.pattern.to_lowercase())
}
} else {
true
}
};
let mut entries: Vec<_> = self
.all_entries
.iter()
.filter(|e| {
if !built_in_pass(e) {
return false;
}
if let Some(predicate) = &self.custom_filter {
if !predicate(e) {
return false;
}
}
true
})
.cloned()
.collect();
if self.config.show_parent_entry && !self.virtual_root && self.flat_mode.is_none() {
if let Some(parent) = self.current_path.parent() {
if !is_virtual_root_path(&self.current_path) {
entries.insert(0, FileEntry::parent_entry(parent.to_path_buf()));
}
}
}
self.files.update_full(entries);
self.files.catchup();
}
async fn navigate_internal(&mut self, path: PathBuf) -> Result<ActionResult, NavError> {
if is_virtual_root_path(&path) {
if self.roots.is_empty() {
return Err(NavError::NotFound(path));
}
self.history.push(&self.current_path, self.cursor);
self.current_path = virtual_root_path();
self.virtual_root = true;
self.filter = None;
if self.config.clear_selection_on_navigate {
self.selection.clear();
}
self.load_directory().await?;
return Ok(ActionResult::DirectoryChanged);
}
if !path.exists() {
return Err(NavError::NotFound(path));
}
if !path.is_dir() {
return Err(NavError::NotADirectory(path));
}
self.history.push(&self.current_path, self.cursor);
self.current_path = path;
self.virtual_root = false;
self.filter = None;
if self.config.clear_selection_on_navigate {
self.selection.clear();
}
self.load_directory().await?;
Ok(ActionResult::DirectoryChanged)
}
fn move_cursor(&mut self, delta: isize) {
self.cursor = nav::move_cursor(self.cursor, delta, self.files.len());
self.update_scroll();
}
fn move_cursor_extend(&mut self, delta: isize) {
let len = self.files.len();
if len == 0 {
return;
}
let old_cursor = self.cursor;
let new_cursor = nav::move_cursor(self.cursor, delta, len);
let files = &self.files;
self.selection.extend_to(
old_cursor,
new_cursor,
|i| files.get(i).map(|e| e.path.clone()),
len,
);
self.cursor = new_cursor;
self.update_scroll();
}
fn update_scroll(&mut self) {
self.scroll_offset = nav::update_scroll_offset(
self.cursor,
self.scroll_offset,
self.config.scroll_padding,
self.viewport_height,
self.files.len(),
);
}
fn toggle_current_selection(&mut self) {
if let Some(path) = self.current_entry().map(|e| e.path.clone()) {
self.selection.toggle(&path);
}
}
fn select_all_visible(&mut self) {
for entry in self.files.iter() {
self.selection.select(&entry.path);
}
}
pub fn toggle_hidden(&mut self) {
self.config.show_hidden = !self.config.show_hidden;
self.files.set_show_hidden(self.config.show_hidden);
self.files.catchup();
let len = self.files.len();
if len > 0 && self.cursor >= len {
self.cursor = len - 1;
}
self.update_scroll();
}
fn cycle_sort(&mut self) {
use SortBy::*;
self.config.sort_by = match self.config.sort_by {
Name => NameDesc,
NameDesc => Extension,
Extension => Size,
Size => SizeDesc,
SizeDesc => Modified,
Modified => ModifiedDesc,
ModifiedDesc => Name,
DirsFirst => Name,
};
self.files.set_sort(self.config.sort_by);
self.files.catchup();
}
async fn action_enter(&mut self) -> ActionResult {
if let Some(entry) = self.current_entry().cloned() {
if entry.is_dir {
if self.flat_mode.is_some() {
return ActionResult::Done;
}
match self.navigate_internal(entry.path).await {
Ok(r) => r,
Err(_) => ActionResult::Done,
}
} else {
ActionResult::FileSelected(entry.path)
}
} else {
ActionResult::Done
}
}
async fn action_go_parent(&mut self) -> ActionResult {
if self.virtual_root {
return ActionResult::Done;
}
if !self.roots.is_empty() && self.roots.iter().any(|r| r == &self.current_path) {
let came_from = self.current_path.clone();
match self.navigate_internal(virtual_root_path()).await {
Ok(r) => {
if let Some(idx) = self.files.iter().position(|e| e.path == came_from) {
self.cursor = idx;
self.update_scroll();
}
r
}
Err(_) => ActionResult::Done,
}
} else if let Some(parent) = self.current_path.parent().map(|p| p.to_path_buf()) {
let current_name = self
.current_path
.file_name()
.map(|n| n.to_string_lossy().into_owned());
match self.navigate_internal(parent).await {
Ok(r) => {
if let Some(name) = current_name {
if let Some(idx) = self.files.iter().position(|e| e.name == name) {
self.cursor = idx;
self.update_scroll();
}
}
r
}
Err(_) => ActionResult::Done,
}
} else {
ActionResult::Done
}
}
async fn action_go_back(&mut self) -> ActionResult {
if let Some(entry) = self.history.go_back(&self.current_path, self.cursor) {
self.virtual_root = is_virtual_root_path(&entry.path);
self.current_path = entry.path;
self.filter = None;
if self.config.clear_selection_on_navigate {
self.selection.clear();
}
let _ = self.load_directory().await;
self.cursor = entry.cursor.min(self.files.len().saturating_sub(1));
self.update_scroll();
ActionResult::DirectoryChanged
} else {
ActionResult::Done
}
}
async fn action_go_forward(&mut self) -> ActionResult {
if let Some(entry) = self.history.go_forward(&self.current_path, self.cursor) {
self.virtual_root = is_virtual_root_path(&entry.path);
self.current_path = entry.path;
self.filter = None;
if self.config.clear_selection_on_navigate {
self.selection.clear();
}
let _ = self.load_directory().await;
self.cursor = entry.cursor.min(self.files.len().saturating_sub(1));
self.update_scroll();
ActionResult::DirectoryChanged
} else {
ActionResult::Done
}
}
fn action_cut(&mut self) -> ActionResult {
let paths = actions::collect_target_paths(
self.selection.iter(),
self.current_entry().map(|e| e.path.as_path()),
self.selection.is_empty(),
);
actions::build_cut_clipboard(paths, &self.current_path)
}
fn action_copy(&mut self) -> ActionResult {
let paths = actions::collect_target_paths(
self.selection.iter(),
self.current_entry().map(|e| e.path.as_path()),
self.selection.is_empty(),
);
actions::build_copy_clipboard(paths, &self.current_path)
}
async fn action_delete(&mut self) -> ActionResult {
let paths: Vec<PathBuf> = self
.selected_or_current()
.iter()
.map(|p| p.to_path_buf())
.collect();
if paths.is_empty() {
return ActionResult::Done;
}
if self.config.confirm_delete {
self.pending = Some(PendingState::Confirmation(PendingOp::Delete { paths }));
ActionResult::NeedsConfirmation(PendingOp::Delete {
paths: self
.selected_or_current()
.iter()
.map(|p| p.to_path_buf())
.collect(),
})
} else {
for path in &paths {
let _ = fnc_file_ops::delete_path(path);
}
let _ = self.refresh().await;
ActionResult::Done
}
}
fn action_rename(&mut self) -> ActionResult {
if let Some(name) = self.current_entry().map(|e| e.name.clone()) {
self.pending = Some(PendingState::Input(InputRequest::Rename {
current_name: name.clone(),
}));
ActionResult::NeedsInput(InputRequest::Rename { current_name: name })
} else {
ActionResult::Done
}
}
fn action_create_dir(&mut self) -> ActionResult {
self.pending = Some(PendingState::Input(InputRequest::NewDirectory));
ActionResult::NeedsInput(InputRequest::NewDirectory)
}
fn action_create_file(&mut self) -> ActionResult {
self.pending = Some(PendingState::Input(InputRequest::NewFile));
ActionResult::NeedsInput(InputRequest::NewFile)
}
fn action_start_filter(&mut self) -> ActionResult {
let current = self.filter.as_ref().map(|f| f.pattern.clone());
self.pending = Some(PendingState::Input(InputRequest::Filter {
current: current.clone(),
}));
ActionResult::NeedsInput(InputRequest::Filter { current })
}
fn action_start_path_input(&mut self) -> ActionResult {
self.pending = Some(PendingState::Input(InputRequest::Path {
current: self.current_path.clone(),
}));
ActionResult::NeedsInput(InputRequest::Path {
current: self.current_path.clone(),
})
}
fn execute_pending_op(&mut self, op: &PendingOp) -> Result<(), NavError> {
match op {
PendingOp::Delete { paths } => {
for path in paths {
fnc_file_ops::delete_path(path)?;
}
}
PendingOp::Rename { from, to } => {
fnc_file_ops::rename_path(from, to)?;
}
PendingOp::Overwrite { path } => {
fnc_file_ops::delete_path(path)?;
}
}
Ok(())
}
async fn complete_rename(&mut self, new_name: &str) -> Result<ActionResult, NavError> {
validate_name(new_name)?;
if let Some(entry) = self.current_entry() {
let new_path = entry.path.parent().unwrap_or(Path::new("")).join(new_name);
if new_path.exists() && self.config.confirm_overwrite {
self.pending = Some(PendingState::Confirmation(PendingOp::Rename {
from: entry.path.clone(),
to: new_path.clone(),
}));
return Ok(ActionResult::NeedsConfirmation(PendingOp::Overwrite {
path: new_path,
}));
}
fnc_file_ops::rename_path(&entry.path, &new_path)?;
self.refresh().await?;
}
Ok(ActionResult::Done)
}
async fn complete_create_dir(&mut self, name: &str) -> Result<ActionResult, NavError> {
validate_name(name)?;
let path = self.current_path.join(name);
fnc_file_ops::create_directory(&path)?;
self.refresh().await?;
Ok(ActionResult::Done)
}
async fn complete_create_file(&mut self, name: &str) -> Result<ActionResult, NavError> {
validate_name(name)?;
let path = self.current_path.join(name);
fnc_file_ops::create_file(&path)?;
self.refresh().await?;
Ok(ActionResult::Done)
}
}