feat: join multiple operations

Allow for joining operations into a single Task that
will produce a single Message:PendingResults message such
that multiple Message::PendingComplete and Message::PendingError
messages can be handled together to, for example, show
a single error dialog with multiple errors.
This commit is contained in:
Jonatan Pettersson 2026-01-11 19:50:28 +01:00 committed by Jacob Kauffmann
parent 41cdf89604
commit 4c6f2db5f2

View file

@ -418,6 +418,7 @@ pub enum Message {
PendingComplete(u64, OperationSelection),
PendingDismiss,
PendingError(u64, OperationError),
PendingResults(Vec<(u64, OperationSelection)>, Vec<(u64, OperationError)>),
PendingPause(u64, bool),
PendingPauseAll(bool),
PermanentlyDelete(Option<Entity>),
@ -531,6 +532,7 @@ pub enum DialogPage {
},
EmptyTrash,
FailedOperation(u64),
FailedOperations(Vec<u64>),
ExtractPassword {
id: u64,
password: String,
@ -1278,6 +1280,158 @@ impl App {
.map(cosmic::Action::App)
}
/// Will join operations together into a single task that will return a single
/// Message::PendingResults message when all operations are complete.
fn join_operations(&mut self, operations: Vec<Operation>) -> Task<Message> {
Task::batch(
operations
.into_iter()
.map(|operation| self.operation(operation)),
)
.collect()
.map(|messages| {
let results = messages.into_iter().fold(
Message::PendingResults(Vec::new(), Vec::new()),
|mut acc, message| {
if let Message::PendingResults(completed, errors) = &mut acc {
match message {
cosmic::Action::App(Message::PendingComplete(id, selection)) => {
completed.push((id, selection));
}
cosmic::Action::App(Message::PendingError(id, err)) => {
errors.push((id, err));
}
_ => {}
}
}
acc
},
);
cosmic::Action::App(results)
})
}
fn handle_completed_operations(
&mut self,
completed: Vec<(u64, OperationSelection)>,
) -> Task<Message> {
let mut commands = Vec::with_capacity(4 * completed.len());
let mut op_sel = OperationSelection::default();
for (id, op_sel_pending) in completed {
op_sel.ignored.extend(op_sel_pending.ignored);
op_sel.selected.extend(op_sel_pending.selected);
if let Some((op, _)) = self.pending_operations.remove(&id) {
// Show toast for some operations
if let Some(description) = op.toast() {
if let Operation::Delete { ref paths } = op {
let paths: Arc<[PathBuf]> = Arc::from(paths.as_slice());
commands.push(
self.toasts
.push(
widget::toaster::Toast::new(description)
.action(fl!("undo"), move |tid| {
Message::UndoTrash(tid, paths.clone())
}),
)
.map(cosmic::Action::App),
);
} else {
commands.push(
self.toasts
.push(widget::toaster::Toast::new(description))
.map(cosmic::Action::App),
);
}
}
// If a favorite for a path has been renamed or moved, update it.
if let Operation::Rename { ref from, ref to } = op {
if self.update_favorites([(from, to)].as_slice()) {
commands.push(self.update_config());
}
} else if let Operation::Move {
ref paths, ref to, ..
} = op
{
let path_changes: Box<[_]> = paths
.iter()
.filter_map(|from| from.file_name().map(|name| (from, to.join(name))))
.collect();
if self.update_favorites(&path_changes) {
commands.push(self.update_config());
}
}
if matches!(op, Operation::RemoveFromRecents { .. }) {
commands.push(self.rescan_recents());
}
self.complete_operations.insert(id, op);
}
}
// Close progress notification if all relevant operations are finished
if !self
.pending_operations
.values()
.any(|(op, _)| op.show_progress_notification())
{
self.progress_operations.clear();
}
// Potentially show a notification
commands.push(self.update_notification());
// Rescan and select based on operation
commands.push(self.rescan_operation_selection(op_sel));
// Manually rescan any trash tabs after any operation is completed
commands.push(self.rescan_trash());
return Task::batch(commands);
}
fn handle_operation_errors(&mut self, errors: Vec<(u64, OperationError)>) -> Task<Message> {
let mut tasks = Vec::new();
let mut failed = Vec::new();
for (id, err) in errors.into_iter() {
if let Some((op, controller)) = self.pending_operations.remove(&id) {
// Only show dialog if not cancelled
if !controller.is_cancelled() {
match err.kind {
OperationErrorType::Generic(_) => failed.push(id),
OperationErrorType::PasswordRequired => {
tasks.push(self.dialog_pages.push_back(DialogPage::ExtractPassword {
id,
password: String::new(),
}));
}
}
}
// Remove from progress
self.progress_operations.remove(&id);
self.failed_operations
.insert(id, (op, controller, err.to_string()));
}
}
if !failed.is_empty() {
tasks.push(
self.dialog_pages
.push_back(DialogPage::FailedOperations(failed)),
);
tasks.push(widget::text_input::focus(self.dialog_text_input.clone()));
}
// Close progress notification if all relevant operations are finished
if !self
.pending_operations
.values()
.any(|(op, _)| op.show_progress_notification())
{
self.progress_operations.clear();
}
// Manually rescan any trash tabs after any operation is completed
tasks.push(self.rescan_trash());
return Task::batch(tasks);
}
fn remove_window(&mut self, id: &window::Id) {
if let Some(window) = self.windows.remove(id) {
match window.kind {
@ -2979,6 +3133,9 @@ impl Application for App {
DialogPage::FailedOperation(id) => {
log::warn!("TODO: retry operation {id}");
}
DialogPage::FailedOperations(_ids) => {
log::warn!("TODO: retry operations");
}
DialogPage::ExtractPassword { id, password } => {
let (operation, _, _err) = self.failed_operations.get(&id).unwrap();
let new_op = match &operation {
@ -3891,106 +4048,19 @@ impl Application for App {
}
}
Message::PendingComplete(id, op_sel) => {
let mut commands = Vec::with_capacity(4);
if let Some((op, _)) = self.pending_operations.remove(&id) {
// Show toast for some operations
if let Some(description) = op.toast() {
if let Operation::Delete { ref paths } = op {
let paths: Arc<[PathBuf]> = Arc::from(paths.as_slice());
commands.push(
self.toasts
.push(
widget::toaster::Toast::new(description)
.action(fl!("undo"), move |tid| {
Message::UndoTrash(tid, paths.clone())
}),
)
.map(cosmic::Action::App),
);
} else {
commands.push(
self.toasts
.push(widget::toaster::Toast::new(description))
.map(cosmic::Action::App),
);
}
}
// If a favorite for a path has been renamed or moved, update it.
if let Operation::Rename { ref from, ref to } = op {
if self.update_favorites([(from, to)].as_slice()) {
commands.push(self.update_config());
}
} else if let Operation::Move {
ref paths, ref to, ..
} = op
{
let path_changes: Box<[_]> = paths
.iter()
.filter_map(|from| from.file_name().map(|name| (from, to.join(name))))
.collect();
if self.update_favorites(&path_changes) {
commands.push(self.update_config());
}
}
if matches!(op, Operation::RemoveFromRecents { .. }) {
commands.push(self.rescan_recents());
}
self.complete_operations.insert(id, op);
}
// Close progress notification if all relevant operations are finished
if !self
.pending_operations
.values()
.any(|(op, _)| op.show_progress_notification())
{
self.progress_operations.clear();
}
// Potentially show a notification
commands.push(self.update_notification());
// Rescan and select based on operation
commands.push(self.rescan_operation_selection(op_sel));
// Manually rescan any trash tabs after any operation is completed
commands.push(self.rescan_trash());
return Task::batch(commands);
return self.handle_completed_operations(vec![(id, op_sel)]);
}
Message::PendingDismiss => {
self.progress_operations.clear();
}
Message::PendingError(id, err) => {
let mut tasks = Vec::new();
if let Some((op, controller)) = self.pending_operations.remove(&id) {
// Only show dialog if not cancelled
if !controller.is_cancelled() {
tasks.push(self.dialog_pages.push_back(match err.kind {
OperationErrorType::Generic(_) => DialogPage::FailedOperation(id),
OperationErrorType::PasswordRequired => DialogPage::ExtractPassword {
id,
password: String::new(),
},
}));
}
tasks.push(widget::text_input::focus(self.dialog_text_input.clone()));
// Remove from progress
self.progress_operations.remove(&id);
self.failed_operations
.insert(id, (op, controller, err.to_string()));
}
// Close progress notification if all relevant operations are finished
if !self
.pending_operations
.values()
.any(|(op, _)| op.show_progress_notification())
{
self.progress_operations.clear();
}
// Manually rescan any trash tabs after any operation is completed
tasks.push(self.rescan_trash());
return Task::batch(tasks);
return self.handle_operation_errors(vec![(id, err)]);
}
Message::PendingResults(completed, errors) => {
return Task::batch(vec![
self.handle_completed_operations(completed),
self.handle_operation_errors(errors),
]);
}
Message::PendingPause(id, pause) => {
if let Some((_, controller)) = self.pending_operations.get(&id) {
@ -4479,11 +4549,17 @@ impl Application for App {
commands.push(self.operation(Operation::SetPermissions { path, mode }));
}
tab::Command::SetMultiplePermissions(permissions) => {
commands.push(Task::batch(permissions.into_iter().map(
|(path, mode)| {
self.operation(Operation::SetPermissions { path, mode })
},
)));
commands.push(
self.join_operations(
permissions
.into_iter()
.map(|(path, mode)| Operation::SetPermissions {
path,
mode,
})
.collect(),
),
);
}
tab::Command::WindowDrag => {
if let Some(window_id) = self.core.main_window_id() {
@ -5447,6 +5523,25 @@ impl Application for App {
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
}
DialogPage::FailedOperations(ids) => {
let errors: Vec<String> = ids
.into_iter()
.filter_map(|id| match self.failed_operations.get(id) {
Some((operation, _, err)) => Some(format!("{operation:#?}\n{err}")),
_ => None,
})
.collect();
//TODO: nice description of error
widget::dialog()
.title("Failed operations")
.body(errors.join("\n\n"))
.icon(icon::from_name("dialog-error").size(64))
//TODO: retry action
.primary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
}
DialogPage::ExtractPassword { id, password } => widget::dialog()
.title(fl!("extract-password-required"))
.icon(icon::from_name("dialog-error").size(64))