mirror of
https://github.com/veeso/termscp.git
synced 2026-08-30 17:05:54 -07:00
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:
+3
-1
@@ -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 = [
|
||||
|
||||
+41
-12
@@ -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]
|
||||
|
||||
@@ -70,7 +70,8 @@ enum Id {
|
||||
MkdirPopup,
|
||||
NewfilePopup,
|
||||
OpenWithPopup,
|
||||
TransferProgressBar,
|
||||
TransferProgressBarFull,
|
||||
TransferProgressBarPartial,
|
||||
QuitPopup,
|
||||
RenamePopup,
|
||||
ReplacePopup,
|
||||
|
||||
@@ -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<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 {
|
||||
component: Gauge::default()
|
||||
.borders(Borders::default().modifiers(BorderType::Rounded))
|
||||
.borders(
|
||||
Borders::default()
|
||||
.modifiers(BorderType::Rounded)
|
||||
.sides(sides),
|
||||
)
|
||||
.foreground(color)
|
||||
.label(label)
|
||||
.progress(prog)
|
||||
|
||||
@@ -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,
|
||||
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()),
|
||||
)
|
||||
} 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 {
|
||||
/// 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();
|
||||
|
||||
@@ -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))),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
/// Send fs entry to remote.
|
||||
/// If dst_name is Some, entry will be saved with a different name.
|
||||
@@ -100,168 +172,209 @@ 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<String>,
|
||||
) -> 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)])
|
||||
}
|
||||
self.mount_progress_bar(format!("Uploading {}…", entry.path().display()));
|
||||
|
||||
/// 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();
|
||||
let result = self.filetransfer_send_recurse(entry, curr_remote_path, dst_name, true);
|
||||
self.umount_progress_bar();
|
||||
result
|
||||
last_redraw = Instant::now();
|
||||
}
|
||||
}
|
||||
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()));
|
||||
// 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();
|
||||
// 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
|
||||
match self.build_send_worklist(entries) {
|
||||
Ok(worklist) => worklist,
|
||||
Err(err) => {
|
||||
self.umount_progress_bar();
|
||||
result
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
} else {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(src, dst)| WorkItem::CopyFile {
|
||||
src: src.clone(),
|
||||
dst: dst.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
remote_path.push(remote_file_name);
|
||||
// Match entry
|
||||
let result: Result<(), String> = if entry.is_dir() {
|
||||
// Create directory on remote first
|
||||
// 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(remote_path.as_path(), true)
|
||||
.mkdir_ex(dst.as_path(), true)
|
||||
{
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created directory \"{}\"", remote_path.display()),
|
||||
format!("Created directory \"{}\"", dst.display()),
|
||||
);
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Failed to create directory \"{}\": {}",
|
||||
remote_path.display(),
|
||||
err
|
||||
),
|
||||
format!("Failed to create directory \"{}\": {err}", dst.display()),
|
||||
);
|
||||
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() {
|
||||
result = Err(err.to_string());
|
||||
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 {
|
||||
match self.filetransfer_send_one(entry, remote_path.as_path(), file_name) {
|
||||
Err(err) => {
|
||||
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(remote_path.as_path())
|
||||
{
|
||||
match self.browser.remote_pane_mut().fs.stat(dst.as_path()) {
|
||||
Err(err) => self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not remove created file {}: {}",
|
||||
remote_path.display(),
|
||||
dst.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
Ok(entry) => {
|
||||
if let Err(err) = self.browser.remote_pane_mut().fs.remove(&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(),
|
||||
dst.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
@@ -269,26 +382,21 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err.to_string())
|
||||
result = Err(err.to_string());
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
if track_progress {
|
||||
self.transfer.progress.increment();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
// Scan dir on remote
|
||||
// Counting is owned by `filetransfer_send_one` / `_with_stream`.
|
||||
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()),
|
||||
);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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<String>,
|
||||
) -> 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,167 +624,125 @@ 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()));
|
||||
// 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();
|
||||
// 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
|
||||
match self.build_recv_worklist(entries) {
|
||||
Ok(worklist) => worklist,
|
||||
Err(err) => {
|
||||
self.umount_progress_bar();
|
||||
result
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
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
|
||||
.mkdir_ex(host_bridge_dir_path.as_path(), true)
|
||||
.mkdir_ex(dst.as_path(), true)
|
||||
{
|
||||
Ok(_) => {
|
||||
// Apply file mode to directory
|
||||
// Apply remote dir mode/timestamps to the created local dir
|
||||
if let Err(err) = self
|
||||
.browser
|
||||
.local_pane_mut()
|
||||
.fs
|
||||
.setstat(host_bridge_dir_path.as_path(), entry.metadata())
|
||||
.setstat(dst.as_path(), src.metadata())
|
||||
{
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not set stat to directory {:?} to \"{}\": {}",
|
||||
entry.metadata(),
|
||||
host_bridge_dir_path.display(),
|
||||
src.metadata(),
|
||||
dst.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
}
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created directory \"{}\"", host_bridge_dir_path.display()),
|
||||
format!("Created directory \"{}\"", dst.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(())
|
||||
self.reload_host_bridge_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not scan directory \"{}\": {}",
|
||||
entry.path().display(),
|
||||
err
|
||||
),
|
||||
format!("Failed to create directory \"{}\": {err}", dst.display()),
|
||||
);
|
||||
Err(err.to_string())
|
||||
result = Err(err.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Failed to create directory \"{}\": {}",
|
||||
host_bridge_dir_path.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
} 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
|
||||
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
|
||||
match self
|
||||
.browser
|
||||
.local_pane_mut()
|
||||
.fs
|
||||
.stat(host_bridge_file_path.as_path())
|
||||
{
|
||||
// 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 {}: {}",
|
||||
host_bridge_file_path.display(),
|
||||
dst.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
Ok(entry) => {
|
||||
if let Err(err) = self.browser.local_pane_mut().fs.remove(&entry) {
|
||||
if let Err(err) =
|
||||
self.browser.local_pane_mut().fs.remove(&entry)
|
||||
{
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not remove created file {}: {}",
|
||||
host_bridge_file_path.display(),
|
||||
dst.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
@@ -694,24 +750,21 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err.to_string())
|
||||
} else {
|
||||
if track_progress {
|
||||
self.transfer.progress.increment();
|
||||
result = Err(err.to_string());
|
||||
break;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
// Reload directory on host_bridge
|
||||
// Counting is owned by `filetransfer_recv_one` / `_with_stream`.
|
||||
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()),
|
||||
);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
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::Percentage(15)).draw_in(f.area());
|
||||
Popup(Size::Percentage(50), Size::Unit(3)).draw_in(f.area());
|
||||
f.render_widget(Clear, popup);
|
||||
self.app.view(&Id::TransferProgressBar, f, 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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -651,25 +651,50 @@ impl AppComponent<Msg, NoUserEvent> 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<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> {
|
||||
self.component.on(ev)
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user