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