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};
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 let own = || owner.clone();
699
700 match entry {
701 Some(e) => {
702 let mut v = vec![];
703 // Exactly what left-clicking the entry does: descend into a
704 // folder, open a text file in the editor (read-only in read-only
705 // folders), or preview media.
706 if let Some(rid) = root_id {
707 v.push(MenuItem::new(
708 if e.is_dir {
709 IconName::FolderOpen
710 } else {
711 IconName::Open
712 },
713 i18n::t(i18n::k::OPEN),
714 open_entry(e, rid, loc, is_rw, open_file),
715 ));
716 }
717 if !e.is_dir {
718 // Serve the file itself in a new tab. HTML and SVG render as
719 // real pages there (sandboxed by the server), which is how
720 // you share a small site or an HTML report.
721 v.push(MenuItem::new(
722 IconName::OpenInNew,
723 i18n::t(i18n::k::OPEN_IN_NEW_TAB),
724 action_open_tab(e, root_id, loc, own()),
725 ));
726 }
727 v.push(MenuItem::new(
728 IconName::Download,
729 i18n::t(i18n::k::DOWNLOAD),
730 action_download(e, root_id, loc, set_dialog, toast, own()),
731 ));
732 v.push(MenuItem::rw(
733 IconName::Rename,
734 i18n::t(i18n::k::RENAME),
735 is_rw,
736 action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
737 ));
738 v.push(MenuItem::rw(
739 IconName::Move,
740 i18n::t(i18n::k::MOVE),
741 is_rw,
742 action_move_copy(
743 Op::Move,
744 e,
745 root_id,
746 me,
747 loc,
748 refresh,
749 toast,
750 set_dialog,
751 own(),
752 ),
753 ));
754 // Copying only writes at the destination, so a read-only source
755 // folder is fine.
756 v.push(MenuItem::new(
757 IconName::Copy,
758 i18n::t(i18n::k::COPY),
759 action_move_copy(
760 Op::Copy,
761 e,
762 root_id,
763 me,
764 loc,
765 refresh,
766 toast,
767 set_dialog,
768 own(),
769 ),
770 ));
771 if loc_now.share_token.is_none() {
772 v.push(MenuItem::new(
773 IconName::Share,
774 i18n::t(i18n::k::SHARE),
775 action_share(
776 e,
777 root_id,
778 loc,
779 allow_writable_shares,
780 is_rw,
781 set_dialog,
782 own(),
783 ),
784 ));
785 }
786 v.push(MenuItem::new(
787 IconName::Info,
788 i18n::t(i18n::k::INFO),
789 action_info(e, loc, root_name, set_dialog, own()),
790 ));
791 v.push(MenuItem::rw(
792 IconName::Delete,
793 i18n::t(i18n::k::DELETE),
794 is_rw,
795 action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
796 ));
797 v
798 }
799 None => vec![
800 MenuItem::rw(
801 IconName::NewFolder,
802 i18n::t(i18n::k::NEW_FOLDER),
803 is_rw,
804 action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
805 ),
806 MenuItem::rw(
807 IconName::Upload,
808 i18n::t(i18n::k::UPLOAD_FILES),
809 is_rw,
810 action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
811 ),
812 MenuItem::rw(
813 IconName::UploadFolder,
814 i18n::t(i18n::k::UPLOAD_FOLDER),
815 is_rw,
816 action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
817 ),
818 ],
819 }
820}
821
822#[component]
823fn CtxMenuView(
824 ctx: ReadSignal<Option<CtxMenu>>,
825 set_ctx: WriteSignal<Option<CtxMenu>>,
826 me: ReadSignal<Option<Me>>,
827 loc: ReadSignal<Location>,
828 refresh: Callback<()>,
829 toast: ToastMsg,
830 set_dialog: WriteSignal<Option<Dialog>>,
831 open_file: WriteSignal<Option<FileView>>,
832 owner: Owner,
833) -> impl IntoView {
834 // Close on any click or Escape.
835 {
836 crate::util::owned_window_listener(leptos::ev::click, move |_| {
837 set_ctx.set(None);
838 });
839 crate::util::owned_window_listener(
840 leptos::ev::keydown,
841 move |ev: web_sys::KeyboardEvent| {
842 if ev.key() == "Escape" {
843 set_ctx.set(None);
844 }
845 },
846 );
847 }
848
849 view! {
850 {move || {
851 let Some(m) = ctx.get() else {
852 return {
853 view! {}.into_any()
854 };
855 };
856 // Keep the menu inside the viewport.
857 let (max_x, max_y) = web_sys::window()
858 .and_then(|w| {
859 w.inner_width()
860 .ok()
861 .and_then(|v| v.as_f64())
862 .zip(w.inner_height().ok().and_then(|v| v.as_f64()))
863 })
864 .map(|(x, y)| (x as i32 - 220, y as i32 - 300))
865 .unwrap_or((m.x, m.y));
866 let x = m.x.min(max_x).max(0);
867 let y = m.y.min(max_y).max(0);
868
869 let items = menu_items(
870 m.entry.as_ref(),
871 me,
872 loc,
873 open_file,
874 refresh,
875 toast,
876 set_dialog,
877 owner.clone(),
878 );
879
880 view! {
881 <div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
882 {items.into_iter().map(|item| {
883 view! { <CtxItem item=item/> }
884 }).collect::<Vec<_>>()}
885 </div>
886 }
887 .into_view()
888 .into_any()
889 }}
890 }
891}
892
893/// One row of the context menu. `action` is `None` for a disabled row, and
894/// `hint` then says why it is disabled.
895#[derive(Clone)]
896struct MenuItem {
897 label: &'static str,
898 icon: IconName,
899 action: Option<Callback<()>>,
900 hint: Option<&'static str>,
901}
902
903impl MenuItem {
904 fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
905 Self {
906 label,
907 icon,
908 action: Some(action),
909 hint: None,
910 }
911 }
912
913 /// An item that only works in a writable folder.
914 fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
915 Self {
916 label,
917 icon,
918 action: is_rw.then_some(action),
919 hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
920 }
921 }
922}
923
924#[component]
925fn CtxItem(item: MenuItem) -> impl IntoView {
926 let MenuItem {
927 label,
928 icon,
929 action,
930 hint,
931 } = item;
932 let enabled = action.is_some();
933 let hint = hint.unwrap_or_default();
934 view! {
935 <div
936 class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
937 title=move || if enabled { "".to_string() } else { hint.to_string() }
938 on:click=move |_| {
939 if let Some(a) = &action {
940 a.run(());
941 }
942 }
943 >
944 <Icon name=icon class="ic-ctx".to_string()/>
945 <span>{label}</span>
946 </div>
947 }
948}
949
950// ---------------------------------------------------------------------------
951// Toolbar selection actions (top right)
952// ---------------------------------------------------------------------------
953
954/// Icon-only buttons for the currently selected entry — the same set of
955/// actions as its right-click menu — or the empty-area actions (new folder,
956/// uploads) when nothing is selected. Rendered in the app's top bar (top
957/// right). Buttons that don't fit collapse into a three-dot overflow menu.
958#[component]
959pub fn SelectionActions(
960 me: ReadSignal<Option<Me>>,
961 loc: ReadSignal<Location>,
962 selected: ReadSignal<Option<Entry>>,
963 open_file: WriteSignal<Option<FileView>>,
964 refresh: Callback<()>,
965 toast: ToastMsg,
966 set_dialog: WriteSignal<Option<Dialog>>,
967 owner: Owner,
968 topbar_ref: NodeRef<leptos::html::Header>,
969) -> impl IntoView {
970 // How many buttons are shown before the overflow kicks in. Starts small
971 // so the first paint never overflows; the effect below widens it as soon
972 // as the real widths are measurable.
973 let (visible_n, set_visible_n) = signal(3usize);
974 // The three-dot overflow menu state. Any outside click or Escape closes
975 // it; a new selection does too. The toggle button stops propagation so
976 // it can actually open the menu.
977 let (more_open, set_more_open) = signal(false);
978 crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
979 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
980 if ev.key() == "Escape" {
981 set_more_open.set(false);
982 }
983 });
984 Effect::new(move |_| {
985 let _ = selected.get();
986 set_more_open.set(false);
987 });
988 let group_ref = NodeRef::<leptos::html::Div>::new();
989
990 let recompute = move || {
991 let Some(tb) = topbar_ref
992 .get()
993 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
994 else {
995 return;
996 };
997 let Some(g) = group_ref
998 .get()
999 .and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
1000 else {
1001 return;
1002 };
1003 // Every button is icon-only with identical padding, so one
1004 // rendered button gives the width of all of them.
1005 let Some(btn) = g.first_element_child() else {
1006 return;
1007 };
1008 // Available width: from the top bar's left content (brand) to its
1009 // right edge — independent of where the group currently sits,
1010 // so the measurement stays valid even while the group overflows.
1011 let tb_r = tb.get_bounding_client_rect();
1012 let start = tb
1013 .query_selector(".brand")
1014 .ok()
1015 .flatten()
1016 .map(|b| b.get_bounding_client_rect())
1017 .map(|r| r.right() + 16.0)
1018 .unwrap_or(0.0);
1019 let avail = (tb_r.right() - start - 10.0).max(40.0);
1020 let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
1021 // How many buttons fit (not counting the overflow button).
1022 set_visible_n.set(((avail / step) as usize).max(1));
1023 };
1024 Effect::new(move |_| {
1025 topbar_ref.get();
1026 group_ref.get();
1027 // Re-measure when the item set (selection, user) or width inputs
1028 // change.
1029 selected.get();
1030 me.get();
1031 recompute();
1032 });
1033 crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
1034
1035 view! {
1036 <div class="sel-actions" node_ref=group_ref>
1037 // The visible buttons and the overflow rows are one item list
1038 // split at the measured count, so the actions are computed once.
1039 {move || {
1040 let items = menu_items(
1041 selected.get().as_ref(),
1042 me,
1043 loc,
1044 open_file,
1045 refresh,
1046 toast,
1047 set_dialog,
1048 owner.clone(),
1049 );
1050 let fit = visible_n.get();
1051 let n = if fit >= items.len() {
1052 items.len()
1053 } else {
1054 // The overflow button takes a slot too, so drop one —
1055 // otherwise the group eats into the space after the brand.
1056 (fit - 1).max(1)
1057 };
1058 let (visible, more) = items.split_at(n);
1059 let visible_view = visible
1060 .iter()
1061 .cloned()
1062 .map(|i| view! {
1063 <SelButton item=i/>
1064 })
1065 .collect_view();
1066 // Rendered as nothing while every action fits.
1067 let more_block = (!more.is_empty()).then(|| {
1068 let more_view = more
1069 .iter()
1070 .cloned()
1071 .map(|i| view! {
1072 <CtxItem item=i/>
1073 })
1074 .collect_view();
1075 view! {
1076 <div class="sel-more-wrap">
1077 <button
1078 class="icon-btn"
1079 title=i18n::tr(i18n::k::MORE_ACTIONS)
1080 aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
1081 on:click=move |ev: MouseEvent| {
1082 ev.stop_propagation();
1083 set_more_open.set(!more_open.get());
1084 }
1085 >
1086 <Icon name=IconName::MoreVert class="ic-btn".to_string()/>
1087 </button>
1088 <div class="sel-more-menu" class:hidden=move || !more_open.get()>
1089 {more_view}
1090 </div>
1091 </div>
1092 }
1093 });
1094 view! {
1095 {visible_view}
1096 {more_block}
1097 }
1098 }}
1099 </div>
1100 }
1101}
1102
1103/// One icon-only toolbar button. A disabled button keeps its tooltip, which
1104/// then explains why.
1105#[component]
1106fn SelButton(item: MenuItem) -> impl IntoView {
1107 let enabled = item.action.is_some();
1108 let title = if enabled {
1109 item.label.to_string()
1110 } else {
1111 format!("{} — {}", item.label, item.hint.unwrap_or_default())
1112 };
1113 view! {
1114 <button
1115 class="icon-btn"
1116 disabled=!enabled
1117 title=title
1118 on:click=move |_| {
1119 if let Some(a) = &item.action {
1120 a.run(());
1121 }
1122 }
1123 >
1124 <Icon name=item.icon class="ic-btn".to_string()/>
1125 </button>
1126 }
1127}
1128
1129// ---------------------------------------------------------------------------
1130// Menu actions
1131// ---------------------------------------------------------------------------
1132
1133fn join_path(dir: &[String], name: &str) -> String {
1134 if dir.is_empty() {
1135 name.to_string()
1136 } else {
1137 format!("{}/{}", dir.join("/"), name)
1138 }
1139}
1140
1141fn is_conflict(e: &api::ApiError) -> bool {
1142 matches!(e, api::ApiError::Http { status: 409, .. })
1143}
1144
1145/// A file operation in flight. Boxed because each caller builds a different
1146/// future, and they all have to fit one type.
1147type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
1148
1149/// Run a file operation that may collide with an existing name.
1150///
1151/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
1152/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
1153/// upload all need exactly this, so it lives here once.
1154///
1155/// `names` is what to list in the dialog when the server does not say. Upload
1156/// reports the exact set it skipped; the single-item operations know theirs
1157/// before they ask.
1158fn run_with_overwrite_retry(
1159 op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
1160 conflict_title: &'static str,
1161 names: Vec<String>,
1162 ok_msg: &'static str,
1163 refresh: Callback<()>,
1164 toast: ToastMsg,
1165 set_dialog: WriteSignal<Option<Dialog>>,
1166) {
1167 fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
1168 show(toast, ok_msg);
1169 refresh.run(());
1170 }
1171
1172 let retry = op.clone();
1173 spawn_local(async move {
1174 let Err(e) = op(false).await else {
1175 done(ok_msg, refresh, toast);
1176 return;
1177 };
1178 let conflicting = e
1179 .skipped()
1180 .map(<[String]>::to_vec)
1181 .or_else(|| is_conflict(&e).then_some(names));
1182 let Some(files) = conflicting else {
1183 show(toast, e.to_string());
1184 return;
1185 };
1186 set_dialog.set(Some(Dialog::Conflict {
1187 title: conflict_title.to_string(),
1188 files,
1189 on_submit: Callback::new(move |_| {
1190 let retry = retry.clone();
1191 spawn_local(async move {
1192 match retry(true).await {
1193 Ok(()) => done(ok_msg, refresh, toast),
1194 Err(e) => show(toast, e.to_string()),
1195 }
1196 });
1197 }),
1198 }));
1199 });
1200}
1201
1202fn action_new_folder(
1203 root_id: Option<i64>,
1204 loc: ReadSignal<Location>,
1205 refresh: Callback<()>,
1206 toast: ToastMsg,
1207 set_dialog: WriteSignal<Option<Dialog>>,
1208 owner: Owner,
1209) -> Callback<()> {
1210 Callback::new(move |_| {
1211 let Some(root_id) = root_id else { return };
1212 let loc = loc.get();
1213 let dir = loc.path.clone();
1214 set_dialog.set(Some(Dialog::Prompt {
1215 title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
1216 label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
1217 initial: String::new(),
1218 submit: i18n::t(i18n::k::CREATE).to_string(),
1219 on_submit: owner.with(|| {
1220 Callback::new(move |name: String| {
1221 let name = name.trim().to_string();
1222 if name.is_empty() {
1223 return;
1224 }
1225 let full = join_path(&dir, &name);
1226 set_dialog.set(None);
1227 spawn_local(async move {
1228 match api::mkdir(root_id, &full).await {
1229 Ok(_) => {
1230 show(toast, i18n::t(i18n::k::FOLDER_CREATED));
1231 refresh.run(());
1232 }
1233 Err(e) => show(toast, e.to_string()),
1234 }
1235 });
1236 })
1237 }),
1238 }));
1239 })
1240}
1241
1242fn action_rename(
1243 entry: &Entry,
1244 root_id: Option<i64>,
1245 loc: ReadSignal<Location>,
1246 refresh: Callback<()>,
1247 toast: ToastMsg,
1248 set_dialog: WriteSignal<Option<Dialog>>,
1249 owner: Owner,
1250) -> Callback<()> {
1251 let name = entry.name.clone();
1252 Callback::new(move |_| {
1253 let Some(root_id) = root_id else { return };
1254 let loc = loc.get();
1255 let dir = loc.path.clone();
1256 let full = join_path(&dir, &name);
1257 set_dialog.set(Some(Dialog::Prompt {
1258 title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
1259 label: i18n::t(i18n::k::NEW_NAME).to_string(),
1260 initial: name.clone(),
1261 submit: i18n::t(i18n::k::RENAME).to_string(),
1262 on_submit: owner.with(|| {
1263 Callback::new(move |new_name: String| {
1264 let new_name = new_name.trim().to_string();
1265 if new_name.is_empty() {
1266 return;
1267 }
1268 set_dialog.set(None);
1269 let full = full.clone();
1270 let target = new_name.clone();
1271 run_with_overwrite_retry(
1272 move |overwrite| {
1273 let (full, new_name) = (full.clone(), new_name.clone());
1274 Box::pin(async move {
1275 api::rename_item(root_id, &full, new_name, overwrite)
1276 .await
1277 .map(|_| ())
1278 })
1279 },
1280 i18n::t(i18n::k::NAME_TAKEN),
1281 vec![target],
1282 i18n::t(i18n::k::RENAMED),
1283 refresh,
1284 toast,
1285 set_dialog,
1286 );
1287 })
1288 }),
1289 }));
1290 })
1291}
1292
1293fn action_delete(
1294 entry: &Entry,
1295 root_id: Option<i64>,
1296 loc: ReadSignal<Location>,
1297 refresh: Callback<()>,
1298 toast: ToastMsg,
1299 set_dialog: WriteSignal<Option<Dialog>>,
1300 owner: Owner,
1301) -> Callback<()> {
1302 let name = entry.name.clone();
1303 let is_dir = entry.is_dir;
1304 Callback::new(move |_| {
1305 let Some(root_id) = root_id else { return };
1306 let loc = loc.get();
1307 let dir = loc.path.clone();
1308 let full = join_path(&dir, &name);
1309 let message = if is_dir {
1310 i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, &name)
1311 } else {
1312 i18n::t_fmt(i18n::k::DELETE_MSG, &name)
1313 };
1314 set_dialog.set(Some(Dialog::Confirm {
1315 title: i18n::t(i18n::k::DELETE).to_string(),
1316 message,
1317 submit: i18n::t(i18n::k::DELETE).to_string(),
1318 danger: true,
1319 on_submit: owner.with(|| {
1320 Callback::new(move |_| {
1321 set_dialog.set(None);
1322 let f = full.clone();
1323 spawn_local(async move {
1324 match api::delete_item(root_id, &f).await {
1325 Ok(_) => {
1326 show(toast, i18n::t(i18n::k::DELETED));
1327 refresh.run(());
1328 }
1329 Err(e) => show(toast, e.to_string()),
1330 }
1331 });
1332 })
1333 }),
1334 }));
1335 })
1336}
1337
1338fn action_upload(
1339 multiple: bool,
1340 directory: bool,
1341 root_id: Option<i64>,
1342 loc: ReadSignal<Location>,
1343 refresh: Callback<()>,
1344 toast: ToastMsg,
1345 set_dialog: WriteSignal<Option<Dialog>>,
1346) -> Callback<()> {
1347 Callback::new(move |_| {
1348 let Some(root_id) = root_id else { return };
1349 let loc = loc.get();
1350 let dir = loc.path.join("/");
1351 let dir1 = dir.clone();
1352 api::pick_files(multiple, directory, move |files| {
1353 let n = files.len();
1354 let dir = dir1.clone();
1355 show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
1356 run_with_overwrite_retry(
1357 move |overwrite| {
1358 let (dir, files) = (dir.clone(), files.clone());
1359 Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
1360 },
1361 i18n::t(i18n::k::SOME_FILES_EXIST),
1362 // Unused: an upload conflict always names the skipped files.
1363 Vec::new(),
1364 i18n::t(i18n::k::UPLOAD_COMPLETE),
1365 refresh,
1366 toast,
1367 set_dialog,
1368 );
1369 });
1370 })
1371}
1372
1373#[allow(clippy::too_many_arguments)] // explicit signal props
1374fn action_move_copy(
1375 op: Op,
1376 entry: &Entry,
1377 root_id: Option<i64>,
1378 me: ReadSignal<Option<Me>>,
1379 loc: ReadSignal<Location>,
1380 refresh: Callback<()>,
1381 toast: ToastMsg,
1382 set_dialog: WriteSignal<Option<Dialog>>,
1383 owner: Owner,
1384) -> Callback<()> {
1385 let name = entry.name.clone();
1386 let entry2 = entry.clone();
1387 Callback::new(move |_| {
1388 let loc = loc.get();
1389 let me = me.get();
1390 let Some(root_id) = root_id else { return };
1391 let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
1392 let dir = loc.path.clone();
1393 let full = join_path(&dir, &name);
1394 let dir_str = loc.path.join("/");
1395 let name3 = name.clone();
1396 let entry3 = entry2.clone();
1397 let full3 = full.clone();
1398 set_dialog.set(Some(Dialog::Picker {
1399 op,
1400 entry: entry3,
1401 roots,
1402 source_root: root_id,
1403 source_dir: dir_str,
1404 on_pick: owner.with(|| {
1405 Callback::new(move |(dst_root, dst_dir): (i64, String)| {
1406 set_dialog.set(None);
1407 let full = full3.clone();
1408 run_with_overwrite_retry(
1409 move |overwrite| {
1410 let (full, dst_dir) = (full.clone(), dst_dir.clone());
1411 Box::pin(async move {
1412 let res = if op == Op::Move {
1413 api::move_item(root_id, &full, dst_root, &dst_dir, overwrite)
1414 .await
1415 } else {
1416 api::copy_item(root_id, &full, dst_root, &dst_dir, overwrite)
1417 .await
1418 };
1419 res.map(|_| ())
1420 })
1421 },
1422 i18n::t(i18n::k::NAME_TAKEN),
1423 vec![name3.clone()],
1424 if op == Op::Move {
1425 i18n::t(i18n::k::MOVED)
1426 } else {
1427 i18n::t(i18n::k::COPIED)
1428 },
1429 refresh,
1430 toast,
1431 set_dialog,
1432 );
1433 })
1434 }),
1435 }));
1436 })
1437}
1438
1439fn action_download(
1440 entry: &Entry,
1441 root_id: Option<i64>,
1442 loc: ReadSignal<Location>,
1443 set_dialog: WriteSignal<Option<Dialog>>,
1444 toast: ToastMsg,
1445 owner: Owner,
1446) -> Callback<()> {
1447 let name = entry.name.clone();
1448 let is_dir = entry.is_dir;
1449 owner.with(|| {
1450 Callback::new(move |_| {
1451 let Some(root_id) = root_id else { return };
1452 let loc = loc.get();
1453 let full = join_path(&loc.path, &name);
1454 if is_dir {
1455 set_dialog.set(Some(Dialog::DownloadFormat {
1456 name: name.clone(),
1457 root_id,
1458 path: full,
1459 }));
1460 } else {
1461 let url = api::download_url(root_id, &full, None);
1462 api::trigger_download(&url, &name);
1463 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
1464 }
1465 })
1466 })
1467}
1468
1469/// Open the file itself in a new tab, via the `?action=preview` endpoint —
1470/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
1471fn action_open_tab(
1472 entry: &Entry,
1473 root_id: Option<i64>,
1474 loc: ReadSignal<Location>,
1475 owner: Owner,
1476) -> Callback<()> {
1477 let name = entry.name.clone();
1478 owner.with(|| {
1479 Callback::new(move |_| {
1480 let Some(root_id) = root_id else { return };
1481 let full = join_path(&loc.get().path, &name);
1482 api::open_in_new_tab(&api::preview_url(root_id, &full));
1483 })
1484 })
1485}
1486
1487/// Open the share dialog for an item (milestone 6).
1488fn action_share(
1489 entry: &Entry,
1490 root_id: Option<i64>,
1491 loc: ReadSignal<Location>,
1492 allow_writable: bool,
1493 root_writable: bool,
1494 set_dialog: WriteSignal<Option<Dialog>>,
1495 owner: Owner,
1496) -> Callback<()> {
1497 let name = entry.name.clone();
1498 owner.with(|| {
1499 Callback::new(move |_| {
1500 let Some(root_id) = root_id else { return };
1501 let loc = loc.get();
1502 let full = join_path(&loc.path, &name);
1503 set_dialog.set(Some(Dialog::Share {
1504 name: name.clone(),
1505 root_id,
1506 path: full,
1507 allow_writable,
1508 root_writable,
1509 }));
1510 })
1511 })
1512}
1513
1514fn action_info(
1515 entry: &Entry,
1516 loc: ReadSignal<Location>,
1517 root_name: String,
1518 set_dialog: WriteSignal<Option<Dialog>>,
1519 owner: Owner,
1520) -> Callback<()> {
1521 let entry2 = entry.clone();
1522 owner.with(|| {
1523 Callback::new(move |_| {
1524 let loc = loc.get();
1525 let full = join_path(&loc.path, &entry2.name);
1526 set_dialog.set(Some(Dialog::Info {
1527 entry: entry2.clone(),
1528 root_name: root_name.clone(),
1529 rel_path: full,
1530 }));
1531 })
1532 })
1533}
1534