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, IconName, icon_for};
10use crate::components::toast::{ToastMsg, show};
11use crate::editor::{EditTarget, EditorModal};
12use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind};
13use crate::router::{Location, navigate};
14use crate::util::{ViewMode, format_date, format_size};
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 crate::util::owned_window_listener(leptos::ev::click, move |_| {
576 set_ctx.set(None);
577 });
578 crate::util::owned_window_listener(
579 leptos::ev::keydown,
580 move |ev: web_sys::KeyboardEvent| {
581 if ev.key() == "Escape" {
582 set_ctx.set(None);
583 }
584 },
585 );
586 }
587
588 view! {
589 {move || {
590 let Some(m) = ctx.get() else {
591 return {
592 view! {}.into_any()
593 };
594 };
595 // Keep the menu inside the viewport.
596 let (max_x, max_y) = web_sys::window()
597 .and_then(|w| {
598 w.inner_width()
599 .ok()
600 .and_then(|v| v.as_f64())
601 .zip(w.inner_height().ok().and_then(|v| v.as_f64()))
602 })
603 .map(|(x, y)| (x as i32 - 220, y as i32 - 300))
604 .unwrap_or((m.x, m.y));
605 let x = m.x.min(max_x).max(0);
606 let y = m.y.min(max_y).max(0);
607
608 let me_now = me.get();
609 let loc_now = loc.get();
610 let is_rw = me_now
611 .as_ref()
612 .and_then(|m2| effective_root(&m2.roots, &loc_now))
613 .map(|r| r.mode == "rw")
614 .unwrap_or(false);
615 let root_name = me_now
616 .as_ref()
617 .and_then(|m2| effective_root(&m2.roots, &loc_now))
618 .map(|r| r.name.clone())
619 .unwrap_or_default();
620 // The id of the root that is actually shown (resolves the single-root
621 // default location, where `loc.root_id` is `None`).
622 let eff_root_id = me_now
623 .as_ref()
624 .and_then(|m2| effective_root(&m2.roots, &loc_now))
625 .map(|r| r.id);
626
627 // (label, action, disabled-hint)
628 let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
629 Some(e) => {
630 let e2 = e.clone();
631 let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
632 if e.is_dir {
633 let l = loc;
634 let name = e2.name.clone();
635 v.push((
636 "Open".into(),
637 Some(Callback::new(move |_| {
638 let cur = l.get();
639 let mut path = cur.path;
640 path.push(name.clone());
641 navigate(&Location {
642 root_id: cur.root_id,
643 path,
644 share_token: cur.share_token.clone(),
645 });
646 })),
647 None,
648 ));
649 }
650 let e_dl = e2.clone();
651 let l_dl = loc;
652 let t_dl = toast;
653 v.push((
654 "Download".into(),
655 Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, t_dl, owner.clone())),
656 None,
657 ));
658 // Editing is only offered for text/code files.
659 if !e2.is_dir && cm::is_text(&e2.name) {
660 let e_ed = e2.clone();
661 let l_ed = loc;
662 v.push((
663 "Edit".into(),
664 rw_action(
665 is_rw,
666 action_edit(&e_ed, eff_root_id, l_ed, set_editor, owner.clone()),
667 ),
668 Some("Read-only folder".into()),
669 ));
670 }
671 let e_rename = e2.clone();
672 let l_rename = loc;
673 v.push((
674 "Rename".into(),
675 rw_action(
676 is_rw,
677 action_rename(&e_rename, eff_root_id, l_rename, refresh, toast, set_dialog, owner.clone()),
678 ),
679 Some("Read-only folder".into()),
680 ));
681 let e_move = e2.clone();
682 let l_move = loc;
683 let m_move = me;
684 v.push((
685 "Move".into(),
686 rw_action(
687 is_rw,
688 action_move_copy(
689 Op::Move,
690 &e_move,
691 eff_root_id,
692 m_move,
693 l_move,
694 refresh,
695 toast,
696 set_dialog,
697 owner.clone(),
698 ),
699 ),
700 Some("Read-only folder".into()),
701 ));
702 let e3 = e2.clone();
703 let l_copy = loc;
704 let m_copy = me;
705 v.push((
706 "Copy".into(),
707 Some(action_move_copy(
708 Op::Copy,
709 &e3,
710 eff_root_id,
711 m_copy,
712 l_copy,
713 refresh,
714 toast,
715 set_dialog,
716 owner.clone(),
717 )),
718 None,
719 ));
720 if loc_now.share_token.is_none() {
721 let e_sh = e2.clone();
722 let l_sh = loc;
723 let allow_wr = me_now
724 .as_ref()
725 .map(|m| m.allow_writable_shares)
726 .unwrap_or(false);
727 v.push((
728 "Share".into(),
729 Some(action_share(
730 &e_sh,
731 eff_root_id,
732 l_sh,
733 allow_wr,
734 set_dialog,
735 owner.clone(),
736 )),
737 None,
738 ));
739 }
740 let e_info = e2.clone();
741 let l_info = loc;
742 v.push((
743 "Info".into(),
744 Some(action_info(
745 &e_info,
746 l_info,
747 root_name.clone(),
748 set_dialog,
749 owner.clone(),
750 )),
751 None,
752 ));
753 let e4 = e2.clone();
754 let l_del = loc;
755 v.push((
756 "Delete".into(),
757 rw_action(
758 is_rw,
759 action_delete(&e4, eff_root_id, l_del, refresh, toast, set_dialog, owner.clone()),
760 ),
761 Some("Read-only folder".into()),
762 ));
763 v
764 }
765 None => {
766 let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
767 let l_nf = loc;
768 v.push((
769 "New folder".into(),
770 rw_action(
771 is_rw,
772 action_new_folder(eff_root_id, l_nf, refresh, toast, set_dialog, owner.clone()),
773 ),
774 Some("Read-only folder".into()),
775 ));
776 let l_up1 = loc;
777 v.push((
778 "Upload files".into(),
779 rw_action(
780 is_rw,
781 action_upload(true, false, eff_root_id, l_up1, refresh, toast, set_dialog),
782 ),
783 Some("Read-only folder".into()),
784 ));
785 let l_up2 = loc;
786 v.push((
787 "Upload folder".into(),
788 rw_action(
789 is_rw,
790 action_upload(true, true, eff_root_id, l_up2, refresh, toast, set_dialog),
791 ),
792 Some("Read-only folder".into()),
793 ));
794 v.push((
795 "Refresh".into(),
796 Some(Callback::new(move |_| refresh.run(()))),
797 None,
798 ));
799 v
800 }
801 };
802
803 view! {
804 <div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
805 {items.into_iter().map(|(label, action, hint)| {
806 view! {
807 <CtxItem label=label action=action hint=hint/>
808 }
809 }).collect::<Vec<_>>()}
810 </div>
811 }
812 .into_view()
813 .into_any()
814 }}
815 }
816}
817
818/// `Some(cb)` when the current folder is writable, else `None` (disabled item).
819fn rw_action(is_rw: bool, cb: Callback<()>) -> Option<Callback<()>> {
820 if is_rw { Some(cb) } else { None }
821}
822
823#[component]
824fn CtxItem(label: String, action: Option<Callback<()>>, hint: Option<String>) -> impl IntoView {
825 let enabled = action.is_some();
826 let hint = hint.unwrap_or_else(|| "Coming soon".to_string());
827 view! {
828 <div
829 class=move || {
830 if enabled {
831 "ctx-item".to_string()
832 } else {
833 "ctx-item disabled".to_string()
834 }
835 }
836 title=move || if enabled { String::new() } else { hint.clone() }
837 on:click=move |_| {
838 if let Some(a) = &action {
839 a.run(());
840 }
841 }
842 >
843 {label}
844 </div>
845 }
846}
847
848// ---------------------------------------------------------------------------
849// Menu actions
850// ---------------------------------------------------------------------------
851
852fn join_path(dir: &[String], name: &str) -> String {
853 if dir.is_empty() {
854 name.to_string()
855 } else {
856 format!("{}/{}", dir.join("/"), name)
857 }
858}
859
860fn is_conflict(e: &api::ApiError) -> bool {
861 matches!(e, api::ApiError::Http { status: 409, .. })
862}
863
864fn action_new_folder(
865 root_id: Option<i64>,
866 loc: ReadSignal<Location>,
867 refresh: Callback<()>,
868 toast: ToastMsg,
869 set_dialog: WriteSignal<Option<Dialog>>,
870 owner: Owner,
871) -> Callback<()> {
872 Callback::new(move |_| {
873 let Some(root_id) = root_id else { return };
874 let loc = loc.get();
875 let dir = loc.path.clone();
876 set_dialog.set(Some(Dialog::Prompt {
877 title: "New folder".into(),
878 label: "Folder name".into(),
879 initial: String::new(),
880 submit: "Create".into(),
881 on_submit: owner.with(|| {
882 Callback::new(move |name: String| {
883 let name = name.trim().to_string();
884 if name.is_empty() {
885 return;
886 }
887 let full = join_path(&dir, &name);
888 set_dialog.set(None);
889 spawn_local(async move {
890 match api::mkdir(root_id, &full).await {
891 Ok(_) => {
892 show(toast, "Folder created");
893 refresh.run(());
894 }
895 Err(e) => show(toast, e.to_string()),
896 }
897 });
898 })
899 }),
900 }));
901 })
902}
903
904fn action_rename(
905 entry: &Entry,
906 root_id: Option<i64>,
907 loc: ReadSignal<Location>,
908 refresh: Callback<()>,
909 toast: ToastMsg,
910 set_dialog: WriteSignal<Option<Dialog>>,
911 owner: Owner,
912) -> Callback<()> {
913 let name = entry.name.clone();
914 Callback::new(move |_| {
915 let Some(root_id) = root_id else { return };
916 let loc = loc.get();
917 let dir = loc.path.clone();
918 let full = join_path(&dir, &name);
919 set_dialog.set(Some(Dialog::Prompt {
920 title: format!("Rename \u{201c}{name}\u{201d}"),
921 label: "New name".into(),
922 initial: name.clone(),
923 submit: "Rename".into(),
924 on_submit: owner.with(|| {
925 Callback::new(move |new_name: String| {
926 let new_name = new_name.trim().to_string();
927 if new_name.is_empty() {
928 return;
929 }
930 set_dialog.set(None);
931 let f1 = full.clone();
932 let f2 = full.clone();
933 spawn_local(async move {
934 match api::rename_item(root_id, &f1, new_name.clone(), false).await {
935 Ok(_) => {
936 show(toast, "Renamed");
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![new_name.clone()],
944 on_submit: Callback::new(move |_| {
945 let f3 = f2.clone();
946 let n2 = new_name.clone();
947 spawn_local(async move {
948 match api::rename_item(root_id, &f3, n2, true).await
949 {
950 Ok(_) => {
951 show(toast, "Renamed");
952 refresh.run(());
953 }
954 Err(e2) => show(toast, e2.to_string()),
955 }
956 });
957 }),
958 }));
959 } else {
960 show(toast, e.to_string());
961 }
962 }
963 }
964 });
965 })
966 }),
967 }));
968 })
969}
970
971fn action_delete(
972 entry: &Entry,
973 root_id: Option<i64>,
974 loc: ReadSignal<Location>,
975 refresh: Callback<()>,
976 toast: ToastMsg,
977 set_dialog: WriteSignal<Option<Dialog>>,
978 owner: Owner,
979) -> Callback<()> {
980 let name = entry.name.clone();
981 let is_dir = entry.is_dir;
982 Callback::new(move |_| {
983 let Some(root_id) = root_id else { return };
984 let loc = loc.get();
985 let dir = loc.path.clone();
986 let full = join_path(&dir, &name);
987 let message = if is_dir {
988 format!(
989 "Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
990 )
991 } else {
992 format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
993 };
994 set_dialog.set(Some(Dialog::Confirm {
995 title: "Delete".into(),
996 message,
997 submit: "Delete".into(),
998 danger: true,
999 on_submit: owner.with(|| {
1000 Callback::new(move |_| {
1001 set_dialog.set(None);
1002 let f = full.clone();
1003 spawn_local(async move {
1004 match api::delete_item(root_id, &f).await {
1005 Ok(_) => {
1006 show(toast, "Deleted");
1007 refresh.run(());
1008 }
1009 Err(e) => show(toast, e.to_string()),
1010 }
1011 });
1012 })
1013 }),
1014 }));
1015 })
1016}
1017
1018fn action_upload(
1019 multiple: bool,
1020 directory: bool,
1021 root_id: Option<i64>,
1022 loc: ReadSignal<Location>,
1023 refresh: Callback<()>,
1024 toast: ToastMsg,
1025 set_dialog: WriteSignal<Option<Dialog>>,
1026) -> Callback<()> {
1027 Callback::new(move |_| {
1028 let Some(root_id) = root_id else { return };
1029 let loc = loc.get();
1030 let dir = loc.path.join("/");
1031 let dir1 = dir.clone();
1032 api::pick_files(multiple, directory, move |files| {
1033 let n = files.len();
1034 let retry = files.clone();
1035 let dir2 = dir1.clone();
1036 show(
1037 toast,
1038 format!(
1039 "Uploading {n} file{}\u{2026}",
1040 if n == 1 { "" } else { "s" }
1041 ),
1042 );
1043 spawn_local(async move {
1044 match api::upload(root_id, &dir2, false, files).await {
1045 Ok(_) => {
1046 show(toast, "Upload complete");
1047 refresh.run(());
1048 }
1049 Err(e) => {
1050 if let Some(skipped) = e.skipped() {
1051 let skipped = skipped.to_vec();
1052 set_dialog.set(Some(Dialog::Conflict {
1053 title: "Some files already exist".into(),
1054 files: skipped,
1055 on_submit: Callback::new(move |_| {
1056 let rf = retry.clone();
1057 let dir3 = dir2.clone();
1058 spawn_local(async move {
1059 match api::upload(root_id, &dir3, true, rf).await {
1060 Ok(_) => {
1061 show(toast, "Upload complete");
1062 refresh.run(());
1063 }
1064 Err(e2) => show(toast, e2.to_string()),
1065 }
1066 });
1067 }),
1068 }));
1069 } else {
1070 show(toast, e.to_string());
1071 }
1072 }
1073 }
1074 });
1075 });
1076 })
1077}
1078
1079#[allow(clippy::too_many_arguments)] // explicit signal props
1080fn action_move_copy(
1081 op: Op,
1082 entry: &Entry,
1083 root_id: Option<i64>,
1084 me: ReadSignal<Option<Me>>,
1085 loc: ReadSignal<Location>,
1086 refresh: Callback<()>,
1087 toast: ToastMsg,
1088 set_dialog: WriteSignal<Option<Dialog>>,
1089 owner: Owner,
1090) -> Callback<()> {
1091 let name = entry.name.clone();
1092 let entry2 = entry.clone();
1093 Callback::new(move |_| {
1094 let loc = loc.get();
1095 let me = me.get();
1096 let Some(root_id) = root_id else { return };
1097 let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
1098 let dir = loc.path.clone();
1099 let full = join_path(&dir, &name);
1100 let dir_str = loc.path.join("/");
1101 let name3 = name.clone();
1102 let entry3 = entry2.clone();
1103 let full3 = full.clone();
1104 set_dialog.set(Some(Dialog::Picker {
1105 op,
1106 entry: entry3,
1107 roots,
1108 source_root: root_id,
1109 source_dir: dir_str,
1110 on_pick: owner.with(|| {
1111 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1112 set_dialog.set(None);
1113 let f1 = full3.clone();
1114 let f2 = full3.clone();
1115 let name4 = name3.clone();
1116 spawn_local(async move {
1117 let res = if op == Op::Move {
1118 api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
1119 } else {
1120 api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
1121 };
1122 match res {
1123 Ok(_) => {
1124 show(toast, if op == Op::Move { "Moved" } else { "Copied" });
1125 refresh.run(());
1126 }
1127 Err(e) => {
1128 if is_conflict(&e) {
1129 set_dialog.set(Some(Dialog::Conflict {
1130 title: "Name already in use".into(),
1131 files: vec![name4],
1132 on_submit: Callback::new(move |_| {
1133 let f3 = f2.clone();
1134 let d2 = dst_dir.clone();
1135 spawn_local(async move {
1136 let res = if op == Op::Move {
1137 api::move_item(
1138 root_id, &f3, dst_root, &d2, true,
1139 )
1140 .await
1141 } else {
1142 api::copy_item(
1143 root_id, &f3, dst_root, &d2, true,
1144 )
1145 .await
1146 };
1147 match res {
1148 Ok(_) => {
1149 show(
1150 toast,
1151 if op == Op::Move {
1152 "Moved"
1153 } else {
1154 "Copied"
1155 },
1156 );
1157 refresh.run(());
1158 }
1159 Err(e2) => show(toast, e2.to_string()),
1160 }
1161 });
1162 }),
1163 }));
1164 } else {
1165 show(toast, e.to_string());
1166 }
1167 }
1168 }
1169 });
1170 })
1171 }),
1172 }));
1173 })
1174}
1175
1176fn action_download(
1177 entry: &Entry,
1178 root_id: Option<i64>,
1179 loc: ReadSignal<Location>,
1180 set_dialog: WriteSignal<Option<Dialog>>,
1181 toast: ToastMsg,
1182 owner: Owner,
1183) -> Callback<()> {
1184 let name = entry.name.clone();
1185 let is_dir = entry.is_dir;
1186 owner.with(|| {
1187 Callback::new(move |_| {
1188 let Some(root_id) = root_id else { return };
1189 let loc = loc.get();
1190 let full = join_path(&loc.path, &name);
1191 if is_dir {
1192 set_dialog.set(Some(Dialog::DownloadFormat {
1193 name: name.clone(),
1194 root_id,
1195 path: full,
1196 }));
1197 } else {
1198 let url = api::download_url(root_id, &full, None);
1199 api::trigger_download(&url, &name);
1200 show(toast, "Download started");
1201 }
1202 })
1203 })
1204}
1205
1206/// Open the text editor for a file (milestone 5).
1207fn action_edit(
1208 entry: &Entry,
1209 root_id: Option<i64>,
1210 loc: ReadSignal<Location>,
1211 set_editor: WriteSignal<Option<EditTarget>>,
1212 owner: Owner,
1213) -> Callback<()> {
1214 let name = entry.name.clone();
1215 owner.with(|| {
1216 Callback::new(move |_| {
1217 let Some(root_id) = root_id else { return };
1218 let loc = loc.get();
1219 let full = join_path(&loc.path, &name);
1220 set_editor.set(Some(EditTarget {
1221 root_id,
1222 path: full,
1223 name: name.clone(),
1224 }));
1225 })
1226 })
1227}
1228
1229/// Open the share dialog for an item (milestone 6).
1230fn action_share(
1231 entry: &Entry,
1232 root_id: Option<i64>,
1233 loc: ReadSignal<Location>,
1234 allow_writable: bool,
1235 set_dialog: WriteSignal<Option<Dialog>>,
1236 owner: Owner,
1237) -> Callback<()> {
1238 let name = entry.name.clone();
1239 owner.with(|| {
1240 Callback::new(move |_| {
1241 let Some(root_id) = root_id else { return };
1242 let loc = loc.get();
1243 let full = join_path(&loc.path, &name);
1244 set_dialog.set(Some(Dialog::Share {
1245 name: name.clone(),
1246 root_id,
1247 path: full,
1248 allow_writable,
1249 }));
1250 })
1251 })
1252}
1253
1254fn action_info(
1255 entry: &Entry,
1256 loc: ReadSignal<Location>,
1257 root_name: String,
1258 set_dialog: WriteSignal<Option<Dialog>>,
1259 owner: Owner,
1260) -> Callback<()> {
1261 let entry2 = entry.clone();
1262 owner.with(|| {
1263 Callback::new(move |_| {
1264 let loc = loc.get();
1265 let full = join_path(&loc.path, &entry2.name);
1266 set_dialog.set(Some(Dialog::Info {
1267 entry: entry2.clone(),
1268 root_name: root_name.clone(),
1269 rel_path: full,
1270 }));
1271 })
1272 })
1273}
1274