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::util::{FILE_PAGE, ViewMode, format_date, format_size, page_footer};
21use crate::views::dialogs::{Dialog, Op};
22use crate::views::file_view::{FileView, UnsupportedTarget};
23
24/// One folder listing. Behind an `Arc` in [`ListState`] so reading the
25/// signal never copies the entries (a folder can hold thousands).
26#[derive(Debug, PartialEq)]
27struct Listing {
28 entries: Vec<Entry>,
29 /// The server had more entries than it will return; the listing holds
30 /// only the first `api_types::MAX_LIST_ENTRIES`.
31 truncated: bool,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35enum ListState {
36 Loading,
37 Error(String),
38 Entries(Arc<Listing>),
39}
40
41/// The root that is actually shown: the one in the location. The one-root
42/// shortcut is shares-only: a public share's location (`#/share/{token}`)
43/// cannot carry its synthetic root's id, which only exists once the share is
44/// resolved. Signed-in users always get an explicit `#/files/{root_id}` URL
45/// (or the no-root empty state on `#/`) — that keeps every file open from a
46/// browser listing on an explicit URL, so navigating away always changes the
47/// hash.
48pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
49 match loc.root_id {
50 Some(id) => roots.iter().find(|r| r.id == id),
51 None if loc.share_token.is_some() && roots.len() == 1 => Some(&roots[0]),
52 None => None,
53 }
54}
55
56/// Where the context menu opens (client x, y). What it offers follows the
57/// selection: the right-click handlers set that first.
58type CtxMenu = (i32, i32);
59
60#[component]
61pub fn Browser(
62 me: ReadSignal<Option<Me>>,
63 set_me: WriteSignal<Option<Me>>,
64 loc: ReadSignal<Location>,
65 /// Opens a file in the full-page view (owned by the shell / share page).
66 open_file: WriteSignal<Option<FileView>>,
67 /// The browser exposes its fetch there so the full-page editor can
68 /// re-list the folder after a save.
69 set_browser_refresh: WriteSignal<Option<Callback<()>>>,
70 /// The selected entries, owned by the shell so their action buttons can
71 /// live in the top bar. Empty shows the empty-area actions there. The
72 /// first entry is the anchor for Shift+click ranges.
73 selected: ReadSignal<Vec<Entry>>,
74 set_selected: WriteSignal<Vec<Entry>>,
75 /// Dialogs are rendered at the shell level.
76 set_dialog: WriteSignal<Option<Dialog>>,
77 /// The open dialog, if any. Escape must not clear the selection while
78 /// one is open (it closes the dialog instead) — checked in the capture
79 /// phase, before the dialog's own bubble-phase listener closes it.
80 dialog: ReadSignal<Option<Dialog>>,
81 /// A file opened in the full-page view. Escape must not clear the
82 /// selection while this is set — that keypress closes the view (handled
83 /// by the shell), and the selection should survive.
84 file_view: ReadSignal<Option<FileView>>,
85) -> impl IntoView {
86 let toast = use_context::<ToastMsg>().expect("toast context");
87 let (view_mode, set_view_mode) = signal(ViewMode::load());
88 let (list_state, set_list_state) = signal(ListState::Loading);
89 let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
90 // Long-lived owner for callbacks created inside short-lived scopes
91 // (context-menu event handlers); otherwise they get disposed with the
92 // menu and the open dialog panics on submit.
93 let ctx_owner = Owner::current().unwrap();
94 // A 403/404 on the listing usually means an admin removed or changed
95 // this root, so re-fetch /me once per (root, path) to update the
96 // sidebar and this view. The key guards against a refresh loop when
97 // /me comes back unchanged.
98 let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
99 // How many entries of the current listing are rendered. Reset by every
100 // fetch, so a new folder starts at one page again.
101 let shown = RwSignal::new(FILE_PAGE);
102 // Bumped per fetch. A response whose generation is no longer current
103 // belongs to a folder the user has already navigated away from.
104 let fetch_gen = StoredValue::new(0u64);
105 // The selected names as a set: every row asks "am I selected?" on each
106 // selection change, which is a scan of the selection per row otherwise.
107 let sel_names = Memo::new(move |_| {
108 selected.with(|v| {
109 v.iter()
110 .map(|e| e.name.clone())
111 .collect::<HashSet<String>>()
112 })
113 });
114
115 // Drop selected entries that disappeared from the listing (deleted,
116 // renamed, …).
117 Effect::new(move |_| {
118 list_state.with(|st| {
119 let ListState::Entries(list) = st else {
120 return;
121 };
122 let names: HashSet<&str> = list.entries.iter().map(|e| e.name.as_str()).collect();
123 let cur = selected.get_untracked();
124 let kept: Vec<Entry> = cur
125 .iter()
126 .filter(|s| names.contains(s.name.as_str()))
127 .cloned()
128 .collect();
129 if kept.len() != cur.len() {
130 set_selected.set(kept);
131 }
132 });
133 });
134 // Escape clears the selection — but only when nothing that also reacts
135 // to Escape is open: the context menu (it has its own Escape handler),
136 // a modal dialog (the <dialog> closes itself; the selection must
137 // survive, same as for the file view), or a full-page file view (the
138 // shell closes it on the same keypress). Registered in the capture
139 // phase so it sees the dialog / file view *before* they close; and it
140 // blurs the focused row, because the keypress would otherwise leave the
141 // row's :focus-visible outline behind.
142 crate::util::owned_window_listener_capture(
143 leptos::ev::keydown,
144 move |ev: web_sys::KeyboardEvent| {
145 if ev.key() != "Escape"
146 || ctx.get().is_some()
147 || dialog.get().is_some()
148 || file_view.get().is_some()
149 {
150 return;
151 }
152 if let Some(active) = web_sys::window()
153 .and_then(|w| w.document())
154 .and_then(|d| d.active_element())
155 .and_then(|a| a.dyn_into::<web_sys::HtmlElement>().ok())
156 {
157 let _ = active.blur();
158 }
159 set_selected.set(Vec::new());
160 },
161 );
162
163 // Fetch the current directory whenever the location (or user) changes.
164 let fetch = Callback::new(move |_| {
165 let Some(me) = me.get() else {
166 return;
167 };
168 let loc = loc.get();
169 let Some(root) = effective_root(&me.roots, &loc) else {
170 return;
171 };
172 set_list_state.set(ListState::Loading);
173 shown.set(FILE_PAGE);
174 fetch_gen.update_value(|g| *g += 1);
175 let my_gen = fetch_gen.get_value();
176 let root_id = root.id;
177 let path_str = loc.path.join("/");
178 // On a public share page the (synthetic) "me" comes from the share
179 // itself, not from a session — refreshing /me would be wrong there.
180 let is_share = loc.share_token.is_some();
181 let set_me2 = set_me;
182 let refresh_key = me_refresh_key;
183 let set_refresh_key = set_me_refresh_key;
184 spawn_local(async move {
185 let resp = api::list_files(root_id, &path_str).await;
186 // A newer fetch (or the view going away) makes this response
187 // stale: applying it would show the wrong folder.
188 if fetch_gen.try_get_value() != Some(my_gen) {
189 return;
190 }
191 match resp {
192 Ok(r) => set_list_state.set(ListState::Entries(Arc::new(Listing {
193 entries: r.entries,
194 truncated: r.truncated,
195 }))),
196 Err(e) => {
197 if !is_share
198 && matches!(e.status(), Some(403) | Some(404))
199 && refresh_key.get() != Some((root_id, path_str.clone()))
200 {
201 set_refresh_key.set(Some((root_id, path_str.clone())));
202 spawn_local(async move {
203 if let Ok(m) = api::me().await {
204 set_me2.set(Some(m));
205 }
206 });
207 }
208 set_list_state.set(ListState::Error(e.to_string()));
209 }
210 }
211 });
212 });
213 Effect::new(move |_| fetch.run(()));
214 {
215 let w = set_browser_refresh;
216 let f = fetch;
217 Effect::new(move |_| {
218 w.set(Some(f));
219 });
220 }
221
222 view! {
223 <div class="browser">
224 {move || {
225 let Some(me_now) = me.get() else {
226 return {
227 view! {}.into_any()
228 };
229 };
230 let loc_now = loc.get();
231 match effective_root(&me_now.roots, &loc_now) {
232 Some(root) => file_browser(
233 me,
234 root,
235 loc,
236 list_state,
237 shown,
238 view_mode,
239 set_view_mode,
240 set_ctx,
241 selected,
242 sel_names,
243 set_selected,
244 fetch,
245 open_file,
246 )
247 .into_any(),
248 None => no_folder_view(&me_now.roots, &loc_now).into_any(),
249 }
250 }}
251 <CtxMenuView
252 ctx=ctx
253 set_ctx=set_ctx
254 me=me
255 loc=loc
256 selected=selected
257 refresh=fetch
258 toast=toast
259 set_dialog=set_dialog
260 open_file=open_file
261 owner=ctx_owner
262 />
263 </div>
264 }
265}
266
267// ---------------------------------------------------------------------------
268// Empty states (no resolvable root)
269// ---------------------------------------------------------------------------
270
271/// Shown when the browser can't resolve a root: the user has no roots at all,
272/// hasn't picked one yet (they are sidebar tabs now), or the URL points at a
273/// root that no longer exists.
274fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
275 let (title, message) = if roots.is_empty() {
276 (
277 i18n::t(i18n::k::NO_FOLDERS_TITLE),
278 i18n::t(i18n::k::NO_FOLDERS_MSG),
279 )
280 } else if loc.root_id.is_none() {
281 (
282 i18n::t(i18n::k::SELECT_FOLDER_TITLE),
283 i18n::t(i18n::k::SELECT_FOLDER_MSG),
284 )
285 } else {
286 (
287 i18n::t(i18n::k::FOLDER_GONE_TITLE),
288 i18n::t(i18n::k::FOLDER_GONE_MSG),
289 )
290 };
291 view! {
292 <div class="empty-state">
293 <Icon name=IconName::FolderOff class="empty-glyph".to_string()/>
294 <h3>{title}</h3>
295 <p class="muted">{message}</p>
296 </div>
297 }
298}
299
300// ---------------------------------------------------------------------------
301// File browser (breadcrumbs + toolbar + entries)
302// ---------------------------------------------------------------------------
303
304#[allow(clippy::too_many_arguments)] // explicit signal props
305fn file_browser(
306 me: ReadSignal<Option<Me>>,
307 root: &RootInfo,
308 loc: ReadSignal<Location>,
309 list_state: ReadSignal<ListState>,
310 shown: RwSignal<usize>,
311 view_mode: ReadSignal<ViewMode>,
312 set_view_mode: WriteSignal<ViewMode>,
313 set_ctx: WriteSignal<Option<CtxMenu>>,
314 selected: ReadSignal<Vec<Entry>>,
315 sel_names: Memo<HashSet<String>>,
316 set_selected: WriteSignal<Vec<Entry>>,
317 fetch: Callback<()>,
318 open_file: WriteSignal<Option<FileView>>,
319) -> impl IntoView {
320 let root_id = root.id;
321 let root_name = root.name.clone();
322 let is_rw = root.mode.is_writable();
323 let loc_now = loc.get();
324 // The user's profile setting: single click opens entries; when off,
325 // a single click selects and a double click opens.
326 let single_click = me
327 .get()
328 .and_then(|m| m.user)
329 .is_some_and(|u| u.single_click_open);
330
331 // Breadcrumbs
332 let crumbs = view! {
333 <div class="crumbs">
334 <span
335 class="crumb"
336 on:click=move |_| {
337 navigate(&Location {
338 root_id: Some(root_id),
339 path: vec![],
340 share_token: loc.get().share_token.clone(),
341 section: Section::Files,
342 });
343 }
344 >
345 {root_name.clone()}
346 </span>
347 {loc_now.path.iter().enumerate().map(|(i, seg)| {
348 let seg = seg.clone();
349 let target: Vec<String> = loc_now.path[..=i].to_vec();
350 let class = if i == loc_now.path.len() - 1 {
351 "crumb current"
352 } else {
353 "crumb"
354 };
355 view! {
356 <span class="crumb-sep">"/"</span>
357 <span
358 class=class
359 on:click=move |_| {
360 navigate(&Location {
361 root_id: Some(root_id),
362 path: target.clone(),
363 share_token: loc.get().share_token.clone(),
364 section: Section::Files,
365 });
366 }
367 >
368 {seg}
369 </span>
370 }
371 }).collect::<Vec<_>>()}
372 </div>
373 };
374
375 // Toolbar: refresh on the left, view toggles on the right. The
376 // selection's action buttons live in the app's top bar.
377 let toolbar = view! {
378 <div class="toolbar">
379 <button
380 class="icon-btn"
381 title=i18n::tr(i18n::k::REFRESH)
382 aria-label=i18n::tr(i18n::k::REFRESH)
383 on:click=move |_| fetch.run(())
384 >
385 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
386 </button>
387 <div class="toolbar-right">
388 <button
389 class=move || {
390 if view_mode.get() == ViewMode::Grid {
391 "icon-btn active".to_string()
392 } else {
393 "icon-btn".to_string()
394 }
395 }
396 title=i18n::tr(i18n::k::GRID_VIEW)
397 on:click=move |_| {
398 set_view_mode.set(ViewMode::Grid);
399 ViewMode::Grid.save();
400 }
401 >
402 <Icon name=IconName::Grid class="ic-btn".to_string()/>
403 </button>
404 <button
405 class=move || {
406 if view_mode.get() == ViewMode::List {
407 "icon-btn active".to_string()
408 } else {
409 "icon-btn".to_string()
410 }
411 }
412 title=i18n::tr(i18n::k::LIST_VIEW)
413 on:click=move |_| {
414 set_view_mode.set(ViewMode::List);
415 ViewMode::List.save();
416 }
417 >
418 <Icon name=IconName::List class="ic-btn".to_string()/>
419 </button>
420 </div>
421 </div>
422 };
423
424 let entries_area = view! {
425 <div
426 class="entries-area"
427 // Clicking empty space clears the selection.
428 on:click=move |_| set_selected.set(Vec::new())
429 on:contextmenu=move |ev: MouseEvent| {
430 ev.prevent_default();
431 set_selected.set(Vec::new());
432 set_ctx.set(Some((ev.client_x(), ev.client_y())));
433 }
434 >
435 {move || {
436 match list_state.get() {
437 ListState::Loading => view! {
438 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
439 }
440 .into_view()
441 .into_any(),
442 ListState::Error(msg) => view! {
443 <div class="card error-card">
444 <span>{msg.clone()}</span>
445 <button class="btn" on:click=move |_| fetch.run(())>
446 {i18n::tr(i18n::k::RETRY)}
447 </button>
448 </div>
449 }
450 .into_view()
451 .into_any(),
452 ListState::Entries(list) => {
453 if list.entries.is_empty() {
454 return view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
455 .into_view()
456 .into_any();
457 }
458 let total = list.entries.len();
459 let truncated = list.truncated;
460 let body = entries_view(
461 view_mode.get(),
462 list,
463 shown,
464 root_id,
465 loc,
466 is_rw,
467 single_click,
468 selected,
469 sel_names,
470 set_selected,
471 set_ctx,
472 open_file,
473 );
474 // Paging footer and, below it, the server-side cap
475 // notice — both belong to the listing, not to one
476 // of the two layouts.
477 view! {
478 {body}
479 {move || page_footer(
480 shown.get().min(total),
481 total,
482 total,
483 FILE_PAGE,
484 move || shown.update(|n| *n += FILE_PAGE),
485 )}
486 {truncated.then(|| view! {
487 <p class="muted list-note">
488 {move || i18n::t_fmt(
489 i18n::k::LIST_TRUNCATED,
490 &api_types::MAX_LIST_ENTRIES.to_string(),
491 )}
492 </p>
493 })}
494 }
495 .into_view()
496 .into_any()
497 }
498 }
499 }}
500 </div>
501 };
502
503 view! {
504 {crumbs}
505 {toolbar}
506 {entries_area}
507 }
508}
509
510/// Right-click on an entry: open the context menu on the selection. An
511/// entry that is already selected keeps the whole selection (multi-item
512/// menu); any other entry becomes the only selected one.
513fn ctx_menu_handler(
514 all: Arc<Listing>,
515 idx: usize,
516 set_ctx: WriteSignal<Option<CtxMenu>>,
517 selected: ReadSignal<Vec<Entry>>,
518 set_selected: WriteSignal<Vec<Entry>>,
519) -> impl Fn(web_sys::MouseEvent) + 'static {
520 move |ev: MouseEvent| {
521 ev.prevent_default();
522 ev.stop_propagation();
523 let entry = &all.entries[idx];
524 if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
525 set_selected.set(vec![entry.clone()]);
526 }
527 set_ctx.set(Some((ev.client_x(), ev.client_y())));
528 }
529}
530
531/// Left-click behaviour. Modifier clicks always select:
532/// - Ctrl/Cmd+click toggles the entry;
533/// - Shift+click selects the range from the anchor (the first selected
534/// entry) to the clicked one, in listing order; with Ctrl it adds the
535/// range instead of replacing the selection.
536///
537/// A plain click follows the "single click opens" profile setting: off
538/// (default) selects the entry alone (a double click opens it); on opens it.
539///
540/// Selecting clicks stop propagation so the empty-area click (deselect) and
541/// the double-click handler don't interfere.
542fn selection_click(
543 all: Arc<Listing>,
544 idx: usize,
545 single_click: bool,
546 selected: ReadSignal<Vec<Entry>>,
547 set_selected: WriteSignal<Vec<Entry>>,
548 open: Callback<()>,
549) -> impl Fn(web_sys::MouseEvent) + 'static {
550 move |ev: MouseEvent| {
551 // Ranges and "is it still there?" run over the whole listing, not
552 // over the rendered page.
553 let entries = &all.entries;
554 let entry = &entries[idx];
555 let ctrl = ev.ctrl_key() || ev.meta_key();
556 if !ctrl && !ev.shift_key() {
557 if single_click {
558 open.run(());
559 return;
560 }
561 ev.stop_propagation();
562 set_selected.set(vec![entry.clone()]);
563 return;
564 }
565 ev.stop_propagation();
566 let cur = selected.get_untracked();
567 if ev.shift_key() {
568 let anchor = match cur.first() {
569 Some(a) => match entries.iter().position(|e| e.name == a.name) {
570 Some(a) => a,
571 None => return,
572 },
573 None => idx,
574 };
575 let (lo, hi) = if anchor <= idx {
576 (anchor, idx)
577 } else {
578 (idx, anchor)
579 };
580 // The anchor stays first so the next Shift+click extends from it.
581 let mut next: Vec<Entry> = if ctrl { cur } else { Vec::new() };
582 let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
583 let mut add = |e: &Entry| {
584 if have.insert(e.name.clone()) {
585 next.push(e.clone());
586 }
587 };
588 add(&entries[anchor]);
589 entries[lo..=hi].iter().for_each(&mut add);
590 set_selected.set(next);
591 return;
592 }
593 let mut next = cur;
594 match next.iter().position(|s| s.name == entry.name) {
595 Some(i) => {
596 next.remove(i);
597 }
598 None => next.push(entry.clone()),
599 }
600 set_selected.set(next);
601 }
602}
603
604/// What "open" does for an entry: descend into a folder, open a text file in
605/// the editor (read-only in read-only folders), open a media preview — or
606/// say so when there is no viewer. Shared by left-click, Enter, and the
607/// context menu's "Open" item.
608fn open_entry(
609 entry: &Entry,
610 root_id: i64,
611 loc: ReadSignal<Location>,
612 is_rw: bool,
613 open_file: WriteSignal<Option<FileView>>,
614) -> Callback<()> {
615 let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
616 Callback::new(move |_| {
617 if is_dir {
618 let cur = loc.get();
619 let mut path = cur.path;
620 path.push(name.clone());
621 navigate(&Location {
622 root_id: Some(root_id),
623 path,
624 share_token: cur.share_token.clone(),
625 section: Section::Files,
626 });
627 return;
628 }
629 let full = join_path(&loc.get().path, &name);
630 if kind == FileKind::Text {
631 open_file.set(Some(FileView::Editor(EditTarget {
632 root_id,
633 path: full,
634 name: name.clone(),
635 readonly: !is_rw,
636 })));
637 return;
638 }
639 match preview_kind(kind) {
640 Some(k) => open_file.set(Some(FileView::Preview(
641 PreviewTarget {
642 root_id,
643 path: full,
644 name: name.clone(),
645 },
646 k,
647 ))),
648 None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
649 root_id,
650 path: full,
651 name: name.clone(),
652 kind,
653 }))),
654 }
655 })
656}
657
658/// Click/Enter on an entry: the shared "open" action, the selection-aware
659/// click handler, and the context-menu handler. The grid and the list differ
660/// only in markup.
661#[allow(clippy::too_many_arguments)] // explicit signal props
662fn entry_callbacks(
663 all: Arc<Listing>,
664 idx: usize,
665 root_id: i64,
666 loc: ReadSignal<Location>,
667 is_rw: bool,
668 single_click: bool,
669 selected: ReadSignal<Vec<Entry>>,
670 set_selected: WriteSignal<Vec<Entry>>,
671 set_ctx: WriteSignal<Option<CtxMenu>>,
672 open_file: WriteSignal<Option<FileView>>,
673) -> (
674 Callback<()>,
675 impl Fn(web_sys::MouseEvent) + 'static,
676 impl Fn(web_sys::MouseEvent) + 'static,
677) {
678 let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file);
679 (
680 open_cb,
681 selection_click(
682 all.clone(),
683 idx,
684 single_click,
685 selected,
686 set_selected,
687 open_cb,
688 ),
689 ctx_menu_handler(all, idx, set_ctx, selected, set_selected),
690 )
691}
692
693/// Whether a double click opens: not with a modifier held, because that is
694/// two selecting clicks (the browser still fires `dblclick` for them).
695fn plain_dblclick(ev: &MouseEvent) -> bool {
696 !(ev.ctrl_key() || ev.meta_key() || ev.shift_key())
697}
698
699/// One listing layout. Grid and list differ only in their container and
700/// item classes and in what each item shows, so they are one function: the
701/// selection, keyboard and context-menu behaviour is identical and must
702/// stay that way.
703#[allow(clippy::too_many_arguments)] // explicit signal props
704fn entries_view(
705 mode: ViewMode,
706 all: Arc<Listing>,
707 shown: RwSignal<usize>,
708 root_id: i64,
709 loc: ReadSignal<Location>,
710 is_rw: bool,
711 single_click: bool,
712 selected: ReadSignal<Vec<Entry>>,
713 sel_names: Memo<HashSet<String>>,
714 set_selected: WriteSignal<Vec<Entry>>,
715 set_ctx: WriteSignal<Option<CtxMenu>>,
716 open_file: WriteSignal<Option<FileView>>,
717) -> impl IntoView {
718 let total = all.entries.len();
719 let grid = mode == ViewMode::Grid;
720 let (container, item, icon_class) = if grid {
721 ("entries-grid", "tile", "tile-icon")
722 } else {
723 ("entries-list", "row", "row-icon")
724 };
725 view! {
726 <div class=container>
727 // Only the visible page is rendered; the click handlers still
728 // index into the whole listing, so Shift+click ranges reach
729 // past it. Keyed by index: within one listing the order is
730 // fixed, so revealing a page only appends items.
731 <For
732 each=move || 0..shown.get().min(total)
733 key=|i: &usize| *i
734 children=move |i: usize| {
735 let e = &all.entries[i];
736 let icon = icon_for(e.kind, &e.name);
737 let name = e.name.clone();
738 let title = name.clone();
739 let size = if e.is_dir { "—".to_string() } else { format_size(e.size) };
740 let date = format_date(&e.mtime);
741 let (open_cb, on_click, on_ctx) = entry_callbacks(
742 all.clone(), i, root_id, loc, is_rw, single_click,
743 selected, set_selected, set_ctx, open_file,
744 );
745 let item_name = name.clone();
746 let selected_now = move || sel_names.with(|s| s.contains(&item_name));
747 view! {
748 <div
749 class=item
750 class:selected=selected_now
751 tabindex=0
752 on:click=on_click
753 on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
754 on:keydown=move |ev: web_sys::KeyboardEvent| {
755 if ev.key() == "Enter" || ev.key() == " " {
756 ev.prevent_default();
757 open_cb.run(());
758 }
759 }
760 on:contextmenu=on_ctx
761 title=title
762 >
763 // `icon_svg`, not `<Icon/>`: no reactive owner
764 // per item (see its doc).
765 {icon_svg(icon, icon_class)}
766 {if grid {
767 view! { <div class="tile-name">{name}</div> }.into_any()
768 } else {
769 view! {
770 <span class="row-name">{name}</span>
771 <span class="row-size muted">{size}</span>
772 <span class="row-date muted">{date}</span>
773 }.into_any()
774 }}
775 </div>
776 }
777 }
778 />
779 </div>
780 }
781}
782
783// ---------------------------------------------------------------------------
784// Context menu
785// ---------------------------------------------------------------------------
786
787/// Build the action list for the selection: the empty area (nothing
788/// selected), one entry, or several. Shared by the right-click context menu
789/// and the toolbar's selection actions so both always offer the same
790/// operations.
791#[allow(clippy::too_many_arguments)] // explicit signal props
792fn menu_items(
793 sel: &[Entry],
794 me: ReadSignal<Option<Me>>,
795 loc: ReadSignal<Location>,
796 open_file: WriteSignal<Option<FileView>>,
797 refresh: Callback<()>,
798 toast: ToastMsg,
799 set_dialog: WriteSignal<Option<Dialog>>,
800 owner: Owner,
801) -> Vec<MenuItem> {
802 let loc_now = loc.get();
803 // The root that is actually shown. Resolves the single-root default
804 // location, where `loc.root_id` is `None`. Read in place: cloning `me`
805 // would copy its whole root list on every selection change.
806 let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
807 let root = me_now
808 .as_ref()
809 .and_then(|m| effective_root(&m.roots, &loc_now));
810 (
811 root.map(|r| r.mode.is_writable()).unwrap_or(false),
812 root.map(|r| r.name.clone()).unwrap_or_default(),
813 root.map(|r| r.id),
814 me_now
815 .as_ref()
816 .map(|m| m.allow_writable_shares)
817 .unwrap_or(false),
818 )
819 });
820
821 match sel {
822 [] => {
823 let mut v = vec![
824 MenuItem::rw(
825 IconName::NewFolder,
826 i18n::t(i18n::k::NEW_FOLDER),
827 is_rw,
828 action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
829 ),
830 MenuItem::rw(
831 IconName::NewFile,
832 i18n::t(i18n::k::NEW_FILE),
833 is_rw,
834 action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
835 ),
836 MenuItem::rw(
837 IconName::Upload,
838 i18n::t(i18n::k::UPLOAD_FILES),
839 is_rw,
840 action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
841 ),
842 MenuItem::rw(
843 IconName::UploadFolder,
844 i18n::t(i18n::k::UPLOAD_FOLDER),
845 is_rw,
846 action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
847 ),
848 ];
849 // Search is a signed-in feature; a share visitor has none.
850 if let Some(rid) = root_id
851 && loc_now.share_token.is_none()
852 {
853 let dir = loc_now.path.join("/");
854 v.push(MenuItem::new(
855 IconName::Search,
856 i18n::t(i18n::k::SEARCH_IN_FOLDER),
857 owner.with(|| {
858 Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
859 }),
860 ));
861 }
862 v
863 }
864 // One entry gets every action; several get only the ones that take
865 // a set (move, copy, delete).
866 _ => {
867 let one = if let [e] = sel { Some(e) } else { None };
868 let mut v = vec![];
869 if let Some(e) = one {
870 // Exactly what left-clicking the entry does: descend into a
871 // folder, open a text file in the editor (read-only in
872 // read-only folders), or preview media.
873 if let Some(rid) = root_id {
874 v.push(MenuItem::new(
875 if e.is_dir {
876 IconName::FolderOpen
877 } else {
878 IconName::Open
879 },
880 i18n::t(i18n::k::OPEN),
881 open_entry(e, rid, loc, is_rw, open_file),
882 ));
883 }
884 if !e.is_dir {
885 // Serve the file itself in a new tab. HTML and SVG render
886 // as real pages there (sandboxed by the server), which is
887 // how you share a small site or an HTML report.
888 v.push(MenuItem::new(
889 IconName::OpenInNew,
890 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
891 action_open_tab(e, root_id, loc, owner.clone()),
892 ));
893 }
894 v.push(MenuItem::new(
895 IconName::Download,
896 i18n::t(i18n::k::DOWNLOAD),
897 action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
898 ));
899 v.push(MenuItem::rw(
900 IconName::Rename,
901 i18n::t(i18n::k::RENAME),
902 is_rw,
903 action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
904 ));
905 }
906 v.push(MenuItem::rw(
907 IconName::Move,
908 i18n::t(i18n::k::MOVE),
909 is_rw,
910 action_move_copy(
911 Op::Move,
912 sel,
913 root_id,
914 me,
915 loc,
916 refresh,
917 toast,
918 set_dialog,
919 owner.clone(),
920 ),
921 ));
922 // Copying only writes at the destination, so a read-only source
923 // folder is fine.
924 v.push(MenuItem::new(
925 IconName::Copy,
926 i18n::t(i18n::k::COPY),
927 action_move_copy(
928 Op::Copy,
929 sel,
930 root_id,
931 me,
932 loc,
933 refresh,
934 toast,
935 set_dialog,
936 owner.clone(),
937 ),
938 ));
939 if let Some(e) = one {
940 if loc_now.share_token.is_none() {
941 v.push(MenuItem::new(
942 IconName::Share,
943 i18n::t(i18n::k::SHARE),
944 action_share(
945 e,
946 root_id,
947 loc,
948 allow_writable_shares,
949 is_rw,
950 set_dialog,
951 owner.clone(),
952 ),
953 ));
954 }
955 v.push(MenuItem::new(
956 IconName::Info,
957 i18n::t(i18n::k::INFO),
958 action_info(e, loc, root_name, set_dialog, owner.clone()),
959 ));
960 }
961 v.push(MenuItem::rw(
962 IconName::Delete,
963 i18n::t(i18n::k::DELETE),
964 is_rw,
965 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
966 ));
967 v
968 }
969 }
970}
971
972#[component]
973fn CtxMenuView(
974 ctx: ReadSignal<Option<CtxMenu>>,
975 set_ctx: WriteSignal<Option<CtxMenu>>,
976 me: ReadSignal<Option<Me>>,
977 loc: ReadSignal<Location>,
978 selected: ReadSignal<Vec<Entry>>,
979 refresh: Callback<()>,
980 toast: ToastMsg,
981 set_dialog: WriteSignal<Option<Dialog>>,
982 open_file: WriteSignal<Option<FileView>>,
983 owner: Owner,
984) -> impl IntoView {
985 // Close on any click or Escape.
986 {
987 crate::util::owned_window_listener(leptos::ev::click, move |_| {
988 set_ctx.set(None);
989 });
990 crate::util::owned_window_listener(
991 leptos::ev::keydown,
992 move |ev: web_sys::KeyboardEvent| {
993 if ev.key() == "Escape" {
994 set_ctx.set(None);
995 }
996 },
997 );
998 }
999
1000 // The menu first paints at the cursor point; after mount, this measures
1001 // its actual size and flips it above/left of the cursor when it would
1002 // overflow the viewport (native context-menu behavior), clamping to the
1003 // viewport edges as a fallback.
1004 let menu_ref = NodeRef::<leptos::html::Div>::new();
1005 Effect::new(move |_| {
1006 let Some((mx, my)) = ctx.get() else {
1007 return;
1008 };
1009 let Some(node) = menu_ref
1010 .get()
1011 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1012 else {
1013 return;
1014 };
1015 let rect = node.get_bounding_client_rect();
1016 let (w, h) = (rect.width() as i32, rect.height() as i32);
1017 let (vw, vh) = web_sys::window()
1018 .and_then(|w| {
1019 let vw = w.inner_width().ok()?.as_f64()?;
1020 let vh = w.inner_height().ok()?.as_f64()?;
1021 Some((vw, vh))
1022 })
1023 .unwrap_or((f64::MAX, f64::MAX));
1024 let (vw, vh) = (vw as i32, vh as i32);
1025 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1026 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1027 let _ = node.style().set_property("left", &format!("{x}px"));
1028 let _ = node.style().set_property("top", &format!("{y}px"));
1029 });
1030
1031 view! {
1032 {move || {
1033 let Some((mx, my)) = ctx.get() else {
1034 return {
1035 view! {}.into_any()
1036 };
1037 };
1038 let items = selected.with(|sel| {
1039 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1040 });
1041
1042 view! {
1043 <div
1044 class="ctx-menu"
1045 node_ref=menu_ref
1046 style=format!("left:{mx}px;top:{my}px")
1047 >
1048 {items.into_iter().map(|item| {
1049 view! { <CtxItem item=item/> }
1050 }).collect::<Vec<_>>()}
1051 </div>
1052 }
1053 .into_view()
1054 .into_any()
1055 }}
1056 }
1057}
1058
1059/// One row of the context menu. `action` is `None` for a disabled row, and
1060/// `hint` then says why it is disabled.
1061#[derive(Clone)]
1062struct MenuItem {
1063 label: &'static str,
1064 icon: IconName,
1065 action: Option<Callback<()>>,
1066 hint: Option<&'static str>,
1067}
1068
1069impl MenuItem {
1070 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1071 Self {
1072 label,
1073 icon,
1074 action: Some(action),
1075 hint: None,
1076 }
1077 }
1078
1079 /// An item that only works in a writable folder.
1080 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1081 Self {
1082 label,
1083 icon,
1084 action: is_rw.then_some(action),
1085 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1086 }
1087 }
1088}
1089
1090#[component]
1091fn CtxItem(item: MenuItem) -> impl IntoView {
1092 let MenuItem {
1093 label,
1094 icon,
1095 action,
1096 hint,
1097 } = item;
1098 let enabled = action.is_some();
1099 let hint = hint.unwrap_or_default();
1100 view! {
1101 <div
1102 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1103 title=move || if enabled { "".to_string() } else { hint.to_string() }
1104 on:click=move |_| {
1105 if let Some(a) = &action {
1106 a.run(());
1107 }
1108 }
1109 >
1110 <Icon name=icon class="ic-ctx".to_string()/>
1111 <span>{label}</span>
1112 </div>
1113 }
1114}
1115
1116// ---------------------------------------------------------------------------
1117// Toolbar selection actions (top right)
1118// ---------------------------------------------------------------------------
1119
1120/// Icon-only buttons for the current selection — the same set of actions
1121/// as its right-click menu — or the empty-area actions (new folder,
1122/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1123/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1124#[component]
1125pub fn SelectionActions(
1126 me: ReadSignal<Option<Me>>,
1127 loc: ReadSignal<Location>,
1128 selected: ReadSignal<Vec<Entry>>,
1129 open_file: WriteSignal<Option<FileView>>,
1130 refresh: Callback<()>,
1131 toast: ToastMsg,
1132 set_dialog: WriteSignal<Option<Dialog>>,
1133 owner: Owner,
1134 topbar_ref: NodeRef<leptos::html::Header>,
1135) -> impl IntoView {
1136 // How many buttons are shown before the overflow kicks in. Starts small
1137 // so the first paint never overflows; the effect below widens it as soon
1138 // as the real widths are measurable.
1139 let (visible_n, set_visible_n) = signal(3usize);
1140 // The three-dot overflow menu state. Any outside click or Escape closes
1141 // it; a new selection does too. The toggle button stops propagation so
1142 // it can actually open the menu.
1143 let (more_open, set_more_open) = signal(false);
1144 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
1145 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
1146 if ev.key() == "Escape" {
1147 set_more_open.set(false);
1148 }
1149 });
1150 Effect::new(move |_| {
1151 let _ = selected.get();
1152 set_more_open.set(false);
1153 });
1154 let group_ref = NodeRef::<leptos::html::Div>::new();
1155
1156 let recompute = move || {
1157 let Some(tb) = topbar_ref
1158 .get()
1159 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1160 else {
1161 return;
1162 };
1163 let Some(g) = group_ref
1164 .get()
1165 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1166 else {
1167 return;
1168 };
1169 // Every button is icon-only with identical padding, so one
1170 // rendered button gives the width of all of them.
1171 let Some(btn) = g.first_element_child() else {
1172 return;
1173 };
1174 // Available width: from the top bar's left content (brand) to its
1175 // right edge — independent of where the group currently sits,
1176 // so the measurement stays valid even while the group overflows.
1177 let tb_r = tb.get_bounding_client_rect();
1178 let start = tb
1179 .query_selector(".brand")
1180 .ok()
1181 .flatten()
1182 .map(|b| b.get_bounding_client_rect())
1183 .map(|r| r.right() + 16.0)
1184 .unwrap_or(0.0);
1185 let avail = (tb_r.right() - start - 10.0).max(40.0);
1186 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1187 // How many buttons fit (not counting the overflow button).
1188 set_visible_n.set(((avail / step) as usize).max(1));
1189 };
1190 // Measuring forces three layout reflows, so it must not run on every
1191 // selection change. Only how many buttons *fit* is measured, and that
1192 // changes when the group appears, disappears, or switches between the
1193 // empty-area, single and multi action sets — not per selected entry.
1194 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1195 Effect::new(move |_| {
1196 topbar_ref.get();
1197 group_ref.get();
1198 // Re-measure when the item set (selection kind, user) or width
1199 // inputs change.
1200 sel_kind.get();
1201 me.track();
1202 recompute();
1203 });
1204 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1205
1206 view! {
1207 <div class="sel-actions" node_ref=group_ref>
1208 // The visible buttons and the overflow rows are one item list
1209 // split at the measured count, so the actions are computed once.
1210 {move || {
1211 // No resolvable root (e.g. the `#/` "select a folder" empty
1212 // state): there is nowhere to act, so hide the whole group
1213 // rather than show a row of disabled buttons.
1214 let has_root = me.with(|m| {
1215 m.as_ref()
1216 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1217 });
1218 if !has_root {
1219 return {
1220 view! {}.into_any()
1221 };
1222 }
1223 let items = selected.with(|sel| {
1224 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1225 });
1226 let fit = visible_n.get();
1227 let n = if fit >= items.len() {
1228 items.len()
1229 } else {
1230 // The overflow button takes a slot too, so drop one —
1231 // otherwise the group eats into the space after the brand.
1232 (fit - 1).max(1)
1233 };
1234 let (visible, more) = items.split_at(n);
1235 let visible_view = visible
1236 .iter()
1237 .cloned()
1238 .map(|i| view! {
1239 <SelButton item=i/>
1240 })
1241 .collect_view();
1242 // Rendered as nothing while every action fits.
1243 let more_block = (!more.is_empty()).then(|| {
1244 let more_view = more
1245 .iter()
1246 .cloned()
1247 .map(|i| view! {
1248 <CtxItem item=i/>
1249 })
1250 .collect_view();
1251 view! {
1252 <div class="sel-more-wrap">
1253 <button
1254 class="icon-btn"
1255 title=i18n::tr(i18n::k::MORE_ACTIONS)
1256 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1257 on:click=move |ev: MouseEvent| {
1258 ev.stop_propagation();
1259 set_more_open.set(!more_open.get());
1260 }
1261 >
1262 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1263 </button>
1264 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1265 {more_view}
1266 </div>
1267 </div>
1268 }
1269 });
1270 view! {
1271 {visible_view}
1272 {more_block}
1273 }
1274 .into_view()
1275 .into_any()
1276 }}
1277 </div>
1278 }
1279}
1280
1281/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1282/// then explains why.
1283#[component]
1284fn SelButton(item: MenuItem) -> impl IntoView {
1285 let enabled = item.action.is_some();
1286 let title = if enabled {
1287 item.label.to_string()
1288 } else {
1289 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1290 };
1291 view! {
1292 <button
1293 class="icon-btn"
1294 disabled=!enabled
1295 title=title
1296 on:click=move |_| {
1297 if let Some(a) = &item.action {
1298 a.run(());
1299 }
1300 }
1301 >
1302 <Icon name=item.icon class="ic-btn".to_string()/>
1303 </button>
1304 }
1305}
1306
1307// ---------------------------------------------------------------------------
1308// Menu actions
1309// ---------------------------------------------------------------------------
1310
1311fn join_path(dir: &[String], name: &str) -> String {
1312 if dir.is_empty() {
1313 name.to_string()
1314 } else {
1315 format!("{}/{}", dir.join("/"), name)
1316 }
1317}
1318
1319fn is_conflict(e: &api::ApiError) -> bool {
1320 matches!(e, api::ApiError::Http { status: 409, .. })
1321}
1322
1323/// A file operation in flight. Boxed because each caller builds a different
1324/// future, and they all have to fit one type.
1325type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1326
1327/// Run a file operation that may collide with an existing name.
1328///
1329/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1330/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1331/// upload all need exactly this, so it lives here once.
1332///
1333/// `names` is what to list in the dialog when the server does not say. Upload
1334/// reports the exact set it skipped; the single-item operations know theirs
1335/// before they ask.
1336fn run_with_overwrite_retry(
1337 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1338 conflict_title: &'static str,
1339 names: Vec<String>,
1340 ok_msg: &'static str,
1341 refresh: Callback<()>,
1342 toast: ToastMsg,
1343 set_dialog: WriteSignal<Option<Dialog>>,
1344) {
1345 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1346 show(toast, ok_msg);
1347 refresh.run(());
1348 }
1349
1350 let retry = op.clone();
1351 spawn_local(async move {
1352 let Err(e) = op(false).await else {
1353 done(ok_msg, refresh, toast);
1354 return;
1355 };
1356 let conflicting = e
1357 .skipped()
1358 .map(<[String]>::to_vec)
1359 .or_else(|| is_conflict(&e).then_some(names));
1360 let Some(files) = conflicting else {
1361 show_error(toast, e.to_string());
1362 return;
1363 };
1364 set_dialog.set(Some(Dialog::Conflict {
1365 title: conflict_title.to_string(),
1366 files,
1367 on_submit: Callback::new(move |_| {
1368 let retry = retry.clone();
1369 spawn_local(async move {
1370 match retry(true).await {
1371 Ok(()) => done(ok_msg, refresh, toast),
1372 Err(e) => show_error(toast, e.to_string()),
1373 }
1374 });
1375 }),
1376 }));
1377 });
1378}
1379
1380fn action_new_folder(
1381 root_id: Option<i64>,
1382 loc: ReadSignal<Location>,
1383 refresh: Callback<()>,
1384 toast: ToastMsg,
1385 set_dialog: WriteSignal<Option<Dialog>>,
1386 owner: Owner,
1387) -> Callback<()> {
1388 Callback::new(move |_| {
1389 let Some(root_id) = root_id else { return };
1390 let loc = loc.get();
1391 let dir = loc.path.clone();
1392 let toast2 = toast;
1393 let refresh2 = refresh;
1394 set_dialog.set(Some(Dialog::PromptOp {
1395 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
1396 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
1397 initial: String::new(),
1398 submit: i18n::t(i18n::k::CREATE).to_string(),
1399 run: owner.with(|| {
1400 Callback::new(move |name: String| {
1401 let full = join_path(&dir, &name);
1402 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1403 api::mkdir(root_id, &full)
1404 .await
1405 .map(|_| ())
1406 .map_err(|e| e.to_string())
1407 });
1408 fut
1409 })
1410 }),
1411 on_ok: owner.with(|| {
1412 Callback::new(move |_| {
1413 show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
1414 refresh2.run(());
1415 })
1416 }),
1417 }));
1418 })
1419}
1420
1421fn action_new_file(
1422 root_id: Option<i64>,
1423 loc: ReadSignal<Location>,
1424 refresh: Callback<()>,
1425 toast: ToastMsg,
1426 set_dialog: WriteSignal<Option<Dialog>>,
1427 owner: Owner,
1428) -> Callback<()> {
1429 Callback::new(move |_| {
1430 let Some(root_id) = root_id else { return };
1431 let loc = loc.get();
1432 let dir = loc.path.clone();
1433 let toast2 = toast;
1434 let refresh2 = refresh;
1435 set_dialog.set(Some(Dialog::PromptOp {
1436 title: i18n::t(i18n::k::NEW_FILE).to_string(),
1437 label: i18n::t(i18n::k::FILE_NAME).to_string(),
1438 initial: String::new(),
1439 submit: i18n::t(i18n::k::CREATE).to_string(),
1440 run: owner.with(|| {
1441 Callback::new(move |name: String| {
1442 let full = join_path(&dir, &name);
1443 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1444 api::create_file(root_id, &full)
1445 .await
1446 .map(|_| ())
1447 .map_err(|e| e.to_string())
1448 });
1449 fut
1450 })
1451 }),
1452 on_ok: owner.with(|| {
1453 Callback::new(move |_| {
1454 show(toast2, i18n::t(i18n::k::FILE_CREATED));
1455 refresh2.run(());
1456 })
1457 }),
1458 }));
1459 })
1460}
1461
1462fn action_rename(
1463 entry: &Entry,
1464 root_id: Option<i64>,
1465 loc: ReadSignal<Location>,
1466 refresh: Callback<()>,
1467 toast: ToastMsg,
1468 set_dialog: WriteSignal<Option<Dialog>>,
1469 owner: Owner,
1470) -> Callback<()> {
1471 let name = entry.name.clone();
1472 Callback::new(move |_| {
1473 let Some(root_id) = root_id else { return };
1474 let loc = loc.get();
1475 let dir = loc.path.clone();
1476 let full = join_path(&dir, &name);
1477 set_dialog.set(Some(Dialog::PromptOp {
1478 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
1479 label: i18n::t(i18n::k::NEW_NAME).to_string(),
1480 initial: name.clone(),
1481 submit: i18n::t(i18n::k::RENAME).to_string(),
1482 run: owner.with(|| {
1483 Callback::new(move |new_name: String| {
1484 let full = full.clone();
1485 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1486 api::rename_item(root_id, &full, new_name, false)
1487 .await
1488 .map(|_| ())
1489 .map_err(|e| e.to_string())
1490 });
1491 fut
1492 })
1493 }),
1494 on_ok: owner.with(|| {
1495 Callback::new(move |_| {
1496 show(toast, i18n::t(i18n::k::RENAMED));
1497 refresh.run(());
1498 })
1499 }),
1500 }));
1501 })
1502}
1503
1504/// Delete the entries after one confirmation. Several are deleted one by
1505/// one; the first failure stops and reports.
1506fn action_delete(
1507 entries: &[Entry],
1508 root_id: Option<i64>,
1509 loc: ReadSignal<Location>,
1510 refresh: Callback<()>,
1511 toast: ToastMsg,
1512 set_dialog: WriteSignal<Option<Dialog>>,
1513 owner: Owner,
1514) -> Callback<()> {
1515 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
1516 Callback::new(move |_| {
1517 let Some(root_id) = root_id else { return };
1518 let dir = loc.get().path;
1519 let message = match items.as_slice() {
1520 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
1521 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
1522 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
1523 };
1524 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
1525 set_dialog.set(Some(Dialog::Confirm {
1526 title: i18n::t(i18n::k::DELETE).to_string(),
1527 message,
1528 submit: i18n::t(i18n::k::DELETE).to_string(),
1529 danger: true,
1530 on_submit: owner.with(|| {
1531 Callback::new(move |_| {
1532 set_dialog.set(None);
1533 let fulls = fulls.clone();
1534 spawn_local(async move {
1535 for f in &fulls {
1536 if let Err(e) = api::delete_item(root_id, f).await {
1537 show_error(toast, e.to_string());
1538 refresh.run(());
1539 return;
1540 }
1541 }
1542 show(toast, i18n::t(i18n::k::DELETED));
1543 refresh.run(());
1544 });
1545 })
1546 }),
1547 }));
1548 })
1549}
1550
1551fn action_upload(
1552 multiple: bool,
1553 directory: bool,
1554 root_id: Option<i64>,
1555 loc: ReadSignal<Location>,
1556 refresh: Callback<()>,
1557 toast: ToastMsg,
1558 set_dialog: WriteSignal<Option<Dialog>>,
1559) -> Callback<()> {
1560 Callback::new(move |_| {
1561 let Some(root_id) = root_id else { return };
1562 let loc = loc.get();
1563 let dir = loc.path.join("/");
1564 let dir1 = dir.clone();
1565 api::pick_files(multiple, directory, move |files| {
1566 let n = files.len();
1567 let dir = dir1.clone();
1568 show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
1569 run_with_overwrite_retry(
1570 move |overwrite| {
1571 let (dir, files) = (dir.clone(), files.clone());
1572 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
1573 },
1574 i18n::t(i18n::k::SOME_FILES_EXIST),
1575 // Unused: an upload conflict always names the skipped files.
1576 Vec::new(),
1577 i18n::t(i18n::k::UPLOAD_COMPLETE),
1578 refresh,
1579 toast,
1580 set_dialog,
1581 );
1582 });
1583 })
1584}
1585
1586/// Move or copy the entries to a folder chosen in the picker. Several are
1587/// processed one by one; name collisions are collected and asked about once,
1588/// and the overwrite retry re-runs only the items that collided.
1589#[allow(clippy::too_many_arguments)] // explicit signal props
1590fn action_move_copy(
1591 op: Op,
1592 entries: &[Entry],
1593 root_id: Option<i64>,
1594 me: ReadSignal<Option<Me>>,
1595 loc: ReadSignal<Location>,
1596 refresh: Callback<()>,
1597 toast: ToastMsg,
1598 set_dialog: WriteSignal<Option<Dialog>>,
1599 owner: Owner,
1600) -> Callback<()> {
1601 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
1602 Callback::new(move |_| {
1603 let loc = loc.get();
1604 let Some(root_id) = root_id else { return };
1605 let roots = me.get().map(|m| m.roots).unwrap_or_default();
1606 let dir = loc.path.clone();
1607 let title = match names.as_slice() {
1608 [one] => i18n::t_fmt(op.title_key(1), one),
1609 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
1610 };
1611 // (name, path relative to the root) per item.
1612 let items: Vec<(String, String)> = names
1613 .iter()
1614 .map(|n| (n.clone(), join_path(&dir, n)))
1615 .collect();
1616 set_dialog.set(Some(Dialog::Picker {
1617 title,
1618 confirm: i18n::t(op.here_key()).to_string(),
1619 roots,
1620 root: root_id,
1621 dir: dir.join("/"),
1622 for_write: true,
1623 on_pick: owner.with(|| {
1624 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1625 // Items still to do. A finished item is dropped, so the
1626 // overwrite retry re-runs only the ones that collided.
1627 let pending = Arc::new(Mutex::new(items.clone()));
1628 run_with_overwrite_retry(
1629 move |overwrite| {
1630 let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
1631 Box::pin(async move {
1632 let items = pending.lock().unwrap().clone();
1633 let mut conflicts = Vec::new();
1634 for (name, full) in items {
1635 let res = if op == Op::Move {
1636 api::move_item(
1637 root_id, &full, dst_root, &dst_dir, overwrite,
1638 )
1639 .await
1640 } else {
1641 api::copy_item(
1642 root_id, &full, dst_root, &dst_dir, overwrite,
1643 )
1644 .await
1645 };
1646 match res {
1647 Ok(_) => {
1648 pending.lock().unwrap().retain(|(_, p)| *p != full)
1649 }
1650 Err(e) if is_conflict(&e) => conflicts.push(name),
1651 Err(e) => return Err(e),
1652 }
1653 }
1654 if conflicts.is_empty() {
1655 Ok(())
1656 } else {
1657 Err(api::ApiError::Http {
1658 status: 409,
1659 message: String::new(),
1660 skipped: Some(conflicts),
1661 })
1662 }
1663 })
1664 },
1665 i18n::t(i18n::k::NAME_TAKEN),
1666 // Unused: the conflict error above always lists the names.
1667 Vec::new(),
1668 if op == Op::Move {
1669 i18n::t(i18n::k::MOVED)
1670 } else {
1671 i18n::t(i18n::k::COPIED)
1672 },
1673 refresh,
1674 toast,
1675 set_dialog,
1676 );
1677 })
1678 }),
1679 }));
1680 })
1681}
1682
1683fn action_download(
1684 entry: &Entry,
1685 root_id: Option<i64>,
1686 loc: ReadSignal<Location>,
1687 set_dialog: WriteSignal<Option<Dialog>>,
1688 toast: ToastMsg,
1689 owner: Owner,
1690) -> Callback<()> {
1691 let name = entry.name.clone();
1692 let is_dir = entry.is_dir;
1693 owner.with(|| {
1694 Callback::new(move |_| {
1695 let Some(root_id) = root_id else { return };
1696 let loc = loc.get();
1697 let full = join_path(&loc.path, &name);
1698 if is_dir {
1699 set_dialog.set(Some(Dialog::DownloadFormat {
1700 name: name.clone(),
1701 root_id,
1702 path: full,
1703 }));
1704 } else {
1705 let url = api::download_url(root_id, &full, None);
1706 api::trigger_download(&url, &name);
1707 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
1708 }
1709 })
1710 })
1711}
1712
1713/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1714/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1715fn action_open_tab(
1716 entry: &Entry,
1717 root_id: Option<i64>,
1718 loc: ReadSignal<Location>,
1719 owner: Owner,
1720) -> Callback<()> {
1721 let name = entry.name.clone();
1722 owner.with(|| {
1723 Callback::new(move |_| {
1724 let Some(root_id) = root_id else { return };
1725 let full = join_path(&loc.get().path, &name);
1726 api::open_in_new_tab(&api::preview_url(root_id, &full));
1727 })
1728 })
1729}
1730
1731/// Open the share dialog for an item (milestone 6).
1732fn action_share(
1733 entry: &Entry,
1734 root_id: Option<i64>,
1735 loc: ReadSignal<Location>,
1736 allow_writable: bool,
1737 root_writable: bool,
1738 set_dialog: WriteSignal<Option<Dialog>>,
1739 owner: Owner,
1740) -> Callback<()> {
1741 let name = entry.name.clone();
1742 owner.with(|| {
1743 Callback::new(move |_| {
1744 let Some(root_id) = root_id else { return };
1745 let loc = loc.get();
1746 let full = join_path(&loc.path, &name);
1747 set_dialog.set(Some(Dialog::Share {
1748 name: name.clone(),
1749 root_id,
1750 path: full,
1751 allow_writable,
1752 root_writable,
1753 }));
1754 })
1755 })
1756}
1757
1758fn action_info(
1759 entry: &Entry,
1760 loc: ReadSignal<Location>,
1761 root_name: String,
1762 set_dialog: WriteSignal<Option<Dialog>>,
1763 owner: Owner,
1764) -> Callback<()> {
1765 let entry2 = entry.clone();
1766 owner.with(|| {
1767 Callback::new(move |_| {
1768 let loc = loc.get();
1769 let full = join_path(&loc.path, &entry2.name);
1770 set_dialog.set(Some(Dialog::Info {
1771 entry: entry2.clone(),
1772 root_name: root_name.clone(),
1773 rel_path: full,
1774 }));
1775 })
1776 })
1777}
1778