fix(chat): use async save dialog for attachments
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:
+39
-18
@@ -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()
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user