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