mirror of
https://github.com/andwati/ftsync.git
synced 2026-10-18 01:12:53 +00:00
yolo
This commit is contained in:
Generated
+1629
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -1,6 +1,28 @@
|
||||
[package]
|
||||
name = "ftsync"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
description = "FTP sync TUI — local directory is authoritative"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
suppaftp = { version = "6.0", features = ["native-tls"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
chrono = "0.4"
|
||||
dirs = "5"
|
||||
glob = "0.3"
|
||||
anyhow = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
@@ -0,0 +1,142 @@
|
||||
# ftsync
|
||||
|
||||
A terminal-based FTP sync tool built with Ratatui. Local directory is always
|
||||
authoritative — new and modified files are uploaded to the server.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust 1.80+ (install via [rustup](https://rustup.rs))
|
||||
- OpenSSL development headers (for FTPS/TLS support)
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install libssl-dev pkg-config
|
||||
|
||||
# Arch
|
||||
sudo pacman -S openssl
|
||||
|
||||
# Fedora
|
||||
sudo dnf install openssl-devel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
# Binary: ./target/release/ftsync
|
||||
```
|
||||
|
||||
Optionally install system-wide:
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running
|
||||
|
||||
### Option A — environment variables (credentials never written to disk)
|
||||
|
||||
```bash
|
||||
FTP_HOST=ftp.example.com \
|
||||
FTP_USER=ian \
|
||||
FTP_PASS=secret \
|
||||
FTP_LOCAL=/home/ian/dist \
|
||||
FTP_REMOTE=/public_html \
|
||||
./target/release/ftsync
|
||||
```
|
||||
|
||||
Full list of env vars:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-------------|------------------------------------|---------|
|
||||
| `FTP_HOST` | FTP server hostname or IP | — |
|
||||
| `FTP_PORT` | FTP port | 21 |
|
||||
| `FTP_USER` | FTP username | — |
|
||||
| `FTP_PASS` | FTP password | — |
|
||||
| `FTP_LOCAL` | Local directory to sync from | — |
|
||||
| `FTP_REMOTE`| Remote directory to sync to | — |
|
||||
| `FTP_TLS` | Enable FTPS/TLS (`1`/`true`/`yes`) | false |
|
||||
|
||||
### Option B — interactive config screen
|
||||
|
||||
Run `ftsync` with no env vars. A config form will appear. Fill in your
|
||||
credentials and press **Enter** — they are saved to `~/.ftpsync.conf`.
|
||||
|
||||
---
|
||||
|
||||
## Keybindings
|
||||
|
||||
### Config screen
|
||||
|
||||
| Key | Action |
|
||||
|------------------|-------------------------|
|
||||
| `Tab` / `↓` | Next field |
|
||||
| `Shift+Tab` / `↑`| Previous field |
|
||||
| `Enter` | Save and go to sync |
|
||||
| `q` / `Esc` | Quit |
|
||||
|
||||
### Sync screen
|
||||
|
||||
| Key | Action |
|
||||
|-----|-----------------------------|
|
||||
| `s` | Start sync |
|
||||
| `d` | Dry run (preview only) |
|
||||
| `c` | Back to config |
|
||||
| `q` | Quit (or abort active sync) |
|
||||
|
||||
### Summary screen
|
||||
|
||||
| Key | Action |
|
||||
|-----|-----------------|
|
||||
| `s` | Sync again |
|
||||
| `c` | Back to config |
|
||||
| `q` | Quit |
|
||||
|
||||
---
|
||||
|
||||
## Config file
|
||||
|
||||
Stored at `~/.ftpsync.conf` (TOML). Environment variables always override
|
||||
values in this file.
|
||||
|
||||
```toml
|
||||
host = "ftp.example.com"
|
||||
port = 21
|
||||
user = "ian"
|
||||
password = "secret"
|
||||
local_dir = "/home/ian/dist"
|
||||
remote_dir = "/public_html"
|
||||
tls = false
|
||||
excludes = [".git", "node_modules", "__pycache__", ".DS_Store", "Thumbs.db"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How sync works
|
||||
|
||||
1. Connects to the FTP server (plain or FTPS).
|
||||
2. Recursively lists all files on the remote, recording their sizes.
|
||||
3. Walks the local directory tree, skipping excluded patterns.
|
||||
4. For each local file: if the remote file exists with the same byte size,
|
||||
it is **skipped**. Otherwise it is **uploaded**.
|
||||
5. Remote directories are created automatically as needed.
|
||||
6. The sync task runs on a Tokio async thread; the Ratatui UI stays
|
||||
responsive throughout via an `mpsc` channel.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs Terminal setup, Tokio event loop, keyboard handling
|
||||
├── app.rs App state machine (screens, fields, sync state)
|
||||
├── ftp.rs FTP/FTPS connection and sync engine (suppaftp)
|
||||
├── ui.rs All Ratatui widget rendering (3 screens)
|
||||
└── config.rs Config loading/saving, env var overlay
|
||||
```
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
use crate::config::Config;
|
||||
use crate::ftp::{SyncEvent, SyncStats};
|
||||
use chrono::Local;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Screen {
|
||||
Config,
|
||||
Sync,
|
||||
Summary,
|
||||
}
|
||||
|
||||
/// Which field is focused on the config screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ConfigField {
|
||||
Host,
|
||||
Port,
|
||||
User,
|
||||
Password,
|
||||
LocalDir,
|
||||
RemoteDir,
|
||||
Excludes,
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl ConfigField {
|
||||
pub const ALL: &'static [ConfigField] = &[
|
||||
ConfigField::Host,
|
||||
ConfigField::Port,
|
||||
ConfigField::User,
|
||||
ConfigField::Password,
|
||||
ConfigField::LocalDir,
|
||||
ConfigField::RemoteDir,
|
||||
ConfigField::Excludes,
|
||||
ConfigField::Tls,
|
||||
];
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
|
||||
Self::ALL[(idx + 1) % Self::ALL.len()]
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
|
||||
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LogEntry {
|
||||
pub time: String,
|
||||
pub level: LogLevel,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LogLevel {
|
||||
Info,
|
||||
Skip,
|
||||
Upload,
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub screen: Screen,
|
||||
pub config: Config,
|
||||
|
||||
// Config screen state
|
||||
pub focused_field: ConfigField,
|
||||
pub dry_run: bool,
|
||||
|
||||
// Sync screen state
|
||||
pub status_line: String,
|
||||
pub current_file: String,
|
||||
pub current_file_size: u64,
|
||||
pub total_local: usize,
|
||||
pub files_done: usize,
|
||||
pub log: Vec<LogEntry>,
|
||||
pub log_scroll: usize,
|
||||
|
||||
// Channel for receiving sync events
|
||||
pub event_rx: Option<mpsc::Receiver<SyncEvent>>,
|
||||
|
||||
// Summary
|
||||
pub stats: Option<SyncStats>,
|
||||
pub sync_active: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let config = Config::load();
|
||||
Self {
|
||||
screen: if config.is_complete() { Screen::Sync } else { Screen::Config },
|
||||
config,
|
||||
focused_field: ConfigField::Host,
|
||||
dry_run: false,
|
||||
status_line: "Ready".into(),
|
||||
current_file: String::new(),
|
||||
current_file_size: 0,
|
||||
total_local: 0,
|
||||
files_done: 0,
|
||||
log: Vec::new(),
|
||||
log_scroll: 0,
|
||||
event_rx: None,
|
||||
stats: None,
|
||||
sync_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_log(&mut self, level: LogLevel, message: String) {
|
||||
let time = Local::now().format("%H:%M:%S").to_string();
|
||||
self.log.push(LogEntry { time, level, message });
|
||||
// Auto-scroll to bottom (len >= 1 since we just pushed)
|
||||
self.log_scroll = self.log.len() - 1;
|
||||
}
|
||||
|
||||
/// Drain pending sync events from the channel, update state accordingly.
|
||||
pub fn poll_events(&mut self) {
|
||||
let Some(rx) = self.event_rx.as_mut() else { return };
|
||||
let mut events = Vec::new();
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
events.push(e);
|
||||
}
|
||||
for event in events {
|
||||
self.apply_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, event: SyncEvent) {
|
||||
match event {
|
||||
SyncEvent::Connecting => {
|
||||
self.status_line = "Connecting…".into();
|
||||
self.push_log(LogLevel::Info, format!("Connecting to {}:{}", self.config.host, self.config.port));
|
||||
}
|
||||
SyncEvent::Connected => {
|
||||
self.status_line = "Connected".into();
|
||||
self.push_log(LogLevel::Success, "Connected successfully".into());
|
||||
}
|
||||
SyncEvent::Scanning => {
|
||||
self.status_line = "Scanning remote…".into();
|
||||
self.push_log(LogLevel::Info, "Scanning remote directory…".into());
|
||||
}
|
||||
SyncEvent::RemoteScanned(n) => {
|
||||
self.push_log(LogLevel::Info, format!("Remote: {} file(s) found", n));
|
||||
}
|
||||
SyncEvent::LocalScanned(n) => {
|
||||
self.total_local = n;
|
||||
self.push_log(LogLevel::Info, format!("Local: {} file(s) to consider", n));
|
||||
self.status_line = "Syncing…".into();
|
||||
}
|
||||
SyncEvent::Uploading { path, size } => {
|
||||
self.current_file = path.clone();
|
||||
self.current_file_size = size;
|
||||
self.status_line = "Uploading…".into();
|
||||
self.push_log(LogLevel::Upload, format!("↑ {} ({} bytes)", path, size));
|
||||
}
|
||||
SyncEvent::Uploaded { path: _, size: _ } => {
|
||||
self.files_done += 1;
|
||||
}
|
||||
SyncEvent::Skipped(path) => {
|
||||
self.files_done += 1;
|
||||
self.push_log(LogLevel::Skip, format!("– {} (unchanged)", path));
|
||||
}
|
||||
SyncEvent::DryRun(path) => {
|
||||
self.files_done += 1;
|
||||
self.push_log(LogLevel::Upload, format!("[dry] {}", path));
|
||||
}
|
||||
SyncEvent::Error { path, message } => {
|
||||
self.files_done += 1;
|
||||
self.push_log(LogLevel::Error, format!("✗ {} — {}", path, message));
|
||||
}
|
||||
SyncEvent::Done(stats) => {
|
||||
self.sync_active = false;
|
||||
self.current_file = String::new();
|
||||
self.stats = Some(stats);
|
||||
self.screen = Screen::Summary;
|
||||
}
|
||||
SyncEvent::FatalError(msg) => {
|
||||
self.sync_active = false;
|
||||
self.status_line = format!("Error: {}", msg);
|
||||
self.push_log(LogLevel::Error, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset state for a fresh sync run.
|
||||
pub fn reset_sync(&mut self) {
|
||||
self.log.clear();
|
||||
self.log_scroll = 0;
|
||||
self.status_line = "Ready".into();
|
||||
self.current_file.clear();
|
||||
self.current_file_size = 0;
|
||||
self.total_local = 0;
|
||||
self.files_done = 0;
|
||||
self.stats = None;
|
||||
self.sync_active = true;
|
||||
}
|
||||
|
||||
pub fn progress_ratio(&self) -> f64 {
|
||||
if self.total_local == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.files_done as f64 / self.total_local as f64
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config field helpers ──────────────────────────────────────────────────
|
||||
|
||||
pub fn config_field_value(&self, field: ConfigField) -> String {
|
||||
match field {
|
||||
ConfigField::Host => self.config.host.clone(),
|
||||
ConfigField::Port => self.config.port.to_string(),
|
||||
ConfigField::User => self.config.user.clone(),
|
||||
ConfigField::Password => "*".repeat(self.config.password.len()),
|
||||
ConfigField::LocalDir => self.config.local_dir.clone(),
|
||||
ConfigField::RemoteDir => self.config.remote_dir.clone(),
|
||||
ConfigField::Excludes => self.config.excludes.join(", "),
|
||||
ConfigField::Tls => if self.config.tls { "yes".into() } else { "no".into() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a character into the currently focused config field.
|
||||
pub fn config_field_push(&mut self, c: char) {
|
||||
match self.focused_field {
|
||||
ConfigField::Host => self.config.host.push(c),
|
||||
ConfigField::Port => self.config.port = format!("{}{}", self.config.port, c).parse().unwrap_or(self.config.port),
|
||||
ConfigField::User => self.config.user.push(c),
|
||||
ConfigField::Password => self.config.password.push(c),
|
||||
ConfigField::LocalDir => self.config.local_dir.push(c),
|
||||
ConfigField::RemoteDir => self.config.remote_dir.push(c),
|
||||
ConfigField::Excludes => {} // handled separately
|
||||
ConfigField::Tls => {
|
||||
if c == ' ' || c == 'y' || c == 'n' {
|
||||
self.config.tls = !self.config.tls;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_field_pop(&mut self) {
|
||||
match self.focused_field {
|
||||
ConfigField::Host => { self.config.host.pop(); }
|
||||
ConfigField::Port => {
|
||||
let mut s = self.config.port.to_string();
|
||||
s.pop();
|
||||
self.config.port = s.parse().unwrap_or(21);
|
||||
}
|
||||
ConfigField::User => { self.config.user.pop(); }
|
||||
ConfigField::Password => { self.config.password.pop(); }
|
||||
ConfigField::LocalDir => { self.config.local_dir.pop(); }
|
||||
ConfigField::RemoteDir => { self.config.remote_dir.pop(); }
|
||||
ConfigField::Excludes => {}
|
||||
ConfigField::Tls => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub local_dir: String,
|
||||
pub remote_dir: String,
|
||||
pub tls: bool,
|
||||
pub excludes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn default_excludes() -> Vec<String> {
|
||||
vec![
|
||||
".git".into(),
|
||||
".svn".into(),
|
||||
"__pycache__".into(),
|
||||
".DS_Store".into(),
|
||||
"Thumbs.db".into(),
|
||||
"node_modules".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load from ~/.ftpsync.conf (TOML), then overlay env vars on top.
|
||||
pub fn load() -> Self {
|
||||
let mut cfg = Self::from_file().unwrap_or_default();
|
||||
cfg.overlay_env();
|
||||
if cfg.port == 0 {
|
||||
cfg.port = 21;
|
||||
}
|
||||
if cfg.excludes.is_empty() {
|
||||
cfg.excludes = Self::default_excludes();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".ftpsync.conf"))
|
||||
}
|
||||
|
||||
fn from_file() -> Option<Self> {
|
||||
let path = Self::config_path()?;
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
toml::from_str(&content).ok()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
if let Some(path) = Self::config_path() {
|
||||
let content = toml::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
std::fs::write(path, content)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn overlay_env(&mut self) {
|
||||
if let Ok(v) = std::env::var("FTP_HOST") { self.host = v; }
|
||||
if let Ok(v) = std::env::var("FTP_PORT") { self.port = v.parse().unwrap_or(21); }
|
||||
if let Ok(v) = std::env::var("FTP_USER") { self.user = v; }
|
||||
if let Ok(v) = std::env::var("FTP_PASS") { self.password = v; }
|
||||
if let Ok(v) = std::env::var("FTP_LOCAL") { self.local_dir = v; }
|
||||
if let Ok(v) = std::env::var("FTP_REMOTE") { self.remote_dir = v; }
|
||||
if let Ok(v) = std::env::var("FTP_TLS") {
|
||||
self.tls = matches!(v.to_lowercase().as_str(), "1" | "true" | "yes");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.host.is_empty()
|
||||
&& !self.user.is_empty()
|
||||
&& !self.password.is_empty()
|
||||
&& !self.local_dir.is_empty()
|
||||
&& !self.remote_dir.is_empty()
|
||||
}
|
||||
}
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
use crate::config::Config;
|
||||
use glob::Pattern;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use suppaftp::{FtpStream, NativeTlsFtpStream};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Number of parallel FTP connections used for uploads.
|
||||
const WORKER_COUNT: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncEvent {
|
||||
Connecting,
|
||||
Connected,
|
||||
Scanning,
|
||||
RemoteScanned(usize),
|
||||
LocalScanned(usize),
|
||||
Uploading { path: String, size: u64 },
|
||||
Uploaded { path: String, size: u64 },
|
||||
Skipped(String),
|
||||
DryRun(String),
|
||||
Error { path: String, message: String },
|
||||
Done(SyncStats),
|
||||
FatalError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncStats {
|
||||
pub uploaded: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: usize,
|
||||
pub bytes: u64,
|
||||
pub duration_secs: u64,
|
||||
}
|
||||
|
||||
// ─── Upload work item ─────────────────────────────────────────────────────────
|
||||
|
||||
struct UploadTask {
|
||||
local_path: PathBuf,
|
||||
rel: String,
|
||||
local_size: u64,
|
||||
remote_file_path: String,
|
||||
}
|
||||
|
||||
// ─── Exclusion ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_excluded(rel_path: &str, patterns: &[Pattern]) -> bool {
|
||||
let path = Path::new(rel_path);
|
||||
for component in path.components() {
|
||||
let s = component.as_os_str().to_string_lossy();
|
||||
for pat in patterns {
|
||||
if pat.matches(&s) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn compile_patterns(excludes: &[String]) -> Vec<Pattern> {
|
||||
excludes
|
||||
.iter()
|
||||
.filter_map(|e| Pattern::new(e).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Remote helpers (plain FTP) ───────────────────────────────────────────────
|
||||
|
||||
fn mkdirs_plain(ftp: &mut FtpStream, remote_path: &str) {
|
||||
let mut current = String::new();
|
||||
for part in remote_path.split('/').filter(|p| !p.is_empty()) {
|
||||
if current.is_empty() {
|
||||
current = part.to_string();
|
||||
} else {
|
||||
current = format!("{}/{}", current, part);
|
||||
}
|
||||
let _ = ftp.mkdir(¤t); // ignore error (already exists)
|
||||
}
|
||||
}
|
||||
|
||||
fn list_remote_plain(ftp: &mut FtpStream, remote_dir: &str) -> Vec<(String, u64)> {
|
||||
let mut result = Vec::new();
|
||||
list_recursive_plain(ftp, remote_dir, "", &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn list_recursive_plain(
|
||||
ftp: &mut FtpStream,
|
||||
base: &str,
|
||||
prefix: &str,
|
||||
out: &mut Vec<(String, u64)>,
|
||||
) {
|
||||
if ftp.cwd(base).is_err() {
|
||||
return;
|
||||
}
|
||||
let lines = match ftp.list(None) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return,
|
||||
};
|
||||
for line in lines {
|
||||
let parts: Vec<&str> = line.splitn(9, ' ').collect();
|
||||
if parts.len() < 9 {
|
||||
continue;
|
||||
}
|
||||
let name = parts[8].trim();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let rel = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
if line.starts_with('d') {
|
||||
list_recursive_plain(ftp, &format!("{}/{}", base, name), &rel, out);
|
||||
let _ = ftp.cwd(base);
|
||||
} else {
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
out.push((rel, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Local file walker ────────────────────────────────────────────────────────
|
||||
|
||||
fn walk_local(local_root: &Path, patterns: &[Pattern]) -> Vec<(PathBuf, String)> {
|
||||
let mut files = Vec::new();
|
||||
walk_dir(local_root, local_root, patterns, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
fn walk_dir(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
patterns: &[Pattern],
|
||||
out: &mut Vec<(PathBuf, String)>,
|
||||
) {
|
||||
let entries = match std::fs::read_dir(current) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(ft) = entry.file_type() else { continue };
|
||||
let path = entry.path();
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
if is_excluded(&rel, patterns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ft.is_dir() {
|
||||
walk_dir(root, &path, patterns, out);
|
||||
} else if ft.is_file() {
|
||||
out.push((path, rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn run_sync(config: Config, dry_run: bool, tx: mpsc::Sender<SyncEvent>) {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let _ = tx.send(SyncEvent::Connecting).await;
|
||||
|
||||
let patterns = compile_patterns(&config.excludes);
|
||||
let local_root = PathBuf::from(&config.local_dir);
|
||||
|
||||
if config.tls {
|
||||
run_tls(config, dry_run, patterns, local_root, start, tx).await;
|
||||
} else {
|
||||
run_plain(config, dry_run, patterns, local_root, start, tx).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Plain FTP ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run_plain(
|
||||
config: Config,
|
||||
dry_run: bool,
|
||||
patterns: Vec<Pattern>,
|
||||
local_root: PathBuf,
|
||||
start: std::time::Instant,
|
||||
tx: mpsc::Sender<SyncEvent>,
|
||||
) {
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
|
||||
// ── Phase 1: scan with a single connection ────────────────────────────────
|
||||
|
||||
let mut ftp = match FtpStream::connect(&addr) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("Connect failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = ftp.login(&config.user, &config.password) {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("Login failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
|
||||
ftp.set_mode(suppaftp::Mode::Passive);
|
||||
let _ = tx.send(SyncEvent::Connected).await;
|
||||
|
||||
let _ = tx.send(SyncEvent::Scanning).await;
|
||||
let remote_files = list_remote_plain(&mut ftp, &config.remote_dir);
|
||||
let _ = tx.send(SyncEvent::RemoteScanned(remote_files.len())).await;
|
||||
|
||||
let local_files = walk_local(&local_root, &patterns);
|
||||
let _ = tx.send(SyncEvent::LocalScanned(local_files.len())).await;
|
||||
|
||||
let remote_map: HashMap<String, u64> = remote_files.into_iter().collect();
|
||||
|
||||
let mut stats = SyncStats::default();
|
||||
let mut upload_tasks: Vec<UploadTask> = Vec::new();
|
||||
|
||||
for (local_path, rel) in local_files {
|
||||
let local_size = local_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let remote_size = remote_map.get(&rel).copied();
|
||||
|
||||
if remote_size == Some(local_size) {
|
||||
let _ = tx.send(SyncEvent::Skipped(rel)).await;
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
let _ = tx.send(SyncEvent::DryRun(rel)).await;
|
||||
stats.uploaded += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote_file_path = format!("{}/{}", config.remote_dir.trim_end_matches('/'), rel);
|
||||
upload_tasks.push(UploadTask { local_path, rel, local_size, remote_file_path });
|
||||
}
|
||||
|
||||
if upload_tasks.is_empty() {
|
||||
let _ = ftp.quit();
|
||||
stats.duration_secs = start.elapsed().as_secs();
|
||||
let _ = tx.send(SyncEvent::Done(stats)).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-create all remote directories on the scan connection so workers
|
||||
// don't need to think about directory creation at all.
|
||||
let _ = ftp.cwd("/");
|
||||
let mut created_dirs: HashSet<String> = HashSet::new();
|
||||
for task in &upload_tasks {
|
||||
let dir = task.remote_file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
|
||||
if created_dirs.insert(dir.clone()) {
|
||||
mkdirs_plain(&mut ftp, &dir);
|
||||
}
|
||||
}
|
||||
let _ = ftp.quit();
|
||||
|
||||
// ── Phase 2: parallel uploads ─────────────────────────────────────────────
|
||||
|
||||
let queue: Arc<Mutex<VecDeque<UploadTask>>> =
|
||||
Arc::new(Mutex::new(upload_tasks.into_iter().collect()));
|
||||
|
||||
let config = Arc::new(config);
|
||||
let mut handles = Vec::with_capacity(WORKER_COUNT);
|
||||
|
||||
for _ in 0..WORKER_COUNT {
|
||||
let queue = Arc::clone(&queue);
|
||||
let config = Arc::clone(&config);
|
||||
let tx = tx.clone();
|
||||
let addr = addr.clone();
|
||||
|
||||
handles.push(tokio::task::spawn_blocking(move || -> WorkerResult {
|
||||
let mut ftp = match FtpStream::connect(&addr) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
drain_queue_as_errors(&queue, &tx, format!("Connect failed: {}", e));
|
||||
return WorkerResult::default();
|
||||
}
|
||||
};
|
||||
if let Err(e) = ftp.login(&config.user, &config.password) {
|
||||
drain_queue_as_errors(&queue, &tx, format!("Login failed: {}", e));
|
||||
let _ = ftp.quit();
|
||||
return WorkerResult::default();
|
||||
}
|
||||
ftp.set_mode(suppaftp::Mode::Passive);
|
||||
|
||||
let mut result = WorkerResult::default();
|
||||
loop {
|
||||
let task = match queue.lock().unwrap().pop_front() {
|
||||
Some(t) => t,
|
||||
None => break,
|
||||
};
|
||||
upload_one_plain(&mut ftp, task, &tx, &mut result);
|
||||
}
|
||||
let _ = ftp.quit();
|
||||
result
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
if let Ok(r) = handle.await {
|
||||
stats.uploaded += r.uploaded;
|
||||
stats.errors += r.errors;
|
||||
stats.bytes += r.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
stats.duration_secs = start.elapsed().as_secs();
|
||||
let _ = tx.send(SyncEvent::Done(stats)).await;
|
||||
}
|
||||
|
||||
fn upload_one_plain(
|
||||
ftp: &mut FtpStream,
|
||||
task: UploadTask,
|
||||
tx: &mpsc::Sender<SyncEvent>,
|
||||
result: &mut WorkerResult,
|
||||
) {
|
||||
let _ = tx.blocking_send(SyncEvent::Uploading { path: task.rel.clone(), size: task.local_size });
|
||||
|
||||
match std::fs::File::open(&task.local_path) {
|
||||
Ok(file) => {
|
||||
let mut reader = BufReader::new(file);
|
||||
match ftp.put_file(&task.remote_file_path, &mut reader) {
|
||||
Ok(_) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Uploaded {
|
||||
path: task.rel,
|
||||
size: task.local_size,
|
||||
});
|
||||
result.uploaded += 1;
|
||||
result.bytes += task.local_size;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() });
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() });
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TLS FTP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run_tls(
|
||||
config: Config,
|
||||
dry_run: bool,
|
||||
patterns: Vec<Pattern>,
|
||||
local_root: PathBuf,
|
||||
start: std::time::Instant,
|
||||
tx: mpsc::Sender<SyncEvent>,
|
||||
) {
|
||||
use suppaftp::{NativeTlsConnector, native_tls::TlsConnector};
|
||||
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
|
||||
// ── Phase 1: scan with a single TLS connection ────────────────────────────
|
||||
|
||||
let connector = match TlsConnector::new() {
|
||||
Ok(c) => NativeTlsConnector::from(c),
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("TLS init failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stream = match NativeTlsFtpStream::connect(&addr) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("Connect failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut ftp = match stream.into_secure(connector, &config.host) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("TLS handshake failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = ftp.login(&config.user, &config.password) {
|
||||
let _ = tx.send(SyncEvent::FatalError(format!("Login failed: {}", e))).await;
|
||||
return;
|
||||
}
|
||||
|
||||
ftp.set_mode(suppaftp::Mode::Passive);
|
||||
let _ = tx.send(SyncEvent::Connected).await;
|
||||
let _ = tx.send(SyncEvent::Scanning).await;
|
||||
|
||||
let mut remote_files: Vec<(String, u64)> = Vec::new();
|
||||
tls_list_recursive(&mut ftp, &config.remote_dir, "", &mut remote_files);
|
||||
let _ = tx.send(SyncEvent::RemoteScanned(remote_files.len())).await;
|
||||
|
||||
let local_files = walk_local(&local_root, &patterns);
|
||||
let _ = tx.send(SyncEvent::LocalScanned(local_files.len())).await;
|
||||
|
||||
let remote_map: HashMap<String, u64> = remote_files.into_iter().collect();
|
||||
|
||||
let mut stats = SyncStats::default();
|
||||
let mut upload_tasks: Vec<UploadTask> = Vec::new();
|
||||
|
||||
for (local_path, rel) in local_files {
|
||||
let local_size = local_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let remote_size = remote_map.get(&rel).copied();
|
||||
|
||||
if remote_size == Some(local_size) {
|
||||
let _ = tx.send(SyncEvent::Skipped(rel)).await;
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
let _ = tx.send(SyncEvent::DryRun(rel)).await;
|
||||
stats.uploaded += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote_file_path = format!("{}/{}", config.remote_dir.trim_end_matches('/'), rel);
|
||||
upload_tasks.push(UploadTask { local_path, rel, local_size, remote_file_path });
|
||||
}
|
||||
|
||||
if upload_tasks.is_empty() {
|
||||
let _ = ftp.quit();
|
||||
stats.duration_secs = start.elapsed().as_secs();
|
||||
let _ = tx.send(SyncEvent::Done(stats)).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-create all remote directories on the scan connection.
|
||||
let _ = ftp.cwd("/");
|
||||
let mut created_dirs: HashSet<String> = HashSet::new();
|
||||
for task in &upload_tasks {
|
||||
let dir = task.remote_file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
|
||||
if created_dirs.insert(dir.clone()) {
|
||||
tls_mkdirs(&mut ftp, &dir);
|
||||
}
|
||||
}
|
||||
let _ = ftp.quit();
|
||||
|
||||
// ── Phase 2: parallel TLS uploads ────────────────────────────────────────
|
||||
|
||||
let queue: Arc<Mutex<VecDeque<UploadTask>>> =
|
||||
Arc::new(Mutex::new(upload_tasks.into_iter().collect()));
|
||||
|
||||
let config = Arc::new(config);
|
||||
let mut handles = Vec::with_capacity(WORKER_COUNT);
|
||||
|
||||
for _ in 0..WORKER_COUNT {
|
||||
let queue = Arc::clone(&queue);
|
||||
let config = Arc::clone(&config);
|
||||
let tx = tx.clone();
|
||||
let addr = addr.clone();
|
||||
|
||||
handles.push(tokio::task::spawn_blocking(move || -> WorkerResult {
|
||||
use suppaftp::{NativeTlsConnector, native_tls::TlsConnector};
|
||||
|
||||
let connector = match TlsConnector::new() {
|
||||
Ok(c) => NativeTlsConnector::from(c),
|
||||
Err(e) => {
|
||||
drain_queue_as_errors(&queue, &tx, format!("TLS init failed: {}", e));
|
||||
return WorkerResult::default();
|
||||
}
|
||||
};
|
||||
let stream = match NativeTlsFtpStream::connect(&addr) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
drain_queue_as_errors(&queue, &tx, format!("Connect failed: {}", e));
|
||||
return WorkerResult::default();
|
||||
}
|
||||
};
|
||||
let mut ftp = match stream.into_secure(connector, &config.host) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
drain_queue_as_errors(&queue, &tx, format!("TLS handshake failed: {}", e));
|
||||
return WorkerResult::default();
|
||||
}
|
||||
};
|
||||
if let Err(e) = ftp.login(&config.user, &config.password) {
|
||||
drain_queue_as_errors(&queue, &tx, format!("Login failed: {}", e));
|
||||
let _ = ftp.quit();
|
||||
return WorkerResult::default();
|
||||
}
|
||||
ftp.set_mode(suppaftp::Mode::Passive);
|
||||
|
||||
let mut result = WorkerResult::default();
|
||||
loop {
|
||||
let task = match queue.lock().unwrap().pop_front() {
|
||||
Some(t) => t,
|
||||
None => break,
|
||||
};
|
||||
upload_one_tls(&mut ftp, task, &tx, &mut result);
|
||||
}
|
||||
let _ = ftp.quit();
|
||||
result
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
if let Ok(r) = handle.await {
|
||||
stats.uploaded += r.uploaded;
|
||||
stats.errors += r.errors;
|
||||
stats.bytes += r.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
stats.duration_secs = start.elapsed().as_secs();
|
||||
let _ = tx.send(SyncEvent::Done(stats)).await;
|
||||
}
|
||||
|
||||
fn upload_one_tls(
|
||||
ftp: &mut NativeTlsFtpStream,
|
||||
task: UploadTask,
|
||||
tx: &mpsc::Sender<SyncEvent>,
|
||||
result: &mut WorkerResult,
|
||||
) {
|
||||
let _ = tx.blocking_send(SyncEvent::Uploading { path: task.rel.clone(), size: task.local_size });
|
||||
|
||||
match std::fs::File::open(&task.local_path) {
|
||||
Ok(file) => {
|
||||
let mut reader = BufReader::new(file);
|
||||
match ftp.put_file(&task.remote_file_path, &mut reader) {
|
||||
Ok(_) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Uploaded {
|
||||
path: task.rel,
|
||||
size: task.local_size,
|
||||
});
|
||||
result.uploaded += 1;
|
||||
result.bytes += task.local_size;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() });
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() });
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared worker helpers ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct WorkerResult {
|
||||
uploaded: usize,
|
||||
errors: usize,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// Drain any remaining items from the shared queue and report each as an error.
|
||||
/// Used when a worker fails to connect so no items are silently lost.
|
||||
fn drain_queue_as_errors(
|
||||
queue: &Mutex<VecDeque<UploadTask>>,
|
||||
tx: &mpsc::Sender<SyncEvent>,
|
||||
message: String,
|
||||
) {
|
||||
let items: Vec<_> = queue.lock().unwrap().drain(..).collect();
|
||||
for task in items {
|
||||
let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: message.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TLS helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn tls_mkdirs(ftp: &mut NativeTlsFtpStream, remote_path: &str) {
|
||||
let mut current = String::new();
|
||||
for part in remote_path.split('/').filter(|p| !p.is_empty()) {
|
||||
current = if current.is_empty() { part.to_string() } else { format!("{}/{}", current, part) };
|
||||
let _ = ftp.mkdir(¤t);
|
||||
}
|
||||
}
|
||||
|
||||
fn tls_list_recursive(
|
||||
ftp: &mut NativeTlsFtpStream,
|
||||
base: &str,
|
||||
prefix: &str,
|
||||
out: &mut Vec<(String, u64)>,
|
||||
) {
|
||||
if ftp.cwd(base).is_err() { return; }
|
||||
let lines = match ftp.list(None) { Ok(l) => l, Err(_) => return };
|
||||
for line in lines {
|
||||
let parts: Vec<&str> = line.splitn(9, ' ').collect();
|
||||
if parts.len() < 9 { continue; }
|
||||
let name = parts[8].trim();
|
||||
if name == "." || name == ".." { continue; }
|
||||
let rel = if prefix.is_empty() { name.to_string() } else { format!("{}/{}", prefix, name) };
|
||||
if line.starts_with('d') {
|
||||
tls_list_recursive(ftp, &format!("{}/{}", base, name), &rel, out);
|
||||
let _ = ftp.cwd(base);
|
||||
} else {
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
out.push((rel, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-2
@@ -1,3 +1,129 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod app;
|
||||
mod config;
|
||||
mod ftp;
|
||||
mod ui;
|
||||
|
||||
use app::{App, Screen};
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use std::{io, time::Duration};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let res = run_app(&mut terminal).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(e) = res {
|
||||
eprintln!("Error: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> anyhow::Result<()> {
|
||||
let mut app = App::new();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui::draw(f, &app))?;
|
||||
app.poll_events();
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match app.screen {
|
||||
Screen::Config => {
|
||||
if handle_config_input(&mut app, key.code, key.modifiers) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Screen::Sync => {
|
||||
if handle_sync_input(&mut app, key.code).await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Screen::Summary => {
|
||||
if handle_summary_input(&mut app, key.code).await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_config_input(app: &mut App, code: KeyCode, mods: KeyModifiers) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Tab => app.focused_field = app.focused_field.next(),
|
||||
KeyCode::BackTab => app.focused_field = app.focused_field.prev(),
|
||||
KeyCode::Enter => {
|
||||
let _ = app.config.save();
|
||||
app.screen = Screen::Sync;
|
||||
}
|
||||
KeyCode::Backspace => app.config_field_pop(),
|
||||
KeyCode::Char(c) => {
|
||||
if mods.is_empty() || mods == KeyModifiers::SHIFT {
|
||||
app.config_field_push(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => app.focused_field = app.focused_field.prev(),
|
||||
KeyCode::Down => app.focused_field = app.focused_field.next(),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle_sync_input(app: &mut App, code: KeyCode) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
if !app.sync_active {
|
||||
return true;
|
||||
}
|
||||
app.event_rx = None;
|
||||
app.sync_active = false;
|
||||
app.status_line = "Aborted".into();
|
||||
}
|
||||
KeyCode::Char('s') if !app.sync_active => start_sync(app, false).await,
|
||||
KeyCode::Char('d') if !app.sync_active => start_sync(app, true).await,
|
||||
KeyCode::Char('c') if !app.sync_active => app.screen = Screen::Config,
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle_summary_input(app: &mut App, code: KeyCode) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Char('s') => {
|
||||
app.screen = Screen::Sync;
|
||||
start_sync(app, false).await;
|
||||
}
|
||||
KeyCode::Char('c') => app.screen = Screen::Config,
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn start_sync(app: &mut App, dry_run: bool) {
|
||||
app.reset_sync();
|
||||
app.screen = Screen::Sync;
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
app.event_rx = Some(rx);
|
||||
let config = app.config.clone();
|
||||
tokio::spawn(async move {
|
||||
ftp::run_sync(config, dry_run, tx).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
use crate::app::{App, ConfigField, LogLevel, Screen};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
const BRAND: Color = Color::Cyan;
|
||||
const ACCENT: Color = Color::Yellow;
|
||||
const MUTED: Color = Color::DarkGray;
|
||||
const OK: Color = Color::Green;
|
||||
const ERR: Color = Color::Red;
|
||||
const SKIP_COL: Color = Color::Blue;
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &App) {
|
||||
match app.screen {
|
||||
Screen::Config => draw_config(f, app),
|
||||
Screen::Sync => draw_sync(f, app),
|
||||
Screen::Summary => draw_summary(f, app),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config screen ────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_config(f: &mut Frame, app: &App) {
|
||||
let area = f.area();
|
||||
|
||||
let outer = Block::default()
|
||||
.title(Span::styled(
|
||||
" ftsync — Configuration ",
|
||||
Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(BRAND));
|
||||
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
// Vertically centre the form
|
||||
let fields = ConfigField::ALL;
|
||||
let form_height = fields.len() as u16 * 3 + 4; // 3 lines per field + footer
|
||||
let top_pad = inner.height.saturating_sub(form_height) / 2;
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(form_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let form_area = rows[1];
|
||||
|
||||
// Split form into label col + input col
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(4), // left margin
|
||||
Constraint::Length(14), // labels
|
||||
Constraint::Length(2), // gap
|
||||
Constraint::Min(30), // inputs
|
||||
Constraint::Length(4), // right margin
|
||||
])
|
||||
.split(form_area);
|
||||
|
||||
let label_area = cols[1];
|
||||
let input_area = cols[3];
|
||||
|
||||
let mut label_constraints = Vec::new();
|
||||
let mut input_constraints = Vec::new();
|
||||
for _ in fields {
|
||||
label_constraints.push(Constraint::Length(3));
|
||||
input_constraints.push(Constraint::Length(3));
|
||||
}
|
||||
label_constraints.push(Constraint::Length(4)); // footer
|
||||
input_constraints.push(Constraint::Length(4));
|
||||
|
||||
let label_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(label_constraints)
|
||||
.split(label_area);
|
||||
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(input_constraints)
|
||||
.split(input_area);
|
||||
|
||||
for (i, &field) in fields.iter().enumerate() {
|
||||
let focused = app.focused_field == field;
|
||||
let label = config_field_label(field);
|
||||
let value = app.config_field_value(field);
|
||||
|
||||
let label_style = if focused {
|
||||
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(MUTED)
|
||||
};
|
||||
|
||||
let input_border = if focused {
|
||||
Style::default().fg(ACCENT)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
let label_widget = Paragraph::new(label)
|
||||
.style(label_style)
|
||||
.alignment(Alignment::Right);
|
||||
f.render_widget(label_widget, centre_vertically(label_rows[i], 1));
|
||||
|
||||
let input_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(input_border);
|
||||
|
||||
let display = if field == ConfigField::Password {
|
||||
"•".repeat(app.config.password.len())
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
|
||||
let cursor_display = if focused {
|
||||
format!("{}█", display)
|
||||
} else {
|
||||
display
|
||||
};
|
||||
|
||||
let input_widget = Paragraph::new(cursor_display)
|
||||
.block(input_block)
|
||||
.style(Style::default().fg(Color::White));
|
||||
f.render_widget(input_widget, input_rows[i]);
|
||||
}
|
||||
|
||||
// Footer hints
|
||||
let footer_area = input_rows[fields.len()];
|
||||
let hints = Line::from(vec![
|
||||
Span::styled("Tab", Style::default().fg(ACCENT)),
|
||||
Span::raw(" next "),
|
||||
Span::styled("Shift+Tab", Style::default().fg(ACCENT)),
|
||||
Span::raw(" prev "),
|
||||
Span::styled("Enter", Style::default().fg(OK)),
|
||||
Span::raw(" go to sync "),
|
||||
Span::styled("q", Style::default().fg(ERR)),
|
||||
Span::raw(" quit"),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(hints), footer_area);
|
||||
}
|
||||
|
||||
fn centre_vertically(area: Rect, height: u16) -> Rect {
|
||||
let pad = area.height.saturating_sub(height) / 2;
|
||||
Rect {
|
||||
y: area.y + pad,
|
||||
height,
|
||||
..area
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_sync(f: &mut Frame, app: &App) {
|
||||
let area = f.area();
|
||||
|
||||
// Outer block
|
||||
let title_text = if app.dry_run { " ftsync — Dry Run " } else { " ftsync — Sync " };
|
||||
let outer = Block::default()
|
||||
.title(Span::styled(title_text, Style::default().fg(BRAND).add_modifier(Modifier::BOLD)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(BRAND));
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
// Split: left panel | right log
|
||||
let h_split = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
|
||||
.split(inner);
|
||||
|
||||
draw_sync_left(f, app, h_split[0]);
|
||||
draw_sync_log(f, app, h_split[1]);
|
||||
}
|
||||
|
||||
fn draw_sync_left(f: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Status ", Style::default().fg(MUTED)))
|
||||
.borders(Borders::RIGHT)
|
||||
.border_style(Style::default().fg(MUTED));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // padding
|
||||
Constraint::Length(1), // local dir
|
||||
Constraint::Length(1), // remote
|
||||
Constraint::Length(1), // status
|
||||
Constraint::Length(1), // padding
|
||||
Constraint::Length(1), // current file
|
||||
Constraint::Length(1), // padding
|
||||
Constraint::Length(3), // progress bar
|
||||
Constraint::Min(0), // spacer
|
||||
Constraint::Length(1), // footer
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let kv = |key: &str, val: &str| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{:<12}", key), Style::default().fg(MUTED)),
|
||||
Span::styled(val.to_string(), Style::default().fg(Color::White)),
|
||||
])
|
||||
};
|
||||
|
||||
f.render_widget(Paragraph::new(kv("Local", &app.config.local_dir)), rows[1]);
|
||||
f.render_widget(
|
||||
Paragraph::new(kv("Remote", &format!("{}:{}{}", app.config.host, app.config.port, app.config.remote_dir))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!("{:<12}", "Status"), Style::default().fg(MUTED)),
|
||||
Span::styled(app.status_line.clone(), Style::default().fg(ACCENT)),
|
||||
])),
|
||||
rows[3],
|
||||
);
|
||||
|
||||
// Current file (truncated)
|
||||
let max_w = rows[5].width.saturating_sub(2) as usize;
|
||||
let file_display = truncate_left(&app.current_file, max_w);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("↑ ", Style::default().fg(OK)),
|
||||
Span::styled(file_display, Style::default().fg(Color::White)),
|
||||
])),
|
||||
rows[5],
|
||||
);
|
||||
|
||||
// Progress bar
|
||||
let ratio = app.progress_ratio();
|
||||
let label = if app.total_local > 0 {
|
||||
format!("{}/{} files", app.files_done, app.total_local)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.gauge_style(Style::default().fg(BRAND).bg(Color::Black))
|
||||
.ratio(ratio)
|
||||
.label(label);
|
||||
f.render_widget(gauge, rows[7]);
|
||||
|
||||
// Footer hints
|
||||
let hints = if app.sync_active {
|
||||
Line::from(vec![Span::styled("q", Style::default().fg(ERR)), Span::raw(" abort")])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("s", Style::default().fg(ACCENT)),
|
||||
Span::raw(" sync "),
|
||||
Span::styled("d", Style::default().fg(ACCENT)),
|
||||
Span::raw(" dry-run "),
|
||||
Span::styled("c", Style::default().fg(MUTED)),
|
||||
Span::raw(" config "),
|
||||
Span::styled("q", Style::default().fg(ERR)),
|
||||
Span::raw(" quit"),
|
||||
])
|
||||
};
|
||||
f.render_widget(Paragraph::new(hints), rows[9]);
|
||||
}
|
||||
|
||||
fn draw_sync_log(f: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Log ", Style::default().fg(MUTED)))
|
||||
.borders(Borders::NONE);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let visible = inner.height as usize;
|
||||
let total = app.log.len();
|
||||
let start = if total > visible {
|
||||
total - visible
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let items: Vec<ListItem> = app.log[start..]
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let (icon, col) = match e.level {
|
||||
LogLevel::Info => ("·", MUTED),
|
||||
LogLevel::Skip => ("–", SKIP_COL),
|
||||
LogLevel::Upload => ("↑", OK),
|
||||
LogLevel::Error => ("✗", ERR),
|
||||
LogLevel::Success => ("✓", OK),
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{} ", e.time), Style::default().fg(MUTED)),
|
||||
Span::styled(format!("{} ", icon), Style::default().fg(col)),
|
||||
Span::styled(e.message.clone(), Style::default().fg(Color::White)),
|
||||
]);
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(List::new(items), inner);
|
||||
}
|
||||
|
||||
// ─── Summary screen ───────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_summary(f: &mut Frame, app: &App) {
|
||||
let area = f.area();
|
||||
|
||||
let outer = Block::default()
|
||||
.title(Span::styled(" ftsync — Complete ", Style::default().fg(OK).add_modifier(Modifier::BOLD)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(OK));
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
let stats = match &app.stats {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let box_w = 44u16;
|
||||
let box_h = 10u16;
|
||||
let x = inner.x + inner.width.saturating_sub(box_w) / 2;
|
||||
let y = inner.y + inner.height.saturating_sub(box_h) / 2;
|
||||
let box_area = Rect { x, y, width: box_w, height: box_h };
|
||||
|
||||
f.render_widget(Clear, box_area);
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).border_style(Style::default().fg(OK));
|
||||
let content_area = block.inner(box_area);
|
||||
f.render_widget(block, box_area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1), // uploaded
|
||||
Constraint::Length(1), // skipped
|
||||
Constraint::Length(1), // errors
|
||||
Constraint::Length(1), // bytes
|
||||
Constraint::Length(1), // duration
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1), // footer
|
||||
])
|
||||
.split(content_area);
|
||||
|
||||
let stat_line = |icon: &str, icon_col: Color, label: &str, val: String| -> Paragraph<'static> {
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {} ", icon), Style::default().fg(icon_col)),
|
||||
Span::styled(format!("{:<14}", label), Style::default().fg(MUTED)),
|
||||
Span::styled(val, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
]))
|
||||
};
|
||||
|
||||
f.render_widget(stat_line("✓", OK, "Uploaded", format!("{} file(s)", stats.uploaded)), rows[1]);
|
||||
f.render_widget(stat_line("–", SKIP_COL, "Skipped", format!("{} file(s)", stats.skipped)), rows[2]);
|
||||
f.render_widget(stat_line("✗", ERR, "Errors", stats.errors.to_string()), rows[3]);
|
||||
f.render_widget(stat_line("⇅", BRAND, "Transferred", format_bytes(stats.bytes)), rows[4]);
|
||||
f.render_widget(stat_line("⏱", MUTED, "Duration", format_duration(stats.duration_secs)), rows[5]);
|
||||
|
||||
let footer = Line::from(vec![
|
||||
Span::styled(" s", Style::default().fg(ACCENT)),
|
||||
Span::raw(" sync again "),
|
||||
Span::styled("c", Style::default().fg(MUTED)),
|
||||
Span::raw(" config "),
|
||||
Span::styled("q", Style::default().fg(ERR)),
|
||||
Span::raw(" quit"),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(footer), rows[7]);
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn config_field_label(field: ConfigField) -> &'static str {
|
||||
match field {
|
||||
ConfigField::Host => "Host",
|
||||
ConfigField::Port => "Port",
|
||||
ConfigField::User => "User",
|
||||
ConfigField::Password => "Password",
|
||||
ConfigField::LocalDir => "Local Dir",
|
||||
ConfigField::RemoteDir => "Remote Dir",
|
||||
ConfigField::Excludes => "Excludes",
|
||||
ConfigField::Tls => "TLS",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(b: u64) -> String {
|
||||
if b >= 1_073_741_824 {
|
||||
format!("{:.2} GB", b as f64 / 1_073_741_824.0)
|
||||
} else if b >= 1_048_576 {
|
||||
format!("{:.2} MB", b as f64 / 1_048_576.0)
|
||||
} else if b >= 1_024 {
|
||||
format!("{:.1} KB", b as f64 / 1_024.0)
|
||||
} else {
|
||||
format!("{} B", b)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
if h > 0 {
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
} else {
|
||||
format!("{:02}:{:02}", m, s)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_left(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("…{}", &s[s.len() - max.saturating_sub(1)..])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user