1use std::rc::Rc;
29
30use gpui::{
31 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
32 Styled, Window, div, prelude::FluentBuilder, px,
33};
34use gpui_kit_assets::Icon;
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
37
38use crate::controls::button::{Button, IconButton};
39use crate::controls::dropzone::Dropzone;
40use crate::display::badge::Tone;
41use crate::display::empty::{EmptyKind, EmptyState};
42use crate::display::progress::ProgressBar;
43use crate::display::status::StatusDot;
44use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
45use crate::strings::{ActiveStrings, StringKey};
46
47type FileHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
48
49#[derive(Debug, Clone, PartialEq, Default)]
51pub enum UploadState {
52 #[default]
54 Queued,
55 Uploading {
58 fraction: Option<f32>,
59 },
60 Done,
61 Failed {
63 reason: SharedString,
64 },
65 Cancelled,
67 Refused {
69 reason: SharedString,
70 },
71}
72
73impl UploadState {
74 pub fn name(&self) -> &'static str {
77 match self {
78 Self::Queued => "queued",
79 Self::Uploading { .. } => "uploading",
80 Self::Done => "done",
81 Self::Failed { .. } => "failed",
82 Self::Cancelled => "cancelled",
83 Self::Refused { .. } => "refused",
84 }
85 }
86
87 pub fn is_settled(&self) -> bool {
89 matches!(
90 self,
91 Self::Done | Self::Failed { .. } | Self::Cancelled | Self::Refused { .. }
92 )
93 }
94
95 pub fn is_retryable(&self) -> bool {
97 matches!(self, Self::Failed { .. } | Self::Cancelled)
98 }
99
100 fn tone(&self) -> Tone {
101 match self {
102 Self::Queued => Tone::Neutral,
103 Self::Uploading { .. } => Tone::Accent,
104 Self::Done => Tone::Success,
105 Self::Failed { .. } => Tone::Danger,
106 Self::Cancelled => Tone::Neutral,
107 Self::Refused { .. } => Tone::Warning,
110 }
111 }
112
113 fn wording(&self, cx: &App) -> SharedString {
115 match self {
116 Self::Failed { reason } | Self::Refused { reason } => reason.clone(),
118 Self::Queued => cx.strings().text(StringKey::UploadQueued),
119 Self::Uploading { .. } => cx.strings().text(StringKey::UploadUploading),
120 Self::Done => cx.strings().text(StringKey::UploadDone),
121 Self::Cancelled => cx.strings().text(StringKey::UploadCancelled),
122 }
123 }
124
125 fn fraction(&self) -> Option<f32> {
127 match self {
128 Self::Uploading { fraction } => *fraction,
129 Self::Done => Some(1.0),
130 Self::Queued | Self::Cancelled | Self::Refused { .. } => Some(0.0),
132 Self::Failed { .. } => Some(0.0),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq)]
139pub struct Upload {
140 pub id: SharedString,
141 pub name: SharedString,
142 pub size: Option<SharedString>,
144 pub state: UploadState,
145}
146
147impl Upload {
148 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
149 Self {
150 id: id.into(),
151 name: name.into(),
152 size: None,
153 state: UploadState::default(),
154 }
155 }
156
157 pub fn size(mut self, size: impl Into<SharedString>) -> Self {
159 self.size = Some(size.into());
160 self
161 }
162
163 pub fn state(mut self, state: UploadState) -> Self {
164 self.state = state;
165 self
166 }
167
168 pub fn uploading(self, fraction: impl Into<Option<f32>>) -> Self {
169 self.state(UploadState::Uploading {
170 fraction: fraction.into(),
171 })
172 }
173
174 pub fn done(self) -> Self {
175 self.state(UploadState::Done)
176 }
177
178 pub fn failed(self, reason: impl Into<SharedString>) -> Self {
179 self.state(UploadState::Failed {
180 reason: reason.into(),
181 })
182 }
183
184 pub fn cancelled(self) -> Self {
185 self.state(UploadState::Cancelled)
186 }
187
188 pub fn refused(self, reason: impl Into<SharedString>) -> Self {
189 self.state(UploadState::Refused {
190 reason: reason.into(),
191 })
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq)]
197pub enum OverallProgress {
198 Known(f32),
200 Indeterminate,
202 Settled,
204}
205
206#[derive(IntoElement)]
208pub struct UploadList {
209 ident: Ident,
210 uploads: Vec<Upload>,
211 zone: Option<Dropzone>,
212 size: ControlSize,
213 disabled: bool,
214 show_overall: bool,
215 on_retry: Option<FileHandler>,
216 on_cancel: Option<FileHandler>,
217 on_remove: Option<FileHandler>,
218}
219
220impl std::fmt::Debug for UploadList {
221 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 formatter
223 .debug_struct("UploadList")
224 .field("ident", &self.ident)
225 .field("uploads", &self.uploads.len())
226 .field("has_zone", &self.zone.is_some())
227 .field("disabled", &self.disabled)
228 .finish()
229 }
230}
231
232impl UploadList {
233 pub fn new(ident: impl Into<Ident>) -> Self {
234 Self {
235 ident: ident.into(),
236 uploads: Vec::new(),
237 zone: None,
238 size: ControlSize::Sm,
239 disabled: false,
240 show_overall: true,
241 on_retry: None,
242 on_cancel: None,
243 on_remove: None,
244 }
245 }
246
247 pub fn upload(mut self, upload: Upload) -> Self {
248 self.uploads.push(upload);
249 self
250 }
251
252 pub fn uploads(mut self, uploads: impl IntoIterator<Item = Upload>) -> Self {
253 self.uploads.extend(uploads);
254 self
255 }
256
257 pub fn dropzone(mut self, zone: Dropzone) -> Self {
263 self.zone = Some(zone);
264 self
265 }
266
267 pub fn show_overall(mut self, show: bool) -> Self {
269 self.show_overall = show;
270 self
271 }
272
273 pub fn on_retry(
277 mut self,
278 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
279 ) -> Self {
280 self.on_retry = Some(Rc::new(handler));
281 self
282 }
283
284 pub fn on_cancel(
287 mut self,
288 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
289 ) -> Self {
290 self.on_cancel = Some(Rc::new(handler));
291 self
292 }
293
294 pub fn on_remove(
297 mut self,
298 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
299 ) -> Self {
300 self.on_remove = Some(Rc::new(handler));
301 self
302 }
303
304 pub fn overall(&self) -> OverallProgress {
311 let counted: Vec<&Upload> = self
312 .uploads
313 .iter()
314 .filter(|upload| !matches!(upload.state, UploadState::Refused { .. }))
318 .collect();
319 if counted.is_empty() {
320 return OverallProgress::Settled;
321 }
322 if counted.iter().all(|upload| upload.state.is_settled()) {
323 return OverallProgress::Settled;
324 }
325 let mut total = 0.0f32;
326 for upload in &counted {
327 let Some(fraction) = upload.state.fraction() else {
328 return OverallProgress::Indeterminate;
329 };
330 total += fraction;
331 }
332 OverallProgress::Known((total / counted.len() as f32).clamp(0.0, 1.0))
333 }
334
335 fn row(&self, upload: &Upload, cx: &mut App) -> AnyElement {
336 let theme = cx.theme().clone();
337 let ident = self.ident.child(upload.id.as_ref());
338 let live = !self.disabled;
339 let wording = upload.state.wording(cx);
340
341 let retry = self
345 .on_retry
346 .clone()
347 .filter(|_| live && upload.state.is_retryable())
348 .map(|handler| {
349 let id = upload.id.clone();
350 Button::new(ident.child("retry"))
351 .label(cx.strings().text(StringKey::TryAgain))
352 .ghost()
353 .control_size(ControlSize::Xs)
354 .semantic_parent(ident.semantic_id())
355 .on_click(move |window, cx| handler(id.clone(), window, cx))
356 });
357
358 let cancel = self
359 .on_cancel
360 .clone()
361 .filter(|_| live && !upload.state.is_settled())
362 .map(|handler| {
363 let id = upload.id.clone();
364 IconButton::new(
365 ident.child("cancel"),
366 Icon::Stop,
367 cx.strings()
368 .format(StringKey::UploadCancel, &[upload.name.as_ref()]),
369 )
370 .control_size(ControlSize::Xs)
371 .semantic_parent(ident.semantic_id())
372 .on_click(move |window, cx| handler(id.clone(), window, cx))
373 });
374
375 let remove = self
376 .on_remove
377 .clone()
378 .filter(|_| live && upload.state.is_settled())
379 .map(|handler| {
380 let id = upload.id.clone();
381 IconButton::new(
382 ident.child("remove"),
383 Icon::Close,
384 cx.strings()
385 .format(StringKey::UploadRemove, &[upload.name.as_ref()]),
386 )
387 .control_size(ControlSize::Xs)
388 .semantic_parent(ident.semantic_id())
389 .on_click(move |window, cx| handler(id.clone(), window, cx))
390 });
391
392 let bar = matches!(upload.state, UploadState::Uploading { .. }).then(|| {
393 let mut bar = ProgressBar::new(ident.child("progress"));
394 if let UploadState::Uploading {
395 fraction: Some(fraction),
396 } = upload.state
397 {
398 bar = bar.fraction(fraction);
399 }
400 bar
401 });
402
403 div()
404 .row()
405 .items_start()
406 .w_full()
407 .gap_token(&theme, Space::Sm)
408 .px_token(&theme, Space::Sm)
409 .py_token(&theme, Space::Xs)
410 .child(div().flex_none().mt(px(5.0)).child({
411 let dot = StatusDot::new(upload.state.tone());
412 match upload.state {
415 UploadState::Uploading { .. } => dot.busy(ident.child("mark")),
416 _ => dot,
417 }
418 }))
419 .child(
420 div()
421 .column()
422 .flex_1()
423 .min_w_0()
424 .gap_token(&theme, Space::Xs)
425 .child(
426 div()
427 .row()
428 .w_full()
429 .gap_token(&theme, Space::Sm)
430 .child(
431 foundation_text(&theme, TypeScale::Label, upload.name.clone())
432 .flex_1()
433 .min_w_0(),
434 )
435 .children(upload.size.clone().map(|size| {
436 foundation_text(&theme, TypeScale::Caption, size)
437 .flex_none()
438 .text_tone(&theme, gpui_kit_theme::TextTone::Faint)
439 })),
440 )
441 .child(match upload.state {
442 UploadState::Failed { .. } => {
443 foundation_text(&theme, TypeScale::Caption, wording.clone())
444 .text_color(theme.colors.danger)
445 }
446 UploadState::Refused { .. } => {
447 foundation_text(&theme, TypeScale::Caption, wording.clone())
448 .text_color(theme.colors.warning)
449 }
450 _ => foundation_text(&theme, TypeScale::Caption, wording.clone())
451 .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
452 })
453 .children(bar),
454 )
455 .children(retry)
456 .children(cancel)
457 .children(remove)
458 .semantic_in(
459 cx,
460 NodeSpec::new(ident.semantic_id(), Role::Row)
461 .parent(self.ident.semantic_id())
462 .text(upload.name.clone())
463 .value(upload.state.name())
466 .busy(matches!(upload.state, UploadState::Uploading { .. }))
467 .invalid(matches!(upload.state, UploadState::Failed { .. }))
468 .disabled(matches!(upload.state, UploadState::Refused { .. })),
471 )
472 .into_any_element()
473 }
474}
475
476impl Disableable for UploadList {
477 fn disabled(mut self, disabled: bool) -> Self {
478 self.disabled = disabled;
479 self
480 }
481}
482
483impl Sizable for UploadList {
484 fn control_size(mut self, size: ControlSize) -> Self {
485 self.size = size;
486 self
487 }
488}
489
490impl RenderOnce for UploadList {
491 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
492 let theme = cx.theme().clone();
493 let overall = self.overall();
494 let overall_ident = self.ident.child("overall");
495
496 let progress = (self.show_overall && overall != OverallProgress::Settled).then(|| {
497 let mut bar = ProgressBar::new(overall_ident.clone())
498 .label(cx.strings().text(StringKey::UploadOverall));
499 if let OverallProgress::Known(fraction) = overall {
500 bar = bar.fraction(fraction);
501 }
502 bar
503 });
504
505 let rows: Vec<AnyElement> = self
506 .uploads
507 .iter()
508 .map(|upload| self.row(upload, cx))
509 .collect();
510
511 let body = if rows.is_empty() {
512 EmptyState::new(
513 self.ident.child("empty"),
514 cx.strings().text(StringKey::UploadEmpty),
515 )
516 .kind(EmptyKind::Unstarted)
517 .into_any_element()
518 } else {
519 div().column().w_full().children(rows).into_any_element()
520 };
521
522 div()
523 .id(self.ident.element_id())
524 .column()
525 .w_full()
526 .gap_token(&theme, Space::Sm)
527 .radius(&theme, Radius::Card)
528 .when(self.disabled, |element| {
529 element.opacity(theme.opacity.disabled)
530 })
531 .children(self.zone)
532 .children(progress)
533 .child(body)
534 .semantic_in(
535 cx,
536 NodeSpec::new(self.ident.semantic_id(), Role::List)
537 .disabled(self.disabled)
538 .value(self.uploads.len().to_string()),
539 )
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn list(uploads: impl IntoIterator<Item = Upload>) -> UploadList {
548 UploadList::new("attachments").uploads(uploads)
549 }
550
551 #[test]
552 fn a_refusal_is_not_a_failure_and_offers_no_retry() {
553 let refused = UploadState::Refused {
554 reason: "larger than 25 MB".into(),
555 };
556 let failed = UploadState::Failed {
557 reason: "the connection dropped".into(),
558 };
559 assert_ne!(refused.name(), failed.name());
560 assert!(!refused.is_retryable());
561 assert!(failed.is_retryable());
562 assert!(refused.is_settled() && failed.is_settled());
563 }
564
565 #[test]
566 fn an_unknown_extent_takes_the_whole_batch_indeterminate() {
567 let known = list([
568 Upload::new("a", "a.bin").uploading(0.5),
569 Upload::new("b", "b.bin").done(),
570 ]);
571 assert_eq!(known.overall(), OverallProgress::Known(0.75));
572
573 let unknown = list([
574 Upload::new("a", "a.bin").uploading(None),
575 Upload::new("b", "b.bin").done(),
576 ]);
577 assert_eq!(unknown.overall(), OverallProgress::Indeterminate);
578 }
579
580 #[test]
581 fn a_batch_with_nothing_in_flight_claims_no_progress_at_all() {
582 let settled = list([
583 Upload::new("a", "a.bin").done(),
584 Upload::new("b", "b.bin").failed("the connection dropped"),
585 ]);
586 assert_eq!(settled.overall(), OverallProgress::Settled);
587 assert_eq!(list([]).overall(), OverallProgress::Settled);
588 }
589
590 #[test]
591 fn a_refused_file_is_not_part_of_the_work_being_measured() {
592 let batch = list([
593 Upload::new("a", "a.bin").uploading(0.5),
594 Upload::new("b", "b.exe").refused("this zone does not take programs"),
595 ]);
596 assert_eq!(batch.overall(), OverallProgress::Known(0.5));
599 }
600}