use std::collections::{HashMap, HashSet};
use std::fmt;
use std::mem;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[cfg(test)]
mod fuzz;
mod listing;
#[cfg(test)]
mod tests;
mod view;
pub(crate) use listing::remote_dir_arg;
use listing::{Listing, Ssh, home_dir, list};
use view::{Geometry, buffer_rows, footer_layout, geometry};
const DEFAULT_VIEW: (u16, u16) = (80, 24);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
Local(PathBuf),
Remote(Remote),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Remote {
pub server: String,
pub ssh_args: Vec<String>,
pub path: String,
}
impl Location {
pub fn join(&self, name: &str) -> Location {
match self {
Location::Local(path) => Location::Local(path.join(name)),
Location::Remote(remote) => Location::Remote(remote.with_path(join_remote(&remote.path, name))),
}
}
fn parent(&self) -> Option<Location> {
match self {
Location::Local(path) => {
path.parent().filter(|p| !p.as_os_str().is_empty()).map(|p| Location::Local(p.to_path_buf()))
}
Location::Remote(remote) => {
parent_remote(&remote.path).map(|path| Location::Remote(remote.with_path(path)))
}
}
}
pub fn display(&self) -> String {
match self {
Location::Local(path) => path.display().to_string(),
Location::Remote(remote) => format!("{}:{}", remote.server, remote.path),
}
}
pub fn exact_display(&self) -> Option<String> {
match self {
Location::Local(path) => path.to_str().map(str::to_owned),
Location::Remote(_) => Some(self.display()),
}
}
fn short_display(&self) -> String {
match self {
Location::Local(path) => match home_dir().and_then(|home| path.strip_prefix(home).ok()) {
Some(rest) if rest.as_os_str().is_empty() => "~".to_owned(),
Some(rest) => format!("~/{}", rest.display()),
None => path.display().to_string(),
},
Location::Remote(_) => self.display(),
}
}
pub fn name(&self) -> Option<String> {
match self {
Location::Local(path) => path.file_name().map(|n| n.to_string_lossy().into_owned()),
Location::Remote(remote) => {
let trimmed = remote.path.trim_end_matches('/');
if trimmed.is_empty() || (trimmed.starts_with('~') && !trimmed.contains('/')) {
return None;
}
trimmed.rsplit('/').next().map(str::to_owned)
}
}
}
}
impl Remote {
fn with_path(&self, path: String) -> Remote {
Remote { server: self.server.clone(), ssh_args: self.ssh_args.clone(), path }
}
}
fn join_remote(base: &str, name: &str) -> String {
if base.is_empty() {
format!("~/{name}")
} else if base.ends_with('/') {
format!("{base}{name}")
} else {
format!("{base}/{name}")
}
}
fn parent_remote(path: &str) -> Option<String> {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() || (trimmed.starts_with('~') && !trimmed.contains('/')) {
return None;
}
match trimmed.rfind('/') {
None => Some("~".to_owned()),
Some(index) => {
let parent = trimmed[..index].trim_end_matches('/');
Some(if parent.is_empty() { "/".to_owned() } else { parent.to_owned() })
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry {
name: String,
is_dir: bool,
is_link: bool,
size: u64,
mtime: i64,
exact: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKey {
Up,
Down,
PageUp,
PageDown,
Home,
End,
Enter,
Backspace,
Tab,
Esc,
Delete,
Insert,
Char(char),
F(u8),
}
#[derive(Debug, Clone, PartialEq)]
pub enum FilesAction {
None,
Copy {
sources: Vec<Location>,
dest: Location,
replaces: Vec<String>,
},
Move {
sources: Vec<Location>,
dest: Location,
replaces: Vec<String>,
},
Delete(Vec<Location>),
Preview(PathBuf),
Edit(Location),
Shell(Location),
PickLocation(Side),
Close,
Refused(String),
}
const INEXACT: &str = "a name here is not valid UTF-8: kraal cannot point at it safely; use a shell (key s)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Left,
Right,
}
impl Side {
fn index(self) -> usize {
match self {
Side::Left => 0,
Side::Right => 1,
}
}
fn other(self) -> Side {
match self {
Side::Left => Side::Right,
Side::Right => Side::Left,
}
}
}
struct Done {
side: Side,
request: u64,
result: Listing,
}
#[derive(Clone, Copy)]
enum Row<'a> {
Parent,
Entry(&'a Entry),
}
struct Panel {
location: Location,
entries: Vec<Entry>,
shown: Vec<usize>,
has_parent: bool,
loading: bool,
error: Option<String>,
marked: HashSet<String>,
filter: String,
editing: bool,
cursor: usize,
offsets: HashMap<u32, usize>,
request: u64,
cancel: Arc<AtomicBool>,
select: Option<String>,
}
impl Panel {
fn new(location: Location) -> Panel {
Panel {
has_parent: location.parent().is_some(),
location,
entries: Vec::new(),
shown: Vec::new(),
loading: false,
error: None,
marked: HashSet::new(),
filter: String::new(),
editing: false,
cursor: 0,
offsets: HashMap::new(),
request: 0,
cancel: Arc::new(AtomicBool::new(false)),
select: None,
}
}
fn supersede(&mut self) -> (u64, Arc<AtomicBool>) {
self.cancel.store(true, Ordering::Relaxed);
self.cancel = Arc::new(AtomicBool::new(false));
self.request = self.request.wrapping_add(1);
self.loading = true;
(self.request, Arc::clone(&self.cancel))
}
fn reset(&mut self, location: Location, select: Option<String>) {
self.has_parent = location.parent().is_some();
self.location = location;
self.entries.clear();
self.shown.clear();
self.error = None;
self.marked.clear();
self.filter.clear();
self.editing = false;
self.cursor = 0;
self.offsets.clear();
self.select = select;
}
fn apply(&mut self, result: Listing, show_hidden: bool) {
self.loading = false;
match result {
Ok((location, mut entries)) => {
sort_entries(&mut entries);
let keep = self.select.take().or_else(|| self.current().map(|e| e.name.clone()));
self.has_parent = location.parent().is_some();
self.location = location;
let names: HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
self.marked.retain(|name| names.contains(name.as_str()));
self.entries = entries;
self.error = None;
self.update_shown(show_hidden);
self.restore_cursor(keep.as_deref());
}
Err(message) => {
self.entries.clear();
self.marked.clear();
self.select = None;
self.error = Some(message);
self.update_shown(show_hidden);
self.restore_cursor(None);
}
}
}
fn rows_len(&self) -> usize {
usize::from(self.has_parent) + self.shown.len()
}
fn row(&self, index: usize) -> Option<Row<'_>> {
if self.has_parent && index == 0 {
return Some(Row::Parent);
}
let index = index.checked_sub(usize::from(self.has_parent))?;
self.shown.get(index).and_then(|&i| self.entries.get(i)).map(Row::Entry)
}
fn current(&self) -> Option<&Entry> {
match self.row(self.cursor)? {
Row::Entry(entry) => Some(entry),
Row::Parent => None,
}
}
fn shown_entries(&self) -> impl Iterator<Item = &Entry> {
self.shown.iter().filter_map(|&i| self.entries.get(i))
}
fn marked_entries(&self) -> Vec<&Entry> {
self.shown_entries().filter(|e| self.marked.contains(&e.name)).collect()
}
fn row_of(&self, name: &str) -> Option<usize> {
let index = self.shown.iter().position(|&i| self.entries.get(i).is_some_and(|e| e.name == name))?;
Some(index + usize::from(self.has_parent))
}
fn first_entry_row(&self) -> usize {
if self.shown.is_empty() { 0 } else { usize::from(self.has_parent) }
}
fn update_shown(&mut self, show_hidden: bool) {
let filter = self.filter.to_lowercase();
self.shown = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| show_hidden || !e.name.starts_with('.'))
.filter(|(_, e)| filter.is_empty() || e.name.to_lowercase().contains(&filter))
.map(|(i, _)| i)
.collect();
}
fn refilter(&mut self, show_hidden: bool) {
let keep = self.current().map(|e| e.name.clone());
self.update_shown(show_hidden);
self.restore_cursor(keep.as_deref());
}
fn set_filter(&mut self, filter: String, show_hidden: bool) {
let keep = self.current().map(|e| e.name.clone());
self.filter = filter;
self.update_shown(show_hidden);
self.cursor = keep.and_then(|name| self.row_of(&name)).unwrap_or_else(|| self.first_entry_row());
}
fn restore_cursor(&mut self, name: Option<&str>) {
if let Some(row) = name.and_then(|name| self.row_of(name)) {
self.cursor = row;
} else {
self.cursor = self.cursor.min(self.rows_len().saturating_sub(1));
}
}
fn set_offset(&mut self, viewer: u32, offset: usize) {
self.offsets.insert(viewer, offset);
}
fn move_by(&mut self, delta: isize) {
let target = self.cursor.saturating_add_signed(delta);
self.cursor = target.min(self.rows_len().saturating_sub(1));
}
fn scroll_offset(&self, viewer: u32, height: usize) -> usize {
let rows = self.rows_len();
let mut offset = self.offsets.get(&viewer).copied().unwrap_or(0);
if height == 0 {
return offset.min(rows.saturating_sub(1));
}
if self.cursor < offset {
offset = self.cursor;
} else if self.cursor >= offset.saturating_add(height) {
offset = self.cursor + 1 - height;
}
offset.min(rows.saturating_sub(height))
}
}
fn sort_entries(entries: &mut [Entry]) {
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase(), e.name.clone()));
}
pub struct FilesPane {
panels: [Panel; 2],
active: Side,
show_hidden: bool,
view: Option<(u16, u16)>,
viewer: u32,
done: Arc<Mutex<Vec<Done>>>,
wake: Arc<dyn Fn() + Send + Sync>,
ssh: Ssh,
}
impl fmt::Debug for FilesPane {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FilesPane")
.field("left", &self.panels[0].location)
.field("right", &self.panels[1].location)
.field("active", &self.active)
.field("view", &self.view)
.finish_non_exhaustive()
}
}
impl Drop for FilesPane {
fn drop(&mut self) {
for panel in &self.panels {
panel.cancel.store(true, Ordering::Relaxed);
}
}
}
impl FilesPane {
pub fn new(left: Location, right: Location, wake: Arc<dyn Fn() + Send + Sync>) -> FilesPane {
FilesPane::with_ssh(left, right, wake, Ssh::default())
}
fn with_ssh(left: Location, right: Location, wake: Arc<dyn Fn() + Send + Sync>, ssh: Ssh) -> FilesPane {
let mut pane = FilesPane {
panels: [Panel::new(left), Panel::new(right)],
active: Side::Left,
show_hidden: false,
view: None,
viewer: 0,
done: Arc::new(Mutex::new(Vec::new())),
wake,
ssh,
};
pane.load(Side::Left);
pane.load(Side::Right);
pane
}
pub fn title(&self) -> String {
let [left, right] = &self.panels;
format!("files · {} ⇄ {}", left.location.short_display(), right.location.short_display())
}
pub fn location(&self, side: Side) -> &Location {
&self.panel(side).location
}
pub fn set_location(&mut self, side: Side, loc: Location) {
self.navigate(side, loc, None);
}
pub fn refresh(&mut self) {
self.load(Side::Left);
self.load(Side::Right);
}
pub fn poll(&mut self) -> bool {
let done = mem::take(&mut *self.done.lock().unwrap_or_else(PoisonError::into_inner));
let mut changed = false;
for item in done {
if self.panel(item.side).request == item.request {
let show_hidden = self.show_hidden;
self.panel_mut(item.side).apply(item.result, show_hidden);
changed = true;
}
}
changed
}
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
let area = area.intersection(buf.area);
self.view = Some((area.width, area.height));
let height = usize::from(geometry(area, self.active).list.1);
let viewer = self.viewer;
for panel in &mut self.panels {
let offset = panel.scroll_offset(viewer, height);
panel.set_offset(viewer, offset);
}
self.draw(area, buf);
}
pub fn set_viewer(&mut self, viewer: u32) {
self.viewer = viewer;
}
pub fn forget_viewer(&mut self, viewer: u32) {
for panel in &mut self.panels {
panel.offsets.remove(&viewer);
}
}
pub fn visible_text(&self) -> String {
let (width, height) = self.view.unwrap_or(DEFAULT_VIEW);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
self.draw(area, &mut buf);
buffer_rows(&buf, area).join("\n")
}
pub fn key(&mut self, key: FileKey) -> FilesAction {
let side = self.active;
let show_hidden = self.show_hidden;
if self.panel(side).editing {
let panel = self.panel_mut(side);
match key {
FileKey::Char(c) => {
let mut filter = panel.filter.clone();
filter.push(c);
panel.set_filter(filter, show_hidden);
return FilesAction::None;
}
FileKey::Backspace => {
let mut filter = panel.filter.clone();
if filter.pop().is_none() {
panel.editing = false;
}
panel.set_filter(filter, show_hidden);
return FilesAction::None;
}
FileKey::Esc => {
panel.editing = false;
panel.set_filter(String::new(), show_hidden);
return FilesAction::None;
}
FileKey::Enter => {
panel.editing = false;
return FilesAction::None;
}
_ => {}
}
}
let page = isize::try_from(self.list_height().saturating_sub(1).max(1)).unwrap_or(1);
match key {
FileKey::Up | FileKey::Char('k') => self.panel_mut(side).move_by(-1),
FileKey::Down | FileKey::Char('j') => self.panel_mut(side).move_by(1),
FileKey::PageUp => self.panel_mut(side).move_by(-page),
FileKey::PageDown => self.panel_mut(side).move_by(page),
FileKey::Home => self.panel_mut(side).cursor = 0,
FileKey::End => {
let panel = self.panel_mut(side);
panel.cursor = panel.rows_len().saturating_sub(1);
}
FileKey::Enter | FileKey::F(3) => return self.open(side),
FileKey::Backspace => self.go_up(side),
FileKey::Tab => {
self.panel_mut(side).editing = false;
self.active = side.other();
}
FileKey::Char(' ') | FileKey::Insert => {
let panel = self.panel_mut(side);
if let Some(name) = panel.current().map(|e| e.name.clone())
&& !panel.marked.remove(&name)
{
panel.marked.insert(name);
}
panel.move_by(1);
}
FileKey::Char('*') => {
let panel = self.panel_mut(side);
let names: Vec<String> = panel.shown_entries().map(|e| e.name.clone()).collect();
if names.iter().all(|name| panel.marked.contains(name)) {
for name in &names {
panel.marked.remove(name);
}
} else {
panel.marked.extend(names);
}
}
FileKey::Char('c') | FileKey::F(5) => return self.transfer(side, false),
FileKey::Char('m') | FileKey::F(6) => return self.transfer(side, true),
FileKey::Char('d') | FileKey::Delete | FileKey::F(8) => {
return match self.sources(side) {
Ok(sources) if sources.is_empty() => FilesAction::None,
Ok(sources) => FilesAction::Delete(sources),
Err(why) => FilesAction::Refused(why),
};
}
FileKey::Char('e') | FileKey::F(4) => return self.edit(side),
FileKey::Char('s') => return FilesAction::Shell(self.panel(side).location.clone()),
FileKey::Char('o' | ':') => return FilesAction::PickLocation(side),
FileKey::Char('r') => self.refresh(),
FileKey::Char('.') => {
self.show_hidden = !self.show_hidden;
for panel in &mut self.panels {
panel.refilter(self.show_hidden);
}
}
FileKey::Char('/') => {
let panel = self.panel_mut(side);
panel.editing = true;
}
FileKey::Esc if !self.panel(side).filter.is_empty() => {
self.panel_mut(side).set_filter(String::new(), show_hidden);
}
FileKey::Char('q') | FileKey::Esc | FileKey::F(10) => return FilesAction::Close,
_ => {}
}
FilesAction::None
}
pub fn click(&mut self, col: u16, row: u16, double: bool) -> FilesAction {
let geo = self.geometry();
if geo.footer == Some(row) {
let hints = self.hints();
let hit =
footer_layout(&hints, geo.area.width).into_iter().find(|&(start, end, _)| (start..end).contains(&col));
return match hit.and_then(|(_, _, index)| hints.get(index)).and_then(|hint| hint.key) {
Some(key) => self.key(key),
None => FilesAction::None,
};
}
let Some(side) = geo.side_at(col) else {
return FilesAction::None;
};
if side != self.active {
self.panel_mut(self.active).editing = false;
self.active = side;
}
let (list_y, list_height) = geo.list;
if row < list_y || row - list_y >= list_height {
return FilesAction::None;
}
let viewer = self.viewer;
let panel = self.panel_mut(side);
let index = panel.scroll_offset(viewer, usize::from(list_height)).saturating_add(usize::from(row - list_y));
if index >= panel.rows_len() {
return FilesAction::None;
}
panel.cursor = index;
if double { self.open(side) } else { FilesAction::None }
}
pub fn set_view(&mut self, viewer: u32, width: u16, height: u16) {
self.viewer = viewer;
self.view = Some((width, height));
}
pub fn scroll_active(&mut self, delta: i32) {
let side = self.active;
self.scroll_side(side, delta);
}
pub fn scroll(&mut self, col: u16, delta: i32) {
let side = self.geometry().side_at(col).unwrap_or(self.active);
self.scroll_side(side, delta);
}
fn scroll_side(&mut self, side: Side, delta: i32) {
let height = usize::from(self.geometry().list.1).max(1);
let viewer = self.viewer;
let panel = self.panel_mut(side);
let rows = panel.rows_len();
let max_offset = rows.saturating_sub(height);
let delta = isize::try_from(delta).unwrap_or(0);
let offset = panel.scroll_offset(viewer, height).saturating_add_signed(delta).min(max_offset);
panel.set_offset(viewer, offset);
let last_visible = (offset + height - 1).min(rows.saturating_sub(1));
panel.cursor = panel.cursor.clamp(offset.min(last_visible), last_visible);
}
fn panel(&self, side: Side) -> &Panel {
&self.panels[side.index()]
}
fn panel_mut(&mut self, side: Side) -> &mut Panel {
&mut self.panels[side.index()]
}
fn geometry(&self) -> Geometry {
let (width, height) = self.view.unwrap_or(DEFAULT_VIEW);
geometry(Rect::new(0, 0, width, height), self.active)
}
fn list_height(&self) -> usize {
usize::from(self.geometry().list.1)
}
fn navigate(&mut self, side: Side, location: Location, select: Option<String>) {
self.panel_mut(side).reset(location, select);
self.load(side);
}
fn load(&mut self, side: Side) {
let panel = self.panel_mut(side);
let (request, cancel) = panel.supersede();
let location = panel.location.clone();
let (done, wake, ssh) = (Arc::clone(&self.done), Arc::clone(&self.wake), self.ssh.clone());
let spawned = thread::Builder::new().name("kraal-files".to_owned()).spawn(move || {
let result = list(&location, &ssh, &cancel);
if cancel.load(Ordering::Relaxed) {
return;
}
done.lock().unwrap_or_else(PoisonError::into_inner).push(Done { side, request, result });
wake();
});
if let Err(err) = spawned {
let show_hidden = self.show_hidden;
self.panel_mut(side).apply(Err(format!("cannot start listing: {err}")), show_hidden);
}
}
fn open(&mut self, side: Side) -> FilesAction {
let panel = self.panel(side);
match panel.row(panel.cursor) {
Some(Row::Parent) => self.go_up(side),
Some(Row::Entry(entry)) if !entry.exact => return FilesAction::Refused(INEXACT.into()),
Some(Row::Entry(entry)) if entry.is_dir => {
let target = panel.location.join(&entry.name);
self.navigate(side, target, None);
}
Some(Row::Entry(entry)) => {
if let Location::Local(path) = panel.location.join(&entry.name) {
return FilesAction::Preview(path);
}
}
None => {}
}
FilesAction::None
}
fn edit(&self, side: Side) -> FilesAction {
let panel = self.panel(side);
match panel.current() {
None => FilesAction::None,
Some(entry) if !entry.exact => FilesAction::Refused(INEXACT.into()),
Some(entry) if entry.is_dir => {
FilesAction::Refused(format!("{} is a directory: e edits a file", entry.name))
}
Some(entry) => FilesAction::Edit(panel.location.join(&entry.name)),
}
}
fn go_up(&mut self, side: Side) {
let location = &self.panel(side).location;
if let Some(parent) = location.parent() {
let name = location.name();
self.navigate(side, parent, name);
}
}
fn sources(&self, side: Side) -> Result<Vec<Location>, String> {
let panel = self.panel(side);
let marked = panel.marked_entries();
let entries = if marked.is_empty() { panel.current().into_iter().collect() } else { marked };
if entries.iter().any(|e| !e.exact) {
return Err(INEXACT.into());
}
Ok(entries.into_iter().map(|e| panel.location.join(&e.name)).collect())
}
fn transfer(&mut self, side: Side, is_move: bool) -> FilesAction {
let sources = match self.sources(side) {
Ok(s) => s,
Err(why) => return FilesAction::Refused(why),
};
let target = self.panel(side.other());
let dest = target.location.clone();
if sources.is_empty() || dest == self.panel(side).location {
return FilesAction::None;
}
let replaces: Vec<String> = sources
.iter()
.filter_map(Location::name)
.filter(|n| !target.loading && target.entries.iter().any(|e| &e.name == n))
.collect();
if is_move {
FilesAction::Move { sources, dest, replaces }
} else {
self.panel_mut(side).marked.clear();
FilesAction::Copy { sources, dest, replaces }
}
}
}