//! The file browser: breadcrumbs, grid/list views, root picker, context menu. use std::collections::HashSet; use std::sync::{Arc, Mutex}; 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::editor::EditTarget; use crate::i18n; use crate::icons::{IconName, icon_for}; use crate::preview::{PreviewTarget, preview_kind}; use crate::router::{Location, Section, navigate}; use crate::util::{FILE_PAGE, ViewMode, format_date, format_size, page_footer}; use crate::views::dialogs::{Dialog, Op}; use crate::views::file_view::{FileView, UnsupportedTarget}; /// One folder listing. Behind an `Arc` in [`ListState`] so reading the /// signal never copies the entries (a folder can hold thousands). #[derive(Debug, PartialEq)] struct Listing { entries: Vec, /// The server had more entries than it will return; the listing holds /// only the first `api_types::MAX_LIST_ENTRIES`. truncated: bool, } #[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 (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); // How many entries of the current listing are rendered. Reset by every // fetch, so a new folder starts at one page again. let shown = RwSignal::new(FILE_PAGE); // 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()); }, ); // Fetch the current directory whenever the location (or user) changes. let fetch = Callback::new(move |_| { let Some(me) = me.get() else { return; }; let loc = loc.get(); let Some(root) = effective_root(&me.roots, &loc) else { return; }; set_list_state.set(ListState::Loading); shown.set(FILE_PAGE); fetch_gen.update_value(|g| *g += 1); let my_gen = fetch_gen.get_value(); let root_id = root.id; let path_str = loc.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.share_token.is_some(); let set_me2 = set_me; let refresh_key = me_refresh_key; let set_refresh_key = set_me_refresh_key; spawn_local(async move { let resp = api::list_files(root_id, &path_str).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) => set_list_state.set(ListState::Entries(Arc::new(Listing { entries: r.entries, truncated: r.truncated, }))), Err(e) => { if !is_share && matches!(e.status(), Some(403) | Some(404)) && refresh_key.get() != Some((root_id, path_str.clone())) { set_refresh_key.set(Some((root_id, path_str.clone()))); spawn_local(async move { if let Ok(m) = api::me().await { set_me2.set(Some(m)); } }); } set_list_state.set(ListState::Error(e.to_string())); } } }); }); Effect::new(move |_| fetch.run(())); { let w = set_browser_refresh; let f = fetch; Effect::new(move |_| { w.set(Some(f)); }); } view! {
{move || { let Some(me_now) = me.get() else { return { view! {}.into_any() }; }; let loc_now = loc.get(); match effective_root(&me_now.roots, &loc_now) { Some(root) => file_browser( me, root, loc, list_state, shown, view_mode, set_view_mode, set_ctx, selected, sel_names, set_selected, fetch, open_file, ) .into_any(), None => no_folder_view(&me_now.roots, &loc_now).into_any(), } }}
} } // --------------------------------------------------------------------------- // 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, list_state: ReadSignal, shown: RwSignal, view_mode: ReadSignal, set_view_mode: WriteSignal, 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(); // 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); // 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::>()}
}; // 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! {
}; let entries_area = view! {
{move || { match list_state.get() { ListState::Loading => view! {

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

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

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

} .into_view() .into_any(); } let total = list.entries.len(); let truncated = list.truncated; let body = entries_view( view_mode.get(), list, shown, root_id, loc, is_rw, single_click, selected, sel_names, set_selected, set_ctx, open_file, ); // Paging footer and, below it, the server-side cap // notice — both belong to the listing, not to one // of the two layouts. view! { {body} {move || page_footer( shown.get().min(total), total, total, FILE_PAGE, move || shown.update(|n| *n += FILE_PAGE), )} {truncated.then(|| view! {

{move || i18n::t_fmt( i18n::k::LIST_TRUNCATED, &api_types::MAX_LIST_ENTRIES.to_string(), )}

})} } .into_view() .into_any() } } }}
}; view! { {crumbs} {toolbar} {entries_area} } } /// Right-click on an entry: open the context menu on the selection. An /// entry that is already selected keeps the whole selection (multi-item /// menu); any other entry becomes the only selected one. fn ctx_menu_handler( all: Arc, idx: usize, set_ctx: WriteSignal>, selected: ReadSignal>, set_selected: WriteSignal>, ) -> impl Fn(web_sys::MouseEvent) + 'static { move |ev: MouseEvent| { ev.prevent_default(); ev.stop_propagation(); 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((ev.client_x(), ev.client_y()))); } } /// 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| { // Ranges and "is it still there?" run over the whole listing, not // over the rendered page. 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() { let anchor = match cur.first() { Some(a) => match entries.iter().position(|e| e.name == a.name) { Some(a) => a, None => return, }, None => idx, }; let (lo, hi) = if anchor <= idx { (anchor, idx) } else { (idx, anchor) }; // The anchor stays first so the next Shift+click extends from it. let mut next: Vec = if ctrl { cur } else { Vec::new() }; let mut have: HashSet = next.iter().map(|s| s.name.clone()).collect(); let mut add = |e: &Entry| { if have.insert(e.name.clone()) { next.push(e.clone()); } }; add(&entries[anchor]); entries[lo..=hi].iter().for_each(&mut add); 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 { root_id: Some(root_id), path, share_token: cur.share_token.clone(), section: Section::Files, }); return; } let full = join_path(&loc.get().path, &name); if kind == FileKind::Text { open_file.set(Some(FileView::Editor(EditTarget { root_id, path: full, name: name.clone(), readonly: !is_rw, }))); return; } match preview_kind(kind) { Some(k) => open_file.set(Some(FileView::Preview( PreviewTarget { root_id, path: full, name: name.clone(), }, k, ))), None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget { root_id, path: full, name: name.clone(), kind, }))), } }) } /// Click/Enter on an entry: the shared "open" action, the selection-aware /// click handler, and the context-menu handler. The grid and the list differ /// only in markup. #[allow(clippy::too_many_arguments)] // explicit signal props fn entry_callbacks( all: Arc, idx: usize, root_id: i64, loc: ReadSignal, is_rw: bool, single_click: bool, selected: ReadSignal>, set_selected: WriteSignal>, set_ctx: WriteSignal>, open_file: WriteSignal>, ) -> ( Callback<()>, impl Fn(web_sys::MouseEvent) + 'static, impl Fn(web_sys::MouseEvent) + 'static, ) { let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file); ( open_cb, selection_click( all.clone(), idx, single_click, selected, set_selected, open_cb, ), ctx_menu_handler(all, idx, set_ctx, selected, set_selected), ) } /// 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, shown: RwSignal, root_id: i64, loc: ReadSignal, is_rw: bool, single_click: 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") }; view! {
// Only the visible page is rendered; the click handlers still // index into the whole listing, so Shift+click ranges reach // past it. Keyed by index: within one listing the order is // fixed, so revealing a page only appends items. // `icon_svg`, not ``: no reactive owner // per item (see its doc). {icon_svg(icon, icon_class)} {if grid { view! {
{name}
}.into_any() } else { view! { {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, 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_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()), ), MenuItem::rw( IconName::NewFile, i18n::t(i18n::k::NEW_FILE), is_rw, action_new_file(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, refresh, toast, set_dialog), ), MenuItem::rw( IconName::UploadFolder, i18n::t(i18n::k::UPLOAD_FOLDER), is_rw, action_upload(true, true, root_id, loc, refresh, toast, set_dialog), ), ]; // Search is a signed-in feature; a share visitor has none. 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_rename(e, 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, 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, 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( e, root_id, loc, 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, 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 Some((mx, my)) = ctx.get() else { return { view! {}.into_any() }; }; let items = selected.with(|sel| { menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone()) }); view! {
{items.into_iter().map(|item| { view! { } }).collect::>()}
} .into_view() .into_any() }} } } /// 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, 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); // The three-dot overflow menu state. Any outside click or Escape closes // it; a new selection does too. The toggle button stops propagation so // it can actually open the menu. let (more_open, set_more_open) = signal(false); crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false)); crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() == "Escape" { set_more_open.set(false); } }); Effect::new(move |_| { let _ = selected.get(); set_more_open.set(false); }); 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 { view! {}.into_any() }; } let items = selected.with(|sel| { menu_items(sel, me, loc, open_file, refresh, toast, 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(); view! {
{more_view}
} }); view! { {visible_view} {more_block} } .into_view() .into_any() }}
} } /// 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 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, .. }) } /// A file operation in flight. Boxed because each caller builds a different /// future, and they all have to fit one type. type OpFuture = std::pin::Pin>>>; /// Run a file operation that may collide with an existing name. /// /// `op(overwrite)` builds the request. On a 409 the colliding names are shown /// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and /// upload all need exactly this, so it lives here once. /// /// `names` is what to list in the dialog when the server does not say. Upload /// reports the exact set it skipped; the single-item operations know theirs /// before they ask. fn run_with_overwrite_retry( op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static, conflict_title: &'static str, names: Vec, ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, ) { fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) { show(toast, ok_msg); refresh.run(()); } let retry = op.clone(); spawn_local(async move { let Err(e) = op(false).await else { done(ok_msg, refresh, toast); return; }; let conflicting = e .skipped() .map(<[String]>::to_vec) .or_else(|| is_conflict(&e).then_some(names)); let Some(files) = conflicting else { show_error(toast, e.to_string()); return; }; set_dialog.set(Some(Dialog::Conflict { title: conflict_title.to_string(), files, on_submit: Callback::new(move |_| { let retry = retry.clone(); spawn_local(async move { match retry(true).await { Ok(()) => done(ok_msg, refresh, toast), Err(e) => show_error(toast, e.to_string()), } }); }), })); }); } fn action_new_folder( root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let dir = loc.path.clone(); let toast2 = toast; let refresh2 = refresh; set_dialog.set(Some(Dialog::PromptOp { title: i18n::t(i18n::k::NEW_FOLDER).to_string(), label: i18n::t(i18n::k::FOLDER_NAME).to_string(), initial: String::new(), submit: i18n::t(i18n::k::CREATE).to_string(), run: owner.with(|| { Callback::new(move |name: String| { let full = join_path(&dir, &name); let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move { api::mkdir(root_id, &full) .await .map(|_| ()) .map_err(|e| e.to_string()) }); fut }) }), on_ok: owner.with(|| { Callback::new(move |_| { show(toast2, i18n::t(i18n::k::FOLDER_CREATED)); refresh2.run(()); }) }), })); }) } fn action_new_file( root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let dir = loc.path.clone(); let toast2 = toast; let refresh2 = refresh; set_dialog.set(Some(Dialog::PromptOp { title: i18n::t(i18n::k::NEW_FILE).to_string(), label: i18n::t(i18n::k::FILE_NAME).to_string(), initial: String::new(), submit: i18n::t(i18n::k::CREATE).to_string(), run: owner.with(|| { Callback::new(move |name: String| { let full = join_path(&dir, &name); let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move { api::create_file(root_id, &full) .await .map(|_| ()) .map_err(|e| e.to_string()) }); fut }) }), on_ok: owner.with(|| { Callback::new(move |_| { show(toast2, i18n::t(i18n::k::FILE_CREATED)); refresh2.run(()); }) }), })); }) } fn action_rename( entry: &Entry, root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let dir = loc.path.clone(); let full = join_path(&dir, &name); set_dialog.set(Some(Dialog::PromptOp { title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name), label: i18n::t(i18n::k::NEW_NAME).to_string(), initial: name.clone(), submit: i18n::t(i18n::k::RENAME).to_string(), run: owner.with(|| { Callback::new(move |new_name: String| { let full = full.clone(); let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move { api::rename_item(root_id, &full, new_name, false) .await .map(|_| ()) .map_err(|e| e.to_string()) }); fut }) }), on_ok: owner.with(|| { Callback::new(move |_| { show(toast, i18n::t(i18n::k::RENAMED)); 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(()); }); }) }), })); }) } fn action_upload( multiple: bool, directory: bool, root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, ) -> Callback<()> { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let dir = loc.path.join("/"); let dir1 = dir.clone(); api::pick_files(multiple, directory, move |files| { let n = files.len(); let dir = dir1.clone(); show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string())); run_with_overwrite_retry( move |overwrite| { let (dir, files) = (dir.clone(), files.clone()); Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await }) }, i18n::t(i18n::k::SOME_FILES_EXIST), // Unused: an upload conflict always names the skipped files. Vec::new(), i18n::t(i18n::k::UPLOAD_COMPLETE), refresh, toast, set_dialog, ); }); }) } /// 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, /// and the overwrite retry re-runs only the items that collided. #[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, 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)| { // Items still to do. A finished item is dropped, so the // overwrite retry re-runs only the ones that collided. let pending = Arc::new(Mutex::new(items.clone())); run_with_overwrite_retry( move |overwrite| { let (pending, dst_dir) = (pending.clone(), dst_dir.clone()); Box::pin(async move { let items = pending.lock().unwrap().clone(); let mut conflicts = Vec::new(); for (name, full) in items { let res = if op == Op::Move { api::move_item( root_id, &full, dst_root, &dst_dir, overwrite, ) .await } else { api::copy_item( root_id, &full, dst_root, &dst_dir, overwrite, ) .await }; match res { Ok(_) => { pending.lock().unwrap().retain(|(_, p)| *p != full) } Err(e) if is_conflict(&e) => conflicts.push(name), Err(e) => return Err(e), } } if conflicts.is_empty() { Ok(()) } else { Err(api::ApiError::Http { status: 409, message: String::new(), skipped: Some(conflicts), }) } }) }, i18n::t(i18n::k::NAME_TAKEN), // Unused: the conflict error above always lists the names. Vec::new(), if op == Op::Move { i18n::t(i18n::k::MOVED) } else { i18n::t(i18n::k::COPIED) }, refresh, toast, set_dialog, ); }) }), })); }) } 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). fn action_share( entry: &Entry, root_id: Option, loc: ReadSignal, allow_writable: bool, root_writable: bool, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); owner.with(|| { Callback::new(move |_| { let Some(root_id) = root_id else { return }; let loc = loc.get(); let full = join_path(&loc.path, &name); set_dialog.set(Some(Dialog::Share { name: name.clone(), root_id, path: full, 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, })); }) }) }