diff --git a/Cargo.lock b/Cargo.lock index 65f1d9c..54c2783 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,7 @@ dependencies = [ "anyhow", "chrono", "dirs", + "libc", "ratatui", "serde", "toml", diff --git a/Cargo.toml b/Cargo.toml index 1ac8c78..fff32c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] } chrono = "0.4" unicode-normalization = "0.1" dirs = "6" +libc = "0.2" anyhow = "1" [profile.release] diff --git a/README.md b/README.md index c3cbce2..f4d56df 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ living dashboard instead of a batch CLI. 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. +- **3 tabs** — Dashboard (per-player overview + settings, storage usage when + mounted, library + sync pipeline), Sync (live step pipeline + progress + gauge), Logs (color-coded, follow mode). +- **Live sync engine** — streams `rclone` and `rsync` output, parses progress + (byte-weighted via `--info=progress2`), 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`. @@ -39,7 +39,7 @@ cargo build --release | `↑`/`↓` `j`/`k` | move selection | | `s` / `Enter`| sync selected device | | `a` | abort running sync | -| `Tab` / `1-4`| switch tab | +| `Tab` / `1-3`| switch tab | | `r` | refresh device mounts | | `f` | toggle log follow | | `u` / `d` | scroll logs | diff --git a/src/app.rs b/src/app.rs index c1f2eb3..273039b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,17 +16,15 @@ 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 const ALL: [Tab; 3] = [Tab::Dashboard, Tab::Sync, Tab::Logs]; 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 { @@ -34,7 +32,6 @@ impl Tab { Tab::Dashboard => 1, Tab::Sync => 2, Tab::Logs => 3, - Tab::Config => 4, } } } @@ -196,7 +193,6 @@ impl App { 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, _ => {} } } diff --git a/src/config.rs b/src/config.rs index 92fccd7..b60dc53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -124,7 +124,6 @@ pub struct Config { pub storagebox_hoerspiele_source: String, pub storagebox_excludes: Vec, pub devices: Vec, - pub raw_text: String, } impl Config { @@ -258,7 +257,6 @@ pub fn load(config_path: Option) -> Result { .map(|v| v.clone()) .unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()), devices, - raw_text: text, } } else { Config { @@ -270,7 +268,6 @@ pub fn load(config_path: Option) -> Result { 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(), } }; diff --git a/src/disk.rs b/src/disk.rs new file mode 100644 index 0000000..56dffe4 --- /dev/null +++ b/src/disk.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +/// Total and available bytes on a mounted filesystem (statvfs). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiskStats { + pub total: u64, + pub available: u64, +} + +pub fn disk_stats(path: &Path) -> Option { + let c = std::ffi::CString::new(path.to_str()?).ok()?; + let mut s: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 { + return None; + } + let frsize = (s.f_frsize.max(1)) as u64; + Some(DiskStats { + total: s.f_blocks as u64 * frsize, + available: s.f_bavail as u64 * frsize, + }) +} + +pub fn fmt_bytes(n: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = n as f64; + let mut u = 0; + while v >= 1024.0 && u < 4 { + v /= 1024.0; + u += 1; + } + if u == 0 { + format!("{v:.0} {}", UNITS[u]) + } else { + format!("{v:.1} {}", UNITS[u]) + } +} + +#[cfg(test)] +mod tests { + use super::fmt_bytes; + + #[test] + fn formats_bytes() { + assert_eq!(fmt_bytes(0), "0 B"); + assert_eq!(fmt_bytes(512), "512 B"); + assert_eq!(fmt_bytes(1024), "1.0 KiB"); + assert_eq!(fmt_bytes(137438953472), "128.0 GiB"); + assert_eq!(fmt_bytes(1_099_511_627_776), "1.0 TiB"); + } +} diff --git a/src/main.rs b/src/main.rs index 5e51376..4959bfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod disk; mod engine; mod progress; mod ui; diff --git a/src/ui/components.rs b/src/ui/components.rs index c6ccccc..749bc42 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -353,7 +353,7 @@ pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) { ("↑↓/jk", "nav"), ("s", "sync"), ("a", "abort"), - ("Tab/1-4", "tab"), + ("Tab/1-3", "tab"), ("r", "refresh"), ("f", "follow"), ("?", "help"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 623819a..f346f46 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -56,7 +56,6 @@ fn draw_main(f: &mut Frame, area: Rect, app: &App) { 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), } } @@ -82,15 +81,13 @@ fn draw_help(f: &mut Frame) { " ↑↓ / j k move selection", " s / Enter sync selected device", " a abort running sync", - " Tab / 1-4 switch tab", + " Tab / 1-3 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() { diff --git a/src/ui/tabs/config_view.rs b/src/ui/tabs/config_view.rs deleted file mode 100644 index 7e3bc3b..0000000 --- a/src/ui/tabs/config_view.rs +++ /dev/null @@ -1,84 +0,0 @@ -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 = 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))) -} diff --git a/src/ui/tabs/dashboard.rs b/src/ui/tabs/dashboard.rs index e916dfa..989f558 100644 --- a/src/ui/tabs/dashboard.rs +++ b/src/ui/tabs/dashboard.rs @@ -1,4 +1,6 @@ use crate::app::{relative_ago, App}; +use crate::config::Firmware; +use crate::disk::{disk_stats, fmt_bytes}; use crate::ui::theme; use crate::ui::truncate; use ratatui::Frame; @@ -21,25 +23,18 @@ fn card(title: &str) -> Block<'static> { 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(format!(" {label:<14}"), 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), - ) +fn firmware_color(fw: &Firmware) -> ratatui::style::Color { + match fw { + Firmware::Rockbox => theme::MAGENTA, + Firmware::Android => theme::CYAN, + Firmware::Mlove => theme::ORANGE, + Firmware::Apple => theme::GREEN, + } } pub fn draw(f: &mut Frame, area: Rect, app: &App) { @@ -48,21 +43,41 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { 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); + let [row1, row2, chain] = Layout::vertical([ + Constraint::Min(9), + Constraint::Min(8), + Constraint::Length(4), + ]) + .areas(area); - // ── device card ── - let dev_block = card("DEVICE"); - let inner = dev_block.inner(left); - f.render_widget(dev_block, left); + let [left, right] = + Layout::horizontal([Constraint::Percentage(46), Constraint::Percentage(54)]).areas(row1); + draw_device_card(f, left, app, device); + draw_settings_card(f, right, device); + + let [lib, pipe] = + Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(row2); + draw_library_card(f, lib, app); + draw_pipeline_card(f, pipe, device); + + draw_chain(f, chain, app, device); +} + +fn draw_device_card(f: &mut Frame, area: Rect, app: &App, device: &crate::config::Device) { + let block = card("DEVICE"); + let inner = block.inner(area); + f.render_widget(block, area); + + let name = truncate(&device.name, inner.width.saturating_sub(6) as usize); + let fw = device.firmware.label().to_string(); + let fw_color = firmware_color(&device.firmware); 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" { @@ -74,36 +89,127 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { None => "never".to_string(), }; - let lines = vec![ + let mut lines: Vec = vec![ Line::from(vec![ Span::raw(" "), + Span::styled(name, Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)), Span::styled( - truncate(&device.name, inner.width.saturating_sub(4) as usize), - Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + format!(" [{fw}] "), + Style::default().fg(fw_color).add_modifier(Modifier::BOLD), ), - firmware_badge(&fw), ]), Line::raw(""), Line::from(vec![ Span::styled(" status", Style::default().fg(theme::DIM)), - Span::raw(" "), - status.clone(), + Span::raw(" "), + status, ]), - 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)), ]; + + // mounted storage usage (total / available) + let storage = if device.mounted { + device.mount_point.as_ref().and_then(|p| disk_stats(p)) + } else { + None + }; + + if let Some(stats) = storage { + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled(" storage", Style::default().fg(theme::DIM)), + Span::raw(" "), + Span::styled( + format!( + "{} total · {} free", + fmt_bytes(stats.total), + fmt_bytes(stats.available) + ), + Style::default().fg(theme::CYAN), + ), + ])); + } + let _ = app; 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); + // storage usage gauge underneath + if let Some(stats) = storage { + let used = stats.total.saturating_sub(stats.available); + let pct = if stats.total > 0 { + (used as f64 / stats.total as f64 * 100.0) as u16 + } else { + 0 + }; + let gauge_area = Rect { + y: inner.y + inner.height.saturating_sub(1), + x: inner.x, + width: inner.width, + height: 1, + }; + f.render_widget( + ratatui::widgets::Gauge::default() + .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) + .ratio((pct as f64) / 100.0) + .label(Span::styled( + format!(" {pct}% used "), + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + )), + gauge_area, + ); + } +} + +fn draw_settings_card(f: &mut Frame, area: Rect, device: &crate::config::Device) { + let block = card("PLAYER SETTINGS"); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = vec![Line::raw("")]; + lines.push(kv( + "content type", + device.content_type.clone(), + Style::default().fg(theme::FG), + )); + lines.push(kv( + "mount point", + device.mount_str(), + Style::default().fg(theme::CYAN), + )); + lines.push(kv( + "music folder", + device.music_folder.clone(), + Style::default().fg(theme::CYAN), + )); + lines.push(kv( + "fat order", + if device.preserve_fat_order { "yes".into() } else { "no".into() }, + Style::default().fg(if device.preserve_fat_order { theme::YELLOW } else { theme::DIM }), + )); + if device.firmware == Firmware::Apple { + lines.push(kv( + "sync command", + truncate(&device.sync_command, inner.width.saturating_sub(18) as usize), + Style::default().fg(theme::MAGENTA), + )); + } + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled( + " edit ~/.config/dap-sync/config.toml", + Style::default().fg(theme::DIM), + ), + ])); + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); +} + +fn draw_library_card(f: &mut Frame, area: Rect, app: &App) { + let block = card("LIBRARY"); + let inner = block.inner(area); + f.render_widget(block, area); let excludes = app.config.storagebox_excludes.len(); - let lib_lines = vec![ + let lines = vec![ + Line::raw(""), kv( "music", app.config.local_music_dir.display().to_string(), @@ -137,13 +243,41 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { 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()); + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); } -fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) { +fn draw_pipeline_card(f: &mut Frame, area: Rect, device: &crate::config::Device) { + let block = card("SYNC PIPELINE"); + let inner = block.inner(area); + f.render_widget(block, area); + + let steps: Vec<(&str, &str)> = vec![ + ("1", "mirror StorageBox → local"), + ("2", "NFC-aware diff"), + ("3", "rsync copy"), + ("4", if device.firmware == Firmware::Mlove { + "eject + fatsort" + } else { + "eject" + }), + ]; + let mut lines: Vec = Vec::new(); + for (n, label) in steps { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!(" {n} "), + Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(label, Style::default().fg(theme::FG)), + ])); + lines.push(Line::raw("")); + } + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); +} + +fn draw_chain(f: &mut Frame, area: Rect, app: &App, device: &crate::config::Device) { let block = card("SYNC PATH"); let inner = block.inner(area); f.render_widget(block, area); @@ -157,7 +291,7 @@ fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) { 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 device_name = truncate(&device.name, seg3.max(3)); let mut line = vec![ Span::raw(" "), @@ -165,7 +299,7 @@ fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) { arrow("rclone"), chip(&local), arrow("rsync"), - chip(&device), + chip(&device_name), ]; let used: usize = line.iter().map(|s| s.width()).sum(); if used < inner.width as usize { @@ -185,10 +319,7 @@ fn chip(text: &str) -> Span<'static> { } fn arrow(label: &str) -> Span<'static> { - Span::styled( - format!(" ─{label}▶ "), - Style::default().fg(theme::CYAN), - ) + Span::styled(format!(" ─{label}▶ "), Style::default().fg(theme::CYAN)) } fn draw_empty(f: &mut Frame, area: Rect) { diff --git a/src/ui/tabs/mod.rs b/src/ui/tabs/mod.rs index ff49686..8cbc470 100644 --- a/src/ui/tabs/mod.rs +++ b/src/ui/tabs/mod.rs @@ -1,4 +1,3 @@ -pub mod config_view; pub mod dashboard; pub mod logs; pub mod sync;