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