use crate::config::{IconMode, Layout};
use crate::format::Format;
use crate::git::{self, GitStatus};
use crate::media::{self, ImagePane};
use crate::{fileop, highlight, icons, query, theme, typeahead};
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use image::DynamicImage;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout as RtLayout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget,
};
use ratatui::{DefaultTerminal, Frame};
use std::cmp::Ordering;
use std::fs;
use std::io::{self, Read};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
struct Entry {
name: String,
path: PathBuf,
kind: Format,
size: u64,
modified: Option<SystemTime>,
}
enum Mode {
Browse,
Filter,
Search,
Op(OpView),
Input(Prompt),
}
struct Prompt {
ask: Ask,
edit: crate::lineedit::LineEdit,
}
#[derive(Clone)]
enum Ask {
Rename { path: PathBuf },
Create { parent: PathBuf },
}
impl Ask {
fn prefix(&self) -> &'static str {
match self {
Ask::Rename { .. } => "rename: ",
Ask::Create { .. } => "new: ",
}
}
fn hint(&self) -> &'static str {
match self {
Ask::Rename { .. } => "[Enter] rename [Esc] cancel",
Ask::Create { .. } => "end with / for a folder [Enter] create [Esc] cancel",
}
}
}
enum OpView {
Confirm(Pending),
Report(fileop::Report),
}
struct Pending {
plan: fileop::Plan,
inputs: Option<Box<Replan>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Transfer {
Copy,
Move,
}
impl Transfer {
fn of(cut: bool) -> Self {
if cut {
Transfer::Move
} else {
Transfer::Copy
}
}
fn op(self, sources: Vec<fileop::Source>, dest: PathBuf) -> fileop::Op {
match self {
Transfer::Copy => fileop::Op::Copy { sources, dest },
Transfer::Move => fileop::Op::Move { sources, dest },
}
}
}
struct Replan {
transfer: Transfer,
sources: Vec<fileop::Source>,
dest: PathBuf,
dest_listing: Vec<String>,
missing: Vec<PathBuf>,
cwd: PathBuf,
}
impl Replan {
fn plan(&self, policy: fileop::Conflict) -> Result<fileop::Plan, fileop::Refusal> {
fileop::plan(
self.transfer.op(self.sources.clone(), self.dest.clone()),
&fileop::PlanCtx {
dest_listing: &self.dest_listing,
cwd: &self.cwd,
missing: &self.missing,
policy,
},
)
}
}
struct Clip {
cut: bool,
paths: Vec<PathBuf>,
}
struct InFlight {
label: String,
total: usize,
targets: Vec<PathBuf>,
landing: Option<String>,
items: usize,
bytes: u64,
current: PathBuf,
}
impl InFlight {
fn new(label: String, total: usize, targets: Vec<PathBuf>, landing: Option<String>) -> Self {
InFlight {
label,
total,
targets,
landing,
items: 0,
bytes: 0,
current: PathBuf::new(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MetaCol {
Size,
Modified,
None,
}
impl MetaCol {
fn toggle(self) -> Self {
match self {
MetaCol::Modified => MetaCol::Size,
MetaCol::Size | MetaCol::None => MetaCol::Modified,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SortKey {
Name,
Size,
Modified,
Ext,
}
impl SortKey {
fn cycle(self) -> Self {
match self {
SortKey::Name => SortKey::Size,
SortKey::Size => SortKey::Modified,
SortKey::Modified => SortKey::Ext,
SortKey::Ext => SortKey::Name,
}
}
fn label(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
SortKey::Ext => "ext",
}
}
}
#[derive(Clone, Copy)]
struct Sort {
key: SortKey,
reverse: bool,
}
impl Sort {
fn default() -> Self {
Sort {
key: SortKey::Name,
reverse: false,
}
}
fn label(self) -> String {
let arrow = if self.reverse { "↓" } else { "↑" };
format!("sort: {} {arrow}", self.key.label())
}
}
fn name_ext(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => ext,
_ => "",
}
}
fn cmp_name_ci(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().flat_map(char::to_lowercase);
let mut bi = b.chars().flat_map(char::to_lowercase);
loop {
match (ai.next(), bi.next()) {
(Some(x), Some(y)) => match x.cmp(&y) {
Ordering::Equal => continue,
ord => return ord,
},
(Some(_), None) => return Ordering::Greater,
(None, Some(_)) => return Ordering::Less,
(None, None) => return Ordering::Equal,
}
}
}
trait Sortable {
fn sort_name(&self) -> &str;
fn sort_is_dir(&self) -> bool;
fn sort_size(&self) -> u64;
fn sort_modified(&self) -> Option<SystemTime>;
}
impl Sortable for Entry {
fn sort_name(&self) -> &str {
&self.name
}
fn sort_is_dir(&self) -> bool {
self.kind == Format::Directory
}
fn sort_size(&self) -> u64 {
self.size
}
fn sort_modified(&self) -> Option<SystemTime> {
self.modified
}
}
impl Sortable for crate::search::Hit {
fn sort_name(&self) -> &str {
&self.rel
}
fn sort_is_dir(&self) -> bool {
self.kind == Format::Directory
}
fn sort_size(&self) -> u64 {
self.size
}
fn sort_modified(&self) -> Option<SystemTime> {
self.modified
}
}
fn sort_cmp<T: Sortable>(a: &T, b: &T, sort: Sort) -> Ordering {
let dirs_first = b.sort_is_dir().cmp(&a.sort_is_dir());
if dirs_first != Ordering::Equal {
return dirs_first; }
let by_name = || cmp_name_ci(a.sort_name(), b.sort_name());
let ord = match sort.key {
SortKey::Name => by_name(),
SortKey::Size => a.sort_size().cmp(&b.sort_size()).then_with(by_name),
SortKey::Modified => a.sort_modified().cmp(&b.sort_modified()).then_with(by_name),
SortKey::Ext => {
cmp_name_ci(name_ext(a.sort_name()), name_ext(b.sort_name())).then_with(by_name)
}
};
if sort.reverse {
ord.reverse()
} else {
ord
}
}
enum Pv {
Text, Loading, Image, }
enum Rastered {
Still(DynamicImage),
Animated(Vec<media::Frame>),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SlideDir {
FromRight,
FromLeft,
}
struct Slide {
anim: crate::anim::Anim,
dir: SlideDir,
old: Buffer,
frames: u32,
}
struct SearchState {
query: String,
engine: Option<crate::search::Search>,
results: Vec<crate::search::Hit>,
state: ListState,
done: bool,
capped: bool,
}
impl SearchState {
fn new() -> Self {
SearchState {
query: String::new(),
engine: None,
results: Vec::new(),
state: ListState::default(),
done: false,
capped: false,
}
}
fn selected(&self) -> Option<&crate::search::Hit> {
self.state.selected().and_then(|i| self.results.get(i))
}
fn move_sel(&mut self, delta: isize) {
let next = search_sel(self.state.selected(), delta, self.results.len());
self.state.select(next);
}
}
struct App {
cwd: PathBuf,
icons: IconMode,
layout: Layout,
git_enabled: bool,
git: Option<std::collections::HashMap<String, GitStatus>>,
head: Option<git::RepoHead>,
all: Vec<Entry>,
view: Vec<usize>, parent: Vec<Entry>,
state: ListState,
filter: String,
mode: Mode,
show_hidden: bool,
viewport_h: u16,
status: Option<String>,
typeahead: String,
typeahead_at: Option<Instant>,
preview: Vec<Line<'static>>,
preview_for: Option<PathBuf>,
pv: Pv,
caption: String,
pane: Option<ImagePane>,
img_cache: Vec<(PathBuf, DynamicImage)>,
raster_tx: Sender<(PathBuf, Option<Rastered>)>,
raster_rx: Receiver<(PathBuf, Option<Rastered>)>,
raster_pending: Option<PathBuf>, raster_want: Option<(PathBuf, Format)>, preview_animated: bool,
meta: MetaCol,
crumb_hits: Vec<(Range<u16>, PathBuf)>,
list_area: Rect,
parent_area: Option<Rect>,
spin: usize,
animate: bool,
fade: Option<crate::anim::Anim>,
fade_frames: u32,
slide: Option<Slide>,
search: Option<SearchState>,
search_area: Rect,
sort: Sort,
help: bool,
marks: crate::marks::Marks,
clip: Option<Clip>,
op: Option<fileop::Run>,
op_progress: Option<InFlight>,
journal: Vec<fileop::Journal>,
}
enum Action {
Quit,
Open(PathBuf),
}
enum CharAction {
Down,
Up,
HalfDown,
HalfUp,
Top,
Bottom,
Open,
OpenExternal,
Parent,
Filter,
Search,
ToggleHidden,
ToggleLayout,
ToggleMeta,
CycleSort,
ReverseSort,
Help,
ToggleMark,
InvertMarks,
Trash,
Yank,
Cut,
Paste,
Rename,
Create,
Undo,
YankPath,
Quit,
}
fn browse_char(c: char) -> Option<CharAction> {
Some(match c {
'j' => CharAction::Down,
'k' => CharAction::Up,
'd' => CharAction::HalfDown,
'u' => CharAction::HalfUp,
'g' => CharAction::Top,
'G' => CharAction::Bottom,
'l' => CharAction::Open,
'x' => CharAction::OpenExternal,
'h' => CharAction::Parent,
'/' => CharAction::Filter,
'S' => CharAction::Search,
'.' => CharAction::ToggleHidden,
'M' => CharAction::ToggleLayout,
't' => CharAction::ToggleMeta,
'o' => CharAction::CycleSort,
'O' => CharAction::ReverseSort,
' ' => CharAction::ToggleMark,
'V' => CharAction::InvertMarks,
'D' => CharAction::Trash,
'y' => CharAction::Yank,
'X' => CharAction::Cut,
'p' => CharAction::Paste,
'r' => CharAction::Rename,
'a' => CharAction::Create,
'U' => CharAction::Undo,
'Y' => CharAction::YankPath,
'?' => CharAction::Help,
'q' => CharAction::Quit,
_ => return None,
})
}
struct MouseGuard(bool);
impl MouseGuard {
fn enable(on: bool) -> Self {
if on {
let _ = crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture);
}
MouseGuard(on)
}
}
impl Drop for MouseGuard {
fn drop(&mut self) {
if self.0 {
let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture);
}
}
}
pub fn run(
start: String,
icons: IconMode,
layout: Layout,
git_enabled: bool,
mouse: bool,
) -> io::Result<()> {
let cwd = fs::canonicalize(&start).unwrap_or_else(|_| PathBuf::from(&start));
let pane = ImagePane::new().ok();
let (raster_tx, raster_rx) = std::sync::mpsc::channel();
let mut app = App {
cwd,
icons,
layout,
git_enabled,
git: None,
head: None,
all: Vec::new(),
view: Vec::new(),
parent: Vec::new(),
state: ListState::default(),
filter: String::new(),
mode: Mode::Browse,
show_hidden: false,
viewport_h: 0,
status: None,
typeahead: String::new(),
typeahead_at: None,
preview: Vec::new(),
preview_for: None,
pv: Pv::Text,
caption: String::new(),
pane,
img_cache: Vec::new(),
raster_tx,
raster_rx,
raster_pending: None,
raster_want: None,
preview_animated: false,
meta: MetaCol::Size,
crumb_hits: Vec::new(),
list_area: Rect::default(),
parent_area: None,
spin: 0,
animate: crate::anim::enabled(),
fade: None,
fade_frames: 0,
slide: None,
search: None,
search_area: Rect::default(),
sort: Sort::default(),
help: false,
marks: crate::marks::Marks::new(),
clip: None,
op: None,
op_progress: None,
journal: Vec::new(),
};
app.load();
loop {
let mut term = ratatui::init();
let guard = MouseGuard::enable(mouse);
let action = app.main_loop(&mut term);
drop(guard);
ratatui::restore();
match action {
Ok(Action::Quit) => return Ok(()),
Ok(Action::Open(path)) => {
crate::open_interactive(&path.to_string_lossy());
app.preview_for = None; }
Err(e) => return Err(e),
}
}
}
struct Sel {
name: String,
path: PathBuf,
kind: Format,
size: u64,
modified: Option<SystemTime>,
}
impl App {
fn cur_sel(&self) -> Option<Sel> {
if let Some(search) = self.search.as_ref() {
let hit = search.selected()?;
let name = hit
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| hit.rel.clone());
Some(Sel {
name,
path: hit.path.clone(),
kind: hit.kind,
size: hit.size,
modified: hit.modified,
})
} else {
let e = self.selected()?;
Some(Sel {
name: e.name.clone(),
path: e.path.clone(),
kind: e.kind,
size: e.size,
modified: e.modified,
})
}
}
fn load(&mut self) {
self.all = read_entries(&self.cwd, self.sort);
self.parent = match self.cwd.parent() {
Some(p) => read_entries(p, self.sort),
None => Vec::new(),
};
self.git = if self.git_enabled {
git::status_map(&self.cwd)
} else {
None
};
self.head = if self.git.is_some() {
git::head_info(&self.cwd)
} else {
None
};
self.refilter();
}
fn refilter(&mut self) {
let q = query::parse(&self.filter);
self.view = self
.all
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.name.starts_with('.'))
.filter(|(_, e)| q.matches(&e.name, e.kind, e.size, e.modified))
.map(|(i, _)| i)
.collect();
let sel = if self.view.is_empty() {
None
} else {
Some(self.state.selected().unwrap_or(0).min(self.view.len() - 1))
};
self.state.select(sel);
self.preview_for = None;
}
fn resort(&mut self) {
let sort = self.sort;
self.all.sort_by(|a, b| sort_cmp(a, b, sort));
self.parent.sort_by(|a, b| sort_cmp(a, b, sort));
self.refilter();
self.status = Some(self.sort.label());
}
fn selected(&self) -> Option<&Entry> {
let i = self.state.selected()?;
self.all.get(*self.view.get(i)?)
}
fn view_rows(&self) -> Vec<(PathBuf, u64, bool)> {
self.view
.iter()
.map(|&i| {
let e = &self.all[i];
(e.path.clone(), e.size, e.kind == Format::Directory)
})
.collect()
}
fn move_sel(&mut self, delta: isize) {
if self.view.is_empty() {
return;
}
let n = self.view.len() as isize;
let cur = self.state.selected().unwrap_or(0) as isize;
let next = (cur + delta).clamp(0, n - 1);
self.state.select(Some(next as usize));
}
fn enter_dir(&mut self, path: PathBuf, dir: SlideDir) {
let old = if self.animate {
self.snapshot_current_inner()
} else {
None
};
self.cwd = path;
self.filter.clear();
self.mode = Mode::Browse;
self.state.select(Some(0));
self.status = None;
self.typeahead.clear();
self.typeahead_at = None;
self.load();
if self.animate {
let now = Instant::now();
self.fade = Some(crate::anim::Anim::new(now, NAV_ANIM));
self.fade_frames = 0;
self.slide = old.map(|old| Slide {
anim: crate::anim::Anim::new(now, NAV_ANIM),
dir,
old,
frames: 0,
});
}
}
fn go_parent(&mut self) {
if let Some(parent) = self.cwd.parent().map(Path::to_path_buf) {
let from = self
.cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned());
self.enter_dir(parent, SlideDir::FromLeft);
if let Some(name) = from {
if let Some(pos) = self.view.iter().position(|&i| self.all[i].name == name) {
self.state.select(Some(pos));
}
}
}
}
fn snapshot_current_inner(&self) -> Option<Buffer> {
let area = self.list_area;
let inner = entry_inner(area);
if inner.width == 0 || inner.height == 0 {
return None;
}
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: String::new(),
git: self.git.as_ref(),
meta: self.meta,
fade_t: None,
marks: mark_gutter(&self.marks),
};
let (_, window) = visible_window(
self.state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, &view, self.icons, window.clone());
let list = entry_list(items, None);
let mut buf = Buffer::empty(inner);
let mut state = ListState::default();
state.select(window_selection(view.selected, &window));
render_items_into(&mut buf, inner, list, &mut state);
Some(buf)
}
fn activate(&mut self) -> Option<Action> {
let e = self.selected()?;
if e.kind == Format::Directory {
let p = e.path.clone();
self.enter_dir(p, SlideDir::FromRight);
None
} else if e.kind.opens() {
Some(Action::Open(e.path.clone()))
} else {
self.status = Some(format!("no viewer for {}", e.kind.label()));
None
}
}
fn exit_search(&mut self) {
self.search = None;
self.mode = Mode::Browse;
self.status = None;
self.preview_for = None;
}
fn restart_search(&mut self) {
let cwd = self.cwd.clone();
let show_hidden = self.show_hidden;
let Some(search) = self.search.as_mut() else {
return;
};
let q = query::parse(&search.query);
search.results.clear();
search.state.select(None);
search.done = false;
search.capped = false;
search.engine = if q.is_empty() {
None
} else {
Some(crate::search::start(cwd, q, show_hidden))
};
}
fn pump_search(&mut self) -> bool {
let sort = self.sort;
let Some(search) = self.search.as_mut() else {
return false;
};
let Some(engine) = search.engine.as_ref() else {
return false;
};
let msgs = engine.drain();
if msgs.is_empty() {
return false;
}
let anchor = search.selected().map(|h| h.path.clone());
for msg in msgs {
match msg {
crate::search::Msg::Hit(h) => search.results.push(h),
crate::search::Msg::Done { capped } => {
search.done = true;
search.capped = capped;
search.engine = None; }
}
}
search.results.sort_by(|a, b| sort_cmp(a, b, sort));
let restored = anchor.and_then(|p| search.results.iter().position(|h| h.path == p));
search
.state
.select(restored.or((!search.results.is_empty()).then_some(0)));
true
}
fn pump_fileop(&mut self) -> bool {
let msgs = match self.op.as_ref() {
Some(run) => run.drain(),
None => return false,
};
if msgs.is_empty() {
return false;
}
let mut done = None;
for msg in msgs {
match msg {
fileop::Msg::Progress {
items,
bytes,
current,
} => {
if let Some(flight) = self.op_progress.as_mut() {
flight.items = items;
flight.bytes = bytes;
flight.current = current;
}
}
fileop::Msg::Done(report) => done = Some(report),
}
}
if let Some(report) = done {
self.finish_op(report);
}
true
}
fn finish_op(&mut self, report: fileop::Report) {
self.op = None;
let flight = self.op_progress.take();
if let Some(flight) = &flight {
for path in &flight.targets {
self.marks.remove(path);
}
}
if !clip_survives(report.kind) {
self.clip = None;
}
let wanted = flight
.as_ref()
.and_then(|flight| flight.landing.clone())
.or_else(|| self.selected().map(|e| e.name.clone()));
let prev = self.state.selected();
self.load();
let names: Vec<&str> = self
.view
.iter()
.map(|&i| self.all[i].name.as_str())
.collect();
let next = reselect(&names, wanted.as_deref(), prev);
self.state.select(next);
if !report.journal.steps.is_empty() {
push_journal(&mut self.journal, report.journal.clone(), UNDO_DEPTH);
}
self.status = Some(op_done_status(
report.kind,
report.direction,
report.items,
report.bytes,
));
if !report_speaks(&report) {
return;
}
if matches!(self.mode, Mode::Search) {
self.exit_search();
}
self.show_op(OpView::Report(report));
}
fn show_op(&mut self, view: OpView) {
self.help = false;
self.mode = Mode::Op(view);
}
fn request_trash(&mut self) {
if self.op.is_some() {
self.status = Some("busy: an operation is already running".to_string());
return;
}
let paths = targets(&self.marks, self.selected().map(|e| e.path.as_path()));
if paths.is_empty() {
self.status = Some(fileop::Refusal::NothingSelected.to_string());
return;
}
let collected = match fileop::collect(&paths) {
Ok(collected) => collected,
Err(refusal) => {
self.status = Some(refusal.to_string());
return;
}
};
if collected.sources.is_empty() {
let gone = mark_count(collected.missing.len());
for path in &collected.missing {
self.marks.remove(path);
}
self.status = Some(format!("{gone} already gone, so there is nothing to trash"));
return;
}
let cwd = self.cwd.clone();
let resolved = fileop::plan(
fileop::Op::Trash {
sources: collected.sources,
},
&fileop::PlanCtx {
dest_listing: &[],
cwd: &cwd,
missing: &collected.missing,
policy: fileop::Conflict::Rename,
},
);
match resolved {
Ok(plan) => self.show_op(OpView::Confirm(Pending {
plan,
inputs: None,
})),
Err(refusal) => self.status = Some(refusal.to_string()),
}
}
fn load_clip(&mut self, cut: bool) {
let paths = targets(&self.marks, self.selected().map(|e| e.path.as_path()));
if paths.is_empty() {
self.status = Some(fileop::Refusal::NothingSelected.to_string());
return;
}
self.status = Some(clip_status(cut, paths.len()));
self.clip = Some(Clip { cut, paths });
}
fn request_paste(&mut self) {
if self.op.is_some() {
self.status = Some("busy: an operation is already running".to_string());
return;
}
let Some(clip) = self.clip.as_ref() else {
self.status = Some("nothing on the clipboard: [y] copies, [X] cuts".to_string());
return;
};
let cut = clip.cut;
let paths = clip.paths.clone();
let collected = match fileop::collect(&paths) {
Ok(collected) => collected,
Err(refusal) => {
self.status = Some(refusal.to_string());
return;
}
};
if collected.sources.is_empty() {
self.clip = None;
let gone = mark_count(collected.missing.len());
self.status = Some(format!("{gone} already gone, so there is nothing to paste"));
return;
}
let cwd = self.cwd.clone();
let inputs = Replan {
transfer: Transfer::of(cut),
sources: collected.sources,
dest: cwd.clone(),
dest_listing: dest_listing(&cwd),
missing: collected.missing,
cwd,
};
match inputs.plan(fileop::Conflict::Rename) {
Ok(plan) => self.show_op(OpView::Confirm(Pending {
plan,
inputs: Some(Box::new(inputs)),
})),
Err(refusal) => self.status = Some(refusal.to_string()),
}
}
fn request_rename(&mut self) {
if self.op.is_some() {
self.status = Some("busy: an operation is already running".to_string());
return;
}
let one = if self.marks.is_empty() {
self.selected()
.map(|e| (e.path.clone(), e.kind == Format::Directory))
} else if self.marks.len() == 1 {
self.marks
.marks()
.first()
.map(|m| (m.path.clone(), m.is_dir))
} else {
self.status = Some(format!(
"{}: rename acts on one entry, so clear them with [Esc] first",
mark_count(self.marks.len())
));
return;
};
let Some((path, is_dir)) = one else {
self.status = Some(fileop::Refusal::NothingSelected.to_string());
return;
};
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
self.status = Some(fileop::Refusal::FilesystemRoot.to_string());
return;
};
let cursor = stem_end(&name, is_dir);
self.status = None; self.mode = Mode::Input(Prompt {
ask: Ask::Rename { path },
edit: crate::lineedit::LineEdit::with_text(&name, cursor),
});
}
fn request_create(&mut self) {
if self.op.is_some() {
self.status = Some("busy: an operation is already running".to_string());
return;
}
self.status = None; self.mode = Mode::Input(Prompt {
ask: Ask::Create {
parent: self.cwd.clone(),
},
edit: crate::lineedit::LineEdit::new(),
});
}
fn handle_input_key(&mut self, code: KeyCode) -> Option<Action> {
match code {
KeyCode::Enter => self.submit_prompt(),
KeyCode::Esc => {
self.mode = Mode::Browse;
self.status = Some("cancelled".to_string());
}
_ => {
let Mode::Input(prompt) = &mut self.mode else {
return None;
};
match code {
KeyCode::Char(c) => prompt.edit.insert(c),
KeyCode::Backspace => prompt.edit.backspace(),
KeyCode::Delete => prompt.edit.delete(),
KeyCode::Left => prompt.edit.left(),
KeyCode::Right => prompt.edit.right(),
KeyCode::Home => prompt.edit.home(),
KeyCode::End => prompt.edit.end(),
_ => {}
}
}
}
None
}
fn submit_prompt(&mut self) {
let Mode::Input(prompt) = &self.mode else {
return;
};
let ask = prompt.ask.clone();
let name = prompt.edit.text().to_string();
let cwd = self.cwd.clone();
let resolved = match &ask {
Ask::Rename { path } => rename_plan(path, &name, &cwd),
Ask::Create { parent } => create_plan(parent, &name, &cwd),
};
match resolved {
Ok(plan) => {
self.mode = Mode::Browse;
self.start_op(plan);
}
Err(refusal) => self.status = Some(refusal.to_string()),
}
}
fn toggle_overwrite(&mut self) {
let outcome = match &self.mode {
Mode::Op(OpView::Confirm(pending)) => pending
.inputs
.as_ref()
.map(|inputs| inputs.plan(flip_policy(pending.plan.policy))),
_ => None,
};
match outcome {
Some(Ok(plan)) => {
if let Mode::Op(OpView::Confirm(pending)) = &mut self.mode {
pending.plan = plan;
}
}
Some(Err(refusal)) => self.status = Some(refusal.to_string()),
None => {}
}
}
fn start_op(&mut self, plan: fileop::Plan) {
let label = plan.summary();
let total = plan.items();
let mut targets: Vec<PathBuf> = plan
.steps
.iter()
.map(|s| s.src.clone())
.filter(|p| !p.as_os_str().is_empty())
.collect();
targets.extend(plan.missing.iter().cloned());
let landing = landing_name(&plan.steps);
let flight = InFlight::new(label, total, targets, landing);
self.begin_run(fileop::start(plan), flight);
}
fn undo_last(&mut self) {
if self.op.is_some() {
self.status = Some("busy: an operation is already running".to_string());
return;
}
let Some(journal) = self.journal.pop() else {
self.status = Some("nothing to undo".to_string());
return;
};
let total = journal.steps.len();
let label = undo_label(journal.kind);
let landing = undo_landing(&journal.steps);
let flight = InFlight::new(label, total, Vec::new(), landing);
self.begin_run(fileop::start_undo(journal), flight);
}
fn begin_run(&mut self, run: fileop::Run, flight: InFlight) {
self.op_progress = Some(flight);
self.op = Some(run);
}
fn yank_paths(&mut self) {
let paths = targets(&self.marks, self.selected().map(|e| e.path.as_path()));
if paths.is_empty() {
self.status = Some(fileop::Refusal::NothingSelected.to_string());
return;
}
let text = clipboard_text(&self.cwd, &paths);
self.status = Some(match crate::util::copy_to_clipboard(&text) {
Ok(()) => yank_status(paths.len()),
Err(refusal) => refusal,
});
}
fn handle_op_key(&mut self, code: KeyCode) -> Option<Action> {
if !matches!(self.mode, Mode::Op(OpView::Confirm(_))) {
self.mode = Mode::Browse;
return None;
}
match code {
KeyCode::Enter | KeyCode::Char('y') => {
if let Mode::Op(OpView::Confirm(pending)) =
std::mem::replace(&mut self.mode, Mode::Browse)
{
self.start_op(pending.plan);
}
}
KeyCode::Esc | KeyCode::Char('n') => {
self.mode = Mode::Browse;
self.status = Some("cancelled".to_string());
}
KeyCode::Char('o') => self.toggle_overwrite(),
_ => {}
}
None
}
fn search_move(&mut self, delta: isize) {
if let Some(search) = self.search.as_mut() {
search.move_sel(delta);
}
}
fn activate_search(&mut self) -> Option<Action> {
let sel = self.cur_sel()?;
if sel.kind == Format::Directory {
self.exit_search();
self.enter_dir(sel.path, SlideDir::FromRight);
self.slide = None;
None
} else if sel.kind.opens() {
Some(Action::Open(sel.path))
} else {
self.status = Some(format!("no viewer for {}", sel.kind.label()));
None
}
}
fn main_loop(&mut self, term: &mut DefaultTerminal) -> io::Result<Action> {
let mut dirty = true;
loop {
if self.pump_search() {
dirty = true;
}
if self.pump_fileop() {
dirty = true;
}
let cur = self.cur_sel().map(|s| s.path);
if cur != self.preview_for {
self.build_preview();
self.preview_for = cur;
dirty = true;
}
if self.pump_raster() {
dirty = true;
}
if let Some(fade) = self.fade {
let now = Instant::now();
dirty = true;
if fade.done(now) {
crate::anim::record("folder-fade", self.fade_frames, fade.elapsed(now));
self.fade = None;
} else {
self.fade_frames = self.fade_frames.saturating_add(1);
}
}
let slide_done = self
.slide
.as_ref()
.map(|s| s.anim.done(Instant::now()))
.unwrap_or(false);
if let Some(slide) = self.slide.as_mut() {
dirty = true;
if slide_done {
crate::anim::record(
"folder-slide",
slide.frames,
slide.anim.elapsed(Instant::now()),
);
} else {
slide.frames = slide.frames.saturating_add(1);
}
}
if slide_done {
self.slide = None;
}
if dirty {
term.draw(|f| self.render(f))?;
dirty = false;
}
let raster_active = self.raster_pending.is_some() || self.raster_want.is_some();
let animating = self.preview_animated && matches!(self.pv, Pv::Image);
let searching = self.search.as_ref().is_some_and(|s| s.engine.is_some());
let operating = self.op.is_some();
let fading = self.fade.is_some() || self.slide.is_some();
let timeout = if fading {
Duration::from_millis(4)
} else if raster_active || animating || searching || operating {
Duration::from_millis(60)
} else {
Duration::from_millis(1000)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
dirty = true;
self.fade = None;
self.slide = None;
if let Some(action) = self.handle_key(key) {
return Ok(action);
}
}
Event::Mouse(me) if matches!(self.mode, Mode::Search) => match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
let (offset, len, cur) = match self.search.as_ref() {
Some(s) => (s.state.offset(), s.results.len(), s.state.selected()),
None => (0, 0, None),
};
if let Some(idx) =
row_to_index(self.search_area, offset, me.row, me.column, len)
{
if cur == Some(idx) {
if let Some(action) = self.activate_search() {
return Ok(action);
}
} else if let Some(s) = self.search.as_mut() {
s.state.select(Some(idx));
}
dirty = true;
}
}
MouseEventKind::ScrollDown => {
self.search_move(1);
dirty = true;
}
MouseEventKind::ScrollUp => {
self.search_move(-1);
dirty = true;
}
_ => {}
},
Event::Mouse(_) if matches!(self.mode, Mode::Op(_)) => {}
Event::Mouse(_) if self.help => {
self.help = false;
dirty = true;
}
Event::Mouse(me) => match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
if me.row == 0 {
if let Some(target) = crumb_hit(&self.crumb_hits, me.column) {
if target != self.cwd {
self.enter_dir(target, SlideDir::FromLeft);
dirty = true;
}
}
} else if let Some(idx) = row_to_index(
self.list_area,
self.state.offset(),
me.row,
me.column,
self.view.len(),
) {
if self.state.selected() == Some(idx) {
if let Some(action) = self.activate() {
return Ok(action);
}
} else {
self.state.select(Some(idx));
}
dirty = true;
} else if self
.parent_area
.is_some_and(|a| rect_contains(a, me.column, me.row))
{
self.go_parent();
dirty = true;
}
}
MouseEventKind::ScrollDown => {
self.move_sel(1);
dirty = true;
}
MouseEventKind::ScrollUp => {
self.move_sel(-1);
dirty = true;
}
_ => {}
},
Event::Resize(..) => dirty = true,
_ => {}
}
} else {
if raster_active {
self.spin = self.spin.wrapping_add(1);
dirty = true;
}
if animating {
if let Some(pane) = self.pane.as_mut() {
if pane.tick(Instant::now()) {
dirty = true;
}
}
}
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Option<Action> {
let code = key.code;
if matches!(self.mode, Mode::Op(_)) {
return self.handle_op_key(code);
}
if matches!(self.mode, Mode::Input(_)) {
return self.handle_input_key(code);
}
if let Mode::Filter = self.mode {
match code {
KeyCode::Esc => {
self.filter.clear();
self.mode = Mode::Browse;
self.refilter();
}
KeyCode::Enter => self.mode = Mode::Browse,
KeyCode::Backspace => {
self.filter.pop();
self.refilter();
}
KeyCode::Down => self.move_sel(1),
KeyCode::Up => self.move_sel(-1),
KeyCode::Char(c) => {
self.filter.push(c);
self.refilter();
}
_ => {}
}
return None;
}
if let Mode::Search = self.mode {
let half = (self.viewport_h / 2).max(1) as isize;
match code {
KeyCode::Esc => self.exit_search(),
KeyCode::Enter | KeyCode::Right => return self.activate_search(),
KeyCode::Backspace => {
if let Some(s) = self.search.as_mut() {
s.query.pop();
}
self.restart_search();
}
KeyCode::Down => self.search_move(1),
KeyCode::Up => self.search_move(-1),
KeyCode::PageDown => self.search_move(half),
KeyCode::PageUp => self.search_move(-half),
KeyCode::Char(c) => {
if let Some(s) = self.search.as_mut() {
s.query.push(c);
}
self.restart_search();
}
_ => {}
}
return None;
}
if self.help {
self.help = false;
return None;
}
let now = Instant::now();
let is_active = typeahead::active(now, self.typeahead_at, typeahead::TIMEOUT);
if !is_active && !self.typeahead.is_empty() {
self.typeahead.clear();
if self
.status
.as_deref()
.is_some_and(|s| s.starts_with("type: "))
{
self.status = None;
}
}
if is_active {
match code {
KeyCode::Esc => {
self.cancel_typeahead();
return None;
}
KeyCode::Backspace => {
self.typeahead.pop();
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
_ => {}
}
}
if code == KeyCode::Char('a') && key.modifiers.contains(KeyModifiers::CONTROL) {
let rows = self.view_rows();
self.marks
.mark_all(rows.iter().map(|(p, s, d)| (p.as_path(), *s, *d)));
return None;
}
if let KeyCode::Char(c) = code {
let ctrl_alt = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
if !ctrl_alt {
match typeahead::action(is_active, browse_char(c).is_some()) {
typeahead::Action::Append => {
self.typeahead.push(c);
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
typeahead::Action::StartNew => {
self.typeahead = c.to_string();
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
typeahead::Action::PassThrough => {}
}
}
}
let half = (self.viewport_h / 2).max(1) as isize;
match code {
KeyCode::Char(c) => {
if let Some(action) = browse_char(c) {
return self.run_char_action(action);
}
}
KeyCode::Esc => match escape(
self.op.is_some(),
self.clip.is_some(),
!self.marks.is_empty(),
) {
Escape::CancelOp => {
if let Some(run) = self.op.as_ref() {
run.cancel();
}
self.status = Some("cancelling: it will report what it finished".to_string());
}
Escape::ClearClip => {
self.clip = None;
self.status = Some("clipboard cleared".to_string());
}
Escape::ClearMarks => self.marks.clear(),
Escape::Quit => return Some(Action::Quit),
},
KeyCode::Down => self.move_sel(1),
KeyCode::Up => self.move_sel(-1),
KeyCode::PageDown => self.move_sel(half),
KeyCode::PageUp => self.move_sel(-half),
KeyCode::Home => self.state.select(Some(0)),
KeyCode::End => {
if !self.view.is_empty() {
self.state.select(Some(self.view.len() - 1));
}
}
KeyCode::Enter | KeyCode::Right => return self.activate(),
KeyCode::Left | KeyCode::Backspace => self.go_parent(),
_ => {}
}
None
}
fn run_char_action(&mut self, action: CharAction) -> Option<Action> {
let half = (self.viewport_h / 2).max(1) as isize;
match action {
CharAction::Down => self.move_sel(1),
CharAction::Up => self.move_sel(-1),
CharAction::HalfDown => self.move_sel(half),
CharAction::HalfUp => self.move_sel(-half),
CharAction::Top => self.state.select(Some(0)),
CharAction::Bottom => {
if !self.view.is_empty() {
self.state.select(Some(self.view.len() - 1));
}
}
CharAction::Open => return self.activate(),
CharAction::OpenExternal => {
if let Some(e) = self.selected() {
crate::util::open_in_native_app(&e.path.to_string_lossy());
}
}
CharAction::Parent => self.go_parent(),
CharAction::Filter => {
self.mode = Mode::Filter;
self.filter.clear();
self.status = None; self.refilter();
}
CharAction::Search => {
self.mode = Mode::Search;
self.search = Some(SearchState::new());
self.status = None; }
CharAction::ToggleHidden => {
self.show_hidden = !self.show_hidden;
self.refilter();
}
CharAction::ToggleLayout => self.layout = self.layout.cycle(),
CharAction::ToggleMeta => self.meta = self.meta.toggle(),
CharAction::CycleSort => {
self.sort.key = self.sort.key.cycle();
self.resort();
}
CharAction::ReverseSort => {
self.sort.reverse = !self.sort.reverse;
self.resort();
}
CharAction::Help => self.help = !self.help,
CharAction::ToggleMark => {
if let Some(e) = self.selected() {
let path = e.path.clone();
let (size, is_dir) = (e.size, e.kind == Format::Directory);
self.marks.toggle(&path, size, is_dir);
let next = mark_advance(self.state.selected(), self.view.len());
self.state.select(next);
}
}
CharAction::InvertMarks => {
let rows = self.view_rows();
self.marks
.invert(rows.iter().map(|(p, s, d)| (p.as_path(), *s, *d)));
}
CharAction::Trash => self.request_trash(),
CharAction::Yank => self.load_clip(false),
CharAction::Cut => self.load_clip(true),
CharAction::Paste => self.request_paste(),
CharAction::Rename => self.request_rename(),
CharAction::Create => self.request_create(),
CharAction::Undo => self.undo_last(),
CharAction::YankPath => self.yank_paths(),
CharAction::Quit => {
if self.op.is_some() {
self.status = Some("an operation is running: [Esc] cancels it".to_string());
return None;
}
return Some(Action::Quit);
}
}
None
}
fn apply_typeahead(&mut self) {
let idx = {
let names: Vec<&str> = self
.view
.iter()
.map(|&i| self.all[i].name.as_str())
.collect();
typeahead::match_prefix(&names, &self.typeahead)
};
if let Some(i) = idx {
self.state.select(Some(i));
}
self.status = Some(format!("type: {}", self.typeahead));
}
fn cancel_typeahead(&mut self) {
self.typeahead.clear();
self.typeahead_at = None;
self.status = None;
}
fn cache_put(&mut self, path: PathBuf, img: DynamicImage) {
self.img_cache.push((path, img));
if self.img_cache.len() > 8 {
self.img_cache.remove(0);
}
}
fn show_image(&mut self, img: DynamicImage) {
if let Some(pane) = self.pane.as_mut() {
pane.set(img);
}
self.pv = Pv::Image;
self.preview_animated = false;
}
fn show_animation(&mut self, frames: Vec<media::Frame>) {
if let Some(pane) = self.pane.as_mut() {
pane.set_animation(frames);
}
self.pv = Pv::Image;
self.preview_animated = true;
}
fn pump_raster(&mut self) -> bool {
let mut dirty = false;
let cur = self.cur_sel().map(|s| s.path);
while let Ok((path, result)) = self.raster_rx.try_recv() {
if let Some(Rastered::Still(img)) = &result {
self.cache_put(path.clone(), img.clone());
}
if self.raster_pending.as_deref() == Some(path.as_path()) {
self.raster_pending = None;
}
if Some(&path) != cur.as_ref() {
continue; }
match result {
Some(Rastered::Still(img)) => self.show_image(img),
Some(Rastered::Animated(frames)) => self.show_animation(frames),
None => {
self.preview.push(no_preview());
self.pv = Pv::Text;
}
}
dirty = true;
}
if self.raster_pending.is_none() {
if let Some((path, kind)) = self.raster_want.take() {
if let Some((_, img)) = self.img_cache.iter().find(|(p, _)| *p == path) {
if Some(&path) == cur.as_ref() {
let img = img.clone();
self.show_image(img);
dirty = true;
}
} else {
let tx = self.raster_tx.clone();
let p = path.clone();
thread::spawn(move || {
let result: Option<Rastered> = match kind {
Format::Image => media::decode_frames(&p)
.map(Rastered::Animated)
.or_else(|| {
crate::util::open_image_reader(&p)
.ok()
.and_then(|r| r.decode().ok())
.map(Rastered::Still)
}),
Format::Pdf => crate::pdf::poster(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Video => crate::video::poster(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Svg => crate::svg::render_svg(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Keynote => crate::keynote::preview_image(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
_ => None,
};
let _ = tx.send((p, result));
});
self.raster_pending = Some(path);
}
}
}
dirty
}
fn build_preview(&mut self) {
self.preview.clear();
self.pv = Pv::Text;
self.preview_animated = false;
self.raster_want = None;
let Some(sel) = self.cur_sel() else { return };
let name = sel.name;
let kind = sel.kind;
let size = sel.size;
let modified = sel.modified;
let path = sel.path;
let mut meta = kind.label().to_string();
if kind != Format::Directory {
meta.push_str(&format!(" · {}", crate::util::human_size(size)));
}
if let Some(m) = modified {
meta.push_str(&format!(" · {}", crate::util::rel_time(m)));
}
self.preview.push(Line::from(Span::styled(
name.clone(),
Style::default()
.fg(kind.color())
.add_modifier(Modifier::BOLD),
)));
self.preview.push(Line::from(Span::styled(
meta.clone(),
Style::default().fg(theme::palette().dim),
)));
self.preview.push(Line::from(""));
if matches!(
kind,
Format::Image | Format::Svg | Format::Pdf | Format::Video | Format::Keynote
) && self.pane.is_some()
{
let extra = match kind {
Format::Image => crate::util::image_dimensions(&path)
.map(|(w, h)| format!(" · {w}×{h}"))
.unwrap_or_default(),
Format::Video => " · Enter to play".into(),
Format::Pdf => " · page 1".into(),
Format::Svg => " · Enter for source".into(),
Format::Keynote => " · preview".into(),
_ => String::new(),
};
self.caption = format!("{name} {meta}{extra}");
if let Some((_, img)) = self.img_cache.iter().find(|(p, _)| *p == path) {
let img = img.clone();
self.show_image(img); } else {
self.pv = Pv::Loading;
self.raster_want = Some((path, kind));
}
return;
}
match kind {
Format::Directory => self.preview_dir(&path),
Format::Markdown => self.preview_markdown(read_capped(&path)),
Format::Html => {
match crate::html::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Docx => {
match crate::docx::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Pptx => {
match crate::pptx::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Epub => {
match crate::epub::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Ipynb => {
match crate::ipynb::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Sheet | Format::Data => self.preview_sheet(&path),
Format::Archive => self.preview_archive(&path),
Format::Binary => self.preview_hex(&path),
_ => self.preview_text_head(&path),
}
}
fn preview_dir(&mut self, path: &Path) {
let mut kids: Vec<(String, bool)> = match fs::read_dir(path) {
Ok(rd) => rd
.flatten()
.map(|c| {
let n = c.file_name().to_string_lossy().into_owned();
(n, c.path().is_dir())
})
.filter(|(n, _)| self.show_hidden || !n.starts_with('.'))
.collect(),
Err(_) => {
self.preview.push(no_preview());
return;
}
};
kids.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
let total = kids.len();
if total == 0 {
self.preview.push(Line::from(Span::styled(
"empty",
Style::default().fg(theme::palette().dim),
)));
return;
}
self.preview.insert(
2,
Line::from(Span::styled(
format!("{total} items"),
Style::default().fg(theme::palette().dim),
)),
);
for (n, d) in kids.into_iter().take(300) {
let (c, suffix) = if d {
(theme::palette().dir, "/")
} else {
(theme::palette().other, "")
};
self.preview.push(Line::from(Span::styled(
format!("{n}{suffix}"),
Style::default().fg(c),
)));
}
}
fn preview_markdown(&mut self, src: String) {
let width = preview_text_width();
let (lines, _, _) = crate::markdown::Rendered::build(&src).layout(width);
self.preview.extend(lines.into_iter().take(600));
}
fn preview_sheet(&mut self, path: &Path) {
const MAX_COLS: usize = 20;
const COL_CAP: usize = 18; let Some(rows) = crate::sheet::preview_grid(&path.to_string_lossy(), 200, MAX_COLS) else {
self.preview.push(no_preview());
return;
};
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![1usize; ncols];
for r in &rows {
for (i, c) in r.iter().enumerate() {
widths[i] = widths[i].max(c.chars().count()).min(COL_CAP);
}
}
for (ri, r) in rows.iter().take(400).enumerate() {
let spans: Vec<Span> = (0..ncols)
.map(|i| {
let cell = r.get(i).map(String::as_str).unwrap_or("");
let color = if ri == 0 {
theme::palette().accent
} else {
theme::palette().other
};
Span::styled(
format!("{} ", pad_cell(cell, widths[i])),
Style::default().fg(color),
)
})
.collect();
self.preview.push(Line::from(spans));
}
}
fn preview_archive(&mut self, path: &Path) {
match crate::archive::entries(&path.to_string_lossy()) {
Ok(list) => {
self.preview.insert(
2,
Line::from(Span::styled(
format!("{} entries", list.len()),
Style::default().fg(theme::palette().dim),
)),
);
for e in list.into_iter().take(500) {
let size = if e.is_dir {
" dir".to_string()
} else {
format!("{:>8}", crate::util::human_size(e.size))
};
let color = if e.is_dir {
theme::palette().dir
} else {
theme::palette().other
};
self.preview.push(Line::from(vec![
Span::styled(
format!("{size} "),
Style::default().fg(theme::palette().dim),
),
Span::styled(e.name, Style::default().fg(color)),
]));
}
}
Err(_) => self.preview.push(no_preview()),
}
}
fn preview_hex(&mut self, path: &Path) {
for line in crate::hex::preview(&path.to_string_lossy(), 500) {
self.preview.push(Line::from(Span::styled(
line,
Style::default().fg(theme::palette().other),
)));
}
}
fn preview_text_head(&mut self, path: &Path) {
let Some(text) = head_text(path, 64 * 1024, 500) else {
self.preview.push(no_preview());
return;
};
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if !highlight::is_text_ext(&ext) {
for l in text.lines() {
self.preview.push(Line::from(Span::styled(
l.to_string(),
Style::default().fg(theme::palette().other),
)));
}
return;
}
let syntax = highlight::syntax_for(&ext).unwrap_or(highlight::PLAIN);
for line in highlight::highlight(&text, syntax) {
let spans: Vec<Span> = line
.into_iter()
.map(|tok| {
Span::styled(tok.text, Style::default().fg(theme::token_color(tok.kind)))
})
.collect();
self.preview.push(Line::from(spans));
}
}
fn render(&mut self, f: &mut Frame) {
if matches!(self.mode, Mode::Search) {
self.render_search(f);
return;
}
let area = f.area();
let rows = RtLayout::default()
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
self.render_crumb(f, rows[0]);
let has_parent = self.cwd.parent().is_some();
let filter = matches!(self.mode, Mode::Filter);
let now = Instant::now();
let fade_t = self
.fade
.map(|a| crate::anim::ease_out_cubic(a.progress(now)));
if effective_columns(self.layout, area.width, has_parent) == 3 {
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(34),
Constraint::Percentage(46),
])
.split(rows[1]);
self.render_parent(f, cols[0]);
self.list_area = cols[1];
self.parent_area = Some(cols[0]);
self.viewport_h = cols[1].height.saturating_sub(2);
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: format!(" {} ", self.view.len()),
git: self.git.as_ref(), meta: self.meta,
fade_t, marks: mark_gutter(&self.marks),
};
match self.slide.as_ref().filter(|s| !s.anim.done(now)) {
Some(slide) => render_entry_slide(
f,
cols[1],
&view,
&self.state,
self.icons,
true,
filter,
slide,
now,
),
None => {
render_entry_list(f, cols[1], &view, &mut self.state, true, filter, self.icons)
}
}
self.render_preview(f, cols[2]);
} else {
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(rows[1]);
self.list_area = cols[0];
self.parent_area = None;
self.viewport_h = cols[0].height.saturating_sub(2);
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: format!(" {} ", self.view.len()),
git: self.git.as_ref(), meta: self.meta,
fade_t, marks: mark_gutter(&self.marks),
};
match self.slide.as_ref().filter(|s| !s.anim.done(now)) {
Some(slide) => render_entry_slide(
f,
cols[0],
&view,
&self.state,
self.icons,
true,
filter,
slide,
now,
),
None => {
render_entry_list(f, cols[0], &view, &mut self.state, true, filter, self.icons)
}
}
self.render_preview(f, cols[1]);
}
self.render_status(f, rows[2]);
if self.help {
render_browse_help(f, area, self.sort);
}
if let Mode::Op(view) = &self.mode {
render_op_overlay(f, area, view);
}
}
fn render_parent(&self, f: &mut Frame, area: Rect) {
let Some(parent) = self.cwd.parent().map(Path::to_path_buf) else {
return;
};
let entries = &self.parent;
let order: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.name.starts_with('.'))
.map(|(i, _)| i)
.collect();
let here = self
.cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned());
let selected = here
.as_deref()
.and_then(|name| order.iter().position(|&i| entries[i].name == name));
let mut state = ListState::default();
state.select(selected);
let view = EntryListView {
entries,
order: &order,
selected,
title: format!(" {} ", pretty_dir_name(&parent)),
git: None,
marks: None,
meta: MetaCol::None, fade_t: None, };
render_entry_list(f, area, &view, &mut state, false, false, self.icons);
}
fn render_crumb(&mut self, f: &mut Frame, area: Rect) {
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let home = std::env::var_os("HOME").map(PathBuf::from);
let segments = crumb_segments(&self.cwd, home.as_deref());
let last = segments.len().saturating_sub(1);
self.crumb_hits.clear();
let mut spans = vec![Span::raw(" ")];
let mut x = area.x.saturating_add(1);
let mut prev_ends_slash = false;
for (idx, (label, target)) in segments.iter().enumerate() {
if idx > 0 && !prev_ends_slash {
spans.push(Span::styled("/".to_string(), Style::default().fg(dim)));
x = x.saturating_add(1);
}
let w = label.chars().count() as u16;
let start = x;
let style = if idx == last {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(dim)
};
spans.push(Span::styled(label.clone(), style));
x = x.saturating_add(w);
self.crumb_hits.push((start..x, target.clone()));
prev_ends_slash = label.ends_with('/');
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
if let Some(head) = &self.head {
let dirty = self.git.as_ref().is_some_and(|m| !m.is_empty());
let git_spans = head_spans(head, dirty, self.icons);
let w: u16 = git_spans
.iter()
.map(|s| s.content.chars().count() as u16)
.sum();
let right = area.x + area.width;
if w > 0 && x + 2 + w <= right {
let rect = Rect::new(right - w, area.y, w, 1);
f.render_widget(Paragraph::new(Line::from(git_spans)), rect);
}
}
}
fn render_preview(&mut self, f: &mut Frame, area: Rect) {
match self.pv {
Pv::Image => {
let block = preview_block(caption_title(&self.caption, area));
let inner = block.inner(area);
f.render_widget(block, area);
if let Some(pane) = self.pane.as_mut() {
pane.render(f, inner);
}
}
Pv::Loading => {
let block = preview_block(caption_title(&self.caption, area));
let inner = block.inner(area);
f.render_widget(block, area);
let frame = SPINNER[self.spin % SPINNER.len()];
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("{frame} rendering…"),
Style::default().fg(theme::palette().dim),
))),
inner,
);
}
Pv::Text => {
let block = preview_block(preview_caption(" Preview "));
let inner_h = area.height.saturating_sub(2) as usize;
let text: Vec<Line> = self.preview.iter().take(inner_h).cloned().collect();
f.render_widget(Paragraph::new(Text::from(text)).block(block), area);
}
}
}
fn render_status(&self, f: &mut Frame, area: Rect) {
if let Mode::Input(prompt) = &self.mode {
f.render_widget(
Paragraph::new(Line::from(prompt_spans(
prompt.ask.prefix(),
&prompt.edit,
prompt.ask.hint(),
))),
area,
);
return;
}
let txt = if let Mode::Filter = self.mode {
let hint = if query::parse(&self.filter).has_predicates() {
"[Enter] keep [Esc] clear"
} else {
"text + kind: ext: size: modified: [Enter] keep [Esc] clear"
};
format!(" /{} {hint}", self.filter)
} else if let Mode::Op(view) = &self.mode {
match view {
OpView::Confirm(pending) => {
format!(
" {} {}",
pending.plan.summary(),
confirm_keys(pending.inputs.is_some(), true)
)
}
OpView::Report(report) => {
format!(" {} [any key] close", report_count(report))
}
}
} else if let Some(flight) = &self.op_progress {
format!(
" {}",
op_progress_status(&flight.label, flight.items, flight.total, &flight.current)
)
} else if let Some(s) = &self.status {
format!(" {s}")
} else if !self.marks.is_empty() {
let dirs = self.marks.marks().iter().filter(|m| m.is_dir).count();
let line = marks_status(self.marks.len(), dirs, self.marks.bytes());
format!(" {line}")
} else if let Some(clip) = &self.clip {
format!(" {}", clip_status(clip.cut, clip.paths.len()))
} else {
format!(" {}", browse_hint(area.width, self.show_hidden))
};
let color = match &self.mode {
Mode::Filter => theme::palette().doc,
Mode::Op(OpView::Report(report)) if !report.failures.is_empty() => theme::palette().pdf,
Mode::Op(OpView::Report(_)) => theme::palette().dim,
Mode::Input(_) => theme::palette().doc,
Mode::Browse | Mode::Search | Mode::Op(OpView::Confirm(_)) => theme::palette().dim,
};
f.render_widget(
Paragraph::new(Line::from(txt)).style(Style::default().fg(color)),
area,
);
}
fn render_search(&mut self, f: &mut Frame) {
let area = f.area();
let rows = RtLayout::default()
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
self.render_search_input(f, rows[0]);
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(rows[1]);
self.render_results(f, cols[0]);
self.render_preview(f, cols[1]);
self.render_search_status(f, rows[2]);
}
fn render_search_input(&self, f: &mut Frame, area: Rect) {
let accent = theme::palette().accent;
let query = self.search.as_ref().map(|s| s.query.as_str()).unwrap_or("");
let line = Line::from(vec![
Span::styled(
format!(" ⌕ {query}"),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(accent)),
]);
f.render_widget(Paragraph::new(line), area);
}
fn render_results(&mut self, f: &mut Frame, area: Rect) {
self.search_area = area;
self.viewport_h = area.height.saturating_sub(2);
let accent = theme::palette().accent;
let n = self.search.as_ref().map(|s| s.results.len()).unwrap_or(0);
let title = Line::from(Span::styled(
format!(" {n} "),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(title);
f.render_widget(block, area);
let inner = entry_inner(area);
let (offset, len, selected) = match self.search.as_ref() {
Some(s) => (s.state.offset(), s.results.len(), s.state.selected()),
None => (0, 0, None),
};
let (new_offset, window) = visible_window(offset, selected, len, inner.height as usize);
let items = self.search_items(area.width, window.clone());
let list = List::new(items)
.highlight_style(
Style::default()
.bg(theme::palette().selection)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎ ");
if let Some(search) = self.search.as_mut() {
let mut local = ListState::default();
local.select(window_selection(selected, &window));
render_items_into(f.buffer_mut(), inner, list, &mut local);
*search.state.offset_mut() = new_offset;
}
}
fn search_items(&self, width: u16, window: std::ops::Range<usize>) -> Vec<ListItem<'static>> {
let Some(search) = self.search.as_ref() else {
return Vec::new();
};
let dim = theme::palette().dim;
let chrome_w = match self.icons {
IconMode::None => 4,
_ => 6,
};
let inner_w = width.saturating_sub(chrome_w) as usize;
search.results[window]
.iter()
.map(|hit| {
let mut spans: Vec<Span> = Vec::with_capacity(3);
let rel_color = match self.icons {
IconMode::Unicode => {
let c = hit.kind.color();
spans.push(Span::styled(
format!("{} ", hit.kind.glyph()),
Style::default().fg(c),
));
c
}
IconMode::Nerd => {
let ext = hit
.path
.extension()
.map(|x| x.to_string_lossy().to_lowercase())
.unwrap_or_default();
let c = icons::nerd_color(&ext, hit.kind);
spans.push(Span::styled(
format!("{} ", icons::nerd_glyph(&ext, hit.kind)),
Style::default().fg(c),
));
c
}
IconMode::None => hit.kind.color(),
};
let rel_shown = truncate(&hit.rel, inner_w);
let budget = inner_w.saturating_sub(rel_shown.chars().count());
spans.push(Span::styled(rel_shown, Style::default().fg(rel_color)));
let suffix = snippet_suffix(hit.snippet.as_ref());
if !suffix.is_empty() && budget > 0 {
let suffix_shown = truncate(&suffix, budget);
spans.push(Span::styled(suffix_shown, Style::default().fg(dim)));
}
ListItem::new(Line::from(spans))
})
.collect()
}
fn render_search_status(&self, f: &mut Frame, area: Rect) {
let dim = theme::palette().dim;
let txt = match self.search.as_ref() {
None => String::new(),
Some(s) if query::parse(&s.query).is_empty() => {
" type to search · kind: ext: size: content: … [Esc] back".to_string()
}
Some(s) => {
let n = s.results.len();
let state = if !s.done {
format!("searching… {n} found")
} else if n == 0 {
"no matches".to_string()
} else if s.capped {
format!("{n} results (capped at 5000)")
} else {
format!("{n} results")
};
format!(" {state} [Enter] open [Esc] back")
}
};
f.render_widget(
Paragraph::new(Line::from(txt)).style(Style::default().fg(dim)),
area,
);
}
}
const MILLER_MIN: u16 = 100;
const NAV_ANIM: Duration = Duration::from_millis(150);
const FADE_BG: Color = Color::Rgb(16, 16, 20);
const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const UNDO_DEPTH: usize = 16;
const MISSING_ROWS: usize = 4;
struct EntryListView<'a> {
entries: &'a [Entry],
order: &'a [usize],
selected: Option<usize>,
title: String,
git: Option<&'a std::collections::HashMap<String, GitStatus>>,
meta: MetaCol,
fade_t: Option<f32>,
marks: Option<&'a crate::marks::Marks>,
}
fn mark_gutter(marks: &crate::marks::Marks) -> Option<&crate::marks::Marks> {
(!marks.is_empty()).then_some(marks)
}
fn mark_glyph(icons: IconMode) -> &'static str {
match icons {
IconMode::None => "*",
_ => "◆",
}
}
fn entry_chrome_w(icons: IconMode, git: bool, marks: bool) -> u16 {
let base = match icons {
IconMode::None => 4, _ => 6, };
base + if git { 2 } else { 0 } + if marks { 2 } else { 0 }
}
fn effective_columns(layout: Layout, width: u16, has_parent: bool) -> u8 {
let wants_miller = match layout {
Layout::Miller | Layout::Auto => true,
Layout::Double => false,
};
if wants_miller && width >= MILLER_MIN && has_parent {
3
} else {
2
}
}
fn render_entry_list(
f: &mut Frame,
area: Rect,
view: &EntryListView,
state: &mut ListState,
active: bool,
filter: bool,
icons: IconMode,
) {
let block = entry_block(view, active, filter);
f.render_widget(block, area);
let inner = entry_inner(area);
let (offset, window) = visible_window(
state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, view, icons, window.clone());
let list = entry_list(items, view.fade_t);
let mut local = ListState::default();
local.select(window_selection(view.selected, &window));
render_items_into(f.buffer_mut(), inner, list, &mut local);
state.select(view.selected);
*state.offset_mut() = offset;
}
fn window_selection(selected: Option<usize>, window: &std::ops::Range<usize>) -> Option<usize> {
selected
.filter(|s| window.contains(s))
.map(|s| s - window.start)
}
#[allow(clippy::too_many_arguments)]
fn render_entry_slide(
f: &mut Frame,
area: Rect,
view: &EntryListView,
state: &ListState,
icons: IconMode,
active: bool,
filter: bool,
slide: &Slide,
now: Instant,
) {
let block = entry_block(view, active, filter);
f.render_widget(block, area);
let inner = entry_inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let (_, window) = visible_window(
state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, view, icons, window.clone());
let list = entry_list(items, view.fade_t);
let mut new_buf = Buffer::empty(inner);
let mut st = ListState::default(); st.select(window_selection(view.selected, &window));
render_items_into(&mut new_buf, inner, list, &mut st);
let t = crate::anim::ease_out_cubic(slide.anim.progress(now));
let (old_dx, new_dx) = slide_offsets(slide.dir, t, inner.width);
blit_shifted(f.buffer_mut(), &slide.old, inner, old_dx);
blit_shifted(f.buffer_mut(), &new_buf, inner, new_dx);
}
fn entry_inner(area: Rect) -> Rect {
Block::default().borders(Borders::ALL).inner(area)
}
fn fade_color(fade_t: Option<f32>, c: Color) -> Color {
match fade_t {
Some(t) => crate::anim::lerp_color(FADE_BG, c, t),
None => c,
}
}
fn entry_block(view: &EntryListView, active: bool, filter: bool) -> Block<'static> {
let accent = theme::palette().accent;
let border = if active {
if filter {
theme::palette().doc
} else {
accent
}
} else {
theme::palette().dim
};
let title_style = if active {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::palette().dim)
};
let title = Line::from(Span::styled(view.title.clone(), title_style));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border))
.title(title)
}
fn visible_window(
offset: usize,
selected: Option<usize>,
len: usize,
height: usize,
) -> (usize, std::ops::Range<usize>) {
if height == 0 || len == 0 {
return (0, 0..0);
}
let max_offset = len.saturating_sub(height);
let mut offset = offset.min(max_offset);
if let Some(sel) = selected {
let sel = sel.min(len - 1);
if sel < offset {
offset = sel; } else if sel >= offset + height {
offset = sel - height + 1; }
offset = offset.min(max_offset);
}
let end = (offset + height).min(len);
(offset, offset..end)
}
fn entry_items(
area: Rect,
view: &EntryListView,
icons: IconMode,
window: std::ops::Range<usize>,
) -> Vec<ListItem<'static>> {
let fade = |c: Color| fade_color(view.fade_t, c);
let chrome_w = entry_chrome_w(icons, view.git.is_some(), view.marks.is_some());
let inner_w = area.width.saturating_sub(chrome_w) as usize;
let size_w = 8usize;
let size_reserve = if view.meta == MetaCol::None {
0
} else {
size_w + 1
};
let name_w = inner_w.saturating_sub(size_reserve).max(4);
let now = SystemTime::now();
view.order[window]
.iter()
.map(|&i| {
let e = &view.entries[i];
let name = truncate(&e.name, name_w);
let meta_str = match view.meta {
MetaCol::Size => {
if e.kind == Format::Directory {
String::new()
} else {
crate::util::human_size(e.size)
}
}
MetaCol::Modified => e
.modified
.map(|m| crate::util::human_age(m, now))
.unwrap_or_default(),
MetaCol::None => String::new(),
};
let mut spans: Vec<Span> = Vec::with_capacity(5);
if let Some(marks) = view.marks {
if marks.contains(&e.path) {
spans.push(Span::styled(
format!("{} ", mark_glyph(icons)),
Style::default().fg(fade(theme::palette().accent)),
));
} else {
spans.push(Span::raw(" "));
}
}
let name_color = match icons {
IconMode::Unicode => {
let c = e.kind.color();
spans.push(Span::styled(
format!("{} ", e.kind.glyph()),
Style::default().fg(fade(c)),
));
c
}
IconMode::Nerd => {
let ext = e
.path
.extension()
.map(|x| x.to_string_lossy().to_lowercase())
.unwrap_or_default();
let c = icons::nerd_color(&ext, e.kind);
spans.push(Span::styled(
format!("{} ", icons::nerd_glyph(&ext, e.kind)),
Style::default().fg(fade(c)),
));
c
}
IconMode::None => e.kind.color(),
};
if let Some(git) = view.git {
match git.get(&e.name) {
Some(st) => spans.push(Span::styled(
format!("{} ", st.glyph()),
Style::default().fg(fade(st.color())),
)),
None => spans.push(Span::raw(" ")),
}
}
spans.push(Span::styled(
format!("{name:<name_w$}"),
Style::default().fg(fade(name_color)),
));
if view.meta != MetaCol::None {
spans.push(Span::styled(
format!(" {meta_str:>size_w$}"),
Style::default().fg(fade(theme::palette().dim)),
));
}
ListItem::new(Line::from(spans))
})
.collect()
}
fn entry_list<'a>(items: Vec<ListItem<'a>>, fade_t: Option<f32>) -> List<'a> {
List::new(items)
.highlight_style(
Style::default()
.bg(fade_color(fade_t, theme::palette().selection))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎ ")
}
fn render_items_into(buf: &mut Buffer, inner: Rect, list: List, state: &mut ListState) {
StatefulWidget::render(list, inner, buf, state);
}
fn slide_offsets(dir: SlideDir, t: f32, w: u16) -> (i32, i32) {
let w = w as f32;
let shift = (t * w).round() as i32; let anti = ((1.0 - t) * w).round() as i32; match dir {
SlideDir::FromRight => (-shift, anti),
SlideDir::FromLeft => (shift, -anti),
}
}
fn blit_shifted(dst: &mut Buffer, src: &Buffer, inner: Rect, dx: i32) {
let (left, right) = (inner.left() as i32, inner.right() as i32);
for y in inner.top()..inner.bottom() {
for x in inner.left()..inner.right() {
let x2 = x as i32 + dx;
if x2 < left || x2 >= right {
continue; }
let Some(cell) = src.cell((x, y)) else {
continue;
};
let cell = cell.clone();
if let Some(d) = dst.cell_mut((x2 as u16, y)) {
*d = cell;
}
}
}
}
fn read_entries(dir: &Path, sort: Sort) -> Vec<Entry> {
let mut entries = Vec::new();
if let Ok(rd) = fs::read_dir(dir) {
for ent in rd.flatten() {
let name = ent.file_name().to_string_lossy().into_owned();
let path = ent.path();
let meta = ent.metadata().ok();
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let kind = crate::format::classify(&ext, is_dir, None);
entries.push(Entry {
name,
path,
kind,
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
modified: meta.and_then(|m| m.modified().ok()),
});
}
}
entries.sort_by(|a, b| sort_cmp(a, b, sort));
entries
}
fn centered_rect(area: Rect, pct_w: u16, pct_h: u16) -> Rect {
let w = area.width * pct_w / 100;
let h = area.height * pct_h / 100;
Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
}
}
fn render_browse_help(f: &mut Frame, area: Rect, sort: Sort) {
let popup = centered_rect(area, 60, 80);
f.render_widget(Clear, popup);
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let heading = |s: &str| {
Line::from(Span::styled(
s.to_string(),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
};
let row = |keys: &str, desc: &str| {
Line::from(vec![
Span::styled(format!(" {keys:<12}"), Style::default().fg(accent)),
Span::styled(desc.to_string(), Style::default().fg(dim)),
])
};
let mut lines = vec![
heading(" Navigate"),
row("j / k", "down / up (↑/↓ too)"),
row("d / u", "half-page down / up"),
row("g / G", "top / bottom"),
row("h / l", "parent / open (←/→, Enter)"),
row("x", "open in native app (OS default)"),
row("type…", "jump to a name (typeahead)"),
Line::from(""),
heading(" Select"),
row("Space", "mark / unmark, then move down"),
row("V", "invert marks in this view"),
row("Ctrl-a", "mark everything in this view"),
row("Esc", "back out: run → clipboard → marks → quit"),
Line::from(""),
heading(" Act"),
row("y / X", "copy / cut the selection to the clipboard"),
row("p", "paste here (shows the plan first; [o] overwrites)"),
row("r", "rename (cursor lands before the extension)"),
row("a", "create (a trailing / makes a folder)"),
row(
"D",
"move to trash (shows the plan first; never permanent)",
),
row(
"U",
"undo the last operation (a trashing is Finder's to undo)",
),
row("Y", "yank absolute path(s) (OSC 52; the terminal decides)"),
Line::from(""),
heading(" Find"),
row("/", "filter this folder (kind: ext: size: modified:)"),
row("S", "recursive search (also content:)"),
Line::from(""),
heading(" Sort"),
row("o", "cycle key: name → size → modified → ext"),
row("O", "reverse direction"),
Line::from(""),
heading(" Display"),
row(".", "toggle hidden files"),
row("t", "toggle size / modified column"),
row("M", "cycle layout: auto → miller → double"),
Line::from(""),
heading(" Other"),
row("q", "quit (refused while an operation runs)"),
row("?", "close this help"),
Line::from(""),
Line::from(Span::styled(
format!(" {}", sort.label()),
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
)),
];
let inner_h = popup.height.saturating_sub(2) as usize;
lines.truncate(inner_h);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(Line::from(Span::styled(
" Keys — any key to close ",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)));
f.render_widget(Paragraph::new(Text::from(lines)).block(block), popup);
}
fn render_op_overlay(f: &mut Frame, area: Rect, view: &OpView) {
let popup = centered_rect(area, 60, 60);
f.render_widget(Clear, popup);
let accent = theme::palette().accent;
let w = popup.width.saturating_sub(2) as usize;
let h = popup.height.saturating_sub(2) as usize;
let (title, lines) = match view {
OpView::Confirm(pending) => (
format!(
" {} · {} ",
kind_title(pending.plan.kind),
confirm_keys(pending.inputs.is_some(), false)
),
confirm_lines(&pending.plan, w, h),
),
OpView::Report(report) => (
format!(" {} · [any key] close ", report_title(report)),
report_lines(report, w, h),
),
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(Line::from(Span::styled(
title,
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)));
f.render_widget(Paragraph::new(Text::from(lines)).block(block), popup);
}
fn confirm_lines(plan: &fileop::Plan, w: usize, h: usize) -> Vec<Line<'static>> {
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let danger = theme::palette().pdf;
let styled = |text: String, color: Color| {
Line::from(Span::styled(truncate(&text, w), Style::default().fg(color)))
};
let mut lines = vec![Line::from(Span::styled(
truncate(&format!(" {}", plan.summary()), w),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))];
if !plan.dest.as_os_str().is_empty() {
lines.push(styled(format!(" into {}", plan.dest.display()), dim));
}
for (text, alarming) in collision_lines(plan.policy, plan.renamed(), plan.overwrites()) {
lines.push(styled(
format!(" {text}"),
if alarming { danger } else { dim },
));
}
if !plan.missing.is_empty() {
lines.push(Line::from(""));
lines.push(styled(
format!(" {} already gone:", mark_count(plan.missing.len())),
danger,
));
let (shown, hidden) = fit_rows(plan.missing.len(), MISSING_ROWS);
for path in plan.missing.iter().take(shown) {
lines.push(styled(format!(" {}", path.display()), dim));
}
if hidden > 0 {
lines.push(styled(format!(" … and {hidden} more"), dim));
}
}
lines.push(Line::from(""));
let (shown, hidden) = fit_rows(plan.steps.len(), h.saturating_sub(lines.len()));
for step in plan.steps.iter().take(shown) {
lines.push(styled(format!(" {}", step_line(step, plan.kind)), accent));
}
if hidden > 0 {
lines.push(Line::from(Span::styled(
truncate(&format!(" … and {hidden} more"), w),
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
)));
}
lines.truncate(h);
lines
}
fn report_speaks(report: &fileop::Report) -> bool {
!report.failures.is_empty() || !report.notes.is_empty()
}
fn report_rows(report: &fileop::Report) -> Vec<(String, bool)> {
report
.failures
.iter()
.map(|failure| (failure.msg.clone(), true))
.chain(report.notes.iter().map(|note| (note.clone(), false)))
.collect()
}
fn report_lines(report: &fileop::Report, w: usize, h: usize) -> Vec<Line<'static>> {
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let danger = theme::palette().pdf;
let mut lines = vec![
Line::from(Span::styled(
truncate(
&format!(
" {}",
op_done_status(report.kind, report.direction, report.items, report.bytes)
),
w,
),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
let rows = report_rows(report);
let (shown, hidden) = fit_rows(rows.len(), h.saturating_sub(lines.len()));
for (text, alarming) in rows.iter().take(shown) {
lines.push(Line::from(Span::styled(
truncate(&format!(" {text}"), w),
Style::default().fg(if *alarming { danger } else { dim }),
)));
}
if hidden > 0 {
lines.push(Line::from(Span::styled(
truncate(&format!(" … and {hidden} more"), w),
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
)));
}
lines.truncate(h);
lines
}
fn fit_rows(total: usize, room: usize) -> (usize, usize) {
if total <= room {
return (total, 0);
}
let shown = room.saturating_sub(1);
(shown, total - shown)
}
fn leaf_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn step_line(step: &fileop::Step, kind: fileop::Kind) -> String {
let src = leaf_name(&step.src);
let dest = leaf_name(&step.dest);
match kind {
fileop::Kind::Trash => format!("{src} → trash"),
fileop::Kind::Create => dest,
fileop::Kind::Copy | fileop::Kind::Move | fileop::Kind::Rename => {
if src == dest {
src
} else {
format!("{src} → {dest}")
}
}
}
}
fn kind_title(kind: fileop::Kind) -> &'static str {
match kind {
fileop::Kind::Copy => "Copy",
fileop::Kind::Move => "Move",
fileop::Kind::Rename => "Rename",
fileop::Kind::Create => "Create",
fileop::Kind::Trash => "Move to trash",
}
}
fn done_verb(kind: fileop::Kind) -> &'static str {
match kind {
fileop::Kind::Copy => "copied",
fileop::Kind::Move => "moved",
fileop::Kind::Rename => "renamed",
fileop::Kind::Create => "created",
fileop::Kind::Trash => "trashed",
}
}
fn undone_noun(kind: fileop::Kind) -> &'static str {
match kind {
fileop::Kind::Copy => "copy",
fileop::Kind::Move => "move",
fileop::Kind::Rename => "rename",
fileop::Kind::Create => "creation",
fileop::Kind::Trash => "trashing",
}
}
fn op_done_status(
kind: fileop::Kind,
direction: fileop::Direction,
items: usize,
bytes: u64,
) -> String {
let noun = if items == 1 { "item" } else { "items" };
let mut out = match direction {
fileop::Direction::Forward => format!("{} {items} {noun}", done_verb(kind)),
fileop::Direction::Undo => format!("undid the {} of {items} {noun}", undone_noun(kind)),
};
if bytes > 0 {
out.push_str(&format!(", {}", crate::util::human_size(bytes)));
}
if kind == fileop::Kind::Trash && direction == fileop::Direction::Forward {
out.push_str(" · restore from the system trash");
}
out
}
fn report_title(report: &fileop::Report) -> String {
let what = match report.direction {
fileop::Direction::Forward => kind_title(report.kind).to_string(),
fileop::Direction::Undo => format!("Undo {}", undone_noun(report.kind)),
};
format!("{what} · {}", report_count(report))
}
fn report_count(report: &fileop::Report) -> String {
if report.failures.is_empty() {
note_count(report.notes.len())
} else {
fail_count(report.failures.len())
}
}
fn undo_label(kind: fileop::Kind) -> String {
format!("undo the {}", undone_noun(kind))
}
fn undo_landing(steps: &[fileop::Undoable]) -> Option<String> {
match steps {
[fileop::Undoable::Moved { from, .. }] => {
from.file_name().map(|n| n.to_string_lossy().into_owned())
}
_ => None,
}
}
fn op_progress_status(label: &str, items: usize, total: usize, current: &Path) -> String {
let mut out = format!("{label} · {items}/{total}");
let name = leaf_name(current);
if !name.is_empty() {
out.push_str(&format!(" · {name}"));
}
out
}
fn fail_count(n: usize) -> String {
let noun = if n == 1 { "step" } else { "steps" };
format!("{n} {noun} failed")
}
fn mark_count(n: usize) -> String {
let noun = if n == 1 { "mark" } else { "marks" };
format!("{n} {noun}")
}
fn note_count(n: usize) -> String {
let noun = if n == 1 { "note" } else { "notes" };
format!("{n} {noun}")
}
fn clipboard_text(base: &Path, paths: &[PathBuf]) -> String {
paths
.iter()
.map(|p| {
if p.is_absolute() {
p.display().to_string()
} else {
base.join(p).display().to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn yank_status(n: usize) -> String {
let noun = if n == 1 { "path" } else { "paths" };
format!("sent {n} {noun} to the terminal via OSC 52 · the terminal decides whether the clipboard takes it")
}
fn targets(marks: &crate::marks::Marks, cursor: Option<&Path>) -> Vec<PathBuf> {
if !marks.is_empty() {
return marks.marks().iter().map(|m| m.path.clone()).collect();
}
cursor.map(Path::to_path_buf).into_iter().collect()
}
fn dest_listing(dir: &Path) -> Vec<String> {
match fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
fn stem_end(name: &str, is_dir: bool) -> usize {
if !is_dir {
if let Some(dot) = name.rfind('.') {
if dot > 0 && dot + 1 < name.len() {
return name[..dot].chars().count();
}
}
}
name.chars().count()
}
fn landing_name(steps: &[fileop::Step]) -> Option<String> {
match steps {
[only] => only
.dest
.file_name()
.map(|n| n.to_string_lossy().into_owned()),
_ => None,
}
}
fn prompt_spans(prefix: &str, edit: &crate::lineedit::LineEdit, hint: &str) -> Vec<Span<'static>> {
let doc = theme::palette().doc;
let (head, under, tail) = edit.split();
vec![
Span::styled(format!(" {prefix}{head}"), Style::default().fg(doc)),
if under.is_empty() {
Span::styled("█", Style::default().fg(doc))
} else {
Span::styled(
under.to_string(),
Style::default().fg(doc).add_modifier(Modifier::REVERSED),
)
},
Span::styled(tail.to_string(), Style::default().fg(doc)),
Span::styled(format!(" {hint}"), Style::default().fg(doc)),
]
}
fn rename_plan(path: &Path, new_name: &str, cwd: &Path) -> Result<fileop::Plan, fileop::Refusal> {
let Some(parent) = path.parent() else {
return Err(fileop::Refusal::FilesystemRoot);
};
let source = rename_source(path)?;
let listing = dest_listing(parent);
fileop::plan(
fileop::Op::Rename {
source,
new_name: new_name.to_string(),
},
&fileop::PlanCtx {
dest_listing: &listing,
cwd,
missing: &[],
policy: fileop::Conflict::Rename,
},
)
}
fn rename_source(path: &Path) -> Result<fileop::Source, fileop::Refusal> {
let meta = fs::symlink_metadata(path).map_err(|e| fileop::Refusal::Io {
path: path.to_path_buf(),
msg: e.to_string(),
})?;
let ft = meta.file_type();
let kind = if ft.is_symlink() {
fileop::NodeKind::Symlink
} else if ft.is_dir() {
fileop::NodeKind::Dir
} else {
fileop::NodeKind::File
};
Ok(fileop::Source {
path: path.to_path_buf(),
kind,
nodes: Vec::new(),
items: 1,
bytes: 0,
})
}
fn create_plan(parent: &Path, name: &str, cwd: &Path) -> Result<fileop::Plan, fileop::Refusal> {
let listing = dest_listing(parent);
fileop::plan(
fileop::Op::Create {
parent: parent.to_path_buf(),
name: name.to_string(),
},
&fileop::PlanCtx {
dest_listing: &listing,
cwd,
missing: &[],
policy: fileop::Conflict::Rename,
},
)
}
fn flip_policy(policy: fileop::Conflict) -> fileop::Conflict {
match policy {
fileop::Conflict::Rename => fileop::Conflict::Overwrite,
fileop::Conflict::Overwrite => fileop::Conflict::Rename,
}
}
fn confirm_keys(toggleable: bool, alternates: bool) -> &'static str {
match (toggleable, alternates) {
(true, true) => "[Enter]/[y] run [o] overwrite [Esc]/[n] cancel",
(true, false) => "[Enter] run [o] overwrite [Esc] cancel",
(false, true) => "[Enter]/[y] run [Esc]/[n] cancel",
(false, false) => "[Enter] run [Esc] cancel",
}
}
fn collision_lines(
policy: fileop::Conflict,
renamed: usize,
overwrites: usize,
) -> Vec<(String, bool)> {
let mut out = Vec::new();
if renamed > 0 {
out.push((format!("{renamed} suffixed to avoid a collision"), false));
}
if policy == fileop::Conflict::Overwrite {
let noun = if overwrites == 1 { "entry" } else { "entries" };
out.push(if overwrites == 0 {
(
"overwrite is on, but nothing here collides".to_string(),
false,
)
} else {
(
format!("{overwrites} existing {noun} replaced, each trashed first"),
true,
)
});
}
out
}
fn clip_status(cut: bool, n: usize) -> String {
let noun = if n == 1 { "item" } else { "items" };
let verb = if cut { "move" } else { "copy" };
format!("clipboard: {n} {noun} to {verb} · [p] paste here")
}
fn clip_survives(kind: fileop::Kind) -> bool {
match kind {
fileop::Kind::Move => false,
fileop::Kind::Copy | fileop::Kind::Rename | fileop::Kind::Create | fileop::Kind::Trash => {
true
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Escape {
CancelOp,
ClearClip,
ClearMarks,
Quit,
}
fn escape(op_running: bool, has_clip: bool, has_marks: bool) -> Escape {
if op_running {
Escape::CancelOp
} else if has_clip {
Escape::ClearClip
} else if has_marks {
Escape::ClearMarks
} else {
Escape::Quit
}
}
fn reselect(names: &[&str], wanted: Option<&str>, prev: Option<usize>) -> Option<usize> {
if names.is_empty() {
return None;
}
if let Some(wanted) = wanted {
if let Some(i) = names.iter().position(|n| *n == wanted) {
return Some(i);
}
}
Some(prev.unwrap_or(0).min(names.len() - 1))
}
fn push_journal(stack: &mut Vec<fileop::Journal>, journal: fileop::Journal, depth: usize) {
stack.push(journal);
while stack.len() > depth {
stack.remove(0);
}
}
fn pretty_dir_name(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string())
}
pub fn dump(path: &str) -> String {
let mut names: Vec<String> = match fs::read_dir(path) {
Ok(rd) => rd
.flatten()
.map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
if e.path().is_dir() {
format!("{n}/")
} else {
n
}
})
.collect(),
Err(e) => return format!("sucher: {path}: {e}\n"),
};
names.sort_by_key(|n| n.to_lowercase());
let mut out = names.join("\n");
out.push('\n');
out
}
fn preview_text_width() -> usize {
let cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
((cols as usize * 58 / 100).saturating_sub(2)).max(20)
}
fn read_capped(path: &Path) -> String {
let mut f = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return String::new(),
};
let mut buf = vec![0u8; 256 * 1024];
let n = f.read(&mut buf).unwrap_or(0);
buf.truncate(n);
String::from_utf8_lossy(&buf).into_owned()
}
fn preview_block(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::palette().dim))
.title(title)
}
fn preview_caption(s: &str) -> Line<'static> {
Line::from(Span::styled(
s.to_string(),
Style::default()
.fg(theme::palette().accent)
.add_modifier(Modifier::BOLD),
))
}
fn caption_title(caption: &str, area: Rect) -> Line<'static> {
let shown = truncate(caption, area.width.saturating_sub(4) as usize);
preview_caption(&format!(" {shown} "))
}
fn no_preview() -> Line<'static> {
Line::from(Span::styled(
"No preview",
Style::default().fg(theme::palette().dim),
))
}
fn head_text(path: &Path, max_bytes: usize, max_lines: usize) -> Option<String> {
let mut f = fs::File::open(path).ok()?;
let mut buf = vec![0u8; max_bytes];
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
if buf.contains(&0) {
return None; }
let s = String::from_utf8_lossy(&buf);
Some(s.lines().take(max_lines).collect::<Vec<_>>().join("\n"))
}
fn head_spans(head: &git::RepoHead, dirty: bool, icons: IconMode) -> Vec<Span<'static>> {
let name = match (&head.branch, &head.oid_short) {
(Some(branch), _) => branch.clone(),
(None, Some(oid)) => format!("@{oid}"),
(None, None) => return Vec::new(),
};
let p = theme::palette();
let ascii = icons == IconMode::None;
let glyph = match icons {
IconMode::Nerd => "\u{e0a0}",
IconMode::Unicode => "⎇",
IconMode::None => "git:",
};
let mut spans = vec![
Span::styled(glyph.to_string(), Style::default().fg(p.dim)),
Span::raw(" "),
Span::styled(name, Style::default().fg(p.accent)),
];
if let Some((ahead, behind)) = head.ahead_behind {
if ahead > 0 {
let txt = if ascii {
format!(" +{ahead}")
} else {
format!(" ↑{ahead}")
};
spans.push(Span::styled(txt, Style::default().fg(p.sheet)));
}
if behind > 0 {
let txt = if ascii {
format!(" -{behind}")
} else {
format!(" ↓{behind}")
};
spans.push(Span::styled(txt, Style::default().fg(p.pdf)));
}
}
if dirty {
let dot = if ascii { " *" } else { " ●" };
spans.push(Span::styled(dot.to_string(), Style::default().fg(p.doc)));
}
spans.push(Span::raw(" "));
spans
}
fn crumb_segments(cwd: &Path, home: Option<&Path>) -> Vec<(String, PathBuf)> {
if let Some(home) = home {
if let Ok(rest) = cwd.strip_prefix(home) {
let mut out = vec![("~".to_string(), home.to_path_buf())];
let mut acc = home.to_path_buf();
for comp in rest.components() {
let name = comp.as_os_str().to_string_lossy().into_owned();
acc = acc.join(&name);
out.push((name, acc.clone()));
}
return out;
}
}
let mut out = vec![("/".to_string(), PathBuf::from("/"))];
let mut acc = PathBuf::from("/");
for comp in cwd.components() {
if let std::path::Component::Normal(os) = comp {
let name = os.to_string_lossy().into_owned();
acc = acc.join(&name);
out.push((name, acc.clone()));
}
}
out
}
fn crumb_hit(hits: &[(Range<u16>, PathBuf)], x: u16) -> Option<PathBuf> {
hits.iter()
.find(|(range, _)| range.contains(&x))
.map(|(_, target)| target.clone())
}
fn rect_contains(r: Rect, col: u16, row: u16) -> bool {
col >= r.x
&& col < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
fn row_to_index(
list_area: Rect,
offset: usize,
row: u16,
col: u16,
view_len: usize,
) -> Option<usize> {
if col < list_area.x || col >= list_area.x.saturating_add(list_area.width) {
return None;
}
let first_row = list_area.y.saturating_add(1);
let last_inner = list_area
.y
.saturating_add(list_area.height)
.saturating_sub(2);
if row < first_row || row > last_inner {
return None; }
let idx = offset + (row - first_row) as usize;
(idx < view_len).then_some(idx)
}
fn mark_advance(cur: Option<usize>, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
Some((cur.unwrap_or(0) + 1).min(len - 1))
}
fn marks_status(total: usize, dirs: usize, bytes: u64) -> String {
let files = total.saturating_sub(dirs);
let folders = if dirs == 1 { "folder" } else { "folders" };
let size = if dirs == 0 {
crate::util::human_size(bytes)
} else if files == 0 {
format!("{dirs} {folders}")
} else {
format!("{} + {dirs} {folders}", crate::util::human_size(bytes))
};
format!("{total} marked · {size} · [y] copy [X] cut [D] trash")
}
fn browse_hint(width: u16, show_hidden: bool) -> String {
let dot = if show_hidden {
"[.] dot (shown)"
} else {
"[.] dot (hidden)"
};
let mut segs: Vec<(&str, u8)> = vec![
("[j/k] move", 4),
("[Enter] open", 3),
("[Space] mark", 2),
("[/] filter", 5),
("[S] search", 6),
(dot, 7),
("[?] help", 0),
];
let budget = width.saturating_sub(1) as usize;
loop {
let line = segs.iter().map(|(s, _)| *s).collect::<Vec<_>>().join(" ");
if line.chars().count() <= budget || segs.len() == 1 {
return line;
}
let worst = segs
.iter()
.enumerate()
.max_by_key(|(_, (_, rank))| *rank)
.map(|(i, _)| i)
.unwrap_or(0);
segs.remove(worst);
}
}
fn search_sel(cur: Option<usize>, delta: isize, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
let cur = cur.unwrap_or(0) as isize;
Some((cur + delta).clamp(0, len as isize - 1) as usize)
}
fn snippet_suffix(snippet: Option<&(u64, String)>) -> String {
match snippet {
Some((lnum, text)) => format!(" {lnum}: {text}"),
None => String::new(),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let t: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn pad_cell(s: &str, w: usize) -> String {
let t = truncate(s, w);
let len = t.chars().count();
format!("{t}{}", " ".repeat(w.saturating_sub(len)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn entry(name: &str, kind: Format, size: u64, mtime: Option<SystemTime>) -> Entry {
Entry {
name: name.to_string(),
path: PathBuf::from(name),
kind,
size,
modified: mtime,
}
}
fn sorted_names(mut v: Vec<Entry>, sort: Sort) -> Vec<String> {
v.sort_by(|a, b| sort_cmp(a, b, sort));
v.into_iter().map(|e| e.name).collect()
}
#[test]
fn dirs_always_sort_before_files_regardless_of_key_or_reverse() {
let v = || {
vec![
entry("zzz.txt", Format::Text, 1, None),
entry("adir", Format::Directory, 999, None),
]
};
for reverse in [false, true] {
let names = sorted_names(
v(),
Sort {
key: SortKey::Size,
reverse,
},
);
assert_eq!(names[0], "adir", "dir must lead (reverse={reverse})");
}
}
#[test]
fn name_sort_is_case_insensitive_and_reverses() {
let v = || {
vec![
entry("Banana", Format::Text, 0, None),
entry("apple", Format::Text, 0, None),
entry("Cherry", Format::Text, 0, None),
]
};
assert_eq!(
sorted_names(v(), Sort::default()),
vec!["apple", "Banana", "Cherry"]
);
assert_eq!(
sorted_names(
v(),
Sort {
key: SortKey::Name,
reverse: true
}
),
vec!["Cherry", "Banana", "apple"]
);
}
#[test]
fn size_sort_orders_ascending_then_breaks_ties_by_name() {
let v = vec![
entry("big", Format::Text, 100, None),
entry("small", Format::Text, 10, None),
entry("mid_b", Format::Text, 50, None),
entry("mid_a", Format::Text, 50, None), ];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Size,
reverse: false
}
),
vec!["small", "mid_a", "mid_b", "big"]
);
}
#[test]
fn modified_sort_oldest_first_missing_counts_as_oldest() {
let base = SystemTime::UNIX_EPOCH;
let older = base + Duration::from_secs(100);
let newer = base + Duration::from_secs(200);
let v = vec![
entry("new", Format::Text, 0, Some(newer)),
entry("none", Format::Text, 0, None), entry("old", Format::Text, 0, Some(older)),
];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Modified,
reverse: false
}
),
vec!["none", "old", "new"]
);
}
#[test]
fn ext_sort_groups_by_extension_then_name() {
let v = vec![
entry("b.rs", Format::Text, 0, None),
entry("a.rs", Format::Text, 0, None),
entry("c.md", Format::Text, 0, None),
entry("readme", Format::Text, 0, None), ];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Ext,
reverse: false
}
),
vec!["readme", "c.md", "a.rs", "b.rs"]
);
}
#[test]
fn name_ext_handles_dotfiles_and_missing() {
assert_eq!(name_ext("photo.JPG"), "JPG"); assert_eq!(name_ext("archive.tar.gz"), "gz"); assert_eq!(name_ext("README"), ""); assert_eq!(name_ext(".gitignore"), ""); }
#[test]
fn cmp_name_ci_is_case_insensitive_and_ordered() {
use std::cmp::Ordering;
assert_eq!(cmp_name_ci("Apple", "apple"), Ordering::Equal);
assert_eq!(cmp_name_ci("Apple", "banana"), Ordering::Less);
assert_eq!(cmp_name_ci("apple", "Banana"), Ordering::Less);
assert_eq!(cmp_name_ci("BANANA", "apple"), Ordering::Greater);
assert_eq!(cmp_name_ci("app", "apple"), Ordering::Less);
assert_eq!(cmp_name_ci("apple", "app"), Ordering::Greater);
}
#[test]
fn ext_sort_order_is_case_insensitive() {
let v = vec![
entry("b.RS", Format::Text, 0, None),
entry("a.rs", Format::Text, 0, None),
entry("c.Md", Format::Text, 0, None),
];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Ext,
reverse: false
}
),
vec!["c.Md", "a.rs", "b.RS"] );
}
#[test]
fn search_hits_sort_by_relative_path_grouped_by_folder() {
let hit = |rel: &str| crate::search::Hit {
path: PathBuf::from(rel),
rel: rel.to_string(),
kind: Format::Text,
size: 0,
modified: None,
snippet: None,
};
let mut v = [
hit("zzz.txt"),
hit("src/b.rs"),
hit("src/a.rs"),
hit("readme.md"),
];
v.sort_by(|a, b| sort_cmp(a, b, Sort::default()));
let rels: Vec<&str> = v.iter().map(|h| h.rel.as_str()).collect();
assert_eq!(rels, vec!["readme.md", "src/a.rs", "src/b.rs", "zzz.txt"]);
}
#[test]
fn sort_key_cycles_through_all_four() {
let k = SortKey::Name;
let k = k.cycle();
assert!(matches!(k, SortKey::Size));
let k = k.cycle();
assert!(matches!(k, SortKey::Modified));
let k = k.cycle();
assert!(matches!(k, SortKey::Ext));
let k = k.cycle();
assert!(matches!(k, SortKey::Name)); }
#[test]
fn miller_three_columns_only_when_wide_and_parented() {
assert_eq!(effective_columns(Layout::Miller, MILLER_MIN, true), 3);
assert_eq!(effective_columns(Layout::Miller, 200, true), 3);
assert_eq!(effective_columns(Layout::Miller, MILLER_MIN - 1, true), 2);
assert_eq!(effective_columns(Layout::Miller, 200, false), 2);
}
#[test]
fn double_is_always_two_columns() {
assert_eq!(effective_columns(Layout::Double, 200, true), 2);
assert_eq!(effective_columns(Layout::Double, 40, true), 2);
}
#[test]
fn crumb_segments_under_home_are_tilde_anchored() {
let home = PathBuf::from("/Users/j");
let segs = crumb_segments(Path::new("/Users/j/src/app"), Some(&home));
assert_eq!(
segs,
vec![
("~".to_string(), PathBuf::from("/Users/j")),
("src".to_string(), PathBuf::from("/Users/j/src")),
("app".to_string(), PathBuf::from("/Users/j/src/app")),
]
);
assert_eq!(
crumb_segments(&home, Some(&home)),
vec![("~".to_string(), PathBuf::from("/Users/j"))]
);
}
#[test]
fn crumb_segments_outside_home_are_root_anchored() {
let home = PathBuf::from("/Users/j");
let segs = crumb_segments(Path::new("/usr/local/bin"), Some(&home));
assert_eq!(
segs,
vec![
("/".to_string(), PathBuf::from("/")),
("usr".to_string(), PathBuf::from("/usr")),
("local".to_string(), PathBuf::from("/usr/local")),
("bin".to_string(), PathBuf::from("/usr/local/bin")),
]
);
assert_eq!(
crumb_segments(Path::new("/"), Some(&home)),
vec![("/".to_string(), PathBuf::from("/"))]
);
assert_eq!(
crumb_segments(Path::new("/etc"), None),
vec![
("/".to_string(), PathBuf::from("/")),
("etc".to_string(), PathBuf::from("/etc")),
]
);
}
fn spans_text(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn head_spans_branch_ahead_behind_dirty() {
let head = git::RepoHead {
branch: Some("main".to_string()),
oid_short: Some("0123456".to_string()),
ahead_behind: Some((2, 1)),
};
let s = head_spans(&head, true, IconMode::Unicode);
assert_eq!(spans_text(&s), "⎇ main ↑2 ↓1 ● ");
}
#[test]
fn head_spans_in_sync_clean_is_just_the_branch() {
let head = git::RepoHead {
branch: Some("main".to_string()),
oid_short: Some("0123456".to_string()),
ahead_behind: Some((0, 0)),
};
assert_eq!(
spans_text(&head_spans(&head, false, IconMode::Unicode)),
"⎇ main "
);
}
#[test]
fn head_spans_detached_shows_short_oid() {
let head = git::RepoHead {
branch: None,
oid_short: Some("abc1234".to_string()),
ahead_behind: None,
};
assert_eq!(
spans_text(&head_spans(&head, false, IconMode::Nerd)),
"\u{e0a0} @abc1234 "
);
}
#[test]
fn head_spans_ascii_mode_is_pure_ascii() {
let head = git::RepoHead {
branch: Some("feat/x".to_string()),
oid_short: Some("abc1234".to_string()),
ahead_behind: Some((3, 0)),
};
let text = spans_text(&head_spans(&head, true, IconMode::None));
assert_eq!(text, "git: feat/x +3 * ");
assert!(text.is_ascii());
}
#[test]
fn head_spans_unborn_repo_renders_nothing() {
let head = git::RepoHead {
branch: None,
oid_short: None,
ahead_behind: None,
};
assert!(head_spans(&head, true, IconMode::Unicode).is_empty());
}
#[test]
fn crumb_hit_resolves_column_to_target() {
let hits = vec![
(1u16..2u16, PathBuf::from("/Users/j")), (3u16..6u16, PathBuf::from("/Users/j/src")), ];
assert_eq!(crumb_hit(&hits, 1), Some(PathBuf::from("/Users/j")));
assert_eq!(crumb_hit(&hits, 3), Some(PathBuf::from("/Users/j/src")));
assert_eq!(crumb_hit(&hits, 5), Some(PathBuf::from("/Users/j/src")));
assert_eq!(crumb_hit(&hits, 2), None);
assert_eq!(crumb_hit(&hits, 6), None);
assert_eq!(crumb_hit(&hits, 0), None);
}
#[test]
fn row_to_index_maps_clicks_inside_the_pane() {
let area = Rect {
x: 10,
y: 2,
width: 30,
height: 10,
};
assert_eq!(row_to_index(area, 0, 3, 15, 5), Some(0));
assert_eq!(row_to_index(area, 0, 4, 15, 5), Some(1));
assert_eq!(row_to_index(area, 0, 2, 15, 5), None);
assert_eq!(row_to_index(area, 0, 8, 15, 5), None);
assert_eq!(row_to_index(area, 0, 11, 15, 5), None);
assert_eq!(row_to_index(area, 0, 3, 9, 5), None); assert_eq!(row_to_index(area, 0, 3, 40, 5), None); assert_eq!(row_to_index(area, 12, 3, 15, 100), Some(12));
assert_eq!(row_to_index(area, 12, 5, 15, 100), Some(14));
assert_eq!(row_to_index(area, 12, 5, 15, 13), None);
}
#[test]
fn visible_window_scrolls_to_keep_selection_visible() {
assert_eq!(visible_window(0, Some(3), 100, 10), (0, 0..10));
assert_eq!(visible_window(5, Some(8), 100, 10), (5, 5..15));
assert_eq!(visible_window(0, Some(50), 100, 10), (41, 41..51));
assert_eq!(visible_window(0, Some(9), 100, 10), (0, 0..10)); assert_eq!(visible_window(0, Some(10), 100, 10), (1, 1..11)); assert_eq!(visible_window(40, Some(12), 100, 10), (12, 12..22));
assert_eq!(visible_window(999, None, 100, 10), (90, 90..100));
assert_eq!(visible_window(0, Some(99), 100, 10), (90, 90..100));
assert_eq!(visible_window(0, None, 0, 10), (0, 0..0));
assert_eq!(visible_window(7, Some(3), 0, 10), (0, 0..0));
assert_eq!(visible_window(0, Some(0), 100, 0), (0, 0..0));
assert_eq!(visible_window(0, Some(2), 4, 10), (0, 0..4));
assert_eq!(visible_window(30, Some(0), 5, 40), (0, 0..5));
}
#[test]
fn rect_contains_includes_borders_excludes_beyond() {
let r = Rect {
x: 5,
y: 1,
width: 4,
height: 3,
};
assert!(rect_contains(r, 5, 1)); assert!(rect_contains(r, 8, 3)); assert!(!rect_contains(r, 9, 3)); assert!(!rect_contains(r, 8, 4)); assert!(!rect_contains(r, 4, 2)); }
#[test]
fn slide_offsets_endpoints_and_midpoint() {
assert_eq!(slide_offsets(SlideDir::FromRight, 0.0, 40), (0, 40));
assert_eq!(slide_offsets(SlideDir::FromRight, 1.0, 40), (-40, 0));
assert_eq!(slide_offsets(SlideDir::FromRight, 0.5, 40), (-20, 20));
assert_eq!(slide_offsets(SlideDir::FromLeft, 0.0, 40), (0, -40));
assert_eq!(slide_offsets(SlideDir::FromLeft, 1.0, 40), (40, 0));
assert_eq!(slide_offsets(SlideDir::FromLeft, 0.5, 40), (20, -20));
}
#[test]
fn search_sel_clamps_and_handles_empty() {
assert_eq!(search_sel(None, 1, 0), None);
assert_eq!(search_sel(Some(0), -1, 0), None);
assert_eq!(search_sel(None, 0, 5), Some(0));
assert_eq!(search_sel(None, 1, 5), Some(1));
assert_eq!(search_sel(Some(2), 1, 5), Some(3));
assert_eq!(search_sel(Some(2), -1, 5), Some(1));
assert_eq!(search_sel(Some(0), -1, 5), Some(0));
assert_eq!(search_sel(Some(4), 1, 5), Some(4));
assert_eq!(search_sel(Some(4), 10, 5), Some(4));
assert_eq!(search_sel(Some(1), -10, 5), Some(0));
}
#[test]
fn snippet_suffix_formats_or_empties() {
assert_eq!(
snippet_suffix(Some(&(42, "let x = 1;".to_string()))),
" 42: let x = 1;"
);
assert_eq!(snippet_suffix(None), "");
}
#[test]
fn mark_keys_are_bound_chars_so_typeahead_passes_them_through() {
assert!(matches!(browse_char(' '), Some(CharAction::ToggleMark)));
assert!(matches!(browse_char('V'), Some(CharAction::InvertMarks)));
for c in [' ', 'V'] {
assert!(matches!(
typeahead::action(false, browse_char(c).is_some()),
typeahead::Action::PassThrough
));
}
assert!(browse_char('R').is_none());
assert!(matches!(browse_char('a'), Some(CharAction::Create)));
}
#[test]
fn mark_advance_steps_down_and_stops_at_the_last_row() {
assert_eq!(mark_advance(None, 0), None);
assert_eq!(mark_advance(Some(3), 0), None);
assert_eq!(mark_advance(None, 5), Some(1));
assert_eq!(mark_advance(Some(0), 5), Some(1));
assert_eq!(mark_advance(Some(3), 5), Some(4));
assert_eq!(mark_advance(Some(4), 5), Some(4));
assert_eq!(mark_advance(Some(0), 1), Some(0));
}
#[test]
fn mark_gutter_costs_two_cells_only_when_it_is_drawn() {
for icons in [IconMode::None, IconMode::Unicode, IconMode::Nerd] {
for git in [false, true] {
let without = entry_chrome_w(icons, git, false);
let with = entry_chrome_w(icons, git, true);
assert_eq!(with, without + 2, "{git}");
}
}
assert_eq!(entry_chrome_w(IconMode::None, false, false), 4);
assert_eq!(entry_chrome_w(IconMode::Unicode, false, false), 6);
assert_eq!(entry_chrome_w(IconMode::Nerd, false, false), 6);
assert_eq!(entry_chrome_w(IconMode::Unicode, true, false), 8);
assert_eq!(entry_chrome_w(IconMode::Unicode, true, true), 10);
assert_eq!(entry_chrome_w(IconMode::None, true, true), 8);
}
#[test]
fn mark_gutter_is_some_only_when_something_is_marked() {
let mut marks = crate::marks::Marks::new();
assert!(mark_gutter(&marks).is_none());
marks.insert(Path::new("/a/b.txt"), 10, false);
assert!(mark_gutter(&marks).is_some());
marks.clear();
assert!(mark_gutter(&marks).is_none());
}
#[test]
fn mark_glyph_is_one_cell_and_ascii_under_icon_mode_none() {
assert_eq!(mark_glyph(IconMode::None), "*");
for icons in [IconMode::None, IconMode::Unicode, IconMode::Nerd] {
assert_eq!(mark_glyph(icons).chars().count(), 1);
}
assert!(mark_glyph(IconMode::None).is_ascii());
}
#[test]
fn marks_status_never_implies_folder_contents_were_measured() {
const KEYS: &str = " · [y] copy [X] cut [D] trash";
assert_eq!(
marks_status(3, 0, 1_200_000),
format!("3 marked · 1.1M{KEYS}")
);
assert_eq!(marks_status(1, 0, 0), format!("1 marked · 0 B{KEYS}"));
assert_eq!(
marks_status(3, 1, 2048),
format!("3 marked · 2.0K + 1 folder{KEYS}")
);
assert_eq!(
marks_status(5, 2, 2048),
format!("5 marked · 2.0K + 2 folders{KEYS}")
);
assert_eq!(marks_status(1, 1, 0), format!("1 marked · 1 folder{KEYS}"));
assert_eq!(marks_status(2, 2, 0), format!("2 marked · 2 folders{KEYS}"));
}
#[test]
fn the_idle_hint_fits_its_pane_and_never_drops_the_help_pointer() {
let full = browse_hint(200, false);
assert_eq!(
full,
"[j/k] move [Enter] open [Space] mark [/] filter [S] search [.] dot (hidden) [?] help"
);
assert!(browse_hint(200, true).contains("[.] dot (shown)"));
for w in 10u16..=200 {
let hint = browse_hint(w, false);
assert!(
hint.chars().count() <= (w as usize).saturating_sub(1) || hint == "[?] help",
"width {w} overflowed: {hint:?}"
);
assert!(hint.contains("[?] help"), "width {w} lost the pointer");
}
let tight = browse_hint(40, false);
assert!(tight.contains("[Space] mark"), "{tight:?}");
assert!(!tight.contains("[.] dot"), "{tight:?}");
}
fn trash_step(src: &str, items: usize, bytes: u64) -> fileop::Step {
fileop::Step {
src: PathBuf::from(src),
dest: PathBuf::new(),
kind: fileop::NodeKind::File,
nodes: Vec::new(),
items,
bytes,
renamed: false,
overwrite: false,
}
}
#[test]
fn trash_is_a_bound_char_so_typeahead_passes_it_through() {
assert!(matches!(browse_char('D'), Some(CharAction::Trash)));
assert!(matches!(
typeahead::action(false, browse_char('D').is_some()),
typeahead::Action::PassThrough
));
assert!(matches!(browse_char('d'), Some(CharAction::HalfDown)));
}
#[test]
fn targets_prefer_the_mark_set_and_fall_back_to_the_cursor() {
let mut marks = crate::marks::Marks::new();
let cursor = PathBuf::from("/here/under-cursor.txt");
assert_eq!(
targets(&marks, Some(cursor.as_path())),
vec![cursor.clone()]
);
assert!(targets(&marks, None).is_empty());
marks.insert(Path::new("/a/one.txt"), 1, false);
marks.insert(Path::new("/b/two.txt"), 2, false);
assert_eq!(
targets(&marks, Some(cursor.as_path())),
vec![PathBuf::from("/a/one.txt"), PathBuf::from("/b/two.txt")]
);
marks.clear();
marks.insert(Path::new("/z/last.txt"), 1, false);
marks.insert(Path::new("/a/first.txt"), 1, false);
assert_eq!(
targets(&marks, None),
vec![PathBuf::from("/z/last.txt"), PathBuf::from("/a/first.txt")]
);
}
#[test]
fn the_undo_stack_is_bounded_and_evicts_the_oldest() {
let journal = |n: usize| fileop::Journal {
kind: fileop::Kind::Trash,
steps: vec![fileop::Undoable::Trashed {
path: PathBuf::from(format!("/gone/{n}")),
}],
};
let mut stack = Vec::new();
for n in 0..UNDO_DEPTH {
push_journal(&mut stack, journal(n), UNDO_DEPTH);
}
assert_eq!(stack.len(), UNDO_DEPTH);
assert_eq!(stack[0], journal(0));
push_journal(&mut stack, journal(99), UNDO_DEPTH);
assert_eq!(stack.len(), UNDO_DEPTH);
assert_eq!(stack[0], journal(1));
assert_eq!(stack[UNDO_DEPTH - 1], journal(99));
for n in 100..120 {
push_journal(&mut stack, journal(n), UNDO_DEPTH);
}
assert_eq!(stack.len(), UNDO_DEPTH);
assert_eq!(stack[UNDO_DEPTH - 1], journal(119));
}
fn report(
kind: fileop::Kind,
direction: fileop::Direction,
failures: &[&str],
notes: &[&str],
) -> fileop::Report {
fileop::Report {
kind,
direction,
items: 1,
bytes: 0,
failures: failures
.iter()
.map(|msg| fileop::Failure {
path: PathBuf::from("/somewhere"),
msg: (*msg).to_string(),
})
.collect(),
notes: notes.iter().map(|n| (*n).to_string()).collect(),
journal: fileop::Journal {
kind,
steps: Vec::new(),
},
}
}
#[test]
fn undo_and_yank_path_are_bound_chars_so_typeahead_passes_them_through() {
assert!(matches!(browse_char('U'), Some(CharAction::Undo)));
assert!(matches!(browse_char('Y'), Some(CharAction::YankPath)));
for c in ['U', 'Y'] {
assert!(matches!(
typeahead::action(false, browse_char(c).is_some()),
typeahead::Action::PassThrough
));
}
assert!(matches!(browse_char('u'), Some(CharAction::HalfUp)));
assert!(matches!(browse_char('y'), Some(CharAction::Yank)));
}
#[test]
fn the_done_status_names_the_direction_the_run_travelled() {
let fwd = fileop::Direction::Forward;
let undo = fileop::Direction::Undo;
let cases = [
(fileop::Kind::Copy, fwd, "copied 2 items"),
(fileop::Kind::Copy, undo, "undid the copy of 2 items"),
(fileop::Kind::Move, fwd, "moved 2 items"),
(fileop::Kind::Move, undo, "undid the move of 2 items"),
(fileop::Kind::Rename, fwd, "renamed 2 items"),
(fileop::Kind::Rename, undo, "undid the rename of 2 items"),
(fileop::Kind::Create, fwd, "created 2 items"),
(fileop::Kind::Create, undo, "undid the creation of 2 items"),
(
fileop::Kind::Trash,
fwd,
"trashed 2 items · restore from the system trash",
),
(fileop::Kind::Trash, undo, "undid the trashing of 2 items"),
];
for (kind, direction, expected) in cases {
assert_eq!(op_done_status(kind, direction, 2, 0), expected);
}
assert!(!op_done_status(fileop::Kind::Trash, undo, 0, 0).contains("restore from"));
assert_eq!(
op_done_status(fileop::Kind::Rename, undo, 1, 0),
"undid the rename of 1 item"
);
assert_eq!(
op_done_status(fileop::Kind::Move, undo, 1, 1024),
"undid the move of 1 item, 1.0K"
);
}
#[test]
fn the_undo_progress_label_names_the_operation_being_reversed() {
assert_eq!(undo_label(fileop::Kind::Move), "undo the move");
assert_eq!(undo_label(fileop::Kind::Create), "undo the creation");
assert_eq!(
op_progress_status(&undo_label(fileop::Kind::Move), 1, 3, Path::new("/a/b.txt")),
"undo the move · 1/3 · b.txt"
);
}
#[test]
fn a_report_with_only_notes_still_opens_the_overlay() {
let quiet = report(fileop::Kind::Copy, fileop::Direction::Forward, &[], &[]);
assert!(!report_speaks(&quiet), "a clean run has nothing to add");
let noted = report(
fileop::Kind::Trash,
fileop::Direction::Undo,
&[],
&["/a.txt went to the system trash"],
);
assert!(report_speaks(¬ed));
let failed = report(
fileop::Kind::Copy,
fileop::Direction::Forward,
&["cannot copy /a.txt"],
&[],
);
assert!(report_speaks(&failed));
let both = report(
fileop::Kind::Move,
fileop::Direction::Undo,
&["cannot put /a.txt back"],
&["/b.txt went to the system trash"],
);
assert!(report_speaks(&both));
}
#[test]
fn notes_render_beside_failures_and_never_in_the_danger_colour() {
let both = report(
fileop::Kind::Move,
fileop::Direction::Undo,
&["cannot put /a.txt back"],
&["/b.txt went to the system trash"],
);
assert_eq!(
report_rows(&both),
vec![
("cannot put /a.txt back".to_string(), true),
("/b.txt went to the system trash".to_string(), false),
]
);
let danger = theme::palette().pdf;
let dim = theme::palette().dim;
assert_ne!(danger, dim, "the two readings must be distinguishable");
let lines = report_lines(&both, 80, 10);
let fg = |i: usize| lines[i].spans[0].style.fg;
assert_eq!(fg(2), Some(danger), "a failure keeps the danger colour");
assert_eq!(fg(3), Some(dim), "a note takes the informational one");
assert!(lines[0].spans[0].content.contains("undid the move"));
}
#[test]
fn the_report_title_names_the_direction_and_counts_what_it_has() {
let undone = report(
fileop::Kind::Trash,
fileop::Direction::Undo,
&[],
&["/a.txt went to the system trash"],
);
assert_eq!(report_title(&undone), "Undo trashing · 1 note");
assert_eq!(report_count(&undone), "1 note");
let failed = report(
fileop::Kind::Trash,
fileop::Direction::Forward,
&["no trash here", "nor here"],
&[],
);
assert_eq!(report_title(&failed), "Move to trash · 2 steps failed");
let both = report(
fileop::Kind::Move,
fileop::Direction::Undo,
&["cannot put /a.txt back"],
&["/b.txt went to the system trash"],
);
assert_eq!(report_title(&both), "Undo move · 1 step failed");
}
#[test]
fn an_undo_lands_the_cursor_only_on_something_it_restored() {
assert_eq!(
undo_landing(&[fileop::Undoable::Moved {
from: PathBuf::from("/here/before.txt"),
to: PathBuf::from("/here/after.txt"),
}]),
Some("before.txt".to_string())
);
assert_eq!(
undo_landing(&[fileop::Undoable::Created {
path: PathBuf::from("/here/made.txt"),
}]),
None
);
assert_eq!(
undo_landing(&[fileop::Undoable::Trashed {
path: PathBuf::from("/here/gone.txt"),
}]),
None
);
assert_eq!(
undo_landing(&[
fileop::Undoable::Moved {
from: PathBuf::from("/a"),
to: PathBuf::from("/b"),
},
fileop::Undoable::Moved {
from: PathBuf::from("/c"),
to: PathBuf::from("/d"),
},
]),
None
);
assert_eq!(undo_landing(&[]), None);
}
#[test]
fn a_yank_sends_absolute_lines_and_claims_nothing_about_the_clipboard() {
let base = Path::new("/home/j/src");
assert_eq!(
clipboard_text(base, &[PathBuf::from("/a/one.txt")]),
"/a/one.txt"
);
assert_eq!(
clipboard_text(
base,
&[PathBuf::from("/a/one.txt"), PathBuf::from("/b/two.txt")]
),
"/a/one.txt\n/b/two.txt"
);
assert_eq!(
clipboard_text(base, &[PathBuf::from("rel.txt")]),
"/home/j/src/rel.txt"
);
assert_eq!(clipboard_text(base, &[]), "");
let one = yank_status(1);
assert_eq!(
one,
"sent 1 path to the terminal via OSC 52 · the terminal decides whether the clipboard takes it"
);
assert!(yank_status(3).starts_with("sent 3 paths"));
assert!(
!one.contains("copied") && !one.contains("clipboard now"),
"the status must not claim the clipboard changed: {one}"
);
}
#[test]
fn fit_rows_spends_a_row_saying_what_it_is_not_showing() {
assert_eq!(fit_rows(0, 10), (0, 0));
assert_eq!(fit_rows(8, 10), (8, 0));
assert_eq!(fit_rows(10, 10), (10, 0));
assert_eq!(fit_rows(11, 10), (9, 2));
assert_eq!(fit_rows(41, 9), (8, 33));
assert_eq!(fit_rows(41, 1), (0, 41));
assert_eq!(fit_rows(41, 0), (0, 41));
assert_eq!(fit_rows(0, 0), (0, 0));
for total in 0..20 {
for room in 0..20 {
let (shown, hidden) = fit_rows(total, room);
assert_eq!(shown + hidden, total, "{total} in {room}");
if room > 0 {
assert!(shown + usize::from(hidden > 0) <= room, "{total} in {room}");
}
}
}
}
#[test]
fn step_lines_name_both_ends_only_when_they_differ() {
assert_eq!(
step_line(&trash_step("/here/notes.md", 1, 12), fileop::Kind::Trash),
"notes.md → trash"
);
let mut copied = trash_step("/src/a.txt", 1, 3);
copied.dest = PathBuf::from("/dst/a (2).txt");
copied.renamed = true;
assert_eq!(
step_line(&copied, fileop::Kind::Copy),
"a.txt → a (2).txt"
);
let mut plain = trash_step("/src/a.txt", 1, 3);
plain.dest = PathBuf::from("/dst/a.txt");
assert_eq!(step_line(&plain, fileop::Kind::Move), "a.txt");
let mut made = trash_step("", 1, 0);
made.dest = PathBuf::from("/dst/new.md");
assert_eq!(step_line(&made, fileop::Kind::Create), "new.md");
}
#[test]
fn operation_status_lines_read_as_sentences() {
let forward = fileop::Direction::Forward;
assert_eq!(
op_done_status(fileop::Kind::Trash, forward, 3, 2048),
"trashed 3 items, 2.0K · restore from the system trash"
);
assert_eq!(
op_done_status(fileop::Kind::Create, forward, 1, 0),
"created 1 item"
);
assert_eq!(
op_done_status(fileop::Kind::Copy, forward, 2, 1024),
"copied 2 items, 1.0K"
);
assert_eq!(
op_progress_status("trash 3 items, 2.0K", 2, 3, Path::new("/a/b/notes.md")),
"trash 3 items, 2.0K · 2/3 · notes.md"
);
assert_eq!(
op_progress_status("trash 3 items", 0, 3, Path::new("")),
"trash 3 items · 0/3"
);
assert_eq!(fail_count(1), "1 step failed");
assert_eq!(fail_count(4), "4 steps failed");
assert_eq!(mark_count(1), "1 mark");
assert_eq!(mark_count(2), "2 marks");
assert_eq!(note_count(1), "1 note");
assert_eq!(note_count(2), "2 notes");
}
#[test]
fn reselect_keeps_the_name_then_the_row() {
let after = ["a.txt", "b.txt", "c.txt"];
assert_eq!(reselect(&after, Some("c.txt"), Some(0)), Some(2));
assert_eq!(reselect(&after, Some("gone.txt"), Some(1)), Some(1));
assert_eq!(reselect(&after, Some("gone.txt"), Some(9)), Some(2));
assert_eq!(reselect(&after, None, None), Some(0));
assert_eq!(reselect(&[], Some("a.txt"), Some(0)), None);
}
#[test]
fn clipboard_keys_are_bound_chars_so_typeahead_passes_them_through() {
assert!(matches!(browse_char('y'), Some(CharAction::Yank)));
assert!(matches!(browse_char('X'), Some(CharAction::Cut)));
assert!(matches!(browse_char('p'), Some(CharAction::Paste)));
for c in ['y', 'X', 'p'] {
assert!(matches!(
typeahead::action(false, browse_char(c).is_some()),
typeahead::Action::PassThrough
));
}
assert!(matches!(browse_char('x'), Some(CharAction::OpenExternal)));
}
#[test]
fn a_cut_pastes_as_a_move_and_a_yank_as_a_copy() {
assert_eq!(Transfer::of(true), Transfer::Move);
assert_eq!(Transfer::of(false), Transfer::Copy);
let sources = vec![plan_source("/src/a.txt")];
assert!(matches!(
Transfer::Copy.op(sources.clone(), PathBuf::from("/dst")),
fileop::Op::Copy { .. }
));
assert!(matches!(
Transfer::Move.op(sources, PathBuf::from("/dst")),
fileop::Op::Move { .. }
));
}
fn plan_source(path: &str) -> fileop::Source {
fileop::Source {
path: PathBuf::from(path),
kind: fileop::NodeKind::File,
nodes: Vec::new(),
items: 1,
bytes: 1,
}
}
#[test]
fn replanning_reuses_the_collected_sources_under_the_other_policy() {
let inputs = Replan {
transfer: Transfer::Copy,
sources: vec![plan_source("/src/a.txt")],
dest: PathBuf::from("/dst"),
dest_listing: vec!["a.txt".to_string()],
missing: vec![PathBuf::from("/src/gone.txt")],
cwd: PathBuf::from("/dst"),
};
let suffixed = inputs.plan(fileop::Conflict::Rename).expect("plans");
assert_eq!(suffixed.steps[0].dest, PathBuf::from("/dst/a (2).txt"));
assert_eq!(suffixed.overwrites(), 0);
assert_eq!(suffixed.policy, fileop::Conflict::Rename);
let over = inputs
.plan(flip_policy(suffixed.policy))
.expect("plans either way");
assert_eq!(over.steps[0].dest, PathBuf::from("/dst/a.txt"));
assert_eq!(over.overwrites(), 1);
assert_eq!(over.policy, fileop::Conflict::Overwrite);
assert_eq!(over.missing, vec![PathBuf::from("/src/gone.txt")]);
assert_eq!(
flip_policy(fileop::Conflict::Overwrite),
fileop::Conflict::Rename
);
assert_eq!(
inputs.plan(flip_policy(over.policy)).expect("plans").steps[0].dest,
PathBuf::from("/dst/a (2).txt")
);
}
#[test]
fn only_a_real_overwrite_earns_the_danger_colour() {
assert!(collision_lines(fileop::Conflict::Rename, 0, 0).is_empty());
assert_eq!(
collision_lines(fileop::Conflict::Rename, 2, 0),
vec![("2 suffixed to avoid a collision".to_string(), false)]
);
assert_eq!(
collision_lines(fileop::Conflict::Overwrite, 0, 1),
vec![(
"1 existing entry replaced, each trashed first".to_string(),
true
)]
);
assert_eq!(
collision_lines(fileop::Conflict::Overwrite, 0, 3)[0].0,
"3 existing entries replaced, each trashed first"
);
assert_eq!(
collision_lines(fileop::Conflict::Overwrite, 0, 0),
vec![(
"overwrite is on, but nothing here collides".to_string(),
false
)]
);
let both = collision_lines(fileop::Conflict::Overwrite, 1, 1);
assert_eq!(both.len(), 2);
assert!(!both[0].1);
assert!(both[1].1);
}
#[test]
fn the_overwrite_toggle_is_advertised_only_where_it_applies() {
assert!(confirm_keys(true, false).contains("[o] overwrite"));
assert!(confirm_keys(true, true).contains("[o] overwrite"));
assert!(!confirm_keys(false, false).contains("[o]"));
assert!(!confirm_keys(false, true).contains("[o]"));
assert!(confirm_keys(true, true).contains("[y] run"));
assert!(!confirm_keys(true, false).contains("[y]"));
for keys in [
confirm_keys(true, true),
confirm_keys(true, false),
confirm_keys(false, true),
confirm_keys(false, false),
] {
assert!(keys.contains("[Enter]"), "{keys}");
assert!(keys.contains("[Esc]"), "{keys}");
}
}
#[test]
fn clip_status_names_the_operation_and_the_key() {
assert_eq!(
clip_status(false, 3),
"clipboard: 3 items to copy · [p] paste here"
);
assert_eq!(
clip_status(true, 1),
"clipboard: 1 item to move · [p] paste here"
);
}
#[test]
fn the_clipboard_survives_a_copy_and_goes_after_a_cut() {
assert!(clip_survives(fileop::Kind::Copy));
assert!(!clip_survives(fileop::Kind::Move));
assert!(clip_survives(fileop::Kind::Trash));
assert!(clip_survives(fileop::Kind::Rename));
assert!(clip_survives(fileop::Kind::Create));
}
#[test]
fn the_escape_ladder_backs_out_one_layer_per_press() {
assert_eq!(escape(true, true, true), Escape::CancelOp);
assert_eq!(escape(true, false, false), Escape::CancelOp);
assert_eq!(escape(true, true, false), Escape::CancelOp);
assert_eq!(escape(true, false, true), Escape::CancelOp);
assert_eq!(escape(false, true, true), Escape::ClearClip);
assert_eq!(escape(false, true, false), Escape::ClearClip);
assert_eq!(escape(false, false, true), Escape::ClearMarks);
assert_eq!(escape(false, false, false), Escape::Quit);
let (mut running, mut clip, mut marks) = (true, true, true);
let mut seen = Vec::new();
for _ in 0..4 {
let step = escape(running, clip, marks);
seen.push(step);
match step {
Escape::CancelOp => running = false,
Escape::ClearClip => clip = false,
Escape::ClearMarks => marks = false,
Escape::Quit => {}
}
}
assert_eq!(
seen,
vec![
Escape::CancelOp,
Escape::ClearClip,
Escape::ClearMarks,
Escape::Quit
]
);
}
#[test]
fn rename_and_create_are_bound_chars_so_typeahead_passes_them_through() {
assert!(matches!(browse_char('r'), Some(CharAction::Rename)));
assert!(matches!(browse_char('a'), Some(CharAction::Create)));
for c in ['r', 'a'] {
assert!(matches!(
typeahead::action(false, browse_char(c).is_some()),
typeahead::Action::PassThrough
));
}
let rename = Ask::Rename {
path: PathBuf::from("/d/a.txt"),
};
let create = Ask::Create {
parent: PathBuf::from("/d"),
};
assert_ne!(rename.prefix(), create.prefix());
assert!(create.hint().contains('/'), "{}", create.hint());
for ask in [&rename, &create] {
assert!(ask.hint().contains("[Enter]"), "{}", ask.hint());
assert!(ask.hint().contains("[Esc]"), "{}", ask.hint());
}
}
#[test]
fn the_rename_cursor_lands_at_the_end_of_the_stem() {
assert_eq!(stem_end("notes.md", false), 5);
assert_eq!(stem_end(".gitignore", false), 10);
assert_eq!(stem_end("foo.tar.gz", false), 7);
assert_eq!(stem_end("v1.2", true), 4);
assert_eq!(stem_end("v1.2", false), 2);
assert_eq!(stem_end("odd.", false), 4);
assert_eq!(stem_end("README", false), 6);
assert_eq!(stem_end("", false), 0);
assert_eq!(stem_end("café.txt", false), 4);
assert_eq!(stem_end("🎉.png", false), 1);
}
fn dest_step(src: &str, dest: &str) -> fileop::Step {
fileop::Step {
src: PathBuf::from(src),
dest: PathBuf::from(dest),
kind: fileop::NodeKind::File,
nodes: Vec::new(),
items: 1,
bytes: 0,
renamed: false,
overwrite: false,
}
}
#[test]
fn the_landing_name_comes_only_from_a_single_step_with_a_destination() {
assert_eq!(
landing_name(&[dest_step("/d/old.txt", "/d/new.txt")]),
Some("new.txt".to_string())
);
assert_eq!(
landing_name(&[dest_step("", "/d/notes.md")]),
Some("notes.md".to_string())
);
assert_eq!(
landing_name(&[dest_step("/a/one", "/d/one"), dest_step("/b/two", "/d/two")]),
None
);
assert_eq!(landing_name(&[trash_step("/d/gone.txt", 1, 0)]), None);
assert_eq!(landing_name(&[]), None);
let after = ["a.txt", "new.txt", "z.txt"];
let landing = landing_name(&[dest_step("/d/old.txt", "/d/new.txt")]);
assert_eq!(reselect(&after, landing.as_deref(), Some(0)), Some(1));
}
#[test]
fn the_prompt_line_shows_the_cursor_wherever_it_sits() {
let edit = crate::lineedit::LineEdit::with_text("notes.md", 5);
let spans = prompt_spans("rename: ", &edit, "[Esc] cancel");
assert_eq!(spans_text(&spans), " rename: notes.md [Esc] cancel");
assert_eq!(spans[1].content, ".");
let edit = crate::lineedit::LineEdit::with_text("notes.md", 8);
let spans = prompt_spans("rename: ", &edit, "go");
assert_eq!(spans_text(&spans), " rename: notes.md█ go");
assert_eq!(spans[1].content, "█");
let empty = crate::lineedit::LineEdit::new();
assert_eq!(
spans_text(&prompt_spans("new: ", &empty, "go")),
" new: █ go"
);
let edit = crate::lineedit::LineEdit::with_text("café.txt", 3);
let spans = prompt_spans("rename: ", &edit, "go");
assert_eq!(spans[1].content, "é");
assert_eq!(spans_text(&spans), " rename: café.txt go");
}
#[test]
fn auto_behaves_as_miller_gated_on_width() {
assert_eq!(effective_columns(Layout::Auto, 200, true), 3);
assert_eq!(effective_columns(Layout::Auto, 80, true), 2);
assert_eq!(effective_columns(Layout::Auto, 200, false), 2);
}
}