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