dap-tui: ratatui device-sync dashboard

Full rewrite of dap-sync as a Rust TUI on ratatui:
- Tokyo Night theme, gradient title rule, rounded panels
- Devices sidebar with live mount status + last-sync age
- Dashboard / Sync / Logs / Config tabs
- Live sync engine: rclone mirror, NFC-aware diff, rsync
  progress parsing, podkit + mlove (fatsort) finalize
- Abort-safe subprocess handling, persisted sync state
This commit is contained in:
Sebastian
2026-08-22 09:26:15 +02:00
commit 16a5a4327e
18 changed files with 4972 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1955
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dap-tui"
version = "0.1.0"
edition = "2021"
description = "Device sync for portable players — TUI edition (ratatui)"
license = "MIT"
[dependencies]
ratatui = "0.30"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
chrono = "0.4"
unicode-normalization = "0.1"
dirs = "6"
anyhow = "1"
[profile.release]
lto = true
opt-level = 3
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
# dap-tui
A terminal UI for device sync of portable music players, rewritten from scratch
in Rust on top of [ratatui](https://ratatui.rs/).
It mirrors the StorageBox masters library into your local music directory, then
syncs a device profile — the same pipeline as the Python `dap-sync`, now as a
living dashboard instead of a batch CLI.
## Highlights
- **Tokyo Night** palette, rounded panels, gradient title rule, status & key
bars.
- **Devices sidebar** — live mount status (green dot = mounted), firmware tags,
last-sync age per device.
- **4 tabs** — Dashboard (device + library cards, source-chain diagram), Sync
(live step pipeline + progress gauge), Logs (color-coded, follow mode),
Config (syntax-coloured TOML).
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress,
NFC-aware diffing for macOS→FAT32/Android devices, optional `fatsort`
finalize for Mlove devices.
- **Abort-safe** — `a` kills the running child process cleanly.
- Reads the existing `~/.config/dap-sync/config.toml` (compatible with
`dap-sync`), persists last-sync state to `~/.local/share/dap-sync/sync-state.toml`.
## Install / run
```bash
cargo build --release
./target/release/dap-tui # uses ~/.config/dap-sync/config.toml
./target/release/dap-tui --config /path/to/config.toml
./target/release/dap-tui --skip-mirror # skip the StorageBox → local mirror step
```
## Keybindings
| Key | Action |
| ------------ | ---------------------------- |
| `↑`/`↓` `j`/`k` | move selection |
| `s` / `Enter`| sync selected device |
| `a` | abort running sync |
| `Tab` / `1-4`| switch tab |
| `r` | refresh device mounts |
| `f` | toggle log follow |
| `u` / `d` | scroll logs |
| `x` | clear logs |
| `?` / `h` | help overlay |
| `q` / `Esc` | quit |
## Configuration
`dap-tui` reads the standard `dap-sync` config format:
```toml
[paths]
local_music_dir = "~/Music"
local_hoerspiele_dir = "~/hoerspiele"
[storagebox]
source = "storagebox:masters"
exclude = ["/Music/**", ".DS_Store", ".localized"]
[[devices]]
name = "iPod Rockbox"
firmware = "rockbox"
mount_point = "/media/sebastian/IPOD"
music_folder = "Music"
```
Firmware types: `apple` (podkit command), `rockbox`, `android` (rsync),
`mlove` (rsync + `fatsort`). Devices with `content_type = "hoerspiele"` sync
against the hoerspiele library.
## How the sync pipeline works
1. **Mirror** — `rclone sync` (or local `rsync`) pulls the StorageBox source
into the local library.
2. **Diff** — the device is scanned and compared to the source with
NFC-normalised paths, size, and 2-second-rounded mtimes (FAT32 friendly).
3. **Sync** — stale files are removed first, then `rsync --files-from` copies
only what changed, with live per-file progress.
4. **Finalize** (mlove) — unmounts the volume and runs `fatsort` via
`sudo -n` to keep FAT directory order stable. If passwordless sudo is not
available the step is reported as a warning and the commands are printed.
## Development
```bash
cargo test # parser + config tests
cargo run
```
+286
View File
@@ -0,0 +1,286 @@
use crate::config::{Config, Device};
use crate::engine::{Engine, EngineEvent, LogLevel};
use crate::ui;
use anyhow::Result;
use ratatui::crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Dashboard = 0,
Sync = 1,
Logs = 2,
Config = 3,
}
impl Tab {
pub const ALL: [Tab; 4] = [Tab::Dashboard, Tab::Sync, Tab::Logs, Tab::Config];
pub fn label(&self) -> &'static str {
match self {
Tab::Dashboard => "Dashboard",
Tab::Sync => "Sync",
Tab::Logs => "Logs",
Tab::Config => "Config",
}
}
pub fn number(&self) -> usize {
match self {
Tab::Dashboard => 1,
Tab::Sync => 2,
Tab::Logs => 3,
Tab::Config => 4,
}
}
}
pub struct App {
pub config: Config,
pub engine: Engine,
pub skip_mirror: bool,
pub tab: Tab,
pub selected: usize,
pub sidebar_scroll: usize,
pub logs: Vec<crate::engine::LogEntry>,
pub log_follow: bool,
pub log_scroll: usize,
pub frame: u64,
pub should_quit: bool,
pub show_help: bool,
pub status_msg: Option<(String, LogLevel)>,
}
impl App {
pub fn new(config: Config, skip_mirror: bool) -> Result<App> {
let engine = Engine::spawn();
Ok(App {
config,
engine,
skip_mirror,
tab: Tab::Dashboard,
selected: 0,
sidebar_scroll: 0,
logs: Vec::new(),
log_follow: true,
log_scroll: 0,
frame: 0,
should_quit: false,
show_help: false,
status_msg: None,
})
}
pub fn selected_device(&self) -> Option<&Device> {
self.config.devices.get(self.selected)
}
pub fn spinner(&self) -> &'static str {
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
FRAMES[(self.frame as usize / 3) % FRAMES.len()]
}
pub fn is_syncing(&self) -> bool {
self.engine.running()
}
fn refresh_mounts(&mut self) {
for d in self.config.devices.iter_mut() {
d.mounted = d
.mount_point
.as_ref()
.map(|p| p.exists())
.unwrap_or(false);
}
}
fn drain_engine(&mut self) {
while let Ok(ev) = self.engine.events.try_recv() {
match ev {
EngineEvent::Log(entry) => {
let is_error = matches!(entry.level, LogLevel::Error);
self.logs.push(entry);
if self.logs.len() > 2000 {
let overflow = self.logs.len() - 2000;
self.logs.drain(0..overflow);
}
if is_error {
self.status_msg = Some((
"see Logs tab (3) for details".to_string(),
LogLevel::Error,
));
}
}
EngineEvent::Changed => {}
EngineEvent::Finished { ok, summary } => {
let level = if ok { LogLevel::Success } else { LogLevel::Error };
self.status_msg = Some((summary.clone(), level));
}
}
}
}
fn start_sync(&mut self) {
let Some(device) = self.selected_device().cloned() else {
self.status_msg = Some(("no device selected".to_string(), LogLevel::Warn));
return;
};
if self.is_syncing() {
return;
}
self.status_msg = None;
let cfg = Arc::new(self.config.clone());
self.engine
.start_sync(device, cfg, self.skip_mirror);
}
fn abort_sync(&mut self) {
if self.is_syncing() {
self.engine.abort_sync();
self.status_msg = Some(("aborting…".to_string(), LogLevel::Warn));
}
}
pub fn on_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if self.show_help {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')) {
self.show_help = false;
}
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
KeyCode::Char('g') => {
self.selected = 0;
self.sidebar_scroll = 0;
}
KeyCode::Char('G') => {
let n = self.config.devices.len().saturating_sub(1);
self.selected = n;
self.ensure_selection_visible();
}
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
KeyCode::Char('a') => self.abort_sync(),
KeyCode::Char('r') => {
self.refresh_mounts();
self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info));
}
KeyCode::Char('f') => {
self.log_follow = !self.log_follow;
self.log_scroll = 0;
}
KeyCode::Char('x') => {
self.logs.clear();
self.log_scroll = 0;
}
KeyCode::Char('u') => self.scroll_logs(-10),
KeyCode::Char('d') => self.scroll_logs(10),
KeyCode::Tab => self.tab = next_tab(self.tab),
KeyCode::BackTab => self.tab = prev_tab(self.tab),
KeyCode::Char('1') => self.tab = Tab::Dashboard,
KeyCode::Char('2') => self.tab = Tab::Sync,
KeyCode::Char('3') => self.tab = Tab::Logs,
KeyCode::Char('4') => self.tab = Tab::Config,
_ => {}
}
}
fn move_selection(&mut self, delta: i32) {
if self.config.devices.is_empty() {
return;
}
let n = self.config.devices.len();
self.selected = ((self.selected as i32 + delta).rem_euclid(n as i32)) as usize;
self.ensure_selection_visible();
}
fn ensure_selection_visible(&mut self) {
const VISIBLE: usize = 12;
if self.selected < self.sidebar_scroll {
self.sidebar_scroll = self.selected;
}
if self.selected >= self.sidebar_scroll + VISIBLE {
self.sidebar_scroll = self.selected + 1 - VISIBLE;
}
}
fn scroll_logs(&mut self, delta: i32) {
self.log_follow = false;
let max = self.logs.len().saturating_sub(1) as i32;
self.log_scroll = ((self.log_scroll as i32 + delta).clamp(0, max)) as usize;
}
fn tick(&mut self) {
self.frame += 1;
if self.frame % 30 == 0 {
self.refresh_mounts();
}
if self.is_syncing() {
let aborted = self.engine.abort.load(Ordering::Relaxed);
if let Ok(mut s) = self.engine.state.lock() {
s.aborting = aborted;
}
}
}
pub fn run(&mut self) -> Result<()> {
let mut terminal = ratatui::init();
let res = (|| -> Result<()> {
loop {
self.drain_engine();
self.tick();
terminal.draw(|f| ui::draw(f, self))?;
if self.should_quit {
break;
}
if event::poll(Duration::from_millis(40))? {
if let Event::Key(key) = event::read()? {
self.on_key(key);
}
}
}
Ok(())
})();
ratatui::restore();
res
}
}
fn next_tab(t: Tab) -> Tab {
Tab::ALL[(t as usize + 1) % Tab::ALL.len()]
}
fn prev_tab(t: Tab) -> Tab {
Tab::ALL[(t as usize + Tab::ALL.len() - 1) % Tab::ALL.len()]
}
pub fn relative_ago(dt: chrono::DateTime<chrono::Local>) -> String {
let now = chrono::Local::now();
let secs = (now - dt).num_seconds().max(0);
if secs < 5 {
"just now".to_string()
} else if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
+381
View File
@@ -0,0 +1,381 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Local};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_FILE: &str = "~/.config/dap-sync/config.toml";
pub const DEFAULT_SYNC_COMMAND: &str = "podkit sync -c main";
pub const DEFAULT_STORAGEBOX_SOURCE: &str = "storagebox:masters";
pub const DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE: &str = "storagebox:backup/hoerspiele";
pub const DEFAULT_EXCLUDES: &[&str] = &["/Music/**", ".DS_Store", ".localized"];
fn expand_home(s: &str) -> PathBuf {
if let Some(rest) = s.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(s)
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct RawPaths {
local_music_dir: Option<String>,
local_hoerspiele_dir: Option<String>,
output_dir: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct RawStoragebox {
source: Option<String>,
hoerspiele_source: Option<String>,
exclude: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct RawSync {
command: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct RawDevice {
name: Option<String>,
firmware: Option<String>,
content_type: Option<String>,
sync_command: Option<String>,
mount_point: Option<String>,
music_folder: Option<String>,
cache_dir: Option<String>,
preserve_fat_order: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct RawConfig {
paths: RawPaths,
storagebox: RawStoragebox,
sync: RawSync,
devices: Vec<RawDevice>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Firmware {
Apple,
Rockbox,
Android,
Mlove,
}
impl Firmware {
pub fn from_str(s: &str) -> Firmware {
match s.trim().to_lowercase().as_str() {
"rockbox" => Firmware::Rockbox,
"android" => Firmware::Android,
"mlove" => Firmware::Mlove,
_ => Firmware::Apple,
}
}
pub fn label(&self) -> &'static str {
match self {
Firmware::Apple => "apple",
Firmware::Rockbox => "rockbox",
Firmware::Android => "android",
Firmware::Mlove => "mlove",
}
}
}
#[derive(Debug, Clone)]
pub struct Device {
pub name: String,
pub firmware: Firmware,
pub content_type: String,
pub sync_command: String,
pub mount_point: Option<PathBuf>,
pub music_folder: String,
pub preserve_fat_order: bool,
pub mounted: bool,
pub last_sync: Option<DateTime<Local>>,
pub last_status: String,
}
impl Device {
pub fn mount_str(&self) -> String {
match &self.mount_point {
Some(p) => p.display().to_string(),
None => "—".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub config_file: PathBuf,
pub local_music_dir: PathBuf,
pub local_hoerspiele_dir: PathBuf,
pub output_dir: PathBuf,
pub storagebox_source: String,
pub storagebox_hoerspiele_source: String,
pub storagebox_excludes: Vec<String>,
pub devices: Vec<Device>,
pub raw_text: String,
}
impl Config {
fn refresh_mounts(&mut self) {
for d in self.devices.iter_mut() {
d.mounted = d
.mount_point
.as_ref()
.map(|p| p.exists())
.unwrap_or(false);
}
}
fn apply_state(&mut self, state: &StateStore) {
for d in self.devices.iter_mut() {
if let Some(rec) = state.records.get(&d.name) {
d.last_sync = rec.last_sync;
d.last_status = rec.status.clone();
}
}
}
}
pub struct StateStore {
pub records: BTreeMap<String, StateRecord>,
}
#[derive(Debug, Clone)]
pub struct StateRecord {
pub last_sync: Option<DateTime<Local>>,
pub status: String,
}
fn default_config_path() -> PathBuf {
expand_home(DEFAULT_CONFIG_FILE)
}
pub fn load(config_path: Option<String>) -> Result<Config> {
let path = match config_path {
Some(p) => expand_home(&p),
None => default_config_path(),
};
let mut cfg = if path.exists() {
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
let raw: RawConfig = toml::from_str(&text)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
let local_music = raw
.paths
.local_music_dir
.map(|s| expand_home(&s))
.unwrap_or_else(|| expand_home("~/Music"));
let local_hoerspiele = raw
.paths
.local_hoerspiele_dir
.map(|s| expand_home(&s))
.unwrap_or_else(|| expand_home("~/hoerspiele"));
let output_dir = raw
.paths
.output_dir
.map(|s| expand_home(&s))
.unwrap_or_else(|| expand_home("~/.local/share/dap-sync"));
let sync_cmd = raw
.sync
.command
.clone()
.unwrap_or_else(|| DEFAULT_SYNC_COMMAND.to_string());
let mut devices = Vec::new();
for rd in raw.devices.iter().filter(|d| d.name.is_some()) {
let name = rd.name.clone().unwrap();
let firmware = Firmware::from_str(rd.firmware.as_deref().unwrap_or("apple"));
let content_type = rd
.content_type
.clone()
.unwrap_or_else(|| "music".to_string())
.trim()
.to_lowercase();
let sync_command = rd
.sync_command
.clone()
.unwrap_or_else(|| sync_cmd.clone());
let mount_point = rd
.mount_point
.clone()
.map(|s| expand_home(&s));
let music_folder = rd
.music_folder
.clone()
.unwrap_or_else(|| {
match firmware {
Firmware::Mlove => "Hörspiele".to_string(),
_ => "Music".to_string(),
}
});
let preserve_fat_order = rd
.preserve_fat_order
.unwrap_or_else(|| firmware == Firmware::Mlove);
devices.push(Device {
name,
firmware,
content_type,
sync_command,
mount_point,
music_folder,
preserve_fat_order,
mounted: false,
last_sync: None,
last_status: String::new(),
});
}
Config {
config_file: path.clone(),
local_music_dir: local_music,
local_hoerspiele_dir: local_hoerspiele,
output_dir,
storagebox_source: raw
.storagebox
.source
.unwrap_or_else(|| DEFAULT_STORAGEBOX_SOURCE.to_string()),
storagebox_hoerspiele_source: raw
.storagebox
.hoerspiele_source
.unwrap_or_else(|| DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string()),
storagebox_excludes: raw
.storagebox
.exclude
.map(|v| v.clone())
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()),
devices,
raw_text: text,
}
} else {
Config {
config_file: path.clone(),
local_music_dir: expand_home("~/Music"),
local_hoerspiele_dir: expand_home("~/hoerspiele"),
output_dir: expand_home("~/.local/share/dap-sync"),
storagebox_source: DEFAULT_STORAGEBOX_SOURCE.to_string(),
storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(),
storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(),
devices: Vec::new(),
raw_text: "# no config file found\n".to_string(),
}
};
cfg.refresh_mounts();
let store = load_state(&cfg.output_dir);
cfg.apply_state(&store);
Ok(cfg)
}
pub fn state_file(output_dir: &Path) -> PathBuf {
output_dir.join("sync-state.toml")
}
pub fn load_state(output_dir: &Path) -> StateStore {
let file = state_file(output_dir);
let mut records = BTreeMap::new();
if let Ok(text) = fs::read_to_string(&file) {
if let Ok(table) = text.parse::<toml::Table>() {
for (name, val) in table.iter() {
if let Some(sub) = val.as_table() {
let last_sync = sub
.get("last_sync")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Local));
let status = sub
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
records.insert(name.clone(), StateRecord { last_sync, status });
}
}
}
}
StateStore { records }
}
pub fn save_state(
output_dir: &Path,
name: &str,
last_sync: DateTime<Local>,
status: &str,
) {
let file = state_file(output_dir);
let mut table: toml::Table = fs::read_to_string(&file)
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
.unwrap_or_default();
let mut sub = toml::map::Map::new();
sub.insert("last_sync".into(), toml::Value::String(last_sync.to_rfc3339()));
sub.insert("status".into(), toml::Value::String(status.to_string()));
table.insert(name.into(), toml::Value::Table(sub));
if let Some(parent) = file.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(text) = toml::to_string(&table) {
let _ = fs::write(&file, text);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_home() {
let p = expand_home("~/foo/bar");
assert_eq!(p, dirs::home_dir().unwrap().join("foo/bar"));
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
}
#[test]
fn loads_devices_from_config() {
let dir = std::env::temp_dir().join("dap-tui-config-test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[paths]
local_music_dir = "~/Music"
[storagebox]
source = "storagebox:masters"
exclude = [".DS_Store"]
[[devices]]
name = "Test iPod"
firmware = "rockbox"
mount_point = "/media/ipod"
music_folder = "Music"
"#,
)
.unwrap();
let cfg = load(Some(path.display().to_string())).unwrap();
assert_eq!(cfg.devices.len(), 1);
let d = &cfg.devices[0];
assert_eq!(d.name, "Test iPod");
assert_eq!(d.firmware.label(), "rockbox");
assert_eq!(d.music_folder, "Music");
assert_eq!(cfg.storagebox_excludes, vec![".DS_Store"]);
assert_eq!(cfg.local_music_dir, dirs::home_dir().unwrap().join("Music"));
}
}
+776
View File
@@ -0,0 +1,776 @@
use crate::config::{self, Config, Device, Firmware};
use crate::progress::{parse_rclone_stats, parse_rsync_line, RsyncLine};
use chrono::Local;
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Transfer,
Warn,
Error,
Success,
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub level: LogLevel,
pub msg: String,
pub ts: chrono::DateTime<Local>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepStatus {
Pending,
Active,
Done,
Error,
}
#[derive(Debug, Clone)]
pub struct StepState {
pub title: String,
pub detail: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Default)]
pub struct ProgressInfo {
pub pct: u16,
pub done: String,
pub total: String,
pub speed: String,
pub eta: String,
pub current: String,
}
#[derive(Debug, Clone)]
pub struct SyncState {
pub running: bool,
pub aborting: bool,
pub device: Option<String>,
pub steps: Vec<StepState>,
pub mirror: ProgressInfo,
pub device_prog: ProgressInfo,
pub result: Option<bool>,
pub summary: String,
}
impl Default for SyncState {
fn default() -> Self {
SyncState {
running: false,
aborting: false,
device: None,
steps: Vec::new(),
mirror: ProgressInfo::default(),
device_prog: ProgressInfo::default(),
result: None,
summary: String::new(),
}
}
}
#[derive(Debug, Clone)]
pub enum EngineEvent {
Log(LogEntry),
Changed,
Finished {
ok: bool,
summary: String,
},
}
struct WorkerCtx {
state: Arc<Mutex<SyncState>>,
tx: mpsc::Sender<EngineEvent>,
abort: Arc<AtomicBool>,
}
impl WorkerCtx {
fn log(&self, level: LogLevel, msg: impl Into<String>) {
let _ = self.tx.send(EngineEvent::Log(LogEntry {
level,
msg: msg.into(),
ts: Local::now(),
}));
}
fn changed(&self) {
let _ = self.tx.send(EngineEvent::Changed);
}
fn set_step(&self, idx: usize, status: StepStatus, detail: impl Into<String>) {
if let Ok(mut s) = self.state.lock() {
if let Some(step) = s.steps.get_mut(idx) {
step.status = status;
step.detail = detail.into();
}
}
self.changed();
}
fn set_mirror(&self, p: crate::progress::RcloneStats) {
if let Ok(mut s) = self.state.lock() {
s.mirror.pct = p.pct;
s.mirror.done = p.done;
s.mirror.total = p.total;
s.mirror.speed = p.speed;
s.mirror.eta = p.eta;
}
self.changed();
}
fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str) {
if let Ok(mut s) = self.state.lock() {
let p = &mut s.device_prog;
p.done = done.to_string();
p.total = total.to_string();
p.speed = speed.to_string();
p.eta = eta.to_string();
if !current.is_empty() {
p.current = current.to_string();
}
if total > 0 {
p.pct = ((done as f64 / total as f64) * 100.0) as u16;
}
}
self.changed();
}
}
pub struct Engine {
pub state: Arc<Mutex<SyncState>>,
pub events: mpsc::Receiver<EngineEvent>,
tx: mpsc::Sender<EngineEvent>,
pub abort: Arc<AtomicBool>,
}
impl Engine {
pub fn spawn() -> Engine {
let (tx, events) = mpsc::channel();
Engine {
state: Arc::new(Mutex::new(SyncState::default())),
events,
tx,
abort: Arc::new(AtomicBool::new(false)),
}
}
pub fn running(&self) -> bool {
self.state.lock().map(|s| s.running).unwrap_or(false)
}
pub fn start_sync(&self, device: Device, config: Arc<Config>, skip_mirror: bool) {
if self.running() {
return;
}
self.abort.store(false, Ordering::Relaxed);
let ctx = Arc::new(WorkerCtx {
state: self.state.clone(),
tx: self.tx.clone(),
abort: self.abort.clone(),
});
thread::spawn(move || run_sync(config, device, skip_mirror, ctx));
}
pub fn abort_sync(&self) {
self.abort.store(true, Ordering::Relaxed);
}
}
// ── sync pipeline ────────────────────────────────────────────────────────────
fn build_steps(device: &Device) -> Vec<StepState> {
let mut steps = vec![
StepState {
title: "Mirror StorageBox".to_string(),
detail: "refresh local library".to_string(),
status: StepStatus::Pending,
},
StepState {
title: format!("Sync {}", device.name),
detail: format!("{} · {}", device.firmware.label(), device.content_type),
status: StepStatus::Pending,
},
];
if device.firmware == Firmware::Mlove {
steps.push(StepState {
title: "Finalize FAT".to_string(),
detail: "unmount + fatsort".to_string(),
status: StepStatus::Pending,
});
}
steps
}
fn run_sync(config: Arc<Config>, device: Device, skip_mirror: bool, ctx: Arc<WorkerCtx>) {
let name = device.name.clone();
{
let mut s = ctx.state.lock().unwrap();
*s = SyncState::default();
s.running = true;
s.device = Some(name.clone());
s.steps = build_steps(&device);
s.summary = "Starting sync…".to_string();
}
ctx.changed();
ctx.log(LogLevel::Info, format!("── Starting sync for {}", name));
let (ok, summary) = match run_pipeline(&config, &device, skip_mirror, &ctx) {
Ok(()) => (true, "Sync complete".to_string()),
Err(e) => {
ctx.log(LogLevel::Error, e.clone());
(false, e)
}
};
{
let mut s = ctx.state.lock().unwrap();
s.running = false;
s.aborting = false;
s.result = Some(ok);
s.summary = summary.clone();
for step in s.steps.iter_mut() {
if step.status == StepStatus::Active {
step.status = if ok { StepStatus::Done } else { StepStatus::Error };
}
}
}
ctx.log(
if ok { LogLevel::Success } else { LogLevel::Error },
if ok { format!("✓ {summary}") } else { format!("✗ {summary}") },
);
ctx.changed();
let _ = ctx.tx.send(EngineEvent::Finished {
ok,
summary: summary.clone(),
});
config::save_state(&config.output_dir, &name, Local::now(), if ok { "ok" } else { "error" });
}
fn run_pipeline(
config: &Config,
device: &Device,
skip_mirror: bool,
ctx: &WorkerCtx,
) -> Result<(), String> {
let steps = build_steps(device);
if !skip_mirror {
ctx.set_step(0, StepStatus::Active, "refreshing local library…");
run_mirror(config, device, ctx)?;
ctx.set_step(0, StepStatus::Done, "local library refreshed");
} else {
ctx.set_step(0, StepStatus::Done, "skipped (--skip-mirror)");
}
if ctx.abort.load(Ordering::Relaxed) {
return Err("aborted".to_string());
}
ctx.set_step(1, StepStatus::Active, "connecting to device…");
match device.firmware {
Firmware::Apple => run_podkit(config, device, ctx)?,
_ => run_rsync_device(config, device, ctx)?,
}
ctx.set_step(1, StepStatus::Done, "device synced");
if device.firmware == Firmware::Mlove {
if ctx.abort.load(Ordering::Relaxed) {
return Err("aborted".to_string());
}
ctx.set_step(2, StepStatus::Active, "unmounting + running fatsort…");
if let Err(e) = finalize_fat(device, ctx) {
ctx.log(LogLevel::Warn, format!("finalize skipped: {e}"));
ctx.set_step(
2,
StepStatus::Error,
"device synced, but eject/fatsort needs to run manually",
);
} else {
ctx.set_step(2, StepStatus::Done, "device ejected & sorted");
}
}
let _ = steps;
Ok(())
}
// ── step 1: mirror StorageBox → local library ────────────────────────────────
fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let (source, local_dir, label) = if device.content_type == "hoerspiele" {
(
config.storagebox_hoerspiele_source.clone(),
config.local_hoerspiele_dir.clone(),
"Hörspiele",
)
} else {
(
config.storagebox_source.clone(),
config.local_music_dir.clone(),
"Music",
)
};
fs::create_dir_all(&local_dir).map_err(|e| format!("cannot create {}: {e}", local_dir.display()))?;
let src_path = PathBuf::from(&source);
if src_path.is_absolute() && src_path.is_dir() {
ctx.log(
LogLevel::Info,
format!("Mirroring {label} from local source {} → {}", source, local_dir.display()),
);
let args = vec![
"-a".to_string(),
"--delete".to_string(),
"--info=progress2,name1".to_string(),
"--exclude=.DS_Store".to_string(),
"--exclude=.localized".to_string(),
format!("{}/", source),
local_dir.display().to_string(),
];
return stream(ctx, "rsync", &args, |line| {
if let Some(l) = parse_rsync_line(line) {
match l {
RsyncLine::Progress(p) => {
if let Ok(mut s) = ctx.state.lock() {
s.mirror.pct = p.pct;
s.mirror.done = p.done.to_string();
s.mirror.total = p.total.to_string();
s.mirror.speed = p.speed.clone();
s.mirror.eta = p.eta.clone();
}
true
}
RsyncLine::Total(_) | RsyncLine::File(_) => true,
}
} else {
false
}
});
}
ctx.log(
LogLevel::Info,
format!("Mirroring {label} via rclone {} → {}", source, local_dir.display()),
);
let mut args = vec![
"sync".to_string(),
source.clone(),
local_dir.display().to_string(),
];
for pat in &config.storagebox_excludes {
args.push("--exclude".to_string());
args.push(pat.clone());
}
args.extend(
[
"--create-empty-src-dirs",
"--stats",
"1s",
"--stats-one-line",
"--stats-log-level",
"NOTICE",
]
.iter()
.map(|s| s.to_string()),
);
stream(ctx, "rclone", &args, |line| {
if let Some(p) = parse_rclone_stats(line) {
ctx.set_mirror(p);
true
} else {
false
}
})
}
// ── step 2: device sync ───────────────────────────────────────────────────────
fn run_podkit(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let cmd = device
.sync_command
.replace("{local_music_dir}", &config.local_music_dir.display().to_string());
ctx.log(LogLevel::Info, format!("Running podkit: {cmd}"));
stream(ctx, "sh", &["-c".to_string(), cmd], |line| {
ctx.log(LogLevel::Info, line);
true // consumed — avoid double-logging in the outer loop
})
}
fn scan_music_dir(base: &Path) -> HashMap<String, (u64, i64)> {
let mut map = HashMap::new();
let mut stack = vec![base.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_s = name.to_string_lossy().to_string();
if name_s.starts_with('.') || name_s.starts_with("._") {
continue;
}
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let rel = match path.strip_prefix(base) {
Ok(r) => r,
Err(_) => continue,
};
let key = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().nfc().collect::<String>())
.collect::<Vec<_>>()
.join("/");
if let Ok(meta) = entry.metadata() {
let size = meta.len();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mtime = mtime.div_euclid(2) * 2; // FAT32 2s resolution
map.insert(key, (size, mtime));
}
}
}
map
}
fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let mount = device
.mount_point
.as_ref()
.ok_or_else(|| "mount_point is not set for this device".to_string())?;
if !mount.exists() {
return Err(format!(
"device not found at {} — connect & mount it, then try again",
mount.display()
));
}
let (src_dir, label) = if device.content_type == "hoerspiele" {
(config.local_hoerspiele_dir.clone(), "Hörspiele")
} else {
(config.local_music_dir.clone(), "Music")
};
let dest = mount.join(&device.music_folder);
fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", dest.display()))?;
ctx.log(LogLevel::Info, format!("Scanning local {label} library…"));
let src_map = scan_music_dir(&src_dir);
ctx.log(LogLevel::Info, format!("Scanning device {}…", dest.display()));
let dst_map = scan_music_dir(&dest);
let mut to_copy: Vec<String> = Vec::new();
for (k, (size, mtime)) in &src_map {
let changed = dst_map
.get(k)
.map(|(dsize, dmtime)| dsize != size || dmtime != mtime)
.unwrap_or(true);
if changed {
to_copy.push(k.clone());
}
}
if device.preserve_fat_order {
to_copy.sort();
}
let to_delete: Vec<String> = dst_map.keys().filter(|k| !src_map.contains_key(*k)).cloned().collect();
ctx.log(
LogLevel::Info,
format!(
"{} source files · {} to copy · {} to remove",
src_map.len(),
to_copy.len(),
to_delete.len()
),
);
if to_copy.is_empty() && to_delete.is_empty() {
ctx.log(LogLevel::Success, "Everything up to date.");
return Ok(());
}
if !to_delete.is_empty() {
remove_stale(&dest, &to_delete);
ctx.log(LogLevel::Info, format!("Removed {} stale file(s).", to_delete.len()));
}
if !to_copy.is_empty() {
let tmp = write_file_list(&to_copy)?;
let res = {
let args = vec![
"-t".to_string(),
"--files-from".to_string(),
tmp.clone(),
"--progress".to_string(),
"--exclude=.DS_Store".to_string(),
"--exclude=._*".to_string(),
format!("{}/", src_dir.display()),
dest.display().to_string(),
];
let total = to_copy.len() as u64;
stream(ctx, "rsync", &args, |line| {
match parse_rsync_line(line) {
Some(RsyncLine::Progress(p)) => {
let done = if p.done > 0 { p.done } else { total };
ctx.set_device_progress(done, total, &p.speed, &p.eta, "");
true
}
Some(RsyncLine::Total(_)) => true,
Some(RsyncLine::File(path)) => {
ctx.set_device_progress(0, total, "", "", &path);
ctx.log(LogLevel::Transfer, path);
true
}
None => false,
}
})
};
let _ = fs::remove_file(&tmp);
res?;
}
Ok(())
}
fn remove_stale(dest: &Path, to_delete: &[String]) {
let mut actual: HashMap<String, PathBuf> = HashMap::new();
let mut stack = vec![dest.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_s = name.to_string_lossy().to_string();
if name_s.starts_with('.') || name_s.starts_with("._") {
continue;
}
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if let Ok(rel) = path.strip_prefix(dest) {
let key = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().nfc().collect::<String>())
.collect::<Vec<_>>()
.join("/");
actual.insert(key, path);
}
}
}
for key in to_delete {
if let Some(p) = actual.get(key) {
if p.exists() {
let _ = fs::remove_file(p);
if let Some(parent) = p.parent() {
let _ = fs::remove_dir(parent); // remove if now empty
}
}
}
}
}
fn write_file_list(items: &[String]) -> Result<String, String> {
let path = std::env::temp_dir().join(format!("dap-tui-files-{}.txt", std::process::id()));
let content = items.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("\n");
fs::write(&path, content).map_err(|e| format!("cannot write file list: {e}"))?;
Ok(path.display().to_string())
}
// ── mlove finalize ────────────────────────────────────────────────────────────
fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> {
let out = Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.output()
.map_err(|e| format!("cannot run {cmd}: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok((out.status.code().unwrap_or(-1), text))
}
fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let mount = device
.mount_point
.as_ref()
.ok_or_else(|| "mount_point is required to run fatsort".to_string())?;
let target = resolve_fatsort_target(mount)?;
ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display()));
let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?;
if code != 0 {
ctx.log(
LogLevel::Warn,
format!(
"sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}",
mount_display = mount.display()
),
);
}
ctx.log(LogLevel::Info, format!("Running fatsort on {target}…"));
let (code, detail) = capture("sudo", &["-n", "fatsort", &target])?;
if code != 0 {
return Err(format!(
"fatsort failed (exit {code}) {detail} — run manually: sudo fatsort {target}"
));
}
Ok(())
}
fn resolve_fatsort_target(mount: &Path) -> Result<String, String> {
if !mount.is_dir() {
return Ok(mount.display().to_string());
}
let (code, out) = capture(
"findmnt",
&["-n", "-o", "SOURCE", "-T", mount.to_str().unwrap_or("")],
)?;
if code != 0 || out.is_empty() {
return Err(format!(
"could not resolve a block device for {}",
mount.display()
));
}
Ok(out)
}
// ── subprocess plumbing ───────────────────────────────────────────────────────
/// Run a command, streaming its output lines to `on_line`. Returns Err on
/// failure or abort. Lines returned `true` from `on_line` are treated as
/// consumed (progress); unconsumed lines are logged.
fn stream<F>(
ctx: &WorkerCtx,
cmd: &str,
args: &[String],
mut on_line: F,
) -> Result<(), String>
where
F: FnMut(&str) -> bool,
{
let mut child = Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("could not start {cmd}: {e}"))?;
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let (err_tx, err_rx) = mpsc::channel::<String>();
let err_handle = thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut partial: Vec<u8> = Vec::new();
loop {
match stderr.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
partial.extend_from_slice(&buf[..n]);
let mut pos = 0;
for (i, b) in partial.iter().enumerate() {
if *b == b'\n' || *b == b'\r' {
let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string();
if !line.is_empty() {
let _ = err_tx.send(line);
}
pos = i + 1;
}
}
partial.drain(0..pos);
}
}
}
if !partial.is_empty() {
let line = String::from_utf8_lossy(&partial).trim().to_string();
if !line.is_empty() {
let _ = err_tx.send(line);
}
}
});
let mut buf = [0u8; 8192];
let mut partial: Vec<u8> = Vec::new();
loop {
if ctx.abort.load(Ordering::Relaxed) {
let _ = child.kill();
let _ = child.wait();
return Err("aborted".to_string());
}
match stdout.read(&mut buf) {
Ok(0) => break,
Err(_) => break,
Ok(n) => {
partial.extend_from_slice(&buf[..n]);
let mut pos = 0;
for (i, b) in partial.iter().enumerate() {
if *b == b'\n' || *b == b'\r' {
let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string();
if !line.is_empty() && !on_line(&line) {
ctx.log(LogLevel::Info, line);
}
pos = i + 1;
}
}
partial.drain(0..pos);
}
}
}
if !partial.is_empty() {
let line = String::from_utf8_lossy(&partial).trim().to_string();
if !line.is_empty() && !on_line(&line) {
ctx.log(LogLevel::Info, line);
}
}
let _ = err_handle.join();
for line in err_rx.try_iter() {
if !line.is_empty() && !on_line(&line) {
let lower = line.to_lowercase();
let level = if lower.contains("error") || lower.contains("failed") {
LogLevel::Error
} else {
LogLevel::Info
};
ctx.log(level, line);
}
}
let status = child.wait().map_err(|e| format!("{cmd} failed: {e}"))?;
if ctx.abort.load(Ordering::Relaxed) {
return Err("aborted".to_string());
}
if !status.success() {
return Err(format!(
"{cmd} failed (exit {})",
status.code().unwrap_or(-1)
));
}
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
mod app;
mod config;
mod engine;
mod progress;
mod ui;
use anyhow::Result;
fn print_help() {
println!("dap-tui — device sync TUI (ratatui)");
println!();
println!("Usage: dap-tui [OPTIONS]");
println!();
println!("Options:");
println!(" --config <PATH> use an alternate config file");
println!(" -s, --skip-mirror skip the StorageBox → local mirror step");
println!(" -V, --version print version");
println!(" -h, --help show this help");
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut config_path: Option<String> = None;
let mut skip_mirror = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_help();
return Ok(());
}
"-V" | "--version" => {
println!("dap-tui {}", app::VERSION);
return Ok(());
}
"--config" => {
i += 1;
if let Some(p) = args.get(i) {
config_path = Some(p.clone());
}
}
"-s" | "--skip-mirror" => skip_mirror = true,
other if other.starts_with("--config=") => {
config_path = Some(other[9..].to_string());
}
_ => {}
}
i += 1;
}
let cfg = config::load(config_path)?;
let mut app = app::App::new(cfg, skip_mirror)?;
app.run()
}
+225
View File
@@ -0,0 +1,225 @@
/// Parsers for rclone and rsync progress output. Hand-rolled, no regex dep.
const AUDIO_EXTS: &[&str] = &[".m4a", ".mp3", ".aac", ".ogg", ".wav", ".wv", ".ape", ".flac", ".m4b"];
#[derive(Debug, Clone, PartialEq)]
pub enum RsyncLine {
/// Total file count announced by rsync ("Transfer starting: N files")
Total(u64),
/// A per-file progress update
Progress(RsyncProgress),
/// A transferred audio file path (current file being handled)
File(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct RsyncProgress {
pub done: u64,
pub total: u64,
pub pct: u16,
pub speed: String,
pub eta: String,
}
fn ends_with_audio_ext(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
AUDIO_EXTS.iter().any(|e| lower.ends_with(e))
}
fn parse_u64(s: &str) -> Option<u64> {
s.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.ok()
}
fn find_pct(s: &str) -> Option<u16> {
for tok in s.split_whitespace() {
if tok.ends_with('%') && tok.len() > 1 {
if let Some(p) = tok[..tok.len() - 1].parse::<u16>().ok() {
return Some(p.min(100));
}
}
}
None
}
fn find_speed(s: &str) -> String {
for tok in s.split_whitespace() {
if tok.ends_with("/s") && tok.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return tok.to_string();
}
}
String::new()
}
fn find_eta(s: &str) -> String {
if let Some(idx) = s.find("ETA") {
let rest = s[idx + 3..].trim();
if !rest.is_empty() {
return rest.to_string();
}
}
String::new()
}
/// Parse a single rsync progress line.
pub fn parse_rsync_line(line: &str) -> Option<RsyncLine> {
let line = line.trim();
if let Some(rest) = line.strip_prefix("Transfer starting:") {
if let Some(n) = parse_u64(rest) {
return Some(RsyncLine::Total(n));
}
}
// Per-file progress: " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)"
if line.contains("xfer#") || line.contains("xfr#") || line.contains("to-check=") {
let mut done = 0;
let mut total = 0;
if let Some(idx) = line.find('#') {
// take the integer immediately after '#'
let rest: String = line[idx + 1..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(n) = rest.parse::<u64>() {
done = n;
}
}
if let Some(idx) = line.find("to-check=") {
let rest = &line[idx + 9..];
let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')');
let _remaining = it.next().and_then(parse_u64);
if let Some(b) = it.next().and_then(parse_u64) {
total = b; // "to-check=remaining/total" → total is second value
}
} else if let Some(idx) = line.find("to-chk=") {
let rest = &line[idx + 7..];
let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')');
let _remaining = it.next().and_then(parse_u64);
if let Some(b) = it.next().and_then(parse_u64) {
total = b;
}
}
let pct = find_pct(line).unwrap_or(0);
return Some(RsyncLine::Progress(RsyncProgress {
done,
total,
pct,
speed: find_speed(line),
eta: find_eta(line),
}));
}
// Plain audio file path line (transferred file being processed).
if ends_with_audio_ext(line) {
// Skip stats-looking lines that just happen to end in an audio ext.
if find_pct(line).is_none() && !line.starts_with(' ') {
return Some(RsyncLine::File(line.to_string()));
}
}
None
}
#[derive(Debug, Clone, PartialEq)]
pub struct RcloneStats {
pub done: String,
pub total: String,
pub pct: u16,
pub speed: String,
pub eta: String,
}
/// Parse a rclone `--stats-one-line` "Transferred:" line.
pub fn parse_rclone_stats(line: &str) -> Option<RcloneStats> {
let line = line.trim();
if !line.contains(" / ") || !line.contains('%') {
return None;
}
let body = line
.split_once(':')
.map(|(_, rest)| rest.trim())
.unwrap_or(line);
if !body.contains(" / ") {
return None;
}
let mut parts = body.split(',');
let sizes = parts.next()?.trim();
let (done, total) = sizes.split_once('/')?;
let done = done.trim().to_string();
let total = total.trim().to_string();
let pct = parts
.next()
.and_then(|p| {
let p = p.trim().trim_end_matches('%');
p.parse::<u16>().ok()
})
.unwrap_or(0)
.min(100);
let speed = parts.next().map(|s| s.trim().to_string()).unwrap_or_default();
let eta = parts
.next()
.map(|s| s.trim().trim_start_matches("ETA").trim().to_string())
.unwrap_or_default();
Some(RcloneStats { done, total, pct, speed, eta })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rclone_stats_line() {
let s = parse_rclone_stats(
"Transferred: 512.000 MiB / 1.000 GiB, 50%, 25.00 MiB/s, ETA 20s",
)
.unwrap();
assert_eq!(s.done, "512.000 MiB");
assert_eq!(s.total, "1.000 GiB");
assert_eq!(s.pct, 50);
assert_eq!(s.speed, "25.00 MiB/s");
assert_eq!(s.eta, "20s");
}
#[test]
fn rejects_non_stats_line() {
assert!(parse_rclone_stats("Elapsed time: 5.0s").is_none());
}
#[test]
fn parses_rsync_progress() {
let line = " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)";
match parse_rsync_line(line) {
Some(RsyncLine::Progress(p)) => {
assert_eq!(p.done, 12);
assert_eq!(p.total, 200);
assert_eq!(p.pct, 100);
assert_eq!(p.speed, "1.23MB/s");
}
other => panic!("expected progress, got {other:?}"),
}
}
#[test]
fn parses_rsync_total() {
match parse_rsync_line("Transfer starting: 150 files") {
Some(RsyncLine::Total(n)) => assert_eq!(n, 150),
other => panic!("expected total, got {other:?}"),
}
}
#[test]
fn parses_rsync_file_line() {
match parse_rsync_line("Artist - Album/track1.mp3") {
Some(RsyncLine::File(p)) => assert_eq!(p, "Artist - Album/track1.mp3"),
other => panic!("expected file, got {other:?}"),
}
}
}
+400
View File
@@ -0,0 +1,400 @@
use crate::app::{relative_ago, App, Tab};
use crate::engine::{LogLevel, StepStatus};
use crate::ui::theme;
use crate::ui::truncate;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
// ── title bar ─────────────────────────────────────────────────────────────────
pub fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![
Span::styled(
" dap-tui ",
Style::default()
.fg(theme::BG)
.bg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("device sync", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
"StorageBox → local → player",
Style::default().fg(theme::DIM),
),
];
let mut right_spans = vec![
Span::styled(
if app.is_syncing() {
format!("{} syncing", app.spinner())
} else {
"● ready".to_string()
},
Style::default().fg(if app.is_syncing() { theme::CYAN } else { theme::GREEN }),
),
Span::raw(" "),
Span::styled(
truncate(
&app.config.config_file.display().to_string(),
(area.width.max(9) - 8) as usize,
),
Style::default().fg(theme::DIM),
),
];
// pad to full width
let left_w: usize = spans.iter().map(|s| s.width()).sum();
let right_w: usize = right_spans.iter().map(|s| s.width()).sum();
let total = area.width as usize;
let pad = total.saturating_sub(left_w + right_w);
spans.push(Span::raw(" ".repeat(pad)));
spans.append(&mut right_spans);
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_ALT)),
area,
);
}
// ── gradient rule ─────────────────────────────────────────────────────────────
pub fn draw_rule(f: &mut Frame, area: Rect, _app: &App) {
let mut spans = Vec::with_capacity(area.width as usize);
let colors = [theme::ACCENT, theme::CYAN, theme::MAGENTA, theme::DIM];
for i in 0..area.width {
let t = i as f32 / area.width.max(1) as f32;
let idx = if t < 0.25 {
0
} else if t < 0.5 {
1
} else if t < 0.75 {
2
} else {
3
};
spans.push(Span::styled("─", Style::default().fg(colors[idx])));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
area,
);
}
// ── sidebar ───────────────────────────────────────────────────────────────────
pub fn draw_sidebar(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::BG_ALT))
.title(Span::styled(
" DEVICES ",
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
f.render_widget(block, area);
let [head, list, foot] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
.areas(inner);
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
let syncing = app.engine.state.lock().map(|s| s.running).unwrap_or(false);
let head_line = Line::from(vec![
Span::styled(
format!("{} devices", app.config.devices.len()),
Style::default().fg(theme::FG),
),
Span::raw(" · "),
Span::styled(
format!("{mounted} mounted"),
Style::default().fg(if mounted > 0 { theme::GREEN } else { theme::DIM }),
),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG_ALT)),
head,
);
draw_device_list(f, list, app, syncing);
let foot_line = Line::from(vec![
Span::styled("s sync", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
Span::styled(" · ", Style::default().fg(theme::DIM)),
Span::styled("r refresh", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
if app.skip_mirror {
Span::styled(" · mirror off", Style::default().fg(theme::YELLOW))
} else {
Span::styled("", Style::default())
},
]);
f.render_widget(
Paragraph::new(foot_line).style(Style::default().bg(theme::BG_ALT)),
foot,
);
}
fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
if app.config.devices.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" no devices configured",
Style::default().fg(theme::DIM),
)))
.style(Style::default().bg(theme::BG_ALT)),
area,
);
return;
}
let item_h: u16 = 2;
let visible = (area.height / item_h) as usize;
let start = app.sidebar_scroll.min(app.config.devices.len());
let rows = Layout::vertical(
(0..visible)
.map(|_| Constraint::Length(item_h))
.collect::<Vec<_>>(),
)
.split(area);
let sync_device = if syncing {
app.engine.state.lock().unwrap().device.clone()
} else {
None
};
for (i, row) in rows.iter().enumerate() {
let idx = start + i;
let Some(device) = app.config.devices.get(idx) else {
continue;
};
let selected = idx == app.selected;
let row_style = if selected {
Style::default()
.bg(theme::ACCENT)
.fg(theme::BG)
} else {
Style::default().bg(theme::BG_ALT).fg(theme::FG)
};
let mut spans: Vec<Span> = Vec::new();
// status icon
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
if is_this {
spans.push(Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::BG),
));
} else if device.mounted {
spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN })));
} else {
spans.push(Span::styled("○", Style::default().fg(theme::DIM)));
}
spans.push(Span::raw(" "));
// selection marker + name
let marker = if selected { "▸ " } else { " " };
let name = truncate(&device.name, row.width.max(1) as usize - 8);
spans.push(Span::styled(
format!("{marker}{name}"),
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
));
f.render_widget(Paragraph::new(Line::from(spans)).style(row_style), *row);
// second line
let sub = if let Some(last) = device.last_sync {
format!(
" {} · {} · {}",
device.firmware.label(),
device.content_type,
relative_ago(last)
)
} else {
format!(" {} · {}", device.firmware.label(), device.content_type)
};
let sub_style = if selected {
Style::default().bg(theme::ACCENT).fg(theme::BG)
} else {
Style::default().bg(theme::BG_ALT).fg(theme::DIM)
};
let row2 = Rect {
y: row.y + 1,
..*row
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(truncate(&sub, row.width.max(1) as usize), sub_style)))
.style(sub_style),
row2,
);
}
}
// ── tab bar ───────────────────────────────────────────────────────────────────
pub fn draw_tabbar(f: &mut Frame, area: Rect, app: &App) {
let mut spans: Vec<Span> = Vec::new();
for (i, tab) in Tab::ALL.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let active = app.tab == *tab;
let syncing_badge = *tab == Tab::Sync && app.is_syncing();
let text = format!(" {} {} ", tab.number(), tab.label());
if active {
spans.push(Span::styled(
text,
Style::default()
.bg(theme::ACCENT)
.fg(theme::BG)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
text,
Style::default().bg(theme::BG_ALT).fg(theme::DIM),
));
}
if syncing_badge {
spans.push(Span::raw(" "));
spans.push(Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::CYAN),
));
}
}
let rest: u16 = area
.width
.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
if rest > 0 {
spans.push(Span::raw(" ".repeat(rest as usize)));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
area,
);
}
// ── status bar ────────────────────────────────────────────────────────────────
pub fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let (msg, style) = status_text(app);
let left_spans: Vec<Span> = vec![
Span::styled(" ", Style::default()),
Span::styled(msg, style),
];
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let right_spans = vec![
Span::styled(
format!("{} devices · {mounted} mounted", app.config.devices.len()),
Style::default().fg(theme::DIM),
),
Span::raw(" "),
Span::styled(
format!("v{} · {now}", crate::app::VERSION),
Style::default().fg(theme::DIM),
),
];
let total = area.width as usize;
let lw: usize = left_spans.iter().map(|s| s.width()).sum();
let rw: usize = right_spans.iter().map(|s| s.width()).sum();
let pad = total.saturating_sub(lw + rw);
let mut all = left_spans;
all.push(Span::raw(" ".repeat(pad)));
all.extend(right_spans);
f.render_widget(
Paragraph::new(Line::from(all)).style(Style::default().bg(theme::BG_ALT)),
area,
);
}
fn status_text(app: &App) -> (String, Style) {
if let Some((msg, level)) = &app.status_msg {
let style = match level {
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
LogLevel::Warn => Style::default().fg(theme::YELLOW),
_ => Style::default().fg(theme::GREEN),
};
return (format!(" {msg}"), style);
}
if app.is_syncing() {
let dev = app
.engine
.state
.lock()
.unwrap()
.device
.clone()
.unwrap_or_default();
return (
format!(" {} Syncing {dev}…", app.spinner()),
Style::default().fg(theme::CYAN),
);
}
(" Ready".to_string(), Style::default().fg(theme::DIM))
}
// ── key bar ───────────────────────────────────────────────────────────────────
pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
let keys: &[(&str, &str)] = &[
("↑↓/jk", "nav"),
("s", "sync"),
("a", "abort"),
("Tab/1-4", "tab"),
("r", "refresh"),
("f", "follow"),
("?", "help"),
("q", "quit"),
];
let mut spans: Vec<Span> = Vec::new();
let mut first = true;
for (key, label) in keys {
if !first {
spans.push(Span::styled(" ", Style::default().fg(theme::DIM)));
}
first = false;
spans.push(Span::styled(
format!(" {key} "),
Style::default().fg(theme::ACCENT).bg(theme::BG_ALT).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(*label, Style::default().fg(theme::DIM)));
}
let rest = area.width.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
if rest > 0 {
spans.push(Span::raw(" ".repeat(rest as usize)));
}
f.render_widget(
Paragraph::new(Line::from(spans))
.style(Style::default().bg(theme::BG))
.alignment(Alignment::Left),
area,
);
}
// ── shared helpers ────────────────────────────────────────────────────────────
pub fn step_icon(status: StepStatus, app: &App) -> Span<'static> {
match status {
StepStatus::Done => Span::styled("●", Style::default().fg(theme::GREEN)),
StepStatus::Active => Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
),
StepStatus::Pending => Span::styled("○", Style::default().fg(theme::DIM)),
StepStatus::Error => Span::styled("✗", Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)),
}
}
+108
View File
@@ -0,0 +1,108 @@
pub mod components;
pub mod tabs;
pub mod theme;
use crate::app::App;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::widgets::{Block, Borders, Clear};
use theme::{BG, DIM};
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
f.render_widget(Block::default().style(Style::default().bg(BG)), area);
let v = Layout::vertical([
Constraint::Length(2), // title
Constraint::Length(1), // gradient rule
Constraint::Min(0), // body
Constraint::Length(1), // status
Constraint::Length(1), // keys
]);
let [title, rule, body, status, keys] = v.areas(area);
components::draw_title(f, title, app);
components::draw_rule(f, rule, app);
let [side, main] = Layout::horizontal([Constraint::Length(34), Constraint::Min(0)]).areas(body);
components::draw_sidebar(f, side, app);
draw_main(f, main, app);
components::draw_status(f, status, app);
components::draw_keys(f, keys, app);
if app.show_help {
draw_help(f);
}
}
fn draw_main(f: &mut Frame, area: Rect, app: &App) {
let [tabbar, content] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
components::draw_tabbar(f, tabbar, app);
match app.tab {
crate::app::Tab::Dashboard => tabs::dashboard::draw(f, content, app),
crate::app::Tab::Sync => tabs::sync::draw(f, content, app),
crate::app::Tab::Logs => tabs::logs::draw(f, content, app),
crate::app::Tab::Config => tabs::config_view::draw(f, content, app),
}
}
fn draw_help(f: &mut Frame) {
let area = f.area();
let box_w = 58;
let box_h = 16;
let x = (area.width.saturating_sub(box_w)) / 2;
let y = (area.height.saturating_sub(box_h)) / 2;
let rect = Rect::new(
x.max(0),
y.max(0),
box_w.min(area.width),
box_h.min(area.height),
);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::ui::theme::ACCENT))
.style(Style::default().bg(crate::ui::theme::PANEL_BG))
.title(" KEYBINDINGS ".cyan().bold());
let lines = [
" ↑↓ / j k move selection",
" s / Enter sync selected device",
" a abort running sync",
" Tab / 1-4 switch tab",
" r refresh devices",
" f toggle log follow",
" u / d scroll logs",
" x clear logs",
" ? / h this help",
" q / Esc quit",
"",
" ← → (todo: device nav)",
];
let mut spans = Vec::new();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
spans.push(ratatui::text::Line::raw(""));
}
let styled = if line.contains('/') {
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(crate::ui::theme::FG))
} else {
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(DIM))
};
spans.push(ratatui::text::Line::from(styled));
}
f.render_widget(ratatui::widgets::Paragraph::new(ratatui::text::Text::from(spans)).block(block), rect);
}
+84
View File
@@ -0,0 +1,84 @@
use crate::app::App;
use crate::ui::theme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let path = app.config.config_file.display().to_string();
let head_line = Line::from(vec![
Span::styled(" config", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(path.clone(), Style::default().fg(theme::DIM)),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
head,
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG));
let inner = block.inner(body);
f.render_widget(block, body);
let lines: Vec<Line> = app
.config
.raw_text
.lines()
.map(syntax_line)
.collect();
let content_h = inner.height.saturating_sub(1) as usize;
let offset = lines.len().saturating_sub(content_h);
f.render_widget(
Paragraph::new(lines)
.scroll((offset as u16, 0))
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
fn syntax_line(raw: &str) -> Line<'static> {
let line = raw;
let trimmed = line.trim_start();
if trimmed.is_empty() {
return Line::raw("");
}
if trimmed.starts_with('#') {
return Line::from(Span::styled(line.to_string(), Style::default().fg(theme::DIM)));
}
if trimmed.starts_with('[') {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme::MAGENTA).add_modifier(Modifier::BOLD),
));
}
if let Some(idx) = line.find('=') {
let key = line[..idx].trim_end();
let value = line[idx + 1..].trim_start();
let value_style = if value.starts_with('"') {
Style::default().fg(theme::GREEN)
} else if value.starts_with('[') {
Style::default().fg(theme::CYAN)
} else if value.starts_with("true") || value.starts_with("false") {
Style::default().fg(theme::YELLOW)
} else {
Style::default().fg(theme::ORANGE)
};
return Line::from(vec![
Span::styled(format!("{key}"), Style::default().fg(theme::ACCENT)),
Span::styled(" = ", Style::default().fg(theme::DIM)),
Span::styled(value.to_string(), value_style),
]);
}
Line::from(Span::styled(line.to_string(), Style::default().fg(theme::FG)))
}
+213
View File
@@ -0,0 +1,213 @@
use crate::app::{relative_ago, App};
use crate::ui::theme;
use crate::ui::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
fn card(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG))
.title(Span::styled(
format!(" {title} "),
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
))
}
fn kv<'a>(label: &str, value: String, value_style: Style) -> Line<'a> {
Line::from(vec![
Span::styled(
format!(" {label:<12}"),
Style::default().fg(theme::DIM),
),
Span::styled(value, value_style),
])
}
fn firmware_badge(fw: &str) -> Span<'static> {
let color = match fw {
"rockbox" => theme::MAGENTA,
"android" => theme::CYAN,
"mlove" => theme::ORANGE,
_ => theme::GREEN,
};
Span::styled(
format!(" [{fw}] "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let Some(device) = app.selected_device() else {
draw_empty(f, area);
return;
};
let [top, chain] = Layout::vertical([Constraint::Min(8), Constraint::Length(4)]).areas(area);
let [left, right] =
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(top);
// ── device card ──
let dev_block = card("DEVICE");
let inner = dev_block.inner(left);
f.render_widget(dev_block, left);
let status = if device.mounted {
Span::styled("● mounted", Style::default().fg(theme::GREEN))
} else {
Span::styled("○ not mounted", Style::default().fg(theme::RED))
};
let fw = device.firmware.label().to_string();
let last = match device.last_sync {
Some(dt) => {
if device.last_status == "ok" {
format!("{} (ok)", relative_ago(dt))
} else {
format!("{} (error)", relative_ago(dt))
}
}
None => "never".to_string(),
};
let lines = vec![
Line::from(vec![
Span::raw(" "),
Span::styled(
truncate(&device.name, inner.width.saturating_sub(4) as usize),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
),
firmware_badge(&fw),
]),
Line::raw(""),
Line::from(vec![
Span::styled(" status", Style::default().fg(theme::DIM)),
Span::raw(" "),
status.clone(),
]),
kv("firmware", fw.clone(), Style::default().fg(theme::FG)),
kv("content", device.content_type.clone(), Style::default().fg(theme::FG)),
kv("mount", device.mount_str(), Style::default().fg(theme::CYAN)),
kv("folder", device.music_folder.clone(), Style::default().fg(theme::CYAN)),
kv("last sync", last, Style::default().fg(theme::YELLOW)),
];
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
// ── library card ──
let lib_block = card("LIBRARY");
let inner = lib_block.inner(right);
f.render_widget(lib_block, right);
let excludes = app.config.storagebox_excludes.len();
let lib_lines = vec![
kv(
"music",
app.config.local_music_dir.display().to_string(),
Style::default().fg(theme::CYAN),
),
kv(
"hoerspiele",
app.config.local_hoerspiele_dir.display().to_string(),
Style::default().fg(theme::CYAN),
),
Line::raw(""),
kv(
"storagebox",
app.config.storagebox_source.clone(),
Style::default().fg(theme::FG),
),
kv(
"source hsp",
app.config.storagebox_hoerspiele_source.clone(),
Style::default().fg(theme::FG),
),
kv(
"excludes",
format!("{excludes} patterns"),
Style::default().fg(theme::DIM),
),
Line::raw(""),
kv(
"output",
app.config.output_dir.display().to_string(),
Style::default().fg(theme::DIM),
),
];
f.render_widget(Paragraph::new(lib_lines).style(Style::default().bg(theme::PANEL_BG)), inner);
// ── source chain ──
draw_chain(f, chain, app, device.name.clone());
}
fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) {
let block = card("SYNC PATH");
let inner = block.inner(area);
f.render_widget(block, area);
let w = inner.width.saturating_sub(2) as usize;
// reserve: indent(2) + 2×arrow(20) + 3×chip brackets(9)
let avail = w.saturating_sub(31);
let seg1 = avail * 40 / 100;
let seg2 = avail * 30 / 100;
let seg3 = avail.saturating_sub(seg1 + seg2);
let src = truncate(&app.config.storagebox_source, seg1.max(3));
let local = truncate(&app.config.local_music_dir.display().to_string(), seg2.max(3));
let device = truncate(&device_name, seg3.max(3));
let mut line = vec![
Span::raw(" "),
chip(&src),
arrow("rclone"),
chip(&local),
arrow("rsync"),
chip(&device),
];
let used: usize = line.iter().map(|s| s.width()).sum();
if used < inner.width as usize {
line.push(Span::raw(" ".repeat(inner.width as usize - used)));
}
f.render_widget(
Paragraph::new(Line::from(line)).style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
fn chip(text: &str) -> Span<'static> {
Span::styled(
format!(" [{text}]"),
Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD),
)
}
fn arrow(label: &str) -> Span<'static> {
Span::styled(
format!(" ─{label}▶ "),
Style::default().fg(theme::CYAN),
)
}
fn draw_empty(f: &mut Frame, area: Rect) {
let block = card("DEVICE");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::raw(""),
Line::from(Span::styled(
" No devices configured.",
Style::default().fg(theme::YELLOW),
)),
Line::from(Span::styled(
" Add [[devices]] entries to your config.toml",
Style::default().fg(theme::DIM),
)),
])
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
+91
View File
@@ -0,0 +1,91 @@
use crate::app::App;
use crate::engine::LogLevel;
use crate::ui::theme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
fn level_style(level: LogLevel) -> Style {
match level {
LogLevel::Info => Style::default().fg(theme::FG),
LogLevel::Transfer => Style::default().fg(theme::CYAN),
LogLevel::Warn => Style::default().fg(theme::YELLOW),
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
LogLevel::Success => Style::default().fg(theme::GREEN),
}
}
fn level_tag(level: LogLevel) -> &'static str {
match level {
LogLevel::Info => "INF",
LogLevel::Transfer => "XFER",
LogLevel::Warn => "WRN",
LogLevel::Error => "ERR",
LogLevel::Success => "OK",
}
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let follow_tag = if app.log_follow {
"follow on".to_string()
} else {
"follow off".to_string()
};
let head_line = Line::from(vec![
Span::styled(
format!(" {} log entries", app.logs.len()),
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
),
Span::raw(" · "),
Span::styled(follow_tag, Style::default().fg(theme::DIM)),
Span::raw(" · "),
Span::styled(
"f toggle · u/d scroll · x clear",
Style::default().fg(theme::DIM),
),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
head,
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG));
let inner = block.inner(body);
f.render_widget(block, body);
let lines: Vec<Line> = app
.logs
.iter()
.map(|e| {
let ts = e.ts.format("%H:%M:%S").to_string();
Line::from(vec![
Span::styled(format!(" {ts} "), Style::default().fg(theme::DIM)),
Span::styled(format!("{:>4}", level_tag(e.level)), level_style(e.level)),
Span::raw(" "),
Span::styled(e.msg.clone(), level_style(e.level)),
])
})
.collect();
let content_h = inner.height.saturating_sub(1) as usize;
let offset = if app.log_follow {
lines.len().saturating_sub(content_h)
} else {
app.log_scroll.min(lines.len().saturating_sub(content_h))
};
f.render_widget(
Paragraph::new(lines)
.scroll((offset as u16, 0))
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config_view;
pub mod dashboard;
pub mod logs;
pub mod sync;
+246
View File
@@ -0,0 +1,246 @@
use crate::app::App;
use crate::engine::{StepState, StepStatus, SyncState};
use crate::ui::components::step_icon;
use crate::ui::theme;
use crate::ui::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, BorderType, Gauge, Paragraph};
fn panel(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG))
.title(Span::styled(
format!(" {title} "),
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
))
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let state = app.engine.state.lock().unwrap().clone();
let header = match (&state.device, state.running) {
(Some(name), true) => {
if state.aborting {
format!(" Aborting sync → {name}")
} else {
format!(" Syncing → {name}")
}
}
(Some(name), false) => format!(" Ready to sync → {name}"),
_ => " Sync".to_string(),
};
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
header,
Style::default()
.fg(theme::FG)
.add_modifier(Modifier::BOLD),
)))
.style(Style::default().bg(theme::BG)),
head,
);
if !state.running {
draw_idle(f, body, &state, app);
return;
}
let [steps, progress] =
Layout::vertical([Constraint::Min(6), Constraint::Length(6)]).areas(body);
draw_steps(f, steps, &state, app);
draw_progress(f, progress, &state);
}
fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) {
let [info, hint] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(area);
let block = panel("LAST SYNC");
let inner = block.inner(info);
f.render_widget(block, info);
let mut lines: Vec<Line> = Vec::new();
if let Some(ok) = state.result {
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
if ok { "✓" } else { "✗" },
Style::default().fg(if ok { theme::GREEN } else { theme::RED }).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(state.summary.clone(), Style::default().fg(theme::FG)),
]));
} else {
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"No sync has been run yet.",
Style::default().fg(theme::DIM),
),
]));
}
if let Some(name) = &state.device {
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Press ", Style::default().fg(theme::DIM)),
Span::styled(" s ", Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD)),
Span::styled(format!(" to sync {name}"), Style::default().fg(theme::DIM)),
]));
}
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
let hint_block = panel("PIPELINE");
let hint_inner = hint_block.inner(hint);
f.render_widget(hint_block, hint);
f.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::raw(" "),
Span::styled("1 ", Style::default().fg(theme::ACCENT)),
Span::styled("rclone sync", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("2 ", Style::default().fg(theme::ACCENT)),
Span::styled("NFC diff", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("3 ", Style::default().fg(theme::ACCENT)),
Span::styled("rsync", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("4 ", Style::default().fg(theme::ACCENT)),
Span::styled("eject / fatsort", Style::default().fg(theme::FG)),
]),
])
.style(Style::default().bg(theme::PANEL_BG)),
hint_inner,
);
}
fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) {
let block = panel("STEPS");
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::raw(""));
for step in &state.steps {
lines.push(step_line(step, app));
lines.push(Line::raw(""));
}
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
}
fn step_line(step: &StepState, app: &App) -> Line<'static> {
let icon = step_icon(step.status, app);
let title_style = match step.status {
StepStatus::Active => Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
StepStatus::Done => Style::default().fg(theme::GREEN),
StepStatus::Error => Style::default().fg(theme::RED),
StepStatus::Pending => Style::default().fg(theme::DIM),
};
Line::from(vec![
Span::raw(" "),
icon,
Span::raw(" "),
Span::styled(step.title.clone(), title_style),
Span::raw(" "),
Span::styled(step.detail.clone(), Style::default().fg(theme::DIM)),
])
}
fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
let block = panel("PROGRESS");
let inner = block.inner(area);
f.render_widget(block, area);
let active = state
.steps
.iter()
.position(|s| s.status == StepStatus::Active)
.unwrap_or(usize::MAX);
let (pct, done, total, speed, eta, current, label) = if active == 0 {
(
state.mirror.pct,
state.mirror.done.clone(),
state.mirror.total.clone(),
state.mirror.speed.clone(),
state.mirror.eta.clone(),
state.mirror.current.clone(),
"Mirroring local library",
)
} else if active == 1 {
(
state.device_prog.pct,
state.device_prog.done.clone(),
state.device_prog.total.clone(),
state.device_prog.speed.clone(),
state.device_prog.eta.clone(),
state.device_prog.current.clone(),
"Syncing device",
)
} else {
(0, String::new(), String::new(), String::new(), String::new(), String::new(), "Finalizing")
};
let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
let g = Gauge::default()
.block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::DIM)))
.gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT))
.ratio((pct as f64) / 100.0)
.label(Span::styled(
format!(" {label} · {pct}% "),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
));
f.render_widget(g, gauge_row);
let mut lines: Vec<Line> = Vec::new();
if !done.is_empty() || !total.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
if done.is_empty() { "…".to_string() } else { done.clone() },
Style::default().fg(theme::CYAN),
),
Span::styled(" / ", Style::default().fg(theme::DIM)),
Span::styled(
if total.is_empty() { "…".to_string() } else { total },
Style::default().fg(theme::CYAN),
),
]));
}
let mut right: Vec<Span> = Vec::new();
if !speed.is_empty() {
right.push(Span::styled(speed, Style::default().fg(theme::YELLOW)));
}
if !eta.is_empty() {
right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM)));
right.push(Span::styled(eta, Style::default().fg(theme::YELLOW)));
}
if !current.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
truncate(&current, inner.width.saturating_sub(2) as usize),
Style::default().fg(theme::DIM),
),
]));
} else {
lines.push(Line::raw(""));
}
let mut stats_line = lines;
stats_line.push(Line::from(
std::iter::once(Span::raw(" "))
.chain(right.into_iter())
.collect::<Vec<_>>(),
));
f.render_widget(Paragraph::new(stats_line).style(Style::default().bg(theme::PANEL_BG)), stats);
}
+15
View File
@@ -0,0 +1,15 @@
use ratatui::style::Color;
// Tokyo Night palette
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
pub const BG_ALT: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const PANEL_BG: Color = Color::Rgb(0x1f, 0x23, 0x33);
pub const FG: Color = Color::Rgb(0xc0, 0xca, 0xf5);
pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
pub const CYAN: Color = Color::Rgb(0x7d, 0xcf, 0xff);
pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68);
pub const RED: Color = Color::Rgb(0xf7, 0x76, 0x8e);
pub const MAGENTA: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
pub const ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);