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::editor::Editor;
20use crate::preview::Preview;
21use crate::router::{self, Location, Section};
22use crate::uploads;
23use crate::util::owned_window_listener;
24use crate::views::browser::{Acts, SelectionActions};
25use crate::views::dialogs::{Dialog, DialogView};
26use crate::views::file_view::{FileView, FileViewActions, FileViewTitle};
27use crate::views::uploads::UploadPanel;
28
29/// The shared state and behavior around the full-page file view.
30///
31/// Create one per page in the page component body, after `provide_toast`:
32/// the signals, the navigation effect and the Escape listener are owned by
33/// the page, so everything is cleaned up when the page unmounts.
34pub struct FileWorkspace {
35 pub file_view: ReadSignal<Option<FileView>>,
36 /// Open a file in the full-page view (hand to the browser / search).
37 pub open_file: WriteSignal<Option<FileView>>,
38 pub selected: ReadSignal<Vec<Entry>>,
39 pub set_selected: WriteSignal<Vec<Entry>>,
40 pub dialog: ReadSignal<Option<Dialog>>,
41 pub set_dialog: WriteSignal<Option<Dialog>>,
42 /// The browser stores its fetch there so the editor can re-list after a
43 /// save.
44 pub set_browser_refresh: WriteSignal<Option<Callback<()>>>,
45 pub topbar_ref: NodeRef<Header>,
46
47 me: ReadSignal<Option<Me>>,
48 loc: ReadSignal<Location>,
49 toast: ToastMsg,
50 owner: Owner,
51 save_cb: ReadSignal<Option<Callback<()>>>,
52 set_save_cb: WriteSignal<Option<Callback<()>>>,
53 close_cb: ReadSignal<Option<Callback<()>>>,
54 set_close_cb: WriteSignal<Option<Callback<()>>>,
55 dirty: ReadSignal<bool>,
56 set_dirty: WriteSignal<bool>,
57 saving: ReadSignal<bool>,
58 set_saving: WriteSignal<bool>,
59 browser_refresh: ReadSignal<Option<Callback<()>>>,
60 close_file: Callback<()>,
61 /// What the file view becomes on close (see the parameter of the same
62 /// name in [`FileWorkspace::new`]).
63 close_target: Arc<dyn Fn() -> Option<FileView> + Send + Sync>,
64 /// Whether the topbar shows the selection actions while no file is
65 /// open (i.e. the page's browser is the visible content).
66 show_actions: Arc<dyn Fn() -> bool + Send + Sync>,
67}
68
69impl FileWorkspace {
70 /// `close_target` is what the file view becomes on close: `None` =
71 /// back to the listing (signed-in app); `Some` = the same file is
72 /// shown again (a file share has nothing to close back to). With a
73 /// `Some` target the close button and Escape are disabled.
74 ///
75 /// `show_actions` decides whether the topbar shows the selection
76 /// actions while no file is open (i.e. the page's browser is the
77 /// visible content).
78 pub fn new(
79 me: ReadSignal<Option<Me>>,
80 loc: ReadSignal<Location>,
81 close_target: impl Fn() -> Option<FileView> + 'static + Send + Sync,
82 show_actions: impl Fn() -> bool + 'static + Send + Sync,
83 ) -> Self {
84 let toast = use_context::<ToastMsg>().expect("toast context");
85 let owner = Owner::current().unwrap();
86 let close_target = Arc::new(close_target);
87 let show_actions = Arc::new(show_actions);
88
89 let (file_view, set_file_view) = signal(Option::<FileView>::None);
90 // The open editor stores its save callback here for the top-bar button.
91 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
92 // The open editor's guarded close request (unsaved-changes prompt).
93 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
94 let (dirty, set_dirty) = signal(false);
95 let (saving, set_saving) = signal(false);
96 // The browser's fetch, exposed so the editor can re-list after a save.
97 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
98 // The selected entries in the file browser; their action buttons
99 // live in the top bar's top right.
100 let (selected, set_selected) = signal(Vec::<Entry>::new());
101 // Dialogs are rendered here, above everything.
102 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
103 let topbar_ref = NodeRef::<Header>::new();
104
105 // Navigating (new folder, root, section) invalidates the selection
106 // and the dialog, which act on it. A page turn or an open file keeps
107 // them. An upload's conflict question is not about the folder shown.
108 let folder = Memo::new(move |_| loc.with(Location::folder_key));
109 Effect::new(move |_| {
110 folder.with(|_| ());
111 set_selected.set(Vec::new());
112 set_dialog.update(|d| {
113 if !matches!(d, Some(Dialog::UploadConflict { .. })) {
114 *d = None;
115 }
116 });
117 });
118
119 // A finished upload re-lists the folder it went into, if that is
120 // the one on screen.
121 // The closure captures this page's signals: it must go when the
122 // page does, or a job finishing later reads disposed signals.
123 let hook = uploads::set_on_done(move |root_id, dir| {
124 let here = loc.with_untracked(|l| {
125 me.with_untracked(|m| {
126 m.as_ref()
127 .and_then(|m| crate::views::browser::effective_root(&m.roots, l))
128 .is_some_and(|r| r.id == root_id)
129 && l.path.join("/") == dir
130 })
131 });
132 if here && let Some(f) = browser_refresh.get_untracked() {
133 f.run(());
134 }
135 });
136 on_cleanup(move || uploads::clear_on_done(hook));
137 // Leaving the page kills every running upload: let the browser ask.
138 owned_window_listener(
139 leptos::ev::beforeunload,
140 move |ev: web_sys::BeforeUnloadEvent| {
141 if uploads::any_active() {
142 ev.prevent_default();
143 ev.set_return_value("");
144 }
145 },
146 );
147
148 let close_now = {
149 let ct = close_target.clone();
150 move || {
151 set_save_cb.set(None);
152 set_close_cb.set(None);
153 set_dirty.set(false);
154 set_saving.set(false);
155 set_file_view.set(ct());
156 }
157 };
158 // The URL holds the open file, so closing edits the URL. The effect
159 // below then closes the view.
160 let close_file = {
161 let close_now = close_now.clone();
162 Callback::new(move |_| {
163 if loc.with_untracked(|l| l.open.is_none()) {
164 close_now();
165 return;
166 }
167 set_dirty.set(false);
168 router::close_open();
169 })
170 };
171
172 // A file shown here goes into the URL. Every file view shares one
173 // history entry: a switch to the next file replaces it.
174 {
175 let ct = close_target.clone();
176 Effect::new(move |_| {
177 let param = file_view.with(|v| {
178 v.as_ref()
179 .and_then(|v| loc.with_untracked(|l| open_param(l, v)))
180 });
181 let Some(p) = param else {
182 return;
183 };
184 if ct().is_none() && loc.with_untracked(|l| l.open.as_ref() != Some(&p)) {
185 router::set_open(&p);
186 }
187 });
188 }
189 // A URL without a file (Back, a link, a new folder) closes the view.
190 // Back cannot be cancelled, so with unsaved changes the file's entry
191 // comes back and the editor asks first.
192 {
193 let ct = close_target.clone();
194 let open_url = StoredValue::new(None::<String>);
195 Effect::new(move |_| {
196 if loc.with(|l| l.open.is_some()) {
197 open_url.set_value(Some(router::current_hash()));
198 return;
199 }
200 if ct().is_some() || file_view.with_untracked(Option::is_none) {
201 return;
202 }
203 if dirty.get_untracked()
204 && let (Some(url), Some(ask)) = (open_url.get_value(), close_cb.get_untracked())
205 {
206 router::push_open_url(&url);
207 ask.run(());
208 return;
209 }
210 close_now();
211 // Back lands on the page the first file was opened from. The
212 // arrow keys may have moved on since: show the last file's page.
213 let Some(was) = open_url.get_value().map(|u| router::parse_hash(&u)) else {
214 return;
215 };
216 let now = loc.get_untracked();
217 if router::at_open_origin()
218 && was.folder_key() == now.folder_key()
219 && (was.page, was.size, was.sort) != (now.page, now.size, now.sort)
220 {
221 router::replace(&Location { open: None, ..was });
222 }
223 });
224 }
225
226 // Escape closes previews and the "no preview" view — except when
227 // there is nothing to close back to (file share). The editor handles
228 // Escape itself (it asks first when there are unsaved changes).
229 {
230 let ct = close_target.clone();
231 owned_window_listener(keydown, move |ev: KeyboardEvent| {
232 if ev.key() != "Escape" || ct().is_some() {
233 return;
234 }
235 if file_view
236 .get()
237 .is_some_and(|v| v.kind != api_types::FileKind::Text)
238 {
239 close_file.run(());
240 }
241 });
242 }
243
244 Self {
245 file_view,
246 open_file: set_file_view,
247 selected,
248 set_selected,
249 dialog,
250 set_dialog,
251 set_browser_refresh,
252 topbar_ref,
253 me,
254 loc,
255 toast,
256 owner,
257 save_cb,
258 set_save_cb,
259 close_cb,
260 set_close_cb,
261 dirty,
262 set_dirty,
263 saving,
264 set_saving,
265 browser_refresh,
266 close_file,
267 close_target,
268 show_actions,
269 }
270 }
271
272 /// Topbar left: the brand. In file mode the brand shrinks to its icon
273 /// (a link to `/`) and the file title takes its place.
274 pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
275 let file_view = self.file_view;
276 let dirty = self.dirty;
277 let me = self.me;
278 // The home link opens the profile's default root, else the overview.
279 // A share page's synthetic `me` has no default, so it stays `#/`.
280 let home = move || {
281 let root = me.with(|m| {
282 m.as_ref()
283 .and_then(|m| m.user.as_ref())
284 .and_then(|u| u.default_root_id)
285 });
286 match root {
287 Some(id) => {
288 crate::router::location_to_hash(&Location::folder(Some(id), None, vec![]))
289 }
290 None => "#/".to_string(),
291 }
292 };
293 view! {
294 {move || match file_view.get() {
295 Some(ref v) => view! {
296 <a class="brand brand-icon" href=home>
297 <Logo />
298 </a>
299 <FileViewTitle view=v.clone() dirty=dirty />
300 }
301 .into_any(),
302 None => view! {
303 <a class="brand" href=home>
304 <Logo />
305 <span class="brand-name">"dovenest"</span>
306 </a>
307 }
308 .into_any(),
309 }}
310 }
311 }
312
313 /// Topbar right: the open file's actions (download / save / close) or,
314 /// while no file is open, the selected entry's actions.
315 pub fn topbar_right(&self) -> impl IntoView + 'static + use<> {
316 let file_view = self.file_view;
317 let show_actions = self.show_actions.clone();
318 let close_target = self.close_target.clone();
319 let save_cb = self.save_cb;
320 let dirty = self.dirty;
321 let saving = self.saving;
322 let close_cb = self.close_cb;
323 let close_file = self.close_file;
324 let me = self.me;
325 let loc = self.loc;
326 let selected = self.selected;
327 let open_file = self.open_file;
328 let browser_refresh = self.browser_refresh;
329 let toast = self.toast;
330 let dialog = self.dialog;
331 let set_dialog = self.set_dialog;
332 let owner = self.owner.clone();
333 let topbar_ref = self.topbar_ref;
334 view! {
335 {move || {
336 if let Some(v) = file_view.get() {
337 view! {
338 <FileViewActions
339 view=v
340 saving=saving
341 dirty=dirty
342 save_cb=save_cb
343 close_cb=close_cb
344 show_close=close_target().is_none()
345 on_close=close_file
346 toast=toast
347 />
348 }
349 .into_any()
350 } else if show_actions() {
351 let refresh = browser_refresh
352 .get()
353 .unwrap_or_else(|| Callback::new(move |_| {}));
354 let acts = Acts {
355 me,
356 loc,
357 open_file,
358 refresh,
359 toast,
360 dialog,
361 set_dialog,
362 owner: owner.clone(),
363 };
364 view! { <SelectionActions selected=selected acts=acts topbar_ref=topbar_ref/> }
365 .into_any()
366 } else {
367 view! {}.into_any()
368 }
369 }}
370 }
371 }
372
373 /// The full-page content (preview / editor / "no preview"), rendered
374 /// while a file is open.
375 pub fn file_content(&self) -> impl IntoView + 'static + use<> {
376 let file_view = self.file_view;
377 let set_save_cb = self.set_save_cb;
378 let set_close_cb = self.set_close_cb;
379 let dirty = self.dirty;
380 let set_dirty = self.set_dirty;
381 let set_saving = self.set_saving;
382 let close_file = self.close_file;
383 let toast = self.toast;
384 let browser_refresh = self.browser_refresh;
385 view! {
386 {move || file_view.get().map(|v| {
387 let refresh = browser_refresh
388 .get()
389 .unwrap_or_else(|| Callback::new(move |_| {}));
390 let content = if v.kind == api_types::FileKind::Text {
391 view! {
392 <Editor
393 target=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 .into_any()
405 } else {
406 view! { <Preview target=v/> }.into_any()
407 };
408 view! { <div class="file-view">{content}</div> }
409 })}
410 }
411 }
412
413 /// The page-level overlays: toasts + dialogs, above everything.
414 pub fn overlays(&self) -> impl IntoView + 'static + use<> {
415 let toast = self.toast;
416 let dialog = self.dialog;
417 let set_dialog = self.set_dialog;
418 view! {
419 <ToastView toast=toast />
420 <UploadPanel />
421 <DialogView dialog=dialog set_dialog=set_dialog />
422 }
423 }
424}
425
426/// How the URL names the open file `v`: by name in the listed folder, or
427/// on the search view by its path in the root. `None` for a file outside
428/// the folder, which the URL cannot name.
429fn open_param(loc: &Location, v: &FileView) -> Option<String> {
430 let path = v.path.as_str();
431 if loc.section == Section::Search {
432 return Some(path.to_string());
433 }
434 let dir = loc.path.join("/");
435 let name = if dir.is_empty() {
436 path
437 } else {
438 path.strip_prefix(&dir)?.strip_prefix('/')?
439 };
440 (!name.contains('/')).then(|| name.to_string())
441}
442