From 7e4f2f2127c94a9002481da88951a8a47a88b71d Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 21 Jun 2026 03:43:12 -0400 Subject: [PATCH] fix(chat): use async save dialog for attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Save-attachment handler called the blocking rfd::FileDialog::save_file() directly inside iced's update() loop. That blocking dialog spins its own GTK loop; invoked from within iced's already-running event loop (notably the Linux xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog becomes unresponsive — Save/Cancel clicks are never processed. Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file *picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which already use the async variant. The chosen path's bytes are written when the future resolves; the status line is reported via a new AttachmentSaved message. No blocking call remains in the update loop. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 57 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 714bd2a..9849fd1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -287,6 +287,8 @@ pub enum AppMessage { AttachmentFilePicked(Option<(String, Vec)>), /// Save (downloading first if needed) a received attachment to disk. SaveAttachment(crate::files::AttachmentId), + /// Result of the async save dialog: a status line to show, or None if cancelled. + AttachmentSaved(Option), /// Fetch (if needed) and start an inline audio attachment. PlayAudio(crate::files::AttachmentId), PauseAudio, @@ -1094,12 +1096,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let needs_save = state.pending_saves.remove(&id); let needs_play = state.pending_plays.remove(&id); state.attachment_data.insert(id, AttachmentState::Ready(data)); - if needs_save { - save_attachment_to_disk(state, id); - } if needs_play { play_ready_audio(state, id); } + if needs_save { + return save_attachment_task(state, id); + } } UiEvent::AttachmentFailed { id, error } => { state.pending_saves.remove(&id); @@ -1663,7 +1665,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // If we already have the bytes, save now; otherwise fetch from the // sender and save when AttachmentReady arrives (pending_saves). if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) { - save_attachment_to_disk(state, id); + return save_attachment_task(state, id); } else if let Some((from, att)) = find_attachment_source(state, id) { if let Ok(eid) = from.parse::() { state.pending_saves.insert(id); @@ -1676,6 +1678,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } } } + AppMessage::AttachmentSaved(msg) => { + if let Some(m) = msg { + state.status_message = m; + } + } AppMessage::PlayAudio(id) => { if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) { play_ready_audio(state, id); @@ -1982,11 +1989,18 @@ fn play_ready_audio(state: &mut AppState, id: crate::files::AttachmentId) { /// Write a ready attachment's bytes to a user-chosen location via a native save /// dialog. The default filename comes from the (already-sanitized) descriptor. -/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable -/// for a deliberate save action. -fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) { +/// Build a Task that opens the native save dialog off the UI thread and writes +/// the (already-fetched) attachment bytes to the chosen path. No-op task if the +/// bytes aren't ready. +/// +/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins +/// its own GTK loop, and invoking it from inside iced's running event loop with +/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel +/// stop responding. The file *picker* paths already use the async variant; this +/// is the one save path that must match. +fn save_attachment_task(state: &AppState, id: crate::files::AttachmentId) -> Task { let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else { - return; + return Task::none(); }; let data = data.clone(); let default_name = state @@ -1999,16 +2013,23 @@ fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) .map(|a| a.name.clone()) }) .unwrap_or_else(|| "download".to_string()); - if let Some(path) = rfd::FileDialog::new() - .set_file_name(default_name) - .set_title("Save attachment") - .save_file() - { - match std::fs::write(&path, &data) { - Ok(()) => state.status_message = format!("Saved {}", path.display()), - Err(e) => state.status_message = format!("Save failed: {e}"), - } - } + Task::perform( + async move { + let handle = rfd::AsyncFileDialog::new() + .set_file_name(default_name) + .set_title("Save attachment") + .save_file() + .await; + match handle { + Some(h) => match std::fs::write(h.path(), &data) { + Ok(()) => Some(format!("Saved {}", h.path().display())), + Err(e) => Some(format!("Save failed: {e}")), + }, + None => None, + } + }, + AppMessage::AttachmentSaved, + ) } fn horizontal_space() -> iced::widget::Space {