Skip to main content

gpui_kit/controls/
upload_list.rs

1//! Files on their way somewhere, over the [`Dropzone`] that took them.
2//!
3//! Nothing here uploads anything: this crate has no network, the same reason
4//! `ImageViewer` fetches nothing and `TransportBar` plays nothing. Every state
5//! on the list is a fact the host established, and every control reports.
6//!
7//! # A refusal is not a failure
8//!
9//! A file the host would not take — too large, the wrong kind, past a quota —
10//! never started, so it did not fail. [`UploadState::Refused`] is its own
11//! state, carrying the host's reason and offering **no retry**, because trying
12//! the same file against the same rule again cannot end differently. That is
13//! [`Dropzone`]'s own distinction between refusing and idle, carried one step
14//! further: the zone refuses a payload while it is over the zone, and this
15//! list keeps the refusal afterwards where the file's row is.
16//!
17//! [`UploadState::Failed`] is the other thing entirely: it started, it did not
18//! finish, the host's reason says why, and a retry is offered.
19//!
20//! # Overall progress is only claimed when it is known
21//!
22//! The list adds up the per-file fractions, and stops if any file that is
23//! uploading does not have one. A file uploading against a length nobody
24//! declared has no fraction, so the total has no extent either, and the bar
25//! goes indeterminate exactly as [`ProgressBar`] already does rather than
26//! inventing a percentage out of a file count.
27
28use 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/// Where one file has got to.
50#[derive(Debug, Clone, PartialEq, Default)]
51pub enum UploadState {
52    /// Accepted and waiting its turn. Nothing has been sent.
53    #[default]
54    Queued,
55    /// On its way. `fraction` is `None` when nobody knows how much there is
56    /// to send, which is a state and not a zero.
57    Uploading {
58        fraction: Option<f32>,
59    },
60    Done,
61    /// It started and did not finish, in the host's own words.
62    Failed {
63        reason: SharedString,
64    },
65    /// Somebody stopped it. Distinct from failed: nothing went wrong.
66    Cancelled,
67    /// The host would not take it at all, in its own words.
68    Refused {
69        reason: SharedString,
70    },
71}
72
73impl UploadState {
74    /// The name the semantic tree publishes, so a test tells the six apart
75    /// without reading a colour.
76    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    /// Whether anything more is going to happen to this file on its own.
88    pub fn is_settled(&self) -> bool {
89        matches!(
90            self,
91            Self::Done | Self::Failed { .. } | Self::Cancelled | Self::Refused { .. }
92        )
93    }
94
95    /// Whether trying again could end differently. A refusal could not.
96    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            // A refusal is the host declining, not something breaking, and it
108            // reads at the same weight `Dropzone` gives one.
109            Self::Refused { .. } => Tone::Warning,
110        }
111    }
112
113    /// The words beside the file's name.
114    fn wording(&self, cx: &App) -> SharedString {
115        match self {
116            // The host's own reason outranks the catalogue's word for it.
117            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    /// How much of this file is done, when that is known at all.
126    fn fraction(&self) -> Option<f32> {
127        match self {
128            Self::Uploading { fraction } => *fraction,
129            Self::Done => Some(1.0),
130            // Nothing has been sent, or nothing more will be.
131            Self::Queued | Self::Cancelled | Self::Refused { .. } => Some(0.0),
132            Self::Failed { .. } => Some(0.0),
133        }
134    }
135}
136
137/// One file on the list, identified by business identity.
138#[derive(Debug, Clone, PartialEq)]
139pub struct Upload {
140    pub id: SharedString,
141    pub name: SharedString,
142    /// The size, already worded by the host. This crate formats no quantities.
143    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    /// The size as the host already worded it.
158    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/// How much of the whole batch is done, if anybody knows.
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub enum OverallProgress {
198    /// Every file in flight declared an extent, so the total has one.
199    Known(f32),
200    /// At least one file in flight has no extent, so neither has the total.
201    Indeterminate,
202    /// Nothing is in flight.
203    Settled,
204}
205
206/// A list of files being uploaded, optionally over the zone that took them.
207#[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    /// The zone the files arrive through, drawn above the list.
258    ///
259    /// Sharing the surface is the point: a payload the zone refuses while it
260    /// is being dragged and a file the host refused after it landed are the
261    /// same refusal, said in the same place.
262    pub fn dropzone(mut self, zone: Dropzone) -> Self {
263        self.zone = Some(zone);
264        self
265    }
266
267    /// Whether the batch progress bar is drawn at all.
268    pub fn show_overall(mut self, show: bool) -> Self {
269        self.show_overall = show;
270        self
271    }
272
273    /// Reports a file that should be tried again. The list retries nothing.
274    ///
275    /// A refused file never gets this control, whatever the handler says.
276    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    /// Reports a file that should be stopped. Offered only while one is still
285    /// on its way or waiting to be.
286    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    /// Reports a file that should leave the list. Offered only once nothing
295    /// more is going to happen to it.
296    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    /// How much of the batch is done.
305    ///
306    /// Known only when every file still in flight declared an extent. A single
307    /// file uploading against an unknown length takes the whole batch to
308    /// indeterminate, because a total assembled from a number nobody has is
309    /// not a total.
310    pub fn overall(&self) -> OverallProgress {
311        let counted: Vec<&Upload> = self
312            .uploads
313            .iter()
314            // A refused file was never part of the work, so it is not part of
315            // the denominator either: a batch of nine that refused one is nine
316            // eighths done otherwise.
317            .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        // A refusal never gets a retry: the same file against the same rule
342        // cannot end differently, and a control that could not work is worse
343        // than none.
344        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                // Only a file actually on its way moves. A queued one is
413                // waiting for a turn, which is not the same as working.
414                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                    // The state is published by name, so a refusal and a
464                    // failure cannot be mistaken for one another.
465                    .value(upload.state.name())
466                    .busy(matches!(upload.state, UploadState::Uploading { .. }))
467                    .invalid(matches!(upload.state, UploadState::Failed { .. }))
468                    // A refusal is the host declining, not the row breaking,
469                    // so it is published as disabled rather than invalid.
470                    .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        // Half of the one file that is actually being sent, not a quarter of
597        // two files one of which was never taken.
598        assert_eq!(batch.overall(), OverallProgress::Known(0.5));
599    }
600}