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