router.rs
⎇
Raw
1//! Tiny hash-based router.
2//!
3//! - `#/` — root picker / single-root browser
4//! - `#/files/{root_id}/{segment/...}` — a user's folder
5//! - `#/share/{token}/{segment/...}` — a public share (no login)
6//! - `#/shares`, `#/users`, `#/settings/{tab}`, `#/uploads` — the signed-in
7//! shell's manage views
8//! - `#/calendar`, `#/contacts` — calendars and address books
9//!
10//! The URL hash is the single source of truth; the `Location` signal in the
11//! app mirrors it. A folder listing carries `?page=&size=&sort=&desc=`, and
12//! an open file `open=`.
13
14use leptos::prelude::*;
15use wasm_bindgen::JsValue;
16
17use crate::util::{FILE_PAGE, PAGE_SIZES, SortKey, SortSpec, load_page_size};
18
19/// Which section of the signed-in shell a location points at. `Files` is the
20/// browser; the rest are the manage views, each with its own URL.
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub enum Section {
23 Files,
24 /// The search view (`#/search`); query params live in the hash query.
25 Search,
26 Shares,
27 Users,
28 Settings,
29 /// This session's upload jobs (`#/uploads`).
30 Uploads,
31 Calendar,
32 Contacts,
33}
34
35impl Section {
36 /// The URL segment for this section, or `None` for `Files` (which has no
37 /// segment of its own — the browser is the default view).
38 const fn segment(self) -> Option<&'static str> {
39 match self {
40 Section::Files => None,
41 Section::Search => Some("search"),
42 Section::Shares => Some("shares"),
43 Section::Users => Some("users"),
44 Section::Settings => Some("settings"),
45 Section::Uploads => Some("uploads"),
46 Section::Calendar => Some("calendar"),
47 Section::Contacts => Some("contacts"),
48 }
49 }
50
51 /// The inverse of [`segment`]: the section a URL segment names. `files`
52 /// has no segment, so it is never returned here.
53 fn from_segment(seg: &str) -> Option<Self> {
54 [
55 Section::Search,
56 Section::Shares,
57 Section::Users,
58 Section::Settings,
59 Section::Uploads,
60 Section::Calendar,
61 Section::Contacts,
62 ]
63 .into_iter()
64 .find(|s| s.segment() == Some(seg))
65 }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct Location {
70 /// None = the top-level "folders" view (root picker).
71 pub root_id: Option<i64>,
72 /// Path segments relative to the current root (or share target). On the
73 /// settings view, the tab.
74 pub path: Vec<String>,
75 /// Present when the location is a public share (`#/share/{token}/...`).
76 pub share_token: Option<String>,
77 /// Which shell section the location points at.
78 pub section: Section,
79 /// The listing page, 0-based. The URL counts from 1.
80 pub page: usize,
81 pub size: usize,
82 pub sort: SortSpec,
83 /// The open file: its name in the folder, or on the search view its
84 /// path in the searched root.
85 pub open: Option<String>,
86 /// The search view's own params. Only compared: a new search is a new
87 /// location.
88 pub search: String,
89}
90
91impl Location {
92 pub const fn root() -> Self {
93 Self {
94 root_id: None,
95 path: Vec::new(),
96 share_token: None,
97 section: Section::Files,
98 page: 0,
99 size: FILE_PAGE,
100 sort: SortSpec {
101 key: SortKey::Name,
102 asc: true,
103 },
104 open: None,
105 search: String::new(),
106 }
107 }
108
109 /// A folder in the browser, in the user's saved order and page size.
110 pub fn folder(root_id: Option<i64>, share_token: Option<String>, path: Vec<String>) -> Self {
111 Self {
112 root_id,
113 path,
114 share_token,
115 sort: SortSpec::load(),
116 size: load_page_size(),
117 ..Self::root()
118 }
119 }
120
121 /// A folder listing, which always carries its order and page size.
122 fn is_folder(&self) -> bool {
123 self.section == Section::Files && (self.root_id.is_some() || self.share_token.is_some())
124 }
125
126 /// What names the folder or view, without the listing params.
127 pub fn folder_key(&self) -> (Option<i64>, Vec<String>, Option<String>, Section) {
128 (
129 self.root_id,
130 self.path.clone(),
131 self.share_token.clone(),
132 self.section,
133 )
134 }
135
136 /// A folder's order and page size are always written, so a link keeps
137 /// its position when a default changes. Then the open file.
138 fn query_pairs(&self) -> Vec<(&'static str, String)> {
139 let mut out = Vec::new();
140 if self.is_folder() {
141 if self.page != 0 {
142 out.push(("page", (self.page + 1).to_string()));
143 }
144 out.push(("sort", self.sort.key.as_str().to_string()));
145 if !self.sort.asc {
146 out.push(("desc", "1".to_string()));
147 }
148 out.push(("size", self.size.to_string()));
149 }
150 if let Some(o) = &self.open {
151 out.push(("open", o.clone()));
152 }
153 out
154 }
155
156 /// The inverse of [`Self::query_pairs`]. Bad values are ignored: they
157 /// come from hand-typed URLs.
158 fn apply_pair(&mut self, key: &str, value: String) {
159 match key {
160 "page" => {
161 if let Ok(n) = value.parse::<usize>() {
162 self.page = n.saturating_sub(1);
163 }
164 }
165 "size" => {
166 if let Some(n) = value.parse().ok().filter(|n| PAGE_SIZES.contains(n)) {
167 self.size = n;
168 }
169 }
170 "sort" => {
171 if let Some(key) = SortKey::parse(&value) {
172 self.sort = SortSpec { key, asc: true };
173 }
174 }
175 "desc" => self.sort.asc = value != "1",
176 "open" if !value.is_empty() => self.open = Some(value),
177 _ => {}
178 }
179 }
180}
181
182/// Query keys the router owns, in the order they apply: `desc` after
183/// `sort`. The search view reads its own keys.
184const QUERY_KEYS: [&str; 5] = ["page", "sort", "desc", "size", "open"];
185
186/// Percent-encode one path segment for the URL hash. Without this a name
187/// containing `#`, `%` or `/` would break the round trip.
188fn encode_segment(seg: &str) -> String {
189 js_sys::encode_uri_component(seg).into()
190}
191
192fn decode_segment(seg: &str) -> String {
193 // A hand-typed hash can hold invalid escapes; keep it verbatim then.
194 js_sys::decode_uri_component(seg)
195 .map(String::from)
196 .unwrap_or_else(|_| seg.to_string())
197}
198
199pub fn current_hash() -> String {
200 web_sys::window()
201 .and_then(|w| w.location().hash().ok())
202 .unwrap_or_default()
203}
204
205pub fn parse_location() -> Location {
206 parse_hash(&current_hash())
207}
208
209pub fn parse_hash(hash: &str) -> Location {
210 let h = hash.trim_start_matches('#');
211 let h = h.trim_start_matches('/');
212 let (h, query) = h.split_once('?').unwrap_or((h, ""));
213 let mut parts = h.split('/').filter(|s| !s.is_empty());
214 let mut loc = match parts.next() {
215 Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
216 Some(root_id) => Location {
217 root_id: Some(root_id),
218 path: parts.map(decode_segment).collect(),
219 ..Location::root()
220 },
221 None => Location::root(),
222 },
223 // `#/share/{token}/...` — public share page.
224 Some("share") => match parts.next() {
225 Some(token) => Location {
226 path: parts.map(decode_segment).collect(),
227 share_token: Some(token.to_string()),
228 ..Location::root()
229 },
230 // `#/share` without a token is not a share; treat it as home.
231 None => Location::root(),
232 },
233 // `#/search`, `#/shares`, `#/users`, `#/settings/{tab}` — the manage
234 // views. None carries a root.
235 seg => match seg.and_then(Section::from_segment) {
236 Some(section) => Location {
237 section,
238 path: parts.map(decode_segment).collect(),
239 ..Location::root()
240 },
241 None => Location::root(),
242 },
243 };
244 let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else {
245 return loc;
246 };
247 // A folder URL without them (typed, or from before they existed)
248 // gets the saved ones.
249 if loc.is_folder() {
250 loc.sort = SortSpec::load();
251 loc.size = load_page_size();
252 }
253 for k in QUERY_KEYS {
254 if let Some(v) = params.get(k) {
255 loc.apply_pair(k, v);
256 }
257 }
258 if loc.section == Section::Search {
259 let _ = params.sort();
260 for k in QUERY_KEYS {
261 params.delete(k);
262 }
263 loc.search = params.to_string().into();
264 }
265 loc
266}
267
268pub fn location_to_hash(loc: &Location) -> String {
269 let mut s = if let Some(token) = &loc.share_token {
270 format!("#/share/{token}")
271 } else if let Some(segment) = loc.section.segment() {
272 format!("#/{segment}")
273 } else if let Some(id) = loc.root_id {
274 format!("#/files/{id}")
275 } else {
276 "#/".to_string()
277 };
278 for seg in &loc.path {
279 s.push('/');
280 s.push_str(&encode_segment(seg));
281 }
282 let pairs = loc.query_pairs();
283 if let (false, Ok(params)) = (pairs.is_empty(), web_sys::UrlSearchParams::new()) {
284 for (k, v) in pairs {
285 params.append(k, &v);
286 }
287 s.push('?');
288 s.push_str(&String::from(params.to_string()));
289 }
290 s
291}
292
293thread_local! {
294 /// The server's `--public-url`, set from every `/me` response. `None`
295 /// means "use the address the browser is connected to".
296 static PUBLIC_URL: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
297}
298
299pub fn set_public_url(url: Option<String>) {
300 PUBLIC_URL.with(|p| *p.borrow_mut() = url);
301}
302
303fn public_url() -> Option<String> {
304 PUBLIC_URL.with(|p| p.borrow().clone())
305}
306
307/// The absolute URL of a server route: under the public URL when the
308/// server has one, else the browser's origin. Origin-relative, not
309/// page-relative: server routes do not live under the app's own path.
310pub fn server_url(path: &str) -> String {
311 let origin = public_url().unwrap_or_else(|| {
312 web_sys::window()
313 .and_then(|w| w.location().origin().ok())
314 .unwrap_or_default()
315 });
316 format!("{}{path}", origin.trim_end_matches('/'))
317}
318
319/// Build the WebDAV mount URL for a share token. Only folder shares have
320/// one; a file share has no collection to mount.
321pub fn dav_share_url(token: &str) -> String {
322 server_url(&format!("{}/{token}", api_types::DAV_SHARE))
323}
324
325/// Build the share link for a token: under the public URL when the server
326/// has one, else relative to the current page.
327pub fn share_url(token: &str) -> String {
328 if let Some(p) = public_url() {
329 return format!("{p}/#/share/{token}");
330 }
331 let base = web_sys::window()
332 .and_then(|w| w.location().href().ok())
333 .unwrap_or_default();
334 // Cut off any existing hash, then append the share hash.
335 let cut = base.find('#').unwrap_or(base.len());
336 format!("{}#/share/{token}", &base[..cut])
337}
338
339thread_local! {
340 static SET_LOC: std::cell::Cell<Option<WriteSignal<Location>>> =
341 const { std::cell::Cell::new(None) };
342 /// Set by [`close_open`] until its Back arrives, so a second close does
343 /// not go Back twice.
344 static CLOSING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
345}
346
347/// Make `set_loc` follow the URL: now, on Back / Forward, and on every
348/// change made through this module. `popstate` also fires for a changed
349/// hash (a link, a typed URL).
350pub fn install(set_loc: WriteSignal<Location>) {
351 SET_LOC.with(|s| s.set(Some(set_loc)));
352 sync();
353 crate::util::owned_window_listener(leptos::ev::popstate, |_| sync());
354 // Back into a page from before a reload can restore it from the
355 // browser's page cache, without a popstate.
356 crate::util::owned_window_listener(leptos::ev::pageshow, |_| sync());
357}
358
359fn sync() {
360 CLOSING.with(|c| c.set(false));
361 let Some(set_loc) = SET_LOC.with(std::cell::Cell::get) else {
362 return;
363 };
364 let hash = current_hash();
365 let next = parse_hash(&hash);
366 // Spell out a folder URL's position, so a copied link reproduces it.
367 let canon = location_to_hash(&next);
368 if next.is_folder() && canon != hash {
369 replace_state(&canon);
370 }
371 set_loc.maybe_update(|cur| {
372 let changed = *cur != next;
373 if changed {
374 *cur = next;
375 }
376 changed
377 });
378}
379
380/// History state of an entry that [`set_open`] pushed. Closing that file
381/// goes Back instead of adding an entry.
382const OPEN_MARK: &str = "fbng-open";
383/// History state of the entry a file was opened from.
384const ORIGIN_MARK: &str = "fbng-origin";
385
386fn history() -> Option<web_sys::History> {
387 web_sys::window()?.history().ok()
388}
389
390fn state_is(mark: &str) -> bool {
391 history()
392 .and_then(|h| h.state().ok())
393 .and_then(|s| s.as_string())
394 .is_some_and(|s| s == mark)
395}
396
397/// Keeps the state: a replaced entry stays marked.
398fn replace_state(url: &str) {
399 if let Some(h) = history() {
400 let state = h.state().unwrap_or(JsValue::NULL);
401 let _ = h.replace_state_with_url(&state, "", Some(url));
402 }
403}
404
405fn push_state(url: &str, state: &JsValue) {
406 if url == current_hash() {
407 replace_state(url);
408 } else if let Some(h) = history() {
409 let _ = h.push_state_with_url(state, "", Some(url));
410 }
411}
412
413/// Go to a new location, with a history entry.
414pub fn navigate(loc: &Location) {
415 push_state(&location_to_hash(loc), &JsValue::NULL);
416 sync();
417}
418
419/// Correct the current location, without a history entry.
420pub fn replace(loc: &Location) {
421 replace_url(&location_to_hash(loc));
422}
423
424pub fn replace_url(url: &str) {
425 replace_state(url);
426 sync();
427}
428
429/// `hash` with `open` set or removed. Edits the raw hash, so the search
430/// view's own params survive.
431pub fn hash_with_open(hash: &str, open: Option<&str>) -> String {
432 let (path, query) = hash.split_once('?').unwrap_or((hash, ""));
433 let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else {
434 return hash.to_string();
435 };
436 match open {
437 Some(o) => params.set("open", o),
438 None => params.delete("open"),
439 }
440 let qs = String::from(params.to_string());
441 if qs.is_empty() {
442 path.to_string()
443 } else {
444 format!("{path}?{qs}")
445 }
446}
447
448/// Put the open file into the URL. Opening one adds a history entry, so Back
449/// closes it. Switching to another file replaces the entry.
450pub fn set_open(open: &str) {
451 let url = hash_with_open(&current_hash(), Some(open));
452 if parse_location().open.is_some() {
453 replace_url(&url);
454 return;
455 }
456 if let Some(h) = history() {
457 let _ = h.replace_state_with_url(&JsValue::from_str(ORIGIN_MARK), "", None);
458 }
459 push_open_url(&url);
460}
461
462/// Take the open file out of the URL: Back to the entry it was opened
463/// from, else a replace (the page was loaded with the file open).
464pub fn close_open() {
465 if CLOSING.with(std::cell::Cell::get) {
466 return;
467 }
468 match history() {
469 Some(h) if state_is(OPEN_MARK) => {
470 CLOSING.with(|c| c.set(true));
471 let _ = h.back();
472 }
473 _ => clear_open(),
474 }
475}
476
477/// Whether Back has just returned to the entry a file was opened from.
478pub fn at_open_origin() -> bool {
479 state_is(ORIGIN_MARK)
480}
481
482/// Take the open file out of the URL, without a history entry.
483pub fn clear_open() {
484 replace_url(&hash_with_open(&current_hash(), None));
485}
486
487/// Add an entry for `url`, which has an open file. Also undoes a Back that
488/// must not close the file.
489pub fn push_open_url(url: &str) {
490 push_state(url, &JsValue::from_str(OPEN_MARK));
491 sync();
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn query_pairs_round_trip() {
500 let loc = Location {
501 root_id: Some(1),
502 page: 2,
503 size: 200,
504 sort: SortSpec {
505 key: SortKey::Size,
506 asc: false,
507 },
508 open: Some("a b.jpg".into()),
509 ..Location::root()
510 };
511 let mut back = Location::root();
512 for (k, v) in loc.query_pairs() {
513 back.apply_pair(k, v);
514 }
515 assert_eq!(
516 Location {
517 root_id: None,
518 ..back
519 },
520 Location {
521 root_id: None,
522 ..loc
523 }
524 );
525 // A folder always names its order and size; other views do not.
526 let folder = Location {
527 root_id: Some(1),
528 ..Location::root()
529 };
530 assert_eq!(
531 folder.query_pairs(),
532 [("sort", "name".to_string()), ("size", "100".to_string())]
533 );
534 assert!(Location::root().query_pairs().is_empty());
535 // Hand-typed values that make no sense keep the defaults.
536 let mut bad = Location::root();
537 bad.apply_pair("size", "37".into());
538 bad.apply_pair("page", "x".into());
539 bad.apply_pair("sort", "color".into());
540 bad.apply_pair("open", String::new());
541 assert_eq!(bad, Location::root());
542 }
543}
544