From 1dafc768508a407845fcecd5737230d309d7d4f0 Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Mon, 8 Jun 2026 14:46:07 +0200 Subject: [PATCH] fix(progress): rework transfer progress panel (#424) Migrate the transfer progress UI to tuirealm 4, where the stdlib `ProgressBar` widget was dropped, by rebuilding the dual-bar panel on top of `Gauge`. - Restore the unified two-bar look: the full bar (top) and partial bar (bottom) draw joined borders so they read as a single panel; a single file shows one fully-bordered bar. - Redraw on every file boundary in the send/recv queue loops so the full bar's (N/total) counter advances even for small files that finish within one in-loop redraw interval. - Track progress with a single `TransferProgress` (exact file count from the pre-scan, lazy partial/full computation) and consolidate the theme progress-bar fields. --- Cargo.toml | 4 +- src/config/themes.rs | 53 +- src/ui/activities/filetransfer.rs | 3 +- .../components/popups/progress_bar.rs | 19 +- .../activities/filetransfer/lib/transfer.rs | 340 +++---- .../activities/filetransfer/misc/filelist.rs | 66 +- .../filetransfer/session/transfer.rs | 849 +++++++++++------- src/ui/activities/filetransfer/view/layout.rs | 34 +- src/ui/activities/filetransfer/view/popups.rs | 47 +- src/ui/activities/setup.rs | 9 +- src/ui/activities/setup/actions.rs | 19 +- src/ui/activities/setup/components/theme.rs | 39 +- src/ui/activities/setup/update.rs | 20 +- src/ui/activities/setup/view/theme.rs | 37 +- themes/catppuccin-frappe.toml | 3 +- themes/catppuccin-latte.toml | 3 +- themes/catppuccin-macchiato.toml | 3 +- themes/catppuccin-moka.toml | 3 +- themes/default.toml | 3 +- themes/earth-wind-fire.toml | 3 +- themes/horizon.toml | 3 +- themes/mono-bright.toml | 3 +- themes/mono-dark.toml | 3 +- themes/sugarplum.toml | 3 +- themes/ubuntu.toml | 3 +- themes/veeso.toml | 3 +- 26 files changed, 943 insertions(+), 632 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ba8c177..2118e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,9 @@ remotefs = "0.3" remotefs-aws-s3 = "0.4" remotefs-kube = "0.4" remotefs-smb = { version = "0.3", optional = true } -remotefs-ssh = { version = "0.8", default-features = false, features = ["russh"] } +remotefs-ssh = { version = "0.8", default-features = false, features = [ + "russh", +] } remotefs-webdav = "0.2" rpassword = "7" self_update = { version = "0.42", default-features = false, features = [ diff --git a/src/config/themes.rs b/src/config/themes.rs index 1803731..f5f93c1 100644 --- a/src/config/themes.rs +++ b/src/config/themes.rs @@ -56,7 +56,9 @@ pub struct Theme { #[serde(serialize_with = "serialize_color")] pub transfer_log_window: Color, #[serde(serialize_with = "serialize_color")] - pub transfer_progress_bar: Color, + pub transfer_progress_bar_full: Color, + #[serde(serialize_with = "serialize_color")] + pub transfer_progress_bar_partial: Color, #[serde(serialize_with = "serialize_color")] pub transfer_remote_explorer_background: Color, #[serde(serialize_with = "serialize_color")] @@ -93,7 +95,8 @@ impl Default for Theme { transfer_local_explorer_highlighted: Color::Yellow, transfer_log_background: Color::Reset, transfer_log_window: Color::LightGreen, - transfer_progress_bar: Color::Green, + transfer_progress_bar_full: Color::Green, + transfer_progress_bar_partial: Color::Green, transfer_remote_explorer_background: Color::Reset, transfer_remote_explorer_foreground: Color::Reset, transfer_remote_explorer_highlighted: Color::LightBlue, @@ -182,11 +185,15 @@ impl ThemeFile { defaults.transfer_log_background, ), transfer_log_window: pick(self.transfer_log_window, defaults.transfer_log_window), - transfer_progress_bar: pick( - self.transfer_progress_bar - .or(self.transfer_progress_bar_full) - .or(self.transfer_progress_bar_partial), - defaults.transfer_progress_bar, + transfer_progress_bar_full: pick( + self.transfer_progress_bar_full + .or_else(|| self.transfer_progress_bar.clone()), + defaults.transfer_progress_bar_full, + ), + transfer_progress_bar_partial: pick( + self.transfer_progress_bar_partial + .or(self.transfer_progress_bar), + defaults.transfer_progress_bar_partial, ), transfer_remote_explorer_background: pick( self.transfer_remote_explorer_background, @@ -278,7 +285,8 @@ mod tests { assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow); assert_eq!(theme.transfer_log_background, Color::Reset); assert_eq!(theme.transfer_log_window, Color::LightGreen); - assert_eq!(theme.transfer_progress_bar, Color::Green); + assert_eq!(theme.transfer_progress_bar_full, Color::Green); + assert_eq!(theme.transfer_progress_bar_partial, Color::Green); assert_eq!(theme.transfer_remote_explorer_background, Color::Reset); assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset); assert_eq!(theme.transfer_remote_explorer_highlighted, Color::LightBlue); @@ -295,11 +303,11 @@ mod tests { "#; let theme: Theme = toml::from_str(toml).expect("theme should load"); assert_eq!(theme.auth_protocol, Color::Yellow); - assert_eq!(theme.transfer_progress_bar, Color::Green); + assert_eq!(theme.transfer_progress_bar_full, Color::Green); } #[test] - fn should_ignore_duplicated_legacy_progress_bar_fields() { + fn should_distinguish_full_and_partial_progress_bar_fields() { let toml = r#" auth_protocol = "Yellow" transfer_progress_bar_full = "Green" @@ -307,8 +315,29 @@ mod tests { "#; let theme: Theme = toml::from_str(toml).expect("theme should load"); assert_eq!(theme.auth_protocol, Color::Yellow); - // `_full` wins because `transfer_progress_bar` and `_full` are checked first. - assert_eq!(theme.transfer_progress_bar, Color::Green); + assert_eq!(theme.transfer_progress_bar_full, Color::Green); + assert_eq!(theme.transfer_progress_bar_partial, Color::Red); + } + + #[test] + fn should_fall_back_to_single_progress_bar_field() { + let toml = r#"transfer_progress_bar = "Red""#; + let theme: Theme = toml::from_str(toml).unwrap(); + assert_eq!(theme.transfer_progress_bar_full, Color::Red); + assert_eq!(theme.transfer_progress_bar_partial, Color::Red); + } + + #[test] + fn should_prefer_explicit_full_over_legacy_progress_bar() { + let toml = r#" +transfer_progress_bar = "Red" +transfer_progress_bar_full = "Green" +"#; + let theme: Theme = toml::from_str(toml).unwrap(); + // Explicit full field wins over the legacy single field + assert_eq!(theme.transfer_progress_bar_full, Color::Green); + // Partial has no explicit value, so it falls back to the legacy field + assert_eq!(theme.transfer_progress_bar_partial, Color::Red); } #[test] diff --git a/src/ui/activities/filetransfer.rs b/src/ui/activities/filetransfer.rs index 987c970..09ca485 100644 --- a/src/ui/activities/filetransfer.rs +++ b/src/ui/activities/filetransfer.rs @@ -70,7 +70,8 @@ enum Id { MkdirPopup, NewfilePopup, OpenWithPopup, - TransferProgressBar, + TransferProgressBarFull, + TransferProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, diff --git a/src/ui/activities/filetransfer/components/popups/progress_bar.rs b/src/ui/activities/filetransfer/components/popups/progress_bar.rs index 3359427..d49c88d 100644 --- a/src/ui/activities/filetransfer/components/popups/progress_bar.rs +++ b/src/ui/activities/filetransfer/components/popups/progress_bar.rs @@ -1,7 +1,7 @@ use tui_realm_stdlib::components::Gauge; use tuirealm::component::{AppComponent, Component}; use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers, NoUserEvent}; -use tuirealm::props::{BorderType, Borders, Color, HorizontalAlignment, Title}; +use tuirealm::props::{BorderSides, BorderType, Borders, Color, HorizontalAlignment, Title}; use crate::ui::activities::filetransfer::{Msg, TransferMsg}; @@ -11,10 +11,23 @@ pub struct TransferProgressBar { } impl TransferProgressBar { - pub fn new>(prog: f64, label: S, title: S, color: Color) -> Self { + /// Build a gauge. `sides` selects which borders to draw so two gauges can be + /// stacked into a single seamless panel (e.g. the upper bar omits its bottom + /// edge and the lower bar its top edge). + pub fn new>( + prog: f64, + label: S, + title: S, + color: Color, + sides: BorderSides, + ) -> Self { Self { component: Gauge::default() - .borders(Borders::default().modifiers(BorderType::Rounded)) + .borders( + Borders::default() + .modifiers(BorderType::Rounded) + .sides(sides), + ) .foreground(color) .label(label) .progress(prog) diff --git a/src/ui/activities/filetransfer/lib/transfer.rs b/src/ui/activities/filetransfer/lib/transfer.rs index 8af3238..31b47cb 100644 --- a/src/ui/activities/filetransfer/lib/transfer.rs +++ b/src/ui/activities/filetransfer/lib/transfer.rs @@ -7,31 +7,29 @@ use std::time::Instant; use bytesize::ByteSize; -/// Tracks overall transfer progress with byte-level estimation. +/// Tracks transfer progress with two exact levels. /// -/// For single-file transfers, progress is exact (known file size). -/// For multi-file transfers, uses lazy accumulation: as each file starts, -/// its size is added to `known_total_bytes`, and the remaining files' -/// total is estimated from the running average file size. +/// - Partial: the current file's byte progress (`cur_written / cur_size`). +/// - Full: file-count weighted, `(files_completed + cur_fraction) / files_total`. +/// +/// No total-size estimation: `files_total` is exact (from the pre-scan). pub struct TransferProgress { - files_completed: usize, files_total: usize, - bytes_written: usize, - known_total_bytes: usize, - nonzero_files_started: usize, - files_started: usize, + files_completed: usize, + cur_file_size: usize, + cur_file_written: usize, + total_bytes_written: usize, pub(crate) started: Instant, } impl Default for TransferProgress { fn default() -> Self { Self { - files_completed: 0, files_total: 0, - bytes_written: 0, - known_total_bytes: 0, - nonzero_files_started: 0, - files_started: 0, + files_completed: 0, + cur_file_size: 0, + cur_file_written: 0, + total_bytes_written: 0, started: Instant::now(), } } @@ -41,101 +39,83 @@ impl fmt::Display for TransferProgress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let eta = match self.calc_eta() { 0 => String::from("--:--"), - seconds => format!( - "{:0width$}:{:0width$}", - seconds / 60, - seconds % 60, - width = 2 - ), + seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60), }; - if self.is_single_file() { - write!( - f, - "{} / {} — {:.1}% — ETA {} ({}/s)", - ByteSize(self.bytes_written as u64), - ByteSize(self.known_total_bytes as u64), - self.calc_progress() * 100.0, - eta, - ByteSize(self.calc_bytes_per_second()), - ) - } else { - write!( - f, - "{} transferred — ~{:.1}% — ETA {} ({}/s)", - ByteSize(self.bytes_written as u64), - self.calc_progress() * 100.0, - eta, - ByteSize(self.calc_bytes_per_second()), - ) - } + write!( + f, + "{} / {} — {:.1}% — ETA {} ({}/s)", + ByteSize(self.cur_file_written as u64), + ByteSize(self.cur_file_size as u64), + self.calc_partial_progress() * 100.0, + eta, + ByteSize(self.calc_bytes_per_second()), + ) } } impl TransferProgress { - /// Initialize for a new transfer batch. - pub fn init(&mut self, total_files: usize) { + /// Initialize for a new transfer batch with an exact file count. + pub fn init(&mut self, files_total: usize) { + self.files_total = files_total; self.files_completed = 0; - self.files_total = total_files; - self.bytes_written = 0; - self.known_total_bytes = 0; - self.nonzero_files_started = 0; - self.files_started = 0; + self.cur_file_size = 0; + self.cur_file_written = 0; + self.total_bytes_written = 0; self.started = Instant::now(); } - /// Update files_total without resetting byte accumulators. - /// Used by recursive directory transfers that re-discover file counts. - pub fn set_files_total(&mut self, total: usize) { - self.files_total = total; + /// Begin a new file with a known size. + pub fn start_file(&mut self, size: usize) { + self.cur_file_size = size; + self.cur_file_written = 0; } - /// Register a file that is about to be transferred. - pub fn register_file(&mut self, size: usize) { - self.files_started += 1; - if size > 0 { - self.nonzero_files_started += 1; - self.known_total_bytes += size; - } - } - - /// Register a file that was skipped (unchanged). - /// Atomically registers, adds bytes, and increments completion. - pub fn register_skipped_file(&mut self, size: usize) { - self.register_file(size); - self.add_bytes(size); - self.increment(); - } - - /// Add transferred bytes. + /// Add bytes written to the current file. pub fn add_bytes(&mut self, delta: usize) { - self.bytes_written += delta; + self.cur_file_written += delta; + self.total_bytes_written += delta; } - /// Mark one file as completed. - pub fn increment(&mut self) { + /// Mark the current file as fully transferred. + /// + /// Clears the current-file byte counters so a finished file is only ever + /// counted via `files_completed` and never double-counted as an in-progress + /// fraction in [`Self::calc_full_progress`]. + pub fn finish_file(&mut self) { + self.files_completed += 1; + self.cur_file_size = 0; + self.cur_file_written = 0; + } + + /// Mark an unchanged file as done (counts toward the full bar, no bytes). + pub fn skip_file(&mut self) { self.files_completed += 1; } - /// Estimate the total transfer size in bytes. - pub fn estimated_total(&self) -> usize { - if self.files_total <= 1 || self.files_started >= self.files_total { - return self.known_total_bytes; + /// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0. + pub fn calc_partial_progress(&self) -> f64 { + if self.cur_file_size == 0 { + return 1.0; } - if self.nonzero_files_started == 0 { - return self.known_total_bytes; - } - let avg = self.known_total_bytes / self.nonzero_files_started; - let remaining = self.files_total - self.files_started; - self.known_total_bytes + avg * remaining + (self.cur_file_written as f64 / self.cur_file_size as f64).min(1.0) } - /// Calculate progress as 0.0..=1.0. - pub fn calc_progress(&self) -> f64 { - let total = self.estimated_total(); - if total == 0 { + /// Overall progress (0.0..=1.0): file-weighted with intra-file interpolation. + /// + /// The current file only contributes a fraction while it is genuinely in + /// progress (`cur_file_size > 0` and not all files completed). A finished + /// file clears `cur_file_size` (see [`Self::finish_file`]) so it is counted + /// exactly once via `files_completed`. + pub fn calc_full_progress(&self) -> f64 { + if self.files_total == 0 { return 0.0; } - (self.bytes_written as f64 / total as f64).min(1.0) + let cur_fraction = if self.cur_file_size == 0 || self.files_completed >= self.files_total { + 0.0 + } else { + self.calc_partial_progress() + }; + ((self.files_completed as f64 + cur_fraction) / self.files_total as f64).min(1.0) } pub fn is_single_file(&self) -> bool { @@ -143,8 +123,8 @@ impl TransferProgress { } #[cfg(test)] - pub fn bytes_written(&self) -> usize { - self.bytes_written + pub fn total_bytes_written(&self) -> usize { + self.total_bytes_written } #[cfg(test)] @@ -152,41 +132,32 @@ impl TransferProgress { self.files_completed } - #[cfg(test)] - pub fn files_started(&self) -> usize { - self.files_started - } - - /// Calculate bytes per second based on elapsed time. + /// Bytes per second over the whole transfer. pub fn calc_bytes_per_second(&self) -> u64 { let elapsed_secs = self.started.elapsed().as_secs(); match elapsed_secs { - 0 => { - if self.bytes_written > 0 && self.bytes_written >= self.estimated_total() { - self.bytes_written as u64 - } else { - 0 - } - } - _ => self.bytes_written as u64 / elapsed_secs, + 0 => self.total_bytes_written as u64, + _ => self.total_bytes_written as u64 / elapsed_secs, } } - /// Calculate ETA in seconds. + /// ETA in seconds based on full progress. pub fn calc_eta(&self) -> u64 { let elapsed_secs = self.started.elapsed().as_secs(); - let percent = self.calc_progress() * 100.0; - match percent as u64 { + let percent = (self.calc_full_progress() * 100.0) as u64; + match percent { 0 => 0, - p => ((elapsed_secs * 100) / p) - elapsed_secs, + p => ((elapsed_secs * 100) / p).saturating_sub(elapsed_secs), } } - /// Format the file count string for multi-file title: "(3/12)" - /// Uses `files_started` (not `files_completed`) so the display shows the - /// file currently being transferred, not the last one that finished. + /// File counter for the full-bar title: "(7/312)". pub fn file_count_display(&self) -> String { - format!("({}/{})", self.files_started, self.files_total) + format!( + "({}/{})", + self.files_completed.min(self.files_total), + self.files_total + ) } } @@ -224,7 +195,7 @@ impl TransferStates { /// Total bytes transferred (for notification threshold). pub fn full_size(&self) -> usize { - self.progress.bytes_written + self.progress.total_bytes_written } } @@ -256,24 +227,27 @@ mod test { #[test] fn test_transfer_progress_single_file() { let mut progress = TransferProgress::default(); - assert_eq!(progress.calc_progress(), 0.0); + assert_eq!(progress.calc_full_progress(), 0.0); assert!(progress.is_single_file()); progress.init(1); assert!(progress.is_single_file()); - assert_eq!(progress.calc_progress(), 0.0); + assert_eq!(progress.calc_full_progress(), 0.0); - progress.register_file(1024); - assert_eq!(progress.estimated_total(), 1024); - assert_eq!(progress.calc_progress(), 0.0); + progress.start_file(1024); + assert_eq!(progress.calc_partial_progress(), 0.0); + assert_eq!(progress.calc_full_progress(), 0.0); + // Partial and full track together for a single file. progress.add_bytes(256); - assert_eq!(progress.bytes_written(), 256); - assert_eq!(progress.calc_progress(), 0.25); + assert!((progress.calc_partial_progress() - 0.25).abs() < 1e-9); + assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9); progress.add_bytes(768); - assert_eq!(progress.calc_progress(), 1.0); - progress.increment(); + assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9); + + progress.finish_file(); + assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9); assert_eq!(progress.files_completed(), 1); } @@ -283,80 +257,52 @@ mod test { progress.init(4); assert!(!progress.is_single_file()); - progress.register_file(1000); - assert_eq!(progress.estimated_total(), 4000); - + // File 1 fully transferred => full ≈ 0.25 + progress.start_file(1000); progress.add_bytes(1000); - progress.increment(); - assert!((progress.calc_progress() - 0.25).abs() < 0.001); - - progress.register_file(500); - assert_eq!(progress.estimated_total(), 3000); + progress.finish_file(); + assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9); + // File 2 half transferred => partial ≈ 0.5, full ≈ 0.375 + progress.start_file(1000); progress.add_bytes(500); - progress.increment(); - assert!((progress.calc_progress() - 0.5).abs() < 0.001); - - progress.register_file(500); - assert_eq!(progress.estimated_total(), 2666); - - progress.add_bytes(500); - progress.increment(); - - progress.register_file(2000); - assert_eq!(progress.estimated_total(), 4000); - - progress.add_bytes(2000); - progress.increment(); - assert_eq!(progress.calc_progress(), 1.0); + assert!((progress.calc_partial_progress() - 0.5).abs() < 1e-9); + assert!((progress.calc_full_progress() - 0.375).abs() < 1e-9); } #[test] fn test_transfer_progress_skipped_file() { - let mut progress = TransferProgress::default(); - progress.init(3); - - progress.register_file(100); - progress.add_bytes(100); - progress.increment(); - - progress.register_skipped_file(200); - assert_eq!(progress.bytes_written(), 300); - assert_eq!(progress.files_completed(), 2); - assert_eq!(progress.files_started(), 2); - } - - #[test] - fn test_transfer_progress_zero_size_files() { - let mut progress = TransferProgress::default(); - progress.init(3); - - progress.register_file(0); - progress.add_bytes(0); - progress.increment(); - - progress.register_file(1000); - assert_eq!(progress.estimated_total(), 2000); - } - - #[test] - fn test_transfer_progress_set_files_total() { let mut progress = TransferProgress::default(); progress.init(2); - progress.register_file(500); - progress.add_bytes(500); - progress.increment(); - progress.set_files_total(3); - assert_eq!(progress.bytes_written(), 500); - assert_eq!(progress.files_started(), 1); + // One file actually transferred. + progress.start_file(100); + progress.add_bytes(100); + progress.finish_file(); + + // One file skipped (unchanged): counts toward completion, no bytes. + progress.skip_file(); + + assert_eq!(progress.files_completed(), 2); + assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9); + // Only the transferred file contributes bytes. + assert_eq!(progress.total_bytes_written(), 100); + } + + #[test] + fn test_transfer_progress_zero_size_file() { + let mut progress = TransferProgress::default(); + progress.init(1); + + progress.start_file(0); + assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9); } #[test] fn test_transfer_progress_timing() { let mut progress = TransferProgress::default(); progress.init(1); - progress.register_file(1024); + progress.start_file(1024); progress.started = progress .started @@ -364,42 +310,12 @@ mod test { .unwrap(); progress.add_bytes(256); + // 256 bytes over 4 seconds => 64 bytes/s assert_eq!(progress.calc_bytes_per_second(), 64); + // 25% done after 4s => total 16s => 12s remaining assert_eq!(progress.calc_eta(), 12); } - #[test] - fn test_transfer_progress_display_single() { - let mut progress = TransferProgress::default(); - progress.init(1); - progress.register_file(1024); - progress.started = progress - .started - .checked_sub(Duration::from_secs(4)) - .unwrap(); - progress.add_bytes(256); - - let display = progress.to_string(); - assert!(display.contains("/ 1.0 KiB")); - assert!(!display.contains('~')); - } - - #[test] - fn test_transfer_progress_display_multi() { - let mut progress = TransferProgress::default(); - progress.init(4); - progress.register_file(1024); - progress.started = progress - .started - .checked_sub(Duration::from_secs(4)) - .unwrap(); - progress.add_bytes(256); - - let display = progress.to_string(); - assert!(display.contains("transferred")); - assert!(display.contains('~')); - } - #[test] fn test_transfer_states() { let mut states = TransferStates::default(); diff --git a/src/ui/activities/filetransfer/misc/filelist.rs b/src/ui/activities/filetransfer/misc/filelist.rs index ee404d0..e12aa40 100644 --- a/src/ui/activities/filetransfer/misc/filelist.rs +++ b/src/ui/activities/filetransfer/misc/filelist.rs @@ -181,31 +181,69 @@ impl FileTransferActivity { &mut self, filename: String, ) { + // Update the partial bar with the current file progress ui_result(self.app.attr( - &Id::TransferProgressBar, + &Id::TransferProgressBarPartial, Attribute::Text, AttrValue::String(self.transfer.progress.to_string()), )); ui_result(self.app.attr( - &Id::TransferProgressBar, + &Id::TransferProgressBarPartial, Attribute::Value, AttrValue::Payload(PropPayload::Single(PropValue::F64( - self.transfer.progress.calc_progress(), + self.transfer.progress.calc_partial_progress(), ))), )); - let title = if self.transfer.progress.is_single_file() { - filename - } else { - format!( - "{} {}", - filename, - self.transfer.progress.file_count_display() - ) - }; ui_result(self.app.attr( - &Id::TransferProgressBar, + &Id::TransferProgressBarPartial, Attribute::Title, - AttrValue::Title(Title::from(title).alignment(HorizontalAlignment::Center)), + AttrValue::Title(Title::from(filename).alignment(HorizontalAlignment::Center)), + )); + // Update the full bar with the overall progress (only for multi-file transfers) + if !self.transfer.progress.is_single_file() { + ui_result(self.app.attr( + &Id::TransferProgressBarFull, + Attribute::Value, + AttrValue::Payload(PropPayload::Single(PropValue::F64( + self.transfer.progress.calc_full_progress(), + ))), + )); + ui_result( + self.app.attr( + &Id::TransferProgressBarFull, + Attribute::Title, + AttrValue::Title( + Title::from(format!( + "Total {}", + self.transfer.progress.file_count_display() + )) + .alignment(HorizontalAlignment::Center), + ), + ), + ); + } + } + + /// Update the progress bar to reflect the pre-transfer scan state. + /// + /// Shows how many directories and files have been discovered so far and keeps + /// the progress value at `0.0` since the total is not yet known. + pub(in crate::ui::activities::filetransfer) fn update_scan_progress( + &mut self, + dirs: usize, + files: usize, + ) { + // During the scan only the partial bar is rendered (the progress model + // reports `is_single_file()`), so write the scan text there. + ui_result(self.app.attr( + &Id::TransferProgressBarPartial, + Attribute::Text, + AttrValue::String(format!("Scanning… {dirs} dirs, {files} files")), + )); + ui_result(self.app.attr( + &Id::TransferProgressBarPartial, + Attribute::Value, + AttrValue::Payload(PropPayload::Single(PropValue::F64(0.0))), )); } diff --git a/src/ui/activities/filetransfer/session/transfer.rs b/src/ui/activities/filetransfer/session/transfer.rs index 09bc137..e60075e 100644 --- a/src/ui/activities/filetransfer/session/transfer.rs +++ b/src/ui/activities/filetransfer/session/transfer.rs @@ -44,6 +44,78 @@ pub(in crate::ui::activities::filetransfer) enum TransferPayload { TransferQueue(Vec<(File, PathBuf)>), } +/// A single planned transfer action produced by the pre-scan. +#[derive(Debug, PartialEq, Eq)] +pub(in crate::ui::activities::filetransfer) enum WorkItem { + /// Create a directory at the destination path. No progress weight. + /// `src` carries the source `File` so its metadata can be mirrored onto the + /// created directory (used by downloads to restore mode/timestamps). + Mkdir { src: File, dst: PathBuf }, + /// Copy one file from `src` to `dst`. Weighted as one file. + CopyFile { src: File, dst: PathBuf }, +} + +/// Recursively flatten `entries` into an ordered work-queue. +/// +/// `list_dir` lists the children of a directory `File`. `on_progress` is called +/// after each item is appended with `(dirs_seen, files_seen)` and returns `false` +/// to abort the walk early. +/// +/// Pure over its closures so it can be unit-tested without a real filesystem. +/// +/// The production scan lives in [`FileTransferActivity::scan_worklist`], which +/// inlines the same stack-walk to avoid a double `&mut self` borrow; this pure +/// version exists to validate the flattening algorithm in isolation. +#[cfg(test)] +fn flatten_worklist( + entries: &[(File, PathBuf)], + list_dir: &mut L, + on_progress: &mut P, +) -> Result, String> +where + L: FnMut(&Path) -> Result, String>, + P: FnMut(usize, usize) -> bool, +{ + let mut out: Vec = Vec::new(); + let mut dirs = 0usize; + let mut files = 0usize; + let mut stack: Vec<(File, PathBuf)> = entries.iter().rev().cloned().collect(); + while let Some((entry, dst)) = stack.pop() { + if entry.is_dir() { + out.push(WorkItem::Mkdir { + src: entry.clone(), + dst: dst.clone(), + }); + dirs += 1; + if !on_progress(dirs, files) { + return Err("aborted".to_string()); + } + let children = list_dir(entry.path())?; + for child in children.into_iter().rev() { + let mut child_dst = dst.clone(); + child_dst.push(child.name()); + stack.push((child, child_dst)); + } + } else { + out.push(WorkItem::CopyFile { + src: entry.clone(), + dst: dst.clone(), + }); + files += 1; + if !on_progress(dirs, files) { + return Err("aborted".to_string()); + } + } + } + Ok(out) +} + +/// Returns whether `entries` contains at least one directory, in which case a +/// pre-scan is required to build the work-queue. +fn selection_has_dirs(entries: &[(File, PathBuf)]) -> bool { + entries.iter().any(|(f, _)| f.is_dir()) +} + impl FileTransferActivity { /// Send fs entry to remote. /// If dst_name is Some, entry will be saved with a different name. @@ -100,195 +172,231 @@ impl FileTransferActivity { None => PathBuf::from(file_name.as_str()), }; remote_path.push(remote_file_name); - // Send + // Send (counting is owned by `filetransfer_send_one` / `_with_stream`) let result = self.filetransfer_send_one(file, remote_path.as_path(), file_name); - if result.is_ok() { - self.transfer.progress.increment(); - } // Umount progress bar self.umount_progress_bar(); // Return result result.map_err(|x| x.to_string()) } - /// Send a `TransferPayload` of type `Any`. + /// Send a `TransferPayload` of type `Any` by delegating to the work-queue. fn filetransfer_send_any( &mut self, entry: &File, curr_remote_path: &Path, dst_name: Option, ) -> Result<(), String> { - self.transfer.reset(); - self.transfer.progress.init(0); - if !entry.is_dir() { - self.transfer.progress.set_files_total(1); + let mut dst = PathBuf::from(curr_remote_path); + dst.push(dst_name.unwrap_or_else(|| entry.name())); + self.filetransfer_send_transfer_queue(&[(entry.clone(), dst)]) + } + + /// Build the upload work-queue by scanning the local selection. + fn build_send_worklist( + &mut self, + entries: &[(File, PathBuf)], + ) -> Result, String> { + self.scan_worklist(entries, false) + } + + /// Build the download work-queue by scanning the remote selection. + fn build_recv_worklist( + &mut self, + entries: &[(File, PathBuf)], + ) -> Result, String> { + self.scan_worklist(entries, true) + } + + /// Shared scan walk. `remote_side` selects which pane lists directories + /// (remote for downloads, local for uploads). + /// + /// The walk is abortable via [`crate::ui::activities::filetransfer::lib::TransferStates::aborted`] + /// and periodically redraws a "Scanning…" popup to keep the UI responsive. + fn scan_worklist( + &mut self, + entries: &[(File, PathBuf)], + remote_side: bool, + ) -> Result, String> { + let mut out: Vec = Vec::new(); + let mut dirs = 0usize; + let mut files = 0usize; + let mut last_redraw = Instant::now(); + let mut stack: Vec<(File, PathBuf)> = entries.iter().rev().cloned().collect(); + while let Some((entry, dst)) = stack.pop() { + if self.transfer.aborted() { + return Err("Scan aborted".to_string()); + } + if entry.is_dir() { + out.push(WorkItem::Mkdir { + src: entry.clone(), + dst: dst.clone(), + }); + dirs += 1; + let listed = if remote_side { + self.browser.remote_pane_mut().fs.list_dir(entry.path()) + } else { + self.browser.local_pane_mut().fs.list_dir(entry.path()) + }; + match listed { + Ok(children) => { + for child in children.into_iter().rev() { + let mut child_dst = dst.clone(); + child_dst.push(child.name()); + stack.push((child, child_dst)); + } + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Could not scan \"{}\": {err}", entry.path().display()), + ); + return Err(err.to_string()); + } + } + } else { + out.push(WorkItem::CopyFile { + src: entry.clone(), + dst: dst.clone(), + }); + files += 1; + } + // Redraw at most every 100ms to keep the UI responsive while scanning. + if last_redraw.elapsed().as_millis() >= 100 { + self.tick(); + self.update_scan_progress(dirs, files); + self.view(); + last_redraw = Instant::now(); + } } - self.mount_progress_bar(format!("Uploading {}…", entry.path().display())); - self.view(); - let result = self.filetransfer_send_recurse(entry, curr_remote_path, dst_name, true); - self.umount_progress_bar(); - result + Ok(out) } /// Send transfer queue entries to remote. + /// + /// When the selection contains directories the tree is scanned first to build + /// an ordered work-queue, giving an exact file count up front; otherwise the + /// flat file selection is mapped directly without scanning. fn filetransfer_send_transfer_queue( &mut self, entries: &[(File, PathBuf)], ) -> Result<(), String> { // Reset states self.transfer.reset(); - // Total = number of queue entries - self.transfer.progress.init(entries.len()); - // Mount progress bar - self.mount_progress_bar(format!("Uploading {} entries…", entries.len())); - self.view(); - // Send each entry - let mut result = Ok(()); - for (entry, remote) in entries { - if self.transfer.aborted() { - break; - } - let r = self.filetransfer_send_recurse(entry, remote, None, false); - if r.is_err() { - result = r; - break; - } - self.transfer.progress.increment(); - } - // Umount progress bar - self.umount_progress_bar(); - result - } - - fn filetransfer_send_recurse( - &mut self, - entry: &File, - curr_remote_path: &Path, - dst_name: Option, - track_progress: bool, - ) -> Result<(), String> { - // Write popup - let file_name = entry.name(); - // Get remote path - let mut remote_path: PathBuf = PathBuf::from(curr_remote_path); - let remote_file_name: PathBuf = match dst_name { - Some(s) => PathBuf::from(s.as_str()), - None => PathBuf::from(file_name.as_str()), - }; - remote_path.push(remote_file_name); - // Match entry - let result: Result<(), String> = if entry.is_dir() { - // Create directory on remote first - match self - .browser - .remote_pane_mut() - .fs - .mkdir_ex(remote_path.as_path(), true) - { - Ok(_) => { - self.log( - LogLevel::Info, - format!("Created directory \"{}\"", remote_path.display()), - ); - } + // Zero the counters so that during the pre-scan `files_total == 0`, making + // `is_single_file()` deterministically true. This forces the layout to render + // the single (partial) bar during the "Preparing/Scanning" phase, where the + // scan text is written. The real `init(total_files)` runs after the scan. + self.transfer.progress.init(0); + // Build the work-queue: scan the tree only when directories are involved. + let worklist = if selection_has_dirs(entries) { + self.mount_progress_bar(String::from("Preparing transfer…")); + self.view(); + match self.build_send_worklist(entries) { + Ok(worklist) => worklist, Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!( - "Failed to create directory \"{}\": {}", - remote_path.display(), - err - ), - ); - return Err(err.to_string()); - } - } - // Get files in dir - match self.browser.local_pane_mut().fs.list_dir(entry.path()) { - Ok(entries) => { - if track_progress { - self.transfer.progress.set_files_total(entries.len()); - } - for entry in entries.iter() { - if self.transfer.aborted() { - break; - } - self.filetransfer_send_recurse(entry, remote_path.as_path(), None, false)?; - if track_progress { - self.transfer.progress.increment(); - } - } - Ok(()) - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!( - "Could not scan directory \"{}\": {}", - entry.path().display(), - err - ), - ); - Err(err.to_string()) + self.umount_progress_bar(); + return Err(err); } } } else { - match self.filetransfer_send_one(entry, remote_path.as_path(), file_name) { - Err(err) => { - // If transfer was abrupted or there was an IO error on remote, remove file - if matches!( - err, - TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) - ) { - // Stat file on remote and remove it if exists - match self - .browser - .remote_pane_mut() - .fs - .stat(remote_path.as_path()) - { - Err(err) => self.log( + entries + .iter() + .map(|(src, dst)| WorkItem::CopyFile { + src: src.clone(), + dst: dst.clone(), + }) + .collect() + }; + // Total = number of files to copy + let total_files = worklist + .iter() + .filter(|item| matches!(item, WorkItem::CopyFile { .. })) + .count(); + self.transfer.progress.init(total_files); + // Mount progress bar + self.mount_progress_bar(format!("Uploading {total_files} files…")); + self.view(); + // Iterate the work-queue + let mut result = Ok(()); + for item in &worklist { + if self.transfer.aborted() { + self.log_and_alert(LogLevel::Warn, "Upload aborted!".to_string()); + break; + } + match item { + WorkItem::Mkdir { src: _, dst } => { + match self + .browser + .remote_pane_mut() + .fs + .mkdir_ex(dst.as_path(), true) + { + Ok(_) => { + self.log( + LogLevel::Info, + format!("Created directory \"{}\"", dst.display()), + ); + self.reload_remote_dir(); + } + Err(err) => { + self.log_and_alert( LogLevel::Error, - format!( - "Could not remove created file {}: {}", - remote_path.display(), - err + format!("Failed to create directory \"{}\": {err}", dst.display()), + ); + result = Err(err.to_string()); + break; + } + } + } + WorkItem::CopyFile { src, dst } => { + if let Err(err) = self.filetransfer_send_one(src, dst.as_path(), src.name()) { + // If transfer was abrupted or there was an IO error on remote, remove file + if matches!( + err, + TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) + ) { + // Stat file on remote and remove it if exists + match self.browser.remote_pane_mut().fs.stat(dst.as_path()) { + Err(err) => self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + dst.display(), + err + ), ), - ), - Ok(entry) => { - if let Err(err) = self.browser.remote_pane_mut().fs.remove(&entry) { - self.log( - LogLevel::Error, - format!( - "Could not remove created file {}: {}", - remote_path.display(), - err - ), - ); + Ok(entry) => { + if let Err(err) = + self.browser.remote_pane_mut().fs.remove(&entry) + { + self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + dst.display(), + err + ), + ); + } } } } + result = Err(err.to_string()); + break; } - Err(err.to_string()) - } - Ok(_) => { - if track_progress { - self.transfer.progress.increment(); - } - Ok(()) + // Counting is owned by `filetransfer_send_one` / `_with_stream`. + self.reload_remote_dir(); + // Redraw on the file boundary so the full bar's (N/total) counter + // advances after every completed file, including small files that + // finish within a single in-loop redraw interval. + self.update_progress_bar(format!("Uploaded \"{}\"", src.name())); + self.view(); } } - }; - // Scan dir on remote - self.reload_remote_dir(); - // If aborted; show popup - if self.transfer.aborted() { - // Log abort - self.log_and_alert( - LogLevel::Warn, - format!("Upload aborted for \"{}\"!", entry.path().display()), - ); } + // Umount progress bar + self.umount_progress_bar(); result } @@ -316,9 +424,7 @@ impl FileTransferActivity { host_bridge.path().display() ), ); - self.transfer - .progress - .register_skipped_file(metadata.size as usize); + self.transfer.progress.skip_file(); return Ok(()); } // Upload file @@ -358,7 +464,7 @@ impl FileTransferActivity { .map_err(TransferErrorReason::HostError) .map(|x| x.metadata().size as usize)?; // Init transfer - self.transfer.progress.register_file(file_size); + self.transfer.progress.start_file(file_size); let file_started = Instant::now(); // Write remote file @@ -447,6 +553,8 @@ impl FileTransferActivity { ByteSize(self.transfer.progress.calc_bytes_per_second()), ), ); + // Count this file as completed (owns the per-file count for the success path). + self.transfer.progress.finish_file(); Ok(()) } @@ -489,16 +597,9 @@ impl FileTransferActivity { host_path: &Path, dst_name: Option, ) -> Result<(), String> { - self.transfer.reset(); - self.transfer.progress.init(0); - if !entry.is_dir() { - self.transfer.progress.set_files_total(1); - } - self.mount_progress_bar(format!("Downloading {}…", entry.path().display())); - self.view(); - let result = self.filetransfer_recv_recurse(entry, host_path, dst_name, true); - self.umount_progress_bar(); - result + let mut dst = PathBuf::from(host_path); + dst.push(dst_name.unwrap_or_else(|| entry.name())); + self.filetransfer_recv_transfer_queue(&[(entry.clone(), dst)]) } /// Receive a single file from remote. @@ -514,11 +615,8 @@ impl FileTransferActivity { // Mount progress bar self.mount_progress_bar(format!("Downloading {}…", entry.path.display())); self.view(); - // Receive + // Receive (counting is owned by `filetransfer_recv_one` / `_with_stream`) let result = self.filetransfer_recv_one(host_bridge_path, entry, entry.name()); - if result.is_ok() { - self.transfer.progress.increment(); - } // Umount progress bar self.umount_progress_bar(); // Return result @@ -526,192 +624,147 @@ impl FileTransferActivity { } /// Receive transfer queue from remote. + /// + /// When the selection contains directories the tree is scanned first to build + /// an ordered work-queue, giving an exact file count up front; otherwise the + /// flat file selection is mapped directly without scanning. fn filetransfer_recv_transfer_queue( &mut self, entries: &[(File, PathBuf)], ) -> Result<(), String> { // Reset states self.transfer.reset(); - // Total = number of queue entries - self.transfer.progress.init(entries.len()); - // Mount progress bar - self.mount_progress_bar(format!("Downloading {} entries…", entries.len())); - self.view(); - // Receive each entry - let mut result = Ok(()); - for (entry, path) in entries { - if self.transfer.aborted() { - break; - } - let r = self.filetransfer_recv_recurse(entry, path, None, false); - if r.is_err() { - result = r; - break; - } - self.transfer.progress.increment(); - } - // Umount progress bar - self.umount_progress_bar(); - result - } - - fn filetransfer_recv_recurse( - &mut self, - entry: &File, - host_bridge_path: &Path, - dst_name: Option, - track_progress: bool, - ) -> Result<(), String> { - // Write popup - let file_name = entry.name(); - // Match entry - let result: Result<(), String> = if entry.is_dir() { - // Get dir name - let mut host_bridge_dir_path: PathBuf = PathBuf::from(host_bridge_path); - match dst_name { - Some(name) => host_bridge_dir_path.push(name), - None => host_bridge_dir_path.push(entry.name()), - } - // Create directory on host_bridge - match self - .browser - .local_pane_mut() - .fs - .mkdir_ex(host_bridge_dir_path.as_path(), true) - { - Ok(_) => { - // Apply file mode to directory - if let Err(err) = self - .browser - .local_pane_mut() - .fs - .setstat(host_bridge_dir_path.as_path(), entry.metadata()) - { - self.log( - LogLevel::Error, - format!( - "Could not set stat to directory {:?} to \"{}\": {}", - entry.metadata(), - host_bridge_dir_path.display(), - err - ), - ); - } - self.log( - LogLevel::Info, - format!("Created directory \"{}\"", host_bridge_dir_path.display()), - ); - // Get files in dir from remote - match self.browser.remote_pane_mut().fs.list_dir(entry.path()) { - Ok(entries) => { - if track_progress { - self.transfer.progress.set_files_total(entries.len()); - } - for entry in entries.iter() { - if self.transfer.aborted() { - break; - } - self.filetransfer_recv_recurse( - entry, - host_bridge_dir_path.as_path(), - None, - false, - )?; - if track_progress { - self.transfer.progress.increment(); - } - } - Ok(()) - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!( - "Could not scan directory \"{}\": {}", - entry.path().display(), - err - ), - ); - Err(err.to_string()) - } - } - } + // Zero the counters so that during the pre-scan `files_total == 0`, making + // `is_single_file()` deterministically true. This forces the layout to render + // the single (partial) bar during the "Preparing/Scanning" phase, where the + // scan text is written. The real `init(total_files)` runs after the scan. + self.transfer.progress.init(0); + // Build the work-queue: scan the tree only when directories are involved. + let worklist = if selection_has_dirs(entries) { + self.mount_progress_bar(String::from("Preparing transfer…")); + self.view(); + match self.build_recv_worklist(entries) { + Ok(worklist) => worklist, Err(err) => { - self.log( - LogLevel::Error, - format!( - "Failed to create directory \"{}\": {}", - host_bridge_dir_path.display(), - err - ), - ); - Err(err.to_string()) + self.umount_progress_bar(); + return Err(err); } } } else { - // Get host_bridge file - let mut host_bridge_file_path: PathBuf = PathBuf::from(host_bridge_path); - let host_bridge_file_name: String = match dst_name { - Some(n) => n, - None => entry.name(), - }; - host_bridge_file_path.push(host_bridge_file_name.as_str()); - // Download file - if let Err(err) = - self.filetransfer_recv_one(host_bridge_file_path.as_path(), entry, file_name) - { - // If transfer was abrupted or there was an IO error on remote, remove file - if matches!( - err, - TransferErrorReason::Abrupted | TransferErrorReason::HostIoError(_) - ) { - // Stat file + entries + .iter() + .map(|(src, dst)| WorkItem::CopyFile { + src: src.clone(), + dst: dst.clone(), + }) + .collect() + }; + // Total = number of files to copy + let total_files = worklist + .iter() + .filter(|item| matches!(item, WorkItem::CopyFile { .. })) + .count(); + self.transfer.progress.init(total_files); + // Mount progress bar + self.mount_progress_bar(format!("Downloading {total_files} files…")); + self.view(); + // Iterate the work-queue + let mut result = Ok(()); + for item in &worklist { + if self.transfer.aborted() { + self.log_and_alert(LogLevel::Warn, "Download aborted!".to_string()); + break; + } + match item { + WorkItem::Mkdir { src, dst } => { match self .browser .local_pane_mut() .fs - .stat(host_bridge_file_path.as_path()) + .mkdir_ex(dst.as_path(), true) { - Err(err) => self.log( - LogLevel::Error, - format!( - "Could not remove created file {}: {}", - host_bridge_file_path.display(), - err - ), - ), - Ok(entry) => { - if let Err(err) = self.browser.local_pane_mut().fs.remove(&entry) { + Ok(_) => { + // Apply remote dir mode/timestamps to the created local dir + if let Err(err) = self + .browser + .local_pane_mut() + .fs + .setstat(dst.as_path(), src.metadata()) + { self.log( LogLevel::Error, format!( - "Could not remove created file {}: {}", - host_bridge_file_path.display(), + "Could not set stat to directory {:?} to \"{}\": {}", + src.metadata(), + dst.display(), err ), ); } + self.log( + LogLevel::Info, + format!("Created directory \"{}\"", dst.display()), + ); + self.reload_host_bridge_dir(); + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Failed to create directory \"{}\": {err}", dst.display()), + ); + result = Err(err.to_string()); + break; } } } - Err(err.to_string()) - } else { - if track_progress { - self.transfer.progress.increment(); + WorkItem::CopyFile { src, dst } => { + if let Err(err) = self.filetransfer_recv_one(dst.as_path(), src, src.name()) { + // If transfer was abrupted or there was an IO error on host, remove file + if matches!( + err, + TransferErrorReason::Abrupted | TransferErrorReason::HostIoError(_) + ) { + // Stat file and remove it if exists + match self.browser.local_pane_mut().fs.stat(dst.as_path()) { + Err(err) => self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + dst.display(), + err + ), + ), + Ok(entry) => { + if let Err(err) = + self.browser.local_pane_mut().fs.remove(&entry) + { + self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + dst.display(), + err + ), + ); + } + } + } + } + result = Err(err.to_string()); + break; + } + // Counting is owned by `filetransfer_recv_one` / `_with_stream`. + self.reload_host_bridge_dir(); + // Redraw on the file boundary so the full bar's (N/total) counter + // advances after every completed file, including small files that + // finish within a single in-loop redraw interval. + self.update_progress_bar(format!("Downloaded \"{}\"", src.name())); + self.view(); } - Ok(()) } - }; - // Reload directory on host_bridge - self.reload_host_bridge_dir(); - // if aborted; show alert - if self.transfer.aborted() { - // Log abort - self.log_and_alert( - LogLevel::Warn, - format!("Download aborted for \"{}\"!", entry.path().display()), - ); } + // Umount progress bar + self.umount_progress_bar(); result } @@ -731,9 +784,7 @@ impl FileTransferActivity { remote.path().display() ), ); - self.transfer - .progress - .register_skipped_file(remote.metadata().size as usize); + self.transfer.progress.skip_file(); return Ok(()); } @@ -768,7 +819,7 @@ impl FileTransferActivity { // Init transfer self.transfer .progress - .register_file(remote.metadata.size as usize); + .start_file(remote.metadata.size as usize); let file_started = Instant::now(); // Write host_bridge file let mut last_redraw: Instant = Instant::now(); @@ -864,6 +915,138 @@ impl FileTransferActivity { ), ); + // Count this file as completed (owns the per-file count for the success path). + self.transfer.progress.finish_file(); Ok(()) } } + +#[cfg(test)] +mod worklist_test { + use std::time::SystemTime; + + use pretty_assertions::assert_eq; + use remotefs::fs::{FileType, Metadata, UnixPex}; + + use super::*; + + fn make_entry(path: &str, is_dir: bool) -> File { + // Use a fixed timestamp so two entries built for the same path compare + // equal (`File` derives `PartialEq` over its full metadata, timestamps + // included), allowing direct equality assertions against the worklist. + let t = SystemTime::UNIX_EPOCH; + let metadata = Metadata { + accessed: Some(t), + created: Some(t), + modified: Some(t), + file_type: if is_dir { + FileType::Directory + } else { + FileType::File + }, + symlink: None, + gid: Some(0), + uid: Some(0), + mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })), + size: 64, + }; + File { + path: PathBuf::from(path), + metadata, + } + } + + #[test] + fn should_flatten_nested_tree_in_order() { + // Tree: /a -> [f1, /b -> [f2]] + let dir_a = make_entry("/src/a", true); + let file_f1 = make_entry("/src/a/f1", false); + let dir_b = make_entry("/src/a/b", true); + let file_f2 = make_entry("/src/a/b/f2", false); + + let entries = vec![(dir_a, PathBuf::from("/dst/a"))]; + + let mut list_dir = |path: &Path| -> Result, String> { + match path.to_string_lossy().as_ref() { + "/src/a" => Ok(vec![file_f1.clone(), dir_b.clone()]), + "/src/a/b" => Ok(vec![file_f2.clone()]), + other => Err(format!("unexpected list_dir on {other}")), + } + }; + let mut on_progress = |_dirs: usize, _files: usize| true; + + let out = flatten_worklist(&entries, &mut list_dir, &mut on_progress).unwrap(); + + assert_eq!( + out, + vec![ + WorkItem::Mkdir { + src: make_entry("/src/a", true), + dst: PathBuf::from("/dst/a"), + }, + WorkItem::CopyFile { + src: make_entry("/src/a/f1", false), + dst: PathBuf::from("/dst/a/f1"), + }, + WorkItem::Mkdir { + src: make_entry("/src/a/b", true), + dst: PathBuf::from("/dst/a/b"), + }, + WorkItem::CopyFile { + src: make_entry("/src/a/b/f2", false), + dst: PathBuf::from("/dst/a/b/f2"), + }, + ] + ); + // Exactly two CopyFile items + let copy_files = out + .iter() + .filter(|item| matches!(item, WorkItem::CopyFile { .. })) + .count(); + assert_eq!(copy_files, 2); + } + + #[test] + fn should_flatten_flat_selection_without_listing_dirs() { + let entries = vec![ + (make_entry("/src/f1", false), PathBuf::from("/dst/f1")), + (make_entry("/src/f2", false), PathBuf::from("/dst/f2")), + ]; + + // `list_dir` must never be called for a flat file selection. + let mut list_dir = + |_path: &Path| -> Result, String> { panic!("list_dir must not be called") }; + let mut on_progress = |_dirs: usize, _files: usize| true; + + let out = flatten_worklist(&entries, &mut list_dir, &mut on_progress).unwrap(); + + assert_eq!( + out, + vec![ + WorkItem::CopyFile { + src: make_entry("/src/f1", false), + dst: PathBuf::from("/dst/f1"), + }, + WorkItem::CopyFile { + src: make_entry("/src/f2", false), + dst: PathBuf::from("/dst/f2"), + }, + ] + ); + assert!( + out.iter() + .all(|item| matches!(item, WorkItem::CopyFile { .. })) + ); + } + + #[test] + fn should_abort_when_on_progress_returns_false() { + let entries = vec![(make_entry("/src/f1", false), PathBuf::from("/dst/f1"))]; + + let mut list_dir = |_path: &Path| -> Result, String> { Ok(vec![]) }; + let mut on_progress = |_dirs: usize, _files: usize| false; + + let result = flatten_worklist(&entries, &mut list_dir, &mut on_progress); + assert!(result.is_err()); + } +} diff --git a/src/ui/activities/filetransfer/view/layout.rs b/src/ui/activities/filetransfer/view/layout.rs index d830517..dfb7831 100644 --- a/src/ui/activities/filetransfer/view/layout.rs +++ b/src/ui/activities/filetransfer/view/layout.rs @@ -177,7 +177,8 @@ impl FileTransferActivity { Id::SaveAsPopup, Id::SymlinkPopup, Id::FileInfoPopup, - Id::TransferProgressBar, + Id::TransferProgressBarPartial, + Id::TransferProgressBarFull, Id::DeletePopup, Id::ReplacePopup, Id::DisconnectPopup, @@ -206,11 +207,29 @@ impl FileTransferActivity { f.render_widget(Clear, popup); self.app.view(popup_id, f, popup); } - Id::TransferProgressBar => { - let popup = - Popup(Size::Percentage(50), Size::Percentage(15)).draw_in(f.area()); - f.render_widget(Clear, popup); - self.app.view(&Id::TransferProgressBar, f, popup); + Id::TransferProgressBarPartial | Id::TransferProgressBarFull => { + // Each gauge occupies 3 rows. During the pre-scan phase only the single + // (partial) bar shows (files_total is 0); both bars appear once the file + // count is known, so a multi-file transfer intentionally goes from one bar + // (scan) to two (transfer). For the two-bar case the full bar is drawn on + // top and the partial bar below; their borders are picked in + // `mount_progress_bar` so the seam between them joins into one panel. + if self.is_single_file_transfer() { + let popup = + Popup(Size::Percentage(50), Size::Unit(3)).draw_in(f.area()); + f.render_widget(Clear, popup); + self.app.view(&Id::TransferProgressBarPartial, f, popup); + } else { + let popup = + Popup(Size::Percentage(50), Size::Unit(6)).draw_in(f.area()); + f.render_widget(Clear, popup); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(3)]) + .split(popup); + self.app.view(&Id::TransferProgressBarFull, f, chunks[0]); + self.app.view(&Id::TransferProgressBarPartial, f, chunks[1]); + } } // Wait popup with dynamic line count Id::WaitPopup => { @@ -365,7 +384,8 @@ impl FileTransferActivity { Id::MkdirPopup, Id::NewfilePopup, Id::OpenWithPopup, - Id::TransferProgressBar, + Id::TransferProgressBarPartial, + Id::TransferProgressBarFull, Id::ExplorerFind, Id::QuitPopup, Id::RenamePopup, diff --git a/src/ui/activities/filetransfer/view/popups.rs b/src/ui/activities/filetransfer/view/popups.rs index 55ea243..fb019ef 100644 --- a/src/ui/activities/filetransfer/view/popups.rs +++ b/src/ui/activities/filetransfer/view/popups.rs @@ -3,7 +3,7 @@ //! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall use remotefs::fs::{File, UnixPex}; -use tuirealm::props::{AttrValue, Attribute, PropPayload, PropValue, SpanStatic}; +use tuirealm::props::{AttrValue, Attribute, BorderSides, PropPayload, PropValue, SpanStatic}; use crate::explorer::FileSorting; use crate::ui::activities::filetransfer::browser::FileExplorerTab; @@ -111,6 +111,7 @@ impl FileTransferActivity { pub(in crate::ui::activities::filetransfer) fn umount_wait(&mut self) { let _ = self.app.umount(&Id::WaitPopup); + self.redraw = true; } /// Mount quit popup @@ -425,19 +426,53 @@ impl FileTransferActivity { &mut self, root_name: String, ) { - let prog_color = self.theme().transfer_progress_bar; + let partial_color = self.theme().transfer_progress_bar_partial; + let full_color = self.theme().transfer_progress_bar_full; + // Pick borders so the two gauges read as one panel. For a multi-file + // transfer the full bar (drawn on top) omits its bottom edge and the + // partial bar (below) omits its top edge, joining seamlessly. A single + // file shows only the partial bar, which therefore keeps all four sides. + let (full_sides, partial_sides) = if self.is_single_file_transfer() { + (BorderSides::ALL, BorderSides::ALL) + } else { + ( + BorderSides::TOP | BorderSides::LEFT | BorderSides::RIGHT, + BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT, + ) + }; ui_result(self.app.remount( - Id::TransferProgressBar, + Id::TransferProgressBarPartial, Box::new(components::TransferProgressBar::new( - 0.0, "", &root_name, prog_color, + 0.0, + "", + root_name.as_str(), + partial_color, + partial_sides, )), vec![], )); - ui_result(self.app.active(&Id::TransferProgressBar)); + ui_result(self.app.remount( + Id::TransferProgressBarFull, + Box::new(components::TransferProgressBar::new( + 0.0, "", "", full_color, full_sides, + )), + vec![], + )); + // Give focus to the partial bar so Ctrl+C abort is handled + ui_result(self.app.active(&Id::TransferProgressBarPartial)); } pub(in crate::ui::activities::filetransfer) fn umount_progress_bar(&mut self) { - let _ = self.app.umount(&Id::TransferProgressBar); + let _ = self.app.umount(&Id::TransferProgressBarPartial); + let _ = self.app.umount(&Id::TransferProgressBarFull); + } + + /// Returns whether the ongoing transfer involves a single file. + /// + /// Used to decide whether to render only the partial progress bar (single file) + /// or both the partial and full progress bars (multi-file transfers). + pub(in crate::ui::activities::filetransfer) fn is_single_file_transfer(&self) -> bool { + self.transfer.progress.is_single_file() } pub(in crate::ui::activities::filetransfer) fn mount_file_sorting(&mut self) { diff --git a/src/ui/activities/setup.rs b/src/ui/activities/setup.rs index c87e40b..14718a1 100644 --- a/src/ui/activities/setup.rs +++ b/src/ui/activities/setup.rs @@ -104,7 +104,8 @@ pub enum IdTheme { MiscSave, MiscTitle, MiscWarn, - ProgBar, + ProgBarFull, + ProgBarPartial, StatusHidden, StatusSorting, StatusSync, @@ -225,8 +226,10 @@ pub enum ThemeMsg { MiscSaveBlurUp, MiscWarnBlurDown, MiscWarnBlurUp, - ProgBarBlurDown, - ProgBarBlurUp, + ProgBarFullBlurDown, + ProgBarFullBlurUp, + ProgBarPartialBlurDown, + ProgBarPartialBlurUp, StatusHiddenBlurDown, StatusHiddenBlurUp, StatusSortingBlurDown, diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 9647364..1d6dd0c 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -249,8 +249,11 @@ impl SetupActivity { IdTheme::LogWindow => { theme.transfer_log_window = color; } - IdTheme::ProgBar => { - theme.transfer_progress_bar = color; + IdTheme::ProgBarFull => { + theme.transfer_progress_bar_full = color; + } + IdTheme::ProgBarPartial => { + theme.transfer_progress_bar_partial = color; } IdTheme::StatusHidden => { theme.transfer_status_hidden = color; @@ -337,9 +340,12 @@ impl SetupActivity { let transfer_log_window = self .get_color(&Id::Theme(IdTheme::LogWindow)) .map_err(|_| Id::Theme(IdTheme::LogWindow))?; - let transfer_progress_bar = self - .get_color(&Id::Theme(IdTheme::ProgBar)) - .map_err(|_| Id::Theme(IdTheme::ProgBar))?; + let transfer_progress_bar_full = self + .get_color(&Id::Theme(IdTheme::ProgBarFull)) + .map_err(|_| Id::Theme(IdTheme::ProgBarFull))?; + let transfer_progress_bar_partial = self + .get_color(&Id::Theme(IdTheme::ProgBarPartial)) + .map_err(|_| Id::Theme(IdTheme::ProgBarPartial))?; let transfer_status_hidden = self .get_color(&Id::Theme(IdTheme::StatusHidden)) .map_err(|_| Id::Theme(IdTheme::StatusHidden))?; @@ -373,7 +379,8 @@ impl SetupActivity { theme.transfer_remote_explorer_highlighted = transfer_remote_explorer_highlighted; theme.transfer_log_background = transfer_log_background; theme.transfer_log_window = transfer_log_window; - theme.transfer_progress_bar = transfer_progress_bar; + theme.transfer_progress_bar_full = transfer_progress_bar_full; + theme.transfer_progress_bar_partial = transfer_progress_bar_partial; theme.transfer_status_hidden = transfer_status_hidden; theme.transfer_status_sorting = transfer_status_sorting; theme.transfer_status_sync_browsing = transfer_status_sync_browsing; diff --git a/src/ui/activities/setup/components/theme.rs b/src/ui/activities/setup/components/theme.rs index ef98669..aae5d09 100644 --- a/src/ui/activities/setup/components/theme.rs +++ b/src/ui/activities/setup/components/theme.rs @@ -651,25 +651,50 @@ impl AppComponent for MiscWarn { } #[derive(Component)] -pub struct ProgBar { +pub struct ProgBarFull { component: InputColor, } -impl ProgBar { +impl ProgBarFull { pub fn new(color: Color) -> Self { Self { component: InputColor::new( - "Progress bar", - IdTheme::ProgBar, + "Progress bar (full)", + IdTheme::ProgBarFull, color, - Msg::Theme(ThemeMsg::ProgBarBlurDown), - Msg::Theme(ThemeMsg::ProgBarBlurUp), + Msg::Theme(ThemeMsg::ProgBarFullBlurDown), + Msg::Theme(ThemeMsg::ProgBarFullBlurUp), ), } } } -impl AppComponent for ProgBar { +impl AppComponent for ProgBarFull { + fn on(&mut self, ev: &Event) -> Option { + self.component.on(ev) + } +} + +#[derive(Component)] +pub struct ProgBarPartial { + component: InputColor, +} + +impl ProgBarPartial { + pub fn new(color: Color) -> Self { + Self { + component: InputColor::new( + "Progress bar (partial)", + IdTheme::ProgBarPartial, + color, + Msg::Theme(ThemeMsg::ProgBarPartialBlurDown), + Msg::Theme(ThemeMsg::ProgBarPartialBlurUp), + ), + } + } +} + +impl AppComponent for ProgBarPartial { fn on(&mut self, ev: &Event) -> Option { self.component.on(ev) } diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 6ba2301..7fb3cdd 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -466,7 +466,7 @@ impl SetupActivity { } } ThemeMsg::ExplorerRemoteHgBlurDown => { - if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBar)) { + if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBarFull)) { error!("Failed to activate component: {err}"); } } @@ -475,13 +475,23 @@ impl SetupActivity { error!("Failed to activate component: {err}"); } } - ThemeMsg::ProgBarBlurDown => { + ThemeMsg::ProgBarFullBlurDown => { + if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBarPartial)) { + error!("Failed to activate component: {err}"); + } + } + ThemeMsg::ProgBarFullBlurUp => { + if let Err(err) = self.app.active(&Id::Theme(IdTheme::ExplorerRemoteHg)) { + error!("Failed to activate component: {err}"); + } + } + ThemeMsg::ProgBarPartialBlurDown => { if let Err(err) = self.app.active(&Id::Theme(IdTheme::LogBg)) { error!("Failed to activate component: {err}"); } } - ThemeMsg::ProgBarBlurUp => { - if let Err(err) = self.app.active(&Id::Theme(IdTheme::ExplorerRemoteHg)) { + ThemeMsg::ProgBarPartialBlurUp => { + if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBarFull)) { error!("Failed to activate component: {err}"); } } @@ -491,7 +501,7 @@ impl SetupActivity { } } ThemeMsg::LogBgBlurUp => { - if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBar)) { + if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBarPartial)) { error!("Failed to activate component: {err}"); } } diff --git a/src/ui/activities/setup/view/theme.rs b/src/ui/activities/setup/view/theme.rs index 8f49cfd..087586b 100644 --- a/src/ui/activities/setup/view/theme.rs +++ b/src/ui/activities/setup/view/theme.rs @@ -37,7 +37,7 @@ impl SetupActivity { .constraints( [ Constraint::Length(3), // Current tab - Constraint::Min(22), // Main body + Constraint::Min(23), // Main body Constraint::Length(1), // Help footer ] .as_ref(), @@ -185,7 +185,8 @@ impl SetupActivity { .constraints( [ Constraint::Length(1), // Title - Constraint::Length(3), // prog bar + Constraint::Length(3), // prog bar full + Constraint::Length(3), // prog bar partial Constraint::Length(3), // log bg Constraint::Length(3), // log window Constraint::Length(3), // status sorting @@ -202,35 +203,40 @@ impl SetupActivity { transfer_colors_layout_col2[0], ); self.app.view( - &Id::Theme(IdTheme::ProgBar), + &Id::Theme(IdTheme::ProgBarFull), f, transfer_colors_layout_col2[1], ); self.app.view( - &Id::Theme(IdTheme::LogBg), + &Id::Theme(IdTheme::ProgBarPartial), f, transfer_colors_layout_col2[2], ); self.app.view( - &Id::Theme(IdTheme::LogWindow), + &Id::Theme(IdTheme::LogBg), f, transfer_colors_layout_col2[3], ); self.app.view( - &Id::Theme(IdTheme::StatusSorting), + &Id::Theme(IdTheme::LogWindow), f, transfer_colors_layout_col2[4], ); self.app.view( - &Id::Theme(IdTheme::StatusHidden), + &Id::Theme(IdTheme::StatusSorting), f, transfer_colors_layout_col2[5], ); self.app.view( - &Id::Theme(IdTheme::StatusSync), + &Id::Theme(IdTheme::StatusHidden), f, transfer_colors_layout_col2[6], ); + self.app.view( + &Id::Theme(IdTheme::StatusSync), + f, + transfer_colors_layout_col2[7], + ); // Popups self.view_popups(f); }); @@ -425,8 +431,19 @@ impl SetupActivity { error!("Failed to remount component: {err}"); } if let Err(err) = self.app.remount( - Id::Theme(IdTheme::ProgBar), - Box::new(components::ProgBar::new(theme.transfer_progress_bar)), + Id::Theme(IdTheme::ProgBarFull), + Box::new(components::ProgBarFull::new( + theme.transfer_progress_bar_full, + )), + vec![], + ) { + error!("Failed to remount component: {err}"); + } + if let Err(err) = self.app.remount( + Id::Theme(IdTheme::ProgBarPartial), + Box::new(components::ProgBarPartial::new( + theme.transfer_progress_bar_partial, + )), vec![], ) { error!("Failed to remount component: {err}"); diff --git a/themes/catppuccin-frappe.toml b/themes/catppuccin-frappe.toml index 1830282..d02222d 100644 --- a/themes/catppuccin-frappe.toml +++ b/themes/catppuccin-frappe.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#c6d0f5" transfer_local_explorer_highlighted = "#e5c890" transfer_log_background = "#303446" transfer_log_window = "#e5c890" -transfer_progress_bar = "##a6d189" +transfer_progress_bar_full = "##a6d189" +transfer_progress_bar_partial = "##a6d189" transfer_remote_explorer_background = "#303446" transfer_remote_explorer_foreground = "#c6d0f5" transfer_remote_explorer_highlighted = "#99d1db" diff --git a/themes/catppuccin-latte.toml b/themes/catppuccin-latte.toml index c5aa684..3fa3b10 100644 --- a/themes/catppuccin-latte.toml +++ b/themes/catppuccin-latte.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#4c4f69" transfer_local_explorer_highlighted = "#fe640b" transfer_log_background = "#eff1f5" transfer_log_window = "#179299" -transfer_progress_bar = "#40a02b" +transfer_progress_bar_full = "#40a02b" +transfer_progress_bar_partial = "#40a02b" transfer_remote_explorer_background = "#eff1f5" transfer_remote_explorer_foreground = "#4c4f69" transfer_remote_explorer_highlighted = "#1e66f5" diff --git a/themes/catppuccin-macchiato.toml b/themes/catppuccin-macchiato.toml index 32ab4a8..6f347de 100644 --- a/themes/catppuccin-macchiato.toml +++ b/themes/catppuccin-macchiato.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#cad3f5" transfer_local_explorer_highlighted = "#f5a97f" transfer_log_background = "#cad3f5" transfer_log_window = "#a6da95" -transfer_progress_bar = "#a6da95" +transfer_progress_bar_full = "#a6da95" +transfer_progress_bar_partial = "#a6da95" transfer_remote_explorer_background = "#24273a" transfer_remote_explorer_foreground = "#cad3f5" transfer_remote_explorer_highlighted = "#8aadf4" diff --git a/themes/catppuccin-moka.toml b/themes/catppuccin-moka.toml index 1d554b0..3eb8685 100644 --- a/themes/catppuccin-moka.toml +++ b/themes/catppuccin-moka.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#cdd6f4" transfer_local_explorer_highlighted = "#fab387" transfer_log_background = "#1e1e2e" transfer_log_window = "#a6e3a1" -transfer_progress_bar = "#a6e3a1" +transfer_progress_bar_full = "#a6e3a1" +transfer_progress_bar_partial = "#a6e3a1" transfer_remote_explorer_background = "#1e1e2e" transfer_remote_explorer_foreground = "#cdd6f4" transfer_remote_explorer_highlighted = "#89b4fa" diff --git a/themes/default.toml b/themes/default.toml index 6895e9e..9f54d04 100644 --- a/themes/default.toml +++ b/themes/default.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "Yellow" transfer_log_background = "Default" transfer_log_window = "LightGreen" -transfer_progress_bar = "Green" +transfer_progress_bar_full = "Green" +transfer_progress_bar_partial = "Green" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "LightBlue" diff --git a/themes/earth-wind-fire.toml b/themes/earth-wind-fire.toml index ee2b868..c31d16a 100644 --- a/themes/earth-wind-fire.toml +++ b/themes/earth-wind-fire.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "aquamarine" transfer_log_background = "Default" transfer_log_window = "#c43bff" -transfer_progress_bar = "deeppink" +transfer_progress_bar_full = "deeppink" +transfer_progress_bar_partial = "turquoise" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "greenyellow" diff --git a/themes/horizon.toml b/themes/horizon.toml index 33485f9..97bfbfd 100644 --- a/themes/horizon.toml +++ b/themes/horizon.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "lightcoral" transfer_local_explorer_highlighted = "coral" transfer_log_background = "Default" transfer_log_window = "royalblue" -transfer_progress_bar = "hotpink" +transfer_progress_bar_full = "hotpink" +transfer_progress_bar_partial = "deeppink" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "lightsalmon" transfer_remote_explorer_highlighted = "salmon" diff --git a/themes/mono-bright.toml b/themes/mono-bright.toml index be11100..9b8d8d7 100644 --- a/themes/mono-bright.toml +++ b/themes/mono-bright.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "#bbbbbb" transfer_log_background = "Default" transfer_log_window = "black" -transfer_progress_bar = "black" +transfer_progress_bar_full = "black" +transfer_progress_bar_partial = "black" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "#bbbbbb" diff --git a/themes/mono-dark.toml b/themes/mono-dark.toml index e1f8dd9..15b93f4 100644 --- a/themes/mono-dark.toml +++ b/themes/mono-dark.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "white" transfer_log_background = "Default" transfer_log_window = "white" -transfer_progress_bar = "white" +transfer_progress_bar_full = "white" +transfer_progress_bar_partial = "white" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "white" diff --git a/themes/sugarplum.toml b/themes/sugarplum.toml index ad5b0cc..0ecb617 100644 --- a/themes/sugarplum.toml +++ b/themes/sugarplum.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "pink" transfer_local_explorer_highlighted = "hotpink" transfer_log_background = "Default" transfer_log_window = "palevioletred" -transfer_progress_bar = "hotpink" +transfer_progress_bar_full = "hotpink" +transfer_progress_bar_partial = "deeppink" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "plum" transfer_remote_explorer_highlighted = "violet" diff --git a/themes/ubuntu.toml b/themes/ubuntu.toml index 1d7e9a5..0fb50fd 100644 --- a/themes/ubuntu.toml +++ b/themes/ubuntu.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "Yellow" transfer_log_background = "Default" transfer_log_window = "lawngreen" -transfer_progress_bar = "lawngreen" +transfer_progress_bar_full = "lawngreen" +transfer_progress_bar_partial = "lawngreen" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "turquoise" diff --git a/themes/veeso.toml b/themes/veeso.toml index a8c2cf1..c393382 100644 --- a/themes/veeso.toml +++ b/themes/veeso.toml @@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "orange" transfer_log_background = "Default" transfer_log_window = "limegreen" -transfer_progress_bar = "lawngreen" +transfer_progress_bar_full = "lawngreen" +transfer_progress_bar_partial = "limegreen" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "turquoise"