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 // An empty `alt` means a 404 renders
1337 // as nothing, leaving the icon. No
1338 // error handler is needed.
1339 {thumb_src.map(|src| view! {
1340 <img
1341 class="tile-thumb"
1342 src=src
1343 alt=""
1344 loading="lazy"
1345 decoding="async"
1346 />
1347 })}
1348 </div>
1349 <div class="tile-name">{name}</div>
1350 }.into_any()
1351 } else {
1352 view! {
1353 {icon_svg(icon, icon_class)}
1354 <span class="row-name">{name}</span>
1355 <span class="row-size muted">{size}</span>
1356 <span class="row-date muted">{date}</span>
1357 }.into_any()
1358 }}
1359 </div>
1360 }
1361 }
1362 />
1363 </div>
1364 }
1365}
1366
1367// ---------------------------------------------------------------------------
1368// Context menu
1369// ---------------------------------------------------------------------------
1370
1371/// Build the action list for the selection: the empty area (nothing
1372/// selected), one entry, or several. Shared by the right-click context menu
1373/// and the toolbar's selection actions so both always offer the same
1374/// operations.
1375#[allow(clippy::too_many_arguments)] // explicit signal props
1376fn menu_items(
1377 sel: &[Entry],
1378 me: ReadSignal<Option<Me>>,
1379 loc: ReadSignal<Location>,
1380 open_file: WriteSignal<Option<FileView>>,
1381 refresh: Callback<()>,
1382 toast: ToastMsg,
1383 dialog: ReadSignal<Option<Dialog>>,
1384 set_dialog: WriteSignal<Option<Dialog>>,
1385 owner: Owner,
1386) -> Vec<MenuItem> {
1387 let loc_now = loc.get();
1388 // The root that is actually shown. Resolves the single-root default
1389 // location, where `loc.root_id` is `None`. Read in place: cloning `me`
1390 // would copy its whole root list on every selection change.
1391 let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
1392 let root = me_now
1393 .as_ref()
1394 .and_then(|m| effective_root(&m.roots, &loc_now));
1395 (
1396 root.map(|r| r.mode.is_writable()).unwrap_or(false),
1397 root.map(|r| r.name.clone()).unwrap_or_default(),
1398 root.map(|r| r.id),
1399 me_now
1400 .as_ref()
1401 .map(|m| m.allow_writable_shares)
1402 .unwrap_or(false),
1403 )
1404 });
1405
1406 match sel {
1407 [] => {
1408 let mut v = vec![
1409 MenuItem::rw(
1410 IconName::NewFolder,
1411 i18n::t(i18n::k::NEW_FOLDER),
1412 is_rw,
1413 action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
1414 ),
1415 MenuItem::rw(
1416 IconName::NewFile,
1417 i18n::t(i18n::k::NEW_FILE),
1418 is_rw,
1419 action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
1420 ),
1421 MenuItem::rw(
1422 IconName::Upload,
1423 i18n::t(i18n::k::UPLOAD_FILES),
1424 is_rw,
1425 action_upload(
1426 true,
1427 false,
1428 root_id,
1429 loc,
1430 toast,
1431 dialog,
1432 set_dialog,
1433 owner.clone(),
1434 ),
1435 ),
1436 MenuItem::rw(
1437 IconName::UploadFolder,
1438 i18n::t(i18n::k::UPLOAD_FOLDER),
1439 is_rw,
1440 action_upload(
1441 true,
1442 true,
1443 root_id,
1444 loc,
1445 toast,
1446 dialog,
1447 set_dialog,
1448 owner.clone(),
1449 ),
1450 ),
1451 ];
1452 // Sharing and search are signed-in features; a share visitor has
1453 // neither, and `SessionUser` refuses a share token outright.
1454 if loc_now.share_token.is_none() {
1455 v.push(MenuItem::new(
1456 IconName::Share,
1457 i18n::t(i18n::k::SHARE),
1458 action_share(
1459 None,
1460 root_id,
1461 loc,
1462 root_name.clone(),
1463 allow_writable_shares,
1464 is_rw,
1465 set_dialog,
1466 owner.clone(),
1467 ),
1468 ));
1469 }
1470 if let Some(rid) = root_id
1471 && loc_now.share_token.is_none()
1472 {
1473 let dir = loc_now.path.join("/");
1474 v.push(MenuItem::new(
1475 IconName::Search,
1476 i18n::t(i18n::k::SEARCH_IN_FOLDER),
1477 owner.with(|| {
1478 Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
1479 }),
1480 ));
1481 }
1482 v
1483 }
1484 // One entry gets every action; several get only the ones that take
1485 // a set (move, copy, delete).
1486 _ => {
1487 let one = if let [e] = sel { Some(e) } else { None };
1488 let mut v = vec![];
1489 if let Some(e) = one {
1490 // Exactly what left-clicking the entry does: descend into a
1491 // folder, open a text file in the editor (read-only in
1492 // read-only folders), or preview media.
1493 if let Some(rid) = root_id {
1494 v.push(MenuItem::new(
1495 if e.is_dir {
1496 IconName::FolderOpen
1497 } else {
1498 IconName::Open
1499 },
1500 i18n::t(i18n::k::OPEN),
1501 open_entry(e, rid, loc, is_rw, open_file),
1502 ));
1503 }
1504 if !e.is_dir {
1505 // Serve the file itself in a new tab. HTML and SVG render
1506 // as real pages there (sandboxed by the server), which is
1507 // how you share a small site or an HTML report.
1508 v.push(MenuItem::new(
1509 IconName::OpenInNew,
1510 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
1511 action_open_tab(e, root_id, loc, owner.clone()),
1512 ));
1513 }
1514 v.push(MenuItem::new(
1515 IconName::Download,
1516 i18n::t(i18n::k::DOWNLOAD),
1517 action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
1518 ));
1519 v.push(MenuItem::rw(
1520 IconName::Rename,
1521 i18n::t(i18n::k::RENAME),
1522 is_rw,
1523 action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1524 ));
1525 }
1526 v.push(MenuItem::rw(
1527 IconName::Move,
1528 i18n::t(i18n::k::MOVE),
1529 is_rw,
1530 action_move_copy(
1531 Op::Move,
1532 sel,
1533 root_id,
1534 me,
1535 loc,
1536 refresh,
1537 toast,
1538 dialog,
1539 set_dialog,
1540 owner.clone(),
1541 ),
1542 ));
1543 // Copying only writes at the destination, so a read-only source
1544 // folder is fine.
1545 v.push(MenuItem::new(
1546 IconName::Copy,
1547 i18n::t(i18n::k::COPY),
1548 action_move_copy(
1549 Op::Copy,
1550 sel,
1551 root_id,
1552 me,
1553 loc,
1554 refresh,
1555 toast,
1556 dialog,
1557 set_dialog,
1558 owner.clone(),
1559 ),
1560 ));
1561 if let Some(e) = one {
1562 if loc_now.share_token.is_none() {
1563 v.push(MenuItem::new(
1564 IconName::Share,
1565 i18n::t(i18n::k::SHARE),
1566 action_share(
1567 Some(e),
1568 root_id,
1569 loc,
1570 root_name.clone(),
1571 allow_writable_shares,
1572 is_rw,
1573 set_dialog,
1574 owner.clone(),
1575 ),
1576 ));
1577 }
1578 v.push(MenuItem::new(
1579 IconName::Info,
1580 i18n::t(i18n::k::INFO),
1581 action_info(e, loc, root_name, set_dialog, owner.clone()),
1582 ));
1583 }
1584 v.push(MenuItem::rw(
1585 IconName::Delete,
1586 i18n::t(i18n::k::DELETE),
1587 is_rw,
1588 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1589 ));
1590 v
1591 }
1592 }
1593}
1594
1595#[component]
1596fn CtxMenuView(
1597 ctx: ReadSignal<Option<CtxMenu>>,
1598 set_ctx: WriteSignal<Option<CtxMenu>>,
1599 me: ReadSignal<Option<Me>>,
1600 loc: ReadSignal<Location>,
1601 selected: ReadSignal<Vec<Entry>>,
1602 refresh: Callback<()>,
1603 toast: ToastMsg,
1604 dialog: ReadSignal<Option<Dialog>>,
1605 set_dialog: WriteSignal<Option<Dialog>>,
1606 open_file: WriteSignal<Option<FileView>>,
1607 owner: Owner,
1608) -> impl IntoView {
1609 // Close on any click or Escape.
1610 {
1611 crate::util::owned_window_listener(leptos::ev::click, move |_| {
1612 set_ctx.set(None);
1613 });
1614 crate::util::owned_window_listener(
1615 leptos::ev::keydown,
1616 move |ev: web_sys::KeyboardEvent| {
1617 if ev.key() == "Escape" {
1618 set_ctx.set(None);
1619 }
1620 },
1621 );
1622 }
1623
1624 // The menu first paints at the cursor point; after mount, this measures
1625 // its actual size and flips it above/left of the cursor when it would
1626 // overflow the viewport (native context-menu behavior), clamping to the
1627 // viewport edges as a fallback.
1628 let menu_ref = NodeRef::<leptos::html::Div>::new();
1629 Effect::new(move |_| {
1630 let Some((mx, my)) = ctx.get() else {
1631 return;
1632 };
1633 let Some(node) = menu_ref
1634 .get()
1635 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1636 else {
1637 return;
1638 };
1639 let rect = node.get_bounding_client_rect();
1640 let (w, h) = (rect.width() as i32, rect.height() as i32);
1641 let (vw, vh) = web_sys::window()
1642 .and_then(|w| {
1643 let vw = w.inner_width().ok()?.as_f64()?;
1644 let vh = w.inner_height().ok()?.as_f64()?;
1645 Some((vw, vh))
1646 })
1647 .unwrap_or((f64::MAX, f64::MAX));
1648 let (vw, vh) = (vw as i32, vh as i32);
1649 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1650 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1651 let _ = node.style().set_property("left", &format!("{x}px"));
1652 let _ = node.style().set_property("top", &format!("{y}px"));
1653 });
1654
1655 view! {
1656 {move || {
1657 let Some((mx, my)) = ctx.get() else {
1658 return {
1659 view! {}.into_any()
1660 };
1661 };
1662 let items = selected.with(|sel| {
1663 menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
1664 });
1665
1666 view! {
1667 <div
1668 class="ctx-menu"
1669 node_ref=menu_ref
1670 style=format!("left:{mx}px;top:{my}px")
1671 >
1672 {items.into_iter().map(|item| {
1673 view! { <CtxItem item=item/> }
1674 }).collect::<Vec<_>>()}
1675 </div>
1676 }
1677 .into_view()
1678 .into_any()
1679 }}
1680 }
1681}
1682
1683/// One row of the context menu. `action` is `None` for a disabled row, and
1684/// `hint` then says why it is disabled.
1685#[derive(Clone)]
1686struct MenuItem {
1687 label: &'static str,
1688 icon: IconName,
1689 action: Option<Callback<()>>,
1690 hint: Option<&'static str>,
1691}
1692
1693impl MenuItem {
1694 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1695 Self {
1696 label,
1697 icon,
1698 action: Some(action),
1699 hint: None,
1700 }
1701 }
1702
1703 /// An item that only works in a writable folder.
1704 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1705 Self {
1706 label,
1707 icon,
1708 action: is_rw.then_some(action),
1709 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1710 }
1711 }
1712}
1713
1714#[component]
1715fn CtxItem(item: MenuItem) -> impl IntoView {
1716 let MenuItem {
1717 label,
1718 icon,
1719 action,
1720 hint,
1721 } = item;
1722 let enabled = action.is_some();
1723 let hint = hint.unwrap_or_default();
1724 view! {
1725 <div
1726 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1727 title=move || if enabled { "".to_string() } else { hint.to_string() }
1728 on:click=move |_| {
1729 if let Some(a) = &action {
1730 a.run(());
1731 }
1732 }
1733 >
1734 <Icon name=icon class="ic-ctx".to_string()/>
1735 <span>{label}</span>
1736 </div>
1737 }
1738}
1739
1740// ---------------------------------------------------------------------------
1741// Toolbar selection actions (top right)
1742// ---------------------------------------------------------------------------
1743
1744/// Icon-only buttons for the current selection — the same set of actions
1745/// as its right-click menu — or the empty-area actions (new folder,
1746/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1747/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1748#[component]
1749pub fn SelectionActions(
1750 me: ReadSignal<Option<Me>>,
1751 loc: ReadSignal<Location>,
1752 selected: ReadSignal<Vec<Entry>>,
1753 open_file: WriteSignal<Option<FileView>>,
1754 refresh: Callback<()>,
1755 toast: ToastMsg,
1756 dialog: ReadSignal<Option<Dialog>>,
1757 set_dialog: WriteSignal<Option<Dialog>>,
1758 owner: Owner,
1759 topbar_ref: NodeRef<leptos::html::Header>,
1760) -> impl IntoView {
1761 // How many buttons are shown before the overflow kicks in. Starts small
1762 // so the first paint never overflows; the effect below widens it as soon
1763 // as the real widths are measurable.
1764 let (visible_n, set_visible_n) = signal(3usize);
1765 // The three-dot overflow menu state. Any outside click or Escape closes
1766 // it; a new selection does too. The toggle button stops propagation so
1767 // it can actually open the menu.
1768 let (more_open, set_more_open) = signal(false);
1769 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
1770 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
1771 if ev.key() == "Escape" {
1772 set_more_open.set(false);
1773 }
1774 });
1775 Effect::new(move |_| {
1776 let _ = selected.get();
1777 set_more_open.set(false);
1778 });
1779 let group_ref = NodeRef::<leptos::html::Div>::new();
1780
1781 let recompute = move || {
1782 let Some(tb) = topbar_ref
1783 .get()
1784 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1785 else {
1786 return;
1787 };
1788 let Some(g) = group_ref
1789 .get()
1790 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1791 else {
1792 return;
1793 };
1794 // Every button is icon-only with identical padding, so one
1795 // rendered button gives the width of all of them.
1796 let Some(btn) = g.first_element_child() else {
1797 return;
1798 };
1799 // Available width: from the top bar's left content (brand) to its
1800 // right edge — independent of where the group currently sits,
1801 // so the measurement stays valid even while the group overflows.
1802 let tb_r = tb.get_bounding_client_rect();
1803 let start = tb
1804 .query_selector(".brand")
1805 .ok()
1806 .flatten()
1807 .map(|b| b.get_bounding_client_rect())
1808 .map(|r| r.right() + 16.0)
1809 .unwrap_or(0.0);
1810 let avail = (tb_r.right() - start - 10.0).max(40.0);
1811 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1812 // How many buttons fit (not counting the overflow button).
1813 set_visible_n.set(((avail / step) as usize).max(1));
1814 };
1815 // Measuring forces three layout reflows, so it must not run on every
1816 // selection change. Only how many buttons *fit* is measured, and that
1817 // changes when the group appears, disappears, or switches between the
1818 // empty-area, single and multi action sets — not per selected entry.
1819 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1820 Effect::new(move |_| {
1821 topbar_ref.get();
1822 group_ref.get();
1823 // Re-measure when the item set (selection kind, user) or width
1824 // inputs change.
1825 sel_kind.get();
1826 me.track();
1827 recompute();
1828 });
1829 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1830
1831 view! {
1832 <div class="sel-actions" node_ref=group_ref>
1833 // The visible buttons and the overflow rows are one item list
1834 // split at the measured count, so the actions are computed once.
1835 {move || {
1836 // No resolvable root (e.g. the `#/` "select a folder" empty
1837 // state): there is nowhere to act, so hide the whole group
1838 // rather than show a row of disabled buttons.
1839 let has_root = me.with(|m| {
1840 m.as_ref()
1841 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1842 });
1843 if !has_root {
1844 return {
1845 view! {}.into_any()
1846 };
1847 }
1848 let items = selected.with(|sel| {
1849 menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
1850 });
1851 let fit = visible_n.get();
1852 let n = if fit >= items.len() {
1853 items.len()
1854 } else {
1855 // The overflow button takes a slot too, so drop one —
1856 // otherwise the group eats into the space after the brand.
1857 (fit - 1).max(1)
1858 };
1859 let (visible, more) = items.split_at(n);
1860 let visible_view = visible
1861 .iter()
1862 .cloned()
1863 .map(|i| view! {
1864 <SelButton item=i/>
1865 })
1866 .collect_view();
1867 // Rendered as nothing while every action fits.
1868 let more_block = (!more.is_empty()).then(|| {
1869 let more_view = more
1870 .iter()
1871 .cloned()
1872 .map(|i| view! {
1873 <CtxItem item=i/>
1874 })
1875 .collect_view();
1876 view! {
1877 <div class="sel-more-wrap">
1878 <button
1879 class="icon-btn"
1880 title=i18n::tr(i18n::k::MORE_ACTIONS)
1881 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1882 on:click=move |ev: MouseEvent| {
1883 ev.stop_propagation();
1884 set_more_open.set(!more_open.get());
1885 }
1886 >
1887 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1888 </button>
1889 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1890 {more_view}
1891 </div>
1892 </div>
1893 }
1894 });
1895 view! {
1896 {visible_view}
1897 {more_block}
1898 }
1899 .into_view()
1900 .into_any()
1901 }}
1902 </div>
1903 }
1904}
1905
1906/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1907/// then explains why.
1908#[component]
1909fn SelButton(item: MenuItem) -> impl IntoView {
1910 let enabled = item.action.is_some();
1911 let title = if enabled {
1912 item.label.to_string()
1913 } else {
1914 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1915 };
1916 view! {
1917 <button
1918 class="icon-btn"
1919 disabled=!enabled
1920 title=title
1921 on:click=move |_| {
1922 if let Some(a) = &item.action {
1923 a.run(());
1924 }
1925 }
1926 >
1927 <Icon name=item.icon class="ic-btn".to_string()/>
1928 </button>
1929 }
1930}
1931
1932// ---------------------------------------------------------------------------
1933// Menu actions
1934// ---------------------------------------------------------------------------
1935
1936fn join_path(dir: &[String], name: &str) -> String {
1937 if dir.is_empty() {
1938 name.to_string()
1939 } else {
1940 format!("{}/{}", dir.join("/"), name)
1941 }
1942}
1943
1944fn is_conflict(e: &api::ApiError) -> bool {
1945 matches!(e, api::ApiError::Http { status: 409, .. })
1946}
1947
1948/// A file operation in flight. Boxed because each caller builds a different
1949/// future, and they all have to fit one type.
1950type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1951
1952/// Run a file operation that may collide with an existing name.
1953///
1954/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1955/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1956/// upload all need exactly this, so it lives here once.
1957///
1958/// `names` is what to list in the dialog when the server does not say. Upload
1959/// reports the exact set it skipped; the single-item operations know theirs
1960/// before they ask.
1961#[allow(clippy::too_many_arguments)] // explicit signal props
1962fn run_with_overwrite_retry(
1963 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1964 conflict_title: &'static str,
1965 names: Vec<String>,
1966 ok_msg: &'static str,
1967 refresh: Callback<()>,
1968 toast: ToastMsg,
1969 dialog: ReadSignal<Option<Dialog>>,
1970 set_dialog: WriteSignal<Option<Dialog>>,
1971) {
1972 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1973 show(toast, ok_msg);
1974 refresh.run(());
1975 }
1976
1977 let retry = op.clone();
1978 spawn_local(async move {
1979 let Err(e) = op(false).await else {
1980 done(ok_msg, refresh, toast);
1981 return;
1982 };
1983 let conflicting = e
1984 .skipped()
1985 .map(<[String]>::to_vec)
1986 .or_else(|| is_conflict(&e).then_some(names));
1987 let Some(files) = conflicting else {
1988 show_error(toast, e.to_string());
1989 return;
1990 };
1991 crate::views::dialogs::claim_async_dialog(dialog).await;
1992 set_dialog.set(Some(Dialog::Conflict {
1993 title: conflict_title.to_string(),
1994 files,
1995 on_submit: Callback::new(move |_| {
1996 let retry = retry.clone();
1997 spawn_local(async move {
1998 match retry(true).await {
1999 Ok(()) => done(ok_msg, refresh, toast),
2000 Err(e) => show_error(toast, e.to_string()),
2001 }
2002 });
2003 }),
2004 }));
2005 });
2006}
2007
2008fn action_new_folder(
2009 root_id: Option<i64>,
2010 loc: ReadSignal<Location>,
2011 refresh: Callback<()>,
2012 toast: ToastMsg,
2013 set_dialog: WriteSignal<Option<Dialog>>,
2014 owner: Owner,
2015) -> Callback<()> {
2016 Callback::new(move |_| {
2017 let Some(root_id) = root_id else { return };
2018 let loc = loc.get();
2019 let dir = loc.path.clone();
2020 let toast2 = toast;
2021 let refresh2 = refresh;
2022 set_dialog.set(Some(Dialog::PromptOp {
2023 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
2024 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
2025 initial: String::new(),
2026 submit: i18n::t(i18n::k::CREATE).to_string(),
2027 run: owner.with(|| {
2028 Callback::new(move |name: String| {
2029 let full = join_path(&dir, &name);
2030 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
2031 api::mkdir(root_id, &full)
2032 .await
2033 .map(|_| ())
2034 .map_err(|e| e.to_string())
2035 });
2036 fut
2037 })
2038 }),
2039 on_ok: owner.with(|| {
2040 Callback::new(move |_| {
2041 show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
2042 refresh2.run(());
2043 })
2044 }),
2045 }));
2046 })
2047}
2048
2049fn action_new_file(
2050 root_id: Option<i64>,
2051 loc: ReadSignal<Location>,
2052 refresh: Callback<()>,
2053 toast: ToastMsg,
2054 set_dialog: WriteSignal<Option<Dialog>>,
2055 owner: Owner,
2056) -> Callback<()> {
2057 Callback::new(move |_| {
2058 let Some(root_id) = root_id else { return };
2059 let loc = loc.get();
2060 let dir = loc.path.clone();
2061 let toast2 = toast;
2062 let refresh2 = refresh;
2063 set_dialog.set(Some(Dialog::PromptOp {
2064 title: i18n::t(i18n::k::NEW_FILE).to_string(),
2065 label: i18n::t(i18n::k::FILE_NAME).to_string(),
2066 initial: String::new(),
2067 submit: i18n::t(i18n::k::CREATE).to_string(),
2068 run: owner.with(|| {
2069 Callback::new(move |name: String| {
2070 let full = join_path(&dir, &name);
2071 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
2072 api::create_file(root_id, &full)
2073 .await
2074 .map(|_| ())
2075 .map_err(|e| e.to_string())
2076 });
2077 fut
2078 })
2079 }),
2080 on_ok: owner.with(|| {
2081 Callback::new(move |_| {
2082 show(toast2, i18n::t(i18n::k::FILE_CREATED));
2083 refresh2.run(());
2084 })
2085 }),
2086 }));
2087 })
2088}
2089
2090fn action_rename(
2091 entry: &Entry,
2092 root_id: Option<i64>,
2093 loc: ReadSignal<Location>,
2094 refresh: Callback<()>,
2095 toast: ToastMsg,
2096 set_dialog: WriteSignal<Option<Dialog>>,
2097 owner: Owner,
2098) -> Callback<()> {
2099 let name = entry.name.clone();
2100 Callback::new(move |_| {
2101 let Some(root_id) = root_id else { return };
2102 let loc = loc.get();
2103 let dir = loc.path.clone();
2104 let full = join_path(&dir, &name);
2105 set_dialog.set(Some(Dialog::PromptOp {
2106 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
2107 label: i18n::t(i18n::k::NEW_NAME).to_string(),
2108 initial: name.clone(),
2109 submit: i18n::t(i18n::k::RENAME).to_string(),
2110 run: owner.with(|| {
2111 Callback::new(move |new_name: String| {
2112 let full = full.clone();
2113 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
2114 api::rename_item(root_id, &full, new_name, false)
2115 .await
2116 .map(|_| ())
2117 .map_err(|e| e.to_string())
2118 });
2119 fut
2120 })
2121 }),
2122 on_ok: owner.with(|| {
2123 Callback::new(move |_| {
2124 show(toast, i18n::t(i18n::k::RENAMED));
2125 refresh.run(());
2126 })
2127 }),
2128 }));
2129 })
2130}
2131
2132/// Delete the entries after one confirmation. Several are deleted one by
2133/// one; the first failure stops and reports.
2134fn action_delete(
2135 entries: &[Entry],
2136 root_id: Option<i64>,
2137 loc: ReadSignal<Location>,
2138 refresh: Callback<()>,
2139 toast: ToastMsg,
2140 set_dialog: WriteSignal<Option<Dialog>>,
2141 owner: Owner,
2142) -> Callback<()> {
2143 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
2144 Callback::new(move |_| {
2145 let Some(root_id) = root_id else { return };
2146 let dir = loc.get().path;
2147 let message = match items.as_slice() {
2148 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
2149 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
2150 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
2151 };
2152 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
2153 set_dialog.set(Some(Dialog::Confirm {
2154 title: i18n::t(i18n::k::DELETE).to_string(),
2155 message,
2156 submit: i18n::t(i18n::k::DELETE).to_string(),
2157 danger: true,
2158 on_submit: owner.with(|| {
2159 Callback::new(move |_| {
2160 set_dialog.set(None);
2161 let fulls = fulls.clone();
2162 spawn_local(async move {
2163 for f in &fulls {
2164 if let Err(e) = api::delete_item(root_id, f).await {
2165 show_error(toast, e.to_string());
2166 refresh.run(());
2167 return;
2168 }
2169 }
2170 show(toast, i18n::t(i18n::k::DELETED));
2171 refresh.run(());
2172 });
2173 })
2174 }),
2175 }));
2176 })
2177}
2178
2179/// Pick files (or a folder) and upload them into the current directory.
2180///
2181/// Flow: pick → ask the server which targets exist → if any, the conflict
2182/// dialog (overwrite / skip / cancel, "apply to all") → start a job. The
2183/// job runs in `uploads`; its progress shows in the panel, not in a toast.
2184#[allow(clippy::too_many_arguments)] // explicit signal props
2185fn action_upload(
2186 multiple: bool,
2187 directory: bool,
2188 root_id: Option<i64>,
2189 loc: ReadSignal<Location>,
2190 toast: ToastMsg,
2191 dialog: ReadSignal<Option<Dialog>>,
2192 set_dialog: WriteSignal<Option<Dialog>>,
2193 owner: Owner,
2194) -> Callback<()> {
2195 Callback::new(move |_| {
2196 let Some(root_id) = root_id else { return };
2197 let dir = loc.get().path.join("/");
2198 let owner = owner.clone();
2199 api::pick_files(multiple, directory, move |files| {
2200 let label = upload_label(files.iter().map(|(p, _)| p.as_str()));
2201 let job = uploads::prepare(root_id, dir.clone(), label);
2202 upload_prepared(
2203 job,
2204 root_id,
2205 dir.clone(),
2206 files,
2207 toast,
2208 dialog,
2209 set_dialog,
2210 owner.clone(),
2211 );
2212 });
2213 })
2214}
2215
2216/// The job label: the one name for a single item, the top folder when every
2217/// path carries a slash (a folder pick), else "N files". Takes relative
2218/// paths after a pick, top-level names after a drop.
2219fn upload_label<'a>(names: impl Iterator<Item = &'a str>) -> String {
2220 let names: Vec<&str> = names.collect();
2221 match names[..] {
2222 [] => String::new(),
2223 [one] => one.to_string(),
2224 ref many if many.iter().all(|p| p.contains('/')) => {
2225 many[0].split('/').next().unwrap_or_default().to_string()
2226 }
2227 ref many => i18n::t_fmt(i18n::k::UPLOAD_N_FILES, &many.len().to_string()),
2228 }
2229}
2230
2231/// Upload picked or dropped files into `dir`: existence check, conflict
2232/// dialog, transfer. The job already shows in the panel as "preparing".
2233#[allow(clippy::too_many_arguments)] // explicit signal props
2234fn upload_prepared(
2235 job: u64,
2236 root_id: i64,
2237 dir: String,
2238 files: Vec<(String, web_sys::File)>,
2239 toast: ToastMsg,
2240 dialog: ReadSignal<Option<Dialog>>,
2241 set_dialog: WriteSignal<Option<Dialog>>,
2242 owner: Owner,
2243) {
2244 spawn_local(async move {
2245 let paths: Vec<String> = files.iter().map(|(rel, _)| rel.clone()).collect();
2246 let existing = match api::check_exists(root_id, &dir, paths).await {
2247 Ok(e) => e,
2248 Err(e) => {
2249 uploads::discard(job);
2250 show_error(
2251 toast,
2252 i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()),
2253 );
2254 return;
2255 }
2256 };
2257 // A folder where a file would go can never be written.
2258 // Report it in the job and leave it out of the upload.
2259 let dirs: std::collections::HashSet<String> = existing
2260 .iter()
2261 .filter(|e| e.is_dir)
2262 .map(|e| e.path.clone())
2263 .collect();
2264 let conflicts: Vec<String> = existing
2265 .iter()
2266 .filter(|e| !e.is_dir)
2267 .map(|e| e.path.clone())
2268 .collect();
2269 let errors: Vec<(String, String)> = dirs
2270 .iter()
2271 .map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()))
2272 .collect();
2273 let files: Vec<(String, web_sys::File)> = files
2274 .into_iter()
2275 .filter(|(rel, _)| !dirs.contains(rel.as_str()))
2276 .collect();
2277 let start = move |on_conflict: OnConflict, decided: Vec<String>, overwrite: bool| {
2278 uploads::begin(
2279 job,
2280 files.clone(),
2281 errors.clone(),
2282 on_conflict,
2283 decided,
2284 overwrite,
2285 );
2286 };
2287 if conflicts.is_empty() {
2288 start(OnConflict::Ask, Vec::new(), false);
2289 return;
2290 }
2291 // Callbacks made inside a short-lived async scope die with
2292 // it; the browser's owner keeps this one alive.
2293 let on_decide = owner.with(|| {
2294 let conflicts = conflicts.clone();
2295 let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start)));
2296 Callback::new(move |answer: Option<(bool, bool)>| {
2297 let Some(start) = start.lock().unwrap().take() else {
2298 return;
2299 };
2300 let Some((overwrite, apply_all)) = answer else {
2301 uploads::discard(job);
2302 return;
2303 };
2304 // The answer covers the listed files. "Apply to all" extends
2305 // it to files that appear during the transfer.
2306 let rule = match (apply_all, overwrite) {
2307 (false, _) => OnConflict::Ask,
2308 (true, true) => OnConflict::Overwrite,
2309 (true, false) => OnConflict::Skip,
2310 };
2311 start(rule, conflicts.clone(), overwrite);
2312 })
2313 });
2314 // The job sits in the panel as "preparing" while it waits.
2315 crate::views::dialogs::claim_async_dialog(dialog).await;
2316 set_dialog.set(Some(Dialog::UploadConflict {
2317 files: conflicts,
2318 on_decide,
2319 }));
2320 });
2321}
2322
2323/// Move or copy the entries to a folder chosen in the picker. Several are
2324/// processed one by one; name collisions are collected and asked about once,
2325/// and the overwrite retry re-runs only the items that collided.
2326#[allow(clippy::too_many_arguments)] // explicit signal props
2327fn action_move_copy(
2328 op: Op,
2329 entries: &[Entry],
2330 root_id: Option<i64>,
2331 me: ReadSignal<Option<Me>>,
2332 loc: ReadSignal<Location>,
2333 refresh: Callback<()>,
2334 toast: ToastMsg,
2335 dialog: ReadSignal<Option<Dialog>>,
2336 set_dialog: WriteSignal<Option<Dialog>>,
2337 owner: Owner,
2338) -> Callback<()> {
2339 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
2340 Callback::new(move |_| {
2341 let loc = loc.get();
2342 let Some(root_id) = root_id else { return };
2343 let roots = me.get().map(|m| m.roots).unwrap_or_default();
2344 let dir = loc.path.clone();
2345 let title = match names.as_slice() {
2346 [one] => i18n::t_fmt(op.title_key(1), one),
2347 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
2348 };
2349 // (name, path relative to the root) per item.
2350 let items: Vec<(String, String)> = names
2351 .iter()
2352 .map(|n| (n.clone(), join_path(&dir, n)))
2353 .collect();
2354 set_dialog.set(Some(Dialog::Picker {
2355 title,
2356 confirm: i18n::t(op.here_key()).to_string(),
2357 roots,
2358 root: root_id,
2359 dir: dir.join("/"),
2360 for_write: true,
2361 on_pick: owner.with(|| {
2362 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
2363 // Items still to do. A finished item is dropped, so the
2364 // overwrite retry re-runs only the ones that collided.
2365 let pending = Arc::new(Mutex::new(items.clone()));
2366 run_with_overwrite_retry(
2367 move |overwrite| {
2368 let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
2369 Box::pin(async move {
2370 let items = pending.lock().unwrap().clone();
2371 let mut conflicts = Vec::new();
2372 for (name, full) in items {
2373 let res = if op == Op::Move {
2374 api::move_item(
2375 root_id, &full, dst_root, &dst_dir, overwrite,
2376 )
2377 .await
2378 } else {
2379 api::copy_item(
2380 root_id, &full, dst_root, &dst_dir, overwrite,
2381 )
2382 .await
2383 };
2384 match res {
2385 Ok(_) => {
2386 pending.lock().unwrap().retain(|(_, p)| *p != full)
2387 }
2388 Err(e) if is_conflict(&e) => conflicts.push(name),
2389 Err(e) => return Err(e),
2390 }
2391 }
2392 if conflicts.is_empty() {
2393 Ok(())
2394 } else {
2395 Err(api::ApiError::Http {
2396 status: 409,
2397 message: String::new(),
2398 skipped: Some(conflicts),
2399 })
2400 }
2401 })
2402 },
2403 i18n::t(i18n::k::NAME_TAKEN),
2404 // Unused: the conflict error above always lists the names.
2405 Vec::new(),
2406 if op == Op::Move {
2407 i18n::t(i18n::k::MOVED)
2408 } else {
2409 i18n::t(i18n::k::COPIED)
2410 },
2411 refresh,
2412 toast,
2413 dialog,
2414 set_dialog,
2415 );
2416 })
2417 }),
2418 }));
2419 })
2420}
2421
2422fn action_download(
2423 entry: &Entry,
2424 root_id: Option<i64>,
2425 loc: ReadSignal<Location>,
2426 set_dialog: WriteSignal<Option<Dialog>>,
2427 toast: ToastMsg,
2428 owner: Owner,
2429) -> Callback<()> {
2430 let name = entry.name.clone();
2431 let is_dir = entry.is_dir;
2432 owner.with(|| {
2433 Callback::new(move |_| {
2434 let Some(root_id) = root_id else { return };
2435 let loc = loc.get();
2436 let full = join_path(&loc.path, &name);
2437 if is_dir {
2438 set_dialog.set(Some(Dialog::DownloadFormat {
2439 name: name.clone(),
2440 root_id,
2441 path: full,
2442 }));
2443 } else {
2444 let url = api::download_url(root_id, &full, None);
2445 api::trigger_download(&url, &name);
2446 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
2447 }
2448 })
2449 })
2450}
2451
2452/// Open the file itself in a new tab, via the `?action=preview` endpoint —
2453/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
2454fn action_open_tab(
2455 entry: &Entry,
2456 root_id: Option<i64>,
2457 loc: ReadSignal<Location>,
2458 owner: Owner,
2459) -> Callback<()> {
2460 let name = entry.name.clone();
2461 owner.with(|| {
2462 Callback::new(move |_| {
2463 let Some(root_id) = root_id else { return };
2464 let full = join_path(&loc.get().path, &name);
2465 api::open_in_new_tab(&api::preview_url(root_id, &full));
2466 })
2467 })
2468}
2469
2470/// Open the share dialog for an item (milestone 6).
2471///
2472/// `entry` is the item to share, or `None` for the folder currently open.
2473/// The folder case sends an empty path, which the server reads as the root of
2474/// the location. At the top of a root that shares the root itself.
2475#[allow(clippy::too_many_arguments)] // explicit signal props
2476fn action_share(
2477 entry: Option<&Entry>,
2478 root_id: Option<i64>,
2479 loc: ReadSignal<Location>,
2480 root_name: String,
2481 allow_writable: bool,
2482 root_writable: bool,
2483 set_dialog: WriteSignal<Option<Dialog>>,
2484 owner: Owner,
2485) -> Callback<()> {
2486 let entry_name = entry.map(|e| e.name.clone());
2487 owner.with(|| {
2488 Callback::new(move |_| {
2489 let Some(root_id) = root_id else { return };
2490 let loc = loc.get();
2491 let (name, path) = match &entry_name {
2492 Some(n) => (n.clone(), join_path(&loc.path, n)),
2493 // The last path segment names the folder; at the top of a
2494 // root there is none, so the root's own name stands in.
2495 None => (
2496 loc.path
2497 .last()
2498 .cloned()
2499 .unwrap_or_else(|| root_name.clone()),
2500 loc.path.join("/"),
2501 ),
2502 };
2503 set_dialog.set(Some(Dialog::Share {
2504 name,
2505 root_id,
2506 path,
2507 allow_writable,
2508 root_writable,
2509 }));
2510 })
2511 })
2512}
2513
2514fn action_info(
2515 entry: &Entry,
2516 loc: ReadSignal<Location>,
2517 root_name: String,
2518 set_dialog: WriteSignal<Option<Dialog>>,
2519 owner: Owner,
2520) -> Callback<()> {
2521 let entry2 = entry.clone();
2522 owner.with(|| {
2523 Callback::new(move |_| {
2524 let loc = loc.get();
2525 let full = join_path(&loc.path, &entry2.name);
2526 set_dialog.set(Some(Dialog::Info {
2527 entry: entry2.clone(),
2528 root_name: root_name.clone(),
2529 rel_path: full,
2530 }));
2531 })
2532 })
2533}
2534