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