1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
//! Attaching media to the composer while the work happens off the input loop.
//!
//! A pasted file path and a picked MCP resource both take long enough that the
//! composer must not block on them, and both must show up the instant the user
//! acts. So both push a pending attachment, run a task, and replace or remove
//! that entry when the task ends. Those mechanics live here; what each source
//! produces is its own module's policy.
//!
//! Submission is gated on there being no pending attachment, so every path out
//! of a task must either replace the entry or remove it. A task that ends any
//! other way would leave the composer unable to send.
use std::{future::Future, pin::Pin, task::Poll};
use super::{App, ChatMedia, MediaAttachId};
/// A running attach, paired with the composer entry it will settle.
pub(super) struct MediaAttachTask {
pub(super) id: MediaAttachId,
pub(super) task: tokio::task::JoinHandle<MediaAttachOutcome>,
}
impl MediaAttachTask {
fn cancel(self) {
self.task.abort();
}
}
/// How one attach ended.
pub(super) enum MediaAttachOutcome {
/// Media to put in place of the pending entry.
Ready {
media: ChatMedia,
/// Composer preview decoded off the UI thread; converted with the picker
/// when the attach settles.
decoded_preview: Option<super::feed_image::DecodedFeedImage>,
},
/// Nothing was attachable. The source's original text goes back into the
/// composer so the user does not lose what they pasted.
Unsupported { original_text: String },
/// The attach failed. `kind` names the thing that failed, so the status
/// reads as a sentence.
Failed { kind: &'static str, message: String },
}
impl MediaAttachOutcome {
pub(super) fn ready(media: ChatMedia) -> Self {
Self::Ready {
media,
decoded_preview: None,
}
}
pub(super) fn ready_image(
image: rho_providers::model::ImageContent,
decoded_preview: Option<super::feed_image::DecodedFeedImage>,
) -> Self {
Self::Ready {
media: ChatMedia::Image(image),
decoded_preview,
}
}
}
pub(super) struct CompletedMediaAttach {
pub(super) id: MediaAttachId,
pub(super) outcome: MediaAttachOutcome,
}
/// Waits for whichever attach finishes first and takes it off the list.
///
/// Cancellation safe: nothing is removed until a task has actually produced a
/// value, so dropping this future in a `select!` loses no work.
pub(super) async fn next_media_attach_completion(
pending: &mut Vec<MediaAttachTask>,
) -> CompletedMediaAttach {
let (index, id, result) = std::future::poll_fn(|context| {
for (index, pending) in pending.iter_mut().enumerate() {
if let Poll::Ready(result) = Pin::new(&mut pending.task).poll(context) {
return Poll::Ready((index, pending.id, result));
}
}
Poll::Pending
})
.await;
let completed = pending.remove(index);
debug_assert_eq!(completed.id, id);
CompletedMediaAttach {
id,
outcome: result.unwrap_or_else(|error| MediaAttachOutcome::Failed {
kind: "attachment task",
message: error.to_string(),
}),
}
}
impl App {
pub(super) fn cancel_all_pending_attachments(&mut self) {
let ids = self
.input_ui
.attachment_slots()
.iter()
.filter_map(|slot| slot.attachment.pending_id())
.collect::<Vec<_>>();
for id in ids {
self.input_ui.remove_pending_attachment(id);
self.cancel_pending_attachment(id);
}
for orphaned_task in self.media_attach_tasks.drain(..) {
orphaned_task.cancel();
}
}
pub(super) fn cancel_pending_attachment(&mut self, id: MediaAttachId) -> bool {
let Some(index) = self
.media_attach_tasks
.iter()
.position(|pending| pending.id == id)
else {
return false;
};
let pending = self.media_attach_tasks.remove(index);
pending.cancel();
true
}
/// Settle the composer entry this attach owns.
///
/// Every arm ends with the pending entry gone, and each one checks that the
/// entry is still there: the user may have cancelled it with backspace while
/// the task ran, and a cancelled attach must stay cancelled.
pub(super) fn finish_media_attach(&mut self, completion: CompletedMediaAttach) {
let CompletedMediaAttach { id, outcome } = completion;
match outcome {
MediaAttachOutcome::Ready {
media: ChatMedia::Image(image),
decoded_preview,
} => {
self.finish_pending_image(id, image, decoded_preview);
}
MediaAttachOutcome::Ready {
media: media @ ChatMedia::TextDocument(_),
..
} => {
let label = media.composer_label(1);
if self
.input_ui
.replace_pending_attachment(id, media, None)
.is_some()
{
self.notify_status(format!("attached {label}"));
}
}
MediaAttachOutcome::Unsupported { original_text } => {
if self.input_ui.remove_pending_attachment(id).is_some() {
self.insert_pasted_input_text(&original_text);
}
}
MediaAttachOutcome::Failed { kind, message } => {
if self.input_ui.remove_pending_attachment(id).is_some() {
self.notify_status(format!("{kind} failed: {message}"));
}
}
}
}
}