pub mod config;
pub mod error;
pub mod listing;
pub mod local_fs;
pub mod retry;
pub mod transfer;
pub mod transfer_commands;
pub mod transfer_storage;
pub mod tui;
use aws_config::BehaviorVersion;
pub use config::Config;
pub use error::Result;
use listing::list_directory;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io;
use std::path::PathBuf;
use tokio::sync::mpsc;
use local_fs::LocalFs;
use transfer_commands::TransferCommands;
use transfer_storage::TransferStorage;
pub async fn run(config: Config) -> Result<()> {
let aws_config = load_aws_config(&config).await;
let transfer =
transfer_commands::AwsTransferCommands::new(aws_sdk_transfer::Client::new(&aws_config));
let storage = transfer_storage::AwsTransferStorage::new(aws_sdk_s3::Client::new(&aws_config));
let local_fs = local_fs::TokioLocalFs::new();
run_with(&config, &transfer, &storage, &local_fs).await
}
pub async fn run_with<TC, TS, LF>(
config: &Config,
transfer: &TC,
storage: &TS,
local_fs: &LF,
) -> Result<()>
where
TC: TransferCommands + Clone + Send + Sync + 'static,
TS: TransferStorage + Clone + Send + Sync + 'static,
LF: LocalFs + Clone + Send + Sync + 'static,
{
crossterm::terminal::enable_raw_mode().map_err(|e| {
error::Error::io("enable_raw_mode failed", e).with("operation", "terminal_init")
})?;
let mut stdout = io::stdout();
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen).map_err(|e| {
error::Error::io("EnterAlternateScreen failed", e).with("operation", "terminal_init")
})?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(|e| {
error::Error::io("Terminal::new failed", e).with("operation", "terminal_init")
})?;
let (action_tx, mut action_rx) = mpsc::unbounded_channel::<tui::UserAction>();
let mut state = tui::AppState::new(config);
loop {
terminal.draw(|f| tui::draw(f, &state, config))?;
let action_opt = poll_action(&mut action_rx, &mut state)?;
if let Some(action) = action_opt
&& dispatch_action(
action, &mut state, config, transfer, storage, local_fs, &action_tx,
)
.await
{
break;
}
}
crossterm::terminal::disable_raw_mode().map_err(|e| {
error::Error::io("disable_raw_mode failed", e).with("operation", "terminal_teardown")
})?;
crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen).map_err(|e| {
error::Error::io("LeaveAlternateScreen failed", e).with("operation", "terminal_teardown")
})?;
Ok(())
}
fn poll_action(
action_rx: &mut mpsc::UnboundedReceiver<tui::UserAction>,
state: &mut tui::AppState,
) -> Result<Option<tui::UserAction>> {
if let Ok(action) = action_rx.try_recv() {
return Ok(Some(action));
}
if crossterm::event::poll(std::time::Duration::from_millis(100))
.map_err(|e| error::Error::io("event poll failed", e).with("operation", "poll_action"))?
&& let crossterm::event::Event::Key(key) = crossterm::event::read().map_err(|e| {
error::Error::io("event read failed", e).with("operation", "poll_action")
})?
&& key.kind == crossterm::event::KeyEventKind::Press
{
return Ok(handle_key(state, key.code, key.modifiers));
}
Ok(None)
}
fn handle_key(
state: &mut tui::AppState,
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<tui::UserAction> {
use crossterm::event::{KeyCode, KeyModifiers};
match (code, modifiers) {
(KeyCode::Enter, KeyModifiers::NONE) => {
if state.cmd_buffer.trim().is_empty() {
state.cmd_buffer.clear();
return None;
}
let action = state.cmd_buffer.parse::<tui::UserAction>();
state.cmd_buffer.clear();
match action {
Ok(act) => Some(act),
Err(e) => {
state.status_message = e;
None
}
}
}
(KeyCode::Esc, KeyModifiers::NONE) => {
state.cmd_buffer.clear();
None
}
(KeyCode::Backspace, KeyModifiers::NONE) => {
state.cmd_buffer.pop();
None
}
(KeyCode::Char(c), KeyModifiers::NONE) => {
state.cmd_buffer.push(c);
None
}
(KeyCode::Up, KeyModifiers::NONE) => Some(tui::UserAction::CursorUp),
(KeyCode::Down, KeyModifiers::NONE) => Some(tui::UserAction::CursorDown),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_action<TC, TS, LF>(
action: tui::UserAction,
state: &mut tui::AppState,
config: &Config,
transfer: &TC,
storage: &TS,
local_fs: &LF,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
) -> bool
where
TC: TransferCommands + Clone + Send + Sync + 'static,
TS: TransferStorage + Clone + Send + Sync + 'static,
LF: LocalFs + Clone + Send + Sync + 'static,
{
match action {
tui::UserAction::Quit => return true,
tui::UserAction::CursorUp => {
state.selected_index = state.selected_index.saturating_sub(1);
}
tui::UserAction::CursorDown => {
let max = state.entries_for_display().len();
if max > 0 && state.selected_index < max - 1 {
state.selected_index += 1;
}
}
tui::UserAction::CdUp => {
state.current_remote_path = parent_path(&state.current_remote_path);
state.selected_index = 0;
state.status_message.clear();
}
tui::UserAction::CdIn => {
if let Some(dir_path) = state.selected_dir_path() {
state.current_remote_path = dir_path;
state.selected_index = 0;
state.status_message.clear();
} else {
state.status_message =
"no directory selected; use ↑/↓ then cd or cd <name>".to_string();
}
}
tui::UserAction::CdTo(dirname) => {
if let Some(full_path) = state.resolve_dir_name(&dirname) {
state.current_remote_path = full_path;
state.selected_index = 0;
state.status_message.clear();
} else {
state.status_message = format!("no such directory: {dirname}");
}
}
tui::UserAction::Refresh => {
state.set_loading("Listing...");
let path = state.current_remote_path.clone();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
let tx = action_tx.clone();
tokio::spawn(async move {
let res = list_directory(&tc, &sc, &cfg, &path, Some(1000)).await;
let msg = tui::UserAction::AsyncResult(tui::AsyncResultAction::Listing(res));
drop(tx.send(msg));
});
}
tui::UserAction::AsyncResult(a) => a.apply(state),
tui::UserAction::Status(msg) => {
state.clear_loading();
state.status_message = msg;
}
tui::UserAction::Get => {
if let Some(remote_path) = state.selected_file_path() {
spawn_get(
state,
config,
transfer,
storage,
local_fs,
action_tx,
&remote_path,
);
} else {
state.status_message =
"no file selected; use ↑/↓ then get or get <remote_path>".to_string();
}
}
tui::UserAction::GetFile(remote_path) => {
spawn_get(
state,
config,
transfer,
storage,
local_fs,
action_tx,
&remote_path,
);
}
tui::UserAction::Put(local_path_str) => {
let local_path = PathBuf::from(local_path_str.trim());
state.set_loading(format!("Putting {}...", local_path.display()));
let current = state.current_remote_path.clone();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
let lf = local_fs.clone();
let tx = action_tx.clone();
tokio::spawn(async move {
let exists = lf.exists(&local_path).await;
if exists {
let res = transfer::put_file(&tc, &sc, &lf, &cfg, &local_path, ¤t).await;
let msg = tui::UserAction::AsyncResult(tui::AsyncResultAction::Put(res));
drop(tx.send(msg));
} else {
let msg = tui::UserAction::Status(format!(
"File not found: {}",
local_path.display()
));
drop(tx.send(msg));
}
});
}
}
false
}
fn spawn_get<TC, TS, LF>(
state: &mut tui::AppState,
config: &Config,
transfer: &TC,
storage: &TS,
local_fs: &LF,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
remote_path: &str,
) where
TC: TransferCommands + Clone + Send + Sync + 'static,
TS: TransferStorage + Clone + Send + Sync + 'static,
LF: LocalFs + Clone + Send + Sync + 'static,
{
let local_path = state.download_dir.join(
PathBuf::from(remote_path.trim_start_matches('/'))
.file_name()
.unwrap_or(std::ffi::OsStr::new("file")),
);
state.set_loading(format!("Getting {}...", remote_path));
let current = state.current_remote_path.clone();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
let lf = local_fs.clone();
let tx = action_tx.clone();
let remote_path = remote_path.to_string();
tokio::spawn(async move {
let res =
transfer::get_file(&tc, &sc, &lf, &cfg, &remote_path, &local_path, ¤t).await;
drop(
tx.send(tui::UserAction::AsyncResult(tui::AsyncResultAction::Get(
res,
))),
);
});
}
fn parent_path(path: &str) -> String {
let p = path.trim_matches('/');
if p.is_empty() {
return "/".to_string();
}
let mut parts: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect();
parts.pop();
if parts.is_empty() {
"/".to_string()
} else {
format!("/{}/", parts.join("/"))
}
}
async fn load_aws_config(config: &Config) -> aws_config::SdkConfig {
let region = config
.region
.clone()
.unwrap_or_else(|| "us-east-1".to_string());
let profile = config.profile.clone();
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).region(aws_config::Region::new(region));
if let Some(p) = profile {
loader = loader.profile_name(p);
}
loader.load().await
}