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:
Sebastian
2026-08-22 09:57:53 +02:00
parent 416776bdf4
commit 88b53e50d1
12 changed files with 243 additions and 154 deletions
Generated
+1
View File
@@ -258,6 +258,7 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"dirs", "dirs",
"libc",
"ratatui", "ratatui",
"serde", "serde",
"toml", "toml",
+1
View File
@@ -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]
+7 -7
View File
@@ -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
View File
@@ -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,
_ => {} _ => {}
} }
} }
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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() {
-84
View File
@@ -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
View File
@@ -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
View File
@@ -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;