//! Tiny hash-based router. //! //! - `#/` — root picker / single-root browser //! - `#/files/{root_id}/{segment/...}` — a user's folder //! - `#/share/{token}/{segment/...}` — a public share (no login) //! - `#/shares`, `#/users`, `#/settings`, `#/uploads` — the signed-in //! shell's manage views //! //! The URL hash is the single source of truth; the `Location` signal in the //! app mirrors it. /// Which section of the signed-in shell a location points at. `Files` is the /// browser; the rest are the manage views, each with its own URL. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Section { Files, /// The search view (`#/search`); query params live in the hash query. Search, Shares, Users, Settings, /// This session's upload jobs (`#/uploads`). Uploads, } impl Section { /// The URL segment for this section, or `None` for `Files` (which has no /// segment of its own — the browser is the default view). const fn segment(self) -> Option<&'static str> { match self { Section::Files => None, Section::Search => Some("search"), Section::Shares => Some("shares"), Section::Users => Some("users"), Section::Settings => Some("settings"), Section::Uploads => Some("uploads"), } } /// The inverse of [`segment`]: the section a URL segment names. `files` /// has no segment, so it is never returned here. fn from_segment(seg: &str) -> Option { [ Section::Search, Section::Shares, Section::Users, Section::Settings, Section::Uploads, ] .into_iter() .find(|s| s.segment() == Some(seg)) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Location { /// None = the top-level "folders" view (root picker). pub root_id: Option, /// Path segments relative to the current root (or share target). pub path: Vec, /// Present when the location is a public share (`#/share/{token}/...`). pub share_token: Option, /// Which shell section the location points at. pub section: Section, } impl Location { pub const fn root() -> Self { Self { root_id: None, path: Vec::new(), share_token: None, section: Section::Files, } } } /// Percent-encode one path segment for the URL hash. Without this a name /// containing `#`, `%` or `/` would break the round trip. fn encode_segment(seg: &str) -> String { js_sys::encode_uri_component(seg).into() } fn decode_segment(seg: &str) -> String { // A hand-typed hash can hold invalid escapes; keep it verbatim then. js_sys::decode_uri_component(seg) .map(String::from) .unwrap_or_else(|_| seg.to_string()) } pub fn parse_location() -> Location { let hash = web_sys::window() .and_then(|w| w.location().hash().ok()) .unwrap_or_default(); let h = hash.trim_start_matches('#'); let h = h.trim_start_matches('/'); // Hash query (e.g. `#/search?q=…`) is not part of the path; the search // view parses it itself. let h = h.split_once('?').map(|(p, _)| p).unwrap_or(h); let mut parts = h.split('/').filter(|s| !s.is_empty()); match parts.next() { Some("files") => match parts.next().and_then(|s| s.parse().ok()) { Some(root_id) => Location { root_id: Some(root_id), path: parts.map(decode_segment).collect(), share_token: None, section: Section::Files, }, None => Location::root(), }, // `#/share/{token}/...` — public share page. Some("share") => match parts.next() { Some(token) => Location { root_id: None, path: parts.map(decode_segment).collect(), share_token: Some(token.to_string()), section: Section::Files, }, // `#/share` without a token is not a share; treat it as home. None => Location::root(), }, // `#/search`, `#/shares`, `#/users`, `#/settings` — the manage views. // Each is just a section; none carries a root or a path. seg => match seg.and_then(Section::from_segment) { Some(section) => Location { section, ..Location::root() }, None => Location::root(), }, } } pub fn location_to_hash(loc: &Location) -> String { if let Some(token) = &loc.share_token { let mut s = format!("#/share/{token}"); for seg in &loc.path { s.push('/'); s.push_str(&encode_segment(seg)); } return s; } if let Some(segment) = loc.section.segment() { return format!("#/{segment}"); } match loc.root_id { None => "#/".to_string(), Some(id) => { let mut s = format!("#/files/{id}"); for seg in &loc.path { s.push('/'); s.push_str(&encode_segment(seg)); } s } } } /// Change the app location (updates the hash; the app listens for hashchange). pub fn navigate(loc: &Location) { if let Some(w) = web_sys::window() { let _ = w.location().set_hash(&location_to_hash(loc)); } } thread_local! { /// The server's `--public-url`, set from every `/me` response. `None` /// means "use the address the browser is connected to". static PUBLIC_URL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } pub fn set_public_url(url: Option) { PUBLIC_URL.with(|p| *p.borrow_mut() = url); } fn public_url() -> Option { PUBLIC_URL.with(|p| p.borrow().clone()) } /// Build the WebDAV mount URL for a share token. /// /// Origin-relative, not page-relative: the mount is a server route, so it does /// not live under whatever path the app itself was loaded from. Only folder /// shares have one; a file share has no collection to mount. pub fn dav_share_url(token: &str) -> String { let origin = public_url().unwrap_or_else(|| { web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_default() }); format!("{origin}{}/{token}", api_types::DAV_SHARE) } /// Build the share link for a token: under the public URL when the server /// has one, else relative to the current page. pub fn share_url(token: &str) -> String { if let Some(p) = public_url() { return format!("{p}/#/share/{token}"); } let base = web_sys::window() .and_then(|w| w.location().href().ok()) .unwrap_or_default(); // Cut off any existing hash, then append the share hash. let cut = base.find('#').unwrap_or(base.len()); format!("{}#/share/{token}", &base[..cut]) }