workspace.rs
⎇
Raw
1//! The file-view workspace: everything the signed-in shell and the public
2/// share page share around the full-page file view.
3///
4/// Both pages open files in the same topbar-headed view with the same
5/// close/save/dirty plumbing, the same selection + dialog state, and the
6/// same Escape semantics. [`FileWorkspace`] owns all of that; the pages
7/// keep only what differs (sidebar + section routing vs. share resolution
8/// + states) and pull the exposed handles into their browser.
9use std::sync::Arc;
10
11use leptos::ev::keydown;
12use leptos::html::Header;
13use leptos::prelude::*;
14use web_sys::KeyboardEvent;
15
16use crate::api::{Entry, Me};
17use crate::components::logo::Logo;
18use crate::components::toast::{ToastMsg, ToastView};
19use crate::router::Location;
20use crate::uploads;
21use crate::util::owned_window_listener;
22use crate::views::browser::SelectionActions;
23use crate::views::dialogs::{Dialog, DialogView};
24use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
25use crate::views::uploads::UploadPanel;
26
27/// The shared state and behavior around the full-page file view.
28///
29/// Create one per page in the page component body, after `provide_toast`:
30/// the signals, the navigation effect and the Escape listener are owned by
31/// the page, so everything is cleaned up when the page unmounts.
32pub struct FileWorkspace {
33 pub file_view: ReadSignal<Option<FileView>>,
34 /// Open a file in the full-page view (hand to the browser / search).
35 pub open_file: WriteSignal<Option<FileView>>,
36 pub selected: ReadSignal<Vec<Entry>>,
37 pub set_selected: WriteSignal<Vec<Entry>>,
38 pub dialog: ReadSignal<Option<Dialog>>,
39 pub set_dialog: WriteSignal<Option<Dialog>>,
40 /// The browser stores its fetch there so the editor can re-list after a
41 /// save.
42 pub set_browser_refresh: WriteSignal<Option<Callback<()>>>,
43 pub topbar_ref: NodeRef<Header>,
44
45 me: ReadSignal<Option<Me>>,
46 loc: ReadSignal<Location>,
47 toast: ToastMsg,
48 owner: Owner,
49 save_cb: ReadSignal<Option<Callback<()>>>,
50 set_save_cb: WriteSignal<Option<Callback<()>>>,
51 close_cb: ReadSignal<Option<Callback<()>>>,
52 set_close_cb: WriteSignal<Option<Callback<()>>>,
53 dirty: ReadSignal<bool>,
54 set_dirty: WriteSignal<bool>,
55 saving: ReadSignal<bool>,
56 set_saving: WriteSignal<bool>,
57 browser_refresh: ReadSignal<Option<Callback<()>>>,
58 close_file: Callback<()>,
59 /// What the file view becomes on close (see the parameter of the same
60 /// name in [`FileWorkspace::new`]).
61 close_target: Arc<dyn Fn() -> Option<FileView> + Send + Sync>,
62 /// Whether the topbar shows the selection actions while no file is
63 /// open (i.e. the page's browser is the visible content).
64 show_actions: Arc<dyn Fn() -> bool + Send + Sync>,
65}
66
67impl FileWorkspace {
68 /// `close_target` is what the file view becomes on close: `None` =
69 /// back to the listing (signed-in app); `Some` = the same file is
70 /// shown again (a file share has nothing to close back to). With a
71 /// `Some` target the close button and Escape are disabled.
72 ///
73 /// `show_actions` decides whether the topbar shows the selection
74 /// actions while no file is open (i.e. the page's browser is the
75 /// visible content).
76 pub fn new(
77 me: ReadSignal<Option<Me>>,
78 loc: ReadSignal<Location>,
79 close_target: impl Fn() -> Option<FileView> + 'static + Send + Sync,
80 show_actions: impl Fn() -> bool + 'static + Send + Sync,
81 ) -> Self {
82 let toast = use_context::<ToastMsg>().expect("toast context");
83 let owner = Owner::current().unwrap();
84 let close_target = Arc::new(close_target);
85 let show_actions = Arc::new(show_actions);
86
87 let (file_view, set_file_view) = signal(Option::<FileView>::None);
88 // The open editor stores its save callback here for the top-bar button.
89 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
90 // The open editor's guarded close request (unsaved-changes prompt).
91 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
92 let (dirty, set_dirty) = signal(false);
93 let (saving, set_saving) = signal(false);
94 // The browser's fetch, exposed so the editor can re-list after a save.
95 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
96 // The selected entries in the file browser; their action buttons
97 // live in the top bar's top right.
98 let (selected, set_selected) = signal(Vec::<Entry>::new());
99 // Dialogs are rendered here, above everything.
100 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
101 let topbar_ref = NodeRef::<Header>::new();
102
103 // Navigating (new folder, root, section) invalidates the selection.
104 Effect::new(move |_| {
105 let _ = loc.get();
106 set_selected.set(Vec::new());
107 });
108
109 // A finished upload re-lists the folder it went into, if that is
110 // the one on screen.
111 // The closure captures this page's signals: it must go when the
112 // page does, or a job finishing later reads disposed signals.
113 let hook = uploads::set_on_done(move |root_id, dir| {
114 let here = loc.with_untracked(|l| {
115 me.with_untracked(|m| {
116 m.as_ref()
117 .and_then(|m| crate::views::browser::effective_root(&m.roots, l))
118 .is_some_and(|r| r.id == root_id)
119 && l.path.join("/") == dir
120 })
121 });
122 if here && let Some(f) = browser_refresh.get_untracked() {
123 f.run(());
124 }
125 });
126 on_cleanup(move || uploads::clear_on_done(hook));
127 // Leaving the page kills every running upload: let the browser ask.
128 owned_window_listener(
129 leptos::ev::beforeunload,
130 move |ev: web_sys::BeforeUnloadEvent| {
131 if uploads::any_active() {
132 ev.prevent_default();
133 ev.set_return_value("");
134 }
135 },
136 );
137
138 let close_file = {
139 let ct = close_target.clone();
140 Callback::new(move |_| {
141 set_save_cb.set(None);
142 set_close_cb.set(None);
143 set_dirty.set(false);
144 set_saving.set(false);
145 set_file_view.set(ct());
146 })
147 };
148
149 // Escape closes previews and the "no preview" view — except when
150 // there is nothing to close back to (file share). The editor handles
151 // Escape itself (it asks first when there are unsaved changes).
152 {
153 let fv = file_view;
154 let cf = close_file;
155 let ct = close_target.clone();
156 owned_window_listener(keydown, move |ev: KeyboardEvent| {
157 if ev.key() != "Escape" {
158 return;
159 }
160 if ct().is_some() {
161 return;
162 }
163 if fv
164 .get()
165 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
166 {
167 cf.run(());
168 }
169 });
170 }
171
172 Self {
173 file_view,
174 open_file: set_file_view,
175 selected,
176 set_selected,
177 dialog,
178 set_dialog,
179 set_browser_refresh,
180 topbar_ref,
181 me,
182 loc,
183 toast,
184 owner,
185 save_cb,
186 set_save_cb,
187 close_cb,
188 set_close_cb,
189 dirty,
190 set_dirty,
191 saving,
192 set_saving,
193 browser_refresh,
194 close_file,
195 close_target,
196 show_actions,
197 }
198 }
199
200 /// Topbar left: the brand. In file mode the brand shrinks to its icon
201 /// (a link to `/`) and the file title takes its place.
202 pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
203 let file_view = self.file_view;
204 let dirty = self.dirty;
205 let me = self.me;
206 // The home link opens the profile's default root, else the overview.
207 // A share page's synthetic `me` has no default, so it stays `#/`.
208 let home = move || {
209 let root = me.with(|m| {
210 m.as_ref()
211 .and_then(|m| m.user.as_ref())
212 .and_then(|u| u.default_root_id)
213 });
214 match root {
215 Some(id) => crate::router::location_to_hash(&Location {
216 root_id: Some(id),
217 ..Location::root()
218 }),
219 None => "#/".to_string(),
220 }
221 };
222 view! {
223 {move || match file_view.get() {
224 Some(ref v) => view! {
225 <a class="brand brand-icon" href=home>
226 <Logo />
227 </a>
228 <FileViewTitle view=v.clone() dirty=dirty />
229 }
230 .into_view()
231 .into_any(),
232 None => view! {
233 <a class="brand" href=home>
234 <Logo />
235 <span class="brand-name">"filebrowser-ng"</span>
236 </a>
237 }
238 .into_view()
239 .into_any(),
240 }}
241 }
242 }
243
244 /// Topbar right: the open file's actions (download / save / close) or,
245 /// while no file is open, the selected entry's actions.
246 pub fn topbar_right(&self) -> impl IntoView + 'static + use<> {
247 let file_view = self.file_view;
248 let show_actions = self.show_actions.clone();
249 let close_target = self.close_target.clone();
250 let save_cb = self.save_cb;
251 let dirty = self.dirty;
252 let saving = self.saving;
253 let close_cb = self.close_cb;
254 let close_file = self.close_file;
255 let me = self.me;
256 let loc = self.loc;
257 let selected = self.selected;
258 let open_file = self.open_file;
259 let browser_refresh = self.browser_refresh;
260 let toast = self.toast;
261 let dialog = self.dialog;
262 let set_dialog = self.set_dialog;
263 let owner = self.owner.clone();
264 let topbar_ref = self.topbar_ref;
265 view! {
266 {move || {
267 if let Some(v) = file_view.get() {
268 view! {
269 <FileViewActions
270 view=v
271 saving=saving
272 dirty=dirty
273 save_cb=save_cb
274 close_cb=close_cb
275 show_close=close_target().is_none()
276 on_close=close_file
277 toast=toast
278 />
279 }
280 .into_view()
281 .into_any()
282 } else if show_actions() {
283 let refresh = browser_refresh
284 .get()
285 .unwrap_or_else(|| Callback::new(move |_| {}));
286 view! {
287 <SelectionActions
288 me=me
289 loc=loc
290 selected=selected
291 open_file=open_file
292 refresh=refresh
293 toast=toast
294 dialog=dialog
295 set_dialog=set_dialog
296 owner=owner.clone()
297 topbar_ref=topbar_ref
298 />
299 }
300 .into_view()
301 .into_any()
302 } else {
303 view! {}.into_any()
304 }
305 }}
306 }
307 }
308
309 /// The full-page content (preview / editor / "no preview"), rendered
310 /// while a file is open.
311 pub fn file_content(&self) -> impl IntoView + 'static + use<> {
312 let file_view = self.file_view;
313 let set_save_cb = self.set_save_cb;
314 let set_close_cb = self.set_close_cb;
315 let dirty = self.dirty;
316 let set_dirty = self.set_dirty;
317 let set_saving = self.set_saving;
318 let close_file = self.close_file;
319 let toast = self.toast;
320 let browser_refresh = self.browser_refresh;
321 view! {
322 {move || match file_view.get() {
323 Some(v) => {
324 let refresh = browser_refresh
325 .get()
326 .unwrap_or_else(|| Callback::new(move |_| {}));
327 view! {
328 <FileViewContent
329 view=v
330 register_save=set_save_cb
331 register_close=set_close_cb
332 dirty=dirty
333 set_dirty=set_dirty
334 set_saving=set_saving
335 close=close_file
336 toast=toast
337 refresh=refresh
338 />
339 }
340 .into_view()
341 .into_any()
342 }
343 None => view! {}.into_any(),
344 }}
345 }
346 }
347
348 /// The page-level overlays: toasts + dialogs, above everything.
349 pub fn overlays(&self) -> impl IntoView + 'static + use<> {
350 let toast = self.toast;
351 let dialog = self.dialog;
352 let set_dialog = self.set_dialog;
353 view! {
354 <ToastView toast=toast />
355 <UploadPanel />
356 <DialogView dialog=dialog set_dialog=set_dialog />
357 }
358 }
359}
360