browser.rs
⎇
Raw
1//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
2
3use api_types::FileKind;
4use leptos::prelude::*;
5use wasm_bindgen_futures::spawn_local;
6use web_sys::MouseEvent;
7
8use crate::api::{self, Entry, Me, RootInfo};
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
419/// Click/Enter on an entry: descend into a folder, or open the right viewer for
420/// a file. Shared by the grid and the list, which differ only in markup.
421fn open_callback(
422 entry: &Entry,
423 root_id: i64,
424 loc: &ReadSignal<Location>,
425 set_ctx: &WriteSignal<Option<CtxMenu>>,
426 toast: &ToastMsg,
427 set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
428) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
429 let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx, toast);
430 let (t, l, sp) = (*toast, *loc, *set_preview);
431 let (name, is_dir, size, kind) = (entry.name.clone(), entry.is_dir, entry.size, entry.kind);
432 let cb = Callback::new(move |_| {
433 if is_dir {
434 nav();
435 return;
436 }
437 match preview_kind(kind) {
438 Some(k) => {
439 let full = join_path(&l.get().path, &name);
440 sp.set(Some((
441 PreviewTarget {
442 root_id,
443 path: full,
444 name: name.clone(),
445 size,
446 },
447 k,
448 )));
449 }
450 None => show(t, "No preview available — right-click to download"),
451 }
452 });
453 (cb, on_ctx)
454}
455
456fn grid_view(
457 entries: &[Entry],
458 root_id: i64,
459 loc: &ReadSignal<Location>,
460 set_ctx: &WriteSignal<Option<CtxMenu>>,
461 toast: &ToastMsg,
462 set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
463) -> impl IntoView {
464 view! {
465 <div class="entries-grid">
466 {entries.iter().map(|e| {
467 let icon = icon_for(e.kind, &e.name);
468 let name = e.name.clone();
469 let title = name.clone();
470 let (open_cb, on_ctx) =
471 open_callback(e, root_id, loc, set_ctx, toast, set_preview);
472 let oc1 = open_cb;
473 let oc2 = open_cb;
474 view! {
475 <div
476 class="tile"
477 tabindex=0
478 on:click=move |_| oc1.run(())
479 on:keydown=move |ev: web_sys::KeyboardEvent| {
480 if ev.key() == "Enter" || ev.key() == " " {
481 ev.prevent_default();
482 oc2.run(());
483 }
484 }
485 on:contextmenu=on_ctx
486 title=title
487 >
488 <Icon name=icon class=Some("tile-icon".into())/>
489 <div class="tile-name">{name}</div>
490 </div>
491 }
492 }).collect::<Vec<_>>()}
493 </div>
494 }
495}
496
497fn list_view(
498 entries: &[Entry],
499 root_id: i64,
500 loc: &ReadSignal<Location>,
501 set_ctx: &WriteSignal<Option<CtxMenu>>,
502 toast: &ToastMsg,
503 set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
504) -> impl IntoView {
505 view! {
506 <div class="entries-list">
507 {entries.iter().map(|e| {
508 let icon = icon_for(e.kind, &e.name);
509 let name = e.name.clone();
510 let size = format_size(e.size);
511 let date = format_date(&e.mtime);
512 let is_dir = e.is_dir;
513 let title = name.clone();
514 let (open_cb, on_ctx) =
515 open_callback(e, root_id, loc, set_ctx, toast, set_preview);
516 let oc1 = open_cb;
517 let oc2 = open_cb;
518 view! {
519 <div
520 class="row"
521 tabindex=0
522 on:click=move |_| oc1.run(())
523 on:keydown=move |ev: web_sys::KeyboardEvent| {
524 if ev.key() == "Enter" || ev.key() == " " {
525 ev.prevent_default();
526 oc2.run(());
527 }
528 }
529 on:contextmenu=on_ctx
530 title=title
531 >
532 <Icon name=icon class=Some("row-icon".into())/>
533 <span class="row-name">{name}</span>
534 <span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
535 <span class="row-date muted">{date}</span>
536 </div>
537 }
538 }).collect::<Vec<_>>()}
539 </div>
540 }
541}
542
543// ---------------------------------------------------------------------------
544// Context menu
545// ---------------------------------------------------------------------------
546
547#[component]
548fn CtxMenuView(
549 ctx: ReadSignal<Option<CtxMenu>>,
550 set_ctx: WriteSignal<Option<CtxMenu>>,
551 me: ReadSignal<Option<Me>>,
552 loc: ReadSignal<Location>,
553 refresh: Callback<()>,
554 toast: ToastMsg,
555 set_dialog: WriteSignal<Option<Dialog>>,
556 set_editor: WriteSignal<Option<EditTarget>>,
557 owner: Owner,
558) -> impl IntoView {
559 // Close on any click or Escape.
560 {
561 crate::util::owned_window_listener(leptos::ev::click, move |_| {
562 set_ctx.set(None);
563 });
564 crate::util::owned_window_listener(
565 leptos::ev::keydown,
566 move |ev: web_sys::KeyboardEvent| {
567 if ev.key() == "Escape" {
568 set_ctx.set(None);
569 }
570 },
571 );
572 }
573
574 view! {
575 {move || {
576 let Some(m) = ctx.get() else {
577 return {
578 view! {}.into_any()
579 };
580 };
581 // Keep the menu inside the viewport.
582 let (max_x, max_y) = web_sys::window()
583 .and_then(|w| {
584 w.inner_width()
585 .ok()
586 .and_then(|v| v.as_f64())
587 .zip(w.inner_height().ok().and_then(|v| v.as_f64()))
588 })
589 .map(|(x, y)| (x as i32 - 220, y as i32 - 300))
590 .unwrap_or((m.x, m.y));
591 let x = m.x.min(max_x).max(0);
592 let y = m.y.min(max_y).max(0);
593
594 let me_now = me.get();
595 let loc_now = loc.get();
596 let is_rw = me_now
597 .as_ref()
598 .and_then(|m2| effective_root(&m2.roots, &loc_now))
599 .map(|r| r.mode == "rw")
600 .unwrap_or(false);
601 let root_name = me_now
602 .as_ref()
603 .and_then(|m2| effective_root(&m2.roots, &loc_now))
604 .map(|r| r.name.clone())
605 .unwrap_or_default();
606 // The id of the root that is actually shown (resolves the single-root
607 // default location, where `loc.root_id` is `None`).
608 let eff_root_id = me_now
609 .as_ref()
610 .and_then(|m2| effective_root(&m2.roots, &loc_now))
611 .map(|r| r.id);
612
613 // (label, action, disabled-hint)
614 let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
615 Some(e) => {
616 let e2 = e.clone();
617 let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
618 if e.is_dir {
619 let l = loc;
620 let name = e2.name.clone();
621 v.push((
622 "Open".into(),
623 Some(Callback::new(move |_| {
624 let cur = l.get();
625 let mut path = cur.path;
626 path.push(name.clone());
627 navigate(&Location {
628 root_id: cur.root_id,
629 path,
630 share_token: cur.share_token.clone(),
631 });
632 })),
633 None,
634 ));
635 }
636 // Serve the file itself in a new tab. HTML and SVG render
637 // as real pages there (sandboxed by the server), which is
638 // how you share a small site or an HTML report.
639 if !e2.is_dir {
640 let e_tab = e2.clone();
641 v.push((
642 "Open in new tab".into(),
643 Some(action_open_tab(&e_tab, eff_root_id, loc, owner.clone())),
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 files that decode as text.
656 if e2.kind == FileKind::Text {
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 { Some(cb) } else { None }
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 {
947 Ok(_) => {
948 show(toast, "Renamed");
949 refresh.run(());
950 }
951 Err(e2) => show(toast, e2.to_string()),
952 }
953 });
954 }),
955 }));
956 } else {
957 show(toast, e.to_string());
958 }
959 }
960 }
961 });
962 })
963 }),
964 }));
965 })
966}
967
968fn action_delete(
969 entry: &Entry,
970 root_id: Option<i64>,
971 loc: ReadSignal<Location>,
972 refresh: Callback<()>,
973 toast: ToastMsg,
974 set_dialog: WriteSignal<Option<Dialog>>,
975 owner: Owner,
976) -> Callback<()> {
977 let name = entry.name.clone();
978 let is_dir = entry.is_dir;
979 Callback::new(move |_| {
980 let Some(root_id) = root_id else { return };
981 let loc = loc.get();
982 let dir = loc.path.clone();
983 let full = join_path(&dir, &name);
984 let message = if is_dir {
985 format!(
986 "Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
987 )
988 } else {
989 format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
990 };
991 set_dialog.set(Some(Dialog::Confirm {
992 title: "Delete".into(),
993 message,
994 submit: "Delete".into(),
995 danger: true,
996 on_submit: owner.with(|| {
997 Callback::new(move |_| {
998 set_dialog.set(None);
999 let f = full.clone();
1000 spawn_local(async move {
1001 match api::delete_item(root_id, &f).await {
1002 Ok(_) => {
1003 show(toast, "Deleted");
1004 refresh.run(());
1005 }
1006 Err(e) => show(toast, e.to_string()),
1007 }
1008 });
1009 })
1010 }),
1011 }));
1012 })
1013}
1014
1015fn action_upload(
1016 multiple: bool,
1017 directory: bool,
1018 root_id: Option<i64>,
1019 loc: ReadSignal<Location>,
1020 refresh: Callback<()>,
1021 toast: ToastMsg,
1022 set_dialog: WriteSignal<Option<Dialog>>,
1023) -> Callback<()> {
1024 Callback::new(move |_| {
1025 let Some(root_id) = root_id else { return };
1026 let loc = loc.get();
1027 let dir = loc.path.join("/");
1028 let dir1 = dir.clone();
1029 api::pick_files(multiple, directory, move |files| {
1030 let n = files.len();
1031 let retry = files.clone();
1032 let dir2 = dir1.clone();
1033 show(
1034 toast,
1035 format!(
1036 "Uploading {n} file{}\u{2026}",
1037 if n == 1 { "" } else { "s" }
1038 ),
1039 );
1040 spawn_local(async move {
1041 match api::upload(root_id, &dir2, false, files).await {
1042 Ok(_) => {
1043 show(toast, "Upload complete");
1044 refresh.run(());
1045 }
1046 Err(e) => {
1047 if let Some(skipped) = e.skipped() {
1048 let skipped = skipped.to_vec();
1049 set_dialog.set(Some(Dialog::Conflict {
1050 title: "Some files already exist".into(),
1051 files: skipped,
1052 on_submit: Callback::new(move |_| {
1053 let rf = retry.clone();
1054 let dir3 = dir2.clone();
1055 spawn_local(async move {
1056 match api::upload(root_id, &dir3, true, rf).await {
1057 Ok(_) => {
1058 show(toast, "Upload complete");
1059 refresh.run(());
1060 }
1061 Err(e2) => show(toast, e2.to_string()),
1062 }
1063 });
1064 }),
1065 }));
1066 } else {
1067 show(toast, e.to_string());
1068 }
1069 }
1070 }
1071 });
1072 });
1073 })
1074}
1075
1076#[allow(clippy::too_many_arguments)] // explicit signal props
1077fn action_move_copy(
1078 op: Op,
1079 entry: &Entry,
1080 root_id: Option<i64>,
1081 me: ReadSignal<Option<Me>>,
1082 loc: ReadSignal<Location>,
1083 refresh: Callback<()>,
1084 toast: ToastMsg,
1085 set_dialog: WriteSignal<Option<Dialog>>,
1086 owner: Owner,
1087) -> Callback<()> {
1088 let name = entry.name.clone();
1089 let entry2 = entry.clone();
1090 Callback::new(move |_| {
1091 let loc = loc.get();
1092 let me = me.get();
1093 let Some(root_id) = root_id else { return };
1094 let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
1095 let dir = loc.path.clone();
1096 let full = join_path(&dir, &name);
1097 let dir_str = loc.path.join("/");
1098 let name3 = name.clone();
1099 let entry3 = entry2.clone();
1100 let full3 = full.clone();
1101 set_dialog.set(Some(Dialog::Picker {
1102 op,
1103 entry: entry3,
1104 roots,
1105 source_root: root_id,
1106 source_dir: dir_str,
1107 on_pick: owner.with(|| {
1108 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1109 set_dialog.set(None);
1110 let f1 = full3.clone();
1111 let f2 = full3.clone();
1112 let name4 = name3.clone();
1113 spawn_local(async move {
1114 let res = if op == Op::Move {
1115 api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
1116 } else {
1117 api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
1118 };
1119 match res {
1120 Ok(_) => {
1121 show(toast, if op == Op::Move { "Moved" } else { "Copied" });
1122 refresh.run(());
1123 }
1124 Err(e) => {
1125 if is_conflict(&e) {
1126 set_dialog.set(Some(Dialog::Conflict {
1127 title: "Name already in use".into(),
1128 files: vec![name4],
1129 on_submit: Callback::new(move |_| {
1130 let f3 = f2.clone();
1131 let d2 = dst_dir.clone();
1132 spawn_local(async move {
1133 let res = if op == Op::Move {
1134 api::move_item(
1135 root_id, &f3, dst_root, &d2, true,
1136 )
1137 .await
1138 } else {
1139 api::copy_item(
1140 root_id, &f3, dst_root, &d2, true,
1141 )
1142 .await
1143 };
1144 match res {
1145 Ok(_) => {
1146 show(
1147 toast,
1148 if op == Op::Move {
1149 "Moved"
1150 } else {
1151 "Copied"
1152 },
1153 );
1154 refresh.run(());
1155 }
1156 Err(e2) => show(toast, e2.to_string()),
1157 }
1158 });
1159 }),
1160 }));
1161 } else {
1162 show(toast, e.to_string());
1163 }
1164 }
1165 }
1166 });
1167 })
1168 }),
1169 }));
1170 })
1171}
1172
1173fn action_download(
1174 entry: &Entry,
1175 root_id: Option<i64>,
1176 loc: ReadSignal<Location>,
1177 set_dialog: WriteSignal<Option<Dialog>>,
1178 toast: ToastMsg,
1179 owner: Owner,
1180) -> Callback<()> {
1181 let name = entry.name.clone();
1182 let is_dir = entry.is_dir;
1183 owner.with(|| {
1184 Callback::new(move |_| {
1185 let Some(root_id) = root_id else { return };
1186 let loc = loc.get();
1187 let full = join_path(&loc.path, &name);
1188 if is_dir {
1189 set_dialog.set(Some(Dialog::DownloadFormat {
1190 name: name.clone(),
1191 root_id,
1192 path: full,
1193 }));
1194 } else {
1195 let url = api::download_url(root_id, &full, None);
1196 api::trigger_download(&url, &name);
1197 show(toast, "Download started");
1198 }
1199 })
1200 })
1201}
1202
1203/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1204/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1205fn action_open_tab(
1206 entry: &Entry,
1207 root_id: Option<i64>,
1208 loc: ReadSignal<Location>,
1209 owner: Owner,
1210) -> Callback<()> {
1211 let name = entry.name.clone();
1212 owner.with(|| {
1213 Callback::new(move |_| {
1214 let Some(root_id) = root_id else { return };
1215 let full = join_path(&loc.get().path, &name);
1216 api::open_in_new_tab(&api::preview_url(root_id, &full));
1217 })
1218 })
1219}
1220
1221/// Open the text editor for a file (milestone 5).
1222fn action_edit(
1223 entry: &Entry,
1224 root_id: Option<i64>,
1225 loc: ReadSignal<Location>,
1226 set_editor: WriteSignal<Option<EditTarget>>,
1227 owner: Owner,
1228) -> Callback<()> {
1229 let name = entry.name.clone();
1230 owner.with(|| {
1231 Callback::new(move |_| {
1232 let Some(root_id) = root_id else { return };
1233 let loc = loc.get();
1234 let full = join_path(&loc.path, &name);
1235 set_editor.set(Some(EditTarget {
1236 root_id,
1237 path: full,
1238 name: name.clone(),
1239 }));
1240 })
1241 })
1242}
1243
1244/// Open the share dialog for an item (milestone 6).
1245fn action_share(
1246 entry: &Entry,
1247 root_id: Option<i64>,
1248 loc: ReadSignal<Location>,
1249 allow_writable: bool,
1250 set_dialog: WriteSignal<Option<Dialog>>,
1251 owner: Owner,
1252) -> Callback<()> {
1253 let name = entry.name.clone();
1254 owner.with(|| {
1255 Callback::new(move |_| {
1256 let Some(root_id) = root_id else { return };
1257 let loc = loc.get();
1258 let full = join_path(&loc.path, &name);
1259 set_dialog.set(Some(Dialog::Share {
1260 name: name.clone(),
1261 root_id,
1262 path: full,
1263 allow_writable,
1264 }));
1265 })
1266 })
1267}
1268
1269fn action_info(
1270 entry: &Entry,
1271 loc: ReadSignal<Location>,
1272 root_name: String,
1273 set_dialog: WriteSignal<Option<Dialog>>,
1274 owner: Owner,
1275) -> Callback<()> {
1276 let entry2 = entry.clone();
1277 owner.with(|| {
1278 Callback::new(move |_| {
1279 let loc = loc.get();
1280 let full = join_path(&loc.path, &entry2.name);
1281 set_dialog.set(Some(Dialog::Info {
1282 entry: entry2.clone(),
1283 root_name: root_name.clone(),
1284 rel_path: full,
1285 }));
1286 })
1287 })
1288}
1289