//! The file browser: breadcrumbs, grid/list views, root picker, context menu. use std::collections::HashSet; use std::sync::Arc; use api_types::FileKind; use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use crate::api::{self, Entry, Me, RootInfo}; use crate::components::icon::{Icon, icon_svg}; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n; use crate::icons::{IconName, icon_for}; use crate::preview::is_media; use crate::router::{self, Location, navigate}; use crate::uploads::{self, OnConflict}; use crate::util::{ LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, pager, save_page_size, }; use crate::views::dialogs::{Dialog, Op}; use crate::views::file_view::FileView; /// What a listing fetch asks for. Only a change of it fetches again. #[derive(Clone, Debug, PartialEq)] struct ListReq { root_id: i64, is_rw: bool, path: Vec, sort: SortSpec, size: usize, page: usize, } /// One page of a folder listing, in display order. Behind an `Arc` in /// [`ListState`] so reading the signal never copies the entries. #[derive(Debug, PartialEq)] struct Listing { entries: Vec, /// Entries in the whole folder. total: usize, /// Position of `entries[0]` in the whole folder. offset: usize, /// The request, with the page the server answered with. req: ListReq, } /// An arrow-key page turn in flight. #[derive(Clone)] struct PageStep { /// +1 for the next page, -1 for the previous one. dir: isize, /// Started in the media preview, which looks for a previewable file. preview: bool, /// The page the turn started on, to go back to when it finds nothing. origin: usize, selected: Vec, /// The fetch that loads the target page. Any other fetch drops the step. fetch_gen: u64, } #[derive(Clone, Debug, PartialEq)] enum ListState { Loading, Error(String), Entries(Arc), } /// The root that is actually shown: the one in the location. The one-root /// shortcut is shares-only: a public share's location (`#/share/{token}`) /// cannot carry its synthetic root's id, which only exists once the share is /// resolved. Signed-in users always get an explicit `#/files/{root_id}` URL /// (or the no-root empty state on `#/`) — that keeps every file open from a /// browser listing on an explicit URL, so navigating away always changes the /// hash. pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> { match loc.root_id { Some(id) => roots.iter().find(|r| r.id == id), None if loc.share_token.is_some() && roots.len() == 1 => Some(&roots[0]), None => None, } } /// Where the context menu opens (client x, y). What it offers follows the /// selection: the right-click handlers set that first. type CtxMenu = (i32, i32); #[component] pub fn Browser( me: ReadSignal>, set_me: WriteSignal>, loc: ReadSignal, /// Opens a file in the full-page view (owned by the shell / share page). open_file: WriteSignal>, /// The browser exposes its fetch there so the full-page editor can /// re-list the folder after a save. set_browser_refresh: WriteSignal>>, /// The selected entries, owned by the shell so their action buttons can /// live in the top bar. Empty shows the empty-area actions there. The /// first entry is the anchor for Shift+click ranges. selected: ReadSignal>, set_selected: WriteSignal>, /// Dialogs are rendered at the shell level. set_dialog: WriteSignal>, /// The open dialog, if any. Escape must not clear the selection while /// one is open (it closes the dialog instead) — checked in the capture /// phase, before the dialog's own bubble-phase listener closes it. dialog: ReadSignal>, /// A file opened in the full-page view. Escape must not clear the /// selection while this is set — that keypress closes the view (handled /// by the shell), and the selection should survive. file_view: ReadSignal>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let (view_mode, set_view_mode) = signal(ViewMode::load()); let sort = Memo::new(move |_| loc.with(|l| l.sort)); let (list_state, set_list_state) = signal(ListState::Loading); let (ctx, set_ctx) = signal(Option::::None); // Long-lived owner for callbacks created inside short-lived scopes // (context-menu event handlers); otherwise they get disposed with the // menu and the open dialog panics on submit. let ctx_owner = Owner::current().unwrap(); // A 403/404 on the listing usually means an admin removed or changed // this root, so re-fetch /me once per (root, path) to update the // sidebar and this view. The key guards against a refresh loop when // /me comes back unchanged. let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None); let req = Memo::new(move |_| { me.with(|m| { let m = m.as_ref()?; loc.with(|l| { effective_root(&m.roots, l).map(|r| ListReq { root_id: r.id, is_rw: r.mode.is_writable(), path: l.path.clone(), sort: l.sort, size: l.size, page: l.page, }) }) }) }); let requested = StoredValue::new(None::); // Set when the arrow keys step past the edge of the page. let page_step = RwSignal::new(None::); // Bumped per fetch. A response whose generation is no longer current // belongs to a folder the user has already navigated away from. let fetch_gen = StoredValue::new(0u64); // The selected names as a set: every row asks "am I selected?" on each // selection change, which is a scan of the selection per row otherwise. let sel_names = Memo::new(move |_| { selected.with(|v| { v.iter() .map(|e| e.name.clone()) .collect::>() }) }); // Drop selected entries that disappeared from the listing (deleted, // renamed, …). Effect::new(move |_| { list_state.with(|st| { let ListState::Entries(list) = st else { return; }; let names: HashSet<&str> = list.entries.iter().map(|e| e.name.as_str()).collect(); let cur = selected.get_untracked(); let kept: Vec = cur .iter() .filter(|s| names.contains(s.name.as_str())) .cloned() .collect(); if kept.len() != cur.len() { set_selected.set(kept); } }); }); // Escape clears the selection — but only when nothing that also reacts // to Escape is open: the context menu (it has its own Escape handler), // a modal dialog (the closes itself; the selection must // survive, same as for the file view), or a full-page file view (the // shell closes it on the same keypress). Registered in the capture // phase so it sees the dialog / file view *before* they close; and it // blurs the focused row, because the keypress would otherwise leave the // row's :focus-visible outline behind. crate::util::owned_window_listener_capture( leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() != "Escape" || ctx.get().is_some() || dialog.get().is_some() || file_view.get().is_some() { return; } if let Some(active) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.active_element()) .and_then(|a| a.dyn_into::().ok()) { let _ = active.blur(); } set_selected.set(Vec::new()); }, ); // The selection follows the shown file, so closing the preview puts // focus and the action bar on the file that was actually open. let show_preview = move |e: &Entry, root_id: i64| { if !is_media(e.kind) { return; } set_selected.set(vec![e.clone()]); open_file.set(Some(FileView { root_id, path: join_path(&loc.get_untracked().path, &e.name), name: e.name.clone(), kind: e.kind, readonly: false, })); }; // Fetch the page in the URL. `force` fetches it again (a refresh). let load = Callback::new(move |force: bool| { let Some(req) = req.get_untracked() else { return; }; if !force && requested.with_value(|r| r.as_ref() == Some(&req)) { return; } requested.set_value(Some(req.clone())); // A file in the URL that is not open yet (a link, a reload) may sit // on another page than the URL says. let around = loc .with_untracked(|l| l.open.clone()) .filter(|_| file_view.with_untracked(Option::is_none)); set_list_state.set(ListState::Loading); fetch_gen.update_value(|g| *g += 1); let my_gen = fetch_gen.get_value(); let path_str = req.path.join("/"); // On a public share page the (synthetic) "me" comes from the share // itself, not from a session — refreshing /me would be wrong there. let is_share = loc.with_untracked(|l| l.share_token.is_some()); spawn_local(async move { let ListReq { root_id, sort: spec, size, page, .. } = req; let resp = api::list_files( root_id, &path_str, spec.key, !spec.asc, page * size, size, around.as_deref(), ) .await; // A newer fetch (or the view going away) makes this response // stale: applying it would show the wrong folder. if fetch_gen.try_get_value() != Some(my_gen) { return; } match resp { Ok(r) => { let got = ListReq { page: r.offset / size, ..req }; // Past the end, or `around` found the file elsewhere. if got.page != page { requested.set_value(Some(got.clone())); router::replace(&Location { page: got.page, ..loc.get_untracked() }); } let list = Arc::new(Listing { entries: r.entries, total: r.total, offset: r.offset, req: got, }); set_list_state.set(ListState::Entries(list)); } Err(e) => { if !is_share && matches!(e.status(), Some(403) | Some(404)) && me_refresh_key.get() != Some((root_id, path_str.clone())) { set_me_refresh_key.set(Some((root_id, path_str.clone()))); spawn_local(async move { if let Ok(m) = api::me().await { set_me.set(Some(m)); } }); } set_list_state.set(ListState::Error(e.to_string())); } } }); }); Effect::new(move |_| { req.with(|_| ()); load.run(false); }); let fetch = Callback::new(move |_| load.run(true)); let turn_to = move |page: usize| { router::replace(&Location { page, ..loc.get_untracked() }); load.run(false); }; // Load the page in `dir` for the arrow keys. False when there is none. let advance = move |dir: isize, preview: bool, origin: usize, selected: Vec| { let ListState::Entries(list) = list_state.get_untracked() else { return false; }; let more = if dir > 0 { list.offset + list.entries.len() < list.total } else { list.offset > 0 }; if more { turn_to(list.req.page.saturating_add_signed(dir)); page_step.set(Some(PageStep { dir, preview, origin, selected, fetch_gen: fetch_gen.get_value(), })); } more }; let start_turn = move |dir: isize, preview: bool| { advance( dir, preview, loc.with_untracked(|l| l.page), selected.get_untracked(), ); }; let turn_page = Callback::new(move |dir: isize| start_turn(dir, false)); // Finish a page turn once its page has loaded. The preview skips pages // without a previewable file, and returns to the start page when it runs // out of pages or was closed meanwhile. // // ponytail: one request per skipped page, so a long run of // non-previewable files is slow. If that matters, ask the server for // "the next previewable file after X" instead. Effect::new(move |_| { let st = list_state.get(); if st == ListState::Loading { return; } let Some(step) = page_step.get_untracked() else { return; }; page_step.set(None); let ListState::Entries(list) = st else { return; }; if step.fetch_gen != fetch_gen.get_value() { return; } let back = move |step: PageStep| { set_selected.set(step.selected); turn_to(step.origin); }; if !step.preview { let idx = if step.dir > 0 { 0 } else { list.entries.len() - 1 }; set_selected.set(vec![list.entries[idx].clone()]); // The new page renders after this returns. request_animation_frame(move || { if let Some(c) = listing_container() { focus_item(&c, idx); } }); return; } let Some(cur) = file_view.get_untracked().filter(|v| is_media(v.kind)) else { back(step); return; }; let mut files = list.entries.iter().filter(|e| previewable(e)); let e = if step.dir > 0 { files.next() } else { files.next_back() }; match e { Some(e) => show_preview(e, cur.root_id), None => { let (dir, origin) = (step.dir, step.origin); if !advance(dir, true, origin, step.selected.clone()) { back(step); } } } }); // A file named in the URL opens once its listing is on screen. The // workspace closes it when the URL drops it. let open_name = Memo::new(move |_| loc.with(|l| l.open.clone())); Effect::new(move |_| { let ListState::Entries(list) = list_state.get() else { return; }; let Some(name) = open_name.get() else { return; }; // An open file view is either this file, or the next one before // the URL has caught up with it. if file_view.with_untracked(Option::is_some) || req.with_untracked(|r| r.as_ref() != Some(&list.req)) { return; } match list.entries.iter().find(|e| e.name == name && !e.is_dir) { Some(e) => { set_selected.set(vec![e.clone()]); open_entry(e, list.req.root_id, loc, list.req.is_rw, open_file).run(()); } None => { show_error(toast, i18n::t(i18n::k::FILE_NOT_FOUND).to_string()); router::clear_open(); } } }); Effect::new(move |_| { set_browser_refresh.set(Some(fetch)); }); // Left / Right in an open media preview steps to the previous or next // previewable file, in the listing's current order. Past the edge of the // page it loads the neighbouring page and continues there. // // Only previewable files: the editor needs the arrow keys for its // caret, and a folder has nothing to preview. crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { let step: isize = match ev.key().as_str() { "ArrowLeft" => -1, "ArrowRight" => 1, _ => return, }; // A focused player owns its arrow keys (seek / volume), and a // modifier means a browser shortcut (Alt+Left is history back). let on_player = ev .target() .and_then(|t| t.dyn_into::().ok()) .is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO")); if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() { return; } let Some(cur) = file_view.get().filter(|v| is_media(v.kind)) else { return; }; let ListState::Entries(list) = list_state.get() else { return; }; let files: Vec<&Entry> = list.entries.iter().filter(|e| previewable(e)).collect(); let Some(at) = files.iter().position(|e| e.name == cur.name) else { return; }; ev.prevent_default(); match at.checked_add_signed(step).and_then(|i| files.get(i)) { Some(e) => show_preview(e, cur.root_id), None => start_turn(step, true), } }); // Delete asks to delete the selection — the same confirm dialog as the // context menu's Delete. Not while something else owns the keyboard: a // dialog, the full-page file view, the context menu, or a text field. { let owner = ctx_owner.clone(); crate::util::owned_window_listener( leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() != "Delete" || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() || file_view.get().is_some() || ctx.get().is_some() { return; } let in_field = ev .target() .and_then(|t| t.dyn_into::().ok()) .is_some_and(|el| matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA")); if in_field { return; } let loc_now = loc.get(); let (is_rw, root_id) = me.with(|m| { let root = m.as_ref().and_then(|m| effective_root(&m.roots, &loc_now)); ( root.is_some_and(|r| r.mode.is_writable()), root.map(|r| r.id), ) }); if !is_rw { return; } let action = selected.with(|sel| { (!sel.is_empty()).then(|| { action_delete(sel, root_id, loc, fetch, toast, set_dialog, owner.clone()) }) }); if let Some(a) = action { ev.prevent_default(); a.run(()); } }, ); } // Arrow keys while no row has focus (after load, after a click on empty // space, after a preview closed): continue from the first selected entry, // or start at the first entry. Only when focus is on the page body, so // arrows inside a focused button, field or player stay theirs. crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if !matches!( ev.key().as_str(), "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" ) || dialog.get().is_some() || file_view.get().is_some() || ctx.get().is_some() { return; } let on_body = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.active_element()) .is_none_or(|a| a.tag_name() == "BODY"); if !on_body { return; } let ListState::Entries(all) = list_state.get_untracked() else { return; }; let Some(container) = listing_container() else { return; }; let anchor = selected.with_untracked(|sel| anchor_index(&all, sel)); match anchor { Some(idx) => { let grid = view_mode.get_untracked() == ViewMode::Grid; arrow_key( &ev, &container, &all, idx, grid, selected, set_selected, turn_page, ); } None => { let Some(first) = all.entries.first() else { return; }; ev.prevent_default(); set_selected.set(vec![first.clone()]); focus_item(&container, 0); } } }); // Closing the full-page view unmounted the focused row with the // listing. Put focus back on the selected entry, once it is rendered // again, so the arrow keys continue from there. Effect::new(move |was_open: Option| { let open = file_view.get().is_some(); if was_open == Some(true) && !open { let ListState::Entries(all) = list_state.get_untracked() else { return open; }; if let Some(idx) = selected.with_untracked(|sel| anchor_index(&all, sel)) { request_animation_frame(move || { if let Some(c) = listing_container() { focus_item(&c, idx); } }); } } open }); let drop_owner = ctx_owner.clone(); // A page turn or an open file keeps the rendered listing's frame. let folder = Memo::new(move |_| loc.with(Location::folder_key)); view! {
{move || { let me_now = me.get()?; folder.with(|_| ()); let loc_now = loc.get_untracked(); Some(match effective_root(&me_now.roots, &loc_now) { Some(root) => file_browser( me, root, loc, root.mode.is_writable().then(|| { let (root_id, dir, owner) = (root.id, loc_now.path.join("/"), drop_owner.clone()); Callback::new(move |ev: web_sys::DragEvent| { // Read the drop here, not in the task below: // the `DataTransfer` is dead once this // handler returns (see `api::read_drop`). let dropped = api::read_drop(&ev); let names = dropped.names(); if names.is_empty() { return; } // The job shows at once; walking a big // dropped folder takes a while. let label = upload_label(names.iter().map(String::as_str)); let job = uploads::prepare(root_id, dir.clone(), label); let (dir, owner) = (dir.clone(), owner.clone()); spawn_local(async move { let files = api::files_from_drop(dropped).await; if files.is_empty() { uploads::discard(job); return; } uploads::set_label( job, upload_label(files.iter().map(|(p, _)| p.as_str())), ); upload_prepared( job, root_id, dir, files, toast, dialog, set_dialog, owner, ); }); }) }), list_state, turn_page, view_mode, set_view_mode, sort, set_ctx, selected, sel_names, set_selected, fetch, open_file, ) .into_any(), None => no_folder_view(&me_now.roots, &loc_now).into_any(), }) }}
// Outside `.browser`, which is hidden while a preview is open.
} } // --------------------------------------------------------------------------- // Empty states (no resolvable root) // --------------------------------------------------------------------------- /// Shown when the browser can't resolve a root: the user has no roots at all, /// hasn't picked one yet (they are sidebar tabs now), or the URL points at a /// root that no longer exists. fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView { let (title, message) = if roots.is_empty() { ( i18n::t(i18n::k::NO_FOLDERS_TITLE), i18n::t(i18n::k::NO_FOLDERS_MSG), ) } else if loc.root_id.is_none() { ( i18n::t(i18n::k::SELECT_FOLDER_TITLE), i18n::t(i18n::k::SELECT_FOLDER_MSG), ) } else { ( i18n::t(i18n::k::FOLDER_GONE_TITLE), i18n::t(i18n::k::FOLDER_GONE_MSG), ) }; view! {

{title}

{message}

} } // --------------------------------------------------------------------------- // File browser (breadcrumbs + toolbar + entries) // --------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] // explicit signal props fn file_browser( me: ReadSignal>, root: &RootInfo, loc: ReadSignal, // Upload the files of a drop into this folder; `None` when read-only. drop_upload: Option>, list_state: ReadSignal, turn_page: Callback, view_mode: ReadSignal, set_view_mode: WriteSignal, sort: Memo, set_ctx: WriteSignal>, selected: ReadSignal>, sel_names: Memo>, set_selected: WriteSignal>, fetch: Callback<()>, open_file: WriteSignal>, ) -> impl IntoView { let root_id = root.id; let root_name = root.name.clone(); let is_rw = root.mode.is_writable(); let loc_now = loc.get_untracked(); // A new order starts at the first page. let set_sort = move |next: SortSpec| { next.save(); router::replace(&Location { sort: next, page: 0, ..loc.get_untracked() }); }; // The user's profile setting: single click opens entries; when off, // a single click selects and a double click opens. let single_click = me .get() .and_then(|m| m.user) .is_some_and(|u| u.single_click_open); let thumbs = me .get() .is_some_and(|m| m.thumbnails_available && m.user.is_some_and(|u| u.thumbnails)); // Breadcrumbs let crumbs = view! {
{root_name.clone()} {loc_now.path.iter().enumerate().map(|(i, seg)| { let seg = seg.clone(); let target: Vec = loc_now.path[..=i].to_vec(); let class = if i == loc_now.path.len() - 1 { "crumb current" } else { "crumb" }; view! { "/" {seg} } }).collect::>()}
}; // The title names the current order, not the one the click would switch // to: the arrow already shows it. let sort_label = move || { i18n::t(if sort.get().asc { i18n::k::SORT_ASC } else { i18n::k::SORT_DESC }) }; // Toolbar: refresh on the left, view toggles on the right. The // selection's action buttons live in the app's top bar. let toolbar = view! {
{[ (ViewMode::Grid, i18n::k::GRID_VIEW, IconName::Grid), (ViewMode::List, i18n::k::LIST_VIEW, IconName::List), ] .map(|(mode, label, icon)| view! { })}
}; let area_press = LongPress::new(); // Drag-and-drop upload. `dragenter`/`dragleave` also fire for every // child, so a depth counter tells "left the area" from "moved inside". let (drag_depth, set_drag_depth) = signal(0i32); let drag_files = move |ev: &web_sys::DragEvent| drop_upload.is_some() && api::drag_has_files(ev); let entries_area = view! {
0) on:dragenter=move |ev: web_sys::DragEvent| { if drag_files(&ev) { ev.prevent_default(); set_drag_depth.update(|d| *d += 1); } } on:dragover=move |ev: web_sys::DragEvent| { if drag_files(&ev) { // Required, or the browser refuses the drop. ev.prevent_default(); if let Some(dt) = ev.data_transfer() { dt.set_drop_effect("copy"); } } } on:dragleave=move |_| set_drag_depth.update(|d| *d = (*d - 1).max(0)) on:drop=move |ev: web_sys::DragEvent| { set_drag_depth.set(0); let Some(cb) = drop_upload else { return }; if !api::drag_has_files(&ev) { return; } ev.prevent_default(); cb.run(ev); } // Clicking empty space clears the selection. on:click=move |ev: MouseEvent| { if area_press.swallow_click() { // Else the window listener closes the menu just opened. ev.stop_propagation(); return; } set_selected.set(Vec::new()) } on:contextmenu=move |ev: MouseEvent| { ev.prevent_default(); set_selected.set(Vec::new()); set_ctx.set(Some((ev.client_x(), ev.client_y()))); } // Touch long press on empty space: the same menu as a right click. on:pointerdown=move |ev: web_sys::PointerEvent| { area_press.down(&ev, move |x, y| { set_selected.set(Vec::new()); set_ctx.set(Some((x, y))); }); } on:pointerup=move |_| area_press.up() on:pointercancel=move |_| area_press.up() on:pointerleave=move |_| area_press.up() on:pointermove=move |ev: web_sys::PointerEvent| area_press.mv(&ev) >
{i18n::tr(i18n::k::UPLOAD_DROP_HINT)}
{move || { // Until the new folder's fetch starts, the listing is the // old folder's. let stale = move |l: &Listing| { l.req.root_id != root_id || loc.with_untracked(|c| c.path != l.req.path) }; match list_state.get() { ListState::Entries(list) if stale(&list) => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_any(), ListState::Loading => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_any(), ListState::Error(msg) => view! {
{msg.clone()}
} .into_any(), ListState::Entries(list) => { if list.entries.is_empty() { return view! {

{i18n::tr(i18n::k::EMPTY_FOLDER)}

} .into_any(); } let (offset, shown, total, size) = (list.offset, list.entries.len(), list.total, list.req.size); let body = entries_view( view_mode.get(), list, turn_page, root_id, loc, is_rw, single_click, thumbs, selected, sel_names, set_selected, set_ctx, open_file, ); let go = Callback::new(move |page: usize| { navigate(&Location { page, ..loc.get_untracked() }); }); // The new page holds the entry that was first on screen. let set_size = Callback::new(move |n: usize| { save_page_size(n); router::replace(&Location { size: n, page: offset / n, ..loc.get_untracked() }); }); view! { {body} {pager(offset, shown, total, size, go, set_size)} } .into_any() } } }}
}; view! { {crumbs} {toolbar} {entries_area} } } /// Open the context menu for one entry at `at`. Shared by the right click /// and the touch long press. An entry that is already selected keeps the /// whole selection (multi-item menu); any other entry becomes the only /// selected one. fn open_entry_ctx( all: &Listing, idx: usize, set_ctx: WriteSignal>, selected: ReadSignal>, set_selected: WriteSignal>, at: CtxMenu, ) { let entry = &all.entries[idx]; if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) { set_selected.set(vec![entry.clone()]); } set_ctx.set(Some(at)); } /// Items per row in the grid. /// /// Read from the DOM on every call, not cached: the grid is CSS /// `auto-fill`, so only the browser knows the column count, and a cached one /// would go stale on the next resize. fn grid_columns(container: &web_sys::Element) -> usize { let kids = container.children(); let first = kids .item(0) .and_then(|c| c.dyn_into::().ok()); let Some(first) = first else { return 1 }; let top = first.offset_top(); let mut cols = 0usize; for i in 0..kids.length() { let Some(el) = kids .item(i) .and_then(|c| c.dyn_into::().ok()) else { break; }; if el.offset_top() != top { break; } cols += 1; } cols.max(1) } /// Move keyboard focus to the item at `idx` inside `container`. /// /// `focus()` scrolls the element into view on its own, so an item revealed /// by the arrow keys does not need its own scrolling. fn focus_item(container: &web_sys::Element, idx: usize) { if let Some(el) = container .children() .item(idx as u32) .and_then(|c| c.dyn_into::().ok()) { let _ = el.focus(); } } /// The selection after extending from the anchor to `idx`, in listing order. /// /// The anchor is the first selected entry. Every path that replaces the /// selection puts the new entry first, so the next Shift+click or /// Shift+arrow extends from the same place. /// /// `None` when the anchor left the listing. The caller then leaves the /// selection alone rather than guessing a new one. fn range_selection( entries: &[Entry], cur: Vec, idx: usize, add: bool, ) -> Option> { let anchor = match cur.first() { Some(a) => entries.iter().position(|e| e.name == a.name)?, None => idx, }; let (lo, hi) = if anchor <= idx { (anchor, idx) } else { (idx, anchor) }; let mut next: Vec = if add { cur } else { Vec::new() }; let mut have: HashSet = next.iter().map(|s| s.name.clone()).collect(); { let mut push = |e: &Entry| { if have.insert(e.name.clone()) { next.push(e.clone()); } }; push(&entries[anchor]); entries[lo..=hi].iter().for_each(&mut push); } Some(next) } /// Left-click behaviour. Modifier clicks always select: /// - Ctrl/Cmd+click toggles the entry; /// - Shift+click selects the range from the anchor (the first selected /// entry) to the clicked one, in listing order; with Ctrl it adds the /// range instead of replacing the selection. /// /// A plain click follows the "single click opens" profile setting: off /// (default) selects the entry alone (a double click opens it); on opens it. /// /// Selecting clicks stop propagation so the empty-area click (deselect) and /// the double-click handler don't interfere. fn selection_click( all: Arc, idx: usize, single_click: bool, selected: ReadSignal>, set_selected: WriteSignal>, open: Callback<()>, ) -> impl Fn(web_sys::MouseEvent) + 'static { move |ev: MouseEvent| { let entries = &all.entries; let entry = &entries[idx]; let ctrl = ev.ctrl_key() || ev.meta_key(); if !ctrl && !ev.shift_key() { if single_click { open.run(()); return; } ev.stop_propagation(); set_selected.set(vec![entry.clone()]); return; } ev.stop_propagation(); let cur = selected.get_untracked(); if ev.shift_key() { if let Some(next) = range_selection(entries, cur, idx, ctrl) { set_selected.set(next); } return; } let mut next = cur; match next.iter().position(|s| s.name == entry.name) { Some(i) => { next.remove(i); } None => next.push(entry.clone()), } set_selected.set(next); } } /// What "open" does for an entry: descend into a folder, open a text file in /// the editor (read-only in read-only folders), open a media preview — or /// say so when there is no viewer. Shared by left-click, Enter, and the /// context menu's "Open" item. fn open_entry( entry: &Entry, root_id: i64, loc: ReadSignal, is_rw: bool, open_file: WriteSignal>, ) -> Callback<()> { let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind); Callback::new(move |_| { if is_dir { let cur = loc.get(); let mut path = cur.path; path.push(name.clone()); navigate(&Location::folder(Some(root_id), cur.share_token, path)); return; } open_file.set(Some(FileView { root_id, path: join_path(&loc.get().path, &name), name: name.clone(), kind, readonly: !is_rw, })); }) } /// Arrow keys on a focused entry: move focus, and take the selection with it. /// /// The grid uses all four keys, the list only up and down. A list row is the /// full width, so left and right are left to the browser (caret browsing, /// horizontal scrolling). /// /// Shift extends from the anchor, exactly as Shift+click does, so the two /// can be mixed in one selection. The selection covers one page, so Shift /// stops at the page edge; without Shift the key turns the page. #[allow(clippy::too_many_arguments)] // explicit signal props fn arrow_key( ev: &web_sys::KeyboardEvent, container: &web_sys::Element, all: &Arc, idx: usize, grid: bool, selected: ReadSignal>, set_selected: WriteSignal>, turn_page: Callback, ) { // A modifier the handler does not implement belongs to the browser // (Alt+Left is history back, Ctrl+Arrow jumps words in a text field). if ev.ctrl_key() || ev.meta_key() || ev.alt_key() { return; } let cols = if grid { grid_columns(container) } else { 1 }; let step = match ev.key().as_str() { "ArrowUp" => -(cols as isize), "ArrowDown" => cols as isize, "ArrowLeft" if grid => -1, "ArrowRight" if grid => 1, _ => return, }; // Past this point the key is ours: the page must not scroll under it. ev.prevent_default(); let total = all.entries.len(); let target = match idx.checked_add_signed(step) { Some(t) if t < total => t, // Down from above a short last row: the last entry, not the next page. Some(_) if step > 0 && idx / cols < (total - 1) / cols => total - 1, _ => { if !ev.shift_key() { turn_page.run(step.signum()); } return; } }; let cur = selected.get_untracked(); let next = if ev.shift_key() { range_selection(&all.entries, cur, target, false) } else { Some(vec![all.entries[target].clone()]) }; if let Some(next) = next { set_selected.set(next); } focus_item(container, target); } /// The rendered listing container. There is one listing per page at most, /// so the class lookup is unambiguous. fn listing_container() -> Option { web_sys::window()? .document()? .query_selector(".entries-grid, .entries-list") .ok()? } /// Position of the first selected entry in the listing, if any. fn anchor_index(all: &Listing, selected: &[Entry]) -> Option { let first = selected.first()?; all.entries.iter().position(|e| e.name == first.name) } /// Whether a double click opens: not with a modifier held, because that is /// two selecting clicks (the browser still fires `dblclick` for them). fn plain_dblclick(ev: &MouseEvent) -> bool { !(ev.ctrl_key() || ev.meta_key() || ev.shift_key()) } /// One listing layout. Grid and list differ only in their container and /// item classes and in what each item shows, so they are one function: the /// selection, keyboard and context-menu behaviour is identical and must /// stay that way. #[allow(clippy::too_many_arguments)] // explicit signal props fn entries_view( mode: ViewMode, all: Arc, turn_page: Callback, root_id: i64, loc: ReadSignal, is_rw: bool, single_click: bool, thumbs: bool, selected: ReadSignal>, sel_names: Memo>, set_selected: WriteSignal>, set_ctx: WriteSignal>, open_file: WriteSignal>, ) -> impl IntoView { let total = all.entries.len(); let grid = mode == ViewMode::Grid; let (container, item, icon_class) = if grid { ("entries-grid", "tile", "tile-icon") } else { ("entries-list", "row", "row-icon") }; // Grid only: a list row is 28px of icon, too small to be worth a request // per entry. let thumbs = thumbs && grid; let container = match thumbs { true => format!("{container} has-thumbs"), false => container.to_string(), }; // Untracked: a change to the location refetches the listing, which // re-runs this anyway. let dir = loc.get_untracked().path; view! {
// Keyed by index: a listing never changes in place, a new // page is a new listing. ().ok()) .and_then(|el| el.parent_element()) else { return; }; arrow_key(&ev, &container, &all_keys, i, grid, selected, set_selected, turn_page); } on:contextmenu=move |ev: MouseEvent| { ev.prevent_default(); ev.stop_propagation(); let at = (ev.client_x(), ev.client_y()); open_entry_ctx(&all_ctx, i, set_ctx, selected, set_selected, at); } title=title > {if grid { view! {
// `icon_svg`, not ``: no // reactive owner per item (see its // doc). {icon_svg(icon, icon_class)} // A 404 (no thumbnail for this // file) removes the element. Left in // place, a sized broken `` draws // a border over the icon. {thumb_src.map(|src| view! { ().remove(); } } /> })}
{name}
}.into_any() } else { view! { {icon_svg(icon, icon_class)} {name} {size} {date} }.into_any() }}
} } /> } } // --------------------------------------------------------------------------- // Context menu // --------------------------------------------------------------------------- /// Build the action list for the selection: the empty area (nothing /// selected), one entry, or several. Shared by the right-click context menu /// and the toolbar's selection actions so both always offer the same /// operations. #[allow(clippy::too_many_arguments)] // explicit signal props fn menu_items( sel: &[Entry], me: ReadSignal>, loc: ReadSignal, open_file: WriteSignal>, refresh: Callback<()>, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, owner: Owner, ) -> Vec { let loc_now = loc.get(); // The root that is actually shown. Resolves the single-root default // location, where `loc.root_id` is `None`. Read in place: cloning `me` // would copy its whole root list on every selection change. let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| { let root = me_now .as_ref() .and_then(|m| effective_root(&m.roots, &loc_now)); ( root.map(|r| r.mode.is_writable()).unwrap_or(false), root.map(|r| r.name.clone()).unwrap_or_default(), root.map(|r| r.id), me_now .as_ref() .map(|m| m.allow_writable_shares) .unwrap_or(false), ) }); match sel { [] => { let mut v = vec![ MenuItem::rw( IconName::NewFolder, i18n::t(i18n::k::NEW_FOLDER), is_rw, action_name( NameOp::Mkdir, root_id, loc, refresh, toast, set_dialog, owner.clone(), ), ), MenuItem::rw( IconName::NewFile, i18n::t(i18n::k::NEW_FILE), is_rw, action_name( NameOp::CreateFile, root_id, loc, refresh, toast, set_dialog, owner.clone(), ), ), MenuItem::rw( IconName::Upload, i18n::t(i18n::k::UPLOAD_FILES), is_rw, action_upload( true, false, root_id, loc, toast, dialog, set_dialog, owner.clone(), ), ), MenuItem::rw( IconName::UploadFolder, i18n::t(i18n::k::UPLOAD_FOLDER), is_rw, action_upload( true, true, root_id, loc, toast, dialog, set_dialog, owner.clone(), ), ), ]; // Sharing and search are signed-in features; a share visitor has // neither, and `SessionUser` refuses a share token outright. if loc_now.share_token.is_none() { v.push(MenuItem::new( IconName::Share, i18n::t(i18n::k::SHARE), action_share( None, root_id, loc, root_name.clone(), allow_writable_shares, is_rw, set_dialog, owner.clone(), ), )); } if let Some(rid) = root_id && loc_now.share_token.is_none() { let dir = loc_now.path.join("/"); v.push(MenuItem::new( IconName::Search, i18n::t(i18n::k::SEARCH_IN_FOLDER), owner.with(|| { Callback::new(move |_| crate::views::search::open_search_in(rid, &dir)) }), )); } v } // One entry gets every action; several get only the ones that take // a set (move, copy, delete). _ => { let one = if let [e] = sel { Some(e) } else { None }; let mut v = vec![]; if let Some(e) = one { // Exactly what left-clicking the entry does: descend into a // folder, open a text file in the editor (read-only in // read-only folders), or preview media. if let Some(rid) = root_id { v.push(MenuItem::new( if e.is_dir { IconName::FolderOpen } else { IconName::Open }, i18n::t(i18n::k::OPEN), open_entry(e, rid, loc, is_rw, open_file), )); } if !e.is_dir { // Serve the file itself in a new tab. HTML and SVG render // as real pages there (sandboxed by the server), which is // how you share a small site or an HTML report. v.push(MenuItem::new( IconName::OpenInNew, i18n::t(i18n::k::OPEN_IN_NEW_TAB), action_open_tab(e, root_id, loc, owner.clone()), )); } v.push(MenuItem::new( IconName::Download, i18n::t(i18n::k::DOWNLOAD), action_download(e, root_id, loc, set_dialog, toast, owner.clone()), )); v.push(MenuItem::rw( IconName::Rename, i18n::t(i18n::k::RENAME), is_rw, action_name( NameOp::Rename(e.name.clone()), root_id, loc, refresh, toast, set_dialog, owner.clone(), ), )); } v.push(MenuItem::rw( IconName::Move, i18n::t(i18n::k::MOVE), is_rw, action_move_copy( Op::Move, sel, root_id, me, loc, refresh, toast, dialog, set_dialog, owner.clone(), ), )); // Copying only writes at the destination, so a read-only source // folder is fine. v.push(MenuItem::new( IconName::Copy, i18n::t(i18n::k::COPY), action_move_copy( Op::Copy, sel, root_id, me, loc, refresh, toast, dialog, set_dialog, owner.clone(), ), )); if let Some(e) = one { if loc_now.share_token.is_none() { v.push(MenuItem::new( IconName::Share, i18n::t(i18n::k::SHARE), action_share( Some(e), root_id, loc, root_name.clone(), allow_writable_shares, is_rw, set_dialog, owner.clone(), ), )); } v.push(MenuItem::new( IconName::Info, i18n::t(i18n::k::INFO), action_info(e, loc, root_name, set_dialog, owner.clone()), )); } v.push(MenuItem::rw( IconName::Delete, i18n::t(i18n::k::DELETE), is_rw, action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()), )); v } } } #[component] fn CtxMenuView( ctx: ReadSignal>, set_ctx: WriteSignal>, me: ReadSignal>, loc: ReadSignal, selected: ReadSignal>, refresh: Callback<()>, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, open_file: WriteSignal>, owner: Owner, ) -> impl IntoView { // Close on any click or Escape. { crate::util::owned_window_listener(leptos::ev::click, move |_| { set_ctx.set(None); }); crate::util::owned_window_listener( leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() == "Escape" { set_ctx.set(None); } }, ); } // The menu first paints at the cursor point; after mount, this measures // its actual size and flips it above/left of the cursor when it would // overflow the viewport (native context-menu behavior), clamping to the // viewport edges as a fallback. let menu_ref = NodeRef::::new(); Effect::new(move |_| { let Some((mx, my)) = ctx.get() else { return; }; let Some(node) = menu_ref .get() .and_then(|d| d.dyn_into::().ok()) else { return; }; let rect = node.get_bounding_client_rect(); let (w, h) = (rect.width() as i32, rect.height() as i32); let (vw, vh) = web_sys::window() .and_then(|w| { let vw = w.inner_width().ok()?.as_f64()?; let vh = w.inner_height().ok()?.as_f64()?; Some((vw, vh)) }) .unwrap_or((f64::MAX, f64::MAX)); let (vw, vh) = (vw as i32, vh as i32); let x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0)); let y = if my + h > vh { my - h } else { my }.clamp(0, (vh - h).max(0)); let _ = node.style().set_property("left", &format!("{x}px")); let _ = node.style().set_property("top", &format!("{y}px")); }); view! { {move || { let (mx, my) = ctx.get()?; let items = selected.with(|sel| { menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone()) }); Some(view! {
{items.into_iter().map(|item| { view! { } }).collect::>()}
}) }} } } /// One row of the context menu. `action` is `None` for a disabled row, and /// `hint` then says why it is disabled. #[derive(Clone)] struct MenuItem { label: &'static str, icon: IconName, action: Option>, hint: Option<&'static str>, } impl MenuItem { fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self { Self { label, icon, action: Some(action), hint: None, } } /// An item that only works in a writable folder. fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self { Self { label, icon, action: is_rw.then_some(action), hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)), } } } #[component] fn CtxItem(item: MenuItem) -> impl IntoView { let MenuItem { label, icon, action, hint, } = item; let enabled = action.is_some(); let hint = hint.unwrap_or_default(); view! {
{label}
} } // --------------------------------------------------------------------------- // Toolbar selection actions (top right) // --------------------------------------------------------------------------- /// Icon-only buttons for the current selection — the same set of actions /// as its right-click menu — or the empty-area actions (new folder, /// uploads, search) when nothing is selected. Rendered in the app's top bar (top /// right). Buttons that don't fit collapse into a three-dot overflow menu. #[component] pub fn SelectionActions( me: ReadSignal>, loc: ReadSignal, selected: ReadSignal>, open_file: WriteSignal>, refresh: Callback<()>, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, owner: Owner, topbar_ref: NodeRef, ) -> impl IntoView { // How many buttons are shown before the overflow kicks in. Starts small // so the first paint never overflows; the effect below widens it as soon // as the real widths are measurable. let (visible_n, set_visible_n) = signal(3usize); let group_ref = NodeRef::::new(); let recompute = move || { let Some(tb) = topbar_ref .get() .and_then(|d| d.dyn_into::().ok()) else { return; }; let Some(g) = group_ref .get() .and_then(|d| d.dyn_into::().ok()) else { return; }; // Every button is icon-only with identical padding, so one // rendered button gives the width of all of them. let Some(btn) = g.first_element_child() else { return; }; // Available width: from the top bar's left content (brand) to its // right edge — independent of where the group currently sits, // so the measurement stays valid even while the group overflows. let tb_r = tb.get_bounding_client_rect(); let start = tb .query_selector(".brand") .ok() .flatten() .map(|b| b.get_bounding_client_rect()) .map(|r| r.right() + 16.0) .unwrap_or(0.0); let avail = (tb_r.right() - start - 10.0).max(40.0); let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap // How many buttons fit (not counting the overflow button). set_visible_n.set(((avail / step) as usize).max(1)); }; // Measuring forces three layout reflows, so it must not run on every // selection change. Only how many buttons *fit* is measured, and that // changes when the group appears, disappears, or switches between the // empty-area, single and multi action sets — not per selected entry. let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2))); Effect::new(move |_| { topbar_ref.get(); group_ref.get(); // Re-measure when the item set (selection kind, user) or width // inputs change. sel_kind.get(); me.track(); recompute(); }); crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute()); view! {
// The visible buttons and the overflow rows are one item list // split at the measured count, so the actions are computed once. {move || { // No resolvable root (e.g. the `#/` "select a folder" empty // state): there is nowhere to act, so hide the whole group // rather than show a row of disabled buttons. let has_root = me.with(|m| { m.as_ref() .is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some())) }); if !has_root { return None; } let items = selected.with(|sel| { menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone()) }); let fit = visible_n.get(); let n = if fit >= items.len() { items.len() } else { // The overflow button takes a slot too, so drop one — // otherwise the group eats into the space after the brand. (fit - 1).max(1) }; let (visible, more) = items.split_at(n); let visible_view = visible .iter() .cloned() .map(|i| view! { }) .collect_view(); // Rendered as nothing while every action fits. let more_block = (!more.is_empty()).then(|| { let more_view = more .iter() .cloned() .map(|i| view! { }) .collect_view(); let btn = NodeRef::::new(); let menu = NodeRef::::new(); // The popover sits in the top layer, outside this layout, // so it is placed under the button by hand. let (at, set_at) = signal((0.0, 0.0)); view! {
{more_view}
} }); Some(view! { {visible_view} {more_block} }) }}
} } /// One icon-only toolbar button. A disabled button keeps its tooltip, which /// then explains why. #[component] fn SelButton(item: MenuItem) -> impl IntoView { let enabled = item.action.is_some(); let title = if enabled { item.label.to_string() } else { format!("{} — {}", item.label, item.hint.unwrap_or_default()) }; view! { } } // --------------------------------------------------------------------------- // Menu actions // --------------------------------------------------------------------------- fn previewable(e: &Entry) -> bool { !e.is_dir && is_media(e.kind) } fn join_path(dir: &[String], name: &str) -> String { if dir.is_empty() { name.to_string() } else { format!("{}/{}", dir.join("/"), name) } } fn is_conflict(e: &api::ApiError) -> bool { matches!(e, api::ApiError::Http { status: 409, .. }) } /// What a name prompt in the current folder does with the entered name. #[derive(Clone)] enum NameOp { Mkdir, CreateFile, /// The entry's current name. Rename(String), } fn action_name( op: NameOp, root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { use i18n::k; Callback::new(move |_| { let Some(root_id) = root_id else { return }; let dir = loc.get().path; let (title, label, initial, submit, ok) = match &op { NameOp::Mkdir => ( i18n::t(k::NEW_FOLDER).to_string(), k::FOLDER_NAME, String::new(), k::CREATE, k::FOLDER_CREATED, ), NameOp::CreateFile => ( i18n::t(k::NEW_FILE).to_string(), k::FILE_NAME, String::new(), k::CREATE, k::FILE_CREATED, ), NameOp::Rename(old) => ( i18n::t_fmt(k::RENAME_TITLE, old), k::NEW_NAME, old.clone(), k::RENAME, k::RENAMED, ), }; let op = op.clone(); set_dialog.set(Some(Dialog::PromptOp { title, label: i18n::t(label).to_string(), initial, submit: i18n::t(submit).to_string(), run: owner.with(|| { Callback::new(move |name: String| { let (op, dir) = (op.clone(), dir.clone()); let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move { match op { NameOp::Mkdir => api::mkdir(root_id, &join_path(&dir, &name)).await, NameOp::CreateFile => { api::create_file(root_id, &join_path(&dir, &name)).await } NameOp::Rename(old) => { api::rename_item(root_id, &join_path(&dir, &old), name, false).await } } .map(|_| ()) .map_err(|e| e.to_string()) }); fut }) }), on_ok: owner.with(|| { Callback::new(move |_| { show(toast, i18n::t(ok)); refresh.run(()); }) }), })); }) } /// Delete the entries after one confirmation. Several are deleted one by /// one; the first failure stops and reports. fn action_delete( entries: &[Entry], root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect(); Callback::new(move |_| { let Some(root_id) = root_id else { return }; let dir = loc.get().path; let message = match items.as_slice() { [(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name), [(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name), many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()), }; let fulls: Vec = items.iter().map(|(n, _)| join_path(&dir, n)).collect(); set_dialog.set(Some(Dialog::Confirm { title: i18n::t(i18n::k::DELETE).to_string(), message, submit: i18n::t(i18n::k::DELETE).to_string(), danger: true, on_submit: owner.with(|| { Callback::new(move |_| { set_dialog.set(None); let fulls = fulls.clone(); spawn_local(async move { for f in &fulls { if let Err(e) = api::delete_item(root_id, f).await { show_error(toast, e.to_string()); refresh.run(()); return; } } show(toast, i18n::t(i18n::k::DELETED)); refresh.run(()); }); }) }), })); }) } /// Pick files (or a folder) and upload them into the current directory. /// /// Flow: pick → ask the server which targets exist → if any, the conflict /// dialog (overwrite / skip / cancel, "apply to all") → start a job. The /// job runs in `uploads`; its progress shows in the panel, not in a toast. #[allow(clippy::too_many_arguments)] // explicit signal props fn action_upload( multiple: bool, directory: bool, root_id: Option, loc: ReadSignal, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let dir = loc.get().path.join("/"); let owner = owner.clone(); api::pick_files(multiple, directory, move |files| { let label = upload_label(files.iter().map(|(p, _)| p.as_str())); let job = uploads::prepare(root_id, dir.clone(), label); upload_prepared( job, root_id, dir.clone(), files, toast, dialog, set_dialog, owner.clone(), ); }); }) } /// The job label: the one name for a single item, the top folder when every /// path carries a slash (a folder pick), else "N files". Takes relative /// paths after a pick, top-level names after a drop. fn upload_label<'a>(names: impl Iterator) -> String { let names: Vec<&str> = names.collect(); match names[..] { [] => String::new(), [one] => one.to_string(), ref many if many.iter().all(|p| p.contains('/')) => { many[0].split('/').next().unwrap_or_default().to_string() } ref many => i18n::t_fmt(i18n::k::UPLOAD_N_FILES, &many.len().to_string()), } } /// Upload picked or dropped files into `dir`: existence check, conflict /// dialog, transfer. The job already shows in the panel as "preparing". #[allow(clippy::too_many_arguments)] // explicit signal props fn upload_prepared( job: u64, root_id: i64, dir: String, files: Vec<(String, web_sys::File)>, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, owner: Owner, ) { spawn_local(async move { let paths: Vec = files.iter().map(|(rel, _)| rel.clone()).collect(); let existing = match api::check_exists(root_id, &dir, paths).await { Ok(e) => e, Err(e) => { uploads::discard(job); show_error( toast, i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()), ); return; } }; // A folder where a file would go can never be written. // Report it in the job and leave it out of the upload. let dirs: std::collections::HashSet = existing .iter() .filter(|e| e.is_dir) .map(|e| e.path.clone()) .collect(); let conflicts: Vec = existing .iter() .filter(|e| !e.is_dir) .map(|e| e.path.clone()) .collect(); let errors: Vec<(String, String)> = dirs .iter() .map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string())) .collect(); let files: Vec<(String, web_sys::File)> = files .into_iter() .filter(|(rel, _)| !dirs.contains(rel.as_str())) .collect(); let start = move |on_conflict: OnConflict, decided: Vec, overwrite: bool| { uploads::begin( job, files.clone(), errors.clone(), on_conflict, decided, overwrite, ); }; if conflicts.is_empty() { start(OnConflict::Ask, Vec::new(), false); return; } // Callbacks made inside a short-lived async scope die with // it; the browser's owner keeps this one alive. let on_decide = owner.with(|| { let conflicts = conflicts.clone(); let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start))); Callback::new(move |answer: Option<(bool, bool)>| { let Some(start) = start.lock().unwrap().take() else { return; }; let Some((overwrite, apply_all)) = answer else { uploads::discard(job); return; }; // The answer covers the listed files. "Apply to all" extends // it to files that appear during the transfer. let rule = match (apply_all, overwrite) { (false, _) => OnConflict::Ask, (true, true) => OnConflict::Overwrite, (true, false) => OnConflict::Skip, }; start(rule, conflicts.clone(), overwrite); }) }); // The job sits in the panel as "preparing" while it waits. crate::views::dialogs::claim_async_dialog(dialog).await; set_dialog.set(Some(Dialog::UploadConflict { files: conflicts, on_decide, })); }); } /// Move or copy the entries to a folder chosen in the picker. Several are /// processed one by one; name collisions are collected and asked about once. #[allow(clippy::too_many_arguments)] // explicit signal props fn action_move_copy( op: Op, entries: &[Entry], root_id: Option, me: ReadSignal>, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, dialog: ReadSignal>, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); Callback::new(move |_| { let loc = loc.get(); let Some(root_id) = root_id else { return }; let roots = me.get().map(|m| m.roots).unwrap_or_default(); let dir = loc.path.clone(); let title = match names.as_slice() { [one] => i18n::t_fmt(op.title_key(1), one), many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()), }; // (name, path relative to the root) per item. let items: Vec<(String, String)> = names .iter() .map(|n| (n.clone(), join_path(&dir, n))) .collect(); set_dialog.set(Some(Dialog::Picker { title, confirm: i18n::t(op.here_key()).to_string(), roots, root: root_id, dir: dir.join("/"), for_write: true, on_pick: owner.with(|| { Callback::new(move |(dst_root, dst_dir): (i64, String)| { let wire = match op { Op::Move => api_types::Op::Move, Op::Copy => api_types::Op::Copy, }; let ok_msg = i18n::t(if op == Op::Move { i18n::k::MOVED } else { i18n::k::COPIED }); // Runs the items and returns the ones that collided. let run = move |items: Vec<(String, String)>, overwrite: bool, dst_dir: String| async move { let mut conflicts = Vec::new(); for (name, full) in items { match api::mutation(root_id, &full, wire, dst_root, &dst_dir, overwrite) .await { Ok(_) => {} Err(e) if is_conflict(&e) => conflicts.push((name, full)), Err(e) => return Err(e.to_string()), } } Ok(conflicts) }; let items = items.clone(); spawn_local(async move { let conflicts = match run(items, false, dst_dir.clone()).await { Ok(c) if c.is_empty() => { show(toast, ok_msg); refresh.run(()); return; } Ok(c) => c, Err(e) => return show_error(toast, e), }; crate::views::dialogs::claim_async_dialog(dialog).await; set_dialog.set(Some(Dialog::Conflict { title: i18n::t(i18n::k::NAME_TAKEN).to_string(), files: conflicts.iter().map(|(n, _)| n.clone()).collect(), // The retry re-runs only the items that collided. on_submit: Callback::new(move |_| { let (conflicts, dst_dir) = (conflicts.clone(), dst_dir.clone()); spawn_local(async move { match run(conflicts, true, dst_dir).await { Ok(c) if c.is_empty() => { show(toast, ok_msg); refresh.run(()); } Ok(_) => show_error(toast, i18n::t(i18n::k::NAME_TAKEN)), Err(e) => show_error(toast, e), } }); }), })); }); }) }), })); }) } fn action_download( entry: &Entry, root_id: Option, loc: ReadSignal, set_dialog: WriteSignal>, toast: ToastMsg, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); let is_dir = entry.is_dir; owner.with(|| { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let full = join_path(&loc.path, &name); if is_dir { set_dialog.set(Some(Dialog::DownloadFormat { name: name.clone(), root_id, path: full, })); } else { let url = api::download_url(root_id, &full, None); api::trigger_download(&url, &name); show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED)); } }) }) } /// Open the file itself in a new tab, via the `?action=preview` endpoint — /// the browser renders it natively (HTML/SVG under the server's sandbox CSP). fn action_open_tab( entry: &Entry, root_id: Option, loc: ReadSignal, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); owner.with(|| { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let full = join_path(&loc.get().path, &name); api::open_in_new_tab(&api::preview_url(root_id, &full)); }) }) } /// Open the share dialog for an item (milestone 6). /// /// `entry` is the item to share, or `None` for the folder currently open. /// The folder case sends an empty path, which the server reads as the root of /// the location. At the top of a root that shares the root itself. #[allow(clippy::too_many_arguments)] // explicit signal props fn action_share( entry: Option<&Entry>, root_id: Option, loc: ReadSignal, root_name: String, allow_writable: bool, root_writable: bool, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let entry_name = entry.map(|e| e.name.clone()); owner.with(|| { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let (name, path) = match &entry_name { Some(n) => (n.clone(), join_path(&loc.path, n)), // The last path segment names the folder; at the top of a // root there is none, so the root's own name stands in. None => ( loc.path .last() .cloned() .unwrap_or_else(|| root_name.clone()), loc.path.join("/"), ), }; set_dialog.set(Some(Dialog::Share { name, root_id, path, allow_writable, root_writable, })); }) }) } fn action_info( entry: &Entry, loc: ReadSignal, root_name: String, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let entry2 = entry.clone(); owner.with(|| { Callback::new(move |_| { let loc = loc.get(); let full = join_path(&loc.path, &entry2.name); set_dialog.set(Some(Dialog::Info { entry: entry2.clone(), root_name: root_name.clone(), rel_path: full, })); }) }) }