1use std::path::{Path, PathBuf};
15
16use tokio::sync::mpsc;
17
18use crate::actions::fs_ops;
19use crate::app::state::AppState;
20use crate::workers::WorkerMsg;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum TransferKind {
25 Copy,
27 Move,
29}
30
31impl TransferKind {
32 fn past(self) -> &'static str {
34 match self {
35 TransferKind::Copy => "copied",
36 TransferKind::Move => "moved",
37 }
38 }
39
40 pub fn progressive(self) -> &'static str {
42 match self {
43 TransferKind::Copy => "copying",
44 TransferKind::Move => "moving",
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TransferItem {
52 pub source: PathBuf,
54 pub dest: PathBuf,
56}
57
58#[derive(Debug, Clone)]
60pub struct TransferJob {
61 pub id: u64,
63 pub kind: TransferKind,
65 pub label: String,
67 pub items: Vec<TransferItem>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TransferReport {
77 pub id: u64,
79 pub kind: TransferKind,
81 pub label: String,
83 pub done: Vec<(PathBuf, PathBuf)>,
85 pub skipped: Vec<PathBuf>,
87 pub vanished: Vec<PathBuf>,
89 pub failed: Vec<(PathBuf, String)>,
91}
92
93impl TransferReport {
94 pub fn summary(&self) -> String {
99 let label = &self.label;
100 let total = self.done.len() + self.skipped.len() + self.vanished.len() + self.failed.len();
101 if total == 1 {
102 if let Some((source, reason)) = self.failed.first() {
103 return format!("{label}: {}: {reason}", display_name(source));
104 }
105 if let Some(source) = self.skipped.first() {
106 return format!(
107 "{label}: {} already exists at the destination",
108 display_name(source)
109 );
110 }
111 if let Some(source) = self.vanished.first() {
112 return format!("{label}: {} no longer exists", display_name(source));
113 }
114 }
115
116 let mut parts = vec![format!("{} {}", self.done.len(), self.kind.past())];
117 if !self.skipped.is_empty() {
118 parts.push(format!("{} skipped: exists", self.skipped.len()));
119 }
120 if !self.vanished.is_empty() {
121 parts.push(format!("{} gone", self.vanished.len()));
122 }
123 if let Some((source, reason)) = self.failed.first() {
124 parts.push(format!(
125 "{} failed ({}: {reason})",
126 self.failed.len(),
127 display_name(source)
128 ));
129 }
130 format!("{label}: {}", parts.join(", "))
131 }
132
133 pub fn has_failures(&self) -> bool {
137 !self.failed.is_empty() || self.done.is_empty()
138 }
139}
140
141fn display_name(path: &Path) -> String {
143 path.file_name()
144 .map(|n| n.to_string_lossy().into_owned())
145 .unwrap_or_else(|| crate::pathfmt::display(path))
146}
147
148pub fn run(job: &TransferJob, mut progress: impl FnMut(usize, usize)) -> TransferReport {
154 let mut report = TransferReport {
155 id: job.id,
156 kind: job.kind,
157 label: job.label.clone(),
158 done: Vec::new(),
159 skipped: Vec::new(),
160 vanished: Vec::new(),
161 failed: Vec::new(),
162 };
163 let total = job.items.len();
164
165 for (index, item) in job.items.iter().enumerate() {
166 if item.source.symlink_metadata().is_err() {
169 report.vanished.push(item.source.clone());
170 } else if item.dest.symlink_metadata().is_ok() {
171 report.skipped.push(item.source.clone());
172 } else {
173 let result = match job.kind {
174 TransferKind::Copy => fs_ops::copy_path(&item.source, &item.dest),
175 TransferKind::Move => fs_ops::move_path(&item.source, &item.dest),
176 };
177 match result {
178 Ok(()) => report.done.push((item.source.clone(), item.dest.clone())),
179 Err(e) => report.failed.push((item.source.clone(), e.to_string())),
180 }
181 }
182 progress(index + 1, total);
183 }
184 report
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct ActiveTransfer {
190 pub id: u64,
192 pub kind: TransferKind,
194 pub done: usize,
196 pub total: usize,
198}
199
200#[derive(Debug, Default)]
206pub struct Transfers {
207 sender: Option<mpsc::Sender<WorkerMsg>>,
208 next_id: u64,
209 active: Vec<ActiveTransfer>,
210}
211
212impl Transfers {
213 pub fn attach(&mut self, sender: mpsc::Sender<WorkerMsg>) {
215 self.sender = Some(sender);
216 }
217
218 pub fn active(&self) -> &[ActiveTransfer] {
220 &self.active
221 }
222
223 pub fn progress(&mut self, id: u64, done: usize, total: usize) -> bool {
225 match self.active.iter_mut().find(|t| t.id == id) {
226 Some(transfer) => {
227 transfer.done = done;
228 transfer.total = total;
229 true
230 }
231 None => false,
232 }
233 }
234}
235
236pub fn start(state: &mut AppState, kind: TransferKind, label: &str, items: Vec<TransferItem>) {
242 if items.is_empty() {
243 return;
244 }
245 let id = state.transfers.next_id;
246 state.transfers.next_id += 1;
247 let job = TransferJob {
248 id,
249 kind,
250 label: label.to_owned(),
251 items,
252 };
253
254 let background = state
255 .transfers
256 .sender
257 .clone()
258 .zip(tokio::runtime::Handle::try_current().ok());
259 let Some((tx, runtime)) = background else {
260 let report = run(&job, |_, _| {});
261 finish(state, report);
262 return;
263 };
264
265 let total = job.items.len();
266 state.transfers.active.push(ActiveTransfer {
267 id,
268 kind,
269 done: 0,
270 total,
271 });
272 state.dirty = true;
273 runtime.spawn_blocking(move || {
274 let report = run(&job, |done, total| {
275 let _ = tx.try_send(WorkerMsg::TransferProgress { id, done, total });
278 });
279 if tx
280 .blocking_send(WorkerMsg::TransferDone { report })
281 .is_err()
282 {
283 tracing::debug!(id, "transfer finished after the UI went away");
284 }
285 });
286}
287
288pub fn finish(state: &mut AppState, report: TransferReport) {
291 state.transfers.active.retain(|t| t.id != report.id);
292
293 if report.kind == TransferKind::Move {
297 for (source, _) in &report.done {
298 state.marks.remove(source);
299 }
300 }
301 for source in &report.vanished {
302 state.marks.remove(source);
303 }
304
305 let summary = report.summary();
306 if report.has_failures() {
307 state.set_error(summary);
308 } else {
309 state.notify(summary);
310 }
311
312 if let Err(e) = state.refresh() {
313 tracing::warn!("refresh after transfer failed: {e}");
314 }
315 state.dirty = true;
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::fs;
322
323 fn job(kind: TransferKind, items: Vec<TransferItem>) -> TransferJob {
324 TransferJob {
325 id: 1,
326 kind,
327 label: "paste".to_owned(),
328 items,
329 }
330 }
331
332 fn item(source: &Path, dest: &Path) -> TransferItem {
333 TransferItem {
334 source: source.to_owned(),
335 dest: dest.to_owned(),
336 }
337 }
338
339 #[test]
340 fn a_taken_name_is_skipped_and_never_overwritten() {
341 let dir = tempfile::tempdir().unwrap();
342 let a = dir.path().join("a.txt");
343 let b = dir.path().join("b.txt");
344 fs::write(&a, b"new").unwrap();
345 fs::write(&b, b"new b").unwrap();
346 let out = dir.path().join("out");
347 fs::create_dir(&out).unwrap();
348 fs::write(out.join("a.txt"), b"old").unwrap();
349
350 let report = run(
351 &job(
352 TransferKind::Copy,
353 vec![item(&a, &out.join("a.txt")), item(&b, &out.join("b.txt"))],
354 ),
355 |_, _| {},
356 );
357
358 assert_eq!(report.done.len(), 1);
359 assert_eq!(report.skipped, vec![a]);
360 assert_eq!(fs::read(out.join("a.txt")).unwrap(), b"old");
361 assert_eq!(report.summary(), "paste: 1 copied, 1 skipped: exists");
362 assert!(!report.has_failures());
363 }
364
365 #[test]
366 fn a_vanished_source_is_counted_and_the_rest_still_run() {
367 let dir = tempfile::tempdir().unwrap();
368 let gone = dir.path().join("gone.txt");
369 let here = dir.path().join("here.txt");
370 fs::write(&here, b"x").unwrap();
371 let out = dir.path().join("out");
372 fs::create_dir(&out).unwrap();
373
374 let mut steps = Vec::new();
375 let report = run(
376 &job(
377 TransferKind::Move,
378 vec![
379 item(&gone, &out.join("gone.txt")),
380 item(&here, &out.join("here.txt")),
381 ],
382 ),
383 |done, total| steps.push((done, total)),
384 );
385
386 assert_eq!(report.vanished, vec![gone]);
387 assert!(!here.exists());
388 assert!(out.join("here.txt").exists());
389 assert_eq!(steps, vec![(1, 2), (2, 2)]);
390 assert_eq!(report.summary(), "paste: 1 moved, 1 gone");
391 }
392
393 #[test]
394 fn a_single_entry_reports_its_reason_alone() {
395 let dir = tempfile::tempdir().unwrap();
396 let a = dir.path().join("a.txt");
397 fs::write(&a, b"x").unwrap();
398 let mut report = run(&job(TransferKind::Copy, vec![item(&a, &a)]), |_, _| {});
399 report.label = "cp".to_owned();
400 assert_eq!(
401 report.summary(),
402 "cp: a.txt already exists at the destination"
403 );
404 assert!(report.has_failures(), "nothing happened, so it is an error");
405 }
406
407 #[tokio::test(flavor = "multi_thread")]
411 async fn with_a_channel_the_transfer_runs_in_the_background() {
412 let dir = tempfile::tempdir().unwrap();
413 let a = dir.path().join("a.txt");
414 fs::write(&a, b"x").unwrap();
415 let mut state = AppState::new(dir.path().to_owned()).unwrap();
416 let (tx, mut rx) = crate::workers::channel();
417 state.transfers.attach(tx);
418
419 start(
420 &mut state,
421 TransferKind::Copy,
422 "cp",
423 vec![item(&a, &dir.path().join("b.txt"))],
424 );
425 assert_eq!(state.transfers.active().len(), 1, "running, not finished");
426
427 loop {
428 let msg = rx.recv().await.expect("the worker reports");
429 let done = matches!(msg, WorkerMsg::TransferDone { .. });
430 crate::workers::merge(msg, &mut state);
431 if done {
432 break;
433 }
434 }
435 assert!(state.transfers.active().is_empty());
436 assert!(dir.path().join("b.txt").exists());
437 assert!(
438 state.entries.iter().any(|e| e.file_name == "b.txt"),
439 "the listing is refreshed when the report lands"
440 );
441 assert_eq!(state.error_text(), None);
442 }
443
444 #[test]
445 fn nothing_done_is_a_failure_even_without_an_error() {
446 let dir = tempfile::tempdir().unwrap();
447 let gone = dir.path().join("gone");
448 let report = run(
449 &job(TransferKind::Copy, vec![item(&gone, &dir.path().join("x"))]),
450 |_, _| {},
451 );
452 assert!(report.has_failures());
453 }
454}