Skip to main content

gpui_ai/
attachment.rs

1//! Attachment previews: the files a person adds to a prompt and the files a
2//! message carries.
3//!
4//! One [`Attachment`] value describes a file by stable ID, name, kind, size,
5//! optional thumbnail, and upload lifecycle. [`AttachmentPreview`] renders it
6//! as a tile (thumbnail or kind glyph, name, metadata, state) and
7//! [`AttachmentStrip`] lays tiles out in a wrapping row that ripples in.
8//! The same strip serves both sides of a conversation: removable and compact
9//! inside the composer, read-only and openable inside a message.
10//!
11//! Applications own the bytes, the upload, and what "open" means; the
12//! components only report [`AttachmentEvent`]s keyed by attachment ID.
13
14use 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/// The family a file belongs to; drives the fallback glyph and the kind label.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum AttachmentKind {
35    /// Raster or vector images.
36    Image,
37    /// Documents and plain text.
38    Document,
39    /// Source code and scripts.
40    Code,
41    /// Audio recordings.
42    Audio,
43    /// Video clips.
44    Video,
45    /// Compressed bundles.
46    Archive,
47    /// Tabular or structured data.
48    Data,
49    /// Anything else.
50    #[default]
51    Other,
52}
53
54impl AttachmentKind {
55    /// Infers the kind from a file name's extension.
56    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    /// The short human label for this family.
78    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/// One file with stable identity, presentation metadata, and upload state.
106///
107/// Equality compares every field; thumbnails compare by identity so a
108/// snapshot that re-attaches the same image does not invalidate rows.
109#[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    /// Creates a ready attachment; the kind is inferred from the name.
123    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    /// Overrides the inferred kind.
138    pub fn kind(mut self, kind: AttachmentKind) -> Self {
139        self.kind = kind;
140        self
141    }
142
143    /// Sets the size shown in the metadata line.
144    pub fn size_bytes(mut self, size_bytes: u64) -> Self {
145        self.size_bytes = Some(size_bytes);
146        self
147    }
148
149    /// Adds a short detail such as `12 pages` or `1280×720`.
150    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
151        self.detail = Some(detail.into());
152        self
153    }
154
155    /// Shows an image thumbnail instead of the kind glyph.
156    pub fn thumbnail(mut self, image: Arc<Image>) -> Self {
157        self.thumbnail = Some(image);
158        self
159    }
160
161    /// Sets the upload lifecycle: pending or running files are not yet
162    /// usable, failed ones carry a reason.
163    pub fn state(mut self, state: ProgressState) -> Self {
164        self.state = state;
165        self
166    }
167
168    /// Sets the upload fraction (`0.0..=1.0`) shown while running.
169    pub fn progress(mut self, fraction: f32) -> Self {
170        self.progress = Some(fraction.clamp(0.0, 1.0));
171        self
172    }
173
174    /// Returns the stable attachment identifier.
175    pub fn id(&self) -> &SharedString {
176        &self.id
177    }
178
179    /// Returns the visible file name.
180    pub fn name(&self) -> &SharedString {
181        &self.name
182    }
183
184    /// Returns the kind used for the glyph and label.
185    pub fn attachment_kind(&self) -> AttachmentKind {
186        self.kind
187    }
188
189    /// Returns the size, if known.
190    pub fn size(&self) -> Option<u64> {
191        self.size_bytes
192    }
193
194    /// Returns the optional detail text.
195    pub fn detail_text(&self) -> Option<&SharedString> {
196        self.detail.as_ref()
197    }
198
199    /// Returns the thumbnail, if any.
200    pub fn thumbnail_image(&self) -> Option<&Arc<Image>> {
201        self.thumbnail.as_ref()
202    }
203
204    /// Returns the upload lifecycle.
205    pub fn upload_state(&self) -> &ProgressState {
206        &self.state
207    }
208
209    /// Returns whether the file is usable (uploaded, not failed).
210    pub fn is_ready(&self) -> bool {
211        self.state == ProgressState::Complete
212    }
213
214    /// The one-line metadata shown under the name and read to assistive
215    /// technology: kind, size, detail, or the lifecycle when not ready.
216    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    /// The accessible name: file name plus summary.
238    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
277/// Formats a byte count the way file managers do (`820 B`, `12 KB`, `1.4 MB`).
278pub 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/// An interaction emitted by attachment previews.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum AttachmentEvent {
299    /// The user activated a read-only attachment (open, preview, download).
300    Opened {
301        /// Stable attachment identifier.
302        id: SharedString,
303    },
304    /// The user removed an attachment from a composer.
305    Removed {
306        /// Stable attachment identifier.
307        id: SharedString,
308    },
309}
310
311/// One attachment tile.
312///
313/// Read-only tiles become buttons that report [`AttachmentEvent::Opened`]
314/// when a handler is present; removable tiles expose a named remove button
315/// that reports [`AttachmentEvent::Removed`].
316///
317/// # Example
318///
319/// ```no_run
320/// # use gpui_ai::prelude::*;
321/// # fn example(attachment: Attachment) {
322/// AttachmentPreview::new("pricing", &attachment)
323///     .removable(true)
324///     .on_event(|event, _, _| { /* AttachmentEvent::Removed { id } */ });
325/// # }
326/// ```
327#[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    /// Creates a tile for one attachment.
339    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    /// Shows a remove control instead of making the tile openable.
351    pub fn removable(mut self, removable: bool) -> Self {
352        self.removable = removable;
353        self
354    }
355
356    /// Uses the single-line chip layout that suits a composer.
357    pub fn compact(mut self, compact: bool) -> Self {
358        self.compact = compact;
359        self
360    }
361
362    /// Handles typed interactions. Without a handler the tile is static.
363    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        // The compact tile seats its glyph in the medium slot; the full
393        // tile doubles it for a thumbnail. Derived from the size policy so
394        // a resized slot scale moves the tiles with every other glyph box.
395        let slot = crate::sizing::slot_md(cx);
396        let leading_size = if self.compact { slot } else { slot * 2. };
397
398        // Thumbnail when we have one; otherwise the kind glyph on a quiet tint.
399        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 fills; the ring stays a keyboard-focus signal.
523                .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
558/// The shared tile chrome: a surface pill when compact, a surface card
559/// otherwise; failures get a danger hairline.
560fn 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/// A wrapping row of attachment tiles that ripple into place.
593///
594/// # Example
595///
596/// ```no_run
597/// # use gpui_ai::prelude::*;
598/// # fn example(message_attachments: Vec<Attachment>) {
599/// AttachmentStrip::new("message-files")
600///     .items(message_attachments.iter().cloned())
601///     .on_event(|event, _, _| { /* AttachmentEvent::Opened { id } */ });
602/// # }
603/// ```
604#[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    /// Creates an empty strip.
617    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    /// Sets the accessible name of the group.
630    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
631        self.label = label.into();
632        self
633    }
634
635    /// Sets the attachments, in display order.
636    pub fn items(mut self, items: impl IntoIterator<Item = Attachment>) -> Self {
637        self.items = items.into_iter().collect();
638        self
639    }
640
641    /// Makes every tile removable instead of openable.
642    pub fn removable(mut self, removable: bool) -> Self {
643        self.removable = removable;
644        self
645    }
646
647    /// Uses the single-line chip layout for every tile.
648    pub fn compact(mut self, compact: bool) -> Self {
649        self.compact = compact;
650        self
651    }
652
653    /// Handles typed interactions for every tile.
654    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        // Tiles the strip has already shown stay at rest; a file attached to
675        // a mounted composer settles in on the capped cascade, and a strip
676        // restored with its attachments joins at rest.
677        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}