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