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-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
View File
@@ -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]
+2 -1
View File
@@ -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)
+122 -206
View File
@@ -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();
+52 -14
View File
@@ -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))),
));
}
+440 -257
View File
@@ -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());
}
}
+25 -5
View File
@@ -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,
+41 -6
View File
@@ -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) {
+6 -3
View File
@@ -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,
+13 -6
View File
@@ -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;
+32 -7
View File
@@ -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)
}
+15 -5
View File
@@ -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}");
}
}
+27 -10
View File
@@ -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}");
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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
View File
@@ -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"
+2 -1
View File
@@ -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
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"