add /api/folders/{id}/ancestors
this API to iterate parent up to the drive root or the shared folder
this will help UI to build the breadcrumb in 1 API call
and to identify the root element (is it a drive users has access to or
a shared folder ?)
ui: now only 1 API call is now required to build the breadcrumb
- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
regarding performances I try to be minimalis on SQL to prevent any perf regression
- remove old set of sharedids and favoriteids (was not functionnal anymore)
- fix date picker (no past selection) in grant
- fix contextmenu close on /files section
restore the legacy display with new element uploaded, when swimlane is in place
as the sort is done by server side just add new element in a "new elements" swimlane.
if user continue to scroll down in cursor/pages and item is found from server,
UI remove it from the new element and restore the position
other option: is user refresh it's page, server will restore the natural order
example: if a folder has many resource, client will use lazy loading
and load next cursor if scroll reached the bottom of the page
purpose: reduce the amount of call to server
Benchmark-gated (BEFORE/AFTER + equivalence/safety gate per change), same
discipline as rounds 2-12. Full write-up in benches/ROUND13.md.
Shipped:
- V1 Grouped views windowed (files route + ResourceList). The grid arm was
the last unwindowed path (trash is grouped-by-default in grid): each
swimlane now feeds its own VirtualList, outer container a flex stack.
vitest gate: 800-item grouped grid mounts <120 .file-item (was 800).
- Q1 get_users_by_ids drops the <=512 KiB avatar image + ui_preferences
JSONB (notification path never reads them). 30-member fan-out 8.60 ->
0.25 ms (34.3x), ~7.7 MB off the wire.
- Q2 Login provisioning is_empty() -> SELECT EXISTS for calendar + address
book (every login). 0.193 -> 0.170 ms, widens with owned-row count.
- Q3 Recent-access prunes only when the upsert inserted (RETURNING xmax=0)
— a re-access can't grow the set. 0.567 -> 0.324 ms (1.75x).
- L1 Locale supported-codes precomputed once vs rebuilt per anonymous
request. 616 -> 17.3 ns (35.7x), 18 -> 1 allocs.
- H1 Duplicate /api TraceLayer removed (global stack already wraps it).
1.86 -> 1.42 us/request, -6 allocs.
- H2 client_ip span field: borrow-only ClientIpDisplay vs owned String.
187 -> 173 ns, -1 alloc.
Not shipped (discipline): the "media hooks read the blob 3x" lead was a
correctness bug, not a perf dup — the raw-path metadata/faces readers
resolve only for local+unencrypted+single-chunk blobs and silently produce
nothing otherwise. Flagged for maintainers; routing through read_blob_bytes
is a correctness fix (perf-neutral-to-negative), not a benchmark-gated
perf change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
Benchmark-gated round (benches/ROUND12.md; every change ships with a
BEFORE/AFTER harness + equivalence gates, one candidate rejected by its
own bench):
DB / query shapes (bench_round12_queries):
- NC sharee search: username-only projection instead of the 21-column row
(incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on
auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index).
- Password login: delete the redundant full-row update_user — create_session
already stamps last_login_at in its own txn (4.45x per login).
- Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login
now compares profile state in memory and issues ZERO queries when nothing
changed (was: full 17-column rewrite per login).
- Refresh rotation: revoke+insert+stamp fused into one transaction via new
rotate_session port method (1.18x).
- WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file +
check(Update) overlapped with tokio::join!, original result precedence
(cold 1.34x).
- Upload quota gate: user-envelope + drive-cap checks fused into ONE
round-trip (check_upload_quotas) — the NC chunked PUT pays this per
chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep
error shapes byte-identical.
CPU / allocs (bench_round12_micro):
- sized_json: pre-sized listing serialization replacing axum Json's 128 B
seed + doubling-realloc chain on files/folder-resources/photos/search
responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical).
- Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware
pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated
byte-identical incl. 304s).
- Media capture-metadata: single-read extraction — nom-exif now parses the
buffer kamadak already read (zero-copy Bytes) and videos open once with a
kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x
cold cache; extraction outputs gated identical incl. the MIME-mislabel
track fallback).
- Chunked-upload session ops: owner gate folded into the operation's own
DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs,
1.28x per chunk).
Blob cache (bench_blob_cache_index + round-3 regression guard):
- CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache
with byte weigher. The mutex serialized every cached chunk read and scaled
NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are
lock-free (2.17x at K=2). Byte budget now enforced by moka (manual
current_size + collect_evictions machinery deleted); eviction listener
unlinks size-evicted files only (Replaced entries keep their file —
gated). Single-flight miss gate unchanged (16 concurrent misses -> 1
fetch re-verified via the round-3 harness).
- put_blob now populates the cache BEFORE the inner backend consumes the
source file (the old order failed 100% of the time — local renames,
S3/Azure delete the source — so the first read after a whole-file put
re-downloaded from the remote); inner-put failure invalidates the entry.
Frontend (vitest gates):
- List-view thumbnails request the 150px icon rendition instead of 400px
preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per
thumbnail across list views); grid keeps preview.
Rejected by its own bench (kept as evidence in bench_round12_micro §2):
- Single-pass compression predicate: the monomorphized And-chain already
costs ~4.6 ns / 0 allocs total; the fused node measured within noise.
New migration: 20260719000000_users_search_trgm.sql (trgm indexes).
Deferred with prepared design: grouped file/grid view virtualization
(single-VirtualRows flatten, the photos pattern) — next round's headline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
Four SPA hot-path fixes, each shipping with a vitest benchmark gate
(verbatim BEFORE replica + equivalence + perf assertion) so CI
re-verifies the win on every run:
- fetchFolderListing invoked onPage after EVERY 200-row page with the
whole accumulated listing, and the files view re-sorts everything per
emission — O(N²/page) main-thread work on large folders. Page one and
the final page always emit; intermediates coalesce to one per 150 ms.
25×200 load: 30.9 → 4.0 ms (7.8x), 65 000 → 5 200 sorted elements.
- selected/favoriteIds/sharedIds (files) and favoriteIds (recent) were
$state<Set>s copied whole on every toggle. Now one SvelteSet each,
mutated in place (the useSelection pattern): 1 000 toggles @ N=5 000
771.9 → 1.9 ms (399x); one-toggle fan-out across 40 mounted rows
40 → 3 re-runs when refining a select-all.
- batchDelete/moveInto awaited one request per item serially and
probed listing.folders.find per id (O(N·M)). Now an id index built
once + mapLimit(6) fan-out, failure semantics preserved: 100-item
delete @ 5 ms RTT 525 → 89 ms (5.9x), 38 825 → 500 probes.
- t() re-split its dotted key and walked the nested dict on every call,
and interpolate regex-scanned strings without placeholders. Resolved
values now memoize per (dict, key) in a WeakMap + a {{ guard:
20k mixed calls 22.7 → 8.6 ms (2.63x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
Every change is benchmark-verified (harness + before/after numbers in
benches/, measured on this branch; reproduction commands in each doc):
DAV / sync-client hot paths
- PROPFIND dead-properties: one = ANY($1) query per 500-child page instead
of one sequential query per child, and indexable `=` predicates instead
of IS NOT DISTINCT FROM (seq scans). 2,000-child folder: 1.07-4.54 s of
DB chatter -> 4-6 ms (258-773x). Applied to native + NC PROPFIND and
both NC REPORT handlers. [benches/DEAD-PROPS.md]
- Folder paging: keyset cursor (name > $last) + new partial index
(folder_id, name) replaces LIMIT/OFFSET full-folder rescan per page.
Full 20k-file walk: 1266 ms -> 77 ms (16.5x). New migration
20260917000000. [benches/PROPFIND-PAGING.md]
- NC chroot / default-drive resolution: moka caches (30 s TTL, explicit
invalidation on drive mutations) for find_default_for_user and the
markerless chroot FolderDto. 2 uncached queries + 2 pool checkouts per
NC/WebDAV/WOPI request -> sub-us moka hit (p50 0.7-3.6 ms -> ~1 us).
[benches/CHROOT-CACHE.md]
- Quota: PROPFINDs whose prop list never names a quota prop skip the
2-query resolution entirely (wants_quota()); the remaining lookups read
2 columns instead of the full auth.users row with its <=512 KiB avatar
(11-16x, p50 3.4 ms -> 0.29 ms). Same narrow read now gates every
upload quota check. [benches/QUOTA-PATH.md]
CPU on the request path
- ZIP exports (folder download, share ZIP, batch download): entries whose
MIME says already-compressed (JPEG/MP4/zip/pdf/...) are Stored instead
of Deflate - deflate ran inline on the tokio writer task at ~41 MB/s
for ~0% size gain. Mixed media corpus: 4.31x wall and CPU, archive size
unchanged. Shared predicate in common::mime_detect. [benches/ZIP-MEDIA.md]
- Compression layers: tower-http's default maps to Brotli QUALITY 11
(verified in brotli-8.0.2 source and empirically: 90 ms per 64 KiB JSON
response, 1.3 s per 700 KiB bundle). Both layers pinned to Precise(4):
99x less CPU for ~15% more bytes. SPA assets are now precompressed at
build time (scripts/precompress.mjs, 77% smaller) and served via
ServeDir::precompressed_br/gzip: 2016x less per-request work, and
clients get the better q11 bytes. [benches/STATIC-PRECOMPRESSED.md]
Batched / cached backend paths [benches/NPLUS1-AND-CACHES.md]
- Content-search ReBAC re-verification: new
AuthorizationEngine::check_files_read_batch (default = old loop;
PgAclEngine override batches drive resolution + reuses role cache).
200 sequential point SELECTs per search -> 1-2 queries.
- Batch-ZIP subtree downloads: drop per-file re-authz + per-file Recent
recording (2 writes/file) for subtree entries already authorized at the
root - mirrors the native folder-download path. ~6,000 statements
removed from a 2,000-file archive.
- CDC chunk manifests: immutable by content address, now moka-cached
(weight-bounded 32 MiB, 60 s TTL, positive-only, invalidated on delete)
- removes one manifest query (p50 0.44-4.4 ms) from every stream,
range and full blob read.
- People tab: grouped COUNT + batched cover lookup instead of dragging
every face row with its 2 KiB embedding (10k faces: 30.4 ms & 21 MB ->
3.8 ms & 1.3 KB, 8.1x); merge() is one set-based UPDATE.
[benches/PEOPLE-LIST.md]
- Photos timeline cursor: raw timestamptz comparison instead of
EXTRACT(EPOCH ...) wrapper + IS NULL OR disjunction - cursor is an
index boundary again, deep scroll stops re-scanning skipped rows.
- Public share landing: one atomic UPDATE ... access_count + 1 (was
SELECT + full-row write-back: racy, lost updates, clobbered concurrent
owner edits) - 3 round-trips -> 2 per visit.
- move_to_trash: dead full-entity SELECT feeding a documented no-op
removed from both branches; dead fields dropped from TrashService.
- NFC normalization: is_nfc_quick fast path skips the decompose/recompose
state machine for the ~100% already-NFC case (every row loaded from PG).
Frontend
- Large folders paint after page one (~200 items) via fetchFolderListing's
new onPage hook instead of waiting for every sequential page.
- Tested-and-reverted (kept for the record): cached Intl.Collator for name
sorts - vitest showed it 2x SLOWER than V8's argument-less localeCompare
fast path (5.6 ms vs 12.1 ms / 5k names). Sort order untouched.
New bench harnesses under examples/ (bench feature): zip_media,
dead_props, chroot_cache, quota_path, people_list, propfind_paging,
static_precompress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBK1RdtzyP6759Muqe1K1w
- add resource kind filter (file, folder, drive) in shared section (localStorage stored)
- add user preferences serverside store
- add client side dotfile filter (show/hide dotfiles) (user perf stored, default: dotfiles are shown)
for security trashed dotfile are always displayed
protection added: if a folder has only hidden items, a notification invite user to display it
if a user rename or create a hidden item, a notification tells it to user
The e2e CI job ran the legacy `scenarios/*` specs against the vanilla `static/`
frontend that upstream has since removed, so it could never pass. Point CI at
this repo's SvelteKit SPA suite (tests/e2e/spa) and wire up what it needs:
- CI: build the release binary with `--features plugins` (the admin Plugins-tab
specs exercise the WASM runtime) and run `npm run test:coverage`, building the
instrumented SPA with COVERAGE=1 VITE_E2E=1 so the server serves the
data-testid-instrumented build the specs drive.
- Coverage harness: target 127.0.0.1 instead of `localhost` (which resolves to
::1 first on CI runners while the server binds IPv4, so readiness never
connected) and poll `/ready` for webServer readiness; tee start-server-spa.sh
output to a log surfaced by an always-run CI step for diagnostics.
- Files page: restore a persistent breadcrumb home link (buildCrumbs returns
only the path folders, so there was no "go home" affordance), and fix the
`?file=` deep-link race where the viewer→URL effect stripped the param before
the listing loaded — a bookmarked preview link now opens the viewer.
All 101 spa specs pass locally.
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
ShareDialog (~15 KB JS) and MoveDialog (~5 KB JS) were statically imported by the
files, favorites, recent and shared routes, so they downloaded on every visit
even if the user never opened a share/move dialog. Convert them to the existing
lazyComponent pattern (as already used for FileViewer/WopiEditor): the chunk is
fetched the first time the dialog is opened.
The Vite manifest confirms both flip from static to isDynamicEntry. This defers
~26 KB raw / ~9.5 KB gzipped (JS + CSS) off the initial load of those four routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Checkpoint of the in-progress frontend toolchain work (Vite pinned to ^6 after
the 7/8 rolldown build break, eslint-plugin-svelte v3 navigation/reactivity
fixes, CI/Dockerfile/manifest updates) together with three UI performance
optimizations (verified on the Vite 6 build):
- Critical CSS: move auth.css/music.css off the global path into their route
chunks (login/device/nextcloud-login, music) -- -25% gzipped critical CSS
(~5.4 KB) on every non-auth/non-music page load.
- relativeTimeAgo: cache the Intl.RelativeTimeFormat (was rebuilt per call, once
per row per render) -- 22.7x faster date formatting in large lists.
- Virtualize search results and grouped trash (list view) via VirtualList -- DOM
rows mounted stay ~constant (~27) instead of O(N) (94.6% fewer for 500 hits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two robustness fixes behind the recurring "folder upload stuck at ~93%" reports.
1. Skip non-regular files up front. A copied s6/runit service tree contains
FIFOs (e.g. supervise/control named pipes) that report a size but BLOCK
FOREVER when the browser reads them — the deterministic ~8-files-short that
no retry/watchdog tweak could fix. uploadBatch/uploadTree now probe each
file's first chunk against a 3 s timeout (partitionReadable), upload only the
readable ones, and report the rest: "N uploaded · M skipped (not regular
files)". Progress runs over the uploadable count, so it reaches 100% instead
of parking at 93% while a lane hangs on a pipe.
2. Auto-reload on a new deploy. svelte.config.js polls _app/version.json
(60 s); the root layout reloads itself when the deployed build changes —
unless an upload is in flight — so an open tab can't keep running stale code
after a rebuild (the recurring "my fix isn't applied" trap).
npm run check: 0 errors, 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With WASM finally enabled, large files (e.g. 32 MB logs) started running the
delta worker, which opens SEVERAL concurrent requests each (overlapping
negotiate batches + chunk PUTs). A few of those running at once blew past the
browser's ~6 connections-per-host limit, so plain uploads of the small files
queued with zero bytes sent until the 30 s stall watchdog cancelled them — the
upload "stuck at 4% / 94%" with N (pending) XHRs in the Network panel. The
session-refresh request got starved too (the spurious 401s).
- Raise the delta-worker threshold to 64 MB (new DELTA_WORKER_MIN_SIZE) so
typical large files take a single-connection plain upload. Delta's payoff is
sub-file dedup on RE-upload; on a first upload it is pure connection overhead.
Client-side instant-hashing still only reads files < 8 MB into memory.
- Lower upload concurrency 3 -> 2, leaving headroom under the 6-connection
budget for session refresh/poll and the occasional delta worker.
npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-uploading a partially-uploaded folder used to surface hundreds of spurious
"already exists" failures, and a file the watchdog aborted (or one the server
committed just before the client gave up) was lost.
Backend — save_file_with_blob_impl (the shared write path for both plain and
by-hash uploads): on a name conflict (23505), if the existing non-trashed file
holds byte-identical content (same folder, same name, same blob hash), return
that file as success instead of erroring. A different-content clash still
conflicts. Re-upload / re-sync becomes a clean no-op for everything already
stored — only the genuinely missing files transfer.
Frontend — uploadWithRetry: each file gets one automatic retry on a transient
failure (quota is never retried). With backend idempotency, retrying an
already-stored file is an instant no-op and a stalled/aborted file gets a real
second chance, so a folder upload self-completes instead of leaving gaps.
cargo test: 448 passed. npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A folder upload with several large files could appear frozen for ~2 min: a few
concurrent uploads stalled and the old 120s per-file timeout neither aborted the
request (leaving zombie XHRs that exhaust the browser's per-host connection pool)
nor recovered quickly.
- uploadFileWithProgress now self-aborts on a stalled connection: the deadline
resets on every upload-progress tick (a slow but *moving* transfer is fine),
and once the body is sent the server gets a fixed window to respond; on a stall
xhr.abort() frees the connection immediately — no zombie, no cascade.
- Lower upload concurrency 4 -> 3 to reduce server contention from large
concurrent uploads.
- The outer per-file timeout is now just a generous backstop for a wedged delta
worker / by-hash request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend:
- POST /api/dedup/check-batch — returns the subset of submitted whole-file
BLAKE3 hashes the caller already owns, in one query (user-scoped,
anti-enumeration via idx_files_blob_hash). Lets a client learn which of N
files it can skip with a single round trip.
(dedup_service::user_owned_blob_references, dedup_handler, routes) + tests.
Frontend — upload pipeline:
- Instant ("by-hash") upload for content the caller already owns: hash every
in-band file, ONE /api/dedup/check-batch, create the owned ones with zero
content bytes, upload only the rest. Covers all sizes below the 8 MB delta
threshold (delta handles larger files). vendor/hashWasm computes the
whole-file BLAKE3 on the main thread.
- Resilient parallel uploads: bounded concurrency (4) + a per-file deadline,
so one stuck/slow/failing file no longer freezes the whole batch — it blocks
only its own lane and times out / is skipped while the rest proceed. Quota
exhaustion stops the run early; partial results are reported ("N uploaded,
M failed").
- Folder uploads (uploadTree) show live bell progress + a final result and go
through the same dedup + parallel pipeline.
- Storage bar ("Almacenamiento") refreshes after uploads/deletes
(session.refresh) instead of showing the stale login value.
Frontend — i18n / UI fixes:
- Fix literal {{count}} and {{percentage}}/{{used}}/{{total}} (param-name
mismatches) in the selection toolbar and storage line; add es strings.
- Remove the underline on user-menu link rows.
Benchmark (uploadStrategies.bench.test.ts) compares baseline / per-file / batch:
the batch collapses N per-file probes into one check (e.g. a WAN 1000-file run
drops from 1700 to 1001 round trips) while matching per-file's byte savings.
Also includes in-progress group virtual-description i18n work present in the
working tree (groups.ts, ResourceList, locale `groups` keys).
Verified: cargo clippy -D warnings (clean), backend 448 tests; frontend
npm run check (clean), 58 unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the backend+frontend delta-upload (content-dedup) feature with a
batch of frontend fixes from this session.
Upload / dedup:
- Client-hashed delta & instant upload (deltaUpload, hashWasm vendor shim)
- Backend dedup batch endpoint (dedup_service, dedup_handler, routes)
- session store owned-hash helpers; unit tests + upload-strategy bench
Frontend UI/UX:
- Colour file-type icons in grid/list (per-type tinted tiles + glyph hue)
- Robust thumbnail fallback; PDFs now show their type icon (backend
generates no PDF thumbnails) instead of a blank tile
- Fix PDF preview: load via a same-origin blob: iframe — the API URL is
blocked by the global X-Frame-Options: DENY in the browser's framed
PDF viewer, matching the existing CSP `frame-src blob:` design
- Groups: localized virtual-group description (no DB schema-note leak),
add nav.groups to the 15 missing locales, fix primary-button contrast
- Repoint --color-text-light → --color-on-accent (was faint grey on accent)
- Nudge the admin role badge off the user-menu header divider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend — tail latency & throughput:
- FileContentCache, image transcode, and search now use moka single-flight
(try_get_with / get_or_load) so N concurrent misses for the same key
collapse to one disk read / transcode / query instead of a thundering herd.
Microbenchmark (128 concurrent on one hot key): 128 loads / p99 ~1023ms
before vs 1 load / p99 ~32ms after.
- DB: configurable per-statement timeout on the primary pool
(OXICLOUD_DB_STATEMENT_TIMEOUT_SECS, default 30; maintenance pool exempt) so
a runaway query can't pin a connection and starve the pool.
- DB: background pool-saturation monitor
(OXICLOUD_DB_POOL_MONITOR_INTERVAL_SECS) that WARNs as the primary pool nears
exhaustion — the early signal before tail latency cliffs.
- mimalloc: set MIMALLOC_PURGE_DELAY=0 (Dockerfile + compose) so freed pages
return to the OS and RSS tracks the live working set; benchmarked on
musl/aarch64 at ~400MB reclaimed vs 0MB with the default.
Frontend — UI / i18n fixes:
- i18n: fix literal "{{count}}" and "{{percentage}}/{{used}}/{{total}}" in the
selection toolbar and storage line — the call sites passed param names that
didn't match the locale placeholders; unify on `count` and pass the storage
template its params. Add es files.selected_count.
- sidebar: hide the drive picker when there's only one drive (the redundant
"Personal" row); remove the coloured left accent on the active nav item.
- logo: stop clipping the cloud's left bulge — viewBox recentred on the cloud's
true bbox with proportional SVG size so it keeps the same rendered scale.
- user menu: drop the default <a> underline on the link rows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defer loading components that are off the initial render path until they
are first needed, keeping them out of the bundle that loads on page entry.
- Add `lazyComponent` composable: a tiny rune-based holder that dynamic-
imports a component on first `load()` and exposes it for `{@const}`
rendering, with the component type inferred from the module so prop/
binding type-checking stays intact.
- CommandPalette: loaded on the first Cmd/Ctrl+K and mounted open via a
new `autoOpen` prop. It was previously imported by AppShell, i.e. in the
initial chunk of every authenticated route (~27 KB). AppShell now owns
the shortcut that triggers the load.
- FileViewer + WopiEditor: loaded when a preview/editor first opens, across
files, recent, favorites and shared-with-me.
- PhotoLightbox / PlacesMap / PeopleView: loaded on first lightbox open or
when the places/people tab is selected (the latter also defers maplibre).
Once loaded a component stays mounted and behaves exactly as before, so
behavior is unchanged on the second use onward.
Last parity gap from the VanillaJS → Svelte migration (issue #500),
frontend-only — the backend endpoints already exist.
- Drag-out download: onItemDragStart now also sets the DataTransfer
`DownloadURL` type, so dragging a row/selection onto the OS desktop
downloads it — a single file directly (GET /api/files/{id}), a folder as a
zip (GET /api/folders/{id}/download?format=zip), and a multi-selection as
one server-zipped archive via the GET twin GET /api/batch/download
(DownloadURL can only point at a GET URL). The zip name is shared with the
in-app batch download via a new batchZipName() helper.
- Folder drag-drop upload: onDrop now walks dropped directory trees with
webkitGetAsEntry/createReader into {file, relativePath} rows and recreates
the tree server-side, instead of dropping only a folder's top-level files.
The recursive-upload core is extracted into uploadTree() and shared with the
folder picker (onUploadFolder), so both paths behave identically. Plain
multi-file drops keep the existing flat fast-path.
Stacked on #502 (shares onItemDragStart). Frontend gate green (svelte-check
0/0, eslint, stylelint, prettier) + 47 Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two parity gaps from the VanillaJS → Svelte migration (issue #500):
1. URL anchor when viewing a file. Opening a file now writes `?file=<id>`
to the URL, so a preview is bookmarkable, reload-restorable, and
Back/Forward open/close it. The viewer is driven from the URL via two
effects (URL→viewer with `untrack` so a user close can't be re-opened;
viewer→URL to drop the param on close with replaceState). Replaces the
write-less, load-only `maybeOpenDeepLink` (the `?file` reader that was
effectively dead because nothing ever set the param).
2. Multi-selection drag ghost. Dragging more than one item now sets a custom
drag image: a stack of the first few rows plus a count badge, reusing the
already-ported but orphaned `.drag-preview`/`.dragged-items`/
`.dragged-items-badge` styles. Previously a multi-item drag showed only the
browser's default single-row ghost with no count.
Also adds `static/geo/` to .prettierignore (the bundled minified world
basemap from #499 is a data asset and must stay byte-faithful — it was
failing `prettier --check`, blocking the gate).
Frontend gate green: svelte-check 0/0, eslint, stylelint, prettier, 47 Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.
Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.
`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.
The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).
Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
Every folder navigation re-downloaded the full listing: `listFolder` cache-busted
with `?t=<ts>` + `Cache-Control: no-store`, so back/forward and re-entering a
folder always paid a full round-trip + payload.
Now the files browser caches listings in memory and serves SWR:
- On navigation it paints a previously-visited folder instantly from cache, then
revalidates with `If-None-Match` (the backend ETag covers folders + files +
favorite/share badges, so it's a faithful validator). Unchanged → 304 with an
empty body; changed → 200 refreshes cache + UI.
- A generation token guards against a slow in-flight response clobbering a newer
navigation; breadcrumbs now resolve independently so they never block the grid
paint.
- Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go
through `reload()`, which drops the cache and refetches fresh — no stale view
after an action.
API layer (`folders.ts`):
- `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch
(200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a
non-conditional wrapper for the move-dialog tree.
- A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` /
`invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out
of the way; revalidation is driven entirely by our own ETag.
Net: instant back/forward navigation, and an unchanged folder revalidates with a
0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit
tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation),
npm run check, and a headless render of the real files route (list + grid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.
Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
`PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
which already covers public links as 'token' grants — same membership the
/grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).
Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
badge sets straight from the listing and updates them optimistically on
favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
the single, authoritative, fetch-free source.
Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
The files browser re-fetched the first 200 favorites AND the first 200 outgoing
shares on every folder navigation (two round-trips each time) just to render the
star / shared badges — work that grew with how much the user browsed, for data
that barely changes.
Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches
once (concurrent callers share one in-flight request) and every later navigation
reads from cache, so browsing costs zero extra requests. Mutations keep the cache
in sync optimistically:
- favorite toggle / batch-favorite → `setFavorite` (revert on failure),
- share creation → `markShared`, wired through a new optional `onshared` callback
on ShareDialog (fired when a grant or public link is created). This also makes
the shared badge appear immediately instead of only after re-navigating.
Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total.
The 200-item ceiling is unchanged from before; the fully-correct fix is per-item
flags on the listing endpoint (a backend change, noted in the store).
Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic
favorite/share, reset) and a headless render of the real files route in list and
grid (virtualization intact, no runtime errors).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of
ResourceList (recent / favorites / shared / shared-with-me / trash / search) and
the main file browser (`files/[...path]`), in both list and grid layouts.
- VirtualList gains a real grid mode: its inner window carries the caller's grid
class (`files-grid-view`) and lays out `columns` cards per windowed row. The
row pitch is auto-measured (grid card height tracks column width via the 4/3
aspect-ratio thumbnail) and re-measured on resize.
- `useVirtualWindow` now distinguishes scroll from resize and exposes a
`resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when
it can actually change.
- `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min`
/ gap so the windowed row count matches the browser's real wrapping exactly;
shared by both grid callers.
- The files browser flattens folders-then-files into one discriminated `entries`
list rendered through VirtualList (list: columns=1; grid: columns from width).
Grouped (swimlane) views stay fully rendered, as before — they're bounded.
ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after:
rows | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
------+-------------+-----------+---------+--------------+------------
1000 | 979→75 ms | 19k→879 | 16→3 MB | 16→17 ms | 0→0
5000 | 4005→67 ms | 95k→879 | 72→3 MB | 37→17 ms | 26→0
20000 |12015→65 ms | 380k→879 |281→7 MB |197→17 ms |1249→19
Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is
~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns,
4/3 thumbnail tiles, cards land at the expected indices). The files browser
shares the same VirtualList path (it can't be mounted headless — it depends on
$app routing/session — so it's validated via svelte-check + the production build
+ the shared, separately-benchmarked component).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6