fix(chat): use async save dialog for attachments
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:43:12 -04:00
co-authored by Claude Opus 4.8
parent b6eac330ca
commit 7e4f2f2127
+35 -14
View File
@@ -287,6 +287,8 @@ pub enum AppMessage {
AttachmentFilePicked(Option<(String, Vec<u8>)>),
/// 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<String>),
/// 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<AppMessage> {
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<AppMessage> {
// 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::<EndpointId>() {
state.pending_saves.insert(id);
@@ -1676,6 +1678,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
}
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<AppMessage> {
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()
Task::perform(
async move {
let handle = rfd::AsyncFileDialog::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}"),
}
.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 {