This commit is contained in:
2026-03-25 15:20:16 +03:00
parent d8463f0a01
commit 92c3d3be5d
8 changed files with 3282 additions and 3 deletions
Generated
+1629
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -1,6 +1,28 @@
[package] [package]
name = "ftsync" name = "ftsync"
version = "0.1.0" 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] [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
+142
View File
@@ -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
View File
@@ -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 => {}
}
}
}
+79
View File
@@ -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
View File
@@ -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(&current); // 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(&current);
}
}
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
View File
@@ -1,3 +1,129 @@
fn main() { mod app;
println!("Hello, world!"); 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;
});
} }
+419
View File
@@ -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)..])
}
}