//! The file browser: breadcrumbs, grid/list views, root picker, context menu. 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_for, Icon, IconName}; use crate::components::toast::{show, ToastMsg}; use crate::router::{navigate, Location}; use crate::util::{format_date, format_size, ViewMode}; #[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. 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); // 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) { None => root_picker(&me.roots, &set_ctx, &loc_now).into_any(), Some(root) => file_browser( root, &loc, &list_state, &view_mode, &set_view_mode, &set_ctx, &toast, &fetch, ) .into_any(), } }}
} } // --------------------------------------------------------------------------- // Root picker (shown when the user has multiple roots and is at the top level) // --------------------------------------------------------------------------- fn root_picker( roots: &[RootInfo], set_ctx: &WriteSignal>, loc: &Location, ) -> impl IntoView { view! {
"Folders"
{roots.iter().map(|r| { let id = r.id; let name = r.name.clone(); let path = r.path.clone(); let mode = r.mode.clone(); let set_ctx = *set_ctx; let _loc = loc.clone(); view! {
{name}
{if path == "." { "/".to_string() } else { path.clone() }}
{if mode == "rw" { "read-write" } else { "read-only" }}
} }).collect::>()}
} } // --------------------------------------------------------------------------- // File browser (breadcrumbs + toolbar + entries) // --------------------------------------------------------------------------- fn file_browser( root: &RootInfo, loc: &ReadSignal, list_state: &ReadSignal, view_mode: &ReadSignal, set_view_mode: &WriteSignal, set_ctx: &WriteSignal>, toast: &ToastMsg, fetch: &Callback<()>, ) -> 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 (view toggle) let set_view = *set_view_mode; let vm = *view_mode; let toolbar = view! {
}; let ls = *list_state; let loc2 = *loc; let set_ctx2 = *set_ctx; let t = *toast; let refresh = *fetch; let entries_area = view! {
{move || { match ls.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 vm.get() == ViewMode::Grid { grid_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any() } else { list_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any() } } } }}
}; view! { {crumbs} {toolbar} {entries_area} } } fn entry_actions( entry: Entry, root_id: i64, loc: &ReadSignal, set_ctx: &WriteSignal>, _toast: &ToastMsg, ) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) { let l = *loc; let set_ctx = *set_ctx; let entry2 = entry.clone(); let nav = move || { let mut path = l.get().path; path.push(entry.name.clone()); navigate(&Location { root_id: Some(root_id), path, }); }; let ctx_handler = move |ev: MouseEvent| { ev.prevent_default(); ev.stop_propagation(); set_ctx.set(Some(CtxMenu { x: ev.client_x() as i32, y: ev.client_y() as i32, entry: Some(entry2.clone()), })); }; (nav, ctx_handler) } fn grid_view( entries: &[Entry], root_id: i64, loc: &ReadSignal, set_ctx: &WriteSignal>, toast: &ToastMsg, ) -> impl IntoView { view! {
{entries.iter().map(|e| { let icon = icon_for(&e.name, e.is_dir); let name = e.name.clone(); let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast); let t = *toast; let is_dir = e.is_dir; let title = name.clone(); let on_click = move |_| { if is_dir { nav(); } else { show(t, "Previews are coming soon"); } }; view! {
{name}
} }).collect::>()}
} } fn list_view( entries: &[Entry], root_id: i64, loc: &ReadSignal, set_ctx: &WriteSignal>, toast: &ToastMsg, ) -> impl IntoView { view! {
{entries.iter().map(|e| { let icon = icon_for(&e.name, e.is_dir); let name = e.name.clone(); let size = format_size(e.size); let date = format_date(&e.mtime); let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast); let t = *toast; let is_dir = e.is_dir; let title = name.clone(); let on_click = move |_| { if is_dir { nav(); } else { show(t, "Previews are coming soon"); } }; view! {
{name} {if is_dir { "—".to_string() } else { size }} {date}
} }).collect::>()}
} } // --------------------------------------------------------------------------- // Context menu (M2 shell: only "Open" and "Refresh" work yet) // --------------------------------------------------------------------------- #[component] fn CtxMenuView( ctx: ReadSignal>, set_ctx: WriteSignal>, loc: ReadSignal, refresh: Callback<()>, ) -> impl IntoView { // Close on any click or Escape. { let set_ctx = set_ctx; let _h1 = window_event_listener(leptos::ev::click, move |_| { set_ctx.set(None); }); let set_ctx2 = set_ctx; let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() == "Escape" { set_ctx2.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: Vec<(&'static str, Option>)> = match &m.entry { Some(e) => { let mut v: Vec<(&'static str, Option>)> = vec![]; if e.is_dir { let name = e.name.clone(); let l = loc; v.push(( "Open", Some(Callback::new(move |_| { let cur = l.get(); let mut path = cur.path; path.push(name.clone()); // Open relative to the same root. navigate(&Location { root_id: cur.root_id, path, }); })), )); } for label in [ "Download", "Rename", "Move", "Copy", "Share", "Info", "Delete", ] { v.push((label, None)); } v } None => vec![ ("New folder", None), ("Upload", None), ("Refresh", Some(Callback::new(move |_| refresh.run(())))), ], }; view! {
{items.into_iter().map(|(label, action)| { view! { } }).collect::>()}
} .into_view() .into_any() }} } } #[component] fn CtxItem(label: String, action: Option>) -> impl IntoView { let enabled = action.is_some(); view! {
{label}
} }