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::{self, Location, Section};
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 // and the dialog, which act on it. A page turn or an open file keeps
105 // them. An upload's conflict question is not about the folder shown.
106 let folder = Memo::new(move |_| loc.with(Location::folder_key));
107 Effect::new(move |_| {
108 folder.with(|_| ());
109 set_selected.set(Vec::new());
110 set_dialog.update(|d| {
111 if !matches!(d, Some(Dialog::UploadConflict { .. })) {
112 *d = None;
113 }
114 });
115 });
116
117 // A finished upload re-lists the folder it went into, if that is
118 // the one on screen.
119 // The closure captures this page's signals: it must go when the
120 // page does, or a job finishing later reads disposed signals.
121 let hook = uploads::set_on_done(move |root_id, dir| {
122 let here = loc.with_untracked(|l| {
123 me.with_untracked(|m| {
124 m.as_ref()
125 .and_then(|m| crate::views::browser::effective_root(&m.roots, l))
126 .is_some_and(|r| r.id == root_id)
127 && l.path.join("/") == dir
128 })
129 });
130 if here && let Some(f) = browser_refresh.get_untracked() {
131 f.run(());
132 }
133 });
134 on_cleanup(move || uploads::clear_on_done(hook));
135 // Leaving the page kills every running upload: let the browser ask.
136 owned_window_listener(
137 leptos::ev::beforeunload,
138 move |ev: web_sys::BeforeUnloadEvent| {
139 if uploads::any_active() {
140 ev.prevent_default();
141 ev.set_return_value("");
142 }
143 },
144 );
145
146 let close_now = {
147 let ct = close_target.clone();
148 move || {
149 set_save_cb.set(None);
150 set_close_cb.set(None);
151 set_dirty.set(false);
152 set_saving.set(false);
153 set_file_view.set(ct());
154 }
155 };
156 // The URL holds the open file, so closing edits the URL. The effect
157 // below then closes the view.
158 let close_file = {
159 let close_now = close_now.clone();
160 Callback::new(move |_| {
161 if loc.with_untracked(|l| l.open.is_none()) {
162 close_now();
163 return;
164 }
165 set_dirty.set(false);
166 router::close_open();
167 })
168 };
169
170 // A file shown here goes into the URL. Every file view shares one
171 // history entry: a switch to the next file replaces it.
172 {
173 let ct = close_target.clone();
174 Effect::new(move |_| {
175 let param = file_view.with(|v| {
176 v.as_ref()
177 .and_then(|v| loc.with_untracked(|l| open_param(l, v)))
178 });
179 let Some(p) = param else {
180 return;
181 };
182 if ct().is_none() && loc.with_untracked(|l| l.open.as_ref() != Some(&p)) {
183 router::set_open(&p);
184 }
185 });
186 }
187 // A URL without a file (Back, a link, a new folder) closes the view.
188 // Back cannot be cancelled, so with unsaved changes the file's entry
189 // comes back and the editor asks first.
190 {
191 let ct = close_target.clone();
192 let open_url = StoredValue::new(None::<String>);
193 Effect::new(move |_| {
194 if loc.with(|l| l.open.is_some()) {
195 open_url.set_value(Some(router::current_hash()));
196 return;
197 }
198 if ct().is_some() || file_view.with_untracked(Option::is_none) {
199 return;
200 }
201 if dirty.get_untracked()
202 && let (Some(url), Some(ask)) = (open_url.get_value(), close_cb.get_untracked())
203 {
204 router::push_open_url(&url);
205 ask.run(());
206 return;
207 }
208 close_now();
209 // Back lands on the page the first file was opened from. The
210 // arrow keys may have moved on since: show the last file's page.
211 let Some(was) = open_url.get_value().map(|u| router::parse_hash(&u)) else {
212 return;
213 };
214 let now = loc.get_untracked();
215 if router::at_open_origin()
216 && was.folder_key() == now.folder_key()
217 && (was.page, was.size, was.sort) != (now.page, now.size, now.sort)
218 {
219 router::replace(&Location { open: None, ..was });
220 }
221 });
222 }
223
224 // Escape closes previews and the "no preview" view — except when
225 // there is nothing to close back to (file share). The editor handles
226 // Escape itself (it asks first when there are unsaved changes).
227 {
228 let fv = file_view;
229 let cf = close_file;
230 let ct = close_target.clone();
231 owned_window_listener(keydown, move |ev: KeyboardEvent| {
232 if ev.key() != "Escape" {
233 return;
234 }
235 if ct().is_some() {
236 return;
237 }
238 if fv
239 .get()
240 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
241 {
242 cf.run(());
243 }
244 });
245 }
246
247 Self {
248 file_view,
249 open_file: set_file_view,
250 selected,
251 set_selected,
252 dialog,
253 set_dialog,
254 set_browser_refresh,
255 topbar_ref,
256 me,
257 loc,
258 toast,
259 owner,
260 save_cb,
261 set_save_cb,
262 close_cb,
263 set_close_cb,
264 dirty,
265 set_dirty,
266 saving,
267 set_saving,
268 browser_refresh,
269 close_file,
270 close_target,
271 show_actions,
272 }
273 }
274
275 /// Topbar left: the brand. In file mode the brand shrinks to its icon
276 /// (a link to `/`) and the file title takes its place.
277 pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
278 let file_view = self.file_view;
279 let dirty = self.dirty;
280 let me = self.me;
281 // The home link opens the profile's default root, else the overview.
282 // A share page's synthetic `me` has no default, so it stays `#/`.
283 let home = move || {
284 let root = me.with(|m| {
285 m.as_ref()
286 .and_then(|m| m.user.as_ref())
287 .and_then(|u| u.default_root_id)
288 });
289 match root {
290 Some(id) => {
291 crate::router::location_to_hash(&Location::folder(Some(id), None, vec![]))
292 }
293 None => "#/".to_string(),
294 }
295 };
296 view! {
297 {move || match file_view.get() {
298 Some(ref v) => view! {
299 <a class="brand brand-icon" href=home>
300 <Logo />
301 </a>
302 <FileViewTitle view=v.clone() dirty=dirty />
303 }
304 .into_view()
305 .into_any(),
306 None => view! {
307 <a class="brand" href=home>
308 <Logo />
309 <span class="brand-name">"filebrowser-ng"</span>
310 </a>
311 }
312 .into_view()
313 .into_any(),
314 }}
315 }
316 }
317
318 /// Topbar right: the open file's actions (download / save / close) or,
319 /// while no file is open, the selected entry's actions.
320 pub fn topbar_right(&self) -> impl IntoView + 'static + use<> {
321 let file_view = self.file_view;
322 let show_actions = self.show_actions.clone();
323 let close_target = self.close_target.clone();
324 let save_cb = self.save_cb;
325 let dirty = self.dirty;
326 let saving = self.saving;
327 let close_cb = self.close_cb;
328 let close_file = self.close_file;
329 let me = self.me;
330 let loc = self.loc;
331 let selected = self.selected;
332 let open_file = self.open_file;
333 let browser_refresh = self.browser_refresh;
334 let toast = self.toast;
335 let dialog = self.dialog;
336 let set_dialog = self.set_dialog;
337 let owner = self.owner.clone();
338 let topbar_ref = self.topbar_ref;
339 view! {
340 {move || {
341 if let Some(v) = file_view.get() {
342 view! {
343 <FileViewActions
344 view=v
345 saving=saving
346 dirty=dirty
347 save_cb=save_cb
348 close_cb=close_cb
349 show_close=close_target().is_none()
350 on_close=close_file
351 toast=toast
352 />
353 }
354 .into_view()
355 .into_any()
356 } else if show_actions() {
357 let refresh = browser_refresh
358 .get()
359 .unwrap_or_else(|| Callback::new(move |_| {}));
360 view! {
361 <SelectionActions
362 me=me
363 loc=loc
364 selected=selected
365 open_file=open_file
366 refresh=refresh
367 toast=toast
368 dialog=dialog
369 set_dialog=set_dialog
370 owner=owner.clone()
371 topbar_ref=topbar_ref
372 />
373 }
374 .into_view()
375 .into_any()
376 } else {
377 view! {}.into_any()
378 }
379 }}
380 }
381 }
382
383 /// The full-page content (preview / editor / "no preview"), rendered
384 /// while a file is open.
385 pub fn file_content(&self) -> impl IntoView + 'static + use<> {
386 let file_view = self.file_view;
387 let set_save_cb = self.set_save_cb;
388 let set_close_cb = self.set_close_cb;
389 let dirty = self.dirty;
390 let set_dirty = self.set_dirty;
391 let set_saving = self.set_saving;
392 let close_file = self.close_file;
393 let toast = self.toast;
394 let browser_refresh = self.browser_refresh;
395 view! {
396 {move || match file_view.get() {
397 Some(v) => {
398 let refresh = browser_refresh
399 .get()
400 .unwrap_or_else(|| Callback::new(move |_| {}));
401 view! {
402 <FileViewContent
403 view=v
404 register_save=set_save_cb
405 register_close=set_close_cb
406 dirty=dirty
407 set_dirty=set_dirty
408 set_saving=set_saving
409 close=close_file
410 toast=toast
411 refresh=refresh
412 />
413 }
414 .into_view()
415 .into_any()
416 }
417 None => view! {}.into_any(),
418 }}
419 }
420 }
421
422 /// The page-level overlays: toasts + dialogs, above everything.
423 pub fn overlays(&self) -> impl IntoView + 'static + use<> {
424 let toast = self.toast;
425 let dialog = self.dialog;
426 let set_dialog = self.set_dialog;
427 view! {
428 <ToastView toast=toast />
429 <UploadPanel />
430 <DialogView dialog=dialog set_dialog=set_dialog />
431 }
432 }
433}
434
435/// How the URL names the open file `v`: by name in the listed folder, or
436/// on the search view by its path in the root. `None` for a file outside
437/// the folder, which the URL cannot name.
438fn open_param(loc: &Location, v: &FileView) -> Option<String> {
439 let path = v.path();
440 if loc.section == Section::Search {
441 return Some(path.to_string());
442 }
443 let dir = loc.path.join("/");
444 let name = if dir.is_empty() {
445 path
446 } else {
447 path.strip_prefix(&dir)?.strip_prefix('/')?
448 };
449 (!name.contains('/')).then(|| name.to_string())
450}
451