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
+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);