use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
use crate::controls::button::{Button, IconButton};
use crate::controls::dropzone::Dropzone;
use crate::display::badge::Tone;
use crate::display::empty::{EmptyKind, EmptyState};
use crate::display::progress::ProgressBar;
use crate::display::status::StatusDot;
use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
use crate::strings::{ActiveStrings, StringKey};
type FileHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum UploadState {
#[default]
Queued,
Uploading {
fraction: Option<f32>,
},
Done,
Failed {
reason: SharedString,
},
Cancelled,
Refused {
reason: SharedString,
},
}
impl UploadState {
pub fn name(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Uploading { .. } => "uploading",
Self::Done => "done",
Self::Failed { .. } => "failed",
Self::Cancelled => "cancelled",
Self::Refused { .. } => "refused",
}
}
pub fn is_settled(&self) -> bool {
matches!(
self,
Self::Done | Self::Failed { .. } | Self::Cancelled | Self::Refused { .. }
)
}
pub fn is_retryable(&self) -> bool {
matches!(self, Self::Failed { .. } | Self::Cancelled)
}
fn tone(&self) -> Tone {
match self {
Self::Queued => Tone::Neutral,
Self::Uploading { .. } => Tone::Accent,
Self::Done => Tone::Success,
Self::Failed { .. } => Tone::Danger,
Self::Cancelled => Tone::Neutral,
Self::Refused { .. } => Tone::Warning,
}
}
fn wording(&self, cx: &App) -> SharedString {
match self {
Self::Failed { reason } | Self::Refused { reason } => reason.clone(),
Self::Queued => cx.strings().text(StringKey::UploadQueued),
Self::Uploading { .. } => cx.strings().text(StringKey::UploadUploading),
Self::Done => cx.strings().text(StringKey::UploadDone),
Self::Cancelled => cx.strings().text(StringKey::UploadCancelled),
}
}
fn fraction(&self) -> Option<f32> {
match self {
Self::Uploading { fraction } => *fraction,
Self::Done => Some(1.0),
Self::Queued | Self::Cancelled | Self::Refused { .. } => Some(0.0),
Self::Failed { .. } => Some(0.0),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Upload {
pub id: SharedString,
pub name: SharedString,
pub size: Option<SharedString>,
pub state: UploadState,
}
impl Upload {
pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
size: None,
state: UploadState::default(),
}
}
pub fn size(mut self, size: impl Into<SharedString>) -> Self {
self.size = Some(size.into());
self
}
pub fn state(mut self, state: UploadState) -> Self {
self.state = state;
self
}
pub fn uploading(self, fraction: impl Into<Option<f32>>) -> Self {
self.state(UploadState::Uploading {
fraction: fraction.into(),
})
}
pub fn done(self) -> Self {
self.state(UploadState::Done)
}
pub fn failed(self, reason: impl Into<SharedString>) -> Self {
self.state(UploadState::Failed {
reason: reason.into(),
})
}
pub fn cancelled(self) -> Self {
self.state(UploadState::Cancelled)
}
pub fn refused(self, reason: impl Into<SharedString>) -> Self {
self.state(UploadState::Refused {
reason: reason.into(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OverallProgress {
Known(f32),
Indeterminate,
Settled,
}
#[derive(IntoElement)]
pub struct UploadList {
ident: Ident,
uploads: Vec<Upload>,
zone: Option<Dropzone>,
size: ControlSize,
disabled: bool,
show_overall: bool,
on_retry: Option<FileHandler>,
on_cancel: Option<FileHandler>,
on_remove: Option<FileHandler>,
}
impl std::fmt::Debug for UploadList {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UploadList")
.field("ident", &self.ident)
.field("uploads", &self.uploads.len())
.field("has_zone", &self.zone.is_some())
.field("disabled", &self.disabled)
.finish()
}
}
impl UploadList {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
uploads: Vec::new(),
zone: None,
size: ControlSize::Sm,
disabled: false,
show_overall: true,
on_retry: None,
on_cancel: None,
on_remove: None,
}
}
pub fn upload(mut self, upload: Upload) -> Self {
self.uploads.push(upload);
self
}
pub fn uploads(mut self, uploads: impl IntoIterator<Item = Upload>) -> Self {
self.uploads.extend(uploads);
self
}
pub fn dropzone(mut self, zone: Dropzone) -> Self {
self.zone = Some(zone);
self
}
pub fn show_overall(mut self, show: bool) -> Self {
self.show_overall = show;
self
}
pub fn on_retry(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
pub fn on_cancel(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_cancel = Some(Rc::new(handler));
self
}
pub fn on_remove(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
pub fn overall(&self) -> OverallProgress {
let counted: Vec<&Upload> = self
.uploads
.iter()
.filter(|upload| !matches!(upload.state, UploadState::Refused { .. }))
.collect();
if counted.is_empty() {
return OverallProgress::Settled;
}
if counted.iter().all(|upload| upload.state.is_settled()) {
return OverallProgress::Settled;
}
let mut total = 0.0f32;
for upload in &counted {
let Some(fraction) = upload.state.fraction() else {
return OverallProgress::Indeterminate;
};
total += fraction;
}
OverallProgress::Known((total / counted.len() as f32).clamp(0.0, 1.0))
}
fn row(&self, upload: &Upload, cx: &mut App) -> AnyElement {
let theme = cx.theme().clone();
let ident = self.ident.child(upload.id.as_ref());
let live = !self.disabled;
let wording = upload.state.wording(cx);
let retry = self
.on_retry
.clone()
.filter(|_| live && upload.state.is_retryable())
.map(|handler| {
let id = upload.id.clone();
Button::new(ident.child("retry"))
.label(cx.strings().text(StringKey::TryAgain))
.ghost()
.control_size(ControlSize::Xs)
.semantic_parent(ident.semantic_id())
.on_click(move |window, cx| handler(id.clone(), window, cx))
});
let cancel = self
.on_cancel
.clone()
.filter(|_| live && !upload.state.is_settled())
.map(|handler| {
let id = upload.id.clone();
IconButton::new(
ident.child("cancel"),
Icon::Stop,
cx.strings()
.format(StringKey::UploadCancel, &[upload.name.as_ref()]),
)
.control_size(ControlSize::Xs)
.semantic_parent(ident.semantic_id())
.on_click(move |window, cx| handler(id.clone(), window, cx))
});
let remove = self
.on_remove
.clone()
.filter(|_| live && upload.state.is_settled())
.map(|handler| {
let id = upload.id.clone();
IconButton::new(
ident.child("remove"),
Icon::Close,
cx.strings()
.format(StringKey::UploadRemove, &[upload.name.as_ref()]),
)
.control_size(ControlSize::Xs)
.semantic_parent(ident.semantic_id())
.on_click(move |window, cx| handler(id.clone(), window, cx))
});
let bar = matches!(upload.state, UploadState::Uploading { .. }).then(|| {
let mut bar = ProgressBar::new(ident.child("progress"));
if let UploadState::Uploading {
fraction: Some(fraction),
} = upload.state
{
bar = bar.fraction(fraction);
}
bar
});
div()
.row()
.items_start()
.w_full()
.gap_token(&theme, Space::Sm)
.px_token(&theme, Space::Sm)
.py_token(&theme, Space::Xs)
.child(div().flex_none().mt(px(5.0)).child({
let dot = StatusDot::new(upload.state.tone());
match upload.state {
UploadState::Uploading { .. } => dot.busy(ident.child("mark")),
_ => dot,
}
}))
.child(
div()
.column()
.flex_1()
.min_w_0()
.gap_token(&theme, Space::Xs)
.child(
div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.child(
foundation_text(&theme, TypeScale::Label, upload.name.clone())
.flex_1()
.min_w_0(),
)
.children(upload.size.clone().map(|size| {
foundation_text(&theme, TypeScale::Caption, size)
.flex_none()
.text_tone(&theme, gpui_kit_theme::TextTone::Faint)
})),
)
.child(match upload.state {
UploadState::Failed { .. } => {
foundation_text(&theme, TypeScale::Caption, wording.clone())
.text_color(theme.colors.danger)
}
UploadState::Refused { .. } => {
foundation_text(&theme, TypeScale::Caption, wording.clone())
.text_color(theme.colors.warning)
}
_ => foundation_text(&theme, TypeScale::Caption, wording.clone())
.text_tone(&theme, gpui_kit_theme::TextTone::Muted),
})
.children(bar),
)
.children(retry)
.children(cancel)
.children(remove)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(self.ident.semantic_id())
.text(upload.name.clone())
.value(upload.state.name())
.busy(matches!(upload.state, UploadState::Uploading { .. }))
.invalid(matches!(upload.state, UploadState::Failed { .. }))
.disabled(matches!(upload.state, UploadState::Refused { .. })),
)
.into_any_element()
}
}
impl Disableable for UploadList {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for UploadList {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for UploadList {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let overall = self.overall();
let overall_ident = self.ident.child("overall");
let progress = (self.show_overall && overall != OverallProgress::Settled).then(|| {
let mut bar = ProgressBar::new(overall_ident.clone())
.label(cx.strings().text(StringKey::UploadOverall));
if let OverallProgress::Known(fraction) = overall {
bar = bar.fraction(fraction);
}
bar
});
let rows: Vec<AnyElement> = self
.uploads
.iter()
.map(|upload| self.row(upload, cx))
.collect();
let body = if rows.is_empty() {
EmptyState::new(
self.ident.child("empty"),
cx.strings().text(StringKey::UploadEmpty),
)
.kind(EmptyKind::Unstarted)
.into_any_element()
} else {
div().column().w_full().children(rows).into_any_element()
};
div()
.id(self.ident.element_id())
.column()
.w_full()
.gap_token(&theme, Space::Sm)
.radius(&theme, Radius::Card)
.when(self.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.children(self.zone)
.children(progress)
.child(body)
.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::List)
.disabled(self.disabled)
.value(self.uploads.len().to_string()),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(uploads: impl IntoIterator<Item = Upload>) -> UploadList {
UploadList::new("attachments").uploads(uploads)
}
#[test]
fn a_refusal_is_not_a_failure_and_offers_no_retry() {
let refused = UploadState::Refused {
reason: "larger than 25 MB".into(),
};
let failed = UploadState::Failed {
reason: "the connection dropped".into(),
};
assert_ne!(refused.name(), failed.name());
assert!(!refused.is_retryable());
assert!(failed.is_retryable());
assert!(refused.is_settled() && failed.is_settled());
}
#[test]
fn an_unknown_extent_takes_the_whole_batch_indeterminate() {
let known = list([
Upload::new("a", "a.bin").uploading(0.5),
Upload::new("b", "b.bin").done(),
]);
assert_eq!(known.overall(), OverallProgress::Known(0.75));
let unknown = list([
Upload::new("a", "a.bin").uploading(None),
Upload::new("b", "b.bin").done(),
]);
assert_eq!(unknown.overall(), OverallProgress::Indeterminate);
}
#[test]
fn a_batch_with_nothing_in_flight_claims_no_progress_at_all() {
let settled = list([
Upload::new("a", "a.bin").done(),
Upload::new("b", "b.bin").failed("the connection dropped"),
]);
assert_eq!(settled.overall(), OverallProgress::Settled);
assert_eq!(list([]).overall(), OverallProgress::Settled);
}
#[test]
fn a_refused_file_is_not_part_of_the_work_being_measured() {
let batch = list([
Upload::new("a", "a.bin").uploading(0.5),
Upload::new("b", "b.exe").refused("this zone does not take programs"),
]);
assert_eq!(batch.overall(), OverallProgress::Known(0.5));
}
}