Perf: listing cap, paged browser, single-query auth, cache validators
Server: - SQLite: synchronous=NORMAL, prepare_cached, one JOIN for session user + roots and for the admin user list, index on shares.creator_id. - Listing: sort_by_cached_key, cap at MAX_LIST_ENTRIES (10 000) with a `truncated` flag, sniff skipped for the cut tail, one spawn_blocking. - Embedded assets served borrowed with ETag / 304. - Uploads: 1 MiB BufWriter, stat calls off the async runtime. - Search: resolve_dir in spawn_blocking, walker threads capped, four concurrent searches, 503 instead of queueing. - Downloads: Last-Modified + 304 with Cache-Control: no-cache; no validator for files younger than two seconds. - Archive: one stat less per entry, gzip level 1. Argon2 capped at 4. - Release profile: thin LTO, codegen-units=1, wasm opt-level z. Web: - Browser: Arc<Listing>, paged rendering with shared page_footer, keyed <For>, icon_svg rows, selection HashSet memo, stale-fetch guard, truncation notice (LIST_TRUNCATED). - Shell: section memo, no remount on navigation; sidebar roots do not track the location. - Toolbar recompute gated on selection kind; format_date slices the RFC 3339 string; i18n binary search over sorted tables; search collapse state as a counter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCargo.toml
@@ -4,6 +4,14 @@ members = ["api-types", "server", "web"]
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
# No `panic = "abort"`: the server has no CatchPanic layer, a handler panic
# would take the whole process down.
# The wasm bundle: size over speed.
[profile.release.package.web]
opt-level = "z"
# The bundled SQLite amalgamation at -O2 is 63s of the 77s release build.
# -O1 makes it 42s. Our tables are tiny, so the runtime cost is nil.
Mapi-types/src/lib.rs
@@ -229,8 +229,15 @@ pub struct Entry {
#[derive(Serialize, Deserialize)]
pub struct FilesResp {
pub entries: Vec<Entry>,
/// True when the folder had more entries than `MAX_LIST_ENTRIES`.
/// `entries` then holds only the first `MAX_LIST_ENTRIES` after sorting.
#[serde(default)]
pub truncated: bool,
}
/// Cap on entries in one directory listing.
pub const MAX_LIST_ENTRIES: usize = 10_000;
/// One streamed search result. Each is sent as one SSE event
/// (`data: <json>`), in the order found; the `done` event always ends the
/// stream.
Mserver/src/api/admin.rs
@@ -83,11 +83,19 @@ pub async fn list_users(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<AdminUser>>, ApiError> {
let users = state.db.all_users().await?;
let mut out = Vec::with_capacity(users.len());
for u in &users {
out.push(user_info(&state.db, &state, u).await?);
}
let out = state
.db
.all_users_with_roots()
.await?
.into_iter()
.map(|(u, roots)| AdminUser {
id: u.id,
name: u.name,
is_admin: u.is_admin,
active: u.active,
roots: roots.iter().map(|r| root_info(&state, r)).collect(),
})
.collect();
Ok(Json(out))
}
Mserver/src/api/auth.rs
@@ -9,7 +9,7 @@ use serde::Deserialize;
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::db::User;
use crate::db::{RootRow, User};
use crate::error::{ApiError, AppState};
/// GET /api/auth/me
@@ -30,12 +30,15 @@ pub async fn me(
}));
}
let user = require_user(&state, &headers).await?;
Ok(Json(me_for(&state, &user).await?))
let (user, roots) = require_user(&state, &headers).await?;
Ok(Json(me_for(&state, &user, roots).await?))
}
/// The signed-in user behind the session cookie, or 401.
async fn require_user(state: &AppState, headers: &HeaderMap) -> Result<User, ApiError> {
/// The signed-in user behind the session cookie plus their roots, or 401.
async fn require_user(
state: &AppState,
headers: &HeaderMap,
) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(headers) else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
@@ -43,21 +46,22 @@ async fn require_user(state: &AppState, headers: &HeaderMap) -> Result<User, Api
"err_not_signed_in",
));
};
state.db.session_user(&token).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
)
})
state
.db
.session_user_with_roots(&token)
.await?
.ok_or_else(|| {
ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
)
})
}
/// Build the `/api/auth/me` payload for an authenticated user.
async fn me_for(state: &AppState, user: &User) -> Result<Me, ApiError> {
let roots: Vec<RootInfo> = state
.db
.user_roots(user.id)
.await?
async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me, ApiError> {
let roots: Vec<RootInfo> = roots
.into_iter()
.map(|r| RootInfo {
id: r.id,
@@ -122,7 +126,7 @@ pub async fn update_profile(
headers: HeaderMap,
Json(body): Json<ProfilePatch>,
) -> Result<Json<Me>, ApiError> {
let mut user = require_user(&state, &headers).await?;
let (mut user, roots) = require_user(&state, &headers).await?;
if let Some(Some(ref tag)) = body.language
&& !valid_language(tag)
{
@@ -140,7 +144,7 @@ pub async fn update_profile(
state.db.set_user_language(user.id, lang.as_deref()).await?;
user.language = lang;
}
Ok(Json(me_for(&state, &user).await?))
Ok(Json(me_for(&state, &user, roots).await?))
}
/// POST /api/auth/setup — create the first admin account.
Mserver/src/api/common.rs
@@ -119,14 +119,13 @@ async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<Root
"err_not_signed_in",
));
};
let Some(user) = state.db.session_user(&token).await? else {
let Some((user, roots)) = state.db.session_user_with_roots(&token).await? else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
));
};
let roots = state.db.user_roots(user.id).await?;
Ok((user, roots))
}
@@ -196,6 +195,7 @@ pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
/// running it inline would block a tokio worker thread for the whole cost.
pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
let pw = pw.to_string();
let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
.await
.map_err(|_| {
Mserver/src/api/files.rs
@@ -73,11 +73,20 @@ pub async fn file_get(
req_rel,
query.format.as_deref(),
range,
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(state, auth, root_id, req_rel, range_header(&headers)).await
preview(
state,
auth,
root_id,
req_rel,
range_header(&headers),
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
_ => {
@@ -108,11 +117,20 @@ pub async fn list_root(
String::new(),
query.format.as_deref(),
range,
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(state, auth, root_id, String::new(), range_header(&headers)).await
preview(
state,
auth,
root_id,
String::new(),
range_header(&headers),
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_CONTENT => {
content(state, auth, root_id, String::new()).await
@@ -131,6 +149,31 @@ fn range_header(headers: &axum::http::HeaderMap) -> Option<String> {
.map(str::to_string)
}
fn ims_header(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// An mtime (unix seconds) as an HTTP-date, the `Last-Modified` format.
/// None for an unknown mtime (0) and for a file written in the last two
/// seconds: whole-second dates cannot tell two writes in one second apart.
fn http_date(mtime: i64) -> Option<String> {
if mtime <= 0 || mtime + 2 > chrono::Utc::now().timestamp() {
return None;
}
chrono::DateTime::from_timestamp(mtime, 0)
.map(|d| d.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
}
/// True when the client's `If-Modified-Since` is at or after `mtime`.
/// HTTP-dates carry whole seconds, so the comparison is second-precision.
fn unmodified_since(ims: Option<&str>, mtime: i64) -> bool {
chrono::DateTime::parse_from_rfc2822(ims.unwrap_or_default())
.is_ok_and(|t| t.timestamp() >= mtime)
}
async fn list_inner(
state: Arc<AppState>,
auth: AuthUser,
@@ -144,28 +187,21 @@ async fn list_inner(
let root = find_root(&auth.roots, root_id)?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
let full =
tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
// Resolve and list in one blocking hop: both are filesystem work.
let (entries, truncated) = tokio::task::spawn_blocking(move || {
let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?;
fs::list_dir(&full)
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(FilesResp { entries }))
Ok(Json(FilesResp { entries, truncated }))
}
// ---------------------------------------------------------------------------
@@ -203,7 +239,7 @@ async fn resolve_item(
root: &RootRow,
req_rel: &str,
share: Option<&ShareRow>,
) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
) -> Result<(std::path::PathBuf, String, bool, u64, i64), ApiError> {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
// A file share's synthetic root *is* the file, so resolve it directly.
let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
@@ -217,7 +253,8 @@ async fn resolve_item(
.map(|n| n.to_string_lossy().into_owned())
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
let mtime = fs::mtime_secs(&meta).unwrap_or(0);
Ok::<_, FsError>((full, name, meta.is_dir(), meta.len(), mtime))
})
.await
.map_err(|_| {
@@ -239,13 +276,23 @@ async fn download(
req_rel: String,
format: Option<&str>,
range: Option<String>,
ims: Option<String>,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) =
let (full, name, is_dir, size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if !is_dir {
return file_response(&full, &name, size, false, range.as_deref()).await;
return file_response(
&full,
&name,
size,
false,
range.as_deref(),
mtime,
ims.as_deref(),
)
.await;
}
let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
@@ -277,9 +324,10 @@ async fn preview(
root_id: i64,
req_rel: String,
range: Option<String>,
ims: Option<String>,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) =
let (full, name, is_dir, size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::localized(
@@ -288,7 +336,16 @@ async fn preview(
"err_not_a_file",
));
}
file_response(&full, &name, size, true, range.as_deref()).await
file_response(
&full,
&name,
size,
true,
range.as_deref(),
mtime,
ims.as_deref(),
)
.await
}
/// `GET ...?action=content` — raw file bytes for the text preview/editor.
@@ -300,7 +357,7 @@ async fn content(
req_rel: String,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, _name, is_dir, size) =
let (full, _name, is_dir, size, _mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::localized(
@@ -433,7 +490,14 @@ async fn file_response(
size: u64,
inline: bool,
range: Option<&str>,
mtime: i64,
ims: Option<&str>,
) -> Result<Response, ApiError> {
let last_modified = http_date(mtime);
// A revalidating client gets the empty 304 instead of the whole file.
if last_modified.is_some() && unmodified_since(ims, mtime) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
let mime = mime_guess::from_path(full)
.first_or_octet_stream()
.to_string();
@@ -466,6 +530,13 @@ async fn file_response(
.header(header::CONTENT_DISPOSITION, disp)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, end - start);
if let Some(lm) = last_modified {
// `no-cache` keeps browsers from serving a heuristically fresh copy.
// They revalidate instead, and get the 304 above.
res = res
.header(header::LAST_MODIFIED, lm)
.header(header::CACHE_CONTROL, "no-cache");
}
if partial {
res = res.header(
header::CONTENT_RANGE,
@@ -971,7 +1042,8 @@ async fn upload(
// inside the upload directory. `validate_rel_path` blocks `..`, but a
// symlinked directory on disk would otherwise carry the write outside.
let (p, b) = (parent.to_path_buf(), root_abs.clone());
let parent = tokio::task::spawn_blocking(move || {
let file_name = target.file_name().map(|n| n.to_owned());
let (parent, target_state) = tokio::task::spawn_blocking(move || {
let escape = || io::Error::other("upload parent escapes the root");
// Check the nearest existing ancestor *before* creating anything,
// so no directory is ever created outside the root either.
@@ -989,7 +1061,12 @@ async fn upload(
if !fs::is_within_or_eq(&b, &canon) {
return Err(escape());
}
Ok::<_, io::Error>(canon)
// Whether the target already exists, and as what: two stats that
// belong on this thread, not on an async worker.
let state = file_name
.map(|n| canon.join(n))
.map(|t| (t.exists(), t.is_dir()));
Ok::<_, io::Error>((canon, state))
})
.await
.map_err(|_| io::Error::other("join"))?
@@ -1001,7 +1078,7 @@ async fn upload(
"err_bad_upload_path",
)
})?;
let Some(file_name) = target.file_name() else {
let (Some(file_name), Some((exists, is_dir))) = (target.file_name(), target_state) else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
@@ -1010,7 +1087,7 @@ async fn upload(
};
let target = parent.join(file_name);
if target.exists() && !overwrite {
if exists && !overwrite {
// Drain this part and report it as a conflict at the end.
while let Some(_chunk) = field.chunk().await.map_err(|_| {
ApiError::localized(
@@ -1022,8 +1099,8 @@ async fn upload(
skipped.push(part_name);
continue;
}
if target.exists() {
if target.is_dir() {
if exists {
if is_dir {
return Err(ApiError::localized(
StatusCode::CONFLICT,
"a folder with this name already exists",
@@ -1042,13 +1119,16 @@ async fn upload(
// Stream to a temp file in the same directory, then rename into place.
let suffix = crate::auth::random_token();
let tmp = parent.join(format!(".upload-{suffix}"));
let mut tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
let tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
// Buffered: a multipart chunk is often a few kilobytes, and each
// unbuffered write would be its own syscall.
let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file);
let write_failed = loop {
match field.chunk().await.map_err(|_| {
ApiError::localized(
@@ -1063,7 +1143,7 @@ async fn upload(
break true;
}
}
Ok(None) => break false,
Ok(None) => break tmp_file.flush().await.is_err(),
Err(e) => return Err(e),
}
};
@@ -1086,15 +1166,15 @@ async fn upload(
"err_internal",
)
})?;
renamed.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
if let Err(e) = renamed {
let _ = tokio::fs::remove_file(&tmp).await;
tracing::warn!(error = %e, "rename failed during upload");
ApiError::localized(
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
));
}
uploaded += 1;
}
Mserver/src/api/search.rs
@@ -44,6 +44,20 @@ use crate::api::common::AuthUser;
use crate::db::RootRow;
use crate::error::{ApiError, AppState};
/// Concurrent searches. A search owns a pool of walker threads, so the two
/// caps together bound the threads a burst of searches can create.
static SEARCH_SLOTS: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4)));
/// Walker threads per search: the machine's parallelism, capped at 8. The
/// walk is IO-bound, so more threads buy nothing past that.
fn walker_threads() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.min(8)
}
/// Content search skips files larger than this. There is no search budget
/// (the client can stop at any time), but a multi-gigabyte text file would
/// stall its worker indefinitely — ripgrep has no such cap, we do.
@@ -131,7 +145,27 @@ pub(super) async fn search(
.unwrap_or("")
.trim_matches('/')
.to_string();
crate::fs::resolve_dir(&state.root, &root.path, &start_rel)?;
let (server_root, root_path, start) =
(state.root.clone(), root.path.clone(), start_rel.clone());
tokio::task::spawn_blocking(move || crate::fs::resolve_dir(&server_root, &root_path, &start))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
// One slot per running search, held until the walk ends. Each search owns
// a pool of walker threads, so unbounded concurrency would swamp the box.
// No queueing: a waiting request would hang without any response.
let slot = SEARCH_SLOTS.clone().try_acquire_owned().map_err(|_| {
ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"too many searches are running",
)
})?;
let events = search_stream(
q.to_string(),
@@ -140,6 +174,7 @@ pub(super) async fn search(
root,
start_rel,
state.root.clone(),
slot,
);
// `Sse` does the `data: <json>\n\n` framing and the content-type and
// cache headers; nginx and friends still need telling not to buffer.
@@ -181,6 +216,7 @@ fn search_stream(
root: RootRow,
start_rel: String,
server_root: PathBuf,
slot: tokio::sync::OwnedSemaphorePermit,
) -> impl futures_util::Stream<Item = SearchEvent> + Send {
let (tx, rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
let state = Arc::new(SearchState {
@@ -197,6 +233,8 @@ fn search_stream(
});
std::thread::spawn(move || {
// Released when the walk is done, not when the client stops reading.
let _slot = slot;
walk(&state, &q, want_name, want_content);
// `stopped`: the receiver went away (client stopped or navigated)
// or the match cap was hit, before the walk finished.
@@ -236,6 +274,7 @@ fn walk(st: &Arc<SearchState>, q: &str, want_name: bool, want_content: bool) {
// A file browser shows everything, so search must too: no hidden
// or `.gitignore`/`.ignore` filtering. Symlinks stay unfollowed.
.standard_filters(false)
.threads(walker_threads())
.build_parallel()
.run(|| {
let abs = abs.clone();
Mserver/src/api/spa.rs
@@ -1,5 +1,7 @@
use std::borrow::Cow;
use axum::http::Uri;
use axum::http::{Method, StatusCode, header};
use axum::http::{HeaderMap, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
const DEV_HINT: &str = r#"<!doctype html>
@@ -17,7 +19,7 @@ directory is visible — the server looks in <code>target/web</code> or <code>$F
/// Serve the frontend: static assets, with a fallback to index.html so that
/// client-side routes (e.g. `/s/<token>`) work.
pub(super) async fn fallback(method: Method, uri: Uri) -> Response {
pub(super) async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response {
let path = uri.path();
if path.starts_with("/api/") {
return (StatusCode::NOT_FOUND, "unknown endpoint").into_response();
@@ -34,21 +36,51 @@ pub(super) async fn fallback(method: Method, uri: Uri) -> Response {
};
match asset {
Some((bytes, mime, cache)) => (
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes,
)
.into_response(),
Some((bytes, mime, cache, etag)) => {
// The embedded assets carry a content hash, so a repeat visit can
// be answered without sending the body again.
if let Some(tag) = &etag
&& headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').any(|t| t.trim() == tag || t.trim() == "*"))
{
return (
StatusCode::NOT_MODIFIED,
[(header::CACHE_CONTROL, cache), (header::ETAG, tag.clone())],
)
.into_response();
}
let mut res = (
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
body(bytes),
)
.into_response();
if let Some(tag) = etag
&& let Ok(v) = tag.parse()
{
res.headers_mut().insert(header::ETAG, v);
}
res
}
None => match crate::assets::get_asset("index.html") {
Some((bytes, mime, _)) => (
Some((bytes, mime, _, _)) => (
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "no-cache".to_string()),
],
bytes,
body(bytes),
)
.into_response(),
None => DEV_HINT.into_response(),
},
}
}
/// Asset bytes as a response body, without copying the embedded ones.
fn body(bytes: Cow<'static, [u8]>) -> axum::body::Bytes {
match bytes {
Cow::Borrowed(b) => axum::body::Bytes::from_static(b),
Cow::Owned(v) => axum::body::Bytes::from(v),
}
}
Mserver/src/archive.rs
@@ -61,7 +61,8 @@ pub fn build(
match format {
ArchiveFormat::Tar => build_tar(dir, top_name, sink).map(|_| ()),
ArchiveFormat::TarGz => {
let enc = flate2::write::GzEncoder::new(sink, flate2::Compression::default());
// Level 1: the archive is streamed, so throughput beats ratio.
let enc = flate2::write::GzEncoder::new(sink, flate2::Compression::new(1));
build_tar(dir, top_name, enc)?.finish().map(|_| ())
}
ArchiveFormat::TarZst => {
@@ -72,12 +73,8 @@ pub fn build(
}
}
fn mtime_secs(p: &Path) -> u64 {
std::fs::metadata(p)
.ok()
.and_then(|m| crate::fs::mtime_secs(&m))
.unwrap_or(0)
.max(0) as u64
fn mtime_secs(m: &std::fs::Metadata) -> u64 {
crate::fs::mtime_secs(m).unwrap_or(0).max(0) as u64
}
/// Cycle guard: a symlink loop would otherwise recurse forever. Real trees
@@ -154,7 +151,7 @@ fn tar_add<W: Write>(
let mut header = tar::Header::new_gnu();
let meta = std::fs::metadata(abs)?;
header.set_mode(if is_dir { 0o755 } else { 0o644 });
header.set_mtime(mtime_secs(abs));
header.set_mtime(mtime_secs(&meta));
let name = if is_dir {
format!("{entry}/")
} else {
Mserver/src/assets.rs
@@ -1,6 +1,7 @@
//! Static frontend assets: embedded at compile time (`--features embedded`)
//! or read from disk in the dev flow (Trunk's output dir or $FBNG_DIST).
use std::borrow::Cow;
#[cfg(not(feature = "embedded"))]
use std::path::PathBuf;
@@ -13,10 +14,13 @@ mod embedded {
pub struct Assets;
}
/// An asset: bytes, content-type, cache-control, and an ETag when the bytes
/// are embedded (the dev flow serves from disk and has none).
pub(crate) type Asset = (Cow<'static, [u8]>, String, String, Option<String>);
/// Look up an asset by (slash-separated) path.
/// Returns (bytes, content-type, cache-control).
pub(crate) fn get_asset(path: &str) -> Option<(Vec<u8>, String, String)> {
let (bytes, from_disk) = read(path)?;
pub(crate) fn get_asset(path: &str) -> Option<Asset> {
let (bytes, from_disk, etag) = read(path)?;
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
@@ -26,12 +30,23 @@ pub(crate) fn get_asset(path: &str) -> Option<(Vec<u8>, String, String)> {
// Trunk hashes asset file names, so they are safe to cache forever.
"public, max-age=31536000, immutable".to_string()
};
Some((bytes, mime, cache))
Some((bytes, mime, cache, etag))
}
#[cfg(feature = "embedded")]
fn read(path: &str) -> Option<(Vec<u8>, bool)> {
embedded::Assets::get(path).map(|c| (c.data.to_vec(), false))
fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
embedded::Assets::get(path).map(|c| {
// The embedded hash is the file's content hash, so it doubles as a
// strong ETag.
let mut etag = String::with_capacity(2 + 32);
etag.push('"');
for b in c.metadata.sha256_hash().iter().take(16) {
use std::fmt::Write as _;
let _ = write!(etag, "{b:02x}");
}
etag.push('"');
(c.data, false, Some(etag))
})
}
#[cfg(not(feature = "embedded"))]
@@ -55,13 +70,14 @@ fn is_safe_asset_path(path: &str) -> bool {
}
#[cfg(not(feature = "embedded"))]
fn read(path: &str) -> Option<(Vec<u8>, bool)> {
fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
if !is_safe_asset_path(path) {
return None;
}
let p = dev_dist_dir().join(path);
if p.is_file() {
std::fs::read(&p).ok().map(|b| (b, true))
// No ETag from disk: the dev flow wants every reload to be fresh.
std::fs::read(&p).ok().map(|b| (Cow::Owned(b), true, None))
} else {
None
}
Mserver/src/auth.rs
@@ -8,6 +8,10 @@ pub const COOKIE_NAME: &str = "fbng_session";
/// 30 days.
pub const SESSION_MAX_AGE: u64 = 60 * 60 * 24 * 30;
/// Concurrent Argon2 runs. Each one burns a blocking thread and ~19 MiB, so
/// a burst of logins must not take the whole pool. Waiters queue here.
pub(crate) static ARGON2_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut rand::thread_rng());
let hash = Argon2::default()
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 = 4;
const SCHEMA_VERSION: i64 = 5;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -99,6 +99,9 @@ impl Db {
}
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
// WAL plus NORMAL: fsync only at checkpoints. A crash can lose the
// last commits, never the database file.
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", "5000")?;
Self::migrate(&conn)?;
@@ -150,6 +153,12 @@ impl Db {
// browser".
conn.execute_batch("ALTER TABLE users ADD COLUMN language TEXT")?;
}
if version < 5 {
// The share list is queried by creator on every shares page.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -161,7 +170,8 @@ impl Db {
pub async fn user_count(&self) -> DbResult<i64> {
let c = self.0.lock().await;
c.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
let mut stmt = c.prepare_cached("SELECT COUNT(*) FROM users")?;
stmt.query_row([], |r| r.get(0))
}
/// Create the first admin account with the whole root visible (read-write).
@@ -225,6 +235,7 @@ impl Db {
_ => (None, DUMMY_HASH.clone()),
};
// Argon2 is CPU-bound, so it must not run on an async worker thread.
let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
let password = password.to_string();
let ok =
tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
@@ -261,24 +272,49 @@ impl Db {
Ok(())
}
pub async fn session_user(&self, token: &str) -> DbResult<Option<User>> {
/// The session's user plus that user's roots, in one round trip. Every
/// authenticated request needs both, so they are not two queries.
pub async fn session_user_with_roots(
&self,
token: &str,
) -> DbResult<Option<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
c.query_row(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1 AND u.active = 1",
[token],
map_user,
)
.optional()
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language,
r.id, r.path, r.mode
FROM sessions s
JOIN users u ON u.id = s.user_id
LEFT JOIN user_roots r ON r.user_id = u.id
WHERE s.token = ?1 AND u.active = 1
ORDER BY r.id",
)?;
// One row per root; a user without roots still returns one row, with
// the root columns NULL.
let mut user: Option<User> = None;
let mut roots: Vec<RootRow> = Vec::new();
let mut rows = stmt.query([token])?;
while let Some(r) = rows.next()? {
if user.is_none() {
user = Some(map_user(r)?);
}
if let Some(id) = r.get::<_, Option<i64>>(6)? {
roots.push(RootRow {
id,
path: r.get(7)?,
mode: r.get::<_, SqlMode>(8)?.0,
});
}
}
Ok(user.map(|u| (u, roots)))
}
// ---------- roots ----------
pub async fn user_roots(&self, user_id: i64) -> DbResult<Vec<RootRow>> {
let c = self.0.lock().await;
let mut stmt =
c.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")?;
let mut stmt = c.prepare_cached(
"SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], |r| {
Ok(RootRow {
id: r.get(0)?,
@@ -291,14 +327,34 @@ impl Db {
// ---------- admin: user management (M7) ----------
pub async fn all_users(&self) -> DbResult<Vec<User>> {
/// Every user with their roots, in one query. The admin user list needs
/// both, and a per-user roots query would be one round trip per user.
pub async fn all_users_with_roots(&self) -> DbResult<Vec<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
FROM users ORDER BY id",
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language,
r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
ORDER BY u.id, r.id",
)?;
let rows = stmt.query_map([], map_user)?;
rows.collect()
// Rows arrive grouped by user, so a new user id starts a new group.
let mut out: Vec<(User, Vec<RootRow>)> = Vec::new();
let mut rows = stmt.query([])?;
while let Some(r) = rows.next()? {
let uid: i64 = r.get(0)?;
if out.last().is_none_or(|(u, _)| u.id != uid) {
out.push((map_user(r)?, Vec::new()));
}
if let Some(id) = r.get::<_, Option<i64>>(6)? {
out.last_mut().expect("pushed above").1.push(RootRow {
id,
path: r.get(7)?,
mode: r.get::<_, SqlMode>(8)?.0,
});
}
}
Ok(out)
}
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
@@ -515,14 +571,15 @@ impl Db {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
FROM shares WHERE token = ?1";
c.query_row(sql, [token], map_share).optional()
let mut stmt = c.prepare_cached(sql)?;
stmt.query_row([token], map_share).optional()
}
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
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
let mut stmt = c.prepare(sql)?;
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([creator_id], map_share)?;
rows.collect()
}
@@ -541,10 +598,8 @@ impl Db {
pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
let c = self.0.lock().await;
c.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
r.get(0)
})
.optional()
let mut stmt = c.prepare_cached("SELECT value FROM settings WHERE key = ?1")?;
stmt.query_row([key], |r| r.get(0)).optional()
}
pub async fn set_setting(&self, key: &str, value: &str) -> DbResult<()> {
@@ -672,7 +727,7 @@ mod tests {
assert!(!db.allow_writable_shares().await.unwrap());
assert!(db.find_user_by_name("nobody").await.unwrap().is_none());
assert!(db.find_user_by_id(1).await.unwrap().is_none());
assert!(db.all_users().await.unwrap().is_empty());
assert!(db.all_users_with_roots().await.unwrap().is_empty());
}
#[tokio::test]
@@ -757,17 +812,22 @@ mod tests {
#[tokio::test]
async fn sessions_lifecycle() {
let (db, admin) = db_with_admin().await;
assert!(db.session_user("ghost-token").await.unwrap().is_none());
assert!(
db.session_user_with_roots("ghost-token")
.await
.unwrap()
.is_none()
);
db.create_session(admin.id, "tok1").await.unwrap();
let u = db.session_user("tok1").await.unwrap().unwrap();
let (u, _) = db.session_user_with_roots("tok1").await.unwrap().unwrap();
assert_eq!(u.id, admin.id);
// Disabling the user invalidates existing sessions.
db.set_user_active(admin.id, false).await.unwrap();
assert!(db.session_user("tok1").await.unwrap().is_none());
assert!(db.session_user_with_roots("tok1").await.unwrap().is_none());
db.set_user_active(admin.id, true).await.unwrap();
assert!(db.session_user("tok1").await.unwrap().is_some());
assert!(db.session_user_with_roots("tok1").await.unwrap().is_some());
db.delete_session("tok1").await.unwrap();
assert!(db.session_user("tok1").await.unwrap().is_none());
assert!(db.session_user_with_roots("tok1").await.unwrap().is_none());
}
#[tokio::test]
@@ -796,7 +856,7 @@ mod tests {
"bob"
);
assert!(db.find_user_by_name("dave").await.unwrap().is_none());
assert_eq!(db.all_users().await.unwrap().len(), 3);
assert_eq!(db.all_users_with_roots().await.unwrap().len(), 3);
// Root replacement semantics.
let roots = db.user_roots(bob.id).await.unwrap();
Mserver/src/fs.rs
@@ -102,8 +102,9 @@ fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
}
}
/// List a directory (blocking — call via spawn_blocking).
pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
/// List a directory (blocking — call via spawn_blocking). The `bool` is true
/// when the listing was cut to [`api_types::MAX_LIST_ENTRIES`].
pub fn list_dir(dir: &Path) -> Result<(Vec<Entry>, bool), FsError> {
let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
@@ -126,19 +127,22 @@ pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
rows.push((entry, e.path()));
}
// Pass 2: the per-entry syscalls — metadata plus the content sniff.
describe_rows(&mut rows);
// Pass 2: metadata, which the sort needs (folders first).
describe_rows(&mut rows, fill_meta);
let mut entries: Vec<Entry> = rows.into_iter().map(|(e, _)| e).collect();
// Folders first, then case-insensitive name. Sorting after pass 2 means
// the parallel fan-out cannot affect the order.
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
rows.sort_by_cached_key(|(e, _)| (!e.is_dir, e.name.to_lowercase(), e.name.clone()));
// Cut over-long listings here, so the sniff below never sees the tail.
// The client cannot render that many rows anyway.
let truncated = rows.len() > api_types::MAX_LIST_ENTRIES;
rows.truncate(api_types::MAX_LIST_ENTRIES);
// Pass 3: the content sniff, the expensive per-entry syscall.
describe_rows(&mut rows, fill_kind);
Ok((rows.into_iter().map(|(e, _)| e).collect(), truncated))
}
// ---------------------------------------------------------------------------
@@ -196,34 +200,43 @@ impl Drop for SniffPermit {
}
}
/// Fill in each row from its own syscalls — `metadata` and the content sniff —
/// fanning them out across threads for big directories.
/// Metadata for one row. Follows symlinks; a broken link keeps the caller's
/// fallbacks and so shows up as an empty file.
fn fill_meta(entry: &mut Entry, path: &Path) {
if let Ok(m) = std::fs::metadata(path) {
entry.is_dir = m.is_dir();
entry.size = m.len();
entry.mtime = mtime_str(&m);
}
}
/// The content sniff for one row. Needs `is_dir`, so it runs after
/// [`fill_meta`].
fn fill_kind(entry: &mut Entry, path: &Path) {
entry.kind = detect_kind(path, entry.is_dir);
}
/// Apply `one` to each row, fanning the work out across threads for big
/// directories.
///
/// The calling thread takes a chunk too, so `n` helper threads process `n + 1`
/// chunks and a zero-thread grant is simply the serial path.
fn describe_rows(rows: &mut [(Entry, PathBuf)]) {
fn describe(rows: &mut [(Entry, PathBuf)]) {
fn describe_rows(rows: &mut [(Entry, PathBuf)], one: fn(&mut Entry, &Path)) {
fn describe(rows: &mut [(Entry, PathBuf)], one: fn(&mut Entry, &Path)) {
for (entry, path) in rows {
// Follows symlinks; a broken link keeps the caller's fallbacks and
// so shows up as an empty file.
if let Ok(m) = std::fs::metadata(&path) {
entry.is_dir = m.is_dir();
entry.size = m.len();
entry.mtime = mtime_str(&m);
}
entry.kind = detect_kind(path, entry.is_dir);
one(entry, path);
}
}
if rows.len() < SNIFF_CHUNK {
describe(rows);
describe(rows, one);
return;
}
// One chunk per helper thread plus one for this thread.
let want = rows.len().div_ceil(SNIFF_CHUNK).saturating_sub(1);
let permit = SniffPermit::claim(want);
if permit.0 == 0 {
describe(rows);
describe(rows, one);
return;
}
let chunk = rows.len().div_ceil(permit.0 + 1);
@@ -232,10 +245,10 @@ fn describe_rows(rows: &mut [(Entry, PathBuf)]) {
// Hand every chunk but the last to a helper thread.
while rest.len() > chunk {
let (head, tail) = rest.split_at_mut(chunk);
s.spawn(|| describe(head));
s.spawn(move || describe(head, one));
rest = tail;
}
describe(rest);
describe(rest, one);
});
}
@@ -875,7 +888,7 @@ mod tests {
std::fs::write(d.join("b.txt"), "x").unwrap();
std::fs::write(d.join("A.txt"), "x").unwrap();
std::fs::write(d.join("C.md"), "x").unwrap();
let entries = list_dir(&d).unwrap();
let (entries, _) = list_dir(&d).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
// Folders first (alpha-dir, Zeta), then files case-insensitively.
assert_eq!(names, vec!["alpha-dir", "Zeta", "A.txt", "b.txt", "C.md"]);
@@ -906,7 +919,7 @@ mod tests {
let d = t.root.join("withlink");
std::fs::create_dir_all(&d).unwrap();
std::os::unix::fs::symlink(d.join("does-not-exist"), d.join("broken")).unwrap();
let entries = list_dir(&d).unwrap();
let (entries, _) = list_dir(&d).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "broken");
assert!(!entries[0].is_dir);
@@ -1345,7 +1358,7 @@ mod tests {
}
std::fs::create_dir(big.join("a-subdir")).unwrap();
let entries = list_dir(&big).unwrap();
let (entries, _) = list_dir(&big).unwrap();
assert_eq!(entries.len(), n + 1);
// Every row must agree with what a single-threaded read of that same
@@ -1400,7 +1413,7 @@ mod tests {
[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
)
.unwrap();
let entries = list_dir(&t.root.join("docs")).unwrap();
let (entries, _) = list_dir(&t.root.join("docs")).unwrap();
let kind_of = |n: &str| entries.iter().find(|e| e.name == n).unwrap().kind;
assert_eq!(kind_of("inner"), FileKind::Dir);
assert_eq!(kind_of("a.txt"), FileKind::Text);
Mserver/tests/api_embedded.rs
@@ -83,6 +83,20 @@ async fn embedded_frontend_is_complete() {
Some("public, max-age=31536000, immutable"),
"cache-control for {ref_}"
);
// The ETag lets a repeat visit revalidate instead of downloading.
let etag = r
.header("etag")
.unwrap_or_else(|| panic!("etag for {ref_}"));
let r = c
.raw(
axum::http::Method::GET,
&format!("/{ref_}"),
&[("if-none-match", etag.as_str())],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::NOT_MODIFIED, "304 for {ref_}");
assert!(r.body.is_empty(), "304 body for {ref_}");
}
// Client routes fall back to index.html; unknown API paths stay 404.
Mserver/tests/api_files.rs
@@ -990,3 +990,64 @@ async fn archive_does_not_follow_symlinks_out_of_the_root() {
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
}
#[tokio::test]
async fn listing_is_capped_and_reports_truncation() {
let env = Env::new().await;
let admin = env.admin().await;
// A normal listing is not truncated.
let r = admin.get(&root_path("")).await;
assert_eq!(r.json()["truncated"], false);
let big = env.file("big");
std::fs::create_dir_all(&big).unwrap();
for i in 0..api_types::MAX_LIST_ENTRIES + 5 {
std::fs::write(big.join(format!("f{i:06}")), b"").unwrap();
}
let r = admin.get(&root_path("big")).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(
j["entries"].as_array().unwrap().len(),
api_types::MAX_LIST_ENTRIES
);
assert_eq!(j["truncated"], true);
}
#[tokio::test]
async fn download_revalidates_with_last_modified() {
let env = Env::new().await;
let admin = env.admin().await;
let path = format!("{}?action=download", root_path("editme.txt"));
// A file written in the last two seconds gets no validator.
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("last-modified").is_none());
// Backdate the file so the validator appears.
let f = std::fs::File::options()
.write(true)
.open(env.root.path().join("editme.txt"))
.unwrap();
f.set_modified(
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
)
.unwrap();
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
let lm = r.header("last-modified").expect("Last-Modified header");
let r = admin
.raw(
axum::http::Method::GET,
&path,
&[("if-modified-since", lm.as_str())],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::NOT_MODIFIED);
assert!(r.body.is_empty());
}
Mweb/app.css
@@ -611,6 +611,12 @@ button:disabled {
padding: 48px 0;
}
/* Server-side listing cap notice, under the paging footer. */
.list-note {
padding: 8px 14px 0;
font-size: 12px;
}
.error-card {
display: flex;
align-items: center;
Mweb/src/i18n.rs
@@ -14,6 +14,8 @@
//! Parameterized strings carry exactly one `{}` placeholder; callers do
//! `format!(t(k::X), arg)`.
//!
//! Every table is sorted by key so lookups can binary-search.
//!
//! Locale precedence: the signed-in user's profile setting (persisted
//! server-side), else the browser language. `init` + `set_locale` drive a
//! global signal so every `tr` subscriber updates.
@@ -151,8 +153,13 @@ fn text(key: &str, locale: Locale) -> &'static str {
}
}
/// The tables are sorted by key, so a lookup is a binary search. The
/// `tables_are_sorted` test keeps that invariant true.
fn find(table: &'static [(&'static str, &'static str)], key: &str) -> Option<&'static str> {
table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
table
.binary_search_by(|(k, _)| (*k).cmp(key))
.ok()
.map(|i| table[i].1)
}
/// Translation keys. English text is the value in `EN` below.
@@ -173,6 +180,7 @@ pub mod k {
pub const REFRESH: &str = "refresh";
pub const GRID_VIEW: &str = "grid_view";
pub const LIST_VIEW: &str = "list_view";
pub const LIST_TRUNCATED: &str = "list_truncated";
// Entry actions
pub const OPEN: &str = "open";
@@ -471,432 +479,424 @@ pub mod k {
/// Canonical English strings (key → text). The source of truth for which
/// keys exist; DE and FR may be partial and fall back to English.
const EN: &[(&str, &str)] = &[
("no_folders_title", "No folders available"),
(
"no_folders_msg",
"An administrator hasn't assigned you any folders yet.",
),
("select_folder_title", "Select a folder"),
(
"select_folder_msg",
"Choose a folder from the sidebar to start browsing.",
),
("folder_gone_title", "Folder not available"),
("active_can_signin", "Active (can sign in)"),
("add_folder", "Add folder…"),
("add_here", "Add this folder"),
("administrator", "Administrator"),
("allow_editing", "Allow editing (read-write)"),
("allow_rw_shares", "Allow writable shares"),
(
"folder_gone_msg",
"This folder is no longer accessible. Choose another one from the sidebar.",
"allow_rw_shares_desc",
"Let users create read-write share links. Off by default.",
),
("loading", "Loading…"),
("loading_share", "Loading share…"),
("retry", "Retry"),
("empty_folder", "This folder is empty."),
("refresh", "Refresh"),
("grid_view", "Grid view"),
("list_view", "List view"),
("open", "Open"),
("open_in_new_tab", "Open in new tab"),
("download", "Download"),
("rename", "Rename"),
("move", "Move"),
("and_more", "…and {} more"),
("cancel", "Cancel"),
("cant_delete_self", "You cannot delete yourself"),
("close", "Close"),
("close_esc", "Close (Esc)"),
("copied", "Copied"),
("copy", "Copy"),
("done", "Done"),
("share", "Share"),
("info", "Info"),
("delete", "Delete"),
("new_folder", "New folder"),
("new_file", "New file"),
("upload_files", "Upload files"),
("upload_folder", "Upload folder"),
("read_only_hint", "Read-only folder"),
("read_only_badge", "Read-only"),
("more_actions", "More actions"),
("folder_name", "Folder name"),
("file_name", "File name"),
("copy_here", "Copy here"),
("copy_link", "Copy link"),
("copy_manual", "Copy the link manually"),
("copy_n_to", "Copy {} items to…"),
("copy_to", "Copy “{}” to…"),
("create", "Create"),
("rename_title", "Rename “{}”"),
("new_name", "New name"),
("create_admin", "Create admin account"),
("create_link", "Create link"),
("create_user", "Create user"),
("creating", "Creating…"),
("custom", "Custom…"),
("delete", "Delete"),
("delete_all", "Delete all"),
(
"delete_all_msg",
"Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
),
("delete_all_title", "Delete all shares?"),
(
"delete_folder_msg",
"Delete folder “{}” and everything inside it?\nThis cannot be undone.",
),
("delete_msg", "Delete “{}”?\nThis cannot be undone."),
("delete_n_msg", "Delete {} items?\nThis cannot be undone."),
("cancel", "Cancel"),
("delete_partial", "{} of {} shares could not be deleted"),
("delete_share", "Delete share"),
("delete_user", "Delete user"),
(
"overwrite_question",
"The following files already exist. Overwrite them?",
"delete_user_confirm",
"Delete user “{}”? This cannot be undone.",
),
("overwrite", "Overwrite"),
("and_more", "…and {} more"),
("no_subfolders", "No subfolders"),
("move_to", "Move “{}” to…"),
("copy_to", "Copy “{}” to…"),
("move_n_to", "Move {} items to…"),
("copy_n_to", "Copy {} items to…"),
("move_here", "Move here"),
("copy_here", "Copy here"),
("download_title", "Download “{}”"),
("deleted", "Deleted"),
("deleted_n_shares", "Deleted {} shares"),
("discard", "Discard"),
(
"discard_question",
"Discard your changes and close the editor?",
),
("dismiss", "Dismiss"),
("dismiss_error", "Dismiss error"),
("done", "Done"),
("download", "Download"),
(
"download_formats_msg",
"Choose an archive format for the folder.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "File info"),
("name", "Name"),
("type", "Type"),
("folder", "Folder"),
("file", "File"),
("size", "Size"),
("modified", "Modified"),
("location", "Location"),
("close", "Close"),
("folder_created", "Folder created"),
("file_created", "File created"),
("name_taken", "Name already in use"),
("renamed", "Renamed"),
("deleted", "Deleted"),
("uploading", "Uploading {} files…"),
("some_files_exist", "Some files already exist"),
("upload_complete", "Upload complete"),
("moved", "Moved"),
("copied", "Copied"),
("download_started", "Download started"),
("saved", "Saved"),
("menu", "Menu"),
("open_menu", "Open menu"),
("refresh_folders", "Refresh your folders"),
("theme_title", "Theme: {} (click to switch)"),
("log_out", "Log out"),
("files", "Files"),
("manage", "Manage"),
("shares", "Shares"),
("users", "Users"),
("no_users", "No users."),
("settings", "Settings"),
("theme_auto", "Auto"),
("theme_light", "Light"),
("theme_dark", "Dark"),
("search", "Search"),
("search_placeholder", "Search files and their contents"),
("search_name", "Name"),
("search_content", "Content"),
("search_both", "Both"),
("search_goto", "Show in folder"),
("search_run", "Search"),
("search_in", "Search in…"),
("search_here", "Search here"),
("search_in_folder", "Search in this folder"),
("search_stop", "Stop"),
("searching", "searching…"),
("search_n_results", "{} results"),
("search_took", "{} ms"),
("search_stopped", "stopped · {} results"),
("search_scanned", "scanned {} files"),
("search_skipped", "{} skipped (over 10 MiB)"),
("search_n_matches", "{} matches"),
("search_in_n_files", "in {} files"),
("search_collapse_all", "Collapse all"),
("search_expand_all", "Expand all"),
("search_shown_of", "Showing {} of {}"),
("search_show_more", "Show {} more"),
("search_no_results", "no results"),
("saving", "Saving…"),
("profile", "Profile"),
("server", "Server"),
("profile_saved", "Profile saved"),
("profile_save_err", "Could not save profile: {}"),
("settings_saved", "Settings saved"),
("settings_save_err", "Could not save settings: {}"),
("single_click_label", "Use single click to open"),
("download_title", "Download “{}”"),
("edit", "Edit"),
("edit_user_title", "Edit “{}”"),
("empty_folder", "This folder is empty."),
(k::ERR_ADMIN_ONLY, "admin only"),
(k::ERR_ALREADY_SET_UP, "server is already set up"),
(k::ERR_BAD_ACTION, "expected action=content"),
(k::ERR_BAD_BODY, "invalid request body"),
(k::ERR_BAD_EXPIRES_AT, "the expiry date is invalid"),
(
"single_click_desc",
"When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
k::ERR_BAD_FORMAT,
"format must be one of: zip, tar, tar.gz, tar.zst",
),
("save_profile", "Save profile"),
("allow_rw_shares", "Allow writable shares"),
(
"allow_rw_shares_desc",
"Let users create read-write share links. Off by default.",
k::ERR_BAD_MULTIPART,
"expected multipart/form-data with a boundary",
),
("save_settings", "Save settings"),
("language", "Language"),
(k::ERR_BAD_PART_NAME, "invalid part name"),
(
"language_desc",
"The interface language. Automatic uses your browser's language.",
k::ERR_BAD_POST,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
),
("lang_auto", "Automatic"),
("lang_en", "English"),
("lang_de", "German"),
("lang_fr", "French"),
(k::ERR_BAD_UPLOAD, "invalid upload data"),
(k::ERR_BAD_UPLOAD_PATH, "invalid file path in upload"),
(k::ERR_DST_REQUIRED, "dst_root_id is required"),
(k::ERR_FILE_NAME_REQUIRED, "a file name is required"),
(k::ERR_FILES_EXIST, "some files already exist"),
(
"delete_user_confirm",
"Delete user “{}”? This cannot be undone.",
k::ERR_FOLDER_EXISTS,
"a folder with this name already exists",
),
("user_delete_err", "Could not delete user"),
("new_user", "New user"),
("edit", "Edit"),
("delete_user", "Delete user"),
("cant_delete_self", "You cannot delete yourself"),
("root_dup_err", "That folder is already in the list."),
("name_required", "A name is required."),
("pw_short", "Password must be at least 8 characters."),
("need_folder", "Give the user at least one folder."),
("user_created", "User created"),
("user_updated", "User updated"),
("user_save_err", "Could not save user: {}"),
("password", "Password"),
("new_password_keep", "New password (leave blank to keep)"),
("administrator", "Administrator"),
("active_can_signin", "Active (can sign in)"),
("remove_folder", "Remove folder"),
("create_user", "Create user"),
("save", "Save"),
("edit_user_title", "Edit “{}”"),
("folders", "Folders"),
(k::ERR_FOLDER_NAME_REQUIRED, "a folder name is required"),
(k::ERR_FS_CONFLICT, "already exists"),
(k::ERR_FS_FORBIDDEN, "access denied"),
(k::ERR_FS_NOT_A_DIR, "not a folder"),
(k::ERR_FS_NOT_FOUND, "folder not found"),
(
"folders_paths_hint",
"Paths are relative to the server root. “.” is the whole root.",
k::ERR_FS_ROOT_MISSING,
"the configured folder no longer exists",
),
("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"),
("expiry_1w", "1 week"),
("expiry_1mo", "1 month"),
("meta_expires", "expires {}"),
("meta_created", "created {}"),
("mode_ro", "read-only"),
("share_not_found", "Share not found"),
(
"share_not_found_msg",
"This link is invalid or the share was removed.",
),
("share_expired", "Share expired"),
("share_expired_msg", "This share link is no longer active."),
("copy_manual", "Copy the link manually"),
("link_copied", "Link copied to clipboard"),
("share_create_err", "Could not create share: {}"),
("share_link", "Share link"),
(k::ERR_INTERNAL, "internal error"),
(k::ERR_INVALID_CREDENTIALS, "invalid name or password"),
(k::ERR_INVALID_LANGUAGE, "invalid language tag"),
(k::ERR_LAST_ADMIN, "cannot remove the last active admin"),
(
"share_perm_rw",
"Anyone with this link can read and write “{}”.",
k::ERR_LAST_ADMIN_DELETE,
"cannot delete the last active admin",
),
("share_perm_ro", "Anyone with this link can read “{}”."),
("share_title", "Share {}"),
(k::ERR_NAME_LENGTH, "name must be 1–64 characters"),
(k::ERR_NEW_NAME_REQUIRED, "new_name is required"),
(k::ERR_NO_FILES_UPLOADED, "no files were uploaded"),
(k::ERR_NO_SUCH_FOLDER, "no such folder"),
(k::ERR_NOT_A_FILE, "not a file"),
(k::ERR_NOT_SIGNED_IN, "not signed in"),
(k::ERR_OWN_ACCOUNT, "you cannot disable your own account"),
(k::ERR_OWN_ADMIN, "you cannot remove your own admin rights"),
(k::ERR_OWN_DELETE, "you cannot delete your own account"),
(k::ERR_PART_NO_NAME, "part without a name"),
(
"share_dialog_hint",
"Create a link for this item. Writable links let viewers upload and edit.",
k::ERR_PASSWORD_SHORT,
"password must be at least 8 characters",
),
("expires", "Expires"),
("never", "Never"),
("custom", "Custom…"),
("until", "Until"),
("expires_at", "Expires {}"),
("delete_partial", "{} of {} shares could not be deleted"),
("never_expires", "Never expires"),
("allow_editing", "Allow editing (read-write)"),
("rw_disabled", "Writable shares are disabled by the server."),
(k::ERR_PATH_REQUIRED, "a path inside the folder is required"),
(k::ERR_READ_ONLY_FOLDER, "read-only folder"),
(k::ERR_ROOT_FORBIDDEN, "root not accessible"),
(
"rw_ro_folder",
"This folder is read-only, so the share is read-only too.",
k::ERR_RW_RO_FOLDER,
"this folder is read-only for you, so it cannot be shared writably",
),
("creating", "Creating…"),
("create_link", "Create link"),
("your_shares", "Your shares"),
("deleted_n_shares", "Deleted {} shares"),
("shares_delete_err", "Could not delete shares"),
("delete_all", "Delete all"),
("no_shares", "No shares yet"),
(k::ERR_RW_SHARES_DISABLED, "writable shares are disabled"),
(k::ERR_SAVE_FAILED, "could not save the file"),
(
"shares_view_hint",
"Links you created. Right-click a file or folder and choose “Share” to add one.",
k::ERR_SEARCH_BAD_SCOPE,
"scope must be name, content or both",
),
(
"shares_empty_hint",
"Right-click a file or folder and choose “Share” to create a link.",
k::ERR_SEARCH_FORBIDDEN,
"search requires a signed-in session",
),
("copy_link", "Copy link"),
("delete_share", "Delete share"),
("share_deleted", "Share deleted"),
("share_delete_err", "Could not delete share"),
("delete_all_title", "Delete all shares?"),
(
"delete_all_msg",
"Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
k::ERR_SESSION_EXPIRED,
"session expired, please sign in again",
),
("login_subtitle", "Sign in to continue."),
("login_error", "Please enter your name and password."),
("sign_in", "Sign in"),
("setup_name_err", "Please choose a name (1–64 characters)."),
("setup_pw_mismatch", "Passwords don't match."),
("repeat_password", "Repeat password"),
("create_admin", "Create admin account"),
(k::ERR_SHARE_EXPIRED, "this share has expired"),
(k::ERR_SHARE_NOT_FOUND, "share not found"),
(
"setup_intro",
"First boot: create the admin account. This user can manage other users and server settings.",
k::ERR_SHARE_TOKEN_FORBIDDEN,
"a share token cannot be used here; sign in instead",
),
("close_esc", "Close (Esc)"),
("no_preview", "No preview available"),
("no_preview_msg", "The app can’t display “{}” here."),
("dismiss", "Dismiss"),
("dismiss_error", "Dismiss error"),
(k::ERR_TOO_LARGE_PREVIEW, "file too large to preview"),
(k::ERR_TOO_LARGE_SAVE, "file too large to save"),
(k::ERR_USER_EXISTS, "a user with that name already exists"),
(k::ERR_USER_NOT_FOUND, "user not found"),
("expires", "Expires"),
("expires_at", "Expires {}"),
("expiry_1d", "1 day"),
("expiry_1h", "1 hour"),
("expiry_1mo", "1 month"),
("expiry_1w", "1 week"),
("file", "File"),
("file_changed", "File changed on disk"),
("file_created", "File created"),
("file_info", "File info"),
("file_name", "File name"),
("files", "Files"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("fmt_zip", "Zip"),
("folder", "Folder"),
("folder_created", "Folder created"),
(
"modified_after",
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version.",
"folder_gone_msg",
"This folder is no longer accessible. Choose another one from the sidebar.",
),
("load_latest", "Load latest"),
("folder_gone_title", "Folder not available"),
("folder_name", "Folder name"),
("folders", "Folders"),
(
"folders_paths_hint",
"Paths are relative to the server root. “.” is the whole root.",
),
("grid_view", "Grid view"),
("info", "Info"),
("keep_editing", "Keep editing"),
("discard", "Discard"),
("unsaved_changes", "Unsaved changes"),
("lang_auto", "Automatic"),
("lang_de", "German"),
("lang_en", "English"),
("lang_fr", "French"),
("language", "Language"),
(
"discard_question",
"Discard your changes and close the editor?",
"language_desc",
"The interface language. Automatic uses your browser's language.",
),
(k::ERR_INTERNAL, "internal error"),
(k::ERR_NOT_SIGNED_IN, "not signed in"),
("link_copied", "Link copied to clipboard"),
("list_truncated", "Only the first {} entries are shown."),
("list_view", "List view"),
("load_latest", "Load latest"),
("loading", "Loading…"),
("loading_share", "Loading share…"),
("location", "Location"),
("log_out", "Log out"),
("login_error", "Please enter your name and password."),
("login_subtitle", "Sign in to continue."),
("manage", "Manage"),
("menu", "Menu"),
("meta_created", "created {}"),
("meta_expires", "expires {}"),
("mode_ro", "read-only"),
("mode_rw", "read-write"),
("modified", "Modified"),
(
k::ERR_SESSION_EXPIRED,
"session expired, please sign in again",
"modified_after",
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version.",
),
(k::ERR_INVALID_CREDENTIALS, "invalid name or password"),
(k::ERR_ALREADY_SET_UP, "server is already set up"),
(k::ERR_SHARE_NOT_FOUND, "share not found"),
(k::ERR_SHARE_EXPIRED, "this share has expired"),
(k::ERR_BAD_EXPIRES_AT, "the expiry date is invalid"),
("more_actions", "More actions"),
("move", "Move"),
("move_here", "Move here"),
("move_n_to", "Move {} items to…"),
("move_to", "Move “{}” to…"),
("moved", "Moved"),
("name", "Name"),
("name_required", "A name is required."),
("name_taken", "Name already in use"),
("need_folder", "Give the user at least one folder."),
("never", "Never"),
("never_expires", "Never expires"),
("new_file", "New file"),
("new_folder", "New folder"),
("new_name", "New name"),
("new_password_keep", "New password (leave blank to keep)"),
("new_user", "New user"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"a share token cannot be used here; sign in instead",
"no_folders_msg",
"An administrator hasn't assigned you any folders yet.",
),
(k::ERR_NAME_LENGTH, "name must be 1–64 characters"),
("no_folders_title", "No folders available"),
("no_preview", "No preview available"),
("no_preview_msg", "The app can’t display “{}” here."),
("no_shares", "No shares yet"),
("no_subfolders", "No subfolders"),
("no_users", "No users."),
("open", "Open"),
("open_in_new_tab", "Open in new tab"),
("open_menu", "Open menu"),
("overwrite", "Overwrite"),
(
k::ERR_PASSWORD_SHORT,
"password must be at least 8 characters",
"overwrite_question",
"The following files already exist. Overwrite them?",
),
(k::ERR_ADMIN_ONLY, "admin only"),
(k::ERR_USER_EXISTS, "a user with that name already exists"),
(k::ERR_USER_NOT_FOUND, "user not found"),
(k::ERR_OWN_ADMIN, "you cannot remove your own admin rights"),
(k::ERR_OWN_ACCOUNT, "you cannot disable your own account"),
(k::ERR_LAST_ADMIN, "cannot remove the last active admin"),
(k::ERR_OWN_DELETE, "you cannot delete your own account"),
("password", "Password"),
("profile", "Profile"),
("profile_save_err", "Could not save profile: {}"),
("profile_saved", "Profile saved"),
("pw_short", "Password must be at least 8 characters."),
("read_only_badge", "Read-only"),
("read_only_hint", "Read-only folder"),
("refresh", "Refresh"),
("refresh_folders", "Refresh your folders"),
("remove_folder", "Remove folder"),
("rename", "Rename"),
("rename_title", "Rename “{}”"),
("renamed", "Renamed"),
("repeat_password", "Repeat password"),
("retry", "Retry"),
("root_dup_err", "That folder is already in the list."),
("rw_disabled", "Writable shares are disabled by the server."),
(
k::ERR_LAST_ADMIN_DELETE,
"cannot delete the last active admin",
"rw_ro_folder",
"This folder is read-only, so the share is read-only too.",
),
("save", "Save"),
("save_profile", "Save profile"),
("save_settings", "Save settings"),
("saved", "Saved"),
("saving", "Saving…"),
("search", "Search"),
("search_both", "Both"),
("search_collapse_all", "Collapse all"),
("search_content", "Content"),
("search_expand_all", "Expand all"),
("search_failed", "The search failed."),
("search_goto", "Show in folder"),
("search_here", "Search here"),
("search_in", "Search in…"),
("search_in_folder", "Search in this folder"),
("search_in_n_files", "in {} files"),
("search_n_matches", "{} matches"),
("search_n_results", "{} results"),
("search_name", "Name"),
("search_no_results", "no results"),
("search_placeholder", "Search files and their contents"),
("search_run", "Search"),
("search_scanned", "scanned {} files"),
("search_show_more", "Show {} more"),
("search_shown_of", "Showing {} of {}"),
("search_skipped", "{} skipped (over 10 MiB)"),
("search_stop", "Stop"),
("search_stopped", "stopped · {} results"),
("search_took", "{} ms"),
("searching", "searching…"),
(
k::ERR_BAD_FORMAT,
"format must be one of: zip, tar, tar.gz, tar.zst",
"select_folder_msg",
"Choose a folder from the sidebar to start browsing.",
),
(k::ERR_NOT_A_FILE, "not a file"),
(k::ERR_TOO_LARGE_PREVIEW, "file too large to preview"),
(k::ERR_BAD_ACTION, "expected action=content"),
(k::ERR_TOO_LARGE_SAVE, "file too large to save"),
(k::ERR_BAD_BODY, "invalid request body"),
(k::ERR_INVALID_LANGUAGE, "invalid language tag"),
("select_folder_title", "Select a folder"),
("server", "Server"),
("server_root", "Server root"),
("server_unreachable", "The server is not reachable."),
("settings", "Settings"),
("settings_save_err", "Could not save settings: {}"),
("settings_saved", "Settings saved"),
(
k::ERR_BAD_POST,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
"setup_intro",
"First boot: create the admin account. This user can manage other users and server settings.",
),
(k::ERR_FOLDER_NAME_REQUIRED, "a folder name is required"),
(k::ERR_FILE_NAME_REQUIRED, "a file name is required"),
(k::ERR_NEW_NAME_REQUIRED, "new_name is required"),
(k::ERR_DST_REQUIRED, "dst_root_id is required"),
(k::ERR_PATH_REQUIRED, "a path inside the folder is required"),
("setup_name_err", "Please choose a name (1–64 characters)."),
("setup_pw_mismatch", "Passwords don't match."),
("share", "Share"),
("share_create_err", "Could not create share: {}"),
("share_delete_err", "Could not delete share"),
("share_deleted", "Share deleted"),
(
k::ERR_BAD_MULTIPART,
"expected multipart/form-data with a boundary",
"share_dialog_hint",
"Create a link for this item. Writable links let viewers upload and edit.",
),
(k::ERR_BAD_UPLOAD, "invalid upload data"),
(k::ERR_PART_NO_NAME, "part without a name"),
(k::ERR_BAD_PART_NAME, "invalid part name"),
("share_expired", "Share expired"),
("share_expired_msg", "This share link is no longer active."),
("share_link", "Share link"),
("share_not_found", "Share not found"),
(
k::ERR_FOLDER_EXISTS,
"a folder with this name already exists",
"share_not_found_msg",
"This link is invalid or the share was removed.",
),
(k::ERR_SAVE_FAILED, "could not save the file"),
(k::ERR_NO_FILES_UPLOADED, "no files were uploaded"),
(k::ERR_FILES_EXIST, "some files already exist"),
(k::ERR_BAD_UPLOAD_PATH, "invalid file path in upload"),
(k::ERR_NO_SUCH_FOLDER, "no such folder"),
(k::ERR_READ_ONLY_FOLDER, "read-only folder"),
(k::ERR_RW_SHARES_DISABLED, "writable shares are disabled"),
("share_perm_ro", "Anyone with this link can read “{}”."),
(
k::ERR_RW_RO_FOLDER,
"this folder is read-only for you, so it cannot be shared writably",
"share_perm_rw",
"Anyone with this link can read and write “{}”.",
),
(k::ERR_FS_NOT_FOUND, "folder not found"),
(k::ERR_FS_NOT_A_DIR, "not a folder"),
(k::ERR_FS_FORBIDDEN, "access denied"),
("share_title", "Share {}"),
("shares", "Shares"),
("shares_delete_err", "Could not delete shares"),
(
k::ERR_FS_ROOT_MISSING,
"the configured folder no longer exists",
"shares_empty_hint",
"Right-click a file or folder and choose “Share” to create a link.",
),
(k::ERR_FS_CONFLICT, "already exists"),
(
k::ERR_SEARCH_FORBIDDEN,
"search requires a signed-in session",
"shares_view_hint",
"Links you created. Right-click a file or folder and choose “Share” to add one.",
),
("sign_in", "Sign in"),
(
k::ERR_SEARCH_BAD_SCOPE,
"scope must be name, content or both",
"single_click_desc",
"When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
),
(k::ERR_ROOT_FORBIDDEN, "root not accessible"),
("single_click_label", "Use single click to open"),
("size", "Size"),
("some_files_exist", "Some files already exist"),
("theme_auto", "Auto"),
("theme_dark", "Dark"),
("theme_light", "Light"),
("theme_title", "Theme: {} (click to switch)"),
("type", "Type"),
("unsaved_changes", "Unsaved changes"),
("until", "Until"),
("upload_complete", "Upload complete"),
("upload_files", "Upload files"),
("upload_folder", "Upload folder"),
("uploading", "Uploading {} files…"),
("user_created", "User created"),
("user_delete_err", "Could not delete user"),
("user_save_err", "Could not save user: {}"),
("user_updated", "User updated"),
("users", "Users"),
("your_shares", "Your shares"),
];
/// German translations.
const DE: &[(&str, &str)] = &[
("no_folders_title", "Keine Ordner verfügbar"),
(
"no_folders_msg",
"Ein Administrator hat Ihnen noch keine Ordner zugewiesen.",
),
("select_folder_title", "Ordner auswählen"),
(
"select_folder_msg",
"Wählen Sie links einen Ordner aus, um mit dem Durchsuchen zu beginnen.",
),
("folder_gone_title", "Ordner nicht verfügbar"),
("active_can_signin", "Aktiv (kann sich anmelden)"),
("add_folder", "Ordner hinzufügen…"),
("add_here", "Diesen Ordner hinzufügen"),
("administrator", "Administrator"),
("allow_editing", "Bearbeitung erlauben (lesen/schreiben)"),
("allow_rw_shares", "Schreibbare Freigaben erlauben"),
(
"folder_gone_msg",
"Dieser Ordner ist nicht mehr verfügbar. Wählen Sie links einen anderen aus.",
"allow_rw_shares_desc",
"Benutzern erlauben, schreibbare Freigabelinks zu erstellen. Standardmäßig aus.",
),
("loading", "Wird geladen…"),
("loading_share", "Freigabe wird geladen…"),
("retry", "Erneut versuchen"),
("empty_folder", "Dieser Ordner ist leer."),
("refresh", "Aktualisieren"),
("grid_view", "Kachelansicht"),
("list_view", "Listenansicht"),
("open", "Öffnen"),
("open_in_new_tab", "In neuem Tab öffnen"),
("download", "Herunterladen"),
("rename", "Umbenennen"),
("move", "Verschieben"),
("and_more", "…und {} weitere"),
("cancel", "Abbrechen"),
("cant_delete_self", "Sie können sich nicht selbst löschen"),
("close", "Schließen"),
("close_esc", "Schließen (Esc)"),
("copied", "Kopiert"),
("copy", "Kopieren"),
("done", "Fertig"),
("share", "Teilen"),
("info", "Info"),
("delete", "Löschen"),
("new_folder", "Neuer Ordner"),
("new_file", "Neue Datei"),
("upload_files", "Dateien hochladen"),
("upload_folder", "Ordner hochladen"),
("read_only_hint", "Schreibgeschützter Ordner"),
("read_only_badge", "Schreibgeschützt"),
("more_actions", "Weitere Aktionen"),
("folder_name", "Ordnername"),
("file_name", "Dateiname"),
("copy_here", "Hierher kopieren"),
("copy_link", "Link kopieren"),
("copy_manual", "Link manuell kopieren"),
("copy_n_to", "{} Elemente kopieren nach…"),
("copy_to", "„{}“ kopieren nach…"),
("create", "Erstellen"),
("rename_title", "„{}“ umbenennen"),
("new_name", "Neuer Name"),
("create_admin", "Administrator-Konto erstellen"),
("create_link", "Link erstellen"),
("create_user", "Benutzer erstellen"),
("creating", "Wird erstellt…"),
("custom", "Benutzerdefiniert…"),
("delete", "Löschen"),
("delete_all", "Alle löschen"),
(
"delete_all_msg",
"Alle {} Freigabelinks löschen? Jeder, der sie besitzt, verliert den Zugriff. Das kann nicht rückgängig gemacht werden.",
),
("delete_all_title", "Alle Freigaben löschen?"),
(
"delete_folder_msg",
"Ordner „{}“ mit allem Inhalt löschen?\nDas kann nicht rückgängig gemacht werden.",
@@ -909,448 +909,458 @@ const DE: &[(&str, &str)] = &[
"delete_n_msg",
"{} Elemente löschen?\nDas kann nicht rückgängig gemacht werden.",
),
("cancel", "Abbrechen"),
(
"overwrite_question",
"Die folgenden Dateien existieren bereits. Überschreiben?",
"delete_partial",
"{} von {} Freigaben konnten nicht gelöscht werden",
),
("overwrite", "Überschreiben"),
("and_more", "…und {} weitere"),
("no_subfolders", "Keine Unterordner"),
("move_to", "„{}“ verschieben nach…"),
("copy_to", "„{}“ kopieren nach…"),
("move_n_to", "{} Elemente verschieben nach…"),
("copy_n_to", "{} Elemente kopieren nach…"),
("move_here", "Hierher verschieben"),
("copy_here", "Hierher kopieren"),
("download_title", "„{}“ herunterladen"),
("delete_share", "Freigabe löschen"),
("delete_user", "Benutzer löschen"),
(
"download_formats_msg",
"Archivformat für den Ordner wählen.",
"delete_user_confirm",
"Benutzer „{}“ löschen? Das kann nicht rückgängig gemacht werden.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "Datei-Info"),
("name", "Name"),
("type", "Typ"),
("folder", "Ordner"),
("file", "Datei"),
("size", "Größe"),
("modified", "Geändert"),
("location", "Ort"),
("close", "Schließen"),
("folder_created", "Ordner erstellt"),
("file_created", "Datei erstellt"),
("name_taken", "Name wird bereits verwendet"),
("renamed", "Umbenannt"),
("deleted", "Gelöscht"),
("uploading", "{} Datei(en) werden hochgeladen…"),
("some_files_exist", "Einige Dateien existieren bereits"),
("upload_complete", "Hochladen abgeschlossen"),
("moved", "Verschoben"),
("copied", "Kopiert"),
("download_started", "Download gestartet"),
("saved", "Gespeichert"),
("menu", "Menü"),
("open_menu", "Menü öffnen"),
("refresh_folders", "Ordner aktualisieren"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
("log_out", "Abmelden"),
("files", "Dateien"),
("manage", "Verwalten"),
("shares", "Freigaben"),
("users", "Benutzer"),
("no_users", "Keine Benutzer."),
("settings", "Einstellungen"),
("theme_auto", "Auto"),
("theme_light", "Hell"),
("theme_dark", "Dunkel"),
("search", "Suche"),
("deleted_n_shares", "{} Freigabe(n) gelöscht"),
("discard", "Verwerfen"),
(
"search_placeholder",
"Dateien und deren Inhalte durchsuchen",
"discard_question",
"Änderungen verwerfen und den Editor schließen?",
),
("search_name", "Name"),
("search_content", "Inhalt"),
("search_both", "Beides"),
("search_goto", "Im Ordner anzeigen"),
("search_run", "Suchen"),
("search_in", "Suchen in…"),
("search_here", "Hier suchen"),
("search_in_folder", "In diesem Ordner suchen"),
("search_stop", "Stopp"),
("searching", "Suche läuft…"),
("search_n_results", "{} Treffer"),
("search_took", "{} ms"),
("search_stopped", "gestoppt · {} Treffer"),
("search_scanned", "{} Dateien durchsucht"),
("search_skipped", "{} übersprungen (über 10 MiB)"),
("search_n_matches", "{} Treffer"),
("search_in_n_files", "in {} Dateien"),
("search_collapse_all", "Alle einklappen"),
("search_expand_all", "Alle ausklappen"),
("search_shown_of", "{} von {} angezeigt"),
("search_show_more", "{} weitere anzeigen"),
("search_no_results", "keine Treffer"),
("saving", "Wird gespeichert…"),
("profile", "Profil"),
("server", "Server"),
("profile_saved", "Profil gespeichert"),
("dismiss", "Ausblenden"),
("dismiss_error", "Fehler ausblenden"),
("done", "Fertig"),
("download", "Herunterladen"),
(
"profile_save_err",
"Profil konnte nicht gespeichert werden: {}",
"download_formats_msg",
"Archivformat für den Ordner wählen.",
),
("settings_saved", "Einstellungen gespeichert"),
("download_started", "Download gestartet"),
("download_title", "„{}“ herunterladen"),
("edit", "Bearbeiten"),
("edit_user_title", "„{}“ bearbeiten"),
("empty_folder", "Dieser Ordner ist leer."),
(k::ERR_ADMIN_ONLY, "nur für Administratoren"),
(k::ERR_ALREADY_SET_UP, "Server ist bereits eingerichtet"),
(k::ERR_BAD_ACTION, "action=content erwartet"),
(k::ERR_BAD_BODY, "ungültiger Anfrageinhalt"),
(k::ERR_BAD_EXPIRES_AT, "das Ablaufdatum ist ungültig"),
(
"settings_save_err",
"Einstellungen konnten nicht gespeichert werden: {}",
k::ERR_BAD_FORMAT,
"das Format muss eines der folgenden sein: zip, tar, tar.gz, tar.zst",
),
("single_click_label", "Einzelklick öffnet Einträge"),
(
"single_click_desc",
"Wenn aktiv, öffnet ein einfacher Klick einen Eintrag und Strg+Klick wählt ihn aus. Wenn inaktiv, wählt ein einfacher Klick aus und ein Doppelklick öffnet.",
k::ERR_BAD_MULTIPART,
"multipart/form-data mit Boundary erwartet",
),
("save_profile", "Profil speichern"),
("allow_rw_shares", "Schreibbare Freigaben erlauben"),
(k::ERR_BAD_PART_NAME, "ungültiger Part-Name"),
(
"allow_rw_shares_desc",
"Benutzern erlauben, schreibbare Freigabelinks zu erstellen. Standardmäßig aus.",
k::ERR_BAD_POST,
"POST erwartet einen Multipart-Upload, eine JSON-Mutation oder ?action=mkdir/create-file",
),
("save_settings", "Einstellungen speichern"),
("language", "Sprache"),
(k::ERR_BAD_UPLOAD, "ungültige Upload-Daten"),
(k::ERR_BAD_UPLOAD_PATH, "ungültiger Dateipfad im Upload"),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
(
"language_desc",
"Die Sprache der Benutzeroberfläche. „Automatisch“ verwendet die Sprache Ihres Browsers.",
k::ERR_FOLDER_EXISTS,
"ein Ordner mit diesem Namen existiert bereits",
),
("lang_auto", "Automatisch"),
("lang_en", "Englisch"),
("lang_de", "Deutsch"),
("lang_fr", "Französisch"),
(
"delete_user_confirm",
"Benutzer „{}“ löschen? Das kann nicht rückgängig gemacht werden.",
k::ERR_FOLDER_NAME_REQUIRED,
"ein Ordnername ist erforderlich",
),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
("new_user", "Neuer Benutzer"),
("edit", "Bearbeiten"),
("delete_user", "Benutzer löschen"),
("cant_delete_self", "Sie können sich nicht selbst löschen"),
("root_dup_err", "Dieser Ordner ist bereits in der Liste."),
("name_required", "Ein Name ist erforderlich."),
(k::ERR_FS_CONFLICT, "existiert bereits"),
(k::ERR_FS_FORBIDDEN, "Zugriff verweigert"),
(k::ERR_FS_NOT_A_DIR, "kein Ordner"),
(k::ERR_FS_NOT_FOUND, "Ordner nicht gefunden"),
(
"pw_short",
"Das Passwort muss mindestens 8 Zeichen lang sein.",
k::ERR_FS_ROOT_MISSING,
"der konfigurierte Ordner existiert nicht mehr",
),
(k::ERR_INTERNAL, "interner Fehler"),
(k::ERR_INVALID_CREDENTIALS, "Name oder Passwort ungültig"),
(k::ERR_INVALID_LANGUAGE, "ungültiges Sprachkürzel"),
(
"need_folder",
"Dem Benutzer muss mindestens ein Ordner zugewiesen werden.",
k::ERR_LAST_ADMIN,
"der letzte aktive Administrator kann nicht entfernt werden",
),
("user_created", "Benutzer erstellt"),
("user_updated", "Benutzer aktualisiert"),
(
"user_save_err",
"Benutzer konnte nicht gespeichert werden: {}",
k::ERR_LAST_ADMIN_DELETE,
"der letzte aktive Administrator kann nicht gelöscht werden",
),
("password", "Passwort"),
(k::ERR_NAME_LENGTH, "der Name muss 1–64 Zeichen haben"),
(k::ERR_NEW_NAME_REQUIRED, "new_name ist erforderlich"),
(k::ERR_NO_FILES_UPLOADED, "keine Dateien hochgeladen"),
(k::ERR_NO_SUCH_FOLDER, "dieser Ordner existiert nicht"),
(k::ERR_NOT_A_FILE, "keine Datei"),
(k::ERR_NOT_SIGNED_IN, "nicht angemeldet"),
(
"new_password_keep",
"Neues Passwort (leer lassen, um beizubehalten)",
k::ERR_OWN_ACCOUNT,
"Sie können Ihr eigenes Konto nicht deaktivieren",
),
("administrator", "Administrator"),
("active_can_signin", "Aktiv (kann sich anmelden)"),
("remove_folder", "Ordner entfernen"),
("create_user", "Benutzer erstellen"),
("save", "Speichern"),
("edit_user_title", "„{}“ bearbeiten"),
("folders", "Ordner"),
(
"folders_paths_hint",
"Pfade sind relativ zur Serverwurzel. „.“ ist der gesamte Wurzelordner.",
k::ERR_OWN_ADMIN,
"Sie können Ihre eigenen Administratorrechte nicht entfernen",
),
("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"),
("expiry_1w", "1 Woche"),
("expiry_1mo", "1 Monat"),
("meta_expires", "läuft ab: {}"),
("meta_created", "erstellt: {}"),
("mode_ro", "nur lesen"),
("share_not_found", "Freigabe nicht gefunden"),
(
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
k::ERR_OWN_DELETE,
"Sie können Ihr eigenes Konto nicht löschen",
),
(k::ERR_PART_NO_NAME, "Part ohne Namen"),
(
k::ERR_PASSWORD_SHORT,
"das Passwort muss mindestens 8 Zeichen lang sein",
),
("share_expired", "Freigabe abgelaufen"),
(
"share_expired_msg",
"Dieser Freigabelink ist nicht mehr aktiv.",
k::ERR_PATH_REQUIRED,
"ein Pfad innerhalb des Ordners ist erforderlich",
),
("copy_manual", "Link manuell kopieren"),
("link_copied", "Link in die Zwischenablage kopiert"),
(k::ERR_READ_ONLY_FOLDER, "schreibgeschützter Ordner"),
(k::ERR_ROOT_FORBIDDEN, "kein Zugriff auf diesen Ordner"),
(
"share_create_err",
"Freigabe konnte nicht erstellt werden: {}",
k::ERR_RW_RO_FOLDER,
"dieser Ordner ist für Sie schreibgeschützt und kann nicht schreibbar freigegeben werden",
),
("share_link", "Freigabelink"),
(
"share_perm_rw",
"Jeder mit diesem Link kann „{}“ lesen und schreiben.",
k::ERR_RW_SHARES_DISABLED,
"schreibbare Freigaben sind deaktiviert",
),
("share_perm_ro", "Jeder mit diesem Link kann „{}“ lesen."),
("share_title", "{} teilen"),
(
"share_dialog_hint",
"Erstellt einen Link für diesen Eintrag. Mit schreibbaren Links können Empfänger Dateien hochladen und bearbeiten.",
k::ERR_SAVE_FAILED,
"die Datei konnte nicht gespeichert werden",
),
("expires", "Läuft ab"),
("never", "Nie"),
("custom", "Benutzerdefiniert…"),
("until", "Bis"),
("expires_at", "Läuft ab: {}"),
(
"delete_partial",
"{} von {} Freigaben konnten nicht gelöscht werden",
k::ERR_SEARCH_BAD_SCOPE,
"Bereich muss name, content oder both sein",
),
("never_expires", "Läuft nie ab"),
("allow_editing", "Bearbeitung erlauben (lesen/schreiben)"),
(
"rw_disabled",
"Schreibbare Freigaben sind vom Server deaktiviert.",
k::ERR_SEARCH_FORBIDDEN,
"Suche erfordert eine angemeldete Sitzung",
),
(
"rw_ro_folder",
"Dieser Ordner ist schreibgeschützt, daher ist die Freigabe auch schreibgeschützt.",
k::ERR_SESSION_EXPIRED,
"Sitzung abgelaufen, bitte erneut anmelden",
),
("creating", "Wird erstellt…"),
("create_link", "Link erstellen"),
("your_shares", "Ihre Freigaben"),
("deleted_n_shares", "{} Freigabe(n) gelöscht"),
(k::ERR_SHARE_EXPIRED, "diese Freigabe ist abgelaufen"),
(k::ERR_SHARE_NOT_FOUND, "Freigabe nicht gefunden"),
(
"shares_delete_err",
"Freigaben konnten nicht gelöscht werden",
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
),
("delete_all", "Alle löschen"),
("no_shares", "Noch keine Freigaben"),
(k::ERR_TOO_LARGE_PREVIEW, "Datei zu groß für eine Vorschau"),
(k::ERR_TOO_LARGE_SAVE, "Datei zu groß zum Speichern"),
(
"shares_view_hint",
"Von Ihnen erstellte Links. Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen hinzuzufügen.",
k::ERR_USER_EXISTS,
"ein Benutzer mit diesem Namen existiert bereits",
),
(k::ERR_USER_NOT_FOUND, "Benutzer nicht gefunden"),
("expires", "Läuft ab"),
("expires_at", "Läuft ab: {}"),
("expiry_1d", "1 Tag"),
("expiry_1h", "1 Stunde"),
("expiry_1mo", "1 Monat"),
("expiry_1w", "1 Woche"),
("file", "Datei"),
("file_changed", "Datei auf der Festplatte geändert"),
("file_created", "Datei erstellt"),
("file_info", "Datei-Info"),
("file_name", "Dateiname"),
("files", "Dateien"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("fmt_zip", "Zip"),
("folder", "Ordner"),
("folder_created", "Ordner erstellt"),
(
"shares_empty_hint",
"Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen Link zu erstellen.",
"folder_gone_msg",
"Dieser Ordner ist nicht mehr verfügbar. Wählen Sie links einen anderen aus.",
),
("copy_link", "Link kopieren"),
("delete_share", "Freigabe löschen"),
("share_deleted", "Freigabe gelöscht"),
("share_delete_err", "Freigabe konnte nicht gelöscht werden"),
("delete_all_title", "Alle Freigaben löschen?"),
("folder_gone_title", "Ordner nicht verfügbar"),
("folder_name", "Ordnername"),
("folders", "Ordner"),
(
"delete_all_msg",
"Alle {} Freigabelinks löschen? Jeder, der sie besitzt, verliert den Zugriff. Das kann nicht rückgängig gemacht werden.",
"folders_paths_hint",
"Pfade sind relativ zur Serverwurzel. „.“ ist der gesamte Wurzelordner.",
),
("login_subtitle", "Anmelden, um fortzufahren."),
("login_error", "Bitte Name und Passwort eingeben."),
("sign_in", "Anmelden"),
("grid_view", "Kachelansicht"),
("info", "Info"),
("keep_editing", "Weiter bearbeiten"),
("lang_auto", "Automatisch"),
("lang_de", "Deutsch"),
("lang_en", "Englisch"),
("lang_fr", "Französisch"),
("language", "Sprache"),
(
"setup_name_err",
"Bitte wählen Sie einen Namen (1–64 Zeichen).",
"language_desc",
"Die Sprache der Benutzeroberfläche. „Automatisch“ verwendet die Sprache Ihres Browsers.",
),
("setup_pw_mismatch", "Die Passwörter stimmen nicht überein."),
("repeat_password", "Passwort wiederholen"),
("create_admin", "Administrator-Konto erstellen"),
("link_copied", "Link in die Zwischenablage kopiert"),
(
"setup_intro",
"Erster Start: Erstellen Sie das Administrator-Konto. Dieser Benutzer kann andere Benutzer und die Servereinstellungen verwalten.",
"list_truncated",
"Nur die ersten {} Einträge werden angezeigt.",
),
("close_esc", "Schließen (Esc)"),
("no_preview", "Keine Vorschau verfügbar"),
("no_preview_msg", "Die App kann „{}“ hier nicht anzeigen."),
("dismiss", "Ausblenden"),
("dismiss_error", "Fehler ausblenden"),
("file_changed", "Datei auf der Festplatte geändert"),
("list_view", "Listenansicht"),
("load_latest", "Neueste Version laden"),
("loading", "Wird geladen…"),
("loading_share", "Freigabe wird geladen…"),
("location", "Ort"),
("log_out", "Abmelden"),
("login_error", "Bitte Name und Passwort eingeben."),
("login_subtitle", "Anmelden, um fortzufahren."),
("manage", "Verwalten"),
("menu", "Menü"),
("meta_created", "erstellt: {}"),
("meta_expires", "läuft ab: {}"),
("mode_ro", "nur lesen"),
("mode_rw", "lesen/schreiben"),
("modified", "Geändert"),
(
"modified_after",
"Diese Datei wurde nach dem Öffnen geändert. „Neueste Version laden“ verwirft Ihre ungespeicherten Änderungen; „Überschreiben“ behält Ihre Version bei.",
),
("load_latest", "Neueste Version laden"),
("keep_editing", "Weiter bearbeiten"),
("discard", "Verwerfen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
("more_actions", "Weitere Aktionen"),
("move", "Verschieben"),
("move_here", "Hierher verschieben"),
("move_n_to", "{} Elemente verschieben nach…"),
("move_to", "„{}“ verschieben nach…"),
("moved", "Verschoben"),
("name", "Name"),
("name_required", "Ein Name ist erforderlich."),
("name_taken", "Name wird bereits verwendet"),
(
"discard_question",
"Änderungen verwerfen und den Editor schließen?",
"need_folder",
"Dem Benutzer muss mindestens ein Ordner zugewiesen werden.",
),
(k::ERR_INTERNAL, "interner Fehler"),
(k::ERR_NOT_SIGNED_IN, "nicht angemeldet"),
("never", "Nie"),
("never_expires", "Läuft nie ab"),
("new_file", "Neue Datei"),
("new_folder", "Neuer Ordner"),
("new_name", "Neuer Name"),
(
k::ERR_SESSION_EXPIRED,
"Sitzung abgelaufen, bitte erneut anmelden",
"new_password_keep",
"Neues Passwort (leer lassen, um beizubehalten)",
),
(k::ERR_INVALID_CREDENTIALS, "Name oder Passwort ungültig"),
(k::ERR_ALREADY_SET_UP, "Server ist bereits eingerichtet"),
(k::ERR_SHARE_NOT_FOUND, "Freigabe nicht gefunden"),
(k::ERR_SHARE_EXPIRED, "diese Freigabe ist abgelaufen"),
(k::ERR_BAD_EXPIRES_AT, "das Ablaufdatum ist ungültig"),
("new_user", "Neuer Benutzer"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
"no_folders_msg",
"Ein Administrator hat Ihnen noch keine Ordner zugewiesen.",
),
(k::ERR_NAME_LENGTH, "der Name muss 1–64 Zeichen haben"),
("no_folders_title", "Keine Ordner verfügbar"),
("no_preview", "Keine Vorschau verfügbar"),
("no_preview_msg", "Die App kann „{}“ hier nicht anzeigen."),
("no_shares", "Noch keine Freigaben"),
("no_subfolders", "Keine Unterordner"),
("no_users", "Keine Benutzer."),
("open", "Öffnen"),
("open_in_new_tab", "In neuem Tab öffnen"),
("open_menu", "Menü öffnen"),
("overwrite", "Überschreiben"),
(
k::ERR_PASSWORD_SHORT,
"das Passwort muss mindestens 8 Zeichen lang sein",
"overwrite_question",
"Die folgenden Dateien existieren bereits. Überschreiben?",
),
(k::ERR_ADMIN_ONLY, "nur für Administratoren"),
("password", "Passwort"),
("profile", "Profil"),
(
k::ERR_USER_EXISTS,
"ein Benutzer mit diesem Namen existiert bereits",
"profile_save_err",
"Profil konnte nicht gespeichert werden: {}",
),
(k::ERR_USER_NOT_FOUND, "Benutzer nicht gefunden"),
("profile_saved", "Profil gespeichert"),
(
k::ERR_OWN_ADMIN,
"Sie können Ihre eigenen Administratorrechte nicht entfernen",
"pw_short",
"Das Passwort muss mindestens 8 Zeichen lang sein.",
),
("read_only_badge", "Schreibgeschützt"),
("read_only_hint", "Schreibgeschützter Ordner"),
("refresh", "Aktualisieren"),
("refresh_folders", "Ordner aktualisieren"),
("remove_folder", "Ordner entfernen"),
("rename", "Umbenennen"),
("rename_title", "„{}“ umbenennen"),
("renamed", "Umbenannt"),
("repeat_password", "Passwort wiederholen"),
("retry", "Erneut versuchen"),
("root_dup_err", "Dieser Ordner ist bereits in der Liste."),
(
k::ERR_OWN_ACCOUNT,
"Sie können Ihr eigenes Konto nicht deaktivieren",
"rw_disabled",
"Schreibbare Freigaben sind vom Server deaktiviert.",
),
(
k::ERR_LAST_ADMIN,
"der letzte aktive Administrator kann nicht entfernt werden",
"rw_ro_folder",
"Dieser Ordner ist schreibgeschützt, daher ist die Freigabe auch schreibgeschützt.",
),
("save", "Speichern"),
("save_profile", "Profil speichern"),
("save_settings", "Einstellungen speichern"),
("saved", "Gespeichert"),
("saving", "Wird gespeichert…"),
("search", "Suche"),
("search_both", "Beides"),
("search_collapse_all", "Alle einklappen"),
("search_content", "Inhalt"),
("search_expand_all", "Alle ausklappen"),
("search_failed", "Die Suche ist fehlgeschlagen."),
("search_goto", "Im Ordner anzeigen"),
("search_here", "Hier suchen"),
("search_in", "Suchen in…"),
("search_in_folder", "In diesem Ordner suchen"),
("search_in_n_files", "in {} Dateien"),
("search_n_matches", "{} Treffer"),
("search_n_results", "{} Treffer"),
("search_name", "Name"),
("search_no_results", "keine Treffer"),
(
"search_placeholder",
"Dateien und deren Inhalte durchsuchen",
),
("search_run", "Suchen"),
("search_scanned", "{} Dateien durchsucht"),
("search_show_more", "{} weitere anzeigen"),
("search_shown_of", "{} von {} angezeigt"),
("search_skipped", "{} übersprungen (über 10 MiB)"),
("search_stop", "Stopp"),
("search_stopped", "gestoppt · {} Treffer"),
("search_took", "{} ms"),
("searching", "Suche läuft…"),
(
k::ERR_OWN_DELETE,
"Sie können Ihr eigenes Konto nicht löschen",
"select_folder_msg",
"Wählen Sie links einen Ordner aus, um mit dem Durchsuchen zu beginnen.",
),
("select_folder_title", "Ordner auswählen"),
("server", "Server"),
("server_root", "Serverwurzel"),
("server_unreachable", "Der Server ist nicht erreichbar."),
("settings", "Einstellungen"),
(
k::ERR_LAST_ADMIN_DELETE,
"der letzte aktive Administrator kann nicht gelöscht werden",
"settings_save_err",
"Einstellungen konnten nicht gespeichert werden: {}",
),
("settings_saved", "Einstellungen gespeichert"),
(
k::ERR_BAD_FORMAT,
"das Format muss eines der folgenden sein: zip, tar, tar.gz, tar.zst",
"setup_intro",
"Erster Start: Erstellen Sie das Administrator-Konto. Dieser Benutzer kann andere Benutzer und die Servereinstellungen verwalten.",
),
(k::ERR_NOT_A_FILE, "keine Datei"),
(k::ERR_TOO_LARGE_PREVIEW, "Datei zu groß für eine Vorschau"),
(k::ERR_BAD_ACTION, "action=content erwartet"),
(k::ERR_TOO_LARGE_SAVE, "Datei zu groß zum Speichern"),
(k::ERR_INVALID_LANGUAGE, "ungültiges Sprachkürzel"),
(k::ERR_BAD_BODY, "ungültiger Anfrageinhalt"),
(
k::ERR_BAD_POST,
"POST erwartet einen Multipart-Upload, eine JSON-Mutation oder ?action=mkdir/create-file",
"setup_name_err",
"Bitte wählen Sie einen Namen (1–64 Zeichen).",
),
("setup_pw_mismatch", "Die Passwörter stimmen nicht überein."),
("share", "Teilen"),
(
k::ERR_FOLDER_NAME_REQUIRED,
"ein Ordnername ist erforderlich",
"share_create_err",
"Freigabe konnte nicht erstellt werden: {}",
),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_NEW_NAME_REQUIRED, "new_name ist erforderlich"),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
("share_delete_err", "Freigabe konnte nicht gelöscht werden"),
("share_deleted", "Freigabe gelöscht"),
(
k::ERR_PATH_REQUIRED,
"ein Pfad innerhalb des Ordners ist erforderlich",
"share_dialog_hint",
"Erstellt einen Link für diesen Eintrag. Mit schreibbaren Links können Empfänger Dateien hochladen und bearbeiten.",
),
("share_expired", "Freigabe abgelaufen"),
(
k::ERR_BAD_MULTIPART,
"multipart/form-data mit Boundary erwartet",
"share_expired_msg",
"Dieser Freigabelink ist nicht mehr aktiv.",
),
(k::ERR_BAD_UPLOAD, "ungültige Upload-Daten"),
(k::ERR_PART_NO_NAME, "Part ohne Namen"),
(k::ERR_BAD_PART_NAME, "ungültiger Part-Name"),
("share_link", "Freigabelink"),
("share_not_found", "Freigabe nicht gefunden"),
(
k::ERR_FOLDER_EXISTS,
"ein Ordner mit diesem Namen existiert bereits",
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
),
("share_perm_ro", "Jeder mit diesem Link kann „{}“ lesen."),
(
k::ERR_SAVE_FAILED,
"die Datei konnte nicht gespeichert werden",
"share_perm_rw",
"Jeder mit diesem Link kann „{}“ lesen und schreiben.",
),
(k::ERR_NO_FILES_UPLOADED, "keine Dateien hochgeladen"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
(k::ERR_BAD_UPLOAD_PATH, "ungültiger Dateipfad im Upload"),
(k::ERR_NO_SUCH_FOLDER, "dieser Ordner existiert nicht"),
(k::ERR_READ_ONLY_FOLDER, "schreibgeschützter Ordner"),
("share_title", "{} teilen"),
("shares", "Freigaben"),
(
k::ERR_RW_SHARES_DISABLED,
"schreibbare Freigaben sind deaktiviert",
"shares_delete_err",
"Freigaben konnten nicht gelöscht werden",
),
(
k::ERR_RW_RO_FOLDER,
"dieser Ordner ist für Sie schreibgeschützt und kann nicht schreibbar freigegeben werden",
"shares_empty_hint",
"Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen Link zu erstellen.",
),
(k::ERR_FS_NOT_FOUND, "Ordner nicht gefunden"),
(k::ERR_FS_NOT_A_DIR, "kein Ordner"),
(k::ERR_FS_FORBIDDEN, "Zugriff verweigert"),
(
k::ERR_FS_ROOT_MISSING,
"der konfigurierte Ordner existiert nicht mehr",
"shares_view_hint",
"Von Ihnen erstellte Links. Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen hinzuzufügen.",
),
(k::ERR_FS_CONFLICT, "existiert bereits"),
("sign_in", "Anmelden"),
(
k::ERR_SEARCH_FORBIDDEN,
"Suche erfordert eine angemeldete Sitzung",
"single_click_desc",
"Wenn aktiv, öffnet ein einfacher Klick einen Eintrag und Strg+Klick wählt ihn aus. Wenn inaktiv, wählt ein einfacher Klick aus und ein Doppelklick öffnet.",
),
("single_click_label", "Einzelklick öffnet Einträge"),
("size", "Größe"),
("some_files_exist", "Einige Dateien existieren bereits"),
("theme_auto", "Auto"),
("theme_dark", "Dunkel"),
("theme_light", "Hell"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
("type", "Typ"),
("unsaved_changes", "Ungespeicherte Änderungen"),
("until", "Bis"),
("upload_complete", "Hochladen abgeschlossen"),
("upload_files", "Dateien hochladen"),
("upload_folder", "Ordner hochladen"),
("uploading", "{} Datei(en) werden hochgeladen…"),
("user_created", "Benutzer erstellt"),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
(
k::ERR_SEARCH_BAD_SCOPE,
"Bereich muss name, content oder both sein",
"user_save_err",
"Benutzer konnte nicht gespeichert werden: {}",
),
(k::ERR_ROOT_FORBIDDEN, "kein Zugriff auf diesen Ordner"),
("user_updated", "Benutzer aktualisiert"),
("users", "Benutzer"),
("your_shares", "Ihre Freigaben"),
];
/// French translations.
const FR: &[(&str, &str)] = &[
("no_folders_title", "Aucun dossier disponible"),
("active_can_signin", "Actif (peut se connecter)"),
("add_folder", "Ajouter un dossier…"),
("add_here", "Ajouter ce dossier"),
("administrator", "Administrateur"),
(
"no_folders_msg",
"Aucun administrateur ne vous a encore attribué de dossier.",
"allow_editing",
"Autoriser la modification (lecture-écriture)",
),
("select_folder_title", "Choisir un dossier"),
("allow_rw_shares", "Autoriser les partages modifiables"),
(
"select_folder_msg",
"Choisissez un dossier dans le panneau latéral pour commencer à naviguer.",
"allow_rw_shares_desc",
"Autoriser les utilisateurs à créer des liens de partage en lecture-écriture. Désactivé par défaut.",
),
("folder_gone_title", "Dossier indisponible"),
("and_more", "…et {} autres"),
("cancel", "Annuler"),
(
"folder_gone_msg",
"Ce dossier n'est plus accessible. Choisissez-en un autre dans le panneau latéral.",
"cant_delete_self",
"Vous ne pouvez pas supprimer votre propre compte",
),
("loading", "Chargement…"),
("loading_share", "Chargement du partage…"),
("retry", "Réessayer"),
("empty_folder", "Ce dossier est vide."),
("refresh", "Actualiser"),
("grid_view", "Vue en grille"),
("list_view", "Vue en liste"),
("open", "Ouvrir"),
("open_in_new_tab", "Ouvrir dans un nouvel onglet"),
("download", "Télécharger"),
("rename", "Renommer"),
("move", "Déplacer"),
("close", "Fermer"),
("close_esc", "Fermer (Échap)"),
("copied", "Copié"),
("copy", "Copier"),
("done", "Terminé"),
("share", "Partager"),
("info", "Infos"),
("delete", "Supprimer"),
("new_folder", "Nouveau dossier"),
("new_file", "Nouveau fichier"),
("upload_files", "Téléverser des fichiers"),
("upload_folder", "Téléverser un dossier"),
("read_only_hint", "Dossier en lecture seule"),
("read_only_badge", "Lecture seule"),
("more_actions", "Plus d'actions"),
("folder_name", "Nom du dossier"),
("file_name", "Nom du fichier"),
("copy_here", "Copier ici"),
("copy_link", "Copier le lien"),
("copy_manual", "Copiez le lien manuellement"),
("copy_n_to", "Copier {} éléments vers…"),
("copy_to", "Copier « {} » vers…"),
("create", "Créer"),
("rename_title", "Renommer « {} »"),
("new_name", "Nouveau nom"),
("create_admin", "Créer le compte administrateur"),
("create_link", "Créer le lien"),
("create_user", "Créer l'utilisateur"),
("creating", "Création…"),
("custom", "Personnalisé…"),
("delete", "Supprimer"),
("delete_all", "Tout supprimer"),
(
"delete_all_msg",
"Supprimer les {} liens de partage ? Toute personne les possédant perdra l'accès. Cette action est irréversible.",
),
("delete_all_title", "Supprimer tous les partages ?"),
(
"delete_folder_msg",
"Supprimer le dossier « {} » et tout son contenu ?\nCette action est irréversible.",
@@ -1363,397 +1373,404 @@ const FR: &[(&str, &str)] = &[
"delete_n_msg",
"Supprimer {} éléments ?\nCette action est irréversible.",
),
("cancel", "Annuler"),
(
"overwrite_question",
"Les fichiers suivants existent déjà. Les écraser ?",
"delete_partial",
"{} des {} liens n'ont pas pu être supprimés",
),
("overwrite", "Écraser"),
("and_more", "…et {} autres"),
("no_subfolders", "Aucun sous-dossier"),
("move_to", "Déplacer « {} » vers…"),
("copy_to", "Copier « {} » vers…"),
("move_n_to", "Déplacer {} éléments vers…"),
("copy_n_to", "Copier {} éléments vers…"),
("move_here", "Déplacer ici"),
("copy_here", "Copier ici"),
("download_title", "Télécharger « {} »"),
("delete_share", "Supprimer le partage"),
("delete_user", "Supprimer l'utilisateur"),
(
"download_formats_msg",
"Choisissez un format d'archivage pour le dossier.",
"delete_user_confirm",
"Supprimer l'utilisateur « {} » ? Cette action est irréversible.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "Informations"),
("name", "Nom"),
("type", "Type"),
("folder", "Dossier"),
("file", "Fichier"),
("size", "Taille"),
("modified", "Modifié"),
("location", "Emplacement"),
("close", "Fermer"),
("folder_created", "Dossier créé"),
("file_created", "Fichier créé"),
("name_taken", "Ce nom est déjà utilisé"),
("renamed", "Renommé"),
("deleted", "Supprimé"),
("uploading", "Téléversement de {} fichier(s)…"),
("some_files_exist", "Certains fichiers existent déjà"),
("upload_complete", "Téléversement terminé"),
("moved", "Déplacé"),
("copied", "Copié"),
("download_started", "Téléchargement démarré"),
("saved", "Enregistré"),
("menu", "Menu"),
("open_menu", "Ouvrir le menu"),
("refresh_folders", "Actualiser vos dossiers"),
("theme_title", "Thème : {} (cliquer pour changer)"),
("log_out", "Se déconnecter"),
("files", "Fichiers"),
("manage", "Gérer"),
("shares", "Partages"),
("users", "Utilisateurs"),
("no_users", "Aucun utilisateur."),
("settings", "Paramètres"),
("theme_auto", "Auto"),
("theme_light", "Clair"),
("theme_dark", "Sombre"),
("search", "Rechercher"),
("deleted_n_shares", "{} partage(s) supprimé(s)"),
("discard", "Abandonner"),
(
"search_placeholder",
"Rechercher dans les fichiers et leur contenu",
"discard_question",
"Abandonner vos modifications et fermer l'éditeur ?",
),
("dismiss", "Ignorer"),
("dismiss_error", "Ignorer l'erreur"),
("done", "Terminé"),
("download", "Télécharger"),
(
"download_formats_msg",
"Choisissez un format d'archivage pour le dossier.",
),
("search_name", "Nom"),
("search_content", "Contenu"),
("search_both", "Les deux"),
("search_goto", "Afficher dans le dossier"),
("search_run", "Rechercher"),
("search_in", "Rechercher dans…"),
("search_here", "Rechercher ici"),
("search_in_folder", "Rechercher dans ce dossier"),
("search_stop", "Arrêter"),
("searching", "recherche…"),
("search_n_results", "{} résultats"),
("search_took", "{} ms"),
("search_stopped", "arrêté · {} résultats"),
("search_scanned", "{} fichiers parcourus"),
("search_skipped", "{} ignorés (plus de 10 Mio)"),
("search_n_matches", "{} correspondances"),
("search_in_n_files", "dans {} fichiers"),
("search_collapse_all", "Tout replier"),
("search_expand_all", "Tout déplier"),
("search_shown_of", "{} sur {} affichés"),
("search_show_more", "Afficher {} de plus"),
("search_no_results", "aucun résultat"),
("saving", "Enregistrement…"),
("profile", "Profil"),
("server", "Serveur"),
("profile_saved", "Profil enregistré"),
("download_started", "Téléchargement démarré"),
("download_title", "Télécharger « {} »"),
("edit", "Modifier"),
("edit_user_title", "Modifier « {} »"),
("empty_folder", "Ce dossier est vide."),
(k::ERR_ADMIN_ONLY, "réservé aux administrateurs"),
(k::ERR_ALREADY_SET_UP, "le serveur est déjà configuré"),
(k::ERR_BAD_ACTION, "action=content attendu"),
(k::ERR_BAD_BODY, "corps de requête invalide"),
(k::ERR_BAD_EXPIRES_AT, "la date d'expiration est invalide"),
(
"profile_save_err",
"Impossible d'enregistrer le profil : {}",
k::ERR_BAD_FORMAT,
"le format doit être zip, tar, tar.gz ou tar.zst",
),
("settings_saved", "Paramètres enregistrés"),
(
"settings_save_err",
"Impossible d'enregistrer les paramètres : {}",
k::ERR_BAD_MULTIPART,
"multipart/form-data avec boundary attendu",
),
("single_click_label", "Ouvrir au clic simple"),
(k::ERR_BAD_PART_NAME, "nom de partie invalide"),
(
"single_click_desc",
"Quand activé, un simple clic ouvre une entrée et Ctrl+clic la sélectionne. Quand désactivé, un simple clic sélectionne et un double-clic ouvre.",
k::ERR_BAD_POST,
"POST attend un téléversement multipart, une mutation JSON ou ?action=mkdir/create-file",
),
("save_profile", "Enregistrer le profil"),
("allow_rw_shares", "Autoriser les partages modifiables"),
(k::ERR_BAD_UPLOAD, "données de téléversement invalides"),
(
"allow_rw_shares_desc",
"Autoriser les utilisateurs à créer des liens de partage en lecture-écriture. Désactivé par défaut.",
k::ERR_BAD_UPLOAD_PATH,
"chemin de fichier invalide dans le téléversement",
),
("save_settings", "Enregistrer les paramètres"),
("language", "Langue"),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
(k::ERR_FOLDER_EXISTS, "un dossier avec ce nom existe déjà"),
(k::ERR_FOLDER_NAME_REQUIRED, "un nom de dossier est requis"),
(k::ERR_FS_CONFLICT, "existe déjà"),
(k::ERR_FS_FORBIDDEN, "accès refusé"),
(k::ERR_FS_NOT_A_DIR, "ce n'est pas un dossier"),
(k::ERR_FS_NOT_FOUND, "dossier introuvable"),
(k::ERR_FS_ROOT_MISSING, "le dossier configuré n'existe plus"),
(k::ERR_INTERNAL, "erreur interne"),
(k::ERR_INVALID_CREDENTIALS, "nom ou mot de passe incorrect"),
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
(
"language_desc",
"La langue de l'interface. « Automatique » utilise la langue de votre navigateur.",
k::ERR_LAST_ADMIN,
"impossible de supprimer le dernier administrateur actif",
),
("lang_auto", "Automatique"),
("lang_en", "Anglais"),
("lang_de", "Allemand"),
("lang_fr", "Français"),
(
"delete_user_confirm",
"Supprimer l'utilisateur « {} » ? Cette action est irréversible.",
k::ERR_LAST_ADMIN_DELETE,
"impossible de supprimer le dernier administrateur actif",
),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
("new_user", "Nouvel utilisateur"),
("edit", "Modifier"),
("delete_user", "Supprimer l'utilisateur"),
(k::ERR_NAME_LENGTH, "le nom doit contenir 1 à 64 caractères"),
(k::ERR_NEW_NAME_REQUIRED, "new_name est requis"),
(k::ERR_NO_FILES_UPLOADED, "aucun fichier n'a été téléversé"),
(k::ERR_NO_SUCH_FOLDER, "ce dossier n'existe pas"),
(k::ERR_NOT_A_FILE, "ce n'est pas un fichier"),
(k::ERR_NOT_SIGNED_IN, "non connecté"),
(
"cant_delete_self",
"Vous ne pouvez pas supprimer votre propre compte",
k::ERR_OWN_ACCOUNT,
"vous ne pouvez pas désactiver votre propre compte",
),
("root_dup_err", "Ce dossier est déjà dans la liste."),
("name_required", "Un nom est requis."),
(
"pw_short",
"Le mot de passe doit contenir au moins 8 caractères.",
k::ERR_OWN_ADMIN,
"vous ne pouvez pas retirer vos propres droits d'administrateur",
),
("need_folder", "Donnez au moins un dossier à l'utilisateur."),
("user_created", "Utilisateur créé"),
("user_updated", "Utilisateur mis à jour"),
(
"user_save_err",
"Impossible d'enregistrer l'utilisateur : {}",
k::ERR_OWN_DELETE,
"vous ne pouvez pas supprimer votre propre compte",
),
("password", "Mot de passe"),
(k::ERR_PART_NO_NAME, "partie sans nom"),
(
"new_password_keep",
"Nouveau mot de passe (laisser vide pour conserver)",
k::ERR_PASSWORD_SHORT,
"le mot de passe doit contenir au moins 8 caractères",
),
("administrator", "Administrateur"),
("active_can_signin", "Actif (peut se connecter)"),
("remove_folder", "Retirer le dossier"),
("create_user", "Créer l'utilisateur"),
("save", "Enregistrer"),
("edit_user_title", "Modifier « {} »"),
("folders", "Dossiers"),
(k::ERR_PATH_REQUIRED, "un chemin dans le dossier est requis"),
(k::ERR_READ_ONLY_FOLDER, "dossier en lecture seule"),
(k::ERR_ROOT_FORBIDDEN, "dossier inaccessible"),
(
"folders_paths_hint",
"Les chemins sont relatifs à la racine du serveur. « . » désigne toute la racine.",
k::ERR_RW_RO_FOLDER,
"ce dossier est en lecture seule pour vous, il ne peut donc pas être partagé en écriture",
),
("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"),
("expiry_1w", "1 semaine"),
("expiry_1mo", "1 mois"),
("meta_expires", "expire le {}"),
("meta_created", "créé le {}"),
("mode_ro", "lecture seule"),
("share_not_found", "Partage introuvable"),
(
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
k::ERR_RW_SHARES_DISABLED,
"les partages modifiables sont désactivés",
),
("share_expired", "Partage expiré"),
("share_expired_msg", "Ce lien de partage n'est plus actif."),
("copy_manual", "Copiez le lien manuellement"),
("link_copied", "Lien copié dans le presse-papiers"),
("share_create_err", "Impossible de créer le partage : {}"),
("share_link", "Lien de partage"),
(k::ERR_SAVE_FAILED, "impossible d'enregistrer le fichier"),
(
"share_perm_rw",
"Toute personne possédant ce lien peut lire et modifier « {} ».",
k::ERR_SEARCH_BAD_SCOPE,
"la portée doit être name, content ou both",
),
(
"share_perm_ro",
"Toute personne possédant ce lien peut lire « {} ».",
k::ERR_SEARCH_FORBIDDEN,
"la recherche nécessite une session connectée",
),
("share_title", "Partager {}"),
(
"share_dialog_hint",
"Crée un lien pour cet élément. Les liens modifiables permettent aux destinataires de téléverser et de modifier.",
k::ERR_SESSION_EXPIRED,
"session expirée, veuillez vous connecter à nouveau",
),
("expires", "Expire"),
("never", "Jamais"),
("custom", "Personnalisé…"),
("until", "Jusqu'au"),
("expires_at", "Expire le {}"),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(
"delete_partial",
"{} des {} liens n'ont pas pu être supprimés",
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",
),
("never_expires", "N'expire jamais"),
(
"allow_editing",
"Autoriser la modification (lecture-écriture)",
k::ERR_TOO_LARGE_PREVIEW,
"fichier trop volumineux pour l'aperçu",
),
(
"rw_disabled",
"Les partages modifiables sont désactivés par le serveur.",
k::ERR_TOO_LARGE_SAVE,
"fichier trop volumineux pour être enregistré",
),
(k::ERR_USER_EXISTS, "un utilisateur avec ce nom existe déjà"),
(k::ERR_USER_NOT_FOUND, "utilisateur introuvable"),
("expires", "Expire"),
("expires_at", "Expire le {}"),
("expiry_1d", "1 jour"),
("expiry_1h", "1 heure"),
("expiry_1mo", "1 mois"),
("expiry_1w", "1 semaine"),
("file", "Fichier"),
("file_changed", "Fichier modifié sur disque"),
("file_created", "Fichier créé"),
("file_info", "Informations"),
("file_name", "Nom du fichier"),
("files", "Fichiers"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("fmt_zip", "Zip"),
("folder", "Dossier"),
("folder_created", "Dossier créé"),
(
"rw_ro_folder",
"Ce dossier est en lecture seule, le partage l'est donc aussi.",
"folder_gone_msg",
"Ce dossier n'est plus accessible. Choisissez-en un autre dans le panneau latéral.",
),
("creating", "Création…"),
("create_link", "Créer le lien"),
("your_shares", "Vos partages"),
("deleted_n_shares", "{} partage(s) supprimé(s)"),
("shares_delete_err", "Impossible de supprimer les partages"),
("delete_all", "Tout supprimer"),
("no_shares", "Aucun partage pour l'instant"),
("folder_gone_title", "Dossier indisponible"),
("folder_name", "Nom du dossier"),
("folders", "Dossiers"),
(
"shares_view_hint",
"Liens que vous avez créés. Faites un clic droit sur un fichier ou un dossier, puis choisissez « Partager » pour en ajouter un.",
"folders_paths_hint",
"Les chemins sont relatifs à la racine du serveur. « . » désigne toute la racine.",
),
("grid_view", "Vue en grille"),
("info", "Infos"),
("keep_editing", "Continuer à modifier"),
("lang_auto", "Automatique"),
("lang_de", "Allemand"),
("lang_en", "Anglais"),
("lang_fr", "Français"),
("language", "Langue"),
(
"shares_empty_hint",
"Faites un clic droit sur un fichier ou un dossier, puis choisissez « Partager » pour créer un lien.",
"language_desc",
"La langue de l'interface. « Automatique » utilise la langue de votre navigateur.",
),
("copy_link", "Copier le lien"),
("delete_share", "Supprimer le partage"),
("share_deleted", "Partage supprimé"),
("share_delete_err", "Impossible de supprimer le partage"),
("delete_all_title", "Supprimer tous les partages ?"),
("link_copied", "Lien copié dans le presse-papiers"),
(
"delete_all_msg",
"Supprimer les {} liens de partage ? Toute personne les possédant perdra l'accès. Cette action est irréversible.",
"list_truncated",
"Seules les {} premières entrées sont affichées.",
),
("login_subtitle", "Connectez-vous pour continuer."),
("list_view", "Vue en liste"),
("load_latest", "Charger la version la plus récente"),
("loading", "Chargement…"),
("loading_share", "Chargement du partage…"),
("location", "Emplacement"),
("log_out", "Se déconnecter"),
(
"login_error",
"Veuillez saisir votre nom et votre mot de passe.",
),
("sign_in", "Se connecter"),
("login_subtitle", "Connectez-vous pour continuer."),
("manage", "Gérer"),
("menu", "Menu"),
("meta_created", "créé le {}"),
("meta_expires", "expire le {}"),
("mode_ro", "lecture seule"),
("mode_rw", "lecture-écriture"),
("modified", "Modifié"),
(
"setup_name_err",
"Veuillez choisir un nom (1 à 64 caractères).",
"modified_after",
"Ce fichier a été modifié après son ouverture. « Charger la version la plus récente » abandonne vos modifications non enregistrées ; « Écraser » conserve votre version.",
),
("more_actions", "Plus d'actions"),
("move", "Déplacer"),
("move_here", "Déplacer ici"),
("move_n_to", "Déplacer {} éléments vers…"),
("move_to", "Déplacer « {} » vers…"),
("moved", "Déplacé"),
("name", "Nom"),
("name_required", "Un nom est requis."),
("name_taken", "Ce nom est déjà utilisé"),
("need_folder", "Donnez au moins un dossier à l'utilisateur."),
("never", "Jamais"),
("never_expires", "N'expire jamais"),
("new_file", "Nouveau fichier"),
("new_folder", "Nouveau dossier"),
("new_name", "Nouveau nom"),
(
"setup_pw_mismatch",
"Les mots de passe ne correspondent pas.",
"new_password_keep",
"Nouveau mot de passe (laisser vide pour conserver)",
),
("repeat_password", "Répéter le mot de passe"),
("create_admin", "Créer le compte administrateur"),
("new_user", "Nouvel utilisateur"),
(
"setup_intro",
"Premier démarrage : créez le compte administrateur. Cet utilisateur peut gérer les autres utilisateurs et les paramètres du serveur.",
"no_folders_msg",
"Aucun administrateur ne vous a encore attribué de dossier.",
),
("close_esc", "Fermer (Échap)"),
("no_folders_title", "Aucun dossier disponible"),
("no_preview", "Aperçu non disponible"),
(
"no_preview_msg",
"L'application ne peut pas afficher « {} » ici.",
),
("dismiss", "Ignorer"),
("dismiss_error", "Ignorer l'erreur"),
("file_changed", "Fichier modifié sur disque"),
(
"modified_after",
"Ce fichier a été modifié après son ouverture. « Charger la version la plus récente » abandonne vos modifications non enregistrées ; « Écraser » conserve votre version.",
),
("load_latest", "Charger la version la plus récente"),
("keep_editing", "Continuer à modifier"),
("discard", "Abandonner"),
("unsaved_changes", "Modifications non enregistrées"),
("no_shares", "Aucun partage pour l'instant"),
("no_subfolders", "Aucun sous-dossier"),
("no_users", "Aucun utilisateur."),
("open", "Ouvrir"),
("open_in_new_tab", "Ouvrir dans un nouvel onglet"),
("open_menu", "Ouvrir le menu"),
("overwrite", "Écraser"),
(
"discard_question",
"Abandonner vos modifications et fermer l'éditeur ?",
"overwrite_question",
"Les fichiers suivants existent déjà. Les écraser ?",
),
(k::ERR_INTERNAL, "erreur interne"),
(k::ERR_NOT_SIGNED_IN, "non connecté"),
("password", "Mot de passe"),
("profile", "Profil"),
(
k::ERR_SESSION_EXPIRED,
"session expirée, veuillez vous connecter à nouveau",
"profile_save_err",
"Impossible d'enregistrer le profil : {}",
),
(k::ERR_INVALID_CREDENTIALS, "nom ou mot de passe incorrect"),
(k::ERR_ALREADY_SET_UP, "le serveur est déjà configuré"),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(k::ERR_BAD_EXPIRES_AT, "la date d'expiration est invalide"),
("profile_saved", "Profil enregistré"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",
"pw_short",
"Le mot de passe doit contenir au moins 8 caractères.",
),
(k::ERR_NAME_LENGTH, "le nom doit contenir 1 à 64 caractères"),
("read_only_badge", "Lecture seule"),
("read_only_hint", "Dossier en lecture seule"),
("refresh", "Actualiser"),
("refresh_folders", "Actualiser vos dossiers"),
("remove_folder", "Retirer le dossier"),
("rename", "Renommer"),
("rename_title", "Renommer « {} »"),
("renamed", "Renommé"),
("repeat_password", "Répéter le mot de passe"),
("retry", "Réessayer"),
("root_dup_err", "Ce dossier est déjà dans la liste."),
(
k::ERR_PASSWORD_SHORT,
"le mot de passe doit contenir au moins 8 caractères",
"rw_disabled",
"Les partages modifiables sont désactivés par le serveur.",
),
(k::ERR_ADMIN_ONLY, "réservé aux administrateurs"),
(k::ERR_USER_EXISTS, "un utilisateur avec ce nom existe déjà"),
(k::ERR_USER_NOT_FOUND, "utilisateur introuvable"),
(
k::ERR_OWN_ADMIN,
"vous ne pouvez pas retirer vos propres droits d'administrateur",
"rw_ro_folder",
"Ce dossier est en lecture seule, le partage l'est donc aussi.",
),
("save", "Enregistrer"),
("save_profile", "Enregistrer le profil"),
("save_settings", "Enregistrer les paramètres"),
("saved", "Enregistré"),
("saving", "Enregistrement…"),
("search", "Rechercher"),
("search_both", "Les deux"),
("search_collapse_all", "Tout replier"),
("search_content", "Contenu"),
("search_expand_all", "Tout déplier"),
("search_failed", "La recherche a échoué."),
("search_goto", "Afficher dans le dossier"),
("search_here", "Rechercher ici"),
("search_in", "Rechercher dans…"),
("search_in_folder", "Rechercher dans ce dossier"),
("search_in_n_files", "dans {} fichiers"),
("search_n_matches", "{} correspondances"),
("search_n_results", "{} résultats"),
("search_name", "Nom"),
("search_no_results", "aucun résultat"),
(
k::ERR_OWN_ACCOUNT,
"vous ne pouvez pas désactiver votre propre compte",
"search_placeholder",
"Rechercher dans les fichiers et leur contenu",
),
("search_run", "Rechercher"),
("search_scanned", "{} fichiers parcourus"),
("search_show_more", "Afficher {} de plus"),
("search_shown_of", "{} sur {} affichés"),
("search_skipped", "{} ignorés (plus de 10 Mio)"),
("search_stop", "Arrêter"),
("search_stopped", "arrêté · {} résultats"),
("search_took", "{} ms"),
("searching", "recherche…"),
(
k::ERR_LAST_ADMIN,
"impossible de supprimer le dernier administrateur actif",
"select_folder_msg",
"Choisissez un dossier dans le panneau latéral pour commencer à naviguer.",
),
("select_folder_title", "Choisir un dossier"),
("server", "Serveur"),
("server_root", "Racine du serveur"),
("server_unreachable", "Le serveur est injoignable."),
("settings", "Paramètres"),
(
k::ERR_OWN_DELETE,
"vous ne pouvez pas supprimer votre propre compte",
"settings_save_err",
"Impossible d'enregistrer les paramètres : {}",
),
("settings_saved", "Paramètres enregistrés"),
(
k::ERR_LAST_ADMIN_DELETE,
"impossible de supprimer le dernier administrateur actif",
"setup_intro",
"Premier démarrage : créez le compte administrateur. Cet utilisateur peut gérer les autres utilisateurs et les paramètres du serveur.",
),
(
k::ERR_BAD_FORMAT,
"le format doit être zip, tar, tar.gz ou tar.zst",
"setup_name_err",
"Veuillez choisir un nom (1 à 64 caractères).",
),
(k::ERR_NOT_A_FILE, "ce n'est pas un fichier"),
(
k::ERR_TOO_LARGE_PREVIEW,
"fichier trop volumineux pour l'aperçu",
"setup_pw_mismatch",
"Les mots de passe ne correspondent pas.",
),
(k::ERR_BAD_ACTION, "action=content attendu"),
("share", "Partager"),
("share_create_err", "Impossible de créer le partage : {}"),
("share_delete_err", "Impossible de supprimer le partage"),
("share_deleted", "Partage supprimé"),
(
k::ERR_TOO_LARGE_SAVE,
"fichier trop volumineux pour être enregistré",
"share_dialog_hint",
"Crée un lien pour cet élément. Les liens modifiables permettent aux destinataires de téléverser et de modifier.",
),
(k::ERR_BAD_BODY, "corps de requête invalide"),
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
("share_expired", "Partage expiré"),
("share_expired_msg", "Ce lien de partage n'est plus actif."),
("share_link", "Lien de partage"),
("share_not_found", "Partage introuvable"),
(
k::ERR_BAD_POST,
"POST attend un téléversement multipart, une mutation JSON ou ?action=mkdir/create-file",
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
),
(k::ERR_FOLDER_NAME_REQUIRED, "un nom de dossier est requis"),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_NEW_NAME_REQUIRED, "new_name est requis"),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_PATH_REQUIRED, "un chemin dans le dossier est requis"),
(
k::ERR_BAD_MULTIPART,
"multipart/form-data avec boundary attendu",
"share_perm_ro",
"Toute personne possédant ce lien peut lire « {} ».",
),
(k::ERR_BAD_UPLOAD, "données de téléversement invalides"),
(k::ERR_PART_NO_NAME, "partie sans nom"),
(k::ERR_BAD_PART_NAME, "nom de partie invalide"),
(k::ERR_FOLDER_EXISTS, "un dossier avec ce nom existe déjà"),
(k::ERR_SAVE_FAILED, "impossible d'enregistrer le fichier"),
(k::ERR_NO_FILES_UPLOADED, "aucun fichier n'a été téléversé"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
(
k::ERR_BAD_UPLOAD_PATH,
"chemin de fichier invalide dans le téléversement",
"share_perm_rw",
"Toute personne possédant ce lien peut lire et modifier « {} ».",
),
(k::ERR_NO_SUCH_FOLDER, "ce dossier n'existe pas"),
(k::ERR_READ_ONLY_FOLDER, "dossier en lecture seule"),
("share_title", "Partager {}"),
("shares", "Partages"),
("shares_delete_err", "Impossible de supprimer les partages"),
(
k::ERR_RW_SHARES_DISABLED,
"les partages modifiables sont désactivés",
"shares_empty_hint",
"Faites un clic droit sur un fichier ou un dossier, puis choisissez « Partager » pour créer un lien.",
),
(
k::ERR_RW_RO_FOLDER,
"ce dossier est en lecture seule pour vous, il ne peut donc pas être partagé en écriture",
"shares_view_hint",
"Liens que vous avez créés. Faites un clic droit sur un fichier ou un dossier, puis choisissez « Partager » pour en ajouter un.",
),
(k::ERR_FS_NOT_FOUND, "dossier introuvable"),
(k::ERR_FS_NOT_A_DIR, "ce n'est pas un dossier"),
(k::ERR_FS_FORBIDDEN, "accès refusé"),
(k::ERR_FS_ROOT_MISSING, "le dossier configuré n'existe plus"),
(k::ERR_FS_CONFLICT, "existe déjà"),
("sign_in", "Se connecter"),
(
k::ERR_SEARCH_FORBIDDEN,
"la recherche nécessite une session connectée",
"single_click_desc",
"Quand activé, un simple clic ouvre une entrée et Ctrl+clic la sélectionne. Quand désactivé, un simple clic sélectionne et un double-clic ouvre.",
),
("single_click_label", "Ouvrir au clic simple"),
("size", "Taille"),
("some_files_exist", "Certains fichiers existent déjà"),
("theme_auto", "Auto"),
("theme_dark", "Sombre"),
("theme_light", "Clair"),
("theme_title", "Thème : {} (cliquer pour changer)"),
("type", "Type"),
("unsaved_changes", "Modifications non enregistrées"),
("until", "Jusqu'au"),
("upload_complete", "Téléversement terminé"),
("upload_files", "Téléverser des fichiers"),
("upload_folder", "Téléverser un dossier"),
("uploading", "Téléversement de {} fichier(s)…"),
("user_created", "Utilisateur créé"),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
(
k::ERR_SEARCH_BAD_SCOPE,
"la portée doit être name, content ou both",
"user_save_err",
"Impossible d'enregistrer l'utilisateur : {}",
),
(k::ERR_ROOT_FORBIDDEN, "dossier inaccessible"),
("user_updated", "Utilisateur mis à jour"),
("users", "Utilisateurs"),
("your_shares", "Vos partages"),
];
#[cfg(test)]
@@ -1764,6 +1781,17 @@ mod tests {
table.iter().map(|(k, _)| *k).collect()
}
#[test]
fn tables_are_sorted() {
// `find` binary-searches, so an out-of-order entry is unreachable.
for (name, table) in [("EN", EN), ("DE", DE), ("FR", FR)] {
let ks = keys(table);
let mut sorted = ks.clone();
sorted.sort_unstable();
assert_eq!(ks, sorted, "{name} table is not sorted by key");
}
}
#[test]
fn no_duplicate_keys() {
for (name, table) in [("EN", EN), ("DE", DE), ("FR", FR)] {
Mweb/src/util.rs
@@ -2,7 +2,6 @@
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// A window event listener that is removed when the current owner is disposed.
@@ -75,27 +74,16 @@ pub fn format_size(bytes: u64) -> String {
}
/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
/// Falls back to the raw string when it cannot be parsed.
/// Falls back to the raw string when it does not look like one.
///
/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
/// text is a slice of the input. No date parsing is needed.
pub fn format_date(rfc3339: &str) -> String {
let d = js_sys::Date::new(&JsValue::from_str(rfc3339));
if d.get_time().is_nan() {
return rfc3339.to_string();
}
fn pad(n: u32) -> String {
if n < 10 {
format!("0{n}")
} else {
n.to_string()
}
let b = rfc3339.as_bytes();
if b.len() >= 16 && b[10] == b'T' {
return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
}
format!(
"{:04}-{}-{} {}:{}",
d.get_utc_full_year(),
pad(d.get_utc_month() + 1),
pad(d.get_utc_date()),
pad(d.get_utc_hours()),
pad(d.get_utc_minutes()),
)
rfc3339.to_string()
}
pub fn storage_get(key: &str) -> Option<String> {
@@ -138,3 +126,64 @@ impl ViewMode {
);
}
}
/// 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
/// user pays for more rows only by asking for them.
pub const FILE_PAGE: usize = 100;
/// Footer under a paged list: where the user is in the list, and a button
/// to reveal the next page.
///
/// `shown` of `total` is stated plainly rather than only counting what is
/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
/// "5927 more…" does not. The button appears only while more items are
/// actually retained; past the caller's retain cap the count is all that is
/// left to say, so the footer becomes a plain line.
///
/// Returns nothing at all when everything is on screen.
pub fn page_footer(
shown: usize,
retained: usize,
total: usize,
page: usize,
reveal: impl Fn() + 'static,
) -> AnyView {
if total <= shown {
return view! {}.into_view().into_any();
}
let label = crate::i18n::t_fmt2(
crate::i18n::k::SEARCH_SHOWN_OF,
&shown.to_string(),
&total.to_string(),
);
let pageable = retained.saturating_sub(shown);
if pageable == 0 {
return view! { <div class="page-foot">{label}</div> }
.into_view()
.into_any();
}
let next = pageable.min(page);
view! {
<div class="page-foot">
<span class="pf-count">{label}</span>
<button class="pf-more" on:click=move |_| reveal()>
{crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
</button>
</div>
}
.into_view()
.into_any()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_date_drops_seconds_and_zone() {
assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
assert_eq!(format_date("nonsense"), "nonsense");
}
}
Mweb/src/views/browser.rs
@@ -1,5 +1,6 @@
//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use api_types::FileKind;
@@ -9,22 +10,32 @@ use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::Icon;
use crate::components::icon::{Icon, icon_svg};
use crate::components::toast::{ToastMsg, show, show_error};
use crate::editor::EditTarget;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::{ViewMode, format_date, format_size};
use crate::util::{FILE_PAGE, ViewMode, format_date, format_size, page_footer};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
/// One folder listing. Behind an `Arc` in [`ListState`] so reading the
/// signal never copies the entries (a folder can hold thousands).
#[derive(Debug, PartialEq)]
struct Listing {
entries: Vec<Entry>,
/// The server had more entries than it will return; the listing holds
/// only the first `api_types::MAX_LIST_ENTRIES`.
truncated: bool,
}
#[derive(Clone, Debug, PartialEq)]
enum ListState {
Loading,
Error(String),
Entries(Vec<Entry>),
Entries(Arc<Listing>),
}
/// The root that is actually shown: the one in the location. The one-root
@@ -85,21 +96,40 @@ pub fn Browser(
// sidebar and this view. The key guards against a refresh loop when
// /me comes back unchanged.
let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
// How many entries of the current listing are rendered. Reset by every
// fetch, so a new folder starts at one page again.
let shown = RwSignal::new(FILE_PAGE);
// 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 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 |_| {
selected.with(|v| {
v.iter()
.map(|e| e.name.clone())
.collect::<HashSet<String>>()
})
});
// Drop selected entries that disappeared from the listing (deleted,
// renamed, …).
Effect::new(move |_| {
if let ListState::Entries(entries) = list_state.get() {
list_state.with(|st| {
let ListState::Entries(list) = st else {
return;
};
let names: HashSet<&str> = list.entries.iter().map(|e| e.name.as_str()).collect();
let cur = selected.get_untracked();
let kept: Vec<Entry> = cur
.iter()
.filter(|s| entries.iter().any(|e| e.name == s.name))
.filter(|s| names.contains(s.name.as_str()))
.cloned()
.collect();
if kept.len() != cur.len() {
set_selected.set(kept);
}
}
});
});
// Escape clears the selection — but only when nothing that also reacts
// to Escape is open: the context menu (it has its own Escape handler),
@@ -140,6 +170,9 @@ pub fn Browser(
return;
};
set_list_state.set(ListState::Loading);
shown.set(FILE_PAGE);
fetch_gen.update_value(|g| *g += 1);
let my_gen = fetch_gen.get_value();
let root_id = root.id;
let path_str = loc.path.join("/");
// On a public share page the (synthetic) "me" comes from the share
@@ -149,8 +182,17 @@ pub fn Browser(
let refresh_key = me_refresh_key;
let set_refresh_key = set_me_refresh_key;
spawn_local(async move {
match api::list_files(root_id, &path_str).await {
Ok(r) => set_list_state.set(ListState::Entries(r.entries)),
let resp = api::list_files(root_id, &path_str).await;
// A newer fetch (or the view going away) makes this response
// stale: applying it would show the wrong folder.
if fetch_gen.try_get_value() != Some(my_gen) {
return;
}
match resp {
Ok(r) => set_list_state.set(ListState::Entries(Arc::new(Listing {
entries: r.entries,
truncated: r.truncated,
}))),
Err(e) => {
if !is_share
&& matches!(e.status(), Some(403) | Some(404))
@@ -192,10 +234,12 @@ pub fn Browser(
root,
loc,
list_state,
shown,
view_mode,
set_view_mode,
set_ctx,
selected,
sel_names,
set_selected,
fetch,
open_file,
@@ -263,10 +307,12 @@ fn file_browser(
root: &RootInfo,
loc: ReadSignal<Location>,
list_state: ReadSignal<ListState>,
shown: RwSignal<usize>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
set_selected: WriteSignal<Vec<Entry>>,
fetch: Callback<()>,
open_file: WriteSignal<Option<FileView>>,
@@ -301,17 +347,15 @@ fn file_browser(
{loc_now.path.iter().enumerate().map(|(i, seg)| {
let seg = seg.clone();
let target: Vec<String> = loc_now.path[..=i].to_vec();
let is_last = i == loc_now.path.len() - 1;
let class = if i == loc_now.path.len() - 1 {
"crumb current"
} else {
"crumb"
};
view! {
<span class="crumb-sep">"/"</span>
<span
class=move || {
if is_last {
"crumb current".to_string()
} else {
"crumb".to_string()
}
}
class=class
on:click=move |_| {
navigate(&Location {
root_id: Some(root_id),
@@ -405,19 +449,24 @@ fn file_browser(
}
.into_view()
.into_any(),
ListState::Entries(entries) => {
if entries.is_empty() {
view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
ListState::Entries(list) => {
if list.entries.is_empty() {
return view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
.into_view()
.into_any()
} else if view_mode.get() == ViewMode::Grid {
.into_any();
}
let total = list.entries.len();
let truncated = list.truncated;
let body = if view_mode.get() == ViewMode::Grid {
grid_view(
&entries,
list,
shown,
root_id,
loc,
is_rw,
single_click,
selected,
sel_names,
set_selected,
set_ctx,
open_file,
@@ -425,18 +474,43 @@ fn file_browser(
.into_any()
} else {
list_view(
&entries,
list,
shown,
root_id,
loc,
is_rw,
single_click,
selected,
sel_names,
set_selected,
set_ctx,
open_file,
)
.into_any()
};
// Paging footer and, below it, the server-side cap
// notice — both belong to the listing, not to one
// of the two layouts.
view! {
{body}
{move || page_footer(
shown.get().min(total),
total,
total,
FILE_PAGE,
move || shown.update(|n| *n += FILE_PAGE),
)}
{truncated.then(|| view! {
<p class="muted list-note">
{move || i18n::t_fmt(
i18n::k::LIST_TRUNCATED,
&api_types::MAX_LIST_ENTRIES.to_string(),
)}
</p>
})}
}
.into_view()
.into_any()
}
}
}}
@@ -454,7 +528,8 @@ fn file_browser(
/// entry that is already selected keeps the whole selection (multi-item
/// menu); any other entry becomes the only selected one.
fn ctx_menu_handler(
entry: Entry,
all: Arc<Listing>,
idx: usize,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
@@ -462,11 +537,8 @@ fn ctx_menu_handler(
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
if !selected
.get_untracked()
.iter()
.any(|s| s.name == entry.name)
{
let entry = &all.entries[idx];
if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
set_selected.set(vec![entry.clone()]);
}
set_ctx.set(Some((ev.client_x(), ev.client_y())));
@@ -485,14 +557,18 @@ fn ctx_menu_handler(
/// Selecting clicks stop propagation so the empty-area click (deselect) and
/// the double-click handler don't interfere.
fn selection_click(
entry: Entry,
all: Arc<Vec<Entry>>,
all: Arc<Listing>,
idx: usize,
single_click: bool,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
open: Callback<()>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
// Ranges and "is it still there?" run over the whole listing, not
// over the rendered page.
let entries = &all.entries;
let entry = &entries[idx];
let ctrl = ev.ctrl_key() || ev.meta_key();
if !ctrl && !ev.shift_key() {
if single_click {
@@ -506,24 +582,28 @@ fn selection_click(
ev.stop_propagation();
let cur = selected.get_untracked();
if ev.shift_key() {
let pos = |n: &str| all.iter().position(|e| e.name == n);
let anchor = cur
.first()
.map(|a| a.name.clone())
.unwrap_or(entry.name.clone());
let (Some(a), Some(b)) = (pos(&anchor), pos(&entry.name)) else {
return;
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)
};
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
// 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 !next.iter().any(|s| s.name == e.name) {
if have.insert(e.name.clone()) {
next.push(e.clone());
}
};
add(&all[a]);
all[lo..=hi].iter().for_each(&mut add);
add(&entries[anchor]);
entries[lo..=hi].iter().for_each(&mut add);
set_selected.set(next);
return;
}
@@ -597,8 +677,8 @@ fn open_entry(
/// only in markup.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn entry_callbacks(
entry: &Entry,
all: Arc<Vec<Entry>>,
all: Arc<Listing>,
idx: usize,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
@@ -612,18 +692,18 @@ fn entry_callbacks(
impl Fn(web_sys::MouseEvent) + 'static,
impl Fn(web_sys::MouseEvent) + 'static,
) {
let open_cb = open_entry(entry, root_id, loc, is_rw, open_file);
let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file);
(
open_cb,
selection_click(
entry.clone(),
all,
all.clone(),
idx,
single_click,
selected,
set_selected,
open_cb,
),
ctx_menu_handler(entry.clone(), set_ctx, selected, set_selected),
ctx_menu_handler(all, idx, set_ctx, selected, set_selected),
)
}
@@ -635,105 +715,125 @@ fn plain_dblclick(ev: &MouseEvent) -> bool {
#[allow(clippy::too_many_arguments)] // explicit signal props
fn grid_view(
entries: &[Entry],
all: Arc<Listing>,
shown: RwSignal<usize>,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
// Shared by every entry's click handler, for Shift+click ranges.
let all = Arc::new(entries.to_vec());
let total = all.entries.len();
view! {
<div class="entries-grid">
{entries.iter().map(|e| {
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, all.clone(), root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let tile_name = name.clone();
let selected_now = move || sel_check.with(|v| v.iter().any(|s| s.name == tile_name));
view! {
<div
class="tile"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
// Only the visible page is rendered; the click handlers still
// index into the whole listing, so Shift+click ranges reach
// past it. Keyed by index: within one listing the order is
// fixed, so revealing a page only appends tiles.
<For
each=move || 0..shown.get().min(total)
key=|i: &usize| *i
children=move |i: usize| {
let e = &all.entries[i];
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_click, on_ctx) = entry_callbacks(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let tile_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&tile_name));
view! {
<div
class="tile"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
}
}
}
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class="tile-icon".to_string()/>
<div class="tile-name">{name}</div>
</div>
on:contextmenu=on_ctx
title=title
>
// `icon_svg`, not `<Icon/>`: no reactive owner
// per tile (see its doc).
{icon_svg(icon, "tile-icon")}
<div class="tile-name">{name}</div>
</div>
}
}
}).collect::<Vec<_>>()}
/>
</div>
}
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn list_view(
entries: &[Entry],
all: Arc<Listing>,
shown: RwSignal<usize>,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
// Shared by every entry's click handler, for Shift+click ranges.
let all = Arc::new(entries.to_vec());
let total = all.entries.len();
view! {
<div class="entries-list">
{entries.iter().map(|e| {
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let size = format_size(e.size);
let date = format_date(&e.mtime);
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, all.clone(), root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let row_name = name.clone();
let selected_now = move || sel_check.with(|v| v.iter().any(|s| s.name == row_name));
view! {
<div
class="row"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
<For
each=move || 0..shown.get().min(total)
key=|i: &usize| *i
children=move |i: usize| {
let e = &all.entries[i];
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let size = format_size(e.size);
let date = format_date(&e.mtime);
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_click, on_ctx) = entry_callbacks(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let row_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&row_name));
view! {
<div
class="row"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
}
}
}
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class="row-icon".to_string()/>
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
<span class="row-date muted">{date}</span>
</div>
on:contextmenu=on_ctx
title=title
>
{icon_svg(icon, "row-icon")}
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
<span class="row-date muted">{date}</span>
</div>
}
}
}).collect::<Vec<_>>()}
/>
</div>
}
}
@@ -757,20 +857,24 @@ fn menu_items(
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Vec<MenuItem> {
let me_now = me.get();
let loc_now = loc.get();
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`.
let root = me_now
.as_ref()
.and_then(|m| effective_root(&m.roots, &loc_now));
let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false);
let root_name = root.map(|r| r.name.clone()).unwrap_or_default();
let root_id = root.map(|r| r.id);
let allow_writable_shares = me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
// location, where `loc.root_id` is `None`. Read in place: cloning `me`
// would copy its whole root list on every selection change.
let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
let root = me_now
.as_ref()
.and_then(|m| effective_root(&m.roots, &loc_now));
(
root.map(|r| r.mode.is_writable()).unwrap_or(false),
root.map(|r| r.name.clone()).unwrap_or_default(),
root.map(|r| r.id),
me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false),
)
});
match sel {
[] => {
@@ -989,16 +1093,9 @@ fn CtxMenuView(
view! {}.into_any()
};
};
let items = menu_items(
&selected.get(),
me,
loc,
open_file,
refresh,
toast,
set_dialog,
owner.clone(),
);
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
});
view! {
<div
@@ -1148,13 +1245,18 @@ pub fn SelectionActions(
// How many buttons fit (not counting the overflow button).
set_visible_n.set(((avail / step) as usize).max(1));
};
// Measuring forces three layout reflows, so it must not run on every
// selection change. Only how many buttons *fit* is measured, and that
// changes when the group appears, disappears, or switches between the
// empty-area, single and multi action sets — not per selected entry.
let sel_kind = Memo::new(move |_| selected.with(|v| v.len().min(2)));
Effect::new(move |_| {
topbar_ref.get();
group_ref.get();
// Re-measure when the item set (selection, user) or width inputs
// change.
selected.get();
me.get();
// Re-measure when the item set (selection kind, user) or width
// inputs change.
sel_kind.get();
me.track();
recompute();
});
crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
@@ -1167,26 +1269,18 @@ pub fn SelectionActions(
// No resolvable root (e.g. the `#/` "select a folder" empty
// state): there is nowhere to act, so hide the whole group
// rather than show a row of disabled buttons.
let Some(me_now) = me.get() else {
return {
view! {}.into_any()
};
};
if effective_root(&me_now.roots, &loc.get()).is_none() {
let has_root = me.with(|m| {
m.as_ref()
.is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
});
if !has_root {
return {
view! {}.into_any()
};
}
let items = menu_items(
&selected.get(),
me,
loc,
open_file,
refresh,
toast,
set_dialog,
owner.clone(),
);
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
});
let fit = visible_n.get();
let n = if fit >= items.len() {
items.len()
Mweb/src/views/dialogs.rs
@@ -547,12 +547,21 @@ fn PickerDialog(
let toast = use_context::<ToastMsg>().expect("toast context");
// Load the subfolder names of the selected directory whenever it changes.
// Bumped per load, so a slow response for a folder the user has already
// left is dropped instead of replacing the current one.
let load_gen = StoredValue::new(0u64);
Effect::new(move |_| {
let rid = sel_root.get();
let dir = sel_dir.get();
set_loading.set(true);
load_gen.update_value(|g| *g += 1);
let my_gen = load_gen.get_value();
spawn_local(async move {
let subs = match api::list_files(rid, &dir).await {
let resp = api::list_files(rid, &dir).await;
if load_gen.try_get_value() != Some(my_gen) {
return;
}
let subs = match resp {
Ok(r) => r
.entries
.into_iter()
Mweb/src/views/search.rs
@@ -26,15 +26,10 @@ use crate::i18n::{self, k};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::format_size;
use crate::util::{FILE_PAGE, format_size, page_footer};
use crate::views::dialogs::Dialog;
use crate::views::file_view::{FileView, UnsupportedTarget};
/// Name rows rendered at once, and how many more each click of the "show
/// more" row reveals. Small on purpose: rendering a row costs a fixed amount
/// of reactive-graph setup, so the initial paint stays well under a frame and
/// the user pays for more rows only by asking for them.
const FILE_PAGE: usize = 100;
/// Name hits kept for paging. The search keeps running and counting past
/// this; hits beyond it are reported as a plain remainder that cannot be
/// paged to, since holding every hit of a one-character query would grow
@@ -387,6 +382,12 @@ pub fn SearchView(
// remounted. Component-scoped (no static): it is disposed with the view
// and reset on every new search.
let registry: RwSignal<HashMap<Arc<str>, CardState>> = RwSignal::new(HashMap::new());
// How many registry cards are currently collapsed. Kept next to the
// registry instead of derived, so "are they all collapsed?" is a
// comparison rather than a scan of every card signal. Entries are only
// ever added to the registry (never removed individually), so the count
// stays in step.
let collapsed_n = RwSignal::new(0usize);
// The open event source, so Stop and unmount can both reach it.
// Deliberately *not* reactive: the stream outlives the component and
@@ -438,6 +439,7 @@ pub fn SearchView(
match_files_total,
seen: seen_match_files.clone(),
registry,
collapsed_n,
status,
};
let on_stream_event = Callback::new({
@@ -494,6 +496,7 @@ pub fn SearchView(
match_files_total.set(0);
seen_match_files.lock().unwrap().clear();
registry.set(HashMap::new());
collapsed_n.set(0);
status.set(Status::Searching);
// Write the search into the hash without adding a history entry.
if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
@@ -553,19 +556,19 @@ pub fn SearchView(
});
}
// Collapse/expand all cards. Whether the group is fully collapsed is
// derived from the cards themselves (no counter to keep in sync).
// Collapse/expand all cards.
let collapse_all: Callback<(), ()> = {
Callback::new(move |_| {
// Copy the states out before writing them: writing an inner
// signal while the registry's own borrow is held would rely on
// leptos deferring effects to a microtask.
let states: Vec<CardState> = registry.with_untracked(|m| m.values().copied().collect());
let target =
!(!states.is_empty() && states.iter().all(|c| c.collapsed.get_untracked()));
let target = !(!states.is_empty() && collapsed_n.get_untracked() == states.len());
let n = states.len();
for c in states {
c.collapsed.set(target);
}
collapsed_n.set(if target { n } else { 0 });
})
};
@@ -953,7 +956,7 @@ pub fn SearchView(
class="result-group"
style:display=move || {
let show = matches!(scope.get(), Scope::Content | Scope::Both)
&& (!match_files_r.get().is_empty() || status.get() != Status::Idle);
&& (match_files_r.with(|v| !v.is_empty()) || status.get() != Status::Idle);
if show {
"flex"
} else {
@@ -979,10 +982,8 @@ pub fn SearchView(
<span class="grow"></span>
<button class="collapse-all" on:click=move |_| collapse_all.run(())>
{move || {
let all_collapsed = registry.with(|m| {
!m.is_empty()
&& m.values().all(|c| c.collapsed.get())
});
let n = registry.with(|m| m.len());
let all_collapsed = n > 0 && collapsed_n.get() == n;
i18n::t(if all_collapsed {
k::SEARCH_EXPAND_ALL
} else {
@@ -1073,6 +1074,14 @@ pub fn SearchView(
on:click=move |ev: web_sys::MouseEvent| {
ev.stop_propagation();
collapsed.update(|v| *v = !*v);
collapsed_n
.update(|n| {
if collapsed.get_untracked() {
*n += 1;
} else {
*n = n.saturating_sub(1);
}
});
}
>
<span class=move || {
@@ -1157,6 +1166,7 @@ struct FlushCtx {
match_files_total: RwSignal<usize>,
seen: Arc<Mutex<std::collections::HashSet<Arc<str>>>>,
registry: RwSignal<HashMap<Arc<str>, CardState>>,
collapsed_n: RwSignal<usize>,
status: RwSignal<Status>,
}
@@ -1330,7 +1340,10 @@ async fn apply_batch(ctx: &FlushCtx, batch: Vec<SearchEvent>, my_gen: u64) {
// current all-collapsed state, so a live "collapse all"
// is not undone by arriving cards.
let all_collapsed =
!reg.is_empty() && reg.values().all(|c| c.collapsed.get_untracked());
!reg.is_empty() && ctx.collapsed_n.get_untracked() == reg.len();
if all_collapsed {
ctx.collapsed_n.update(|n| *n += 1);
}
lines.truncate(RENDER_LINE_CAP);
reg.insert(
path.clone(),
@@ -1456,46 +1469,6 @@ fn open_match_cb(
})
}
/// Footer under a result list: where the user is in the results, and a button
/// to reveal the next page.
///
/// `shown` of `total` is stated plainly rather than only counting what is
/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
/// "5927 more…" does not. The button appears only while more results are
/// actually retained; past [`RETAIN_FILE_CAP`] the count is all that is left
/// to say, so the footer becomes a plain line.
///
/// Returns nothing at all when everything found is on screen.
fn page_footer(
shown: usize,
retained: usize,
total: usize,
page: usize,
reveal: impl Fn() + 'static,
) -> AnyView {
if total <= shown {
return view! {}.into_view().into_any();
}
let label = i18n::t_fmt2(k::SEARCH_SHOWN_OF, &shown.to_string(), &total.to_string());
let pageable = retained.saturating_sub(shown);
if pageable == 0 {
return view! { <div class="page-foot">{label}</div> }
.into_view()
.into_any();
}
let next = pageable.min(page);
view! {
<div class="page-foot">
<span class="pf-count">{label}</span>
<button class="pf-more" on:click=move |_| reveal()>
{i18n::t_fmt(k::SEARCH_SHOW_MORE, &next.to_string())}
</button>
</div>
}
.into_view()
.into_any()
}
/// Lowercased whitespace-split words of the query.
fn query_words(query: &str) -> Vec<String> {
query.split_whitespace().map(lower).collect()
Mweb/src/views/shell.rs
@@ -67,6 +67,17 @@ pub fn ShellView(
let topbar_right = ws.topbar_right();
let file_content = ws.file_content();
let overlays = ws.overlays();
// The section and the admin flag change far less often than the
// location does. Reading them through memos keeps a folder change from
// remounting the whole content component (and the sidebar's root list).
let section = Memo::new(move |_| loc.with(|l| l.section));
let admin = Memo::new(move |_| is_admin_of(&me.get()));
let cur_root = Memo::new(move |_| {
me.with(|m| {
m.as_ref()
.and_then(|m| loc.with(|l| effective_root(&m.roots, l).map(|r| r.id)))
})
});
// A non-admin cannot open the admin sections. If the URL points at one
// (deep link, hand-typed, or left over from a demotion), redirect to the
@@ -181,7 +192,7 @@ pub fn ShellView(
</div>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Search
class:active=move || section.get() == Section::Search
on:click=move |_| open_section(Section::Search)
>
<Icon name=IconName::Search class="ic-tab".to_string()/>
@@ -193,28 +204,28 @@ pub fn ShellView(
view! {}.into_any()
};
};
let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
m.roots
.iter()
.map(|r| {
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
// The root's path never changes while
// the button exists, so the tooltip is
// not a reactive closure.
let title = if r.path == "." {
"/".to_string()
} else {
r.path.clone()
};
let ro = !r.mode.is_writable();
view! {
<button
class="nav-tab"
class:active=move || {
loc.get().section == Section::Files
&& cur_root == Some(id)
}
title=move || {
if path == "." {
"/".to_string()
} else {
path.clone()
}
section.get() == Section::Files
&& cur_root.get() == Some(id)
}
title=title
on:click=move |_| {
set_nav_open.set(false);
navigate(&Location {
@@ -248,16 +259,16 @@ pub fn ShellView(
<div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Shares
class:active=move || section.get() == Section::Shares
on:click=move |_| open_section(Section::Shares)
>
<Icon name=IconName::Share class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
</button>
<Show when=move || is_admin_of(&me.get())>
<Show when=move || admin.get()>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Users
class:active=move || section.get() == Section::Users
on:click=move |_| open_section(Section::Users)
>
<Icon name=IconName::User class="ic-tab".to_string()/>
@@ -266,7 +277,7 @@ pub fn ShellView(
</Show>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Settings
class:active=move || section.get() == Section::Settings
on:click=move |_| open_section(Section::Settings)
>
<Icon name=IconName::Settings class="ic-tab".to_string()/>
@@ -314,8 +325,7 @@ pub fn ShellView(
}}
<main class="content" class:file-open=move || file_view.get().is_some()>
{move || {
let admin = is_admin_of(&me.get());
match loc.get().section {
match section.get() {
Section::Files => view! {
<Browser
me=me
@@ -345,7 +355,7 @@ pub fn ShellView(
Section::Shares => view! { <SharesView/> }
.into_view()
.into_any(),
Section::Users if admin => {
Section::Users if admin.get() => {
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
}
Section::Settings => view! {