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}