perf: round 16 — shares-lane & contextMap incremental builders, folder/href/disposition/preview alloc cuts
Finishes the route-level half of the O(N²/page) grouped-listing class ROUND15 fixed inside ResourceList, plus a backend CPU/alloc micro-pack. Every change is benchmark-gated with a hard rollback rule; no PostgreSQL needed for any arm (benches/ROUND16.md). Frontend (vitest): - F1 "My shares" lanes: the `lanes` $derived.by re-bucketed the whole accumulated grant list on every page and every grant edit. SharedLanesBuilder re-emits only the fresh page (fan-out + first-appearance header), reusing untouched lanes' array refs. 25.5x fewer emit calls, 8.8x wall, O(N²/page)→O(N). - F2 contextMap (trash/recent/favorites/shared-with-me): each rebuilt a fresh N-key Map, re-hashing every accumulated id, per page. primeContextPage holds a persistent SvelteMap primed per page (the shipped favoriteIds shape). 25.5x fewer entry calls, 7.2x wall. - Extracted the shared O(1) append test (isAppendExtension); F1's gate re-covers it. Backend (counting-allocator): - M1 folder display constants Arc::from -> intern_display (3 sites): 3 -> 0 allocs/row. - M2 build_content_disposition (every download + Range seek): 3 -> 1 alloc, 6x, 2.67x wall. - M3 nc_href (every NC PROPFIND/REPORT href): Vec+join+format -> one pre-sized buffer, keeping urlencoding::encode (byte-identical). 38 -> 27 allocs/op. - M4 NC preview fileId: collect-then-parse -> borrow-slice parse. 4 -> 0 allocs. Gates: sharedLanes/listContext.bench.test.ts, examples/bench_round16_micro.rs (GATE PASS all sections). Frontend: vitest 331 pass, svelte-check clean. Backend: clippy -D warnings clean, 524 lib tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193NjactJVqfU32gxeJDj8m
This commit is contained in:
@@ -882,9 +882,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||||
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||||
category: std::sync::Arc::from("Folder"),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
// §14 provenance not selected by the trash listing query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
|
||||
@@ -180,9 +180,9 @@ impl PathResolverService {
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
// §14 provenance not selected by this resolver path —
|
||||
// it's used for existence/type discrimination, not
|
||||
// detailed DTO emission. Callers that need provenance
|
||||
|
||||
@@ -1153,18 +1153,39 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
|
||||
.remove(b'`')
|
||||
.remove(b'|')
|
||||
.remove(b'~');
|
||||
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
|
||||
// Fast path: a name whose every byte is an RFC 5987 attr-char needs neither
|
||||
// percent-encoding nor ASCII-fallback filtering ('"' and '\\' are not
|
||||
// attr-chars, so none is substituted), so `filename` and `filename*` are the
|
||||
// name verbatim — one allocation (the header) instead of three.
|
||||
let all_attr_char = name.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
b,
|
||||
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
|
||||
)
|
||||
});
|
||||
if all_attr_char {
|
||||
return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}");
|
||||
}
|
||||
|
||||
let ascii_safe: String = name
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_graphic() || *c == ' ')
|
||||
.map(|c| match c {
|
||||
// Slow path: assemble the header in one pre-sized buffer, writing the ASCII
|
||||
// fallback and the percent-encoded form in place — no throwaway `ascii_safe`
|
||||
// / `encoded` Strings. Sized for the worst case (every byte → %XX) so it
|
||||
// never grows.
|
||||
let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32);
|
||||
out.push_str(disposition);
|
||||
out.push_str("; filename=\"");
|
||||
for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') {
|
||||
out.push(match c {
|
||||
'"' | '\\' => '_',
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
|
||||
});
|
||||
}
|
||||
out.push_str("\"; filename*=UTF-8''");
|
||||
for chunk in utf8_percent_encode(name, RFC5987_SET) {
|
||||
out.push_str(chunk);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Route handlers (free functions) ──────────────────────────────────────────
|
||||
|
||||
@@ -43,12 +43,13 @@ pub async fn handle_preview(
|
||||
) -> impl IntoResponse {
|
||||
// Parse the Nextcloud file ID — the NC app may append an instance suffix
|
||||
// (e.g. "00000326ocnca"), so strip non-digit characters first.
|
||||
let numeric_part: String = params
|
||||
let digit_end = params
|
||||
.file_id
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
let nc_file_id: i64 = match numeric_part.parse() {
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.position(|b| !b.is_ascii_digit())
|
||||
.unwrap_or(params.file_id.len());
|
||||
let nc_file_id: i64 = match params.file_id[..digit_end].parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
|
||||
@@ -10,7 +10,7 @@ use quick_xml::{
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::display_helpers::format_file_size;
|
||||
use crate::application::dtos::display_helpers::{format_file_size, intern_display};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
@@ -443,9 +443,9 @@ fn folder_dto_from_search(
|
||||
created_at: sr.created_at,
|
||||
modified_at: sr.modified_at,
|
||||
is_root: sr.is_root,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
// §14 provenance not selected by search results.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
|
||||
@@ -186,19 +186,27 @@ pub fn nc_collection_href(username: &str, subpath: &str) -> String {
|
||||
pub fn nc_href(username: &str, subpath: &str) -> String {
|
||||
let subpath = subpath.trim_matches('/');
|
||||
let encoded_user = urlencoding::encode(username);
|
||||
if subpath.is_empty() {
|
||||
format!("/remote.php/dav/files/{}/", encoded_user)
|
||||
} else {
|
||||
let encoded_segments: Vec<_> = subpath
|
||||
.split('/')
|
||||
.map(|seg| urlencoding::encode(seg))
|
||||
.collect();
|
||||
format!(
|
||||
"/remote.php/dav/files/{}/{}",
|
||||
encoded_user,
|
||||
encoded_segments.join("/")
|
||||
)
|
||||
// Write the prefix, user and each encoded segment straight into one
|
||||
// pre-sized buffer — avoids the per-segment `Vec<Cow>`, the joined String and
|
||||
// the `format!` result the previous `.map(...).collect().join("/")` allocated
|
||||
// on every NC PROPFIND/REPORT href (mirrors the native `encode_uri_path`).
|
||||
// Keeps `urlencoding::encode` so the emitted bytes are unchanged.
|
||||
const PREFIX: &str = "/remote.php/dav/files/";
|
||||
let mut out = String::with_capacity(PREFIX.len() + encoded_user.len() + subpath.len() + 8);
|
||||
out.push_str(PREFIX);
|
||||
out.push_str(&encoded_user);
|
||||
out.push('/');
|
||||
// No empty-segment filter: `split('/')` on an empty (root) subpath yields a
|
||||
// single "" whose encode is "" — leaving the trailing slash above intact —
|
||||
// and any internal "//" is preserved byte-for-byte, exactly as the old
|
||||
// `split → map → join("/")` produced.
|
||||
for (i, seg) in subpath.split('/').enumerate() {
|
||||
if i > 0 {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&urlencoding::encode(seg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dispatch Nextcloud WebDAV request to the appropriate handler.
|
||||
|
||||
Reference in New Issue
Block a user