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