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