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