dashboard: per-player settings, storage usage; drop Config tab
- Remove the separate Config tab (3 tabs now); each player's settings (content type, mount point, music folder, fat order, sync command) are shown in a PLAYER SETTINGS card on the dashboard - Show mounted storage on the device card: total/free space (statvfs) plus a usage gauge - Add library + sync-pipeline cards and full-width SYNC PATH chain - Update keybar/help/README for 3 tabs
This commit is contained in:
Generated
+1
@@ -258,6 +258,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"libc",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
unicode-normalization = "0.1"
|
unicode-normalization = "0.1"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
libc = "0.2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ living dashboard instead of a batch CLI.
|
|||||||
bars.
|
bars.
|
||||||
- **Devices sidebar** — live mount status (green dot = mounted), firmware tags,
|
- **Devices sidebar** — live mount status (green dot = mounted), firmware tags,
|
||||||
last-sync age per device.
|
last-sync age per device.
|
||||||
- **4 tabs** — Dashboard (device + library cards, source-chain diagram), Sync
|
- **3 tabs** — Dashboard (per-player overview + settings, storage usage when
|
||||||
(live step pipeline + progress gauge), Logs (color-coded, follow mode),
|
mounted, library + sync pipeline), Sync (live step pipeline + progress
|
||||||
Config (syntax-coloured TOML).
|
gauge), Logs (color-coded, follow mode).
|
||||||
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress,
|
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress
|
||||||
NFC-aware diffing for macOS→FAT32/Android devices, optional `fatsort`
|
(byte-weighted via `--info=progress2`), NFC-aware diffing for
|
||||||
finalize for Mlove devices.
|
macOS→FAT32/Android devices, optional `fatsort` finalize for Mlove devices.
|
||||||
- **Abort-safe** — `a` kills the running child process cleanly.
|
- **Abort-safe** — `a` kills the running child process cleanly.
|
||||||
- Reads the existing `~/.config/dap-sync/config.toml` (compatible with
|
- Reads the existing `~/.config/dap-sync/config.toml` (compatible with
|
||||||
`dap-sync`), persists last-sync state to `~/.local/share/dap-sync/sync-state.toml`.
|
`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 |
|
| `↑`/`↓` `j`/`k` | move selection |
|
||||||
| `s` / `Enter`| sync selected device |
|
| `s` / `Enter`| sync selected device |
|
||||||
| `a` | abort running sync |
|
| `a` | abort running sync |
|
||||||
| `Tab` / `1-4`| switch tab |
|
| `Tab` / `1-3`| switch tab |
|
||||||
| `r` | refresh device mounts |
|
| `r` | refresh device mounts |
|
||||||
| `f` | toggle log follow |
|
| `f` | toggle log follow |
|
||||||
| `u` / `d` | scroll logs |
|
| `u` / `d` | scroll logs |
|
||||||
|
|||||||
+1
-5
@@ -16,17 +16,15 @@ pub enum Tab {
|
|||||||
Dashboard = 0,
|
Dashboard = 0,
|
||||||
Sync = 1,
|
Sync = 1,
|
||||||
Logs = 2,
|
Logs = 2,
|
||||||
Config = 3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab {
|
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 {
|
pub fn label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tab::Dashboard => "Dashboard",
|
Tab::Dashboard => "Dashboard",
|
||||||
Tab::Sync => "Sync",
|
Tab::Sync => "Sync",
|
||||||
Tab::Logs => "Logs",
|
Tab::Logs => "Logs",
|
||||||
Tab::Config => "Config",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn number(&self) -> usize {
|
pub fn number(&self) -> usize {
|
||||||
@@ -34,7 +32,6 @@ impl Tab {
|
|||||||
Tab::Dashboard => 1,
|
Tab::Dashboard => 1,
|
||||||
Tab::Sync => 2,
|
Tab::Sync => 2,
|
||||||
Tab::Logs => 3,
|
Tab::Logs => 3,
|
||||||
Tab::Config => 4,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +193,6 @@ impl App {
|
|||||||
KeyCode::Char('1') => self.tab = Tab::Dashboard,
|
KeyCode::Char('1') => self.tab = Tab::Dashboard,
|
||||||
KeyCode::Char('2') => self.tab = Tab::Sync,
|
KeyCode::Char('2') => self.tab = Tab::Sync,
|
||||||
KeyCode::Char('3') => self.tab = Tab::Logs,
|
KeyCode::Char('3') => self.tab = Tab::Logs,
|
||||||
KeyCode::Char('4') => self.tab = Tab::Config,
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ pub struct Config {
|
|||||||
pub storagebox_hoerspiele_source: String,
|
pub storagebox_hoerspiele_source: String,
|
||||||
pub storagebox_excludes: Vec<String>,
|
pub storagebox_excludes: Vec<String>,
|
||||||
pub devices: Vec<Device>,
|
pub devices: Vec<Device>,
|
||||||
pub raw_text: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -258,7 +257,6 @@ pub fn load(config_path: Option<String>) -> Result<Config> {
|
|||||||
.map(|v| v.clone())
|
.map(|v| v.clone())
|
||||||
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()),
|
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()),
|
||||||
devices,
|
devices,
|
||||||
raw_text: text,
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Config {
|
Config {
|
||||||
@@ -270,7 +268,6 @@ pub fn load(config_path: Option<String>) -> Result<Config> {
|
|||||||
storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(),
|
storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(),
|
||||||
storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(),
|
storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(),
|
||||||
devices: Vec::new(),
|
devices: Vec::new(),
|
||||||
raw_text: "# no config file found\n".to_string(),
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+50
@@ -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<DiskStats> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod disk;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
|
|||||||
("↑↓/jk", "nav"),
|
("↑↓/jk", "nav"),
|
||||||
("s", "sync"),
|
("s", "sync"),
|
||||||
("a", "abort"),
|
("a", "abort"),
|
||||||
("Tab/1-4", "tab"),
|
("Tab/1-3", "tab"),
|
||||||
("r", "refresh"),
|
("r", "refresh"),
|
||||||
("f", "follow"),
|
("f", "follow"),
|
||||||
("?", "help"),
|
("?", "help"),
|
||||||
|
|||||||
+1
-4
@@ -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::Dashboard => tabs::dashboard::draw(f, content, app),
|
||||||
crate::app::Tab::Sync => tabs::sync::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::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",
|
" ↑↓ / j k move selection",
|
||||||
" s / Enter sync selected device",
|
" s / Enter sync selected device",
|
||||||
" a abort running sync",
|
" a abort running sync",
|
||||||
" Tab / 1-4 switch tab",
|
" Tab / 1-3 switch tab",
|
||||||
" r refresh devices",
|
" r refresh devices",
|
||||||
" f toggle log follow",
|
" f toggle log follow",
|
||||||
" u / d scroll logs",
|
" u / d scroll logs",
|
||||||
" x clear logs",
|
" x clear logs",
|
||||||
" ? / h this help",
|
" ? / h this help",
|
||||||
" q / Esc quit",
|
" q / Esc quit",
|
||||||
"",
|
|
||||||
" ← → (todo: device nav)",
|
|
||||||
];
|
];
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
|||||||
@@ -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<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)))
|
|
||||||
}
|
|
||||||
+180
-49
@@ -1,4 +1,6 @@
|
|||||||
use crate::app::{relative_ago, App};
|
use crate::app::{relative_ago, App};
|
||||||
|
use crate::config::Firmware;
|
||||||
|
use crate::disk::{disk_stats, fmt_bytes};
|
||||||
use crate::ui::theme;
|
use crate::ui::theme;
|
||||||
use crate::ui::truncate;
|
use crate::ui::truncate;
|
||||||
use ratatui::Frame;
|
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> {
|
fn kv<'a>(label: &str, value: String, value_style: Style) -> Line<'a> {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(format!(" {label:<14}"), Style::default().fg(theme::DIM)),
|
||||||
format!(" {label:<12}"),
|
|
||||||
Style::default().fg(theme::DIM),
|
|
||||||
),
|
|
||||||
Span::styled(value, value_style),
|
Span::styled(value, value_style),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn firmware_badge(fw: &str) -> Span<'static> {
|
fn firmware_color(fw: &Firmware) -> ratatui::style::Color {
|
||||||
let color = match fw {
|
match fw {
|
||||||
"rockbox" => theme::MAGENTA,
|
Firmware::Rockbox => theme::MAGENTA,
|
||||||
"android" => theme::CYAN,
|
Firmware::Android => theme::CYAN,
|
||||||
"mlove" => theme::ORANGE,
|
Firmware::Mlove => theme::ORANGE,
|
||||||
_ => theme::GREEN,
|
Firmware::Apple => theme::GREEN,
|
||||||
};
|
}
|
||||||
Span::styled(
|
|
||||||
format!(" [{fw}] "),
|
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let [top, chain] = Layout::vertical([Constraint::Min(8), Constraint::Length(4)]).areas(area);
|
let [row1, row2, chain] = Layout::vertical([
|
||||||
let [left, right] =
|
Constraint::Min(9),
|
||||||
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(top);
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(4),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
// ── device card ──
|
let [left, right] =
|
||||||
let dev_block = card("DEVICE");
|
Layout::horizontal([Constraint::Percentage(46), Constraint::Percentage(54)]).areas(row1);
|
||||||
let inner = dev_block.inner(left);
|
draw_device_card(f, left, app, device);
|
||||||
f.render_widget(dev_block, left);
|
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 {
|
let status = if device.mounted {
|
||||||
Span::styled("● mounted", Style::default().fg(theme::GREEN))
|
Span::styled("● mounted", Style::default().fg(theme::GREEN))
|
||||||
} else {
|
} else {
|
||||||
Span::styled("○ not mounted", Style::default().fg(theme::RED))
|
Span::styled("○ not mounted", Style::default().fg(theme::RED))
|
||||||
};
|
};
|
||||||
let fw = device.firmware.label().to_string();
|
|
||||||
let last = match device.last_sync {
|
let last = match device.last_sync {
|
||||||
Some(dt) => {
|
Some(dt) => {
|
||||||
if device.last_status == "ok" {
|
if device.last_status == "ok" {
|
||||||
@@ -74,36 +89,127 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
None => "never".to_string(),
|
None => "never".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let lines = vec![
|
let mut lines: Vec<Line> = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
|
Span::styled(name, Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
truncate(&device.name, inner.width.saturating_sub(4) as usize),
|
format!(" [{fw}] "),
|
||||||
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
|
Style::default().fg(fw_color).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
firmware_badge(&fw),
|
|
||||||
]),
|
]),
|
||||||
Line::raw(""),
|
Line::raw(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" status", Style::default().fg(theme::DIM)),
|
Span::styled(" status", Style::default().fg(theme::DIM)),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
status.clone(),
|
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)),
|
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);
|
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
|
|
||||||
// ── library card ──
|
// storage usage gauge underneath
|
||||||
let lib_block = card("LIBRARY");
|
if let Some(stats) = storage {
|
||||||
let inner = lib_block.inner(right);
|
let used = stats.total.saturating_sub(stats.available);
|
||||||
f.render_widget(lib_block, right);
|
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<Line> = 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 excludes = app.config.storagebox_excludes.len();
|
||||||
let lib_lines = vec![
|
let lines = vec![
|
||||||
|
Line::raw(""),
|
||||||
kv(
|
kv(
|
||||||
"music",
|
"music",
|
||||||
app.config.local_music_dir.display().to_string(),
|
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),
|
Style::default().fg(theme::DIM),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
f.render_widget(Paragraph::new(lib_lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
f.render_widget(Paragraph::new(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) {
|
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<Line> = 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 block = card("SYNC PATH");
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, 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 src = truncate(&app.config.storagebox_source, seg1.max(3));
|
||||||
let local = truncate(&app.config.local_music_dir.display().to_string(), seg2.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![
|
let mut line = vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
@@ -165,7 +299,7 @@ fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) {
|
|||||||
arrow("rclone"),
|
arrow("rclone"),
|
||||||
chip(&local),
|
chip(&local),
|
||||||
arrow("rsync"),
|
arrow("rsync"),
|
||||||
chip(&device),
|
chip(&device_name),
|
||||||
];
|
];
|
||||||
let used: usize = line.iter().map(|s| s.width()).sum();
|
let used: usize = line.iter().map(|s| s.width()).sum();
|
||||||
if used < inner.width as usize {
|
if used < inner.width as usize {
|
||||||
@@ -185,10 +319,7 @@ fn chip(text: &str) -> Span<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn arrow(label: &str) -> Span<'static> {
|
fn arrow(label: &str) -> Span<'static> {
|
||||||
Span::styled(
|
Span::styled(format!(" ─{label}▶ "), Style::default().fg(theme::CYAN))
|
||||||
format!(" ─{label}▶ "),
|
|
||||||
Style::default().fg(theme::CYAN),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_empty(f: &mut Frame, area: Rect) {
|
fn draw_empty(f: &mut Frame, area: Rect) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod config_view;
|
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|||||||
Reference in New Issue
Block a user