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