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::JsCast;
6use wasm_bindgen_futures::spawn_local;
7use web_sys::MouseEvent;
8
9use crate::api::{self, Entry, Me, RootInfo};
10use crate::components::icon::Icon;
11use crate::components::toast::{ToastMsg, show};
12use crate::editor::EditTarget;
13use crate::icons::{IconName, icon_for};
14use crate::preview::{PreviewTarget, preview_kind};
15use crate::router::{Location, Section, navigate};
16use crate::util::{ViewMode, format_date, format_size};
17use crate::views::dialogs::{Dialog, Op};
18use crate::views::file_view::{FileView, UnsupportedTarget};
19
20#[derive(Clone, Debug, PartialEq)]
21enum ListState {
22 Loading,
23 Error(String),
24 Entries(Vec<Entry>),
25}
26
27/// The root that is actually shown: the one in the location, or — when the
28/// user has exactly one root — that root directly.
29pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
30 match loc.root_id {
31 Some(id) => roots.iter().find(|r| r.id == id),
32 None if roots.len() == 1 => Some(&roots[0]),
33 None => None,
34 }
35}
36
37#[derive(Clone)]
38struct CtxMenu {
39 x: i32,
40 y: i32,
41 /// None = empty area / root tile (no entry selected).
42 entry: Option<Entry>,
43}
44
45#[component]
46pub fn Browser(
47 me: ReadSignal<Option<Me>>,
48 set_me: WriteSignal<Option<Me>>,
49 loc: ReadSignal<Location>,
50 /// Opens a file in the full-page view (owned by the shell / share page).
51 open_file: WriteSignal<Option<FileView>>,
52 /// The browser exposes its fetch there so the full-page editor can
53 /// re-list the folder after a save.
54 set_browser_refresh: WriteSignal<Option<Callback<()>>>,
55 /// The single selected entry, owned by the shell so its action buttons
56 /// can live in the top bar. `None` shows the empty-area actions there.
57 selected: ReadSignal<Option<Entry>>,
58 set_selected: WriteSignal<Option<Entry>>,
59 /// Dialogs are rendered at the shell level.
60 set_dialog: WriteSignal<Option<Dialog>>,
61 /// A file opened in the full-page view. Escape must not clear the
62 /// selection while this is set — that keypress closes the view (handled
63 /// by the shell), and the selection should survive.
64 file_view: ReadSignal<Option<FileView>>,
65) -> impl IntoView {
66 let toast = use_context::<ToastMsg>().expect("toast context");
67 let (view_mode, set_view_mode) = signal(ViewMode::load());
68 let (list_state, set_list_state) = signal(ListState::Loading);
69 let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
70 // Long-lived owner for callbacks created inside short-lived scopes
71 // (context-menu event handlers); otherwise they get disposed with the
72 // menu and the open dialog panics on submit.
73 let ctx_owner = Owner::current().unwrap();
74 // A 403/404 on the listing usually means an admin removed or changed
75 // this root, so re-fetch /me once per (root, path) to update the
76 // sidebar and this view. The key guards against a refresh loop when
77 // /me comes back unchanged.
78 let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
79
80 // Drop the selection when the entry disappears from the listing
81 // (deleted, renamed, …).
82 Effect::new(move |_| {
83 if let ListState::Entries(entries) = list_state.get()
84 && let Some(s) = selected.get()
85 && !entries.iter().any(|e| e.name == s.name)
86 {
87 set_selected.set(None);
88 }
89 });
90 // Escape clears the selection — but only when neither the context menu
91 // (it has its own Escape handler) nor a full-page file view (the shell
92 // closes it on the same keypress) is open. Registered in the capture
93 // phase so it sees the file view *before* the shell's bubble listener
94 // closes it; and it blurs the focused row, because the keypress would
95 // otherwise leave the row's :focus-visible outline behind.
96 crate::util::owned_window_listener_capture(
97 leptos::ev::keydown,
98 move |ev: web_sys::KeyboardEvent| {
99 if ev.key() != "Escape" || ctx.get().is_some() || file_view.get().is_some() {
100 return;
101 }
102 if let Some(active) = web_sys::window()
103 .and_then(|w| w.document())
104 .and_then(|d| d.active_element())
105 .and_then(|a| a.dyn_into::<web_sys::HtmlElement>().ok())
106 {
107 let _ = active.blur();
108 }
109 set_selected.set(None);
110 },
111 );
112
113 // Fetch the current directory whenever the location (or user) changes.
114 let fetch = Callback::new(move |_| {
115 let Some(me) = me.get() else {
116 return;
117 };
118 let loc = loc.get();
119 let Some(root) = effective_root(&me.roots, &loc) else {
120 return;
121 };
122 set_list_state.set(ListState::Loading);
123 let root_id = root.id;
124 let path_str = loc.path.join("/");
125 // On a public share page the (synthetic) "me" comes from the share
126 // itself, not from a session — refreshing /me would be wrong there.
127 let is_share = loc.share_token.is_some();
128 let set_me2 = set_me;
129 let refresh_key = me_refresh_key;
130 let set_refresh_key = set_me_refresh_key;
131 spawn_local(async move {
132 match api::list_files(root_id, &path_str).await {
133 Ok(r) => set_list_state.set(ListState::Entries(r.entries)),
134 Err(e) => {
135 if !is_share
136 && matches!(e.status(), Some(403) | Some(404))
137 && refresh_key.get() != Some((root_id, path_str.clone()))
138 {
139 set_refresh_key.set(Some((root_id, path_str.clone())));
140 spawn_local(async move {
141 if let Ok(m) = api::me().await {
142 set_me2.set(Some(m));
143 }
144 });
145 }
146 set_list_state.set(ListState::Error(e.to_string()));
147 }
148 }
149 });
150 });
151 Effect::new(move |_| fetch.run(()));
152 {
153 let w = set_browser_refresh;
154 let f = fetch;
155 Effect::new(move |_| {
156 w.set(Some(f));
157 });
158 }
159
160 view! {
161 <div class="browser">
162 {move || {
163 let Some(me_now) = me.get() else {
164 return {
165 view! {}.into_any()
166 };
167 };
168 let loc_now = loc.get();
169 match effective_root(&me_now.roots, &loc_now) {
170 Some(root) => file_browser(
171 me,
172 root,
173 loc,
174 list_state,
175 view_mode,
176 set_view_mode,
177 set_ctx,
178 selected,
179 set_selected,
180 fetch,
181 open_file,
182 )
183 .into_any(),
184 None => no_folder_view(&me_now.roots, &loc_now).into_any(),
185 }
186 }}
187 <CtxMenuView
188 ctx=ctx
189 set_ctx=set_ctx
190 me=me
191 loc=loc
192 refresh=fetch
193 toast=toast
194 set_dialog=set_dialog
195 open_file=open_file
196 owner=ctx_owner
197 />
198 </div>
199 }
200}
201
202// ---------------------------------------------------------------------------
203// Empty states (no resolvable root)
204// ---------------------------------------------------------------------------
205
206/// Shown when the browser can't resolve a root: the user has no roots at all,
207/// hasn't picked one yet (they are sidebar tabs now), or the URL points at a
208/// root that no longer exists.
209fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
210 let (title, message) = if roots.is_empty() {
211 (
212 "No folders available",
213 "An administrator hasn't assigned you any folders yet.",
214 )
215 } else if loc.root_id.is_none() {
216 (
217 "Select a folder",
218 "Choose a folder from the sidebar to start browsing.",
219 )
220 } else {
221 (
222 "Folder not available",
223 "This folder is no longer accessible. Choose another one from the sidebar.",
224 )
225 };
226 view! {
227 <div class="empty-state">
228 <Icon name=IconName::FolderOff class="empty-glyph".to_string()/>
229 <h3>{title}</h3>
230 <p class="muted">{message}</p>
231 </div>
232 }
233}
234
235// ---------------------------------------------------------------------------
236// File browser (breadcrumbs + toolbar + entries)
237// ---------------------------------------------------------------------------
238
239#[allow(clippy::too_many_arguments)] // explicit signal props
240fn file_browser(
241 me: ReadSignal<Option<Me>>,
242 root: &RootInfo,
243 loc: ReadSignal<Location>,
244 list_state: ReadSignal<ListState>,
245 view_mode: ReadSignal<ViewMode>,
246 set_view_mode: WriteSignal<ViewMode>,
247 set_ctx: WriteSignal<Option<CtxMenu>>,
248 selected: ReadSignal<Option<Entry>>,
249 set_selected: WriteSignal<Option<Entry>>,
250 fetch: Callback<()>,
251 open_file: WriteSignal<Option<FileView>>,
252) -> impl IntoView {
253 let root_id = root.id;
254 let root_name = root.name.clone();
255 let is_rw = root.mode.is_writable();
256 let loc_now = loc.get();
257 // The user's profile setting: single click opens entries; when off,
258 // a single click selects and a double click opens.
259 let single_click = me
260 .get()
261 .and_then(|m| m.user)
262 .is_some_and(|u| u.single_click_open);
263
264 // Breadcrumbs
265 let crumbs = view! {
266 <div class="crumbs">
267 <span
268 class="crumb"
269 on:click=move |_| {
270 navigate(&Location {
271 root_id: Some(root_id),
272 path: vec![],
273 share_token: loc.get().share_token.clone(),
274 section: Section::Files,
275 });
276 }
277 >
278 {root_name.clone()}
279 </span>
280 {loc_now.path.iter().enumerate().map(|(i, seg)| {
281 let seg = seg.clone();
282 let target: Vec<String> = loc_now.path[..=i].to_vec();
283 let is_last = i == loc_now.path.len() - 1;
284 view! {
285 <span class="crumb-sep">"/"</span>
286 <span
287 class=move || {
288 if is_last {
289 "crumb current".to_string()
290 } else {
291 "crumb".to_string()
292 }
293 }
294 on:click=move |_| {
295 navigate(&Location {
296 root_id: Some(root_id),
297 path: target.clone(),
298 share_token: loc.get().share_token.clone(),
299 section: Section::Files,
300 });
301 }
302 >
303 {seg}
304 </span>
305 }
306 }).collect::<Vec<_>>()}
307 </div>
308 };
309
310 // Toolbar: refresh on the left, view toggles on the right. The
311 // selection's action buttons live in the app's top bar.
312 let toolbar = view! {
313 <div class="toolbar">
314 <button
315 class="icon-btn"
316 title="Refresh"
317 aria-label="Refresh"
318 on:click=move |_| fetch.run(())
319 >
320 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
321 </button>
322 <div class="toolbar-right">
323 <button
324 class=move || {
325 if view_mode.get() == ViewMode::Grid {
326 "icon-btn active".to_string()
327 } else {
328 "icon-btn".to_string()
329 }
330 }
331 title="Grid view"
332 on:click=move |_| {
333 set_view_mode.set(ViewMode::Grid);
334 ViewMode::Grid.save();
335 }
336 >
337 <Icon name=IconName::Grid class="ic-btn".to_string()/>
338 </button>
339 <button
340 class=move || {
341 if view_mode.get() == ViewMode::List {
342 "icon-btn active".to_string()
343 } else {
344 "icon-btn".to_string()
345 }
346 }
347 title="List view"
348 on:click=move |_| {
349 set_view_mode.set(ViewMode::List);
350 ViewMode::List.save();
351 }
352 >
353 <Icon name=IconName::List class="ic-btn".to_string()/>
354 </button>
355 </div>
356 </div>
357 };
358
359 let entries_area = view! {
360 <div
361 class="entries-area"
362 // Clicking empty space clears the selection.
363 on:click=move |_| set_selected.set(None)
364 on:contextmenu=move |ev: MouseEvent| {
365 ev.prevent_default();
366 set_selected.set(None);
367 set_ctx.set(Some(CtxMenu {
368 x: ev.client_x(),
369 y: ev.client_y(),
370 entry: None,
371 }));
372 }
373 >
374 {move || {
375 match list_state.get() {
376 ListState::Loading => view! {
377 <p class="muted center-note">"Loading…"</p>
378 }
379 .into_view()
380 .into_any(),
381 ListState::Error(msg) => view! {
382 <div class="card error-card">
383 <span>{msg.clone()}</span>
384 <button class="btn" on:click=move |_| fetch.run(())>
385 "Retry"
386 </button>
387 </div>
388 }
389 .into_view()
390 .into_any(),
391 ListState::Entries(entries) => {
392 if entries.is_empty() {
393 view! { <p class="muted center-note">"This folder is empty."</p> }
394 .into_view()
395 .into_any()
396 } else if view_mode.get() == ViewMode::Grid {
397 grid_view(
398 &entries,
399 root_id,
400 loc,
401 is_rw,
402 single_click,
403 selected,
404 set_selected,
405 set_ctx,
406 open_file,
407 )
408 .into_any()
409 } else {
410 list_view(
411 &entries,
412 root_id,
413 loc,
414 is_rw,
415 single_click,
416 selected,
417 set_selected,
418 set_ctx,
419 open_file,
420 )
421 .into_any()
422 }
423 }
424 }
425 }}
426 </div>
427 };
428
429 view! {
430 {crumbs}
431 {toolbar}
432 {entries_area}
433 }
434}
435
436/// Right-click on an entry: select it and open the context menu on it.
437fn ctx_menu_handler(
438 entry: Entry,
439 set_ctx: WriteSignal<Option<CtxMenu>>,
440 set_selected: WriteSignal<Option<Entry>>,
441) -> impl Fn(web_sys::MouseEvent) + 'static {
442 move |ev: MouseEvent| {
443 ev.prevent_default();
444 ev.stop_propagation();
445 set_selected.set(Some(entry.clone()));
446 set_ctx.set(Some(CtxMenu {
447 x: ev.client_x(),
448 y: ev.client_y(),
449 entry: Some(entry.clone()),
450 }));
451 }
452}
453
454/// Left-click behaviour per the "single click opens" profile setting:
455/// - off (default): the click selects the entry; a double click opens it;
456/// - on: the click opens it, Ctrl/Cmd+click toggles the selection.
457///
458/// Selecting clicks stop propagation so the empty-area click (deselect) and
459/// the double-click handler don't interfere.
460fn selection_click(
461 entry: Entry,
462 single_click: bool,
463 selected: ReadSignal<Option<Entry>>,
464 set_selected: WriteSignal<Option<Entry>>,
465 open: Callback<()>,
466) -> impl Fn(web_sys::MouseEvent) + 'static {
467 move |ev: MouseEvent| {
468 if single_click {
469 if ev.ctrl_key() || ev.meta_key() {
470 ev.stop_propagation();
471 let is_sel = selected.get().is_some_and(|s| s.name == entry.name);
472 set_selected.set(if is_sel { None } else { Some(entry.clone()) });
473 return;
474 }
475 open.run(());
476 return;
477 }
478 ev.stop_propagation();
479 set_selected.set(Some(entry.clone()));
480 }
481}
482
483/// What "open" does for an entry: descend into a folder, open a text file in
484/// the editor (read-only in read-only folders), open a media preview — or
485/// say so when there is no viewer. Shared by left-click, Enter, and the
486/// context menu's "Open" item.
487fn open_entry(
488 entry: &Entry,
489 root_id: i64,
490 loc: ReadSignal<Location>,
491 is_rw: bool,
492 open_file: WriteSignal<Option<FileView>>,
493) -> Callback<()> {
494 let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
495 Callback::new(move |_| {
496 if is_dir {
497 let cur = loc.get();
498 let mut path = cur.path;
499 path.push(name.clone());
500 navigate(&Location {
501 root_id: Some(root_id),
502 path,
503 share_token: cur.share_token.clone(),
504 section: Section::Files,
505 });
506 return;
507 }
508 let full = join_path(&loc.get().path, &name);
509 if kind == FileKind::Text {
510 open_file.set(Some(FileView::Editor(EditTarget {
511 root_id,
512 path: full,
513 name: name.clone(),
514 readonly: !is_rw,
515 })));
516 return;
517 }
518 match preview_kind(kind) {
519 Some(k) => open_file.set(Some(FileView::Preview(
520 PreviewTarget {
521 root_id,
522 path: full,
523 name: name.clone(),
524 },
525 k,
526 ))),
527 None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
528 root_id,
529 path: full,
530 name: name.clone(),
531 kind,
532 }))),
533 }
534 })
535}
536
537/// Click/Enter on an entry: the shared "open" action, the selection-aware
538/// click handler, and the context-menu handler. The grid and the list differ
539/// only in markup.
540#[allow(clippy::too_many_arguments)] // explicit signal props
541fn entry_callbacks(
542 entry: &Entry,
543 root_id: i64,
544 loc: ReadSignal<Location>,
545 is_rw: bool,
546 single_click: bool,
547 selected: ReadSignal<Option<Entry>>,
548 set_selected: WriteSignal<Option<Entry>>,
549 set_ctx: WriteSignal<Option<CtxMenu>>,
550 open_file: WriteSignal<Option<FileView>>,
551) -> (
552 Callback<()>,
553 impl Fn(web_sys::MouseEvent) + 'static,
554 impl Fn(web_sys::MouseEvent) + 'static,
555) {
556 let open_cb = open_entry(entry, root_id, loc, is_rw, open_file);
557 (
558 open_cb,
559 selection_click(entry.clone(), single_click, selected, set_selected, open_cb),
560 ctx_menu_handler(entry.clone(), set_ctx, set_selected),
561 )
562}
563
564#[allow(clippy::too_many_arguments)] // explicit signal props
565fn grid_view(
566 entries: &[Entry],
567 root_id: i64,
568 loc: ReadSignal<Location>,
569 is_rw: bool,
570 single_click: bool,
571 selected: ReadSignal<Option<Entry>>,
572 set_selected: WriteSignal<Option<Entry>>,
573 set_ctx: WriteSignal<Option<CtxMenu>>,
574 open_file: WriteSignal<Option<FileView>>,
575) -> impl IntoView {
576 view! {
577 <div class="entries-grid">
578 {entries.iter().map(|e| {
579 let icon = icon_for(e.kind, &e.name);
580 let name = e.name.clone();
581 let title = name.clone();
582 let (open_cb, on_click, on_ctx) =
583 entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
584 let sel_check = selected;
585 let tile_name = name.clone();
586 let selected_now = move || sel_check.get().is_some_and(|s| s.name == tile_name);
587 view! {
588 <div
589 class="tile"
590 class:selected=selected_now
591 tabindex=0
592 on:click=on_click
593 on:dblclick=move |_| open_cb.run(())
594 on:keydown=move |ev: web_sys::KeyboardEvent| {
595 if ev.key() == "Enter" || ev.key() == " " {
596 ev.prevent_default();
597 open_cb.run(());
598 }
599 }
600 on:contextmenu=on_ctx
601 title=title
602 >
603 <Icon name=icon class="tile-icon".to_string()/>
604 <div class="tile-name">{name}</div>
605 </div>
606 }
607 }).collect::<Vec<_>>()}
608 </div>
609 }
610}
611
612#[allow(clippy::too_many_arguments)] // explicit signal props
613fn list_view(
614 entries: &[Entry],
615 root_id: i64,
616 loc: ReadSignal<Location>,
617 is_rw: bool,
618 single_click: bool,
619 selected: ReadSignal<Option<Entry>>,
620 set_selected: WriteSignal<Option<Entry>>,
621 set_ctx: WriteSignal<Option<CtxMenu>>,
622 open_file: WriteSignal<Option<FileView>>,
623) -> impl IntoView {
624 view! {
625 <div class="entries-list">
626 {entries.iter().map(|e| {
627 let icon = icon_for(e.kind, &e.name);
628 let name = e.name.clone();
629 let size = format_size(e.size);
630 let date = format_date(&e.mtime);
631 let is_dir = e.is_dir;
632 let title = name.clone();
633 let (open_cb, on_click, on_ctx) =
634 entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
635 let sel_check = selected;
636 let row_name = name.clone();
637 let selected_now = move || sel_check.get().is_some_and(|s| s.name == row_name);
638 view! {
639 <div
640 class="row"
641 class:selected=selected_now
642 tabindex=0
643 on:click=on_click
644 on:dblclick=move |_| open_cb.run(())
645 on:keydown=move |ev: web_sys::KeyboardEvent| {
646 if ev.key() == "Enter" || ev.key() == " " {
647 ev.prevent_default();
648 open_cb.run(());
649 }
650 }
651 on:contextmenu=on_ctx
652 title=title
653 >
654 <Icon name=icon class="row-icon".to_string()/>
655 <span class="row-name">{name}</span>
656 <span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
657 <span class="row-date muted">{date}</span>
658 </div>
659 }
660 }).collect::<Vec<_>>()}
661 </div>
662 }
663}
664
665// ---------------------------------------------------------------------------
666// Context menu
667// ---------------------------------------------------------------------------
668
669/// Build the action list for an entry (or the empty area, when `entry` is
670/// `None`). Shared by the right-click context menu and the toolbar's
671/// selection actions so both always offer the same operations.
672#[allow(clippy::too_many_arguments)] // explicit signal props
673fn menu_items(
674 entry: Option<&Entry>,
675 me: ReadSignal<Option<Me>>,
676 loc: ReadSignal<Location>,
677 open_file: WriteSignal<Option<FileView>>,
678 refresh: Callback<()>,
679 toast: ToastMsg,
680 set_dialog: WriteSignal<Option<Dialog>>,
681 owner: Owner,
682) -> Vec<MenuItem> {
683 let me_now = me.get();
684 let loc_now = loc.get();
685 // The root that is actually shown. Resolves the single-root default
686 // location, where `loc.root_id` is `None`.
687 let root = me_now
688 .as_ref()
689 .and_then(|m| effective_root(&m.roots, &loc_now));
690 let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false);
691 let root_name = root.map(|r| r.name.clone()).unwrap_or_default();
692 let root_id = root.map(|r| r.id);
693 let allow_writable_shares = me_now
694 .as_ref()
695 .map(|m| m.allow_writable_shares)
696 .unwrap_or(false);
697 let own = || owner.clone();
698
699 match entry {
700 Some(e) => {
701 let mut v = vec![];
702 // Exactly what left-clicking the entry does: descend into a
703 // folder, open a text file in the editor (read-only in read-only
704 // folders), or preview media.
705 if let Some(rid) = root_id {
706 v.push(MenuItem::new(
707 if e.is_dir {
708 IconName::FolderOpen
709 } else {
710 IconName::Open
711 },
712 "Open",
713 open_entry(e, rid, loc, is_rw, open_file),
714 ));
715 }
716 if !e.is_dir {
717 // Serve the file itself in a new tab. HTML and SVG render as
718 // real pages there (sandboxed by the server), which is how
719 // you share a small site or an HTML report.
720 v.push(MenuItem::new(
721 IconName::OpenInNew,
722 "Open in new tab",
723 action_open_tab(e, root_id, loc, own()),
724 ));
725 }
726 v.push(MenuItem::new(
727 IconName::Download,
728 "Download",
729 action_download(e, root_id, loc, set_dialog, toast, own()),
730 ));
731 v.push(MenuItem::rw(
732 IconName::Rename,
733 "Rename",
734 is_rw,
735 action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
736 ));
737 v.push(MenuItem::rw(
738 IconName::Move,
739 "Move",
740 is_rw,
741 action_move_copy(
742 Op::Move,
743 e,
744 root_id,
745 me,
746 loc,
747 refresh,
748 toast,
749 set_dialog,
750 own(),
751 ),
752 ));
753 // Copying only writes at the destination, so a read-only source
754 // folder is fine.
755 v.push(MenuItem::new(
756 IconName::Copy,
757 "Copy",
758 action_move_copy(
759 Op::Copy,
760 e,
761 root_id,
762 me,
763 loc,
764 refresh,
765 toast,
766 set_dialog,
767 own(),
768 ),
769 ));
770 if loc_now.share_token.is_none() {
771 v.push(MenuItem::new(
772 IconName::Share,
773 "Share",
774 action_share(
775 e,
776 root_id,
777 loc,
778 allow_writable_shares,
779 is_rw,
780 set_dialog,
781 own(),
782 ),
783 ));
784 }
785 v.push(MenuItem::new(
786 IconName::Info,
787 "Info",
788 action_info(e, loc, root_name, set_dialog, own()),
789 ));
790 v.push(MenuItem::rw(
791 IconName::Delete,
792 "Delete",
793 is_rw,
794 action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
795 ));
796 v
797 }
798 None => vec![
799 MenuItem::rw(
800 IconName::NewFolder,
801 "New folder",
802 is_rw,
803 action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
804 ),
805 MenuItem::rw(
806 IconName::Upload,
807 "Upload files",
808 is_rw,
809 action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
810 ),
811 MenuItem::rw(
812 IconName::UploadFolder,
813 "Upload folder",
814 is_rw,
815 action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
816 ),
817 ],
818 }
819}
820
821#[component]
822fn CtxMenuView(
823 ctx: ReadSignal<Option<CtxMenu>>,
824 set_ctx: WriteSignal<Option<CtxMenu>>,
825 me: ReadSignal<Option<Me>>,
826 loc: ReadSignal<Location>,
827 refresh: Callback<()>,
828 toast: ToastMsg,
829 set_dialog: WriteSignal<Option<Dialog>>,
830 open_file: WriteSignal<Option<FileView>>,
831 owner: Owner,
832) -> impl IntoView {
833 // Close on any click or Escape.
834 {
835 crate::util::owned_window_listener(leptos::ev::click, move |_| {
836 set_ctx.set(None);
837 });
838 crate::util::owned_window_listener(
839 leptos::ev::keydown,
840 move |ev: web_sys::KeyboardEvent| {
841 if ev.key() == "Escape" {
842 set_ctx.set(None);
843 }
844 },
845 );
846 }
847
848 view! {
849 {move || {
850 let Some(m) = ctx.get() else {
851 return {
852 view! {}.into_any()
853 };
854 };
855 // Keep the menu inside the viewport.
856 let (max_x, max_y) = web_sys::window()
857 .and_then(|w| {
858 w.inner_width()
859 .ok()
860 .and_then(|v| v.as_f64())
861 .zip(w.inner_height().ok().and_then(|v| v.as_f64()))
862 })
863 .map(|(x, y)| (x as i32 - 220, y as i32 - 300))
864 .unwrap_or((m.x, m.y));
865 let x = m.x.min(max_x).max(0);
866 let y = m.y.min(max_y).max(0);
867
868 let items = menu_items(
869 m.entry.as_ref(),
870 me,
871 loc,
872 open_file,
873 refresh,
874 toast,
875 set_dialog,
876 owner.clone(),
877 );
878
879 view! {
880 <div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
881 {items.into_iter().map(|item| {
882 view! { <CtxItem item=item/> }
883 }).collect::<Vec<_>>()}
884 </div>
885 }
886 .into_view()
887 .into_any()
888 }}
889 }
890}
891
892/// One row of the context menu. `action` is `None` for a disabled row, and
893/// `hint` then says why it is disabled.
894#[derive(Clone)]
895struct MenuItem {
896 label: &'static str,
897 icon: IconName,
898 action: Option<Callback<()>>,
899 hint: Option<&'static str>,
900}
901
902impl MenuItem {
903 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
904 Self {
905 label,
906 icon,
907 action: Some(action),
908 hint: None,
909 }
910 }
911
912 /// An item that only works in a writable folder.
913 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
914 Self {
915 label,
916 icon,
917 action: is_rw.then_some(action),
918 hint: Some("Read-only folder"),
919 }
920 }
921}
922
923#[component]
924fn CtxItem(item: MenuItem) -> impl IntoView {
925 let MenuItem {
926 label,
927 icon,
928 action,
929 hint,
930 } = item;
931 let enabled = action.is_some();
932 let hint = hint.unwrap_or_default();
933 view! {
934 <div
935 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
936 title=move || if enabled { "".to_string() } else { hint.to_string() }
937 on:click=move |_| {
938 if let Some(a) = &action {
939 a.run(());
940 }
941 }
942 >
943 <Icon name=icon class="ic-ctx".to_string()/>
944 <span>{label}</span>
945 </div>
946 }
947}
948
949// ---------------------------------------------------------------------------
950// Toolbar selection actions (top right)
951// ---------------------------------------------------------------------------
952
953/// Icon-only buttons for the currently selected entry — the same set of
954/// actions as its right-click menu — or the empty-area actions (new folder,
955/// uploads) when nothing is selected. Rendered in the app's top bar (top
956/// right). Buttons that don't fit collapse into a three-dot overflow menu.
957#[component]
958pub fn SelectionActions(
959 me: ReadSignal<Option<Me>>,
960 loc: ReadSignal<Location>,
961 selected: ReadSignal<Option<Entry>>,
962 open_file: WriteSignal<Option<FileView>>,
963 refresh: Callback<()>,
964 toast: ToastMsg,
965 set_dialog: WriteSignal<Option<Dialog>>,
966 owner: Owner,
967 topbar_ref: NodeRef<leptos::html::Header>,
968) -> impl IntoView {
969 // How many buttons are shown before the overflow kicks in. Starts small
970 // so the first paint never overflows; the effect below widens it as soon
971 // as the real widths are measurable.
972 let (visible_n, set_visible_n) = signal(3usize);
973 // The three-dot overflow menu state. Any outside click or Escape closes
974 // it; a new selection does too. The toggle button stops propagation so
975 // it can actually open the menu.
976 let (more_open, set_more_open) = signal(false);
977 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
978 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
979 if ev.key() == "Escape" {
980 set_more_open.set(false);
981 }
982 });
983 Effect::new(move |_| {
984 let _ = selected.get();
985 set_more_open.set(false);
986 });
987 let group_ref = NodeRef::<leptos::html::Div>::new();
988
989 let recompute = move || {
990 let Some(tb) = topbar_ref
991 .get()
992 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
993 else {
994 return;
995 };
996 let Some(g) = group_ref
997 .get()
998 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
999 else {
1000 return;
1001 };
1002 // Every button is icon-only with identical padding, so one
1003 // rendered button gives the width of all of them.
1004 let Some(btn) = g.first_element_child() else {
1005 return;
1006 };
1007 // Available width: from the top bar's left content (brand) to its
1008 // right edge — independent of where the group currently sits,
1009 // so the measurement stays valid even while the group overflows.
1010 let tb_r = tb.get_bounding_client_rect();
1011 let start = tb
1012 .query_selector(".brand")
1013 .ok()
1014 .flatten()
1015 .map(|b| b.get_bounding_client_rect())
1016 .map(|r| r.right() + 16.0)
1017 .unwrap_or(0.0);
1018 let avail = (tb_r.right() - start - 10.0).max(40.0);
1019 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1020 // How many buttons fit (not counting the overflow button).
1021 set_visible_n.set(((avail / step) as usize).max(1));
1022 };
1023 Effect::new(move |_| {
1024 topbar_ref.get();
1025 group_ref.get();
1026 // Re-measure when the item set (selection, user) or width inputs
1027 // change.
1028 selected.get();
1029 me.get();
1030 recompute();
1031 });
1032 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1033
1034 view! {
1035 <div class="sel-actions" node_ref=group_ref>
1036 // The visible buttons and the overflow rows are one item list
1037 // split at the measured count, so the actions are computed once.
1038 {move || {
1039 let items = menu_items(
1040 selected.get().as_ref(),
1041 me,
1042 loc,
1043 open_file,
1044 refresh,
1045 toast,
1046 set_dialog,
1047 owner.clone(),
1048 );
1049 let fit = visible_n.get();
1050 let n = if fit >= items.len() {
1051 items.len()
1052 } else {
1053 // The overflow button takes a slot too, so drop one —
1054 // otherwise the group eats into the space after the brand.
1055 (fit - 1).max(1)
1056 };
1057 let (visible, more) = items.split_at(n);
1058 let visible_view = visible
1059 .iter()
1060 .cloned()
1061 .map(|i| view! {
1062 <SelButton item=i/>
1063 })
1064 .collect_view();
1065 // Rendered as nothing while every action fits.
1066 let more_block = (!more.is_empty()).then(|| {
1067 let more_view = more
1068 .iter()
1069 .cloned()
1070 .map(|i| view! {
1071 <CtxItem item=i/>
1072 })
1073 .collect_view();
1074 view! {
1075 <div class="sel-more-wrap">
1076 <button
1077 class="icon-btn"
1078 title="More actions"
1079 aria-label="More actions"
1080 on:click=move |ev: MouseEvent| {
1081 ev.stop_propagation();
1082 set_more_open.set(!more_open.get());
1083 }
1084 >
1085 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1086 </button>
1087 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1088 {more_view}
1089 </div>
1090 </div>
1091 }
1092 });
1093 view! {
1094 {visible_view}
1095 {more_block}
1096 }
1097 }}
1098 </div>
1099 }
1100}
1101
1102/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1103/// then explains why.
1104#[component]
1105fn SelButton(item: MenuItem) -> impl IntoView {
1106 let enabled = item.action.is_some();
1107 let title = if enabled {
1108 item.label.to_string()
1109 } else {
1110 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1111 };
1112 view! {
1113 <button
1114 class="icon-btn"
1115 disabled=!enabled
1116 title=title
1117 on:click=move |_| {
1118 if let Some(a) = &item.action {
1119 a.run(());
1120 }
1121 }
1122 >
1123 <Icon name=item.icon class="ic-btn".to_string()/>
1124 </button>
1125 }
1126}
1127
1128// ---------------------------------------------------------------------------
1129// Menu actions
1130// ---------------------------------------------------------------------------
1131
1132fn join_path(dir: &[String], name: &str) -> String {
1133 if dir.is_empty() {
1134 name.to_string()
1135 } else {
1136 format!("{}/{}", dir.join("/"), name)
1137 }
1138}
1139
1140fn is_conflict(e: &api::ApiError) -> bool {
1141 matches!(e, api::ApiError::Http { status: 409, .. })
1142}
1143
1144/// A file operation in flight. Boxed because each caller builds a different
1145/// future, and they all have to fit one type.
1146type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1147
1148/// Run a file operation that may collide with an existing name.
1149///
1150/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1151/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1152/// upload all need exactly this, so it lives here once.
1153///
1154/// `names` is what to list in the dialog when the server does not say. Upload
1155/// reports the exact set it skipped; the single-item operations know theirs
1156/// before they ask.
1157fn run_with_overwrite_retry(
1158 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1159 conflict_title: &'static str,
1160 names: Vec<String>,
1161 ok_msg: &'static str,
1162 refresh: Callback<()>,
1163 toast: ToastMsg,
1164 set_dialog: WriteSignal<Option<Dialog>>,
1165) {
1166 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1167 show(toast, ok_msg);
1168 refresh.run(());
1169 }
1170
1171 let retry = op.clone();
1172 spawn_local(async move {
1173 let Err(e) = op(false).await else {
1174 done(ok_msg, refresh, toast);
1175 return;
1176 };
1177 let conflicting = e
1178 .skipped()
1179 .map(<[String]>::to_vec)
1180 .or_else(|| is_conflict(&e).then_some(names));
1181 let Some(files) = conflicting else {
1182 show(toast, e.to_string());
1183 return;
1184 };
1185 set_dialog.set(Some(Dialog::Conflict {
1186 title: conflict_title.to_string(),
1187 files,
1188 on_submit: Callback::new(move |_| {
1189 let retry = retry.clone();
1190 spawn_local(async move {
1191 match retry(true).await {
1192 Ok(()) => done(ok_msg, refresh, toast),
1193 Err(e) => show(toast, e.to_string()),
1194 }
1195 });
1196 }),
1197 }));
1198 });
1199}
1200
1201fn action_new_folder(
1202 root_id: Option<i64>,
1203 loc: ReadSignal<Location>,
1204 refresh: Callback<()>,
1205 toast: ToastMsg,
1206 set_dialog: WriteSignal<Option<Dialog>>,
1207 owner: Owner,
1208) -> Callback<()> {
1209 Callback::new(move |_| {
1210 let Some(root_id) = root_id else { return };
1211 let loc = loc.get();
1212 let dir = loc.path.clone();
1213 set_dialog.set(Some(Dialog::Prompt {
1214 title: "New folder".into(),
1215 label: "Folder name".into(),
1216 initial: String::new(),
1217 submit: "Create".into(),
1218 on_submit: owner.with(|| {
1219 Callback::new(move |name: String| {
1220 let name = name.trim().to_string();
1221 if name.is_empty() {
1222 return;
1223 }
1224 let full = join_path(&dir, &name);
1225 set_dialog.set(None);
1226 spawn_local(async move {
1227 match api::mkdir(root_id, &full).await {
1228 Ok(_) => {
1229 show(toast, "Folder created");
1230 refresh.run(());
1231 }
1232 Err(e) => show(toast, e.to_string()),
1233 }
1234 });
1235 })
1236 }),
1237 }));
1238 })
1239}
1240
1241fn action_rename(
1242 entry: &Entry,
1243 root_id: Option<i64>,
1244 loc: ReadSignal<Location>,
1245 refresh: Callback<()>,
1246 toast: ToastMsg,
1247 set_dialog: WriteSignal<Option<Dialog>>,
1248 owner: Owner,
1249) -> Callback<()> {
1250 let name = entry.name.clone();
1251 Callback::new(move |_| {
1252 let Some(root_id) = root_id else { return };
1253 let loc = loc.get();
1254 let dir = loc.path.clone();
1255 let full = join_path(&dir, &name);
1256 set_dialog.set(Some(Dialog::Prompt {
1257 title: format!("Rename \u{201c}{name}\u{201d}"),
1258 label: "New name".into(),
1259 initial: name.clone(),
1260 submit: "Rename".into(),
1261 on_submit: owner.with(|| {
1262 Callback::new(move |new_name: String| {
1263 let new_name = new_name.trim().to_string();
1264 if new_name.is_empty() {
1265 return;
1266 }
1267 set_dialog.set(None);
1268 let full = full.clone();
1269 let target = new_name.clone();
1270 run_with_overwrite_retry(
1271 move |overwrite| {
1272 let (full, new_name) = (full.clone(), new_name.clone());
1273 Box::pin(async move {
1274 api::rename_item(root_id, &full, new_name, overwrite)
1275 .await
1276 .map(|_| ())
1277 })
1278 },
1279 "Name already in use",
1280 vec![target],
1281 "Renamed",
1282 refresh,
1283 toast,
1284 set_dialog,
1285 );
1286 })
1287 }),
1288 }));
1289 })
1290}
1291
1292fn action_delete(
1293 entry: &Entry,
1294 root_id: Option<i64>,
1295 loc: ReadSignal<Location>,
1296 refresh: Callback<()>,
1297 toast: ToastMsg,
1298 set_dialog: WriteSignal<Option<Dialog>>,
1299 owner: Owner,
1300) -> Callback<()> {
1301 let name = entry.name.clone();
1302 let is_dir = entry.is_dir;
1303 Callback::new(move |_| {
1304 let Some(root_id) = root_id else { return };
1305 let loc = loc.get();
1306 let dir = loc.path.clone();
1307 let full = join_path(&dir, &name);
1308 let message = if is_dir {
1309 format!(
1310 "Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
1311 )
1312 } else {
1313 format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
1314 };
1315 set_dialog.set(Some(Dialog::Confirm {
1316 title: "Delete".into(),
1317 message,
1318 submit: "Delete".into(),
1319 danger: true,
1320 on_submit: owner.with(|| {
1321 Callback::new(move |_| {
1322 set_dialog.set(None);
1323 let f = full.clone();
1324 spawn_local(async move {
1325 match api::delete_item(root_id, &f).await {
1326 Ok(_) => {
1327 show(toast, "Deleted");
1328 refresh.run(());
1329 }
1330 Err(e) => show(toast, e.to_string()),
1331 }
1332 });
1333 })
1334 }),
1335 }));
1336 })
1337}
1338
1339fn action_upload(
1340 multiple: bool,
1341 directory: bool,
1342 root_id: Option<i64>,
1343 loc: ReadSignal<Location>,
1344 refresh: Callback<()>,
1345 toast: ToastMsg,
1346 set_dialog: WriteSignal<Option<Dialog>>,
1347) -> Callback<()> {
1348 Callback::new(move |_| {
1349 let Some(root_id) = root_id else { return };
1350 let loc = loc.get();
1351 let dir = loc.path.join("/");
1352 let dir1 = dir.clone();
1353 api::pick_files(multiple, directory, move |files| {
1354 let n = files.len();
1355 let dir = dir1.clone();
1356 show(
1357 toast,
1358 format!(
1359 "Uploading {n} file{}\u{2026}",
1360 if n == 1 { "" } else { "s" }
1361 ),
1362 );
1363 run_with_overwrite_retry(
1364 move |overwrite| {
1365 let (dir, files) = (dir.clone(), files.clone());
1366 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
1367 },
1368 "Some files already exist",
1369 // Unused: an upload conflict always names the skipped files.
1370 Vec::new(),
1371 "Upload complete",
1372 refresh,
1373 toast,
1374 set_dialog,
1375 );
1376 });
1377 })
1378}
1379
1380#[allow(clippy::too_many_arguments)] // explicit signal props
1381fn action_move_copy(
1382 op: Op,
1383 entry: &Entry,
1384 root_id: Option<i64>,
1385 me: ReadSignal<Option<Me>>,
1386 loc: ReadSignal<Location>,
1387 refresh: Callback<()>,
1388 toast: ToastMsg,
1389 set_dialog: WriteSignal<Option<Dialog>>,
1390 owner: Owner,
1391) -> Callback<()> {
1392 let name = entry.name.clone();
1393 let entry2 = entry.clone();
1394 Callback::new(move |_| {
1395 let loc = loc.get();
1396 let me = me.get();
1397 let Some(root_id) = root_id else { return };
1398 let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
1399 let dir = loc.path.clone();
1400 let full = join_path(&dir, &name);
1401 let dir_str = loc.path.join("/");
1402 let name3 = name.clone();
1403 let entry3 = entry2.clone();
1404 let full3 = full.clone();
1405 set_dialog.set(Some(Dialog::Picker {
1406 op,
1407 entry: entry3,
1408 roots,
1409 source_root: root_id,
1410 source_dir: dir_str,
1411 on_pick: owner.with(|| {
1412 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1413 set_dialog.set(None);
1414 let full = full3.clone();
1415 run_with_overwrite_retry(
1416 move |overwrite| {
1417 let (full, dst_dir) = (full.clone(), dst_dir.clone());
1418 Box::pin(async move {
1419 let res = if op == Op::Move {
1420 api::move_item(root_id, &full, dst_root, &dst_dir, overwrite)
1421 .await
1422 } else {
1423 api::copy_item(root_id, &full, dst_root, &dst_dir, overwrite)
1424 .await
1425 };
1426 res.map(|_| ())
1427 })
1428 },
1429 "Name already in use",
1430 vec![name3.clone()],
1431 if op == Op::Move { "Moved" } else { "Copied" },
1432 refresh,
1433 toast,
1434 set_dialog,
1435 );
1436 })
1437 }),
1438 }));
1439 })
1440}
1441
1442fn action_download(
1443 entry: &Entry,
1444 root_id: Option<i64>,
1445 loc: ReadSignal<Location>,
1446 set_dialog: WriteSignal<Option<Dialog>>,
1447 toast: ToastMsg,
1448 owner: Owner,
1449) -> Callback<()> {
1450 let name = entry.name.clone();
1451 let is_dir = entry.is_dir;
1452 owner.with(|| {
1453 Callback::new(move |_| {
1454 let Some(root_id) = root_id else { return };
1455 let loc = loc.get();
1456 let full = join_path(&loc.path, &name);
1457 if is_dir {
1458 set_dialog.set(Some(Dialog::DownloadFormat {
1459 name: name.clone(),
1460 root_id,
1461 path: full,
1462 }));
1463 } else {
1464 let url = api::download_url(root_id, &full, None);
1465 api::trigger_download(&url, &name);
1466 show(toast, "Download started");
1467 }
1468 })
1469 })
1470}
1471
1472/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1473/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1474fn action_open_tab(
1475 entry: &Entry,
1476 root_id: Option<i64>,
1477 loc: ReadSignal<Location>,
1478 owner: Owner,
1479) -> Callback<()> {
1480 let name = entry.name.clone();
1481 owner.with(|| {
1482 Callback::new(move |_| {
1483 let Some(root_id) = root_id else { return };
1484 let full = join_path(&loc.get().path, &name);
1485 api::open_in_new_tab(&api::preview_url(root_id, &full));
1486 })
1487 })
1488}
1489
1490/// Open the share dialog for an item (milestone 6).
1491fn action_share(
1492 entry: &Entry,
1493 root_id: Option<i64>,
1494 loc: ReadSignal<Location>,
1495 allow_writable: bool,
1496 root_writable: bool,
1497 set_dialog: WriteSignal<Option<Dialog>>,
1498 owner: Owner,
1499) -> Callback<()> {
1500 let name = entry.name.clone();
1501 owner.with(|| {
1502 Callback::new(move |_| {
1503 let Some(root_id) = root_id else { return };
1504 let loc = loc.get();
1505 let full = join_path(&loc.path, &name);
1506 set_dialog.set(Some(Dialog::Share {
1507 name: name.clone(),
1508 root_id,
1509 path: full,
1510 allow_writable,
1511 root_writable,
1512 }));
1513 })
1514 })
1515}
1516
1517fn action_info(
1518 entry: &Entry,
1519 loc: ReadSignal<Location>,
1520 root_name: String,
1521 set_dialog: WriteSignal<Option<Dialog>>,
1522 owner: Owner,
1523) -> Callback<()> {
1524 let entry2 = entry.clone();
1525 owner.with(|| {
1526 Callback::new(move |_| {
1527 let loc = loc.get();
1528 let full = join_path(&loc.path, &entry2.name);
1529 set_dialog.set(Some(Dialog::Info {
1530 entry: entry2.clone(),
1531 root_name: root_name.clone(),
1532 rel_path: full,
1533 }));
1534 })
1535 })
1536}
1537