UI/server: admin folder picker, offline error states, root name option

- Admin user form: folders are picked with the shared folder picker
  instead of a raw path input. Admins get a read-only pseudo root
  (ADMIN_ROOT = -1, the whole server root) on the files API for this.
- Fix custom checkbox width in the user form; left-align picker roots.
- Backend unavailable: fetch failures show a short localized message
  instead of the JS stack; search stops with an error toast (the stream
  tracks the Done event, and the error handler is a plain closure so a
  re-render cannot dispose it); shares view gets an error card + retry.
- New --root-name / FILEBROWSER_ROOT_NAME for the UI name of the root
  (default: the root folder's own name).
- Search: no trailing "/" on the folder label; the walker no longer
  skips hidden or gitignored entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitf7fda0ca6598b0865e939b54a80eb1324112491e
Parent76f85f2
24 files changed, 334 insertions(+), 131 deletions(-)
▾Mapi-types/src/lib.rs
@@ -26,6 +26,10 @@ pub const SEARCH: &str = "/api/search";
pub const ADMIN_USERS: &str = "/api/admin/users";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
/// Pseudo root id every signed-in admin has on the files API: the whole
/// server root, read-only (the admin folder picker browses it). Real root
/// ids are positive database ids. Not listed in `/me`.
pub const ADMIN_ROOT: i64 = -1;
// ---------------------------------------------------------------------------
// Query params
▾Mcompose.yml
@@ -6,6 +6,7 @@ services:
- "8080:8080"
environment:
RUST_LOG: info # server log level: trace/debug/info/warn/error
# FILEBROWSER_ROOT_NAME: Media # UI name of the root folder (default: its folder name, here "data")
volumes:
- ./data:/data # browsed files (server --root)
- ./filebrowser-db:/var/lib/filebrowser # sqlite db: users, shares, settings
▾Mserver/Cargo.toml
@@ -13,7 +13,7 @@ anyhow = "1"
argon2 = "0.5"
axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "env"] }
# `default-features = false` drops the `cfb` dependency (compound-document
# detection we do not need) and makes this a zero-dependency crate.
infer = { version = "0.16", default-features = false, features = ["alloc"] }
▾Mserver/src/api/admin.rs
@@ -21,7 +21,7 @@ use crate::fs;
fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
RootInfo {
id: r.id,
name: display_name(&state.root, &r.path),
name: display_name(state, &r.path),
path: r.path.clone(),
mode: r.mode,
}
▾Mserver/src/api/auth.rs
@@ -61,7 +61,7 @@ async fn me_for(state: &AppState, user: &User) -> Result<Me, ApiError> {
.into_iter()
.map(|r| RootInfo {
id: r.id,
name: display_name(&state.root, &r.path),
name: display_name(state, &r.path),
path: r.path,
mode: r.mode,
})
▾Mserver/src/api/common.rs
@@ -63,8 +63,17 @@ where
};
}
// 2. Signed-in session.
let (_user, roots) = session_auth(parts, state).await?;
// 2. Signed-in session. Admins also get the whole server root,
// read-only, under `ADMIN_ROOT` (the folder picker in user
// management browses it).
let (user, mut roots) = session_auth(parts, state).await?;
if user.is_admin {
roots.push(RootRow {
id: api_types::ADMIN_ROOT,
path: ".".to_string(),
mode: api_types::Mode::Ro,
});
}
Ok(AuthUser { roots, share: None })
}
}
@@ -159,15 +168,15 @@ impl HasState for Arc<AppState> {
/// Display name for a root path: the file/folder name, or the server root's
/// own name when the path is the whole root (".").
pub(crate) fn display_name(server_root: &std::path::Path, rel: &str) -> String {
let name = if rel == "." {
server_root.file_name()
} else {
std::path::Path::new(rel)
.file_name()
.filter(|_| !std::path::Path::new(rel).as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
/// UI name of a root or share target `rel` (relative to the server root):
/// its last path component, or the configured root name for `.`.
pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
if rel == "." {
return state.root_name.clone();
}
std::path::Path::new(rel)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| rel.to_string())
}
▾Mserver/src/api/search.rs
@@ -1,8 +1,9 @@
//! `GET /api/search` — index-free name and content search, streamed as SSE.
//!
//! No index, by design: every search walks the selected root with
//! [`ignore::WalkParallel`] (fd/ripgrep's walker: parallel, skips hidden
//! files, honors `.gitignore`) and matches on the fly.
//! [`ignore::WalkParallel`] (fd/ripgrep's parallel walker, with all its
//! filters off: hidden and gitignored entries are searched too) and matches
//! on the fly.
//!
//! * **Name** (scope `name`/`both`): every word of the query must occur
//! (case-insensitive) in the entry's own name — the last path component,
@@ -232,7 +233,9 @@ fn walk(st: &Arc<SearchState>, q: &str, want_name: bool, want_content: bool) {
return;
}
WalkBuilder::new(&start)
.standard_filters(true) // fd's defaults: hidden files + gitignore
// A file browser shows everything, so search must too: no hidden
// or `.gitignore`/`.ignore` filtering. Symlinks stay unfollowed.
.standard_filters(false)
.build_parallel()
.run(|| {
let abs = abs.clone();
▾Mserver/src/api/shares.rs
@@ -9,7 +9,6 @@
//! Public (no login; resolved by token):
//! - `GET /api/share/{token}` — resolve a share for the share page
use std::path::Path;
use std::sync::Arc;
use api_types::{CreateShare, Mode, OkResp, ShareInfo};
@@ -24,11 +23,11 @@ use crate::error::{ApiError, AppState};
use crate::fs;
/// Shared JSON shape for a share (list / create / public resolve).
fn share_info(row: &ShareRow, server_root: &Path) -> ShareInfo {
fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo {
ShareInfo {
id: row.id,
token: row.token.clone(),
name: display_name(server_root, &row.target),
name: display_name(state, &row.target),
is_file: row.is_file,
writable: row.mode.is_writable(),
target: row.target.clone(),
@@ -46,9 +45,7 @@ pub async fn list(
auth: SessionUser,
) -> Result<Json<Vec<ShareInfo>>, ApiError> {
let rows = state.db.user_shares(auth.user.id).await?;
Ok(Json(
rows.iter().map(|r| share_info(r, &state.root)).collect(),
))
Ok(Json(rows.iter().map(|r| share_info(r, &state)).collect()))
}
/// POST /api/shares — create a share.
@@ -135,7 +132,7 @@ pub async fn create(
)
.await?;
Ok(Json(share_info(&row, &state.root)))
Ok(Json(share_info(&row, &state)))
}
/// DELETE /api/shares/{id} — delete one of the current user's shares.
@@ -173,7 +170,7 @@ pub async fn resolve(
"err_share_expired",
));
}
let mut info = share_info(&row, &state.root);
let mut info = share_info(&row, &state);
// A file share opens straight into the viewer, so the client needs the
// file's kind up front (it cannot list a file's "contents").
if row.is_file {
▾Mserver/src/cli.rs
@@ -14,6 +14,11 @@ pub struct Cli {
#[arg(long)]
pub root: PathBuf,
/// Display name of the root folder in the UI. Defaults to the folder's
/// own name, which in a container is the mount point (e.g. "data").
#[arg(long, env = "FILEBROWSER_ROOT_NAME")]
pub root_name: Option<String>,
/// Path to the SQLite database file (required).
#[arg(long)]
pub db: PathBuf,
▾Mserver/src/error.rs
@@ -10,6 +10,8 @@ pub struct AppState {
pub db: Db,
/// Canonicalized server root folder.
pub root: PathBuf,
/// What the UI calls the root folder (`--root-name`, else its file name).
pub root_name: String,
/// Whether we sit behind a TLS-terminating reverse proxy.
pub https: bool,
}
▾Mserver/src/lib.rs
@@ -37,9 +37,15 @@ pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)>
let db = Db::open(&cli.db).await?;
let root_name = cli
.root_name
.clone()
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| root_file_name(&root));
let state = Arc::new(AppState {
db,
root: root.clone(),
root_name,
https: cli.https,
});
@@ -71,6 +77,13 @@ pub async fn run() -> anyhow::Result<()> {
Ok(())
}
/// The root folder's own name; "/" has none, so fall back to the full path.
pub fn root_file_name(root: &std::path::Path) -> String {
root.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| root.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -80,6 +93,7 @@ mod tests {
Cli {
root: root.to_path_buf(),
db: db.to_path_buf(),
root_name: None,
port: 8080,
bind: "127.0.0.1".into(),
https: false,
▾Mserver/tests/api_admin.rs
@@ -331,3 +331,39 @@ async fn update_unknown_user_is_404() {
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admins_can_browse_the_server_root_read_only() {
let env = Env::new().await;
let admin = env.admin().await;
// Listing and descending work through the normal files API.
let r = admin.get("/api/files/-1").await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let names: Vec<&str> = j["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["is_dir"] == true)
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(names, ["docs", "src"]);
assert_eq!(admin.get("/api/files/-1/docs").await.status, StatusCode::OK);
// Read-only: no writes through the pseudo root.
let r = admin
.raw(
axum::http::Method::POST,
"/api/files/-1/newdir?action=mkdir",
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(!env.file("newdir").exists());
// Not an admin → no such folder.
create_user(&admin, "bob", "bobpass123", &[(".", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(bob.get("/api/files/-1").await.status, StatusCode::FORBIDDEN);
}
▾Mserver/tests/api_auth.rs
@@ -2,6 +2,8 @@
mod common;
use std::sync::Arc;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
@@ -66,6 +68,13 @@ async fn setup_creates_admin_and_sets_cookie() {
assert_eq!(roots.len(), 1);
assert_eq!(roots[0]["path"], ".");
assert_eq!(roots[0]["mode"], "rw");
// The whole root is named after the root folder itself (`--root-name`
// overrides this; see `root_name_is_configurable`).
assert_eq!(roots[0]["name"], env.state.root_name);
assert_eq!(
roots[0]["name"],
env.root.path().file_name().unwrap().to_str().unwrap()
);
// Setup is only available on first boot.
let anon = Client::new(env.app.clone());
@@ -184,3 +193,28 @@ async fn disabled_user_loses_session_and_cannot_login() {
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn root_name_is_configurable() {
let mut env = Env::new().await;
// Rebuild the router with a custom root name (what `--root-name` /
// `FILEBROWSER_ROOT_NAME` sets on the state).
let state = Arc::new(server::error::AppState {
db: env.state.db.clone(),
root: env.state.root.clone(),
root_name: "Media".to_string(),
https: false,
});
env.app = server::api::router(state.clone());
env.state = state;
let admin = env.admin().await;
let j = admin.get("/api/auth/me").await.json();
assert_eq!(j["roots"][0]["name"], "Media");
// Subroots keep their own folder name.
create_user(&admin, "bob", "bobpass123", &[("docs/inner", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(
bob.get("/api/auth/me").await.json()["roots"][0]["name"],
"inner"
);
}
▾Mserver/tests/api_search.rs
@@ -138,3 +138,28 @@ async fn search_rejects_bad_input_and_anonymous_callers() {
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn hidden_and_gitignored_entries_are_searched() {
let env = Env::new().await;
let p = env.root.path();
std::fs::create_dir_all(p.join(".hidden")).unwrap();
std::fs::write(p.join(".hidden/secretnote.txt"), "needle here").unwrap();
std::fs::write(p.join("ignoredfile.log"), "needle here").unwrap();
std::fs::write(p.join(".gitignore"), "*.log\n").unwrap();
let admin = env.admin().await;
let r = admin
.get(&format!("/api/search?q=needle&scope=content&root={ROOT}"))
.await;
let evs = events(&r.text());
let paths: Vec<String> = evs
.iter()
.filter_map(|e| e["path"].as_str().map(str::to_string))
.collect();
assert!(
paths.contains(&".hidden/secretnote.txt".to_string()),
"{paths:?}"
);
assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}");
}
▾Mserver/tests/common/mod.rs
@@ -60,6 +60,7 @@ impl Env {
let state = Arc::new(AppState {
db,
root: p.canonicalize().unwrap(),
root_name: server::root_file_name(p),
https: false,
});
let app = server::api::router(state.clone());
▾Mweb/app.css
@@ -983,6 +983,7 @@ button:disabled {
.picker-root {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 7px 8px;
border: none;
@@ -1628,10 +1629,6 @@ button:disabled {
cursor: pointer;
}
.check-row input {
width: auto;
}
.form-error {
color: var(--danger);
font-size: 13px;
@@ -1664,8 +1661,7 @@ button:disabled {
}
.root-add {
display: flex;
gap: 8px;
align-self: flex-start;
margin-top: 4px;
}
▾Mweb/src/api.rs
@@ -36,10 +36,19 @@ pub enum ApiError {
/// The file changed on disk since it was read (save conflict, HTTP 409).
#[error("the file was changed on disk")]
Conflict,
#[error("network error: {0}")]
/// The request never got a response (server down, connection lost) or
/// the response could not be used. Carries a message ready to display.
#[error("{0}")]
Net(String),
}
/// A JS error's `message` (the whole `Debug` output includes the stack).
fn js_msg(e: &JsValue) -> String {
e.dyn_ref::<js_sys::Error>()
.map(|e| String::from(e.message()))
.unwrap_or_else(|| format!("{e:?}"))
}
impl ApiError {
pub fn skipped(&self) -> Option<&[String]> {
match self {
@@ -324,10 +333,10 @@ pub async fn fetch_content_meta(
.ok()
.flatten()
.and_then(|s| s.parse::<i64>().ok());
let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
let js = JsFuture::from(tp)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let text = js
.as_string()
.ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
@@ -352,15 +361,14 @@ pub async fn save_content(
opts.set_method("PUT");
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_body_opt_str(Some(text));
let headers = web_sys::Headers::new()
.map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
headers
.set("Content-Type", "text/plain; charset=utf-8")
.map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
if !force && let Some(m) = expected_mtime {
headers
.set("X-Expected-Mtime", &m.to_string())
.map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
}
opts.set_headers_headers(&headers);
// 409 is the server's "changed on disk" answer, not a generic HTTP error.
@@ -436,21 +444,20 @@ pub async fn upload(
}
segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
.map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let url = append_query(
&files_url(root_id, dir),
&format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
);
let headers = web_sys::Headers::new()
.map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
headers
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)
.map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
@@ -691,12 +698,15 @@ async fn fetch_checked(
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let req = web_sys::Request::new_with_str_and_init(url, opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
// `fetch` only rejects when no response arrived at all (server down,
// connection lost, blocked): one short localized line instead of the
// JS stack.
let resp_val = JsFuture::from(promise).await.map_err(|_| {
ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string())
})?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
@@ -773,37 +783,54 @@ pub fn search_stream(
// `path`: folder inside the root to start in ("" = the whole root).
path: &str,
on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
on_error: leptos::prelude::Callback<String, ()>,
// A plain closure, not a `Callback`: the caller may run from a render
// closure whose owner is disposed on the next re-render, which would
// dispose a `Callback` created there before the stream fails.
on_error: impl Fn(String) + 'static,
) -> Result<web_sys::EventSource, ApiError> {
let url = format!(
"{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
js_sys::encode_uri_component(&q),
js_sys::encode_uri_component(path),
);
let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(format!("{e:?}")))?;
let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?;
// The server always ends a search with `Done`. `error` fires after that
// for the normal end of the stream too (a reconnect pending), so the flag
// tells a finished search from a dropped or refused connection.
let done = std::rc::Rc::new(std::cell::Cell::new(false));
let done2 = done.clone();
let on_msg =
Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
let Some(data) = ev.data().as_string() else {
return;
};
if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
if matches!(ev, api_types::SearchEvent::Done { .. }) {
done2.set(true);
}
on_event.run(ev);
}
});
src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
on_msg.forget();
// `error` fires both when the request is rejected (`CLOSED`) and when the
// stream ends normally (`CONNECTING`, a reconnect pending). A reconnect
// would re-run the whole search, so close the source either way.
// A reconnect would re-run the whole search, so close the source either
// way. `CLOSED` means the server answered and rejected the request (401,
// 403, 400); anything else with no `Done` is a lost connection.
let s = src.clone();
let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
let failed = s.ready_state() == web_sys::EventSource::CLOSED;
let rejected = s.ready_state() == web_sys::EventSource::CLOSED;
s.close();
if failed {
on_error.run(crate::i18n::error_text(None, "search failed"));
if done.get() {
return;
}
let key = if rejected {
crate::i18n::k::SEARCH_FAILED
} else {
crate::i18n::k::SERVER_UNREACHABLE
};
on_error(crate::i18n::t(key).to_string());
});
src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
on_err.forget();
▾Mweb/src/i18n.rs
@@ -331,7 +331,11 @@ pub mod k {
pub const EDIT_USER_TITLE: &str = "edit_user_title";
pub const FOLDERS: &str = "folders";
pub const FOLDERS_PATHS_HINT: &str = "folders_paths_hint";
pub const ADD: &str = "add";
pub const ADD_FOLDER: &str = "add_folder";
pub const ADD_HERE: &str = "add_here";
pub const SERVER_ROOT: &str = "server_root";
pub const SERVER_UNREACHABLE: &str = "server_unreachable";
pub const SEARCH_FAILED: &str = "search_failed";
pub const MODE_RW: &str = "mode_rw";
pub const EXPIRY_1H: &str = "expiry_1h";
pub const EXPIRY_1D: &str = "expiry_1d";
@@ -656,7 +660,11 @@ const EN: &[(&str, &str)] = &[
"folders_paths_hint",
"Paths are relative to the server root. “.” is the whole root.",
),
("add", "Add"),
("add_folder", "Add folder…"),
("add_here", "Add this folder"),
("server_root", "Server root"),
("server_unreachable", "The server is not reachable."),
("search_failed", "The search failed."),
("mode_rw", "read-write"),
("expiry_1h", "1 hour"),
("expiry_1d", "1 day"),
@@ -1061,7 +1069,11 @@ const DE: &[(&str, &str)] = &[
"folders_paths_hint",
"Pfade sind relativ zur Serverwurzel. „.“ ist der gesamte Wurzelordner.",
),
("add", "Hinzufügen"),
("add_folder", "Ordner hinzufügen…"),
("add_here", "Diesen Ordner hinzufügen"),
("server_root", "Serverwurzel"),
("server_unreachable", "Der Server ist nicht erreichbar."),
("search_failed", "Die Suche ist fehlgeschlagen."),
("mode_rw", "lesen/schreiben"),
("expiry_1h", "1 Stunde"),
("expiry_1d", "1 Tag"),
@@ -1511,7 +1523,11 @@ const FR: &[(&str, &str)] = &[
"folders_paths_hint",
"Les chemins sont relatifs à la racine du serveur. « . » désigne toute la racine.",
),
("add", "Ajouter"),
("add_folder", "Ajouter un dossier…"),
("add_here", "Ajouter ce dossier"),
("server_root", "Racine du serveur"),
("server_unreachable", "Le serveur est injoignable."),
("search_failed", "La recherche a échoué."),
("mode_rw", "lecture-écriture"),
("expiry_1h", "1 heure"),
("expiry_1d", "1 jour"),
▾Mweb/src/views/admin.rs
@@ -17,6 +17,7 @@ use crate::api::{self, AdminUser, Me, Mode};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::icons::IconName;
use crate::views::dialogs::{Dialog, admin_root};
// ---------------------------------------------------------------------------
// Settings view
@@ -325,7 +326,11 @@ enum Editing {
}
#[component]
pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
pub fn UsersView(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
let (editing, set_editing) = signal(Option::<Editing>::None);
@@ -491,6 +496,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
self_id=sid
close=Callback::new(move |_| set.set(None))
on_saved=on_saved
set_dialog=set_dialog
/>
}
.into_view()
@@ -520,6 +526,7 @@ fn UserForm(
self_id: Option<i64>,
close: Callback<()>,
on_saved: Callback<Option<i64>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_new = existing.is_none();
@@ -560,27 +567,35 @@ fn UserForm(
});
}
let (roots, set_roots) = signal(roots0);
let (new_path, set_new_path) = signal(String::new());
let (busy, set_busy) = signal(false);
let (error, set_error) = signal(Option::<String>::None);
let name_readonly = !is_new;
// Pick a folder under the server root; "" (the root itself) is stored
// as ".".
let add_root = move |_| {
let p = new_path.get().trim().to_string();
let path = if p.is_empty() { ".".to_string() } else { p };
let mut v = roots.get();
// Avoid duplicate paths.
if v.iter().any(|r| r.path == path) {
set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
return;
}
v.push(RootDraft {
path: path.clone(),
mode: Mode::Rw,
});
set_roots.set(v);
set_new_path.set(String::new());
set_error.set(None);
set_dialog.set(Some(Dialog::Picker {
title: i18n::t(i18n::k::ADD_FOLDER).to_string(),
confirm: i18n::t(i18n::k::ADD_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)| {
let path = if dir.is_empty() { ".".to_string() } else { dir };
let mut v = roots.get();
if v.iter().any(|r| r.path == path) {
set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
return;
}
v.push(RootDraft {
path,
mode: Mode::Rw,
});
set_roots.set(v);
set_error.set(None);
}),
}));
};
let save = move |_| {
@@ -810,22 +825,10 @@ fn UserForm(
})
.collect::<Vec<_>>()
}}
<div class="root-add">
<input
class="field-input"
value=move || new_path.get()
placeholder="e.g. docs or ."
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_new_path.set(t.value());
}
}
/>
<button class="btn" on:click=add_root>{i18n::tr(i18n::k::ADD)}</button>
</div>
<button class="btn root-add" on:click=add_root>
<Icon name=IconName::Folder class="ic-btn".to_string()/>
{i18n::tr(i18n::k::ADD_FOLDER)}
</button>
</div>
</div>
{move || match error.get() {
▾Mweb/src/views/browser.rs
@@ -1586,7 +1586,6 @@ fn action_move_copy(
for_write: true,
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
set_dialog.set(None);
// Items still to do. A finished item is dropped, so the
// overwrite retry re-runs only the ones that collided.
let pending = Arc::new(Mutex::new(items.clone()));
▾Mweb/src/views/dialogs.rs
@@ -9,7 +9,7 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
@@ -515,6 +515,17 @@ fn ConflictDialog(
// Picker (folder chooser)
// ---------------------------------------------------------------------------
/// The one entry of the admin picker's root pane: the server root, which
/// every admin can browse read-only under [`api_types::ADMIN_ROOT`].
pub fn admin_root() -> RootInfo {
RootInfo {
id: api_types::ADMIN_ROOT,
name: i18n::t(i18n::k::SERVER_ROOT).to_string(),
path: ".".to_string(),
mode: api_types::Mode::Rw,
}
}
/// Two panes: the roots on the left, the folder tree of the selected root
/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
/// the breadcrumbs go back up and name the chosen folder.
@@ -531,18 +542,27 @@ fn PickerDialog(
) -> impl IntoView {
let (sel_root, set_sel_root) = signal(root);
let (sel_dir, set_sel_dir) = signal(dir.clone());
let (entries, set_entries) = signal(Vec::<Entry>::new());
let (entries, set_entries) = signal(Vec::<String>::new());
let (loading, set_loading) = signal(true);
let toast = use_context::<ToastMsg>().expect("toast context");
// Load the subfolders of the selected directory whenever it changes.
// Load the subfolder names of the selected directory whenever it changes.
Effect::new(move |_| {
let rid = sel_root.get();
let dir = sel_dir.get();
set_loading.set(true);
spawn_local(async move {
let subs = match api::list_files(rid, &dir).await {
Ok(r) => r.entries.into_iter().filter(|e| e.is_dir).collect(),
Err(_) => Vec::new(),
Ok(r) => r
.entries
.into_iter()
.filter(|e| e.is_dir)
.map(|e| e.name)
.collect(),
Err(e) => {
show_error(toast, e.to_string());
Vec::new()
}
};
set_entries.set(subs);
set_loading.set(false);
@@ -653,8 +673,8 @@ fn PickerDialog(
.into_any();
}
es.iter()
.map(|e| {
let name = e.name.clone();
.map(|name| {
let name = name.clone();
let name2 = name.clone();
view! {
<div class="picker-row" on:click=move |_| descend(name2.clone())>
▾Mweb/src/views/search.rs
@@ -511,7 +511,22 @@ pub fn SearchView(
root,
&dir,
on_stream_event,
on_error_for(toast, status, search_gen.clone(), my_gen),
// A plain closure, not a `Callback`: this also runs from the
// Search button's render closure, whose owner is disposed
// when the status re-renders. A `Callback` created there is
// gone by the time the stream fails, and the search never
// leaves "searching". Ignored once the search is replaced
// or the view unmounted (both bump the generation).
{
let search_gen = search_gen.clone();
move |msg| {
if search_gen.load(Ordering::SeqCst) != my_gen {
return;
}
show_error(toast, msg);
status.set(Status::Idle);
}
},
) {
Ok(s) => s,
Err(e) => {
@@ -605,7 +620,6 @@ pub fn SearchView(
dir,
for_write: false,
on_pick: Callback::new(move |(root, dir): (i64, String)| {
set_dialog.set(None);
set_folder.set(Some((root, dir)));
}),
}));
@@ -760,7 +774,7 @@ pub fn SearchView(
.and_then(|m| m.roots.into_iter().find(|r| r.id == root))
.map(|r| r.name)
.unwrap_or_default();
if dir.is_empty() { format!("{name}/") } else { format!("{name}/{dir}") }
if dir.is_empty() { name } else { format!("{name}/{dir}") }
}}
</span>
</button>
@@ -1375,23 +1389,6 @@ async fn apply_batch(ctx: &FlushCtx, batch: Vec<SearchEvent>, my_gen: u64) {
.await;
}
/// Reports a stream failure, unless the search it belongs to is already gone
/// (a new search, or the view unmounted — both bump the generation).
fn on_error_for(
toast: ToastMsg,
status: RwSignal<Status>,
search_gen: Arc<AtomicU64>,
my_gen: u64,
) -> Callback<String, ()> {
Callback::new(move |msg| {
if search_gen.load(Ordering::SeqCst) != my_gen {
return;
}
show_error(toast, msg);
status.set(Status::Idle);
})
}
/// Whether the signed-in user may write to `root_id`.
fn is_writable(me: ReadSignal<Option<api_types::Me>>, root_id: i64) -> bool {
me.get_untracked().is_some_and(|m| {
▾Mweb/src/views/shares.rs
@@ -277,18 +277,20 @@ pub fn ShareDialog(
pub fn SharesView() -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
// Set when the list could not be loaded; the error card offers a retry.
let (load_err, set_load_err) = signal(Option::<String>::None);
let (confirm_all, set_confirm_all) = signal(false);
let (busy_all, set_busy_all) = signal(false);
{
let set = set_shares;
let toast2 = toast;
let load = move || {
set_load_err.set(None);
spawn_local(async move {
match api::list_shares().await {
Ok(s) => set.set(Some(s)),
Err(e) => show_error(toast2, e.to_string()),
Ok(s) => set_shares.set(Some(s)),
Err(e) => set_load_err.set(Some(e.to_string())),
}
});
}
};
load();
// Delete every share of the current user (no server-side bulk endpoint).
let delete_all = move |_| {
@@ -364,11 +366,23 @@ pub fn SharesView() -> impl IntoView {
<p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
</div>
{move || match shares.get() {
None => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
None => match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| load()>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
.into_view()
.into_any(),
None => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
},
Some(ref list) if list.is_empty() => view! {
<div class="empty-state">
<Icon name=IconName::Share class="empty-glyph".to_string()/>
▾Mweb/src/views/shell.rs
@@ -346,7 +346,7 @@ pub fn ShellView(
.into_view()
.into_any(),
Section::Users if admin => {
view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
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/>