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::util::owned_window_listener;
21use crate::views::browser::SelectionActions;
22use crate::views::dialogs::{Dialog, DialogView};
23use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
24
25/// The shared state and behavior around the full-page file view.
26///
27/// Create one per page in the page component body, after `provide_toast`:
28/// the signals, the navigation effect and the Escape listener are owned by
29/// the page, so everything is cleaned up when the page unmounts.
30pub struct FileWorkspace {
31 pub file_view: ReadSignal<Option<FileView>>,
32 /// Open a file in the full-page view (hand to the browser / search).
33 pub open_file: WriteSignal<Option<FileView>>,
34 pub selected: ReadSignal<Vec<Entry>>,
35 pub set_selected: WriteSignal<Vec<Entry>>,
36 pub dialog: ReadSignal<Option<Dialog>>,
37 pub set_dialog: WriteSignal<Option<Dialog>>,
38 /// The browser stores its fetch there so the editor can re-list after a
39 /// save.
40 pub set_browser_refresh: WriteSignal<Option<Callback<()>>>,
41 pub topbar_ref: NodeRef<Header>,
42
43 me: ReadSignal<Option<Me>>,
44 loc: ReadSignal<Location>,
45 toast: ToastMsg,
46 owner: Owner,
47 save_cb: ReadSignal<Option<Callback<()>>>,
48 set_save_cb: WriteSignal<Option<Callback<()>>>,
49 close_cb: ReadSignal<Option<Callback<()>>>,
50 set_close_cb: WriteSignal<Option<Callback<()>>>,
51 dirty: ReadSignal<bool>,
52 set_dirty: WriteSignal<bool>,
53 saving: ReadSignal<bool>,
54 set_saving: WriteSignal<bool>,
55 browser_refresh: ReadSignal<Option<Callback<()>>>,
56 close_file: Callback<()>,
57 /// What the file view becomes on close (see the parameter of the same
58 /// name in [`FileWorkspace::new`]).
59 close_target: Arc<dyn Fn() -> Option<FileView> + Send + Sync>,
60 /// Whether the topbar shows the selection actions while no file is
61 /// open (i.e. the page's browser is the visible content).
62 show_actions: Arc<dyn Fn() -> bool + Send + Sync>,
63}
64
65impl FileWorkspace {
66 /// `close_target` is what the file view becomes on close: `None` =
67 /// back to the listing (signed-in app); `Some` = the same file is
68 /// shown again (a file share has nothing to close back to). With a
69 /// `Some` target the close button and Escape are disabled.
70 ///
71 /// `show_actions` decides whether the topbar shows the selection
72 /// actions while no file is open (i.e. the page's browser is the
73 /// visible content).
74 pub fn new(
75 me: ReadSignal<Option<Me>>,
76 loc: ReadSignal<Location>,
77 close_target: impl Fn() -> Option<FileView> + 'static + Send + Sync,
78 show_actions: impl Fn() -> bool + 'static + Send + Sync,
79 ) -> Self {
80 let toast = use_context::<ToastMsg>().expect("toast context");
81 let owner = Owner::current().unwrap();
82 let close_target = Arc::new(close_target);
83 let show_actions = Arc::new(show_actions);
84
85 let (file_view, set_file_view) = signal(Option::<FileView>::None);
86 // The open editor stores its save callback here for the top-bar button.
87 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
88 // The open editor's guarded close request (unsaved-changes prompt).
89 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
90 let (dirty, set_dirty) = signal(false);
91 let (saving, set_saving) = signal(false);
92 // The browser's fetch, exposed so the editor can re-list after a save.
93 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
94 // The selected entries in the file browser; their action buttons
95 // live in the top bar's top right.
96 let (selected, set_selected) = signal(Vec::<Entry>::new());
97 // Dialogs are rendered here, above everything.
98 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
99 let topbar_ref = NodeRef::<Header>::new();
100
101 // Navigating (new folder, root, section) invalidates the selection.
102 Effect::new(move |_| {
103 let _ = loc.get();
104 set_selected.set(Vec::new());
105 });
106
107 let close_file = {
108 let ct = close_target.clone();
109 Callback::new(move |_| {
110 set_save_cb.set(None);
111 set_close_cb.set(None);
112 set_dirty.set(false);
113 set_saving.set(false);
114 set_file_view.set(ct());
115 })
116 };
117
118 // Escape closes previews and the "no preview" view — except when
119 // there is nothing to close back to (file share). The editor handles
120 // Escape itself (it asks first when there are unsaved changes).
121 {
122 let fv = file_view;
123 let cf = close_file;
124 let ct = close_target.clone();
125 owned_window_listener(keydown, move |ev: KeyboardEvent| {
126 if ev.key() != "Escape" {
127 return;
128 }
129 if ct().is_some() {
130 return;
131 }
132 if fv
133 .get()
134 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
135 {
136 cf.run(());
137 }
138 });
139 }
140
141 Self {
142 file_view,
143 open_file: set_file_view,
144 selected,
145 set_selected,
146 dialog,
147 set_dialog,
148 set_browser_refresh,
149 topbar_ref,
150 me,
151 loc,
152 toast,
153 owner,
154 save_cb,
155 set_save_cb,
156 close_cb,
157 set_close_cb,
158 dirty,
159 set_dirty,
160 saving,
161 set_saving,
162 browser_refresh,
163 close_file,
164 close_target,
165 show_actions,
166 }
167 }
168
169 /// Topbar left: the brand. In file mode the brand shrinks to its icon
170 /// (a link to `/`) and the file title takes its place.
171 pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
172 let file_view = self.file_view;
173 let dirty = self.dirty;
174 view! {
175 {move || match file_view.get() {
176 Some(ref v) => view! {
177 <a class="brand brand-icon" href="#/">
178 <Logo />
179 </a>
180 <FileViewTitle view=v.clone() dirty=dirty />
181 }
182 .into_view()
183 .into_any(),
184 None => view! {
185 <a class="brand" href="#/">
186 <Logo />
187 <span class="brand-name">"filebrowser-ng"</span>
188 </a>
189 }
190 .into_view()
191 .into_any(),
192 }}
193 }
194 }
195
196 /// Topbar right: the open file's actions (download / save / close) or,
197 /// while no file is open, the selected entry's actions.
198 pub fn topbar_right(&self) -> impl IntoView + 'static + use<> {
199 let file_view = self.file_view;
200 let show_actions = self.show_actions.clone();
201 let close_target = self.close_target.clone();
202 let save_cb = self.save_cb;
203 let dirty = self.dirty;
204 let saving = self.saving;
205 let close_cb = self.close_cb;
206 let close_file = self.close_file;
207 let me = self.me;
208 let loc = self.loc;
209 let selected = self.selected;
210 let open_file = self.open_file;
211 let browser_refresh = self.browser_refresh;
212 let toast = self.toast;
213 let set_dialog = self.set_dialog;
214 let owner = self.owner.clone();
215 let topbar_ref = self.topbar_ref;
216 view! {
217 {move || {
218 if let Some(v) = file_view.get() {
219 view! {
220 <FileViewActions
221 view=v
222 saving=saving
223 dirty=dirty
224 save_cb=save_cb
225 close_cb=close_cb
226 show_close=close_target().is_none()
227 on_close=close_file
228 toast=toast
229 />
230 }
231 .into_view()
232 .into_any()
233 } else if show_actions() {
234 let refresh = browser_refresh
235 .get()
236 .unwrap_or_else(|| Callback::new(move |_| {}));
237 view! {
238 <SelectionActions
239 me=me
240 loc=loc
241 selected=selected
242 open_file=open_file
243 refresh=refresh
244 toast=toast
245 set_dialog=set_dialog
246 owner=owner.clone()
247 topbar_ref=topbar_ref
248 />
249 }
250 .into_view()
251 .into_any()
252 } else {
253 view! {}.into_any()
254 }
255 }}
256 }
257 }
258
259 /// The full-page content (preview / editor / "no preview"), rendered
260 /// while a file is open.
261 pub fn file_content(&self) -> impl IntoView + 'static + use<> {
262 let file_view = self.file_view;
263 let set_save_cb = self.set_save_cb;
264 let set_close_cb = self.set_close_cb;
265 let dirty = self.dirty;
266 let set_dirty = self.set_dirty;
267 let set_saving = self.set_saving;
268 let close_file = self.close_file;
269 let toast = self.toast;
270 let browser_refresh = self.browser_refresh;
271 view! {
272 {move || match file_view.get() {
273 Some(v) => {
274 let refresh = browser_refresh
275 .get()
276 .unwrap_or_else(|| Callback::new(move |_| {}));
277 view! {
278 <FileViewContent
279 view=v
280 register_save=set_save_cb
281 register_close=set_close_cb
282 dirty=dirty
283 set_dirty=set_dirty
284 set_saving=set_saving
285 close=close_file
286 toast=toast
287 refresh=refresh
288 />
289 }
290 .into_view()
291 .into_any()
292 }
293 None => view! {}.into_any(),
294 }}
295 }
296 }
297
298 /// The page-level overlays: toasts + dialogs, above everything.
299 pub fn overlays(&self) -> impl IntoView + 'static + use<> {
300 let toast = self.toast;
301 let dialog = self.dialog;
302 let set_dialog = self.set_dialog;
303 view! {
304 <ToastView toast=toast />
305 <DialogView dialog=dialog set_dialog=set_dialog />
306 }
307 }
308}
309