URL holds listing page, order, size and open file, with history

- Folder URLs always carry sort/size; bare ones get the saved prefs
- Opening a file pushes an entry; arrow steps replace it; Back closes it
- Server `around=<name>` returns the page holding a file (deep links)
- Search results and the settings tab follow the URL too
- Shell no longer rebuilds per location change; folder change resets
  selection and dialogs; unsaved editor changes survive Back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitf9606545ea1bf2acc81c27c8e52098f933f4959b
Parent4fc67a2
12 files changed, 878 insertions(+), 233 deletions(-)
▾Mapi-types/src/lib.rs
@@ -109,6 +109,9 @@ pub const P_OFFSET: &str = "offset";
pub const P_LIMIT: &str = "limit";
/// `?dirs=true` lists only the subfolders (the folder picker).
pub const P_DIRS: &str = "dirs";
/// `?around=name` returns the page that holds this entry, instead of the
/// one at the offset. A missing name falls back to the offset.
pub const P_AROUND: &str = "around";
// ---------------------------------------------------------------------------
// Wire enums
▾Mserver/src/api/files.rs
@@ -65,6 +65,8 @@ pub struct FileQuery {
limit: Option<usize>,
#[serde(default)]
dirs: bool,
#[serde(default)]
around: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -115,6 +117,7 @@ pub async fn file_get(
offset: query.offset,
limit: query.limit,
dirs_only: query.dirs,
around: query.around.clone(),
};
let json = list_inner(state, auth, root_id, req_rel, opts).await?;
Ok(json.into_response())
@@ -1475,7 +1478,9 @@ fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError
#[cfg(test)]
mod tests {
use super::*;
use api_types::{ACTION_DOWNLOAD, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_SORT};
use api_types::{
ACTION_DOWNLOAD, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_SORT,
};
use axum::http::Uri;
/// The publish step must never replace a file that appeared after the
@@ -1528,13 +1533,20 @@ mod tests {
assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD));
assert_eq!(q.format.as_deref(), Some("zip"));
let list_uri: Uri =
format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true")
format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true&{P_AROUND}=a.txt")
.parse()
.unwrap();
let q: FileQuery = AxumQuery::try_from_uri(&list_uri).unwrap().0;
assert_eq!(
(q.sort, q.desc, q.offset, q.limit, q.dirs),
(SortKey::Size, true, 5, Some(10), true)
(
q.sort,
q.desc,
q.offset,
q.limit,
q.dirs,
q.around.as_deref()
),
(SortKey::Size, true, 5, Some(10), true, Some("a.txt"))
);
// The same constants drive the hand-rolled readers on the POST path.
▾Mserver/src/fs.rs
@@ -117,7 +117,7 @@ fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
}
/// Which slice of a folder [`list_dir`] returns, and in what order.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug, Default)]
pub struct ListOpts {
pub sort: SortKey,
pub desc: bool,
@@ -125,6 +125,8 @@ pub struct ListOpts {
/// Clamped to `1..=MAX_LIST_ENTRIES`; `None` = the cap.
pub limit: Option<usize>,
pub dirs_only: bool,
/// Return the page that holds this name; `offset` when it is missing.
pub around: Option<String>,
}
/// List one page of a directory (blocking — call via spawn_blocking).
@@ -164,8 +166,14 @@ pub fn list_dir(dir: &Path, opts: ListOpts) -> Result<FilesResp, FsError> {
.limit
.unwrap_or(api_types::MAX_LIST_ENTRIES)
.clamp(1, api_types::MAX_LIST_ENTRIES);
let at = opts
.around
.as_deref()
.and_then(|n| rows.iter().position(|(e, _)| e.name == n));
// Past the end happens when a delete empties the last page.
let offset = if opts.offset < total {
let offset = if let Some(i) = at {
i / limit * limit
} else if opts.offset < total {
opts.offset
} else {
total.saturating_sub(1) / limit * limit
@@ -1162,6 +1170,7 @@ mod tests {
offset,
limit: Some(limit),
dirs_only,
around: None,
};
let r = list_dir(&d, opts).unwrap();
let names: Vec<String> = r.entries.into_iter().map(|e| e.name).collect();
@@ -1183,6 +1192,20 @@ mod tests {
// A zero limit is one entry, not a division by zero.
assert_eq!(page(2, 0, false), (vec!["f5".into()], 8, 2));
assert_eq!(page(0, usize::MAX, false).0.len(), 8);
let around = |name: &str| {
let opts = ListOpts {
sort: SortKey::Size,
desc: true,
offset: 3,
limit: Some(3),
around: Some(name.into()),
..ListOpts::default()
};
list_dir(&d, opts).unwrap().offset
};
assert_eq!(around("f1"), 6);
assert_eq!(around("sub"), 0);
assert_eq!(around("missing"), 3);
let empty = t.root.join("empty");
std::fs::create_dir_all(&empty).unwrap();
▾Mweb/src/api.rs
@@ -18,10 +18,10 @@ use api_types::{
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateAppPassword,
CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
Mutation, P_ACTION, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH, P_Q,
P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish,
Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, SortKey, UnlockShare,
UpdateUser,
Mutation, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH,
P_Q, P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish,
PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings,
SortKey, UnlockShare, UpdateUser,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
@@ -329,7 +329,8 @@ fn files_url(root_id: i64, path: &str) -> String {
format!("{base}{}", share_suffix())
}
/// One page of a folder, in the given order.
/// One page of a folder, in the given order. With `around`, the page that
/// holds that name.
pub fn list_files(
root_id: i64,
path: &str,
@@ -337,15 +338,29 @@ pub fn list_files(
desc: bool,
offset: usize,
limit: usize,
around: Option<&str>,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
let url = append_query(
&files_url(root_id, path),
&format!(
"{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
sort.as_str()
),
let mut query = format!(
"{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
sort.as_str()
);
request("GET", url, None::<()>)
if let Some(name) = around {
query.push_str(&format!(
"&{P_AROUND}={}",
String::from(js_sys::encode_uri_component(name))
));
}
request(
"GET",
append_query(&files_url(root_id, path), &query),
None::<()>,
)
}
/// One entry of a folder, with its sniffed kind. `None` when it is gone.
pub async fn find_entry(root_id: i64, dir: &str, name: &str) -> Result<Option<Entry>, ApiError> {
let r = list_files(root_id, dir, SortKey::Name, false, 0, 1, Some(name)).await?;
Ok(r.entries.into_iter().find(|e| e.name == name))
}
/// The subfolders of a folder, by name.
▾Mweb/src/app.rs
@@ -3,7 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::i18n::{self, Locale};
use crate::router::{Location, parse_location};
use crate::router::Location;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum AuthPhase {
@@ -43,12 +43,10 @@ pub fn App() -> impl IntoView {
});
// The URL hash is the source of truth for the location.
{
set_loc.set(parse_location());
crate::util::owned_window_listener(leptos::ev::hashchange, move |_| {
set_loc.set(parse_location());
});
}
crate::router::install(set_loc);
// Read through a memo, so the views below are not rebuilt on every
// location change.
let share_token = Memo::new(move |_| loc.with(|l| l.share_token.clone()));
// Initial /me probe: first boot, signed in, or needs login.
spawn_local(async move {
@@ -69,8 +67,7 @@ pub fn App() -> impl IntoView {
view! {
{move || {
// Public share links (`#/share/{token}/...`) bypass the auth flow.
let loc_now = loc.get();
if let Some(token) = loc_now.share_token.clone() {
if let Some(token) = share_token.get() {
return view! {
<crate::views::share_page::ShareView token=token loc=loc/>
}
▾Mweb/src/i18n.rs
@@ -335,6 +335,7 @@ i18n_keys! {
FILE_CREATED = "file_created" => "File created",
FILE_INFO = "file_info" => "File info",
FILE_NAME = "file_name" => "File name",
FILE_NOT_FOUND = "file_not_found" => "File not found",
FILES = "files" => "Files",
FMT_TAR = "fmt_tar" => "Tar",
FMT_TARGZ = "fmt_targz" => "Tar + gzip",
@@ -891,6 +892,7 @@ const DE: &[(&str, &str)] = &[
("file_created", "Datei erstellt"),
("file_info", "Datei-Info"),
("file_name", "Dateiname"),
("file_not_found", "Datei nicht gefunden"),
("files", "Dateien"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
@@ -1579,6 +1581,7 @@ const FR: &[(&str, &str)] = &[
("file_created", "Fichier créé"),
("file_info", "Informations"),
("file_name", "Nom du fichier"),
("file_not_found", "Fichier introuvable"),
("files", "Fichiers"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
▾Mweb/src/router.rs
@@ -3,11 +3,17 @@
//! - `#/` — 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
//! - `#/shares`, `#/users`, `#/settings/{tab}`, `#/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.
//! app mirrors it. A folder listing carries `?page=&size=&sort=&desc=`, and
//! an open file `open=`.
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use crate::util::{FILE_PAGE, PAGE_SIZES, SortKey, SortSpec, load_page_size};
/// 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.
@@ -56,12 +62,23 @@ impl Section {
pub struct Location {
/// None = the top-level "folders" view (root picker).
pub root_id: Option<i64>,
/// Path segments relative to the current root (or share target).
/// Path segments relative to the current root (or share target). On the
/// settings view, the tab.
pub path: Vec<String>,
/// Present when the location is a public share (`#/share/{token}/...`).
pub share_token: Option<String>,
/// Which shell section the location points at.
pub section: Section,
/// The listing page, 0-based. The URL counts from 1.
pub page: usize,
pub size: usize,
pub sort: SortSpec,
/// The open file: its name in the folder, or on the search view its
/// path in the searched root.
pub open: Option<String>,
/// The search view's own params. Only compared: a new search is a new
/// location.
pub search: String,
}
impl Location {
@@ -71,10 +88,94 @@ impl Location {
path: Vec::new(),
share_token: None,
section: Section::Files,
page: 0,
size: FILE_PAGE,
sort: SortSpec {
key: SortKey::Name,
asc: true,
},
open: None,
search: String::new(),
}
}
/// A folder in the browser, in the user's saved order and page size.
pub fn folder(root_id: Option<i64>, share_token: Option<String>, path: Vec<String>) -> Self {
Self {
root_id,
path,
share_token,
sort: SortSpec::load(),
size: load_page_size(),
..Self::root()
}
}
/// A folder listing, which always carries its order and page size.
fn is_folder(&self) -> bool {
self.section == Section::Files && (self.root_id.is_some() || self.share_token.is_some())
}
/// What names the folder or view, without the listing params.
pub fn folder_key(&self) -> (Option<i64>, Vec<String>, Option<String>, Section) {
(
self.root_id,
self.path.clone(),
self.share_token.clone(),
self.section,
)
}
/// A folder's order and page size are always written, so a link keeps
/// its position when a default changes. Then the open file.
fn query_pairs(&self) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
if self.is_folder() {
if self.page != 0 {
out.push(("page", (self.page + 1).to_string()));
}
out.push(("sort", self.sort.key.as_str().to_string()));
if !self.sort.asc {
out.push(("desc", "1".to_string()));
}
out.push(("size", self.size.to_string()));
}
if let Some(o) = &self.open {
out.push(("open", o.clone()));
}
out
}
/// The inverse of [`Self::query_pairs`]. Bad values are ignored: they
/// come from hand-typed URLs.
fn apply_pair(&mut self, key: &str, value: String) {
match key {
"page" => {
if let Ok(n) = value.parse::<usize>() {
self.page = n.saturating_sub(1);
}
}
"size" => {
if let Some(n) = value.parse().ok().filter(|n| PAGE_SIZES.contains(n)) {
self.size = n;
}
}
"sort" => {
if let Some(key) = SortKey::parse(&value) {
self.sort = SortSpec { key, asc: true };
}
}
"desc" => self.sort.asc = value != "1",
"open" if !value.is_empty() => self.open = Some(value),
_ => {}
}
}
}
/// Query keys the router owns, in the order they apply: `desc` after
/// `sort`. The search view reads its own keys.
const QUERY_KEYS: [&str; 5] = ["page", "sort", "desc", "size", "open"];
/// 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 {
@@ -88,79 +189,98 @@ fn decode_segment(seg: &str) -> String {
.unwrap_or_else(|_| seg.to_string())
}
pub fn parse_location() -> Location {
let hash = web_sys::window()
pub fn current_hash() -> String {
web_sys::window()
.and_then(|w| w.location().hash().ok())
.unwrap_or_default();
.unwrap_or_default()
}
pub fn parse_location() -> Location {
parse_hash(&current_hash())
}
pub fn parse_hash(hash: &str) -> Location {
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 (h, query) = h.split_once('?').unwrap_or((h, ""));
let mut parts = h.split('/').filter(|s| !s.is_empty());
match parts.next() {
let mut loc = 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,
..Location::root()
},
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,
..Location::root()
},
// `#/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.
// `#/search`, `#/shares`, `#/users`, `#/settings/{tab}` — the manage
// views. None carries a root.
seg => match seg.and_then(Section::from_segment) {
Some(section) => Location {
section,
path: parts.map(decode_segment).collect(),
..Location::root()
},
None => Location::root(),
},
};
let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else {
return loc;
};
// A folder URL without them (typed, or from before they existed)
// gets the saved ones.
if loc.is_folder() {
loc.sort = SortSpec::load();
loc.size = load_page_size();
}
}
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));
for k in QUERY_KEYS {
if let Some(v) = params.get(k) {
loc.apply_pair(k, v);
}
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
}
if loc.section == Section::Search {
let _ = params.sort();
for k in QUERY_KEYS {
params.delete(k);
}
loc.search = params.to_string().into();
}
loc
}
/// 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));
pub fn location_to_hash(loc: &Location) -> String {
let mut s = if let Some(token) = &loc.share_token {
format!("#/share/{token}")
} else if let Some(segment) = loc.section.segment() {
format!("#/{segment}")
} else if let Some(id) = loc.root_id {
format!("#/files/{id}")
} else {
"#/".to_string()
};
for seg in &loc.path {
s.push('/');
s.push_str(&encode_segment(seg));
}
let pairs = loc.query_pairs();
if let (false, Ok(params)) = (pairs.is_empty(), web_sys::UrlSearchParams::new()) {
for (k, v) in pairs {
params.append(k, &v);
}
s.push('?');
s.push_str(&String::from(params.to_string()));
}
s
}
thread_local! {
@@ -204,3 +324,209 @@ pub fn share_url(token: &str) -> String {
let cut = base.find('#').unwrap_or(base.len());
format!("{}#/share/{token}", &base[..cut])
}
thread_local! {
static SET_LOC: std::cell::Cell<Option<WriteSignal<Location>>> =
const { std::cell::Cell::new(None) };
/// Set by [`close_open`] until its Back arrives, so a second close does
/// not go Back twice.
static CLOSING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Make `set_loc` follow the URL: now, on Back / Forward, and on every
/// change made through this module. `popstate` also fires for a changed
/// hash (a link, a typed URL).
pub fn install(set_loc: WriteSignal<Location>) {
SET_LOC.with(|s| s.set(Some(set_loc)));
sync();
crate::util::owned_window_listener(leptos::ev::popstate, |_| sync());
// Back into a page from before a reload can restore it from the
// browser's page cache, without a popstate.
crate::util::owned_window_listener(leptos::ev::pageshow, |_| sync());
}
fn sync() {
CLOSING.with(|c| c.set(false));
let Some(set_loc) = SET_LOC.with(std::cell::Cell::get) else {
return;
};
let hash = current_hash();
let next = parse_hash(&hash);
// Spell out a folder URL's position, so a copied link reproduces it.
let canon = location_to_hash(&next);
if next.is_folder() && canon != hash {
replace_state(&canon);
}
set_loc.maybe_update(|cur| {
let changed = *cur != next;
if changed {
*cur = next;
}
changed
});
}
/// History state of an entry that [`set_open`] pushed. Closing that file
/// goes Back instead of adding an entry.
const OPEN_MARK: &str = "fbng-open";
/// History state of the entry a file was opened from.
const ORIGIN_MARK: &str = "fbng-origin";
fn history() -> Option<web_sys::History> {
web_sys::window()?.history().ok()
}
fn state_is(mark: &str) -> bool {
history()
.and_then(|h| h.state().ok())
.and_then(|s| s.as_string())
.is_some_and(|s| s == mark)
}
/// Keeps the state: a replaced entry stays marked.
fn replace_state(url: &str) {
if let Some(h) = history() {
let state = h.state().unwrap_or(JsValue::NULL);
let _ = h.replace_state_with_url(&state, "", Some(url));
}
}
fn push_state(url: &str, state: &JsValue) {
if url == current_hash() {
replace_state(url);
} else if let Some(h) = history() {
let _ = h.push_state_with_url(state, "", Some(url));
}
}
/// Go to a new location, with a history entry.
pub fn navigate(loc: &Location) {
push_state(&location_to_hash(loc), &JsValue::NULL);
sync();
}
/// Correct the current location, without a history entry.
pub fn replace(loc: &Location) {
replace_url(&location_to_hash(loc));
}
pub fn replace_url(url: &str) {
replace_state(url);
sync();
}
/// `hash` with `open` set or removed. Edits the raw hash, so the search
/// view's own params survive.
pub fn hash_with_open(hash: &str, open: Option<&str>) -> String {
let (path, query) = hash.split_once('?').unwrap_or((hash, ""));
let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else {
return hash.to_string();
};
match open {
Some(o) => params.set("open", o),
None => params.delete("open"),
}
let qs = String::from(params.to_string());
if qs.is_empty() {
path.to_string()
} else {
format!("{path}?{qs}")
}
}
/// Put the open file into the URL. Opening one adds a history entry, so Back
/// closes it. Switching to another file replaces the entry.
pub fn set_open(open: &str) {
let url = hash_with_open(&current_hash(), Some(open));
if parse_location().open.is_some() {
replace_url(&url);
return;
}
if let Some(h) = history() {
let _ = h.replace_state_with_url(&JsValue::from_str(ORIGIN_MARK), "", None);
}
push_open_url(&url);
}
/// Take the open file out of the URL: Back to the entry it was opened
/// from, else a replace (the page was loaded with the file open).
pub fn close_open() {
if CLOSING.with(std::cell::Cell::get) {
return;
}
match history() {
Some(h) if state_is(OPEN_MARK) => {
CLOSING.with(|c| c.set(true));
let _ = h.back();
}
_ => clear_open(),
}
}
/// Whether Back has just returned to the entry a file was opened from.
pub fn at_open_origin() -> bool {
state_is(ORIGIN_MARK)
}
/// Take the open file out of the URL, without a history entry.
pub fn clear_open() {
replace_url(&hash_with_open(&current_hash(), None));
}
/// Add an entry for `url`, which has an open file. Also undoes a Back that
/// must not close the file.
pub fn push_open_url(url: &str) {
push_state(url, &JsValue::from_str(OPEN_MARK));
sync();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_pairs_round_trip() {
let loc = Location {
root_id: Some(1),
page: 2,
size: 200,
sort: SortSpec {
key: SortKey::Size,
asc: false,
},
open: Some("a b.jpg".into()),
..Location::root()
};
let mut back = Location::root();
for (k, v) in loc.query_pairs() {
back.apply_pair(k, v);
}
assert_eq!(
Location {
root_id: None,
..back
},
Location {
root_id: None,
..loc
}
);
// A folder always names its order and size; other views do not.
let folder = Location {
root_id: Some(1),
..Location::root()
};
assert_eq!(
folder.query_pairs(),
[("sort", "name".to_string()), ("size", "100".to_string())]
);
assert!(Location::root().query_pairs().is_empty());
// Hand-typed values that make no sense keep the defaults.
let mut bad = Location::root();
bad.apply_pair("size", "37".into());
bad.apply_pair("page", "x".into());
bad.apply_pair("sort", "color".into());
bad.apply_pair("open", String::new());
assert_eq!(bad, Location::root());
}
}
▾Mweb/src/views/admin.rs
@@ -12,6 +12,7 @@ use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
use crate::views::dialogs::{Dialog, admin_root};
// ---------------------------------------------------------------------------
@@ -30,27 +31,39 @@ enum SettingsTab {
impl SettingsTab {
const KEY: &'static str = "fbng.settings-tab";
fn load(is_admin: bool) -> Self {
match crate::util::storage_get(Self::KEY).as_deref() {
Some("server") if is_admin => SettingsTab::Server,
Some("profile") => SettingsTab::Profile,
Some("security") => SettingsTab::Security,
fn parse(s: &str, is_admin: bool) -> Option<Self> {
match s {
"server" if is_admin => Some(SettingsTab::Server),
"profile" => Some(SettingsTab::Profile),
"security" => Some(SettingsTab::Security),
_ => None,
}
}
const fn as_str(self) -> &'static str {
match self {
SettingsTab::Server => "server",
SettingsTab::Security => "security",
SettingsTab::Profile => "profile",
}
}
/// The tab in the URL, else the last one used.
fn current(url_tab: Option<&str>, is_admin: bool) -> Self {
url_tab
.and_then(|t| Self::parse(t, is_admin))
.or_else(|| Self::parse(&crate::util::storage_get(Self::KEY)?, is_admin))
// First visit: admins land on the server tab (the pre-existing
// default), everyone else on their profile.
_ if is_admin => SettingsTab::Server,
_ => SettingsTab::Profile,
}
.unwrap_or(if is_admin {
SettingsTab::Server
} else {
SettingsTab::Profile
})
}
fn save(self) {
crate::util::storage_set(
Self::KEY,
match self {
SettingsTab::Server => "server",
SettingsTab::Security => "security",
SettingsTab::Profile => "profile",
},
);
crate::util::storage_set(Self::KEY, self.as_str());
}
}
@@ -102,6 +115,8 @@ fn SettingToggle(
#[component]
pub fn SettingsView(
me: ReadSignal<Option<Me>>,
/// Its path holds the tab (`#/settings/security`).
loc: ReadSignal<Location>,
set_me: WriteSignal<Option<Me>>,
/// The folder picker for the search exclusions is rendered at the shell
/// level, like every other dialog.
@@ -109,12 +124,20 @@ pub fn SettingsView(
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
// The shell re-creates this view when /me changes (saving resets it), so
// the active tab is remembered in localStorage like the view mode.
let (tab, set_tab) = signal(SettingsTab::load(is_admin()));
// The URL names the tab, so a link can point at one and Back switches
// back. The plain `#/settings` link opens the last one used.
let tab = Memo::new(move |_| {
loc.with(|l| SettingsTab::current(l.path.first().map(String::as_str), is_admin()))
});
let pick_tab = move |t: SettingsTab| {
t.save();
set_tab.set(t);
if t != tab.get_untracked() {
navigate(&Location {
section: Section::Settings,
path: vec![t.as_str().to_string()],
..Location::root()
});
}
};
// --- profile: single-click-to-open ------------------------------------
▾Mweb/src/views/browser.rs
@@ -16,15 +16,25 @@ use crate::editor::EditTarget;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::router::{self, Location, navigate};
use crate::uploads::{self, OnConflict};
use crate::util::{
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, load_page_size, pager,
save_page_size,
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, pager, save_page_size,
};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
/// What a listing fetch asks for. Only a change of it fetches again.
#[derive(Clone, Debug, PartialEq)]
struct ListReq {
root_id: i64,
is_rw: bool,
path: Vec<String>,
sort: SortSpec,
size: usize,
page: usize,
}
/// One page of a folder listing, in display order. Behind an `Arc` in
/// [`ListState`] so reading the signal never copies the entries.
#[derive(Debug, PartialEq)]
@@ -34,6 +44,8 @@ struct Listing {
total: usize,
/// Position of `entries[0]` in the whole folder.
offset: usize,
/// The request, with the page the server answered with.
req: ListReq,
}
/// An arrow-key page turn in flight.
@@ -104,7 +116,7 @@ pub fn Browser(
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (view_mode, set_view_mode) = signal(ViewMode::load());
let (sort, set_sort) = signal(SortSpec::load());
let sort = Memo::new(move |_| loc.with(|l| l.sort));
let (list_state, set_list_state) = signal(ListState::Loading);
let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
// Long-lived owner for callbacks created inside short-lived scopes
@@ -116,14 +128,22 @@ pub fn Browser(
// sidebar and this view. The key guards against a refresh loop when
// /me comes back unchanged.
let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
// The page shown, 0-based. The fetch does not track it: it sets it from
// the server's answer, so tracking would loop. A page change calls the
// fetch itself.
let page = RwSignal::new(0usize);
let page_size = RwSignal::new(load_page_size());
// What the last fetch listed. Another folder or order starts at the
// first page; a refresh keeps the page.
let list_key = StoredValue::new(None::<(i64, String, SortSpec)>);
let req = Memo::new(move |_| {
me.with(|m| {
let m = m.as_ref()?;
loc.with(|l| {
effective_root(&m.roots, l).map(|r| ListReq {
root_id: r.id,
is_rw: r.mode.is_writable(),
path: l.path.clone(),
sort: l.sort,
size: l.size,
page: l.page,
})
})
})
});
let requested = StoredValue::new(None::<ListReq>);
// Set when the arrow keys step past the edge of the page.
let page_step = RwSignal::new(None::<PageStep>);
// Bumped per fetch. A response whose generation is no longer current
@@ -205,37 +225,48 @@ pub fn Browser(
)));
};
// Fetch the current page whenever the location, user, order or page
// size changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
// Fetch the page in the URL. `force` fetches it again (a refresh).
let load = Callback::new(move |force: bool| {
let Some(req) = req.get_untracked() else {
return;
};
let loc = loc.get();
let Some(root) = effective_root(&me.roots, &loc) else {
if !force && requested.with_value(|r| r.as_ref() == Some(&req)) {
return;
};
let spec = sort.get();
let size = page_size.get();
}
requested.set_value(Some(req.clone()));
// A file in the URL that is not open yet (a link, a reload) may sit
// on another page than the URL says.
let around = loc
.with_untracked(|l| l.open.clone())
.filter(|_| file_view.with_untracked(Option::is_none));
set_list_state.set(ListState::Loading);
fetch_gen.update_value(|g| *g += 1);
let my_gen = fetch_gen.get_value();
let root_id = root.id;
let path_str = loc.path.join("/");
let key = Some((root_id, path_str.clone(), spec));
if list_key.get_value() != key {
list_key.set_value(key);
page.set(0);
}
let offset = page.get_untracked() * size;
let path_str = req.path.join("/");
// On a public share page the (synthetic) "me" comes from the share
// itself, not from a session — refreshing /me would be wrong there.
let is_share = loc.share_token.is_some();
let is_share = loc.with_untracked(|l| l.share_token.is_some());
let set_me2 = set_me;
let refresh_key = me_refresh_key;
let set_refresh_key = set_me_refresh_key;
spawn_local(async move {
let resp = api::list_files(root_id, &path_str, spec.key, !spec.asc, offset, size).await;
let ListReq {
root_id,
sort: spec,
size,
page,
..
} = req;
let resp = api::list_files(
root_id,
&path_str,
spec.key,
!spec.asc,
page * size,
size,
around.as_deref(),
)
.await;
// A newer fetch (or the view going away) makes this response
// stale: applying it would show the wrong folder.
if fetch_gen.try_get_value() != Some(my_gen) {
@@ -243,11 +274,23 @@ pub fn Browser(
}
match resp {
Ok(r) => {
page.set(r.offset / size);
let got = ListReq {
page: r.offset / size,
..req
};
// Past the end, or `around` found the file elsewhere.
if got.page != page {
requested.set_value(Some(got.clone()));
router::replace(&Location {
page: got.page,
..loc.get_untracked()
});
}
let list = Arc::new(Listing {
entries: r.entries,
total: r.total,
offset: r.offset,
req: got,
});
set_list_state.set(ListState::Entries(list));
}
@@ -268,7 +311,18 @@ pub fn Browser(
}
});
});
Effect::new(move |_| fetch.run(()));
Effect::new(move |_| {
req.with(|_| ());
load.run(false);
});
let fetch = Callback::new(move |_| load.run(true));
let turn_to = move |page: usize| {
router::replace(&Location {
page,
..loc.get_untracked()
});
load.run(false);
};
// Load the page in `dir` for the arrow keys. False when there is none.
let advance = move |dir: isize, preview: bool, origin: usize, selected: Vec<Entry>| {
@@ -281,8 +335,7 @@ pub fn Browser(
list.offset > 0
};
if more {
page.update(|p| *p = p.saturating_add_signed(dir));
untrack(|| fetch.run(()));
turn_to(list.req.page.saturating_add_signed(dir));
page_step.set(Some(PageStep {
dir,
preview,
@@ -294,7 +347,12 @@ pub fn Browser(
more
};
let start_turn = move |dir: isize, preview: bool| {
advance(dir, preview, page.get_untracked(), selected.get_untracked());
advance(
dir,
preview,
loc.with_untracked(|l| l.page),
selected.get_untracked(),
);
};
let turn_page = Callback::new(move |dir: isize| start_turn(dir, false));
@@ -322,8 +380,7 @@ pub fn Browser(
}
let back = move |step: PageStep| {
set_selected.set(step.selected);
page.set(step.origin);
untrack(|| fetch.run(()));
turn_to(step.origin);
};
if !step.preview {
let idx = if step.dir > 0 {
@@ -360,6 +417,34 @@ pub fn Browser(
}
}
});
// A file named in the URL opens once its listing is on screen. The
// workspace closes it when the URL drops it.
let open_name = Memo::new(move |_| loc.with(|l| l.open.clone()));
Effect::new(move |_| {
let ListState::Entries(list) = list_state.get() else {
return;
};
let Some(name) = open_name.get() else {
return;
};
// An open file view is either this file, or the next one before
// the URL has caught up with it.
if file_view.with_untracked(Option::is_some)
|| req.with_untracked(|r| r.as_ref() != Some(&list.req))
{
return;
}
match list.entries.iter().find(|e| e.name == name && !e.is_dir) {
Some(e) => {
set_selected.set(vec![e.clone()]);
open_entry(e, list.req.root_id, loc, list.req.is_rw, open_file).run(());
}
None => {
show_error(toast, i18n::t(i18n::k::FILE_NOT_FOUND).to_string());
router::clear_open();
}
}
});
{
let w = set_browser_refresh;
let f = fetch;
@@ -529,6 +614,8 @@ pub fn Browser(
});
let drop_owner = ctx_owner.clone();
// A page turn or an open file keeps the rendered listing's frame.
let folder = Memo::new(move |_| loc.with(Location::folder_key));
view! {
<div class="browser">
{move || {
@@ -537,7 +624,8 @@ pub fn Browser(
view! {}.into_any()
};
};
let loc_now = loc.get();
folder.with(|_| ());
let loc_now = loc.get_untracked();
match effective_root(&me_now.roots, &loc_now) {
Some(root) => file_browser(
me,
@@ -577,13 +665,10 @@ pub fn Browser(
})
}),
list_state,
page,
page_size,
turn_page,
view_mode,
set_view_mode,
sort,
set_sort,
set_ctx,
selected,
sel_names,
@@ -661,13 +746,10 @@ fn file_browser(
// Upload the files of a drop into this folder; `None` when read-only.
drop_upload: Option<Callback<web_sys::DragEvent>>,
list_state: ReadSignal<ListState>,
page: RwSignal<usize>,
page_size: RwSignal<usize>,
turn_page: Callback<isize>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
sort: ReadSignal<SortSpec>,
set_sort: WriteSignal<SortSpec>,
sort: Memo<SortSpec>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
@@ -678,7 +760,16 @@ fn file_browser(
let root_id = root.id;
let root_name = root.name.clone();
let is_rw = root.mode.is_writable();
let loc_now = loc.get();
let loc_now = loc.get_untracked();
// A new order starts at the first page.
let set_sort = move |next: SortSpec| {
next.save();
router::replace(&Location {
sort: next,
page: 0,
..loc.get_untracked()
});
};
// The user's profile setting: single click opens entries; when off,
// a single click selects and a double click opens.
let single_click = me
@@ -695,12 +786,11 @@ fn file_browser(
<span
class="crumb"
on:click=move |_| {
navigate(&Location {
root_id: Some(root_id),
path: vec![],
share_token: loc.get().share_token.clone(),
section: Section::Files,
});
navigate(&Location::folder(
Some(root_id),
loc.get().share_token.clone(),
vec![],
));
}
>
{root_name.clone()}
@@ -718,12 +808,11 @@ fn file_browser(
<span
class=class
on:click=move |_| {
navigate(&Location {
root_id: Some(root_id),
path: target.clone(),
share_token: loc.get().share_token.clone(),
section: Section::Files,
});
navigate(&Location::folder(
Some(root_id),
loc.get().share_token.clone(),
target.clone(),
));
}
>
{seg}
@@ -753,9 +842,7 @@ fn file_browser(
on:change=move |ev| {
let Some(v) = crate::util::select_value(&ev) else { return };
let Some(key) = SortKey::parse(&v) else { return };
let next = SortSpec { key, asc: sort.get().asc };
set_sort.set(next);
next.save();
set_sort(SortSpec { key, asc: sort.get().asc });
}
>
{[
@@ -795,9 +882,7 @@ fn file_browser(
}
on:click=move |_| {
let cur = sort.get();
let next = SortSpec { key: cur.key, asc: !cur.asc };
set_sort.set(next);
next.save();
set_sort(SortSpec { key: cur.key, asc: !cur.asc });
}
>
{move || {
@@ -913,7 +998,17 @@ fn file_browser(
</div>
</Show>
{move || {
// Until the new folder's fetch starts, the listing is the
// old folder's.
let stale = move |l: &Listing| {
l.req.root_id != root_id || loc.with_untracked(|c| c.path != l.req.path)
};
match list_state.get() {
ListState::Entries(list) if stale(&list) => view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
ListState::Loading => view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
@@ -935,8 +1030,8 @@ fn file_browser(
.into_view()
.into_any();
}
let (offset, shown, total) =
(list.offset, list.entries.len(), list.total);
let (offset, shown, total, size) =
(list.offset, list.entries.len(), list.total, list.req.size);
let body = entries_view(
view_mode.get(),
list,
@@ -952,19 +1047,24 @@ fn file_browser(
set_ctx,
open_file,
);
let go = Callback::new(move |p: usize| {
page.set(p);
fetch.run(());
let go = Callback::new(move |page: usize| {
navigate(&Location {
page,
..loc.get_untracked()
});
});
// The new page holds the entry that was first on screen.
let set_size = Callback::new(move |n: usize| {
save_page_size(n);
page.set(offset / n);
page_size.set(n);
router::replace(&Location {
size: n,
page: offset / n,
..loc.get_untracked()
});
});
view! {
{body}
{pager(offset, shown, total, page_size.get_untracked(), go, set_size)}
{pager(offset, shown, total, size, go, set_size)}
}
.into_view()
.into_any()
@@ -1169,12 +1269,7 @@ fn open_entry(
let cur = loc.get();
let mut path = cur.path;
path.push(name.clone());
navigate(&Location {
root_id: Some(root_id),
path,
share_token: cur.share_token.clone(),
section: Section::Files,
});
navigate(&Location::folder(Some(root_id), cur.share_token, path));
return;
}
let full = join_path(&loc.get().path, &name);
▾Mweb/src/views/search.rs
@@ -25,7 +25,7 @@ use crate::editor::EditTarget;
use crate::i18n::{self, k};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::router::{self, Location, navigate};
use crate::util::{FILE_PAGE, format_size, page_footer};
use crate::views::dialogs::Dialog;
use crate::views::file_view::{FileView, UnsupportedTarget};
@@ -327,17 +327,13 @@ fn scroll_to_y(y: f64) {
fn goto_parent(root_id: i64, path: &str) {
let mut parts = split_rel(path);
parts.pop();
navigate(&Location {
root_id: Some(root_id),
path: parts,
share_token: None,
section: Section::Files,
});
navigate(&Location::folder(Some(root_id), None, parts));
}
#[component]
pub fn SearchView(
me: ReadSignal<Option<api_types::Me>>,
loc: ReadSignal<Location>,
open_file: WriteSignal<Option<FileView>>,
// While a file view is open it covers the content area, so the search
// UI hides (the view stays mounted: an in-flight search keeps running).
@@ -458,7 +454,24 @@ pub fn SearchView(
}
});
let clear_results = {
let seen = seen_match_files.clone();
move || {
files_w.set(Vec::new());
files_total.set(0);
shown_files.set(FILE_PAGE);
shown_cards.set(CARD_PAGE);
match_files_w.set(Vec::new());
matches_total.set(0);
match_files_total.set(0);
seen.lock().unwrap().clear();
registry.set(HashMap::new());
collapsed_n.set(0);
}
};
let start_search = Callback::new({
let clear_results = clear_results.clone();
let stop = stop_search;
let source = source.clone();
let search_gen = search_gen.clone();
@@ -486,26 +499,15 @@ pub fn SearchView(
};
set_folder.set(Some((root, dir.clone())));
// Fresh state for this run.
files_w.set(Vec::new());
files_total.set(0);
shown_files.set(FILE_PAGE);
shown_cards.set(CARD_PAGE);
match_files_w.set(Vec::new());
matches_total.set(0);
match_files_total.set(0);
seen_match_files.lock().unwrap().clear();
registry.set(HashMap::new());
collapsed_n.set(0);
clear_results();
status.set(Status::Searching);
// Write the search into the hash without adding a history entry.
if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
let _ = hist.replace_state_with_url(
&wasm_bindgen::JsValue::NULL,
"",
Some(&search_hash(&q, scope, root, &dir)),
);
}
// A file opened from a link stays in it.
let open = loc.with_untracked(|l| l.open.clone());
router::replace_url(&router::hash_with_open(
&search_hash(&q, scope, root, &dir),
open.as_deref(),
));
words.set_value(query_words(&q));
let src = match api::search_stream(
@@ -583,7 +585,7 @@ pub fn SearchView(
// Deep link: `#/search?root=…&path=…` sets the start folder (the
// browser's "search in this folder" arrives like this, with no query);
// with a `q` the search also re-runs.
{
let apply_url = move || {
let (q0, s0, f0) = parse_search_url();
// Only honour a root the caller actually has; otherwise keep the
// default so a stale link still searches something.
@@ -594,15 +596,80 @@ pub fn SearchView(
{
set_folder.set(Some((id, dir)));
}
if !q0.is_empty() {
set_query.set(q0);
set_scope.set(s0);
// Run the search from setup (an untracked context), not from an
// `Effect`: the effect would track `start_search`'s signal reads
// and retrigger on the very signal it writes (`folder`),
// re-running the search in a loop and pegging the main thread.
start_search.run(());
}
set_query.set(q0);
set_scope.set(s0);
// Untracked: from an `Effect`, `start_search`'s signal reads would
// retrigger it on the very signal it writes (`folder`), re-running
// the search in a loop and pegging the main thread.
untrack(|| start_search.run(()));
};
apply_url();
// Another search in the URL (Back, a link, the sidebar's bare
// `#/search`). The one this view wrote itself matches its state.
{
let search = Memo::new(move |_| loc.with(|l| l.search.clone()));
Effect::new(move |prev: Option<()>| {
search.with(|_| ());
if prev.is_none() {
return;
}
let (q0, s0, f0) = parse_search_url();
let same_folder = f0.is_none_or(|f| folder.get_untracked() == Some(f));
if same_folder && q0 == query.get_untracked() && s0 == scope.get_untracked() {
return;
}
if q0.trim().is_empty() {
stop_search.run(());
clear_results();
status.set(Status::Idle);
}
apply_url();
});
}
// A file in the URL that is not open yet (a link, Forward) opens from
// its path. The workspace closes it when the URL drops it.
{
let open = Memo::new(move |_| loc.with(|l| l.open.clone()));
Effect::new(move |_| {
let Some(path) = open.get() else {
return;
};
// The root the shown hits came from, not a newly picked one.
let Some((root, _)) = parse_search_url().2.or_else(|| folder.get_untracked()) else {
return;
};
if file_view.with_untracked(Option::is_some) {
return;
}
wasm_bindgen_futures::spawn_local(async move {
let (dir, name) = path.rsplit_once('/').unwrap_or(("", &path));
let found = api::find_entry(root, dir, name).await;
if open.try_get_untracked().flatten().as_ref() != Some(&path) {
return;
}
match found {
Ok(Some(e)) if !e.is_dir => {
let fv = open_file_view(
root,
path.clone(),
e.name,
e.kind,
is_writable(me, root),
);
open_file.set(Some(fv));
}
Ok(_) => {
show_error(toast, i18n::t(k::FILE_NOT_FOUND).to_string());
router::clear_open();
}
Err(e) => {
show_error(toast, e.to_string());
router::clear_open();
}
}
});
});
}
// The start-folder button opens the shared folder picker.
@@ -692,12 +759,7 @@ pub fn SearchView(
return;
};
if hit.is_dir {
navigate(&Location {
root_id: Some(root_id),
path: split_rel(&path),
share_token: None,
section: Section::Files,
});
navigate(&Location::folder(Some(root_id), None, split_rel(&path)));
return;
}
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
@@ -727,7 +789,7 @@ pub fn SearchView(
<input
type="text"
placeholder=move || i18n::t(k::SEARCH_PLACEHOLDER).to_string()
value=move || query.get()
prop:value=move || query.get()
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
▾Mweb/src/views/shell.rs
@@ -130,10 +130,7 @@ pub fn ShellView(
let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value()));
applied.set_value(true);
if let Some(id) = target {
navigate(&Location {
root_id: Some(id),
..Location::root()
});
navigate(&Location::folder(Some(id), None, vec![]));
}
});
}
@@ -151,10 +148,8 @@ pub fn ShellView(
let open_section = move |section: Section| {
set_nav_open.set(false);
navigate(&Location {
root_id: None,
path: Vec::new(),
share_token: None,
section,
..Location::root()
});
};
@@ -265,12 +260,11 @@ pub fn ShellView(
title=title
on:click=move |_| {
set_nav_open.set(false);
navigate(&Location {
root_id: Some(id),
path: vec![],
share_token: None,
section: Section::Files,
});
navigate(&Location::folder(
Some(id),
None,
vec![],
));
}
>
<Icon name=IconName::Folder class="ic-tab".to_string()/>
@@ -390,6 +384,7 @@ pub fn ShellView(
Section::Search => view! {
<SearchView
me=me
loc=loc
open_file=open_file
file_view=file_view
set_dialog=set_dialog
@@ -404,7 +399,7 @@ pub fn ShellView(
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
}
Section::Settings => view! {
<SettingsView me=me set_me=set_me set_dialog=set_dialog/>
<SettingsView me=me loc=loc set_me=set_me set_dialog=set_dialog/>
}
.into_view()
.into_any(),
▾Mweb/src/views/workspace.rs
@@ -16,7 +16,7 @@ use web_sys::KeyboardEvent;
use crate::api::{Entry, Me};
use crate::components::logo::Logo;
use crate::components::toast::{ToastMsg, ToastView};
use crate::router::Location;
use crate::router::{self, Location, Section};
use crate::uploads;
use crate::util::owned_window_listener;
use crate::views::browser::SelectionActions;
@@ -100,10 +100,18 @@ impl FileWorkspace {
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
let topbar_ref = NodeRef::<Header>::new();
// Navigating (new folder, root, section) invalidates the selection.
// Navigating (new folder, root, section) invalidates the selection
// and the dialog, which act on it. A page turn or an open file keeps
// them. An upload's conflict question is not about the folder shown.
let folder = Memo::new(move |_| loc.with(Location::folder_key));
Effect::new(move |_| {
let _ = loc.get();
folder.with(|_| ());
set_selected.set(Vec::new());
set_dialog.update(|d| {
if !matches!(d, Some(Dialog::UploadConflict { .. })) {
*d = None;
}
});
});
// A finished upload re-lists the folder it went into, if that is
@@ -135,17 +143,84 @@ impl FileWorkspace {
},
);
let close_file = {
let close_now = {
let ct = close_target.clone();
Callback::new(move |_| {
move || {
set_save_cb.set(None);
set_close_cb.set(None);
set_dirty.set(false);
set_saving.set(false);
set_file_view.set(ct());
}
};
// The URL holds the open file, so closing edits the URL. The effect
// below then closes the view.
let close_file = {
let close_now = close_now.clone();
Callback::new(move |_| {
if loc.with_untracked(|l| l.open.is_none()) {
close_now();
return;
}
set_dirty.set(false);
router::close_open();
})
};
// A file shown here goes into the URL. Every file view shares one
// history entry: a switch to the next file replaces it.
{
let ct = close_target.clone();
Effect::new(move |_| {
let param = file_view.with(|v| {
v.as_ref()
.and_then(|v| loc.with_untracked(|l| open_param(l, v)))
});
let Some(p) = param else {
return;
};
if ct().is_none() && loc.with_untracked(|l| l.open.as_ref() != Some(&p)) {
router::set_open(&p);
}
});
}
// A URL without a file (Back, a link, a new folder) closes the view.
// Back cannot be cancelled, so with unsaved changes the file's entry
// comes back and the editor asks first.
{
let ct = close_target.clone();
let open_url = StoredValue::new(None::<String>);
Effect::new(move |_| {
if loc.with(|l| l.open.is_some()) {
open_url.set_value(Some(router::current_hash()));
return;
}
if ct().is_some() || file_view.with_untracked(Option::is_none) {
return;
}
if dirty.get_untracked()
&& let (Some(url), Some(ask)) = (open_url.get_value(), close_cb.get_untracked())
{
router::push_open_url(&url);
ask.run(());
return;
}
close_now();
// Back lands on the page the first file was opened from. The
// arrow keys may have moved on since: show the last file's page.
let Some(was) = open_url.get_value().map(|u| router::parse_hash(&u)) else {
return;
};
let now = loc.get_untracked();
if router::at_open_origin()
&& was.folder_key() == now.folder_key()
&& (was.page, was.size, was.sort) != (now.page, now.size, now.sort)
{
router::replace(&Location { open: None, ..was });
}
});
}
// Escape closes previews and the "no preview" view — except when
// there is nothing to close back to (file share). The editor handles
// Escape itself (it asks first when there are unsaved changes).
@@ -212,10 +287,9 @@ impl FileWorkspace {
.and_then(|u| u.default_root_id)
});
match root {
Some(id) => crate::router::location_to_hash(&Location {
root_id: Some(id),
..Location::root()
}),
Some(id) => {
crate::router::location_to_hash(&Location::folder(Some(id), None, vec![]))
}
None => "#/".to_string(),
}
};
@@ -357,3 +431,20 @@ impl FileWorkspace {
}
}
}
/// How the URL names the open file `v`: by name in the listed folder, or
/// on the search view by its path in the root. `None` for a file outside
/// the folder, which the URL cannot name.
fn open_param(loc: &Location, v: &FileView) -> Option<String> {
let path = v.path();
if loc.section == Section::Search {
return Some(path.to_string());
}
let dir = loc.path.join("/");
let name = if dir.is_empty() {
path
} else {
path.strip_prefix(&dir)?.strip_prefix('/')?
};
(!name.contains('/')).then(|| name.to_string())
}