//! The file-view workspace: everything the signed-in shell and the public /// share page share around the full-page file view. /// /// Both pages open files in the same topbar-headed view with the same /// close/save/dirty plumbing, the same selection + dialog state, and the /// same Escape semantics. [`FileWorkspace`] owns all of that; the pages /// keep only what differs (sidebar + section routing vs. share resolution /// + states) and pull the exposed handles into their browser. use std::sync::Arc; use leptos::ev::keydown; use leptos::html::Header; use leptos::prelude::*; use web_sys::KeyboardEvent; use crate::api::{Entry, Me}; use crate::components::logo::Logo; use crate::components::toast::{ToastMsg, ToastView}; use crate::router::{self, Location, Section}; use crate::uploads; use crate::util::owned_window_listener; use crate::views::browser::SelectionActions; use crate::views::dialogs::{Dialog, DialogView}; use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle}; use crate::views::uploads::UploadPanel; /// The shared state and behavior around the full-page file view. /// /// Create one per page in the page component body, after `provide_toast`: /// the signals, the navigation effect and the Escape listener are owned by /// the page, so everything is cleaned up when the page unmounts. pub struct FileWorkspace { pub file_view: ReadSignal>, /// Open a file in the full-page view (hand to the browser / search). pub open_file: WriteSignal>, pub selected: ReadSignal>, pub set_selected: WriteSignal>, pub dialog: ReadSignal>, pub set_dialog: WriteSignal>, /// The browser stores its fetch there so the editor can re-list after a /// save. pub set_browser_refresh: WriteSignal>>, pub topbar_ref: NodeRef
, me: ReadSignal>, loc: ReadSignal, toast: ToastMsg, owner: Owner, save_cb: ReadSignal>>, set_save_cb: WriteSignal>>, close_cb: ReadSignal>>, set_close_cb: WriteSignal>>, dirty: ReadSignal, set_dirty: WriteSignal, saving: ReadSignal, set_saving: WriteSignal, browser_refresh: ReadSignal>>, close_file: Callback<()>, /// What the file view becomes on close (see the parameter of the same /// name in [`FileWorkspace::new`]). close_target: Arc Option + Send + Sync>, /// Whether the topbar shows the selection actions while no file is /// open (i.e. the page's browser is the visible content). show_actions: Arc bool + Send + Sync>, } impl FileWorkspace { /// `close_target` is what the file view becomes on close: `None` = /// back to the listing (signed-in app); `Some` = the same file is /// shown again (a file share has nothing to close back to). With a /// `Some` target the close button and Escape are disabled. /// /// `show_actions` decides whether the topbar shows the selection /// actions while no file is open (i.e. the page's browser is the /// visible content). pub fn new( me: ReadSignal>, loc: ReadSignal, close_target: impl Fn() -> Option + 'static + Send + Sync, show_actions: impl Fn() -> bool + 'static + Send + Sync, ) -> Self { let toast = use_context::().expect("toast context"); let owner = Owner::current().unwrap(); let close_target = Arc::new(close_target); let show_actions = Arc::new(show_actions); let (file_view, set_file_view) = signal(Option::::None); // The open editor stores its save callback here for the top-bar button. let (save_cb, set_save_cb) = signal(Option::>::None); // The open editor's guarded close request (unsaved-changes prompt). let (close_cb, set_close_cb) = signal(Option::>::None); let (dirty, set_dirty) = signal(false); let (saving, set_saving) = signal(false); // The browser's fetch, exposed so the editor can re-list after a save. let (browser_refresh, set_browser_refresh) = signal(Option::>::None); // The selected entries in the file browser; their action buttons // live in the top bar's top right. let (selected, set_selected) = signal(Vec::::new()); // Dialogs are rendered here, above everything. let (dialog, set_dialog) = signal(Option::::None); let topbar_ref = NodeRef::
::new(); // Navigating (new folder, root, section) invalidates the selection // and the dialog, which act on it. A page turn or an open file keeps // them. An upload's conflict question is not about the folder shown. let folder = Memo::new(move |_| loc.with(Location::folder_key)); Effect::new(move |_| { folder.with(|_| ()); set_selected.set(Vec::new()); set_dialog.update(|d| { if !matches!(d, Some(Dialog::UploadConflict { .. })) { *d = None; } }); }); // A finished upload re-lists the folder it went into, if that is // the one on screen. // The closure captures this page's signals: it must go when the // page does, or a job finishing later reads disposed signals. let hook = uploads::set_on_done(move |root_id, dir| { let here = loc.with_untracked(|l| { me.with_untracked(|m| { m.as_ref() .and_then(|m| crate::views::browser::effective_root(&m.roots, l)) .is_some_and(|r| r.id == root_id) && l.path.join("/") == dir }) }); if here && let Some(f) = browser_refresh.get_untracked() { f.run(()); } }); on_cleanup(move || uploads::clear_on_done(hook)); // Leaving the page kills every running upload: let the browser ask. owned_window_listener( leptos::ev::beforeunload, move |ev: web_sys::BeforeUnloadEvent| { if uploads::any_active() { ev.prevent_default(); ev.set_return_value(""); } }, ); let close_now = { let ct = close_target.clone(); move || { set_save_cb.set(None); set_close_cb.set(None); set_dirty.set(false); set_saving.set(false); set_file_view.set(ct()); } }; // The URL holds the open file, so closing edits the URL. The effect // below then closes the view. let close_file = { let close_now = close_now.clone(); Callback::new(move |_| { if loc.with_untracked(|l| l.open.is_none()) { close_now(); return; } set_dirty.set(false); router::close_open(); }) }; // A file shown here goes into the URL. Every file view shares one // history entry: a switch to the next file replaces it. { let ct = close_target.clone(); Effect::new(move |_| { let param = file_view.with(|v| { v.as_ref() .and_then(|v| loc.with_untracked(|l| open_param(l, v))) }); let Some(p) = param else { return; }; if ct().is_none() && loc.with_untracked(|l| l.open.as_ref() != Some(&p)) { router::set_open(&p); } }); } // A URL without a file (Back, a link, a new folder) closes the view. // Back cannot be cancelled, so with unsaved changes the file's entry // comes back and the editor asks first. { let ct = close_target.clone(); let open_url = StoredValue::new(None::); Effect::new(move |_| { if loc.with(|l| l.open.is_some()) { open_url.set_value(Some(router::current_hash())); return; } if ct().is_some() || file_view.with_untracked(Option::is_none) { return; } if dirty.get_untracked() && let (Some(url), Some(ask)) = (open_url.get_value(), close_cb.get_untracked()) { router::push_open_url(&url); ask.run(()); return; } close_now(); // Back lands on the page the first file was opened from. The // arrow keys may have moved on since: show the last file's page. let Some(was) = open_url.get_value().map(|u| router::parse_hash(&u)) else { return; }; let now = loc.get_untracked(); if router::at_open_origin() && was.folder_key() == now.folder_key() && (was.page, was.size, was.sort) != (now.page, now.size, now.sort) { router::replace(&Location { open: None, ..was }); } }); } // Escape closes previews and the "no preview" view — except when // there is nothing to close back to (file share). The editor handles // Escape itself (it asks first when there are unsaved changes). { let ct = close_target.clone(); owned_window_listener(keydown, move |ev: KeyboardEvent| { if ev.key() != "Escape" || ct().is_some() { return; } if file_view .get() .is_some_and(|v| v.kind != api_types::FileKind::Text) { close_file.run(()); } }); } Self { file_view, open_file: set_file_view, selected, set_selected, dialog, set_dialog, set_browser_refresh, topbar_ref, me, loc, toast, owner, save_cb, set_save_cb, close_cb, set_close_cb, dirty, set_dirty, saving, set_saving, browser_refresh, close_file, close_target, show_actions, } } /// Topbar left: the brand. In file mode the brand shrinks to its icon /// (a link to `/`) and the file title takes its place. pub fn topbar_left(&self) -> impl IntoView + 'static + use<> { let file_view = self.file_view; let dirty = self.dirty; let me = self.me; // The home link opens the profile's default root, else the overview. // A share page's synthetic `me` has no default, so it stays `#/`. let home = move || { let root = me.with(|m| { m.as_ref() .and_then(|m| m.user.as_ref()) .and_then(|u| u.default_root_id) }); match root { Some(id) => { crate::router::location_to_hash(&Location::folder(Some(id), None, vec![])) } None => "#/".to_string(), } }; view! { {move || match file_view.get() { Some(ref v) => view! { } .into_any(), None => view! { "filebrowser-ng" } .into_any(), }} } } /// Topbar right: the open file's actions (download / save / close) or, /// while no file is open, the selected entry's actions. pub fn topbar_right(&self) -> impl IntoView + 'static + use<> { let file_view = self.file_view; let show_actions = self.show_actions.clone(); let close_target = self.close_target.clone(); let save_cb = self.save_cb; let dirty = self.dirty; let saving = self.saving; let close_cb = self.close_cb; let close_file = self.close_file; let me = self.me; let loc = self.loc; let selected = self.selected; let open_file = self.open_file; let browser_refresh = self.browser_refresh; let toast = self.toast; let dialog = self.dialog; let set_dialog = self.set_dialog; let owner = self.owner.clone(); let topbar_ref = self.topbar_ref; view! { {move || { if let Some(v) = file_view.get() { view! { } .into_any() } else if show_actions() { let refresh = browser_refresh .get() .unwrap_or_else(|| Callback::new(move |_| {})); view! { } .into_any() } else { view! {}.into_any() } }} } } /// The full-page content (preview / editor / "no preview"), rendered /// while a file is open. pub fn file_content(&self) -> impl IntoView + 'static + use<> { let file_view = self.file_view; let set_save_cb = self.set_save_cb; let set_close_cb = self.set_close_cb; let dirty = self.dirty; let set_dirty = self.set_dirty; let set_saving = self.set_saving; let close_file = self.close_file; let toast = self.toast; let browser_refresh = self.browser_refresh; view! { {move || file_view.get().map(|v| { let refresh = browser_refresh .get() .unwrap_or_else(|| Callback::new(move |_| {})); view! { } })} } } /// The page-level overlays: toasts + dialogs, above everything. pub fn overlays(&self) -> impl IntoView + 'static + use<> { let toast = self.toast; let dialog = self.dialog; let set_dialog = self.set_dialog; view! { } } } /// How the URL names the open file `v`: by name in the listed folder, or /// on the search view by its path in the root. `None` for a file outside /// the folder, which the URL cannot name. fn open_param(loc: &Location, v: &FileView) -> Option { let path = v.path.as_str(); if loc.section == Section::Search { return Some(path.to_string()); } let dir = loc.path.join("/"); let name = if dir.is_empty() { path } else { path.strip_prefix(&dir)?.strip_prefix('/')? }; (!name.contains('/')).then(|| name.to_string()) }