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.
This commit is contained in:
Christian Visintin
2026-06-08 14:46:07 +02:00
parent f066d6a387
commit 1dafc76850
26 changed files with 943 additions and 632 deletions
+3 -1
View File
@@ -67,7 +67,9 @@ remotefs = "0.3"
remotefs-aws-s3 = "0.4" remotefs-aws-s3 = "0.4"
remotefs-kube = "0.4" remotefs-kube = "0.4"
remotefs-smb = { version = "0.3", optional = true } 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" remotefs-webdav = "0.2"
rpassword = "7" rpassword = "7"
self_update = { version = "0.42", default-features = false, features = [ self_update = { version = "0.42", default-features = false, features = [
+41 -12
View File
@@ -56,7 +56,9 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
pub transfer_log_window: Color, pub transfer_log_window: Color,
#[serde(serialize_with = "serialize_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")] #[serde(serialize_with = "serialize_color")]
pub transfer_remote_explorer_background: Color, pub transfer_remote_explorer_background: Color,
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
@@ -93,7 +95,8 @@ impl Default for Theme {
transfer_local_explorer_highlighted: Color::Yellow, transfer_local_explorer_highlighted: Color::Yellow,
transfer_log_background: Color::Reset, transfer_log_background: Color::Reset,
transfer_log_window: Color::LightGreen, 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_background: Color::Reset,
transfer_remote_explorer_foreground: Color::Reset, transfer_remote_explorer_foreground: Color::Reset,
transfer_remote_explorer_highlighted: Color::LightBlue, transfer_remote_explorer_highlighted: Color::LightBlue,
@@ -182,11 +185,15 @@ impl ThemeFile {
defaults.transfer_log_background, defaults.transfer_log_background,
), ),
transfer_log_window: pick(self.transfer_log_window, defaults.transfer_log_window), transfer_log_window: pick(self.transfer_log_window, defaults.transfer_log_window),
transfer_progress_bar: pick( transfer_progress_bar_full: pick(
self.transfer_progress_bar self.transfer_progress_bar_full
.or(self.transfer_progress_bar_full) .or_else(|| self.transfer_progress_bar.clone()),
.or(self.transfer_progress_bar_partial), defaults.transfer_progress_bar_full,
defaults.transfer_progress_bar, ),
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( transfer_remote_explorer_background: pick(
self.transfer_remote_explorer_background, self.transfer_remote_explorer_background,
@@ -278,7 +285,8 @@ mod tests {
assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow); assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow);
assert_eq!(theme.transfer_log_background, Color::Reset); assert_eq!(theme.transfer_log_background, Color::Reset);
assert_eq!(theme.transfer_log_window, Color::LightGreen); 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_background, Color::Reset);
assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset); assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset);
assert_eq!(theme.transfer_remote_explorer_highlighted, Color::LightBlue); 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"); let theme: Theme = toml::from_str(toml).expect("theme should load");
assert_eq!(theme.auth_protocol, Color::Yellow); 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] #[test]
fn should_ignore_duplicated_legacy_progress_bar_fields() { fn should_distinguish_full_and_partial_progress_bar_fields() {
let toml = r#" let toml = r#"
auth_protocol = "Yellow" auth_protocol = "Yellow"
transfer_progress_bar_full = "Green" transfer_progress_bar_full = "Green"
@@ -307,8 +315,29 @@ mod tests {
"#; "#;
let theme: Theme = toml::from_str(toml).expect("theme should load"); let theme: Theme = toml::from_str(toml).expect("theme should load");
assert_eq!(theme.auth_protocol, Color::Yellow); assert_eq!(theme.auth_protocol, Color::Yellow);
// `_full` wins because `transfer_progress_bar` and `_full` are checked first. assert_eq!(theme.transfer_progress_bar_full, Color::Green);
assert_eq!(theme.transfer_progress_bar, 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] #[test]
+2 -1
View File
@@ -70,7 +70,8 @@ enum Id {
MkdirPopup, MkdirPopup,
NewfilePopup, NewfilePopup,
OpenWithPopup, OpenWithPopup,
TransferProgressBar, TransferProgressBarFull,
TransferProgressBarPartial,
QuitPopup, QuitPopup,
RenamePopup, RenamePopup,
ReplacePopup, ReplacePopup,
@@ -1,7 +1,7 @@
use tui_realm_stdlib::components::Gauge; use tui_realm_stdlib::components::Gauge;
use tuirealm::component::{AppComponent, Component}; use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers, NoUserEvent}; 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}; use crate::ui::activities::filetransfer::{Msg, TransferMsg};
@@ -11,10 +11,23 @@ pub struct TransferProgressBar {
} }
impl TransferProgressBar { impl TransferProgressBar {
pub fn new<S: Into<String>>(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<S: Into<String>>(
prog: f64,
label: S,
title: S,
color: Color,
sides: BorderSides,
) -> Self {
Self { Self {
component: Gauge::default() component: Gauge::default()
.borders(Borders::default().modifiers(BorderType::Rounded)) .borders(
Borders::default()
.modifiers(BorderType::Rounded)
.sides(sides),
)
.foreground(color) .foreground(color)
.label(label) .label(label)
.progress(prog) .progress(prog)
+128 -212
View File
@@ -7,31 +7,29 @@ use std::time::Instant;
use bytesize::ByteSize; 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). /// - Partial: the current file's byte progress (`cur_written / cur_size`).
/// For multi-file transfers, uses lazy accumulation: as each file starts, /// - Full: file-count weighted, `(files_completed + cur_fraction) / files_total`.
/// its size is added to `known_total_bytes`, and the remaining files' ///
/// total is estimated from the running average file size. /// No total-size estimation: `files_total` is exact (from the pre-scan).
pub struct TransferProgress { pub struct TransferProgress {
files_completed: usize,
files_total: usize, files_total: usize,
bytes_written: usize, files_completed: usize,
known_total_bytes: usize, cur_file_size: usize,
nonzero_files_started: usize, cur_file_written: usize,
files_started: usize, total_bytes_written: usize,
pub(crate) started: Instant, pub(crate) started: Instant,
} }
impl Default for TransferProgress { impl Default for TransferProgress {
fn default() -> Self { fn default() -> Self {
Self { Self {
files_completed: 0,
files_total: 0, files_total: 0,
bytes_written: 0, files_completed: 0,
known_total_bytes: 0, cur_file_size: 0,
nonzero_files_started: 0, cur_file_written: 0,
files_started: 0, total_bytes_written: 0,
started: Instant::now(), started: Instant::now(),
} }
} }
@@ -41,101 +39,83 @@ impl fmt::Display for TransferProgress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let eta = match self.calc_eta() { let eta = match self.calc_eta() {
0 => String::from("--:--"), 0 => String::from("--:--"),
seconds => format!( seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60),
"{:0width$}:{:0width$}",
seconds / 60,
seconds % 60,
width = 2
),
}; };
if self.is_single_file() { write!(
write!( f,
f, "{} / {} — {:.1}% — ETA {} ({}/s)",
"{} / {} — {:.1}% — ETA {} ({}/s)", ByteSize(self.cur_file_written as u64),
ByteSize(self.bytes_written as u64), ByteSize(self.cur_file_size as u64),
ByteSize(self.known_total_bytes as u64), self.calc_partial_progress() * 100.0,
self.calc_progress() * 100.0, eta,
eta, ByteSize(self.calc_bytes_per_second()),
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()),
)
}
} }
} }
impl TransferProgress { impl TransferProgress {
/// Initialize for a new transfer batch. /// Initialize for a new transfer batch with an exact file count.
pub fn init(&mut self, total_files: usize) { pub fn init(&mut self, files_total: usize) {
self.files_total = files_total;
self.files_completed = 0; self.files_completed = 0;
self.files_total = total_files; self.cur_file_size = 0;
self.bytes_written = 0; self.cur_file_written = 0;
self.known_total_bytes = 0; self.total_bytes_written = 0;
self.nonzero_files_started = 0;
self.files_started = 0;
self.started = Instant::now(); self.started = Instant::now();
} }
/// Update files_total without resetting byte accumulators. /// Begin a new file with a known size.
/// Used by recursive directory transfers that re-discover file counts. pub fn start_file(&mut self, size: usize) {
pub fn set_files_total(&mut self, total: usize) { self.cur_file_size = size;
self.files_total = total; self.cur_file_written = 0;
} }
/// Register a file that is about to be transferred. /// Add bytes written to the current file.
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.
pub fn add_bytes(&mut self, delta: usize) { 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. /// Mark the current file as fully transferred.
pub fn increment(&mut self) { ///
/// 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; self.files_completed += 1;
} }
/// Estimate the total transfer size in bytes. /// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0.
pub fn estimated_total(&self) -> usize { pub fn calc_partial_progress(&self) -> f64 {
if self.files_total <= 1 || self.files_started >= self.files_total { if self.cur_file_size == 0 {
return self.known_total_bytes; return 1.0;
} }
if self.nonzero_files_started == 0 { (self.cur_file_written as f64 / self.cur_file_size as f64).min(1.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
} }
/// Calculate progress as 0.0..=1.0. /// Overall progress (0.0..=1.0): file-weighted with intra-file interpolation.
pub fn calc_progress(&self) -> f64 { ///
let total = self.estimated_total(); /// The current file only contributes a fraction while it is genuinely in
if total == 0 { /// 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; 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 { pub fn is_single_file(&self) -> bool {
@@ -143,8 +123,8 @@ impl TransferProgress {
} }
#[cfg(test)] #[cfg(test)]
pub fn bytes_written(&self) -> usize { pub fn total_bytes_written(&self) -> usize {
self.bytes_written self.total_bytes_written
} }
#[cfg(test)] #[cfg(test)]
@@ -152,41 +132,32 @@ impl TransferProgress {
self.files_completed self.files_completed
} }
#[cfg(test)] /// Bytes per second over the whole transfer.
pub fn files_started(&self) -> usize {
self.files_started
}
/// Calculate bytes per second based on elapsed time.
pub fn calc_bytes_per_second(&self) -> u64 { pub fn calc_bytes_per_second(&self) -> u64 {
let elapsed_secs = self.started.elapsed().as_secs(); let elapsed_secs = self.started.elapsed().as_secs();
match elapsed_secs { match elapsed_secs {
0 => { 0 => self.total_bytes_written as u64,
if self.bytes_written > 0 && self.bytes_written >= self.estimated_total() { _ => self.total_bytes_written as u64 / elapsed_secs,
self.bytes_written as u64
} else {
0
}
}
_ => self.bytes_written as u64 / elapsed_secs,
} }
} }
/// Calculate ETA in seconds. /// ETA in seconds based on full progress.
pub fn calc_eta(&self) -> u64 { pub fn calc_eta(&self) -> u64 {
let elapsed_secs = self.started.elapsed().as_secs(); let elapsed_secs = self.started.elapsed().as_secs();
let percent = self.calc_progress() * 100.0; let percent = (self.calc_full_progress() * 100.0) as u64;
match percent as u64 { match percent {
0 => 0, 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)" /// File counter for the full-bar title: "(7/312)".
/// Uses `files_started` (not `files_completed`) so the display shows the
/// file currently being transferred, not the last one that finished.
pub fn file_count_display(&self) -> String { 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). /// Total bytes transferred (for notification threshold).
pub fn full_size(&self) -> usize { pub fn full_size(&self) -> usize {
self.progress.bytes_written self.progress.total_bytes_written
} }
} }
@@ -256,24 +227,27 @@ mod test {
#[test] #[test]
fn test_transfer_progress_single_file() { fn test_transfer_progress_single_file() {
let mut progress = TransferProgress::default(); 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()); assert!(progress.is_single_file());
progress.init(1); progress.init(1);
assert!(progress.is_single_file()); assert!(progress.is_single_file());
assert_eq!(progress.calc_progress(), 0.0); assert_eq!(progress.calc_full_progress(), 0.0);
progress.register_file(1024); progress.start_file(1024);
assert_eq!(progress.estimated_total(), 1024); assert_eq!(progress.calc_partial_progress(), 0.0);
assert_eq!(progress.calc_progress(), 0.0); assert_eq!(progress.calc_full_progress(), 0.0);
// Partial and full track together for a single file.
progress.add_bytes(256); progress.add_bytes(256);
assert_eq!(progress.bytes_written(), 256); assert!((progress.calc_partial_progress() - 0.25).abs() < 1e-9);
assert_eq!(progress.calc_progress(), 0.25); assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
progress.add_bytes(768); progress.add_bytes(768);
assert_eq!(progress.calc_progress(), 1.0); assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
progress.increment();
progress.finish_file();
assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9);
assert_eq!(progress.files_completed(), 1); assert_eq!(progress.files_completed(), 1);
} }
@@ -283,80 +257,52 @@ mod test {
progress.init(4); progress.init(4);
assert!(!progress.is_single_file()); assert!(!progress.is_single_file());
progress.register_file(1000); // File 1 fully transferred => full ≈ 0.25
assert_eq!(progress.estimated_total(), 4000); progress.start_file(1000);
progress.add_bytes(1000); progress.add_bytes(1000);
progress.increment(); progress.finish_file();
assert!((progress.calc_progress() - 0.25).abs() < 0.001); assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
progress.register_file(500);
assert_eq!(progress.estimated_total(), 3000);
// File 2 half transferred => partial ≈ 0.5, full ≈ 0.375
progress.start_file(1000);
progress.add_bytes(500); progress.add_bytes(500);
progress.increment(); assert!((progress.calc_partial_progress() - 0.5).abs() < 1e-9);
assert!((progress.calc_progress() - 0.5).abs() < 0.001); assert!((progress.calc_full_progress() - 0.375).abs() < 1e-9);
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);
} }
#[test] #[test]
fn test_transfer_progress_skipped_file() { 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(); let mut progress = TransferProgress::default();
progress.init(2); progress.init(2);
progress.register_file(500);
progress.add_bytes(500);
progress.increment();
progress.set_files_total(3); // One file actually transferred.
assert_eq!(progress.bytes_written(), 500); progress.start_file(100);
assert_eq!(progress.files_started(), 1); 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] #[test]
fn test_transfer_progress_timing() { fn test_transfer_progress_timing() {
let mut progress = TransferProgress::default(); let mut progress = TransferProgress::default();
progress.init(1); progress.init(1);
progress.register_file(1024); progress.start_file(1024);
progress.started = progress progress.started = progress
.started .started
@@ -364,42 +310,12 @@ mod test {
.unwrap(); .unwrap();
progress.add_bytes(256); progress.add_bytes(256);
// 256 bytes over 4 seconds => 64 bytes/s
assert_eq!(progress.calc_bytes_per_second(), 64); assert_eq!(progress.calc_bytes_per_second(), 64);
// 25% done after 4s => total 16s => 12s remaining
assert_eq!(progress.calc_eta(), 12); 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] #[test]
fn test_transfer_states() { fn test_transfer_states() {
let mut states = TransferStates::default(); let mut states = TransferStates::default();
+52 -14
View File
@@ -181,31 +181,69 @@ impl FileTransferActivity {
&mut self, &mut self,
filename: String, filename: String,
) { ) {
// Update the partial bar with the current file progress
ui_result(self.app.attr( ui_result(self.app.attr(
&Id::TransferProgressBar, &Id::TransferProgressBarPartial,
Attribute::Text, Attribute::Text,
AttrValue::String(self.transfer.progress.to_string()), AttrValue::String(self.transfer.progress.to_string()),
)); ));
ui_result(self.app.attr( ui_result(self.app.attr(
&Id::TransferProgressBar, &Id::TransferProgressBarPartial,
Attribute::Value, Attribute::Value,
AttrValue::Payload(PropPayload::Single(PropValue::F64( 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( ui_result(self.app.attr(
&Id::TransferProgressBar, &Id::TransferProgressBarPartial,
Attribute::Title, 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))),
)); ));
} }
+516 -333
View File
@@ -44,6 +44,78 @@ pub(in crate::ui::activities::filetransfer) enum TransferPayload {
TransferQueue(Vec<(File, PathBuf)>), 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<L, P>(
entries: &[(File, PathBuf)],
list_dir: &mut L,
on_progress: &mut P,
) -> Result<Vec<WorkItem>, String>
where
L: FnMut(&Path) -> Result<Vec<File>, String>,
P: FnMut(usize, usize) -> bool,
{
let mut out: Vec<WorkItem> = 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 { impl FileTransferActivity {
/// Send fs entry to remote. /// Send fs entry to remote.
/// If dst_name is Some, entry will be saved with a different name. /// 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()), None => PathBuf::from(file_name.as_str()),
}; };
remote_path.push(remote_file_name); 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); let result = self.filetransfer_send_one(file, remote_path.as_path(), file_name);
if result.is_ok() {
self.transfer.progress.increment();
}
// Umount progress bar // Umount progress bar
self.umount_progress_bar(); self.umount_progress_bar();
// Return result // Return result
result.map_err(|x| x.to_string()) 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( fn filetransfer_send_any(
&mut self, &mut self,
entry: &File, entry: &File,
curr_remote_path: &Path, curr_remote_path: &Path,
dst_name: Option<String>, dst_name: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
self.transfer.reset(); let mut dst = PathBuf::from(curr_remote_path);
self.transfer.progress.init(0); dst.push(dst_name.unwrap_or_else(|| entry.name()));
if !entry.is_dir() { self.filetransfer_send_transfer_queue(&[(entry.clone(), dst)])
self.transfer.progress.set_files_total(1); }
/// Build the upload work-queue by scanning the local selection.
fn build_send_worklist(
&mut self,
entries: &[(File, PathBuf)],
) -> Result<Vec<WorkItem>, 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<Vec<WorkItem>, 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<Vec<WorkItem>, String> {
let mut out: Vec<WorkItem> = 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())); Ok(out)
self.view();
let result = self.filetransfer_send_recurse(entry, curr_remote_path, dst_name, true);
self.umount_progress_bar();
result
} }
/// Send transfer queue entries to remote. /// 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( fn filetransfer_send_transfer_queue(
&mut self, &mut self,
entries: &[(File, PathBuf)], entries: &[(File, PathBuf)],
) -> Result<(), String> { ) -> Result<(), String> {
// Reset states // Reset states
self.transfer.reset(); self.transfer.reset();
// Total = number of queue entries // Zero the counters so that during the pre-scan `files_total == 0`, making
self.transfer.progress.init(entries.len()); // `is_single_file()` deterministically true. This forces the layout to render
// Mount progress bar // the single (partial) bar during the "Preparing/Scanning" phase, where the
self.mount_progress_bar(format!("Uploading {} entries…", entries.len())); // scan text is written. The real `init(total_files)` runs after the scan.
self.view(); self.transfer.progress.init(0);
// Send each entry // Build the work-queue: scan the tree only when directories are involved.
let mut result = Ok(()); let worklist = if selection_has_dirs(entries) {
for (entry, remote) in entries { self.mount_progress_bar(String::from("Preparing transfer…"));
if self.transfer.aborted() { self.view();
break; match self.build_send_worklist(entries) {
} Ok(worklist) => worklist,
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<String>,
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()),
);
}
Err(err) => { Err(err) => {
self.log_and_alert( self.umount_progress_bar();
LogLevel::Error, return Err(err);
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())
} }
} }
} else { } else {
match self.filetransfer_send_one(entry, remote_path.as_path(), file_name) { entries
Err(err) => { .iter()
// If transfer was abrupted or there was an IO error on remote, remove file .map(|(src, dst)| WorkItem::CopyFile {
if matches!( src: src.clone(),
err, dst: dst.clone(),
TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) })
) { .collect()
// Stat file on remote and remove it if exists };
match self // Total = number of files to copy
.browser let total_files = worklist
.remote_pane_mut() .iter()
.fs .filter(|item| matches!(item, WorkItem::CopyFile { .. }))
.stat(remote_path.as_path()) .count();
{ self.transfer.progress.init(total_files);
Err(err) => self.log( // 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, LogLevel::Error,
format!( format!("Failed to create directory \"{}\": {err}", dst.display()),
"Could not remove created file {}: {}", );
remote_path.display(), result = Err(err.to_string());
err 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) => {
Ok(entry) => { if let Err(err) =
if let Err(err) = self.browser.remote_pane_mut().fs.remove(&entry) { self.browser.remote_pane_mut().fs.remove(&entry)
self.log( {
LogLevel::Error, self.log(
format!( LogLevel::Error,
"Could not remove created file {}: {}", format!(
remote_path.display(), "Could not remove created file {}: {}",
err dst.display(),
), err
); ),
);
}
} }
} }
} }
result = Err(err.to_string());
break;
} }
Err(err.to_string()) // Counting is owned by `filetransfer_send_one` / `_with_stream`.
} self.reload_remote_dir();
Ok(_) => { // Redraw on the file boundary so the full bar's (N/total) counter
if track_progress { // advances after every completed file, including small files that
self.transfer.progress.increment(); // finish within a single in-loop redraw interval.
} self.update_progress_bar(format!("Uploaded \"{}\"", src.name()));
Ok(()) 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 result
} }
@@ -316,9 +424,7 @@ impl FileTransferActivity {
host_bridge.path().display() host_bridge.path().display()
), ),
); );
self.transfer self.transfer.progress.skip_file();
.progress
.register_skipped_file(metadata.size as usize);
return Ok(()); return Ok(());
} }
// Upload file // Upload file
@@ -358,7 +464,7 @@ impl FileTransferActivity {
.map_err(TransferErrorReason::HostError) .map_err(TransferErrorReason::HostError)
.map(|x| x.metadata().size as usize)?; .map(|x| x.metadata().size as usize)?;
// Init transfer // Init transfer
self.transfer.progress.register_file(file_size); self.transfer.progress.start_file(file_size);
let file_started = Instant::now(); let file_started = Instant::now();
// Write remote file // Write remote file
@@ -447,6 +553,8 @@ impl FileTransferActivity {
ByteSize(self.transfer.progress.calc_bytes_per_second()), 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(()) Ok(())
} }
@@ -489,16 +597,9 @@ impl FileTransferActivity {
host_path: &Path, host_path: &Path,
dst_name: Option<String>, dst_name: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
self.transfer.reset(); let mut dst = PathBuf::from(host_path);
self.transfer.progress.init(0); dst.push(dst_name.unwrap_or_else(|| entry.name()));
if !entry.is_dir() { self.filetransfer_recv_transfer_queue(&[(entry.clone(), dst)])
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
} }
/// Receive a single file from remote. /// Receive a single file from remote.
@@ -514,11 +615,8 @@ impl FileTransferActivity {
// Mount progress bar // Mount progress bar
self.mount_progress_bar(format!("Downloading {}", entry.path.display())); self.mount_progress_bar(format!("Downloading {}", entry.path.display()));
self.view(); 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()); let result = self.filetransfer_recv_one(host_bridge_path, entry, entry.name());
if result.is_ok() {
self.transfer.progress.increment();
}
// Umount progress bar // Umount progress bar
self.umount_progress_bar(); self.umount_progress_bar();
// Return result // Return result
@@ -526,192 +624,147 @@ impl FileTransferActivity {
} }
/// Receive transfer queue from remote. /// 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( fn filetransfer_recv_transfer_queue(
&mut self, &mut self,
entries: &[(File, PathBuf)], entries: &[(File, PathBuf)],
) -> Result<(), String> { ) -> Result<(), String> {
// Reset states // Reset states
self.transfer.reset(); self.transfer.reset();
// Total = number of queue entries // Zero the counters so that during the pre-scan `files_total == 0`, making
self.transfer.progress.init(entries.len()); // `is_single_file()` deterministically true. This forces the layout to render
// Mount progress bar // the single (partial) bar during the "Preparing/Scanning" phase, where the
self.mount_progress_bar(format!("Downloading {} entries…", entries.len())); // scan text is written. The real `init(total_files)` runs after the scan.
self.view(); self.transfer.progress.init(0);
// Receive each entry // Build the work-queue: scan the tree only when directories are involved.
let mut result = Ok(()); let worklist = if selection_has_dirs(entries) {
for (entry, path) in entries { self.mount_progress_bar(String::from("Preparing transfer…"));
if self.transfer.aborted() { self.view();
break; match self.build_recv_worklist(entries) {
} Ok(worklist) => worklist,
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<String>,
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())
}
}
}
Err(err) => { Err(err) => {
self.log( self.umount_progress_bar();
LogLevel::Error, return Err(err);
format!(
"Failed to create directory \"{}\": {}",
host_bridge_dir_path.display(),
err
),
);
Err(err.to_string())
} }
} }
} else { } else {
// Get host_bridge file entries
let mut host_bridge_file_path: PathBuf = PathBuf::from(host_bridge_path); .iter()
let host_bridge_file_name: String = match dst_name { .map(|(src, dst)| WorkItem::CopyFile {
Some(n) => n, src: src.clone(),
None => entry.name(), dst: dst.clone(),
}; })
host_bridge_file_path.push(host_bridge_file_name.as_str()); .collect()
// Download file };
if let Err(err) = // Total = number of files to copy
self.filetransfer_recv_one(host_bridge_file_path.as_path(), entry, file_name) let total_files = worklist
{ .iter()
// If transfer was abrupted or there was an IO error on remote, remove file .filter(|item| matches!(item, WorkItem::CopyFile { .. }))
if matches!( .count();
err, self.transfer.progress.init(total_files);
TransferErrorReason::Abrupted | TransferErrorReason::HostIoError(_) // Mount progress bar
) { self.mount_progress_bar(format!("Downloading {total_files} files…"));
// Stat file 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 match self
.browser .browser
.local_pane_mut() .local_pane_mut()
.fs .fs
.stat(host_bridge_file_path.as_path()) .mkdir_ex(dst.as_path(), true)
{ {
Err(err) => self.log( Ok(_) => {
LogLevel::Error, // Apply remote dir mode/timestamps to the created local dir
format!( if let Err(err) = self
"Could not remove created file {}: {}", .browser
host_bridge_file_path.display(), .local_pane_mut()
err .fs
), .setstat(dst.as_path(), src.metadata())
), {
Ok(entry) => {
if let Err(err) = self.browser.local_pane_mut().fs.remove(&entry) {
self.log( self.log(
LogLevel::Error, LogLevel::Error,
format!( format!(
"Could not remove created file {}: {}", "Could not set stat to directory {:?} to \"{}\": {}",
host_bridge_file_path.display(), src.metadata(),
dst.display(),
err 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()) WorkItem::CopyFile { src, dst } => {
} else { if let Err(err) = self.filetransfer_recv_one(dst.as_path(), src, src.name()) {
if track_progress { // If transfer was abrupted or there was an IO error on host, remove file
self.transfer.progress.increment(); 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 result
} }
@@ -731,9 +784,7 @@ impl FileTransferActivity {
remote.path().display() remote.path().display()
), ),
); );
self.transfer self.transfer.progress.skip_file();
.progress
.register_skipped_file(remote.metadata().size as usize);
return Ok(()); return Ok(());
} }
@@ -768,7 +819,7 @@ impl FileTransferActivity {
// Init transfer // Init transfer
self.transfer self.transfer
.progress .progress
.register_file(remote.metadata.size as usize); .start_file(remote.metadata.size as usize);
let file_started = Instant::now(); let file_started = Instant::now();
// Write host_bridge file // Write host_bridge file
let mut last_redraw: Instant = Instant::now(); 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(()) 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<Vec<File>, 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<Vec<File>, 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<Vec<File>, 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());
}
}
+27 -7
View File
@@ -177,7 +177,8 @@ impl FileTransferActivity {
Id::SaveAsPopup, Id::SaveAsPopup,
Id::SymlinkPopup, Id::SymlinkPopup,
Id::FileInfoPopup, Id::FileInfoPopup,
Id::TransferProgressBar, Id::TransferProgressBarPartial,
Id::TransferProgressBarFull,
Id::DeletePopup, Id::DeletePopup,
Id::ReplacePopup, Id::ReplacePopup,
Id::DisconnectPopup, Id::DisconnectPopup,
@@ -206,11 +207,29 @@ impl FileTransferActivity {
f.render_widget(Clear, popup); f.render_widget(Clear, popup);
self.app.view(popup_id, f, popup); self.app.view(popup_id, f, popup);
} }
Id::TransferProgressBar => { Id::TransferProgressBarPartial | Id::TransferProgressBarFull => {
let popup = // Each gauge occupies 3 rows. During the pre-scan phase only the single
Popup(Size::Percentage(50), Size::Percentage(15)).draw_in(f.area()); // (partial) bar shows (files_total is 0); both bars appear once the file
f.render_widget(Clear, popup); // count is known, so a multi-file transfer intentionally goes from one bar
self.app.view(&Id::TransferProgressBar, f, popup); // (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 // Wait popup with dynamic line count
Id::WaitPopup => { Id::WaitPopup => {
@@ -365,7 +384,8 @@ impl FileTransferActivity {
Id::MkdirPopup, Id::MkdirPopup,
Id::NewfilePopup, Id::NewfilePopup,
Id::OpenWithPopup, Id::OpenWithPopup,
Id::TransferProgressBar, Id::TransferProgressBarPartial,
Id::TransferProgressBarFull,
Id::ExplorerFind, Id::ExplorerFind,
Id::QuitPopup, Id::QuitPopup,
Id::RenamePopup, Id::RenamePopup,
+41 -6
View File
@@ -3,7 +3,7 @@
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall //! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use remotefs::fs::{File, UnixPex}; 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::explorer::FileSorting;
use crate::ui::activities::filetransfer::browser::FileExplorerTab; use crate::ui::activities::filetransfer::browser::FileExplorerTab;
@@ -111,6 +111,7 @@ impl FileTransferActivity {
pub(in crate::ui::activities::filetransfer) fn umount_wait(&mut self) { pub(in crate::ui::activities::filetransfer) fn umount_wait(&mut self) {
let _ = self.app.umount(&Id::WaitPopup); let _ = self.app.umount(&Id::WaitPopup);
self.redraw = true;
} }
/// Mount quit popup /// Mount quit popup
@@ -425,19 +426,53 @@ impl FileTransferActivity {
&mut self, &mut self,
root_name: String, 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( ui_result(self.app.remount(
Id::TransferProgressBar, Id::TransferProgressBarPartial,
Box::new(components::TransferProgressBar::new( Box::new(components::TransferProgressBar::new(
0.0, "", &root_name, prog_color, 0.0,
"",
root_name.as_str(),
partial_color,
partial_sides,
)), )),
vec![], 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) { 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) { pub(in crate::ui::activities::filetransfer) fn mount_file_sorting(&mut self) {
+6 -3
View File
@@ -104,7 +104,8 @@ pub enum IdTheme {
MiscSave, MiscSave,
MiscTitle, MiscTitle,
MiscWarn, MiscWarn,
ProgBar, ProgBarFull,
ProgBarPartial,
StatusHidden, StatusHidden,
StatusSorting, StatusSorting,
StatusSync, StatusSync,
@@ -225,8 +226,10 @@ pub enum ThemeMsg {
MiscSaveBlurUp, MiscSaveBlurUp,
MiscWarnBlurDown, MiscWarnBlurDown,
MiscWarnBlurUp, MiscWarnBlurUp,
ProgBarBlurDown, ProgBarFullBlurDown,
ProgBarBlurUp, ProgBarFullBlurUp,
ProgBarPartialBlurDown,
ProgBarPartialBlurUp,
StatusHiddenBlurDown, StatusHiddenBlurDown,
StatusHiddenBlurUp, StatusHiddenBlurUp,
StatusSortingBlurDown, StatusSortingBlurDown,
+13 -6
View File
@@ -249,8 +249,11 @@ impl SetupActivity {
IdTheme::LogWindow => { IdTheme::LogWindow => {
theme.transfer_log_window = color; theme.transfer_log_window = color;
} }
IdTheme::ProgBar => { IdTheme::ProgBarFull => {
theme.transfer_progress_bar = color; theme.transfer_progress_bar_full = color;
}
IdTheme::ProgBarPartial => {
theme.transfer_progress_bar_partial = color;
} }
IdTheme::StatusHidden => { IdTheme::StatusHidden => {
theme.transfer_status_hidden = color; theme.transfer_status_hidden = color;
@@ -337,9 +340,12 @@ impl SetupActivity {
let transfer_log_window = self let transfer_log_window = self
.get_color(&Id::Theme(IdTheme::LogWindow)) .get_color(&Id::Theme(IdTheme::LogWindow))
.map_err(|_| Id::Theme(IdTheme::LogWindow))?; .map_err(|_| Id::Theme(IdTheme::LogWindow))?;
let transfer_progress_bar = self let transfer_progress_bar_full = self
.get_color(&Id::Theme(IdTheme::ProgBar)) .get_color(&Id::Theme(IdTheme::ProgBarFull))
.map_err(|_| Id::Theme(IdTheme::ProgBar))?; .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 let transfer_status_hidden = self
.get_color(&Id::Theme(IdTheme::StatusHidden)) .get_color(&Id::Theme(IdTheme::StatusHidden))
.map_err(|_| 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_remote_explorer_highlighted = transfer_remote_explorer_highlighted;
theme.transfer_log_background = transfer_log_background; theme.transfer_log_background = transfer_log_background;
theme.transfer_log_window = transfer_log_window; 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_hidden = transfer_status_hidden;
theme.transfer_status_sorting = transfer_status_sorting; theme.transfer_status_sorting = transfer_status_sorting;
theme.transfer_status_sync_browsing = transfer_status_sync_browsing; theme.transfer_status_sync_browsing = transfer_status_sync_browsing;
+32 -7
View File
@@ -651,25 +651,50 @@ impl AppComponent<Msg, NoUserEvent> for MiscWarn {
} }
#[derive(Component)] #[derive(Component)]
pub struct ProgBar { pub struct ProgBarFull {
component: InputColor, component: InputColor,
} }
impl ProgBar { impl ProgBarFull {
pub fn new(color: Color) -> Self { pub fn new(color: Color) -> Self {
Self { Self {
component: InputColor::new( component: InputColor::new(
"Progress bar", "Progress bar (full)",
IdTheme::ProgBar, IdTheme::ProgBarFull,
color, color,
Msg::Theme(ThemeMsg::ProgBarBlurDown), Msg::Theme(ThemeMsg::ProgBarFullBlurDown),
Msg::Theme(ThemeMsg::ProgBarBlurUp), Msg::Theme(ThemeMsg::ProgBarFullBlurUp),
), ),
} }
} }
} }
impl AppComponent<Msg, NoUserEvent> for ProgBar { impl AppComponent<Msg, NoUserEvent> for ProgBarFull {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
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<Msg, NoUserEvent> for ProgBarPartial {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> { fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
self.component.on(ev) self.component.on(ev)
} }
+15 -5
View File
@@ -466,7 +466,7 @@ impl SetupActivity {
} }
} }
ThemeMsg::ExplorerRemoteHgBlurDown => { 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}"); error!("Failed to activate component: {err}");
} }
} }
@@ -475,13 +475,23 @@ impl SetupActivity {
error!("Failed to activate component: {err}"); 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)) { if let Err(err) = self.app.active(&Id::Theme(IdTheme::LogBg)) {
error!("Failed to activate component: {err}"); error!("Failed to activate component: {err}");
} }
} }
ThemeMsg::ProgBarBlurUp => { ThemeMsg::ProgBarPartialBlurUp => {
if let Err(err) = self.app.active(&Id::Theme(IdTheme::ExplorerRemoteHg)) { if let Err(err) = self.app.active(&Id::Theme(IdTheme::ProgBarFull)) {
error!("Failed to activate component: {err}"); error!("Failed to activate component: {err}");
} }
} }
@@ -491,7 +501,7 @@ impl SetupActivity {
} }
} }
ThemeMsg::LogBgBlurUp => { 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}"); error!("Failed to activate component: {err}");
} }
} }
+27 -10
View File
@@ -37,7 +37,7 @@ impl SetupActivity {
.constraints( .constraints(
[ [
Constraint::Length(3), // Current tab Constraint::Length(3), // Current tab
Constraint::Min(22), // Main body Constraint::Min(23), // Main body
Constraint::Length(1), // Help footer Constraint::Length(1), // Help footer
] ]
.as_ref(), .as_ref(),
@@ -185,7 +185,8 @@ impl SetupActivity {
.constraints( .constraints(
[ [
Constraint::Length(1), // Title 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 bg
Constraint::Length(3), // log window Constraint::Length(3), // log window
Constraint::Length(3), // status sorting Constraint::Length(3), // status sorting
@@ -202,35 +203,40 @@ impl SetupActivity {
transfer_colors_layout_col2[0], transfer_colors_layout_col2[0],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::ProgBar), &Id::Theme(IdTheme::ProgBarFull),
f, f,
transfer_colors_layout_col2[1], transfer_colors_layout_col2[1],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::LogBg), &Id::Theme(IdTheme::ProgBarPartial),
f, f,
transfer_colors_layout_col2[2], transfer_colors_layout_col2[2],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::LogWindow), &Id::Theme(IdTheme::LogBg),
f, f,
transfer_colors_layout_col2[3], transfer_colors_layout_col2[3],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::StatusSorting), &Id::Theme(IdTheme::LogWindow),
f, f,
transfer_colors_layout_col2[4], transfer_colors_layout_col2[4],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::StatusHidden), &Id::Theme(IdTheme::StatusSorting),
f, f,
transfer_colors_layout_col2[5], transfer_colors_layout_col2[5],
); );
self.app.view( self.app.view(
&Id::Theme(IdTheme::StatusSync), &Id::Theme(IdTheme::StatusHidden),
f, f,
transfer_colors_layout_col2[6], transfer_colors_layout_col2[6],
); );
self.app.view(
&Id::Theme(IdTheme::StatusSync),
f,
transfer_colors_layout_col2[7],
);
// Popups // Popups
self.view_popups(f); self.view_popups(f);
}); });
@@ -425,8 +431,19 @@ impl SetupActivity {
error!("Failed to remount component: {err}"); error!("Failed to remount component: {err}");
} }
if let Err(err) = self.app.remount( if let Err(err) = self.app.remount(
Id::Theme(IdTheme::ProgBar), Id::Theme(IdTheme::ProgBarFull),
Box::new(components::ProgBar::new(theme.transfer_progress_bar)), 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![], vec![],
) { ) {
error!("Failed to remount component: {err}"); error!("Failed to remount component: {err}");
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#c6d0f5"
transfer_local_explorer_highlighted = "#e5c890" transfer_local_explorer_highlighted = "#e5c890"
transfer_log_background = "#303446" transfer_log_background = "#303446"
transfer_log_window = "#e5c890" 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_background = "#303446"
transfer_remote_explorer_foreground = "#c6d0f5" transfer_remote_explorer_foreground = "#c6d0f5"
transfer_remote_explorer_highlighted = "#99d1db" transfer_remote_explorer_highlighted = "#99d1db"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#4c4f69"
transfer_local_explorer_highlighted = "#fe640b" transfer_local_explorer_highlighted = "#fe640b"
transfer_log_background = "#eff1f5" transfer_log_background = "#eff1f5"
transfer_log_window = "#179299" 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_background = "#eff1f5"
transfer_remote_explorer_foreground = "#4c4f69" transfer_remote_explorer_foreground = "#4c4f69"
transfer_remote_explorer_highlighted = "#1e66f5" transfer_remote_explorer_highlighted = "#1e66f5"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#cad3f5"
transfer_local_explorer_highlighted = "#f5a97f" transfer_local_explorer_highlighted = "#f5a97f"
transfer_log_background = "#cad3f5" transfer_log_background = "#cad3f5"
transfer_log_window = "#a6da95" 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_background = "#24273a"
transfer_remote_explorer_foreground = "#cad3f5" transfer_remote_explorer_foreground = "#cad3f5"
transfer_remote_explorer_highlighted = "#8aadf4" transfer_remote_explorer_highlighted = "#8aadf4"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "#cdd6f4"
transfer_local_explorer_highlighted = "#fab387" transfer_local_explorer_highlighted = "#fab387"
transfer_log_background = "#1e1e2e" transfer_log_background = "#1e1e2e"
transfer_log_window = "#a6e3a1" 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_background = "#1e1e2e"
transfer_remote_explorer_foreground = "#cdd6f4" transfer_remote_explorer_foreground = "#cdd6f4"
transfer_remote_explorer_highlighted = "#89b4fa" transfer_remote_explorer_highlighted = "#89b4fa"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "Yellow" transfer_local_explorer_highlighted = "Yellow"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "LightGreen" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "LightBlue" transfer_remote_explorer_highlighted = "LightBlue"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "aquamarine" transfer_local_explorer_highlighted = "aquamarine"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "#c43bff" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "greenyellow" transfer_remote_explorer_highlighted = "greenyellow"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "lightcoral"
transfer_local_explorer_highlighted = "coral" transfer_local_explorer_highlighted = "coral"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "royalblue" 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_background = "Default"
transfer_remote_explorer_foreground = "lightsalmon" transfer_remote_explorer_foreground = "lightsalmon"
transfer_remote_explorer_highlighted = "salmon" transfer_remote_explorer_highlighted = "salmon"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "#bbbbbb" transfer_local_explorer_highlighted = "#bbbbbb"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "black" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "#bbbbbb" transfer_remote_explorer_highlighted = "#bbbbbb"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "white" transfer_local_explorer_highlighted = "white"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "white" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "white" transfer_remote_explorer_highlighted = "white"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "pink"
transfer_local_explorer_highlighted = "hotpink" transfer_local_explorer_highlighted = "hotpink"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "palevioletred" 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_background = "Default"
transfer_remote_explorer_foreground = "plum" transfer_remote_explorer_foreground = "plum"
transfer_remote_explorer_highlighted = "violet" transfer_remote_explorer_highlighted = "violet"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "Yellow" transfer_local_explorer_highlighted = "Yellow"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "lawngreen" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "turquoise" transfer_remote_explorer_highlighted = "turquoise"
+2 -1
View File
@@ -17,7 +17,8 @@ transfer_local_explorer_foreground = "Default"
transfer_local_explorer_highlighted = "orange" transfer_local_explorer_highlighted = "orange"
transfer_log_background = "Default" transfer_log_background = "Default"
transfer_log_window = "limegreen" 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_background = "Default"
transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_foreground = "Default"
transfer_remote_explorer_highlighted = "turquoise" transfer_remote_explorer_highlighted = "turquoise"