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:
+35
-14
@@ -287,6 +287,8 @@ pub enum AppMessage {
|
|||||||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||||||
/// Save (downloading first if needed) a received attachment to disk.
|
/// Save (downloading first if needed) a received attachment to disk.
|
||||||
SaveAttachment(crate::files::AttachmentId),
|
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.
|
/// Fetch (if needed) and start an inline audio attachment.
|
||||||
PlayAudio(crate::files::AttachmentId),
|
PlayAudio(crate::files::AttachmentId),
|
||||||
PauseAudio,
|
PauseAudio,
|
||||||
@@ -1094,12 +1096,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let needs_save = state.pending_saves.remove(&id);
|
let needs_save = state.pending_saves.remove(&id);
|
||||||
let needs_play = state.pending_plays.remove(&id);
|
let needs_play = state.pending_plays.remove(&id);
|
||||||
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
||||||
if needs_save {
|
|
||||||
save_attachment_to_disk(state, id);
|
|
||||||
}
|
|
||||||
if needs_play {
|
if needs_play {
|
||||||
play_ready_audio(state, id);
|
play_ready_audio(state, id);
|
||||||
}
|
}
|
||||||
|
if needs_save {
|
||||||
|
return save_attachment_task(state, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::AttachmentFailed { id, error } => {
|
UiEvent::AttachmentFailed { id, error } => {
|
||||||
state.pending_saves.remove(&id);
|
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
|
// If we already have the bytes, save now; otherwise fetch from the
|
||||||
// sender and save when AttachmentReady arrives (pending_saves).
|
// sender and save when AttachmentReady arrives (pending_saves).
|
||||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
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) {
|
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||||
if let Ok(eid) = from.parse::<EndpointId>() {
|
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||||
state.pending_saves.insert(id);
|
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) => {
|
AppMessage::PlayAudio(id) => {
|
||||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||||
play_ready_audio(state, id);
|
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
|
/// 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.
|
/// 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
|
/// Build a Task that opens the native save dialog off the UI thread and writes
|
||||||
/// for a deliberate save action.
|
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
|
||||||
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
|
/// 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 {
|
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||||
return;
|
return Task::none();
|
||||||
};
|
};
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
let default_name = state
|
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())
|
.map(|a| a.name.clone())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "download".to_string());
|
.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_file_name(default_name)
|
||||||
.set_title("Save attachment")
|
.set_title("Save attachment")
|
||||||
.save_file()
|
.save_file()
|
||||||
{
|
.await;
|
||||||
match std::fs::write(&path, &data) {
|
match handle {
|
||||||
Ok(()) => state.status_message = format!("Saved {}", path.display()),
|
Some(h) => match std::fs::write(h.path(), &data) {
|
||||||
Err(e) => state.status_message = format!("Save failed: {e}"),
|
Ok(()) => Some(format!("Saved {}", h.path().display())),
|
||||||
}
|
Err(e) => Some(format!("Save failed: {e}")),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
AppMessage::AttachmentSaved,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn horizontal_space() -> iced::widget::Space {
|
fn horizontal_space() -> iced::widget::Space {
|
||||||
|
|||||||
Reference in New Issue
Block a user