1use crate::{
15 control::{PressReleaseExt as _, composed_button},
16 handlers::SharedHandler,
17 motion::{ArrivalRoster, MotionTokens, Shimmer},
18 stream::ProgressState,
19 surface::icon_button,
20 theme::SemanticStyledExt as _,
21};
22use gpui::{
23 App, ClickEvent, ElementId, FontWeight, Image, InteractiveElement as _, IntoElement, ObjectFit,
24 ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
25 StyleRefinement, Styled, StyledImage as _, Window, div, img, prelude::FluentBuilder as _,
26};
27use gpui_component::{
28 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner,
29};
30use std::{fmt, rc::Rc, sync::Arc};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum AttachmentKind {
35 Image,
37 Document,
39 Code,
41 Audio,
43 Video,
45 Archive,
47 Data,
49 #[default]
51 Other,
52}
53
54impl AttachmentKind {
55 pub fn from_name(name: &str) -> Self {
57 let extension = name
58 .rsplit_once('.')
59 .map(|(_, extension)| extension.to_ascii_lowercase())
60 .unwrap_or_default();
61 match extension.as_str() {
62 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "heic" | "avif" => {
63 Self::Image
64 }
65 "md" | "txt" | "pdf" | "doc" | "docx" | "rtf" | "odt" | "pages" => Self::Document,
66 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "rb" | "java" | "kt" | "swift"
67 | "c" | "h" | "cpp" | "hpp" | "cs" | "sh" | "zsh" | "toml" | "yaml" | "yml"
68 | "html" | "css" | "sql" => Self::Code,
69 "mp3" | "wav" | "m4a" | "ogg" | "flac" | "aac" => Self::Audio,
70 "mp4" | "mov" | "webm" | "mkv" | "avi" => Self::Video,
71 "zip" | "tar" | "gz" | "tgz" | "7z" | "rar" | "bz2" | "xz" => Self::Archive,
72 "csv" | "tsv" | "json" | "xlsx" | "xls" | "parquet" | "ndjson" => Self::Data,
73 _ => Self::Other,
74 }
75 }
76
77 pub fn label(self) -> &'static str {
79 match self {
80 Self::Image => "Image",
81 Self::Document => "Document",
82 Self::Code => "Code",
83 Self::Audio => "Audio",
84 Self::Video => "Video",
85 Self::Archive => "Archive",
86 Self::Data => "Data",
87 Self::Other => "File",
88 }
89 }
90
91 fn icon(self) -> IconName {
92 match self {
93 Self::Image => IconName::GalleryVerticalEnd,
94 Self::Document => IconName::File,
95 Self::Code => IconName::SquareTerminal,
96 Self::Audio => IconName::Play,
97 Self::Video => IconName::Frame,
98 Self::Archive => IconName::Folder,
99 Self::Data => IconName::ChartPie,
100 Self::Other => IconName::File,
101 }
102 }
103}
104
105#[derive(Clone)]
110pub struct Attachment {
111 id: SharedString,
112 name: SharedString,
113 kind: AttachmentKind,
114 size_bytes: Option<u64>,
115 detail: Option<SharedString>,
116 thumbnail: Option<Arc<Image>>,
117 state: ProgressState,
118 progress: Option<f32>,
119}
120
121impl Attachment {
122 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
124 let name = name.into();
125 Self {
126 id: id.into(),
127 kind: AttachmentKind::from_name(&name),
128 name,
129 size_bytes: None,
130 detail: None,
131 thumbnail: None,
132 state: ProgressState::Complete,
133 progress: None,
134 }
135 }
136
137 pub fn kind(mut self, kind: AttachmentKind) -> Self {
139 self.kind = kind;
140 self
141 }
142
143 pub fn size_bytes(mut self, size_bytes: u64) -> Self {
145 self.size_bytes = Some(size_bytes);
146 self
147 }
148
149 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
151 self.detail = Some(detail.into());
152 self
153 }
154
155 pub fn thumbnail(mut self, image: Arc<Image>) -> Self {
157 self.thumbnail = Some(image);
158 self
159 }
160
161 pub fn state(mut self, state: ProgressState) -> Self {
164 self.state = state;
165 self
166 }
167
168 pub fn progress(mut self, fraction: f32) -> Self {
170 self.progress = Some(fraction.clamp(0.0, 1.0));
171 self
172 }
173
174 pub fn id(&self) -> &SharedString {
176 &self.id
177 }
178
179 pub fn name(&self) -> &SharedString {
181 &self.name
182 }
183
184 pub fn attachment_kind(&self) -> AttachmentKind {
186 self.kind
187 }
188
189 pub fn size(&self) -> Option<u64> {
191 self.size_bytes
192 }
193
194 pub fn detail_text(&self) -> Option<&SharedString> {
196 self.detail.as_ref()
197 }
198
199 pub fn thumbnail_image(&self) -> Option<&Arc<Image>> {
201 self.thumbnail.as_ref()
202 }
203
204 pub fn upload_state(&self) -> &ProgressState {
206 &self.state
207 }
208
209 pub fn is_ready(&self) -> bool {
211 self.state == ProgressState::Complete
212 }
213
214 pub fn summary(&self) -> String {
217 match &self.state {
218 ProgressState::Pending => "Queued".to_owned(),
219 ProgressState::Running => match self.progress {
220 Some(fraction) => format!("Uploading {}%", (fraction * 100.0).round() as u32),
221 None => "Uploading".to_owned(),
222 },
223 ProgressState::Failed(reason) => format!("Failed: {reason}"),
224 ProgressState::Complete => {
225 let mut parts = vec![self.kind.label().to_owned()];
226 if let Some(size) = self.size_bytes {
227 parts.push(format_bytes(size));
228 }
229 if let Some(detail) = &self.detail {
230 parts.push(detail.to_string());
231 }
232 parts.join(" · ")
233 }
234 }
235 }
236
237 pub fn accessibility_label(&self) -> String {
239 format!("{}, {}", self.name, self.summary())
240 }
241}
242
243impl PartialEq for Attachment {
244 fn eq(&self, other: &Self) -> bool {
245 self.id == other.id
246 && self.name == other.name
247 && self.kind == other.kind
248 && self.size_bytes == other.size_bytes
249 && self.detail == other.detail
250 && self.state == other.state
251 && self.progress == other.progress
252 && match (&self.thumbnail, &other.thumbnail) {
253 (Some(mine), Some(theirs)) => Arc::ptr_eq(mine, theirs),
254 (None, None) => true,
255 _ => false,
256 }
257 }
258}
259
260impl Eq for Attachment {}
261
262impl fmt::Debug for Attachment {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 f.debug_struct("Attachment")
265 .field("id", &self.id)
266 .field("name", &self.name)
267 .field("kind", &self.kind)
268 .field("size_bytes", &self.size_bytes)
269 .field("detail", &self.detail)
270 .field("thumbnail", &self.thumbnail.as_ref().map(|_| "<image>"))
271 .field("state", &self.state)
272 .field("progress", &self.progress)
273 .finish()
274 }
275}
276
277pub fn format_bytes(bytes: u64) -> String {
279 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
280 if bytes < 1000 {
281 return format!("{bytes} B");
282 }
283 let mut value = bytes as f64;
284 let mut unit = 0;
285 while value >= 1000.0 && unit < UNITS.len() - 1 {
286 value /= 1000.0;
287 unit += 1;
288 }
289 if value >= 10.0 {
290 format!("{} {}", value.round() as u64, UNITS[unit])
291 } else {
292 format!("{value:.1} {}", UNITS[unit])
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum AttachmentEvent {
299 Opened {
301 id: SharedString,
303 },
304 Removed {
306 id: SharedString,
308 },
309}
310
311#[derive(IntoElement)]
328pub struct AttachmentPreview {
329 id: ElementId,
330 style: StyleRefinement,
331 attachment: Attachment,
332 removable: bool,
333 compact: bool,
334 on_event: Option<SharedHandler<AttachmentEvent>>,
335}
336
337impl AttachmentPreview {
338 pub fn new(id: impl Into<ElementId>, attachment: &Attachment) -> Self {
340 Self {
341 id: id.into(),
342 style: StyleRefinement::default(),
343 attachment: attachment.clone(),
344 removable: false,
345 compact: false,
346 on_event: None,
347 }
348 }
349
350 pub fn removable(mut self, removable: bool) -> Self {
352 self.removable = removable;
353 self
354 }
355
356 pub fn compact(mut self, compact: bool) -> Self {
358 self.compact = compact;
359 self
360 }
361
362 pub fn on_event(
364 mut self,
365 handler: impl Fn(&AttachmentEvent, &mut Window, &mut App) + 'static,
366 ) -> Self {
367 self.on_event = Some(Rc::new(handler));
368 self
369 }
370
371 fn on_shared_event(mut self, handler: SharedHandler<AttachmentEvent>) -> Self {
372 self.on_event = Some(handler);
373 self
374 }
375}
376
377impl Styled for AttachmentPreview {
378 fn style(&mut self) -> &mut StyleRefinement {
379 &mut self.style
380 }
381}
382
383impl RenderOnce for AttachmentPreview {
384 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
385 let tokens = cx.theme().semantic_tokens();
386 let attachment = self.attachment;
387 let debug_id = attachment.id.to_string();
388 let label = attachment.accessibility_label();
389 let summary = attachment.summary();
390 let failed = matches!(attachment.state, ProgressState::Failed(_));
391 let running = attachment.state == ProgressState::Running;
392 let slot = crate::sizing::slot_md(cx);
396 let leading_size = if self.compact { slot } else { slot * 2. };
397
398 let leading = match (&attachment.thumbnail, running) {
400 (Some(image), _) => img(image.clone())
401 .flex_none()
402 .size(leading_size)
403 .rounded(tokens.radius.sm)
404 .object_fit(ObjectFit::Cover)
405 .into_any_element(),
406 (None, true) if self.compact => Spinner::new().xsmall().into_any_element(),
407 (None, _) => div()
408 .flex_none()
409 .size(leading_size)
410 .flex()
411 .items_center()
412 .justify_center()
413 .rounded(tokens.radius.sm)
414 .bg(if failed {
415 cx.theme().danger.opacity(0.12)
416 } else {
417 cx.theme().primary.opacity(0.1)
418 })
419 .text_color(if failed {
420 cx.theme().danger
421 } else {
422 cx.theme().primary
423 })
424 .child(if running {
425 Spinner::new().xsmall().into_any_element()
426 } else {
427 Icon::new(attachment.kind.icon())
428 .xsmall()
429 .into_any_element()
430 })
431 .into_any_element(),
432 };
433
434 let name = div()
435 .min_w_0()
436 .truncate()
437 .text_token(tokens.typography.sm)
438 .font_weight(FontWeight::MEDIUM)
439 .text_color(cx.theme().foreground)
440 .child(attachment.name.clone());
441 let meta_text = if running {
442 Shimmer::new((self.id.clone(), "summary"), summary.clone()).into_any_element()
443 } else {
444 div().truncate().child(summary.clone()).into_any_element()
445 };
446 let meta = div()
447 .min_w_0()
448 .text_token(tokens.typography.xs)
449 .text_color(if failed {
450 cx.theme().danger
451 } else {
452 cx.theme().muted_foreground
453 })
454 .child(meta_text);
455
456 let body = if self.compact {
457 h_flex()
458 .min_w_0()
459 .items_baseline()
460 .gap(tokens.spacing.xs)
461 .child(name)
462 .when(!attachment.is_ready(), |this| this.child(meta))
463 .into_any_element()
464 } else {
465 div()
466 .flex()
467 .flex_col()
468 .min_w_0()
469 .gap(tokens.spacing.xxs)
470 .child(name)
471 .child(meta)
472 .into_any_element()
473 };
474
475 let compact = self.compact;
476 let style = self.style;
477 match (self.removable, self.on_event.clone()) {
478 (true, handler) => {
479 let remove_id = attachment.id.clone();
480 let remove_debug_id = debug_id.clone();
481 tile_frame(div().id(self.id.clone()), compact, failed, cx)
482 .role(Role::Group)
483 .aria_label(label.clone())
484 .debug_selector(move || format!("attachment-{debug_id}"))
485 .child(leading)
486 .child(body)
487 .when_some(handler, |this, handler| {
488 this.child(
489 icon_button(
490 (self.id.clone(), "remove"),
491 IconName::Close,
492 format!("Remove {}", attachment.name),
493 window,
494 cx,
495 )
496 .debug_selector(move || format!("attachment-remove-{remove_debug_id}"))
497 .on_click(
498 move |_: &ClickEvent, window, cx| {
499 handler(
500 &AttachmentEvent::Removed {
501 id: remove_id.clone(),
502 },
503 window,
504 cx,
505 )
506 },
507 ),
508 )
509 })
510 .refine_style(&style)
511 .into_any_element()
512 }
513 (false, Some(handler)) => {
514 let open_id = attachment.id.clone();
515 tile_frame(
516 composed_button(self.id.clone(), label.clone()),
517 compact,
518 failed,
519 cx,
520 )
521 .debug_selector(move || format!("attachment-{debug_id}"))
522 .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
524 .active(|style| style.bg(cx.theme().accent))
525 .focus_visible(|style| style.border_color(cx.theme().ring))
526 .press_release(
527 ElementId::from((self.id.clone(), "press")),
528 tokens.radius.md,
529 window,
530 cx,
531 )
532 .child(leading)
533 .child(body)
534 .on_click(move |_: &ClickEvent, window, cx| {
535 handler(
536 &AttachmentEvent::Opened {
537 id: open_id.clone(),
538 },
539 window,
540 cx,
541 )
542 })
543 .refine_style(&style)
544 .into_any_element()
545 }
546 (false, None) => tile_frame(div().id(self.id.clone()), compact, failed, cx)
547 .role(Role::Group)
548 .aria_label(label.clone())
549 .debug_selector(move || format!("attachment-{debug_id}"))
550 .child(leading)
551 .child(body)
552 .refine_style(&style)
553 .into_any_element(),
554 }
555 }
556}
557
558fn tile_frame<E: Styled>(element: E, compact: bool, failed: bool, cx: &App) -> E {
561 let tokens = cx.theme().semantic_tokens();
562 element
563 .flex()
564 .items_center()
565 .max_w_full()
566 .min_w_0()
567 .gap(tokens.spacing.sm)
568 .px(if compact {
569 tokens.spacing.sm
570 } else {
571 tokens.spacing.md
572 })
573 .py(if compact {
574 tokens.spacing.xxs
575 } else {
576 tokens.spacing.sm
577 })
578 .border_1()
579 .border_color(if failed {
580 cx.theme().danger.opacity(0.5)
581 } else {
582 cx.theme().border
583 })
584 .rounded(if compact {
585 tokens.radius.full
586 } else {
587 tokens.radius.md
588 })
589 .bg(tokens.colors.surface)
590}
591
592#[derive(IntoElement)]
605pub struct AttachmentStrip {
606 id: ElementId,
607 style: StyleRefinement,
608 label: SharedString,
609 items: Vec<Attachment>,
610 removable: bool,
611 compact: bool,
612 on_event: Option<SharedHandler<AttachmentEvent>>,
613}
614
615impl AttachmentStrip {
616 pub fn new(id: impl Into<ElementId>) -> Self {
618 Self {
619 id: id.into(),
620 style: StyleRefinement::default(),
621 label: "Attachments".into(),
622 items: Vec::new(),
623 removable: false,
624 compact: false,
625 on_event: None,
626 }
627 }
628
629 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
631 self.label = label.into();
632 self
633 }
634
635 pub fn items(mut self, items: impl IntoIterator<Item = Attachment>) -> Self {
637 self.items = items.into_iter().collect();
638 self
639 }
640
641 pub fn removable(mut self, removable: bool) -> Self {
643 self.removable = removable;
644 self
645 }
646
647 pub fn compact(mut self, compact: bool) -> Self {
649 self.compact = compact;
650 self
651 }
652
653 pub fn on_event(
655 mut self,
656 handler: impl Fn(&AttachmentEvent, &mut Window, &mut App) + 'static,
657 ) -> Self {
658 self.on_event = Some(Rc::new(handler));
659 self
660 }
661}
662
663impl Styled for AttachmentStrip {
664 fn style(&mut self) -> &mut StyleRefinement {
665 &mut self.style
666 }
667}
668
669impl RenderOnce for AttachmentStrip {
670 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
671 let tokens = cx.theme().semantic_tokens();
672 let root_id = self.id.clone();
673
674 let motion = MotionTokens::read(cx).clone();
678 let roster = window.use_keyed_state((root_id.clone(), "arrivals"), cx, |_, _| {
679 ArrivalRoster::new()
680 });
681 roster.update(cx, |roster, cx| {
682 roster.note(
683 self.items.iter().map(|attachment| {
684 ElementId::Name(SharedString::from(format!("tile-arrive-{}", attachment.id)))
685 }),
686 true,
687 &motion,
688 cx.background_executor().now(),
689 );
690 });
691
692 let mut tiles = Vec::with_capacity(self.items.len());
693 for attachment in self.items.iter() {
694 let tile_id = ElementId::from((root_id.clone(), format!("tile-{}", attachment.id)));
695 let mut preview = AttachmentPreview::new(tile_id, attachment)
696 .removable(self.removable)
697 .compact(self.compact);
698 if let Some(handler) = self.on_event.clone() {
699 preview = preview.on_shared_event(handler);
700 }
701 let arrival = roster.update(cx, |roster, cx| {
702 roster.progress(
703 &ElementId::Name(SharedString::from(format!("tile-arrive-{}", attachment.id))),
704 window,
705 cx,
706 )
707 });
708 let preview = match arrival {
709 Some(progress) => preview
710 .opacity(progress)
711 .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx)),
712 None => preview,
713 };
714 tiles.push(preview);
715 }
716 h_flex()
717 .id(self.id)
718 .role(Role::Group)
719 .aria_label(self.label)
720 .flex_wrap()
721 .w_full()
722 .min_w_0()
723 .gap(tokens.spacing.xs)
724 .children(tiles)
725 .refine_style(&self.style)
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use gpui::{Context, Render, TestAppContext, VisualTestContext, div, px};
733
734 struct StripProbe {
735 count: usize,
736 }
737
738 impl Render for StripProbe {
739 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
740 div().w(px(360.)).h(px(320.)).child(
741 AttachmentStrip::new("probe-strip").items(
742 (0..self.count).map(|ix| Attachment::new(format!("a-{ix}"), "notes.md")),
743 ),
744 )
745 }
746 }
747
748 fn draw(cx: &mut VisualTestContext) {
749 cx.update(|window, cx| window.draw(cx).clear(cx));
750 }
751
752 #[gpui::test]
753 fn a_restored_strip_rests_and_a_newly_attached_file_settles_in(cx: &mut TestAppContext) {
754 cx.update(crate::init);
755 let (probe, cx) = cx.add_window_view(|_, _| StripProbe { count: 3 });
756 let cx: &mut VisualTestContext = cx;
757 draw(cx);
758 crate::motion::take_reveal_frame_requests();
759 draw(cx);
760 assert_eq!(
761 crate::motion::take_reveal_frame_requests(),
762 0,
763 "a restored strip joins at rest"
764 );
765
766 probe.update(cx, |probe, cx| {
767 probe.count = 4;
768 cx.notify();
769 });
770 draw(cx);
771 assert!(
772 crate::motion::take_reveal_frame_requests() > 0,
773 "a file attached to a mounted strip must settle in"
774 );
775 }
776
777 #[test]
778 fn kinds_follow_extensions_case_insensitively() {
779 assert_eq!(AttachmentKind::from_name("Hero.PNG"), AttachmentKind::Image);
780 assert_eq!(
781 AttachmentKind::from_name("notes.md"),
782 AttachmentKind::Document
783 );
784 assert_eq!(AttachmentKind::from_name("main.rs"), AttachmentKind::Code);
785 assert_eq!(AttachmentKind::from_name("call.m4a"), AttachmentKind::Audio);
786 assert_eq!(AttachmentKind::from_name("demo.mov"), AttachmentKind::Video);
787 assert_eq!(
788 AttachmentKind::from_name("bundle.tar.gz"),
789 AttachmentKind::Archive
790 );
791 assert_eq!(AttachmentKind::from_name("sales.csv"), AttachmentKind::Data);
792 assert_eq!(AttachmentKind::from_name("README"), AttachmentKind::Other);
793 }
794
795 #[test]
796 fn byte_counts_read_like_a_file_manager() {
797 assert_eq!(format_bytes(820), "820 B");
798 assert_eq!(format_bytes(12_300), "12 KB");
799 assert_eq!(format_bytes(1_400_000), "1.4 MB");
800 assert_eq!(format_bytes(3_500_000_000), "3.5 GB");
801 }
802
803 #[test]
804 fn summaries_describe_lifecycle_before_metadata() {
805 let ready = Attachment::new("a", "pricing.md")
806 .size_bytes(12_300)
807 .detail("12 pages");
808 assert_eq!(ready.summary(), "Document · 12 KB · 12 pages");
809 assert_eq!(
810 ready.accessibility_label(),
811 "pricing.md, Document · 12 KB · 12 pages"
812 );
813
814 let uploading = Attachment::new("b", "hero.png")
815 .state(ProgressState::Running)
816 .progress(0.4);
817 assert_eq!(uploading.summary(), "Uploading 40%");
818 assert!(!uploading.is_ready());
819
820 let failed =
821 Attachment::new("c", "big.zip").state(ProgressState::Failed("Too large".into()));
822 assert_eq!(failed.summary(), "Failed: Too large");
823 }
824
825 #[test]
826 fn equality_compares_thumbnails_by_identity() {
827 let image = Arc::new(Image::from_bytes(gpui::ImageFormat::Png, Vec::new()));
828 let first = Attachment::new("a", "hero.png").thumbnail(image.clone());
829 let same = Attachment::new("a", "hero.png").thumbnail(image);
830 let other = Attachment::new("a", "hero.png").thumbnail(Arc::new(Image::from_bytes(
831 gpui::ImageFormat::Png,
832 Vec::new(),
833 )));
834 assert_eq!(first, same);
835 assert_ne!(first, other);
836 }
837}