From cf8f0c9a365adce8773088b486a4bf760ef90f1b Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Tue, 14 Jul 2026 21:43:11 +0200 Subject: [PATCH 1/6] auto redirect to idp if no other authentication method is configured --- frontend/src/routes/login/+page.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 6527a482..df48ec0f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -323,6 +323,19 @@ setupAvailable = !status.initialized; if (setupAvailable) mode = 'setup'; + // 4) Auto-redirect: when OIDC is the only auth method, skip the login page. + // Guard against loops: if the IdP returned ?error=, fall through to the UI. + if ( + oidc.enabled && + oidc.password_login_enabled === false && + oidc.authorize_endpoint && + !setupAvailable && + !page.url.searchParams.has('error') + ) { + window.location.replace(oidc.authorize_endpoint); + return; + } + booting = false; }); From 1acac1d6994826b2534698037da04c5611f54f6b Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Tue, 14 Jul 2026 22:14:19 +0200 Subject: [PATCH 2/6] test(frontend): verify OIDC-only auto-redirect on the login page Covers the four-way guard added in e5f8610d: redirects when OIDC is the sole login method, and stays on the form/setup screen when password login is still enabled, the IdP just bounced with ?error=, or the server hasn't been set up yet. --- frontend/src/routes/login/page.test.ts | 65 +++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/login/page.test.ts b/frontend/src/routes/login/page.test.ts index d230fca7..fa347c00 100644 --- a/frontend/src/routes/login/page.test.ts +++ b/frontend/src/routes/login/page.test.ts @@ -1,4 +1,4 @@ -import { it, expect, vi, beforeEach } from 'vitest'; +import { it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; const { goto, pageState, session } = vi.hoisted(() => { @@ -53,6 +53,24 @@ beforeEach(() => { m(auth.getAuthStatus).mockResolvedValue({ initialized: true }); }); +// jsdom's `Location` can't be spied on in place (its setters trigger +// "not implemented" navigation errors), so swap the whole object for a +// stub around each test that needs to observe `window.location.replace`. +const originalLocation = window.location; +let replaceSpy: ReturnType; + +beforeEach(() => { + replaceSpy = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, replace: replaceSpy } + }); +}); + +afterEach(() => { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); +}); + it('logs in and redirects', async () => { m(auth.login).mockResolvedValue({ user: { id: '1' } }); render(LoginPage); @@ -179,3 +197,48 @@ it('renders an SSO sign-in link when an OIDC provider is configured', async () = const sso = await screen.findByTestId('login-oidc-btn'); expect(sso.getAttribute('href')).toBe('https://idp.test/auth'); }); + +it('auto-redirects to the IdP when OIDC is the only login method', async () => { + m(auth.getOidcProviders).mockResolvedValue({ + enabled: true, + password_login_enabled: false, + authorize_endpoint: '/api/auth/oidc/authorize' + }); + render(LoginPage); + await waitFor(() => expect(replaceSpy).toHaveBeenCalledWith('/api/auth/oidc/authorize')); +}); + +it('does not auto-redirect when password login is also enabled', async () => { + m(auth.getOidcProviders).mockResolvedValue({ + enabled: true, + password_login_enabled: true, + authorize_endpoint: '/api/auth/oidc/authorize' + }); + render(LoginPage); + await screen.findByTestId('login-form'); + expect(replaceSpy).not.toHaveBeenCalled(); +}); + +it('does not auto-redirect after the IdP already returned an error (loop guard)', async () => { + pageState.url = new URL('http://localhost/login?error=access_denied'); + m(auth.getOidcProviders).mockResolvedValue({ + enabled: true, + password_login_enabled: false, + authorize_endpoint: '/api/auth/oidc/authorize' + }); + render(LoginPage); + await screen.findByTestId('login-form'); + expect(replaceSpy).not.toHaveBeenCalled(); +}); + +it('does not auto-redirect during first-run setup', async () => { + m(auth.getAuthStatus).mockResolvedValue({ initialized: false }); + m(auth.getOidcProviders).mockResolvedValue({ + enabled: true, + password_login_enabled: false, + authorize_endpoint: '/api/auth/oidc/authorize' + }); + render(LoginPage); + await screen.findByTestId('login-setup-form'); + expect(replaceSpy).not.toHaveBeenCalled(); +}); From 6215f37bf62556da82f868f4050468d32b27348a Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 15 Jul 2026 21:03:17 +0200 Subject: [PATCH 3/6] fix: dedupe IdP auto-redirect and fix CSP/bfcache bug on the SPA shell - Extract tryAutoRedirectToIdp() on the login page so onMount's redirect guard and the post-setup flow share one check instead of drifting. - Fix a real bug: 304 Not Modified responses carry no Content-Type, so is_html misclassified them and attached the strict headerless CSP, which browsers merge into the cached 200's effective headers and defeat the SPA's hash-based CSP on revalidated repeat visits. - Add Cache-Control: no-store on the SPA shell to opt out of bfcache, preventing a pre-deploy shell (stale inline hydration script + CSP hash) from being resurrected byte-for-byte across the OIDC redirect's full-page navigations. - Add a manual, human-run SSO-only script/env (ports 8090/1081) since the automated oidc.hurl suite keeps password login enabled and never exercises the auto-redirect guard. --- .gitignore | 2 + frontend/src/routes/login/+page.svelte | 28 ++-- justfile | 13 ++ src/main.rs | 34 ++++- tests/common/server-with-oidc-only.env | 79 +++++++++++ tests/oidc/fake_idp/server.js | 7 +- tests/oidc/run-manual-sso-only.sh | 176 +++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 tests/common/server-with-oidc-only.env create mode 100755 tests/oidc/run-manual-sso-only.sh diff --git a/.gitignore b/.gitignore index 689de6d5..763da59d 100644 --- a/.gitignore +++ b/.gitignore @@ -100,7 +100,9 @@ tests/e2e/test-results/ tests/e2e/blob-report/ tests/e2e/playwright/.cache/ tests/e2e/playwright/.auth/ + tests/webdav/storage-litmus/ +tests/oidc-manual/ tests/caldav/storage/ tests/caldav/.venv/ tests/caldav/__pycache__/ diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index df48ec0f..d7166627 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -249,6 +249,19 @@ } } + // Shared by onMount step 4 and onSetup: true + navigates away iff OIDC is + // the only login method. Centralised so the guard can't drift between the + // two call sites (only the `?error=` loop-guard, checked at onMount time, + // doesn't apply post-setup — a freshly created admin can't have bounced + // off the IdP yet). + function tryAutoRedirectToIdp(): boolean { + if (oidc.enabled && oidc.password_login_enabled === false && oidc.authorize_endpoint) { + window.location.replace(oidc.authorize_endpoint); + return true; + } + return false; + } + async function onSetup(e: SubmitEvent) { e.preventDefault(); setupError = ''; @@ -260,10 +273,14 @@ busy = true; try { await setupAdmin(setupEmail, setupPassword); - setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.'); setupEmail = setupPassword = setupConfirm = ''; // Admin now exists — fold the setup affordance away and return to login. setupAvailable = false; + // OIDC-only: the login page would immediately redirect on the next + // visit anyway — skip the "you can now sign in" detour and forward + // straight to the IdP instead of leaving a dead-end local form. + if (tryAutoRedirectToIdp()) return; + setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.'); setTimeout(() => { mode = 'login'; setupSuccess = ''; @@ -325,14 +342,7 @@ // 4) Auto-redirect: when OIDC is the only auth method, skip the login page. // Guard against loops: if the IdP returned ?error=, fall through to the UI. - if ( - oidc.enabled && - oidc.password_login_enabled === false && - oidc.authorize_endpoint && - !setupAvailable && - !page.url.searchParams.has('error') - ) { - window.location.replace(oidc.authorize_endpoint); + if (!setupAvailable && !page.url.searchParams.has('error') && tryAutoRedirectToIdp()) { return; } diff --git a/justfile b/justfile index 345fed49..e8870ce0 100644 --- a/justfile +++ b/justfile @@ -180,6 +180,12 @@ front-design: # --config server-with-oidc.env so the # api and webdav suites stay on the # OIDC-off config. +# * tests/oidc/run-manual-sso-only.sh — NOT part of this chain (see +# `oidc-manual-sso-only` below): a +# http://localhost:8090/files/1bf4713c-891e-46fb-acf0-b10231fe32c8 human-run check that OIDC-as-only- +# login-method actually redirects a +# real browser, which the curl-driven +# suite above can't observe. # # Same chain runs in CI under the `api-test` job in # .github/workflows/ci.yml; keep the order in sync so a local pass means @@ -228,6 +234,13 @@ test-caldav: cargo build ./tests/caldav/run-pycaldav.sh +# Manual, human-run: launches OxiCloud with OIDC as the ONLY login method +# (fake IdP on :1081, server on :8090) and waits for you to eyeball the +# /login auto-redirect in a real browser. Not part of `just api-test` — +# there's no automated assertion here, it's a visual check. Ctrl-C to stop. +#oidc-manual-sso-only: +# bash tests/oidc/run-manual-sso-only.sh + # --------------------------------------------------------------------------- # SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes # drive its dev server, build, lint and tests. diff --git a/src/main.rs b/src/main.rs index d2d59541..f57fb08a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -923,12 +923,44 @@ async fn run() -> Result<(), Box> { next: axum::middleware::Next, ) -> axum::response::Response { let mut res = next.run(req).await; + // A 304 Not Modified carries no entity headers (no Content-Type) since + // there's no body — `is_html` would read `None` and misclassify it as + // "not html", attaching the strict headerless CSP below. Browsers merge + // a 304's headers into the cached document's effective response, so + // that stray header would then stack with (and defeat) the SPA's own + // hash-based `` CSP on every revalidated repeat visit — this was + // a real bug (see git blame): a browser tab reopened at `/login` after + // the first, freshly-fetched visit got permanently stuck behind the + // boot spinner because its now-conditionally-cached `200` picked up an + // extra hash-less `script-src 'self'` header from the 304 that + // revalidated it, blocking the app's own inline hydration script. + // Nothing to add on a 304 regardless — its headers must only carry + // caching metadata, never a fresh policy decision. + if res.status() == axum::http::StatusCode::NOT_MODIFIED { + return res; + } let is_html = res .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.starts_with("text/html")); - if !is_html { + if is_html { + // `no-store` (not just `no-cache`) on the SPA shell: Chrome/Firefox/ + // Safari all treat `no-store` as an explicit opt-out of the + // back-forward cache (bfcache), which is a full in-memory snapshot + // of the page that bypasses HTTP revalidation entirely — `no-cache` + // alone does NOT prevent it. Without this, a shell instance loaded + // before a deploy can be resurrected byte-for-byte (old inline + // hydration script + old CSP hash) after navigating away and back — + // e.g. the OIDC login round-trip's two full-page navigations — and + // the resurrected page's old CSP `` no longer matches assets + // referenced by the current build, leaving the app permanently + // stuck behind the boot spinner until a hard reload. + res.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + } else { res.headers_mut().insert( axum::http::header::CONTENT_SECURITY_POLICY, HeaderValue::from_static( diff --git a/tests/common/server-with-oidc-only.env b/tests/common/server-with-oidc-only.env new file mode 100644 index 00000000..47f04d5a --- /dev/null +++ b/tests/common/server-with-oidc-only.env @@ -0,0 +1,79 @@ +# OxiCloud test-server env file for the MANUAL SSO-only auto-redirect test. +# +# Layered on top of server-with-oidc.env: identical EXCEPT +# OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true, which makes OIDC the ONLY +# login method (magic-link is already hard-disabled whenever OIDC is +# enabled, per the "OIDC master rule" — see example.env). This is the +# config the frontend's login-page auto-redirect guard +# (frontend/src/routes/login/+page.svelte) actually fires under — +# tests/common/server-with-oidc.env keeps password login on, so the +# automated tests/oidc/oidc.hurl suite never exercises the redirect. +# +# Used by tests/oidc/run-manual-sso-only.sh (human-run, not CI). Distinct +# ports (8090 / IdP 1081) so it doesn't collide with a concurrently running +# `just api-test` (which uses 8087 / IdP 1080) or a local `cargo run` dev +# server. +# +# `--config` makes the binary read THIS file verbatim — there is no +# auto-merge with server.env, so every variable the server needs has +# to be repeated here (same rationale as server-with-oidc.env). + +# ── Shared test config (mirrors server.env) ──────────────────────────────── +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=false +OXICLOUD_NEXTCLOUD_ENABLED=true +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,oxicloud::infrastructure::services::oidc_service=info,oxicloud::application::services::auth_application_service=info" + +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 + +# Mock SMTP — kept wired even though magic-link login is disabled under the +# OIDC master rule, so the invite/mail transport doesn't 503 unconfigured. +OXICLOUD_SMTP_MOCK=true +OXICLOUD_SMTP_HOST=localhost +OXICLOUD_SMTP_PORT=25 +OXICLOUD_SMTP_FROM='OxiCloud Tests ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# ── OIDC client wired at the fake-idp sidecar (SSO-only) ─────────────────── +# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the +# issuer at the root URL; discovery is at /.well-known/openid-configuration +# under it. Update the `clients[0].client_id` field there in tandem if you +# rename the client. +OXICLOUD_OIDC_ENABLED=true +OXICLOUD_OIDC_ISSUER_URL=http://localhost:1081 +OXICLOUD_OIDC_CLIENT_ID=oxicloud-test +OXICLOUD_OIDC_CLIENT_SECRET=test-client-secret-not-used-in-prod +# The IdP redirects back to this exact URL after auto-approving; must +# match the OxiCloud server's actual host + port. +OXICLOUD_OIDC_REDIRECT_URI=http://localhost:8090/api/auth/oidc/callback +OXICLOUD_OIDC_SCOPES="openid profile email" +# Frontend redirect target after a successful callback. The backend +# appends `/login?oidc_code=…` to this base, so the value here is the +# SPA origin only. +OXICLOUD_OIDC_FRONTEND_URL=http://localhost:8090 +OXICLOUD_OIDC_AUTO_PROVISION=true +OXICLOUD_OIDC_PROVIDER_NAME=MockSSO-Only +# Group-to-role mapping — same fake-idp claim shape as server-with-oidc.env. +OXICLOUD_OIDC_ADMIN_GROUPS=admin-users + +# The single flag that makes OIDC the ONLY login method: is_password_login_allowed() +# is exactly `!disable_password_login` (auth_application_service.rs). Magic-link +# is already hard-disabled whenever OIDC is enabled, regardless of AUTH_METHODS. +OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true + +OXICLOUD_REQUIRE_VERIFIED_EMAIL=false diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index f54adf5f..a7c0ea27 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -67,7 +67,12 @@ const configuration = { { client_id: 'oxicloud-test', client_secret: 'test-client-secret-not-used-in-prod', - redirect_uris: ['http://localhost:8087/api/auth/oidc/callback'], + // 8087: automated tests/oidc/oidc.hurl suite. 8090: human-run + // tests/oidc/run-manual-sso-only.sh (SSO-only auto-redirect check). + redirect_uris: [ + 'http://localhost:8087/api/auth/oidc/callback', + 'http://localhost:8090/api/auth/oidc/callback', + ], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', diff --git a/tests/oidc/run-manual-sso-only.sh b/tests/oidc/run-manual-sso-only.sh new file mode 100755 index 00000000..02f426aa --- /dev/null +++ b/tests/oidc/run-manual-sso-only.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# MANUAL, human-run SSO-only auto-redirect check. NOT part of `just +# api-test` / CI — there is no automated assertion here, this launches a +# real server + real fake IdP and waits for a human to open a browser and +# eyeball the behavior. +# +# What it proves that the automated suites can't: +# * tests/oidc/oidc.hurl drives the OIDC flow via curl against +# tests/common/server-with-oidc.env, which keeps password login +# enabled — the frontend's login-page auto-redirect guard +# (frontend/src/routes/login/+page.svelte) never fires there. +# * The Vitest coverage for that guard (frontend/src/routes/login/ +# page.test.ts) mocks getOidcProviders() and stubs +# window.location.replace — it proves the logic is right, not that a +# real browser actually navigates away when the backend is genuinely +# OIDC-only. +# +# This script starts OxiCloud with tests/common/server-with-oidc-only.env +# (OIDC is the ONLY login method) against the same fake IdP used by the +# automated suite, then blocks until you Ctrl-C. +# +# Ports (deliberately distinct from tests/oidc/run.sh's 8087 / 1080, so +# this can run alongside `just api-test` or a local `cargo run` dev +# server): OxiCloud on 8090, fake IdP on 1081. +# +# Prerequisites: docker, cargo, node >= 20, npm. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +OIDC_DIR="$REPO_ROOT/tests/oidc" +FAKE_IDP_DIR="$OIDC_DIR/fake_idp" + +SERVER_PORT=8090 +IDP_PORT=1081 +base_url="http://localhost:$SERVER_PORT" +oidc_issuer="http://localhost:$IDP_PORT" + +# ── Helpers ──────────────────────────────────────────────────────────────── +log() { echo "[oidc-manual] $*"; } +die() { echo "[oidc-manual] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 0.5 + done +} + +# ── Fake-IdP process management (mirrors tests/oidc/run.sh) ──────────────── +kill_fake_idp() { + pkill -f "tests/oidc/fake_idp/server.js" 2>/dev/null || true + pkill -f "node.*server.js" 2>/dev/null || true + if command -v lsof >/dev/null 2>&1; then + local pids + pids=$(lsof -ti :"$IDP_PORT" 2>/dev/null || true) + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + fi + fi +} + +# ── Teardown (always runs on exit) ───────────────────────────────────────── +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + log "Stopping fake-idp..." + kill_fake_idp + bash "$COMMON/stop-db.sh" || true +} +trap cleanup EXIT + +# ── 1. Postgres ──────────────────────────────────────────────────────────── +bash "$COMMON/spawn-db.sh" + +# ── 2. Fake IdP (Node) ───────────────────────────────────────────────────── +log "Installing fake-idp dependencies..." +if [[ -f "$FAKE_IDP_DIR/package-lock.json" ]]; then + (cd "$FAKE_IDP_DIR" && npm ci --silent --no-audit --no-fund) +else + (cd "$FAKE_IDP_DIR" && npm install --silent --no-audit --no-fund) +fi + +log "Sweeping any orphan fake-idp processes from prior runs..." +kill_fake_idp +sleep 0.3 + +log "Starting fake-idp on port $IDP_PORT..." +FAKE_IDP_ISSUER="$oidc_issuer" FAKE_IDP_PORT="$IDP_PORT" \ + node "$FAKE_IDP_DIR/server.js" > /tmp/fake-idp-manual.log 2>&1 & +log "Waiting for fake-idp discovery endpoint..." +wait_for_http "$oidc_issuer/.well-known/openid-configuration" 30 +log "fake-idp is ready (logs: /tmp/fake-idp-manual.log)" + +# ── 3. Load shared server env (SSO-only) ──────────────────────────────────── +set -a +# shellcheck source=../common/server-with-oidc-only.env +source "$COMMON/server-with-oidc-only.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc-manual/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 3.5. Ensure the SPA is built (static-dist/) ──────────────────────────── +# The auto-redirect only fires against the production SPA bundle; without +# it `resolve_static_path` falls back to OXICLOUD_STATIC_PATH=./static, +# which doesn't have it. The frontend is a pure CSR SPA (prerender=false in +# +layout.ts) — there is only ONE shell file, static-dist/index.html, that +# every route (including /login) falls back to. Check for that, not a +# per-route file (one never gets emitted; checking for it would force a +# full rebuild on every single invocation). +DIST_DIR="$REPO_ROOT/static-dist" +if [[ ! -f "$DIST_DIR/index.html" ]]; then + log "Building SvelteKit SPA (static-dist/index.html missing)..." + (cd "$REPO_ROOT/frontend" \ + && npm ci --silent --no-audit --no-fund \ + && npm run build) || die "Frontend build failed; static-dist/ is required" +fi + +# ── 4. Start OxiCloud server with OIDC-only config ────────────────────────── +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac +fi + +log "Starting OxiCloud server with OIDC-only config on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-with-oidc-only.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 5. Hand off to the human ──────────────────────────────────────────────── +cat < must NOT redirect (loop guard); shows the login form. + * First run / no admin yet (already handled above by wiping + storage) -> shows the setup wizard, not a redirect, until + you complete it once via the IdP. + +Press Ctrl-C to stop the server and tear down. +========================================================== + +EOF + +wait "$SERVER_PID" From 494dcb84867415e27186db196306b285771a5525 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Fri, 17 Jul 2026 00:06:53 +0200 Subject: [PATCH 4/6] fix/address flaky wall clock based test by doing best of 3 --- .../api/endpoints/deltaUpload.hash.test.ts | 84 ++++++++++++------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/frontend/src/lib/api/endpoints/deltaUpload.hash.test.ts b/frontend/src/lib/api/endpoints/deltaUpload.hash.test.ts index 5d316ce7..31d7c73b 100644 --- a/frontend/src/lib/api/endpoints/deltaUpload.hash.test.ts +++ b/frontend/src/lib/api/endpoints/deltaUpload.hash.test.ts @@ -24,6 +24,7 @@ describe('worker-pool hashing (architecture gate)', () => { // on the main thread, serially. const nFiles = 24; const size = 4 * 1024 * 1024; + const trials = 3; const dir = await fs.mkdtemp(join(tmpdir(), 'hashbench-')); const paths: string[] = []; for (let i = 0; i < nFiles; i++) { @@ -35,12 +36,14 @@ describe('worker-pool hashing (architecture gate)', () => { } // Sequential (old): read + hash on the calling thread. - const t0 = performance.now(); - for (const p of paths) { - const b = await fs.readFile(p); - createHash('sha256').update(b).digest('hex'); - } - const seqMs = performance.now() - t0; + const runSequential = async () => { + const t0 = performance.now(); + for (const p of paths) { + const b = await fs.readFile(p); + createHash('sha256').update(b).digest('hex'); + } + return performance.now() - t0; + }; // 3-lane pool (new): each worker reads + hashes its own files. const lanes = 3; @@ -53,35 +56,52 @@ describe('worker-pool hashing (architecture gate)', () => { parentPort.postMessage(createHash('sha256').update(b).digest('hex')); }); `; - const workers = Array.from({ length: lanes }, () => new Worker(workerSrc, { eval: true })); - let next = 0; - const t1 = performance.now(); - await Promise.all( - workers.map( - (w) => - new Promise((resolve, reject) => { - const feed = () => { - if (next >= paths.length) { - resolve(); - return; - } - const i = next++; - w.once('message', () => feed()); - w.once('error', reject); - w.postMessage(paths[i]); - }; - feed(); - }) - ) - ); - const poolMs = performance.now() - t1; - await Promise.all(workers.map((w) => w.terminate())); + const runPooled = async () => { + const workers = Array.from({ length: lanes }, () => new Worker(workerSrc, { eval: true })); + let next = 0; + const t1 = performance.now(); + await Promise.all( + workers.map( + (w) => + new Promise((resolve, reject) => { + const feed = () => { + if (next >= paths.length) { + resolve(); + return; + } + const i = next++; + w.once('message', () => feed()); + w.once('error', reject); + w.postMessage(paths[i]); + }; + feed(); + }) + ) + ); + const ms = performance.now() - t1; + await Promise.all(workers.map((w) => w.terminate())); + return ms; + }; + + // Best-of-`trials` wall-clock per strategy: a single sample is prone + // to scheduler/GC noise on a loaded machine, which can tip either + // side when the two are close. Noise only ever adds delay, so the + // minimum across trials is each strategy's true achievable time — + // a genuine architecture regression still fails every trial. + const seqTimes: number[] = []; + const poolTimes: number[] = []; + for (let i = 0; i < trials; i++) { + seqTimes.push(await runSequential()); + poolTimes.push(await runPooled()); + } + const seqMs = Math.min(...seqTimes); + const poolMs = Math.min(...poolTimes); + await fs.rm(dir, { recursive: true, force: true }); - // eslint-disable-next-line no-console console.info( - `read+hash ${nFiles} x 4 MiB: sequential ${seqMs.toFixed(0)} ms vs 3-lane pool ${poolMs.toFixed(0)} ms (${(seqMs / poolMs).toFixed(1)}x)` + `read+hash ${nFiles} x 4 MiB over ${trials} trials: best sequential ${seqMs.toFixed(0)} ms vs best 3-lane pool ${poolMs.toFixed(0)} ms (${(seqMs / poolMs).toFixed(1)}x)` ); expect(poolMs).toBeLessThan(seqMs); - }); + }, 20000); }); From 0980178b887daf2d70b37f6719955b7345d2eaf8 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 7 Jul 2026 23:08:10 +0200 Subject: [PATCH 5/6] refactor(front): ResourceList component for all views purpose is to share the same component for all sections will be easier code to maintain, and more evolutive --- frontend/eslint.config.js | 11 + .../src/lib/components/ResourceList.svelte | 490 ++++++++++++++---- frontend/src/lib/utils/thumbnail.ts | 18 +- frontend/src/routes/favorites/+page.svelte | 147 +++--- frontend/src/routes/favorites/page.test.ts | 4 + frontend/src/routes/recent/+page.svelte | 173 ++++--- .../src/routes/shared-with-me/+page.svelte | 74 ++- frontend/src/routes/trash/+page.svelte | 101 ++-- 8 files changed, 661 insertions(+), 357 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index e35bae4e..35141f55 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -16,6 +16,17 @@ export default ts.config( ...globals.browser, ...globals.node } + }, + rules: { + // `_`-prefixed args are the codebase's "intentionally unused" + // convention — mostly Svelte snippet positional params that + // have to be declared but aren't read (e.g. `dateCell(_item, + // ctx)`). Match the widely-used JS/TS ecosystem pattern so + // the intent is respected without per-line disable comments. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ] } }, { diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index d8be2591..75b0e867 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -1,32 +1,38 @@ @@ -59,13 +74,40 @@ import VirtualList from '$lib/components/VirtualList.svelte'; import { t } from '$lib/i18n/index.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; + import { preferences } from '$lib/stores/preferences.svelte'; import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; import { gridColumns } from '$lib/utils/grid'; + import { fileThumbnailUrl } from '$lib/api/endpoints/files'; + import { + canThumbnailClientSide, + preloadPdf, + queueGenerate as queueThumbnailGenerate + } from '$lib/utils/thumbnail'; interface Props { title: string; - items: ResourceEntry[]; + items: Array; + /** + * Per-item envelope info keyed by `item.id`. See `ItemContext` + * above. When absent, ResourceList uses the intrinsic item + * fields (`modified_at`, `created_by`). + */ + contextMap?: Map; + /** + * Set of item ids the caller considers "favorite". When + * provided, the star widget renders next to each row and + * `onfavorite` is invoked on click. Kept as an external Set so + * the page owns the source of truth (e.g. the favorites store). + */ + favoriteIds?: Set; + /** + * Resolve `userId → display name`. Optional; when absent + * `UserVignette` falls back to its own internal resolution. + * Accepts `null` for consistency with the useOwnerCache API + * (returns `null` for a not-yet-resolved id). + */ + resolveOwnerName?: (userId: string) => string | null | undefined; loading?: boolean; error?: string | null; /** Empty-state primary line. */ @@ -86,7 +128,7 @@ /** Override the date column header label (e.g. trash → "Remaining"). */ dateLabel?: string; /** Custom renderer for the date cell (e.g. trash expiry chip). */ - dateCell?: Snippet<[ResourceEntry]>; + dateCell?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>; /** * Optional per-bucket action button rendered alongside the swimlane * header label. Receives the bucket key (the value `bucketOf` @@ -99,10 +141,21 @@ showOwner?: boolean; /** Allow grid/list toggle (shares the app-wide view mode). */ showViewToggle?: boolean; - /** Show the dotfile-visibility eye toggle in the toolbar. - * Opt-in per host page — surfaces that never filter dotfiles - * (favorites, trash) leave this false so the button doesn't - * appear to do nothing. Forwarded to ListToolbar. */ + /** Show the dotfile-visibility eye toggle in the toolbar AND + * apply the corresponding filter to `items` when + * `preferences.hideDotfiles` is true. Opt-in per host page — + * surfaces that never filter dotfiles (favorites, trash) leave + * this false so the button doesn't appear AND the filter never + * kicks in. Single flag governs both concerns so a page can't + * accidentally expose the button without wiring the filter or + * vice-versa. + * + * A host page that needs to surface "N items hidden" in its + * empty state derives that count independently via the shared + * `isDotfile` predicate in `$lib/utils/dotfileFilter` — no + * count-out prop here (avoids a bindable whose $bindable + * default is always shadowed by the effect that would sync it, + * and keeps the component's API one-way-inbound). */ showDotfileToggle?: boolean; /** Multi-select checkboxes + selection model. */ selectable?: boolean; @@ -116,20 +169,74 @@ reversed?: boolean; /** Called when group-by or direction changes; page should reload page 1. */ onreload?: (orderBy: string, reversed: boolean) => void; - onopen?: (entry: ResourceEntry) => void; - /** Per-entry favorite star toggle. */ - onfavorite?: (entry: ResourceEntry) => void; - /** Selection changed (set of selected entry ids). */ + onopen?: (item: FileItem | FolderItem) => void; + /** Per-item favorite star toggle. */ + onfavorite?: (item: FileItem | FolderItem) => void; + /** Selection changed (set of selected item ids). */ onselectionchange?: (ids: Set) => void; - actions?: Snippet<[ResourceEntry]>; + actions?: Snippet<[FileItem | FolderItem]>; toolbar?: Snippet; - /** Batch toolbar shown when items are selected; receives selected entries. */ - batchToolbar?: Snippet<[ResourceEntry[]]>; + /** Batch toolbar shown when items are selected; receives selected items. */ + batchToolbar?: Snippet<[Array]>; + /** + * Render `` thumbnails on file rows and fall back to + * client-side generation when the server doesn't have one + * (image / PDF / video via `$lib/utils/thumbnail`). Default on + * — every view that lists real files gets the same behaviour. + * Set false for views that never benefit (empty states, + * synthetic rows). + */ + enableThumbnails?: boolean; + /** + * Enable per-row drag/drop hooks. Used by the files browser so + * a folder row is a drop target and any row is draggable to + * another folder or the breadcrumb. Pages that don't wire these + * (trash, favorites, recent, shared-with-me) opt out of the + * drag-drop UX entirely by leaving the callbacks unset. + */ + isDraggable?: (item: FileItem | FolderItem) => boolean; + isDropTarget?: (item: FileItem | FolderItem) => boolean; + /** + * Which item id currently shows the drop-target highlight (page + * owns the state so it can share it with breadcrumb / other drop + * zones). Only meaningful when `isDropTarget` is provided. + */ + dropTargetId?: string | null; + onitemdragstart?: (e: DragEvent, item: FileItem | FolderItem) => void; + onitemdragover?: (e: DragEvent, item: FileItem | FolderItem) => void; + onitemdragleave?: (e: DragEvent, item: FileItem | FolderItem) => void; + onitemdrop?: (e: DragEvent, item: FileItem | FolderItem) => void; + /** + * Override the list-view column header. When provided, + * ResourceList renders this instead of its default header — + * used by the files browser to expose clickable column-sort + * buttons (name / size / type / modified). Pages that override + * this typically also handle sorting on their side (pass + * pre-sorted `items`) rather than relying on `onreload`. + */ + listHeader?: Snippet; + /** + * Open the row on single click (default) vs. double click. + * Files browser prefers double-click so single-click can drive + * the shift-range selection model without accidentally + * navigating. + */ + openOnDoubleClick?: boolean; + /** + * Enable shift-click range selection. The row that was clicked + * without shift becomes the anchor; the next shift-click + * selects the range between anchor and target in visible order. + * Requires `selectable`. + */ + shiftRangeSelect?: boolean; } let { title, items, + contextMap, + favoriteIds, + resolveOwnerName, loading = false, error = null, emptyText, @@ -159,10 +266,69 @@ onselectionchange, actions, toolbar, - batchToolbar + batchToolbar, + enableThumbnails = true, + isDraggable, + isDropTarget, + dropTargetId = null, + onitemdragstart, + onitemdragover, + onitemdragleave, + onitemdrop, + listHeader: listHeaderOverride, + openOnDoubleClick = false, + shiftRangeSelect = false }: Props = $props(); - const isEmpty = $derived(items.length === 0); + // ── Per-item accessors ──────────────────────────────────────────────────── + // Every read of an item field goes through these helpers so the + // contextMap override for date + owner is centralised. Kept as + // module-level fns (not $derived) — they run on each row render; + // caching a Map on every items/contextMap change would be wasteful. + function ctxOf(id: string): ItemContext | undefined { + return contextMap?.get(id); + } + function dateOf(item: FileItem | FolderItem): number | string | null { + return ctxOf(item.id)?.date ?? item.modified_at; + } + function ownerIdOf(item: FileItem | FolderItem): string | null { + const ctx = ctxOf(item.id); + return ctx && 'ownerId' in ctx ? (ctx.ownerId ?? null) : (item.created_by ?? null); + } + function sizeOf(item: FileItem | FolderItem): number | null { + return isFile(item) ? item.size : null; + } + function mimeOf(item: FileItem | FolderItem): string | null { + return isFile(item) ? item.mime_type : null; + } + function iconClassOf(item: FileItem | FolderItem): string { + return item.icon_class; + } + + // ── Dotfile filter ──────────────────────────────────────────────────────── + // Two conditions gate the filter (both must be true): + // 1. Host page opted in via `showDotfileToggle` — so pages where + // dotfiles are always visible (favorites, trash) never hide them + // even if the user's global preference is on. + // 2. User preference is set to hide — read from the reactive + // `preferences.hideDotfiles` getter, so a toolbar click flips + // this list in real time without a reload. + // The `visibleItems` derived is what every downstream reader + // (bucketing, rendering, "all-selected", range-select) uses, so + // hidden rows disappear consistently across grid, list, and every + // group-by dimension. `selectedItems` and the reap-stale-selection + // effect stay on the raw `items` — selection persists across a + // display filter toggle, matching how file managers treat a + // filter-hide as "hidden, not gone". + const filterDotfiles = $derived(showDotfileToggle && preferences.hideDotfiles); + const visibleItems = $derived( + filterDotfiles ? items.filter((i) => !i.name.startsWith('.')) : items + ); + + // isEmpty tracks the VISIBLE list — an all-dotfile page with the + // filter on shows the empty state (the host page's `emptyHint` can + // reference `hiddenCount` to say "3 items hidden by the filter"). + const isEmpty = $derived(visibleItems.length === 0); const viewClass = $derived( filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view' ); @@ -207,27 +373,29 @@ * Partition the visible items into grouped sections when a `bucketOf` is * active. Server order is preserved within and across buckets (first-seen). */ - const sections = $derived.by((): Array<{ key: string; label: string; rows: ResourceEntry[] }> => { - const bucketOf = activeGroup?.bucketOf; - if (!bucketOf) return [{ key: '', label: '', rows: items }]; - const order: string[] = []; - // Transient bucketing map computed inside $derived.by — not reactive state. - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const map = new Map(); - for (const entry of items) { - const k = bucketOf(entry) ?? '∅'; - if (!map.has(k)) { - map.set(k, []); - order.push(k); + const sections = $derived.by( + (): Array<{ key: string; label: string; rows: Array }> => { + const bucketOf = activeGroup?.bucketOf; + if (!bucketOf) return [{ key: '', label: '', rows: visibleItems }]; + const order: string[] = []; + // Transient bucketing map computed inside $derived.by — not reactive state. + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const map = new Map>(); + for (const item of visibleItems) { + const k = bucketOf(item, ctxOf(item.id)) ?? '∅'; + if (!map.has(k)) { + map.set(k, []); + order.push(k); + } + map.get(k)!.push(item); } - map.get(k)!.push(entry); + return order.map((k) => ({ + key: k, + label: activeGroup?.labelOf?.(k) ?? k, + rows: map.get(k)! + })); } - return order.map((k) => ({ - key: k, - label: activeGroup?.labelOf?.(k) ?? k, - rows: map.get(k)! - })); - }); + ); const grouped = $derived(!!activeGroup?.bucketOf); // ── Selection ───────────────────────────────────────────────────────────── @@ -239,20 +407,74 @@ else selected.add(id); onselectionchange?.(selected); } + + /** + * Anchor id for shift-range selection. The row clicked without + * shift becomes the anchor; the next shift-click selects every + * row between anchor and target in visible order. Kept in module + * state so it survives re-renders that don't drop the component. + */ + let selectionAnchor = $state(null); + function selectRange(anchorId: string, targetId: string) { + // Range-select over the VISIBLE order — a shift-click can't reach + // a row the user can't see. + const order = visibleItems.map((i) => i.id); + const a = order.indexOf(anchorId); + const b = order.indexOf(targetId); + if (a < 0 || b < 0) return; + const [lo, hi] = a < b ? [a, b] : [b, a]; + for (let i = lo; i <= hi; i++) selected.add(order[i]); + onselectionchange?.(selected); + } + /** + * Left-click handler that either navigates (`onopen`) or manages + * selection depending on modifiers + config. Returns `true` when + * the click was consumed by selection, so callers can suppress the + * open. Enabled only for `selectable + shiftRangeSelect` callers. + */ + function handleRowClick(e: MouseEvent, id: string): boolean { + if (!selectable || !shiftRangeSelect) return false; + if (e.shiftKey && selectionAnchor) { + e.preventDefault(); + selectRange(selectionAnchor, id); + return true; + } + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggleSelected(id); + selectionAnchor = id; + return true; + } + // Plain click: only sets the anchor; open (if any) still fires. + selectionAnchor = id; + return false; + } function clearSelection() { selected.clear(); onselectionchange?.(selected); } - const allSelected = $derived(items.length > 0 && selected.size === items.length); + // "All-selected" means every VISIBLE row is selected — hiding + // dotfiles by preference shouldn't be confused with "not selected". + const allSelected = $derived( + visibleItems.length > 0 && visibleItems.every((i) => selected.has(i.id)) + ); function toggleSelectAll() { if (allSelected) clearSelection(); else { selected.clear(); - for (const i of items) selected.add(i.id); + // Select all VISIBLE rows only. A user hiding dotfiles then + // pressing select-all shouldn't sweep in the hidden files + // they can't see — that would be a footgun for destructive + // batch actions. + for (const i of visibleItems) selected.add(i.id); onselectionchange?.(selected); } } - const selectedEntries = $derived(items.filter((i) => selected.has(i.id))); + // `selectedItems` and the reap-stale effect below stay on the RAW + // items — selection persists across a display-filter toggle, and + // stale-selection cleanup only fires when items truly leave the + // dataset (reload, delete, etc.), not when the filter hides them. + const selectedItems = $derived(items.filter((i) => selected.has(i.id))); // Drop selection ids that are no longer present after a reload. $effect(() => { @@ -276,20 +498,20 @@ let ctxOpen = $state(false); let ctxX = $state(0); let ctxY = $state(0); - let ctxEntry = $state(null); + let ctxItem = $state(null); - function openContext(e: MouseEvent, entry: ResourceEntry) { + function openContext(e: MouseEvent, item: FileItem | FolderItem) { if (!contextActions?.length) return; e.preventDefault(); e.stopPropagation(); - ctxEntry = entry; + ctxItem = item; ctxX = Math.min(e.clientX, window.innerWidth - 220); ctxY = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24)); ctxOpen = true; } function closeContext() { ctxOpen = false; - ctxEntry = null; + ctxItem = null; } // ── Infinite scroll (IntersectionObserver) ──────────────────────────────── @@ -309,9 +531,10 @@ return () => obs.disconnect(); }); - function ownerTitle(entry: ResourceEntry): string { - const owner = entry.ownerName ?? entry.ownerId ?? ''; - const path = entry.path ?? ''; + function ownerTitle(item: FileItem | FolderItem): string { + const ownerId = ownerIdOf(item); + const owner = ownerId ? (resolveOwnerName?.(ownerId) ?? ownerId) : ''; + const path = item.path ?? ''; return [ owner && `${t('files.col_owner', 'Owner')}: ${owner}`, path && `${t('files.col_path', 'Location')}: ${path}` @@ -321,81 +544,131 @@ } -{#snippet row(entry: ResourceEntry)} - {@const iconName = entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)} +{#snippet row(item: FileItem | FolderItem)} + {@const kind = isFile(item) ? 'file' : 'folder'} + {@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))} + {@const isFav = favoriteIds?.has(item.id) ?? false} + {@const ctx = ctxOf(item.id)} + {@const ownerId = ownerIdOf(item)} + {@const dateVal = dateOf(item)} + {@const sizeVal = sizeOf(item)} + {@const mimeVal = mimeOf(item)} + {@const draggable = isDraggable?.(item) ?? false} + {@const dropTarget = isDropTarget?.(item) ?? false}
onopen(entry) : undefined} - onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined} - oncontextmenu={contextActions?.length ? (e) => openContext(e, entry) : undefined} + aria-label={onopen ? item.name : undefined} + data-testid={item.name} + title={showOwner ? ownerTitle(item) : undefined} + {draggable} + ondragstart={draggable && onitemdragstart ? (e) => onitemdragstart(e, item) : undefined} + ondragover={dropTarget && onitemdragover ? (e) => onitemdragover(e, item) : undefined} + ondragleave={dropTarget && onitemdragleave ? (e) => onitemdragleave(e, item) : undefined} + ondrop={dropTarget && onitemdrop ? (e) => onitemdrop(e, item) : undefined} + onclick={onopen + ? (e) => { + // Selection-first for shift/meta clicks; only "open" fires on a + // plain click when the click wasn't consumed by selection. + if (handleRowClick(e, item.id)) return; + if (!openOnDoubleClick) onopen(item); + } + : undefined} + ondblclick={onopen && openOnDoubleClick ? () => onopen(item) : undefined} + onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(item) : undefined} + oncontextmenu={contextActions?.length ? (e) => openContext(e, item) : undefined} > {#if selectable} {/if}
+ + {#if enableThumbnails && kind === 'file' && mimeVal && canThumbnailClientSide( { id: item.id, name: item.name, mime_type: mimeVal } )} + { + const img = e.currentTarget as HTMLImageElement; + img.style.display = 'none'; + if (mimeVal === 'application/pdf') preloadPdf(); + void queueThumbnailGenerate( + { id: item.id, name: item.name, mime_type: mimeVal }, + (dataUrl) => { + img.src = dataUrl; + img.style.display = ''; + } + ); + }} + /> + {/if} - {entry.name} + {item.name}
{#if showOwner}
- {#if entry.ownerId} - + {#if ownerId} + {:else} - {entry.ownerName ?? '—'} + — {/if}
{/if} - {#if showPath}
{entry.path ?? ''}
{/if} - {#if showType}
{entry.typeLabel ?? ''}
{/if} + {#if showPath}
{item.path ?? ''}
{/if} + {#if showType}
{item.category ?? ''}
{/if} {#if showSize} -
{entry.size != null ? formatBytes(entry.size) : '—'}
+
{sizeVal != null ? formatBytes(sizeVal) : '—'}
{/if} {#if showDate}
- {#if dateCell}{@render dateCell(entry)}{:else}{formatDate(entry.date)}{/if} + {#if dateCell}{@render dateCell(item, ctx)}{:else}{formatDate(dateVal)}{/if}
{/if}
- {#if showDate && dateCell}{@render dateCell(entry)}{/if} + {#if showDate && dateCell}{@render dateCell(item, ctx)}{/if} - {#if entry.size != null}{formatBytes(entry.size)}{/if} - {#if entry.date != null}{formatDate(entry.date)}{/if} + {#if sizeVal != null}{formatBytes(sizeVal)}{/if} + {#if dateVal != null}{formatDate(dateVal)}{/if}
{#if onfavorite} {/if} {#if actions} -
{@render actions(entry)}
+
{@render actions(item)}
{/if}
{/snippet} @@ -435,7 +708,7 @@ {t('files.selected_count', { count: selected.size }, '{{count}} selected')} -
{@render batchToolbar(selectedEntries)}
+
{@render batchToolbar(selectedItems)}
{/if} @@ -453,7 +726,7 @@
{#if grouped}
- {@render listHeader()} + {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} {#each sections as section (section.key)}
{section.label} @@ -480,13 +753,13 @@
- {@render listHeader()} - e.id} {row} /> + {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} + e.id} {row} />
{:else} {/snippet} -{#if ctxOpen && ctxEntry && contextActions} +{#if ctxOpen && ctxItem && contextActions}