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 v.push(MenuItem::new(
1696 IconName::Info,
1697 i18n::t(i18n::k::INFO),
1698 action_info(e, loc, root_name, set_dialog, owner.clone()),
1699 ));
1700 }
1701 v.push(MenuItem::rw(
1702 IconName::Delete,
1703 i18n::t(i18n::k::DELETE),
1704 is_rw,
1705 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1706 ));
1707 v
1708 }
1709 }
1710}
1711
1712#[component]
1713fn CtxMenuView(
1714 ctx: ReadSignal<Option<CtxMenu>>,
1715 set_ctx: WriteSignal<Option<CtxMenu>>,
1716 me: ReadSignal<Option<Me>>,
1717 loc: ReadSignal<Location>,
1718 selected: ReadSignal<Vec<Entry>>,
1719 refresh: Callback<()>,
1720 toast: ToastMsg,
1721 dialog: ReadSignal<Option<Dialog>>,
1722 set_dialog: WriteSignal<Option<Dialog>>,
1723 open_file: WriteSignal<Option<FileView>>,
1724 owner: Owner,
1725) -> impl IntoView {
1726 // Close on any click or Escape.
1727 {
1728 crate::util::owned_window_listener(leptos::ev::click, move |_| {
1729 set_ctx.set(None);
1730 });
1731 crate::util::owned_window_listener(
1732 leptos::ev::keydown,
1733 move |ev: web_sys::KeyboardEvent| {
1734 if ev.key() == "Escape" {
1735 set_ctx.set(None);
1736 }
1737 },
1738 );
1739 }
1740
1741 // The menu first paints at the cursor point; after mount, this measures
1742 // its actual size and flips it above/left of the cursor when it would
1743 // overflow the viewport (native context-menu behavior), clamping to the
1744 // viewport edges as a fallback.
1745 let menu_ref = NodeRef::<leptos::html::Div>::new();
1746 Effect::new(move |_| {
1747 let Some((mx, my)) = ctx.get() else {
1748 return;
1749 };
1750 let Some(node) = menu_ref
1751 .get()
1752 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1753 else {
1754 return;
1755 };
1756 let rect = node.get_bounding_client_rect();
1757 let (w, h) = (rect.width() as i32, rect.height() as i32);
1758 let (vw, vh) = web_sys::window()
1759 .and_then(|w| {
1760 let vw = w.inner_width().ok()?.as_f64()?;
1761 let vh = w.inner_height().ok()?.as_f64()?;
1762 Some((vw, vh))
1763 })
1764 .unwrap_or((f64::MAX, f64::MAX));
1765 let (vw, vh) = (vw as i32, vh as i32);
1766 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1767 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1768 let _ = node.style().set_property("left", &format!("{x}px"));
1769 let _ = node.style().set_property("top", &format!("{y}px"));
1770 });
1771
1772 view! {
1773 {move || {
1774 let (mx, my) = ctx.get()?;
1775 let items = selected.with(|sel| {
1776 menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
1777 });
1778
1779 Some(view! {
1780 <div
1781 class="ctx-menu"
1782 node_ref=menu_ref
1783 style=format!("left:{mx}px;top:{my}px")
1784 >
1785 {items.into_iter().map(|item| {
1786 view! { <CtxItem item=item/> }
1787 }).collect::<Vec<_>>()}
1788 </div>
1789 })
1790 }}
1791 }
1792}
1793
1794/// One row of the context menu. `action` is `None` for a disabled row, and
1795/// `hint` then says why it is disabled.
1796#[derive(Clone)]
1797struct MenuItem {
1798 label: &'static str,
1799 icon: IconName,
1800 action: Option<Callback<()>>,
1801 hint: Option<&'static str>,
1802}
1803
1804impl MenuItem {
1805 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1806 Self {
1807 label,
1808 icon,
1809 action: Some(action),
1810 hint: None,
1811 }
1812 }
1813
1814 /// An item that only works in a writable folder.
1815 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1816 Self {
1817 label,
1818 icon,
1819 action: is_rw.then_some(action),
1820 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1821 }
1822 }
1823}
1824
1825#[component]
1826fn CtxItem(item: MenuItem) -> impl IntoView {
1827 let MenuItem {
1828 label,
1829 icon,
1830 action,
1831 hint,
1832 } = item;
1833 let enabled = action.is_some();
1834 let hint = hint.unwrap_or_default();
1835 view! {
1836 <div
1837 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1838 title=move || if enabled { "".to_string() } else { hint.to_string() }
1839 on:click=move |_| {
1840 if let Some(a) = &action {
1841 a.run(());
1842 }
1843 }
1844 >
1845 <Icon name=icon class="ic-ctx".to_string()/>
1846 <span>{label}</span>
1847 </div>
1848 }
1849}
1850
1851// ---------------------------------------------------------------------------
1852// Toolbar selection actions (top right)
1853// ---------------------------------------------------------------------------
1854
1855/// Icon-only buttons for the current selection — the same set of actions
1856/// as its right-click menu — or the empty-area actions (new folder,
1857/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1858/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1859#[component]
1860pub fn SelectionActions(
1861 me: ReadSignal<Option<Me>>,
1862 loc: ReadSignal<Location>,
1863 selected: ReadSignal<Vec<Entry>>,
1864 open_file: WriteSignal<Option<FileView>>,
1865 refresh: Callback<()>,
1866 toast: ToastMsg,
1867 dialog: ReadSignal<Option<Dialog>>,
1868 set_dialog: WriteSignal<Option<Dialog>>,
1869 owner: Owner,
1870 topbar_ref: NodeRef<leptos::html::Header>,
1871) -> impl IntoView {
1872 // How many buttons are shown before the overflow kicks in. Starts small
1873 // so the first paint never overflows; the effect below widens it as soon
1874 // as the real widths are measurable.
1875 let (visible_n, set_visible_n) = signal(3usize);
1876 let group_ref = NodeRef::<leptos::html::Div>::new();
1877
1878 let recompute = move || {
1879 let Some(tb) = topbar_ref
1880 .get()
1881 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1882 else {
1883 return;
1884 };
1885 let Some(g) = group_ref
1886 .get()
1887 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1888 else {
1889 return;
1890 };
1891 // Every button is icon-only with identical padding, so one
1892 // rendered button gives the width of all of them.
1893 let Some(btn) = g.first_element_child() else {
1894 return;
1895 };
1896 // Available width: from the top bar's left content (brand) to its
1897 // right edge — independent of where the group currently sits,
1898 // so the measurement stays valid even while the group overflows.
1899 let tb_r = tb.get_bounding_client_rect();
1900 let start = tb
1901 .query_selector(".brand")
1902 .ok()
1903 .flatten()
1904 .map(|b| b.get_bounding_client_rect())
1905 .map(|r| r.right() + 16.0)
1906 .unwrap_or(0.0);
1907 let avail = (tb_r.right() - start - 10.0).max(40.0);
1908 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1909 // How many buttons fit (not counting the overflow button).
1910 set_visible_n.set(((avail / step) as usize).max(1));
1911 };
1912 // Measuring forces three layout reflows, so it must not run on every
1913 // selection change. Only how many buttons *fit* is measured, and that
1914 // changes when the group appears, disappears, or switches between the
1915 // empty-area, single and multi action sets — not per selected entry.
1916 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1917 Effect::new(move |_| {
1918 topbar_ref.get();
1919 group_ref.get();
1920 // Re-measure when the item set (selection kind, user) or width
1921 // inputs change.
1922 sel_kind.get();
1923 me.track();
1924 recompute();
1925 });
1926 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1927
1928 view! {
1929 <div class="sel-actions" node_ref=group_ref>
1930 // The visible buttons and the overflow rows are one item list
1931 // split at the measured count, so the actions are computed once.
1932 {move || {
1933 // No resolvable root (e.g. the `#/` "select a folder" empty
1934 // state): there is nowhere to act, so hide the whole group
1935 // rather than show a row of disabled buttons.
1936 let has_root = me.with(|m| {
1937 m.as_ref()
1938 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1939 });
1940 if !has_root {
1941 return None;
1942 }
1943 let items = selected.with(|sel| {
1944 menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
1945 });
1946 let fit = visible_n.get();
1947 let n = if fit >= items.len() {
1948 items.len()
1949 } else {
1950 // The overflow button takes a slot too, so drop one —
1951 // otherwise the group eats into the space after the brand.
1952 (fit - 1).max(1)
1953 };
1954 let (visible, more) = items.split_at(n);
1955 let visible_view = visible
1956 .iter()
1957 .cloned()
1958 .map(|i| view! {
1959 <SelButton item=i/>
1960 })
1961 .collect_view();
1962 // Rendered as nothing while every action fits.
1963 let more_block = (!more.is_empty()).then(|| {
1964 let more_view = more
1965 .iter()
1966 .cloned()
1967 .map(|i| view! {
1968 <CtxItem item=i/>
1969 })
1970 .collect_view();
1971 let btn = NodeRef::<leptos::html::Button>::new();
1972 let menu = NodeRef::<leptos::html::Div>::new();
1973 // The popover sits in the top layer, outside this layout,
1974 // so it is placed under the button by hand.
1975 let (at, set_at) = signal((0.0, 0.0));
1976 view! {
1977 <button
1978 class="icon-btn"
1979 node_ref=btn
1980 popovertarget="sel-more-menu"
1981 title=i18n::tr(i18n::k::MORE_ACTIONS)
1982 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1983 on:click=move |_| {
1984 let (Some(b), Some(doc)) = (btn.get(), document().document_element()) else {
1985 return;
1986 };
1987 let r = b.get_bounding_client_rect();
1988 set_at.set((r.bottom() + 6.0, f64::from(doc.client_width()) - r.right()));
1989 }
1990 >
1991 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1992 </button>
1993 <div
1994 id="sel-more-menu"
1995 class="sel-more-menu"
1996 popover="auto"
1997 node_ref=menu
1998 style:top=move || format!("{}px", at.get().0)
1999 style:right=move || format!("{}px", at.get().1)
2000 on:click=move |_| {
2001 if let Some(m) = menu.get() {
2002 let _ = m.hide_popover();
2003 }
2004 }
2005 >
2006 {more_view}
2007 </div>
2008 }
2009 });
2010 Some(view! {
2011 {visible_view}
2012 {more_block}
2013 })
2014 }}
2015 </div>
2016 }
2017}
2018
2019/// One icon-only toolbar button. A disabled button keeps its tooltip, which
2020/// then explains why.
2021#[component]
2022fn SelButton(item: MenuItem) -> impl IntoView {
2023 let enabled = item.action.is_some();
2024 let title = if enabled {
2025 item.label.to_string()
2026 } else {
2027 format!("{} — {}", item.label, item.hint.unwrap_or_default())
2028 };
2029 view! {
2030 <button
2031 class="icon-btn"
2032 disabled=!enabled
2033 title=title
2034 on:click=move |_| {
2035 if let Some(a) = &item.action {
2036 a.run(());
2037 }
2038 }
2039 >
2040 <Icon name=item.icon class="ic-btn".to_string()/>
2041 </button>
2042 }
2043}
2044
2045// ---------------------------------------------------------------------------
2046// Menu actions
2047// ---------------------------------------------------------------------------
2048
2049fn previewable(e: &Entry) -> bool {
2050 !e.is_dir && is_media(e.kind)
2051}
2052
2053fn join_path(dir: &[String], name: &str) -> String {
2054 if dir.is_empty() {
2055 name.to_string()
2056 } else {
2057 format!("{}/{}", dir.join("/"), name)
2058 }
2059}
2060
2061fn is_conflict(e: &api::ApiError) -> bool {
2062 matches!(e, api::ApiError::Http { status: 409, .. })
2063}
2064
2065/// What a name prompt in the current folder does with the entered name.
2066#[derive(Clone)]
2067enum NameOp {
2068 Mkdir,
2069 CreateFile,
2070 /// The entry's current name.
2071 Rename(String),
2072}
2073
2074fn action_name(
2075 op: NameOp,
2076 root_id: Option<i64>,
2077 loc: ReadSignal<Location>,
2078 refresh: Callback<()>,
2079 toast: ToastMsg,
2080 set_dialog: WriteSignal<Option<Dialog>>,
2081 owner: Owner,
2082) -> Callback<()> {
2083 use i18n::k;
2084 Callback::new(move |_| {
2085 let Some(root_id) = root_id else { return };
2086 let dir = loc.get().path;
2087 let (title, label, initial, submit, ok) = match &op {
2088 NameOp::Mkdir => (
2089 i18n::t(k::NEW_FOLDER).to_string(),
2090 k::FOLDER_NAME,
2091 String::new(),
2092 k::CREATE,
2093 k::FOLDER_CREATED,
2094 ),
2095 NameOp::CreateFile => (
2096 i18n::t(k::NEW_FILE).to_string(),
2097 k::FILE_NAME,
2098 String::new(),
2099 k::CREATE,
2100 k::FILE_CREATED,
2101 ),
2102 NameOp::Rename(old) => (
2103 i18n::t_fmt(k::RENAME_TITLE, old),
2104 k::NEW_NAME,
2105 old.clone(),
2106 k::RENAME,
2107 k::RENAMED,
2108 ),
2109 };
2110 let op = op.clone();
2111 set_dialog.set(Some(Dialog::PromptOp {
2112 title,
2113 label: i18n::t(label).to_string(),
2114 initial,
2115 submit: i18n::t(submit).to_string(),
2116 run: owner.with(|| {
2117 Callback::new(move |name: String| {
2118 let (op, dir) = (op.clone(), dir.clone());
2119 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
2120 match op {
2121 NameOp::Mkdir => api::mkdir(root_id, &join_path(&dir, &name)).await,
2122 NameOp::CreateFile => {
2123 api::create_file(root_id, &join_path(&dir, &name)).await
2124 }
2125 NameOp::Rename(old) => {
2126 api::rename_item(root_id, &join_path(&dir, &old), name, false).await
2127 }
2128 }
2129 .map(|_| ())
2130 .map_err(|e| e.to_string())
2131 });
2132 fut
2133 })
2134 }),
2135 on_ok: owner.with(|| {
2136 Callback::new(move |_| {
2137 show(toast, i18n::t(ok));
2138 refresh.run(());
2139 })
2140 }),
2141 }));
2142 })
2143}
2144
2145/// Delete the entries after one confirmation. Several are deleted one by
2146/// one; the first failure stops and reports.
2147fn action_delete(
2148 entries: &[Entry],
2149 root_id: Option<i64>,
2150 loc: ReadSignal<Location>,
2151 refresh: Callback<()>,
2152 toast: ToastMsg,
2153 set_dialog: WriteSignal<Option<Dialog>>,
2154 owner: Owner,
2155) -> Callback<()> {
2156 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
2157 Callback::new(move |_| {
2158 let Some(root_id) = root_id else { return };
2159 let dir = loc.get().path;
2160 let message = match items.as_slice() {
2161 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
2162 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
2163 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
2164 };
2165 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
2166 set_dialog.set(Some(Dialog::Confirm {
2167 title: i18n::t(i18n::k::DELETE).to_string(),
2168 message,
2169 submit: i18n::t(i18n::k::DELETE).to_string(),
2170 danger: true,
2171 on_submit: owner.with(|| {
2172 Callback::new(move |_| {
2173 set_dialog.set(None);
2174 let fulls = fulls.clone();
2175 spawn_local(async move {
2176 for f in &fulls {
2177 if let Err(e) = api::delete_item(root_id, f).await {
2178 show_error(toast, e.to_string());
2179 refresh.run(());
2180 return;
2181 }
2182 }
2183 show(toast, i18n::t(i18n::k::DELETED));
2184 refresh.run(());
2185 });
2186 })
2187 }),
2188 }));
2189 })
2190}
2191
2192/// Pick files (or a folder) and upload them into the current directory.
2193///
2194/// Flow: pick → ask the server which targets exist → if any, the conflict
2195/// dialog (overwrite / skip / cancel, "apply to all") → start a job. The
2196/// job runs in `uploads`; its progress shows in the panel, not in a toast.
2197#[allow(clippy::too_many_arguments)] // explicit signal props
2198fn action_upload(
2199 multiple: bool,
2200 directory: bool,
2201 root_id: Option<i64>,
2202 loc: ReadSignal<Location>,
2203 toast: ToastMsg,
2204 dialog: ReadSignal<Option<Dialog>>,
2205 set_dialog: WriteSignal<Option<Dialog>>,
2206 owner: Owner,
2207) -> Callback<()> {
2208 Callback::new(move |_| {
2209 let Some(root_id) = root_id else { return };
2210 let dir = loc.get().path.join("/");
2211 let owner = owner.clone();
2212 api::pick_files(multiple, directory, move |files| {
2213 let label = upload_label(files.iter().map(|(p, _)| p.as_str()));
2214 let job = uploads::prepare(root_id, dir.clone(), label);
2215 upload_prepared(
2216 job,
2217 root_id,
2218 dir.clone(),
2219 files,
2220 toast,
2221 dialog,
2222 set_dialog,
2223 owner.clone(),
2224 );
2225 });
2226 })
2227}
2228
2229/// The job label: the one name for a single item, the top folder when every
2230/// path carries a slash (a folder pick), else "N files". Takes relative
2231/// paths after a pick, top-level names after a drop.
2232fn upload_label<'a>(names: impl Iterator<Item = &'a str>) -> String {
2233 let names: Vec<&str> = names.collect();
2234 match names[..] {
2235 [] => String::new(),
2236 [one] => one.to_string(),
2237 ref many if many.iter().all(|p| p.contains('/')) => {
2238 many[0].split('/').next().unwrap_or_default().to_string()
2239 }
2240 ref many => i18n::t_fmt(i18n::k::UPLOAD_N_FILES, &many.len().to_string()),
2241 }
2242}
2243
2244/// Upload picked or dropped files into `dir`: existence check, conflict
2245/// dialog, transfer. The job already shows in the panel as "preparing".
2246#[allow(clippy::too_many_arguments)] // explicit signal props
2247fn upload_prepared(
2248 job: u64,
2249 root_id: i64,
2250 dir: String,
2251 files: Vec<(String, web_sys::File)>,
2252 toast: ToastMsg,
2253 dialog: ReadSignal<Option<Dialog>>,
2254 set_dialog: WriteSignal<Option<Dialog>>,
2255 owner: Owner,
2256) {
2257 spawn_local(async move {
2258 let paths: Vec<String> = files.iter().map(|(rel, _)| rel.clone()).collect();
2259 let existing = match api::check_exists(root_id, &dir, paths).await {
2260 Ok(e) => e,
2261 Err(e) => {
2262 uploads::discard(job);
2263 show_error(
2264 toast,
2265 i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()),
2266 );
2267 return;
2268 }
2269 };
2270 // A folder where a file would go can never be written.
2271 // Report it in the job and leave it out of the upload.
2272 let dirs: std::collections::HashSet<String> = existing
2273 .iter()
2274 .filter(|e| e.is_dir)
2275 .map(|e| e.path.clone())
2276 .collect();
2277 let conflicts: Vec<String> = existing
2278 .iter()
2279 .filter(|e| !e.is_dir)
2280 .map(|e| e.path.clone())
2281 .collect();
2282 let errors: Vec<(String, String)> = dirs
2283 .iter()
2284 .map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()))
2285 .collect();
2286 let files: Vec<(String, web_sys::File)> = files
2287 .into_iter()
2288 .filter(|(rel, _)| !dirs.contains(rel.as_str()))
2289 .collect();
2290 let start = move |on_conflict: OnConflict, decided: Vec<String>, overwrite: bool| {
2291 uploads::begin(
2292 job,
2293 files.clone(),
2294 errors.clone(),
2295 on_conflict,
2296 decided,
2297 overwrite,
2298 );
2299 };
2300 if conflicts.is_empty() {
2301 start(OnConflict::Ask, Vec::new(), false);
2302 return;
2303 }
2304 // Callbacks made inside a short-lived async scope die with
2305 // it; the browser's owner keeps this one alive.
2306 let on_decide = owner.with(|| {
2307 let conflicts = conflicts.clone();
2308 let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start)));
2309 Callback::new(move |answer: Option<(bool, bool)>| {
2310 let Some(start) = start.lock().unwrap().take() else {
2311 return;
2312 };
2313 let Some((overwrite, apply_all)) = answer else {
2314 uploads::discard(job);
2315 return;
2316 };
2317 // The answer covers the listed files. "Apply to all" extends
2318 // it to files that appear during the transfer.
2319 let rule = match (apply_all, overwrite) {
2320 (false, _) => OnConflict::Ask,
2321 (true, true) => OnConflict::Overwrite,
2322 (true, false) => OnConflict::Skip,
2323 };
2324 start(rule, conflicts.clone(), overwrite);
2325 })
2326 });
2327 // The job sits in the panel as "preparing" while it waits.
2328 crate::views::dialogs::claim_async_dialog(dialog).await;
2329 set_dialog.set(Some(Dialog::UploadConflict {
2330 files: conflicts,
2331 on_decide,
2332 }));
2333 });
2334}
2335
2336/// Move or copy the entries to a folder chosen in the picker. Several are
2337/// processed one by one; name collisions are collected and asked about once.
2338#[allow(clippy::too_many_arguments)] // explicit signal props
2339fn action_move_copy(
2340 op: Op,
2341 entries: &[Entry],
2342 root_id: Option<i64>,
2343 me: ReadSignal<Option<Me>>,
2344 loc: ReadSignal<Location>,
2345 refresh: Callback<()>,
2346 toast: ToastMsg,
2347 dialog: ReadSignal<Option<Dialog>>,
2348 set_dialog: WriteSignal<Option<Dialog>>,
2349 owner: Owner,
2350) -> Callback<()> {
2351 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
2352 Callback::new(move |_| {
2353 let loc = loc.get();
2354 let Some(root_id) = root_id else { return };
2355 let roots = me.get().map(|m| m.roots).unwrap_or_default();
2356 let dir = loc.path.clone();
2357 let title = match names.as_slice() {
2358 [one] => i18n::t_fmt(op.title_key(1), one),
2359 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
2360 };
2361 // (name, path relative to the root) per item.
2362 let items: Vec<(String, String)> = names
2363 .iter()
2364 .map(|n| (n.clone(), join_path(&dir, n)))
2365 .collect();
2366 set_dialog.set(Some(Dialog::Picker {
2367 title,
2368 confirm: i18n::t(op.here_key()).to_string(),
2369 roots,
2370 root: root_id,
2371 dir: dir.join("/"),
2372 for_write: true,
2373 on_pick: owner.with(|| {
2374 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
2375 let wire = match op {
2376 Op::Move => api_types::Op::Move,
2377 Op::Copy => api_types::Op::Copy,
2378 };
2379 let ok_msg = i18n::t(if op == Op::Move {
2380 i18n::k::MOVED
2381 } else {
2382 i18n::k::COPIED
2383 });
2384 // Runs the items and returns the ones that collided.
2385 let run = move |items: Vec<(String, String)>,
2386 overwrite: bool,
2387 dst_dir: String| async move {
2388 let mut conflicts = Vec::new();
2389 for (name, full) in items {
2390 match api::mutation(root_id, &full, wire, dst_root, &dst_dir, overwrite)
2391 .await
2392 {
2393 Ok(_) => {}
2394 Err(e) if is_conflict(&e) => conflicts.push((name, full)),
2395 Err(e) => return Err(e.to_string()),
2396 }
2397 }
2398 Ok(conflicts)
2399 };
2400 let items = items.clone();
2401 spawn_local(async move {
2402 let conflicts = match run(items, false, dst_dir.clone()).await {
2403 Ok(c) if c.is_empty() => {
2404 show(toast, ok_msg);
2405 refresh.run(());
2406 return;
2407 }
2408 Ok(c) => c,
2409 Err(e) => return show_error(toast, e),
2410 };
2411 crate::views::dialogs::claim_async_dialog(dialog).await;
2412 set_dialog.set(Some(Dialog::Conflict {
2413 title: i18n::t(i18n::k::NAME_TAKEN).to_string(),
2414 files: conflicts.iter().map(|(n, _)| n.clone()).collect(),
2415 // The retry re-runs only the items that collided.
2416 on_submit: Callback::new(move |_| {
2417 let (conflicts, dst_dir) = (conflicts.clone(), dst_dir.clone());
2418 spawn_local(async move {
2419 match run(conflicts, true, dst_dir).await {
2420 Ok(c) if c.is_empty() => {
2421 show(toast, ok_msg);
2422 refresh.run(());
2423 }
2424 Ok(_) => show_error(toast, i18n::t(i18n::k::NAME_TAKEN)),
2425 Err(e) => show_error(toast, e),
2426 }
2427 });
2428 }),
2429 }));
2430 });
2431 })
2432 }),
2433 }));
2434 })
2435}
2436
2437fn action_download(
2438 entry: &Entry,
2439 root_id: Option<i64>,
2440 loc: ReadSignal<Location>,
2441 set_dialog: WriteSignal<Option<Dialog>>,
2442 toast: ToastMsg,
2443 owner: Owner,
2444) -> Callback<()> {
2445 let name = entry.name.clone();
2446 let is_dir = entry.is_dir;
2447 owner.with(|| {
2448 Callback::new(move |_| {
2449 let Some(root_id) = root_id else { return };
2450 let loc = loc.get();
2451 let full = join_path(&loc.path, &name);
2452 if is_dir {
2453 set_dialog.set(Some(Dialog::DownloadFormat {
2454 name: name.clone(),
2455 root_id,
2456 path: full,
2457 }));
2458 } else {
2459 let url = api::download_url(root_id, &full, None);
2460 api::trigger_download(&url, &name);
2461 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
2462 }
2463 })
2464 })
2465}
2466
2467/// Open the file itself in a new tab, via the `?action=preview` endpoint —
2468/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
2469fn action_open_tab(
2470 entry: &Entry,
2471 root_id: Option<i64>,
2472 loc: ReadSignal<Location>,
2473 owner: Owner,
2474) -> Callback<()> {
2475 let name = entry.name.clone();
2476 owner.with(|| {
2477 Callback::new(move |_| {
2478 let Some(root_id) = root_id else { return };
2479 let full = join_path(&loc.get().path, &name);
2480 api::open_in_new_tab(&api::preview_url(root_id, &full));
2481 })
2482 })
2483}
2484
2485/// Open the share dialog for an item (milestone 6).
2486///
2487/// `entry` is the item to share, or `None` for the folder currently open.
2488/// The folder case sends an empty path, which the server reads as the root of
2489/// the location. At the top of a root that shares the root itself.
2490#[allow(clippy::too_many_arguments)] // explicit signal props
2491fn action_share(
2492 entry: Option<&Entry>,
2493 root_id: Option<i64>,
2494 loc: ReadSignal<Location>,
2495 root_name: String,
2496 allow_writable: bool,
2497 root_writable: bool,
2498 set_dialog: WriteSignal<Option<Dialog>>,
2499 owner: Owner,
2500) -> Callback<()> {
2501 let entry_name = entry.map(|e| e.name.clone());
2502 owner.with(|| {
2503 Callback::new(move |_| {
2504 let Some(root_id) = root_id else { return };
2505 let loc = loc.get();
2506 let (name, path) = match &entry_name {
2507 Some(n) => (n.clone(), join_path(&loc.path, n)),
2508 // The last path segment names the folder; at the top of a
2509 // root there is none, so the root's own name stands in.
2510 None => (
2511 loc.path
2512 .last()
2513 .cloned()
2514 .unwrap_or_else(|| root_name.clone()),
2515 loc.path.join("/"),
2516 ),
2517 };
2518 set_dialog.set(Some(Dialog::Share {
2519 name,
2520 root_id,
2521 path,
2522 allow_writable,
2523 root_writable,
2524 }));
2525 })
2526 })
2527}
2528
2529fn action_info(
2530 entry: &Entry,
2531 loc: ReadSignal<Location>,
2532 root_name: String,
2533 set_dialog: WriteSignal<Option<Dialog>>,
2534 owner: Owner,
2535) -> Callback<()> {
2536 let entry2 = entry.clone();
2537 owner.with(|| {
2538 Callback::new(move |_| {
2539 let loc = loc.get();
2540 let full = join_path(&loc.path, &entry2.name);
2541 set_dialog.set(Some(Dialog::Info {
2542 entry: entry2.clone(),
2543 root_name: root_name.clone(),
2544 rel_path: full,
2545 }));
2546 })
2547 })
2548}
2549