keyboard navigation, sorting, share passwords, search excludes
Arrow keys move the selection in the browser: all four in grid view, up and down in list view, Shift extends from the anchor. Left and right step between previewable files while a preview is open. A sort control orders by name, size or last modified, either direction, folders always first. Shares take an optional password. The gate is the AuthUser extractor, not just the resolve endpoint, so the file API is covered too. Unlocking sets a per-share HttpOnly cookie, because previews and downloads are plain URLs in src and href attributes, which carry nothing else. The unlock route reuses the login route's per-attempt delay. A new admin setting lists folders no search may enter. Excluded folders are skipped whole during the walk, so nothing under them is read. The editor's unsaved-changes and conflict dialogs used a class with no CSS anywhere, left behind when the app moved to a native dialog. They render through the Modal component now, which brings the backdrop, the focus trap and Escape. Also: .center-screen asked for a full 100vh below the topbar and pushed a scrollbar onto every page that used it; .btn-primary forced 100% width on every primary button in the app.
Mapi-types/src/lib.rs
@@ -20,6 +20,8 @@ pub const FILES: &str = "/api/files";
pub const SHARES: &str = "/api/shares";
/// Public share resolve (no login): `{SHARE}/{token}`.
pub const SHARE: &str = "/api/share";
/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
/// `GET /api/search` — name and/or content search, streamed as SSE.
pub const SEARCH: &str = "/api/search";
@@ -172,9 +174,13 @@ pub struct UpdateUser {
}
/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
#[derive(Serialize, Deserialize, Clone, Copy)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Settings {
pub allow_writable_shares: bool,
/// Folders left out of every search, as paths relative to the server
/// root. A path covers everything beneath it.
#[serde(default)]
pub search_excludes: Vec<String>,
}
#[derive(Serialize, Deserialize)]
@@ -187,6 +193,15 @@ pub struct CreateShare {
/// Absolute expiry as RFC 3339; absent = never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Password the visitor must enter before the share opens; absent = none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
#[derive(Serialize, Deserialize)]
pub struct UnlockShare {
pub password: String,
}
// ---------------------------------------------------------------------------
@@ -329,6 +344,9 @@ pub struct ShareInfo {
/// The file's kind for file shares (None for folder shares, and when
/// not sniffed — the public resolve endpoint fills it in).
pub kind: Option<FileKind>,
/// Whether the share asks for a password. Never the password itself.
#[serde(default)]
pub has_password: bool,
}
/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
Mserver/src/api/admin.rs
@@ -244,6 +244,7 @@ pub async fn get_settings(
) -> Result<Json<Settings>, ApiError> {
Ok(Json(Settings {
allow_writable_shares: state.db.allow_writable_shares().await?,
search_excludes: state.db.search_excludes().await?,
}))
}
@@ -257,5 +258,20 @@ pub async fn update_settings(
.db
.set_allow_writable_shares(body.allow_writable_shares)
.await?;
Ok(Json(body))
// Normalised so the search can compare plain strings. "." is dropped:
// excluding the root would switch search off instead of narrowing it.
let mut excludes: Vec<String> = Vec::new();
for p in &body.search_excludes {
let p = p.trim().replace('\\', "/");
let p = p.trim_matches('/');
if p.is_empty() || p == "." || excludes.iter().any(|e| e == p) {
continue;
}
excludes.push(p.to_string());
}
state.db.set_search_excludes(&excludes).await?;
Ok(Json(Settings {
allow_writable_shares: body.allow_writable_shares,
search_excludes: excludes,
}))
}
Mserver/src/api/common.rs
@@ -40,6 +40,12 @@ where
if let Some(share_token) = share_token_from_request(parts) {
return match state.db.share_by_token(&share_token).await? {
Some(share) if !share.is_expired() => {
// The password guards the files, not just the share
// page. A check only on resolve would leave the file
// API open to anyone holding the link.
if share_is_locked(state, &share, &parts.headers).await? {
return Err(crate::api::shares::locked_error());
}
let roots = vec![RootRow {
id: share.id,
path: share.target.clone(),
@@ -132,6 +138,21 @@ pub(crate) async fn session_auth(
Ok((user, roots))
}
/// Whether `share` still needs its password entered by this caller.
pub(crate) async fn share_is_locked(
state: &AppState,
share: &ShareRow,
headers: &axum::http::HeaderMap,
) -> Result<bool, ApiError> {
if share.password_hash.is_none() {
return Ok(false);
}
let Some(unlock) = crate::auth::parse_share_cookie(headers, share.id) else {
return Ok(true);
};
Ok(!state.db.share_unlock_valid(&unlock, share.id).await?)
}
/// Extract the share token from a request, if present: a `?share=<token>`
/// query param or an `X-Share-Token` header.
fn share_token_from_request(parts: &Parts) -> Option<String> {
Mserver/src/api/mod.rs
@@ -2,7 +2,7 @@ use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SEARCH,
SHARE, SHARES,
SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -79,6 +79,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let files_item = format!("{FILES}/{{root_id}}/{{*path}}");
let shares_id = format!("{SHARES}/{{id}}");
let share_token = format!("{SHARE}/{{token}}");
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
Router::new()
@@ -97,6 +98,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(SHARES, post(shares::create))
.route(&shares_id, delete(shares::delete))
.route(&share_token, get(shares::resolve))
.route(&share_unlock, post(shares::unlock))
.route(ADMIN_USERS, get(admin::list_users))
.route(ADMIN_USERS, post(admin::create_user))
.route(&admin_user_id, put(admin::update_user))
Mserver/src/api/search.rs
@@ -170,6 +170,7 @@ pub(super) async fn search(
root,
start_rel,
state.root.clone(),
state.db.search_excludes().await?,
slot,
);
// `Sse` does the `data: <json>\n\n` framing and the content-type and
@@ -188,6 +189,9 @@ struct SearchState {
/// Where the walk starts, relative to the root ("" = the root itself).
start_rel: String,
server_root: PathBuf,
/// Admin-configured folders left out of every search, relative to the
/// server root and already normalised (no slashes at either end).
excludes: Vec<String>,
started: Instant,
/// Directory entries visited.
scanned: AtomicUsize,
@@ -205,6 +209,7 @@ struct SearchState {
/// Stopping: when the client goes away, axum drops the body stream, which
/// drops the receiver. Every `is_closed` check in the walkers then yields
/// `WalkState::Quit` at the next entry, so the walk unwinds promptly.
#[allow(clippy::too_many_arguments)] // one search's whole configuration
fn search_stream(
q: String,
want_name: bool,
@@ -212,6 +217,7 @@ fn search_stream(
root: RootRow,
start_rel: String,
server_root: PathBuf,
excludes: Vec<String>,
slot: tokio::sync::OwnedSemaphorePermit,
) -> impl futures_util::Stream<Item = SearchEvent> + Send {
let (tx, rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
@@ -221,6 +227,7 @@ fn search_stream(
root,
start_rel,
server_root,
excludes,
started: Instant::now(),
scanned: AtomicUsize::new(0),
skipped: AtomicUsize::new(0),
@@ -249,6 +256,28 @@ fn search_stream(
tokio_stream::wrappers::ReceiverStream::new(rx)
}
/// A root-relative path re-expressed relative to the server root, the form
/// the exclude list is stored in. `root_path` is "." for the whole root.
fn join_rel(root_path: &str, rel: &str) -> String {
let root_path = root_path.trim_matches('/');
if root_path.is_empty() || root_path == "." {
rel.to_string()
} else {
format!("{root_path}/{rel}")
}
}
/// Whether `path` is an excluded folder or sits under one.
///
/// `Path::starts_with` compares whole components, so "docs" does not exclude
/// the sibling "docs-archive". A plain string prefix would.
fn is_excluded(excludes: &[String], path: &str) -> bool {
let path = Path::new(path);
excludes
.iter()
.any(|e| crate::fs::is_within_or_eq(Path::new(e), path))
}
/// True when every query word occurs in `name`. Both are already lowercased.
///
/// `name` is the entry's own name, never its path — see the module docs.
@@ -308,6 +337,19 @@ fn visit(
let rel = rel.to_string_lossy().replace('\\', "/");
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
// `Skip` on the folder itself stops the walker descending, so nothing
// underneath is ever read.
if !st.excludes.is_empty() {
let from_server_root = join_rel(&st.root.path, &rel);
if is_excluded(&st.excludes, &from_server_root) {
return if is_dir {
WalkState::Skip
} else {
WalkState::Continue
};
}
}
if want_name {
// Matched against the entry's own name, not its path: matching the
// path makes every descendant of a matching directory a hit too
@@ -437,6 +479,33 @@ fn truncate_line(line: &[u8]) -> String {
mod tests {
use super::*;
/// A sibling whose name merely starts with an excluded folder's name
/// must still be searchable.
#[test]
fn excludes_cover_descendants_but_not_name_siblings() {
let ex = vec!["private".to_string(), "a/b".to_string()];
assert!(is_excluded(&ex, "private"));
assert!(is_excluded(&ex, "private/deep/file.txt"));
assert!(is_excluded(&ex, "a/b"));
assert!(is_excluded(&ex, "a/b/c.txt"));
assert!(!is_excluded(&ex, "private-archive"));
assert!(!is_excluded(&ex, "privateer.txt"));
assert!(!is_excluded(&ex, "a"));
assert!(!is_excluded(&ex, "a/bc"));
assert!(!is_excluded(&ex, "other/private"));
assert!(!is_excluded(&[], "anything"));
}
/// Results are root-relative; the exclude list is server-root-relative.
#[test]
fn join_rel_lifts_a_path_to_the_server_root() {
assert_eq!(join_rel(".", "docs/a.txt"), "docs/a.txt");
assert_eq!(join_rel("", "docs/a.txt"), "docs/a.txt");
assert_eq!(join_rel("home/bob", "docs/a.txt"), "home/bob/docs/a.txt");
assert_eq!(join_rel("/home/bob/", "x"), "home/bob/x");
}
/// A rename of one of the `P_*` constants without the matching field
/// rename would silently stop the server from reading the parameter the
/// client sends. This builds the query string from the constants and
Mserver/src/auth.rs
@@ -103,13 +103,46 @@ pub fn clear_session_cookie(https: bool) -> String {
c
}
/// Cookie name proving that the visitor unlocked share `share_id`.
///
/// One cookie per share: a visitor may hold links to several protected
/// shares, and one shared name would let each unlock evict the last.
pub fn share_cookie_name(share_id: i64) -> String {
format!("fbng_share_{share_id}")
}
/// Session cookie for an unlocked share. A session cookie (no `Max-Age`), so
/// the unlock lasts as long as the browser stays open and is not written to
/// disk.
pub fn share_cookie(share_id: i64, token: &str, https: bool) -> String {
let mut c = format!(
"{}={token}; Path=/; HttpOnly; SameSite=Lax",
share_cookie_name(share_id)
);
if https {
c.push_str("; Secure");
}
c
}
/// Extract the unlock token for `share_id` from the Cookie header.
pub fn parse_share_cookie(headers: &axum::http::HeaderMap, share_id: i64) -> Option<String> {
cookie_value(headers, &share_cookie_name(share_id))
}
/// Extract the session token from the Cookie header, if present.
pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
cookie_value(headers, COOKIE_NAME)
}
/// One cookie's value out of the `Cookie` header. Empty values are treated
/// as absent: that is how a cleared cookie arrives before it expires.
fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
for part in header.split(';') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=')
&& k == COOKIE_NAME
&& k == name
&& !v.is_empty()
{
return Some(v.to_string());
@@ -123,6 +156,26 @@ mod tests {
use super::*;
use axum::http::{HeaderMap, header};
#[test]
fn share_cookie_is_per_share_and_session_scoped() {
let c = share_cookie(7, "tok", false);
assert!(c.starts_with("fbng_share_7=tok;"));
assert!(c.contains("HttpOnly"));
// No Max-Age: the unlock must not outlive the browser session.
assert!(!c.contains("Max-Age"));
assert!(!c.contains("Secure"));
assert!(share_cookie(7, "tok", true).contains("Secure"));
let mut h = HeaderMap::new();
h.insert(
header::COOKIE,
"fbng_share_7=abc; fbng_share_8=def".parse().unwrap(),
);
assert_eq!(parse_share_cookie(&h, 7).as_deref(), Some("abc"));
assert_eq!(parse_share_cookie(&h, 8).as_deref(), Some("def"));
assert_eq!(parse_share_cookie(&h, 9), None);
}
#[test]
fn password_hash_round_trip() {
let h = hash_password("hunter22").unwrap();
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 5;
const SCHEMA_VERSION: i64 = 7;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -61,6 +61,10 @@ pub struct ShareRow {
pub mode: Mode,
pub created_at: String,
pub expires_at: Option<String>,
/// Argon2 hash of the share's password, when it has one. Resolve,
/// listing and download all stay locked until the visitor enters it and
/// gets an unlock cookie.
pub password_hash: Option<String>,
}
impl ShareRow {
@@ -159,6 +163,26 @@ impl Db {
"CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
)?;
}
if version < 6 {
// Unlocks cascade with their share, which cascades with its
// creator's account.
conn.execute_batch(
"ALTER TABLE shares ADD COLUMN password_hash TEXT;
CREATE TABLE IF NOT EXISTS share_unlocks (
token TEXT PRIMARY KEY,
share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);",
)?;
}
if version < 7 {
// `delete_share` cascades into share_unlocks, which is a full
// scan of that table without this.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_share_unlocks_share
ON share_unlocks(share_id)",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -493,6 +517,7 @@ impl Db {
// ---------- shares ----------
#[allow(clippy::too_many_arguments)] // one row's columns, all required
pub async fn create_share(
&self,
creator_id: i64,
@@ -501,11 +526,13 @@ impl Db {
is_file: bool,
mode: Mode,
expires_at: Option<&str>,
password_hash: Option<&str>,
) -> DbResult<ShareRow> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT INTO shares
(token, creator_id, target, is_file, mode, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
token,
creator_id,
@@ -513,7 +540,8 @@ impl Db {
is_file as i64,
SqlMode(mode),
now(),
expires_at
expires_at,
password_hash
],
)?;
let id = c.last_insert_rowid();
@@ -526,12 +554,47 @@ impl Db {
mode,
created_at: now(),
expires_at: expires_at.map(|s| s.to_string()),
password_hash: password_hash.map(|s| s.to_string()),
})
}
/// Record that a visitor entered `share_id`'s password, and return the
/// token that proves it (the value of their unlock cookie).
pub async fn create_share_unlock(&self, share_id: i64) -> DbResult<String> {
let token = crate::auth::random_token();
let c = self.0.lock().await;
// Old unlocks go first. The cookie carrying them is a session
// cookie, so it is already gone from every browser; without this the
// rows would accumulate forever, one per unlock.
c.execute(
"DELETE FROM share_unlocks WHERE created_at < ?1",
[expiry_cutoff()],
)?;
c.execute(
"INSERT INTO share_unlocks (token, share_id, created_at) VALUES (?1, ?2, ?3)",
params![token, share_id, now()],
)?;
Ok(token)
}
/// Whether `token` is a live unlock for `share_id`.
///
/// The share id is part of the lookup, so an unlock for one share cannot
/// open another.
pub async fn share_unlock_valid(&self, token: &str, share_id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
let mut stmt =
c.prepare_cached("SELECT 1 FROM share_unlocks WHERE token = ?1 AND share_id = ?2")?;
Ok(stmt
.query_row(params![token, share_id], |_| Ok(()))
.optional()?
.is_some())
}
pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE token = ?1";
let mut stmt = c.prepare_cached(sql)?;
stmt.query_row([token], map_share).optional()
@@ -539,7 +602,8 @@ impl Db {
pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([creator_id], map_share)?;
@@ -577,6 +641,35 @@ impl Db {
// ---------- settings ----------
/// Folders excluded from search, as paths relative to the server root.
///
/// Stored as one JSON array in a settings row. A table of its own would
/// be overkill for a hand-edited list read once per search.
pub async fn search_excludes(&self) -> DbResult<Vec<String>> {
let raw = self.get_setting("search_excludes").await?;
// Normalised on read as well as on write. A value edited straight
// into the database would otherwise never match: `is_excluded`
// compares against paths with no slash at either end.
let clean = |v: Vec<String>| -> Vec<String> {
v.into_iter()
.map(|p| p.trim().replace('\\', "/").trim_matches('/').to_string())
.filter(|p| !p.is_empty() && p != ".")
.collect()
};
// A hand-edited, unparseable value falls back to no exclusions,
// the same as an absent row.
Ok(raw
.as_deref()
.and_then(|v| serde_json::from_str::<Vec<String>>(v).ok())
.map(clean)
.unwrap_or_default())
}
pub async fn set_search_excludes(&self, paths: &[String]) -> DbResult<()> {
let json = serde_json::to_string(paths).unwrap_or_else(|_| "[]".to_string());
self.set_setting("search_excludes", &json).await
}
pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached("SELECT value FROM settings WHERE key = ?1")?;
@@ -644,6 +737,7 @@ fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
mode: r.get::<_, SqlMode>(5)?.0,
created_at: r.get(6)?,
expires_at: r.get(7)?,
password_hash: r.get(8)?,
})
}
@@ -653,6 +747,16 @@ static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
crate::auth::hash_password(&crate::auth::random_token()).expect("argon2 hash")
});
/// How long an unlock row outlives its cookie. The cookie dies with the
/// browser, so this only bounds the rows left behind by closed sessions.
const UNLOCK_MAX_AGE_DAYS: i64 = 7;
/// The timestamp an unlock row must be newer than to survive a cleanup.
fn expiry_cutoff() -> String {
(chrono::Utc::now() - chrono::Duration::days(UNLOCK_MAX_AGE_DAYS))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
@@ -990,6 +1094,7 @@ mod tests {
mode: Mode::Ro,
created_at: "2024-01-01T00:00:00Z".into(),
expires_at: expires_at.map(str::to_string),
password_hash: None,
}
}
@@ -1006,7 +1111,7 @@ mod tests {
async fn shares_crud() {
let (db, admin) = db_with_admin().await;
let s1 = db
.create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None)
.create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None, None)
.await
.unwrap();
let s2 = db
@@ -1017,6 +1122,7 @@ mod tests {
true,
Mode::Rw,
Some("2999-01-01T00:00:00Z"),
None,
)
.await
.unwrap();
@@ -1043,11 +1149,60 @@ mod tests {
assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
}
/// The unlock token is what a visitor's cookie carries, so an unlock
/// that opened the wrong share would be a full bypass of the password.
#[tokio::test]
async fn share_unlocks_are_bound_to_one_share() {
let (db, admin) = db_with_admin().await;
let a = db
.create_share(
admin.id,
"tok-a",
"docs",
false,
Mode::Ro,
None,
Some("hash"),
)
.await
.unwrap();
let b = db
.create_share(
admin.id,
"tok-b",
"other",
false,
Mode::Ro,
None,
Some("hash"),
)
.await
.unwrap();
assert_eq!(
db.share_by_token("tok-a")
.await
.unwrap()
.unwrap()
.password_hash,
Some("hash".to_string())
);
let unlock = db.create_share_unlock(a.id).await.unwrap();
assert!(db.share_unlock_valid(&unlock, a.id).await.unwrap());
assert!(!db.share_unlock_valid(&unlock, b.id).await.unwrap());
assert!(!db.share_unlock_valid("nonsense", a.id).await.unwrap());
// Deleting the share takes its unlocks with it, so a re-created
// share that happened to reuse the id could not inherit them.
assert!(db.delete_share(a.id, admin.id).await.unwrap());
assert!(!db.share_unlock_valid(&unlock, a.id).await.unwrap());
}
#[tokio::test]
async fn revoking_a_path_takes_its_descendants_only() {
let (db, admin) = db_with_admin().await;
let mk = async |token: &str, target: &str| {
db.create_share(admin.id, token, target, false, Mode::Ro, None)
db.create_share(admin.id, token, target, false, Mode::Ro, None, None)
.await
.unwrap();
};
Mserver/tests/api_search.rs
@@ -163,3 +163,123 @@ async fn hidden_and_gitignored_entries_are_searched() {
);
assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}");
}
/// An excluded folder is invisible to search: not as a name hit, and not as
/// a source of content hits from inside it.
#[tokio::test]
async fn excluded_folders_never_appear_in_results() {
let env = Env::new().await;
let admin = env.admin().await;
// Baseline: "docs" and the file under it are both findable.
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
let paths: Vec<String> = events(&r.text())
.iter()
.filter(|e| e["type"] == "file")
.map(|e| e["path"].as_str().unwrap_or_default().to_string())
.collect();
assert!(paths.contains(&"docs/inner/hello.txt".to_string()));
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
// Normalisation: the stored form has no surrounding slashes.
"search_excludes": ["/docs/"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "settings: {}", r.text());
assert_eq!(r.json()["search_excludes"][0], "docs");
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
let evs = events(&r.text());
for e in &evs {
let p = e["path"].as_str().unwrap_or_default();
assert!(
!p.starts_with("docs"),
"excluded folder leaked into results: {e}"
);
}
// A search for the folder's own name finds nothing either.
let r = admin
.get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
.await;
assert!(
!events(&r.text()).iter().any(|e| e["type"] == "file"),
"the excluded folder itself was still listed"
);
// Everything outside it is untouched.
let r = admin
.get(&format!("/api/search?q=main&scope=name&root={ROOT}"))
.await;
assert!(
events(&r.text()).iter().any(|e| e["path"] == "src/main.rs"),
"an unrelated folder was excluded too"
);
}
/// "." would exclude the whole root, which turns search off rather than
/// narrowing it. Blank and duplicate entries are dropped the same way.
#[tokio::test]
async fn exclude_list_is_normalised() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
"search_excludes": [".", "", " ", "docs", "docs/", "/src"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
let got = r.json();
let list: Vec<String> = got["search_excludes"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(list, vec!["docs".to_string(), "src".to_string()]);
// And it survives a round trip.
let r = admin.get("/api/admin/settings").await;
assert_eq!(r.json()["search_excludes"], got["search_excludes"]);
}
/// Windows-style separators must survive normalisation. Trimming the
/// slashes before converting the backslashes left `\docs\` stored as
/// `/docs/`, which then matched nothing.
#[tokio::test]
async fn backslash_paths_are_normalised_before_trimming() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
"search_excludes": ["\\docs\\"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["search_excludes"][0], "docs");
// And it actually excludes.
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
for e in events(&r.text()) {
let p = e["path"].as_str().unwrap_or_default();
assert!(!p.starts_with("docs"), "not excluded: {e}");
}
}
Mweb/app.css
@@ -84,11 +84,25 @@ body {
text-align: center;
}
/* Inside the app frame the topbar already took part of the viewport, so a
full 100vh here overflows and shows a scrollbar with nothing to scroll to.
Login and setup render .center-screen as the whole page, where the 100vh
above is right. */
.content > .center-screen {
min-height: 0;
flex: 1;
}
.auth-card {
width: 100%;
max-width: 380px;
}
/* The auth forms have a single action, so its button spans the form. */
.auth-card .btn-primary {
width: 100%;
}
.auth-card h1 {
margin: 0 0 8px;
font-size: 22px;
@@ -186,8 +200,9 @@ button:disabled {
cursor: default;
}
/* No width here on purpose. Full width belongs to the few places that ask
for it (.auth-card, .unlock-btn), not to every dialog's action row. */
.btn-primary {
width: 100%;
background: var(--accent);
/* One step away from the fill, per theme (see palette above), so the
button's edge reads the same as on neutral buttons. */
@@ -576,6 +591,35 @@ button:disabled {
gap: 6px;
}
/* Sort control in the toolbar. The select mirrors the app's other selects:
no native chrome, own arrow. */
.sort-label {
font-size: 13px;
color: var(--muted);
align-self: center;
margin-right: 2px;
}
.sort-select {
appearance: none;
width: auto;
padding: 7px 32px 7px 10px;
border: 1px solid var(--border);
background-color: var(--panel);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 12px 12px;
color: var(--text);
font: inherit;
font-size: 13px;
}
.sort-select:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.icon-btn {
padding: 7px 9px;
border: 1px solid var(--border);
@@ -1303,15 +1347,8 @@ button:disabled {
justify-content: center;
}
.file-nopreview .btn-primary {
width: auto;
margin-top: 14px;
}
/* Conflict / discard dialogs render above the file view. */
.editor-sub-overlay {
z-index: 101;
background: rgb(0 0 0 / 25%);
}
/* ---------------------------------------------------------------------------
* Download format dialog
* ------------------------------------------------------------------------- */
@@ -1391,7 +1428,6 @@ button:disabled {
.share-expiry-row label {
font-size: 13px;
color: var(--muted);
min-width: 52px;
}
.share-expiry-row select,
@@ -1404,6 +1440,62 @@ button:disabled {
font: inherit;
}
/* Optional password on the create-share form. Laid out like the expiry
row above it, so the form reads as one column of labelled fields. */
.share-pw-row {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
}
.share-pw-row label {
font-size: 13px;
color: var(--muted);
}
/* Expiry and password sit in one column, so their inputs line up. */
.share-expiry-row label,
.share-pw-row label {
min-width: 64px;
}
.share-pw-row input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
}
.share-pw-hint {
font-size: 12px;
margin: 0 0 10px;
}
/* The password gate of a protected share: the whole page is this card. */
.unlock-card {
width: 320px;
max-width: calc(100vw - 32px);
padding: 20px;
text-align: left;
}
.unlock-card h2 {
margin: 0 0 6px;
font-size: 18px;
}
.unlock-card input {
margin-top: 14px;
}
.unlock-btn {
width: 100%;
margin-top: 12px;
}
.share-expiry-note {
margin: 4px 0 0;
font-size: 12.5px;
@@ -1631,6 +1723,50 @@ button:disabled {
box-sizing: border-box;
}
/* Search exclusions: a bordered block matching the setting row above it. */
.excludes-field {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
}
.excludes-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.exclude-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.exclude-path {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-remove {
flex: none;
padding: 4px 6px;
}
.exclude-add {
align-self: flex-start;
}
.excludes-empty {
font-size: 13px;
margin: 0;
}
.field-hint {
font-size: 12px;
margin: -2px 0 8px;
Mweb/src/api.rs
@@ -16,7 +16,8 @@ use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT,
P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARES, Settings, UpdateUser,
P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare,
UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
@@ -483,6 +484,7 @@ pub fn create_share(
path: &str,
writable: bool,
expires_at: Option<&str>,
password: Option<&str>,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
@@ -492,6 +494,7 @@ pub fn create_share(
path: path.to_string(),
writable,
expires_at: expires_at.map(|s| s.to_string()),
password: password.map(|s| s.to_string()),
}),
)
}
@@ -500,13 +503,29 @@ pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp,
request("DELETE", format!("{SHARES}/{id}"), None::<()>)
}
/// Public: resolve a share (no session required).
/// Public: resolve a share (no session required). A password-protected
/// share answers 401 until [`unlock_share`] has run in this browser.
pub fn resolve_share(
token: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request("GET", format!("{SHARE}/{token}"), None::<()>)
}
/// Public: submit a protected share's password. The proof of the unlock is
/// a cookie the server sets, so nothing has to be kept here.
pub fn unlock_share(
token: &str,
password: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
Some(UnlockShare {
password: password.to_string(),
}),
)
}
// ---------------------------------------------------------------------------
// Admin (milestone 7): user management + settings
// ---------------------------------------------------------------------------
@@ -570,14 +589,18 @@ pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings
request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
}
/// PUT replaces the whole settings object, so every setting has to be
/// passed. Omitting one would reset it.
pub fn update_admin_settings(
allow_writable_shares: bool,
search_excludes: Vec<String>,
) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
request(
"PUT",
ADMIN_SETTINGS.to_string(),
Some(Settings {
allow_writable_shares,
search_excludes,
}),
)
}
Mweb/src/editor.rs
@@ -15,6 +15,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
use crate::icons::IconName;
@@ -324,23 +325,23 @@ pub fn Editor(
}}
// Conflict: the file changed on disk since it was opened.
// Closing it means "keep my version, decide later", so Escape and
// a backdrop click leave the editor open and dirty.
{move || {
if conflict.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
{i18n::tr(i18n::k::LOAD_LATEST)}
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
<Modal class="card" on_close=Callback::new(move |_| set_conflict.set(false))>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
{i18n::tr(i18n::k::LOAD_LATEST)}
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
</Modal>
}
.into_view()
.into_any()
@@ -349,27 +350,26 @@ pub fn Editor(
}
}}
// Unsaved-changes confirmation.
// Unsaved-changes confirmation. Dismissing it is "keep editing":
// the destructive choice needs the button.
{move || {
if confirm_discard.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
{i18n::tr(i18n::k::KEEP_EDITING)}
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
{i18n::tr(i18n::k::DISCARD)}
</button>
</div>
<Modal class="card" on_close=Callback::new(move |_| set_confirm_discard.set(false))>
<h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
{i18n::tr(i18n::k::KEEP_EDITING)}
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
{i18n::tr(i18n::k::DISCARD)}
</button>
</div>
</div>
</Modal>
}
.into_view()
.into_any()
Mweb/src/i18n.rs
@@ -280,12 +280,19 @@ i18n_keys! {
ERR_SEARCH_FORBIDDEN = "err_search_forbidden" => "search requires a signed-in session",
ERR_SESSION_EXPIRED = "err_session_expired" => "session expired, please sign in again",
ERR_SHARE_EXPIRED = "err_share_expired" => "this share has expired",
ERR_SHARE_LOCKED = "err_share_locked" => "This share is password protected.",
ERR_SHARE_NO_PASSWORD = "err_share_no_password" => "This share has no password.",
ERR_SHARE_NOT_FOUND = "err_share_not_found" => "share not found",
ERR_SHARE_TOKEN_FORBIDDEN = "err_share_token_forbidden" => "a share token cannot be used here; sign in instead",
ERR_SHARE_WRONG_PASSWORD = "err_share_wrong_password" => "Wrong password.",
ERR_TOO_LARGE_PREVIEW = "err_too_large_preview" => "file too large to preview",
ERR_TOO_LARGE_SAVE = "err_too_large_save" => "file too large to save",
ERR_USER_EXISTS = "err_user_exists" => "a user with that name already exists",
ERR_USER_NOT_FOUND = "err_user_not_found" => "user not found",
EXCLUDE_DUP_ERR = "exclude_dup_err" => "That folder is already excluded.",
EXCLUDE_FOLDER = "exclude_folder" => "Exclude folder…",
EXCLUDE_HERE = "exclude_here" => "Exclude this folder",
EXCLUDE_ROOT_ERR = "exclude_root_err" => "The whole root cannot be excluded; that would turn search off.",
EXPIRES = "expires" => "Expires",
EXPIRES_AT = "expires_at" => "Expires {}",
EXPIRY_1D = "expiry_1d" => "1 day",
@@ -392,6 +399,8 @@ i18n_keys! {
SEARCH_BOTH = "search_both" => "Both",
SEARCH_COLLAPSE_ALL = "search_collapse_all" => "Collapse all",
SEARCH_CONTENT = "search_content" => "Content",
SEARCH_EXCLUDES = "search_excludes" => "Excluded from search",
SEARCH_EXCLUDES_EMPTY = "search_excludes_empty" => "No folders are excluded.",
SEARCH_EXPAND_ALL = "search_expand_all" => "Expand all",
SEARCH_FAILED = "search_failed" => "The search failed.",
SEARCH_GOTO = "search_goto" => "Show in folder",
@@ -432,10 +441,13 @@ i18n_keys! {
SHARE_EXPIRED = "share_expired" => "Share expired",
SHARE_EXPIRED_MSG = "share_expired_msg" => "This share link is no longer active.",
SHARE_LINK = "share_link" => "Share link",
SHARE_LOCKED_TITLE = "share_locked_title" => "Password required",
SHARE_NOT_FOUND = "share_not_found" => "Share not found",
SHARE_NOT_FOUND_MSG = "share_not_found_msg" => "This link is invalid or the share was removed.",
SHARE_PASSWORD_HINT = "share_password_hint" => "Optional. Visitors must enter it before the share opens, at least 8 characters.",
SHARE_PERM_RO = "share_perm_ro" => "Anyone with this link can read “{}”.",
SHARE_PERM_RW = "share_perm_rw" => "Anyone with this link can read and write “{}”.",
SHARE_PROTECTED = "share_protected" => "Password protected",
SHARE_TITLE = "share_title" => "Share {}",
SHARES = "shares" => "Shares",
SHARES_DELETE_ERR = "shares_delete_err" => "Could not delete shares",
@@ -446,11 +458,15 @@ i18n_keys! {
SINGLE_CLICK_LABEL = "single_click_label" => "Use single click to open",
SIZE = "size" => "Size",
SOME_FILES_EXIST = "some_files_exist" => "Some files already exist",
SORT_ASC = "sort_asc" => "Ascending",
SORT_BY = "sort_by" => "Sort by",
SORT_DESC = "sort_desc" => "Descending",
THEME_AUTO = "theme_auto" => "Auto",
THEME_DARK = "theme_dark" => "Dark",
THEME_LIGHT = "theme_light" => "Light",
THEME_TITLE = "theme_title" => "Theme: {} (click to switch)",
TYPE = "type" => "Type",
UNLOCK = "unlock" => "Open share",
UNSAVED_CHANGES = "unsaved_changes" => "Unsaved changes",
UNTIL = "until" => "Until",
UPLOAD_COMPLETE = "upload_complete" => "Upload complete",
@@ -648,11 +664,17 @@ const DE: &[(&str, &str)] = &[
"Sitzung abgelaufen, bitte erneut anmelden",
),
(k::ERR_SHARE_EXPIRED, "diese Freigabe ist abgelaufen"),
(k::ERR_SHARE_LOCKED, "Diese Freigabe ist passwortgeschützt."),
(
k::ERR_SHARE_NO_PASSWORD,
"Diese Freigabe hat kein Passwort.",
),
(k::ERR_SHARE_NOT_FOUND, "Freigabe nicht gefunden"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
),
(k::ERR_SHARE_WRONG_PASSWORD, "Falsches Passwort."),
(k::ERR_TOO_LARGE_PREVIEW, "Datei zu groß für eine Vorschau"),
(k::ERR_TOO_LARGE_SAVE, "Datei zu groß zum Speichern"),
(
@@ -660,6 +682,16 @@ const DE: &[(&str, &str)] = &[
"ein Benutzer mit diesem Namen existiert bereits",
),
(k::ERR_USER_NOT_FOUND, "Benutzer nicht gefunden"),
(
"exclude_dup_err",
"Dieser Ordner ist bereits ausgeschlossen.",
),
("exclude_folder", "Ordner ausschließen…"),
("exclude_here", "Diesen Ordner ausschließen"),
(
"exclude_root_err",
"Das gesamte Stammverzeichnis kann nicht ausgeschlossen werden; das würde die Suche abschalten.",
),
("expires", "Läuft ab"),
("expires_at", "Läuft ab: {}"),
("expiry_1d", "1 Tag"),
@@ -805,6 +837,8 @@ const DE: &[(&str, &str)] = &[
("search_both", "Beides"),
("search_collapse_all", "Alle einklappen"),
("search_content", "Inhalt"),
("search_excludes", "Von der Suche ausgeschlossen"),
("search_excludes_empty", "Keine Ordner ausgeschlossen."),
("search_expand_all", "Alle ausklappen"),
("search_failed", "Die Suche ist fehlgeschlagen."),
("search_goto", "Im Ordner anzeigen"),
@@ -869,16 +903,22 @@ const DE: &[(&str, &str)] = &[
"Dieser Freigabelink ist nicht mehr aktiv.",
),
("share_link", "Freigabelink"),
("share_locked_title", "Passwort erforderlich"),
("share_not_found", "Freigabe nicht gefunden"),
(
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
),
(
"share_password_hint",
"Optional. Besucher müssen es eingeben, bevor die Freigabe geöffnet wird, mindestens 8 Zeichen.",
),
("share_perm_ro", "Jeder mit diesem Link kann „{}“ lesen."),
(
"share_perm_rw",
"Jeder mit diesem Link kann „{}“ lesen und schreiben.",
),
("share_protected", "Passwortgeschützt"),
("share_title", "{} teilen"),
("shares", "Freigaben"),
(
@@ -901,11 +941,15 @@ const DE: &[(&str, &str)] = &[
("single_click_label", "Einzelklick öffnet Einträge"),
("size", "Größe"),
("some_files_exist", "Einige Dateien existieren bereits"),
("sort_asc", "Aufsteigend"),
("sort_by", "Sortieren nach"),
("sort_desc", "Absteigend"),
("theme_auto", "Auto"),
("theme_dark", "Dunkel"),
("theme_light", "Hell"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
("type", "Typ"),
("unlock", "Freigabe öffnen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
("until", "Bis"),
("upload_complete", "Hochladen abgeschlossen"),
@@ -1100,11 +1144,20 @@ const FR: &[(&str, &str)] = &[
"session expirée, veuillez vous connecter à nouveau",
),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(
k::ERR_SHARE_LOCKED,
"Ce partage est protégé par un mot de passe.",
),
(
k::ERR_SHARE_NO_PASSWORD,
"Ce partage n'a pas de mot de passe.",
),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",
),
(k::ERR_SHARE_WRONG_PASSWORD, "Mot de passe incorrect."),
(
k::ERR_TOO_LARGE_PREVIEW,
"fichier trop volumineux pour l'aperçu",
@@ -1115,6 +1168,13 @@ const FR: &[(&str, &str)] = &[
),
(k::ERR_USER_EXISTS, "un utilisateur avec ce nom existe déjà"),
(k::ERR_USER_NOT_FOUND, "utilisateur introuvable"),
("exclude_dup_err", "Ce dossier est déjà exclu."),
("exclude_folder", "Exclure un dossier…"),
("exclude_here", "Exclure ce dossier"),
(
"exclude_root_err",
"La racine entière ne peut pas être exclue ; cela désactiverait la recherche.",
),
("expires", "Expire"),
("expires_at", "Expire le {}"),
("expiry_1d", "1 jour"),
@@ -1263,6 +1323,8 @@ const FR: &[(&str, &str)] = &[
("search_both", "Les deux"),
("search_collapse_all", "Tout replier"),
("search_content", "Contenu"),
("search_excludes", "Exclu de la recherche"),
("search_excludes_empty", "Aucun dossier exclu."),
("search_expand_all", "Tout déplier"),
("search_failed", "La recherche a échoué."),
("search_goto", "Afficher dans le dossier"),
@@ -1324,11 +1386,16 @@ const FR: &[(&str, &str)] = &[
("share_expired", "Partage expiré"),
("share_expired_msg", "Ce lien de partage n'est plus actif."),
("share_link", "Lien de partage"),
("share_locked_title", "Mot de passe requis"),
("share_not_found", "Partage introuvable"),
(
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
),
(
"share_password_hint",
"Facultatif. Les visiteurs doivent le saisir avant d'ouvrir le partage, au moins 8 caractères.",
),
(
"share_perm_ro",
"Toute personne possédant ce lien peut lire « {} ».",
@@ -1337,6 +1404,7 @@ const FR: &[(&str, &str)] = &[
"share_perm_rw",
"Toute personne possédant ce lien peut lire et modifier « {} ».",
),
("share_protected", "Protégé par mot de passe"),
("share_title", "Partager {}"),
("shares", "Partages"),
("shares_delete_err", "Impossible de supprimer les partages"),
@@ -1356,11 +1424,15 @@ const FR: &[(&str, &str)] = &[
("single_click_label", "Ouvrir au clic simple"),
("size", "Taille"),
("some_files_exist", "Certains fichiers existent déjà"),
("sort_asc", "Croissant"),
("sort_by", "Trier par"),
("sort_desc", "Décroissant"),
("theme_auto", "Auto"),
("theme_dark", "Sombre"),
("theme_light", "Clair"),
("theme_title", "Thème : {} (cliquer pour changer)"),
("type", "Type"),
("unlock", "Ouvrir le partage"),
("unsaved_changes", "Modifications non enregistrées"),
("until", "Jusqu'au"),
("upload_complete", "Téléversement terminé"),
Mweb/src/icons.rs
@@ -91,6 +91,10 @@ pub enum IconName {
ThemeDark,
/// material-symbols:more-vert
MoreVert,
/// material-symbols:arrow-upward
SortAsc,
/// material-symbols:arrow-downward
SortDesc,
}
impl IconName {
@@ -214,6 +218,12 @@ impl IconName {
Self::MoreVert => {
r#"<path fill="currentColor" d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8"/>"#
}
Self::SortAsc => {
r#"<path fill="currentColor" d="M11 20V7.825l-5.6 5.6L4 12l8-8l8 8l-1.4 1.425l-5.6-5.6V20z"/>"#
}
Self::SortDesc => {
r#"<path fill="currentColor" d="M11 4v12.175l-5.6-5.6L4 12l8 8l8-8l-1.4-1.425l-5.6 5.6V4z"/>"#
}
}
}
}
Mweb/src/util.rs
@@ -134,6 +134,129 @@ impl ViewMode {
}
}
/// What a listing is ordered by. Folders always sort before files, so a
/// size or date order does not scatter them through the listing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SortKey {
Name,
Size,
Modified,
}
impl SortKey {
pub fn as_str(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"name" => Some(SortKey::Name),
"size" => Some(SortKey::Size),
"modified" => Some(SortKey::Modified),
_ => None,
}
}
}
/// A sort key plus its direction, persisted like [`ViewMode`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct SortSpec {
pub key: SortKey,
pub asc: bool,
}
impl Default for SortSpec {
fn default() -> Self {
// The server's own order, so the first paint after a fetch does not
// reshuffle.
Self {
key: SortKey::Name,
asc: true,
}
}
}
impl SortSpec {
const KEY: &'static str = "fbng.sort";
pub fn load() -> Self {
let raw = storage_get(Self::KEY).unwrap_or_default();
let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
match SortKey::parse(key) {
Some(key) => Self {
key,
asc: dir != "desc",
},
None => Self::default(),
}
}
pub fn save(self) {
let dir = if self.asc { "asc" } else { "desc" };
storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
}
/// Order `entries` in place.
///
/// Name is compared case-insensitively, with the raw name as the
/// tie-breaker so two names differing only in case keep a stable order.
/// Folders have no meaningful size, so they fall back to the name order
/// among themselves.
pub fn apply(self, entries: &mut [crate::api::Entry]) {
match self.key {
SortKey::Name => entries.sort_by(|a, b| {
a.is_dir
.cmp(&b.is_dir)
.reverse()
.then_with(|| cmp_name(a, b).dir(self.asc))
}),
SortKey::Size => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
if a.is_dir {
cmp_name(a, b)
} else {
a.size
.cmp(&b.size)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
}
})
}),
SortKey::Modified => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
// RFC 3339 UTC with a fixed width, so bytewise order is
// chronological order.
a.mtime
.cmp(&b.mtime)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
})
}),
}
}
}
fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
}
trait OrderingDir {
fn dir(self, asc: bool) -> std::cmp::Ordering;
}
impl OrderingDir for std::cmp::Ordering {
fn dir(self, asc: bool) -> std::cmp::Ordering {
if asc { self } else { self.reverse() }
}
}
/// Entries rendered at once in a listing, and how many more each click of
/// the "show more" row reveals. Rendering a row costs a fixed amount of
/// reactive-graph setup, so the first paint stays well under a frame and the
@@ -188,6 +311,83 @@ pub fn page_footer(
mod tests {
use super::*;
fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
crate::api::Entry {
name: name.to_string(),
is_dir,
size,
mtime: mtime.to_string(),
kind: if is_dir {
api_types::FileKind::Dir
} else {
api_types::FileKind::Binary
},
}
}
/// This order is what the browser's arrow keys and Shift+click ranges
/// walk, so a wrong order moves the wrong file.
#[test]
fn sort_keeps_folders_first_in_every_order() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
];
for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
for asc in [true, false] {
SortSpec { key, asc }.apply(&mut v);
assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
}
}
}
#[test]
fn sort_orders_by_the_chosen_key() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
];
let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
SortSpec {
key: SortKey::Name,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
SortSpec {
key: SortKey::Name,
asc: false,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
SortSpec {
key: SortKey::Size,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
SortSpec {
key: SortKey::Modified,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
}
#[test]
fn sort_spec_round_trips_through_its_stored_form() {
// No localStorage in the test runner, so only the parse half runs
// here. `load` falls back to the default.
assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
assert_eq!(SortKey::parse("bogus"), None);
assert_eq!(SortSpec::default().key, SortKey::Name);
assert!(SortSpec::default().asc);
}
#[test]
fn format_date_drops_seconds_and_zone() {
assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
Mweb/src/views/admin.rs
@@ -50,18 +50,17 @@ impl SettingsTab {
}
}
/// One "setting" tab body: a checkbox with description and a busy save
/// button. Both tabs (profile, server) share exactly this shape, so the
/// markup lives here once.
/// One labelled checkbox row for a setting.
///
/// The save button belongs to the tab, not the row. The server tab writes
/// all of its settings in one PUT, so a per-row button would save the other
/// rows' unsaved state too.
#[component]
fn SettingToggle(
label: &'static str,
desc: &'static str,
value: ReadSignal<Option<bool>>,
set_value: WriteSignal<Option<bool>>,
busy: ReadSignal<bool>,
save: Callback<()>,
save_label: &'static str,
) -> impl IntoView {
view! {
{move || match value.get() {
@@ -89,15 +88,6 @@ fn SettingToggle(
}
/>
</label>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=move |_| save.run(())
>
{move || if busy.get() { i18n::t(i18n::k::SAVING).to_string() } else { save_label.to_string() }}
</button>
</div>
}
.into_view()
.into_any(),
@@ -106,7 +96,13 @@ fn SettingToggle(
}
#[component]
pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
pub fn SettingsView(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
/// The folder picker for the search exclusions is rendered at the shell
/// level, like every other dialog.
set_dialog: WriteSignal<Option<Dialog>>,
) -> 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
@@ -159,11 +155,14 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
});
});
// --- server: allow writable shares (admin only) ------------------------
// --- server settings (admin only) --------------------------------------
let (value, set_value) = signal(Option::<bool>::None);
let (excludes, set_excludes) = signal(Vec::<String>::new());
let (excl_error, set_excl_error) = signal(Option::<String>::None);
let (busy, set_busy) = signal(false);
{
let set = set_value;
let set_ex = set_excludes;
let toast2 = toast;
spawn_local(async move {
// Non-admins don't have a Server tab; don't even probe the
@@ -172,21 +171,54 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
return;
}
match api::get_admin_settings().await {
Ok(s) => set.set(Some(s.allow_writable_shares)),
Ok(s) => {
set.set(Some(s.allow_writable_shares));
set_ex.set(s.search_excludes);
}
Err(e) => show_error(toast2, e.to_string()),
}
});
}
// Opens the same folder picker the user editor uses, over the whole
// server root (an exclusion is not tied to any one user's folders).
let add_exclude = move |_| {
set_excl_error.set(None);
set_dialog.set(Some(Dialog::Picker {
title: i18n::t(i18n::k::EXCLUDE_FOLDER).to_string(),
confirm: i18n::t(i18n::k::EXCLUDE_HERE).to_string(),
roots: vec![admin_root()],
root: api_types::ADMIN_ROOT,
dir: String::new(),
for_write: false,
on_pick: Callback::new(move |(_, dir): (i64, String)| {
// An empty dir is the server root. Excluding it would
// switch search off altogether.
if dir.is_empty() {
set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_ROOT_ERR).to_string()));
return;
}
let mut v = excludes.get();
if v.contains(&dir) {
set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_DUP_ERR).to_string()));
return;
}
v.push(dir);
set_excludes.set(v);
set_excl_error.set(None);
}),
}));
};
let save = move |_| {
if busy.get() {
return;
}
let Some(v) = value.get() else { return };
let ex = excludes.get();
set_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
spawn_local(async move {
match api::update_admin_settings(v).await {
match api::update_admin_settings(v, ex).await {
Ok(_) => {
show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
// Re-fetch this session's /me so the share dialog (and
@@ -300,10 +332,75 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
value=value
set_value=set_value
busy=busy
save=Callback::new(save)
save_label=i18n::t(i18n::k::SAVE_SETTINGS)
/>
<div class="excludes-field">
<span class="setting-label">{i18n::tr(i18n::k::SEARCH_EXCLUDES)}</span>
<div class="excludes-list">
{move || {
let list = excludes.get();
if list.is_empty() {
return view! {
<p class="muted excludes-empty">
{i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}
</p>
}
.into_view()
.into_any();
}
list.iter()
.enumerate()
.map(|(i, path)| {
view! {
<div class="exclude-row">
<Icon name=IconName::Folder class="ic-row".to_string()/>
<span class="exclude-path">{path.clone()}</span>
<button
class="icon-btn exclude-remove"
title=i18n::t(i18n::k::REMOVE_FOLDER)
aria-label=i18n::t(i18n::k::REMOVE_FOLDER)
on:click=move |_| {
let mut v = excludes.get();
if i < v.len() {
v.remove(i);
}
set_excludes.set(v);
set_excl_error.set(None);
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
<button class="btn btn-sm exclude-add" on:click=add_exclude>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{i18n::tr(i18n::k::EXCLUDE_FOLDER)}
</button>
{move || {
let Some(e) = excl_error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
}}
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || busy.get() || value.get().is_none()
on:click=move |_| save(())
>
{move || if busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE_SETTINGS).to_string()
}}
</button>
</div>
</Show>
</div>
}
Mweb/src/views/browser.rs
@@ -17,7 +17,7 @@ use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::{FILE_PAGE, ViewMode, format_date, format_size, page_footer};
use crate::util::{FILE_PAGE, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
@@ -85,6 +85,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 (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
@@ -102,6 +103,22 @@ pub fn Browser(
// Bumped per fetch. A response whose generation is no longer current
// belongs to a folder the user has already navigated away from.
let fetch_gen = StoredValue::new(0u64);
// The listing in display order. Everything downstream indexes into
// this, not into the fetched order: Shift+click ranges and the arrow
// keys walk what the user sees. A Memo, so a re-sort costs one pass per
// change, not one per render.
let sorted = Memo::new(move |_| match list_state.get() {
ListState::Entries(list) => {
let mut entries = list.entries.clone();
sort.get().apply(&mut entries);
ListState::Entries(Arc::new(Listing {
entries,
truncated: list.truncated,
}))
}
other => other,
});
// The selected names as a set: every row asks "am I selected?" on each
// selection change, which is a scan of the selection per row otherwise.
let sel_names = Memo::new(move |_| {
@@ -115,7 +132,7 @@ pub fn Browser(
// Drop selected entries that disappeared from the listing (deleted,
// renamed, …).
Effect::new(move |_| {
list_state.with(|st| {
sorted.with(|st| {
let ListState::Entries(list) = st else {
return;
};
@@ -160,6 +177,59 @@ pub fn Browser(
},
);
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order.
//
// Only previewable files: the editor needs the arrow keys for its
// caret, and a folder has nothing to preview.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and a
// modifier means a browser shortcut (Alt+Left is history back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get() else {
return;
};
let ListState::Entries(list) = sorted.get() else {
return;
};
let shown: Vec<&Entry> = list
.entries
.iter()
.filter(|e| !e.is_dir && preview_kind(e.kind).is_some())
.collect();
let Some(at) = shown.iter().position(|e| e.name == cur.name) else {
return;
};
let next = at as isize + step;
if next < 0 || next as usize >= shown.len() {
return;
}
let e = shown[next as usize];
let Some(kind) = preview_kind(e.kind) else {
return;
};
ev.prevent_default();
open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id: cur.root_id,
path: join_path(&loc.get().path, &e.name),
name: e.name.clone(),
},
kind,
)));
});
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
@@ -233,10 +303,12 @@ pub fn Browser(
me,
root,
loc,
list_state,
sorted,
shown,
view_mode,
set_view_mode,
sort,
set_sort,
set_ctx,
selected,
sel_names,
@@ -306,10 +378,12 @@ fn file_browser(
me: ReadSignal<Option<Me>>,
root: &RootInfo,
loc: ReadSignal<Location>,
list_state: ReadSignal<ListState>,
list_state: Memo<ListState>,
shown: RwSignal<usize>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
sort: ReadSignal<SortSpec>,
set_sort: WriteSignal<SortSpec>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
@@ -385,6 +459,69 @@ fn file_browser(
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
</button>
<div class="toolbar-right">
<label class="sort-label" for="sort-key">{i18n::tr(i18n::k::SORT_BY)}</label>
<select
id="sort-key"
class="sort-select"
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();
}
>
{[
(SortKey::Name, i18n::k::NAME),
(SortKey::Size, i18n::k::SIZE),
(SortKey::Modified, i18n::k::MODIFIED),
]
.map(|(key, label)| {
view! {
<option
value=key.as_str()
selected=move || sort.get().key == key
>
{i18n::tr(label)}
</option>
}
})
.to_vec()}
</select>
<button
class="icon-btn"
title=move || {
// The title names the current order, not the one the
// click would switch to: the arrow already shows it.
if sort.get().asc {
i18n::t(i18n::k::SORT_ASC)
} else {
i18n::t(i18n::k::SORT_DESC)
}
}
aria-label=move || {
if sort.get().asc {
i18n::t(i18n::k::SORT_ASC)
} else {
i18n::t(i18n::k::SORT_DESC)
}
}
on:click=move |_| {
let cur = sort.get();
let next = SortSpec { key: cur.key, asc: !cur.asc };
set_sort.set(next);
next.save();
}
>
{move || {
let name = if sort.get().asc {
IconName::SortAsc
} else {
IconName::SortDesc
};
icon_svg(name, "ic-btn")
}}
</button>
<button
class=move || {
if view_mode.get() == ViewMode::Grid {
@@ -528,6 +665,85 @@ fn ctx_menu_handler(
}
}
/// Items per row in the grid.
///
/// Read from the DOM on every call, not cached: the grid is CSS
/// `auto-fill`, so only the browser knows the column count, and a cached one
/// would go stale on the next resize.
fn grid_columns(container: &web_sys::Element) -> usize {
let kids = container.children();
let first = kids
.item(0)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok());
let Some(first) = first else { return 1 };
let top = first.offset_top();
let mut cols = 0usize;
for i in 0..kids.length() {
let Some(el) = kids
.item(i)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
else {
break;
};
if el.offset_top() != top {
break;
}
cols += 1;
}
cols.max(1)
}
/// Move keyboard focus to the item at `idx` inside `container`.
///
/// `focus()` scrolls the element into view on its own, so an item revealed
/// by the arrow keys does not need its own scrolling.
fn focus_item(container: &web_sys::Element, idx: usize) {
if let Some(el) = container
.children()
.item(idx as u32)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = el.focus();
}
}
/// The selection after extending from the anchor to `idx`, in listing order.
///
/// The anchor is the first selected entry. Every path that replaces the
/// selection puts the new entry first, so the next Shift+click or
/// Shift+arrow extends from the same place.
///
/// `None` when the anchor left the listing. The caller then leaves the
/// selection alone rather than guessing a new one.
fn range_selection(
entries: &[Entry],
cur: Vec<Entry>,
idx: usize,
add: bool,
) -> Option<Vec<Entry>> {
let anchor = match cur.first() {
Some(a) => entries.iter().position(|e| e.name == a.name)?,
None => idx,
};
let (lo, hi) = if anchor <= idx {
(anchor, idx)
} else {
(idx, anchor)
};
let mut next: Vec<Entry> = if add { cur } else { Vec::new() };
let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
{
let mut push = |e: &Entry| {
if have.insert(e.name.clone()) {
next.push(e.clone());
}
};
push(&entries[anchor]);
entries[lo..=hi].iter().for_each(&mut push);
}
Some(next)
}
/// Left-click behaviour. Modifier clicks always select:
/// - Ctrl/Cmd+click toggles the entry;
/// - Shift+click selects the range from the anchor (the first selected
@@ -565,29 +781,9 @@ fn selection_click(
ev.stop_propagation();
let cur = selected.get_untracked();
if ev.shift_key() {
let anchor = match cur.first() {
Some(a) => match entries.iter().position(|e| e.name == a.name) {
Some(a) => a,
None => return,
},
None => idx,
};
let (lo, hi) = if anchor <= idx {
(anchor, idx)
} else {
(idx, anchor)
};
// The anchor stays first so the next Shift+click extends from it.
let mut next: Vec<Entry> = if ctrl { cur } else { Vec::new() };
let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
let mut add = |e: &Entry| {
if have.insert(e.name.clone()) {
next.push(e.clone());
}
};
add(&entries[anchor]);
entries[lo..=hi].iter().for_each(&mut add);
set_selected.set(next);
if let Some(next) = range_selection(entries, cur, idx, ctrl) {
set_selected.set(next);
}
return;
}
let mut next = cur;
@@ -690,6 +886,78 @@ fn entry_callbacks(
)
}
/// Arrow keys on a focused entry: move focus, and take the selection with it.
///
/// The grid uses all four keys, the list only up and down. A list row is the
/// full width, so left and right are left to the browser (caret browsing,
/// horizontal scrolling).
///
/// Shift extends from the anchor, exactly as Shift+click does, so the two
/// can be mixed in one selection.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn arrow_key(
ev: &web_sys::KeyboardEvent,
all: &Arc<Listing>,
idx: usize,
grid: bool,
shown: RwSignal<usize>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
) {
// A modifier the handler does not implement belongs to the browser
// (Alt+Left is history back, Ctrl+Arrow jumps words in a text field).
if ev.ctrl_key() || ev.meta_key() || ev.alt_key() {
return;
}
let Some(container) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|el| el.parent_element())
else {
return;
};
let cols = if grid { grid_columns(&container) } else { 1 };
let step = match ev.key().as_str() {
"ArrowUp" => -(cols as isize),
"ArrowDown" => cols as isize,
"ArrowLeft" if grid => -1,
"ArrowRight" if grid => 1,
_ => return,
};
// Past this point the key is ours: the page must not scroll under it.
ev.prevent_default();
let total = all.entries.len();
let target = idx as isize + step;
if target < 0 || target as usize >= total {
return;
}
let target = target as usize;
let cur = selected.get_untracked();
let next = if ev.shift_key() {
range_selection(&all.entries, cur, target, false)
} else {
Some(vec![all.entries[target].clone()])
};
if let Some(next) = next {
set_selected.set(next);
}
if target < shown.get_untracked() {
focus_item(&container, target);
return;
}
// Reveal whole pages until the target is rendered. The focus call has
// to wait for that render, hence the animation frame.
shown.update(|n| {
while *n <= target {
*n += FILE_PAGE;
}
});
request_animation_frame(move || focus_item(&container, target));
}
/// Whether a double click opens: not with a modifier held, because that is
/// two selecting clicks (the browser still fires `dblclick` for them).
fn plain_dblclick(ev: &MouseEvent) -> bool {
@@ -742,6 +1010,7 @@ fn entries_view(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let all_keys = all.clone();
let item_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
view! {
@@ -755,7 +1024,9 @@ fn entries_view(
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
return;
}
arrow_key(&ev, &all_keys, i, grid, shown, selected, set_selected);
}
on:contextmenu=on_ctx
title=title
Mweb/src/views/shell.rs
@@ -359,7 +359,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/>
<SettingsView me=me set_me=set_me set_dialog=set_dialog/>
}
.into_view()
.into_any(),