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