use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
use crate::actions::fs_ops;
use crate::app::state::AppState;
use crate::workers::WorkerMsg;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferKind {
Copy,
Move,
}
impl TransferKind {
fn past(self) -> &'static str {
match self {
TransferKind::Copy => "copied",
TransferKind::Move => "moved",
}
}
pub fn progressive(self) -> &'static str {
match self {
TransferKind::Copy => "copying",
TransferKind::Move => "moving",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransferItem {
pub source: PathBuf,
pub dest: PathBuf,
}
#[derive(Debug, Clone)]
pub struct TransferJob {
pub id: u64,
pub kind: TransferKind,
pub label: String,
pub items: Vec<TransferItem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransferReport {
pub id: u64,
pub kind: TransferKind,
pub label: String,
pub done: Vec<(PathBuf, PathBuf)>,
pub skipped: Vec<PathBuf>,
pub vanished: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
}
impl TransferReport {
pub fn summary(&self) -> String {
let label = &self.label;
let total = self.done.len() + self.skipped.len() + self.vanished.len() + self.failed.len();
if total == 1 {
if let Some((source, reason)) = self.failed.first() {
return format!("{label}: {}: {reason}", display_name(source));
}
if let Some(source) = self.skipped.first() {
return format!(
"{label}: {} already exists at the destination",
display_name(source)
);
}
if let Some(source) = self.vanished.first() {
return format!("{label}: {} no longer exists", display_name(source));
}
}
let mut parts = vec![format!("{} {}", self.done.len(), self.kind.past())];
if !self.skipped.is_empty() {
parts.push(format!("{} skipped: exists", self.skipped.len()));
}
if !self.vanished.is_empty() {
parts.push(format!("{} gone", self.vanished.len()));
}
if let Some((source, reason)) = self.failed.first() {
parts.push(format!(
"{} failed ({}: {reason})",
self.failed.len(),
display_name(source)
));
}
format!("{label}: {}", parts.join(", "))
}
pub fn has_failures(&self) -> bool {
!self.failed.is_empty() || self.done.is_empty()
}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| crate::pathfmt::display(path))
}
pub fn run(job: &TransferJob, mut progress: impl FnMut(usize, usize)) -> TransferReport {
let mut report = TransferReport {
id: job.id,
kind: job.kind,
label: job.label.clone(),
done: Vec::new(),
skipped: Vec::new(),
vanished: Vec::new(),
failed: Vec::new(),
};
let total = job.items.len();
for (index, item) in job.items.iter().enumerate() {
if item.source.symlink_metadata().is_err() {
report.vanished.push(item.source.clone());
} else if item.dest.symlink_metadata().is_ok() {
report.skipped.push(item.source.clone());
} else {
let result = match job.kind {
TransferKind::Copy => fs_ops::copy_path(&item.source, &item.dest),
TransferKind::Move => fs_ops::move_path(&item.source, &item.dest),
};
match result {
Ok(()) => report.done.push((item.source.clone(), item.dest.clone())),
Err(e) => report.failed.push((item.source.clone(), e.to_string())),
}
}
progress(index + 1, total);
}
report
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveTransfer {
pub id: u64,
pub kind: TransferKind,
pub done: usize,
pub total: usize,
}
#[derive(Debug, Default)]
pub struct Transfers {
sender: Option<mpsc::Sender<WorkerMsg>>,
next_id: u64,
active: Vec<ActiveTransfer>,
}
impl Transfers {
pub fn attach(&mut self, sender: mpsc::Sender<WorkerMsg>) {
self.sender = Some(sender);
}
pub fn active(&self) -> &[ActiveTransfer] {
&self.active
}
pub fn progress(&mut self, id: u64, done: usize, total: usize) -> bool {
match self.active.iter_mut().find(|t| t.id == id) {
Some(transfer) => {
transfer.done = done;
transfer.total = total;
true
}
None => false,
}
}
}
pub fn start(state: &mut AppState, kind: TransferKind, label: &str, items: Vec<TransferItem>) {
if items.is_empty() {
return;
}
let id = state.transfers.next_id;
state.transfers.next_id += 1;
let job = TransferJob {
id,
kind,
label: label.to_owned(),
items,
};
let background = state
.transfers
.sender
.clone()
.zip(tokio::runtime::Handle::try_current().ok());
let Some((tx, runtime)) = background else {
let report = run(&job, |_, _| {});
finish(state, report);
return;
};
let total = job.items.len();
state.transfers.active.push(ActiveTransfer {
id,
kind,
done: 0,
total,
});
state.dirty = true;
runtime.spawn_blocking(move || {
let report = run(&job, |done, total| {
let _ = tx.try_send(WorkerMsg::TransferProgress { id, done, total });
});
if tx
.blocking_send(WorkerMsg::TransferDone { report })
.is_err()
{
tracing::debug!(id, "transfer finished after the UI went away");
}
});
}
pub fn finish(state: &mut AppState, report: TransferReport) {
state.transfers.active.retain(|t| t.id != report.id);
if report.kind == TransferKind::Move {
for (source, _) in &report.done {
state.marks.remove(source);
}
}
for source in &report.vanished {
state.marks.remove(source);
}
let summary = report.summary();
if report.has_failures() {
state.set_error(summary);
} else {
state.notify(summary);
}
if let Err(e) = state.refresh() {
tracing::warn!("refresh after transfer failed: {e}");
}
state.dirty = true;
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn job(kind: TransferKind, items: Vec<TransferItem>) -> TransferJob {
TransferJob {
id: 1,
kind,
label: "paste".to_owned(),
items,
}
}
fn item(source: &Path, dest: &Path) -> TransferItem {
TransferItem {
source: source.to_owned(),
dest: dest.to_owned(),
}
}
#[test]
fn a_taken_name_is_skipped_and_never_overwritten() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
fs::write(&a, b"new").unwrap();
fs::write(&b, b"new b").unwrap();
let out = dir.path().join("out");
fs::create_dir(&out).unwrap();
fs::write(out.join("a.txt"), b"old").unwrap();
let report = run(
&job(
TransferKind::Copy,
vec![item(&a, &out.join("a.txt")), item(&b, &out.join("b.txt"))],
),
|_, _| {},
);
assert_eq!(report.done.len(), 1);
assert_eq!(report.skipped, vec![a]);
assert_eq!(fs::read(out.join("a.txt")).unwrap(), b"old");
assert_eq!(report.summary(), "paste: 1 copied, 1 skipped: exists");
assert!(!report.has_failures());
}
#[test]
fn a_vanished_source_is_counted_and_the_rest_still_run() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("gone.txt");
let here = dir.path().join("here.txt");
fs::write(&here, b"x").unwrap();
let out = dir.path().join("out");
fs::create_dir(&out).unwrap();
let mut steps = Vec::new();
let report = run(
&job(
TransferKind::Move,
vec![
item(&gone, &out.join("gone.txt")),
item(&here, &out.join("here.txt")),
],
),
|done, total| steps.push((done, total)),
);
assert_eq!(report.vanished, vec![gone]);
assert!(!here.exists());
assert!(out.join("here.txt").exists());
assert_eq!(steps, vec![(1, 2), (2, 2)]);
assert_eq!(report.summary(), "paste: 1 moved, 1 gone");
}
#[test]
fn a_single_entry_reports_its_reason_alone() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
fs::write(&a, b"x").unwrap();
let mut report = run(&job(TransferKind::Copy, vec![item(&a, &a)]), |_, _| {});
report.label = "cp".to_owned();
assert_eq!(
report.summary(),
"cp: a.txt already exists at the destination"
);
assert!(report.has_failures(), "nothing happened, so it is an error");
}
#[tokio::test(flavor = "multi_thread")]
async fn with_a_channel_the_transfer_runs_in_the_background() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
fs::write(&a, b"x").unwrap();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
let (tx, mut rx) = crate::workers::channel();
state.transfers.attach(tx);
start(
&mut state,
TransferKind::Copy,
"cp",
vec![item(&a, &dir.path().join("b.txt"))],
);
assert_eq!(state.transfers.active().len(), 1, "running, not finished");
loop {
let msg = rx.recv().await.expect("the worker reports");
let done = matches!(msg, WorkerMsg::TransferDone { .. });
crate::workers::merge(msg, &mut state);
if done {
break;
}
}
assert!(state.transfers.active().is_empty());
assert!(dir.path().join("b.txt").exists());
assert!(
state.entries.iter().any(|e| e.file_name == "b.txt"),
"the listing is refreshed when the report lands"
);
assert_eq!(state.error_text(), None);
}
#[test]
fn nothing_done_is_a_failure_even_without_an_error() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("gone");
let report = run(
&job(TransferKind::Copy, vec![item(&gone, &dir.path().join("x"))]),
|_, _| {},
);
assert!(report.has_failures());
}
}