//! Modal dialogs: text prompts (new folder / rename), confirmations (delete), //! overwrite conflict warnings, and the move/copy destination picker. use std::sync::Arc; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{self, Entry, RootInfo}; use crate::components::icon::Icon; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n; use crate::icons::IconName; /// Which mutating operation the destination picker is for. #[derive(Clone, Copy, PartialEq)] pub enum Op { Move, Copy, } impl Op { /// The destination picker's title for `n` items: "Move “{}” to…" for /// one (the name goes in), "Move {} items to…" for more (the count). pub fn title_key(self, n: usize) -> &'static str { use crate::i18n::k; match (self, n) { (Op::Move, 1) => k::MOVE_TO, (Op::Copy, 1) => k::COPY_TO, (Op::Move, _) => k::MOVE_N_TO, (Op::Copy, _) => k::COPY_N_TO, } } /// The translated "… here" confirm button label. pub fn here_key(self) -> &'static str { match self { Op::Move => crate::i18n::k::MOVE_HERE, Op::Copy => crate::i18n::k::COPY_HERE, } } } /// The single active dialog. The browser renders whichever is `Some`. #[derive(Clone)] pub enum Dialog { /// Text input (new folder, new file, rename). Submit runs an async /// operation; failures are shown inside the dialog, which stays open, /// and on success it closes. PromptOp { title: String, label: String, initial: String, submit: String, /// Runs the operation for the submitted (trimmed, non-empty) name. /// Returns the localized error message on failure. run: Callback, /// Runs after a successful operation (toast + refresh); the dialog /// closes regardless. on_ok: Callback<()>, }, /// Simple confirmation (delete). Confirm { title: String, message: String, submit: String, danger: bool, on_submit: Callback<()>, }, /// Overwrite warning listing the conflicting files. Conflict { title: String, files: Vec, on_submit: Callback<()>, }, /// Upload pre-check found existing files. Overwrite or skip them, and /// optionally make that the rule for files that appear mid-transfer. UploadConflict { files: Vec, /// `(overwrite, apply_to_all)`. /// `None` when the user cancelled (button, Escape, backdrop). on_decide: Callback>, }, /// Folder picker: the move/copy destination, or where a search starts. Picker { title: String, /// The confirm button label ("Move here", "Search here"). confirm: String, roots: Vec, /// The folder the picker opens in. root: i64, dir: String, /// The picked folder is written to (move/copy destination): only a /// writable root can be confirmed, and not the folder the picker /// opened in (a move or copy onto itself is a no-op). for_write: bool, on_pick: Callback<(i64, String)>, }, /// Folder download — format choice (zip | tar | tar.gz | tar.zst). DownloadFormat { name: String, root_id: i64, path: String, }, /// Read-only item info. Info { entry: Entry, root_name: String, rel_path: String, }, /// Create a share link for an item. Share { name: String, root_id: i64, path: String, /// The server's global "allow writable shares" setting. allow_writable: bool, /// Whether the item's root is writable — a writable share is /// impossible from a read-only root, so the checkbox hides. root_writable: bool, }, } /// The future returned by [`Dialog::PromptOp`]'s `run` callback. pub type PromptOpFuture = std::pin::Pin>>>; /// Split a "/"-separated directory path into its non-empty segments. pub(crate) fn dir_segs(dir: &str) -> Vec { dir.split('/') .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() } /// Wait until the shell's one dialog slot is free. A background task /// (upload or move conflict) must not replace a dialog that is showing, /// whether another task or the user opened it. /// /// ponytail: 200 ms poll, wake channel if latency matters. pub async fn claim_async_dialog(dialog: ReadSignal>) { while dialog.get_untracked().is_some() { gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await; } } #[component] pub fn DialogView( dialog: ReadSignal>, set_dialog: WriteSignal>, ) -> impl IntoView { // Escape is the 's own; see `Modal`. move || { let d = dialog.get()?; let close = Callback::new(move |_| set_dialog.set(None)); Some(match d { Dialog::PromptOp { title, label, initial, submit, run, on_ok, } => view! { } .into_any(), Dialog::Confirm { title, message, submit, danger, on_submit, } => view! { } .into_any(), Dialog::UploadConflict { files, on_decide } => view! { } .into_any(), Dialog::Conflict { title, files, on_submit, } => view! { } .into_any(), Dialog::Picker { title, confirm, roots, root, dir, for_write, on_pick, } => view! { } .into_any(), Dialog::DownloadFormat { name, root_id, path, } => view! { } .into_any(), Dialog::Info { entry, root_name, rel_path, } => view! { } .into_any(), Dialog::Share { name, root_id, path, allow_writable, root_writable, } => view! { } .into_any(), }) } } // --------------------------------------------------------------------------- // Prompt (text input running an async operation) // --------------------------------------------------------------------------- #[component] fn PromptOpDialog( title: String, label: String, initial: String, submit: String, run: Callback, on_ok: Callback<()>, on_close: Callback<()>, ) -> impl IntoView { let input_id = "fbng-prompt-op-input"; let (error, set_error) = signal(Option::::None); let (busy, set_busy) = signal(false); // The closure captures only Copy values, so the Enter-key and button // handlers can share it directly. let submit_fn = move || { let name = api::input_value(input_id).trim().to_string(); if name.is_empty() || busy.get() { return; } set_busy.set(true); set_error.set(None); let fut = run.run(name); spawn_local(async move { match fut.await { Ok(()) => { on_ok.run(()); on_close.run(()); } Err(msg) => { set_error.set(Some(msg)); set_busy.set(false); } } }); }; view! { {move || error.get().map(|err| view! {

{err}

})}
} } // --------------------------------------------------------------------------- // Confirm and conflicts // --------------------------------------------------------------------------- /// A yes/no question. `files` lists the affected names under the message. #[component] pub fn ConfirmDialog( title: String, message: String, submit: String, danger: bool, #[prop(optional)] files: Vec, /// The dismiss button label. Defaults to "Cancel". #[prop(optional)] cancel: Option, on_submit: Callback<()>, on_close: Callback<()>, ) -> impl IntoView { let cancel = cancel.unwrap_or_else(|| i18n::t(i18n::k::CANCEL).to_string()); view! { {(!files.is_empty()).then(|| conflict_list(&files))} } } /// The list of colliding files shown by both conflict dialogs. Long lists /// are cut off with an "and N more" line. fn conflict_list(files: &[String]) -> impl IntoView + use<> { let shown: Vec = files.iter().take(20).cloned().collect(); let extra = files.len() - shown.len(); view! {
    {shown.into_iter().map(|f| view! {
  • {f}
  • }).collect::>()} 0 }>
  • {move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}
} } /// The upload pre-check dialog: Overwrite, Skip, Cancel, and "apply to all" /// (on by default) for files that appear during the transfer. #[component] fn UploadConflictDialog( files: Vec, on_decide: Callback>, on_close: Callback<()>, ) -> impl IntoView { let (apply_all, set_apply_all) = signal(true); // The close event fires after a decision too; answer once. let answered = StoredValue::new(false); let decide = move |overwrite: bool| { answered.set_value(true); on_decide.run(Some((overwrite, apply_all.get_untracked()))); on_close.run(()); }; let on_close = Callback::new(move |()| { if !answered.get_value() { answered.set_value(true); on_decide.run(None); } on_close.run(()); }); view! { {conflict_list(&files)} } } // --------------------------------------------------------------------------- // Picker (folder chooser) // --------------------------------------------------------------------------- /// The one entry of the admin picker's root pane: the server root, which /// every admin can browse read-only under [`api_types::ADMIN_ROOT`]. pub fn admin_root() -> RootInfo { RootInfo { id: api_types::ADMIN_ROOT, name: i18n::t(i18n::k::SERVER_ROOT).to_string(), path: ".".to_string(), mode: api_types::Mode::Rw, } } /// Two panes: the roots on the left, the folder tree of the selected root /// on the right (breadcrumbs + subfolders). Clicking a subfolder descends; /// the breadcrumbs go back up and name the chosen folder. #[component] fn PickerDialog( title: String, confirm: String, roots: Vec, root: i64, dir: String, for_write: bool, on_pick: Callback<(i64, String)>, on_close: Callback<()>, ) -> impl IntoView { let (sel_root, set_sel_root) = signal(root); let (sel_dir, set_sel_dir) = signal(dir.clone()); let (entries, set_entries) = signal(Vec::::new()); let (loading, set_loading) = signal(true); let toast = use_context::().expect("toast context"); // Load the subfolder names of the selected directory whenever it changes. // Bumped per load, so a slow response for a folder the user has already // left is dropped instead of replacing the current one. let load_gen = StoredValue::new(0u64); Effect::new(move |_| { let rid = sel_root.get(); let dir = sel_dir.get(); set_loading.set(true); load_gen.update_value(|g| *g += 1); let my_gen = load_gen.get_value(); spawn_local(async move { let resp = api::list_dirs(rid, &dir).await; if load_gen.try_get_value() != Some(my_gen) { return; } let subs = match resp { Ok(r) => r.entries.into_iter().map(|e| e.name).collect(), Err(e) => { show_error(toast, e.to_string()); Vec::new() } }; set_entries.set(subs); set_loading.set(false); }); }); let by_id = Arc::new(roots.clone()); let root_name = { let by_id = by_id.clone(); move |rid: i64| { by_id .iter() .find(|r| r.id == rid) .map(|r| r.name.clone()) .unwrap_or_default() } }; let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable()); let descend = move |name: String| { let cur = sel_dir.get(); set_sel_dir.set(if cur.is_empty() { name } else { format!("{cur}/{name}") }); }; view! {
{roots.iter().map(|r| { let rid = r.id; let ro = !r.mode.is_writable(); let name = r.name.clone(); view! { } }).collect::>()}
{move || root_name(sel_root.get())} {move || { let segs = dir_segs(&sel_dir.get()); segs .iter() .enumerate() .map(|(i, seg)| { let seg = seg.clone(); let prefix: String = segs[..=i].join("/"); let is_last = i == segs.len() - 1; view! { "/" {seg} } }) .collect::>() }}
{move || { let es = entries.get(); if loading.get() { return view! {

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

} .into_any(); } if es.is_empty() { return view! {
{i18n::tr(i18n::k::NO_SUBFOLDERS)}
} .into_any(); } es.iter() .map(|name| { let name = name.clone(); let name2 = name.clone(); view! {
{name}
} }) .collect::>() .into_any() }}
} } // --------------------------------------------------------------------------- // Download format (folder archive choice) // --------------------------------------------------------------------------- #[component] fn DownloadFormatDialog( name: String, root_id: i64, path: String, on_close: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let name_title = name.clone(); let formats: [(&str, &str, &str); 4] = [ ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"), ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"), ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"), ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"), ]; view! {
{formats.iter().map(|(id, label, ext)| { let (id, label, ext) = (*id, *label, *ext); let path = path.clone(); let name = name.clone(); view! { } }).collect::>()}
} } // --------------------------------------------------------------------------- // Item info // --------------------------------------------------------------------------- #[component] fn InfoDialog( entry: Entry, root_name: String, rel_path: String, on_close: Callback<()>, ) -> impl IntoView { // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt"). let is_dir = entry.is_dir; let size = if is_dir { "—".to_string() } else { crate::util::format_size(entry.size) }; let modified = crate::util::format_date(&entry.mtime); let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); let location = if parent.is_empty() { format!("{root_name}/") } else { format!("{root_name}/{parent}") }; view! {
{i18n::tr(i18n::k::NAME)}
{entry.name.clone()}
{i18n::tr(i18n::k::TYPE)}
{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}
{i18n::tr(i18n::k::SIZE)}
{size}
{i18n::tr(i18n::k::MODIFIED)}
{modified}
{i18n::tr(i18n::k::LOCATION)}
{location}
} }