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