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 // Search is a signed-in feature; a share visitor has none.
1121 if let Some(rid) = root_id
1122 && loc_now.share_token.is_none()
1123 {
1124 let dir = loc_now.path.join("/");
1125 v.push(MenuItem::new(
1126 IconName::Search,
1127 i18n::t(i18n::k::SEARCH_IN_FOLDER),
1128 owner.with(|| {
1129 Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
1130 }),
1131 ));
1132 }
1133 v
1134 }
1135 // One entry gets every action; several get only the ones that take
1136 // a set (move, copy, delete).
1137 _ => {
1138 let one = if let [e] = sel { Some(e) } else { None };
1139 let mut v = vec![];
1140 if let Some(e) = one {
1141 // Exactly what left-clicking the entry does: descend into a
1142 // folder, open a text file in the editor (read-only in
1143 // read-only folders), or preview media.
1144 if let Some(rid) = root_id {
1145 v.push(MenuItem::new(
1146 if e.is_dir {
1147 IconName::FolderOpen
1148 } else {
1149 IconName::Open
1150 },
1151 i18n::t(i18n::k::OPEN),
1152 open_entry(e, rid, loc, is_rw, open_file),
1153 ));
1154 }
1155 if !e.is_dir {
1156 // Serve the file itself in a new tab. HTML and SVG render
1157 // as real pages there (sandboxed by the server), which is
1158 // how you share a small site or an HTML report.
1159 v.push(MenuItem::new(
1160 IconName::OpenInNew,
1161 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
1162 action_open_tab(e, root_id, loc, owner.clone()),
1163 ));
1164 }
1165 v.push(MenuItem::new(
1166 IconName::Download,
1167 i18n::t(i18n::k::DOWNLOAD),
1168 action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
1169 ));
1170 v.push(MenuItem::rw(
1171 IconName::Rename,
1172 i18n::t(i18n::k::RENAME),
1173 is_rw,
1174 action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1175 ));
1176 }
1177 v.push(MenuItem::rw(
1178 IconName::Move,
1179 i18n::t(i18n::k::MOVE),
1180 is_rw,
1181 action_move_copy(
1182 Op::Move,
1183 sel,
1184 root_id,
1185 me,
1186 loc,
1187 refresh,
1188 toast,
1189 set_dialog,
1190 owner.clone(),
1191 ),
1192 ));
1193 // Copying only writes at the destination, so a read-only source
1194 // folder is fine.
1195 v.push(MenuItem::new(
1196 IconName::Copy,
1197 i18n::t(i18n::k::COPY),
1198 action_move_copy(
1199 Op::Copy,
1200 sel,
1201 root_id,
1202 me,
1203 loc,
1204 refresh,
1205 toast,
1206 set_dialog,
1207 owner.clone(),
1208 ),
1209 ));
1210 if let Some(e) = one {
1211 if loc_now.share_token.is_none() {
1212 v.push(MenuItem::new(
1213 IconName::Share,
1214 i18n::t(i18n::k::SHARE),
1215 action_share(
1216 e,
1217 root_id,
1218 loc,
1219 allow_writable_shares,
1220 is_rw,
1221 set_dialog,
1222 owner.clone(),
1223 ),
1224 ));
1225 }
1226 v.push(MenuItem::new(
1227 IconName::Info,
1228 i18n::t(i18n::k::INFO),
1229 action_info(e, loc, root_name, set_dialog, owner.clone()),
1230 ));
1231 }
1232 v.push(MenuItem::rw(
1233 IconName::Delete,
1234 i18n::t(i18n::k::DELETE),
1235 is_rw,
1236 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1237 ));
1238 v
1239 }
1240 }
1241}
1242
1243#[component]
1244fn CtxMenuView(
1245 ctx: ReadSignal<Option<CtxMenu>>,
1246 set_ctx: WriteSignal<Option<CtxMenu>>,
1247 me: ReadSignal<Option<Me>>,
1248 loc: ReadSignal<Location>,
1249 selected: ReadSignal<Vec<Entry>>,
1250 refresh: Callback<()>,
1251 toast: ToastMsg,
1252 set_dialog: WriteSignal<Option<Dialog>>,
1253 open_file: WriteSignal<Option<FileView>>,
1254 owner: Owner,
1255) -> impl IntoView {
1256 // Close on any click or Escape.
1257 {
1258 crate::util::owned_window_listener(leptos::ev::click, move |_| {
1259 set_ctx.set(None);
1260 });
1261 crate::util::owned_window_listener(
1262 leptos::ev::keydown,
1263 move |ev: web_sys::KeyboardEvent| {
1264 if ev.key() == "Escape" {
1265 set_ctx.set(None);
1266 }
1267 },
1268 );
1269 }
1270
1271 // The menu first paints at the cursor point; after mount, this measures
1272 // its actual size and flips it above/left of the cursor when it would
1273 // overflow the viewport (native context-menu behavior), clamping to the
1274 // viewport edges as a fallback.
1275 let menu_ref = NodeRef::<leptos::html::Div>::new();
1276 Effect::new(move |_| {
1277 let Some((mx, my)) = ctx.get() else {
1278 return;
1279 };
1280 let Some(node) = menu_ref
1281 .get()
1282 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1283 else {
1284 return;
1285 };
1286 let rect = node.get_bounding_client_rect();
1287 let (w, h) = (rect.width() as i32, rect.height() as i32);
1288 let (vw, vh) = web_sys::window()
1289 .and_then(|w| {
1290 let vw = w.inner_width().ok()?.as_f64()?;
1291 let vh = w.inner_height().ok()?.as_f64()?;
1292 Some((vw, vh))
1293 })
1294 .unwrap_or((f64::MAX, f64::MAX));
1295 let (vw, vh) = (vw as i32, vh as i32);
1296 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1297 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1298 let _ = node.style().set_property("left", &format!("{x}px"));
1299 let _ = node.style().set_property("top", &format!("{y}px"));
1300 });
1301
1302 view! {
1303 {move || {
1304 let Some((mx, my)) = ctx.get() else {
1305 return {
1306 view! {}.into_any()
1307 };
1308 };
1309 let items = selected.with(|sel| {
1310 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1311 });
1312
1313 view! {
1314 <div
1315 class="ctx-menu"
1316 node_ref=menu_ref
1317 style=format!("left:{mx}px;top:{my}px")
1318 >
1319 {items.into_iter().map(|item| {
1320 view! { <CtxItem item=item/> }
1321 }).collect::<Vec<_>>()}
1322 </div>
1323 }
1324 .into_view()
1325 .into_any()
1326 }}
1327 }
1328}
1329
1330/// One row of the context menu. `action` is `None` for a disabled row, and
1331/// `hint` then says why it is disabled.
1332#[derive(Clone)]
1333struct MenuItem {
1334 label: &'static str,
1335 icon: IconName,
1336 action: Option<Callback<()>>,
1337 hint: Option<&'static str>,
1338}
1339
1340impl MenuItem {
1341 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1342 Self {
1343 label,
1344 icon,
1345 action: Some(action),
1346 hint: None,
1347 }
1348 }
1349
1350 /// An item that only works in a writable folder.
1351 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1352 Self {
1353 label,
1354 icon,
1355 action: is_rw.then_some(action),
1356 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1357 }
1358 }
1359}
1360
1361#[component]
1362fn CtxItem(item: MenuItem) -> impl IntoView {
1363 let MenuItem {
1364 label,
1365 icon,
1366 action,
1367 hint,
1368 } = item;
1369 let enabled = action.is_some();
1370 let hint = hint.unwrap_or_default();
1371 view! {
1372 <div
1373 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1374 title=move || if enabled { "".to_string() } else { hint.to_string() }
1375 on:click=move |_| {
1376 if let Some(a) = &action {
1377 a.run(());
1378 }
1379 }
1380 >
1381 <Icon name=icon class="ic-ctx".to_string()/>
1382 <span>{label}</span>
1383 </div>
1384 }
1385}
1386
1387// ---------------------------------------------------------------------------
1388// Toolbar selection actions (top right)
1389// ---------------------------------------------------------------------------
1390
1391/// Icon-only buttons for the current selection — the same set of actions
1392/// as its right-click menu — or the empty-area actions (new folder,
1393/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1394/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1395#[component]
1396pub fn SelectionActions(
1397 me: ReadSignal<Option<Me>>,
1398 loc: ReadSignal<Location>,
1399 selected: ReadSignal<Vec<Entry>>,
1400 open_file: WriteSignal<Option<FileView>>,
1401 refresh: Callback<()>,
1402 toast: ToastMsg,
1403 set_dialog: WriteSignal<Option<Dialog>>,
1404 owner: Owner,
1405 topbar_ref: NodeRef<leptos::html::Header>,
1406) -> impl IntoView {
1407 // How many buttons are shown before the overflow kicks in. Starts small
1408 // so the first paint never overflows; the effect below widens it as soon
1409 // as the real widths are measurable.
1410 let (visible_n, set_visible_n) = signal(3usize);
1411 // The three-dot overflow menu state. Any outside click or Escape closes
1412 // it; a new selection does too. The toggle button stops propagation so
1413 // it can actually open the menu.
1414 let (more_open, set_more_open) = signal(false);
1415 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
1416 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
1417 if ev.key() == "Escape" {
1418 set_more_open.set(false);
1419 }
1420 });
1421 Effect::new(move |_| {
1422 let _ = selected.get();
1423 set_more_open.set(false);
1424 });
1425 let group_ref = NodeRef::<leptos::html::Div>::new();
1426
1427 let recompute = move || {
1428 let Some(tb) = topbar_ref
1429 .get()
1430 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1431 else {
1432 return;
1433 };
1434 let Some(g) = group_ref
1435 .get()
1436 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1437 else {
1438 return;
1439 };
1440 // Every button is icon-only with identical padding, so one
1441 // rendered button gives the width of all of them.
1442 let Some(btn) = g.first_element_child() else {
1443 return;
1444 };
1445 // Available width: from the top bar's left content (brand) to its
1446 // right edge — independent of where the group currently sits,
1447 // so the measurement stays valid even while the group overflows.
1448 let tb_r = tb.get_bounding_client_rect();
1449 let start = tb
1450 .query_selector(".brand")
1451 .ok()
1452 .flatten()
1453 .map(|b| b.get_bounding_client_rect())
1454 .map(|r| r.right() + 16.0)
1455 .unwrap_or(0.0);
1456 let avail = (tb_r.right() - start - 10.0).max(40.0);
1457 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1458 // How many buttons fit (not counting the overflow button).
1459 set_visible_n.set(((avail / step) as usize).max(1));
1460 };
1461 // Measuring forces three layout reflows, so it must not run on every
1462 // selection change. Only how many buttons *fit* is measured, and that
1463 // changes when the group appears, disappears, or switches between the
1464 // empty-area, single and multi action sets — not per selected entry.
1465 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1466 Effect::new(move |_| {
1467 topbar_ref.get();
1468 group_ref.get();
1469 // Re-measure when the item set (selection kind, user) or width
1470 // inputs change.
1471 sel_kind.get();
1472 me.track();
1473 recompute();
1474 });
1475 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1476
1477 view! {
1478 <div class="sel-actions" node_ref=group_ref>
1479 // The visible buttons and the overflow rows are one item list
1480 // split at the measured count, so the actions are computed once.
1481 {move || {
1482 // No resolvable root (e.g. the `#/` "select a folder" empty
1483 // state): there is nowhere to act, so hide the whole group
1484 // rather than show a row of disabled buttons.
1485 let has_root = me.with(|m| {
1486 m.as_ref()
1487 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1488 });
1489 if !has_root {
1490 return {
1491 view! {}.into_any()
1492 };
1493 }
1494 let items = selected.with(|sel| {
1495 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1496 });
1497 let fit = visible_n.get();
1498 let n = if fit >= items.len() {
1499 items.len()
1500 } else {
1501 // The overflow button takes a slot too, so drop one —
1502 // otherwise the group eats into the space after the brand.
1503 (fit - 1).max(1)
1504 };
1505 let (visible, more) = items.split_at(n);
1506 let visible_view = visible
1507 .iter()
1508 .cloned()
1509 .map(|i| view! {
1510 <SelButton item=i/>
1511 })
1512 .collect_view();
1513 // Rendered as nothing while every action fits.
1514 let more_block = (!more.is_empty()).then(|| {
1515 let more_view = more
1516 .iter()
1517 .cloned()
1518 .map(|i| view! {
1519 <CtxItem item=i/>
1520 })
1521 .collect_view();
1522 view! {
1523 <div class="sel-more-wrap">
1524 <button
1525 class="icon-btn"
1526 title=i18n::tr(i18n::k::MORE_ACTIONS)
1527 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1528 on:click=move |ev: MouseEvent| {
1529 ev.stop_propagation();
1530 set_more_open.set(!more_open.get());
1531 }
1532 >
1533 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1534 </button>
1535 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1536 {more_view}
1537 </div>
1538 </div>
1539 }
1540 });
1541 view! {
1542 {visible_view}
1543 {more_block}
1544 }
1545 .into_view()
1546 .into_any()
1547 }}
1548 </div>
1549 }
1550}
1551
1552/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1553/// then explains why.
1554#[component]
1555fn SelButton(item: MenuItem) -> impl IntoView {
1556 let enabled = item.action.is_some();
1557 let title = if enabled {
1558 item.label.to_string()
1559 } else {
1560 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1561 };
1562 view! {
1563 <button
1564 class="icon-btn"
1565 disabled=!enabled
1566 title=title
1567 on:click=move |_| {
1568 if let Some(a) = &item.action {
1569 a.run(());
1570 }
1571 }
1572 >
1573 <Icon name=item.icon class="ic-btn".to_string()/>
1574 </button>
1575 }
1576}
1577
1578// ---------------------------------------------------------------------------
1579// Menu actions
1580// ---------------------------------------------------------------------------
1581
1582fn join_path(dir: &[String], name: &str) -> String {
1583 if dir.is_empty() {
1584 name.to_string()
1585 } else {
1586 format!("{}/{}", dir.join("/"), name)
1587 }
1588}
1589
1590fn is_conflict(e: &api::ApiError) -> bool {
1591 matches!(e, api::ApiError::Http { status: 409, .. })
1592}
1593
1594/// A file operation in flight. Boxed because each caller builds a different
1595/// future, and they all have to fit one type.
1596type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1597
1598/// Run a file operation that may collide with an existing name.
1599///
1600/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1601/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1602/// upload all need exactly this, so it lives here once.
1603///
1604/// `names` is what to list in the dialog when the server does not say. Upload
1605/// reports the exact set it skipped; the single-item operations know theirs
1606/// before they ask.
1607fn run_with_overwrite_retry(
1608 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1609 conflict_title: &'static str,
1610 names: Vec<String>,
1611 ok_msg: &'static str,
1612 refresh: Callback<()>,
1613 toast: ToastMsg,
1614 set_dialog: WriteSignal<Option<Dialog>>,
1615) {
1616 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1617 show(toast, ok_msg);
1618 refresh.run(());
1619 }
1620
1621 let retry = op.clone();
1622 spawn_local(async move {
1623 let Err(e) = op(false).await else {
1624 done(ok_msg, refresh, toast);
1625 return;
1626 };
1627 let conflicting = e
1628 .skipped()
1629 .map(<[String]>::to_vec)
1630 .or_else(|| is_conflict(&e).then_some(names));
1631 let Some(files) = conflicting else {
1632 show_error(toast, e.to_string());
1633 return;
1634 };
1635 set_dialog.set(Some(Dialog::Conflict {
1636 title: conflict_title.to_string(),
1637 files,
1638 on_submit: Callback::new(move |_| {
1639 let retry = retry.clone();
1640 spawn_local(async move {
1641 match retry(true).await {
1642 Ok(()) => done(ok_msg, refresh, toast),
1643 Err(e) => show_error(toast, e.to_string()),
1644 }
1645 });
1646 }),
1647 }));
1648 });
1649}
1650
1651fn action_new_folder(
1652 root_id: Option<i64>,
1653 loc: ReadSignal<Location>,
1654 refresh: Callback<()>,
1655 toast: ToastMsg,
1656 set_dialog: WriteSignal<Option<Dialog>>,
1657 owner: Owner,
1658) -> Callback<()> {
1659 Callback::new(move |_| {
1660 let Some(root_id) = root_id else { return };
1661 let loc = loc.get();
1662 let dir = loc.path.clone();
1663 let toast2 = toast;
1664 let refresh2 = refresh;
1665 set_dialog.set(Some(Dialog::PromptOp {
1666 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
1667 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
1668 initial: String::new(),
1669 submit: i18n::t(i18n::k::CREATE).to_string(),
1670 run: owner.with(|| {
1671 Callback::new(move |name: String| {
1672 let full = join_path(&dir, &name);
1673 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1674 api::mkdir(root_id, &full)
1675 .await
1676 .map(|_| ())
1677 .map_err(|e| e.to_string())
1678 });
1679 fut
1680 })
1681 }),
1682 on_ok: owner.with(|| {
1683 Callback::new(move |_| {
1684 show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
1685 refresh2.run(());
1686 })
1687 }),
1688 }));
1689 })
1690}
1691
1692fn action_new_file(
1693 root_id: Option<i64>,
1694 loc: ReadSignal<Location>,
1695 refresh: Callback<()>,
1696 toast: ToastMsg,
1697 set_dialog: WriteSignal<Option<Dialog>>,
1698 owner: Owner,
1699) -> Callback<()> {
1700 Callback::new(move |_| {
1701 let Some(root_id) = root_id else { return };
1702 let loc = loc.get();
1703 let dir = loc.path.clone();
1704 let toast2 = toast;
1705 let refresh2 = refresh;
1706 set_dialog.set(Some(Dialog::PromptOp {
1707 title: i18n::t(i18n::k::NEW_FILE).to_string(),
1708 label: i18n::t(i18n::k::FILE_NAME).to_string(),
1709 initial: String::new(),
1710 submit: i18n::t(i18n::k::CREATE).to_string(),
1711 run: owner.with(|| {
1712 Callback::new(move |name: String| {
1713 let full = join_path(&dir, &name);
1714 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1715 api::create_file(root_id, &full)
1716 .await
1717 .map(|_| ())
1718 .map_err(|e| e.to_string())
1719 });
1720 fut
1721 })
1722 }),
1723 on_ok: owner.with(|| {
1724 Callback::new(move |_| {
1725 show(toast2, i18n::t(i18n::k::FILE_CREATED));
1726 refresh2.run(());
1727 })
1728 }),
1729 }));
1730 })
1731}
1732
1733fn action_rename(
1734 entry: &Entry,
1735 root_id: Option<i64>,
1736 loc: ReadSignal<Location>,
1737 refresh: Callback<()>,
1738 toast: ToastMsg,
1739 set_dialog: WriteSignal<Option<Dialog>>,
1740 owner: Owner,
1741) -> Callback<()> {
1742 let name = entry.name.clone();
1743 Callback::new(move |_| {
1744 let Some(root_id) = root_id else { return };
1745 let loc = loc.get();
1746 let dir = loc.path.clone();
1747 let full = join_path(&dir, &name);
1748 set_dialog.set(Some(Dialog::PromptOp {
1749 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
1750 label: i18n::t(i18n::k::NEW_NAME).to_string(),
1751 initial: name.clone(),
1752 submit: i18n::t(i18n::k::RENAME).to_string(),
1753 run: owner.with(|| {
1754 Callback::new(move |new_name: String| {
1755 let full = full.clone();
1756 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1757 api::rename_item(root_id, &full, new_name, false)
1758 .await
1759 .map(|_| ())
1760 .map_err(|e| e.to_string())
1761 });
1762 fut
1763 })
1764 }),
1765 on_ok: owner.with(|| {
1766 Callback::new(move |_| {
1767 show(toast, i18n::t(i18n::k::RENAMED));
1768 refresh.run(());
1769 })
1770 }),
1771 }));
1772 })
1773}
1774
1775/// Delete the entries after one confirmation. Several are deleted one by
1776/// one; the first failure stops and reports.
1777fn action_delete(
1778 entries: &[Entry],
1779 root_id: Option<i64>,
1780 loc: ReadSignal<Location>,
1781 refresh: Callback<()>,
1782 toast: ToastMsg,
1783 set_dialog: WriteSignal<Option<Dialog>>,
1784 owner: Owner,
1785) -> Callback<()> {
1786 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
1787 Callback::new(move |_| {
1788 let Some(root_id) = root_id else { return };
1789 let dir = loc.get().path;
1790 let message = match items.as_slice() {
1791 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
1792 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
1793 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
1794 };
1795 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
1796 set_dialog.set(Some(Dialog::Confirm {
1797 title: i18n::t(i18n::k::DELETE).to_string(),
1798 message,
1799 submit: i18n::t(i18n::k::DELETE).to_string(),
1800 danger: true,
1801 on_submit: owner.with(|| {
1802 Callback::new(move |_| {
1803 set_dialog.set(None);
1804 let fulls = fulls.clone();
1805 spawn_local(async move {
1806 for f in &fulls {
1807 if let Err(e) = api::delete_item(root_id, f).await {
1808 show_error(toast, e.to_string());
1809 refresh.run(());
1810 return;
1811 }
1812 }
1813 show(toast, i18n::t(i18n::k::DELETED));
1814 refresh.run(());
1815 });
1816 })
1817 }),
1818 }));
1819 })
1820}
1821
1822fn action_upload(
1823 multiple: bool,
1824 directory: bool,
1825 root_id: Option<i64>,
1826 loc: ReadSignal<Location>,
1827 refresh: Callback<()>,
1828 toast: ToastMsg,
1829 set_dialog: WriteSignal<Option<Dialog>>,
1830) -> Callback<()> {
1831 Callback::new(move |_| {
1832 let Some(root_id) = root_id else { return };
1833 let loc = loc.get();
1834 let dir = loc.path.join("/");
1835 let dir1 = dir.clone();
1836 api::pick_files(multiple, directory, move |files| {
1837 let n = files.len();
1838 let dir = dir1.clone();
1839 show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
1840 run_with_overwrite_retry(
1841 move |overwrite| {
1842 let (dir, files) = (dir.clone(), files.clone());
1843 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
1844 },
1845 i18n::t(i18n::k::SOME_FILES_EXIST),
1846 // Unused: an upload conflict always names the skipped files.
1847 Vec::new(),
1848 i18n::t(i18n::k::UPLOAD_COMPLETE),
1849 refresh,
1850 toast,
1851 set_dialog,
1852 );
1853 });
1854 })
1855}
1856
1857/// Move or copy the entries to a folder chosen in the picker. Several are
1858/// processed one by one; name collisions are collected and asked about once,
1859/// and the overwrite retry re-runs only the items that collided.
1860#[allow(clippy::too_many_arguments)] // explicit signal props
1861fn action_move_copy(
1862 op: Op,
1863 entries: &[Entry],
1864 root_id: Option<i64>,
1865 me: ReadSignal<Option<Me>>,
1866 loc: ReadSignal<Location>,
1867 refresh: Callback<()>,
1868 toast: ToastMsg,
1869 set_dialog: WriteSignal<Option<Dialog>>,
1870 owner: Owner,
1871) -> Callback<()> {
1872 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
1873 Callback::new(move |_| {
1874 let loc = loc.get();
1875 let Some(root_id) = root_id else { return };
1876 let roots = me.get().map(|m| m.roots).unwrap_or_default();
1877 let dir = loc.path.clone();
1878 let title = match names.as_slice() {
1879 [one] => i18n::t_fmt(op.title_key(1), one),
1880 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
1881 };
1882 // (name, path relative to the root) per item.
1883 let items: Vec<(String, String)> = names
1884 .iter()
1885 .map(|n| (n.clone(), join_path(&dir, n)))
1886 .collect();
1887 set_dialog.set(Some(Dialog::Picker {
1888 title,
1889 confirm: i18n::t(op.here_key()).to_string(),
1890 roots,
1891 root: root_id,
1892 dir: dir.join("/"),
1893 for_write: true,
1894 on_pick: owner.with(|| {
1895 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1896 // Items still to do. A finished item is dropped, so the
1897 // overwrite retry re-runs only the ones that collided.
1898 let pending = Arc::new(Mutex::new(items.clone()));
1899 run_with_overwrite_retry(
1900 move |overwrite| {
1901 let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
1902 Box::pin(async move {
1903 let items = pending.lock().unwrap().clone();
1904 let mut conflicts = Vec::new();
1905 for (name, full) in items {
1906 let res = if op == Op::Move {
1907 api::move_item(
1908 root_id, &full, dst_root, &dst_dir, overwrite,
1909 )
1910 .await
1911 } else {
1912 api::copy_item(
1913 root_id, &full, dst_root, &dst_dir, overwrite,
1914 )
1915 .await
1916 };
1917 match res {
1918 Ok(_) => {
1919 pending.lock().unwrap().retain(|(_, p)| *p != full)
1920 }
1921 Err(e) if is_conflict(&e) => conflicts.push(name),
1922 Err(e) => return Err(e),
1923 }
1924 }
1925 if conflicts.is_empty() {
1926 Ok(())
1927 } else {
1928 Err(api::ApiError::Http {
1929 status: 409,
1930 message: String::new(),
1931 skipped: Some(conflicts),
1932 })
1933 }
1934 })
1935 },
1936 i18n::t(i18n::k::NAME_TAKEN),
1937 // Unused: the conflict error above always lists the names.
1938 Vec::new(),
1939 if op == Op::Move {
1940 i18n::t(i18n::k::MOVED)
1941 } else {
1942 i18n::t(i18n::k::COPIED)
1943 },
1944 refresh,
1945 toast,
1946 set_dialog,
1947 );
1948 })
1949 }),
1950 }));
1951 })
1952}
1953
1954fn action_download(
1955 entry: &Entry,
1956 root_id: Option<i64>,
1957 loc: ReadSignal<Location>,
1958 set_dialog: WriteSignal<Option<Dialog>>,
1959 toast: ToastMsg,
1960 owner: Owner,
1961) -> Callback<()> {
1962 let name = entry.name.clone();
1963 let is_dir = entry.is_dir;
1964 owner.with(|| {
1965 Callback::new(move |_| {
1966 let Some(root_id) = root_id else { return };
1967 let loc = loc.get();
1968 let full = join_path(&loc.path, &name);
1969 if is_dir {
1970 set_dialog.set(Some(Dialog::DownloadFormat {
1971 name: name.clone(),
1972 root_id,
1973 path: full,
1974 }));
1975 } else {
1976 let url = api::download_url(root_id, &full, None);
1977 api::trigger_download(&url, &name);
1978 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
1979 }
1980 })
1981 })
1982}
1983
1984/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1985/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1986fn action_open_tab(
1987 entry: &Entry,
1988 root_id: Option<i64>,
1989 loc: ReadSignal<Location>,
1990 owner: Owner,
1991) -> Callback<()> {
1992 let name = entry.name.clone();
1993 owner.with(|| {
1994 Callback::new(move |_| {
1995 let Some(root_id) = root_id else { return };
1996 let full = join_path(&loc.get().path, &name);
1997 api::open_in_new_tab(&api::preview_url(root_id, &full));
1998 })
1999 })
2000}
2001
2002/// Open the share dialog for an item (milestone 6).
2003fn action_share(
2004 entry: &Entry,
2005 root_id: Option<i64>,
2006 loc: ReadSignal<Location>,
2007 allow_writable: bool,
2008 root_writable: bool,
2009 set_dialog: WriteSignal<Option<Dialog>>,
2010 owner: Owner,
2011) -> Callback<()> {
2012 let name = entry.name.clone();
2013 owner.with(|| {
2014 Callback::new(move |_| {
2015 let Some(root_id) = root_id else { return };
2016 let loc = loc.get();
2017 let full = join_path(&loc.path, &name);
2018 set_dialog.set(Some(Dialog::Share {
2019 name: name.clone(),
2020 root_id,
2021 path: full,
2022 allow_writable,
2023 root_writable,
2024 }));
2025 })
2026 })
2027}
2028
2029fn action_info(
2030 entry: &Entry,
2031 loc: ReadSignal<Location>,
2032 root_name: String,
2033 set_dialog: WriteSignal<Option<Dialog>>,
2034 owner: Owner,
2035) -> Callback<()> {
2036 let entry2 = entry.clone();
2037 owner.with(|| {
2038 Callback::new(move |_| {
2039 let loc = loc.get();
2040 let full = join_path(&loc.path, &entry2.name);
2041 set_dialog.set(Some(Dialog::Info {
2042 entry: entry2.clone(),
2043 root_name: root_name.clone(),
2044 rel_path: full,
2045 }));
2046 })
2047 })
2048}
2049