use cosmic::iced::futures::{channel::mpsc, executor, SinkExt}; use std::{ fs, path::PathBuf, sync::{ atomic::{self, AtomicU64}, Arc, }, }; use crate::{app::Message, fl}; fn err_str(err: T) -> String { err.to_string() } #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Operation { /// Copy items Copy { paths: Vec, to: PathBuf, }, /// Move items to the trash Delete { paths: Vec, }, /// Empty the trash EmptyTrash, /// Move items Move { paths: Vec, to: PathBuf, }, NewFile { path: PathBuf, }, NewFolder { path: PathBuf, }, Rename { from: PathBuf, to: PathBuf, }, /// Restore a path from the trash Restore { paths: Vec, }, } impl Operation { /// Perform the operation pub async fn perform( self, id: u64, msg_tx: &Arc>>, ) -> Result<(), String> { let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 0.0)) .await; //TODO: IF ERROR, RETURN AN Operation THAT CAN UNDO THE CURRENT STATE //TODO: SAFELY HANDLE CANCEL match self { Self::Copy { paths, to } => { // Handle duplicate file names by renaming paths let (paths, to): (Vec<_>, Vec<_>) = tokio::task::spawn_blocking(move || { paths .into_iter() .zip(std::iter::repeat(to.as_path())) .map(|(from, to)| { if matches!(from.parent(), Some(parent) if parent == to) { // `from`'s parent is equal to `to` which means we're copying to the same // directory (duplicating files) let mut to = to.to_owned(); // Separate the full file name into its file name plus extension. // `[Path::file_stem]` returns the full name for dotfiles (e.g. // .someconf is the file name) let to = if let (Some(stem), ext) = ( // FIXME: Replace `[Path::file_stem]` with `[Path::file_prefix]` when stablized to handle .tar.gz et al. better from.file_stem().and_then(|name| name.to_str()), from.extension() .and_then(|ext| ext.to_str()) .unwrap_or_default(), ) { // '.' needs to be re-added for paths with extensions. let dot = if ext.is_empty() { "" } else { "." }; let mut n = 0u32; // Loop until a valid `copy n` variant is found loop { n = if let Some(n) = n.checked_add(1) { n } else { // TODO: Return error? fs_extra will handle it anyway break to; }; // Rebuild file name let new_name = format!("{stem} ({} {n}){dot}{ext}", fl!("copy_noun")); to = to.join(new_name); if !matches!(to.try_exists(), Ok(true)) { break to; } // Continue if a copy with index exists to.pop(); } } else { to }; (from, to) } else if let Some(name) = from.is_file().then(|| from.file_name()).flatten() { let to = to.join(name); (from, to) } else { (from, to.to_owned()) } }) .unzip() }) .await .unwrap(); let msg_tx = msg_tx.clone(); tokio::task::spawn_blocking(move || -> fs_extra::error::Result<()> { log::info!("Copy {:?} to {:?}", paths, to); //TODO: set options as desired let dir_options = fs_extra::dir::CopyOptions::default().copy_inside(true); let file_options = fs_extra::file::CopyOptions::default(); let copied_bytes = AtomicU64::default(); let total_bytes = paths .iter() .map(fs_extra::dir::get_size) .sum::>()?; let handler = || { executor::block_on(async { let _ = msg_tx .lock() .await .send(Message::PendingProgress( id, 100.0 * copied_bytes.load(atomic::Ordering::Relaxed) as f32 / total_bytes as f32, )) .await; }) }; // Files and directory progress are handled separately let file_handler = |progress: fs_extra::file::TransitProcess| { copied_bytes.fetch_add(progress.copied_bytes, atomic::Ordering::Relaxed); handler(); }; let dir_handler = |progress: fs_extra::TransitProcess| { copied_bytes.fetch_add(progress.copied_bytes, atomic::Ordering::Relaxed); handler(); //TODO: handle exceptions fs_extra::dir::TransitProcessResult::ContinueOrAbort }; for (from, to) in paths.into_iter().zip(to.into_iter()) { if from.is_dir() { fs_extra::copy_items_with_progress( &[from], to, &dir_options, dir_handler, )?; } else { fs_extra::file::copy_with_progress( from, to, &file_options, file_handler, )?; } } Ok(()) }) .await .map_err(err_str)? .map_err(err_str)?; } Self::Delete { paths } => { let total = paths.len(); let mut count = 0; for path in paths { tokio::task::spawn_blocking(|| trash::delete(path)) .await .map_err(err_str)? .map_err(err_str)?; count += 1; let _ = msg_tx .lock() .await .send(Message::PendingProgress( id, 100.0 * (count as f32) / (total as f32), )) .await; } } Self::EmptyTrash => { #[cfg(any( target_os = "windows", all( unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android") ) ))] { tokio::task::spawn_blocking(|| { let items = trash::os_limited::list()?; trash::os_limited::purge_all(items) }) .await .map_err(err_str)? .map_err(err_str)?; } let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 100.0)) .await; } Self::Move { paths, to } => { let msg_tx = msg_tx.clone(); tokio::task::spawn_blocking(move || { log::info!("Move {:?} to {:?}", paths, to); let options = fs_extra::dir::CopyOptions::default(); //TODO: set options as desired fs_extra::move_items_with_progress(&paths, &to, &options, |progress| { executor::block_on(async { let _ = msg_tx .lock() .await .send(Message::PendingProgress( id, 100.0 * (progress.copied_bytes as f32) / (progress.total_bytes as f32), )) .await; }); //TODO: handle exceptions fs_extra::dir::TransitProcessResult::ContinueOrAbort }) }) .await .map_err(err_str)? .map_err(err_str)?; } Self::NewFolder { path } => { tokio::task::spawn_blocking(|| fs::create_dir(path)) .await .map_err(err_str)? .map_err(err_str)?; let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 100.0)) .await; } Self::NewFile { path } => { tokio::task::spawn_blocking(|| fs::File::create(path)) .await .map_err(err_str)? .map_err(err_str)?; let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 100.0)) .await; } Self::Rename { from, to } => { tokio::task::spawn_blocking(|| fs::rename(from, to)) .await .map_err(err_str)? .map_err(err_str)?; let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 100.0)) .await; } #[cfg(target_os = "macos")] Self::Restore { .. } => { // TODO: add support for macos return Err("Restoring from trash is not supported on macos".to_string()); } #[cfg(not(target_os = "macos"))] Self::Restore { paths } => { let total = paths.len(); let mut count = 0; for path in paths { tokio::task::spawn_blocking(|| trash::os_limited::restore_all([path])) .await .map_err(err_str)? .map_err(err_str)?; count += 1; let _ = msg_tx .lock() .await .send(Message::PendingProgress( id, 100.0 * (count as f32) / (total as f32), )) .await; } } } let _ = msg_tx .lock() .await .send(Message::PendingProgress(id, 100.0)) .await; Ok(()) } } #[cfg(test)] mod tests { use std::{ fs::{self, File}, io, path::PathBuf, }; use cosmic::iced::futures::channel::mpsc; use log::{debug, trace}; use test_log::test; use tokio::sync; use super::Operation; use crate::{ app::{ test_utils::{ empty_fs, filter_dirs, filter_files, read_dir_sorted, simple_fs, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, }, Message, }, fl, }; // Tests hang with lower values const BUF_SIZE: usize = 8; /// Simple wrapper around `[Operation::Copy]` pub async fn operation_copy(paths: Vec, to: PathBuf) -> Result<(), String> { let id = fastrand::u64(0..u64::MAX); let (tx, mut rx) = mpsc::channel(BUF_SIZE); Operation::Copy { paths: paths.clone(), to: to.clone(), } .perform(id, &sync::Mutex::new(tx).into()) .await?; loop { match rx.try_next() { Ok(Some(Message::PendingProgress(id, progress))) => { trace!("({id}) [ {paths:?} => {to:?} ] {progress}% complete)") } Ok(None) => break, Err(e) => panic!("Receiving message from operation should succeed: {e:?}"), _ => unreachable!("Only `Message::PendingProgress` is sent from operation"), } } Ok(()) } #[test(tokio::test)] async fn copy_file_to_same_location() -> io::Result<()> { let fs = simple_fs(NUM_FILES, 0, 1, 0, NAME_LEN)?; let path = fs.path(); // Get the first file from the first directory let first_dir = filter_dirs(path)? .next() .expect("Should have at least one directory"); let first_file = filter_files(&first_dir)? .next() .expect("Should have at least one file"); // Duplicate that file let base_name = first_file .file_name() .and_then(|name| name.to_str()) .expect("File name exists and is valid"); debug!( "Duplicating {} in {}", first_file.display(), first_dir.display() ); operation_copy(vec![first_file.clone()], first_dir.clone()) .await .expect("Copy operation should have succeeded"); assert!(first_file.exists(), "Original file should still exist"); let expected = first_dir.join(format!("{base_name} ({} 1)", fl!("copy_noun"))); assert!(expected.exists(), "File should have been duplicated"); Ok(()) } #[test(tokio::test)] async fn copy_file_with_extension_to_same_loc() -> io::Result<()> { let fs = empty_fs()?; let path = fs.path(); let base_name = "foo.txt"; let base_path = path.join(base_name); File::create(&base_path)?; debug!("Duplicating {}", base_path.display()); operation_copy(vec![base_path.clone()], path.to_owned()) .await .expect("Copy operation should have succeeded"); assert!(base_path.exists(), "Original file should still exist"); let expected = path.join(format!("foo ({} 1).txt", fl!("copy_noun"))); assert!(expected.exists(), "File should have been duplicated"); Ok(()) } #[test(tokio::test)] async fn copy_dir_to_same_location() -> io::Result<()> { let fs = simple_fs(NUM_FILES, 0, NUM_DIRS, NUM_NESTED, NAME_LEN)?; let path = fs.path(); // First directory path let first_dir = filter_dirs(path)? .next() .expect("Should have at least one directory"); let base_name = first_dir .file_name() .and_then(|name| name.to_str()) .expect("First directory exists and has a valid name"); debug!("Duplicating directory {}", first_dir.display()); operation_copy(vec![first_dir.clone()], path.to_owned()) .await .expect("Copy operation should have succeeded"); assert!(first_dir.exists(), "Original directory should still exist"); let expected = path.join(format!("{base_name} ({} 1)", fl!("copy_noun"))); assert!(expected.exists(), "Directory should have been duplicated"); Ok(()) } #[test(tokio::test)] async fn copying_file_multiple_times_to_same_location() -> io::Result<()> { let fs = empty_fs()?; let path = fs.path(); let base_name = "cosmic"; let base_path = path.join(base_name); File::create(&base_path)?; for i in 1..5 { debug!("Duplicating {}", base_path.display()); operation_copy(vec![base_path.clone()], path.to_owned()) .await .expect("Copy operation should have succeeded"); assert!(base_path.exists(), "Original file should still exist"); assert!( path.join(format!("{base_name} ({} {i})", fl!("copy_noun"))) .exists(), "File should have been duplicated (copy #{i})" ); } Ok(()) } #[test(tokio::test)] async fn copy_to_diff_dir_doesnt_dupe_files() -> io::Result<()> { let fs = simple_fs(NUM_FILES, NUM_HIDDEN, NUM_DIRS, NUM_NESTED, NAME_LEN)?; let path = fs.path(); let (first_dir, second_dir) = { let mut dirs = filter_dirs(path)?; ( dirs.next().expect("Should have at least two dirs"), dirs.next().expect("Should have at least two dirs"), ) }; let first_file = filter_files(&first_dir)? .next() .expect("Should have at least one file"); // Both directories have a file with the same name. let base_name = first_file .file_name() .and_then(|name| name.to_str()) .expect("File name exists and is valid"); debug!( "Copying {} to {}", first_file.display(), second_dir.display() ); operation_copy(vec![first_file.clone()], second_dir.clone()) .await .expect_err( "Copy operation should have failed because we're copying to different directories", ); assert!( first_dir.join(base_name).exists(), "First file should still exist" ); assert!( second_dir.join(base_name).exists(), "Second file should still exist" ); Ok(()) } #[test(tokio::test)] async fn copy_file_with_diff_name_to_diff_dir() -> io::Result<()> { let fs = empty_fs()?; let path = fs.path(); let dir_path = path.join("cosmic"); fs::create_dir(&dir_path)?; let file_path = path.join("ferris"); File::create(&file_path)?; let expected = dir_path.join("ferris"); debug!("Copying {} to {}", file_path.display(), expected.display()); operation_copy(vec![file_path.clone()], dir_path.clone()) .await .expect("Copy operation should have succeeded"); assert!(file_path.exists(), "Original file should still exist"); assert!(expected.exists(), "File should have been copied"); Ok(()) } }