Files
termscp/src/ui/activities/filetransfer/lib/transfer.rs
T
Christian Visintin 1dafc76850 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.
2026-06-08 16:02:27 +02:00

337 lines
9.8 KiB
Rust

//! ## FileTransferActivity
//!
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use std::fmt;
use std::time::Instant;
use bytesize::ByteSize;
/// Tracks transfer progress with two exact levels.
///
/// - Partial: the current file's byte progress (`cur_written / cur_size`).
/// - Full: file-count weighted, `(files_completed + cur_fraction) / files_total`.
///
/// No total-size estimation: `files_total` is exact (from the pre-scan).
pub struct TransferProgress {
files_total: usize,
files_completed: usize,
cur_file_size: usize,
cur_file_written: usize,
total_bytes_written: usize,
pub(crate) started: Instant,
}
impl Default for TransferProgress {
fn default() -> Self {
Self {
files_total: 0,
files_completed: 0,
cur_file_size: 0,
cur_file_written: 0,
total_bytes_written: 0,
started: Instant::now(),
}
}
}
impl fmt::Display for TransferProgress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let eta = match self.calc_eta() {
0 => String::from("--:--"),
seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60),
};
write!(
f,
"{} / {} — {:.1}% — ETA {} ({}/s)",
ByteSize(self.cur_file_written as u64),
ByteSize(self.cur_file_size as u64),
self.calc_partial_progress() * 100.0,
eta,
ByteSize(self.calc_bytes_per_second()),
)
}
}
impl TransferProgress {
/// Initialize for a new transfer batch with an exact file count.
pub fn init(&mut self, files_total: usize) {
self.files_total = files_total;
self.files_completed = 0;
self.cur_file_size = 0;
self.cur_file_written = 0;
self.total_bytes_written = 0;
self.started = Instant::now();
}
/// Begin a new file with a known size.
pub fn start_file(&mut self, size: usize) {
self.cur_file_size = size;
self.cur_file_written = 0;
}
/// Add bytes written to the current file.
pub fn add_bytes(&mut self, delta: usize) {
self.cur_file_written += delta;
self.total_bytes_written += delta;
}
/// Mark the current file as fully transferred.
///
/// Clears the current-file byte counters so a finished file is only ever
/// counted via `files_completed` and never double-counted as an in-progress
/// fraction in [`Self::calc_full_progress`].
pub fn finish_file(&mut self) {
self.files_completed += 1;
self.cur_file_size = 0;
self.cur_file_written = 0;
}
/// Mark an unchanged file as done (counts toward the full bar, no bytes).
pub fn skip_file(&mut self) {
self.files_completed += 1;
}
/// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0.
pub fn calc_partial_progress(&self) -> f64 {
if self.cur_file_size == 0 {
return 1.0;
}
(self.cur_file_written as f64 / self.cur_file_size as f64).min(1.0)
}
/// Overall progress (0.0..=1.0): file-weighted with intra-file interpolation.
///
/// The current file only contributes a fraction while it is genuinely in
/// progress (`cur_file_size > 0` and not all files completed). A finished
/// file clears `cur_file_size` (see [`Self::finish_file`]) so it is counted
/// exactly once via `files_completed`.
pub fn calc_full_progress(&self) -> f64 {
if self.files_total == 0 {
return 0.0;
}
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 {
self.files_total <= 1
}
#[cfg(test)]
pub fn total_bytes_written(&self) -> usize {
self.total_bytes_written
}
#[cfg(test)]
pub fn files_completed(&self) -> usize {
self.files_completed
}
/// Bytes per second over the whole transfer.
pub fn calc_bytes_per_second(&self) -> u64 {
let elapsed_secs = self.started.elapsed().as_secs();
match elapsed_secs {
0 => self.total_bytes_written as u64,
_ => self.total_bytes_written as u64 / elapsed_secs,
}
}
/// ETA in seconds based on full progress.
pub fn calc_eta(&self) -> u64 {
let elapsed_secs = self.started.elapsed().as_secs();
let percent = (self.calc_full_progress() * 100.0) as u64;
match percent {
0 => 0,
p => ((elapsed_secs * 100) / p).saturating_sub(elapsed_secs),
}
}
/// File counter for the full-bar title: "(7/312)".
pub fn file_count_display(&self) -> String {
format!(
"({}/{})",
self.files_completed.min(self.files_total),
self.files_total
)
}
}
/// Contains the states related to the transfer process.
pub struct TransferStates {
aborted: bool,
pub progress: TransferProgress,
}
impl Default for TransferStates {
fn default() -> Self {
Self::new()
}
}
impl TransferStates {
pub fn new() -> Self {
Self {
aborted: false,
progress: TransferProgress::default(),
}
}
pub fn reset(&mut self) {
self.aborted = false;
}
pub fn abort(&mut self) {
self.aborted = true;
}
pub fn aborted(&self) -> bool {
self.aborted
}
/// Total bytes transferred (for notification threshold).
pub fn full_size(&self) -> usize {
self.progress.total_bytes_written
}
}
// -- Options
/// Defines the transfer options for transfer actions
#[derive(Default)]
pub struct TransferOpts {
/// Save file as
pub save_as: Option<String>,
}
impl TransferOpts {
/// Define the name of the file to be saved
pub fn save_as<S: AsRef<str>>(mut self, n: Option<S>) -> Self {
self.save_as = n.map(|x| x.as_ref().to_string());
self
}
}
#[cfg(test)]
mod test {
use std::time::Duration;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_transfer_progress_single_file() {
let mut progress = TransferProgress::default();
assert_eq!(progress.calc_full_progress(), 0.0);
assert!(progress.is_single_file());
progress.init(1);
assert!(progress.is_single_file());
assert_eq!(progress.calc_full_progress(), 0.0);
progress.start_file(1024);
assert_eq!(progress.calc_partial_progress(), 0.0);
assert_eq!(progress.calc_full_progress(), 0.0);
// Partial and full track together for a single file.
progress.add_bytes(256);
assert!((progress.calc_partial_progress() - 0.25).abs() < 1e-9);
assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
progress.add_bytes(768);
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
progress.finish_file();
assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9);
assert_eq!(progress.files_completed(), 1);
}
#[test]
fn test_transfer_progress_multi_file() {
let mut progress = TransferProgress::default();
progress.init(4);
assert!(!progress.is_single_file());
// File 1 fully transferred => full ≈ 0.25
progress.start_file(1000);
progress.add_bytes(1000);
progress.finish_file();
assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
// File 2 half transferred => partial ≈ 0.5, full ≈ 0.375
progress.start_file(1000);
progress.add_bytes(500);
assert!((progress.calc_partial_progress() - 0.5).abs() < 1e-9);
assert!((progress.calc_full_progress() - 0.375).abs() < 1e-9);
}
#[test]
fn test_transfer_progress_skipped_file() {
let mut progress = TransferProgress::default();
progress.init(2);
// One file actually transferred.
progress.start_file(100);
progress.add_bytes(100);
progress.finish_file();
// One file skipped (unchanged): counts toward completion, no bytes.
progress.skip_file();
assert_eq!(progress.files_completed(), 2);
assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9);
// Only the transferred file contributes bytes.
assert_eq!(progress.total_bytes_written(), 100);
}
#[test]
fn test_transfer_progress_zero_size_file() {
let mut progress = TransferProgress::default();
progress.init(1);
progress.start_file(0);
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
}
#[test]
fn test_transfer_progress_timing() {
let mut progress = TransferProgress::default();
progress.init(1);
progress.start_file(1024);
progress.started = progress
.started
.checked_sub(Duration::from_secs(4))
.unwrap();
progress.add_bytes(256);
// 256 bytes over 4 seconds => 64 bytes/s
assert_eq!(progress.calc_bytes_per_second(), 64);
// 25% done after 4s => total 16s => 12s remaining
assert_eq!(progress.calc_eta(), 12);
}
#[test]
fn test_transfer_states() {
let mut states = TransferStates::default();
assert!(!states.aborted());
states.abort();
assert!(states.aborted());
states.reset();
assert!(!states.aborted());
}
#[test]
fn transfer_opts() {
let opts = TransferOpts::default();
assert!(opts.save_as.is_none());
let opts = TransferOpts::default().save_as(Some("omar.txt"));
assert_eq!(opts.save_as.as_deref().unwrap(), "omar.txt");
}
}