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 (Esc closes it; the selection must survive, same as
137 // for the file view), or a full-page file view (the shell closes it on
138 // the same keypress). Registered in the capture phase so it sees the
139 // dialog / file view *before* their bubble listeners close them; 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 = if view_mode.get() == ViewMode::Grid {
461 grid_view(
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 .into_any()
475 } else {
476 list_view(
477 list,
478 shown,
479 root_id,
480 loc,
481 is_rw,
482 single_click,
483 selected,
484 sel_names,
485 set_selected,
486 set_ctx,
487 open_file,
488 )
489 .into_any()
490 };
491 // Paging footer and, below it, the server-side cap
492 // notice — both belong to the listing, not to one
493 // of the two layouts.
494 view! {
495 {body}
496 {move || page_footer(
497 shown.get().min(total),
498 total,
499 total,
500 FILE_PAGE,
501 move || shown.update(|n| *n += FILE_PAGE),
502 )}
503 {truncated.then(|| view! {
504 <p class="muted list-note">
505 {move || i18n::t_fmt(
506 i18n::k::LIST_TRUNCATED,
507 &api_types::MAX_LIST_ENTRIES.to_string(),
508 )}
509 </p>
510 })}
511 }
512 .into_view()
513 .into_any()
514 }
515 }
516 }}
517 </div>
518 };
519
520 view! {
521 {crumbs}
522 {toolbar}
523 {entries_area}
524 }
525}
526
527/// Right-click on an entry: open the context menu on the selection. An
528/// entry that is already selected keeps the whole selection (multi-item
529/// menu); any other entry becomes the only selected one.
530fn ctx_menu_handler(
531 all: Arc<Listing>,
532 idx: usize,
533 set_ctx: WriteSignal<Option<CtxMenu>>,
534 selected: ReadSignal<Vec<Entry>>,
535 set_selected: WriteSignal<Vec<Entry>>,
536) -> impl Fn(web_sys::MouseEvent) + 'static {
537 move |ev: MouseEvent| {
538 ev.prevent_default();
539 ev.stop_propagation();
540 let entry = &all.entries[idx];
541 if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
542 set_selected.set(vec![entry.clone()]);
543 }
544 set_ctx.set(Some((ev.client_x(), ev.client_y())));
545 }
546}
547
548/// Left-click behaviour. Modifier clicks always select:
549/// - Ctrl/Cmd+click toggles the entry;
550/// - Shift+click selects the range from the anchor (the first selected
551/// entry) to the clicked one, in listing order; with Ctrl it adds the
552/// range instead of replacing the selection.
553///
554/// A plain click follows the "single click opens" profile setting: off
555/// (default) selects the entry alone (a double click opens it); on opens it.
556///
557/// Selecting clicks stop propagation so the empty-area click (deselect) and
558/// the double-click handler don't interfere.
559fn selection_click(
560 all: Arc<Listing>,
561 idx: usize,
562 single_click: bool,
563 selected: ReadSignal<Vec<Entry>>,
564 set_selected: WriteSignal<Vec<Entry>>,
565 open: Callback<()>,
566) -> impl Fn(web_sys::MouseEvent) + 'static {
567 move |ev: MouseEvent| {
568 // Ranges and "is it still there?" run over the whole listing, not
569 // over the rendered page.
570 let entries = &all.entries;
571 let entry = &entries[idx];
572 let ctrl = ev.ctrl_key() || ev.meta_key();
573 if !ctrl && !ev.shift_key() {
574 if single_click {
575 open.run(());
576 return;
577 }
578 ev.stop_propagation();
579 set_selected.set(vec![entry.clone()]);
580 return;
581 }
582 ev.stop_propagation();
583 let cur = selected.get_untracked();
584 if ev.shift_key() {
585 let anchor = match cur.first() {
586 Some(a) => match entries.iter().position(|e| e.name == a.name) {
587 Some(a) => a,
588 None => return,
589 },
590 None => idx,
591 };
592 let (lo, hi) = if anchor <= idx {
593 (anchor, idx)
594 } else {
595 (idx, anchor)
596 };
597 // The anchor stays first so the next Shift+click extends from it.
598 let mut next: Vec<Entry> = if ctrl { cur } else { Vec::new() };
599 let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
600 let mut add = |e: &Entry| {
601 if have.insert(e.name.clone()) {
602 next.push(e.clone());
603 }
604 };
605 add(&entries[anchor]);
606 entries[lo..=hi].iter().for_each(&mut add);
607 set_selected.set(next);
608 return;
609 }
610 let mut next = cur;
611 match next.iter().position(|s| s.name == entry.name) {
612 Some(i) => {
613 next.remove(i);
614 }
615 None => next.push(entry.clone()),
616 }
617 set_selected.set(next);
618 }
619}
620
621/// What "open" does for an entry: descend into a folder, open a text file in
622/// the editor (read-only in read-only folders), open a media preview — or
623/// say so when there is no viewer. Shared by left-click, Enter, and the
624/// context menu's "Open" item.
625fn open_entry(
626 entry: &Entry,
627 root_id: i64,
628 loc: ReadSignal<Location>,
629 is_rw: bool,
630 open_file: WriteSignal<Option<FileView>>,
631) -> Callback<()> {
632 let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
633 Callback::new(move |_| {
634 if is_dir {
635 let cur = loc.get();
636 let mut path = cur.path;
637 path.push(name.clone());
638 navigate(&Location {
639 root_id: Some(root_id),
640 path,
641 share_token: cur.share_token.clone(),
642 section: Section::Files,
643 });
644 return;
645 }
646 let full = join_path(&loc.get().path, &name);
647 if kind == FileKind::Text {
648 open_file.set(Some(FileView::Editor(EditTarget {
649 root_id,
650 path: full,
651 name: name.clone(),
652 readonly: !is_rw,
653 })));
654 return;
655 }
656 match preview_kind(kind) {
657 Some(k) => open_file.set(Some(FileView::Preview(
658 PreviewTarget {
659 root_id,
660 path: full,
661 name: name.clone(),
662 },
663 k,
664 ))),
665 None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
666 root_id,
667 path: full,
668 name: name.clone(),
669 kind,
670 }))),
671 }
672 })
673}
674
675/// Click/Enter on an entry: the shared "open" action, the selection-aware
676/// click handler, and the context-menu handler. The grid and the list differ
677/// only in markup.
678#[allow(clippy::too_many_arguments)] // explicit signal props
679fn entry_callbacks(
680 all: Arc<Listing>,
681 idx: usize,
682 root_id: i64,
683 loc: ReadSignal<Location>,
684 is_rw: bool,
685 single_click: bool,
686 selected: ReadSignal<Vec<Entry>>,
687 set_selected: WriteSignal<Vec<Entry>>,
688 set_ctx: WriteSignal<Option<CtxMenu>>,
689 open_file: WriteSignal<Option<FileView>>,
690) -> (
691 Callback<()>,
692 impl Fn(web_sys::MouseEvent) + 'static,
693 impl Fn(web_sys::MouseEvent) + 'static,
694) {
695 let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file);
696 (
697 open_cb,
698 selection_click(
699 all.clone(),
700 idx,
701 single_click,
702 selected,
703 set_selected,
704 open_cb,
705 ),
706 ctx_menu_handler(all, idx, set_ctx, selected, set_selected),
707 )
708}
709
710/// Whether a double click opens: not with a modifier held, because that is
711/// two selecting clicks (the browser still fires `dblclick` for them).
712fn plain_dblclick(ev: &MouseEvent) -> bool {
713 !(ev.ctrl_key() || ev.meta_key() || ev.shift_key())
714}
715
716#[allow(clippy::too_many_arguments)] // explicit signal props
717fn grid_view(
718 all: Arc<Listing>,
719 shown: RwSignal<usize>,
720 root_id: i64,
721 loc: ReadSignal<Location>,
722 is_rw: bool,
723 single_click: bool,
724 selected: ReadSignal<Vec<Entry>>,
725 sel_names: Memo<HashSet<String>>,
726 set_selected: WriteSignal<Vec<Entry>>,
727 set_ctx: WriteSignal<Option<CtxMenu>>,
728 open_file: WriteSignal<Option<FileView>>,
729) -> impl IntoView {
730 let total = all.entries.len();
731 view! {
732 <div class="entries-grid">
733 // Only the visible page is rendered; the click handlers still
734 // index into the whole listing, so Shift+click ranges reach
735 // past it. Keyed by index: within one listing the order is
736 // fixed, so revealing a page only appends tiles.
737 <For
738 each=move || 0..shown.get().min(total)
739 key=|i: &usize| *i
740 children=move |i: usize| {
741 let e = &all.entries[i];
742 let icon = icon_for(e.kind, &e.name);
743 let name = e.name.clone();
744 let title = name.clone();
745 let (open_cb, on_click, on_ctx) = entry_callbacks(
746 all.clone(), i, root_id, loc, is_rw, single_click,
747 selected, set_selected, set_ctx, open_file,
748 );
749 let tile_name = name.clone();
750 let selected_now = move || sel_names.with(|s| s.contains(&tile_name));
751 view! {
752 <div
753 class="tile"
754 class:selected=selected_now
755 tabindex=0
756 on:click=on_click
757 on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
758 on:keydown=move |ev: web_sys::KeyboardEvent| {
759 if ev.key() == "Enter" || ev.key() == " " {
760 ev.prevent_default();
761 open_cb.run(());
762 }
763 }
764 on:contextmenu=on_ctx
765 title=title
766 >
767 // `icon_svg`, not `<Icon/>`: no reactive owner
768 // per tile (see its doc).
769 {icon_svg(icon, "tile-icon")}
770 <div class="tile-name">{name}</div>
771 </div>
772 }
773 }
774 />
775 </div>
776 }
777}
778
779#[allow(clippy::too_many_arguments)] // explicit signal props
780fn list_view(
781 all: Arc<Listing>,
782 shown: RwSignal<usize>,
783 root_id: i64,
784 loc: ReadSignal<Location>,
785 is_rw: bool,
786 single_click: bool,
787 selected: ReadSignal<Vec<Entry>>,
788 sel_names: Memo<HashSet<String>>,
789 set_selected: WriteSignal<Vec<Entry>>,
790 set_ctx: WriteSignal<Option<CtxMenu>>,
791 open_file: WriteSignal<Option<FileView>>,
792) -> impl IntoView {
793 let total = all.entries.len();
794 view! {
795 <div class="entries-list">
796 <For
797 each=move || 0..shown.get().min(total)
798 key=|i: &usize| *i
799 children=move |i: usize| {
800 let e = &all.entries[i];
801 let icon = icon_for(e.kind, &e.name);
802 let name = e.name.clone();
803 let size = format_size(e.size);
804 let date = format_date(&e.mtime);
805 let is_dir = e.is_dir;
806 let title = name.clone();
807 let (open_cb, on_click, on_ctx) = entry_callbacks(
808 all.clone(), i, root_id, loc, is_rw, single_click,
809 selected, set_selected, set_ctx, open_file,
810 );
811 let row_name = name.clone();
812 let selected_now = move || sel_names.with(|s| s.contains(&row_name));
813 view! {
814 <div
815 class="row"
816 class:selected=selected_now
817 tabindex=0
818 on:click=on_click
819 on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
820 on:keydown=move |ev: web_sys::KeyboardEvent| {
821 if ev.key() == "Enter" || ev.key() == " " {
822 ev.prevent_default();
823 open_cb.run(());
824 }
825 }
826 on:contextmenu=on_ctx
827 title=title
828 >
829 {icon_svg(icon, "row-icon")}
830 <span class="row-name">{name}</span>
831 <span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
832 <span class="row-date muted">{date}</span>
833 </div>
834 }
835 }
836 />
837 </div>
838 }
839}
840
841// ---------------------------------------------------------------------------
842// Context menu
843// ---------------------------------------------------------------------------
844
845/// Build the action list for the selection: the empty area (nothing
846/// selected), one entry, or several. Shared by the right-click context menu
847/// and the toolbar's selection actions so both always offer the same
848/// operations.
849#[allow(clippy::too_many_arguments)] // explicit signal props
850fn menu_items(
851 sel: &[Entry],
852 me: ReadSignal<Option<Me>>,
853 loc: ReadSignal<Location>,
854 open_file: WriteSignal<Option<FileView>>,
855 refresh: Callback<()>,
856 toast: ToastMsg,
857 set_dialog: WriteSignal<Option<Dialog>>,
858 owner: Owner,
859) -> Vec<MenuItem> {
860 let loc_now = loc.get();
861 // The root that is actually shown. Resolves the single-root default
862 // location, where `loc.root_id` is `None`. Read in place: cloning `me`
863 // would copy its whole root list on every selection change.
864 let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
865 let root = me_now
866 .as_ref()
867 .and_then(|m| effective_root(&m.roots, &loc_now));
868 (
869 root.map(|r| r.mode.is_writable()).unwrap_or(false),
870 root.map(|r| r.name.clone()).unwrap_or_default(),
871 root.map(|r| r.id),
872 me_now
873 .as_ref()
874 .map(|m| m.allow_writable_shares)
875 .unwrap_or(false),
876 )
877 });
878
879 match sel {
880 [] => {
881 let mut v = vec![
882 MenuItem::rw(
883 IconName::NewFolder,
884 i18n::t(i18n::k::NEW_FOLDER),
885 is_rw,
886 action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
887 ),
888 MenuItem::rw(
889 IconName::NewFile,
890 i18n::t(i18n::k::NEW_FILE),
891 is_rw,
892 action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
893 ),
894 MenuItem::rw(
895 IconName::Upload,
896 i18n::t(i18n::k::UPLOAD_FILES),
897 is_rw,
898 action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
899 ),
900 MenuItem::rw(
901 IconName::UploadFolder,
902 i18n::t(i18n::k::UPLOAD_FOLDER),
903 is_rw,
904 action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
905 ),
906 ];
907 // Search is a signed-in feature; a share visitor has none.
908 if let Some(rid) = root_id
909 && loc_now.share_token.is_none()
910 {
911 let dir = loc_now.path.join("/");
912 v.push(MenuItem::new(
913 IconName::Search,
914 i18n::t(i18n::k::SEARCH_IN_FOLDER),
915 owner.with(|| {
916 Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
917 }),
918 ));
919 }
920 v
921 }
922 // One entry gets every action; several get only the ones that take
923 // a set (move, copy, delete).
924 _ => {
925 let one = if let [e] = sel { Some(e) } else { None };
926 let mut v = vec![];
927 if let Some(e) = one {
928 // Exactly what left-clicking the entry does: descend into a
929 // folder, open a text file in the editor (read-only in
930 // read-only folders), or preview media.
931 if let Some(rid) = root_id {
932 v.push(MenuItem::new(
933 if e.is_dir {
934 IconName::FolderOpen
935 } else {
936 IconName::Open
937 },
938 i18n::t(i18n::k::OPEN),
939 open_entry(e, rid, loc, is_rw, open_file),
940 ));
941 }
942 if !e.is_dir {
943 // Serve the file itself in a new tab. HTML and SVG render
944 // as real pages there (sandboxed by the server), which is
945 // how you share a small site or an HTML report.
946 v.push(MenuItem::new(
947 IconName::OpenInNew,
948 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
949 action_open_tab(e, root_id, loc, owner.clone()),
950 ));
951 }
952 v.push(MenuItem::new(
953 IconName::Download,
954 i18n::t(i18n::k::DOWNLOAD),
955 action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
956 ));
957 v.push(MenuItem::rw(
958 IconName::Rename,
959 i18n::t(i18n::k::RENAME),
960 is_rw,
961 action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
962 ));
963 }
964 v.push(MenuItem::rw(
965 IconName::Move,
966 i18n::t(i18n::k::MOVE),
967 is_rw,
968 action_move_copy(
969 Op::Move,
970 sel,
971 root_id,
972 me,
973 loc,
974 refresh,
975 toast,
976 set_dialog,
977 owner.clone(),
978 ),
979 ));
980 // Copying only writes at the destination, so a read-only source
981 // folder is fine.
982 v.push(MenuItem::new(
983 IconName::Copy,
984 i18n::t(i18n::k::COPY),
985 action_move_copy(
986 Op::Copy,
987 sel,
988 root_id,
989 me,
990 loc,
991 refresh,
992 toast,
993 set_dialog,
994 owner.clone(),
995 ),
996 ));
997 if let Some(e) = one {
998 if loc_now.share_token.is_none() {
999 v.push(MenuItem::new(
1000 IconName::Share,
1001 i18n::t(i18n::k::SHARE),
1002 action_share(
1003 e,
1004 root_id,
1005 loc,
1006 allow_writable_shares,
1007 is_rw,
1008 set_dialog,
1009 owner.clone(),
1010 ),
1011 ));
1012 }
1013 v.push(MenuItem::new(
1014 IconName::Info,
1015 i18n::t(i18n::k::INFO),
1016 action_info(e, loc, root_name, set_dialog, owner.clone()),
1017 ));
1018 }
1019 v.push(MenuItem::rw(
1020 IconName::Delete,
1021 i18n::t(i18n::k::DELETE),
1022 is_rw,
1023 action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
1024 ));
1025 v
1026 }
1027 }
1028}
1029
1030#[component]
1031fn CtxMenuView(
1032 ctx: ReadSignal<Option<CtxMenu>>,
1033 set_ctx: WriteSignal<Option<CtxMenu>>,
1034 me: ReadSignal<Option<Me>>,
1035 loc: ReadSignal<Location>,
1036 selected: ReadSignal<Vec<Entry>>,
1037 refresh: Callback<()>,
1038 toast: ToastMsg,
1039 set_dialog: WriteSignal<Option<Dialog>>,
1040 open_file: WriteSignal<Option<FileView>>,
1041 owner: Owner,
1042) -> impl IntoView {
1043 // Close on any click or Escape.
1044 {
1045 crate::util::owned_window_listener(leptos::ev::click, move |_| {
1046 set_ctx.set(None);
1047 });
1048 crate::util::owned_window_listener(
1049 leptos::ev::keydown,
1050 move |ev: web_sys::KeyboardEvent| {
1051 if ev.key() == "Escape" {
1052 set_ctx.set(None);
1053 }
1054 },
1055 );
1056 }
1057
1058 // The menu first paints at the cursor point; after mount, this measures
1059 // its actual size and flips it above/left of the cursor when it would
1060 // overflow the viewport (native context-menu behavior), clamping to the
1061 // viewport edges as a fallback.
1062 let menu_ref = NodeRef::<leptos::html::Div>::new();
1063 Effect::new(move |_| {
1064 let Some((mx, my)) = ctx.get() else {
1065 return;
1066 };
1067 let Some(node) = menu_ref
1068 .get()
1069 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1070 else {
1071 return;
1072 };
1073 let rect = node.get_bounding_client_rect();
1074 let (w, h) = (rect.width() as i32, rect.height() as i32);
1075 let (vw, vh) = web_sys::window()
1076 .and_then(|w| {
1077 let vw = w.inner_width().ok()?.as_f64()?;
1078 let vh = w.inner_height().ok()?.as_f64()?;
1079 Some((vw, vh))
1080 })
1081 .unwrap_or((f64::MAX, f64::MAX));
1082 let (vw, vh) = (vw as i32, vh as i32);
1083 let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
1084 let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0));
1085 let _ = node.style().set_property("left", &format!("{x}px"));
1086 let _ = node.style().set_property("top", &format!("{y}px"));
1087 });
1088
1089 view! {
1090 {move || {
1091 let Some((mx, my)) = ctx.get() else {
1092 return {
1093 view! {}.into_any()
1094 };
1095 };
1096 let items = selected.with(|sel| {
1097 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1098 });
1099
1100 view! {
1101 <div
1102 class="ctx-menu"
1103 node_ref=menu_ref
1104 style=format!("left:{mx}px;top:{my}px")
1105 >
1106 {items.into_iter().map(|item| {
1107 view! { <CtxItem item=item/> }
1108 }).collect::<Vec<_>>()}
1109 </div>
1110 }
1111 .into_view()
1112 .into_any()
1113 }}
1114 }
1115}
1116
1117/// One row of the context menu. `action` is `None` for a disabled row, and
1118/// `hint` then says why it is disabled.
1119#[derive(Clone)]
1120struct MenuItem {
1121 label: &'static str,
1122 icon: IconName,
1123 action: Option<Callback<()>>,
1124 hint: Option<&'static str>,
1125}
1126
1127impl MenuItem {
1128 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
1129 Self {
1130 label,
1131 icon,
1132 action: Some(action),
1133 hint: None,
1134 }
1135 }
1136
1137 /// An item that only works in a writable folder.
1138 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
1139 Self {
1140 label,
1141 icon,
1142 action: is_rw.then_some(action),
1143 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
1144 }
1145 }
1146}
1147
1148#[component]
1149fn CtxItem(item: MenuItem) -> impl IntoView {
1150 let MenuItem {
1151 label,
1152 icon,
1153 action,
1154 hint,
1155 } = item;
1156 let enabled = action.is_some();
1157 let hint = hint.unwrap_or_default();
1158 view! {
1159 <div
1160 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
1161 title=move || if enabled { "".to_string() } else { hint.to_string() }
1162 on:click=move |_| {
1163 if let Some(a) = &action {
1164 a.run(());
1165 }
1166 }
1167 >
1168 <Icon name=icon class="ic-ctx".to_string()/>
1169 <span>{label}</span>
1170 </div>
1171 }
1172}
1173
1174// ---------------------------------------------------------------------------
1175// Toolbar selection actions (top right)
1176// ---------------------------------------------------------------------------
1177
1178/// Icon-only buttons for the current selection — the same set of actions
1179/// as its right-click menu — or the empty-area actions (new folder,
1180/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
1181/// right). Buttons that don't fit collapse into a three-dot overflow menu.
1182#[component]
1183pub fn SelectionActions(
1184 me: ReadSignal<Option<Me>>,
1185 loc: ReadSignal<Location>,
1186 selected: ReadSignal<Vec<Entry>>,
1187 open_file: WriteSignal<Option<FileView>>,
1188 refresh: Callback<()>,
1189 toast: ToastMsg,
1190 set_dialog: WriteSignal<Option<Dialog>>,
1191 owner: Owner,
1192 topbar_ref: NodeRef<leptos::html::Header>,
1193) -> impl IntoView {
1194 // How many buttons are shown before the overflow kicks in. Starts small
1195 // so the first paint never overflows; the effect below widens it as soon
1196 // as the real widths are measurable.
1197 let (visible_n, set_visible_n) = signal(3usize);
1198 // The three-dot overflow menu state. Any outside click or Escape closes
1199 // it; a new selection does too. The toggle button stops propagation so
1200 // it can actually open the menu.
1201 let (more_open, set_more_open) = signal(false);
1202 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
1203 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
1204 if ev.key() == "Escape" {
1205 set_more_open.set(false);
1206 }
1207 });
1208 Effect::new(move |_| {
1209 let _ = selected.get();
1210 set_more_open.set(false);
1211 });
1212 let group_ref = NodeRef::<leptos::html::Div>::new();
1213
1214 let recompute = move || {
1215 let Some(tb) = topbar_ref
1216 .get()
1217 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1218 else {
1219 return;
1220 };
1221 let Some(g) = group_ref
1222 .get()
1223 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1224 else {
1225 return;
1226 };
1227 // Every button is icon-only with identical padding, so one
1228 // rendered button gives the width of all of them.
1229 let Some(btn) = g.first_element_child() else {
1230 return;
1231 };
1232 // Available width: from the top bar's left content (brand) to its
1233 // right edge — independent of where the group currently sits,
1234 // so the measurement stays valid even while the group overflows.
1235 let tb_r = tb.get_bounding_client_rect();
1236 let start = tb
1237 .query_selector(".brand")
1238 .ok()
1239 .flatten()
1240 .map(|b| b.get_bounding_client_rect())
1241 .map(|r| r.right() + 16.0)
1242 .unwrap_or(0.0);
1243 let avail = (tb_r.right() - start - 10.0).max(40.0);
1244 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1245 // How many buttons fit (not counting the overflow button).
1246 set_visible_n.set(((avail / step) as usize).max(1));
1247 };
1248 // Measuring forces three layout reflows, so it must not run on every
1249 // selection change. Only how many buttons *fit* is measured, and that
1250 // changes when the group appears, disappears, or switches between the
1251 // empty-area, single and multi action sets — not per selected entry.
1252 let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
1253 Effect::new(move |_| {
1254 topbar_ref.get();
1255 group_ref.get();
1256 // Re-measure when the item set (selection kind, user) or width
1257 // inputs change.
1258 sel_kind.get();
1259 me.track();
1260 recompute();
1261 });
1262 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1263
1264 view! {
1265 <div class="sel-actions" node_ref=group_ref>
1266 // The visible buttons and the overflow rows are one item list
1267 // split at the measured count, so the actions are computed once.
1268 {move || {
1269 // No resolvable root (e.g. the `#/` "select a folder" empty
1270 // state): there is nowhere to act, so hide the whole group
1271 // rather than show a row of disabled buttons.
1272 let has_root = me.with(|m| {
1273 m.as_ref()
1274 .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
1275 });
1276 if !has_root {
1277 return {
1278 view! {}.into_any()
1279 };
1280 }
1281 let items = selected.with(|sel| {
1282 menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
1283 });
1284 let fit = visible_n.get();
1285 let n = if fit >= items.len() {
1286 items.len()
1287 } else {
1288 // The overflow button takes a slot too, so drop one —
1289 // otherwise the group eats into the space after the brand.
1290 (fit - 1).max(1)
1291 };
1292 let (visible, more) = items.split_at(n);
1293 let visible_view = visible
1294 .iter()
1295 .cloned()
1296 .map(|i| view! {
1297 <SelButton item=i/>
1298 })
1299 .collect_view();
1300 // Rendered as nothing while every action fits.
1301 let more_block = (!more.is_empty()).then(|| {
1302 let more_view = more
1303 .iter()
1304 .cloned()
1305 .map(|i| view! {
1306 <CtxItem item=i/>
1307 })
1308 .collect_view();
1309 view! {
1310 <div class="sel-more-wrap">
1311 <button
1312 class="icon-btn"
1313 title=i18n::tr(i18n::k::MORE_ACTIONS)
1314 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1315 on:click=move |ev: MouseEvent| {
1316 ev.stop_propagation();
1317 set_more_open.set(!more_open.get());
1318 }
1319 >
1320 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1321 </button>
1322 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1323 {more_view}
1324 </div>
1325 </div>
1326 }
1327 });
1328 view! {
1329 {visible_view}
1330 {more_block}
1331 }
1332 .into_view()
1333 .into_any()
1334 }}
1335 </div>
1336 }
1337}
1338
1339/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1340/// then explains why.
1341#[component]
1342fn SelButton(item: MenuItem) -> impl IntoView {
1343 let enabled = item.action.is_some();
1344 let title = if enabled {
1345 item.label.to_string()
1346 } else {
1347 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1348 };
1349 view! {
1350 <button
1351 class="icon-btn"
1352 disabled=!enabled
1353 title=title
1354 on:click=move |_| {
1355 if let Some(a) = &item.action {
1356 a.run(());
1357 }
1358 }
1359 >
1360 <Icon name=item.icon class="ic-btn".to_string()/>
1361 </button>
1362 }
1363}
1364
1365// ---------------------------------------------------------------------------
1366// Menu actions
1367// ---------------------------------------------------------------------------
1368
1369fn join_path(dir: &[String], name: &str) -> String {
1370 if dir.is_empty() {
1371 name.to_string()
1372 } else {
1373 format!("{}/{}", dir.join("/"), name)
1374 }
1375}
1376
1377fn is_conflict(e: &api::ApiError) -> bool {
1378 matches!(e, api::ApiError::Http { status: 409, .. })
1379}
1380
1381/// A file operation in flight. Boxed because each caller builds a different
1382/// future, and they all have to fit one type.
1383type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1384
1385/// Run a file operation that may collide with an existing name.
1386///
1387/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1388/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1389/// upload all need exactly this, so it lives here once.
1390///
1391/// `names` is what to list in the dialog when the server does not say. Upload
1392/// reports the exact set it skipped; the single-item operations know theirs
1393/// before they ask.
1394fn run_with_overwrite_retry(
1395 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1396 conflict_title: &'static str,
1397 names: Vec<String>,
1398 ok_msg: &'static str,
1399 refresh: Callback<()>,
1400 toast: ToastMsg,
1401 set_dialog: WriteSignal<Option<Dialog>>,
1402) {
1403 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1404 show(toast, ok_msg);
1405 refresh.run(());
1406 }
1407
1408 let retry = op.clone();
1409 spawn_local(async move {
1410 let Err(e) = op(false).await else {
1411 done(ok_msg, refresh, toast);
1412 return;
1413 };
1414 let conflicting = e
1415 .skipped()
1416 .map(<[String]>::to_vec)
1417 .or_else(|| is_conflict(&e).then_some(names));
1418 let Some(files) = conflicting else {
1419 show_error(toast, e.to_string());
1420 return;
1421 };
1422 set_dialog.set(Some(Dialog::Conflict {
1423 title: conflict_title.to_string(),
1424 files,
1425 on_submit: Callback::new(move |_| {
1426 let retry = retry.clone();
1427 spawn_local(async move {
1428 match retry(true).await {
1429 Ok(()) => done(ok_msg, refresh, toast),
1430 Err(e) => show_error(toast, e.to_string()),
1431 }
1432 });
1433 }),
1434 }));
1435 });
1436}
1437
1438fn action_new_folder(
1439 root_id: Option<i64>,
1440 loc: ReadSignal<Location>,
1441 refresh: Callback<()>,
1442 toast: ToastMsg,
1443 set_dialog: WriteSignal<Option<Dialog>>,
1444 owner: Owner,
1445) -> Callback<()> {
1446 Callback::new(move |_| {
1447 let Some(root_id) = root_id else { return };
1448 let loc = loc.get();
1449 let dir = loc.path.clone();
1450 let toast2 = toast;
1451 let refresh2 = refresh;
1452 set_dialog.set(Some(Dialog::PromptOp {
1453 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
1454 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
1455 initial: String::new(),
1456 submit: i18n::t(i18n::k::CREATE).to_string(),
1457 run: owner.with(|| {
1458 Callback::new(move |name: String| {
1459 let full = join_path(&dir, &name);
1460 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1461 api::mkdir(root_id, &full)
1462 .await
1463 .map(|_| ())
1464 .map_err(|e| e.to_string())
1465 });
1466 fut
1467 })
1468 }),
1469 on_ok: owner.with(|| {
1470 Callback::new(move |_| {
1471 show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
1472 refresh2.run(());
1473 })
1474 }),
1475 }));
1476 })
1477}
1478
1479fn action_new_file(
1480 root_id: Option<i64>,
1481 loc: ReadSignal<Location>,
1482 refresh: Callback<()>,
1483 toast: ToastMsg,
1484 set_dialog: WriteSignal<Option<Dialog>>,
1485 owner: Owner,
1486) -> Callback<()> {
1487 Callback::new(move |_| {
1488 let Some(root_id) = root_id else { return };
1489 let loc = loc.get();
1490 let dir = loc.path.clone();
1491 let toast2 = toast;
1492 let refresh2 = refresh;
1493 set_dialog.set(Some(Dialog::PromptOp {
1494 title: i18n::t(i18n::k::NEW_FILE).to_string(),
1495 label: i18n::t(i18n::k::FILE_NAME).to_string(),
1496 initial: String::new(),
1497 submit: i18n::t(i18n::k::CREATE).to_string(),
1498 run: owner.with(|| {
1499 Callback::new(move |name: String| {
1500 let full = join_path(&dir, &name);
1501 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1502 api::create_file(root_id, &full)
1503 .await
1504 .map(|_| ())
1505 .map_err(|e| e.to_string())
1506 });
1507 fut
1508 })
1509 }),
1510 on_ok: owner.with(|| {
1511 Callback::new(move |_| {
1512 show(toast2, i18n::t(i18n::k::FILE_CREATED));
1513 refresh2.run(());
1514 })
1515 }),
1516 }));
1517 })
1518}
1519
1520fn action_rename(
1521 entry: &Entry,
1522 root_id: Option<i64>,
1523 loc: ReadSignal<Location>,
1524 refresh: Callback<()>,
1525 toast: ToastMsg,
1526 set_dialog: WriteSignal<Option<Dialog>>,
1527 owner: Owner,
1528) -> Callback<()> {
1529 let name = entry.name.clone();
1530 Callback::new(move |_| {
1531 let Some(root_id) = root_id else { return };
1532 let loc = loc.get();
1533 let dir = loc.path.clone();
1534 let full = join_path(&dir, &name);
1535 set_dialog.set(Some(Dialog::PromptOp {
1536 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
1537 label: i18n::t(i18n::k::NEW_NAME).to_string(),
1538 initial: name.clone(),
1539 submit: i18n::t(i18n::k::RENAME).to_string(),
1540 run: owner.with(|| {
1541 Callback::new(move |new_name: String| {
1542 let full = full.clone();
1543 let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
1544 api::rename_item(root_id, &full, new_name, false)
1545 .await
1546 .map(|_| ())
1547 .map_err(|e| e.to_string())
1548 });
1549 fut
1550 })
1551 }),
1552 on_ok: owner.with(|| {
1553 Callback::new(move |_| {
1554 show(toast, i18n::t(i18n::k::RENAMED));
1555 refresh.run(());
1556 })
1557 }),
1558 }));
1559 })
1560}
1561
1562/// Delete the entries after one confirmation. Several are deleted one by
1563/// one; the first failure stops and reports.
1564fn action_delete(
1565 entries: &[Entry],
1566 root_id: Option<i64>,
1567 loc: ReadSignal<Location>,
1568 refresh: Callback<()>,
1569 toast: ToastMsg,
1570 set_dialog: WriteSignal<Option<Dialog>>,
1571 owner: Owner,
1572) -> Callback<()> {
1573 let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
1574 Callback::new(move |_| {
1575 let Some(root_id) = root_id else { return };
1576 let dir = loc.get().path;
1577 let message = match items.as_slice() {
1578 [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
1579 [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
1580 many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
1581 };
1582 let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
1583 set_dialog.set(Some(Dialog::Confirm {
1584 title: i18n::t(i18n::k::DELETE).to_string(),
1585 message,
1586 submit: i18n::t(i18n::k::DELETE).to_string(),
1587 danger: true,
1588 on_submit: owner.with(|| {
1589 Callback::new(move |_| {
1590 set_dialog.set(None);
1591 let fulls = fulls.clone();
1592 spawn_local(async move {
1593 for f in &fulls {
1594 if let Err(e) = api::delete_item(root_id, f).await {
1595 show_error(toast, e.to_string());
1596 refresh.run(());
1597 return;
1598 }
1599 }
1600 show(toast, i18n::t(i18n::k::DELETED));
1601 refresh.run(());
1602 });
1603 })
1604 }),
1605 }));
1606 })
1607}
1608
1609fn action_upload(
1610 multiple: bool,
1611 directory: bool,
1612 root_id: Option<i64>,
1613 loc: ReadSignal<Location>,
1614 refresh: Callback<()>,
1615 toast: ToastMsg,
1616 set_dialog: WriteSignal<Option<Dialog>>,
1617) -> Callback<()> {
1618 Callback::new(move |_| {
1619 let Some(root_id) = root_id else { return };
1620 let loc = loc.get();
1621 let dir = loc.path.join("/");
1622 let dir1 = dir.clone();
1623 api::pick_files(multiple, directory, move |files| {
1624 let n = files.len();
1625 let dir = dir1.clone();
1626 show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
1627 run_with_overwrite_retry(
1628 move |overwrite| {
1629 let (dir, files) = (dir.clone(), files.clone());
1630 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
1631 },
1632 i18n::t(i18n::k::SOME_FILES_EXIST),
1633 // Unused: an upload conflict always names the skipped files.
1634 Vec::new(),
1635 i18n::t(i18n::k::UPLOAD_COMPLETE),
1636 refresh,
1637 toast,
1638 set_dialog,
1639 );
1640 });
1641 })
1642}
1643
1644/// Move or copy the entries to a folder chosen in the picker. Several are
1645/// processed one by one; name collisions are collected and asked about once,
1646/// and the overwrite retry re-runs only the items that collided.
1647#[allow(clippy::too_many_arguments)] // explicit signal props
1648fn action_move_copy(
1649 op: Op,
1650 entries: &[Entry],
1651 root_id: Option<i64>,
1652 me: ReadSignal<Option<Me>>,
1653 loc: ReadSignal<Location>,
1654 refresh: Callback<()>,
1655 toast: ToastMsg,
1656 set_dialog: WriteSignal<Option<Dialog>>,
1657 owner: Owner,
1658) -> Callback<()> {
1659 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
1660 Callback::new(move |_| {
1661 let loc = loc.get();
1662 let Some(root_id) = root_id else { return };
1663 let roots = me.get().map(|m| m.roots).unwrap_or_default();
1664 let dir = loc.path.clone();
1665 let title = match names.as_slice() {
1666 [one] => i18n::t_fmt(op.title_key(1), one),
1667 many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
1668 };
1669 // (name, path relative to the root) per item.
1670 let items: Vec<(String, String)> = names
1671 .iter()
1672 .map(|n| (n.clone(), join_path(&dir, n)))
1673 .collect();
1674 set_dialog.set(Some(Dialog::Picker {
1675 title,
1676 confirm: i18n::t(op.here_key()).to_string(),
1677 roots,
1678 root: root_id,
1679 dir: dir.join("/"),
1680 for_write: true,
1681 on_pick: owner.with(|| {
1682 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1683 // Items still to do. A finished item is dropped, so the
1684 // overwrite retry re-runs only the ones that collided.
1685 let pending = Arc::new(Mutex::new(items.clone()));
1686 run_with_overwrite_retry(
1687 move |overwrite| {
1688 let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
1689 Box::pin(async move {
1690 let items = pending.lock().unwrap().clone();
1691 let mut conflicts = Vec::new();
1692 for (name, full) in items {
1693 let res = if op == Op::Move {
1694 api::move_item(
1695 root_id, &full, dst_root, &dst_dir, overwrite,
1696 )
1697 .await
1698 } else {
1699 api::copy_item(
1700 root_id, &full, dst_root, &dst_dir, overwrite,
1701 )
1702 .await
1703 };
1704 match res {
1705 Ok(_) => {
1706 pending.lock().unwrap().retain(|(_, p)| *p != full)
1707 }
1708 Err(e) if is_conflict(&e) => conflicts.push(name),
1709 Err(e) => return Err(e),
1710 }
1711 }
1712 if conflicts.is_empty() {
1713 Ok(())
1714 } else {
1715 Err(api::ApiError::Http {
1716 status: 409,
1717 message: String::new(),
1718 skipped: Some(conflicts),
1719 })
1720 }
1721 })
1722 },
1723 i18n::t(i18n::k::NAME_TAKEN),
1724 // Unused: the conflict error above always lists the names.
1725 Vec::new(),
1726 if op == Op::Move {
1727 i18n::t(i18n::k::MOVED)
1728 } else {
1729 i18n::t(i18n::k::COPIED)
1730 },
1731 refresh,
1732 toast,
1733 set_dialog,
1734 );
1735 })
1736 }),
1737 }));
1738 })
1739}
1740
1741fn action_download(
1742 entry: &Entry,
1743 root_id: Option<i64>,
1744 loc: ReadSignal<Location>,
1745 set_dialog: WriteSignal<Option<Dialog>>,
1746 toast: ToastMsg,
1747 owner: Owner,
1748) -> Callback<()> {
1749 let name = entry.name.clone();
1750 let is_dir = entry.is_dir;
1751 owner.with(|| {
1752 Callback::new(move |_| {
1753 let Some(root_id) = root_id else { return };
1754 let loc = loc.get();
1755 let full = join_path(&loc.path, &name);
1756 if is_dir {
1757 set_dialog.set(Some(Dialog::DownloadFormat {
1758 name: name.clone(),
1759 root_id,
1760 path: full,
1761 }));
1762 } else {
1763 let url = api::download_url(root_id, &full, None);
1764 api::trigger_download(&url, &name);
1765 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
1766 }
1767 })
1768 })
1769}
1770
1771/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1772/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1773fn action_open_tab(
1774 entry: &Entry,
1775 root_id: Option<i64>,
1776 loc: ReadSignal<Location>,
1777 owner: Owner,
1778) -> Callback<()> {
1779 let name = entry.name.clone();
1780 owner.with(|| {
1781 Callback::new(move |_| {
1782 let Some(root_id) = root_id else { return };
1783 let full = join_path(&loc.get().path, &name);
1784 api::open_in_new_tab(&api::preview_url(root_id, &full));
1785 })
1786 })
1787}
1788
1789/// Open the share dialog for an item (milestone 6).
1790fn action_share(
1791 entry: &Entry,
1792 root_id: Option<i64>,
1793 loc: ReadSignal<Location>,
1794 allow_writable: bool,
1795 root_writable: bool,
1796 set_dialog: WriteSignal<Option<Dialog>>,
1797 owner: Owner,
1798) -> Callback<()> {
1799 let name = entry.name.clone();
1800 owner.with(|| {
1801 Callback::new(move |_| {
1802 let Some(root_id) = root_id else { return };
1803 let loc = loc.get();
1804 let full = join_path(&loc.path, &name);
1805 set_dialog.set(Some(Dialog::Share {
1806 name: name.clone(),
1807 root_id,
1808 path: full,
1809 allow_writable,
1810 root_writable,
1811 }));
1812 })
1813 })
1814}
1815
1816fn action_info(
1817 entry: &Entry,
1818 loc: ReadSignal<Location>,
1819 root_name: String,
1820 set_dialog: WriteSignal<Option<Dialog>>,
1821 owner: Owner,
1822) -> Callback<()> {
1823 let entry2 = entry.clone();
1824 owner.with(|| {
1825 Callback::new(move |_| {
1826 let loc = loc.get();
1827 let full = join_path(&loc.path, &entry2.name);
1828 set_dialog.set(Some(Dialog::Info {
1829 entry: entry2.clone(),
1830 root_name: root_name.clone(),
1831 rel_path: full,
1832 }));
1833 })
1834 })
1835}
1836