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