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 ct = close_target.clone();
229 owned_window_listener(keydown, move |ev: KeyboardEvent| {
230 if ev.key() != "Escape" || ct().is_some() {
231 return;
232 }
233 if file_view
234 .get()
235 .is_some_and(|v| v.kind != api_types::FileKind::Text)
236 {
237 close_file.run(());
238 }
239 });
240 }
241
242 Self {
243 file_view,
244 open_file: set_file_view,
245 selected,
246 set_selected,
247 dialog,
248 set_dialog,
249 set_browser_refresh,
250 topbar_ref,
251 me,
252 loc,
253 toast,
254 owner,
255 save_cb,
256 set_save_cb,
257 close_cb,
258 set_close_cb,
259 dirty,
260 set_dirty,
261 saving,
262 set_saving,
263 browser_refresh,
264 close_file,
265 close_target,
266 show_actions,
267 }
268 }
269
270 /// Topbar left: the brand. In file mode the brand shrinks to its icon
271 /// (a link to `/`) and the file title takes its place.
272 pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
273 let file_view = self.file_view;
274 let dirty = self.dirty;
275 let me = self.me;
276 // The home link opens the profile's default root, else the overview.
277 // A share page's synthetic `me` has no default, so it stays `#/`.
278 let home = move || {
279 let root = me.with(|m| {
280 m.as_ref()
281 .and_then(|m| m.user.as_ref())
282 .and_then(|u| u.default_root_id)
283 });
284 match root {
285 Some(id) => {
286 crate::router::location_to_hash(&Location::folder(Some(id), None, vec![]))
287 }
288 None => "#/".to_string(),
289 }
290 };
291 view! {
292 {move || match file_view.get() {
293 Some(ref v) => view! {
294 <a class="brand brand-icon" href=home>
295 <Logo />
296 </a>
297 <FileViewTitle view=v.clone() dirty=dirty />
298 }
299 .into_any(),
300 None => view! {
301 <a class="brand" href=home>
302 <Logo />
303 <span class="brand-name">"dovenest"</span>
304 </a>
305 }
306 .into_any(),
307 }}
308 }
309 }
310
311 /// Topbar right: the open file's actions (download / save / close) or,
312 /// while no file is open, the selected entry's actions.
313 pub fn topbar_right(&self) -> impl IntoView + 'static + use<> {
314 let file_view = self.file_view;
315 let show_actions = self.show_actions.clone();
316 let close_target = self.close_target.clone();
317 let save_cb = self.save_cb;
318 let dirty = self.dirty;
319 let saving = self.saving;
320 let close_cb = self.close_cb;
321 let close_file = self.close_file;
322 let me = self.me;
323 let loc = self.loc;
324 let selected = self.selected;
325 let open_file = self.open_file;
326 let browser_refresh = self.browser_refresh;
327 let toast = self.toast;
328 let dialog = self.dialog;
329 let set_dialog = self.set_dialog;
330 let owner = self.owner.clone();
331 let topbar_ref = self.topbar_ref;
332 view! {
333 {move || {
334 if let Some(v) = file_view.get() {
335 view! {
336 <FileViewActions
337 view=v
338 saving=saving
339 dirty=dirty
340 save_cb=save_cb
341 close_cb=close_cb
342 show_close=close_target().is_none()
343 on_close=close_file
344 toast=toast
345 />
346 }
347 .into_any()
348 } else if show_actions() {
349 let refresh = browser_refresh
350 .get()
351 .unwrap_or_else(|| Callback::new(move |_| {}));
352 view! {
353 <SelectionActions
354 me=me
355 loc=loc
356 selected=selected
357 open_file=open_file
358 refresh=refresh
359 toast=toast
360 dialog=dialog
361 set_dialog=set_dialog
362 owner=owner.clone()
363 topbar_ref=topbar_ref
364 />
365 }
366 .into_any()
367 } else {
368 view! {}.into_any()
369 }
370 }}
371 }
372 }
373
374 /// The full-page content (preview / editor / "no preview"), rendered
375 /// while a file is open.
376 pub fn file_content(&self) -> impl IntoView + 'static + use<> {
377 let file_view = self.file_view;
378 let set_save_cb = self.set_save_cb;
379 let set_close_cb = self.set_close_cb;
380 let dirty = self.dirty;
381 let set_dirty = self.set_dirty;
382 let set_saving = self.set_saving;
383 let close_file = self.close_file;
384 let toast = self.toast;
385 let browser_refresh = self.browser_refresh;
386 view! {
387 {move || file_view.get().map(|v| {
388 let refresh = browser_refresh
389 .get()
390 .unwrap_or_else(|| Callback::new(move |_| {}));
391 view! {
392 <FileViewContent
393 view=v
394 register_save=set_save_cb
395 register_close=set_close_cb
396 dirty=dirty
397 set_dirty=set_dirty
398 set_saving=set_saving
399 close=close_file
400 toast=toast
401 refresh=refresh
402 />
403 }
404 })}
405 }
406 }
407
408 /// The page-level overlays: toasts + dialogs, above everything.
409 pub fn overlays(&self) -> impl IntoView + 'static + use<> {
410 let toast = self.toast;
411 let dialog = self.dialog;
412 let set_dialog = self.set_dialog;
413 view! {
414 <ToastView toast=toast />
415 <UploadPanel />
416 <DialogView dialog=dialog set_dialog=set_dialog />
417 }
418 }
419}
420
421/// How the URL names the open file `v`: by name in the listed folder, or
422/// on the search view by its path in the root. `None` for a file outside
423/// the folder, which the URL cannot name.
424fn open_param(loc: &Location, v: &FileView) -> Option<String> {
425 let path = v.path.as_str();
426 if loc.section == Section::Search {
427 return Some(path.to_string());
428 }
429 let dir = loc.path.join("/");
430 let name = if dir.is_empty() {
431 path
432 } else {
433 path.strip_prefix(&dir)?.strip_prefix('/')?
434 };
435 (!name.contains('/')).then(|| name.to_string())
436}
437