Skip to main content

liora_components/
upload.rs

1use crate::gpui_compat::element_id;
2use gpui::{
3    Context, IntoElement, MouseButton, PathPromptOptions, Pixels, Render, SharedString, Window,
4    div, prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::{
10    path::{Path, PathBuf},
11    sync::Arc,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum UploadStatus {
16    Ready,
17    Uploading,
18    Success,
19    Error,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum UploadListType {
24    #[default]
25    Text,
26    PictureCard,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct UploadFile {
31    pub id: SharedString,
32    pub name: SharedString,
33    pub size: Option<u64>,
34    pub status: UploadStatus,
35    pub progress: u8,
36    pub description: Option<SharedString>,
37    pub path: Option<PathBuf>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum UploadRejectReason {
42    TypeMismatch,
43    TooLarge,
44    MetadataUnavailable,
45}
46
47pub struct Upload {
48    id: SharedString,
49    files: Vec<UploadFile>,
50    list_type: UploadListType,
51    drag: bool,
52    disabled: bool,
53    multiple: bool,
54    limit: Option<usize>,
55    accept: Option<SharedString>,
56    max_size: Option<u64>,
57    selecting: bool,
58    last_error: Option<SharedString>,
59    button_text: SharedString,
60    tip: Option<SharedString>,
61    width: Option<Pixels>,
62    on_select: Option<Arc<dyn Fn(&mut Upload, &mut Context<Upload>) + 'static>>,
63    on_remove:
64        Option<Arc<dyn Fn(&mut Upload, UploadFile, &mut Window, &mut Context<Upload>) + 'static>>,
65}
66
67impl UploadFile {
68    pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
69        Self {
70            id: id.into(),
71            name: name.into(),
72            size: None,
73            status: UploadStatus::Ready,
74            progress: 0,
75            description: None,
76            path: None,
77        }
78    }
79
80    pub fn size(mut self, size: u64) -> Self {
81        self.size = Some(size);
82        self
83    }
84
85    pub fn status(mut self, status: UploadStatus) -> Self {
86        self.status = status;
87        self
88    }
89
90    pub fn progress(mut self, progress: u8) -> Self {
91        self.progress = progress.min(100);
92        self
93    }
94
95    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
96        self.description = Some(description.into());
97        self
98    }
99
100    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
101        self.path = Some(path.into());
102        self
103    }
104}
105
106impl Upload {
107    pub fn new() -> Self {
108        Self {
109            id: liora_core::unique_id("upload"),
110            files: Vec::new(),
111            list_type: UploadListType::Text,
112            drag: false,
113            disabled: false,
114            multiple: false,
115            limit: None,
116            accept: None,
117            max_size: None,
118            selecting: false,
119            last_error: None,
120            button_text: "点击上传".into(),
121            tip: None,
122            width: None,
123            on_select: None,
124            on_remove: None,
125        }
126    }
127
128    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
129        self.id = id.into();
130        self
131    }
132
133    pub fn files(mut self, files: impl IntoIterator<Item = UploadFile>) -> Self {
134        self.files = files.into_iter().collect();
135        self
136    }
137
138    pub fn add_file(mut self, file: UploadFile) -> Self {
139        self.files.push(file);
140        self
141    }
142
143    pub fn list_type(mut self, list_type: UploadListType) -> Self {
144        self.list_type = list_type;
145        self
146    }
147
148    pub fn picture_card(self) -> Self {
149        self.list_type(UploadListType::PictureCard)
150    }
151
152    pub fn drag(mut self, drag: bool) -> Self {
153        self.drag = drag;
154        self
155    }
156
157    pub fn disabled(mut self, disabled: bool) -> Self {
158        self.disabled = disabled;
159        self
160    }
161
162    pub fn multiple(mut self, multiple: bool) -> Self {
163        self.multiple = multiple;
164        self
165    }
166
167    pub fn limit(mut self, limit: usize) -> Self {
168        self.limit = Some(limit);
169        self
170    }
171
172    pub fn accept(mut self, accept: impl Into<SharedString>) -> Self {
173        self.accept = Some(accept.into());
174        self
175    }
176
177    pub fn max_size(mut self, bytes: u64) -> Self {
178        self.max_size = Some(bytes);
179        self
180    }
181
182    pub fn button_text(mut self, text: impl Into<SharedString>) -> Self {
183        self.button_text = text.into();
184        self
185    }
186
187    pub fn tip(mut self, tip: impl Into<SharedString>) -> Self {
188        self.tip = Some(tip.into());
189        self
190    }
191
192    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
193        self.width = Some(width.into());
194        self
195    }
196
197    pub fn width_lg(self) -> Self {
198        self.width(px(420.0))
199    }
200
201    pub fn on_select(mut self, f: impl Fn(&mut Upload, &mut Context<Upload>) + 'static) -> Self {
202        self.on_select = Some(Arc::new(f));
203        self
204    }
205
206    pub fn on_remove(
207        mut self,
208        f: impl Fn(&mut Upload, UploadFile, &mut Window, &mut Context<Upload>) + 'static,
209    ) -> Self {
210        self.on_remove = Some(Arc::new(f));
211        self
212    }
213
214    pub fn set_files(&mut self, files: Vec<UploadFile>, cx: &mut Context<Self>) {
215        self.files = files;
216        cx.notify();
217    }
218
219    pub fn push_file(&mut self, file: UploadFile, cx: &mut Context<Self>) {
220        if !Self::can_accept_more_len(self.files.len(), self.limit, self.disabled) {
221            return;
222        }
223        self.files.push(file);
224        cx.notify();
225    }
226
227    pub fn file_count(&self) -> usize {
228        self.files.len()
229    }
230
231    pub fn files_ref(&self) -> &[UploadFile] {
232        &self.files
233    }
234
235    pub fn selected_paths(&self) -> Vec<PathBuf> {
236        self.files
237            .iter()
238            .filter_map(|file| file.path.clone())
239            .collect()
240    }
241
242    pub fn can_accept_more_len(current_len: usize, limit: Option<usize>, disabled: bool) -> bool {
243        !disabled && !limit.is_some_and(|limit| current_len >= limit)
244    }
245
246    pub fn matches_accept_name(name: &str, accept: Option<&str>) -> bool {
247        let Some(accept) = accept else {
248            return true;
249        };
250        let accept = accept.trim();
251        if accept.is_empty() {
252            return true;
253        }
254
255        let lower_name = name.to_lowercase();
256        let ext = Path::new(name)
257            .extension()
258            .and_then(|ext| ext.to_str())
259            .map(|ext| ext.to_lowercase());
260
261        accept
262            .split(',')
263            .map(str::trim)
264            .filter(|token| !token.is_empty())
265            .any(|token| {
266                let token = token.to_lowercase();
267                if token == "*" || token == "*/*" {
268                    return true;
269                }
270                if let Some(expected_ext) = token.strip_prefix('.') {
271                    return ext.as_deref() == Some(expected_ext);
272                }
273                if token.ends_with("/*") {
274                    return matches_mime_group(ext.as_deref(), token.trim_end_matches("/*"));
275                }
276                lower_name.ends_with(&token)
277            })
278    }
279
280    pub fn validate_file_name_size(
281        name: &str,
282        size: Option<u64>,
283        accept: Option<&str>,
284        max_size: Option<u64>,
285    ) -> Result<(), UploadRejectReason> {
286        if !Self::matches_accept_name(name, accept) {
287            return Err(UploadRejectReason::TypeMismatch);
288        }
289        if let Some(max_size) = max_size {
290            let Some(size) = size else {
291                return Err(UploadRejectReason::MetadataUnavailable);
292            };
293            if size > max_size {
294                return Err(UploadRejectReason::TooLarge);
295            }
296        }
297        Ok(())
298    }
299
300    pub fn validate_path(
301        path: &Path,
302        accept: Option<&str>,
303        max_size: Option<u64>,
304    ) -> Result<UploadFile, UploadRejectReason> {
305        let name = path
306            .file_name()
307            .and_then(|name| name.to_str())
308            .unwrap_or("file")
309            .to_string();
310        let size = std::fs::metadata(path).ok().map(|metadata| metadata.len());
311        Self::validate_file_name_size(&name, size, accept, max_size)?;
312        Ok(UploadFile::new(path.to_string_lossy().into_owned(), name)
313            .size(size.unwrap_or(0))
314            .path(path.to_path_buf()))
315    }
316
317    pub fn remove_file_by_id(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
318        if let Some(index) = self.files.iter().position(|file| file.id.as_ref() == id) {
319            let file = self.files.remove(index);
320            if let Some(on_remove) = self.on_remove.clone() {
321                on_remove(self, file, window, cx);
322            } else {
323                cx.notify();
324            }
325        }
326    }
327
328    fn trigger_select(&mut self, window: &mut Window, cx: &mut Context<Self>) {
329        if !Self::can_accept_more_len(self.files.len(), self.limit, self.disabled) || self.selecting
330        {
331            return;
332        }
333
334        self.selecting = true;
335        self.last_error = None;
336        let receiver = cx.prompt_for_paths(PathPromptOptions {
337            files: true,
338            directories: false,
339            multiple: self.multiple,
340            prompt: Some(self.button_text.clone()),
341        });
342        cx.notify();
343
344        cx.spawn(async move |this, cx| {
345            let result = receiver.await;
346            this.update(cx, |upload, cx| {
347                upload.selecting = false;
348                match result {
349                    Ok(Ok(Some(paths))) => {
350                        upload.accept_selected_paths(paths, cx);
351                    }
352                    Ok(Ok(None)) => {
353                        upload.last_error = None;
354                        cx.notify();
355                    }
356                    Ok(Err(err)) => {
357                        upload.last_error = Some(format!("文件选择器打开失败:{err}").into());
358                        cx.notify();
359                    }
360                    Err(_) => {
361                        upload.last_error = Some("文件选择器已取消".into());
362                        cx.notify();
363                    }
364                }
365            })
366            .ok();
367        })
368        .detach();
369
370        let _ = window;
371    }
372
373    fn accept_selected_paths(&mut self, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
374        let accept = self.accept.as_ref().map(SharedString::as_str);
375        let mut rejected = 0usize;
376        for path in paths {
377            if !Self::can_accept_more_len(self.files.len(), self.limit, self.disabled) {
378                break;
379            }
380            match Self::validate_path(&path, accept, self.max_size) {
381                Ok(file) => self.files.push(file),
382                Err(_) => rejected += 1,
383            }
384            if !self.multiple {
385                break;
386            }
387        }
388        if rejected > 0 {
389            self.last_error =
390                Some(format!("已忽略 {rejected} 个不符合类型或大小限制的文件").into());
391        } else {
392            self.last_error = None;
393        }
394        if let Some(on_select) = self.on_select.clone() {
395            on_select(self, cx);
396        }
397        cx.notify();
398    }
399}
400
401impl Render for Upload {
402    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
403        let theme = cx.global::<Config>().theme.clone();
404        let can_add = Self::can_accept_more_len(self.files.len(), self.limit, self.disabled)
405            && !self.selecting;
406        let entity = cx.entity().clone();
407        let trigger_id = format!("{}-trigger", self.id);
408
409        div()
410            .flex()
411            .flex_col()
412            .gap_3()
413            .when_some(self.width, |s, width| s.w(width))
414            .child(if self.drag {
415                render_drag_trigger(
416                    trigger_id,
417                    self.button_text.clone(),
418                    self.accept.clone(),
419                    self.multiple,
420                    self.max_size,
421                    can_add,
422                    &theme,
423                    entity.clone(),
424                )
425                .into_any_element()
426            } else {
427                render_button_trigger(
428                    trigger_id,
429                    if self.selecting {
430                        "选择中...".into()
431                    } else {
432                        self.button_text.clone()
433                    },
434                    can_add,
435                    &theme,
436                    entity.clone(),
437                )
438                .into_any_element()
439            })
440            .when_some(self.tip.clone(), |s, tip| {
441                s.child(div().text_xs().text_color(theme.neutral.text_3).child(tip))
442            })
443            .when_some(self.last_error.clone(), |s, error| {
444                s.child(div().text_xs().text_color(theme.danger.base).child(error))
445            })
446            .child(match self.list_type {
447                UploadListType::Text => {
448                    render_text_file_list(self.id.clone(), &self.files, &theme, entity)
449                        .into_any_element()
450                }
451                UploadListType::PictureCard => {
452                    render_picture_file_list(self.id.clone(), &self.files, &theme, entity)
453                        .into_any_element()
454                }
455            })
456    }
457}
458
459fn render_button_trigger(
460    id: String,
461    text: SharedString,
462    enabled: bool,
463    theme: &liora_theme::Theme,
464    upload: gpui::Entity<Upload>,
465) -> impl IntoElement {
466    let trigger_color = if enabled {
467        theme.primary.base
468    } else {
469        theme.neutral.text_3
470    };
471
472    div()
473        .id(element_id(id))
474        .flex()
475        .items_center()
476        .gap_2()
477        .px_4()
478        .py_2()
479        .rounded(px(theme.radius.md))
480        .border_1()
481        .border_color(if enabled {
482            theme.primary.base
483        } else {
484            theme.neutral.border
485        })
486        .bg(if enabled {
487            theme.neutral.card
488        } else {
489            theme.neutral.hover
490        })
491        .text_color(trigger_color)
492        .cursor_pointer()
493        .hover(|s| {
494            if enabled {
495                s.cursor_pointer().bg(theme.primary.light_9)
496            } else {
497                s
498            }
499        })
500        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
501            if enabled {
502                upload.update(cx, |upload, cx| upload.trigger_select(window, cx));
503            }
504        })
505        .child(
506            Icon::new(IconName::Upload)
507                .size(px(16.0))
508                .color(trigger_color),
509        )
510        .child(div().text_sm().child(text))
511}
512
513fn render_drag_trigger(
514    id: String,
515    text: SharedString,
516    accept: Option<SharedString>,
517    multiple: bool,
518    max_size: Option<u64>,
519    enabled: bool,
520    theme: &liora_theme::Theme,
521    upload: gpui::Entity<Upload>,
522) -> impl IntoElement {
523    let hint = match (multiple, accept) {
524        (true, Some(accept)) => format!("支持多选,类型:{}", accept),
525        (true, None) => "支持多选".to_string(),
526        (false, Some(accept)) => format!("文件类型:{}", accept),
527        (false, None) => "将文件拖到此处,或点击选择".to_string(),
528    };
529    let hint = match max_size {
530        Some(max_size) => format!("{},单文件 ≤ {}", hint, format_size(max_size)),
531        None => hint,
532    };
533    let text_color = if enabled {
534        theme.neutral.text_1
535    } else {
536        theme.neutral.text_3
537    };
538
539    div()
540        .id(element_id(id))
541        .h(px(150.0))
542        .flex()
543        .flex_col()
544        .items_center()
545        .justify_center()
546        .gap_3()
547        .rounded(px(theme.radius.lg))
548        .border_1()
549        .border_color(if enabled {
550            theme.primary.light_9
551        } else {
552            theme.neutral.border
553        })
554        .bg(if enabled {
555            theme.primary.light_9
556        } else {
557            theme.neutral.hover
558        })
559        .cursor_pointer()
560        .hover(|s| {
561            if enabled {
562                s.cursor_pointer().border_color(theme.primary.base)
563            } else {
564                s
565            }
566        })
567        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
568            if enabled {
569                upload.update(cx, |upload, cx| upload.trigger_select(window, cx));
570            }
571        })
572        .child(Icon::new(IconName::Upload).size(px(32.0)).color(text_color))
573        .child(div().text_sm().text_color(text_color).child(text))
574        .child(div().text_xs().text_color(theme.neutral.text_3).child(hint))
575}
576
577fn render_text_file_list(
578    id: SharedString,
579    files: &[UploadFile],
580    theme: &liora_theme::Theme,
581    upload: gpui::Entity<Upload>,
582) -> impl IntoElement {
583    div()
584        .flex()
585        .flex_col()
586        .gap_2()
587        .children(files.iter().cloned().map(move |file| {
588            render_text_file_item(
589                format!("{}-file-{}", id, file.id),
590                file,
591                theme.clone(),
592                upload.clone(),
593            )
594        }))
595}
596
597fn render_text_file_item(
598    id: String,
599    file: UploadFile,
600    theme: liora_theme::Theme,
601    upload: gpui::Entity<Upload>,
602) -> impl IntoElement {
603    let file_id = file.id.clone();
604    div()
605        .id(element_id(id))
606        .flex()
607        .flex_col()
608        .gap_1()
609        .rounded(px(theme.radius.md))
610        .px_3()
611        .py_2()
612        .bg(theme.neutral.card)
613        .border_1()
614        .border_color(theme.neutral.border)
615        .child(
616            div()
617                .flex()
618                .items_center()
619                .gap_2()
620                .child(status_icon(file.status, &theme))
621                .child(
622                    div()
623                        .flex_1()
624                        .min_w(px(0.0))
625                        .child(
626                            div()
627                                .text_sm()
628                                .text_color(theme.neutral.text_1)
629                                .child(file.name.clone()),
630                        )
631                        .child(
632                            div()
633                                .text_xs()
634                                .text_color(theme.neutral.text_3)
635                                .child(file_meta(&file)),
636                        ),
637                )
638                .child(
639                    div()
640                        .id(element_id(format!("{}-remove", file_id)))
641                        .p_1()
642                        .rounded(px(theme.radius.sm))
643                        .cursor_pointer()
644                        .hover(|s| s.cursor_pointer().bg(theme.neutral.hover))
645                        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
646                            let file_id = file_id.clone();
647                            upload.update(cx, |upload, cx| {
648                                upload.remove_file_by_id(file_id.as_ref(), window, cx)
649                            });
650                        })
651                        .child(
652                            Icon::new(IconName::Trash2)
653                                .size(px(14.0))
654                                .color(theme.neutral.icon),
655                        ),
656                ),
657        )
658        .when(file.status == UploadStatus::Uploading, |s| {
659            s.child(progress_bar(file.progress, &theme))
660        })
661}
662
663fn render_picture_file_list(
664    id: SharedString,
665    files: &[UploadFile],
666    theme: &liora_theme::Theme,
667    upload: gpui::Entity<Upload>,
668) -> impl IntoElement {
669    div()
670        .flex()
671        .flex_wrap()
672        .gap_3()
673        .children(files.iter().cloned().map(move |file| {
674            let file_id = file.id.clone();
675            let remove_id = file.id.clone();
676            div()
677                .id(element_id(format!("{}-picture-{}", id, file.id)))
678                .relative()
679                .w(px(112.0))
680                .h(px(112.0))
681                .flex()
682                .flex_col()
683                .items_center()
684                .justify_center()
685                .gap_2()
686                .rounded(px(theme.radius.lg))
687                .border_1()
688                .border_color(theme.neutral.border)
689                .bg(theme.neutral.hover)
690                .child(status_icon(file.status, &theme).size(px(24.0)))
691                .child(
692                    div()
693                        .px_2()
694                        .text_xs()
695                        .text_color(theme.neutral.text_1)
696                        .child(file.name.clone()),
697                )
698                .when(file.status == UploadStatus::Uploading, |s| {
699                    s.child(
700                        div()
701                            .absolute()
702                            .bottom(px(8.0))
703                            .left(px(8.0))
704                            .right(px(8.0))
705                            .child(progress_bar(file.progress, &theme)),
706                    )
707                })
708                .child({
709                    let upload = upload.clone();
710                    div()
711                        .id(element_id(format!("{}-picture-remove", file_id)))
712                        .absolute()
713                        .top(px(6.0))
714                        .right(px(6.0))
715                        .p_1()
716                        .rounded(px(theme.radius.sm))
717                        .bg(theme.neutral.card.opacity(0.9))
718                        .cursor_pointer()
719                        .hover(|s| s.cursor_pointer().bg(theme.neutral.card))
720                        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
721                            let remove_id = remove_id.clone();
722                            upload.update(cx, |upload, cx| {
723                                upload.remove_file_by_id(remove_id.as_ref(), window, cx)
724                            });
725                        })
726                        .child(
727                            Icon::new(IconName::X)
728                                .size(px(14.0))
729                                .color(theme.neutral.icon),
730                        )
731                })
732        }))
733}
734
735fn matches_mime_group(ext: Option<&str>, group: &str) -> bool {
736    match group {
737        "image" => matches!(
738            ext,
739            Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
740        ),
741        "text" => matches!(
742            ext,
743            Some("txt" | "md" | "csv" | "json" | "toml" | "yaml" | "yml" | "rs")
744        ),
745        "audio" => matches!(ext, Some("mp3" | "wav" | "ogg" | "flac" | "m4a")),
746        "video" => matches!(ext, Some("mp4" | "mov" | "webm" | "mkv" | "avi")),
747        _ => false,
748    }
749}
750
751fn status_icon(status: UploadStatus, theme: &liora_theme::Theme) -> Icon {
752    match status {
753        UploadStatus::Ready => Icon::new(IconName::File)
754            .size(px(16.0))
755            .color(theme.neutral.icon),
756        UploadStatus::Uploading => Icon::new(IconName::Upload)
757            .size(px(16.0))
758            .color(theme.primary.base),
759        UploadStatus::Success => Icon::new(IconName::CircleCheck)
760            .size(px(16.0))
761            .color(theme.success.base),
762        UploadStatus::Error => Icon::new(IconName::CircleX)
763            .size(px(16.0))
764            .color(theme.danger.base),
765    }
766}
767
768fn progress_bar(progress: u8, theme: &liora_theme::Theme) -> impl IntoElement {
769    div()
770        .h(px(4.0))
771        .rounded(px(999.0))
772        .bg(theme.neutral.hover)
773        .child(
774            div()
775                .h_full()
776                .w(gpui::relative(progress as f32 / 100.0))
777                .rounded(px(999.0))
778                .bg(theme.primary.base),
779        )
780}
781
782fn file_meta(file: &UploadFile) -> String {
783    let status = match file.status {
784        UploadStatus::Ready => "等待上传",
785        UploadStatus::Uploading => "上传中",
786        UploadStatus::Success => "上传成功",
787        UploadStatus::Error => "上传失败",
788    };
789    let size = file
790        .size
791        .map(format_size)
792        .unwrap_or_else(|| "未知大小".to_string());
793    match &file.description {
794        Some(description) => format!("{} · {} · {}", status, size, description),
795        None => format!("{} · {}", status, size),
796    }
797}
798
799fn format_size(size: u64) -> String {
800    const KB: f64 = 1024.0;
801    const MB: f64 = KB * 1024.0;
802    let size = size as f64;
803    if size >= MB {
804        format!("{:.1} MB", size / MB)
805    } else if size >= KB {
806        format!("{:.1} KB", size / KB)
807    } else {
808        format!("{} B", size as u64)
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn upload_width_lg_sets_demo_width() {
818        assert_eq!(Upload::new().width_lg().width, Some(px(420.0)));
819    }
820}