cosmic_files/operation/
mod.rs

1use crate::app::{ArchiveType, DialogPage, Message, REPLACE_BUTTON_ID};
2use crate::config::IconSizes;
3use crate::spawn_detached::spawn_detached;
4use crate::{archive, fl, tab};
5use cosmic::iced::futures::channel::mpsc::Sender;
6use cosmic::iced::futures::{self, SinkExt, StreamExt, stream};
7use std::borrow::Cow;
8use std::fmt::Formatter;
9use std::fs;
10use std::io::{self, Read, Write};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use tokio::sync::{Mutex as TokioMutex, mpsc};
14use walkdir::WalkDir;
15use zip::AesMode::Aes256;
16
17pub use self::controller::{Controller, ControllerState};
18pub mod controller;
19
20pub use notifiers::*;
21mod notifiers;
22
23pub use self::reader::OpReader;
24pub mod reader;
25
26use self::recursive::{Context, Method};
27pub mod recursive;
28
29async fn handle_replace(
30    msg_tx: Arc<TokioMutex<Sender<Message>>>,
31    file_from: PathBuf,
32    file_to: PathBuf,
33    multiple: bool,
34    conflict_count: usize,
35) -> ReplaceResult {
36    let item_from = match tab::item_from_path(file_from, IconSizes::default()) {
37        Ok(ok) => Box::new(ok),
38        Err(err) => {
39            log::warn!("{err}");
40            return ReplaceResult::Cancel;
41        }
42    };
43
44    let item_to = match tab::item_from_path(file_to, IconSizes::default()) {
45        Ok(ok) => Box::new(ok),
46        Err(err) => {
47            log::warn!("{err}");
48            return ReplaceResult::Cancel;
49        }
50    };
51
52    let (tx, mut rx) = mpsc::channel(1);
53    let _ = msg_tx
54        .lock()
55        .await
56        .send(Message::DialogPush(
57            DialogPage::Replace {
58                from: item_from,
59                to: item_to,
60                multiple,
61                apply_to_all: false,
62                conflict_count,
63                tx,
64            },
65            Some(REPLACE_BUTTON_ID.clone()),
66        ))
67        .await;
68    rx.recv().await.unwrap_or(ReplaceResult::Cancel)
69}
70
71fn get_directory_name(file_name: &str) -> &str {
72    // TODO: Chain with COMPOUND_EXTENSIONS once more formats are supported
73    for ext in crate::archive::SUPPORTED_EXTENSIONS {
74        if let Some(stripped) = file_name.strip_suffix(ext) {
75            return stripped;
76        }
77    }
78    file_name
79}
80
81#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
82pub enum ReplaceResult {
83    Replace(bool),
84    KeepBoth,
85    Skip(bool),
86    Cancel,
87}
88
89async fn copy_or_move(
90    paths: Vec<PathBuf>,
91    to: PathBuf,
92    method: Method,
93    msg_tx: &Arc<TokioMutex<Sender<Message>>>,
94    controller: Controller,
95) -> Result<OperationSelection, OperationError> {
96    let msg_tx = msg_tx.clone();
97    let controller_c = controller.clone();
98
99    compio::runtime::spawn(async move {
100        let controller = controller_c;
101        log::info!(
102            "{} {:?} to {}",
103            match method {
104                Method::Copy => "Copy",
105                Method::Move { .. } => "Move",
106            },
107            paths,
108            to.display()
109        );
110
111        // Handle duplicate file names by renaming paths
112        let from_to_pairs_iter = paths
113            .into_iter()
114            .zip(std::iter::repeat(to.as_path()))
115            .filter_map(|(from, to)| {
116                if matches!(from.parent(), Some(parent) if parent == to)
117                    && matches!(method, Method::Copy)
118                {
119                    // `from`'s parent is equal to `to` which means we're copying to the same
120                    // directory (duplicating files)
121                    let to = copy_unique_path(&from, to);
122                    Some((from, to))
123                } else if let Some(name) = from.file_name() {
124                    let to = to.join(name);
125                    Some((from, to))
126                } else {
127                    //TODO: how to handle from missing file name?
128                    None
129                }
130            });
131
132        // Attempt quick and simple renames
133        //TODO: allow rename to be used for directories in recursive context?
134
135        let from_to_pairs: Vec<(PathBuf, PathBuf)> = if matches!(method, Method::Move { .. }) {
136            from_to_pairs_iter
137                .map(|(from, to)| async move {
138                    //TODO: show replace dialog here?
139                    if to.exists() {
140                        return Some((from, to));
141                    }
142
143                    match compio::fs::rename(&from, &to).await {
144                        Ok(()) => {
145                            log::info!("renamed {} to {}", from.display(), to.display());
146                            None
147                        }
148                        Err(err) => {
149                            log::info!(
150                                "failed to rename {} to {}, fallback to recursive move: {}",
151                                from.display(),
152                                to.display(),
153                                err
154                            );
155                            Some((from, to))
156                        }
157                    }
158                })
159                .collect::<cosmic::iced::futures::stream::FuturesOrdered<_>>()
160                .fold(Vec::new(), |mut pairs, pair| async move {
161                    if let Some(pair) = pair {
162                        pairs.push(pair);
163                    }
164                    pairs
165                })
166                .await
167        } else {
168            from_to_pairs_iter.collect()
169        };
170
171        let mut context = Context::new(controller.clone());
172
173        {
174            let controller = controller.clone();
175            context = context.on_progress(move |_op, progress| {
176                let item_progress = match progress.total_bytes {
177                    Some(total_bytes) => {
178                        if total_bytes == 0 {
179                            1.0
180                        } else {
181                            progress.current_bytes as f32 / total_bytes as f32
182                        }
183                    }
184                    None => 0.0,
185                };
186                let total_progress =
187                    (item_progress + progress.current_ops as f32) / progress.total_ops as f32;
188                controller.set_progress(total_progress);
189            });
190        }
191
192        {
193            let msg_tx = msg_tx.clone();
194            context = context.on_replace(move |op, conflict_count| {
195                let msg_tx = msg_tx.clone();
196                Box::pin(handle_replace(
197                    msg_tx,
198                    op.from.clone(),
199                    op.to.clone(),
200                    true,
201                    conflict_count,
202                ))
203            });
204        }
205
206        context
207            .recursive_copy_or_move(from_to_pairs, method)
208            .await?;
209
210        Result::<OperationSelection, OperationError>::Ok(context.op_sel)
211    })
212    .await
213    .map_err(wrap_compio_spawn_error)?
214}
215
216pub async fn sync_to_disk(
217    written_files: Vec<PathBuf>,
218    target_dirs: std::collections::HashSet<PathBuf>,
219) {
220    // Sync files to disk
221    stream::iter(written_files.into_iter().map(|path| async move {
222        if let Ok(file) = compio::fs::OpenOptions::new().write(true).open(&path).await {
223            let _ = file.sync_all().await;
224        }
225    }))
226    .buffer_unordered(32)
227    .collect::<()>()
228    .await;
229
230    // Sync directories to disk
231    stream::iter(target_dirs.into_iter().map(|path| async move {
232        if let Ok(dir) = compio::fs::OpenOptions::new().read(true).open(&path).await {
233            let _ = dir.sync_all().await;
234        }
235    }))
236    .buffer_unordered(16)
237    .collect::<()>()
238    .await;
239}
240
241pub fn copy_unique_path(from: &Path, to: &Path) -> PathBuf {
242    // List of compound extensions to check
243    const COMPOUND_EXTENSIONS: &[&str] = &[
244        ".tar.gz",
245        ".tar.bz2",
246        ".tar.xz",
247        ".tar.zst",
248        ".tar.lz",
249        ".tar.lzma",
250        ".tar.sz",
251        ".tar.lzo",
252        ".tar.br",
253        ".tar.Z",
254        ".tar.pz",
255    ];
256
257    let mut to = to.to_owned();
258    if let Some(file_name) = from.file_name().and_then(|name| name.to_str()) {
259        let (stem, ext) = if from.is_dir() {
260            (file_name.to_string(), None)
261        } else {
262            let file_name = file_name.to_string();
263            COMPOUND_EXTENSIONS
264                .iter()
265                .copied()
266                .find(|&ext| file_name.ends_with(ext))
267                .map(|ext| {
268                    (
269                        file_name.strip_suffix(ext).unwrap().to_string(),
270                        Some(ext[1..].to_string()),
271                    )
272                })
273                .unwrap_or_else(|| {
274                    from.file_stem()
275                        .and_then(|s| s.to_str())
276                        .map_or((file_name, None), |stem| {
277                            (
278                                stem.to_string(),
279                                from.extension()
280                                    .and_then(|e| e.to_str())
281                                    .map(str::to_string),
282                            )
283                        })
284                })
285        };
286
287        for n in 0.. {
288            let new_name = if n == 0 {
289                file_name.to_string()
290            } else {
291                match ext {
292                    Some(ref ext) => format!("{} ({} {}).{}", stem, fl!("copy_noun"), n, ext),
293                    None => format!("{} ({} {})", stem, fl!("copy_noun"), n),
294                }
295            };
296
297            to.push(&new_name);
298
299            if !matches!(to.try_exists(), Ok(true)) {
300                break;
301            }
302            // Continue if a copy with index exists
303            to.pop();
304        }
305    }
306    to
307}
308
309fn file_name(path: &Path) -> Cow<'_, str> {
310    path.file_name()
311        .map_or_else(|| fl!("unknown-folder").into(), |x| x.to_string_lossy())
312}
313
314fn parent_name(path: &Path) -> Cow<'_, str> {
315    let Some(parent) = path.parent() else {
316        return fl!("unknown-folder").into();
317    };
318
319    file_name(parent)
320}
321
322fn paths_parent_name(paths: &[PathBuf]) -> Cow<'_, str> {
323    let Some(first_path) = paths.first() else {
324        return fl!("unknown-folder").into();
325    };
326
327    let Some(parent) = first_path.parent() else {
328        return fl!("unknown-folder").into();
329    };
330
331    for path in paths {
332        //TODO: is it possible to have different parents, and what should be returned?
333        if path.parent() != Some(parent) {
334            return fl!("unknown-folder").into();
335        }
336    }
337
338    file_name(parent)
339}
340
341#[derive(Clone, Debug, Default)]
342pub struct OperationSelection {
343    // Paths to ignore if they are already selected
344    pub ignored: Vec<PathBuf>,
345    // Paths to select
346    pub selected: Vec<PathBuf>,
347}
348
349#[derive(Clone, Debug, Eq, Hash, PartialEq)]
350pub enum Operation {
351    /// Compress files
352    Compress {
353        paths: Vec<PathBuf>,
354        to: PathBuf,
355        archive_type: ArchiveType,
356        password: Option<String>,
357    },
358    /// Copy items
359    Copy {
360        paths: Vec<PathBuf>,
361        to: PathBuf,
362    },
363    /// Move items to the trash
364    Delete {
365        paths: Vec<PathBuf>,
366    },
367    /// Delete a path from the trash
368    DeleteTrash {
369        items: Vec<trash::TrashItem>,
370    },
371    /// Empty the trash
372    EmptyTrash,
373    /// Uncompress files
374    Extract {
375        paths: Box<[PathBuf]>,
376        to: PathBuf,
377        password: Option<String>,
378    },
379    /// Move items
380    Move {
381        paths: Vec<PathBuf>,
382        to: PathBuf,
383        cross_device_copy: bool,
384    },
385    NewFile {
386        path: PathBuf,
387    },
388    NewFolder {
389        path: PathBuf,
390    },
391    /// Permanently delete items, skipping the trash
392    PermanentlyDelete {
393        paths: Box<[PathBuf]>,
394    },
395    RemoveFromRecents {
396        paths: Box<[PathBuf]>,
397    },
398    Rename {
399        from: PathBuf,
400        to: PathBuf,
401    },
402    /// Restore a path from the trash
403    Restore {
404        items: Vec<trash::TrashItem>,
405    },
406    /// Set executable and launch
407    SetExecutableAndLaunch {
408        path: PathBuf,
409    },
410    /// Set permissions
411    SetPermissions {
412        path: PathBuf,
413        mode: u32,
414    },
415}
416
417#[derive(Clone, Debug)]
418pub enum OperationErrorType {
419    Generic(String),
420    PasswordRequired,
421}
422#[derive(Clone, Debug)]
423pub struct OperationError {
424    pub kind: OperationErrorType,
425}
426
427impl OperationError {
428    pub fn from_state(state: ControllerState, controller: &Controller) -> Self {
429        let message = if state == ControllerState::Failed {
430            controller.set_state(ControllerState::Failed);
431            fl!("failed")
432        } else {
433            controller.cancel();
434            fl!("cancelled")
435        };
436
437        Self {
438            kind: OperationErrorType::Generic(message),
439        }
440    }
441
442    pub fn from_err<T: ToString>(err: T, controller: &Controller) -> Self {
443        controller.set_state(ControllerState::Failed);
444
445        Self {
446            kind: OperationErrorType::Generic(err.to_string()),
447        }
448    }
449
450    pub fn from_kind(kind: OperationErrorType, controller: &Controller) -> Self {
451        controller.set_state(ControllerState::Failed);
452        Self { kind }
453    }
454
455    pub fn from_msg(m: impl Into<String>) -> Self {
456        Self {
457            kind: OperationErrorType::Generic(m.into()),
458        }
459    }
460}
461
462impl std::error::Error for OperationError {}
463
464impl std::fmt::Display for OperationError {
465    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
466        match &self.kind {
467            OperationErrorType::Generic(s) => s.fmt(f),
468            OperationErrorType::PasswordRequired => f.write_str("Password required"),
469        }
470    }
471}
472
473impl Operation {
474    pub fn pending_text(&self, ratio: f32, state: ControllerState) -> String {
475        let percent = (ratio * 100.0) as i32;
476        let progress = || match state {
477            ControllerState::Running => fl!("progress", percent = percent),
478            ControllerState::Paused => fl!("progress-paused", percent = percent),
479            ControllerState::Cancelled => fl!("progress-cancelled", percent = percent),
480            ControllerState::Failed => fl!("progress-failed", percent = percent),
481        };
482        match self {
483            Self::Compress { paths, to, .. } => fl!(
484                "compressing",
485                items = paths.len(),
486                from = paths_parent_name(paths),
487                to = file_name(to),
488                progress = progress()
489            ),
490            Self::Copy { paths, to } => fl!(
491                "copying",
492                items = paths.len(),
493                from = paths_parent_name(paths),
494                to = file_name(to),
495                progress = progress()
496            ),
497            Self::Delete { paths } => fl!(
498                "moving",
499                items = paths.len(),
500                from = paths_parent_name(paths),
501                to = fl!("trash"),
502                progress = progress()
503            ),
504            Self::DeleteTrash { items } => {
505                fl!("deleting", items = items.len(), progress = progress())
506            }
507            Self::EmptyTrash => fl!("emptying-trash", progress = progress()),
508            Self::Extract {
509                paths,
510                to,
511                password: _,
512            } => fl!(
513                "extracting",
514                items = paths.len(),
515                from = paths_parent_name(paths),
516                to = file_name(to),
517                progress = progress()
518            ),
519            Self::Move { paths, to, .. } => fl!(
520                "moving",
521                items = paths.len(),
522                from = paths_parent_name(paths),
523                to = file_name(to),
524                progress = progress()
525            ),
526            Self::NewFile { path } => fl!(
527                "creating",
528                name = file_name(path),
529                parent = parent_name(path)
530            ),
531            Self::NewFolder { path } => fl!(
532                "creating",
533                name = file_name(path),
534                parent = parent_name(path)
535            ),
536            Self::PermanentlyDelete { paths } => fl!("permanently-deleting", items = paths.len()),
537            Self::Rename { from, to } => {
538                fl!("renaming", from = file_name(from), to = file_name(to))
539            }
540            Self::RemoveFromRecents { paths } => fl!("removing-from-recents", items = paths.len()),
541            Self::Restore { items } => fl!("restoring", items = items.len(), progress = progress()),
542            Self::SetExecutableAndLaunch { path } => {
543                fl!("setting-executable-and-launching", name = file_name(path))
544            }
545            Self::SetPermissions { path, mode } => {
546                fl!(
547                    "setting-permissions",
548                    name = file_name(path),
549                    mode = format!("{:#03o}", mode)
550                )
551            }
552        }
553    }
554
555    pub fn completed_text(&self) -> String {
556        match self {
557            Self::Compress { paths, to, .. } => fl!(
558                "compressed",
559                items = paths.len(),
560                from = paths_parent_name(paths),
561                to = file_name(to)
562            ),
563            Self::Copy { paths, to } => fl!(
564                "copied",
565                items = paths.len(),
566                from = paths_parent_name(paths),
567                to = file_name(to)
568            ),
569            Self::Delete { paths } => fl!(
570                "moved",
571                items = paths.len(),
572                from = paths_parent_name(paths),
573                to = fl!("trash")
574            ),
575            Self::DeleteTrash { items } => fl!("deleted", items = items.len()),
576            Self::EmptyTrash => fl!("emptied-trash"),
577            Self::Extract {
578                paths,
579                to,
580                password: _,
581            } => fl!(
582                "extracted",
583                items = paths.len(),
584                from = paths_parent_name(paths),
585                to = file_name(to)
586            ),
587            Self::Move { paths, to, .. } => fl!(
588                "moved",
589                items = paths.len(),
590                from = paths_parent_name(paths),
591                to = file_name(to)
592            ),
593            Self::NewFile { path } => fl!(
594                "created",
595                name = file_name(path),
596                parent = parent_name(path)
597            ),
598            Self::NewFolder { path } => fl!(
599                "created",
600                name = file_name(path),
601                parent = parent_name(path)
602            ),
603            Self::PermanentlyDelete { paths } => fl!("permanently-deleted", items = paths.len()),
604            Self::RemoveFromRecents { paths } => fl!("removed-from-recents", items = paths.len()),
605            Self::Rename { from, to } => fl!("renamed", from = file_name(from), to = file_name(to)),
606            Self::Restore { items } => fl!("restored", items = items.len()),
607            Self::SetExecutableAndLaunch { path } => {
608                fl!("set-executable-and-launched", name = file_name(path))
609            }
610            Self::SetPermissions { path, mode } => {
611                fl!(
612                    "set-permissions",
613                    name = file_name(path),
614                    mode = format!("{:#03o}", mode)
615                )
616            }
617        }
618    }
619
620    pub const fn show_progress_notification(&self) -> bool {
621        // Long running operations show a progress notification
622        match self {
623            Self::Compress { .. }
624            | Self::Copy { .. }
625            | Self::Delete { .. }
626            | Self::DeleteTrash { .. }
627            | Self::EmptyTrash
628            | Self::Extract { .. }
629            | Self::Move { .. }
630            | Self::PermanentlyDelete { .. }
631            | Self::Restore { .. } => true,
632            Self::NewFile { .. }
633            | Self::NewFolder { .. }
634            | Self::RemoveFromRecents { .. }
635            | Self::Rename { .. }
636            | Self::SetExecutableAndLaunch { .. }
637            | Self::SetPermissions { .. } => false,
638        }
639    }
640
641    pub fn toast(&self) -> Option<String> {
642        match self {
643            Self::Compress { .. } => Some(self.completed_text()),
644            Self::Delete { .. } => Some(self.completed_text()),
645            Self::Extract { .. } => Some(self.completed_text()),
646            //TODO: more toasts
647            _ => None,
648        }
649    }
650
651    /// Perform the operation
652    pub async fn perform(
653        self,
654        msg_tx: &Arc<TokioMutex<Sender<Message>>>,
655        controller: Controller,
656    ) -> Result<OperationSelection, OperationError> {
657        let controller_clone = controller.clone();
658
659        //TODO: IF ERROR, RETURN AN Operation THAT CAN UNDO THE CURRENT STATE
660        let paths: Result<OperationSelection, OperationError> = match self {
661            Self::Compress {
662                paths,
663                to,
664                archive_type,
665                password,
666            } => {
667                let controller_c = controller.clone();
668                compio::runtime::spawn_blocking(
669                    move || -> Result<OperationSelection, OperationError> {
670                        let controller = controller_c;
671                        let Some(relative_root) = to.parent() else {
672                            return Err(OperationError::from_err(
673                                format!("path {} has no parent directory", to.display()),
674                                &controller,
675                            ));
676                        };
677
678                        let op_sel = OperationSelection {
679                            ignored: paths.clone(),
680                            selected: vec![to.clone()],
681                        };
682
683                        let mut paths = paths;
684                        for path in &paths.clone() {
685                            if path.is_dir() {
686                                let new_paths_it = WalkDir::new(path).into_iter();
687                                for entry in new_paths_it.skip(1) {
688                                    let entry = entry
689                                        .map_err(|e| OperationError::from_err(e, &controller))?;
690                                    paths.push(entry.into_path());
691                                }
692                            }
693                        }
694
695                        match archive_type {
696                            ArchiveType::Tgz => {
697                                let mut archive = fs::File::create(&to)
698                                    .map(io::BufWriter::new)
699                                    .map(|w| {
700                                        flate2::write::GzEncoder::new(
701                                            w,
702                                            flate2::Compression::default(),
703                                        )
704                                    })
705                                    .map(tar::Builder::new)
706                                    .map_err(|e| OperationError::from_err(e, &controller))?;
707
708                                let total_paths = paths.len();
709                                for (i, path) in paths.iter().enumerate() {
710                                    futures::executor::block_on(async {
711                                        controller
712                                            .check()
713                                            .await
714                                            .map_err(|e| OperationError::from_state(e, &controller))
715                                    })?;
716
717                                    controller.set_progress((i as f32) / total_paths as f32);
718
719                                    if let Some(relative_path) = path
720                                        .strip_prefix(relative_root)
721                                        .map_err(|e| OperationError::from_err(e, &controller))?
722                                        .to_str()
723                                    {
724                                        archive
725                                            .append_path_with_name(path, relative_path)
726                                            .map_err(|e| {
727                                                OperationError::from_err(e, &controller)
728                                            })?;
729                                    }
730                                }
731
732                                archive
733                                    .finish()
734                                    .map_err(|e| OperationError::from_err(e, &controller))?;
735                            }
736                            ArchiveType::Zip => {
737                                let mut archive = fs::File::create(&to)
738                                    .map(io::BufWriter::new)
739                                    .map(zip::ZipWriter::new)
740                                    .map_err(|e| OperationError::from_err(e, &controller))?;
741
742                                let total_paths = paths.len();
743                                let mut buffer = vec![0; 4 * 1024 * 1024];
744                                for (i, path) in paths.iter().enumerate() {
745                                    futures::executor::block_on(async {
746                                        controller
747                                            .check()
748                                            .await
749                                            .map_err(|s| OperationError::from_state(s, &controller))
750                                    })?;
751
752                                    controller.set_progress((i as f32) / total_paths as f32);
753
754                                    let mut zip_options = zip::write::SimpleFileOptions::default();
755                                    if password.is_some() {
756                                        zip_options = zip_options.with_aes_encryption(
757                                            Aes256,
758                                            password.as_deref().unwrap(),
759                                        );
760                                    }
761                                    if let Some(relative_path) = path
762                                        .strip_prefix(relative_root)
763                                        .map_err(|e| OperationError::from_err(e, &controller))?
764                                        .to_str()
765                                    {
766                                        let mut file = fs::File::open(path).map_err(|e| {
767                                            OperationError::from_err(e, &controller)
768                                        })?;
769                                        let metadata = file.metadata().map_err(|e| {
770                                            OperationError::from_err(e, &controller)
771                                        })?;
772
773                                        if let Ok(modified) = metadata.modified()
774                                            && let Some(last_modified) =
775                                                archive::system_time_to_zip_date_time(modified)
776                                        {
777                                            zip_options =
778                                                zip_options.last_modified_time(last_modified);
779                                        }
780
781                                        #[cfg(unix)]
782                                        {
783                                            use std::os::unix::fs::MetadataExt;
784                                            let mode = metadata.mode();
785                                            zip_options = zip_options.unix_permissions(mode);
786                                        }
787
788                                        if path.is_file() {
789                                            let total = metadata.len();
790                                            if total >= 4 * 1024 * 1024 * 1024 {
791                                                // The large file option must be enabled for files above 4 GiB
792                                                zip_options = zip_options.large_file(true);
793                                            }
794                                            archive
795                                                .start_file(relative_path, zip_options)
796                                                .map_err(|e| {
797                                                    OperationError::from_err(e, &controller)
798                                                })?;
799                                            let mut current = 0;
800                                            loop {
801                                                futures::executor::block_on(async {
802                                                    controller.check().await.map_err(|s| {
803                                                        OperationError::from_state(s, &controller)
804                                                    })
805                                                })?;
806
807                                                let count =
808                                                    file.read(&mut buffer).map_err(|e| {
809                                                        OperationError::from_err(e, &controller)
810                                                    })?;
811                                                if count == 0 {
812                                                    break;
813                                                }
814                                                archive.write_all(&buffer[..count]).map_err(
815                                                    |e| OperationError::from_err(e, &controller),
816                                                )?;
817                                                current += count;
818
819                                                let file_progress = current as f32 / total as f32;
820                                                let total_progress =
821                                                    (i as f32 + file_progress) / total_paths as f32;
822                                                controller.set_progress(total_progress);
823                                            }
824                                        } else {
825                                            archive
826                                                .add_directory(relative_path, zip_options)
827                                                .map_err(|e| {
828                                                    OperationError::from_err(e, &controller)
829                                                })?;
830                                        }
831                                    }
832                                }
833
834                                archive
835                                    .finish()
836                                    .map_err(|e| OperationError::from_err(e, &controller))?;
837                            }
838                        }
839
840                        Ok(op_sel)
841                    },
842                )
843                .await
844                .map_err(wrap_compio_spawn_error)?
845            }
846            Self::Copy { paths, to } => {
847                copy_or_move(paths, to, Method::Copy, msg_tx, controller).await
848            }
849            Self::Delete { paths } => {
850                let total = paths.len();
851                for (i, path) in paths.into_iter().enumerate() {
852                    futures::executor::block_on(async {
853                        controller
854                            .check()
855                            .await
856                            .map_err(|s| OperationError::from_state(s, &controller))
857                    })?;
858
859                    controller.set_progress((i as f32) / (total as f32));
860
861                    let _items_opt = compio::runtime::spawn_blocking(|| trash::delete(path))
862                        .await
863                        .map_err(wrap_compio_spawn_error)?;
864                    //TODO: items_opt allows for easy restore
865                }
866                Ok(OperationSelection::default())
867            }
868            Self::DeleteTrash { items } => {
869                #[cfg(any(
870                    target_os = "windows",
871                    all(
872                        unix,
873                        not(target_os = "macos"),
874                        not(target_os = "ios"),
875                        not(target_os = "android")
876                    )
877                ))]
878                {
879                    let controller_clone = controller.clone();
880                    compio::runtime::spawn_blocking(move || -> Result<(), OperationError> {
881                        let controller = controller_clone;
882                        let count = items.len();
883                        for (i, item) in items.into_iter().enumerate() {
884                            futures::executor::block_on(async {
885                                controller
886                                    .check()
887                                    .await
888                                    .map_err(|s| OperationError::from_state(s, &controller))
889                            })?;
890
891                            controller.set_progress(i as f32 / count as f32);
892
893                            trash::os_limited::purge_all([item])
894                                .map_err(|e| OperationError::from_err(e, &controller))?;
895                        }
896                        Ok(())
897                    })
898                    .await
899                    .map_err(wrap_compio_spawn_error)?
900                    .map_err(|e| OperationError::from_err(e, &controller))?;
901                }
902                Ok(OperationSelection::default())
903            }
904            Self::EmptyTrash => {
905                #[cfg(any(
906                    target_os = "windows",
907                    all(
908                        unix,
909                        not(target_os = "macos"),
910                        not(target_os = "ios"),
911                        not(target_os = "android")
912                    )
913                ))]
914                {
915                    let controller_clone = controller.clone();
916                    compio::runtime::spawn_blocking(move || -> Result<(), OperationError> {
917                        let controller = controller_clone;
918                        let items = trash::os_limited::list()
919                            .map_err(|e| OperationError::from_err(e, &controller))?;
920                        let count = items.len();
921                        let mut errors: Vec<trash::Error> = Vec::new();
922
923                        for (i, item) in items.into_iter().enumerate() {
924                            futures::executor::block_on(async {
925                                controller
926                                    .check()
927                                    .await
928                                    .map_err(|s| OperationError::from_state(s, &controller))
929                            })?;
930
931                            if let Err(e) = trash::os_limited::purge_all([item]) {
932                                errors.push(e);
933                            }
934
935                            controller.set_progress(i as f32 / count as f32);
936                        }
937
938                        // Report errors at the end
939                        if !errors.is_empty() {
940                            log::warn!("Failed to purge {} items:", errors.len());
941                            for e in &errors {
942                                log::warn!("  - {e}");
943                            }
944
945                            // Return an error to signal partial failure
946                            return Err(OperationError::from_err(
947                                format!(
948                                    "Failed to delete {} of {} items. Check log for details.",
949                                    errors.len(),
950                                    count
951                                ),
952                                &controller,
953                            ));
954                        }
955
956                        Ok(())
957                    })
958                    .await
959                    .map_err(wrap_compio_spawn_error)?
960                    .map_err(|e| OperationError::from_err(e, &controller))?;
961                }
962                Ok(OperationSelection::default())
963            }
964            Self::Extract {
965                paths,
966                to,
967                password,
968            } => {
969                let controller_clone = controller.clone();
970                compio::runtime::spawn(async move {
971                    let extracted = compio::runtime::spawn_blocking(move || {
972                        let controller = controller_clone;
973                        let total_paths = paths.len();
974                        let mut op_sel = OperationSelection::default();
975                        let mut written_files = Vec::new();
976                        let mut target_dirs = std::collections::HashSet::new();
977                        for (i, path) in paths.iter().enumerate() {
978                            futures::executor::block_on(async {
979                                controller
980                                    .check()
981                                    .await
982                                    .map_err(|s| OperationError::from_state(s, &controller))
983                            })?;
984
985                            controller.set_progress((i as f32) / total_paths as f32);
986
987                            if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
988                                let dir_name = get_directory_name(file_name);
989                                let mut new_dir = to.join(dir_name);
990
991                                if new_dir.exists()
992                                    && let Some(new_dir_parent) = new_dir.parent()
993                                {
994                                    new_dir = copy_unique_path(&new_dir, new_dir_parent);
995                                }
996
997                                op_sel.ignored.push(path.clone());
998                                op_sel.selected.push(new_dir.clone());
999
1000                                let (files, dirs) = crate::archive::extract(
1001                                    path,
1002                                    &new_dir,
1003                                    &password,
1004                                    &controller,
1005                                )?;
1006                                written_files.extend(files);
1007                                target_dirs.extend(dirs);
1008                            }
1009                        }
1010
1011                        Ok::<_, OperationError>((op_sel, written_files, target_dirs))
1012                    })
1013                    .await
1014                    .map_err(wrap_compio_spawn_error)??;
1015
1016                    let (op_sel, written_files, target_dirs) = extracted;
1017                    if !written_files.is_empty() || !target_dirs.is_empty() {
1018                        sync_to_disk(written_files, target_dirs).await;
1019                    }
1020
1021                    Ok::<_, OperationError>(op_sel)
1022                })
1023                .await
1024                .map_err(wrap_compio_spawn_error)?
1025            }
1026            Self::Move {
1027                paths,
1028                to,
1029                cross_device_copy,
1030            } => {
1031                copy_or_move(
1032                    paths,
1033                    to,
1034                    Method::Move { cross_device_copy },
1035                    msg_tx,
1036                    controller,
1037                )
1038                .await
1039            }
1040            Self::NewFolder { path } => {
1041                let controller_clone = controller.clone();
1042                compio::runtime::spawn(async move {
1043                    let controller = controller_clone;
1044                    controller
1045                        .check()
1046                        .await
1047                        .map_err(|s| OperationError::from_state(s, &controller))?;
1048                    compio::fs::create_dir(&path)
1049                        .await
1050                        .map_err(|e| OperationError::from_err(e, &controller))?;
1051                    Result::<_, OperationError>::Ok(OperationSelection {
1052                        ignored: Vec::new(),
1053                        selected: vec![path],
1054                    })
1055                })
1056            }
1057            .await
1058            .map_err(wrap_compio_spawn_error)?,
1059            Self::NewFile { path } => {
1060                let controller_clone = controller.clone();
1061                compio::runtime::spawn(async move {
1062                    let controller = controller_clone;
1063                    controller
1064                        .check()
1065                        .await
1066                        .map_err(|s| OperationError::from_state(s, &controller))?;
1067                    compio::fs::File::create(&path)
1068                        .await
1069                        .map_err(|e| OperationError::from_err(e, &controller))?;
1070                    Result::<_, OperationError>::Ok(OperationSelection {
1071                        ignored: Vec::new(),
1072                        selected: vec![path],
1073                    })
1074                })
1075            }
1076            .await
1077            .map_err(wrap_compio_spawn_error)?,
1078            Self::PermanentlyDelete { paths } => {
1079                let total = paths.len();
1080                for (idx, path) in paths.into_iter().enumerate() {
1081                    controller
1082                        .check()
1083                        .await
1084                        .map_err(|s| OperationError::from_state(s, &controller))?;
1085
1086                    controller.set_progress((idx as f32) / (total as f32));
1087
1088                    tokio::task::spawn_blocking(|| {
1089                        if path.is_symlink() || path.is_file() {
1090                            fs::remove_file(path)
1091                        } else if path.is_dir() {
1092                            fs::remove_dir_all(path)
1093                        } else {
1094                            Err(std::io::Error::new(
1095                                std::io::ErrorKind::InvalidData,
1096                                "File to delete is not symlink, file or directory",
1097                            ))
1098                        }
1099                    })
1100                    .await
1101                    .map_err(|e| OperationError::from_err(e, &controller))?
1102                    .map_err(|e| OperationError::from_err(e, &controller))?;
1103                }
1104
1105                Ok(OperationSelection::default())
1106            }
1107            Self::RemoveFromRecents { paths } => {
1108                tokio::task::spawn_blocking(move || {
1109                    let path_refs = paths.iter().map(PathBuf::as_path).collect::<Box<[_]>>();
1110                    recently_used_xbel::remove_recently_used(&path_refs)
1111                })
1112                .await
1113                .map_err(|e| OperationError::from_err(e, &controller))?
1114                .map_err(|e| OperationError::from_err(e, &controller))?;
1115
1116                Ok(OperationSelection::default())
1117            }
1118            Self::Rename { from, to } => {
1119                let controller_clone = controller.clone();
1120
1121                compio::runtime::spawn(async move {
1122                    let controller = controller_clone;
1123                    controller
1124                        .check()
1125                        .await
1126                        .map_err(|s| OperationError::from_state(s, &controller))?;
1127                    compio::fs::rename(&from, &to)
1128                        .await
1129                        .map_err(|e| OperationError::from_err(e, &controller))?;
1130                    Result::<_, OperationError>::Ok(OperationSelection {
1131                        ignored: vec![from],
1132                        selected: vec![to],
1133                    })
1134                })
1135            }
1136            .await
1137            .map_err(wrap_compio_spawn_error)?,
1138            #[cfg(target_os = "macos")]
1139            Self::Restore { .. } => {
1140                // TODO: add support for macos
1141                return Err(OperationError::from_msg(
1142                    "Restoring from trash is not supported on macos",
1143                ));
1144            }
1145            #[cfg(not(target_os = "macos"))]
1146            Self::Restore { items } => {
1147                let total = items.len();
1148                let mut paths = Vec::with_capacity(total);
1149                for (i, item) in items.into_iter().enumerate() {
1150                    controller
1151                        .check()
1152                        .await
1153                        .map_err(|s| OperationError::from_state(s, &controller))?;
1154
1155                    controller.set_progress((i as f32) / (total as f32));
1156
1157                    paths.push(item.original_path());
1158
1159                    compio::runtime::spawn_blocking(|| trash::os_limited::restore_all([item]))
1160                        .await
1161                        .map_err(wrap_compio_spawn_error)?
1162                        .map_err(|e| OperationError::from_err(e, &controller))?;
1163                }
1164                Ok(OperationSelection {
1165                    ignored: Vec::new(),
1166                    selected: paths,
1167                })
1168            }
1169            Self::SetExecutableAndLaunch { path } => {
1170                controller
1171                    .check()
1172                    .await
1173                    .map_err(|s| OperationError::from_state(s, &controller))?;
1174
1175                let controller_clone = controller.clone();
1176                compio::runtime::spawn_blocking(move || -> Result<(), OperationError> {
1177                    let controller = controller_clone;
1178                    //TODO: what to do on non-Unix systems?
1179                    #[cfg(unix)]
1180                    {
1181                        use std::os::unix::fs::PermissionsExt;
1182
1183                        let mut perms = fs::metadata(&path)
1184                            .map_err(|e| OperationError::from_err(e, &controller))?
1185                            .permissions();
1186                        let current_mode = perms.mode();
1187                        let new_mode = current_mode | 0o111;
1188                        perms.set_mode(new_mode);
1189                        fs::set_permissions(&path, perms)
1190                            .map_err(|e| OperationError::from_err(e, &controller))?;
1191                    }
1192
1193                    let mut command = std::process::Command::new(path);
1194                    spawn_detached(&mut command)
1195                        .map_err(|e| OperationError::from_err(e, &controller))?;
1196
1197                    Ok(())
1198                })
1199                .await
1200                .map_err(wrap_compio_spawn_error)?
1201                .map_err(|e| OperationError::from_err(e, &controller))?;
1202                Ok(OperationSelection::default())
1203            }
1204            Self::SetPermissions { path, mode: _ } => {
1205                controller
1206                    .check()
1207                    .await
1208                    .map_err(|s| OperationError::from_state(s, &controller))?;
1209
1210                let controller_clone = controller.clone();
1211                let path_clone = path.clone();
1212                compio::runtime::spawn_blocking(move || -> Result<(), OperationError> {
1213                    let _controller = controller_clone;
1214                    let _path = path_clone;
1215                    //TODO: what to do on non-Unix systems?
1216                    #[cfg(unix)]
1217                    {
1218                        use std::os::unix::fs::PermissionsExt;
1219                        let perms = fs::Permissions::from_mode(mode);
1220                        fs::set_permissions(&path, perms)
1221                            .map_err(|e| OperationError::from_err(e, &controller))?;
1222                    }
1223
1224                    Ok(())
1225                })
1226                .await
1227                .map_err(wrap_compio_spawn_error)?
1228                .map_err(|e| OperationError::from_err(e, &controller))?;
1229                Ok(OperationSelection {
1230                    ignored: Vec::new(),
1231                    selected: vec![path],
1232                })
1233            }
1234        };
1235
1236        controller_clone.set_progress(1.0);
1237
1238        paths
1239    }
1240}
1241
1242#[track_caller]
1243fn wrap_compio_spawn_error(err: Box<dyn std::any::Any + Send>) -> OperationError {
1244    log::error!(
1245        "compio runtime spawn failed: {}",
1246        std::backtrace::Backtrace::capture()
1247    );
1248
1249    // Preserve error if it's already an OperationError
1250    if let Ok(err) = err.downcast() {
1251        *err
1252    } else {
1253        OperationError::from_msg("compio runtime spawn failed")
1254    }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    use std::fs::{self, File};
1260    use std::io;
1261    use std::path::PathBuf;
1262
1263    use cosmic::iced::futures::channel::mpsc;
1264    use cosmic::iced::futures::{StreamExt, future};
1265    use log::debug;
1266    use test_log::test;
1267    use tokio::sync;
1268
1269    use super::{Controller, Operation, OperationError, OperationSelection, ReplaceResult};
1270    use crate::app::test_utils::{
1271        NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, empty_fs, filter_dirs, filter_files,
1272        simple_fs,
1273    };
1274    use crate::app::{DialogPage, Message};
1275    use crate::fl;
1276
1277    /// Simple wrapper around `[Operation::Copy]`
1278    pub async fn operation_copy(
1279        paths: Vec<PathBuf>,
1280        to: PathBuf,
1281    ) -> Result<OperationSelection, OperationError> {
1282        let id = fastrand::u64(0..u64::MAX);
1283        let (tx, mut rx) = mpsc::channel(1);
1284        let paths_clone = paths.clone();
1285        let to_clone = to.clone();
1286
1287        // Wrap this into its own future so that it may be polled concurerntly with the message handler.
1288        let handle_copy = async move {
1289            Operation::Copy {
1290                paths: paths_clone,
1291                to: to_clone,
1292            }
1293            .perform(&sync::Mutex::new(tx).into(), Controller::default())
1294            .await
1295        };
1296
1297        // Concurrently handling messages will prevent the mpsc channel from blocking when full.
1298        let handle_messages = async move {
1299            while let Some(msg) = rx.next().await {
1300                match msg {
1301                    Message::DialogPush(DialogPage::Replace { tx, .. }, _id_to_focus) => {
1302                        debug!("[{id}] Replace request");
1303                        tx.send(ReplaceResult::Cancel)
1304                            .await
1305                            .expect("Sending a response to a replace request should succeed");
1306                    }
1307                    _ => unreachable!(
1308                        "Only [ `Message::PendingProgress`, `Message::DialogPush(DialogPage::Replace)` ] are sent from operation"
1309                    ),
1310                }
1311            }
1312        };
1313
1314        future::join(handle_messages, handle_copy).await.1
1315    }
1316
1317    #[test(compio::test)]
1318    async fn copy_file_to_same_location() -> io::Result<()> {
1319        let fs = simple_fs(NUM_FILES, 0, 1, 0, NAME_LEN)?;
1320        let path = fs.path();
1321
1322        // Get the first file from the first directory
1323        let first_dir = filter_dirs(path)?
1324            .next()
1325            .expect("Should have at least one directory");
1326        let first_file = filter_files(&first_dir)?
1327            .next()
1328            .expect("Should have at least one file");
1329
1330        // Duplicate that file
1331        let base_name = first_file
1332            .file_name()
1333            .and_then(|name| name.to_str())
1334            .expect("File name exists and is valid");
1335        debug!(
1336            "Duplicating {} in {}",
1337            first_file.display(),
1338            first_dir.display()
1339        );
1340        operation_copy(vec![first_file.clone()], first_dir.clone())
1341            .await
1342            .expect("Copy operation should have succeeded");
1343
1344        assert!(first_file.exists(), "Original file should still exist");
1345        let expected = first_dir.join(format!("{base_name} ({} 1)", fl!("copy_noun")));
1346        assert!(expected.exists(), "File should have been duplicated");
1347
1348        Ok(())
1349    }
1350
1351    #[test(compio::test)]
1352    async fn copy_file_with_extension_to_same_loc() -> io::Result<()> {
1353        let fs = empty_fs()?;
1354        let path = fs.path();
1355
1356        let base_name = "foo.txt";
1357        let base_path = path.join(base_name);
1358        File::create(&base_path)?;
1359        debug!("Duplicating {}", base_path.display());
1360        operation_copy(vec![base_path.clone()], path.to_owned())
1361            .await
1362            .expect("Copy operation should have succeeded");
1363
1364        assert!(base_path.exists(), "Original file should still exist");
1365        let expected = path.join(format!("foo ({} 1).txt", fl!("copy_noun")));
1366        assert!(expected.exists(), "File should have been duplicated");
1367
1368        Ok(())
1369    }
1370
1371    #[test(compio::test)]
1372    async fn copy_dir_to_same_location() -> io::Result<()> {
1373        let fs = simple_fs(NUM_FILES, 0, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
1374        let path = fs.path();
1375
1376        // First directory path
1377        let first_dir = filter_dirs(path)?
1378            .next()
1379            .expect("Should have at least one directory");
1380        let base_name = first_dir
1381            .file_name()
1382            .and_then(|name| name.to_str())
1383            .expect("First directory exists and has a valid name");
1384        debug!("Duplicating directory {}", first_dir.display());
1385        operation_copy(vec![first_dir.clone()], path.to_owned())
1386            .await
1387            .expect("Copy operation should have succeeded");
1388
1389        assert!(first_dir.exists(), "Original directory should still exist");
1390        let expected = path.join(format!("{base_name} ({} 1)", fl!("copy_noun")));
1391        assert!(expected.exists(), "Directory should have been duplicated");
1392
1393        Ok(())
1394    }
1395
1396    #[test(compio::test)]
1397    async fn copying_file_multiple_times_to_same_location() -> io::Result<()> {
1398        let fs = empty_fs()?;
1399        let path = fs.path();
1400
1401        let base_name = "cosmic";
1402        let base_path = path.join(base_name);
1403        File::create(&base_path)?;
1404
1405        for i in 1..5 {
1406            debug!("Duplicating {}", base_path.display());
1407            operation_copy(vec![base_path.clone()], path.to_owned())
1408                .await
1409                .expect("Copy operation should have succeeded");
1410            assert!(base_path.exists(), "Original file should still exist");
1411            assert!(
1412                path.join(format!("{base_name} ({} {i})", fl!("copy_noun")))
1413                    .exists(),
1414                "File should have been duplicated (copy #{i})"
1415            );
1416        }
1417
1418        Ok(())
1419    }
1420
1421    #[test(compio::test)]
1422    async fn copy_to_diff_dir_doesnt_dupe_files() -> io::Result<()> {
1423        let fs = simple_fs(NUM_FILES, NUM_HIDDEN, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
1424        let path = fs.path();
1425
1426        let (first_dir, second_dir) = {
1427            let mut dirs = filter_dirs(path)?;
1428            (
1429                dirs.next().expect("Should have at least two dirs"),
1430                dirs.next().expect("Should have at least two dirs"),
1431            )
1432        };
1433        let first_file = filter_files(&first_dir)?
1434            .next()
1435            .expect("Should have at least one file");
1436        // Both directories have a file with the same name.
1437        let base_name = first_file
1438            .file_name()
1439            .and_then(|name| name.to_str())
1440            .expect("File name exists and is valid");
1441
1442        debug!(
1443            "Copying {} to {}",
1444            first_file.display(),
1445            second_dir.display()
1446        );
1447        operation_copy(vec![first_file.clone()], second_dir.clone())
1448            .await
1449            .expect(concat!(
1450                "Copy operation should have been cancelled ",
1451                "because we're copying to different directories ",
1452                "without replacement"
1453            ));
1454        assert!(
1455            first_dir.join(base_name).exists(),
1456            "First file should still exist"
1457        );
1458        assert!(
1459            second_dir.join(base_name).exists(),
1460            "Second file should still exist"
1461        );
1462
1463        Ok(())
1464    }
1465
1466    #[test(compio::test)]
1467    async fn copy_file_with_diff_name_to_diff_dir() -> io::Result<()> {
1468        let fs = empty_fs()?;
1469        let path = fs.path();
1470
1471        let dir_path = path.join("cosmic");
1472        fs::create_dir(&dir_path)?;
1473        let file_path = path.join("ferris");
1474        File::create(&file_path)?;
1475        let expected = dir_path.join("ferris");
1476
1477        debug!("Copying {} to {}", file_path.display(), expected.display());
1478        operation_copy(vec![file_path.clone()], dir_path.clone())
1479            .await
1480            .expect("Copy operation should have succeeded");
1481
1482        assert!(file_path.exists(), "Original file should still exist");
1483        assert!(expected.exists(), "File should have been copied");
1484
1485        Ok(())
1486    }
1487}