browser.rs
⎇
Raw
1//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
2
3use std::collections::HashSet;
4use std::sync::{Arc, Mutex};
5
6use api_types::FileKind;
7use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9use wasm_bindgen_futures::spawn_local;
10use web_sys::MouseEvent;
11
12use crate::api::{self, Entry, Me, RootInfo};
13use crate::components::icon::{Icon, icon_svg};
14use crate::components::toast::{ToastMsg, show, show_error};
15use crate::editor::EditTarget;
16use crate::i18n;
17use crate::icons::{IconName, icon_for};
18use crate::preview::{PreviewTarget, preview_kind};
19use crate::router::{Location, Section, navigate};
20use crate::util::{FILE_PAGE, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer};
21use crate::views::dialogs::{Dialog, Op};
22use crate::views::file_view::{FileView, UnsupportedTarget};
23
24/// One folder listing. Behind an `Arc` in [`ListState`] so reading the
25/// signal never copies the entries (a folder can hold thousands).
26#[derive(Debug, PartialEq)]
27struct Listing {
28 entries: Vec<Entry>,
29 /// The server had more entries than it will return; the listing holds
30 /// only the first `api_types::MAX_LIST_ENTRIES`.
31 truncated: bool,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35enum ListState {
36 Loading,
37 Error(String),
38 Entries(Arc<Listing>),
39}
40
41/// The root that is actually shown: the one in the location. The one-root
42/// shortcut is shares-only: a public share's location (`#/share/{token}`)
43/// cannot carry its synthetic root's id, which only exists once the share is
44/// resolved. Signed-in users always get an explicit `#/files/{root_id}` URL
45/// (or the no-root empty state on `#/`) — that keeps every file open from a
46/// browser listing on an explicit URL, so navigating away always changes the
47/// hash.
48pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
49 match loc.root_id {
50 Some(id) => roots.iter().find(|r| r.id == id),
51 None if loc.share_token.is_some() && roots.len() == 1 => Some(&roots[0]),
52 None => None,
53 }
54}
55
56/// Where the context menu opens (client x, y). What it offers follows the
57/// selection: the right-click handlers set that first.
58type CtxMenu = (i32, i32);
59
60#[component]
61pub fn Browser(
62 me: ReadSignal<Option<Me>>,
63 set_me: WriteSignal<Option<Me>>,
64 loc: ReadSignal<Location>,
65 /// Opens a file in the full-page view (owned by the shell / share page).
66 open_file: WriteSignal<Option<FileView>>,
67 /// The browser exposes its fetch there so the full-page editor can
68 /// re-list the folder after a save.
69 set_browser_refresh: WriteSignal<Option<Callback<()>>>,
70 /// The selected entries, owned by the shell so their action buttons can
71 /// live in the top bar. Empty shows the empty-area actions there. The
72 /// first entry is the anchor for Shift+click ranges.
73 selected: ReadSignal<Vec<Entry>>,
74 set_selected: WriteSignal<Vec<Entry>>,
75 /// Dialogs are rendered at the shell level.
76 set_dialog: WriteSignal<Option<Dialog>>,
77 /// The open dialog, if any. Escape must not clear the selection while
78 /// one is open (it closes the dialog instead) — checked in the capture
79 /// phase, before the dialog's own bubble-phase listener closes it.
80 dialog: ReadSignal<Option<Dialog>>,
81 /// A file opened in the full-page view. Escape must not clear the
82 /// selection while this is set — that keypress closes the view (handled
83 /// by the shell), and the selection should survive.
84 file_view: ReadSignal<Option<FileView>>,
85) -> impl IntoView {
86 let toast = use_context::<ToastMsg>().expect("toast context");
87 let (view_mode, set_view_mode) = signal(ViewMode::load());
88 let (sort, set_sort) = signal(SortSpec::load());
89 let (list_state, set_list_state) = signal(ListState::Loading);
90 let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
91 // Long-lived owner for callbacks created inside short-lived scopes
92 // (context-menu event handlers); otherwise they get disposed with the
93 // menu and the open dialog panics on submit.
94 let ctx_owner = Owner::current().unwrap();
95 // A 403/404 on the listing usually means an admin removed or changed
96 // this root, so re-fetch /me once per (root, path) to update the
97 // sidebar and this view. The key guards against a refresh loop when
98 // /me comes back unchanged.
99 let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
100 // How many entries of the current listing are rendered. Reset by every
101 // fetch, so a new folder starts at one page again.
102 let shown = RwSignal::new(FILE_PAGE);
103 // Bumped per fetch. A response whose generation is no longer current
104 // belongs to a folder the user has already navigated away from.
105 let fetch_gen = StoredValue::new(0u64);
106 // The listing in display order. Everything downstream indexes into
107 // this, not into the fetched order: Shift+click ranges and the arrow
108 // keys walk what the user sees. A Memo, so a re-sort costs one pass per
109 // change, not one per render.
110 let sorted = Memo::new(move |_| match list_state.get() {
111 ListState::Entries(list) => {
112 let mut entries = list.entries.clone();
113 sort.get().apply(&mut entries);
114 ListState::Entries(Arc::new(Listing {
115 entries,
116 truncated: list.truncated,
117 }))
118 }
119 other => other,
120 });
121
122 // The selected names as a set: every row asks "am I selected?" on each
123 // selection change, which is a scan of the selection per row otherwise.
124 let sel_names = Memo::new(move |_| {
125 selected.with(|v| {
126 v.iter()
127 .map(|e| e.name.clone())
128 .collect::<HashSet<String>>()
129 })
130 });
131
132 // Drop selected entries that disappeared from the listing (deleted,
133 // renamed, …).
134 Effect::new(move |_| {
135 sorted.with(|st| {
136 let ListState::Entries(list) = st else {
137 return;
138 };
139 let names: HashSet<&str> = list.entries.iter().map(|e| e.name.as_str()).collect();
140 let cur = selected.get_untracked();
141 let kept: Vec<Entry> = cur
142 .iter()
143 .filter(|s| names.contains(s.name.as_str()))
144 .cloned()
145 .collect();
146 if kept.len() != cur.len() {
147 set_selected.set(kept);
148 }
149 });
150 });
151 // Escape clears the selection — but only when nothing that also reacts
152 // to Escape is open: the context menu (it has its own Escape handler),
153 // a modal dialog (the <dialog> closes itself; the selection must
154 // survive, same as for the file view), or a full-page file view (the
155 // shell closes it on the same keypress). Registered in the capture
156 // phase so it sees the dialog / file view *before* they close; and it
157 // blurs the focused row, because the keypress would otherwise leave the
158 // row's :focus-visible outline behind.
159 crate::util::owned_window_listener_capture(
160 leptos::ev::keydown,
161 move |ev: web_sys::KeyboardEvent| {
162 if ev.key() != "Escape"
163 || ctx.get().is_some()
164 || dialog.get().is_some()
165 || file_view.get().is_some()
166 {
167 return;
168 }
169 if let Some(active) = web_sys::window()
170 .and_then(|w| w.document())
171 .and_then(|d| d.active_element())
172 .and_then(|a| a.dyn_into::<web_sys::HtmlElement>().ok())
173 {
174 let _ = active.blur();
175 }
176 set_selected.set(Vec::new());
177 },
178 );
179
180 // Left / Right in an open media preview steps to the previous or next
181 // previewable file, in the listing's current order.
182 //
183 // Only previewable files: the editor needs the arrow keys for its
184 // caret, and a folder has nothing to preview.
185 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
186 let step: isize = match ev.key().as_str() {
187 "ArrowLeft" => -1,
188 "ArrowRight" => 1,
189 _ => return,
190 };
191 // A focused player owns its arrow keys (seek / volume), and a
192 // modifier means a browser shortcut (Alt+Left is history back).
193 let on_player = ev
194 .target()
195 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
196 .is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
197 if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
198 return;
199 }
200 let Some(FileView::Preview(cur, _)) = file_view.get() else {
201 return;
202 };
203 let ListState::Entries(list) = sorted.get() else {
204 return;
205 };
206 let shown: Vec<&Entry> = list
207 .entries
208 .iter()
209 .filter(|e| !e.is_dir && preview_kind(e.kind).is_some())
210 .collect();
211 let Some(at) = shown.iter().position(|e| e.name == cur.name) else {
212 return;
213 };
214 let next = at as isize + step;
215 if next < 0 || next as usize >= shown.len() {
216 return;
217 }
218 let e = shown[next as usize];
219 let Some(kind) = preview_kind(e.kind) else {
220 return;
221 };
222 ev.prevent_default();
223 // The selection follows the shown file, so closing the preview puts
224 // focus and the action bar on the file that was actually open.
225 set_selected.set(vec![e.clone()]);
226 open_file.set(Some(FileView::Preview(
227 PreviewTarget {
228 root_id: cur.root_id,
229 path: join_path(&loc.get().path, &e.name),
230 name: e.name.clone(),
231 },
232 kind,
233 )));
234 });
235
236 // Fetch the current directory whenever the location (or user) changes.
237 let fetch = Callback::new(move |_| {
238 let Some(me) = me.get() else {
239 return;
240 };
241 let loc = loc.get();
242 let Some(root) = effective_root(&me.roots, &loc) else {
243 return;
244 };
245 set_list_state.set(ListState::Loading);
246 shown.set(FILE_PAGE);
247 fetch_gen.update_value(|g| *g += 1);
248 let my_gen = fetch_gen.get_value();
249 let root_id = root.id;
250 let path_str = loc.path.join("/");
251 // On a public share page the (synthetic) "me" comes from the share
252 // itself, not from a session — refreshing /me would be wrong there.
253 let is_share = loc.share_token.is_some();
254 let set_me2 = set_me;
255 let refresh_key = me_refresh_key;
256 let set_refresh_key = set_me_refresh_key;
257 spawn_local(async move {
258 let resp = api::list_files(root_id, &path_str).await;
259 // A newer fetch (or the view going away) makes this response
260 // stale: applying it would show the wrong folder.
261 if fetch_gen.try_get_value() != Some(my_gen) {
262 return;
263 }
264 match resp {
265 Ok(r) => set_list_state.set(ListState::Entries(Arc::new(Listing {
266 entries: r.entries,
267 truncated: r.truncated,
268 }))),
269 Err(e) => {
270 if !is_share
271 && matches!(e.status(), Some(403) | Some(404))
272 && refresh_key.get() != Some((root_id, path_str.clone()))
273 {
274 set_refresh_key.set(Some((root_id, path_str.clone())));
275 spawn_local(async move {
276 if let Ok(m) = api::me().await {
277 set_me2.set(Some(m));
278 }
279 });
280 }
281 set_list_state.set(ListState::Error(e.to_string()));
282 }
283 }
284 });
285 });
286 Effect::new(move |_| fetch.run(()));
287 {
288 let w = set_browser_refresh;
289 let f = fetch;
290 Effect::new(move |_| {
291 w.set(Some(f));
292 });
293 }
294
295 // Delete asks to delete the selection — the same confirm dialog as the
296 // context menu's Delete. Not while something else owns the keyboard: a
297 // dialog, the full-page file view, the context menu, or a text field.
298 {
299 let owner = ctx_owner.clone();
300 crate::util::owned_window_listener(
301 leptos::ev::keydown,
302 move |ev: web_sys::KeyboardEvent| {
303 if ev.key() != "Delete"
304 || ev.ctrl_key()
305 || ev.meta_key()
306 || ev.alt_key()
307 || dialog.get().is_some()
308 || file_view.get().is_some()
309 || ctx.get().is_some()
310 {
311 return;
312 }
313 let in_field = ev
314 .target()
315 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
316 .is_some_and(|el| matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA"));
317 if in_field {
318 return;
319 }
320 let loc_now = loc.get();
321 let (is_rw, root_id) = me.with(|m| {
322 let root = m.as_ref().and_then(|m| effective_root(&m.roots, &loc_now));
323 (
324 root.is_some_and(|r| r.mode.is_writable()),
325 root.map(|r| r.id),
326 )
327 });
328 if !is_rw {
329 return;
330 }
331 let action = selected.with(|sel| {
332 (!sel.is_empty()).then(|| {
333 action_delete(sel, root_id, loc, fetch, toast, set_dialog, owner.clone())
334 })
335 });
336 if let Some(a) = action {
337 ev.prevent_default();
338 a.run(());
339 }
340 },
341 );
342 }
343
344 // Arrow keys while no row has focus (after load, after a click on empty
345 // space, after a preview closed): continue from the first selected entry,
346 // or start at the first entry. Only when focus is on the page body, so
347 // arrows inside a focused button, field or player stay theirs.
348 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
349 if !matches!(
350 ev.key().as_str(),
351 "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
352 ) || dialog.get().is_some()
353 || file_view.get().is_some()
354 || ctx.get().is_some()
355 {
356 return;
357 }
358 let on_body = web_sys::window()
359 .and_then(|w| w.document())
360 .and_then(|d| d.active_element())
361 .is_none_or(|a| a.tag_name() == "BODY");
362 if !on_body {
363 return;
364 }
365 let ListState::Entries(all) = sorted.get_untracked() else {
366 return;
367 };
368 let Some(container) = listing_container() else {
369 return;
370 };
371 let anchor = selected.with_untracked(|sel| anchor_index(&all, sel));
372 match anchor {
373 Some(idx) => {
374 let grid = view_mode.get_untracked() == ViewMode::Grid;
375 arrow_key(
376 &ev,
377 &container,
378 &all,
379 idx,
380 grid,
381 shown,
382 selected,
383 set_selected,
384 );
385 }
386 None => {
387 let Some(first) = all.entries.first() else {
388 return;
389 };
390 ev.prevent_default();
391 set_selected.set(vec![first.clone()]);
392 focus_item(&container, 0);
393 }
394 }
395 });
396
397 // Closing the full-page view unmounted the focused row with the
398 // listing. Put focus back on the selected entry, once it is rendered
399 // again, so the arrow keys continue from there.
400 Effect::new(move |was_open: Option<bool>| {
401 let open = file_view.get().is_some();
402 if was_open == Some(true) && !open {
403 let ListState::Entries(all) = sorted.get_untracked() else {
404 return open;
405 };
406 if let Some(idx) = selected.with_untracked(|sel| anchor_index(&all, sel)) {
407 request_animation_frame(move || {
408 if let Some(c) = listing_container() {
409 focus_item(&c, idx);
410 }
411 });
412 }
413 }
414 open
415 });
416
417 view! {
418 <div class="browser">
419 {move || {
420 let Some(me_now) = me.get() else {
421 return {
422 view! {}.into_any()
423 };
424 };
425 let loc_now = loc.get();
426 match effective_root(&me_now.roots, &loc_now) {
427 Some(root) => file_browser(
428 me,
429 root,
430 loc,
431 sorted,
432 shown,
433 view_mode,
434 set_view_mode,
435 sort,
436 set_sort,
437 set_ctx,
438 selected,
439 sel_names,
440 set_selected,
441 fetch,
442 open_file,
443 )
444 .into_any(),
445 None => no_folder_view(&me_now.roots, &loc_now).into_any(),
446 }
447 }}
448 <CtxMenuView
449 ctx=ctx
450 set_ctx=set_ctx
451 me=me
452 loc=loc
453 selected=selected
454 refresh=fetch
455 toast=toast
456 set_dialog=set_dialog
457 open_file=open_file
458 owner=ctx_owner
459 />
460 </div>
461 }
462}
463
464// ---------------------------------------------------------------------------
465// Empty states (no resolvable root)
466// ---------------------------------------------------------------------------
467
468/// Shown when the browser can't resolve a root: the user has no roots at all,
469/// hasn't picked one yet (they are sidebar tabs now), or the URL points at a
470/// root that no longer exists.
471fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
472 let (title, message) = if roots.is_empty() {
473 (
474 i18n::t(i18n::k::NO_FOLDERS_TITLE),
475 i18n::t(i18n::k::NO_FOLDERS_MSG),
476 )
477 } else if loc.root_id.is_none() {
478 (
479 i18n::t(i18n::k::SELECT_FOLDER_TITLE),
480 i18n::t(i18n::k::SELECT_FOLDER_MSG),
481 )
482 } else {
483 (
484 i18n::t(i18n::k::FOLDER_GONE_TITLE),
485 i18n::t(i18n::k::FOLDER_GONE_MSG),
486 )
487 };
488 view! {
489 <div class="empty-state">
490 <Icon name=IconName::FolderOff class="empty-glyph".to_string()/>
491 <h3>{title}</h3>
492 <p class="muted">{message}</p>
493 </div>
494 }
495}
496
497// ---------------------------------------------------------------------------
498// File browser (breadcrumbs + toolbar + entries)
499// ---------------------------------------------------------------------------
500
501#[allow(clippy::too_many_arguments)] // explicit signal props
502fn file_browser(
503 me: ReadSignal<Option<Me>>,
504 root: &RootInfo,
505 loc: ReadSignal<Location>,
506 list_state: Memo<ListState>,
507 shown: RwSignal<usize>,
508 view_mode: ReadSignal<ViewMode>,
509 set_view_mode: WriteSignal<ViewMode>,
510 sort: ReadSignal<SortSpec>,
511 set_sort: WriteSignal<SortSpec>,
512 set_ctx: WriteSignal<Option<CtxMenu>>,
513 selected: ReadSignal<Vec<Entry>>,
514 sel_names: Memo<HashSet<String>>,
515 set_selected: WriteSignal<Vec<Entry>>,
516 fetch: Callback<()>,
517 open_file: WriteSignal<Option<FileView>>,
518) -> impl IntoView {
519 let root_id = root.id;
520 let root_name = root.name.clone();
521 let is_rw = root.mode.is_writable();
522 let loc_now = loc.get();
523 // The user's profile setting: single click opens entries; when off,
524 // a single click selects and a double click opens.
525 let single_click = me
526 .get()
527 .and_then(|m| m.user)
528 .is_some_and(|u| u.single_click_open);
529 let thumbs = me
530 .get()
531 .is_some_and(|m| m.thumbnails_available && m.user.is_some_and(|u| u.thumbnails));
532
533 // Breadcrumbs
534 let crumbs = view! {
535 <div class="crumbs">
536 <span
537 class="crumb"
538 on:click=move |_| {
539 navigate(&Location {
540 root_id: Some(root_id),
541 path: vec![],
542 share_token: loc.get().share_token.clone(),
543 section: Section::Files,
544 });
545 }
546 >
547 {root_name.clone()}
548 </span>
549 {loc_now.path.iter().enumerate().map(|(i, seg)| {
550 let seg = seg.clone();
551 let target: Vec<String> = loc_now.path[..=i].to_vec();
552 let class = if i == loc_now.path.len() - 1 {
553 "crumb current"
554 } else {
555 "crumb"
556 };
557 view! {
558 <span class="crumb-sep">"/"</span>
559 <span
560 class=class
561 on:click=move |_| {
562 navigate(&Location {
563 root_id: Some(root_id),
564 path: target.clone(),
565 share_token: loc.get().share_token.clone(),
566 section: Section::Files,
567 });
568 }
569 >
570 {seg}
571 </span>
572 }
573 }).collect::<Vec<_>>()}
574 </div>
575 };
576
577 // Toolbar: refresh on the left, view toggles on the right. The
578 // selection's action buttons live in the app's top bar.
579 let toolbar = view! {
580 <div class="toolbar">
581 <button
582 class="icon-btn"
583 title=i18n::tr(i18n::k::REFRESH)
584 aria-label=i18n::tr(i18n::k::REFRESH)
585 on:click=move |_| fetch.run(())
586 >
587 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
588 </button>
589 <div class="toolbar-right">
590 <label class="sort-label" for="sort-key">{i18n::tr(i18n::k::SORT_BY)}</label>
591 <select
592 id="sort-key"
593 class="sort-select"
594 on:change=move |ev| {
595 let Some(v) = crate::util::select_value(&ev) else { return };
596 let Some(key) = SortKey::parse(&v) else { return };
597 let next = SortSpec { key, asc: sort.get().asc };
598 set_sort.set(next);
599 next.save();
600 }
601 >
602 {[
603 (SortKey::Name, i18n::k::NAME),
604 (SortKey::Size, i18n::k::SIZE),
605 (SortKey::Modified, i18n::k::MODIFIED),
606 ]
607 .map(|(key, label)| {
608 view! {
609 <option
610 value=key.as_str()
611 selected=move || sort.get().key == key
612 >
613 {i18n::tr(label)}
614 </option>
615 }
616 })
617 .to_vec()}
618 </select>
619 <button
620 class="icon-btn"
621 title=move || {
622 // The title names the current order, not the one the
623 // click would switch to: the arrow already shows it.
624 if sort.get().asc {
625 i18n::t(i18n::k::SORT_ASC)
626 } else {
627 i18n::t(i18n::k::SORT_DESC)
628 }
629 }
630 aria-label=move || {
631 if sort.get().asc {
632 i18n::t(i18n::k::SORT_ASC)
633 } else {
634 i18n::t(i18n::k::SORT_DESC)
635 }
636 }
637 on:click=move |_| {
638 let cur = sort.get();
639 let next = SortSpec { key: cur.key, asc: !cur.asc };
640 set_sort.set(next);
641 next.save();
642 }
643 >
644 {move || {
645 let name = if sort.get().asc {
646 IconName::SortAsc
647 } else {
648 IconName::SortDesc
649 };
650 icon_svg(name, "ic-btn")
651 }}
652 </button>
653 <button
654 class=move || {
655 if view_mode.get() == ViewMode::Grid {
656 "icon-btn active".to_string()
657 } else {
658 "icon-btn".to_string()
659 }
660 }
661 title=i18n::tr(i18n::k::GRID_VIEW)
662 on:click=move |_| {
663 set_view_mode.set(ViewMode::Grid);
664 ViewMode::Grid.save();
665 }
666 >
667 <Icon name=IconName::Grid class="ic-btn".to_string()/>
668 </button>
669 <button
670 class=move || {
671 if view_mode.get() == ViewMode::List {
672 "icon-btn active".to_string()
673 } else {
674 "icon-btn".to_string()
675 }
676 }
677 title=i18n::tr(i18n::k::LIST_VIEW)
678 on:click=move |_| {
679 set_view_mode.set(ViewMode::List);
680 ViewMode::List.save();
681 }
682 >
683 <Icon name=IconName::List class="ic-btn".to_string()/>
684 </button>
685 </div>
686 </div>
687 };
688
689 let entries_area = view! {
690 <div
691 class="entries-area"
692 // Clicking empty space clears the selection.
693 on:click=move |_| set_selected.set(Vec::new())
694 on:contextmenu=move |ev: MouseEvent| {
695 ev.prevent_default();
696 set_selected.set(Vec::new());
697 set_ctx.set(Some((ev.client_x(), ev.client_y())));
698 }
699 >
700 {move || {
701 match list_state.get() {
702 ListState::Loading => view! {
703 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
704 }
705 .into_view()
706 .into_any(),
707 ListState::Error(msg) => view! {
708 <div class="card error-card">
709 <span>{msg.clone()}</span>
710 <button class="btn" on:click=move |_| fetch.run(())>
711 {i18n::tr(i18n::k::RETRY)}
712 </button>
713 </div>
714 }
715 .into_view()
716 .into_any(),
717 ListState::Entries(list) => {
718 if list.entries.is_empty() {
719 return view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
720 .into_view()
721 .into_any();
722 }
723 let total = list.entries.len();
724 let truncated = list.truncated;
725 let body = entries_view(
726 view_mode.get(),
727 list,
728 shown,
729 root_id,
730 loc,
731 is_rw,
732 single_click,
733 thumbs,
734 selected,
735 sel_names,
736 set_selected,
737 set_ctx,
738 open_file,
739 );
740 // Paging footer and, below it, the server-side cap
741 // notice — both belong to the listing, not to one
742 // of the two layouts.
743 view! {
744 {body}
745 {move || page_footer(
746 shown.get().min(total),
747 total,
748 total,
749 FILE_PAGE,
750 move || shown.update(|n| *n += FILE_PAGE),
751 )}
752 {truncated.then(|| view! {
753 <p class="muted list-note">
754 {move || i18n::t_fmt(
755 i18n::k::LIST_TRUNCATED,
756 &api_types::MAX_LIST_ENTRIES.to_string(),
757 )}
758 </p>
759 })}
760 }
761 .into_view()
762 .into_any()
763 }
764 }
765 }}
766 </div>
767 };
768
769 view! {
770 {crumbs}
771 {toolbar}
772 {entries_area}
773 }
774}
775
776/// Right-click on an entry: open the context menu on the selection. An
777/// entry that is already selected keeps the whole selection (multi-item
778/// menu); any other entry becomes the only selected one.
779fn ctx_menu_handler(
780 all: Arc<Listing>,
781 idx: usize,
782 set_ctx: WriteSignal<Option<CtxMenu>>,
783 selected: ReadSignal<Vec<Entry>>,
784 set_selected: WriteSignal<Vec<Entry>>,
785) -> impl Fn(web_sys::MouseEvent) + 'static {
786 move |ev: MouseEvent| {
787 ev.prevent_default();
788 ev.stop_propagation();
789 let entry = &all.entries[idx];
790 if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
791 set_selected.set(vec![entry.clone()]);
792 }
793 set_ctx.set(Some((ev.client_x(), ev.client_y())));
794 }
795}
796
797/// Items per row in the grid.
798///
799/// Read from the DOM on every call, not cached: the grid is CSS
800/// `auto-fill`, so only the browser knows the column count, and a cached one
801/// would go stale on the next resize.
802fn grid_columns(container: &web_sys::Element) -> usize {
803 let kids = container.children();
804 let first = kids
805 .item(0)
806 .and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok());
807 let Some(first) = first else { return 1 };
808 let top = first.offset_top();
809 let mut cols = 0usize;
810 for i in 0..kids.length() {
811 let Some(el) = kids
812 .item(i)
813 .and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
814 else {
815 break;
816 };
817 if el.offset_top() != top {
818 break;
819 }
820 cols += 1;
821 }
822 cols.max(1)
823}
824
825/// Move keyboard focus to the item at `idx` inside `container`.
826///
827/// `focus()` scrolls the element into view on its own, so an item revealed
828/// by the arrow keys does not need its own scrolling.
829fn focus_item(container: &web_sys::Element, idx: usize) {
830 if let Some(el) = container
831 .children()
832 .item(idx as u32)
833 .and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
834 {
835 let _ = el.focus();
836 }
837}
838
839/// The selection after extending from the anchor to `idx`, in listing order.
840///
841/// The anchor is the first selected entry. Every path that replaces the
842/// selection puts the new entry first, so the next Shift+click or
843/// Shift+arrow extends from the same place.
844///
845/// `None` when the anchor left the listing. The caller then leaves the
846/// selection alone rather than guessing a new one.
847fn range_selection(
848 entries: &[Entry],
849 cur: Vec<Entry>,
850 idx: usize,
851 add: bool,
852) -> Option<Vec<Entry>> {
853 let anchor = match cur.first() {
854 Some(a) => entries.iter().position(|e| e.name == a.name)?,
855 None => idx,
856 };
857 let (lo, hi) = if anchor <= idx {
858 (anchor, idx)
859 } else {
860 (idx, anchor)
861 };
862 let mut next: Vec<Entry> = if add { cur } else { Vec::new() };
863 let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
864 {
865 let mut push = |e: &Entry| {
866 if have.insert(e.name.clone()) {
867 next.push(e.clone());
868 }
869 };
870 push(&entries[anchor]);
871 entries[lo..=hi].iter().for_each(&mut push);
872 }
873 Some(next)
874}
875
876/// Left-click behaviour. Modifier clicks always select:
877/// - Ctrl/Cmd+click toggles the entry;
878/// - Shift+click selects the range from the anchor (the first selected
879/// entry) to the clicked one, in listing order; with Ctrl it adds the
880/// range instead of replacing the selection.
881///
882/// A plain click follows the "single click opens" profile setting: off
883/// (default) selects the entry alone (a double click opens it); on opens it.
884///
885/// Selecting clicks stop propagation so the empty-area click (deselect) and
886/// the double-click handler don't interfere.
887fn selection_click(
888 all: Arc<Listing>,
889 idx: usize,
890 single_click: bool,
891 selected: ReadSignal<Vec<Entry>>,
892 set_selected: WriteSignal<Vec<Entry>>,
893 open: Callback<()>,
894) -> impl Fn(web_sys::MouseEvent) + 'static {
895 move |ev: MouseEvent| {
896 // Ranges and "is it still there?" run over the whole listing, not
897 // over the rendered page.
898 let entries = &all.entries;
899 let entry = &entries[idx];
900 let ctrl = ev.ctrl_key() || ev.meta_key();
901 if !ctrl && !ev.shift_key() {
902 if single_click {
903 open.run(());
904 return;
905 }
906 ev.stop_propagation();
907 set_selected.set(vec![entry.clone()]);
908 return;
909 }
910 ev.stop_propagation();
911 let cur = selected.get_untracked();
912 if ev.shift_key() {
913 if let Some(next) = range_selection(entries, cur, idx, ctrl) {
914 set_selected.set(next);
915 }
916 return;
917 }
918 let mut next = cur;
919 match next.iter().position(|s| s.name == entry.name) {
920 Some(i) => {
921 next.remove(i);
922 }
923 None => next.push(entry.clone()),
924 }
925 set_selected.set(next);
926 }
927}
928
929/// What "open" does for an entry: descend into a folder, open a text file in
930/// the editor (read-only in read-only folders), open a media preview — or
931/// say so when there is no viewer. Shared by left-click, Enter, and the
932/// context menu's "Open" item.
933fn open_entry(
934 entry: &Entry,
935 root_id: i64,
936 loc: ReadSignal<Location>,
937 is_rw: bool,
938 open_file: WriteSignal<Option<FileView>>,
939) -> Callback<()> {
940 let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
941 Callback::new(move |_| {
942 if is_dir {
943 let cur = loc.get();
944 let mut path = cur.path;
945 path.push(name.clone());
946 navigate(&Location {
947 root_id: Some(root_id),
948 path,
949 share_token: cur.share_token.clone(),
950 section: Section::Files,
951 });
952 return;
953 }
954 let full = join_path(&loc.get().path, &name);
955 if kind == FileKind::Text {
956 open_file.set(Some(FileView::Editor(EditTarget {
957 root_id,
958 path: full,
959 name: name.clone(),
960 readonly: !is_rw,
961 })));
962 return;
963 }
964 match preview_kind(kind) {
965 Some(k) => open_file.set(Some(FileView::Preview(
966 PreviewTarget {
967 root_id,
968 path: full,
969 name: name.clone(),
970 },
971 k,
972 ))),
973 None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
974 root_id,
975 path: full,
976 name: name.clone(),
977 kind,
978 }))),
979 }
980 })
981}
982
983/// Click/Enter on an entry: the shared "open" action, the selection-aware
984/// click handler, and the context-menu handler. The grid and the list differ
985/// only in markup.
986#[allow(clippy::too_many_arguments)] // explicit signal props
987fn entry_callbacks(
988 all: Arc<Listing>,
989 idx: usize,
990 root_id: i64,
991 loc: ReadSignal<Location>,
992 is_rw: bool,
993 single_click: bool,
994 selected: ReadSignal<Vec<Entry>>,
995 set_selected: WriteSignal<Vec<Entry>>,
996 set_ctx: WriteSignal<Option<CtxMenu>>,
997 open_file: WriteSignal<Option<FileView>>,
998) -> (
999 Callback<()>,
1000 impl Fn(web_sys::MouseEvent) + 'static,
1001 impl Fn(web_sys::MouseEvent) + 'static,
1002) {
1003 let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file);
1004 (
1005 open_cb,
1006 selection_click(
1007 all.clone(),
1008 idx,
1009 single_click,
1010 selected,
1011 set_selected,
1012 open_cb,
1013 ),
1014 ctx_menu_handler(all, idx, set_ctx, selected, set_selected),
1015 )
1016}
1017
1018/// Arrow keys on a focused entry: move focus, and take the selection with it.
1019///
1020/// The grid uses all four keys, the list only up and down. A list row is the
1021/// full width, so left and right are left to the browser (caret browsing,
1022/// horizontal scrolling).
1023///
1024/// Shift extends from the anchor, exactly as Shift+click does, so the two
1025/// can be mixed in one selection.
1026#[allow(clippy::too_many_arguments)] // explicit signal props
1027fn arrow_key(
1028 ev: &web_sys::KeyboardEvent,
1029 container: &web_sys::Element,
1030 all: &Arc<Listing>,
1031 idx: usize,
1032 grid: bool,
1033 shown: RwSignal<usize>,
1034 selected: ReadSignal<Vec<Entry>>,
1035 set_selected: WriteSignal<Vec<Entry>>,
1036) {
1037 // A modifier the handler does not implement belongs to the browser
1038 // (Alt+Left is history back, Ctrl+Arrow jumps words in a text field).
1039 if ev.ctrl_key() || ev.meta_key() || ev.alt_key() {
1040 return;
1041 }
1042 let cols = if grid { grid_columns(container) } else { 1 };
1043 let step = match ev.key().as_str() {
1044 "ArrowUp" => -(cols as isize),
1045 "ArrowDown" => cols as isize,
1046 "ArrowLeft" if grid => -1,
1047 "ArrowRight" if grid => 1,
1048 _ => return,
1049 };
1050 // Past this point the key is ours: the page must not scroll under it.
1051 ev.prevent_default();
1052
1053 let total = all.entries.len();
1054 let target = idx as isize + step;
1055 if target < 0 || target as usize >= total {
1056 return;
1057 }
1058 let target = target as usize;
1059
1060 let cur = selected.get_untracked();
1061 let next = if ev.shift_key() {
1062 range_selection(&all.entries, cur, target, false)
1063 } else {
1064 Some(vec![all.entries[target].clone()])
1065 };
1066 if let Some(next) = next {
1067 set_selected.set(next);
1068 }
1069
1070 if target < shown.get_untracked() {
1071 focus_item(container, target);
1072 return;
1073 }
1074 // Reveal whole pages until the target is rendered. The focus call has
1075 // to wait for that render, hence the animation frame.
1076 shown.update(|n| {
1077 while *n <= target {
1078 *n += FILE_PAGE;
1079 }
1080 });
1081 let container = container.clone();
1082 request_animation_frame(move || focus_item(&container, target));
1083}
1084
1085/// The rendered listing container. There is one listing per page at most,
1086/// so the class lookup is unambiguous.
1087fn listing_container() -> Option<web_sys::Element> {
1088 web_sys::window()?
1089 .document()?
1090 .query_selector(".entries-grid, .entries-list")
1091 .ok()?
1092}
1093
1094/// Position of the first selected entry in the listing, if any.
1095fn anchor_index(all: &Listing, selected: &[Entry]) -> Option<usize> {
1096 let first = selected.first()?;
1097 all.entries.iter().position(|e| e.name == first.name)
1098}
1099
1100/// Whether a double click opens: not with a modifier held, because that is
1101/// two selecting clicks (the browser still fires `dblclick` for them).
1102fn plain_dblclick(ev: &MouseEvent) -> bool {
1103 !(ev.ctrl_key() || ev.meta_key() || ev.shift_key())
1104}
1105
1106/// One listing layout. Grid and list differ only in their container and
1107/// item classes and in what each item shows, so they are one function: the
1108/// selection, keyboard and context-menu behaviour is identical and must
1109/// stay that way.
1110#[allow(clippy::too_many_arguments)] // explicit signal props
1111fn entries_view(
1112 mode: ViewMode,
1113 all: Arc<Listing>,
1114 shown: RwSignal<usize>,
1115 root_id: i64,
1116 loc: ReadSignal<Location>,
1117 is_rw: bool,
1118 single_click: bool,
1119 thumbs: bool,
1120 selected: ReadSignal<Vec<Entry>>,
1121 sel_names: Memo<HashSet<String>>,
1122 set_selected: WriteSignal<Vec<Entry>>,
1123 set_ctx: WriteSignal<Option<CtxMenu>>,
1124 open_file: WriteSignal<Option<FileView>>,
1125) -> impl IntoView {
1126 let total = all.entries.len();
1127 let grid = mode == ViewMode::Grid;
1128 let (container, item, icon_class) = if grid {
1129 ("entries-grid", "tile", "tile-icon")
1130 } else {
1131 ("entries-list", "row", "row-icon")
1132 };
1133 // Grid only: a list row is 28px of icon, too small to be worth a request
1134 // per entry.
1135 let thumbs = thumbs && grid;
1136 let container = match thumbs {
1137 true => format!("{container} has-thumbs"),
1138 false => container.to_string(),
1139 };
1140 // Untracked: a change to the location refetches the listing, which
1141 // re-runs this anyway.
1142 let dir = loc.get_untracked().path;
1143 view! {
1144 <div class=container>
1145 // Only the visible page is rendered; the click handlers still
1146 // index into the whole listing, so Shift+click ranges reach
1147 // past it. Keyed by index: within one listing the order is
1148 // fixed, so revealing a page only appends items.
1149 <For
1150 each=move || 0..shown.get().min(total)
1151 key=|i: &usize| *i
1152 children=move |i: usize| {
1153 let e = &all.entries[i];
1154 let icon = icon_for(e.kind, &e.name);
1155 let name = e.name.clone();
1156 let title = name.clone();
1157 let size = if e.is_dir { "—".to_string() } else { format_size(e.size) };
1158 let date = format_date(&e.mtime);
1159 let (open_cb, on_click, on_ctx) = entry_callbacks(
1160 all.clone(), i, root_id, loc, is_rw, single_click,
1161 selected, set_selected, set_ctx, open_file,
1162 );
1163 let thumb_src = (thumbs
1164 && !e.is_dir
1165 && matches!(e.kind, FileKind::Image | FileKind::Video))
1166 .then(|| api::thumb_url(root_id, &join_path(&dir, &e.name), &e.mtime));
1167 let all_keys = all.clone();
1168 let item_name = name.clone();
1169 let selected_now = move || sel_names.with(|s| s.contains(&item_name));
1170 view! {
1171 <div
1172 class=item
1173 class:selected=selected_now
1174 tabindex=0
1175 on:click=on_click
1176 on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
1177 on:keydown=move |ev: web_sys::KeyboardEvent| {
1178 if ev.key() == "Enter" || ev.key() == " " {
1179 ev.prevent_default();
1180 open_cb.run(());
1181 return;
1182 }
1183 let Some(container) = ev
1184 .target()
1185 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
1186 .and_then(|el| el.parent_element())
1187 else {
1188 return;
1189 };
1190 arrow_key(&ev, &container, &all_keys, i, grid, shown, selected, set_selected);
1191 }
1192 on:contextmenu=on_ctx
1193 title=title
1194 >
1195 {if grid {
1196 view! {
1197 <div class="tile-media">
1198 // `icon_svg`, not `<Icon/>`: no
1199 // reactive owner per item (see its
1200 // doc).
1201 {icon_svg(icon, icon_class)}
1202 // An empty `alt` means a 404 renders
1203 // as nothing, leaving the icon. No
1204 // error handler is needed.
1205 {thumb_src.map(|src| view! {
1206 <img
1207 class="tile-thumb"
1208 src=src
1209 alt=""
1210 loading="lazy"
1211 decoding="async"
1212 />
1213 })}
1214 </div>
1215 <div class="tile-name">{name}</div>
1216 }.into_any()
1217 } else {
1218 view! {
1219 {icon_svg(icon, icon_class)}
1220 <span class="row-name">{name}</span>
1221 <span class="row-size muted">{size}</span>
1222 <span class="row-date muted">{date}</span>
1223 }.into_any()
1224 }}
1225 </div>
1226 }
1227 }
1228 />
1229 </div>
1230 }
1231}
1232
1233// ---------------------------------------------------------------------------
1234// Context menu
1235// ---------------------------------------------------------------------------
1236
1237/// Build the action list for the selection: the empty area (nothing
1238/// selected), one entry, or several. Shared by the right-click context menu
1239/// and the toolbar's selection actions so both always offer the same
1240/// operations.
1241#[allow(clippy::too_many_arguments)] // explicit signal props
1242fn menu_items(
1243 sel: &[Entry],
1244 me: ReadSignal<Option<Me>>,
1245 loc: ReadSignal<Location>,
1246 open_file: WriteSignal<Option<FileView>>,
1247 refresh: Callback<()>,
1248 toast: ToastMsg,
1249 set_dialog: WriteSignal<Option<Dialog>>,
1250 owner: Owner,
1251) -> Vec<MenuItem> {
1252 let loc_now = loc.get();
1253 // The root that is actually shown. Resolves the single-root default
1254 // location, where `loc.root_id` is `None`. Read in place: cloning `me`
1255 // would copy its whole root list on every selection change.
1256 let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
1257 let root = me_now
1258 .as_ref()
1259 .and_then(|m| effective_root(&m.roots, &loc_now));
1260 (
1261 root.map(|r| r.mode.is_writable()).unwrap_or(false),
1262 root.map(|r| r.name.clone()).unwrap_or_default(),
1263 root.map(|r| r.id),
1264 me_now
1265 .as_ref()
1266 .map(|m| m.allow_writable_shares)
1267 .unwrap_or(false),
1268 )
1269 });
1270
1271 match sel {
1272 [] => {
1273 let mut v = vec![
1274 MenuItem::rw(
1275 IconName::NewFolder,
1276 i18n::t(i18n::k::NEW_FOLDER),
1277 is_rw,
1278 action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
1279 ),
1280 MenuItem::rw(
1281 IconName::NewFile,
1282 i18n::t(i18n::k::NEW_FILE),
1283 is_rw,
1284 action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
1285 ),
1286 MenuItem::rw(
1287 IconName::Upload,
1288 i18n::t(i18n::k::UPLOAD_FILES),
1289 is_rw,
1290 action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
1291 ),
1292 MenuItem::rw(
1293 IconName::UploadFolder,
1294 i18n::t(i18n::k::UPLOAD_FOLDER),
1295 is_rw,
1296 action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
1297 ),
1298 ];
1299 // Sharing and search are signed-in features; a share visitor has
1300 // neither, and `SessionUser` refuses a share token outright.
1301 if loc_now.share_token.is_none() {
1302 v.push(MenuItem::new(
1303 IconName::Share,
1304 i18n::t(i18n::k::SHARE),
1305 action_share(
1306 None,
1307 root_id,
1308 loc,
1309 root_name.clone(),
1310 allow_writable_shares,
1311 is_rw,
1312 set_dialog,
1313 owner.clone(),
1314 ),
1315 ));
1316 }
1317 if let Some(rid) = root_id
1318 && loc_now.share_token.is_none()
1319 {
1320 let dir = loc_now.path.join("/");
1321 v.push(MenuItem::new(
1322 IconName::Search,
1323 i18n::t(i18n::k::SEARCH_IN_FOLDER),
1324 owner.with(|| {
1325 Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
1326 }),
1327 ));
1328 }
1329 v
1330 }
1331 // One entry gets every action; several get only the ones that take
1332 // a set (move, copy, delete).
1333 _ => {
1334 let one = if let [e] = sel { Some(e) } else { None };
1335 let mut v = vec![];
1336 if let Some(e) = one {
1337 // Exactly what left-clicking the entry does: descend into a
1338 // folder, open a text file in the editor (read-only in
1339 // read-only folders), or preview media.
1340 if let Some(rid) = root_id {
1341 v.push(MenuItem::new(
1342 if e.is_dir {
1343 IconName::FolderOpen
1344 } else {
1345 IconName::Open
1346 },
1347 i18n::t(i18n::k::OPEN),
1348 open_entry(e, rid, loc, is_rw, open_file),
1349 ));
1350 }
1351 if !e.is_dir {
1352 // Serve the file itself in a new tab. HTML and SVG render
1353 // as real pages there (sandboxed by the server), which is
1354 // how you share a small site or an HTML report.
1355 v.push(MenuItem::new(
1356 IconName::OpenInNew,
1357 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
1358 action_open_tab(e, root_id, loc, owner.clone()),
1359 ));
1360 }
1361 v.push(MenuItem::new(
1362 IconName::Download,
1363 i18n::t(i18n::k::DOWNLOAD),
1364 action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
1365 ));
1366 v.push(MenuItem::rw(
1367 IconName::Rename,
1368 i18n::t(i18n::k::RENAME),
1369 is_rw,
1370 action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1371 ));
1372 }
1373 v.push(MenuItem::rw(
1374 IconName::Move,
1375 i18n::t(i18n::k::MOVE),
1376 is_rw,
1377 action_move_copy(
1378 Op::Move,
1379 sel,
1380 root_id,
1381 me,
1382 loc,
1383 refresh,
1384 toast,
1385 set_dialog,
1386 owner.clone(),
1387 ),
1388 ));
1389 // Copying only writes at the destination, so a read-only source
1390 // folder is fine.
1391 v.push(MenuItem::new(
1392 IconName::Copy,
1393 i18n::t(i18n::k::COPY),
1394 action_move_copy(
1395 Op::Copy,
1396 sel,
1397 root_id,
1398 me,
1399 loc,
1400 refresh,
1401 toast,
1402 set_dialog,
1403 owner.clone(),
1404 ),
1405 ));
1406 if let Some(e) = one {
1407 if loc_now.share_token.is_none() {
1408 v.push(MenuItem::new(
1409 IconName::Share,
1410 i18n::t(i18n::k::SHARE),
1411 action_share(
1412 Some(e),
1413 root_id,
1414 loc,
1415 root_name.clone(),
1416 allow_writable_shares,
1417 is_rw,
1418 set_dialog,
1419 owner.clone(),
1420 ),
1421 ));
1422 }
1423 v.push(MenuItem::new(
1424 IconName::Info,
1425 i18n::t(i18n::k::INFO),
1426 action_info(e, loc, root_name, set_dialog, owner.clone()),
1427 ));
1428 }
1429 v.push(MenuItem::rw(
1430 IconName::Delete,
1431 i18n::t(i18n::k::DELETE),
1432 is_rw,
1433 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1434 ));
1435 v
1436 }
1437 }
1438}
1439
1440#[component]
1441fn CtxMenuView(
1442 ctx: ReadSignal<Option<CtxMenu>>,
1443 set_ctx: WriteSignal<Option<CtxMenu>>,
1444 me: ReadSignal<Option<Me>>,
1445 loc: ReadSignal<Location>,
1446 selected: ReadSignal<Vec<Entry>>,
1447 refresh: Callback<()>,
1448 toast: ToastMsg,
1449 set_dialog: WriteSignal<Option<Dialog>>,
1450 open_file: WriteSignal<Option<FileView>>,
1451 owner: Owner,
1452) -> impl IntoView {
1453 // Close on any click or Escape.
1454 {
1455 crate::util::owned_window_listener(leptos::ev::click, move |_| {
1456 set_ctx.set(None);
1457 });
1458 crate::util::owned_window_listener(
1459 leptos::ev::keydown,
1460 move |ev: web_sys::KeyboardEvent| {
1461 if ev.key() == "Escape" {
1462 set_ctx.set(None);
1463 }
1464 },
1465 );
1466 }
1467
1468 // The menu first paints at the cursor point; after mount, this measures
1469 // its actual size and flips it above/left of the cursor when it would
1470 // overflow the viewport (native context-menu behavior), clamping to the
1471 // viewport edges as a fallback.
1472 let menu_ref = NodeRef::<leptos::html::Div>::new();
1473 Effect::new(move |_| {
1474 let Some((mx, my)) = ctx.get() else {
1475 return;
1476 };
1477 let Some(node) = menu_ref
1478 .get()
1479 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1480 else {
1481 return;
1482 };
1483 let rect = node.get_bounding_client_rect();
1484 let (w, h) = (rect.width() as i32, rect.height() as i32);
1485 let (vw, vh) = web_sys::window()
1486 .and_then(|w| {
1487 let vw = w.inner_width().ok()?.as_f64()?;
1488 let vh = w.inner_height().ok()?.as_f64()?;
1489 Some((vw, vh))
1490 })
1491 .unwrap_or((f64::MAX, f64::MAX));
1492 let (vw, vh) = (vw as i32, vh as i32);
1493 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1494 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1495 let _ = node.style().set_property("left", &format!("{x}px"));
1496 let _ = node.style().set_property("top", &format!("{y}px"));
1497 });
1498
1499 view! {
1500 {move || {
1501 let Some((mx, my)) = ctx.get() else {
1502 return {
1503 view! {}.into_any()
1504 };
1505 };
1506 let items = selected.with(|sel| {
1507 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1508 });
1509
1510 view! {
1511 <div
1512 class="ctx-menu"
1513 node_ref=menu_ref
1514 style=format!("left:{mx}px;top:{my}px")
1515 >
1516 {items.into_iter().map(|item| {
1517 view! { <CtxItem item=item/> }
1518 }).collect::<Vec<_>>()}
1519 </div>
1520 }
1521 .into_view()
1522 .into_any()
1523 }}
1524 }
1525}
1526
1527/// One row of the context menu. `action` is `None` for a disabled row, and
1528/// `hint` then says why it is disabled.
1529#[derive(Clone)]
1530struct MenuItem {
1531 label: &'static str,
1532 icon: IconName,
1533 action: Option<Callback<()>>,
1534 hint: Option<&'static str>,
1535}
1536
1537impl MenuItem {
1538 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1539 Self {
1540 label,
1541 icon,
1542 action: Some(action),
1543 hint: None,
1544 }
1545 }
1546
1547 /// An item that only works in a writable folder.
1548 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1549 Self {
1550 label,
1551 icon,
1552 action: is_rw.then_some(action),
1553 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1554 }
1555 }
1556}
1557
1558#[component]
1559fn CtxItem(item: MenuItem) -> impl IntoView {
1560 let MenuItem {
1561 label,
1562 icon,
1563 action,
1564 hint,
1565 } = item;
1566 let enabled = action.is_some();
1567 let hint = hint.unwrap_or_default();
1568 view! {
1569 <div
1570 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1571 title=move || if enabled { "".to_string() } else { hint.to_string() }
1572 on:click=move |_| {
1573 if let Some(a) = &action {
1574 a.run(());
1575 }
1576 }
1577 >
1578 <Icon name=icon class="ic-ctx".to_string()/>
1579 <span>{label}</span>
1580 </div>
1581 }
1582}
1583
1584// ---------------------------------------------------------------------------
1585// Toolbar selection actions (top right)
1586// ---------------------------------------------------------------------------
1587
1588/// Icon-only buttons for the current selection — the same set of actions
1589/// as its right-click menu — or the empty-area actions (new folder,
1590/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1591/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1592#[component]
1593pub fn SelectionActions(
1594 me: ReadSignal<Option<Me>>,
1595 loc: ReadSignal<Location>,
1596 selected: ReadSignal<Vec<Entry>>,
1597 open_file: WriteSignal<Option<FileView>>,
1598 refresh: Callback<()>,
1599 toast: ToastMsg,
1600 set_dialog: WriteSignal<Option<Dialog>>,
1601 owner: Owner,
1602 topbar_ref: NodeRef<leptos::html::Header>,
1603) -> impl IntoView {
1604 // How many buttons are shown before the overflow kicks in. Starts small
1605 // so the first paint never overflows; the effect below widens it as soon
1606 // as the real widths are measurable.
1607 let (visible_n, set_visible_n) = signal(3usize);
1608 // The three-dot overflow menu state. Any outside click or Escape closes
1609 // it; a new selection does too. The toggle button stops propagation so
1610 // it can actually open the menu.
1611 let (more_open, set_more_open) = signal(false);
1612 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
1613 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
1614 if ev.key() == "Escape" {
1615 set_more_open.set(false);
1616 }
1617 });
1618 Effect::new(move |_| {
1619 let _ = selected.get();
1620 set_more_open.set(false);
1621 });
1622 let group_ref = NodeRef::<leptos::html::Div>::new();
1623
1624 let recompute = move || {
1625 let Some(tb) = topbar_ref
1626 .get()
1627 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1628 else {
1629 return;
1630 };
1631 let Some(g) = group_ref
1632 .get()
1633 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1634 else {
1635 return;
1636 };
1637 // Every button is icon-only with identical padding, so one
1638 // rendered button gives the width of all of them.
1639 let Some(btn) = g.first_element_child() else {
1640 return;
1641 };
1642 // Available width: from the top bar's left content (brand) to its
1643 // right edge — independent of where the group currently sits,
1644 // so the measurement stays valid even while the group overflows.
1645 let tb_r = tb.get_bounding_client_rect();
1646 let start = tb
1647 .query_selector(".brand")
1648 .ok()
1649 .flatten()
1650 .map(|b| b.get_bounding_client_rect())
1651 .map(|r| r.right() + 16.0)
1652 .unwrap_or(0.0);
1653 let avail = (tb_r.right() - start - 10.0).max(40.0);
1654 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1655 // How many buttons fit (not counting the overflow button).
1656 set_visible_n.set(((avail / step) as usize).max(1));
1657 };
1658 // Measuring forces three layout reflows, so it must not run on every
1659 // selection change. Only how many buttons *fit* is measured, and that
1660 // changes when the group appears, disappears, or switches between the
1661 // empty-area, single and multi action sets — not per selected entry.
1662 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1663 Effect::new(move |_| {
1664 topbar_ref.get();
1665 group_ref.get();
1666 // Re-measure when the item set (selection kind, user) or width
1667 // inputs change.
1668 sel_kind.get();
1669 me.track();
1670 recompute();
1671 });
1672 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1673
1674 view! {
1675 <div class="sel-actions" node_ref=group_ref>
1676 // The visible buttons and the overflow rows are one item list
1677 // split at the measured count, so the actions are computed once.
1678 {move || {
1679 // No resolvable root (e.g. the `#/` "select a folder" empty
1680 // state): there is nowhere to act, so hide the whole group
1681 // rather than show a row of disabled buttons.
1682 let has_root = me.with(|m| {
1683 m.as_ref()
1684 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1685 });
1686 if !has_root {
1687 return {
1688 view! {}.into_any()
1689 };
1690 }
1691 let items = selected.with(|sel| {
1692 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1693 });
1694 let fit = visible_n.get();
1695 let n = if fit >= items.len() {
1696 items.len()
1697 } else {
1698 // The overflow button takes a slot too, so drop one —
1699 // otherwise the group eats into the space after the brand.
1700 (fit - 1).max(1)
1701 };
1702 let (visible, more) = items.split_at(n);
1703 let visible_view = visible
1704 .iter()
1705 .cloned()
1706 .map(|i| view! {
1707 <SelButton item=i/>
1708 })
1709 .collect_view();
1710 // Rendered as nothing while every action fits.
1711 let more_block = (!more.is_empty()).then(|| {
1712 let more_view = more
1713 .iter()
1714 .cloned()
1715 .map(|i| view! {
1716 <CtxItem item=i/>
1717 })
1718 .collect_view();
1719 view! {
1720 <div class="sel-more-wrap">
1721 <button
1722 class="icon-btn"
1723 title=i18n::tr(i18n::k::MORE_ACTIONS)
1724 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1725 on:click=move |ev: MouseEvent| {
1726 ev.stop_propagation();
1727 set_more_open.set(!more_open.get());
1728 }
1729 >
1730 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1731 </button>
1732 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1733 {more_view}
1734 </div>
1735 </div>
1736 }
1737 });
1738 view! {
1739 {visible_view}
1740 {more_block}
1741 }
1742 .into_view()
1743 .into_any()
1744 }}
1745 </div>
1746 }
1747}
1748
1749/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1750/// then explains why.
1751#[component]
1752fn SelButton(item: MenuItem) -> impl IntoView {
1753 let enabled = item.action.is_some();
1754 let title = if enabled {
1755 item.label.to_string()
1756 } else {
1757 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1758 };
1759 view! {
1760 <button
1761 class="icon-btn"
1762 disabled=!enabled
1763 title=title
1764 on:click=move |_| {
1765 if let Some(a) = &item.action {
1766 a.run(());
1767 }
1768 }
1769 >
1770 <Icon name=item.icon class="ic-btn".to_string()/>
1771 </button>
1772 }
1773}
1774
1775// ---------------------------------------------------------------------------
1776// Menu actions
1777// ---------------------------------------------------------------------------
1778
1779fn join_path(dir: &[String], name: &str) -> String {
1780 if dir.is_empty() {
1781 name.to_string()
1782 } else {
1783 format!("{}/{}", dir.join("/"), name)
1784 }
1785}
1786
1787fn is_conflict(e: &api::ApiError) -> bool {
1788 matches!(e, api::ApiError::Http { status: 409, .. })
1789}
1790
1791/// A file operation in flight. Boxed because each caller builds a different
1792/// future, and they all have to fit one type.
1793type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1794
1795/// Run a file operation that may collide with an existing name.
1796///
1797/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1798/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1799/// upload all need exactly this, so it lives here once.
1800///
1801/// `names` is what to list in the dialog when the server does not say. Upload
1802/// reports the exact set it skipped; the single-item operations know theirs
1803/// before they ask.
1804fn run_with_overwrite_retry(
1805 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1806 conflict_title: &'static str,
1807 names: Vec<String>,
1808 ok_msg: &'static str,
1809 refresh: Callback<()>,
1810 toast: ToastMsg,
1811 set_dialog: WriteSignal<Option<Dialog>>,
1812) {
1813 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1814 show(toast, ok_msg);
1815 refresh.run(());
1816 }
1817
1818 let retry = op.clone();
1819 spawn_local(async move {
1820 let Err(e) = op(false).await else {
1821 done(ok_msg, refresh, toast);
1822 return;
1823 };
1824 let conflicting = e
1825 .skipped()
1826 .map(<[String]>::to_vec)
1827 .or_else(|| is_conflict(&e).then_some(names));
1828 let Some(files) = conflicting else {
1829 show_error(toast, e.to_string());
1830 return;
1831 };
1832 set_dialog.set(Some(Dialog::Conflict {
1833 title: conflict_title.to_string(),
1834 files,
1835 on_submit: Callback::new(move |_| {
1836 let retry = retry.clone();
1837 spawn_local(async move {
1838 match retry(true).await {
1839 Ok(()) => done(ok_msg, refresh, toast),
1840 Err(e) => show_error(toast, e.to_string()),
1841 }
1842 });
1843 }),
1844 }));
1845 });
1846}
1847
1848fn action_new_folder(
1849 root_id: Option<i64>,
1850 loc: ReadSignal<Location>,
1851 refresh: Callback<()>,
1852 toast: ToastMsg,
1853 set_dialog: WriteSignal<Option<Dialog>>,
1854 owner: Owner,
1855) -> Callback<()> {
1856 Callback::new(move |_| {
1857 let Some(root_id) = root_id else { return };
1858 let loc = loc.get();
1859 let dir = loc.path.clone();
1860 let toast2 = toast;
1861 let refresh2 = refresh;
1862 set_dialog.set(Some(Dialog::PromptOp {
1863 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
1864 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
1865 initial: String::new(),
1866 submit: i18n::t(i18n::k::CREATE).to_string(),
1867 run: owner.with(|| {
1868 Callback::new(move |name: String| {
1869 let full = join_path(&dir, &name);
1870 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1871 api::mkdir(root_id, &full)
1872 .await
1873 .map(|_| ())
1874 .map_err(|e| e.to_string())
1875 });
1876 fut
1877 })
1878 }),
1879 on_ok: owner.with(|| {
1880 Callback::new(move |_| {
1881 show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
1882 refresh2.run(());
1883 })
1884 }),
1885 }));
1886 })
1887}
1888
1889fn action_new_file(
1890 root_id: Option<i64>,
1891 loc: ReadSignal<Location>,
1892 refresh: Callback<()>,
1893 toast: ToastMsg,
1894 set_dialog: WriteSignal<Option<Dialog>>,
1895 owner: Owner,
1896) -> Callback<()> {
1897 Callback::new(move |_| {
1898 let Some(root_id) = root_id else { return };
1899 let loc = loc.get();
1900 let dir = loc.path.clone();
1901 let toast2 = toast;
1902 let refresh2 = refresh;
1903 set_dialog.set(Some(Dialog::PromptOp {
1904 title: i18n::t(i18n::k::NEW_FILE).to_string(),
1905 label: i18n::t(i18n::k::FILE_NAME).to_string(),
1906 initial: String::new(),
1907 submit: i18n::t(i18n::k::CREATE).to_string(),
1908 run: owner.with(|| {
1909 Callback::new(move |name: String| {
1910 let full = join_path(&dir, &name);
1911 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1912 api::create_file(root_id, &full)
1913 .await
1914 .map(|_| ())
1915 .map_err(|e| e.to_string())
1916 });
1917 fut
1918 })
1919 }),
1920 on_ok: owner.with(|| {
1921 Callback::new(move |_| {
1922 show(toast2, i18n::t(i18n::k::FILE_CREATED));
1923 refresh2.run(());
1924 })
1925 }),
1926 }));
1927 })
1928}
1929
1930fn action_rename(
1931 entry: &Entry,
1932 root_id: Option<i64>,
1933 loc: ReadSignal<Location>,
1934 refresh: Callback<()>,
1935 toast: ToastMsg,
1936 set_dialog: WriteSignal<Option<Dialog>>,
1937 owner: Owner,
1938) -> Callback<()> {
1939 let name = entry.name.clone();
1940 Callback::new(move |_| {
1941 let Some(root_id) = root_id else { return };
1942 let loc = loc.get();
1943 let dir = loc.path.clone();
1944 let full = join_path(&dir, &name);
1945 set_dialog.set(Some(Dialog::PromptOp {
1946 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
1947 label: i18n::t(i18n::k::NEW_NAME).to_string(),
1948 initial: name.clone(),
1949 submit: i18n::t(i18n::k::RENAME).to_string(),
1950 run: owner.with(|| {
1951 Callback::new(move |new_name: String| {
1952 let full = full.clone();
1953 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1954 api::rename_item(root_id, &full, new_name, false)
1955 .await
1956 .map(|_| ())
1957 .map_err(|e| e.to_string())
1958 });
1959 fut
1960 })
1961 }),
1962 on_ok: owner.with(|| {
1963 Callback::new(move |_| {
1964 show(toast, i18n::t(i18n::k::RENAMED));
1965 refresh.run(());
1966 })
1967 }),
1968 }));
1969 })
1970}
1971
1972/// Delete the entries after one confirmation. Several are deleted one by
1973/// one; the first failure stops and reports.
1974fn action_delete(
1975 entries: &[Entry],
1976 root_id: Option<i64>,
1977 loc: ReadSignal<Location>,
1978 refresh: Callback<()>,
1979 toast: ToastMsg,
1980 set_dialog: WriteSignal<Option<Dialog>>,
1981 owner: Owner,
1982) -> Callback<()> {
1983 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
1984 Callback::new(move |_| {
1985 let Some(root_id) = root_id else { return };
1986 let dir = loc.get().path;
1987 let message = match items.as_slice() {
1988 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
1989 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
1990 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
1991 };
1992 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
1993 set_dialog.set(Some(Dialog::Confirm {
1994 title: i18n::t(i18n::k::DELETE).to_string(),
1995 message,
1996 submit: i18n::t(i18n::k::DELETE).to_string(),
1997 danger: true,
1998 on_submit: owner.with(|| {
1999 Callback::new(move |_| {
2000 set_dialog.set(None);
2001 let fulls = fulls.clone();
2002 spawn_local(async move {
2003 for f in &fulls {
2004 if let Err(e) = api::delete_item(root_id, f).await {
2005 show_error(toast, e.to_string());
2006 refresh.run(());
2007 return;
2008 }
2009 }
2010 show(toast, i18n::t(i18n::k::DELETED));
2011 refresh.run(());
2012 });
2013 })
2014 }),
2015 }));
2016 })
2017}
2018
2019fn action_upload(
2020 multiple: bool,
2021 directory: bool,
2022 root_id: Option<i64>,
2023 loc: ReadSignal<Location>,
2024 refresh: Callback<()>,
2025 toast: ToastMsg,
2026 set_dialog: WriteSignal<Option<Dialog>>,
2027) -> Callback<()> {
2028 Callback::new(move |_| {
2029 let Some(root_id) = root_id else { return };
2030 let loc = loc.get();
2031 let dir = loc.path.join("/");
2032 let dir1 = dir.clone();
2033 api::pick_files(multiple, directory, move |files| {
2034 let n = files.len();
2035 let dir = dir1.clone();
2036 show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
2037 run_with_overwrite_retry(
2038 move |overwrite| {
2039 let (dir, files) = (dir.clone(), files.clone());
2040 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
2041 },
2042 i18n::t(i18n::k::SOME_FILES_EXIST),
2043 // Unused: an upload conflict always names the skipped files.
2044 Vec::new(),
2045 i18n::t(i18n::k::UPLOAD_COMPLETE),
2046 refresh,
2047 toast,
2048 set_dialog,
2049 );
2050 });
2051 })
2052}
2053
2054/// Move or copy the entries to a folder chosen in the picker. Several are
2055/// processed one by one; name collisions are collected and asked about once,
2056/// and the overwrite retry re-runs only the items that collided.
2057#[allow(clippy::too_many_arguments)] // explicit signal props
2058fn action_move_copy(
2059 op: Op,
2060 entries: &[Entry],
2061 root_id: Option<i64>,
2062 me: ReadSignal<Option<Me>>,
2063 loc: ReadSignal<Location>,
2064 refresh: Callback<()>,
2065 toast: ToastMsg,
2066 set_dialog: WriteSignal<Option<Dialog>>,
2067 owner: Owner,
2068) -> Callback<()> {
2069 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
2070 Callback::new(move |_| {
2071 let loc = loc.get();
2072 let Some(root_id) = root_id else { return };
2073 let roots = me.get().map(|m| m.roots).unwrap_or_default();
2074 let dir = loc.path.clone();
2075 let title = match names.as_slice() {
2076 [one] => i18n::t_fmt(op.title_key(1), one),
2077 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
2078 };
2079 // (name, path relative to the root) per item.
2080 let items: Vec<(String, String)> = names
2081 .iter()
2082 .map(|n| (n.clone(), join_path(&dir, n)))
2083 .collect();
2084 set_dialog.set(Some(Dialog::Picker {
2085 title,
2086 confirm: i18n::t(op.here_key()).to_string(),
2087 roots,
2088 root: root_id,
2089 dir: dir.join("/"),
2090 for_write: true,
2091 on_pick: owner.with(|| {
2092 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
2093 // Items still to do. A finished item is dropped, so the
2094 // overwrite retry re-runs only the ones that collided.
2095 let pending = Arc::new(Mutex::new(items.clone()));
2096 run_with_overwrite_retry(
2097 move |overwrite| {
2098 let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
2099 Box::pin(async move {
2100 let items = pending.lock().unwrap().clone();
2101 let mut conflicts = Vec::new();
2102 for (name, full) in items {
2103 let res = if op == Op::Move {
2104 api::move_item(
2105 root_id, &full, dst_root, &dst_dir, overwrite,
2106 )
2107 .await
2108 } else {
2109 api::copy_item(
2110 root_id, &full, dst_root, &dst_dir, overwrite,
2111 )
2112 .await
2113 };
2114 match res {
2115 Ok(_) => {
2116 pending.lock().unwrap().retain(|(_, p)| *p != full)
2117 }
2118 Err(e) if is_conflict(&e) => conflicts.push(name),
2119 Err(e) => return Err(e),
2120 }
2121 }
2122 if conflicts.is_empty() {
2123 Ok(())
2124 } else {
2125 Err(api::ApiError::Http {
2126 status: 409,
2127 message: String::new(),
2128 skipped: Some(conflicts),
2129 })
2130 }
2131 })
2132 },
2133 i18n::t(i18n::k::NAME_TAKEN),
2134 // Unused: the conflict error above always lists the names.
2135 Vec::new(),
2136 if op == Op::Move {
2137 i18n::t(i18n::k::MOVED)
2138 } else {
2139 i18n::t(i18n::k::COPIED)
2140 },
2141 refresh,
2142 toast,
2143 set_dialog,
2144 );
2145 })
2146 }),
2147 }));
2148 })
2149}
2150
2151fn action_download(
2152 entry: &Entry,
2153 root_id: Option<i64>,
2154 loc: ReadSignal<Location>,
2155 set_dialog: WriteSignal<Option<Dialog>>,
2156 toast: ToastMsg,
2157 owner: Owner,
2158) -> Callback<()> {
2159 let name = entry.name.clone();
2160 let is_dir = entry.is_dir;
2161 owner.with(|| {
2162 Callback::new(move |_| {
2163 let Some(root_id) = root_id else { return };
2164 let loc = loc.get();
2165 let full = join_path(&loc.path, &name);
2166 if is_dir {
2167 set_dialog.set(Some(Dialog::DownloadFormat {
2168 name: name.clone(),
2169 root_id,
2170 path: full,
2171 }));
2172 } else {
2173 let url = api::download_url(root_id, &full, None);
2174 api::trigger_download(&url, &name);
2175 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
2176 }
2177 })
2178 })
2179}
2180
2181/// Open the file itself in a new tab, via the `?action=preview` endpoint —
2182/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
2183fn action_open_tab(
2184 entry: &Entry,
2185 root_id: Option<i64>,
2186 loc: ReadSignal<Location>,
2187 owner: Owner,
2188) -> Callback<()> {
2189 let name = entry.name.clone();
2190 owner.with(|| {
2191 Callback::new(move |_| {
2192 let Some(root_id) = root_id else { return };
2193 let full = join_path(&loc.get().path, &name);
2194 api::open_in_new_tab(&api::preview_url(root_id, &full));
2195 })
2196 })
2197}
2198
2199/// Open the share dialog for an item (milestone 6).
2200///
2201/// `entry` is the item to share, or `None` for the folder currently open.
2202/// The folder case sends an empty path, which the server reads as the root of
2203/// the location. At the top of a root that shares the root itself.
2204#[allow(clippy::too_many_arguments)] // explicit signal props
2205fn action_share(
2206 entry: Option<&Entry>,
2207 root_id: Option<i64>,
2208 loc: ReadSignal<Location>,
2209 root_name: String,
2210 allow_writable: bool,
2211 root_writable: bool,
2212 set_dialog: WriteSignal<Option<Dialog>>,
2213 owner: Owner,
2214) -> Callback<()> {
2215 let entry_name = entry.map(|e| e.name.clone());
2216 owner.with(|| {
2217 Callback::new(move |_| {
2218 let Some(root_id) = root_id else { return };
2219 let loc = loc.get();
2220 let (name, path) = match &entry_name {
2221 Some(n) => (n.clone(), join_path(&loc.path, n)),
2222 // The last path segment names the folder; at the top of a
2223 // root there is none, so the root's own name stands in.
2224 None => (
2225 loc.path
2226 .last()
2227 .cloned()
2228 .unwrap_or_else(|| root_name.clone()),
2229 loc.path.join("/"),
2230 ),
2231 };
2232 set_dialog.set(Some(Dialog::Share {
2233 name,
2234 root_id,
2235 path,
2236 allow_writable,
2237 root_writable,
2238 }));
2239 })
2240 })
2241}
2242
2243fn action_info(
2244 entry: &Entry,
2245 loc: ReadSignal<Location>,
2246 root_name: String,
2247 set_dialog: WriteSignal<Option<Dialog>>,
2248 owner: Owner,
2249) -> Callback<()> {
2250 let entry2 = entry.clone();
2251 owner.with(|| {
2252 Callback::new(move |_| {
2253 let loc = loc.get();
2254 let full = join_path(&loc.path, &entry2.name);
2255 set_dialog.set(Some(Dialog::Info {
2256 entry: entry2.clone(),
2257 root_name: root_name.clone(),
2258 rel_path: full,
2259 }));
2260 })
2261 })
2262}
2263