transfer_family_cli 0.1.0

TUI to browse and transfer files via AWS Transfer Family connector
Documentation
//! TUI (terminal UI) for the Transfer Family Connector CLI.

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;

/// Spinner frames (ASCII, 4 frames).
const SPINNER_FRAMES: &[char] = &['|', '/', '-', '\\'];
const SPINNER_MS_PER_FRAME: u128 = 80;

/// App state for the TUI.
#[non_exhaustive]
pub struct AppState {
    pub current_remote_path: String,
    pub listing: Option<DirectoryListing>,
    pub status_message: String,
    pub selected_index: usize,
    /// When loading, time at which the operation started (None = not loading).
    pub loading_started: Option<Instant>,
    pub download_dir: PathBuf,
    /// Command line buffer (e.g. "ls", "cd ..", "get file.txt", "put ./local.txt").
    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())
    }

    /// Resolve "cd &lt;name&gt;" to full remote path from current listing. Name is the last segment (e.g. "foo" or "..").
    #[must_use]
    pub fn resolve_dir_name(&self, name: &str) -> Option<String> {
        if name == ".." {
            return None; // Caller handles cd ..
        }
        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
    }

    /// Sets loading state for async operations (spinner, message).
    pub fn set_loading(&mut self, message: impl Into<String>) {
        self.loading_started = Some(Instant::now());
        self.status_message = message.into();
    }

    /// Clears loading state after async operation completes.
    pub const fn clear_loading(&mut self) {
        self.loading_started = None;
    }
}

/// Result of an async operation (listing, get, put).
#[derive(Clone, Debug)]
pub enum AsyncResultAction {
    Listing(std::result::Result<DirectoryListing, Error>),
    Get(std::result::Result<(), Error>),
    Put(std::result::Result<(), Error>),
}

impl AsyncResultAction {
    /// Applies the result to app state: clears loading and updates status/listing.
    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());
            }
        }
    }
}

/// Behavioral category of a user action (for documentation, tests, logging).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActionKind {
    /// Quit, `CursorUp`, `CursorDown` - no loading/status changes.
    Sync,
    /// `CdUp`, `CdIn`, `CdTo` - clear status on success.
    SyncClearStatus,
    /// Refresh, Get, `GetFile`, Put - trigger async work, set loading.
    AsyncTrigger,
    /// `AsyncResult` - clear loading, update state from result.
    AsyncResult,
    /// Status - clear loading, set message.
    StatusOverride,
}

/// User action from the TUI (parsed command or key) or from async task (result).
#[derive(Clone, Debug)]
pub enum UserAction {
    Quit,
    Refresh,      // ls
    CdIn,         // cd (into selected dir)
    CdUp,         // cd ..
    CdTo(String), // cd <dirname>
    CursorUp,
    CursorDown,
    Get,             // get (selected file)
    GetFile(String), // get <remote_path>
    Put(String),     // put <local_path>
    Status(String),
    /// Result of async operation (listing, get, put).
    AsyncResult(AsyncResultAction),
}

impl FromStr for UserAction {
    type Err = String;

    /// Parses a command line into a `UserAction`. Familiar syntax: ls, cd [dir|..], get [file], put `local_path`, quit.
    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 {
    /// Returns the behavioral category of this action.
    #[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,
        }
    }
}

/// Draws the TUI frame.
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);
}