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