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