Refactor: extract the shared file-view workspace from shell + share page
The signed-in shell and the public share page duplicated the whole file-view plumbing (~110 lines each): the signal cluster, the navigation-clears-selection effect, close_file, the Escape handler, the topbar brand/title/actions, the full-page content, and the toast/dialog overlays. Every file-view feature had to be implemented twice, and the two copies had already forked (the share's is_file guards). FileWorkspace now owns all of that, parameterized by two closures: - close_target: what the file view becomes on close. The app returns None (back to the listing); a file share returns the shared file — which also unifies the forked behavior: with a Some target the close button and Escape are disabled, because there is nowhere to close back to. - show_actions: when the topbar shows the selection actions (app: files section; share page: once active). Shell and share keep only what differs: sidebar + section routing vs. share resolution + states.
Mweb/src/views/file_view.rs
@@ -126,7 +126,9 @@ pub fn FileViewActions(
/// The editor registers its close request here (it asks about unsaved
/// changes first); other views close directly.
close_cb: ReadSignal<Option<Callback<()>>>,
/// False for file shares, where there is nowhere to close back to.
/// Whether the close button is shown at all: closing means "back to
/// the listing", so it is hidden when there is nothing to go back to
/// (file shares).
show_close: bool,
on_close: Callback<()>,
toast: ToastMsg,
Mweb/src/views/mod.rs
@@ -8,3 +8,4 @@ pub mod setup;
pub mod share_page;
pub mod shares;
pub mod shell;
pub mod workspace;
Mweb/src/views/shell.rs
@@ -4,18 +4,16 @@ use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Entry, Me};
use crate::api::{self, Me};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastMsg, ToastView, provide_toast, show_error};
use crate::components::toast::{ToastMsg, provide_toast, show_error};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
use crate::views::admin::{SettingsView, UsersView};
use crate::views::browser::{Browser, SelectionActions, effective_root};
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::browser::{Browser, effective_root};
use crate::views::search::SearchView;
use crate::views::shares::SharesView;
use crate::views::workspace::FileWorkspace;
/// Whether the signed-in user has admin rights.
fn is_admin_of(me: &Option<Me>) -> bool {
@@ -48,63 +46,27 @@ pub fn ShellView(
let toast = provide_toast();
// True while the off-canvas sidebar is open (narrow screens only).
let (nav_open, set_nav_open) = signal(false);
// A file opened in the full-page view (preview / editor / "no preview");
// the top bar doubles as its header.
let (file_view, set_file_view) = signal(Option::<FileView>::None);
// The open editor stores its save callback here for the top-bar button.
let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
// The open editor's guarded close request (unsaved-changes prompt).
let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
// The editor's state, shared with the top bar (dirty dot, Save button).
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::<Callback<()>>::None);
let topbar_ref = NodeRef::<leptos::html::Header>::new();
// The single selected entry in the file browser; its action buttons live
// in the top bar's top right. `None` shows the empty-area actions.
let (selected, set_selected) = signal(Option::<Entry>::None);
// Dialogs are rendered here, above everything (used by both the browser
// and the top bar's selection actions).
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
// Long-lived owner for callbacks created inside short-lived scopes
// (top-bar selection actions).
let shell_owner = Owner::current().unwrap();
// Navigating (new folder, root, section) invalidates the selection.
Effect::new(move |_| {
let _ = loc.get();
set_selected.set(None);
});
let close_file = Callback::new(move |_| {
set_file_view.set(None);
set_save_cb.set(None);
set_close_cb.set(None);
set_dirty.set(false);
set_saving.set(false);
});
// Escape closes previews and the "no preview" view. The editor handles
// Escape itself (it asks first when there are unsaved changes).
{
let fv = file_view;
let cf = close_file;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if fv
.get()
.is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
{
cf.run(());
}
},
);
}
// The shared file-view plumbing (topbar, close/save/dirty, selection,
// dialogs, Escape). Closing returns to the browser listing; the
// selection actions show wherever the browser is the visible section.
let ws = FileWorkspace::new(
me,
loc,
|| None,
move || loc.get().section == Section::Files,
);
let file_view = ws.file_view;
let open_file = ws.open_file;
let selected = ws.selected;
let set_selected = ws.set_selected;
let dialog = ws.dialog;
let set_dialog = ws.set_dialog;
let set_browser_refresh = ws.set_browser_refresh;
let topbar_ref = ws.topbar_ref;
let topbar_left = ws.topbar_left();
let topbar_right = ws.topbar_right();
let file_content = ws.file_content();
let overlays = ws.overlays();
// A non-admin cannot open the admin sections. If the URL points at one
// (deep link, hand-typed, or left over from a demotion), redirect to the
@@ -168,63 +130,11 @@ pub fn ShellView(
>
<Icon name=IconName::Menu class="ic-btn".to_string()/>
</button>
{move || match file_view.get() {
Some(ref v) => view! {
// The brand stays in file mode as an icon-only
// link to the root folder, left of the file title.
<a class="brand brand-icon" href="#/">
<Logo/>
</a>
<FileViewTitle view=v.clone() dirty=dirty/>
}
.into_view()
.into_any(),
None => view! {
<a class="brand" href="#/"><Logo/><span class="brand-name">"filebrowser-ng"</span></a>
}
.into_view()
.into_any(),
}}
{topbar_left}
</div>
<div class="topbar-right">
{topbar_right}
</div>
{move || {
if let Some(v) = file_view.get() {
view! {
<FileViewActions
view=v
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=true
on_close=close_file
toast=toast
/>
}
.into_view()
.into_any()
} else if loc.get().section == Section::Files {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<SelectionActions
me=me
loc=loc
selected=selected
open_file=set_file_view
refresh=refresh
toast=toast
set_dialog=set_dialog
owner=shell_owner.clone()
topbar_ref=topbar_ref
/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</header>
{move || {
if nav_open.get() {
@@ -411,7 +321,7 @@ pub fn ShellView(
me=me
set_me=set_me
loc=loc
open_file=set_file_view
open_file=open_file
set_browser_refresh=set_browser_refresh
selected=selected
set_selected=set_selected
@@ -425,7 +335,7 @@ pub fn ShellView(
Section::Search => view! {
<SearchView
me=me
open_file=set_file_view
open_file=open_file
file_view=file_view
/>
}
@@ -449,7 +359,7 @@ pub fn ShellView(
me=me
set_me=set_me
loc=loc
open_file=set_file_view
open_file=open_file
set_browser_refresh=set_browser_refresh
selected=selected
set_selected=set_selected
@@ -462,33 +372,10 @@ pub fn ShellView(
.into_any(),
}
}}
{move || match file_view.get() {
Some(v) => {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<FileViewContent
view=v
register_save=set_save_cb
register_close=set_close_cb
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close_file
toast=toast
refresh=refresh
/>
}
.into_view()
.into_any()
}
None => view! {}.into_any(),
}}
{file_content}
</main>
</div>
<ToastView toast=toast/>
<DialogView dialog=dialog set_dialog=set_dialog/>
{overlays}
</div>
}
}
Aweb/src/views/workspace.rs
@@ -0,0 +1,308 @@
//! 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::Location;
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};
/// 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<Option<FileView>>,
/// Open a file in the full-page view (hand to the browser / search).
pub open_file: WriteSignal<Option<FileView>>,
pub selected: ReadSignal<Option<Entry>>,
pub set_selected: WriteSignal<Option<Entry>>,
pub dialog: ReadSignal<Option<Dialog>>,
pub set_dialog: WriteSignal<Option<Dialog>>,
/// The browser stores its fetch there so the editor can re-list after a
/// save.
pub set_browser_refresh: WriteSignal<Option<Callback<()>>>,
pub topbar_ref: NodeRef<Header>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
toast: ToastMsg,
owner: Owner,
save_cb: ReadSignal<Option<Callback<()>>>,
set_save_cb: WriteSignal<Option<Callback<()>>>,
close_cb: ReadSignal<Option<Callback<()>>>,
set_close_cb: WriteSignal<Option<Callback<()>>>,
dirty: ReadSignal<bool>,
set_dirty: WriteSignal<bool>,
saving: ReadSignal<bool>,
set_saving: WriteSignal<bool>,
browser_refresh: ReadSignal<Option<Callback<()>>>,
close_file: Callback<()>,
/// What the file view becomes on close (see the parameter of the same
/// name in [`FileWorkspace::new`]).
close_target: Arc<dyn Fn() -> Option<FileView> + 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<dyn Fn() -> 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<Option<Me>>,
loc: ReadSignal<Location>,
close_target: impl Fn() -> Option<FileView> + 'static + Send + Sync,
show_actions: impl Fn() -> bool + 'static + Send + Sync,
) -> Self {
let toast = use_context::<ToastMsg>().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::<FileView>::None);
// The open editor stores its save callback here for the top-bar button.
let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
// The open editor's guarded close request (unsaved-changes prompt).
let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::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::<Callback<()>>::None);
// The single selected entry in the file browser; its action buttons
// live in the top bar's top right.
let (selected, set_selected) = signal(Option::<Entry>::None);
// Dialogs are rendered here, above everything.
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
let topbar_ref = NodeRef::<Header>::new();
// Navigating (new folder, root, section) invalidates the selection.
Effect::new(move |_| {
let _ = loc.get();
set_selected.set(None);
});
let close_file = {
let ct = close_target.clone();
Callback::new(move |_| {
set_save_cb.set(None);
set_close_cb.set(None);
set_dirty.set(false);
set_saving.set(false);
set_file_view.set(ct());
})
};
// 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 fv = file_view;
let cf = close_file;
let ct = close_target.clone();
owned_window_listener(keydown, move |ev: KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if ct().is_some() {
return;
}
if fv
.get()
.is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
{
cf.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;
view! {
{move || match file_view.get() {
Some(ref v) => view! {
<a class="brand brand-icon" href="#/">
<Logo />
</a>
<FileViewTitle view=v.clone() dirty=dirty />
}
.into_view()
.into_any(),
None => view! {
<a class="brand" href="#/">
<Logo />
<span class="brand-name">"filebrowser-ng"</span>
</a>
}
.into_view()
.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 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! {
<FileViewActions
view=v
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=close_target().is_none()
on_close=close_file
toast=toast
/>
}
.into_view()
.into_any()
} else if show_actions() {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<SelectionActions
me=me
loc=loc
selected=selected
open_file=open_file
refresh=refresh
toast=toast
set_dialog=set_dialog
owner=owner.clone()
topbar_ref=topbar_ref
/>
}
.into_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 || match file_view.get() {
Some(v) => {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<FileViewContent
view=v
register_save=set_save_cb
register_close=set_close_cb
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close_file
toast=toast
refresh=refresh
/>
}
.into_view()
.into_any()
}
None => view! {}.into_any(),
}}
}
}
/// 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! {
<ToastView toast=toast />
<DialogView dialog=dialog set_dialog=set_dialog />
}
}
}