review pass: mount safety, abort/quit robustness, UI fixes
Engine / safety: - verify mount points against /proc/self/mountinfo instead of path existence; refuse to sync into a leftover (unmounted) directory that would otherwise receive the library on the root filesystem - resolve udisks block devices by label when the path is not mounted, and never let findmnt fall back to the containing root filesystem - abort now SIGTERMs the whole child process group (sh -c/podkit and rsync children included), escalating to SIGKILL after 2s - quitting while a sync runs aborts and waits for the engine instead of orphaning the child - atomically mark the engine busy before spawning to prevent double syncs; persist state before publishing completion - don't follow symlinked directories while scanning (recursion loops) - prune empty directories recursively when removing stale files UI: - help overlay was clipped (hardcoded height): size it from content - speed/ETA were pushed onto a line the progress panel clipped; show them right-aligned on the bytes row - leaving log follow with u/d now scrolls from the bottom instead of jumping to the top of the log - reload config with r (label updated), refresh mounts on completion so last-sync ages are current - fix width underflows on narrow terminals; terminal-aware sidebar scrolling Tests: mountinfo unescape, process-group abort, and a TestBackend render smoke test covering all tabs, small terminals and the help overlay.
This commit is contained in:
@@ -19,7 +19,11 @@ living dashboard instead of a batch CLI.
|
|||||||
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress
|
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress
|
||||||
(byte-weighted via `--info=progress2`), NFC-aware diffing for
|
(byte-weighted via `--info=progress2`), NFC-aware diffing for
|
||||||
macOS→FAT32/Android devices, optional `fatsort` 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 group cleanly; quitting
|
||||||
|
while a sync runs aborts it first instead of leaving it orphaned.
|
||||||
|
- **Mount-aware** — a path that exists but is not a real mount point is flagged
|
||||||
|
and refused as a sync target, so a leftover mount directory can't silently
|
||||||
|
receive a sync on the root filesystem.
|
||||||
- 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`.
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ cargo build --release
|
|||||||
| `a` | abort running sync |
|
| `a` | abort running sync |
|
||||||
| `m` | mount / unmount device (udisks2, no sudo) |
|
| `m` | mount / unmount device (udisks2, no sudo) |
|
||||||
| `Tab` / `1-3`| switch tab |
|
| `Tab` / `1-3`| switch tab |
|
||||||
| `r` | refresh device mounts |
|
| `r` | reload config + refresh mounts |
|
||||||
| `f` | toggle log follow |
|
| `f` | toggle log follow |
|
||||||
| `u` / `d` | scroll logs |
|
| `u` / `d` | scroll logs |
|
||||||
| `x` | clear logs |
|
| `x` | clear logs |
|
||||||
@@ -91,6 +95,11 @@ Devices are mounted/unmounted without sudo via **udisks2**:
|
|||||||
mounts the target first (resolved by mount-point label via `lsblk`). Only the
|
mounts the target first (resolved by mount-point label via `lsblk`). Only the
|
||||||
mlove `fatsort` step needs root (raw block device).
|
mlove `fatsort` step needs root (raw block device).
|
||||||
|
|
||||||
|
Mount status and sync targets are validated against the kernel mount table
|
||||||
|
(`/proc/self/mountinfo`), not just directory existence: if the configured
|
||||||
|
`mount_point` exists but is not actually mounted, `dap-tui` refuses to sync to
|
||||||
|
it rather than writing the library onto the root filesystem.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+65
-22
@@ -48,6 +48,9 @@ pub struct App {
|
|||||||
pub logs: Vec<crate::engine::LogEntry>,
|
pub logs: Vec<crate::engine::LogEntry>,
|
||||||
pub log_follow: bool,
|
pub log_follow: bool,
|
||||||
pub log_scroll: usize,
|
pub log_scroll: usize,
|
||||||
|
/// Height of the log viewport as last rendered (used to scroll from the
|
||||||
|
/// bottom when leaving follow mode).
|
||||||
|
pub log_view: std::cell::Cell<usize>,
|
||||||
|
|
||||||
pub frame: u64,
|
pub frame: u64,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
@@ -68,6 +71,7 @@ impl App {
|
|||||||
logs: Vec::new(),
|
logs: Vec::new(),
|
||||||
log_follow: true,
|
log_follow: true,
|
||||||
log_scroll: 0,
|
log_scroll: 0,
|
||||||
|
log_view: std::cell::Cell::new(0),
|
||||||
frame: 0,
|
frame: 0,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
@@ -88,16 +92,6 @@ impl App {
|
|||||||
self.engine.running()
|
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) {
|
fn drain_engine(&mut self) {
|
||||||
while let Ok(ev) = self.engine.events.try_recv() {
|
while let Ok(ev) = self.engine.events.try_recv() {
|
||||||
match ev {
|
match ev {
|
||||||
@@ -116,9 +110,17 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EngineEvent::Changed => {}
|
EngineEvent::Changed => {}
|
||||||
EngineEvent::Finished { ok, summary } => {
|
EngineEvent::Finished { device, ok, summary } => {
|
||||||
|
// Refresh in-memory last-sync state so the dashboard shows
|
||||||
|
// the run that just finished instead of the stale time
|
||||||
|
// loaded at startup (the engine already persisted it).
|
||||||
|
if let Some(d) = self.config.devices.iter_mut().find(|d| d.name == device) {
|
||||||
|
d.last_sync = Some(chrono::Local::now());
|
||||||
|
d.last_status = if ok { "ok" } else { "error" }.to_string();
|
||||||
|
}
|
||||||
|
self.config.refresh_mounts();
|
||||||
let level = if ok { LogLevel::Success } else { LogLevel::Error };
|
let level = if ok { LogLevel::Success } else { LogLevel::Error };
|
||||||
self.status_msg = Some((summary.clone(), level));
|
self.status_msg = Some((summary, level));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +172,7 @@ impl App {
|
|||||||
self.status_msg = Some((format!("{action} failed — {e}"), LogLevel::Error));
|
self.status_msg = Some((format!("{action} failed — {e}"), LogLevel::Error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.refresh_mounts();
|
self.config.refresh_mounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_key(&mut self, key: KeyEvent) {
|
pub fn on_key(&mut self, key: KeyEvent) {
|
||||||
@@ -203,10 +205,7 @@ impl App {
|
|||||||
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
|
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
|
||||||
KeyCode::Char('a') => self.abort_sync(),
|
KeyCode::Char('a') => self.abort_sync(),
|
||||||
KeyCode::Char('m') => self.toggle_mount(),
|
KeyCode::Char('m') => self.toggle_mount(),
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => self.reload_config(),
|
||||||
self.refresh_mounts();
|
|
||||||
self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info));
|
|
||||||
}
|
|
||||||
KeyCode::Char('f') => {
|
KeyCode::Char('f') => {
|
||||||
self.log_follow = !self.log_follow;
|
self.log_follow = !self.log_follow;
|
||||||
self.log_scroll = 0;
|
self.log_scroll = 0;
|
||||||
@@ -236,25 +235,60 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_selection_visible(&mut self) {
|
fn ensure_selection_visible(&mut self) {
|
||||||
const VISIBLE: usize = 12;
|
// Sidebar rows available: terminal height − title(2) − rule(1) −
|
||||||
|
// status(1) − keys(1) − sidebar head/foot(2), in 2-line device rows.
|
||||||
|
let visible = ratatui::crossterm::terminal::size()
|
||||||
|
.map(|(_, h)| ((h.saturating_sub(7) / 2).max(1)) as usize)
|
||||||
|
.unwrap_or(12);
|
||||||
if self.selected < self.sidebar_scroll {
|
if self.selected < self.sidebar_scroll {
|
||||||
self.sidebar_scroll = self.selected;
|
self.sidebar_scroll = self.selected;
|
||||||
}
|
}
|
||||||
if self.selected >= self.sidebar_scroll + VISIBLE {
|
if self.selected >= self.sidebar_scroll + visible {
|
||||||
self.sidebar_scroll = self.selected + 1 - VISIBLE;
|
self.sidebar_scroll = self.selected + 1 - visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_logs(&mut self, delta: i32) {
|
fn scroll_logs(&mut self, delta: i32) {
|
||||||
self.log_follow = false;
|
if self.log_follow {
|
||||||
|
// Already pinned to the bottom: scrolling further down is a no-op,
|
||||||
|
// while scrolling up must start from the bottom of the buffer
|
||||||
|
// instead of jumping back to the top of the log.
|
||||||
|
if delta > 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let page = self.log_view.get().max(1);
|
||||||
|
self.log_scroll = self.logs.len().saturating_sub(page);
|
||||||
|
self.log_follow = false;
|
||||||
|
}
|
||||||
let max = self.logs.len().saturating_sub(1) as i32;
|
let max = self.logs.len().saturating_sub(1) as i32;
|
||||||
self.log_scroll = ((self.log_scroll as i32 + delta).clamp(0, max)) as usize;
|
self.log_scroll = ((self.log_scroll as i32 + delta).clamp(0, max)) as usize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reload_config(&mut self) {
|
||||||
|
let path = self.config.config_file.display().to_string();
|
||||||
|
match crate::config::load(Some(path)) {
|
||||||
|
Ok(cfg) => {
|
||||||
|
self.config = cfg;
|
||||||
|
if self.selected >= self.config.devices.len() {
|
||||||
|
self.selected = self.config.devices.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
self.sidebar_scroll = 0;
|
||||||
|
self.ensure_selection_visible();
|
||||||
|
self.status_msg = Some((
|
||||||
|
"config reloaded · devices refreshed".to_string(),
|
||||||
|
LogLevel::Info,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status_msg = Some((format!("config reload failed — {e}"), LogLevel::Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tick(&mut self) {
|
fn tick(&mut self) {
|
||||||
self.frame += 1;
|
self.frame += 1;
|
||||||
if self.frame % 30 == 0 {
|
if self.frame % 30 == 0 {
|
||||||
self.refresh_mounts();
|
self.config.refresh_mounts();
|
||||||
}
|
}
|
||||||
if self.is_syncing() {
|
if self.is_syncing() {
|
||||||
let aborted = self.engine.abort.load(Ordering::Relaxed);
|
let aborted = self.engine.abort.load(Ordering::Relaxed);
|
||||||
@@ -282,6 +316,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
|
// Never leave a sync child process orphaned when the user quits:
|
||||||
|
// request an abort and give the engine a moment to terminate it.
|
||||||
|
if self.engine.running() {
|
||||||
|
self.engine.abort_sync();
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while self.engine.running() && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -130,12 +130,13 @@ pub struct Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
fn refresh_mounts(&mut self) {
|
/// Refresh `Device::mounted` from the kernel mount table (no subprocess).
|
||||||
|
pub fn refresh_mounts(&mut self) {
|
||||||
for d in self.devices.iter_mut() {
|
for d in self.devices.iter_mut() {
|
||||||
d.mounted = d
|
d.mounted = d
|
||||||
.mount_point
|
.mount_point
|
||||||
.as_ref()
|
.as_deref()
|
||||||
.map(|p| p.exists())
|
.map(crate::disk::is_mounted)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-1
@@ -1,5 +1,52 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Whether `path` is currently a mount point.
|
||||||
|
///
|
||||||
|
/// Scanning `/proc/self/mountinfo` is used instead of `path.exists()` so a
|
||||||
|
/// leftover mount-point directory (e.g. `/media/sebastian/IPOD` after an
|
||||||
|
/// unclean eject) is not mistaken for a mounted device. Writing a device sync
|
||||||
|
/// into an unmounted directory would silently fill the root filesystem.
|
||||||
|
pub fn is_mounted(path: &Path) -> bool {
|
||||||
|
let Some(target) = path.to_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let target = target.trim_end_matches('/');
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Ok(text) = std::fs::read_to_string("/proc/self/mountinfo") {
|
||||||
|
return text.lines().any(|line| {
|
||||||
|
let Some(field) = line.split(' ').nth(4) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
unescape_octal(field).trim_end_matches('/') == target
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for platforms without /proc: best effort only.
|
||||||
|
path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the octal escaping (`\040` etc.) used by /proc mount tables.
|
||||||
|
fn unescape_octal(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let octal = bytes[i] == b'\\'
|
||||||
|
&& i + 3 < bytes.len()
|
||||||
|
&& bytes[i + 1..=i + 3].iter().all(|b| (b'0'..=b'7').contains(b));
|
||||||
|
if octal {
|
||||||
|
let v = (bytes[i + 1] - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
|
||||||
|
out.push(v as char);
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i] as char);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Total and available bytes on a mounted filesystem (statvfs).
|
/// Total and available bytes on a mounted filesystem (statvfs).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct DiskStats {
|
pub struct DiskStats {
|
||||||
@@ -37,7 +84,14 @@ pub fn fmt_bytes(n: u64) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::fmt_bytes;
|
use super::{fmt_bytes, unescape_octal};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unescapes_mountinfo_paths() {
|
||||||
|
assert_eq!(unescape_octal("/media/sebastian/IPOD"), "/media/sebastian/IPOD");
|
||||||
|
assert_eq!(unescape_octal("/media/My\\040Disk"), "/media/My Disk");
|
||||||
|
assert_eq!(unescape_octal("/media/back\\134slash"), "/media/back\\slash");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_bytes() {
|
fn formats_bytes() {
|
||||||
|
|||||||
+154
-24
@@ -83,6 +83,8 @@ pub enum EngineEvent {
|
|||||||
Log(LogEntry),
|
Log(LogEntry),
|
||||||
Changed,
|
Changed,
|
||||||
Finished {
|
Finished {
|
||||||
|
/// Name of the device the finished sync targeted.
|
||||||
|
device: String,
|
||||||
ok: bool,
|
ok: bool,
|
||||||
summary: String,
|
summary: String,
|
||||||
},
|
},
|
||||||
@@ -180,8 +182,19 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_sync(&self, device: Device, config: Arc<Config>, skip_mirror: bool) {
|
pub fn start_sync(&self, device: Device, config: Arc<Config>, skip_mirror: bool) {
|
||||||
if self.running() {
|
// Mark the engine busy *before* spawning. Otherwise a double keypress
|
||||||
return;
|
// could pass the `running()` check twice and start two concurrent
|
||||||
|
// syncs (the worker used to be the one setting `running`).
|
||||||
|
match self.state.lock() {
|
||||||
|
Ok(mut s) => {
|
||||||
|
if s.running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.running = true;
|
||||||
|
s.device = Some(device.name.clone());
|
||||||
|
s.summary = "Starting sync…".to_string();
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
}
|
}
|
||||||
self.abort.store(false, Ordering::Relaxed);
|
self.abort.store(false, Ordering::Relaxed);
|
||||||
let ctx = Arc::new(WorkerCtx {
|
let ctx = Arc::new(WorkerCtx {
|
||||||
@@ -243,6 +256,14 @@ fn run_sync(config: Arc<Config>, device: Device, skip_mirror: bool, ctx: Arc<Wor
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Persist first: a new sync may start as soon as `running` flips to false
|
||||||
|
// (and reset the shared abort flag), so all shared state must be final
|
||||||
|
// before we publish completion.
|
||||||
|
config::save_state(&config.output_dir, &name, Local::now(), if ok { "ok" } else { "error" });
|
||||||
|
ctx.log(
|
||||||
|
if ok { LogLevel::Success } else { LogLevel::Error },
|
||||||
|
if ok { format!("✓ {summary}") } else { format!("✗ {summary}") },
|
||||||
|
);
|
||||||
{
|
{
|
||||||
let mut s = ctx.state.lock().unwrap();
|
let mut s = ctx.state.lock().unwrap();
|
||||||
s.running = false;
|
s.running = false;
|
||||||
@@ -255,17 +276,12 @@ fn run_sync(config: Arc<Config>, device: Device, skip_mirror: bool, ctx: Arc<Wor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.log(
|
|
||||||
if ok { LogLevel::Success } else { LogLevel::Error },
|
|
||||||
if ok { format!("✓ {summary}") } else { format!("✗ {summary}") },
|
|
||||||
);
|
|
||||||
ctx.changed();
|
ctx.changed();
|
||||||
let _ = ctx.tx.send(EngineEvent::Finished {
|
let _ = ctx.tx.send(EngineEvent::Finished {
|
||||||
|
device: name.clone(),
|
||||||
ok,
|
ok,
|
||||||
summary: summary.clone(),
|
summary: summary.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
config::save_state(&config.output_dir, &name, Local::now(), if ok { "ok" } else { "error" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_pipeline(
|
fn run_pipeline(
|
||||||
@@ -274,8 +290,6 @@ fn run_pipeline(
|
|||||||
skip_mirror: bool,
|
skip_mirror: bool,
|
||||||
ctx: &WorkerCtx,
|
ctx: &WorkerCtx,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let steps = build_steps(device);
|
|
||||||
|
|
||||||
if !skip_mirror {
|
if !skip_mirror {
|
||||||
ctx.set_step(0, StepStatus::Active, "refreshing local library…");
|
ctx.set_step(0, StepStatus::Active, "refreshing local library…");
|
||||||
run_mirror(config, device, ctx)?;
|
run_mirror(config, device, ctx)?;
|
||||||
@@ -312,7 +326,6 @@ fn run_pipeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = steps;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +458,11 @@ fn scan_music_dir(base: &Path) -> HashMap<String, (u64, i64)> {
|
|||||||
}
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
|
// Never follow symlinked directories: a device can contain
|
||||||
|
// self-referential links and we would recurse forever.
|
||||||
|
if entry.file_type().map(|t| t.is_symlink()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
stack.push(path);
|
stack.push(path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -479,21 +497,26 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "mount_point is not set for this device".to_string())?;
|
.ok_or_else(|| "mount_point is not set for this device".to_string())?;
|
||||||
|
|
||||||
if !mount.exists() {
|
if !crate::disk::is_mounted(mount) {
|
||||||
ctx.log(LogLevel::Info, format!("device not mounted at {} — trying udisksctl…", mount.display()));
|
ctx.log(
|
||||||
|
LogLevel::Info,
|
||||||
|
format!("device not mounted at {} — trying udisksctl…", mount.display()),
|
||||||
|
);
|
||||||
match crate::udisks::mount_device(mount) {
|
match crate::udisks::mount_device(mount) {
|
||||||
Ok(msg) => ctx.log(LogLevel::Info, format!("auto-mounted: {msg}")),
|
Ok(msg) => ctx.log(LogLevel::Info, format!("auto-mounted: {msg}")),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"device not found at {} — connect & mount it (m) and retry. {e}",
|
"device not mounted at {} — connect & mount it (m) and retry. {e}",
|
||||||
mount.display()
|
mount.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !mount.exists() {
|
// A leftover (empty) mount-point directory must not be mistaken for a
|
||||||
|
// mounted device — rsync would otherwise happily fill the root filesystem.
|
||||||
|
if !crate::disk::is_mounted(mount) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"device not found at {} — connect & mount it (m) and retry",
|
"{} exists but is not a mount point — refusing to sync. Connect the device and press 'm'.",
|
||||||
mount.display()
|
mount.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -626,8 +649,13 @@ fn remove_stale(dest: &Path, to_delete: &[String]) {
|
|||||||
if let Some(p) = actual.get(key) {
|
if let Some(p) = actual.get(key) {
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
let _ = fs::remove_file(p);
|
let _ = fs::remove_file(p);
|
||||||
if let Some(parent) = p.parent() {
|
// Prune directories that are now empty, up to (not including) dest.
|
||||||
let _ = fs::remove_dir(parent); // remove if now empty
|
let mut parent = p.parent();
|
||||||
|
while let Some(d) = parent {
|
||||||
|
if d == dest || fs::remove_dir(d).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parent = d.parent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,6 +771,33 @@ fn pump_lines<R: std::io::Read + Send + 'static>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put the child in its own process group so a whole pipeline (e.g.
|
||||||
|
/// `sh -c podkit …`, or rsync's own children) can be signalled on abort.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn detach_process_group(command: &mut Command) {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
unsafe {
|
||||||
|
command.pre_exec(|| {
|
||||||
|
libc::setpgid(0, 0);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the process group of `child` (SIGTERM, or SIGKILL when `force`).
|
||||||
|
fn terminate_process_group(child: &mut std::process::Child, force: bool) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
let sig = if force { libc::SIGKILL } else { libc::SIGTERM };
|
||||||
|
libc::kill(-(child.id() as i32), sig);
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = force;
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a command, streaming its output lines to `on_line`. Returns Err on
|
/// 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
|
/// failure or abort. Lines returned `true` from `on_line` are treated as
|
||||||
/// consumed (progress); unconsumed lines are logged.
|
/// consumed (progress); unconsumed lines are logged.
|
||||||
@@ -759,11 +814,16 @@ fn stream<F>(
|
|||||||
where
|
where
|
||||||
F: FnMut(&str) -> bool,
|
F: FnMut(&str) -> bool,
|
||||||
{
|
{
|
||||||
let mut child = Command::new(cmd)
|
let mut command = Command::new(cmd);
|
||||||
|
command
|
||||||
.args(args)
|
.args(args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped());
|
||||||
|
#[cfg(unix)]
|
||||||
|
detach_process_group(&mut command);
|
||||||
|
|
||||||
|
let mut child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("could not start {cmd}: {e}"))?;
|
.map_err(|e| format!("could not start {cmd}: {e}"))?;
|
||||||
|
|
||||||
@@ -775,11 +835,19 @@ where
|
|||||||
let out_thread = thread::spawn(move || pump_lines(stdout, false, out_tx));
|
let out_thread = thread::spawn(move || pump_lines(stdout, false, out_tx));
|
||||||
let err_thread = thread::spawn(move || pump_lines(stderr, true, tx));
|
let err_thread = thread::spawn(move || pump_lines(stderr, true, tx));
|
||||||
|
|
||||||
|
// On abort, ask the whole process group to terminate; escalate to SIGKILL
|
||||||
|
// if the pipes have not closed within a grace period.
|
||||||
|
let mut terminating: Option<std::time::Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
if ctx.abort.load(Ordering::Relaxed) {
|
if ctx.abort.load(Ordering::Relaxed) && terminating.is_none() {
|
||||||
let _ = child.kill();
|
terminate_process_group(&mut child, false);
|
||||||
let _ = child.wait();
|
terminating = Some(std::time::Instant::now());
|
||||||
return Err("aborted".to_string());
|
}
|
||||||
|
if let Some(started) = terminating {
|
||||||
|
if started.elapsed() > std::time::Duration::from_secs(2) {
|
||||||
|
terminate_process_group(&mut child, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
|
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
|
||||||
Ok((is_stderr, line)) => {
|
Ok((is_stderr, line)) => {
|
||||||
@@ -853,4 +921,66 @@ mod tests {
|
|||||||
assert!(seen[0].contains("first stats line"));
|
assert!(seen[0].contains("first stats line"));
|
||||||
assert!(seen[1].contains("second stats line"));
|
assert!(seen[1].contains("second stats line"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn process_is_alive(pid: i32) -> bool {
|
||||||
|
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||||
|
// `pid (comm) state …` — comm may itself contain spaces/parens, so
|
||||||
|
// split at the *last* `) ` and read the state character.
|
||||||
|
Ok(stat) => stat
|
||||||
|
.rsplit(") ")
|
||||||
|
.next()
|
||||||
|
.map(|rest| !rest.starts_with('Z'))
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn abort_terminates_whole_process_group() {
|
||||||
|
// `sh -c` spawns a long-running grandchild; aborting must terminate
|
||||||
|
// the whole group, not just the direct shell child (which used to be
|
||||||
|
// left orphaned and running when the user pressed `a` or quit).
|
||||||
|
let (tx, _rx) = mpsc::channel::<EngineEvent>();
|
||||||
|
let ctx = Arc::new(WorkerCtx {
|
||||||
|
state: Arc::new(Mutex::new(SyncState::default())),
|
||||||
|
tx,
|
||||||
|
abort: Arc::new(AtomicBool::new(false)),
|
||||||
|
});
|
||||||
|
let grandchild: Arc<Mutex<Option<i32>>> = Arc::new(Mutex::new(None));
|
||||||
|
let capture = grandchild.clone();
|
||||||
|
let args: Vec<String> = vec![
|
||||||
|
"-c".to_string(),
|
||||||
|
"sleep 30 & echo CHILD:$!; wait".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let abort = ctx.abort.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
abort.store(true, Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let result = stream(&ctx, "sh", &args, move |line| {
|
||||||
|
if let Some(pid) = line.strip_prefix("CHILD:") {
|
||||||
|
*capture.lock().unwrap() = pid.trim().parse().ok();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
assert!(result.is_err(), "aborted stream must return an error");
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < std::time::Duration::from_secs(5),
|
||||||
|
"abort must not hang"
|
||||||
|
);
|
||||||
|
|
||||||
|
let pid = grandchild.lock().unwrap().expect("grandchild pid captured");
|
||||||
|
for _ in 0..20 {
|
||||||
|
if !process_is_alive(pid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
panic!("grandchild process {pid} survived the abort");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -19,7 +19,10 @@ fn run(cmd: &str, args: &[&str]) -> (bool, String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_block_device(path: &Path) -> Option<String> {
|
fn resolve_block_device(path: &Path) -> Option<String> {
|
||||||
if path.is_dir() {
|
// Only trust findmnt when the path really is a mount point; otherwise
|
||||||
|
// findmnt happily reports the containing filesystem (e.g. /), which would
|
||||||
|
// make us "mount" the root device.
|
||||||
|
if crate::disk::is_mounted(path) {
|
||||||
let (ok, out) = run("findmnt", &["-n", "-o", "SOURCE", "-T", path.to_str()?]);
|
let (ok, out) = run("findmnt", &["-n", "-o", "SOURCE", "-T", path.to_str()?]);
|
||||||
if ok {
|
if ok {
|
||||||
let src = out.trim();
|
let src = out.trim();
|
||||||
@@ -29,22 +32,33 @@ fn resolve_block_device(path: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let wanted = path.file_name()?.to_string_lossy().to_lowercase();
|
let wanted = path.file_name()?.to_string_lossy().to_lowercase();
|
||||||
let (ok, out) = run("lsblk", &["-o", "PATH,LABEL", "-n", "-l"]);
|
let (ok, out) = run("lsblk", &["-o", "PATH,LABEL", "-n", "-l", "-P"]);
|
||||||
if !ok {
|
if !ok {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for line in out.lines() {
|
for line in out.lines() {
|
||||||
let mut parts = line.split_whitespace();
|
let Some(dev) = field(line, "PATH") else {
|
||||||
let Some(dev) = parts.next() else { continue };
|
continue;
|
||||||
if let Some(label) = parts.next() {
|
};
|
||||||
if !label.is_empty() && label.to_lowercase() == wanted {
|
let Some(label) = field(line, "LABEL") else {
|
||||||
return Some(dev.to_string());
|
continue;
|
||||||
}
|
};
|
||||||
|
if !label.is_empty() && label.to_lowercase() == wanted {
|
||||||
|
return Some(dev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a `KEY="value"` field from `lsblk -P` output.
|
||||||
|
fn field(line: &str, key: &str) -> Option<String> {
|
||||||
|
let needle = format!("{key}=\"");
|
||||||
|
let start = line.find(&needle)? + needle.len();
|
||||||
|
let rest = &line[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mount_device(path: &Path) -> Result<String, String> {
|
pub fn mount_device(path: &Path) -> Result<String, String> {
|
||||||
let Some(dev) = resolve_block_device(path) else {
|
let Some(dev) = resolve_block_device(path) else {
|
||||||
return Err(format!("no block device found for {}", path.display()));
|
return Err(format!("no block device found for {}", path.display()));
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
|
|||||||
|
|
||||||
// selection marker + name
|
// selection marker + name
|
||||||
let marker = if selected { "▸ " } else { " " };
|
let marker = if selected { "▸ " } else { " " };
|
||||||
let name = truncate(&device.name, row.width.max(1) as usize - 8);
|
let name = truncate(&device.name, (row.width as usize).saturating_sub(8));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("{marker}{name}"),
|
format!("{marker}{name}"),
|
||||||
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
|
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
|
||||||
@@ -358,7 +358,7 @@ pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
|
|||||||
("a", "abort"),
|
("a", "abort"),
|
||||||
("m", "mount"),
|
("m", "mount"),
|
||||||
("Tab/1-3", "tab"),
|
("Tab/1-3", "tab"),
|
||||||
("r", "refresh"),
|
("r", "reload"),
|
||||||
("f", "follow"),
|
("f", "follow"),
|
||||||
("?", "help"),
|
("?", "help"),
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
|
|||||||
+121
-41
@@ -5,10 +5,11 @@ pub mod theme;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Style, Stylize};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::widgets::{Block, Borders, Clear};
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, BorderType, Clear, Paragraph};
|
||||||
|
|
||||||
use theme::{BG, DIM};
|
use theme::BG;
|
||||||
|
|
||||||
pub fn truncate(s: &str, max: usize) -> String {
|
pub fn truncate(s: &str, max: usize) -> String {
|
||||||
if s.chars().count() <= max {
|
if s.chars().count() <= max {
|
||||||
@@ -60,47 +61,126 @@ fn draw_main(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_help(f: &mut Frame) {
|
fn draw_help(f: &mut Frame) {
|
||||||
|
let entries: &[(&str, &str)] = &[
|
||||||
|
("↑↓ / j k", "move selection"),
|
||||||
|
("s / Enter", "sync selected device"),
|
||||||
|
("a", "abort running sync"),
|
||||||
|
("m", "mount / unmount device (udisks)"),
|
||||||
|
("Tab / 1-3", "switch tab"),
|
||||||
|
("r", "reload config · refresh mounts"),
|
||||||
|
("f", "toggle log follow"),
|
||||||
|
("u / d", "scroll logs"),
|
||||||
|
("x", "clear logs"),
|
||||||
|
("? / h", "this help"),
|
||||||
|
("q / Esc", "quit"),
|
||||||
|
];
|
||||||
|
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let box_w = 58;
|
let box_w: u16 = 52.min(area.width);
|
||||||
let box_h = 16;
|
// blank + entries + blank + footer hint + two borders
|
||||||
let x = (area.width.saturating_sub(box_w)) / 2;
|
let box_h: u16 = (entries.len() as u16 + 5).min(area.height);
|
||||||
let y = (area.height.saturating_sub(box_h)) / 2;
|
let x = area.width.saturating_sub(box_w) / 2;
|
||||||
let rect = Rect::new(
|
let y = area.height.saturating_sub(box_h) / 2;
|
||||||
x.max(0),
|
let rect = Rect::new(x, y, box_w, box_h);
|
||||||
y.max(0),
|
|
||||||
box_w.min(area.width),
|
|
||||||
box_h.min(area.height),
|
|
||||||
);
|
|
||||||
f.render_widget(Clear, rect);
|
f.render_widget(Clear, rect);
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(crate::ui::theme::ACCENT))
|
.border_type(BorderType::Rounded)
|
||||||
.style(Style::default().bg(crate::ui::theme::PANEL_BG))
|
.border_style(Style::default().fg(theme::ACCENT))
|
||||||
.title(" KEYBINDINGS ".cyan().bold());
|
.style(Style::default().bg(theme::PANEL_BG))
|
||||||
let lines = [
|
.title(Span::styled(
|
||||||
" ↑↓ / j k move selection",
|
" KEYBINDINGS ",
|
||||||
" s / Enter sync selected device",
|
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
|
||||||
" a abort running sync",
|
));
|
||||||
" m mount / unmount selected device (udisks)",
|
|
||||||
" Tab / 1-3 switch tab",
|
let mut lines: Vec<Line> = vec![Line::raw("")];
|
||||||
" r refresh devices",
|
for (key, desc) in entries {
|
||||||
" f toggle log follow",
|
lines.push(Line::from(vec![
|
||||||
" u / d scroll logs",
|
Span::raw(" "),
|
||||||
" x clear logs",
|
Span::styled(
|
||||||
" ? / h this help",
|
format!("{key:<11}"),
|
||||||
" q / Esc quit",
|
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
|
||||||
];
|
),
|
||||||
let mut spans = Vec::new();
|
Span::styled(*desc, Style::default().fg(theme::FG)),
|
||||||
for (i, line) in lines.iter().enumerate() {
|
]));
|
||||||
if i > 0 {
|
}
|
||||||
spans.push(ratatui::text::Line::raw(""));
|
lines.push(Line::raw(""));
|
||||||
}
|
lines.push(Line::from(Span::styled(
|
||||||
let styled = if line.contains('/') {
|
" press Esc / ? / q to close",
|
||||||
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(crate::ui::theme::FG))
|
Style::default().fg(theme::DIM),
|
||||||
} else {
|
)));
|
||||||
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(DIM))
|
|
||||||
};
|
f.render_widget(
|
||||||
spans.push(ratatui::text::Line::from(styled));
|
Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::config::{Config, Device, Firmware};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn test_app() -> App {
|
||||||
|
let config = Config {
|
||||||
|
config_file: PathBuf::from("/tmp/dap-tui-test/config.toml"),
|
||||||
|
local_music_dir: PathBuf::from("/tmp/dap-tui-test/music"),
|
||||||
|
local_hoerspiele_dir: PathBuf::from("/tmp/dap-tui-test/hoerspiele"),
|
||||||
|
output_dir: PathBuf::from("/tmp/dap-tui-test/state"),
|
||||||
|
storagebox_source: "storagebox:test".into(),
|
||||||
|
storagebox_hoerspiele_source: "storagebox:test-hoerspiele".into(),
|
||||||
|
storagebox_excludes: vec!["/Music/**".into()],
|
||||||
|
devices: vec![Device {
|
||||||
|
name: "Test Player".into(),
|
||||||
|
firmware: Firmware::Rockbox,
|
||||||
|
content_type: "music".into(),
|
||||||
|
sync_command: "true".into(),
|
||||||
|
mount_point: Some(PathBuf::from("/tmp/dap-tui-test/not-mounted")),
|
||||||
|
music_folder: "Music".into(),
|
||||||
|
preserve_fat_order: false,
|
||||||
|
mounted: false,
|
||||||
|
last_sync: None,
|
||||||
|
last_status: String::new(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
App::new(config, false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(app: &App, width: u16, height: u16) -> String {
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
|
terminal.draw(|f| draw(f, app)).unwrap();
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.symbol())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_all_tabs_and_help_without_panicking() {
|
||||||
|
// Small terminals used to underflow widths; the help overlay used to
|
||||||
|
// clip its last entries because the hardcoded height was too small.
|
||||||
|
for (w, h) in [(40u16, 10u16), (80, 24), (160, 48)] {
|
||||||
|
let mut app = test_app();
|
||||||
|
for tab in crate::app::Tab::ALL {
|
||||||
|
app.tab = tab;
|
||||||
|
let _ = render(&app, w, h);
|
||||||
|
}
|
||||||
|
app.show_help = true;
|
||||||
|
let out = render(&app, w, h);
|
||||||
|
if h >= 24 {
|
||||||
|
assert!(out.contains("KEYBINDINGS"), "help title missing at {w}x{h}");
|
||||||
|
assert!(out.contains("quit"), "last help entry clipped at {w}x{h}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f.render_widget(ratatui::widgets::Paragraph::new(ratatui::text::Text::from(spans)).block(block), rect);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let name = truncate(&device.name, inner.width.saturating_sub(26).max(4) as usize);
|
let name = truncate(&device.name, inner.width.saturating_sub(26).max(4) as usize);
|
||||||
let mut spans: Vec<Span> = vec![Span::raw(" "), Span::styled(name, Style::default().fg(theme::FG))];
|
let mut spans: Vec<Span> = vec![Span::raw(" "), Span::styled(name, Style::default().fg(theme::FG))];
|
||||||
let used: usize = spans.iter().map(|s| s.width()).sum();
|
let used: usize = spans.iter().map(|s| s.width()).sum();
|
||||||
let left = inner.width as usize - 2 - status_span.width();
|
let left = (inner.width as usize).saturating_sub(2 + status_span.width());
|
||||||
if used < left {
|
if used < left {
|
||||||
spans.push(Span::raw(" ".repeat(left - used)));
|
spans.push(Span::raw(" ".repeat(left - used)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let content_h = inner.height.saturating_sub(1) as usize;
|
let content_h = inner.height.saturating_sub(1) as usize;
|
||||||
|
app.log_view.set(content_h);
|
||||||
let offset = if app.log_follow {
|
let offset = if app.log_follow {
|
||||||
lines.len().saturating_sub(content_h)
|
lines.len().saturating_sub(content_h)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+34
-34
@@ -53,7 +53,7 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let [steps, progress] =
|
let [steps, progress] =
|
||||||
Layout::vertical([Constraint::Min(6), Constraint::Length(6)]).areas(body);
|
Layout::vertical([Constraint::Min(6), Constraint::Length(7)]).areas(body);
|
||||||
draw_steps(f, steps, &state, app);
|
draw_steps(f, steps, &state, app);
|
||||||
draw_progress(f, progress, &state);
|
draw_progress(f, progress, &state);
|
||||||
}
|
}
|
||||||
@@ -202,21 +202,20 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
|
|||||||
));
|
));
|
||||||
f.render_widget(g, gauge_row);
|
f.render_widget(g, gauge_row);
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
// Line 1: bytes transferred on the left, speed + ETA right-aligned on the
|
||||||
if !done.is_empty() || !total.is_empty() {
|
// same row (they used to be pushed onto a third line that the panel height
|
||||||
lines.push(Line::from(vec![
|
// clipped, so they were usually invisible).
|
||||||
Span::raw(" "),
|
let mut left: Vec<Span> = vec![Span::raw(" ")];
|
||||||
Span::styled(
|
left.push(Span::styled(
|
||||||
if done.is_empty() { "…".to_string() } else { done.clone() },
|
if done.is_empty() { "…".to_string() } else { done.clone() },
|
||||||
Style::default().fg(theme::CYAN),
|
Style::default().fg(theme::CYAN),
|
||||||
),
|
));
|
||||||
Span::styled(" / ", Style::default().fg(theme::DIM)),
|
left.push(Span::styled(" / ", Style::default().fg(theme::DIM)));
|
||||||
Span::styled(
|
left.push(Span::styled(
|
||||||
if total.is_empty() { "…".to_string() } else { total },
|
if total.is_empty() { "…".to_string() } else { total },
|
||||||
Style::default().fg(theme::CYAN),
|
Style::default().fg(theme::CYAN),
|
||||||
),
|
));
|
||||||
]));
|
|
||||||
}
|
|
||||||
let mut right: Vec<Span> = Vec::new();
|
let mut right: Vec<Span> = Vec::new();
|
||||||
if !speed.is_empty() {
|
if !speed.is_empty() {
|
||||||
right.push(Span::styled(speed, Style::default().fg(theme::YELLOW)));
|
right.push(Span::styled(speed, Style::default().fg(theme::YELLOW)));
|
||||||
@@ -225,22 +224,23 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
|
|||||||
right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM)));
|
right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM)));
|
||||||
right.push(Span::styled(eta, Style::default().fg(theme::YELLOW)));
|
right.push(Span::styled(eta, Style::default().fg(theme::YELLOW)));
|
||||||
}
|
}
|
||||||
if !current.is_empty() {
|
|
||||||
lines.push(Line::from(vec![
|
let used: usize = left.iter().map(|s| s.width()).sum::<usize>()
|
||||||
Span::raw(" "),
|
+ right.iter().map(|s| s.width()).sum::<usize>();
|
||||||
Span::styled(
|
let pad = (inner.width as usize).saturating_sub(used + 2);
|
||||||
truncate(¤t, inner.width.saturating_sub(2) as usize),
|
left.push(Span::raw(" ".repeat(pad)));
|
||||||
Style::default().fg(theme::DIM),
|
left.extend(right);
|
||||||
),
|
|
||||||
]));
|
let mut lines = vec![Line::from(left)];
|
||||||
} else {
|
lines.push(Line::from(vec![
|
||||||
lines.push(Line::raw(""));
|
Span::raw(" "),
|
||||||
}
|
Span::styled(
|
||||||
let mut stats_line = lines;
|
truncate(¤t, inner.width.saturating_sub(2) as usize),
|
||||||
stats_line.push(Line::from(
|
Style::default().fg(theme::DIM),
|
||||||
std::iter::once(Span::raw(" "))
|
),
|
||||||
.chain(right.into_iter())
|
]));
|
||||||
.collect::<Vec<_>>(),
|
f.render_widget(
|
||||||
));
|
Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)),
|
||||||
f.render_widget(Paragraph::new(stats_line).style(Style::default().bg(theme::PANEL_BG)), stats);
|
stats,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user