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