Skip to main content

fm/modes/menu/
copy_move.rs

1use std::fmt::{Display, Write};
2use std::path::PathBuf;
3use std::sync::mpsc::Sender;
4use std::sync::Arc;
5use std::thread;
6
7use anyhow::{bail, Context, Result};
8use fs_extra;
9use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
10
11use crate::common::{is_in_path, random_name, NOTIFY_EXECUTABLE};
12use crate::event::FmEvents;
13use crate::io::execute;
14use crate::modes::human_size;
15use crate::{log_info, log_line};
16
17/// Store a copy or move.
18/// It will be send as an Fm Event to update the marks.
19#[derive(Default, Debug, Clone)]
20pub struct DoneCopyMove {
21    pub copy_move: CopyMove,
22    pub from: PathBuf,
23    pub final_to: PathBuf,
24}
25
26impl Display for DoneCopyMove {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(
29            f,
30            "{copy_move} from: {from} to: {to}",
31            copy_move = self.copy_move.preterit(),
32            from = self.from.display(),
33            to = self.final_to.display()
34        )
35    }
36}
37
38// Won't replace with ratatui component since it's less flexible
39/// Send the progress bar to event dispatcher, allowing its display
40fn handle_progress_display(
41    pb: &ProgressBar,
42    process_info: fs_extra::TransitProcess,
43) -> fs_extra::dir::TransitProcessResult {
44    let progress = progress_bar_position(&process_info);
45    pb.set_position(progress);
46    fs_extra::dir::TransitProcessResult::ContinueOrAbort
47}
48
49/// Position of the progress bar.
50/// We have to handle properly 0 bytes to avoid division by zero.
51fn progress_bar_position(process_info: &fs_extra::TransitProcess) -> u64 {
52    if process_info.total_bytes == 0 {
53        return 0;
54    }
55    100 * process_info.copied_bytes / process_info.total_bytes
56}
57
58/// Different kind of movement of files : copying or moving.
59#[derive(Default, Debug, Clone, Copy)]
60pub enum CopyMove {
61    Copy,
62    #[default]
63    Move,
64}
65
66impl CopyMove {
67    /// True iff this operation is a copy and not a move.
68    #[inline]
69    pub fn is_copy(&self) -> bool {
70        matches!(self, Self::Copy)
71    }
72
73    fn verb(&self) -> &str {
74        match self {
75            Self::Copy => "copy",
76            Self::Move => "move",
77        }
78    }
79
80    fn preterit(&self) -> &str {
81        match self {
82            Self::Copy => "copied",
83            Self::Move => "moved",
84        }
85    }
86
87    fn copier<P, Q, F>(
88        &self,
89    ) -> for<'a, 'b> fn(
90        &'a [P],
91        Q,
92        &'b fs_extra::dir::CopyOptions,
93        F,
94    ) -> Result<u64, fs_extra::error::Error>
95    where
96        P: AsRef<std::path::Path>,
97        Q: AsRef<std::path::Path>,
98        F: FnMut(fs_extra::TransitProcess) -> fs_extra::dir::TransitProcessResult,
99    {
100        match self {
101            Self::Copy => fs_extra::copy_items_with_progress,
102            Self::Move => fs_extra::move_items_with_progress,
103        }
104    }
105
106    fn log_and_notify(&self, hs_bytes: &str) {
107        let message = format!("{preterit} {hs_bytes} bytes", preterit = self.preterit());
108        let _ = notify(&message);
109        log_info!("{message}");
110        log_line!("{message}");
111    }
112
113    fn setup_progress_bar(
114        &self,
115        width: u16,
116        height: u16,
117    ) -> Result<(InMemoryTerm, ProgressBar, fs_extra::dir::CopyOptions)> {
118        let width = width.saturating_sub(4);
119        let in_mem = InMemoryTerm::new(height, width);
120        let pb = ProgressBar::with_draw_target(
121            Some(100),
122            ProgressDrawTarget::term_like(Box::new(in_mem.clone())),
123        );
124        let action = self.verb().to_owned();
125        pb.set_style(
126            ProgressStyle::with_template(
127                "{spinner} {action} [{elapsed}] [{wide_bar}] {percent}% ({eta})",
128            )?
129            .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
130                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
131            })
132            .with_key("action", move |_: &ProgressState, w: &mut dyn Write| {
133                write!(w, "{}", &action).unwrap()
134            })
135            .progress_chars("#>-"),
136        );
137        let options = fs_extra::dir::CopyOptions::new();
138        Ok((in_mem, pb, options))
139    }
140}
141
142/// Will copy or move a bunch of files to `dest`.
143/// A progress bar is displayed.
144/// A notification is then sent to the user if a compatible notification system
145/// is installed.
146///
147/// If a file is copied or moved to a folder which already contains a file with the same name,
148/// the copie/moved file has a `_` appended to its name.
149///
150/// This is done by :
151/// 1. creating a random temporary folder in the destination,
152/// 2. moving / copying every file there,
153/// 3. moving all file to their final destination, appending enough `_` to get an unique file name,
154/// 4. deleting the now empty temporary folder.
155///
156/// This quite complex behavior is the only way I could find to keep the progress bar while allowing to
157/// create copies of files in the same dir.
158///
159/// It also sends an event "file copied" once all the files are copied
160pub fn copy_move<P>(
161    copy_move: CopyMove,
162    sources: Vec<PathBuf>,
163    dest: P,
164    width: u16,
165    height: u16,
166    fm_sender: Arc<Sender<FmEvents>>,
167) -> Result<InMemoryTerm>
168where
169    P: AsRef<std::path::Path>,
170{
171    let (in_mem, progress_bar, options) = copy_move.setup_progress_bar(width, height)?;
172    let handle_progress = move |process_info: fs_extra::TransitProcess| {
173        handle_progress_display(&progress_bar, process_info)
174    };
175    let mut conflict_handler = ConflictHandler::new(copy_move, &sources, dest)?;
176
177    let _ = thread::spawn(move || {
178        let transfered_bytes = match copy_move.copier()(
179            &sources,
180            &conflict_handler.temp_dest,
181            &options,
182            handle_progress,
183        ) {
184            Ok(transfered_bytes) => transfered_bytes,
185            Err(e) => {
186                log_info!("Error: {e:?}");
187                log_line!("Error: {e:?}");
188                0
189            }
190        };
191
192        fm_sender.send(FmEvents::Refresh).unwrap_or_default();
193
194        match conflict_handler.solve_conflicts() {
195            Ok(done_copy_moves) => fm_sender
196                .send(FmEvents::FileCopied(done_copy_moves))
197                .unwrap_or_default(),
198            Err(error) => log_info!("Conflict Handler error: {error}"),
199        };
200
201        copy_move.log_and_notify(&human_size(transfered_bytes));
202    });
203    Ok(in_mem)
204}
205
206/// Deal with conflicting filenames during a copy or a move.
207struct ConflictHandler {
208    /// Is this a copy or a move ?
209    copy_move: CopyMove,
210    /// Source path of files which are copied or moved.
211    sources: Vec<PathBuf>,
212    /// The destination of the files.
213    /// If there's no conflicting filenames, it's their final destination
214    /// otherwise it's a temporary folder we'll create.
215    temp_dest: PathBuf,
216    /// True iff there's at least one file name conflict:
217    /// an already existing file in the destination with the same name
218    /// as a file from source.
219    has_conflict: bool,
220    /// Defined to the final destination if there's a conflict.
221    /// None otherwise.
222    final_dest: Option<PathBuf>,
223    /// Store every move which were done by ConflictHandler
224    done_copy_moves: Vec<DoneCopyMove>,
225}
226
227impl ConflictHandler {
228    /// Creates a new `ConflictHandler` instance.
229    /// We check for conflict and create the temporary folder if needed.
230    fn new<P>(copy_move: CopyMove, sources: &[PathBuf], dest: P) -> Result<Self>
231    where
232        P: AsRef<std::path::Path>,
233    {
234        let has_conflict = ConflictHandler::check_filename_conflict(sources, &dest)?;
235        let temp_dest: PathBuf;
236        let final_dest: Option<PathBuf>;
237        if has_conflict {
238            temp_dest = Self::create_temporary_destination(&dest)?;
239            final_dest = Some(dest.as_ref().to_path_buf());
240        } else {
241            temp_dest = dest.as_ref().to_path_buf();
242            final_dest = None;
243        };
244        let done_copy_moves = vec![];
245        let sources = sources.to_vec();
246
247        Ok(Self {
248            copy_move,
249            sources,
250            temp_dest,
251            has_conflict,
252            final_dest,
253            done_copy_moves,
254        })
255    }
256
257    /// Creates a randomly named folder in the destination.
258    /// The name is `fm-random` where `random` is a random string of length 7.
259    fn create_temporary_destination<P>(dest: P) -> Result<PathBuf>
260    where
261        P: AsRef<std::path::Path>,
262    {
263        let mut temp_dest = dest.as_ref().to_path_buf();
264        let rand_str = random_name();
265        temp_dest.push(rand_str);
266        std::fs::create_dir(&temp_dest)?;
267        Ok(temp_dest)
268    }
269
270    /// Move every file from `temp_dest` to `final_dest`.
271    /// If the `final_dest` already contains a file with the same name,
272    /// the moved file has enough `_` appended to its name to make it unique.
273    fn move_copied_files_to_dest(&mut self) -> Result<()> {
274        while let Some(from) = self.sources.pop() {
275            let done_copy_move = self.move_single_file_to_dest(from)?;
276            self.done_copy_moves.push(done_copy_move);
277        }
278
279        if self.temp_dest.read_dir()?.next().is_some() {
280            bail!(
281                "temp_dest {temp_dest} should be empty.",
282                temp_dest = self.temp_dest.display()
283            )
284        }
285        Ok(())
286    }
287
288    /// Move a single file to `final_dest`.
289    /// If the file already exists in `final_dest` the moved one has enough '_' appended
290    /// to its name to make it unique.
291    fn move_single_file_to_dest(&mut self, from: PathBuf) -> Result<DoneCopyMove> {
292        let filename = from.file_name().context("Should have a filename")?;
293        let mut filename = filename.to_string_lossy().to_string();
294        let mut temp_path = self.temp_dest.clone();
295        temp_path.push(&filename);
296
297        let mut final_to = self
298            .final_dest
299            .clone()
300            .context("Final dest shouldn't be None")?;
301        final_to.push(&filename);
302        while final_to.exists() {
303            final_to.pop();
304            filename.push('_');
305            final_to.push(&filename);
306        }
307        std::fs::rename(temp_path, &final_to)?;
308        Ok(DoneCopyMove {
309            copy_move: self.copy_move,
310            from,
311            final_to,
312        })
313    }
314
315    /// True iff `dest` contains any file with the same file name as one of `sources`.
316    fn check_filename_conflict<P>(sources: &[PathBuf], dest: P) -> Result<bool>
317    where
318        P: AsRef<std::path::Path>,
319    {
320        for file in sources {
321            let filename = file.file_name().context("Couldn't read filename")?;
322            let mut new_path = dest.as_ref().to_path_buf();
323            new_path.push(filename);
324            if new_path.exists() {
325                return Ok(true);
326            }
327        }
328        Ok(false)
329    }
330
331    /// Does nothing if there's no conflicting filenames during the copy/move.
332    /// Move back every file, appending '_' to their name until the name is unique.
333    /// Delete the temp folder.
334    fn solve_conflicts(&mut self) -> Result<Vec<DoneCopyMove>> {
335        if self.has_conflict {
336            self.move_copied_files_to_dest()?;
337            self.delete_temp_dest()?;
338        } else {
339            self.build_non_conflict_copy_moves()?;
340        }
341        Ok(std::mem::take(&mut self.done_copy_moves))
342    }
343
344    /// Fill the vector of done action during this copy_move.
345    fn build_non_conflict_copy_moves(&mut self) -> Result<()> {
346        while let Some(from) = self.sources.pop() {
347            let filename = from.file_name().context("Should have a filename")?;
348
349            let mut final_to = self.temp_dest.clone();
350            final_to.push(filename);
351            self.done_copy_moves.push(DoneCopyMove {
352                copy_move: self.copy_move,
353                from,
354                final_to,
355            })
356        }
357        Ok(())
358    }
359
360    /// Delete the temporary folder used when copying files.
361    /// An error is returned if the temporary foldern isn't empty which
362    /// should always be the case.
363    fn delete_temp_dest(&self) -> Result<()> {
364        std::fs::remove_dir(&self.temp_dest)?;
365        Ok(())
366    }
367}
368
369impl Drop for ConflictHandler {
370    /// To ensure the folder is deleted no matter what.
371    fn drop(&mut self) {
372        let _ = self.delete_temp_dest();
373    }
374}
375
376/// Send a notification to the desktop.
377/// Does nothing if "notify-send" isn't installed.
378fn notify(text: &str) -> Result<()> {
379    if is_in_path(NOTIFY_EXECUTABLE) {
380        execute(NOTIFY_EXECUTABLE, &[text])?;
381    }
382    Ok(())
383}