browser.rs
⎇
Raw
1//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
2
3use leptos::prelude::*;
4use wasm_bindgen_futures::spawn_local;
5use web_sys::MouseEvent;
6
7use crate::api::{self, Entry, Me, RootInfo};
8use crate::components::icon::{icon_for, Icon, IconName};
9use crate::components::toast::{show, ToastMsg};
10use crate::router::{navigate, Location};
11use crate::util::{format_date, format_size, ViewMode};
12use crate::views::dialogs::{Dialog, DialogView, Op};
13
14#[derive(Clone, Debug, PartialEq)]
15enum ListState {
16 Loading,
17 Error(String),
18 Entries(Vec<Entry>),
19}
20
21/// The root that is actually shown: the one in the location, or — when the
22/// user has exactly one root — that root directly.
23fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
24 match loc.root_id {
25 Some(id) => roots.iter().find(|r| r.id == id),
26 None if roots.len() == 1 => Some(&roots[0]),
27 None => None,
28 }
29}
30
31#[derive(Clone)]
32struct CtxMenu {
33 x: i32,
34 y: i32,
35 /// None = empty area / root tile (no entry selected).
36 entry: Option<Entry>,
37}
38
39#[component]
40pub fn Browser(
41 me: ReadSignal<Option<Me>>,
42 loc: ReadSignal<Location>,
43) -> impl IntoView {
44 let toast = use_context::<ToastMsg>().expect("toast context");
45 let (view_mode, set_view_mode) = signal(ViewMode::load());
46 let (list_state, set_list_state) = signal(ListState::Loading);
47 let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
48 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
49 // Long-lived owner for callbacks created inside short-lived scopes
50 // (context-menu event handlers); otherwise they get disposed with the
51 // menu and the open dialog panics on submit.
52 let me_owner = Owner::current().unwrap();
53
54 // Fetch the current directory whenever the location (or user) changes.
55 let fetch = Callback::new(move |_| {
56 let Some(me) = me.get() else {
57 return;
58 };
59 let loc = loc.get();
60 let Some(root) = effective_root(&me.roots, &loc) else {
61 return;
62 };
63 set_list_state.set(ListState::Loading);
64 let root_id = root.id;
65 let path_str = loc.path.join("/");
66 spawn_local(async move {
67 match api::list_files(root_id, &path_str).await {
68 Ok(r) => set_list_state.set(ListState::Entries(r.entries)),
69 Err(e) => set_list_state.set(ListState::Error(e.to_string())),
70 }
71 });
72 });
73 Effect::new(move |_| fetch.run(()));
74
75 view! {
76 <div class="browser">
77 {move || {
78 let Some(me) = me.get() else {
79 return view! {}.into_any();
80 };
81 let loc_now = loc.get();
82 match effective_root(&me.roots, &loc_now) {
83 None => root_picker(&me.roots, &set_ctx, &loc_now).into_any(),
84 Some(root) => file_browser(
85 root,
86 &loc,
87 &list_state,
88 &view_mode,
89 &set_view_mode,
90 &set_ctx,
91 &toast,
92 &fetch,
93 )
94 .into_any(),
95 }
96 }}
97 <CtxMenuView
98 ctx=ctx
99 set_ctx=set_ctx
100 me=me
101 loc=loc
102 refresh=fetch
103 toast=toast
104 set_dialog=set_dialog
105 owner=me_owner
106 />
107 <DialogView dialog=dialog set_dialog=set_dialog/>
108 </div>
109 }
110}
111
112// ---------------------------------------------------------------------------
113// Root picker (shown when the user has multiple roots and is at the top level)
114// ---------------------------------------------------------------------------
115
116fn root_picker(
117 roots: &[RootInfo],
118 set_ctx: &WriteSignal<Option<CtxMenu>>,
119 loc: &Location,
120) -> impl IntoView {
121 view! {
122 <div class="crumbs">
123 <span class="crumb current">"Folders"</span>
124 </div>
125 <div class="root-picker">
126 {roots.iter().map(|r| {
127 let id = r.id;
128 let name = r.name.clone();
129 let path = r.path.clone();
130 let mode = r.mode.clone();
131 let set_ctx = *set_ctx;
132 let _loc = loc.clone();
133 view! {
134 <div
135 class="root-tile"
136 on:click=move |_| {
137 navigate(&Location {
138 root_id: Some(id),
139 path: vec![],
140 });
141 }
142 on:contextmenu=move |ev: MouseEvent| {
143 ev.prevent_default();
144 set_ctx.set(Some(CtxMenu {
145 x: ev.client_x() as i32,
146 y: ev.client_y() as i32,
147 entry: None,
148 }));
149 }
150 >
151 <Icon name=IconName::Folder class=Some("icon-big".into())/>
152 <div class="root-tile-name">{name}</div>
153 <div class="root-tile-path muted">
154 {if path == "." { "/".to_string() } else { path.clone() }}
155 </div>
156 <span class="badge">
157 {if mode == "rw" { "read-write" } else { "read-only" }}
158 </span>
159 </div>
160 }
161 }).collect::<Vec<_>>()}
162 </div>
163 }
164}
165
166// ---------------------------------------------------------------------------
167// File browser (breadcrumbs + toolbar + entries)
168// ---------------------------------------------------------------------------
169
170fn file_browser(
171 root: &RootInfo,
172 loc: &ReadSignal<Location>,
173 list_state: &ReadSignal<ListState>,
174 view_mode: &ReadSignal<ViewMode>,
175 set_view_mode: &WriteSignal<ViewMode>,
176 set_ctx: &WriteSignal<Option<CtxMenu>>,
177 toast: &ToastMsg,
178 fetch: &Callback<()>,
179) -> impl IntoView {
180 let root_id = root.id;
181 let root_name = root.name.clone();
182 let loc_now = loc.get();
183
184 // Breadcrumbs
185 let crumbs = view! {
186 <div class="crumbs">
187 <span
188 class="crumb"
189 on:click=move |_| {
190 navigate(&Location {
191 root_id: Some(root_id),
192 path: vec![],
193 });
194 }
195 >
196 {root_name.clone()}
197 </span>
198 {loc_now.path.iter().enumerate().map(|(i, seg)| {
199 let seg = seg.clone();
200 let target: Vec<String> = loc_now.path[..=i].to_vec();
201 let is_last = i == loc_now.path.len() - 1;
202 view! {
203 <span class="crumb-sep">"/"</span>
204 <span
205 class=move || {
206 if is_last {
207 "crumb current".to_string()
208 } else {
209 "crumb".to_string()
210 }
211 }
212 on:click=move |_| {
213 navigate(&Location {
214 root_id: Some(root_id),
215 path: target.clone(),
216 });
217 }
218 >
219 {seg}
220 </span>
221 }
222 }).collect::<Vec<_>>()}
223 </div>
224 };
225
226 // Toolbar (view toggle)
227 let set_view = *set_view_mode;
228 let vm = *view_mode;
229 let toolbar = view! {
230 <div class="toolbar">
231 <button
232 class=move || {
233 if vm.get() == ViewMode::Grid {
234 "icon-btn active".to_string()
235 } else {
236 "icon-btn".to_string()
237 }
238 }
239 title="Grid view"
240 on:click=move |_| {
241 set_view.set(ViewMode::Grid);
242 ViewMode::Grid.save();
243 }
244 >
245 <Icon name=IconName::Grid class=Some("ic-btn".into())/>
246 </button>
247 <button
248 class=move || {
249 if vm.get() == ViewMode::List {
250 "icon-btn active".to_string()
251 } else {
252 "icon-btn".to_string()
253 }
254 }
255 title="List view"
256 on:click=move |_| {
257 set_view.set(ViewMode::List);
258 ViewMode::List.save();
259 }
260 >
261 <Icon name=IconName::List class=Some("ic-btn".into())/>
262 </button>
263 </div>
264 };
265
266 let ls = *list_state;
267 let loc2 = *loc;
268 let set_ctx2 = *set_ctx;
269 let t = *toast;
270 let refresh = *fetch;
271 let entries_area = view! {
272 <div
273 class="entries-area"
274 on:contextmenu=move |ev: MouseEvent| {
275 ev.prevent_default();
276 set_ctx2.set(Some(CtxMenu {
277 x: ev.client_x() as i32,
278 y: ev.client_y() as i32,
279 entry: None,
280 }));
281 }
282 >
283 {move || {
284 match ls.get() {
285 ListState::Loading => view! {
286 <p class="muted center-note">"Loading…"</p>
287 }
288 .into_view()
289 .into_any(),
290 ListState::Error(msg) => view! {
291 <div class="card error-card">
292 <span>{msg.clone()}</span>
293 <button class="btn" on:click=move |_| refresh.run(())>
294 "Retry"
295 </button>
296 </div>
297 }
298 .into_view()
299 .into_any(),
300 ListState::Entries(entries) => {
301 if entries.is_empty() {
302 view! { <p class="muted center-note">"This folder is empty."</p> }
303 .into_view()
304 .into_any()
305 } else if vm.get() == ViewMode::Grid {
306 grid_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any()
307 } else {
308 list_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any()
309 }
310 }
311 }
312 }}
313 </div>
314 };
315
316 view! {
317 {crumbs}
318 {toolbar}
319 {entries_area}
320 }
321}
322
323fn entry_actions(
324 entry: Entry,
325 root_id: i64,
326 loc: &ReadSignal<Location>,
327 set_ctx: &WriteSignal<Option<CtxMenu>>,
328 _toast: &ToastMsg,
329) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) {
330 let l = *loc;
331 let set_ctx = *set_ctx;
332 let entry2 = entry.clone();
333 let nav = move || {
334 let mut path = l.get().path;
335 path.push(entry.name.clone());
336 navigate(&Location {
337 root_id: Some(root_id),
338 path,
339 });
340 };
341 let ctx_handler = move |ev: MouseEvent| {
342 ev.prevent_default();
343 ev.stop_propagation();
344 set_ctx.set(Some(CtxMenu {
345 x: ev.client_x() as i32,
346 y: ev.client_y() as i32,
347 entry: Some(entry2.clone()),
348 }));
349 };
350 (nav, ctx_handler)
351}
352
353fn grid_view(
354 entries: &[Entry],
355 root_id: i64,
356 loc: &ReadSignal<Location>,
357 set_ctx: &WriteSignal<Option<CtxMenu>>,
358 toast: &ToastMsg,
359) -> impl IntoView {
360 view! {
361 <div class="entries-grid">
362 {entries.iter().map(|e| {
363 let icon = icon_for(&e.name, e.is_dir);
364 let name = e.name.clone();
365 let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
366 let t = *toast;
367 let is_dir = e.is_dir;
368 let title = name.clone();
369 let on_click = move |_| {
370 if is_dir {
371 nav();
372 } else {
373 show(t, "Previews are coming soon");
374 }
375 };
376 view! {
377 <div
378 class="tile"
379 on:click=on_click
380 on:contextmenu=on_ctx
381 title=title
382 >
383 <Icon name=icon class=Some("tile-icon".into())/>
384 <div class="tile-name">{name}</div>
385 </div>
386 }
387 }).collect::<Vec<_>>()}
388 </div>
389 }
390}
391
392fn list_view(
393 entries: &[Entry],
394 root_id: i64,
395 loc: &ReadSignal<Location>,
396 set_ctx: &WriteSignal<Option<CtxMenu>>,
397 toast: &ToastMsg,
398) -> impl IntoView {
399 view! {
400 <div class="entries-list">
401 {entries.iter().map(|e| {
402 let icon = icon_for(&e.name, e.is_dir);
403 let name = e.name.clone();
404 let size = format_size(e.size);
405 let date = format_date(&e.mtime);
406 let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
407 let t = *toast;
408 let is_dir = e.is_dir;
409 let title = name.clone();
410 let on_click = move |_| {
411 if is_dir {
412 nav();
413 } else {
414 show(t, "Previews are coming soon");
415 }
416 };
417 view! {
418 <div class="row" on:click=on_click on:contextmenu=on_ctx title=title>
419 <Icon name=icon class=Some("row-icon".into())/>
420 <span class="row-name">{name}</span>
421 <span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
422 <span class="row-date muted">{date}</span>
423 </div>
424 }
425 }).collect::<Vec<_>>()}
426 </div>
427 }
428}
429
430// ---------------------------------------------------------------------------
431// Context menu
432// ---------------------------------------------------------------------------
433
434#[component]
435fn CtxMenuView(
436 ctx: ReadSignal<Option<CtxMenu>>,
437 set_ctx: WriteSignal<Option<CtxMenu>>,
438 me: ReadSignal<Option<Me>>,
439 loc: ReadSignal<Location>,
440 refresh: Callback<()>,
441 toast: ToastMsg,
442 set_dialog: WriteSignal<Option<Dialog>>,
443 owner: Owner,
444) -> impl IntoView {
445 // Close on any click or Escape.
446 {
447 let set_ctx = set_ctx;
448 let _h1 = window_event_listener(leptos::ev::click, move |_| {
449 set_ctx.set(None);
450 });
451 let set_ctx2 = set_ctx;
452 let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
453 if ev.key() == "Escape" {
454 set_ctx2.set(None);
455 }
456 });
457 }
458
459 view! {
460 {move || {
461 let Some(m) = ctx.get() else {
462 return view! {}.into_any();
463 };
464 // Keep the menu inside the viewport.
465 let (max_x, max_y) = web_sys::window()
466 .and_then(|w| {
467 w.inner_width()
468 .ok()
469 .and_then(|v| v.as_f64())
470 .zip(w.inner_height().ok().and_then(|v| v.as_f64()))
471 })
472 .map(|(x, y)| (x as i32 - 220, y as i32 - 300))
473 .unwrap_or((m.x, m.y));
474 let x = m.x.min(max_x).max(0);
475 let y = m.y.min(max_y).max(0);
476
477 let me_now = me.get();
478 let loc_now = loc.get();
479 let is_rw = me_now
480 .as_ref()
481 .and_then(|m2| effective_root(&m2.roots, &loc_now))
482 .map(|r| r.mode == "rw")
483 .unwrap_or(false);
484
485 // (label, action, disabled-hint)
486 let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
487 Some(e) => {
488 let e2 = e.clone();
489 let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
490 if e.is_dir {
491 let l = loc;
492 let name = e2.name.clone();
493 v.push((
494 "Open".into(),
495 Some(Callback::new(move |_| {
496 let cur = l.get();
497 let mut path = cur.path;
498 path.push(name.clone());
499 navigate(&Location {
500 root_id: cur.root_id,
501 path,
502 });
503 })),
504 None,
505 ));
506 }
507 v.push(("Download".into(), None, Some("Coming soon".into())));
508 let e_rename = e2.clone();
509 let l_rename = loc;
510 v.push((
511 "Rename".into(),
512 rw_action(
513 is_rw,
514 action_rename(&e_rename, l_rename, refresh, toast, set_dialog, owner.clone()),
515 ),
516 Some("Read-only folder".into()),
517 ));
518 let e_move = e2.clone();
519 let l_move = loc;
520 let m_move = me;
521 v.push((
522 "Move".into(),
523 rw_action(
524 is_rw,
525 action_move_copy(
526 Op::Move,
527 &e_move,
528 m_move,
529 l_move,
530 refresh,
531 toast,
532 set_dialog,
533 owner.clone(),
534 ),
535 ),
536 Some("Read-only folder".into()),
537 ));
538 let e3 = e2.clone();
539 let l_copy = loc;
540 let m_copy = me;
541 v.push((
542 "Copy".into(),
543 Some(action_move_copy(
544 Op::Copy,
545 &e3,
546 m_copy,
547 l_copy,
548 refresh,
549 toast,
550 set_dialog,
551 owner.clone(),
552 )),
553 None,
554 ));
555 v.push(("Share".into(), None, Some("Coming soon".into())));
556 v.push(("Info".into(), None, Some("Coming soon".into())));
557 let e4 = e2.clone();
558 let l_del = loc;
559 v.push((
560 "Delete".into(),
561 rw_action(
562 is_rw,
563 action_delete(&e4, l_del, refresh, toast, set_dialog, owner.clone()),
564 ),
565 Some("Read-only folder".into()),
566 ));
567 v
568 }
569 None => {
570 let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
571 let l_nf = loc;
572 v.push((
573 "New folder".into(),
574 rw_action(
575 is_rw,
576 action_new_folder(l_nf, refresh, toast, set_dialog, owner.clone()),
577 ),
578 Some("Read-only folder".into()),
579 ));
580 let l_up1 = loc;
581 v.push((
582 "Upload files".into(),
583 rw_action(
584 is_rw,
585 action_upload(true, false, l_up1, refresh, toast, set_dialog),
586 ),
587 Some("Read-only folder".into()),
588 ));
589 let l_up2 = loc;
590 v.push((
591 "Upload folder".into(),
592 rw_action(
593 is_rw,
594 action_upload(true, true, l_up2, refresh, toast, set_dialog),
595 ),
596 Some("Read-only folder".into()),
597 ));
598 v.push((
599 "Refresh".into(),
600 Some(Callback::new(move |_| refresh.run(()))),
601 None,
602 ));
603 v
604 }
605 };
606
607 view! {
608 <div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
609 {items.into_iter().map(|(label, action, hint)| {
610 view! {
611 <CtxItem label=label action=action hint=hint/>
612 }
613 }).collect::<Vec<_>>()}
614 </div>
615 }
616 .into_view()
617 .into_any()
618 }}
619 }
620}
621
622/// `Some(cb)` when the current folder is writable, else `None` (disabled item).
623fn rw_action(is_rw: bool, cb: Callback<()>) -> Option<Callback<()>> {
624 if is_rw {
625 Some(cb)
626 } else {
627 None
628 }
629}
630
631#[component]
632fn CtxItem(label: String, action: Option<Callback<()>>, hint: Option<String>) -> impl IntoView {
633 let enabled = action.is_some();
634 let hint = hint.unwrap_or_else(|| "Coming soon".to_string());
635 view! {
636 <div
637 class=move || {
638 if enabled {
639 "ctx-item".to_string()
640 } else {
641 "ctx-item disabled".to_string()
642 }
643 }
644 title=move || if enabled { String::new() } else { hint.clone() }
645 on:click=move |_| {
646 if let Some(a) = &action {
647 a.run(());
648 }
649 }
650 >
651 {label}
652 </div>
653 }
654}
655
656// ---------------------------------------------------------------------------
657// Menu actions
658// ---------------------------------------------------------------------------
659
660fn join_path(dir: &[String], name: &str) -> String {
661 if dir.is_empty() {
662 name.to_string()
663 } else {
664 format!("{}/{}", dir.join("/"), name)
665 }
666}
667
668fn is_conflict(e: &api::ApiError) -> bool {
669 matches!(e, api::ApiError::Http { status: 409, .. })
670}
671
672fn action_new_folder(
673 loc: ReadSignal<Location>,
674 refresh: Callback<()>,
675 toast: ToastMsg,
676 set_dialog: WriteSignal<Option<Dialog>>,
677 owner: Owner,
678) -> Callback<()> {
679 Callback::new(move |_| {
680 let loc = loc.get();
681 let root_id = match loc.root_id {
682 Some(id) => id,
683 None => return,
684 };
685 let dir = loc.path.clone();
686 set_dialog.set(Some(Dialog::Prompt {
687 title: "New folder".into(),
688 label: "Folder name".into(),
689 initial: String::new(),
690 submit: "Create".into(),
691 on_submit: owner.with(|| {
692 Callback::new(move |name: String| {
693 let name = name.trim().to_string();
694 if name.is_empty() {
695 return;
696 }
697 let full = join_path(&dir, &name);
698 set_dialog.set(None);
699 spawn_local(async move {
700 match api::mkdir(root_id, &full).await {
701 Ok(_) => {
702 show(toast, "Folder created");
703 refresh.run(());
704 }
705 Err(e) => show(toast, &e.to_string()),
706 }
707 });
708 })
709 }),
710 }));
711 })
712}
713
714fn action_rename(
715 entry: &Entry,
716 loc: ReadSignal<Location>,
717 refresh: Callback<()>,
718 toast: ToastMsg,
719 set_dialog: WriteSignal<Option<Dialog>>,
720 owner: Owner,
721) -> Callback<()> {
722 let name = entry.name.clone();
723 Callback::new(move |_| {
724 let loc = loc.get();
725 let root_id = match loc.root_id {
726 Some(id) => id,
727 None => return,
728 };
729 let dir = loc.path.clone();
730 let full = join_path(&dir, &name);
731 set_dialog.set(Some(Dialog::Prompt {
732 title: format!("Rename \u{201c}{name}\u{201d}"),
733 label: "New name".into(),
734 initial: name.clone(),
735 submit: "Rename".into(),
736 on_submit: owner.with(|| {
737 Callback::new(move |new_name: String| {
738 let new_name = new_name.trim().to_string();
739 if new_name.is_empty() {
740 return;
741 }
742 set_dialog.set(None);
743 let f1 = full.clone();
744 let f2 = full.clone();
745 spawn_local(async move {
746 match api::rename_item(root_id, &f1, new_name.clone(), false).await {
747 Ok(_) => {
748 show(toast, "Renamed");
749 refresh.run(());
750 }
751 Err(e) => {
752 if is_conflict(&e) {
753 set_dialog.set(Some(Dialog::Conflict {
754 title: "Name already in use".into(),
755 files: vec![new_name.clone()],
756 on_submit: Callback::new(move |_| {
757 let f3 = f2.clone();
758 let n2 = new_name.clone();
759 spawn_local(async move {
760 match api::rename_item(root_id, &f3, n2, true).await {
761 Ok(_) => {
762 show(toast, "Renamed");
763 refresh.run(());
764 }
765 Err(e2) => show(toast, &e2.to_string()),
766 }
767 });
768 }),
769 }));
770 } else {
771 show(toast, &e.to_string());
772 }
773 }
774 }
775 });
776 })
777 }),
778 }));
779 })
780}
781
782fn action_delete(
783 entry: &Entry,
784 loc: ReadSignal<Location>,
785 refresh: Callback<()>,
786 toast: ToastMsg,
787 set_dialog: WriteSignal<Option<Dialog>>,
788 owner: Owner,
789) -> Callback<()> {
790 let name = entry.name.clone();
791 let is_dir = entry.is_dir;
792 Callback::new(move |_| {
793 let loc = loc.get();
794 let root_id = match loc.root_id {
795 Some(id) => id,
796 None => return,
797 };
798 let dir = loc.path.clone();
799 let full = join_path(&dir, &name);
800 let message = if is_dir {
801 format!(
802 "Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
803 )
804 } else {
805 format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
806 };
807 set_dialog.set(Some(Dialog::Confirm {
808 title: "Delete".into(),
809 message,
810 submit: "Delete".into(),
811 danger: true,
812 on_submit: owner.with(|| {
813 Callback::new(move |_| {
814 set_dialog.set(None);
815 let f = full.clone();
816 spawn_local(async move {
817 match api::delete_item(root_id, &f).await {
818 Ok(_) => {
819 show(toast, "Deleted");
820 refresh.run(());
821 }
822 Err(e) => show(toast, &e.to_string()),
823 }
824 });
825 })
826 }),
827 }));
828 })
829}
830
831fn action_upload(
832 multiple: bool,
833 directory: bool,
834 loc: ReadSignal<Location>,
835 refresh: Callback<()>,
836 toast: ToastMsg,
837 set_dialog: WriteSignal<Option<Dialog>>,
838) -> Callback<()> {
839 Callback::new(move |_| {
840 let loc = loc.get();
841 let root_id = match loc.root_id {
842 Some(id) => id,
843 None => return,
844 };
845 let dir = loc.path.join("/");
846 let dir1 = dir.clone();
847 api::pick_files(multiple, directory, move |files| {
848 let n = files.len();
849 let retry = files.clone();
850 let dir2 = dir1.clone();
851 show(
852 toast,
853 &format!("Uploading {n} file{}\u{2026}", if n == 1 { "" } else { "s" }),
854 );
855 spawn_local(async move {
856 match api::upload(root_id, &dir2, false, files).await {
857 Ok(_) => {
858 show(toast, "Upload complete");
859 refresh.run(());
860 }
861 Err(e) => {
862 if let Some(skipped) = e.skipped() {
863 let skipped = skipped.to_vec();
864 set_dialog.set(Some(Dialog::Conflict {
865 title: "Some files already exist".into(),
866 files: skipped,
867 on_submit: Callback::new(move |_| {
868 let rf = retry.clone();
869 let dir3 = dir2.clone();
870 spawn_local(async move {
871 match api::upload(root_id, &dir3, true, rf).await {
872 Ok(_) => {
873 show(toast, "Upload complete");
874 refresh.run(());
875 }
876 Err(e2) => show(toast, &e2.to_string()),
877 }
878 });
879 }),
880 }));
881 } else {
882 show(toast, &e.to_string());
883 }
884 }
885 }
886 });
887 });
888 })
889}
890
891fn action_move_copy(
892 op: Op,
893 entry: &Entry,
894 me: ReadSignal<Option<Me>>,
895 loc: ReadSignal<Location>,
896 refresh: Callback<()>,
897 toast: ToastMsg,
898 set_dialog: WriteSignal<Option<Dialog>>,
899 owner: Owner,
900) -> Callback<()> {
901 let name = entry.name.clone();
902 let entry2 = entry.clone();
903 Callback::new(move |_| {
904 let loc = loc.get();
905 let me = me.get();
906 let root_id = match loc.root_id {
907 Some(id) => id,
908 None => return,
909 };
910 let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
911 let dir = loc.path.clone();
912 let full = join_path(&dir, &name);
913 let dir_str = loc.path.join("/");
914 let name3 = name.clone();
915 let entry3 = entry2.clone();
916 let full3 = full.clone();
917 set_dialog.set(Some(Dialog::Picker {
918 op,
919 entry: entry3,
920 roots,
921 source_root: root_id,
922 source_dir: dir_str,
923 on_pick: owner.with(|| Callback::new(move |(dst_root, dst_dir): (i64, String)| {
924 set_dialog.set(None);
925 let f1 = full3.clone();
926 let f2 = full3.clone();
927 let name4 = name3.clone();
928 spawn_local(async move {
929 let res = if op == Op::Move {
930 api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
931 } else {
932 api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
933 };
934 match res {
935 Ok(_) => {
936 show(toast, if op == Op::Move { "Moved" } else { "Copied" });
937 refresh.run(());
938 }
939 Err(e) => {
940 if is_conflict(&e) {
941 set_dialog.set(Some(Dialog::Conflict {
942 title: "Name already in use".into(),
943 files: vec![name4],
944 on_submit: Callback::new(move |_| {
945 let f3 = f2.clone();
946 let d2 = dst_dir.clone();
947 spawn_local(async move {
948 let res = if op == Op::Move {
949 api::move_item(root_id, &f3, dst_root, &d2, true)
950 .await
951 } else {
952 api::copy_item(root_id, &f3, dst_root, &d2, true)
953 .await
954 };
955 match res {
956 Ok(_) => {
957 show(
958 toast,
959 if op == Op::Move { "Moved" } else { "Copied" },
960 );
961 refresh.run(());
962 }
963 Err(e2) => show(toast, &e2.to_string()),
964 }
965 });
966 }),
967 }));
968 } else {
969 show(toast, &e.to_string());
970 }
971 }
972 }
973 });
974 })),
975 }));
976 })
977}
978