//! 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::editor::Editor;
use crate::preview::Preview;
use crate::router::{self, Location, Section};
use crate::uploads;
use crate::util::owned_window_listener;
use crate::views::browser::{Acts, SelectionActions};
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, 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! {
"dovenest"
}
.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 |_| {}));
let acts = Acts {
me,
loc,
open_file,
refresh,
toast,
dialog,
set_dialog,
owner: owner.clone(),
};
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 |_| {}));
let content = if v.kind == api_types::FileKind::Text {
view! {
}
.into_any()
} else {
view! { }.into_any()
};
view! { {content}
}
})}
}
}
/// 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())
}