use crate::config::Config;
use crate::error::Error;
use crate::listing::DirectoryListing;
use crate::types::RemotePath;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Instant;
const SPINNER_FRAMES: &[char] = &['|', '/', '-', '\\'];
const SPINNER_MS_PER_FRAME: u128 = 80;
#[non_exhaustive]
pub struct AppState {
pub current_remote_path: RemotePath,
pub listing: Option<DirectoryListing>,
pub status_message: String,
pub selected_index: usize,
pub loading_started: Option<Instant>,
pub download_dir: PathBuf,
pub cmd_buffer: String,
pub pending_rm: Option<RemotePath>,
pub listing_glob: Option<String>,
}
fn glob_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
fn match_at(p: &[char], n: &[char]) -> bool {
if p.is_empty() {
return n.is_empty();
}
if p.first() == Some(&'*') {
let rest = p.get(1..).unwrap_or_default();
if rest.is_empty() {
return true;
}
for i in 0..=n.len() {
if match_at(rest, n.get(i..).unwrap_or_default()) {
return true;
}
}
return false;
}
if n.is_empty() {
return false;
}
if p.first() == Some(&'?') || p.first() == n.first() {
return match_at(
p.get(1..).unwrap_or_default(),
n.get(1..).unwrap_or_default(),
);
}
false
}
match_at(&p, &n)
}
impl AppState {
#[must_use]
pub fn new(config: &Config) -> Self {
Self {
current_remote_path: RemotePath::from("/"),
listing: None,
status_message: String::new(),
selected_index: 0,
loading_started: None,
download_dir: config.download_dir.clone(),
cmd_buffer: String::new(),
pending_rm: None,
listing_glob: None,
}
}
pub fn clamp_selected_index(&mut self) {
let len = self.entries_for_display().len();
if len > 0 {
self.selected_index = self.selected_index.min(len - 1);
} else {
self.selected_index = 0;
}
}
#[must_use]
pub fn entries_for_display(&self) -> Vec<(String, bool, RemotePath)> {
let mut entries = Vec::new();
if let Some(ref listing) = self.listing {
let filter = self.listing_glob.as_deref();
for p in &listing.paths {
let name = p.path.rsplit('/').next().unwrap_or(&p.path);
if !name.is_empty() && filter.is_none_or(|g| glob_match(g, name)) {
let path = RemotePath::from(p.path.clone());
entries.push((format!(" {name}/"), true, path));
}
}
for f in &listing.files {
let name = f.file_path.rsplit('/').next().unwrap_or(&f.file_path);
if !name.is_empty() && filter.is_none_or(|g| glob_match(g, name)) {
let path = RemotePath::from(f.file_path.clone());
let size = f
.size
.map(|s| format!(" {name} ({s} B)"))
.unwrap_or_else(|| format!(" {name}"));
entries.push((size, false, path));
}
}
}
entries
}
#[must_use]
pub fn selected_file_path(&self) -> Option<RemotePath> {
let entries = self.entries_for_display();
let (_, is_dir, path) = entries.get(self.selected_index)?;
if *is_dir { None } else { Some(path.clone()) }
}
#[must_use]
pub fn selected_dir_path(&self) -> Option<RemotePath> {
let entries = self.entries_for_display();
let (_, is_dir, path) = entries.get(self.selected_index)?;
if *is_dir { Some(path.clone()) } else { None }
}
#[must_use]
pub fn resolve_dir_name(&self, name: &str) -> Option<RemotePath> {
if name == ".." {
return None; }
let listing = self.listing.as_ref()?;
let name = name.trim_end_matches('/');
for p in &listing.paths {
let segment = p
.path
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(&p.path);
if segment == name {
return Some(RemotePath::from(p.path.clone()));
}
}
None
}
pub fn set_loading(&mut self, message: impl Into<String>) {
self.loading_started = Some(Instant::now());
self.status_message = message.into();
}
pub const fn clear_loading(&mut self) {
self.loading_started = None;
}
}
#[derive(Clone, Debug)]
pub enum AsyncResultAction {
Listing(std::result::Result<DirectoryListing, Error>),
Get(std::result::Result<(), Error>),
Put(std::result::Result<(), Error>),
Delete(std::result::Result<(), Error>),
Move(std::result::Result<(), Error>),
}
impl AsyncResultAction {
#[must_use]
pub const fn should_refresh_listing_on_success(&self) -> bool {
matches!(
self,
AsyncResultAction::Delete(Ok(())) | AsyncResultAction::Move(Ok(()))
)
}
pub fn apply(self, state: &mut AppState) {
state.clear_loading();
match self {
AsyncResultAction::Listing(Ok(listing)) => {
state.listing = Some(listing);
state.status_message.clear();
state.clamp_selected_index();
}
AsyncResultAction::Listing(Err(e)) => {
state.status_message = format!("Listing failed: {}", e.display_for_user());
}
AsyncResultAction::Get(res) => {
Self::apply_op_result(state, "Download complete.", res, "Get");
}
AsyncResultAction::Put(res) => {
Self::apply_op_result(state, "Upload complete.", res, "Put");
}
AsyncResultAction::Delete(res) => {
Self::apply_op_result(state, "Deleted.", res, "Delete");
}
AsyncResultAction::Move(res) => {
Self::apply_op_result(state, "Moved.", res, "Move");
}
}
}
fn apply_op_result(
state: &mut AppState,
success_msg: &str,
result: std::result::Result<(), Error>,
op_name: &str,
) {
match result {
Ok(()) => state.status_message = success_msg.to_string(),
Err(e) => {
state.status_message = format!("{op_name} failed: {}", e.display_for_user());
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActionKind {
Sync,
SyncClearStatus,
AsyncTrigger,
AsyncResult,
StatusOverride,
}
#[derive(Clone, Debug)]
pub enum UserAction {
Quit,
Refresh(Option<String>), CdIn, CdUp, CdTo(String), CursorUp,
CursorDown,
Get, GetFile(String), Put(String), Rm, RmFile(String), ConfirmRm(bool),
Mv(String, String), MvTo(String), Status(String),
AsyncResult(AsyncResultAction),
}
fn parse_mv(rest: &str) -> std::result::Result<UserAction, String> {
let r = rest.trim();
if r.is_empty() {
return Err("mv requires source and destination, e.g. mv file.txt newname.txt".to_string());
}
match r.find(char::is_whitespace) {
None => Ok(UserAction::MvTo(r.to_string())),
Some(i) => {
let src = r[..i].trim().to_string();
let dest = r[i + 1..].trim().to_string();
if dest.is_empty() {
Err("mv requires destination, e.g. mv file.txt newname.txt".to_string())
} else {
Ok(UserAction::Mv(src, dest))
}
}
}
}
impl FromStr for UserAction {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let line = s.trim();
let (cmd, rest) = match line.find(char::is_whitespace) {
Some(i) => (line[..i].trim(), line[i + 1..].trim()),
None => (line, ""),
};
let cmd = cmd.to_lowercase();
match (cmd.as_str(), rest) {
("ls", "") => Ok(UserAction::Refresh(None)),
("ls", r) => Ok(UserAction::Refresh(Some(r.to_string()))),
("cd", "") => Ok(UserAction::CdIn),
("cd", "..") => Ok(UserAction::CdUp),
("cd", r) => Ok(UserAction::CdTo(r.to_string())),
("get", "") => Ok(UserAction::Get),
("get", r) => Ok(UserAction::GetFile(r.to_string())),
("put", "") => Err("put requires a local path, e.g. put ./file.txt".to_string()),
("put", r) => Ok(UserAction::Put(r.to_string())),
("rm", "") => Ok(UserAction::Rm),
("rm", r) => Ok(UserAction::RmFile(r.to_string())),
("mv", "") => {
Err("mv requires source and destination, e.g. mv file.txt newname.txt".to_string())
}
("mv", r) => parse_mv(r),
("quit", _) | ("q", _) => Ok(UserAction::Quit),
_ => Err(format!(
"unknown command: {cmd}. Use ls, cd, get, put, rm, mv, quit."
)),
}
}
}
impl UserAction {
#[must_use]
pub const fn kind(&self) -> ActionKind {
match self {
UserAction::Quit | UserAction::CursorUp | UserAction::CursorDown => ActionKind::Sync,
UserAction::CdUp | UserAction::CdIn | UserAction::CdTo(_) => {
ActionKind::SyncClearStatus
}
UserAction::Refresh(_)
| UserAction::Get
| UserAction::GetFile(_)
| UserAction::Put(_)
| UserAction::Rm
| UserAction::RmFile(_)
| UserAction::Mv(_, _)
| UserAction::MvTo(_) => ActionKind::AsyncTrigger,
UserAction::ConfirmRm(_) => ActionKind::SyncClearStatus,
UserAction::AsyncResult(_) => ActionKind::AsyncResult,
UserAction::Status(_) => ActionKind::StatusOverride,
}
}
}
pub fn draw(frame: &mut Frame, state: &AppState, config: &Config) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(3), ])
.split(frame.area());
let entries = state.entries_for_display();
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(i, (text, _, _))| {
let style = if i == state.selected_index {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(text.as_str(), style)))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(format!(
" Remote: {} | Connector: {} ",
state.current_remote_path, config.connector_id
))
.borders(Borders::ALL),
);
let list_chunk = chunks.first().copied().unwrap_or_default();
frame.render_widget(list, list_chunk);
let status: String = if let Some(started) = state.loading_started {
let spinner_char = {
let idx = (started.elapsed().as_millis() / SPINNER_MS_PER_FRAME) as usize
% SPINNER_FRAMES.len();
SPINNER_FRAMES.get(idx).copied().unwrap_or('|')
};
let msg = if state.status_message.is_empty() {
"Loading..."
} else {
state.status_message.as_str()
};
format!(" [{spinner_char}] {msg} ")
} else if state.status_message.is_empty() {
" ls cd [dir|..] get [file] put <local> rm [file] mv [src] dest quit ".to_string()
} else {
state.status_message.clone()
};
let status_para = Paragraph::new(status.as_str())
.block(Block::default().borders(Borders::NONE))
.wrap(Wrap { trim: true });
let status_chunk = chunks.get(1).copied().unwrap_or_default();
frame.render_widget(status_para, status_chunk);
let cmd_line = format!("> {}_", state.cmd_buffer);
let cmd_para = Paragraph::new(cmd_line.as_str())
.block(Block::default().borders(Borders::ALL).title(" Command "))
.wrap(Wrap { trim: true });
let cmd_chunk = chunks.get(2).copied().unwrap_or_default();
frame.render_widget(cmd_para, cmd_chunk);
if let Some(ref path) = state.pending_rm {
let area = frame.area();
let popup_width = area.width.saturating_sub(4).clamp(20, 60);
let popup_height = 5u16;
let popup_rect = Rect {
x: area.width.saturating_sub(popup_width) / 2,
y: area.height.saturating_sub(popup_height) / 2,
width: popup_width,
height: popup_height,
};
frame.render_widget(Clear, popup_rect);
let msg = format!("Delete {}? (y/n)", path);
let block = Block::default().borders(Borders::ALL).title(" Delete ");
let para = Paragraph::new(msg.as_str())
.block(block)
.wrap(Wrap { trim: true });
frame.render_widget(para, popup_rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::listing::{DirectoryListing, ListedFile, ListedPath};
#[test]
fn glob_match_star_matches_any_sequence() {
assert!(super::glob_match("A*100*", "ABC100200300"));
assert!(super::glob_match("A*100*", "A100"));
assert!(super::glob_match("*", "anything"));
assert!(super::glob_match("*", ""));
assert!(super::glob_match("*.txt", "file.txt"));
assert!(super::glob_match("*.txt", ".txt"));
assert!(!super::glob_match("A*100*", "ABC200300"));
assert!(!super::glob_match("*.txt", "file.dat"));
}
#[test]
fn glob_match_question_matches_single_char() {
assert!(super::glob_match("?", "a"));
assert!(super::glob_match("f?o", "foo"));
assert!(super::glob_match("?.txt", "a.txt"));
assert!(!super::glob_match("?", ""));
assert!(!super::glob_match("?", "ab"));
assert!(!super::glob_match("f?o", "fo"));
}
#[test]
fn glob_match_empty_and_literal() {
assert!(super::glob_match("", ""));
assert!(!super::glob_match("", "x"));
assert!(super::glob_match("abc", "abc"));
assert!(!super::glob_match("abc", "ab"));
assert!(!super::glob_match("abc", "abcd"));
}
#[test]
fn ls_parses_without_glob() {
let a: UserAction = "ls".parse().unwrap();
assert!(matches!(a, UserAction::Refresh(None)));
}
#[test]
fn ls_parses_with_glob() {
let a: UserAction = "ls A*100*".parse().unwrap();
assert!(matches!(&a, UserAction::Refresh(Some(p)) if p == "A*100*"));
let b: UserAction = "ls *.txt".parse().unwrap();
assert!(matches!(&b, UserAction::Refresh(Some(p)) if p == "*.txt"));
}
#[test]
fn entries_for_display_filters_by_glob() {
let config = crate::config::test_config();
let mut state = AppState::new(&config);
state.listing = Some(DirectoryListing {
paths: vec![
ListedPath {
path: "/foo".to_string(),
},
ListedPath {
path: "/ABC100dir".to_string(),
},
],
files: vec![
ListedFile {
file_path: "/readme.txt".to_string(),
modified_timestamp: None,
size: Some(1024),
},
ListedFile {
file_path: "/ABC100200300".to_string(),
modified_timestamp: None,
size: None,
},
ListedFile {
file_path: "/other.dat".to_string(),
modified_timestamp: None,
size: None,
},
],
truncated: false,
});
let all = state.entries_for_display();
assert_eq!(all.len(), 5);
state.listing_glob = Some("A*100*".to_string());
let filtered = state.entries_for_display();
assert_eq!(filtered.len(), 2);
let names: Vec<String> = filtered
.iter()
.map(|(s, _, _)| {
s.trim()
.trim_end_matches('/')
.replace(" (", " ")
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
})
.collect();
assert!(names.contains(&"ABC100dir".to_string()));
assert!(names.contains(&"ABC100200300".to_string()));
}
#[test]
fn clamp_selected_index_after_filter() {
let config = crate::config::test_config();
let mut state = AppState::new(&config);
state.listing = Some(DirectoryListing {
paths: vec![],
files: vec![
ListedFile {
file_path: "/a".to_string(),
modified_timestamp: None,
size: None,
},
ListedFile {
file_path: "/b".to_string(),
modified_timestamp: None,
size: None,
},
],
truncated: false,
});
state.selected_index = 5;
state.clamp_selected_index();
assert_eq!(state.selected_index, 1);
state.listing_glob = Some("c".to_string());
state.selected_index = 1;
state.clamp_selected_index();
assert_eq!(state.selected_index, 0);
}
}