Implement apply to all and keep both in replace dialog, fixes #180

This commit is contained in:
Jeremy Soller 2024-07-08 14:35:20 -06:00
parent 97444c3572
commit 14be0d82f9
No known key found for this signature in database
GPG key ID: D02FD439211AF56F
3 changed files with 160 additions and 68 deletions

View file

@ -50,6 +50,9 @@ replace-warning = Do you want to replace it with the one you are saving? Replaci
replace-warning-operation = Do you want to replace it? Replacing it will overwrite its content.
original-file = Original file
replace-with = Replace with
apply-to-all = Apply to all
keep-both = Keep both
skip = Skip
# Context Pages

View file

@ -234,6 +234,7 @@ pub enum Message {
PendingProgress(u64, f32),
RescanTrash,
Rename(Option<Entity>),
ReplaceResult(ReplaceResult),
RestoreFromTrash(Option<Entity>),
SearchActivate,
SearchClear,
@ -300,6 +301,8 @@ pub enum DialogPage {
Replace {
from: tab::Item,
to: tab::Item,
multiple: bool,
apply_to_all: bool,
tx: mpsc::Sender<ReplaceResult>,
},
}
@ -1204,14 +1207,8 @@ impl Application for App {
let to = parent.join(name);
self.operation(Operation::Rename { from, to });
}
DialogPage::Replace { tx, .. } => {
return Command::perform(
async move {
let _ = tx.send(ReplaceResult::Replace).await;
message::none()
},
|x| x,
);
DialogPage::Replace { .. } => {
log::warn!("replace dialog should be completed with replace result");
}
}
}
@ -1602,6 +1599,25 @@ impl Application for App {
}
}
}
Message::ReplaceResult(replace_result) => {
if let Some(dialog_page) = self.dialog_pages.pop_front() {
match dialog_page {
DialogPage::Replace { tx, .. } => {
return Command::perform(
async move {
let _ = tx.send(replace_result).await;
message::none()
},
|x| x,
);
}
other => {
log::warn!("tried to send replace result to the wrong dialog");
self.dialog_pages.push_front(other);
}
}
}
}
Message::RestoreFromTrash(entity_opt) => {
let mut paths = Vec::new();
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
@ -2246,17 +2262,55 @@ impl Application for App {
.spacing(space_xxs),
)
}
DialogPage::Replace { from, to, .. } => {
widget::dialog(fl!("replace-title", filename = to.name.as_str()))
DialogPage::Replace {
from,
to,
multiple,
apply_to_all,
tx,
} => {
let dialog = widget::dialog(fl!("replace-title", filename = to.name.as_str()))
.body(fl!("replace-warning-operation"))
.control(to.replace_view(fl!("original-file"), IconSizes::default()))
.control(from.replace_view(fl!("replace-with"), IconSizes::default()))
.primary_action(
widget::button::suggested(fl!("replace")).on_press(Message::DialogComplete),
)
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
.primary_action(widget::button::suggested(fl!("replace")).on_press(
Message::ReplaceResult(ReplaceResult::Replace(*apply_to_all)),
));
if *multiple {
dialog
.control(widget::checkbox(
fl!("apply-to-all"),
*apply_to_all,
|apply_to_all| {
Message::DialogUpdate(DialogPage::Replace {
from: from.clone(),
to: to.clone(),
multiple: *multiple,
apply_to_all,
tx: tx.clone(),
})
},
))
.secondary_action(
widget::button::standard(fl!("skip")).on_press(Message::ReplaceResult(
ReplaceResult::Skip(*apply_to_all),
)),
)
.tertiary_action(
widget::button::text(fl!("cancel"))
.on_press(Message::ReplaceResult(ReplaceResult::Cancel)),
)
} else {
dialog
.secondary_action(
widget::button::standard(fl!("cancel"))
.on_press(Message::ReplaceResult(ReplaceResult::Cancel)),
)
.tertiary_action(
widget::button::text(fl!("keep-both"))
.on_press(Message::ReplaceResult(ReplaceResult::KeepBoth)),
)
}
}
};

View file

@ -1,7 +1,7 @@
use cosmic::iced::futures::{channel::mpsc::Sender, executor, SinkExt};
use std::{
fs,
path::PathBuf,
path::{Path, PathBuf},
sync::{
atomic::{self, AtomicU64},
Arc,
@ -23,6 +23,7 @@ fn handle_replace(
msg_tx: &Arc<Mutex<Sender<Message>>>,
file_from: PathBuf,
file_to: PathBuf,
multiple: bool,
) -> ReplaceResult {
let item_from = match tab::item_from_path(file_from, IconSizes::default()) {
Ok(ok) => ok,
@ -48,6 +49,8 @@ fn handle_replace(
.send(Message::DialogPush(DialogPage::Replace {
from: item_from,
to: item_to,
multiple,
apply_to_all: false,
tx,
}))
.await;
@ -73,7 +76,12 @@ fn handle_progress_state(
return fs_extra::dir::TransitProcessResult::Abort;
};
handle_replace(msg_tx, file_from, file_to).into()
if file_from == file_to {
log::warn!("trying to copy {:?} to itself", file_from);
return fs_extra::dir::TransitProcessResult::Abort;
}
handle_replace(msg_tx, file_from, file_to, true).into()
}
fs_extra::dir::TransitState::NoAccess => {
//TODO: permission error dialog
@ -84,16 +92,33 @@ fn handle_progress_state(
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ReplaceResult {
Replace,
Skip,
Replace(bool),
KeepBoth,
Skip(bool),
Cancel,
}
impl From<ReplaceResult> for fs_extra::dir::TransitProcessResult {
fn from(f: ReplaceResult) -> fs_extra::dir::TransitProcessResult {
match f {
ReplaceResult::Replace => fs_extra::dir::TransitProcessResult::Overwrite,
ReplaceResult::Skip => fs_extra::dir::TransitProcessResult::Skip,
ReplaceResult::Replace(apply_to_all) => {
if apply_to_all {
fs_extra::dir::TransitProcessResult::OverwriteAll
} else {
fs_extra::dir::TransitProcessResult::Overwrite
}
}
ReplaceResult::KeepBoth => {
log::warn!("tried to keep both when replacing multiple files");
fs_extra::dir::TransitProcessResult::Abort
}
ReplaceResult::Skip(apply_to_all) => {
if apply_to_all {
fs_extra::dir::TransitProcessResult::SkipAll
} else {
fs_extra::dir::TransitProcessResult::Skip
}
}
ReplaceResult::Cancel => fs_extra::dir::TransitProcessResult::Abort,
}
}
@ -133,6 +158,45 @@ pub enum Operation {
},
}
fn copy_unique_path(from: &Path, to: &Path) -> PathBuf {
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)
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
}
}
impl Operation {
/// Perform the operation
pub async fn perform(
@ -159,44 +223,7 @@ impl Operation {
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
};
let to = copy_unique_path(&from, &to);
(from, to)
} else if let Some(name) =
from.is_file().then(|| from.file_name()).flatten()
@ -243,26 +270,34 @@ impl Operation {
handler();
handle_progress_state(&msg_tx, &progress)
};
for (from, to) in paths.into_iter().zip(to.into_iter()) {
for (from, mut to) in paths.into_iter().zip(to.into_iter()) {
if from.is_dir() {
let options = fs_extra::dir::CopyOptions::default().copy_inside(true);
fs_extra::copy_items_with_progress(&[from], to, &options, dir_handler)?;
} else {
let mut options = fs_extra::file::CopyOptions::default();
if to.exists() {
match handle_replace(&msg_tx, from.clone(), to.clone()) {
ReplaceResult::Replace => {
match handle_replace(&msg_tx, from.clone(), to.clone(), false) {
ReplaceResult::Replace(_) => {
options.overwrite = true;
}
ReplaceResult::Skip => {
ReplaceResult::KeepBoth => {
match to.parent() {
Some(to_parent) => {
to = copy_unique_path(&from, &to_parent);
}
None => {
log::warn!("failed to get parent of {:?}", to);
//TODO: error?
}
}
}
ReplaceResult::Skip(_) => {
options.skip_exist = true;
}
ReplaceResult::Cancel => {
//TODO: be silent, but collect actual changes made for undo
return Err(fs_extra::error::Error::new(
fs_extra::error::ErrorKind::Interrupted,
"operation cancelled",
));
continue;
}
}
}