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