use std::{
collections::VecDeque,
io::{self},
path::{Path, PathBuf},
time::{Duration, Instant},
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Editor {
#[default]
None,
Helix,
Neovim,
Vim,
Nano,
Micro,
Emacs,
VSCode,
Zed,
Xcode,
AndroidStudio,
RustRover,
IntelliJIdea,
WebStorm,
PyCharm,
GoLand,
CLion,
Fleet,
Sublime,
RubyMine,
PHPStorm,
Rider,
Eclipse,
Custom(String),
}
impl Editor {
pub fn binary(&self) -> Option<String> {
match self {
Editor::None => Option::None,
Editor::Helix => Some(Self::resolve_helix()),
Editor::Neovim => Some("nvim".to_string()),
Editor::Vim => Some("vim".to_string()),
Editor::Nano => Some("nano".to_string()),
Editor::Micro => Some("micro".to_string()),
Editor::Emacs => Some("emacs".to_string()),
Editor::VSCode => Some("code".to_string()),
Editor::Zed => Some("zed".to_string()),
Editor::Xcode => Some("xed".to_string()),
Editor::AndroidStudio => Some("studio".to_string()),
Editor::RustRover => Some("rustrover".to_string()),
Editor::IntelliJIdea => Some("idea".to_string()),
Editor::WebStorm => Some("webstorm".to_string()),
Editor::PyCharm => Some("pycharm".to_string()),
Editor::GoLand => Some("goland".to_string()),
Editor::CLion => Some("clion".to_string()),
Editor::Fleet => Some("fleet".to_string()),
Editor::Sublime => Some("subl".to_string()),
Editor::RubyMine => Some("rubymine".to_string()),
Editor::PHPStorm => Some("phpstorm".to_string()),
Editor::Rider => Some("rider".to_string()),
Editor::Eclipse => Some("eclipse".to_string()),
Editor::Custom(s) => Some(s.clone()),
}
}
fn resolve_helix() -> String {
for candidate in &["hx", "helix"] {
if which_on_path(candidate) {
return candidate.to_string();
}
}
"hx".to_string()
}
pub fn label(&self) -> &str {
match self {
Editor::None => "none",
Editor::Helix => "helix",
Editor::Neovim => "nvim",
Editor::Vim => "vim",
Editor::Nano => "nano",
Editor::Micro => "micro",
Editor::Emacs => "emacs",
Editor::VSCode => "vscode",
Editor::Zed => "zed",
Editor::Xcode => "xcode",
Editor::AndroidStudio => "android-studio",
Editor::RustRover => "rustrover",
Editor::IntelliJIdea => "intellij",
Editor::WebStorm => "webstorm",
Editor::PyCharm => "pycharm",
Editor::GoLand => "goland",
Editor::CLion => "clion",
Editor::Fleet => "fleet",
Editor::Sublime => "sublime",
Editor::RubyMine => "rubymine",
Editor::PHPStorm => "phpstorm",
Editor::Rider => "rider",
Editor::Eclipse => "eclipse",
Editor::Custom(s) => s.as_str(),
}
}
#[allow(dead_code)]
pub fn cycle(&self) -> Editor {
match self {
Editor::None => Editor::Helix,
Editor::Helix => Editor::Neovim,
Editor::Neovim => Editor::Vim,
Editor::Vim => Editor::Nano,
Editor::Nano => Editor::Micro,
Editor::Micro => Editor::None,
_ => Editor::None,
}
}
pub fn to_key(&self) -> String {
match self {
Editor::None => "none".to_string(),
Editor::Helix => "helix".to_string(),
Editor::Neovim => "nvim".to_string(),
Editor::Vim => "vim".to_string(),
Editor::Nano => "nano".to_string(),
Editor::Micro => "micro".to_string(),
Editor::Emacs => "emacs".to_string(),
Editor::VSCode => "vscode".to_string(),
Editor::Zed => "zed".to_string(),
Editor::Xcode => "xcode".to_string(),
Editor::AndroidStudio => "android-studio".to_string(),
Editor::RustRover => "rustrover".to_string(),
Editor::IntelliJIdea => "intellij".to_string(),
Editor::WebStorm => "webstorm".to_string(),
Editor::PyCharm => "pycharm".to_string(),
Editor::GoLand => "goland".to_string(),
Editor::CLion => "clion".to_string(),
Editor::Fleet => "fleet".to_string(),
Editor::Sublime => "sublime".to_string(),
Editor::RubyMine => "rubymine".to_string(),
Editor::PHPStorm => "phpstorm".to_string(),
Editor::Rider => "rider".to_string(),
Editor::Eclipse => "eclipse".to_string(),
Editor::Custom(s) => format!("custom:{s}"),
}
}
pub fn from_key(s: &str) -> Option<Editor> {
if s.is_empty() {
return Option::None;
}
Some(match s {
"none" => Editor::None,
"helix" => Editor::Helix,
"nvim" => Editor::Neovim,
"vim" => Editor::Vim,
"nano" => Editor::Nano,
"micro" => Editor::Micro,
"emacs" => Editor::Emacs,
"vscode" => Editor::VSCode,
"zed" => Editor::Zed,
"xcode" => Editor::Xcode,
"android-studio" => Editor::AndroidStudio,
"rustrover" => Editor::RustRover,
"intellij" => Editor::IntelliJIdea,
"webstorm" => Editor::WebStorm,
"pycharm" => Editor::PyCharm,
"goland" => Editor::GoLand,
"clion" => Editor::CLion,
"fleet" => Editor::Fleet,
"sublime" => Editor::Sublime,
"rubymine" => Editor::RubyMine,
"phpstorm" => Editor::PHPStorm,
"rider" => Editor::Rider,
"eclipse" => Editor::Eclipse,
_ if s.starts_with("custom:") => Editor::Custom(s["custom:".len()..].to_string()),
other => Editor::Custom(other.to_string()),
})
}
}
fn which_on_path(name: &str) -> bool {
let path_var = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path_var).any(|dir| {
let candidate = dir.join(name);
candidate
.metadata()
.map(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.is_file() && (m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
m.is_file()
}
})
.unwrap_or(false)
})
}
#[derive(Debug, Clone)]
pub struct AppOptions {
pub pane_dirs: Vec<PathBuf>,
pub extensions: Vec<String>,
pub show_hidden: bool,
pub show_sizes: bool,
pub theme_idx: usize,
pub show_theme_panel: bool,
pub single_pane: bool,
pub sort_mode: SortMode,
pub cd_on_exit: bool,
pub editor: Editor,
pub verbose: bool,
pub startup_log: Vec<String>,
}
impl Default for AppOptions {
fn default() -> Self {
Self {
pane_dirs: vec![PathBuf::from(".")],
extensions: vec![],
show_hidden: false,
show_sizes: true,
theme_idx: 0,
show_theme_panel: false,
single_pane: false,
sort_mode: SortMode::default(),
cd_on_exit: false,
editor: Editor::default(),
verbose: false,
startup_log: Vec::new(),
}
}
}
use crate::inline_editor::{EditorAction, InlineEditor};
use crate::preview::PreviewState;
use crate::{ExplorerOutcome, FileExplorer, SortMode, Theme};
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipOp {
Copy,
Cut,
}
#[derive(Debug, Clone)]
pub struct ClipboardItem {
pub paths: Vec<PathBuf>,
pub op: ClipOp,
}
impl ClipboardItem {
pub fn icon(&self) -> &'static str {
match self.op {
ClipOp::Copy => "\u{1F4CB}", ClipOp::Cut => "\u{2702} ", }
}
pub fn label(&self) -> &'static str {
match self.op {
ClipOp::Copy => "Copy",
ClipOp::Cut => "Cut ",
}
}
pub fn count(&self) -> usize {
self.paths.len()
}
pub fn first_path(&self) -> Option<&PathBuf> {
self.paths.first()
}
}
#[derive(Debug)]
pub enum Modal {
Delete {
path: PathBuf,
},
MultiDelete {
paths: Vec<PathBuf>,
},
Overwrite {
src: PathBuf,
dst: PathBuf,
is_cut: bool,
},
}
pub struct Snackbar {
pub message: String,
pub expires_at: Instant,
pub is_error: bool,
}
impl Snackbar {
#[allow(dead_code)]
pub fn info(message: impl Into<String>) -> Self {
Self {
message: message.into(),
expires_at: Instant::now() + Duration::from_secs(3),
is_error: false,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
message: message.into(),
expires_at: Instant::now() + Duration::from_secs(4),
is_error: true,
}
}
pub fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
#[derive(Debug, Clone)]
pub struct CopyProgress {
pub label: String,
pub done: usize,
pub total: usize,
pub current_item: String,
}
impl CopyProgress {
pub fn new(label: impl Into<String>, total: usize) -> Self {
Self {
label: label.into(),
done: 0,
total,
current_item: String::new(),
}
}
pub fn fraction(&self) -> f64 {
if self.total == 0 {
1.0
} else {
self.done as f64 / self.total as f64
}
}
pub fn is_complete(&self) -> bool {
self.done >= self.total
}
}
pub struct App {
pub panes: Vec<FileExplorer>,
pub active_idx: usize,
pub clipboard: Option<ClipboardItem>,
pub themes: Vec<(&'static str, &'static str, Theme)>,
pub theme_idx: usize,
pub show_theme_panel: bool,
pub show_options_panel: bool,
pub single_pane: bool,
pub modal: Option<Modal>,
pub selected: Option<PathBuf>,
pub status_msg: String,
pub snackbar: Option<Snackbar>,
pub copy_progress: Option<CopyProgress>,
pub cd_on_exit: bool,
pub editor: Editor,
pub open_with_editor: Option<PathBuf>,
pub show_editor_panel: bool,
pub editor_panel_idx: usize,
pub verbose: bool,
pub debug_log: Vec<String>,
pub debug_scroll: usize,
pub show_preview: bool,
pub preview_state: PreviewState,
pub inline_editor: Option<InlineEditor>,
key_bindings: KeyBindings,
operation_mode: OperationMode,
next_operation_id: u64,
queued_operation: Option<OperationRequest>,
pending_operation: Option<OperationRequest>,
event_queue: VecDeque<AppEvent>,
}
impl App {
pub fn new(opts: AppOptions) -> Self {
let dirs = if opts.pane_dirs.is_empty() {
vec![PathBuf::from(".")]
} else {
opts.pane_dirs
};
let panes: Vec<FileExplorer> = dirs
.into_iter()
.map(|dir| {
FileExplorer::builder(dir)
.extension_filter(opts.extensions.clone())
.show_hidden(opts.show_hidden)
.show_sizes(opts.show_sizes)
.sort_mode(opts.sort_mode)
.build()
})
.collect();
let themes = Theme::all_presets();
let theme_idx = opts.theme_idx.min(themes.len().saturating_sub(1));
Self {
panes,
active_idx: 0,
clipboard: None,
themes,
theme_idx,
show_theme_panel: opts.show_theme_panel,
show_options_panel: false,
single_pane: opts.single_pane,
modal: None,
selected: None,
status_msg: String::new(),
snackbar: None,
copy_progress: None,
cd_on_exit: opts.cd_on_exit,
editor: opts.editor,
open_with_editor: None,
show_editor_panel: false,
editor_panel_idx: 0,
verbose: opts.verbose,
debug_log: opts.startup_log,
debug_scroll: 0,
show_preview: false,
preview_state: PreviewState::new(),
inline_editor: None,
key_bindings: KeyBindings::default(),
operation_mode: OperationMode::Immediate,
next_operation_id: 1,
queued_operation: None,
pending_operation: None,
event_queue: VecDeque::new(),
}
}
pub fn log(&mut self, msg: impl Into<String>) {
if self.verbose {
self.debug_log.push(msg.into());
}
}
pub fn first_ide_idx() -> usize {
7
}
pub fn all_editors() -> Vec<Editor> {
vec![
Editor::None,
Editor::Helix,
Editor::Neovim,
Editor::Vim,
Editor::Nano,
Editor::Micro,
Editor::Emacs,
Editor::Sublime,
Editor::VSCode,
Editor::Zed,
Editor::Xcode,
Editor::AndroidStudio,
Editor::RustRover,
Editor::IntelliJIdea,
Editor::WebStorm,
Editor::PyCharm,
Editor::GoLand,
Editor::CLion,
Editor::Fleet,
Editor::RubyMine,
Editor::PHPStorm,
Editor::Rider,
Editor::Eclipse,
]
}
pub fn sync_editor_panel_idx(&mut self) {
let editors = Self::all_editors();
self.editor_panel_idx = editors.iter().position(|e| e == &self.editor).unwrap_or(0);
}
#[allow(dead_code)]
pub fn notify(&mut self, msg: impl Into<String>) {
self.snackbar = Some(Snackbar::info(msg));
}
fn reload_and_notify(&mut self, path: &std::path::Path, verb: &str) {
for p in self.panes.iter_mut() {
p.reload();
}
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
self.notify(format!("{verb} '{name}'"));
}
pub fn notify_error(&mut self, msg: impl Into<String>) {
self.snackbar = Some(Snackbar::error(msg));
}
pub fn theme(&self) -> &Theme {
&self.themes[self.theme_idx].2
}
pub fn theme_name(&self) -> &str {
self.themes[self.theme_idx].0
}
pub fn theme_desc(&self) -> &str {
self.themes[self.theme_idx].1
}
pub fn next_theme(&mut self) {
self.theme_idx = (self.theme_idx + 1) % self.themes.len();
}
pub fn prev_theme(&mut self) {
self.theme_idx = self
.theme_idx
.checked_sub(1)
.unwrap_or(self.themes.len() - 1);
}
}
mod api;
mod clipboard;
mod keys;
mod operation;
mod pane;
pub use api::{
AppBuildError, AppBuilder, AppCommand, AppEvent, AppOutcome, KeyBinding, KeyBindings,
PaneOptions,
};
pub use operation::{
execute_operation, execute_operation_with, execute_operation_with_progress, FileOperation,
OperationApplyError, OperationFailure, OperationId, OperationMode, OperationProgress,
OperationRequest, OperationResult,
};
#[cfg(test)]
#[path = "tests.rs"]
mod tests;