use crate::config::Config;
use crate::error::Error;
use crate::listing::DirectoryListing;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, 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: String,
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,
}
impl AppState {
#[must_use]
pub fn new(config: &Config) -> Self {
Self {
current_remote_path: "/".to_string(),
listing: None,
status_message: String::new(),
selected_index: 0,
loading_started: None,
download_dir: config.download_dir.clone(),
cmd_buffer: String::new(),
}
}
#[must_use]
pub fn entries_for_display(&self) -> Vec<(String, bool)> {
let mut entries = Vec::new();
if let Some(ref listing) = self.listing {
for p in &listing.paths {
let name = p.path.rsplit('/').next().unwrap_or(&p.path).to_string();
if !name.is_empty() {
entries.push((format!(" {name}/"), true));
}
}
for f in &listing.files {
let name = f
.file_path
.rsplit('/')
.next()
.unwrap_or(&f.file_path)
.to_string();
if !name.is_empty() {
let size = f
.size
.map(|s| format!(" {name} ({s} B)"))
.unwrap_or_else(|| format!(" {name}"));
entries.push((size, false));
}
}
}
entries
}
#[must_use]
pub fn selected_file_path(&self) -> Option<String> {
let listing = self.listing.as_ref()?;
let num_dirs = listing.paths.len();
if self.selected_index < num_dirs {
return None;
}
let file_index = self.selected_index - num_dirs;
listing.files.get(file_index).map(|f| f.file_path.clone())
}
#[must_use]
pub fn selected_dir_path(&self) -> Option<String> {
let listing = self.listing.as_ref()?;
listing
.paths
.get(self.selected_index)
.map(|p| p.path.clone())
}
#[must_use]
pub fn resolve_dir_name(&self, name: &str) -> Option<String> {
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(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>),
}
impl AsyncResultAction {
pub fn apply(self, state: &mut AppState) {
state.clear_loading();
match self {
AsyncResultAction::Listing(Ok(listing)) => {
state.listing = Some(listing);
state.status_message.clear();
}
AsyncResultAction::Listing(Err(e)) => {
state.status_message = format!("Listing failed: {}", e.display_for_user());
}
AsyncResultAction::Get(Ok(())) => {
state.status_message = "Download complete.".to_string();
}
AsyncResultAction::Get(Err(e)) => {
state.status_message = format!("Get failed: {}", e.display_for_user());
}
AsyncResultAction::Put(Ok(())) => {
state.status_message = "Upload complete.".to_string();
}
AsyncResultAction::Put(Err(e)) => {
state.status_message = format!("Put 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, CdIn, CdUp, CdTo(String), CursorUp,
CursorDown,
Get, GetFile(String), Put(String), Status(String),
AsyncResult(AsyncResultAction),
}
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),
("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())),
("quit", _) | ("q", _) => Ok(UserAction::Quit),
_ => Err(format!(
"unknown command: {cmd}. Use ls, cd, get, put, 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(_) => {
ActionKind::AsyncTrigger
}
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(1),
])
.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> 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);
}