mirror of
https://github.com/andwati/ftsync.git
synced 2026-10-18 09:22: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]
|
[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
|
||||||
@@ -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() {
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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