cosmic-files/src/operation.rs

549 lines
20 KiB
Rust

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<T: ToString>(err: T) -> String {
err.to_string()
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Operation {
/// Copy items
Copy {
paths: Vec<PathBuf>,
to: PathBuf,
},
/// Move items to the trash
Delete {
paths: Vec<PathBuf>,
},
/// Empty the trash
EmptyTrash,
/// Move items
Move {
paths: Vec<PathBuf>,
to: PathBuf,
},
NewFile {
path: PathBuf,
},
NewFolder {
path: PathBuf,
},
Rename {
from: PathBuf,
to: PathBuf,
},
/// Restore a path from the trash
Restore {
paths: Vec<trash::TrashItem>,
},
}
impl Operation {
/// Perform the operation
pub async fn perform(
self,
id: u64,
msg_tx: &Arc<tokio::sync::Mutex<mpsc::Sender<Message>>>,
) -> 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::<Result<u64, _>>()?;
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<PathBuf>, 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(())
}
}