//! 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::cm; use crate::components::icon::{icon_for, Icon, IconName}; use crate::components::toast::{show, ToastMsg}; use crate::editor::{EditTarget, EditorModal}; use crate::router::{navigate, Location}; use crate::util::{format_date, format_size, ViewMode}; use crate::preview::{preview_kind, PreviewKind, PreviewModal, PreviewTarget}; 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. 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) { 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, &set_preview, ) .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(), } }}
} } // --------------------------------------------------------------------------- // 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 share_tok = loc.share_token.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<()>, set_preview: &WriteSignal>, ) -> impl IntoView { let root_id = root.id; let root_name = root.name.clone(); let loc_now = loc.get(); let loc_sig = *loc; // 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 set_preview2 = *set_preview; 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, &set_preview2).into_any() } else { list_view(&entries, root_id, &loc2, &set_ctx2, &t, &set_preview2).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 cur = l.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() 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, set_preview: &WriteSignal>, ) -> 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 size = e.size; let l = *loc; let sp = *set_preview; let name2 = name.clone(); let on_click = move |_| { if is_dir { nav(); } else { match preview_kind(&name2) { Some(k) => { let full = join_path(&l.get().path, &name2); sp.set(Some(( PreviewTarget { root_id, path: full, name: name2.clone(), size, }, k, ))); } None => show(t, "No preview available — right-click to download"), } } }; view! {
{name}
} }).collect::>()}
} } fn list_view( entries: &[Entry], root_id: i64, loc: &ReadSignal, set_ctx: &WriteSignal>, toast: &ToastMsg, set_preview: &WriteSignal>, ) -> 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 size_bytes = e.size; let l = *loc; let sp = *set_preview; let name2 = name.clone(); let on_click = move |_| { if is_dir { nav(); } else { match preview_kind(&name2) { Some(k) => { let full = join_path(&l.get().path, &name2); sp.set(Some(( PreviewTarget { root_id, path: full, name: name2.clone(), size: size_bytes, }, k, ))); } None => show(t, "No preview available — right-click to download"), } } }; 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. { 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 me_now = me.get(); let loc_now = loc.get(); let is_rw = me_now .as_ref() .and_then(|m2| effective_root(&m2.roots, &loc_now)) .map(|r| r.mode == "rw") .unwrap_or(false); let root_name = me_now .as_ref() .and_then(|m2| effective_root(&m2.roots, &loc_now)) .map(|r| r.name.clone()) .unwrap_or_default(); // The id of the root that is actually shown (resolves the single-root // default location, where `loc.root_id` is `None`). let eff_root_id = me_now .as_ref() .and_then(|m2| effective_root(&m2.roots, &loc_now)) .map(|r| r.id); // (label, action, disabled-hint) let items: Vec<(String, Option>, Option)> = match &m.entry { Some(e) => { let e2 = e.clone(); let mut v: Vec<(String, Option>, Option)> = vec![]; if e.is_dir { let l = loc; let name = e2.name.clone(); v.push(( "Open".into(), Some(Callback::new(move |_| { let cur = l.get(); let mut path = cur.path; path.push(name.clone()); navigate(&Location { root_id: cur.root_id, path, share_token: cur.share_token.clone(), }); })), None, )); } let e_dl = e2.clone(); let l_dl = loc; v.push(( "Download".into(), Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, owner.clone())), None, )); // Editing is only offered for text/code files. if !e2.is_dir && cm::is_text(&e2.name) { let e_ed = e2.clone(); let l_ed = loc; v.push(( "Edit".into(), rw_action( is_rw, action_edit(&e_ed, eff_root_id, l_ed, set_editor, owner.clone()), ), Some("Read-only folder".into()), )); } let e_rename = e2.clone(); let l_rename = loc; v.push(( "Rename".into(), rw_action( is_rw, action_rename(&e_rename, eff_root_id, l_rename, refresh, toast, set_dialog, owner.clone()), ), Some("Read-only folder".into()), )); let e_move = e2.clone(); let l_move = loc; let m_move = me; v.push(( "Move".into(), rw_action( is_rw, action_move_copy( Op::Move, &e_move, eff_root_id, m_move, l_move, refresh, toast, set_dialog, owner.clone(), ), ), Some("Read-only folder".into()), )); let e3 = e2.clone(); let l_copy = loc; let m_copy = me; v.push(( "Copy".into(), Some(action_move_copy( Op::Copy, &e3, eff_root_id, m_copy, l_copy, refresh, toast, set_dialog, owner.clone(), )), None, )); if loc_now.share_token.is_none() { let e_sh = e2.clone(); let l_sh = loc; let allow_wr = me_now .as_ref() .map(|m| m.allow_writable_shares) .unwrap_or(false); v.push(( "Share".into(), Some(action_share( &e_sh, eff_root_id, l_sh, allow_wr, set_dialog, owner.clone(), )), None, )); } let e_info = e2.clone(); let l_info = loc; v.push(( "Info".into(), Some(action_info( &e_info, l_info, root_name.clone(), set_dialog, owner.clone(), )), None, )); let e4 = e2.clone(); let l_del = loc; v.push(( "Delete".into(), rw_action( is_rw, action_delete(&e4, eff_root_id, l_del, refresh, toast, set_dialog, owner.clone()), ), Some("Read-only folder".into()), )); v } None => { let mut v: Vec<(String, Option>, Option)> = vec![]; let l_nf = loc; v.push(( "New folder".into(), rw_action( is_rw, action_new_folder(eff_root_id, l_nf, refresh, toast, set_dialog, owner.clone()), ), Some("Read-only folder".into()), )); let l_up1 = loc; v.push(( "Upload files".into(), rw_action( is_rw, action_upload(true, false, eff_root_id, l_up1, refresh, toast, set_dialog), ), Some("Read-only folder".into()), )); let l_up2 = loc; v.push(( "Upload folder".into(), rw_action( is_rw, action_upload(true, true, eff_root_id, l_up2, refresh, toast, set_dialog), ), Some("Read-only folder".into()), )); v.push(( "Refresh".into(), Some(Callback::new(move |_| refresh.run(()))), None, )); v } }; view! {
{items.into_iter().map(|(label, action, hint)| { view! { } }).collect::>()}
} .into_view() .into_any() }} } } /// `Some(cb)` when the current folder is writable, else `None` (disabled item). fn rw_action(is_rw: bool, cb: Callback<()>) -> Option> { if is_rw { Some(cb) } else { None } } #[component] fn CtxItem(label: String, action: Option>, hint: Option) -> impl IntoView { let enabled = action.is_some(); let hint = hint.unwrap_or_else(|| "Coming soon".to_string()); 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, .. }) } 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 f1 = full.clone(); let f2 = full.clone(); spawn_local(async move { match api::rename_item(root_id, &f1, new_name.clone(), false).await { Ok(_) => { show(toast, "Renamed"); refresh.run(()); } Err(e) => { if is_conflict(&e) { set_dialog.set(Some(Dialog::Conflict { title: "Name already in use".into(), files: vec![new_name.clone()], on_submit: Callback::new(move |_| { let f3 = f2.clone(); let n2 = new_name.clone(); spawn_local(async move { match api::rename_item(root_id, &f3, n2, true).await { Ok(_) => { show(toast, "Renamed"); refresh.run(()); } Err(e2) => show(toast, &e2.to_string()), } }); }), })); } else { show(toast, &e.to_string()); } } } }); }) }), })); }) } 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 retry = files.clone(); let dir2 = dir1.clone(); show( toast, &format!("Uploading {n} file{}\u{2026}", if n == 1 { "" } else { "s" }), ); spawn_local(async move { match api::upload(root_id, &dir2, false, files).await { Ok(_) => { show(toast, "Upload complete"); refresh.run(()); } Err(e) => { if let Some(skipped) = e.skipped() { let skipped = skipped.to_vec(); set_dialog.set(Some(Dialog::Conflict { title: "Some files already exist".into(), files: skipped, on_submit: Callback::new(move |_| { let rf = retry.clone(); let dir3 = dir2.clone(); spawn_local(async move { match api::upload(root_id, &dir3, true, rf).await { Ok(_) => { show(toast, "Upload complete"); refresh.run(()); } Err(e2) => show(toast, &e2.to_string()), } }); }), })); } else { show(toast, &e.to_string()); } } } }); }); }) } 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 f1 = full3.clone(); let f2 = full3.clone(); let name4 = name3.clone(); spawn_local(async move { let res = if op == Op::Move { api::move_item(root_id, &f1, dst_root, &dst_dir, false).await } else { api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await }; match res { Ok(_) => { show(toast, if op == Op::Move { "Moved" } else { "Copied" }); refresh.run(()); } Err(e) => { if is_conflict(&e) { set_dialog.set(Some(Dialog::Conflict { title: "Name already in use".into(), files: vec![name4], on_submit: Callback::new(move |_| { let f3 = f2.clone(); let d2 = dst_dir.clone(); spawn_local(async move { let res = if op == Op::Move { api::move_item(root_id, &f3, dst_root, &d2, true) .await } else { api::copy_item(root_id, &f3, dst_root, &d2, true) .await }; match res { Ok(_) => { show( toast, if op == Op::Move { "Moved" } else { "Copied" }, ); refresh.run(()); } Err(e2) => show(toast, &e2.to_string()), } }); }), })); } else { show(toast, &e.to_string()); } } } }); })), })); }) } fn action_download( entry: &Entry, root_id: Option, loc: ReadSignal, set_dialog: WriteSignal>, 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); } }) }) } /// 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, })); }) }) }