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