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