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