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