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