//! The file browser: breadcrumbs, grid/list views, root picker, context menu. 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; use crate::components::toast::{ToastMsg, show}; 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::{ViewMode, format_date, format_size}; use crate::views::dialogs::{Dialog, Op}; use crate::views::file_view::{FileView, UnsupportedTarget}; #[derive(Clone, Debug, PartialEq)] enum ListState { Loading, Error(String), Entries(Vec), } /// The root that is actually shown: the one in the location, or — when the /// user has exactly one root — that root directly. 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 roots.len() == 1 => Some(&roots[0]), None => None, } } #[derive(Clone)] struct CtxMenu { x: i32, y: i32, /// None = empty area / root tile (no entry selected). entry: Option, } #[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 single selected entry, owned by the shell so its action buttons /// can live in the top bar. `None` shows the empty-area actions there. selected: ReadSignal>, set_selected: WriteSignal>, /// Dialogs are rendered at the shell level. set_dialog: WriteSignal>, /// 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); // Drop the selection when the entry disappears from the listing // (deleted, renamed, …). Effect::new(move |_| { if let ListState::Entries(entries) = list_state.get() && let Some(s) = selected.get() && !entries.iter().any(|e| e.name == s.name) { set_selected.set(None); } }); // Escape clears the selection — but only when neither the context menu // (it has its own Escape handler) nor a full-page file view (the shell // closes it on the same keypress) is open. Registered in the capture // phase so it sees the file view *before* the shell's bubble listener // closes it; 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() || 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(None); }, ); // 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); 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 { match api::list_files(root_id, &path_str).await { Ok(r) => set_list_state.set(ListState::Entries(r.entries)), 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, view_mode, set_view_mode, set_ctx, selected, 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, view_mode: ReadSignal, set_view_mode: WriteSignal, set_ctx: WriteSignal>, selected: ReadSignal>, 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 is_last = i == loc_now.path.len() - 1; 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(entries) => { if entries.is_empty() { view! {

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

} .into_view() .into_any() } else if view_mode.get() == ViewMode::Grid { grid_view( &entries, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file, ) .into_any() } else { list_view( &entries, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file, ) .into_any() } } } }}
}; view! { {crumbs} {toolbar} {entries_area} } } /// Right-click on an entry: select it and open the context menu on it. fn ctx_menu_handler( entry: Entry, set_ctx: WriteSignal>, set_selected: WriteSignal>, ) -> impl Fn(web_sys::MouseEvent) + 'static { move |ev: MouseEvent| { ev.prevent_default(); ev.stop_propagation(); set_selected.set(Some(entry.clone())); set_ctx.set(Some(CtxMenu { x: ev.client_x(), y: ev.client_y(), entry: Some(entry.clone()), })); } } /// Left-click behaviour per the "single click opens" profile setting: /// - off (default): the click selects the entry; a double click opens it; /// - on: the click opens it, Ctrl/Cmd+click toggles the selection. /// /// Selecting clicks stop propagation so the empty-area click (deselect) and /// the double-click handler don't interfere. fn selection_click( entry: Entry, single_click: bool, selected: ReadSignal>, set_selected: WriteSignal>, open: Callback<()>, ) -> impl Fn(web_sys::MouseEvent) + 'static { move |ev: MouseEvent| { if single_click { if ev.ctrl_key() || ev.meta_key() { ev.stop_propagation(); let is_sel = selected.get().is_some_and(|s| s.name == entry.name); set_selected.set(if is_sel { None } else { Some(entry.clone()) }); return; } open.run(()); return; } ev.stop_propagation(); set_selected.set(Some(entry.clone())); } } /// 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( entry: &Entry, 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(entry, root_id, loc, is_rw, open_file); ( open_cb, selection_click(entry.clone(), single_click, selected, set_selected, open_cb), ctx_menu_handler(entry.clone(), set_ctx, set_selected), ) } #[allow(clippy::too_many_arguments)] // explicit signal props fn grid_view( entries: &[Entry], root_id: i64, loc: ReadSignal, is_rw: bool, single_click: bool, selected: ReadSignal>, set_selected: WriteSignal>, set_ctx: WriteSignal>, open_file: WriteSignal>, ) -> impl IntoView { view! {
{entries.iter().map(|e| { let icon = icon_for(e.kind, &e.name); let name = e.name.clone(); let title = name.clone(); let (open_cb, on_click, on_ctx) = entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file); let sel_check = selected; let tile_name = name.clone(); let selected_now = move || sel_check.get().is_some_and(|s| s.name == tile_name); view! {
{name}
} }).collect::>()}
} } #[allow(clippy::too_many_arguments)] // explicit signal props fn list_view( entries: &[Entry], root_id: i64, loc: ReadSignal, is_rw: bool, single_click: bool, selected: ReadSignal>, set_selected: WriteSignal>, set_ctx: WriteSignal>, open_file: WriteSignal>, ) -> impl IntoView { view! {
{entries.iter().map(|e| { let icon = icon_for(e.kind, &e.name); let name = e.name.clone(); let size = format_size(e.size); let date = format_date(&e.mtime); let is_dir = e.is_dir; let title = name.clone(); let (open_cb, on_click, on_ctx) = entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file); let sel_check = selected; let row_name = name.clone(); let selected_now = move || sel_check.get().is_some_and(|s| s.name == row_name); view! {
{name} {if is_dir { "—".to_string() } else { size }} {date}
} }).collect::>()}
} } // --------------------------------------------------------------------------- // Context menu // --------------------------------------------------------------------------- /// Build the action list for an entry (or the empty area, when `entry` is /// `None`). 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( entry: Option<&Entry>, me: ReadSignal>, loc: ReadSignal, open_file: WriteSignal>, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Vec { let me_now = me.get(); let loc_now = loc.get(); // The root that is actually shown. Resolves the single-root default // location, where `loc.root_id` is `None`. let root = me_now .as_ref() .and_then(|m| effective_root(&m.roots, &loc_now)); let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false); let root_name = root.map(|r| r.name.clone()).unwrap_or_default(); let root_id = root.map(|r| r.id); let allow_writable_shares = me_now .as_ref() .map(|m| m.allow_writable_shares) .unwrap_or(false); let own = || owner.clone(); match entry { Some(e) => { let mut v = vec![]; // 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, own()), )); } v.push(MenuItem::new( IconName::Download, i18n::t(i18n::k::DOWNLOAD), action_download(e, root_id, loc, set_dialog, toast, own()), )); v.push(MenuItem::rw( IconName::Rename, i18n::t(i18n::k::RENAME), is_rw, action_rename(e, root_id, loc, refresh, toast, set_dialog, own()), )); v.push(MenuItem::rw( IconName::Move, i18n::t(i18n::k::MOVE), is_rw, action_move_copy( Op::Move, e, root_id, me, loc, refresh, toast, set_dialog, own(), ), )); // 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, e, root_id, me, loc, refresh, toast, set_dialog, own(), ), )); 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, own(), ), )); } v.push(MenuItem::new( IconName::Info, i18n::t(i18n::k::INFO), action_info(e, loc, root_name, set_dialog, own()), )); v.push(MenuItem::rw( IconName::Delete, i18n::t(i18n::k::DELETE), is_rw, action_delete(e, root_id, loc, refresh, toast, set_dialog, own()), )); v } None => vec![ MenuItem::rw( IconName::NewFolder, i18n::t(i18n::k::NEW_FOLDER), is_rw, action_new_folder(root_id, loc, refresh, toast, set_dialog, own()), ), 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), ), ], } } #[component] fn CtxMenuView( ctx: ReadSignal>, set_ctx: WriteSignal>, me: ReadSignal>, loc: 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); } }, ); } view! { {move || { let Some(m) = ctx.get() else { return { view! {}.into_any() }; }; // Keep the menu inside the viewport. let (max_x, max_y) = web_sys::window() .and_then(|w| { w.inner_width() .ok() .and_then(|v| v.as_f64()) .zip(w.inner_height().ok().and_then(|v| v.as_f64())) }) .map(|(x, y)| (x as i32 - 220, y as i32 - 300)) .unwrap_or((m.x, m.y)); let x = m.x.min(max_x).max(0); let y = m.y.min(max_y).max(0); let items = menu_items( m.entry.as_ref(), 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 currently selected entry — the same set of /// actions as its right-click menu — or the empty-area actions (new folder, /// uploads) 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)); }; Effect::new(move |_| { topbar_ref.get(); group_ref.get(); // Re-measure when the item set (selection, user) or width inputs // change. selected.get(); me.get(); 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 || { let items = menu_items( selected.get().as_ref(), 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} } }}
} } /// 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(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(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(); set_dialog.set(Some(Dialog::Prompt { 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(), on_submit: owner.with(|| { Callback::new(move |name: String| { let name = name.trim().to_string(); if name.is_empty() { return; } let full = join_path(&dir, &name); set_dialog.set(None); spawn_local(async move { match api::mkdir(root_id, &full).await { Ok(_) => { show(toast, i18n::t(i18n::k::FOLDER_CREATED)); refresh.run(()); } Err(e) => show(toast, e.to_string()), } }); }) }), })); }) } 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::Prompt { 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(), on_submit: owner.with(|| { Callback::new(move |new_name: String| { let new_name = new_name.trim().to_string(); if new_name.is_empty() { return; } set_dialog.set(None); let full = full.clone(); let target = new_name.clone(); run_with_overwrite_retry( move |overwrite| { let (full, new_name) = (full.clone(), new_name.clone()); Box::pin(async move { api::rename_item(root_id, &full, new_name, overwrite) .await .map(|_| ()) }) }, i18n::t(i18n::k::NAME_TAKEN), vec![target], i18n::t(i18n::k::RENAMED), refresh, toast, set_dialog, ); }) }), })); }) } fn action_delete( entry: &Entry, root_id: Option, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); let is_dir = entry.is_dir; 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); let message = if is_dir { i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, &name) } else { i18n::t_fmt(i18n::k::DELETE_MSG, &name) }; 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 f = full.clone(); spawn_local(async move { match api::delete_item(root_id, &f).await { Ok(_) => { show(toast, i18n::t(i18n::k::DELETED)); refresh.run(()); } Err(e) => show(toast, e.to_string()), } }); }) }), })); }) } 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, ); }); }) } #[allow(clippy::too_many_arguments)] // explicit signal props fn action_move_copy( op: Op, entry: &Entry, root_id: Option, me: ReadSignal>, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, owner: Owner, ) -> Callback<()> { let name = entry.name.clone(); let entry2 = entry.clone(); Callback::new(move |_| { let loc = loc.get(); let me = me.get(); let Some(root_id) = root_id else { return }; let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default(); let dir = loc.path.clone(); let full = join_path(&dir, &name); let dir_str = loc.path.join("/"); let name3 = name.clone(); let entry3 = entry2.clone(); let full3 = full.clone(); set_dialog.set(Some(Dialog::Picker { op, entry: entry3, roots, source_root: root_id, source_dir: dir_str, on_pick: owner.with(|| { Callback::new(move |(dst_root, dst_dir): (i64, String)| { set_dialog.set(None); let full = full3.clone(); run_with_overwrite_retry( move |overwrite| { let (full, dst_dir) = (full.clone(), dst_dir.clone()); Box::pin(async move { 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 }; res.map(|_| ()) }) }, i18n::t(i18n::k::NAME_TAKEN), vec![name3.clone()], 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, })); }) }) }