use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEvent};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use crate::config::Config;
use crate::error::Result;
use crate::finder::{Finder, FinderMatch, FinderWalk};
use crate::mode::normal::NormalState;
use crate::mode::{Action, InputKind, Mode};
use crate::pane::operations;
use crate::pane::{PaneId, PaneState, SortBy, SortDirection};
use crate::platform;
use crate::preview::{self, PreviewState};
use crate::ui;
use crate::ui::dialog::{ConfirmAction, Dialog};
pub struct App {
pub left_pane: PaneState,
pub right_pane: PaneState,
pub active_pane: PaneId,
pub mode: Mode,
pub input_buffer: String,
pub input_kind: Option<InputKind>,
pub show_help: bool,
pub dialog: Option<Dialog>,
pub should_quit: bool,
pub normal_state: NormalState,
pub finder_results: Vec<FinderMatch>,
pub finder_cursor: usize,
pub preview_active: bool,
pub preview_focused: bool,
pub preview_state: PreviewState,
pub config: Config,
finder: Finder,
finder_walk: Option<FinderWalk>,
finder_seen: (usize, bool),
page_size: usize,
pending_external: Option<PendingExternal>,
}
enum PendingExternal {
View(PathBuf),
Edit(PathBuf),
}
impl App {
pub fn new(left_path: PathBuf, right_path: PathBuf) -> Self {
Self::new_with_config(left_path, right_path, Config::default())
}
pub fn new_with_config(left_path: PathBuf, right_path: PathBuf, config: Config) -> Self {
let mut left_pane = PaneState::new(PaneId::Left, left_path);
let mut right_pane = PaneState::new(PaneId::Right, right_path);
Self::apply_general(&mut left_pane, &config);
Self::apply_general(&mut right_pane, &config);
App {
left_pane,
right_pane,
active_pane: PaneId::Left,
mode: Mode::Normal,
input_buffer: String::new(),
input_kind: None,
show_help: false,
dialog: None,
should_quit: false,
normal_state: NormalState::default(),
finder_results: Vec::new(),
finder_cursor: 0,
preview_active: false,
preview_focused: false,
preview_state: PreviewState::default(),
config,
finder: Finder::new(),
finder_walk: None,
finder_seen: (0, false),
page_size: 20,
pending_external: None,
}
}
fn apply_general(pane: &mut PaneState, config: &Config) {
pane.show_hidden = config.general.show_hidden;
if let Some(sort_by) = SortBy::parse(&config.general.sort_by) {
pane.sort_by = sort_by;
}
pane.sort_dir = if config.general.sort_ascending {
SortDirection::Ascending
} else {
SortDirection::Descending
};
let _ = pane.refresh();
}
fn goto_bookmark(&mut self, index: usize) {
let Some(path) = self.config.bookmarks.get(index).cloned() else {
return;
};
if let Err(e) = self.active_pane_mut().goto(path.clone()) {
self.dialog = Some(Dialog::Error {
message: format!(
"Bookmark {} is not an accessible directory: {} ({e})",
index + 1,
path.display()
),
});
}
}
fn surface_nav(&mut self, result: Result<()>) {
if let Err(e) = result {
self.nav_error(e);
}
}
fn nav_error(&mut self, e: crate::error::NcError) {
self.dialog = Some(Dialog::Error {
message: format!("Cannot change directory: {e}"),
});
}
pub fn active_pane_state(&self) -> &PaneState {
match self.active_pane {
PaneId::Left => &self.left_pane,
PaneId::Right => &self.right_pane,
}
}
fn active_pane_mut(&mut self) -> &mut PaneState {
match self.active_pane {
PaneId::Left => &mut self.left_pane,
PaneId::Right => &mut self.right_pane,
}
}
fn other_pane(&self) -> &PaneState {
match self.active_pane {
PaneId::Left => &self.right_pane,
PaneId::Right => &self.left_pane,
}
}
fn other_pane_cwd(&self) -> PathBuf {
self.other_pane().cwd.clone()
}
pub fn run(&mut self) -> Result<()> {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
default_hook(info);
}));
enable_raw_mode().map_err(crate::error::NcError::Io)?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(crate::error::NcError::Io)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(crate::error::NcError::Io)?;
terminal.clear().map_err(crate::error::NcError::Io)?;
let result = self.event_loop(&mut terminal);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result
}
fn event_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
let mut dirty = true;
loop {
if dirty {
let area = terminal.size().map_err(crate::error::NcError::Io)?;
self.page_size = area.height.saturating_sub(4) as usize;
let ps = self.page_size;
self.left_pane.adjust_scroll(ps);
self.right_pane.adjust_scroll(ps);
terminal
.draw(|f| ui::draw(f, self))
.map_err(crate::error::NcError::Io)?;
dirty = false;
}
if self.should_quit {
return Ok(());
}
if event::poll(Duration::from_millis(50)).map_err(crate::error::NcError::Io)? {
match event::read().map_err(crate::error::NcError::Io)? {
Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => {
self.handle_key(key);
dirty = true;
}
Event::Resize(..) => dirty = true,
_ => {}
}
}
if self.refresh_finder_progress() {
dirty = true;
}
if let Some(pending) = self.pending_external.take() {
self.run_external(terminal, pending)?;
dirty = true;
}
}
}
fn run_external(
&mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
pending: PendingExternal,
) -> Result<()> {
match pending {
PendingExternal::View(path) => {
let _ = crate::viewer::view_file(&path);
}
PendingExternal::Edit(path) => {
disable_raw_mode().ok();
execute!(io::stdout(), LeaveAlternateScreen).ok();
if let Some((program, args)) = self.editor_invocation() {
let _ = Command::new(program).args(args).arg(&path).status();
}
}
}
enable_raw_mode().map_err(crate::error::NcError::Io)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(crate::error::NcError::Io)?;
terminal.clear().map_err(crate::error::NcError::Io)?;
self.after_external_return();
Ok(())
}
fn after_external_return(&mut self) {
self.refresh_pane(self.active_pane); }
fn handle_key(&mut self, key: KeyEvent) {
if let Some(dialog) = self.dialog.take() {
self.handle_dialog_key(key, dialog);
return;
}
if self.show_help {
if key.code == crossterm::event::KeyCode::Esc
|| key.code == crossterm::event::KeyCode::Char('?')
{
self.show_help = false;
}
return;
}
let action = match self.mode {
Mode::Normal => crate::mode::normal::handle_key(key, &mut self.normal_state),
Mode::Select => crate::mode::select::handle_key(key),
Mode::Space => crate::mode::space::handle_key(key),
Mode::Goto => crate::mode::goto::handle_key(key),
Mode::Command => crate::mode::command::handle_key(key),
Mode::Input(_) => crate::mode::input::handle_key(key),
Mode::Finder => self.handle_finder_key(key),
};
self.dispatch_action(action);
}
fn handle_dialog_key(&mut self, key: KeyEvent, dialog: Dialog) {
if let Dialog::Confirm { action, .. } = dialog
&& matches!(
key.code,
crossterm::event::KeyCode::Char('y') | crossterm::event::KeyCode::Char('Y')
)
{
self.execute_confirm(action);
}
}
fn execute_confirm(&mut self, action: ConfirmAction) {
match action {
ConfirmAction::Delete { paths } => self.execute_delete(&paths),
ConfirmAction::OverwriteCopy { sources, target } => {
self.execute_copy(&sources, &target);
}
ConfirmAction::OverwriteMove { sources, target } => {
self.execute_move(&sources, &target);
}
ConfirmAction::OverwriteRename { source, new_name } => {
self.execute_rename(&source, &new_name);
}
}
}
fn dispatch_action(&mut self, action: Action) {
if self.preview_active && self.preview_focused {
match action {
Action::CursorUp => {
self.preview_state.scroll_up(1);
return;
}
Action::CursorDown => {
self.preview_state.scroll_down(1, self.page_size);
return;
}
Action::HalfPageUp => {
let half = self.page_size / 2;
self.preview_state.scroll_up(half);
return;
}
Action::HalfPageDown => {
let half = self.page_size / 2;
self.preview_state.scroll_down(half, self.page_size);
return;
}
Action::PageUp => {
let ps = self.page_size;
self.preview_state.scroll_up(ps);
return;
}
Action::PageDown => {
let ps = self.page_size;
self.preview_state.scroll_down(ps, self.page_size);
return;
}
Action::CursorTop => {
self.preview_state.scroll_to_top();
return;
}
Action::CursorBottom => {
self.preview_state.scroll_to_bottom(self.page_size);
return;
}
Action::ExitToNormal => {
self.preview_focused = false;
self.mode = Mode::Normal;
self.normal_state = NormalState::default();
return;
}
Action::SwitchPane | Action::FocusLeftPane | Action::FocusRightPane => {}
Action::Quit | Action::TogglePreview => {}
_ => return,
}
}
match action {
Action::None => {}
Action::Quit => self.should_quit = true,
Action::CursorUp => self.active_pane_mut().cursor_up(),
Action::CursorDown => self.active_pane_mut().cursor_down(),
Action::CursorTop => self.active_pane_mut().cursor_top(),
Action::CursorBottom => self.active_pane_mut().cursor_bottom(),
Action::PageUp => {
let ps = self.page_size;
self.active_pane_mut().page_up(ps);
}
Action::PageDown => {
let ps = self.page_size;
self.active_pane_mut().page_down(ps);
}
Action::HalfPageUp => {
let ps = self.page_size;
self.active_pane_mut().half_page_up(ps);
}
Action::HalfPageDown => {
let ps = self.page_size;
self.active_pane_mut().half_page_down(ps);
}
Action::EnterDir => match self.active_pane_mut().enter() {
Ok(Some(file_path)) => self.open_file(&file_path),
Ok(None) => {}
Err(e) => self.nav_error(e),
},
Action::ParentDir => {
let result = self.active_pane_mut().go_parent();
self.surface_nav(result);
}
Action::SwitchPane | Action::FocusLeftPane | Action::FocusRightPane => {
if self.preview_active {
self.preview_focused = !self.preview_focused;
} else {
match action {
Action::SwitchPane => self.active_pane = self.active_pane.other(),
Action::FocusLeftPane => self.active_pane = PaneId::Left,
Action::FocusRightPane => self.active_pane = PaneId::Right,
_ => unreachable!(),
}
}
}
Action::EnterSelect => self.mode = Mode::Select,
Action::EnterSpace => self.mode = Mode::Space,
Action::EnterGoto => self.mode = Mode::Goto,
Action::EnterCommand => {
self.mode = Mode::Command;
self.input_buffer.clear();
}
Action::EnterInput(kind) => {
self.mode = Mode::Input(kind);
self.input_kind = Some(kind);
self.input_buffer.clear();
if kind == InputKind::Rename
&& let Some(entry) = self.active_pane_state().current_entry()
{
self.input_buffer = entry.name.clone();
}
}
Action::ExitToNormal => {
self.mode = Mode::Normal;
self.normal_state = NormalState::default();
}
Action::ToggleSelect => self.active_pane_mut().toggle_select(),
Action::SelectAll => self.active_pane_mut().select_all(),
Action::InvertSelection => self.active_pane_mut().invert_selection(),
Action::SelectExtendDown => self.active_pane_mut().select_extend_down(),
Action::SelectExtendUp => self.active_pane_mut().select_extend_up(),
Action::DeselectAll => self.active_pane_mut().deselect_all(),
Action::CopyToOther => self.do_copy(),
Action::MoveToOther => self.do_move(),
Action::DeleteSelected => self.confirm_delete_dialog(),
Action::Rename(ref new_name) => self.do_rename(new_name),
Action::Mkdir(ref name) => self.do_mkdir(name),
Action::EditFile => self.edit_file(),
Action::GotoHome => {
if let Some(home) = dirs::home_dir() {
let result = self.active_pane_mut().goto(home);
self.surface_nav(result);
}
}
Action::GotoRoot => {
let result = self.active_pane_mut().goto(PathBuf::from("/"));
self.surface_nav(result);
}
Action::GotoOtherPane => {
let other_cwd = self.other_pane_cwd();
let result = self.active_pane_mut().goto(other_cwd);
self.surface_nav(result);
}
Action::GotoPrevious => {
let prev = self.active_pane_state().prev_dir.clone();
if let Some(prev) = prev {
let result = self.active_pane_mut().goto(prev);
self.surface_nav(result);
}
}
Action::GotoBookmark(index) => self.goto_bookmark(index),
Action::ExecuteCommand(ref cmd) => self.execute_command_str(cmd),
Action::ToggleHidden => {
let id = self.active_pane;
let pane = self.active_pane_mut();
pane.show_hidden = !pane.show_hidden;
self.refresh_pane(id);
}
Action::SetSort(sort_by) => {
let id = self.active_pane;
self.active_pane_mut().sort_by = sort_by;
self.refresh_pane(id);
}
Action::SetFilter(ref filter) => {
let id = self.active_pane;
self.active_pane_mut().filter = filter.clone();
self.refresh_pane(id);
}
Action::ShowHelp => self.show_help = !self.show_help,
Action::ShowFileInfo => self.show_file_info(),
Action::InputChar(c) => self.input_buffer.push(c),
Action::InputBackspace => {
self.input_buffer.pop();
}
Action::InputConfirm => self.handle_input_confirm(),
Action::InputCancel => {
self.input_buffer.clear();
self.input_kind = None;
self.mode = Mode::Normal;
}
Action::EnterFinder => self.enter_finder(),
Action::TogglePreview => self.toggle_preview(),
Action::ViewFile => {
if let Some(entry) = self.active_pane_state().current_entry().cloned()
&& !entry.is_dir
{
self.pending_external = Some(PendingExternal::View(entry.path));
}
}
}
if matches!(self.mode, Mode::Space | Mode::Goto)
&& !matches!(
action,
Action::None
| Action::EnterSpace
| Action::EnterGoto
| Action::EnterInput(_)
| Action::EnterFinder
)
{
self.mode = Mode::Normal;
}
self.update_preview();
}
fn handle_input_confirm(&mut self) {
let buf = self.input_buffer.clone();
let kind = self.input_kind;
self.input_buffer.clear();
self.input_kind = None;
self.mode = Mode::Normal;
match kind {
Some(InputKind::Rename) => self.do_rename(&buf),
Some(InputKind::Mkdir) => self.do_mkdir(&buf),
Some(InputKind::Search) => {
let id = self.active_pane;
self.active_pane_mut().filter = if buf.is_empty() { None } else { Some(buf) };
self.refresh_pane(id);
}
Some(InputKind::GlobSelect) => {
self.active_pane_mut().select_by_glob(&buf);
}
Some(InputKind::CommandLine) | None => {
let action = crate::mode::command::execute_command(&buf);
self.dispatch_action(action);
}
}
}
fn refresh_pane(&mut self, id: PaneId) {
self.refresh_pane_inner(id);
self.invalidate_preview();
}
fn refresh_pane_inner(&mut self, id: PaneId) {
let result = match id {
PaneId::Left => self.left_pane.refresh(),
PaneId::Right => self.right_pane.refresh(),
};
if let Err(e) = result {
self.dialog = Some(Dialog::Error {
message: format!("Failed to refresh directory: {e}"),
});
}
}
fn refresh_both(&mut self) {
self.refresh_pane_inner(PaneId::Left);
self.refresh_pane_inner(PaneId::Right);
self.invalidate_preview();
}
fn collision_message(paths: &[PathBuf], target: &Path) -> Option<String> {
let collisions: Vec<_> = paths
.iter()
.filter_map(|p| p.file_name())
.filter(|name| target.join(name).symlink_metadata().is_ok())
.map(|name| name.to_string_lossy().to_string())
.collect();
match collisions.as_slice() {
[] => None,
[one] => Some(format!("Overwrite {one}?")),
many => Some(format!("Overwrite {} items?", many.len())),
}
}
fn run_batch(&mut self, paths: &[PathBuf], mut op: impl FnMut(&Path) -> Result<()>) {
let mut errors = Vec::new();
for path in paths {
if let Err(e) = op(path) {
errors.push(format!("{}: {e}", path.display()));
}
}
self.refresh_both();
if !errors.is_empty() {
self.dialog = Some(Dialog::Error {
message: errors.join("\n"),
});
}
}
fn do_copy(&mut self) {
let target = self.other_pane_cwd();
let paths = self.active_pane_state().selected_paths();
if paths.is_empty() {
return;
}
match Self::collision_message(&paths, &target) {
None => self.execute_copy(&paths, &target),
Some(message) => {
self.dialog = Some(Dialog::Confirm {
title: "Confirm Copy".into(),
message,
action: ConfirmAction::OverwriteCopy {
sources: paths,
target,
},
})
}
}
}
fn execute_copy(&mut self, paths: &[PathBuf], target: &Path) {
if let Some(msg) = self.check_disk_space(paths, target) {
self.dialog = Some(Dialog::Error { message: msg });
return;
}
self.run_batch(paths, |path| operations::copy_to(path, target));
}
fn do_move(&mut self) {
let target = self.other_pane_cwd();
let paths = self.active_pane_state().selected_paths();
if paths.is_empty() {
return;
}
match Self::collision_message(&paths, &target) {
None => self.execute_move(&paths, &target),
Some(message) => {
self.dialog = Some(Dialog::Confirm {
title: "Confirm Move".into(),
message,
action: ConfirmAction::OverwriteMove {
sources: paths,
target,
},
})
}
}
}
fn execute_move(&mut self, paths: &[PathBuf], target: &Path) {
use std::os::unix::fs::MetadataExt;
if let Ok(target_dev) = std::fs::metadata(target).map(|m| m.dev()) {
let cross: Vec<PathBuf> = paths
.iter()
.filter(|p| {
std::fs::symlink_metadata(p)
.map(|m| m.dev() != target_dev)
.unwrap_or(false) })
.cloned()
.collect();
if !cross.is_empty()
&& let Some(msg) = self.check_disk_space(&cross, target)
{
self.dialog = Some(Dialog::Error { message: msg });
return;
}
}
self.run_batch(paths, |path| operations::move_to(path, target));
}
fn confirm_delete_dialog(&mut self) {
let paths = self.active_pane_state().selected_paths();
if paths.is_empty() {
return;
}
let count = paths.len();
let message = if count == 1 {
format!("Delete {}?", paths[0].display())
} else {
format!("Delete {count} items?")
};
self.dialog = Some(Dialog::Confirm {
title: "Confirm Delete".into(),
message,
action: ConfirmAction::Delete { paths },
});
}
fn execute_delete(&mut self, paths: &[PathBuf]) {
self.run_batch(paths, operations::delete);
}
fn do_rename(&mut self, new_name: &str) {
if new_name.is_empty() {
return;
}
let Some(entry) = self.active_pane_state().current_entry().cloned() else {
return;
};
let Some(parent) = entry.path.parent() else {
return;
};
if parent.join(new_name).symlink_metadata().is_ok() {
self.dialog = Some(Dialog::Confirm {
title: "Confirm Rename".into(),
message: format!("Overwrite {new_name}?"),
action: ConfirmAction::OverwriteRename {
source: entry.path.clone(),
new_name: new_name.to_string(),
},
});
} else {
self.execute_rename(&entry.path, new_name);
}
}
fn execute_rename(&mut self, source: &Path, new_name: &str) {
if let Err(e) = operations::rename(source, new_name) {
self.dialog = Some(Dialog::Error {
message: format!("Rename failed: {e}"),
});
} else {
self.refresh_pane(self.active_pane);
}
}
fn check_disk_space(&self, sources: &[PathBuf], target: &Path) -> Option<String> {
let free = platform::get_free_disk_space(target)?;
let mut total_size = 0u64;
for source in sources {
if let Ok(size) = operations::path_size(source) {
total_size = total_size.saturating_add(size);
}
}
if total_size > free {
Some(format!(
"Not enough disk space. Need {} but only {} available.",
platform::format_file_size(total_size),
platform::format_file_size(free),
))
} else {
None
}
}
fn do_mkdir(&mut self, name: &str) {
if name.is_empty() {
return;
}
let cwd = self.active_pane_state().cwd.clone();
if let Err(e) = operations::mkdir(&cwd, name) {
self.dialog = Some(Dialog::Error {
message: format!("Mkdir failed: {e}"),
});
} else {
self.refresh_pane(self.active_pane);
}
}
fn editor_invocation(&self) -> Option<(String, Vec<String>)> {
let raw = self
.config
.general
.editor
.clone()
.unwrap_or_else(platform::get_default_editor);
let mut parts = raw.split_whitespace().map(str::to_string);
let program = parts.next()?;
Some((program, parts.collect()))
}
fn edit_file(&mut self) {
if let Some(entry) = self.active_pane_state().current_entry().cloned()
&& !entry.is_dir
{
self.pending_external = Some(PendingExternal::Edit(entry.path));
}
}
fn open_file(&mut self, path: &Path) {
self.pending_external = Some(PendingExternal::Edit(path.to_path_buf()));
}
fn show_file_info(&mut self) {
if let Some(entry) = self.active_pane_state().current_entry() {
let info = format!(
"Name: {}\nPath: {}\nSize: {}\nPermissions: {}\nModified: {}",
entry.name,
entry.path.display(),
platform::format_file_size(entry.size),
entry.permissions,
entry
.modified
.map(platform::format_file_time)
.unwrap_or_else(|| "unknown".into()),
);
self.dialog = Some(Dialog::Info {
title: "File Info".into(),
message: info,
});
}
}
fn execute_command_str(&mut self, cmd: &str) {
if let Some(path_str) = cmd.strip_prefix("cd ") {
let path = PathBuf::from(path_str.trim());
let path = if path.is_absolute() {
path
} else {
self.active_pane_state().cwd.join(path)
};
let result = self.active_pane_mut().goto(path);
self.surface_nav(result);
}
}
fn handle_finder_key(&mut self, key: KeyEvent) -> Action {
match key.code {
crossterm::event::KeyCode::Esc => {
self.finder_results.clear();
self.finder_cursor = 0;
self.finder_walk = None; Action::ExitToNormal
}
crossterm::event::KeyCode::Enter => {
if let Some(result) = self.finder_results.get(self.finder_cursor) {
let path = result.path.clone();
self.finder_results.clear();
self.finder_cursor = 0;
self.finder_walk = None; self.mode = Mode::Normal;
if path.is_dir() {
let result = self.active_pane_mut().goto(path);
self.surface_nav(result);
} else if let Some(parent) = path.parent() {
let parent = parent.to_path_buf();
let name = path.file_name().map(|n| n.to_string_lossy().to_string());
let result = self.active_pane_mut().goto(parent);
self.surface_nav(result);
if let Some(name) = name
&& let Some(pos) = self
.active_pane_state()
.entries
.iter()
.position(|e| e.name == name)
{
self.active_pane_mut().cursor = pos;
}
}
}
Action::None
}
crossterm::event::KeyCode::Up => {
if self.finder_cursor > 0 {
self.finder_cursor -= 1;
}
Action::None
}
crossterm::event::KeyCode::Down => {
if !self.finder_results.is_empty()
&& self.finder_cursor < self.finder_results.len() - 1
{
self.finder_cursor += 1;
}
Action::None
}
crossterm::event::KeyCode::Backspace => {
self.input_buffer.pop();
self.update_finder_results();
Action::None
}
crossterm::event::KeyCode::Char(c) if crate::mode::accepts_text(&key) => {
self.input_buffer.push(c);
self.update_finder_results();
Action::None
}
_ => Action::None,
}
}
fn toggle_preview(&mut self) {
self.preview_active = !self.preview_active;
if self.preview_active {
self.update_preview();
} else {
self.preview_focused = false;
self.preview_state.clear();
}
}
fn update_preview(&mut self) {
if !self.preview_active {
return;
}
let Some(entry) = self.active_pane_state().current_entry().cloned() else {
return;
};
if self.preview_state.path.as_ref() == Some(&entry.path) {
return;
}
if entry.is_dir {
let show_hidden = self.active_pane_state().show_hidden;
self.preview_state = preview::load_dir_preview(&entry.path, show_hidden);
} else {
self.preview_state = preview::load_preview(&entry.path);
}
}
fn invalidate_preview(&mut self) {
self.preview_state.path = None;
self.update_preview();
}
fn enter_finder(&mut self) {
self.mode = Mode::Finder;
self.input_buffer.clear();
self.finder_results.clear();
self.finder_cursor = 0;
self.finder_walk = Some(FinderWalk::spawn(self.active_pane_state().cwd.clone()));
self.finder_seen = (0, false);
}
fn update_finder_results(&mut self) {
let Some(walk) = &self.finder_walk else {
self.finder_results.clear();
self.finder_cursor = 0;
return;
};
self.finder_seen = (walk.count(), walk.is_done());
let finder = &mut self.finder;
let query = &self.input_buffer;
self.finder_results = walk.with_paths(|items| finder.find_in(items, query, 20));
self.finder_cursor = 0;
}
fn refresh_finder_progress(&mut self) -> bool {
const RESCORE_BATCH: usize = 512;
if self.mode != Mode::Finder || self.input_buffer.is_empty() {
return false;
}
let Some(walk) = &self.finder_walk else {
return false;
};
let progress = (walk.count(), walk.is_done());
if progress == self.finder_seen {
return false;
}
let done_changed = progress.1 != self.finder_seen.1;
if !done_changed && progress.0.saturating_sub(self.finder_seen.0) < RESCORE_BATCH {
return false;
}
let cursor = self.finder_cursor;
self.update_finder_results();
self.finder_cursor = cursor.min(self.finder_results.len().saturating_sub(1));
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, GeneralConfig};
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let tmp = std::env::temp_dir().join(format!("ncoxide_app_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
tmp
}
#[test]
fn test_new_with_config_seeds_panes() {
let dir = temp_dir("seed");
fs::write(dir.join(".hidden"), "").unwrap();
fs::write(dir.join("visible.txt"), "").unwrap();
let config = Config {
general: GeneralConfig {
show_hidden: true,
sort_by: "size".into(),
sort_ascending: false,
..Default::default()
},
..Default::default()
};
let app = App::new_with_config(dir.clone(), dir.clone(), config);
assert!(app.left_pane.show_hidden);
assert_eq!(app.left_pane.sort_by, SortBy::Size);
assert_eq!(app.left_pane.sort_dir, SortDirection::Descending);
assert!(app.left_pane.entries.iter().any(|e| e.name == ".hidden"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_editor_invocation_splits_program_and_args() {
let dir = temp_dir("editor");
let mut config = Config::default();
config.general.editor = Some("code -w --reuse-window".into());
let app = App::new_with_config(dir.clone(), dir.clone(), config);
assert_eq!(
app.editor_invocation(),
Some((
"code".to_string(),
vec!["-w".to_string(), "--reuse-window".to_string()]
))
);
let mut config = Config::default();
config.general.editor = Some("vi".into());
let app = App::new_with_config(dir.clone(), dir.clone(), config);
assert_eq!(app.editor_invocation(), Some(("vi".to_string(), vec![])));
let mut config = Config::default();
config.general.editor = Some(" ".into());
let app = App::new_with_config(dir.clone(), dir.clone(), config);
assert_eq!(app.editor_invocation(), None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_goto_bookmark_navigates_and_reports_missing() {
let root = temp_dir("bookmark");
let target = root.join("target");
fs::create_dir_all(&target).unwrap();
let config = Config {
bookmarks: vec![target.clone(), root.join("does_not_exist")],
..Default::default()
};
let mut app = App::new_with_config(root.clone(), root.clone(), config);
app.goto_bookmark(0);
assert_eq!(app.active_pane_state().cwd, target);
assert!(app.dialog.is_none());
app.goto_bookmark(1);
assert!(matches!(app.dialog, Some(Dialog::Error { .. })));
app.dialog = None;
app.goto_bookmark(99);
assert!(app.dialog.is_none());
let _ = fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod integration {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::fs;
fn app_with(name: &str, files: &[&str]) -> (PathBuf, App) {
let dir = std::env::temp_dir().join(format!("ncoxide_it_{}_{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for f in files {
fs::write(dir.join(f), b"contents\n").unwrap();
}
let app = App::new(dir.clone(), dir.clone());
(dir, app)
}
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn code(c: KeyCode) -> KeyEvent {
KeyEvent::new(c, KeyModifiers::NONE)
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key(key(c));
}
}
fn preview_text(app: &App) -> String {
app.preview_state
.render(30)
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect()
}
fn render(app: &App, w: u16, h: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| crate::ui::draw(f, app)).unwrap();
let buf = terminal.backend().buffer().clone();
let area = *buf.area();
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn test_copy_to_same_dir_refused_and_file_intact() {
let (dir, mut app) = app_with("selfcopy", &["data.txt"]);
fs::write(dir.join("data.txt"), b"important").unwrap();
app.handle_key(key('y'));
assert!(
matches!(app.dialog, Some(Dialog::Confirm { .. })),
"same-name copy should raise the overwrite confirm"
);
app.handle_key(key('y'));
assert!(
matches!(app.dialog, Some(Dialog::Error { .. })),
"confirmed self-copy must surface an error, not run"
);
assert_eq!(
fs::read_to_string(dir.join("data.txt")).unwrap(),
"important"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_ctrl_chords_do_not_type_into_inputs() {
let (dir, mut app) = app_with("ctrl_input", &["file.txt"]);
app.handle_key(key('r')); assert_eq!(app.input_buffer, "file.txt");
app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(app.input_buffer, "file.txt", "Ctrl-C must not type");
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT));
assert_eq!(app.input_buffer, "file.txt", "Alt-x must not type");
app.handle_key(key('2'));
assert_eq!(app.input_buffer, "file.txt2", "plain chars still type");
app.handle_key(code(KeyCode::Esc));
app.mode = Mode::Finder;
app.input_buffer.clear();
app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(app.input_buffer, "", "Ctrl-C must not type in finder");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_selection_persists_after_esc_and_semicolon_clears() {
let (dir, mut app) = app_with("sel_helix", &["a.txt", "b.txt", "c.txt"]);
app.handle_key(key('v'));
app.handle_key(key('a'));
assert_eq!(app.left_pane.selection_count(), 3);
app.handle_key(code(KeyCode::Esc));
assert_eq!(app.mode, Mode::Normal);
assert_eq!(
app.left_pane.selection_count(),
3,
"Esc must keep the selection (Helix semantics)"
);
app.handle_key(key(';'));
assert_eq!(app.left_pane.selection_count(), 0, "';' clears");
app.handle_key(key('v'));
app.handle_key(key('a'));
assert_eq!(app.left_pane.selection_count(), 3);
app.handle_key(key(';'));
assert_eq!(app.left_pane.selection_count(), 0);
assert_eq!(app.mode, Mode::Select, "';' does not leave Select mode");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_enter_unreadable_dir_surfaces_error_and_stays() {
use std::os::unix::fs::PermissionsExt;
let (dir, mut app) = app_with("unreadable", &[]);
let locked = dir.join("locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
let _ = app.left_pane.refresh();
app.handle_key(key('l')); assert!(matches!(app.dialog, Some(Dialog::Error { .. })));
assert_eq!(app.left_pane.cwd, dir);
assert!(app.left_pane.entries.iter().any(|e| e.name == "locked"));
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_preview_reloads_after_external_return() {
let (dir, mut app) = app_with("ext_preview", &["f.txt"]);
fs::write(dir.join("f.txt"), b"old content").unwrap();
let _ = app.left_pane.refresh();
app.handle_key(key('p')); assert!(preview_text(&app).contains("old content"));
fs::write(dir.join("f.txt"), b"new content").unwrap();
app.after_external_return();
assert!(
preview_text(&app).contains("new content"),
"preview must re-read the file after an external program returns"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_move_via_space_menu_between_panes() {
let (dir, mut app) = app_with("move_sfs", &["moveme.txt"]);
let dst = dir.join("dst");
fs::create_dir_all(&dst).unwrap();
let _ = app.left_pane.refresh();
app.right_pane.goto(dst.clone()).unwrap();
let idx = app
.left_pane
.entries
.iter()
.position(|e| e.name == "moveme.txt")
.unwrap();
app.left_pane.cursor = idx;
app.handle_key(key(' '));
app.handle_key(key('m'));
assert!(app.dialog.is_none(), "move should not raise a dialog");
assert!(!dir.join("moveme.txt").exists());
assert!(dst.join("moveme.txt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_navigation_moves_cursor_and_renders() {
let (dir, mut app) = app_with("nav", &["a.txt", "b.txt", "c.txt"]);
assert_eq!(app.left_pane.cursor, 0);
app.handle_key(key('j'));
assert_eq!(app.left_pane.cursor, 1);
app.handle_key(key('j'));
assert_eq!(app.left_pane.cursor, 2);
app.handle_key(key('k'));
assert_eq!(app.left_pane.cursor, 1);
let screen = render(&app, 80, 24);
assert!(screen.contains("a.txt"));
assert!(screen.contains("c.txt"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_enter_and_parent_directory() {
let (dir, mut app) = app_with("enter", &[]);
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub").join("inner.txt"), b"x").unwrap();
let _ = app.left_pane.refresh();
app.handle_key(key('l'));
assert!(app.left_pane.cwd.ends_with("sub"));
assert!(app.left_pane.entries.iter().any(|e| e.name == "inner.txt"));
app.handle_key(key('h'));
assert_eq!(app.left_pane.cwd, dir);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_tab_switches_pane() {
let (dir, mut app) = app_with("tab", &["a.txt"]);
assert_eq!(app.active_pane, PaneId::Left);
app.handle_key(code(KeyCode::Tab));
assert_eq!(app.active_pane, PaneId::Right);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_select_all_shows_count_in_status() {
let (dir, mut app) = app_with("select", &["a.txt", "b.txt", "c.txt"]);
app.handle_key(key('v')); app.handle_key(key('a')); assert_eq!(app.left_pane.selection_count(), 3);
let screen = render(&app, 200, 24);
assert!(
screen.contains("selected"),
"status should show selection count"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_confirm_then_accept_removes_file() {
let (dir, mut app) = app_with("del_accept", &["target.txt", "keep.txt"]);
let idx = app
.left_pane
.entries
.iter()
.position(|e| e.name == "target.txt")
.unwrap();
app.left_pane.cursor = idx;
app.handle_key(key('d'));
assert!(app.dialog.is_some(), "delete should raise a confirm dialog");
let screen = render(&app, 80, 24);
assert!(screen.contains("Delete"));
assert!(screen.contains("[y]es"));
app.handle_key(key('y'));
assert!(app.dialog.is_none());
assert!(!dir.join("target.txt").exists());
assert!(dir.join("keep.txt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_dismissed_keeps_file() {
let (dir, mut app) = app_with("del_dismiss", &["target.txt"]);
app.handle_key(key('d'));
assert!(app.dialog.is_some());
app.handle_key(key('n')); assert!(app.dialog.is_none());
assert!(dir.join("target.txt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_mkdir_via_space_menu() {
let (dir, mut app) = app_with("mkdir", &["a.txt"]);
app.handle_key(key(' ')); app.handle_key(key('n')); type_str(&mut app, "created");
app.handle_key(code(KeyCode::Enter));
assert!(dir.join("created").is_dir());
assert_eq!(app.mode, Mode::Normal);
assert!(app.left_pane.entries.iter().any(|e| e.name == "created"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_help_overlay_shows_and_dismisses() {
let (dir, mut app) = app_with("help", &["a.txt"]);
app.handle_key(key('?'));
assert!(app.show_help);
let screen = render(&app, 80, 24);
assert!(screen.contains("Navigation"));
app.handle_key(code(KeyCode::Esc));
assert!(!app.show_help);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_dir_preview_shows_contents_and_refreshes_after_ops() {
let (dir, mut app) = app_with("dirprev", &[]);
let sub = dir.join("sub");
fs::create_dir_all(sub.join("inner_dir")).unwrap();
fs::write(sub.join("child.txt"), b"x").unwrap();
let _ = app.left_pane.refresh();
app.handle_key(key('p')); let text = preview_text(&app);
assert!(
text.contains("inner_dir/"),
"dirs listed with slash: {text}"
);
assert!(text.contains("child.txt"), "files listed: {text}");
fs::write(sub.join("added.txt"), b"y").unwrap();
app.handle_key(key(' '));
app.handle_key(key('n'));
type_str(&mut app, "zzz");
app.handle_key(code(KeyCode::Enter));
let text = preview_text(&app);
assert!(
text.contains("added.txt"),
"preview must re-read the dir after a file op: {text}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_finder_scores_background_walk_results() {
let (dir, mut app) = app_with("finder_bg", &["match_me.txt", "other.log"]);
app.handle_key(key(' '));
app.handle_key(key('f'));
assert_eq!(app.mode, Mode::Finder);
let walk = app.finder_walk.as_ref().expect("walk spawned on open");
for _ in 0..100_000 {
if walk.is_done() {
break;
}
std::thread::yield_now();
}
assert!(app.finder_walk.as_ref().unwrap().is_done());
type_str(&mut app, "match");
assert_eq!(app.finder_results.len(), 1);
assert!(app.finder_results[0].display_name.contains("match_me"));
app.handle_key(code(KeyCode::Esc));
assert!(app.finder_walk.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_pane_scrolls_to_keep_cursor_visible() {
let files: Vec<String> = (0..60).map(|i| format!("file{i:02}.txt")).collect();
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
let (dir, mut app) = app_with("scroll", &refs);
let screen = render(&app, 80, 24);
assert!(screen.contains("file00.txt"), "top of list visible");
assert!(!screen.contains("file59.txt"), "bottom not yet visible");
app.handle_key(key('G')); app.left_pane.adjust_scroll(20); let screen = render(&app, 80, 24);
let left_half: String = screen
.lines()
.map(|l| l.chars().take(40).collect::<String>() + "\n")
.collect();
assert!(
left_half.contains("file59.txt"),
"cursor row scrolled into view: {left_half}"
);
assert!(!left_half.contains("file00.txt"), "top scrolled out");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_dir_preview_refreshes_on_hidden_toggle() {
let (dir, mut app) = app_with("dirprev_hidden", &[]);
let sub = dir.join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join(".dotfile"), b"x").unwrap();
fs::write(sub.join("plain.txt"), b"y").unwrap();
let _ = app.left_pane.refresh();
app.handle_key(key('p')); assert!(!preview_text(&app).contains(".dotfile"));
app.handle_key(key('.'));
assert!(
preview_text(&app).contains(".dotfile"),
"dir preview must reload on hidden toggle: {}",
preview_text(&app)
);
app.handle_key(key('.'));
assert!(!preview_text(&app).contains(".dotfile"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_preview_toggle_renders_preview_pane() {
let (dir, mut app) = app_with("preview", &["a.txt"]);
app.handle_key(key('p'));
assert!(app.preview_active);
let screen = render(&app, 100, 24);
assert!(screen.contains("PREVIEW"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_command_mode_prompt_and_escape() {
let (dir, mut app) = app_with("command", &["a.txt"]);
app.handle_key(key(':'));
assert_eq!(app.mode, Mode::Command);
let screen = render(&app, 80, 24);
assert!(screen.contains("CMD"));
app.handle_key(code(KeyCode::Esc));
assert_eq!(app.mode, Mode::Normal);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_render_is_panic_free_at_extreme_sizes() {
let (dir, app) = app_with("empty", &[]); let _ = render(&app, 1, 1);
let _ = render(&app, 20, 3);
let _ = render(&app, 200, 60);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_overlays_render_panic_free_at_tiny_sizes() {
let (dir, mut app) = app_with("tiny_overlays", &["a.txt"]);
for (w, h) in [(1u16, 1u16), (3, 2), (6, 3), (20, 3), (39, 5), (80, 24)] {
app.show_help = true;
let _ = render(&app, w, h);
app.show_help = false;
app.dialog = Some(Dialog::Error {
message: "boom".into(),
});
let _ = render(&app, w, h);
app.dialog = None;
app.mode = Mode::Space;
let _ = render(&app, w, h);
app.mode = Mode::Finder;
app.input_buffer = "q".into();
let _ = render(&app, w, h);
app.mode = Mode::Normal;
app.input_buffer.clear();
}
let _ = fs::remove_dir_all(&dir);
}
}