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