use crate::buffer::Source;
use crate::prompt::TextField;
use ratatui::widgets::StatefulWidget;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
const TEXT_WIDTH: u16 = 30;
const PAGE_SIZE: usize = 10;
pub trait ChooserSource {
type Error: std::fmt::Display;
fn current_dir(&self) -> Result<PathBuf, Self::Error>;
fn read_dir(&self, dir: &Path, show_hidden: bool) -> Result<Vec<Entry>, Self::Error>;
fn open(&self, path: PathBuf) -> Source;
}
#[derive(Default)]
pub struct LocalSource;
impl ChooserSource for LocalSource {
type Error = std::io::Error;
fn current_dir(&self) -> std::io::Result<PathBuf> {
std::env::current_dir()
}
fn read_dir(&self, dir: &Path, show_hidden: bool) -> std::io::Result<Vec<Entry>> {
dir.read_dir()
.and_then(|entries| {
entries
.map(|e| e.and_then(Entry::try_from))
.filter_map(|e| {
if show_hidden {
Some(e)
} else {
match e {
Ok(e) if e.is_hidden() => None,
Ok(e) => Some(Ok(e)),
Err(e) => Some(Err(e)),
}
}
})
.collect()
})
.map(|mut entries: Vec<Entry>| {
entries.sort_unstable_by(|x, y| {
x.is_dir.cmp(&y.is_dir).reverse().then(x.path.cmp(&y.path))
});
entries
})
}
fn open(&self, path: PathBuf) -> Source {
Source::Local(path)
}
}
#[cfg(feature = "ssh")]
pub struct SshSource {
remote: std::rc::Rc<ssh2::Sftp>,
}
#[cfg(feature = "ssh")]
impl SshSource {
pub fn open(session: &ssh2::Session) -> Result<Self, ssh2::Error> {
session.sftp().map(|remote| Self {
remote: std::rc::Rc::new(remote),
})
}
}
#[cfg(feature = "ssh")]
impl ChooserSource for SshSource {
type Error = ssh2::Error;
fn current_dir(&self) -> Result<PathBuf, Self::Error> {
self.remote.realpath(Path::new("."))
}
fn read_dir(&self, dir: &Path, show_hidden: bool) -> Result<Vec<Entry>, Self::Error> {
self.remote
.readdir(dir)
.map(|entries| {
entries
.into_iter()
.map(|(pb, _)| {
Entry::from((
self.remote.stat(&pb).map(|s| s.is_dir()).unwrap_or(false),
pb,
))
})
.filter_map(|e| (show_hidden || !e.is_hidden()).then_some(e))
.collect()
})
.map(|mut entries: Vec<Entry>| {
entries.sort_unstable_by(|x, y| {
x.is_dir.cmp(&y.is_dir).reverse().then(x.path.cmp(&y.path))
});
entries
})
}
fn open(&self, path: PathBuf) -> Source {
Source::Ssh {
sftp: std::rc::Rc::clone(&self.remote),
path,
}
}
}
#[cfg(feature = "ssh")]
pub enum EitherSource {
Local(LocalSource),
Ssh(SshSource),
}
#[cfg(feature = "ssh")]
impl ChooserSource for EitherSource {
type Error = EitherError;
fn current_dir(&self) -> Result<PathBuf, Self::Error> {
match self {
Self::Local(l) => l.current_dir().map_err(EitherError::Io),
Self::Ssh(s) => s.current_dir().map_err(EitherError::Ssh),
}
}
fn read_dir(&self, dir: &Path, show_hidden: bool) -> Result<Vec<Entry>, Self::Error> {
match self {
Self::Local(l) => l.read_dir(dir, show_hidden).map_err(EitherError::Io),
Self::Ssh(s) => s.read_dir(dir, show_hidden).map_err(EitherError::Ssh),
}
}
fn open(&self, path: PathBuf) -> Source {
match self {
Self::Local(l) => l.open(path),
Self::Ssh(s) => s.open(path),
}
}
}
#[cfg(feature = "ssh")]
#[derive(Debug)]
pub enum EitherError {
Io(std::io::Error),
Ssh(ssh2::Error),
}
#[cfg(feature = "ssh")]
impl std::fmt::Display for EitherError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Io(i) => i.fmt(f),
Self::Ssh(s) => s.fmt(f),
}
}
}
#[cfg(feature = "ssh")]
impl std::error::Error for EitherError {}
pub struct FileChooser<S: ChooserSource> {
phantom: std::marker::PhantomData<S>,
}
impl<S: ChooserSource> Default for FileChooser<S> {
fn default() -> Self {
Self {
phantom: std::marker::PhantomData,
}
}
}
impl<S: ChooserSource> StatefulWidget for FileChooser<S> {
type State = FileChooserState<S>;
fn render(
self,
area: ratatui::layout::Rect,
buf: &mut ratatui::buffer::Buffer,
state: &mut FileChooserState<S>,
) {
use crate::buffer::{BufferMessage, render_message};
use crate::help::{CREATE_FILE, OPEN_FILE, render_help};
use crate::scrollbar::{Scrollbar, ScrollbarState};
use ratatui::{
layout::{
Constraint::{Length, Min},
Layout,
},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, List, ListState, Paragraph, Widget},
};
use std::borrow::Cow;
let block = Block::bordered()
.border_type(BorderType::Thick)
.title_top(Line::from(vec![
Span::raw("\u{252b}"),
Span::styled(state.dir.display().to_string(), Style::default().bold()),
Span::raw("\u{2523}"),
]));
ratatui::widgets::Clear.render(area, buf);
let [top_area, list_area] = Layout::vertical([Length(3), Min(0)]).areas(block.inner(area));
let [list_area, scrollbar_area] = Layout::horizontal([Min(0), Length(1)]).areas(list_area);
block.render(area, buf);
let [text_area, _] = Layout::horizontal([Length(TEXT_WIDTH + 2), Min(0)]).areas(top_area);
match &state.chosen {
Chosen::Default => Paragraph::new("")
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.title("Filename"),
)
.render(text_area, buf),
Chosen::New(filename) => Paragraph::new(filename.value().unwrap_or_default())
.scroll((
0,
filename
.cursor_column()
.saturating_sub(TEXT_WIDTH.into())
.try_into()
.unwrap(),
))
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.title("Filename"),
)
.render(text_area, buf),
Chosen::Selected(items) => Paragraph::new(match items.len() {
1 => Cow::Borrowed("1 File Selected"),
n => Cow::Owned(format!("{n} Files Selected")),
})
.block(Block::bordered().border_type(BorderType::Rounded))
.render(text_area, buf),
}
StatefulWidget::render(
(match &state.chosen {
Chosen::Default | Chosen::New(_) => List::new(state.dir_entries()),
Chosen::Selected(selected) => List::new(state.contents.iter().map(|e| {
if selected.contains(&e.path) {
format!("* {}", e.name)
} else {
format!(" {}", e.name)
}
})),
})
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
list_area,
buf,
&mut ListState::default().with_selected(state.selected_entry()),
);
Scrollbar.render(
scrollbar_area,
buf,
&mut ScrollbarState::new(state.contents.len())
.viewport_content_length(list_area.height.into())
.position(
state
.selected_entry()
.unwrap_or_default()
.saturating_sub(list_area.height.into()),
),
);
render_help(
list_area,
buf,
match &state.chosen {
Chosen::Default | Chosen::Selected(_) => OPEN_FILE,
Chosen::New(_) => CREATE_FILE,
},
|b| {
if state.show_hidden {
b.title_top("Showing Hidden")
} else {
b
}
},
);
if let Some(error) = state.error.take() {
render_message(list_area, buf, BufferMessage::Error(error.into()));
}
}
}
pub struct FileChooserState<S: ChooserSource> {
cwd: PathBuf, dir: PathBuf, contents: Vec<Entry>, dir_count: usize, index: Option<usize>, chosen: Chosen, error: Option<String>, source: S, show_hidden: bool, }
impl<S: ChooserSource> FileChooserState<S> {
pub fn new(source: S) -> Result<Self, S::Error> {
let cwd = source.current_dir()?;
let contents = source.read_dir(&cwd, false)?;
Ok(Self {
dir: cwd.clone(),
dir_count: contents.iter().take_while(|e| e.is_dir).count(),
contents,
cwd,
index: None,
chosen: Chosen::default(),
error: None,
source,
show_hidden: false,
})
}
pub fn update_dir(&mut self, new_dir: PathBuf) {
match self.source.read_dir(&new_dir, self.show_hidden) {
Ok(contents) => {
self.dir_count = contents.iter().take_while(|e| e.is_dir).count();
self.contents = contents;
self.index = None;
self.dir = new_dir;
}
Err(err) => {
self.error = Some(err.to_string());
}
}
}
pub fn toggle_show_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
let dir = std::mem::take(&mut self.dir);
self.update_dir(dir);
}
pub fn dir_entries(&self) -> impl Iterator<Item = &str> {
self.contents.iter().map(|e| e.name.as_str())
}
pub fn selected_entry(&self) -> Option<usize> {
self.index
}
pub fn arrow_up(&mut self) {
if matches!(self.chosen, Chosen::Default | Chosen::Selected(_)) {
self.index = match self.index {
None => max_index(&self.chosen, &self.contents, self.dir_count).checked_sub(1),
Some(i) => i.checked_sub(1).or_else(|| {
max_index(&self.chosen, &self.contents, self.dir_count).checked_sub(1)
}),
}
}
}
pub fn arrow_down(&mut self) {
if matches!(self.chosen, Chosen::Default | Chosen::Selected(_)) {
self.index = (match self.index {
None => Some(0),
Some(i) => Some(i + 1),
})
.and_then(|i| i.checked_rem(max_index(&self.chosen, &self.contents, self.dir_count)));
}
}
pub fn page_up(&mut self) {
if matches!(self.chosen, Chosen::Default | Chosen::Selected(_)) {
self.index = (match self.index {
None => Some(0),
Some(idx) => Some(idx.saturating_sub(PAGE_SIZE)),
})
.filter(|i| *i < max_index(&self.chosen, &self.contents, self.dir_count))
}
}
pub fn page_down(&mut self) {
if matches!(self.chosen, Chosen::Default | Chosen::Selected(_)) {
self.index = match max_index(&self.chosen, &self.contents, self.dir_count) {
0 => None,
max => match self.index {
None => Some(PAGE_SIZE.min(max - 1)),
Some(idx) => Some((idx + PAGE_SIZE).min(max - 1)),
},
}
}
}
pub fn home(&mut self) {
match &mut self.chosen {
Chosen::New(filename) => {
filename.cursor_home();
}
_ => {
self.index = match max_index(&self.chosen, &self.contents, self.dir_count) {
0 => None,
_ => Some(0),
}
}
}
}
pub fn end(&mut self) {
match &mut self.chosen {
Chosen::New(filename) => {
filename.cursor_end();
}
_ => {
self.index = max_index(&self.chosen, &self.contents, self.dir_count).checked_sub(1);
}
}
}
pub fn arrow_right(&mut self) {
match &mut self.chosen {
Chosen::New(filename) => {
filename.cursor_forward();
}
_ => {
if let Some(idx) = self.index
&& let Some(Entry {
path, is_dir: true, ..
}) = self.contents.get(idx)
{
self.update_dir(path.clone());
}
}
}
}
pub fn arrow_left(&mut self) {
match &mut self.chosen {
Chosen::New(filename) => {
filename.cursor_back();
}
_ => {
if let Some(parent) = self.dir.parent()
&& parent != Path::new("")
{
self.update_dir(parent.to_path_buf());
}
}
}
}
pub fn insert_char(&mut self, c: char) {
match &mut self.chosen {
Chosen::Default => {
self.chosen = Chosen::New({
let mut filename = TextField::default();
filename.insert_char(c);
filename
});
self.index = None;
}
Chosen::New(prompt) => {
prompt.insert_char(c);
self.index = None;
}
Chosen::Selected(_) => { }
}
}
pub fn backspace(&mut self) {
if let Chosen::New(prompt) = &mut self.chosen {
prompt.backspace();
if prompt.is_empty() {
self.chosen = Chosen::Default;
}
}
}
pub fn toggle_selected(&mut self) {
if let Some(idx) = self.index
&& let Some(Entry {
path,
is_dir: false,
..
}) = self.contents.get(idx)
{
match &mut self.chosen {
Chosen::Default => {
self.chosen = Chosen::Selected(BTreeSet::from([path.clone()]));
}
Chosen::Selected(selected) => {
if !selected.insert(path.clone()) {
selected.remove(path);
if selected.is_empty() {
self.chosen = Chosen::Default;
}
}
}
Chosen::New(_) => { }
}
}
}
pub fn select(&mut self) -> Option<Vec<Source>> {
fn strip_cwd(cwd: &Path, path: &Path) -> PathBuf {
match path.strip_prefix(cwd) {
Ok(stripped) => stripped.to_path_buf(),
Err(_) => path.to_owned(),
}
}
match std::mem::take(&mut self.chosen) {
Chosen::Default => match self.contents.get(self.index?)? {
Entry {
is_dir: true, path, ..
} => {
self.update_dir(path.clone());
None
}
Entry {
is_dir: false,
path,
..
} => Some(vec![self.source.open(strip_cwd(&self.cwd, path))]),
},
Chosen::New(filename) => Some(vec![self.source.open(strip_cwd(
&self.cwd,
&self.dir.join(filename.value().expect("empty filename")),
))]),
Chosen::Selected(selected) => Some(
selected
.into_iter()
.map(|path| self.source.open(strip_cwd(&self.cwd, &path)))
.collect(),
),
}
}
pub fn cursor_position(&self) -> (u16, u16) {
match &self.chosen {
Chosen::Default => (1, 1),
Chosen::New(filename) => (filename.cursor_column() as u16 + 1, 1),
Chosen::Selected(_) => (1, 1),
}
}
}
fn max_index(chosen: &Chosen, contents: &[Entry], dir_count: usize) -> usize {
match chosen {
Chosen::Default | Chosen::Selected(_) => contents.len(),
Chosen::New(_) => dir_count,
}
}
pub struct Entry {
name: String, path: PathBuf, is_dir: bool, }
impl Entry {
fn is_hidden(&self) -> bool {
self.name.starts_with('.')
}
}
impl TryFrom<std::fs::DirEntry> for Entry {
type Error = std::io::Error;
fn try_from(entry: std::fs::DirEntry) -> std::io::Result<Self> {
let path = entry.path();
let is_dir = std::fs::metadata(&path)
.map(|m| m.is_dir())
.unwrap_or(false);
Ok(Self {
name: match is_dir {
false => entry.file_name().display().to_string(),
true => format!(
"{}{}",
entry.file_name().display(),
std::path::MAIN_SEPARATOR,
),
},
is_dir,
path,
})
}
}
#[cfg(feature = "ssh")]
impl From<(bool, PathBuf)> for Entry {
fn from((is_dir, path): (bool, PathBuf)) -> Self {
Self {
name: match is_dir {
false => path
.file_name()
.map(|n| n.display().to_string())
.unwrap_or_default(),
true => format!(
"{}{}",
path.file_name()
.map(|n| n.display().to_string())
.unwrap_or_default(),
std::path::MAIN_SEPARATOR,
),
},
path,
is_dir,
}
}
}
#[derive(Default)]
enum Chosen {
#[default]
Default, New(TextField), Selected(BTreeSet<PathBuf>), }