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