//! The file browser: breadcrumbs, grid/list views, root picker, context menu. use api_types::FileKind; use leptos::prelude::*; 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, EditorModal}; use crate::icons::{IconName, icon_for}; use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind}; use crate::router::{Location, navigate}; use crate::util::{ViewMode, format_date, format_size}; use crate::views::dialogs::{Dialog, DialogView, Op}; #[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>, loc: 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); let (dialog, set_dialog) = signal(Option::::None); let (preview, set_preview) = signal(Option::<(PreviewTarget, PreviewKind)>::None); let (editor, set_editor) = 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 me_owner = Owner::current().unwrap(); // 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("/"); 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) => set_list_state.set(ListState::Error(e.to_string())), } }); }); Effect::new(move |_| fetch.run(())); view! {
{move || { let Some(me) = me.get() else { return { view! {}.into_any() }; }; let loc_now = loc.get(); match effective_root(&me.roots, &loc_now) { Some(root) => file_browser( root, loc, list_state, view_mode, set_view_mode, set_ctx, fetch, set_preview, ) .into_any(), None => no_folder_view(&me.roots, &loc_now).into_any(), } }} {move || { let p = preview.get(); match p { Some((target, kind)) => view! { } .into_view() .into_any(), None => { view! {}.into_any() }, } }} {move || { let ed = editor.get(); match ed { Some(t) => view! { } .into_view() .into_any(), None => { view! {}.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() { ( "No folders available", "An administrator hasn't assigned you any folders yet.", ) } else if loc.root_id.is_none() { ( "Select a folder", "Choose a folder from the sidebar to start browsing.", ) } else { ( "Folder not available", "This folder is no longer accessible. Choose another one from the sidebar.", ) }; view! {

{title}

{message}

} } // --------------------------------------------------------------------------- // File browser (breadcrumbs + toolbar + entries) // --------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] // explicit signal props fn file_browser( root: &RootInfo, loc: ReadSignal, list_state: ReadSignal, view_mode: ReadSignal, set_view_mode: WriteSignal, set_ctx: WriteSignal>, fetch: Callback<()>, set_preview: WriteSignal>, ) -> impl IntoView { let root_id = root.id; let root_name = root.name.clone(); let loc_now = loc.get(); // 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. let toolbar = view! {
}; let entries_area = view! {
{move || { match list_state.get() { ListState::Loading => view! {

"Loading…"

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

"This folder is empty."

} .into_view() .into_any() } else if view_mode.get() == ViewMode::Grid { grid_view(&entries, root_id, loc, set_ctx, set_preview).into_any() } else { list_view(&entries, root_id, loc, set_ctx, set_preview).into_any() } } } }}
}; view! { {crumbs} {toolbar} {entries_area} } } fn entry_actions( entry: Entry, root_id: i64, loc: ReadSignal, set_ctx: WriteSignal>, ) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) { let entry2 = entry.clone(); let nav = move || { let cur = loc.get(); let mut path = cur.path; path.push(entry.name.clone()); navigate(&Location { root_id: Some(root_id), path, share_token: cur.share_token.clone(), }); }; let ctx_handler = move |ev: MouseEvent| { ev.prevent_default(); ev.stop_propagation(); set_ctx.set(Some(CtxMenu { x: ev.client_x(), y: ev.client_y(), entry: Some(entry2.clone()), })); }; (nav, ctx_handler) } /// Click/Enter on an entry: descend into a folder, or open the right viewer for /// a file. Shared by the grid and the list, which differ only in markup. fn open_callback( entry: &Entry, root_id: i64, loc: ReadSignal, set_ctx: WriteSignal>, set_preview: WriteSignal>, ) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) { let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx); // Resolved here, during the caller's render: the callback runs later, when // this reactive owner may already be gone. let toast = use_context::().expect("toast context"); let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind); let cb = Callback::new(move |_| { if is_dir { nav(); return; } match preview_kind(kind) { Some(k) => { let full = join_path(&loc.get().path, &name); set_preview.set(Some(( PreviewTarget { root_id, path: full, name: name.clone(), }, k, ))); } None => show(toast, "No preview available — right-click to download"), } }); (cb, on_ctx) } fn grid_view( entries: &[Entry], root_id: i64, loc: ReadSignal, set_ctx: WriteSignal>, set_preview: 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_ctx) = open_callback(e, root_id, loc, set_ctx, set_preview); let oc1 = open_cb; let oc2 = open_cb; view! {
{name}
} }).collect::>()}
} } fn list_view( entries: &[Entry], root_id: i64, loc: ReadSignal, set_ctx: WriteSignal>, set_preview: 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_ctx) = open_callback(e, root_id, loc, set_ctx, set_preview); let oc1 = open_cb; let oc2 = open_cb; view! {
{name} {if is_dir { "—".to_string() } else { size }} {date}
} }).collect::>()}
} } // --------------------------------------------------------------------------- // Context menu // --------------------------------------------------------------------------- #[component] fn CtxMenuView( ctx: ReadSignal>, set_ctx: WriteSignal>, me: ReadSignal>, loc: ReadSignal, refresh: Callback<()>, toast: ToastMsg, set_dialog: WriteSignal>, set_editor: 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 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(|m2| effective_root(&m2.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(|m2| m2.allow_writable_shares) .unwrap_or(false); let own = || owner.clone(); let items: Vec = match &m.entry { Some(e) => { let mut v = vec![]; if e.is_dir { v.push(MenuItem::new( IconName::FolderOpen, "Open", action_open_dir(e, loc, own()), )); } else { // 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, "Open in new tab", action_open_tab(e, root_id, loc, own()), )); } v.push(MenuItem::new( IconName::Download, "Download", action_download(e, root_id, loc, set_dialog, toast, own()), )); // Editing is only offered for files that decode as text. if e.kind == FileKind::Text { v.push(MenuItem::rw( IconName::Edit, "Edit", is_rw, action_edit(e, root_id, loc, set_editor, own()), )); } v.push(MenuItem::rw( IconName::Rename, "Rename", is_rw, action_rename(e, root_id, loc, refresh, toast, set_dialog, own()), )); v.push(MenuItem::rw( IconName::Move, "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, "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, "Share", action_share(e, root_id, loc, allow_writable_shares, set_dialog, own()), )); } v.push(MenuItem::new( IconName::Info, "Info", action_info(e, loc, root_name, set_dialog, own()), )); v.push(MenuItem::rw( IconName::Delete, "Delete", is_rw, action_delete(e, root_id, loc, refresh, toast, set_dialog, own()), )); v } None => vec![ MenuItem::rw( IconName::NewFolder, "New folder", is_rw, action_new_folder(root_id, loc, refresh, toast, set_dialog, own()), ), MenuItem::rw( IconName::Upload, "Upload files", is_rw, action_upload(true, false, root_id, loc, refresh, toast, set_dialog), ), MenuItem::rw( IconName::UploadFolder, "Upload folder", is_rw, action_upload(true, true, root_id, loc, refresh, toast, set_dialog), ), ], }; 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. 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("Read-only folder"), } } } #[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}
} } // --------------------------------------------------------------------------- // 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: "New folder".into(), label: "Folder name".into(), initial: String::new(), submit: "Create".into(), 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, "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: format!("Rename \u{201c}{name}\u{201d}"), label: "New name".into(), initial: name.clone(), submit: "Rename".into(), 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(|_| ()) }) }, "Name already in use", vec![target], "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 { format!( "Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone." ) } else { format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.") }; set_dialog.set(Some(Dialog::Confirm { title: "Delete".into(), message, submit: "Delete".into(), 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, "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, format!( "Uploading {n} file{}\u{2026}", if n == 1 { "" } else { "s" } ), ); 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 }) }, "Some files already exist", // Unused: an upload conflict always names the skipped files. Vec::new(), "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(|_| ()) }) }, "Name already in use", vec![name3.clone()], if op == Op::Move { "Moved" } else { "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, "Download started"); } }) }) } /// Descend into a folder. fn action_open_dir(entry: &Entry, loc: ReadSignal, owner: Owner) -> Callback<()> { let name = entry.name.clone(); owner.with(|| { Callback::new(move |_| { let cur = loc.get(); let mut path = cur.path; path.push(name.clone()); navigate(&Location { root_id: cur.root_id, path, share_token: cur.share_token, }); }) }) } /// 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 text editor for a file (milestone 5). fn action_edit( entry: &Entry, root_id: Option, loc: ReadSignal, set_editor: 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_editor.set(Some(EditTarget { root_id, path: full, name: name.clone(), })); }) }) } /// Open the share dialog for an item (milestone 6). fn action_share( entry: &Entry, root_id: Option, loc: ReadSignal, allow_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, })); }) }) } 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, })); }) }) }