Skip to main content

trail/actions/
transfer.rs

1//! Copies and moves that run off the UI thread.
2//!
3//! A copy takes as long as the data does: one file picked by hand is quick, a
4//! folder of several gigabytes is not, and run on the UI thread it froze Trail
5//! for the duration (invariant 1). Every `:cp`, `:mv` and paste is therefore a
6//! [`TransferJob`] handed to the blocking pool, which reports progress and a
7//! [`TransferReport`] back over the worker channel.
8//!
9//! The job's destinations are resolved before it starts, on the UI thread —
10//! that is only a `stat` — but the "is the name already taken" check happens in
11//! the worker, immediately before each entry, because the answer can change
12//! while earlier entries are being copied.
13
14use 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/// Whether a transfer leaves the source behind.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum TransferKind {
25    /// The source stays; a copy appears at the destination.
26    Copy,
27    /// The source goes; it appears at the destination instead.
28    Move,
29}
30
31impl TransferKind {
32    /// The past tense used in reports: `copied`, `moved`.
33    fn past(self) -> &'static str {
34        match self {
35            TransferKind::Copy => "copied",
36            TransferKind::Move => "moved",
37        }
38    }
39
40    /// The progressive used while it runs: `copying`, `moving`.
41    pub fn progressive(self) -> &'static str {
42        match self {
43            TransferKind::Copy => "copying",
44            TransferKind::Move => "moving",
45        }
46    }
47}
48
49/// One entry to transfer, with its destination already resolved.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TransferItem {
52    /// The entry being copied or moved.
53    pub source: PathBuf,
54    /// Exactly where it should end up — not the directory it goes into.
55    pub dest: PathBuf,
56}
57
58/// A batch of copies or moves, run in order on the blocking pool.
59#[derive(Debug, Clone)]
60pub struct TransferJob {
61    /// Identifies the job in progress messages and its report.
62    pub id: u64,
63    /// Copy or move.
64    pub kind: TransferKind,
65    /// The command that started it (`cp`, `mv`, `paste`), for the report.
66    pub label: String,
67    /// What to transfer, in order.
68    pub items: Vec<TransferItem>,
69}
70
71/// What became of a [`TransferJob`].
72///
73/// Every entry ends up in exactly one of the four lists. Nothing is ever
74/// overwritten: an occupied destination is `skipped`, never replaced.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TransferReport {
77    /// The job this answers.
78    pub id: u64,
79    /// Copy or move.
80    pub kind: TransferKind,
81    /// The command that started it.
82    pub label: String,
83    /// Entries transferred, as `(source, dest)`.
84    pub done: Vec<(PathBuf, PathBuf)>,
85    /// Entries left alone because their destination name was taken.
86    pub skipped: Vec<PathBuf>,
87    /// Entries that no longer existed when their turn came.
88    pub vanished: Vec<PathBuf>,
89    /// Entries that failed, with the reason.
90    pub failed: Vec<(PathBuf, String)>,
91}
92
93impl TransferReport {
94    /// One status-bar line: `paste: 2 copied, 1 skipped: exists`.
95    ///
96    /// A single entry that failed or was skipped is reported by its reason
97    /// alone, which is what `:mv` and `:cp` have always said.
98    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    /// Whether to report this as an error: something failed, or nothing at all
134    /// was transferred. A batch with some entries skipped and others done is a
135    /// success that mentions the skips.
136    pub fn has_failures(&self) -> bool {
137        !self.failed.is_empty() || self.done.is_empty()
138    }
139}
140
141/// The file name of `path`, or the whole path when it has none.
142fn 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
148/// Runs `job` to completion, calling `progress(done, total)` after each entry.
149///
150/// Blocking: this does the actual copying. Keeps going after a failure, so one
151/// unreadable file does not strand the rest of a paste, and the report says
152/// what happened to each.
153pub 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        // `symlink_metadata` rather than `exists`: a dangling link is still an
167        // entry that can be moved, and still a name that is taken.
168        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/// A transfer that has started and not yet reported.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct ActiveTransfer {
190    /// The job's id.
191    pub id: u64,
192    /// Copy or move.
193    pub kind: TransferKind,
194    /// Entries finished so far.
195    pub done: usize,
196    /// Entries in the job.
197    pub total: usize,
198}
199
200/// The transfers in flight, and the channel they report on.
201///
202/// Without a channel — in tests, which drive [`AppState`] directly — a job
203/// runs to completion inside [`start`], so a test sees the outcome as soon as
204/// the action returns.
205#[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    /// Connects transfers to the worker channel, so they run in the background.
214    pub fn attach(&mut self, sender: mpsc::Sender<WorkerMsg>) {
215        self.sender = Some(sender);
216    }
217
218    /// The transfers still running, oldest first.
219    pub fn active(&self) -> &[ActiveTransfer] {
220        &self.active
221    }
222
223    /// Records a progress report for job `id`. Returns whether it was known.
224    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
236/// Starts copying or moving `items`, reporting under `label`.
237///
238/// Runs on the blocking pool when a worker channel is attached and a tokio
239/// runtime is current, and inline otherwise. Either way the outcome arrives
240/// through [`finish`].
241pub 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            // A dropped progress message only means a stale counter for one
276            // frame; the report itself is sent with back-pressure below.
277            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
288/// Applies a finished transfer: says what happened, drops marks that no longer
289/// name anything, and re-lists the directory.
290pub fn finish(state: &mut AppState, report: TransferReport) {
291    state.transfers.active.retain(|t| t.id != report.id);
292
293    // A moved entry is no longer where its mark says, and a vanished one is
294    // nowhere: neither can be pasted again. A copied or skipped entry is still
295    // where it was, so its mark stays and the same set can be pasted twice.
296    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    /// The path the running app takes: the job leaves the UI thread, the status
408    /// bar has something to show meanwhile, and the report comes back over the
409    /// worker channel.
410    #[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}