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` — the signed-in shell's manage views
7//!
8//! The URL hash is the single source of truth; the `Location` signal in the
9//! app mirrors it.
10
11/// Which section of the signed-in shell a location points at. `Files` is the
12/// browser; the rest are the manage views, each with its own URL.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum Section {
15 Files,
16 /// The search view (`#/search`); query params live in the hash query.
17 Search,
18 Shares,
19 Users,
20 Settings,
21}
22
23impl Section {
24 /// The URL segment for this section, or `None` for `Files` (which has no
25 /// segment of its own — the browser is the default view).
26 const fn segment(self) -> Option<&'static str> {
27 match self {
28 Section::Files => None,
29 Section::Search => Some("search"),
30 Section::Shares => Some("shares"),
31 Section::Users => Some("users"),
32 Section::Settings => Some("settings"),
33 }
34 }
35
36 /// The inverse of [`segment`]: the section a URL segment names. `files`
37 /// has no segment, so it is never returned here.
38 fn from_segment(seg: &str) -> Option<Self> {
39 [
40 Section::Search,
41 Section::Shares,
42 Section::Users,
43 Section::Settings,
44 ]
45 .into_iter()
46 .find(|s| s.segment() == Some(seg))
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Location {
52 /// None = the top-level "folders" view (root picker).
53 pub root_id: Option<i64>,
54 /// Path segments relative to the current root (or share target).
55 pub path: Vec<String>,
56 /// Present when the location is a public share (`#/share/{token}/...`).
57 pub share_token: Option<String>,
58 /// Which shell section the location points at.
59 pub section: Section,
60}
61
62impl Location {
63 pub const fn root() -> Self {
64 Self {
65 root_id: None,
66 path: Vec::new(),
67 share_token: None,
68 section: Section::Files,
69 }
70 }
71}
72
73/// Percent-encode one path segment for the URL hash. Without this a name
74/// containing `#`, `%` or `/` would break the round trip.
75fn encode_segment(seg: &str) -> String {
76 js_sys::encode_uri_component(seg).into()
77}
78
79fn decode_segment(seg: &str) -> String {
80 // A hand-typed hash can hold invalid escapes; keep it verbatim then.
81 js_sys::decode_uri_component(seg)
82 .map(String::from)
83 .unwrap_or_else(|_| seg.to_string())
84}
85
86pub fn parse_location() -> Location {
87 let hash = web_sys::window()
88 .and_then(|w| w.location().hash().ok())
89 .unwrap_or_default();
90 let h = hash.trim_start_matches('#');
91 let h = h.trim_start_matches('/');
92 // Hash query (e.g. `#/search?q=…`) is not part of the path; the search
93 // view parses it itself.
94 let h = h.split_once('?').map(|(p, _)| p).unwrap_or(h);
95 let mut parts = h.split('/').filter(|s| !s.is_empty());
96 match parts.next() {
97 Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
98 Some(root_id) => Location {
99 root_id: Some(root_id),
100 path: parts.map(decode_segment).collect(),
101 share_token: None,
102 section: Section::Files,
103 },
104 None => Location::root(),
105 },
106 // `#/share/{token}/...` — public share page.
107 Some("share") => match parts.next() {
108 Some(token) => Location {
109 root_id: None,
110 path: parts.map(decode_segment).collect(),
111 share_token: Some(token.to_string()),
112 section: Section::Files,
113 },
114 // `#/share` without a token is not a share; treat it as home.
115 None => Location::root(),
116 },
117 // `#/search`, `#/shares`, `#/users`, `#/settings` — the manage views.
118 // Each is just a section; none carries a root or a path.
119 seg => match seg.and_then(Section::from_segment) {
120 Some(section) => Location {
121 section,
122 ..Location::root()
123 },
124 None => Location::root(),
125 },
126 }
127}
128
129pub fn location_to_hash(loc: &Location) -> String {
130 if let Some(token) = &loc.share_token {
131 let mut s = format!("#/share/{token}");
132 for seg in &loc.path {
133 s.push('/');
134 s.push_str(&encode_segment(seg));
135 }
136 return s;
137 }
138 if let Some(segment) = loc.section.segment() {
139 return format!("#/{segment}");
140 }
141 match loc.root_id {
142 None => "#/".to_string(),
143 Some(id) => {
144 let mut s = format!("#/files/{id}");
145 for seg in &loc.path {
146 s.push('/');
147 s.push_str(&encode_segment(seg));
148 }
149 s
150 }
151 }
152}
153
154/// Change the app location (updates the hash; the app listens for hashchange).
155pub fn navigate(loc: &Location) {
156 if let Some(w) = web_sys::window() {
157 let _ = w.location().set_hash(&location_to_hash(loc));
158 }
159}
160
161thread_local! {
162 /// The server's `--public-url`, set from every `/me` response. `None`
163 /// means "use the address the browser is connected to".
164 static PUBLIC_URL: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
165}
166
167pub fn set_public_url(url: Option<String>) {
168 PUBLIC_URL.with(|p| *p.borrow_mut() = url);
169}
170
171fn public_url() -> Option<String> {
172 PUBLIC_URL.with(|p| p.borrow().clone())
173}
174
175/// Build the WebDAV mount URL for a share token.
176///
177/// Origin-relative, not page-relative: the mount is a server route, so it does
178/// not live under whatever path the app itself was loaded from. Only folder
179/// shares have one; a file share has no collection to mount.
180pub fn dav_share_url(token: &str) -> String {
181 let origin = public_url().unwrap_or_else(|| {
182 web_sys::window()
183 .and_then(|w| w.location().origin().ok())
184 .unwrap_or_default()
185 });
186 format!("{origin}{}/{token}", api_types::DAV_SHARE)
187}
188
189/// Build the share link for a token: under the public URL when the server
190/// has one, else relative to the current page.
191pub fn share_url(token: &str) -> String {
192 if let Some(p) = public_url() {
193 return format!("{p}/#/share/{token}");
194 }
195 let base = web_sys::window()
196 .and_then(|w| w.location().href().ok())
197 .unwrap_or_default();
198 // Cut off any existing hash, then append the share hash.
199 let cut = base.find('#').unwrap_or(base.len());
200 format!("{}#/share/{token}", &base[..cut])
201}
202