server-side listing pages, sniff only the shown page; pager with arrow-key page turns
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mapi-types/src/lib.rs
@@ -99,6 +99,16 @@ pub const P_ROOT: &str = "root";
pub const P_PATH: &str = "path";
/// `?overwrite=true|1` on mutations and uploads.
pub const P_OVERWRITE: &str = "overwrite";
/// Listing order ([`SortKey`]); omitted = by name.
pub const P_SORT: &str = "sort";
/// `?desc=true` reverses the listing order. Folders still come first.
pub const P_DESC: &str = "desc";
/// First listing entry to return, in the sorted order.
pub const P_OFFSET: &str = "offset";
/// Listing entries to return, capped at [`MAX_LIST_ENTRIES`]; omitted = the cap.
pub const P_LIMIT: &str = "limit";
/// `?dirs=true` lists only the subfolders (the folder picker).
pub const P_DIRS: &str = "dirs";
// ---------------------------------------------------------------------------
// Wire enums
@@ -149,6 +159,36 @@ pub enum Op {
Copy,
}
/// What a listing is ordered by ([`P_SORT`]). Folders always sort before
/// files, so a size or date order does not scatter them through the listing.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SortKey {
#[default]
Name,
Size,
Modified,
}
impl SortKey {
pub fn as_str(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"name" => Some(SortKey::Name),
"size" => Some(SortKey::Size),
"modified" => Some(SortKey::Modified),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// Request bodies (client → server)
// ---------------------------------------------------------------------------
@@ -280,16 +320,18 @@ pub struct Entry {
pub kind: FileKind,
}
/// One page of a folder listing.
#[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,
/// Entries in the whole folder, across all pages.
pub total: usize,
/// Position of `entries[0]` in the sorted folder. An offset past the end
/// comes back as the start of the last page.
pub offset: usize,
}
/// Cap on entries in one directory listing.
/// Cap on entries in one listing page.
pub const MAX_LIST_ENTRIES: usize = 10_000;
/// One streamed search result. Each is sent as one SSE event
Mserver/src/api/files.rs
@@ -32,7 +32,7 @@ use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{
DeleteResp, Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, P_ACTION,
P_OVERWRITE, SaveResp, UploadResp,
P_OVERWRITE, SaveResp, SortKey, UploadResp,
};
/// Upper bound for the in-memory text endpoint (preview, later editor).
@@ -43,10 +43,10 @@ pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
// ---------------------------------------------------------------------------
/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
/// route lists the directory; `?action=download|preview|content` serve the
/// item itself.
/// route lists the directory, and the listing params pick the page;
/// `?action=download|preview|content` serve the item itself.
///
/// The field names are the shared [`P_ACTION`] / [`P_FORMAT`] constants.
/// The field names are the shared `P_*` constants.
/// `#[serde(rename)]` only takes a literal, so that link cannot be written
/// here; `tests::query_fields_are_the_shared_constants` pins it instead.
#[derive(Deserialize, Default)]
@@ -55,6 +55,16 @@ pub struct FileQuery {
action: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default)]
sort: SortKey,
#[serde(default)]
desc: bool,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
dirs: bool,
}
// ---------------------------------------------------------------------------
@@ -99,7 +109,14 @@ pub async fn file_get(
Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
Some(a) if a == api_types::ACTION_THUMB => thumb(state, auth, root_id, req_rel).await,
_ => {
let json = list_inner(state, auth, root_id, req_rel).await?;
let opts = fs::ListOpts {
sort: query.sort,
desc: query.desc,
offset: query.offset,
limit: query.limit,
dirs_only: query.dirs,
};
let json = list_inner(state, auth, root_id, req_rel, opts).await?;
Ok(json.into_response())
}
}
@@ -171,6 +188,7 @@ async fn list_inner(
auth: AuthUser,
root_id: i64,
req_rel: String,
opts: fs::ListOpts,
) -> Result<Json<FilesResp>, ApiError> {
// A file share's root is the file itself: there is nothing to list.
if auth.share.as_ref().is_some_and(|s| s.is_file) {
@@ -180,13 +198,13 @@ async fn list_inner(
let server_root = state.root.clone();
let root_rel = root.path.clone();
// Resolve and list in one blocking hop: both are filesystem work.
let (entries, truncated) = blocking(move || {
let resp = blocking(move || {
let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?;
fs::list_dir(&full)
fs::list_dir(&full, opts)
})
.await?;
Ok(Json(FilesResp { entries, truncated }))
Ok(Json(resp))
}
// ---------------------------------------------------------------------------
@@ -1457,7 +1475,7 @@ fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError
#[cfg(test)]
mod tests {
use super::*;
use api_types::{ACTION_DOWNLOAD, P_FORMAT};
use api_types::{ACTION_DOWNLOAD, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_SORT};
use axum::http::Uri;
/// The publish step must never replace a file that appeared after the
@@ -1497,7 +1515,7 @@ mod tests {
assert!(!tmp.exists());
}
/// A rename of `P_ACTION` or `P_FORMAT` without the matching field
/// A rename of a `P_*` constant without the matching field
/// rename would silently stop the server from reading the parameter the
/// client sends. This builds the query string from the constants and
/// runs the real extractor over it.
@@ -1509,6 +1527,15 @@ mod tests {
let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
assert_eq!(q.action.as_deref(), Some(ACTION_DOWNLOAD));
assert_eq!(q.format.as_deref(), Some("zip"));
let list_uri: Uri =
format!("/f/1?{P_SORT}=size&{P_DESC}=true&{P_OFFSET}=5&{P_LIMIT}=10&{P_DIRS}=true")
.parse()
.unwrap();
let q: FileQuery = AxumQuery::try_from_uri(&list_uri).unwrap().0;
assert_eq!(
(q.sort, q.desc, q.offset, q.limit, q.dirs),
(SortKey::Size, true, 5, Some(10), true)
);
// The same constants drive the hand-rolled readers on the POST path.
assert_eq!(action_param(&uri).as_deref(), Some(ACTION_DOWNLOAD));
Mserver/src/fs.rs
@@ -2,13 +2,14 @@
//! `<server-root>/<user-root>/<requested-path>`, canonicalizes it and verifies
//! the result is still inside the user's root (blocks `..` and symlink escapes).
use std::cmp::Ordering;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use chrono::DateTime;
use api_types::{Entry, FileKind};
use api_types::{Entry, FileKind, FilesResp, SortKey};
use crate::error::ApiError;
@@ -115,9 +116,19 @@ fn ensure_within(base: &Path, p: &Path) -> Result<(), 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> {
/// Which slice of a folder [`list_dir`] returns, and in what order.
#[derive(Clone, Copy, Debug, Default)]
pub struct ListOpts {
pub sort: SortKey,
pub desc: bool,
pub offset: usize,
/// Clamped to `1..=MAX_LIST_ENTRIES`; `None` = the cap.
pub limit: Option<usize>,
pub dirs_only: bool,
}
/// List one page of a directory (blocking — call via spawn_blocking).
pub fn list_dir(dir: &Path, opts: ListOpts) -> Result<FilesResp, 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,
@@ -140,22 +151,61 @@ pub fn list_dir(dir: &Path) -> Result<(Vec<Entry>, bool), FsError> {
rows.push((entry, e.path()));
}
// Pass 2: metadata, which the sort needs (folders first).
// Pass 2: metadata, which the sort needs.
describe_rows(&mut rows, fill_meta);
if opts.dirs_only {
rows.retain(|(e, _)| e.is_dir);
}
// After pass 2, so the parallel fan-out cannot affect the order.
rows.sort_by(|(a, _), (b, _)| cmp_entries(a, b, opts.sort, opts.desc));
let total = rows.len();
let limit = opts
.limit
.unwrap_or(api_types::MAX_LIST_ENTRIES)
.clamp(1, api_types::MAX_LIST_ENTRIES);
// Past the end happens when a delete empties the last page.
let offset = if opts.offset < total {
opts.offset
} else {
total.saturating_sub(1) / limit * limit
};
let mut page: Vec<(Entry, PathBuf)> = rows.into_iter().skip(offset).take(limit).collect();
// Pass 3: the content sniff, only for the page. It reads file data, which
// on a spinning disk costs a seek per file.
describe_rows(&mut page, fill_kind);
Ok(FilesResp {
entries: page.into_iter().map(|(e, _)| e).collect(),
total,
offset,
})
}
// Folders first, then case-insensitive name. Sorting after pass 2 means
// the parallel fan-out cannot affect the order.
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);
/// The listing order: folders first, then `key`. Folders have no
/// meaningful size, so a size order keeps them in name order.
fn cmp_entries(a: &Entry, b: &Entry, key: SortKey, desc: bool) -> Ordering {
let dir = |o: Ordering| if desc { o.reverse() } else { o };
b.is_dir.cmp(&a.is_dir).then_with(|| match key {
SortKey::Name => dir(cmp_name(a, b)),
SortKey::Size if a.is_dir => cmp_name(a, b),
SortKey::Size => dir(a.size.cmp(&b.size)).then_with(|| cmp_name(a, b)),
// RFC 3339 UTC with a fixed width, so bytewise order is
// chronological order.
SortKey::Modified => dir(a.mtime.cmp(&b.mtime)).then_with(|| cmp_name(a, b)),
})
}
Ok((rows.into_iter().map(|(e, _)| e).collect(), truncated))
/// Case-insensitive, with the raw name as the tie-breaker so two names that
/// differ only in case keep a stable order.
fn cmp_name(a: &Entry, b: &Entry) -> Ordering {
fn lower(s: &str) -> impl Iterator<Item = char> + '_ {
s.chars().flat_map(char::to_lowercase)
}
lower(&a.name)
.cmp(lower(&b.name))
.then_with(|| a.name.cmp(&b.name))
}
// ---------------------------------------------------------------------------
@@ -1024,7 +1074,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, ListOpts::default()).unwrap().entries;
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"]);
@@ -1039,15 +1089,112 @@ mod tests {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
list_dir(&root.join("missing")),
list_dir(&root.join("missing"), ListOpts::default()),
Err(FsError::NotFound)
));
assert!(matches!(
list_dir(&root.join("file.txt")),
list_dir(&root.join("file.txt"), ListOpts::default()),
Err(FsError::NotADirectory)
));
}
fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> Entry {
Entry {
name: name.to_string(),
is_dir,
size,
mtime: mtime.to_string(),
kind: FileKind::Binary,
}
}
fn sorted(mut v: Vec<Entry>, key: SortKey, desc: bool) -> Vec<String> {
v.sort_by(|a, b| cmp_entries(a, b, key, desc));
v.into_iter().map(|e| e.name).collect()
}
/// Paging slices this order, so a wrong order puts a file on the wrong
/// page.
#[test]
fn sort_keeps_folders_first_in_every_order() {
let v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
];
for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
for desc in [false, true] {
assert_eq!(
sorted(v.clone(), key, desc)[0],
"Zdir",
"{key:?} desc={desc}"
);
}
}
}
#[test]
fn sort_orders_by_the_chosen_key() {
let v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
];
let s = |key, desc| sorted(v.clone(), key, desc);
assert_eq!(s(SortKey::Name, false), ["a.txt", "b.txt", "c.txt"]);
assert_eq!(s(SortKey::Name, true), ["c.txt", "b.txt", "a.txt"]);
assert_eq!(s(SortKey::Size, false), ["b.txt", "c.txt", "a.txt"]);
assert_eq!(s(SortKey::Modified, false), ["c.txt", "b.txt", "a.txt"]);
}
#[test]
fn list_dir_pages_the_sorted_listing() {
let t = T::new();
let d = t.root.join("paged");
std::fs::create_dir_all(d.join("sub")).unwrap();
for i in 0..7 {
std::fs::write(d.join(format!("f{i}")), vec![b'x'; i]).unwrap();
}
let page = |offset, limit, dirs_only| {
let opts = ListOpts {
sort: SortKey::Size,
desc: true,
offset,
limit: Some(limit),
dirs_only,
};
let r = list_dir(&d, opts).unwrap();
let names: Vec<String> = r.entries.into_iter().map(|e| e.name).collect();
(names, r.total, r.offset)
};
assert_eq!(
page(0, 3, false),
(vec!["sub".into(), "f6".into(), "f5".into()], 8, 0)
);
assert_eq!(
page(3, 3, false),
(vec!["f4".into(), "f3".into(), "f2".into()], 8, 3)
);
// Past the end: the last page.
assert_eq!(page(50, 3, false), (vec!["f1".into(), "f0".into()], 8, 6));
// Exactly one past a full last page, as after deleting its entries.
assert_eq!(page(8, 4, false).2, 4);
assert_eq!(page(0, 3, true), (vec!["sub".into()], 1, 0));
// A zero limit is one entry, not a division by zero.
assert_eq!(page(2, 0, false), (vec!["f5".into()], 8, 2));
assert_eq!(page(0, usize::MAX, false).0.len(), 8);
let empty = t.root.join("empty");
std::fs::create_dir_all(&empty).unwrap();
let opts = ListOpts {
offset: 30,
limit: Some(10),
..ListOpts::default()
};
let r = list_dir(&empty, opts).unwrap();
assert_eq!((r.entries.len(), r.total, r.offset), (0, 0, 0));
}
#[cfg(unix)]
#[test]
fn list_dir_reports_broken_symlink_as_empty_file() {
@@ -1055,7 +1202,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, ListOpts::default()).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "broken");
assert!(!entries[0].is_dir);
@@ -1500,7 +1647,7 @@ mod tests {
}
std::fs::create_dir(big.join("a-subdir")).unwrap();
let (entries, _) = list_dir(&big).unwrap();
let entries = list_dir(&big, ListOpts::default()).unwrap().entries;
assert_eq!(entries.len(), n + 1);
// Every row must agree with what a single-threaded read of that same
@@ -1555,7 +1702,9 @@ 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"), ListOpts::default())
.unwrap()
.entries;
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_files.rs
@@ -1258,27 +1258,63 @@ async fn archive_does_not_follow_symlinks_out_of_the_root() {
}
#[tokio::test]
async fn listing_is_capped_and_reports_truncation() {
async fn listing_is_paged_in_the_requested_order() {
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 dir = env.file("paged");
std::fs::create_dir_all(dir.join("sub")).unwrap();
for i in 0..25 {
std::fs::write(dir.join(format!("f{i:02}")), vec![b'x'; i]).unwrap();
}
let r = admin.get(&root_path("big")).await;
let names = |j: &serde_json::Value| -> Vec<String> {
j["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap().to_string())
.collect()
};
// No params: the whole folder by name, folders first.
let j = admin.get(&root_path("paged")).await.json();
assert_eq!(
(j["total"].as_u64(), j["offset"].as_u64()),
(Some(26), Some(0))
);
assert_eq!(names(&j).len(), 26);
assert_eq!(names(&j)[0], "sub");
let r = admin
.get(&format!(
"{}?sort=size&desc=true&offset=10&limit=10",
root_path("paged")
))
.await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(
j["entries"].as_array().unwrap().len(),
api_types::MAX_LIST_ENTRIES
(j["total"].as_u64(), j["offset"].as_u64()),
(Some(26), Some(10))
);
assert_eq!(j["truncated"], true);
// Index 0 is "sub", then f24 down to f00.
let want: Vec<String> = (6..=15).rev().map(|i| format!("f{i:02}")).collect();
assert_eq!(names(&j), want);
// An offset past the end returns the last page.
let j = admin
.get(&format!("{}?offset=999&limit=10", root_path("paged")))
.await
.json();
assert_eq!(j["offset"].as_u64(), Some(20));
assert_eq!(names(&j).len(), 6);
let j = admin
.get(&format!("{}?dirs=true", root_path("paged")))
.await
.json();
assert_eq!(names(&j), ["sub"]);
assert_eq!(j["total"].as_u64(), Some(1));
}
#[tokio::test]
Mweb/app.css
@@ -676,12 +676,6 @@ 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;
@@ -2388,6 +2382,7 @@ mark.hl-hit {
as another result row. */
.page-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
@@ -2401,7 +2396,8 @@ mark.hl-hit {
font-variant-numeric: tabular-nums;
}
.page-foot .pf-more {
.page-foot .pf-more,
.pf-btn {
appearance: none;
padding: 5px 12px;
border: 1px solid var(--border);
@@ -2412,11 +2408,84 @@ mark.hl-hit {
cursor: pointer;
}
.page-foot .pf-more:hover {
.page-foot .pf-more:hover,
.pf-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.pf-pages {
display: flex;
align-items: center;
gap: 4px;
margin-right: auto;
}
.pf-btn {
min-width: 30px;
padding: 5px 8px;
font-variant-numeric: tabular-nums;
}
.pf-btn.current {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.pf-btn:disabled {
opacity: 0.4;
cursor: default;
}
.pf-goto {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.pf-goto input {
width: 72px;
padding: 5px 8px;
}
/* An arrow-key page turn is loading. Fixed at the top, so it also shows over
the preview. The delay keeps fast turns from flashing. */
.page-busy {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
overflow: hidden;
z-index: 250;
pointer-events: none;
animation: page-busy-in 0s 200ms both;
}
.page-busy::after {
content: "";
display: block;
width: 30%;
height: 100%;
background: var(--accent);
animation: upload-indeterminate 1.2s ease-in-out infinite;
}
body:has(.page-busy) {
cursor: progress;
}
@keyframes page-busy-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Content matches ----------------------------------------------------- */
.match-card {
Mweb/src/api.rs
@@ -18,9 +18,10 @@ use api_types::{
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateAppPassword,
CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE,
PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish, Root, SEARCH, SHARE,
SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare, UpdateUser,
Mutation, P_ACTION, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH, P_Q,
P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish,
Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, SortKey, UnlockShare,
UpdateUser,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
@@ -328,11 +329,32 @@ fn files_url(root_id: i64, path: &str) -> String {
format!("{base}{}", share_suffix())
}
/// One page of a folder, in the given order.
pub fn list_files(
root_id: i64,
path: &str,
sort: SortKey,
desc: bool,
offset: usize,
limit: usize,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
request("GET", files_url(root_id, path), None::<()>)
let url = append_query(
&files_url(root_id, path),
&format!(
"{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
sort.as_str()
),
);
request("GET", url, None::<()>)
}
/// The subfolders of a folder, by name.
pub fn list_dirs(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
let url = append_query(&files_url(root_id, path), &format!("{P_DIRS}=true"));
request("GET", url, None::<()>)
}
/// Create an empty file. `path` is relative to the root (may contain
Mweb/src/i18n.rs
@@ -357,7 +357,6 @@ i18n_keys! {
LANGUAGE = "language" => "Language",
LANGUAGE_DESC = "language_desc" => "The interface language. Automatic uses your browser's language.",
LINK_COPIED = "link_copied" => "Link copied to clipboard",
LIST_TRUNCATED = "list_truncated" => "Only the first {} entries are shown.",
LIST_VIEW = "list_view" => "List view",
LOAD_LATEST = "load_latest" => "Load latest",
LOADING = "loading" => "Loading…",
@@ -409,6 +408,13 @@ i18n_keys! {
OR_LABEL = "or_label" => "or",
OVERWRITE = "overwrite" => "Overwrite",
OVERWRITE_QUESTION = "overwrite_question" => "The following files already exist. Overwrite them?",
PAGE_FIRST = "page_first" => "First page",
PAGE_GOTO = "page_goto" => "Page",
PAGE_LAST = "page_last" => "Last page",
PAGE_NEXT = "page_next" => "Next page",
PAGE_PREV = "page_prev" => "Previous page",
PAGE_SIZE = "page_size" => "Entries per page",
PAGE_SIZE_N = "page_size_n" => "{} per page",
PASSKEY_ADDED = "passkey_added" => "Passkey added",
PASSKEY_ADDED_ON = "passkey_added_on" => "Added {}",
PASSKEY_LABEL = "passkey_label" => "Name for this passkey",
@@ -916,10 +922,6 @@ const DE: &[(&str, &str)] = &[
"Die Sprache der Benutzeroberfläche. „Automatisch“ verwendet die Sprache Ihres Browsers.",
),
("link_copied", "Link in die Zwischenablage kopiert"),
(
"list_truncated",
"Nur die ersten {} Einträge werden angezeigt.",
),
("list_view", "Listenansicht"),
("load_latest", "Neueste Version laden"),
("loading", "Wird geladen…"),
@@ -992,6 +994,13 @@ const DE: &[(&str, &str)] = &[
"overwrite_question",
"Die folgenden Dateien existieren bereits. Überschreiben?",
),
("page_first", "Erste Seite"),
("page_goto", "Seite"),
("page_last", "Letzte Seite"),
("page_next", "Nächste Seite"),
("page_prev", "Vorherige Seite"),
("page_size", "Einträge pro Seite"),
("page_size_n", "{} pro Seite"),
("passkey_added", "Passkey hinzugefügt"),
("passkey_added_on", "Hinzugefügt {}"),
("passkey_label", "Name für diesen Passkey"),
@@ -1601,10 +1610,6 @@ const FR: &[(&str, &str)] = &[
"La langue de l'interface. « Automatique » utilise la langue de votre navigateur.",
),
("link_copied", "Lien copié dans le presse-papiers"),
(
"list_truncated",
"Seules les {} premières entrées sont affichées.",
),
("list_view", "Vue en liste"),
("load_latest", "Charger la version la plus récente"),
("loading", "Chargement…"),
@@ -1680,6 +1685,13 @@ const FR: &[(&str, &str)] = &[
"overwrite_question",
"Les fichiers suivants existent déjà. Les écraser ?",
),
("page_first", "Première page"),
("page_goto", "Page"),
("page_last", "Dernière page"),
("page_next", "Page suivante"),
("page_prev", "Page précédente"),
("page_size", "Entrées par page"),
("page_size_n", "{} par page"),
("passkey_added", "Clé d'accès ajoutée"),
("passkey_added_on", "Ajoutée {}"),
("passkey_label", "Nom de cette clé d'accès"),
Mweb/src/util.rs
@@ -249,33 +249,7 @@ impl ViewMode {
}
}
/// What a listing is ordered by. Folders always sort before files, so a
/// size or date order does not scatter them through the listing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SortKey {
Name,
Size,
Modified,
}
impl SortKey {
pub fn as_str(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"name" => Some(SortKey::Name),
"size" => Some(SortKey::Size),
"modified" => Some(SortKey::Modified),
_ => None,
}
}
}
pub use api_types::SortKey;
/// A sort key plus its direction, persisted like [`ViewMode`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -286,8 +260,6 @@ pub struct SortSpec {
impl Default for SortSpec {
fn default() -> Self {
// The server's own order, so the first paint after a fetch does not
// reshuffle.
Self {
key: SortKey::Name,
asc: true,
@@ -314,66 +286,10 @@ impl SortSpec {
let dir = if self.asc { "asc" } else { "desc" };
storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
}
/// Order `entries` in place.
///
/// Name is compared case-insensitively, with the raw name as the
/// tie-breaker so two names differing only in case keep a stable order.
/// Folders have no meaningful size, so they fall back to the name order
/// among themselves.
pub fn apply(self, entries: &mut [crate::api::Entry]) {
match self.key {
SortKey::Name => entries.sort_by(|a, b| {
a.is_dir
.cmp(&b.is_dir)
.reverse()
.then_with(|| cmp_name(a, b).dir(self.asc))
}),
SortKey::Size => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
if a.is_dir {
cmp_name(a, b)
} else {
a.size
.cmp(&b.size)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
}
})
}),
SortKey::Modified => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
// RFC 3339 UTC with a fixed width, so bytewise order is
// chronological order.
a.mtime
.cmp(&b.mtime)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
})
}),
}
}
}
fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
}
trait OrderingDir {
fn dir(self, asc: bool) -> std::cmp::Ordering;
}
impl OrderingDir for std::cmp::Ordering {
fn dir(self, asc: bool) -> std::cmp::Ordering {
if asc { self } else { self.reverse() }
}
}
/// Entries rendered at once in a listing, and how many more each click of
/// the "show more" row reveals. Rendering a row costs a fixed amount of
/// Rows rendered at once: the search's "show more" step, and the folder
/// listing's default page size. 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;
@@ -422,75 +338,159 @@ pub fn page_footer(
.into_any()
}
#[cfg(test)]
mod tests {
use super::*;
/// Page sizes the folder listing offers.
pub const PAGE_SIZES: [usize; 4] = [50, 100, 200, 500];
fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
crate::api::Entry {
name: name.to_string(),
is_dir,
size,
mtime: mtime.to_string(),
kind: if is_dir {
api_types::FileKind::Dir
} else {
api_types::FileKind::Binary
},
const PAGE_SIZE_KEY: &str = "fbng.page_size";
pub fn load_page_size() -> usize {
storage_get(PAGE_SIZE_KEY)
.and_then(|v| v.parse().ok())
.filter(|n| PAGE_SIZES.contains(n))
.unwrap_or(FILE_PAGE)
}
pub fn save_page_size(n: usize) {
storage_set(PAGE_SIZE_KEY, &n.to_string());
}
/// The page buttons to show, 0-based: the first, the last, and two on each
/// side of `cur`. `None` is a gap.
pub fn page_numbers(cur: usize, pages: usize) -> Vec<Option<usize>> {
let mut out = Vec::new();
for p in 0..pages {
if p == 0 || p + 1 == pages || p.abs_diff(cur) <= 2 {
out.push(Some(p));
} else if out.last() != Some(&None) {
out.push(None);
}
}
out
}
/// This order is what the browser's arrow keys and Shift+click ranges
/// walk, so a wrong order moves the wrong file.
#[test]
fn sort_keeps_folders_first_in_every_order() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
];
for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
for asc in [true, false] {
SortSpec { key, asc }.apply(&mut v);
assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
}
/// Footer under a server-paged list. Empty when the list fits the smallest
/// page size, since then no control does anything.
pub fn pager(
offset: usize,
shown: usize,
total: usize,
size: usize,
go: Callback<usize>,
set_size: Callback<usize>,
) -> AnyView {
use crate::i18n::k;
if total <= PAGE_SIZES[0] {
return view! {}.into_view().into_any();
}
let cur = offset / size;
let pages = total.div_ceil(size);
let last = pages - 1;
let label = crate::i18n::t_fmt2(
k::SEARCH_SHOWN_OF,
&format!("{}–{}", offset + 1, offset + shown),
&total.to_string(),
);
let nav = move |glyph: &'static str, title: &'static str, to: usize, off: bool| {
view! {
<button
class="pf-btn"
disabled=off
title=crate::i18n::tr(title)
aria-label=crate::i18n::tr(title)
on:click=move |_| go.run(to)
>
{glyph}
</button>
}
};
let numbers = page_numbers(cur, pages)
.into_iter()
.map(|p| match p {
Some(p) => view! {
<button
class="pf-btn"
class:current=p == cur
aria-current=(p == cur).then_some("page")
on:click=move |_| go.run(p)
>
{p + 1}
</button>
}
.into_any(),
None => view! { <span class="pf-gap">"…"</span> }.into_any(),
})
.collect_view();
view! {
<div class="page-foot">
<span class="pf-count">{label}</span>
<nav class="pf-pages">
{nav("«", k::PAGE_FIRST, 0, cur == 0)}
{nav("‹", k::PAGE_PREV, cur.saturating_sub(1), cur == 0)}
{numbers}
{nav("›", k::PAGE_NEXT, (cur + 1).min(last), cur == last)}
{nav("»", k::PAGE_LAST, last, cur == last)}
</nav>
<label class="pf-goto">
{crate::i18n::tr(k::PAGE_GOTO)}
<input
type="number"
min="1"
max=pages.to_string()
prop:value=(cur + 1).to_string()
on:change=move |ev| {
if let Ok(n) = event_target_value(&ev).trim().parse::<usize>() {
go.run(n.clamp(1, pages) - 1);
}
}
/>
</label>
<select
class="sort-select"
aria-label=crate::i18n::tr(k::PAGE_SIZE)
on:change=move |ev| {
if let Some(n) = select_value(&ev).and_then(|v| v.parse().ok()) {
set_size.run(n);
}
}
>
{PAGE_SIZES
.map(|n| view! {
<option value=n.to_string() selected=n == size>
{crate::i18n::t_fmt(k::PAGE_SIZE_N, &n.to_string())}
</option>
})
.to_vec()}
</select>
</div>
}
.into_view()
.into_any()
}
#[test]
fn sort_orders_by_the_chosen_key() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
];
let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
SortSpec {
key: SortKey::Name,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
SortSpec {
key: SortKey::Name,
asc: false,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
#[cfg(test)]
mod tests {
use super::*;
SortSpec {
key: SortKey::Size,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
SortSpec {
key: SortKey::Modified,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
#[test]
fn page_numbers_keep_the_ends_and_the_neighbours() {
let n = |cur, pages| page_numbers(cur, pages);
assert_eq!(n(0, 1), [Some(0)]);
assert_eq!(n(2, 5), [Some(0), Some(1), Some(2), Some(3), Some(4)]);
assert_eq!(n(0, 10), [Some(0), Some(1), Some(2), None, Some(9)]);
assert_eq!(
n(5, 10),
[
Some(0),
None,
Some(3),
Some(4),
Some(5),
Some(6),
Some(7),
None,
Some(9)
]
);
}
#[test]
Mweb/src/views/browser.rs
@@ -19,19 +19,35 @@ use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::uploads::{self, OnConflict};
use crate::util::{
FILE_PAGE, LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer,
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, load_page_size, pager,
save_page_size,
};
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).
/// One page of a folder listing, in display order. Behind an `Arc` in
/// [`ListState`] so reading the signal never copies the entries.
#[derive(Debug, PartialEq)]
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,
/// Entries in the whole folder.
total: usize,
/// Position of `entries[0]` in the whole folder.
offset: usize,
}
/// An arrow-key page turn in flight.
#[derive(Clone)]
struct PageStep {
/// +1 for the next page, -1 for the previous one.
dir: isize,
/// Started in the media preview, which looks for a previewable file.
preview: bool,
/// The page the turn started on, to go back to when it finds nothing.
origin: usize,
selected: Vec<Entry>,
/// The fetch that loads the target page. Any other fetch drops the step.
fetch_gen: u64,
}
#[derive(Clone, Debug, PartialEq)]
@@ -100,27 +116,19 @@ 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);
// The page shown, 0-based. The fetch does not track it: it sets it from
// the server's answer, so tracking would loop. A page change calls the
// fetch itself.
let page = RwSignal::new(0usize);
let page_size = RwSignal::new(load_page_size());
// What the last fetch listed. Another folder or order starts at the
// first page; a refresh keeps the page.
let list_key = StoredValue::new(None::<(i64, String, SortSpec)>);
// Set when the arrow keys step past the edge of the page.
let page_step = RwSignal::new(None::<PageStep>);
// Bumped per fetch. A response whose generation is no longer current
// belongs to a folder the user has already navigated away from.
let fetch_gen = StoredValue::new(0u64);
// The listing in display order. Everything downstream indexes into
// this, not into the fetched order: Shift+click ranges and the arrow
// keys walk what the user sees. A Memo, so a re-sort costs one pass per
// change, not one per render.
let sorted = Memo::new(move |_| match list_state.get() {
ListState::Entries(list) => {
let mut entries = list.entries.clone();
sort.get().apply(&mut entries);
ListState::Entries(Arc::new(Listing {
entries,
truncated: list.truncated,
}))
}
other => other,
});
// The selected names as a set: every row asks "am I selected?" on each
// selection change, which is a scan of the selection per row otherwise.
@@ -135,7 +143,7 @@ pub fn Browser(
// Drop selected entries that disappeared from the listing (deleted,
// renamed, …).
Effect::new(move |_| {
sorted.with(|st| {
list_state.with(|st| {
let ListState::Entries(list) = st else {
return;
};
@@ -180,63 +188,25 @@ pub fn Browser(
},
);
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order.
//
// Only previewable files: the editor needs the arrow keys for its
// caret, and a folder has nothing to preview.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and a
// modifier means a browser shortcut (Alt+Left is history back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get() else {
return;
};
let ListState::Entries(list) = sorted.get() else {
return;
};
let shown: Vec<&Entry> = list
.entries
.iter()
.filter(|e| !e.is_dir && preview_kind(e.kind).is_some())
.collect();
let Some(at) = shown.iter().position(|e| e.name == cur.name) else {
return;
};
let next = at as isize + step;
if next < 0 || next as usize >= shown.len() {
return;
}
let e = shown[next as usize];
// The selection follows the shown file, so closing the preview puts
// focus and the action bar on the file that was actually open.
let show_preview = move |e: &Entry, root_id: i64| {
let Some(kind) = preview_kind(e.kind) else {
return;
};
ev.prevent_default();
// The selection follows the shown file, so closing the preview puts
// focus and the action bar on the file that was actually open.
set_selected.set(vec![e.clone()]);
open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id: cur.root_id,
path: join_path(&loc.get().path, &e.name),
root_id,
path: join_path(&loc.get_untracked().path, &e.name),
name: e.name.clone(),
},
kind,
)));
});
};
// Fetch the current directory whenever the location (or user) changes.
// Fetch the current page whenever the location, user, order or page
// size changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
return;
@@ -245,12 +215,19 @@ pub fn Browser(
let Some(root) = effective_root(&me.roots, &loc) else {
return;
};
let spec = sort.get();
let size = page_size.get();
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("/");
let key = Some((root_id, path_str.clone(), spec));
if list_key.get_value() != key {
list_key.set_value(key);
page.set(0);
}
let offset = page.get_untracked() * size;
// On a public share page the (synthetic) "me" comes from the share
// itself, not from a session — refreshing /me would be wrong there.
let is_share = loc.share_token.is_some();
@@ -258,17 +235,22 @@ pub fn Browser(
let refresh_key = me_refresh_key;
let set_refresh_key = set_me_refresh_key;
spawn_local(async move {
let resp = api::list_files(root_id, &path_str).await;
let resp = api::list_files(root_id, &path_str, spec.key, !spec.asc, offset, size).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,
}))),
Ok(r) => {
page.set(r.offset / size);
let list = Arc::new(Listing {
entries: r.entries,
total: r.total,
offset: r.offset,
});
set_list_state.set(ListState::Entries(list));
}
Err(e) => {
if !is_share
&& matches!(e.status(), Some(403) | Some(404))
@@ -287,6 +269,97 @@ pub fn Browser(
});
});
Effect::new(move |_| fetch.run(()));
// Load the page in `dir` for the arrow keys. False when there is none.
let advance = move |dir: isize, preview: bool, origin: usize, selected: Vec<Entry>| {
let ListState::Entries(list) = list_state.get_untracked() else {
return false;
};
let more = if dir > 0 {
list.offset + list.entries.len() < list.total
} else {
list.offset > 0
};
if more {
page.update(|p| *p = p.saturating_add_signed(dir));
untrack(|| fetch.run(()));
page_step.set(Some(PageStep {
dir,
preview,
origin,
selected,
fetch_gen: fetch_gen.get_value(),
}));
}
more
};
let start_turn = move |dir: isize, preview: bool| {
advance(dir, preview, page.get_untracked(), selected.get_untracked());
};
let turn_page = Callback::new(move |dir: isize| start_turn(dir, false));
// Finish a page turn once its page has loaded. The preview skips pages
// without a previewable file, and returns to the start page when it runs
// out of pages or was closed meanwhile.
//
// ponytail: one request per skipped page, so a long run of
// non-previewable files is slow. If that matters, ask the server for
// "the next previewable file after X" instead.
Effect::new(move |_| {
let st = list_state.get();
if st == ListState::Loading {
return;
}
let Some(step) = page_step.get_untracked() else {
return;
};
page_step.set(None);
let ListState::Entries(list) = st else {
return;
};
if step.fetch_gen != fetch_gen.get_value() {
return;
}
let back = move |step: PageStep| {
set_selected.set(step.selected);
page.set(step.origin);
untrack(|| fetch.run(()));
};
if !step.preview {
let idx = if step.dir > 0 {
0
} else {
list.entries.len() - 1
};
set_selected.set(vec![list.entries[idx].clone()]);
// The new page renders after this returns.
request_animation_frame(move || {
if let Some(c) = listing_container() {
focus_item(&c, idx);
}
});
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get_untracked() else {
back(step);
return;
};
let mut files = list.entries.iter().filter(|e| previewable(e));
let e = if step.dir > 0 {
files.next()
} else {
files.next_back()
};
match e {
Some(e) => show_preview(e, cur.root_id),
None => {
let (dir, origin) = (step.dir, step.origin);
if !advance(dir, true, origin, step.selected.clone()) {
back(step);
}
}
}
});
{
let w = set_browser_refresh;
let f = fetch;
@@ -295,6 +368,44 @@ pub fn Browser(
});
}
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order. Past the edge of the
// page it loads the neighbouring page and continues there.
//
// Only previewable files: the editor needs the arrow keys for its
// caret, and a folder has nothing to preview.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and a
// modifier means a browser shortcut (Alt+Left is history back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get() else {
return;
};
let ListState::Entries(list) = list_state.get() else {
return;
};
let files: Vec<&Entry> = list.entries.iter().filter(|e| previewable(e)).collect();
let Some(at) = files.iter().position(|e| e.name == cur.name) else {
return;
};
ev.prevent_default();
match at.checked_add_signed(step).and_then(|i| files.get(i)) {
Some(e) => show_preview(e, cur.root_id),
None => start_turn(step, true),
}
});
// Delete asks to delete the selection — the same confirm dialog as the
// context menu's Delete. Not while something else owns the keyboard: a
// dialog, the full-page file view, the context menu, or a text field.
@@ -365,7 +476,7 @@ pub fn Browser(
if !on_body {
return;
}
let ListState::Entries(all) = sorted.get_untracked() else {
let ListState::Entries(all) = list_state.get_untracked() else {
return;
};
let Some(container) = listing_container() else {
@@ -381,9 +492,9 @@ pub fn Browser(
&all,
idx,
grid,
shown,
selected,
set_selected,
turn_page,
);
}
None => {
@@ -403,7 +514,7 @@ pub fn Browser(
Effect::new(move |was_open: Option<bool>| {
let open = file_view.get().is_some();
if was_open == Some(true) && !open {
let ListState::Entries(all) = sorted.get_untracked() else {
let ListState::Entries(all) = list_state.get_untracked() else {
return open;
};
if let Some(idx) = selected.with_untracked(|sel| anchor_index(&all, sel)) {
@@ -465,8 +576,10 @@ pub fn Browser(
});
})
}),
sorted,
shown,
list_state,
page,
page_size,
turn_page,
view_mode,
set_view_mode,
sort,
@@ -496,6 +609,10 @@ pub fn Browser(
owner=ctx_owner
/>
</div>
// Outside `.browser`, which is hidden while a preview is open.
<Show when=move || page_step.with(Option::is_some)>
<div class="page-busy"></div>
</Show>
}
}
@@ -543,8 +660,10 @@ fn file_browser(
loc: ReadSignal<Location>,
// Upload the files of a drop into this folder; `None` when read-only.
drop_upload: Option<Callback<web_sys::DragEvent>>,
list_state: Memo<ListState>,
shown: RwSignal<usize>,
list_state: ReadSignal<ListState>,
page: RwSignal<usize>,
page_size: RwSignal<usize>,
turn_page: Callback<isize>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
sort: ReadSignal<SortSpec>,
@@ -816,12 +935,12 @@ fn file_browser(
.into_view()
.into_any();
}
let total = list.entries.len();
let truncated = list.truncated;
let (offset, shown, total) =
(list.offset, list.entries.len(), list.total);
let body = entries_view(
view_mode.get(),
list,
shown,
turn_page,
root_id,
loc,
is_rw,
@@ -833,26 +952,19 @@ fn file_browser(
set_ctx,
open_file,
);
// Paging footer and, below it, the server-side cap
// notice — both belong to the listing, not to one
// of the two layouts.
let go = Callback::new(move |p: usize| {
page.set(p);
fetch.run(());
});
// The new page holds the entry that was first on screen.
let set_size = Callback::new(move |n: usize| {
save_page_size(n);
page.set(offset / n);
page_size.set(n);
});
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>
})}
{pager(offset, shown, total, page_size.get_untracked(), go, set_size)}
}
.into_view()
.into_any()
@@ -1009,8 +1121,6 @@ fn selection_click(
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();
@@ -1138,7 +1248,8 @@ fn entry_callbacks(
/// horizontal scrolling).
///
/// Shift extends from the anchor, exactly as Shift+click does, so the two
/// can be mixed in one selection.
/// can be mixed in one selection. The selection covers one page, so Shift
/// stops at the page edge; without Shift the key turns the page.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn arrow_key(
ev: &web_sys::KeyboardEvent,
@@ -1146,9 +1257,9 @@ fn arrow_key(
all: &Arc<Listing>,
idx: usize,
grid: bool,
shown: RwSignal<usize>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
turn_page: Callback<isize>,
) {
// A modifier the handler does not implement belongs to the browser
// (Alt+Left is history back, Ctrl+Arrow jumps words in a text field).
@@ -1167,11 +1278,17 @@ fn arrow_key(
ev.prevent_default();
let total = all.entries.len();
let target = idx as isize + step;
if target < 0 || target as usize >= total {
return;
}
let target = target as usize;
let target = match idx.checked_add_signed(step) {
Some(t) if t < total => t,
// Down from above a short last row: the last entry, not the next page.
Some(_) if step > 0 && idx / cols < (total - 1) / cols => total - 1,
_ => {
if !ev.shift_key() {
turn_page.run(step.signum());
}
return;
}
};
let cur = selected.get_untracked();
let next = if ev.shift_key() {
@@ -1182,20 +1299,7 @@ fn arrow_key(
if let Some(next) = next {
set_selected.set(next);
}
if target < shown.get_untracked() {
focus_item(container, target);
return;
}
// Reveal whole pages until the target is rendered. The focus call has
// to wait for that render, hence the animation frame.
shown.update(|n| {
while *n <= target {
*n += FILE_PAGE;
}
});
let container = container.clone();
request_animation_frame(move || focus_item(&container, target));
focus_item(container, target);
}
/// The rendered listing container. There is one listing per page at most,
@@ -1227,7 +1331,7 @@ fn plain_dblclick(ev: &MouseEvent) -> bool {
fn entries_view(
mode: ViewMode,
all: Arc<Listing>,
shown: RwSignal<usize>,
turn_page: Callback<isize>,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
@@ -1258,12 +1362,10 @@ fn entries_view(
let dir = loc.get_untracked().path;
view! {
<div class=container>
// 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 items.
// Keyed by index: a listing never changes in place, a new
// page is a new listing.
<For
each=move || 0..shown.get().min(total)
each=move || 0..total
key=|i: &usize| *i
children=move |i: usize| {
let e = &all.entries[i];
@@ -1321,7 +1423,7 @@ fn entries_view(
else {
return;
};
arrow_key(&ev, &container, &all_keys, i, grid, shown, selected, set_selected);
arrow_key(&ev, &container, &all_keys, i, grid, selected, set_selected, turn_page);
}
on:contextmenu=on_ctx
title=title
@@ -1939,6 +2041,10 @@ fn SelButton(item: MenuItem) -> impl IntoView {
// Menu actions
// ---------------------------------------------------------------------------
fn previewable(e: &Entry) -> bool {
!e.is_dir && preview_kind(e.kind).is_some()
}
fn join_path(dir: &[String], name: &str) -> String {
if dir.is_empty() {
name.to_string()
Mweb/src/views/dialogs.rs
@@ -618,17 +618,12 @@ fn PickerDialog(
load_gen.update_value(|g| *g += 1);
let my_gen = load_gen.get_value();
spawn_local(async move {
let resp = api::list_files(rid, &dir).await;
let resp = api::list_dirs(rid, &dir).await;
if load_gen.try_get_value() != Some(my_gen) {
return;
}
let subs = match resp {
Ok(r) => r
.entries
.into_iter()
.filter(|e| e.is_dir)
.map(|e| e.name)
.collect(),
Ok(r) => r.entries.into_iter().map(|e| e.name).collect(),
Err(e) => {
show_error(toast, e.to_string());
Vec::new()