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;
pub mod types;
use aws_config::BehaviorVersion;
pub use config::Config;
pub use error_ctx::Result;
use error_ctx::ResultCtxExt;
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;
use types::RemotePath;
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
}
async fn run_with_impl<TC, TS, LF>(
config: &Config,
transfer: &TC,
storage: &TS,
local_fs: &LF,
) -> error::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(())
}
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,
{
run_with_impl(config, transfer, storage, local_fs)
.await
.context("run TUI")?;
Ok(())
}
fn poll_action(
action_rx: &mut mpsc::UnboundedReceiver<tui::UserAction>,
state: &mut tui::AppState,
) -> error::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};
if state.pending_rm.is_some() {
return handle_confirm_rm_key(code, modifiers);
}
match (code, modifiers) {
(KeyCode::Enter, KeyModifiers::NONE) => handle_enter_key(state),
(KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(tui::UserAction::Quit),
(KeyCode::Esc, KeyModifiers::NONE) => {
state.cmd_buffer.clear();
None
}
(KeyCode::Backspace, KeyModifiers::NONE) => {
state.cmd_buffer.pop();
None
}
(KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => {
state.cmd_buffer.push(c);
None
}
(KeyCode::Up, KeyModifiers::NONE) => Some(tui::UserAction::CursorUp),
(KeyCode::Down, KeyModifiers::NONE) => Some(tui::UserAction::CursorDown),
_ => None,
}
}
const fn handle_confirm_rm_key(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<tui::UserAction> {
use crossterm::event::{KeyCode, KeyModifiers};
match (code, modifiers) {
(KeyCode::Char('y' | 'Y'), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
Some(tui::UserAction::ConfirmRm(true))
}
(KeyCode::Char('n' | 'N'), KeyModifiers::NONE | KeyModifiers::SHIFT)
| (KeyCode::Esc, KeyModifiers::NONE) => Some(tui::UserAction::ConfirmRm(false)),
_ => None,
}
}
fn handle_enter_key(state: &mut tui::AppState) -> Option<tui::UserAction> {
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
}
}
}
#[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,
{
let is_cd_action = matches!(
action,
tui::UserAction::CdUp | tui::UserAction::CdIn | tui::UserAction::CdTo(_)
);
if let Some(quit) = handle_navigation(&action, state)
&& quit
{
return true;
}
if is_cd_action {
state.listing_glob = None;
spawn_listing(state, config, transfer, storage, action_tx);
}
let (handled, need_refresh) = handle_async_result_status(&action, state);
if need_refresh {
spawn_listing(state, config, transfer, storage, action_tx);
}
if handled {
return false;
}
match action {
tui::UserAction::Refresh(glob) => {
state.listing_glob = glob.clone();
state.clamp_selected_index();
spawn_listing(state, config, transfer, storage, action_tx);
}
tui::UserAction::Get => {
if let Some(remote_path) = state.selected_file_path() {
spawn_get(
state,
config,
transfer,
storage,
local_fs,
action_tx,
remote_path.as_str(),
);
} 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,
RemotePath::from(remote_path).as_str(),
);
}
tui::UserAction::Put(local_path_str) => {
let local_path = PathBuf::from(local_path_str.trim());
let current = state.current_remote_path.as_str().to_string();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
let lf = local_fs.clone();
spawn_async_op(
state,
format!("Putting {}...", local_path.display()),
action_tx,
async move {
let exists = lf.exists(&local_path).await;
if exists {
let res =
transfer::put_file(&tc, &sc, &lf, &cfg, &local_path, current.as_str())
.await;
tui::UserAction::AsyncResult(tui::AsyncResultAction::Put(res))
} else {
tui::UserAction::Status(format!("File not found: {}", local_path.display()))
}
},
);
}
tui::UserAction::Rm => {
if let Some(remote_path) = state.selected_file_path() {
state.pending_rm = Some(vec![remote_path]);
} else {
state.status_message =
"no file selected; use ↑/↓ then rm or rm <remote_path>".to_string();
}
}
tui::UserAction::RmFile(rest) => {
if rest.contains('*') || rest.contains('?') {
let paths = state.file_paths_matching_glob(&rest);
if paths.is_empty() {
state.status_message = "no matching files".to_string();
} else {
state.pending_rm = Some(paths);
}
} else {
let full_path = state.current_remote_path.resolve(&rest);
state.pending_rm = Some(vec![full_path]);
}
}
tui::UserAction::ConfirmRm(confirm) => {
let path_opt = state.pending_rm.as_mut().and_then(|q| {
if q.is_empty() {
None
} else {
Some(q.remove(0))
}
});
if let Some(path) = path_opt {
if confirm {
spawn_delete(state, config, transfer, action_tx, path.as_str());
} else {
state.status_message = "Cancelled.".to_string();
}
}
if state.pending_rm.as_ref().map_or(true, Vec::is_empty) {
state.pending_rm = None;
}
}
tui::UserAction::Mv(src, dest) => {
let full_src = state.current_remote_path.resolve(&src);
let full_dest = state.current_remote_path.resolve(&dest);
spawn_move(
state,
config,
transfer,
action_tx,
full_src.as_str(),
full_dest.as_str(),
);
}
tui::UserAction::MvTo(dest) => {
if let Some(path) = state.selected_file_path() {
let full_dest = state.current_remote_path.resolve(&dest);
spawn_move(
state,
config,
transfer,
action_tx,
path.as_str(),
full_dest.as_str(),
);
} else {
state.status_message =
"no file selected; use ↑/↓ then mv <dest> or mv <src> <dest>".to_string();
}
}
tui::UserAction::Quit
| tui::UserAction::CursorUp
| tui::UserAction::CursorDown
| tui::UserAction::CdUp
| tui::UserAction::CdIn
| tui::UserAction::CdTo(_)
| tui::UserAction::AsyncResult(_)
| tui::UserAction::Status(_) => {}
}
false
}
fn spawn_listing<TC, TS>(
state: &mut tui::AppState,
config: &Config,
transfer: &TC,
storage: &TS,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
) where
TC: TransferCommands + Clone + Send + Sync + 'static,
TS: TransferStorage + Clone + Send + Sync + 'static,
{
let path = state.current_remote_path.clone();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
spawn_async_op(state, "Listing...", action_tx, async move {
let res = list_directory(&tc, &sc, &cfg, &path, Some(1000)).await;
tui::UserAction::AsyncResult(tui::AsyncResultAction::Listing(res))
});
}
fn handle_navigation(action: &tui::UserAction, state: &mut tui::AppState) -> Option<bool> {
match action {
tui::UserAction::Quit => Some(true),
tui::UserAction::CursorUp => {
state.selected_index = state.selected_index.saturating_sub(1);
Some(false)
}
tui::UserAction::CursorDown => {
let max = state.entries_for_display().len();
if max > 0 && state.selected_index < max - 1 {
state.selected_index += 1;
}
Some(false)
}
tui::UserAction::CdUp => {
state.current_remote_path = state.current_remote_path.parent();
state.selected_index = 0;
state.status_message.clear();
Some(false)
}
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();
}
Some(false)
}
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}");
}
Some(false)
}
_ => None,
}
}
fn handle_async_result_status(action: &tui::UserAction, state: &mut tui::AppState) -> (bool, bool) {
match action {
tui::UserAction::AsyncResult(a) => {
let refresh = a.should_refresh_listing_on_success();
a.clone().apply(state);
(true, refresh)
}
tui::UserAction::Status(msg) => {
state.clear_loading();
state.status_message = msg.clone();
(true, false)
}
_ => (false, false),
}
}
fn spawn_async_op<Fut>(
state: &mut tui::AppState,
loading_msg: impl Into<String>,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
fut: Fut,
) where
Fut: std::future::Future<Output = tui::UserAction> + Send + 'static,
{
state.set_loading(loading_msg);
let tx = action_tx.clone();
tokio::spawn(async move {
let msg = fut.await;
drop(tx.send(msg));
});
}
fn spawn_delete<TC>(
state: &mut tui::AppState,
config: &Config,
transfer: &TC,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
full_remote_path: &str,
) where
TC: crate::transfer_commands::TransferCommands + Clone + Send + Sync + 'static,
{
let cfg = config.clone();
let tc = transfer.clone();
let full_remote_path = full_remote_path.to_string();
spawn_async_op(
state,
format!("Deleting {}...", full_remote_path),
action_tx,
async move {
let res = transfer::delete_file(&tc, &cfg, &full_remote_path).await;
tui::UserAction::AsyncResult(tui::AsyncResultAction::Delete(res))
},
);
}
fn spawn_move<TC>(
state: &mut tui::AppState,
config: &Config,
transfer: &TC,
action_tx: &mpsc::UnboundedSender<tui::UserAction>,
full_src: &str,
full_dest: &str,
) where
TC: crate::transfer_commands::TransferCommands + Clone + Send + Sync + 'static,
{
let cfg = config.clone();
let tc = transfer.clone();
let full_src = full_src.to_string();
let full_dest = full_dest.to_string();
spawn_async_op(
state,
format!("Moving {}...", full_src),
action_tx,
async move {
let res = transfer::move_file(&tc, &cfg, &full_src, &full_dest).await;
tui::UserAction::AsyncResult(tui::AsyncResultAction::Move(res))
},
);
}
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")),
);
let current = state.current_remote_path.as_str().to_string();
let cfg = config.clone();
let tc = transfer.clone();
let sc = storage.clone();
let lf = local_fs.clone();
let remote_path = remote_path.to_string();
spawn_async_op(
state,
format!("Getting {}...", remote_path),
action_tx,
async move {
let remote_path_rp = RemotePath::from(remote_path);
let res = transfer::get_file(
&tc,
&sc,
&lf,
&cfg,
&remote_path_rp,
&local_path,
current.as_str(),
)
.await;
tui::UserAction::AsyncResult(tui::AsyncResultAction::Get(res))
},
);
}
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
}