fm/modes/menu/
copy_move.rs

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