build(remotefs): migrate to version 1 (#449)
Install.sh / build (macos-latest) (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
CI / toolchain (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / install-scripts (push) Has been cancelled
Site / build-site (push) Has been cancelled
CI / crates-macos-latest (push) Has been cancelled
CI / crates-ubuntu-latest (push) Has been cancelled
CI / crates-windows-latest (push) Has been cancelled
CI / doc (push) Has been cancelled
CI / deny (push) Has been cancelled

* build(remotefs): migrate to version 1

* fix(remote): preserve POSIX paths on Windows
This commit is contained in:
Christian Visintin
2026-09-16 19:32:06 +02:00
committed by GitHub
parent 74c91c747c
commit 4120311bf6
20 changed files with 1760 additions and 1406 deletions
+16 -9
View File
@@ -3,7 +3,7 @@
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use std::fs::OpenOptions;
use std::io::Read;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
@@ -107,10 +107,12 @@ impl FileTransferActivity {
}
};
let new_file_size = match std::io::copy(&mut reader, &mut writer) {
Err(err) => return Err(format!("Could not write file: {err}")),
Ok(size) => size,
};
if let Err(err) = std::io::copy(&mut reader, &mut writer) {
return Err(format!("Could not write file: {err}"));
}
reader
.finish()
.map_err(|err| format!("Could not finish file read: {err}"))?;
// edit file
@@ -126,10 +128,11 @@ impl FileTransferActivity {
};
let mut writer = match self.browser.local_pane_mut().fs.create_file(
entry.path(),
&Metadata {
size: new_file_size,
..Default::default()
},
&Metadata::default().size(
std::fs::metadata(tempfile.as_path())
.map_err(|err| format!("Could not stat edited file: {err}"))?
.len(),
),
) {
Ok(writer) => writer,
Err(err) => {
@@ -141,6 +144,10 @@ impl FileTransferActivity {
return Err(format!("Could not write file: {err}"));
}
writer
.flush()
.map_err(|err| format!("Could not write file: {err}"))?;
self.browser
.local_pane_mut()
.fs
@@ -2,6 +2,12 @@ use remotefs::File;
use super::{FileTransferActivity, LogLevel};
fn aggregate_sizes(sizes: impl IntoIterator<Item = Option<u64>>) -> Option<u64> {
sizes
.into_iter()
.try_fold(0_u64, |total, size| total.checked_add(size?))
}
impl FileTransferActivity {
/// Calculate and display the total size of the selected file(s) via the active tab's pane.
pub(crate) fn action_get_file_size(&mut self) {
@@ -11,17 +17,23 @@ impl FileTransferActivity {
let total_size = self.get_files_size(files);
self.umount_wait();
self.mount_info(format!(
"Total file size: {size}",
size = bytesize::ByteSize::b(total_size)
));
let message = total_size.map_or_else(
|| String::from("Total file size: Unknown"),
|size| {
format!(
"Total file size: {size}",
size = bytesize::ByteSize::b(size)
)
},
);
self.mount_info(message);
}
fn get_files_size(&mut self, files: Vec<File>) -> u64 {
files.into_iter().map(|f| self.get_file_size(f)).sum()
fn get_files_size(&mut self, files: Vec<File>) -> Option<u64> {
aggregate_sizes(files.into_iter().map(|file| self.get_file_size(file)))
}
fn get_file_size(&mut self, file: File) -> u64 {
fn get_file_size(&mut self, file: File) -> Option<u64> {
if let Some(symlink) = &file.metadata().symlink {
match self.browser.fs_pane_mut().fs.stat(symlink) {
Ok(stat) => stat.metadata().size,
@@ -33,12 +45,12 @@ impl FileTransferActivity {
path = symlink.display(),
),
);
0
None
}
}
} else if file.is_dir() {
match self.browser.fs_pane_mut().fs.list_dir(&file.path) {
Ok(list) => list.into_iter().map(|f| self.get_file_size(f)).sum(),
Ok(list) => self.get_files_size(list),
Err(err) => {
self.log(
LogLevel::Error,
@@ -47,7 +59,7 @@ impl FileTransferActivity {
path = file.path.display(),
),
);
0
None
}
}
} else {
@@ -55,3 +67,14 @@ impl FileTransferActivity {
}
}
}
#[cfg(test)]
mod test {
use super::aggregate_sizes;
#[test]
fn aggregates_only_known_sizes() {
assert_eq!(aggregate_sizes([Some(2), Some(3)]), Some(5));
assert_eq!(aggregate_sizes([Some(2), None, Some(3)]), None);
}
}
@@ -28,7 +28,7 @@ impl FileTransferActivity {
.browser
.fs_pane_mut()
.fs
.create_file(file_path.as_path(), &Metadata::default())
.create_file(file_path.as_path(), &Metadata::default().size(0))
{
Ok(f) => f,
Err(err) => {
@@ -131,6 +131,14 @@ impl FileTransferActivity {
return;
}
if let Err(err) = reader.finish() {
self.log(
LogLevel::Error,
format!("Failed to finish bridged file read: {err}"),
);
return;
}
if tmpfile.exists() {
self.open_path_with(tmpfile.as_path(), open_with);
}
@@ -43,11 +43,10 @@ impl FileInfoPopup {
.add_col(SpanStatic::from("File type: "))
.add_col(SpanStatic::raw(filetype.clone()).fg(Color::LightGreen));
}
let (bsize, size): (ByteSize, u64) = (ByteSize(file.metadata().size), file.metadata().size);
texts
.add_row()
.add_col(SpanStatic::from("Size: "))
.add_col(SpanStatic::raw(format!("{bsize} ({size})")).fg(Color::Cyan));
.add_col(SpanStatic::raw(format_file_size(file)).fg(Color::Cyan));
let atime: String = fmt_time(
file.metadata().accessed.unwrap_or(UNIX_EPOCH),
"%b %d %Y %H:%M:%S",
@@ -117,6 +116,13 @@ impl FileInfoPopup {
}
}
fn format_file_size(file: &File) -> String {
match file.metadata().size {
Some(size) => format!("{bsize} ({size})", bsize = ByteSize(size), size = size),
None => String::from("Unknown"),
}
}
impl AppComponent<Msg, NoUserEvent> for FileInfoPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
@@ -128,3 +134,17 @@ impl AppComponent<Msg, NoUserEvent> for FileInfoPopup {
}
}
}
#[cfg(test)]
mod test {
use remotefs::fs::Metadata;
use super::*;
#[test]
fn formats_unknown_file_size_as_unknown() {
let file = File::new("/tmp/unknown", Metadata::default());
assert_eq!(format_file_size(&file), "Unknown");
}
}
+45 -23
View File
@@ -16,7 +16,7 @@ use bytesize::ByteSize;
pub struct TransferProgress {
files_total: usize,
files_completed: usize,
cur_file_size: usize,
cur_file_size: Option<usize>,
cur_file_written: usize,
total_bytes_written: usize,
pub(crate) started: Instant,
@@ -27,7 +27,7 @@ impl Default for TransferProgress {
Self {
files_total: 0,
files_completed: 0,
cur_file_size: 0,
cur_file_size: None,
cur_file_written: 0,
total_bytes_written: 0,
started: Instant::now(),
@@ -41,13 +41,15 @@ impl fmt::Display for TransferProgress {
0 => String::from("--:--"),
seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60),
};
let total = self.cur_file_size.map_or_else(
|| String::from("?"),
|size| ByteSize(size as u64).to_string(),
);
write!(
f,
"{} / {} — {:.1}% — ETA {} ({}/s)",
"{} / {total} — {:.1}% — ETA {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()),
)
}
@@ -58,15 +60,15 @@ impl TransferProgress {
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_size = None;
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;
/// Begin a new file with an optional advertised size.
pub fn start_file(&mut self, size: Option<u64>) {
self.cur_file_size = size.and_then(|size| usize::try_from(size).ok());
self.cur_file_written = 0;
}
@@ -83,7 +85,7 @@ impl TransferProgress {
/// fraction in [`Self::calc_full_progress`].
pub fn finish_file(&mut self) {
self.files_completed += 1;
self.cur_file_size = 0;
self.cur_file_size = None;
self.cur_file_written = 0;
}
@@ -92,25 +94,26 @@ impl TransferProgress {
self.files_completed += 1;
}
/// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0.
/// Fraction of the current file written (0.0..=1.0).
pub fn calc_partial_progress(&self) -> f64 {
if self.cur_file_size == 0 {
return 1.0;
match self.cur_file_size {
None => 0.0,
Some(0) => 1.0,
Some(size) => (self.cur_file_written as f64 / size as f64).min(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
/// progress (a known size 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 {
let cur_fraction = if self.files_completed >= self.files_total {
0.0
} else {
self.calc_partial_progress()
@@ -234,7 +237,7 @@ mod test {
assert!(progress.is_single_file());
assert_eq!(progress.calc_full_progress(), 0.0);
progress.start_file(1024);
progress.start_file(Some(1024));
assert_eq!(progress.calc_partial_progress(), 0.0);
assert_eq!(progress.calc_full_progress(), 0.0);
@@ -258,13 +261,13 @@ mod test {
assert!(!progress.is_single_file());
// File 1 fully transferred => full ≈ 0.25
progress.start_file(1000);
progress.start_file(Some(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.start_file(Some(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);
@@ -276,7 +279,7 @@ mod test {
progress.init(2);
// One file actually transferred.
progress.start_file(100);
progress.start_file(Some(100));
progress.add_bytes(100);
progress.finish_file();
@@ -294,15 +297,34 @@ mod test {
let mut progress = TransferProgress::default();
progress.init(1);
progress.start_file(0);
progress.start_file(Some(0));
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
}
#[test]
fn test_transfer_progress_unknown_size() {
let mut progress = TransferProgress::default();
progress.init(1);
progress.start_file(None);
progress.add_bytes(256);
assert_eq!(progress.calc_partial_progress(), 0.0);
assert_eq!(progress.calc_full_progress(), 0.0);
assert_eq!(progress.calc_eta(), 0);
assert_eq!(progress.total_bytes_written(), 256);
assert!(progress.to_string().contains("?"));
assert!(!progress.to_string().contains("0 B"));
progress.finish_file();
assert_eq!(progress.files_completed(), 1);
assert_eq!(progress.calc_full_progress(), 1.0);
}
#[test]
fn test_transfer_progress_timing() {
let mut progress = TransferProgress::default();
progress.init(1);
progress.start_file(1024);
progress.start_file(Some(1024));
progress.started = progress
.started
@@ -10,7 +10,7 @@ use bytesize::ByteSize;
use remotefs::fs::File;
use thiserror::Error;
use crate::host::HostError;
use crate::host::{HostError, HostReader, HostWriter};
use crate::ui::activities::filetransfer::{FileTransferActivity, LogLevel};
use crate::utils::fmt::fmt_millis;
@@ -457,8 +457,8 @@ impl FileTransferActivity {
host: &File,
remote: &Path,
file_name: String,
mut reader: Box<dyn Read + Send>,
mut writer: Box<dyn Write + Send>,
mut reader: HostReader,
mut writer: HostWriter,
) -> Result<(), TransferErrorReason> {
// Write file
let file_size = self
@@ -467,18 +467,16 @@ impl FileTransferActivity {
.fs
.stat(host.path())
.map_err(TransferErrorReason::HostError)
.map(|x| x.metadata().size as usize)?;
.map(|x| x.metadata().size)?;
// Init transfer
self.transfer.progress.start_file(file_size);
let file_started = Instant::now();
// Write remote file
let mut total_bytes_written: usize = 0;
let mut last_redraw: Instant = Instant::now();
let mut last_input_event_fetch: Option<Instant> = None;
// While the entire file hasn't been completely written,
// Or filetransfer has been aborted
while total_bytes_written < file_size && !self.transfer.aborted() {
// Read until EOF or until the transfer is aborted.
while !self.transfer.aborted() {
// Handle input events (each 500ms) or if never fetched before
if last_input_event_fetch.is_none()
|| last_input_event_fetch
@@ -494,33 +492,17 @@ impl FileTransferActivity {
}
// Read till you can
let mut buffer: [u8; BUFSIZE] = [0; BUFSIZE];
let delta: usize = match reader.read(&mut buffer) {
Ok(bytes_read) => {
total_bytes_written += bytes_read;
if bytes_read == 0 {
continue;
} else {
let mut delta: usize = 0;
while delta < bytes_read {
// Write bytes
match writer.write(&buffer[delta..bytes_read]) {
Ok(bytes) => {
delta += bytes;
}
Err(err) => {
return Err(TransferErrorReason::RemoteIoError(err));
}
}
}
delta
}
}
Err(err) => {
return Err(TransferErrorReason::HostIoError(err));
}
};
let bytes_read = reader
.read(&mut buffer)
.map_err(TransferErrorReason::HostIoError)?;
if bytes_read == 0 {
break;
}
writer
.write_all(&buffer[..bytes_read])
.map_err(TransferErrorReason::RemoteIoError)?;
// Increase progress
self.transfer.progress.add_bytes(delta);
self.transfer.progress.add_bytes(bytes_read);
// Redraw at most every 100ms to keep UI responsive for large files
if last_redraw.elapsed().as_millis() >= 100 {
self.update_progress_bar(format!("Uploading \"{file_name}\"…"));
@@ -528,12 +510,14 @@ impl FileTransferActivity {
last_redraw = Instant::now();
}
}
// Finalize stream
handle_remote_finalize_result(self.browser.remote_pane_mut().fs.finalize_write(writer))?;
// if upload was abrupted, return error
if self.transfer.aborted() {
return Err(TransferErrorReason::Abrupted);
}
writer.flush().map_err(TransferErrorReason::RemoteIoError)?;
reader.finish().map_err(TransferErrorReason::HostError)?;
// Finalize stream
handle_remote_finalize_result(self.browser.remote_pane_mut().fs.finalize_write(writer))?;
// set stat
if let Err(err) = self
.browser
@@ -812,21 +796,17 @@ impl FileTransferActivity {
host_bridge: &Path,
remote: &File,
file_name: String,
mut reader: Box<dyn Read + Send>,
mut writer: Box<dyn Write + Send>,
mut reader: HostReader,
mut writer: HostWriter,
) -> Result<(), TransferErrorReason> {
let mut total_bytes_written: usize = 0;
// Init transfer
self.transfer
.progress
.start_file(remote.metadata.size as usize);
self.transfer.progress.start_file(remote.metadata.size);
let file_started = Instant::now();
// Write host_bridge file
let mut last_redraw: Instant = Instant::now();
let mut last_input_event_fetch: Option<Instant> = None;
// While the entire file hasn't been completely read,
// Or filetransfer has been aborted
while total_bytes_written < remote.metadata.size as usize && !self.transfer.aborted() {
// Read until EOF or until the transfer is aborted.
while !self.transfer.aborted() {
// Handle input events (each 500 ms) or is None
if last_input_event_fetch.is_none()
|| last_input_event_fetch
@@ -842,31 +822,17 @@ impl FileTransferActivity {
}
// Read till you can
let mut buffer: [u8; BUFSIZE] = [0; BUFSIZE];
let delta: usize = match reader.read(&mut buffer) {
Ok(bytes_read) => {
total_bytes_written += bytes_read;
if bytes_read == 0 {
continue;
} else {
let mut delta: usize = 0;
while delta < bytes_read {
// Write bytes
match writer.write(&buffer[delta..bytes_read]) {
Ok(bytes) => delta += bytes,
Err(err) => {
return Err(TransferErrorReason::HostIoError(err));
}
}
}
delta
}
}
Err(err) => {
return Err(TransferErrorReason::RemoteIoError(err));
}
};
let bytes_read = reader
.read(&mut buffer)
.map_err(TransferErrorReason::RemoteIoError)?;
if bytes_read == 0 {
break;
}
writer
.write_all(&buffer[..bytes_read])
.map_err(TransferErrorReason::HostIoError)?;
// Set progress
self.transfer.progress.add_bytes(delta);
self.transfer.progress.add_bytes(bytes_read);
// Redraw at most every 100ms to keep UI responsive for large files
if last_redraw.elapsed().as_millis() >= 100 {
self.update_progress_bar(format!("Downloading \"{file_name}\""));
@@ -879,6 +845,10 @@ impl FileTransferActivity {
return Err(TransferErrorReason::Abrupted);
}
writer.flush().map_err(TransferErrorReason::HostIoError)?;
reader
.finish()
.map_err(TransferErrorReason::RemoteHostError)?;
// Finalize write
self.browser
.local_pane_mut()
@@ -953,25 +923,20 @@ mod worklist_test {
// 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 {
let metadata = Metadata::default()
.accessed(t)
.created(t)
.modified(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,
}
})
.gid(0)
.uid(0)
.mode(UnixPex::from(if is_dir { 0o755 } else { 0o644 }))
.size(64);
File::new(path, metadata)
}
#[test]