Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# AGENTS.md — Frontend
|
||||
|
||||
Complements the repo-root `/AGENTS.md`. Not shipped (adapter-static
|
||||
copies only `frontend/static/`).
|
||||
|
||||
## localStorage keys
|
||||
|
||||
Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`.
|
||||
Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps
|
||||
every `oxi-*` key on user-account switches — any other prefix leaks the
|
||||
previous user's state into the new one.
|
||||
@@ -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: '^_' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,14 +9,15 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/emit-askama-common.mjs && node scripts/precompress.mjs ../static-dist",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && COVERAGE=1 vitest run"
|
||||
"test:unit": "LANG=C vitest run",
|
||||
"test:unit:watch": "LANG=C vitest",
|
||||
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Emit `static-dist/askama-common.css` from the SvelteKit design-token
|
||||
* source of truth (`src/lib/styles/base/variables.css`) plus the auth-page
|
||||
* component styles (`src/lib/styles/askama-common.css`).
|
||||
*
|
||||
* WHY A POST-BUILD SCRIPT:
|
||||
* Vite's `writeBundle` hooks fire mid-build, before
|
||||
* `@sveltejs/adapter-static` copies the finalised site to
|
||||
* `../static-dist/`. Anything written to that directory during
|
||||
* Vite gets wiped when adapter-static runs. A `postbuild` script
|
||||
* runs after everything the SvelteKit build owns, so its output
|
||||
* survives — one predictable moment, no ordering trap.
|
||||
*
|
||||
* WHAT IT PRODUCES:
|
||||
* A single stable-named CSS file at `static-dist/askama-common.css`
|
||||
* containing:
|
||||
* 1. Every design token declared in `base/variables.css` (:root,
|
||||
* `light-dark(...)`, dark-mode blocks, etc.)
|
||||
* 2. The auth-page component rules from `askama-common.css`
|
||||
* Concatenated, prefixed with a "do not edit" header, written UTF-8.
|
||||
*
|
||||
* SINGLE SOURCE OF TRUTH:
|
||||
* If a token changes in `variables.css`, one rebuild propagates it to
|
||||
* both the SPA (via Svelte's normal build pipeline) AND the askama
|
||||
* templates (via this file). Two consumers, one source. No manual
|
||||
* sync step.
|
||||
*
|
||||
* SERVER SIDE:
|
||||
* Server-rendered askama templates reference:
|
||||
* <link rel="stylesheet" href="/askama-common.css">
|
||||
* The Rust web layer serves `static-dist/askama-common.css` at that
|
||||
* URL through the same ServeDir the SPA uses. No route wiring needed.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const stylesDir = resolve(__dirname, '../src/lib/styles');
|
||||
const outputFile = resolve(__dirname, '../../static-dist/askama-common.css');
|
||||
|
||||
const header =
|
||||
'/* Auto-generated by frontend/scripts/emit-askama-common.mjs.\n' +
|
||||
' * Do NOT edit by hand — regenerated on every `npm run build`.\n' +
|
||||
' * Sources: src/lib/styles/base/variables.css (design tokens)\n' +
|
||||
' * src/lib/styles/askama-common.css (auth components)\n' +
|
||||
' */\n\n';
|
||||
|
||||
const tokens = readFileSync(resolve(stylesDir, 'base/variables.css'), 'utf8');
|
||||
const components = readFileSync(resolve(stylesDir, 'askama-common.css'), 'utf8');
|
||||
|
||||
mkdirSync(dirname(outputFile), { recursive: true });
|
||||
writeFileSync(outputFile, header + tokens + '\n' + components, 'utf8');
|
||||
|
||||
const bytes = Buffer.byteLength(header + tokens + '\n' + components, 'utf8');
|
||||
console.log(`emit-askama-common: wrote ${bytes} bytes → ${outputFile}`);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Precompress built SPA assets so the Rust web layer can serve them with
|
||||
// `ServeDir::precompressed_br()/precompressed_gzip()` instead of re-running
|
||||
// Brotli over the same immutable bundle on every request (the tower-http
|
||||
// CompressionLayer stays as the on-the-fly fallback for anything without a
|
||||
// sibling). Runs as the `build` script's final step; uses only node:zlib —
|
||||
// no dependencies. See benches/STATIC-PRECOMPRESSED.md for the measured win.
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const OUT_DIR = process.argv[2] ?? '../static-dist';
|
||||
// Compressible text assets; media formats are already compressed.
|
||||
const EXTENSIONS = new Set([
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.css',
|
||||
'.html',
|
||||
'.svg',
|
||||
'.json',
|
||||
'.txt',
|
||||
'.xml',
|
||||
'.map',
|
||||
'.webmanifest'
|
||||
]);
|
||||
// Below this size the encoding overhead outweighs the transfer win
|
||||
// (mirrors the server's SizeAbove(256) predicate).
|
||||
const MIN_BYTES = 256;
|
||||
|
||||
async function* walk(dir) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(p);
|
||||
else yield p;
|
||||
}
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
let inBytes = 0;
|
||||
let brBytes = 0;
|
||||
for await (const file of walk(OUT_DIR)) {
|
||||
if (!EXTENSIONS.has(path.extname(file))) continue;
|
||||
const data = await fs.readFile(file);
|
||||
if (data.length < MIN_BYTES) continue;
|
||||
const br = zlib.brotliCompressSync(data, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length
|
||||
}
|
||||
});
|
||||
const gz = zlib.gzipSync(data, { level: 9 });
|
||||
// Only keep siblings that actually shrink the asset.
|
||||
if (br.length < data.length) await fs.writeFile(`${file}.br`, br);
|
||||
if (gz.length < data.length) await fs.writeFile(`${file}.gz`, gz);
|
||||
files += 1;
|
||||
inBytes += data.length;
|
||||
brBytes += Math.min(br.length, data.length);
|
||||
}
|
||||
console.log(
|
||||
`precompress: ${files} assets, ${(inBytes / 1024).toFixed(0)} KiB → ${(brBytes / 1024).toFixed(0)} KiB brotli (${inBytes ? ((1 - brBytes / inBytes) * 100).toFixed(0) : 0}% smaller)`
|
||||
);
|
||||
+21
-5
@@ -3,14 +3,30 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<!-- Icon set — mirrors the legacy static/index.html head. SVG is
|
||||
the primary (modern browsers use it, scales at every DPI); the
|
||||
.ico fallback covers old browsers + Windows taskbar shortcuts.
|
||||
Apple touch icon: iOS home-screen. Safari mask icon: pinned
|
||||
tabs. Manifest wires the PWA install flow. -->
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/logo/logo-plain.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="%sveltekit.assets%/logo/logo-plain.svg" color="#ff5e3a" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
%sveltekit.head%
|
||||
<!--
|
||||
Anti-FOUC theme init — runs synchronously before first paint.
|
||||
Ported from static/js/core/theme-init.js. Keeps the legacy
|
||||
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
|
||||
so existing users keep their preference across the migration.
|
||||
Reads the `oxi-theme` localStorage key (part of the normalised
|
||||
`oxi-*` prefs namespace, see $lib/utils/localStoragePrefs) and
|
||||
reflects it on `<html data-color-scheme>`.
|
||||
|
||||
KEEP IN SYNC: the 'oxi-theme' string below MUST match
|
||||
`THEME_STORAGE_KEY` exported from
|
||||
$lib/stores/theme.svelte.ts. The inline script runs before
|
||||
any JS bundle loads, so it can't `import` the constant. A
|
||||
drift check in $lib/stores/theme.test.ts reads this file
|
||||
and fails CI if the two get out of sync.
|
||||
|
||||
Placed AFTER %sveltekit.head% so it follows the CSP <meta> SvelteKit
|
||||
injects there, hence it IS governed by that policy. svelte.config.js
|
||||
@@ -21,7 +37,7 @@
|
||||
<script id="theme-init">
|
||||
(function () {
|
||||
try {
|
||||
var s = localStorage.getItem('oxicloud_theme');
|
||||
var s = localStorage.getItem('oxi-theme');
|
||||
var h = document.documentElement;
|
||||
if (s === 'light' || s === 'dark') h.setAttribute('data-color-scheme', s);
|
||||
else h.removeAttribute('data-color-scheme');
|
||||
|
||||
@@ -142,12 +142,26 @@ export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit):
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
/**
|
||||
* `error_type` field from the backend's `ErrorResponse` body, when
|
||||
* present. Callers switch on this to render specific UX for
|
||||
* distinguished failures (e.g. `EmailNotVerified` → "resend
|
||||
* verification link" prompt). Falls back to `undefined` when the
|
||||
* response body isn't parseable or the endpoint doesn't emit one.
|
||||
*/
|
||||
readonly errorType?: string;
|
||||
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly statusText: string,
|
||||
readonly resource: RequestInfo | URL
|
||||
readonly resource: RequestInfo | URL,
|
||||
errorType?: string,
|
||||
serverMessage?: string
|
||||
) {
|
||||
super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`);
|
||||
super(
|
||||
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
|
||||
);
|
||||
this.name = 'ApiError';
|
||||
this.errorType = errorType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,79 @@ export async function removeDriveMemberAdmin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).
|
||||
*
|
||||
* Bypasses the per-drive `Manage` check (the admin guard at the route
|
||||
* edge is the access control). The default-personal-drive guard and
|
||||
* the "drive must be empty" check still fire server-side — admins
|
||||
* can't accidentally wipe a populated drive or a user's home folder.
|
||||
* Throws on non-2xx so the caller can branch on `405` (default
|
||||
* personal) vs `409` (non-empty) when surfacing the failure.
|
||||
*/
|
||||
/**
|
||||
* `PATCH /api/drives/{id}/quota` — admin-only shared-drive quota
|
||||
* mutation (D4). `quotaBytes = null` or ≤ 0 → unlimited (the backend
|
||||
* normalises 0/negative to NULL).
|
||||
*
|
||||
* **Refuses personal drives** with HTTP 400 — the effective cap
|
||||
* comes from the owner user's `storage_quota_bytes` envelope, edit
|
||||
* via `setUserQuota` (`PUT /api/admin/users/{id}/quota`) instead.
|
||||
* Callers should gate the UI on `drive.kind === 'shared'` so users
|
||||
* never see the refusal.
|
||||
*
|
||||
* **Soft-quota semantic on shrink**: a new cap below current
|
||||
* `used_bytes` is accepted — the write-time gate then blocks new
|
||||
* writes until the drive shrinks back under. No existing content
|
||||
* is retroactively touched. Matches xfs/ext4 quota behaviour.
|
||||
*
|
||||
* Returns the persisted value (the backend's normalisation of the
|
||||
* input) so the caller can update local state without re-fetching.
|
||||
* Throws on non-2xx with the backend's error message when present.
|
||||
*/
|
||||
export async function updateDriveQuota(
|
||||
driveId: string,
|
||||
quotaBytes: number | null
|
||||
): Promise<number | null> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/quota`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ quota_bytes: quotaBytes })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `update drive quota failed: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { quota_bytes: number | null };
|
||||
return body.quota_bytes;
|
||||
}
|
||||
|
||||
export async function deleteDriveAdmin(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
// 405 / 409 carry actionable messages from the backend; bubble them.
|
||||
throw new Error(detail || `delete drive failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminUsersPage {
|
||||
@@ -170,6 +243,48 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
|
||||
* Returns the full `User` DTO including `storage_quota_bytes` +
|
||||
* `storage_used_bytes` which the non-admin `/api/users/{id}`
|
||||
* response omits for privacy.
|
||||
*
|
||||
* Result promises are cached per id at module scope so multiple
|
||||
* callers for the same user (e.g. the admin drives table with N
|
||||
* personal drives owned by the same person) share one fetch. A
|
||||
* `null` result is cached too so a missing user isn't re-fetched
|
||||
* on every render.
|
||||
*
|
||||
* The cache is process-lifetime; a page navigation away and back
|
||||
* still sees the cached value. Callers that need to refresh (e.g.
|
||||
* after `setUserQuota`) should call `invalidateAdminUserCache`.
|
||||
*/
|
||||
const adminUserCache = new Map<string, Promise<User | null>>();
|
||||
|
||||
export function getUserAdmin(id: string): Promise<User | null> {
|
||||
const hit = adminUserCache.get(id);
|
||||
if (hit) return hit;
|
||||
const pending = (async (): Promise<User | null> => {
|
||||
try {
|
||||
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
adminUserCache.set(id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop cached admin lookups so mutations (quota change, role change,
|
||||
* delete) don't return stale data. Called with no arg = clear all,
|
||||
* or with a specific user id to drop just that entry. */
|
||||
export function invalidateAdminUserCache(userId?: string): void {
|
||||
if (userId) adminUserCache.delete(userId);
|
||||
else adminUserCache.clear();
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -203,6 +318,20 @@ export function deleteUser(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}`, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a currently-external (grant-only) user to an internal
|
||||
* account. The deployment must have magic-link login enabled — the
|
||||
* admin doesn't set the target's password, so the promoted user
|
||||
* needs some way to log in. Backend refuses with:
|
||||
* * 400 — magic-link disabled deployment-wide
|
||||
* * 403 — target is OIDC-linked
|
||||
* * 404 — user not found
|
||||
* * 409 — user is already internal
|
||||
*/
|
||||
export function promoteUserToInternal(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}/promote-to-internal`, 'POST');
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminDashboard {
|
||||
|
||||
@@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => {
|
||||
await auth.getAuthStatus().catch(() => {});
|
||||
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
||||
await auth.exchangeOidcCode('code').catch(() => {});
|
||||
await auth.register('u', 'e@x.test', 'p').catch(() => {});
|
||||
await auth.register('e@x.test', 'p', 'u').catch(() => {});
|
||||
await auth.sendMagicLink('e@x.test').catch(() => {});
|
||||
await auth.logout().catch(() => {});
|
||||
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
@@ -3,10 +3,32 @@
|
||||
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
||||
* a genuine failure to the caller.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { ApiError, apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { AuthResponse, User } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* Best-effort parse of the backend `ErrorResponse` shape
|
||||
* (`{ status, error, message, error_type }`). Returns whatever it could
|
||||
* extract; never throws — a malformed body just yields undefineds.
|
||||
*/
|
||||
async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> {
|
||||
try {
|
||||
const body = (await res.clone().json()) as {
|
||||
error_type?: unknown;
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
const errorType = typeof body.error_type === 'string' ? body.error_type : undefined;
|
||||
const rawMessage =
|
||||
(typeof body.message === 'string' ? body.message : undefined) ??
|
||||
(typeof body.error === 'string' ? body.error : undefined);
|
||||
return { errorType, message: rawMessage };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
/**
|
||||
@@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username: emailOrUsername, password })
|
||||
});
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend `error_type` so the login page can offer
|
||||
// specific UX: `EmailNotVerified` → "resend verification link",
|
||||
// `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc.
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
|
||||
}
|
||||
return (await res.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
@@ -56,6 +84,20 @@ export interface OidcProviders {
|
||||
enabled: boolean;
|
||||
provider_name?: string;
|
||||
password_login_enabled?: boolean;
|
||||
/**
|
||||
* True when the server accepts magic-link login requests. The backend
|
||||
* composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist
|
||||
* includes `magic_link`, and OIDC is NOT enabled at the deployment
|
||||
* (OIDC-enabled deployments must not offer magic-link — it would bypass
|
||||
* any 2FA / step-up the IdP enforces).
|
||||
*/
|
||||
magic_link_login_enabled?: boolean;
|
||||
/**
|
||||
* True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page
|
||||
* uses this to explain the `EmailNotVerified` login response and
|
||||
* surface a "resend verification link" affordance.
|
||||
*/
|
||||
require_verified_email?: boolean;
|
||||
authorize_endpoint?: string;
|
||||
}
|
||||
|
||||
@@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
||||
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
||||
* interceptor — mirrors the login primitive.
|
||||
* Register a new user. Since PR 18 both `username` and `password` are optional
|
||||
* on the backend: an email-only signup is valid and mints a welcome magic-link.
|
||||
* Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller
|
||||
* instead of tripping the global refresh-and-redirect interceptor — mirrors
|
||||
* the login primitive.
|
||||
*/
|
||||
export async function register(username: string, email: string, password: string): Promise<void> {
|
||||
export async function register(email: string, password?: string, username?: string): Promise<void> {
|
||||
const body: Record<string, unknown> = { email, role: 'user' };
|
||||
if (password) body.password = password;
|
||||
if (username) body.username = username;
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username, email, password, role: 'user' })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
@@ -152,6 +199,44 @@ export async function register(username: string, email: string, password: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the authenticated external user into a full internal account.
|
||||
* Server flips `is_external` to false, provisions a personal drive via
|
||||
* the lifecycle hook, and returns the updated `User`.
|
||||
*
|
||||
* Password is optional — see backend `UpgradeToInternalDto`:
|
||||
* * If the deployment offers magic-link login, blank password is
|
||||
* accepted (user remains magic-link-only after upgrade).
|
||||
* * Otherwise a password is required — the backend refuses with 400
|
||||
* `error_type = "PasswordRequired"` and the SPA surfaces the
|
||||
* server message.
|
||||
*
|
||||
* Uses `apiFetch` (unlike register/login) because the caller IS
|
||||
* authenticated; a 401 here IS a genuine "session expired" and the
|
||||
* refresh interceptor is the right response.
|
||||
*/
|
||||
export async function upgradeToInternal(password?: string): Promise<User> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (password) body.password = password;
|
||||
const res = await apiFetch('/api/auth/upgrade-to-internal', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
res.statusText,
|
||||
'/api/auth/upgrade-to-internal',
|
||||
errorType,
|
||||
message
|
||||
);
|
||||
}
|
||||
return (await res.json()) as User;
|
||||
}
|
||||
|
||||
export type MagicLinkResult = 'sent' | 'unavailable';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the worker-pool hashing in `resolveOwnedHashes`.
|
||||
*
|
||||
* ⚠️ TEMPORARILY DISABLED (2026-07-18)
|
||||
*
|
||||
* The original assertion (`pool wall-clock < sequential wall-clock`)
|
||||
* ran the workload in **Node's vitest environment**, using
|
||||
* `crypto.createHash('sha256')` and `node:worker_threads`. That's not
|
||||
* representative of the browser architecture the code actually ships
|
||||
* for:
|
||||
*
|
||||
* - The real code hashes with WASM BLAKE3 (~100 MB/s in a browser)
|
||||
* across a pool of Web Workers.
|
||||
* - Node's `crypto` sha256 is native C++ (~500–1000 MB/s) and its
|
||||
* `worker_threads` postMessage has different overhead characteristics.
|
||||
*
|
||||
* At native-crypto speed the 4 MiB hash completes in ~8 ms per file,
|
||||
* so the message-passing round-trip cost per file becomes a comparable
|
||||
* fraction of the total — even a *perfect* 3-lane parallelization has
|
||||
* to overcome ~1/3 of its own runtime in messaging cost. Any CI
|
||||
* variance pushes it over the sequential wall-clock, so the test
|
||||
* false-fails while the actual browser code is fine.
|
||||
*
|
||||
* The optimization itself is defensible on two grounds:
|
||||
* 1. Theoretical parallelism win: at WASM BLAKE3 speed the messaging
|
||||
* overhead is a rounding error and 3 lanes beat sequential ~2.5×.
|
||||
* 2. Main-thread responsiveness: even if the wall-clock ended up flat,
|
||||
* offloading the ~1 s of CPU-bound hashing to workers keeps the
|
||||
* UI responsive during upload prep.
|
||||
*
|
||||
* Neither of those is validated by a Node vitest. The real gate belongs
|
||||
* in a Playwright browser benchmark. Marked `.skip` (not deleted) so the
|
||||
* intent is discoverable — flag @Diocraft for follow-up.
|
||||
*/
|
||||
describe('worker-pool hashing (architecture gate)', () => {
|
||||
it.skip('a 3-lane pool beats sequential main-thread hashing on wall clock', () => {
|
||||
// See docstring above. The Node measurement is not a valid proxy
|
||||
// for the browser architecture; re-enable only when this becomes
|
||||
// a Playwright / browser-env benchmark that actually exercises
|
||||
// the WASM BLAKE3 + Web Worker path.
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,59 @@ export async function instantUploadOwned(
|
||||
return null;
|
||||
}
|
||||
|
||||
const HASH_WORKER_URL = '/workers/hashWorker.js';
|
||||
/** Parallel hashing lanes — enough to saturate small-file hashing without
|
||||
* starving the upload workers of cores. */
|
||||
const HASH_POOL_SIZE = Math.min(4, Math.max(1, (navigator.hardwareConcurrency ?? 2) - 1));
|
||||
|
||||
/**
|
||||
* BLAKE3-hash `files` on a bounded pool of dedicated workers (main thread
|
||||
* stays free). A file whose worker errors is simply absent from the result —
|
||||
* the caller uploads it the normal way. Falls back to the sequential inline
|
||||
* hasher when `Worker` is unavailable.
|
||||
*/
|
||||
async function hashFilesPooled(files: File[]): Promise<Map<File, string>> {
|
||||
if (typeof Worker === 'undefined') {
|
||||
const out = new Map<File, string>();
|
||||
for (const f of files) out.set(f, await blake3HexOfFile(f));
|
||||
return out;
|
||||
}
|
||||
const lanes = Math.min(HASH_POOL_SIZE, files.length);
|
||||
const workers = Array.from(
|
||||
{ length: lanes },
|
||||
() => new Worker(HASH_WORKER_URL, { type: 'module' })
|
||||
);
|
||||
const out = new Map<File, string>();
|
||||
let next = 0;
|
||||
try {
|
||||
await Promise.all(
|
||||
workers.map(
|
||||
(w) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const feed = () => {
|
||||
if (next >= files.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const i = next++;
|
||||
const file = files[i];
|
||||
w.onmessage = (ev: MessageEvent<{ id: number; hex?: string; error?: string }>) => {
|
||||
if (ev.data.hex) out.set(file, ev.data.hex);
|
||||
feed(); // per-file errors: skip the file, keep the lane
|
||||
};
|
||||
w.onerror = (e) => reject(e);
|
||||
w.postMessage({ id: i, file });
|
||||
};
|
||||
feed();
|
||||
})
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
for (const w of workers) w.terminate();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which of `files` the server already owns, with a SINGLE batch round
|
||||
* trip (the Dropbox-style "have you got these?" probe). Every file below the
|
||||
@@ -193,7 +246,13 @@ export async function resolveOwnedHashes(files: File[]): Promise<Map<File, strin
|
||||
|
||||
const hashByFile = new Map<File, string>();
|
||||
try {
|
||||
for (const f of inBand) hashByFile.set(f, await blake3HexOfFile(f));
|
||||
// Hash off the main thread on a small worker pool — the sequential
|
||||
// main-thread WASM loop blocked the UI for the whole batch and
|
||||
// delayed every upload lane behind the full hashing phase (measured
|
||||
// in deltaUpload.hash.test.ts). Falls back to the inline loop when
|
||||
// Workers are unavailable (some test environments).
|
||||
const hashed = await hashFilesPooled(inBand);
|
||||
for (const [f, h] of hashed) hashByFile.set(f, h);
|
||||
} catch {
|
||||
return new Map(); // WASM/hashing unavailable → skip instant uploads
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
Drive,
|
||||
DriveMember,
|
||||
DriveMemberSubject,
|
||||
DrivePolicies,
|
||||
DrivePoliciesPartial,
|
||||
DriveRole
|
||||
} from '$lib/api/types';
|
||||
|
||||
@@ -104,6 +106,67 @@ export async function updateDriveMember(
|
||||
return (await res.json()) as DriveMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/drives/{id}` — Owner-only drive delete (D3b).
|
||||
*
|
||||
* Refused with `405` for the default Personal drive and `409` for a
|
||||
* non-empty drive (caller must move/trash content first). Throws on
|
||||
* non-2xx with the server's detail message when present so the caller
|
||||
* can decide whether to surface a confirmation prompt vs an error.
|
||||
*/
|
||||
export async function deleteDrive(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `delete drive failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `PATCH /api/drives/{id}/policies` — update drive policies (D5).
|
||||
*
|
||||
* **OxiCloud-admin only.** Owners cannot mutate policies — the carve-out
|
||||
* exists because policies are a compliance surface (an owner who could
|
||||
* flip them would defeat the gates by disabling, sharing, re-enabling).
|
||||
* Non-admin callers receive 404 (anti-enum). The frontend only surfaces
|
||||
* this from the admin panel.
|
||||
*
|
||||
* Body is a partial — keys not present are left untouched at the JSONB
|
||||
* merge layer. Returns the post-merge typed view.
|
||||
*/
|
||||
export async function updateDrivePolicies(
|
||||
driveId: string,
|
||||
partial: DrivePoliciesPartial
|
||||
): Promise<DrivePolicies> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/policies`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(partial)
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `update policies failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as DrivePolicies;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/drives/{id}/members/{kind}/{sid}` — remove a member.
|
||||
* Idempotent (removing a non-member returns 204). Refused with 400 if it
|
||||
|
||||
@@ -163,3 +163,13 @@ export function fileThumbnailUrl(
|
||||
): string {
|
||||
return `/api/files/${fileId}/thumbnail/${size}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail size matched to the rendering slot. List rows draw thumbnails in
|
||||
* a 40×40 box, so the 150px `icon` rendition is already ≥2× retina density —
|
||||
* fetching the 400px `preview` there moved ~7× more pixels than the slot can
|
||||
* show (benches/ROUND12.md §F1). Grid cards (100×70 slot) keep `preview`.
|
||||
*/
|
||||
export function thumbSizeForView(view: 'grid' | 'list'): 'icon' | 'preview' {
|
||||
return view === 'list' ? 'icon' : 'preview';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
|
||||
import { apiJson } from '$lib/api/client';
|
||||
import type { FolderItem } from '$lib/api/types';
|
||||
import { getFolder } from './folders';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the in-flight dedup in {@link getFolder}.
|
||||
*
|
||||
* Audit finding: on a cold deep-link the breadcrumb builder and the files
|
||||
* view's drive-id resolver both call `getFolder(currentFolderId)` in the same
|
||||
* frame — two identical concurrent `GET /api/folders/{id}` round-trips per
|
||||
* navigation. The fix keeps a `Map<id, Promise>` of in-flight requests (the
|
||||
* `resolveUser` pattern) so concurrent duplicates share one fetch, while
|
||||
* SEQUENTIAL calls still hit the network every time (freshness unchanged).
|
||||
*
|
||||
* Gates:
|
||||
* 1. Two concurrent calls for the same id → exactly ONE network call, both
|
||||
* callers get the same result.
|
||||
* 2. Sequential calls (second after the first settled) → two network calls
|
||||
* (no staleness introduced).
|
||||
* 3. Distinct ids in flight do not cross-talk.
|
||||
*/
|
||||
|
||||
const mockedApiJson = vi.mocked(apiJson);
|
||||
|
||||
function folder(id: string): FolderItem {
|
||||
return { id, name: `Folder ${id}` } as unknown as FolderItem;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApiJson.mockReset();
|
||||
});
|
||||
|
||||
describe('getFolder in-flight dedup (benchmark gate)', () => {
|
||||
it('concurrent duplicate calls collapse to one request', async () => {
|
||||
let release!: (v: FolderItem) => void;
|
||||
mockedApiJson.mockImplementation(
|
||||
() => new Promise<FolderItem>((r) => (release = r)) as Promise<never>
|
||||
);
|
||||
|
||||
const a = getFolder('f1');
|
||||
const b = getFolder('f1');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(1); // the dedup win
|
||||
|
||||
release(folder('f1'));
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
expect(ra).toEqual(rb);
|
||||
expect(ra.id).toBe('f1');
|
||||
console.log(
|
||||
`[bench] cold deep-link double-fetch: requests BEFORE=2 AFTER=${mockedApiJson.mock.calls.length}`
|
||||
);
|
||||
});
|
||||
|
||||
it('sequential calls still refetch (freshness preserved)', async () => {
|
||||
mockedApiJson.mockResolvedValue(folder('f2') as never);
|
||||
await getFolder('f2');
|
||||
await getFolder('f2');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('distinct ids resolve independently', async () => {
|
||||
mockedApiJson.mockImplementation(((url: string) => {
|
||||
const id = String(url).split('/').pop() ?? '';
|
||||
return Promise.resolve(folder(id));
|
||||
}) as never);
|
||||
const [x, y] = await Promise.all([getFolder('fx'), getFolder('fy')]);
|
||||
expect(x.id).toBe('fx');
|
||||
expect(y.id).toBe('fy');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a failed in-flight request clears the slot so a retry refetches', async () => {
|
||||
mockedApiJson.mockRejectedValueOnce(new Error('boom') as never);
|
||||
await expect(getFolder('f3')).rejects.toThrow('boom');
|
||||
mockedApiJson.mockResolvedValue(folder('f3') as never);
|
||||
await expect(getFolder('f3')).resolves.toMatchObject({ id: 'f3' });
|
||||
});
|
||||
});
|
||||
@@ -88,23 +88,114 @@ export function getFolderName(id: string): string | undefined {
|
||||
return folderNames.get(id);
|
||||
}
|
||||
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
return folder;
|
||||
// In-flight dedup (the `resolveUser` pattern): on a cold deep-link the
|
||||
// breadcrumb builder and the drive-id resolver both request the same folder
|
||||
// concurrently — collapse duplicates into one GET. Entries only live while
|
||||
// the request is in flight, so freshness semantics are unchanged.
|
||||
const folderInflight = new Map<string, Promise<FolderItem>>();
|
||||
|
||||
export function getFolder(id: string): Promise<FolderItem> {
|
||||
const inflight = folderInflight.get(id);
|
||||
if (inflight) return inflight;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
return folder;
|
||||
} finally {
|
||||
folderInflight.delete(id);
|
||||
}
|
||||
})();
|
||||
folderInflight.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** One page of `/api/folders/{id}/resources`. */
|
||||
export interface FolderPage {
|
||||
/**
|
||||
* Items in the exact order the server returned them. Under `order_by=name`,
|
||||
* `type`, `size` the server puts folders first, then files; under
|
||||
* `modified_at` / `created_at` the two kinds interleave. Consumers that
|
||||
* need to preserve the server sort MUST iterate this list — the split
|
||||
* `folders` / `files` arrays lose the interleaving.
|
||||
*/
|
||||
items: (FolderItem | FileItem)[];
|
||||
/** `items` filtered to folder rows (order preserved). */
|
||||
folders: FolderItem[];
|
||||
/** `items` filtered to file rows (order preserved). */
|
||||
files: FileItem[];
|
||||
/** Opaque cursor for the next page; `undefined` on the last page. */
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||
* `/listing` route was removed. We page through to the end (folders sort first
|
||||
* under `order_by=name`) and split the mixed resource items back into
|
||||
* `folders` / `files`.
|
||||
* Fetch a single page of a folder's listing.
|
||||
*
|
||||
* That feed carries no whole-listing ETag, so the 304 conditional fast-path is
|
||||
* gone: `opts.etag` is accepted for call-site compatibility but ignored, and the
|
||||
* in-memory `folderCache` is what the views revalidate against. Favorite/share
|
||||
* badge sets aren't part of this feed either, so they come back empty for now.
|
||||
* `/files` uses this directly and drives its own pagination — the initial
|
||||
* `load()` requests page one; the ResourceList's `onloadmore` (fired by an
|
||||
* IntersectionObserver at the bottom sentinel) requests the next page with
|
||||
* the previous `nextCursor` and appends the results. `orderBy` is passed
|
||||
* through so pages come back in the requested server-side sort order; the
|
||||
* caller resets state and refetches page one on sort/group change.
|
||||
*
|
||||
* The legacy `fetchFolderListing` (below) is a thin loop over this — kept
|
||||
* for the move-dialog folder tree, which genuinely needs every child at
|
||||
* once and doesn't have an infinite-scroll surface.
|
||||
*/
|
||||
export async function fetchFolderPage(
|
||||
folderId: string,
|
||||
opts: {
|
||||
orderBy?: string;
|
||||
reverse?: boolean;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
forceRefresh?: boolean;
|
||||
} = {}
|
||||
): Promise<FolderPage> {
|
||||
const params = new URLSearchParams({
|
||||
order_by: opts.orderBy ?? 'name',
|
||||
limit: String(opts.limit ?? 200)
|
||||
});
|
||||
if (opts.reverse) params.set('reverse', 'true');
|
||||
if (opts.cursor) params.set('cursor', opts.cursor);
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as {
|
||||
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
const items: (FolderItem | FileItem)[] = [];
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') {
|
||||
const f = it.resource as FolderItem;
|
||||
folders.push(f);
|
||||
items.push(f);
|
||||
} else {
|
||||
const f = it.resource as FileItem;
|
||||
files.push(f);
|
||||
items.push(f);
|
||||
}
|
||||
}
|
||||
// Learn the children's names for breadcrumb resolution.
|
||||
for (const f of folders) rememberFolderName(f.id, f.name);
|
||||
return { items, folders, files, nextCursor: page.next_cursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files) by walking every
|
||||
* cursor page eagerly. Only the move-dialog tree still needs this shape —
|
||||
* `/files` switched to {@link fetchFolderPage} for lazy scroll-driven paging.
|
||||
*
|
||||
* `opts.etag` is accepted for call-site compatibility but ignored (the
|
||||
* `/resources` feed carries no whole-listing ETag). Favorite / share badge
|
||||
* sets are unpopulated by this endpoint and come back empty.
|
||||
*/
|
||||
export async function fetchFolderListing(
|
||||
folderId: string,
|
||||
@@ -114,26 +205,14 @@ export async function fetchFolderListing(
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
const page = await fetchFolderPage(folderId, {
|
||||
cursor,
|
||||
forceRefresh: opts.forceRefresh
|
||||
});
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as {
|
||||
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
folders.push(...page.folders);
|
||||
files.push(...page.files);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface ProfilePatch {
|
||||
family_name?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share?: boolean;
|
||||
/**
|
||||
* Partial patch into the opaque UI preferences bag. Server does a
|
||||
* SHALLOW merge — keys present here overwrite existing top-level
|
||||
* keys; absent keys survive. Set a key to `null` to remove it
|
||||
* (server runs `jsonb_strip_nulls` after the merge).
|
||||
*
|
||||
* Wire-side type is `Record<string, unknown>`; the typed view over
|
||||
* this bag lives in `lib/stores/preferences.svelte.ts`.
|
||||
*/
|
||||
ui_preferences?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
|
||||
@@ -30,3 +30,20 @@ export async function clearRecent(): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`clear recent failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single item from the caller's recent history — the "broom"
|
||||
* per-row affordance in the recent view. Distinct from `clearRecent`
|
||||
* (which wipes every entry). 404 means the item wasn't in recents to
|
||||
* begin with — treated as a no-op success by the caller.
|
||||
*/
|
||||
export async function removeFromRecent(kind: ItemType, id: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/recent/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`remove from recent failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the O(1) contact index behind `resolveLabel` /
|
||||
* `resolveRecipient` (recipients.ts).
|
||||
*
|
||||
* Audit finding: both resolvers ran `contactCache.find((x) => x.id === id)`
|
||||
* — a linear scan over the WHOLE system address book — once per rendered
|
||||
* grant row / lane header on /shared, and the page re-renders on every
|
||||
* infinite-scroll page and role change. Cost per frame: O(rows × directory
|
||||
* size) — ~150k comparisons for 30 rows in a 5 000-user org. The fix builds
|
||||
* a `Map<id, Contact>` once per cache identity (exactly like the existing
|
||||
* `groupCache`) and looks up O(1).
|
||||
*
|
||||
* Gates: (1) labels identical to the linear scan for present AND absent
|
||||
* ids; (2) comparison count collapses from rows×C to ~C (one index build);
|
||||
* (3) resolving a full page against a 5 000-contact directory is ≥10x
|
||||
* faster with the index.
|
||||
*/
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function contactLabel(c: Contact): { label: string; email?: string } {
|
||||
return { label: c.full_name || c.email || c.id, email: c.email };
|
||||
}
|
||||
|
||||
function directory(n: number): Contact[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `user-${i}`,
|
||||
full_name: `User Number ${i}`,
|
||||
email: `user${i}@example.com`
|
||||
}));
|
||||
}
|
||||
|
||||
/** BEFORE — verbatim resolver shape: linear `.find` per call. */
|
||||
function makeBefore(cache: Contact[], counter: { cmp: number }) {
|
||||
return (id: string): string => {
|
||||
let found: Contact | undefined;
|
||||
for (const x of cache) {
|
||||
counter.cmp++;
|
||||
if (x.id === id) {
|
||||
found = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found ? contactLabel(found).label : id;
|
||||
};
|
||||
}
|
||||
|
||||
/** AFTER — the shipped shape: identity-memoized Map index, O(1) get. */
|
||||
function makeAfter(cache: Contact[], counter: { cmp: number }) {
|
||||
let contactById: Map<string, Contact> | null = null;
|
||||
let source: Contact[] | null = null;
|
||||
const index = () => {
|
||||
if (!contactById || source !== cache) {
|
||||
contactById = new Map(
|
||||
cache.map((c) => {
|
||||
counter.cmp++;
|
||||
return [c.id, c] as const;
|
||||
})
|
||||
);
|
||||
source = cache;
|
||||
}
|
||||
return contactById;
|
||||
};
|
||||
return (id: string): string => {
|
||||
const c = index().get(id);
|
||||
return c ? contactLabel(c).label : id;
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveLabel contact index (benchmark gate)', () => {
|
||||
const C = 5_000;
|
||||
const contacts = directory(C);
|
||||
// A /shared page: 30 rows, most present, some unknown (revoked users).
|
||||
const rowIds = [
|
||||
...Array.from({ length: 26 }, (_, i) => `user-${i * 137}`),
|
||||
'ghost-1',
|
||||
'ghost-2',
|
||||
'user-4999',
|
||||
'ghost-3'
|
||||
];
|
||||
|
||||
it('labels identical to the linear scan for present and absent ids', () => {
|
||||
const before = makeBefore(contacts, { cmp: 0 });
|
||||
const after = makeAfter(contacts, { cmp: 0 });
|
||||
for (const id of rowIds) {
|
||||
expect(after(id), id).toBe(before(id));
|
||||
}
|
||||
// Absent ids fall back to the raw id in both.
|
||||
expect(after('ghost-1')).toBe('ghost-1');
|
||||
});
|
||||
|
||||
it('comparison count collapses from rows×C to one index build (~C)', () => {
|
||||
const beforeCounter = { cmp: 0 };
|
||||
const before = makeBefore(contacts, beforeCounter);
|
||||
for (const id of rowIds) before(id);
|
||||
// Linear scans: each present id walks ~id-position entries, absent
|
||||
// ids walk the full directory.
|
||||
expect(beforeCounter.cmp).toBeGreaterThan(C * 3);
|
||||
|
||||
const afterCounter = { cmp: 0 };
|
||||
const after = makeAfter(contacts, afterCounter);
|
||||
for (const id of rowIds) after(id);
|
||||
// One index build (C inserts), zero comparisons per lookup after.
|
||||
expect(afterCounter.cmp).toBe(C);
|
||||
|
||||
// A SECOND render frame re-uses the index: zero additional work.
|
||||
for (const id of rowIds) after(id);
|
||||
expect(afterCounter.cmp).toBe(C);
|
||||
});
|
||||
|
||||
it('resolving a page against a 5k directory is ≥10x faster with the index', () => {
|
||||
const frames = 50;
|
||||
|
||||
const before = makeBefore(contacts, { cmp: 0 });
|
||||
const t0 = performance.now();
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (const id of rowIds) before(id);
|
||||
}
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
const after = makeAfter(contacts, { cmp: 0 });
|
||||
const t1 = performance.now();
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (const id of rowIds) after(id);
|
||||
}
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
console.log(
|
||||
`resolveLabel ${frames} frames × ${rowIds.length} rows @ C=${C}: ` +
|
||||
`before ${beforeMs.toFixed(1)} ms, after ${afterMs.toFixed(1)} ms ` +
|
||||
`(${(beforeMs / afterMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 10);
|
||||
});
|
||||
});
|
||||
@@ -134,10 +134,26 @@ export async function ensureResolvers(): Promise<void> {
|
||||
await Promise.all([systemContacts(), loadGroups()]);
|
||||
}
|
||||
|
||||
// O(1) id→contact index over `contactCache`, built once per cache identity.
|
||||
// `resolveLabel`/`resolveRecipient` run per rendered grant row on /shared —
|
||||
// the previous `contactCache.find(...)` linear scan made each render frame
|
||||
// O(rows × directory size).
|
||||
let contactById: Map<string, Contact> | null = null;
|
||||
let contactByIdSource: Contact[] | null = null;
|
||||
|
||||
function contactIndex(): Map<string, Contact> | null {
|
||||
if (!contactCache) return null;
|
||||
if (!contactById || contactByIdSource !== contactCache) {
|
||||
contactById = new Map(contactCache.map((c) => [c.id, c]));
|
||||
contactByIdSource = contactCache;
|
||||
}
|
||||
return contactById;
|
||||
}
|
||||
|
||||
/** Resolve a subject id to a display label using the preloaded caches. */
|
||||
export function resolveLabel(type: 'user' | 'group', id: string): string {
|
||||
if (type === 'group') return groupCache?.get(id) ?? id;
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
const c = contactIndex()?.get(id);
|
||||
return c ? contactLabel(c).label : id;
|
||||
}
|
||||
|
||||
@@ -146,7 +162,7 @@ export function resolveRecipient(type: 'user' | 'group', id: string): Recipient
|
||||
if (type === 'group') {
|
||||
return { type: 'group', id, label: groupCache?.get(id) ?? id };
|
||||
}
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
const c = contactIndex()?.get(id);
|
||||
if (!c) return { type: 'user', id, label: id };
|
||||
const { label, email } = contactLabel(c);
|
||||
return { type: 'user', id, label, sublabel: email };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Round-12 §F1 — list-view thumbnail rendition (benches/ROUND12.md).
|
||||
//
|
||||
// The list rows draw file thumbnails in a 40×40 CSS-px slot (100×70 in
|
||||
// grid), but both views requested the 400px `preview` rendition. The list
|
||||
// view now requests the 150px `icon` rendition: still ≥2× device-pixel
|
||||
// density for the 40px slot, at ~1/7th of the decoded pixels (and roughly
|
||||
// icon ≈ 4-8 KB vs preview ≈ 20-40 KB encoded WebP per thumbnail).
|
||||
//
|
||||
// Gates: the URL actually switches per view; grid keeps `preview`; the
|
||||
// pixel-area saving is the documented ~7x.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fileThumbnailUrl, thumbSizeForView } from './files';
|
||||
|
||||
describe('round12 §F1 — thumbnail rendition per view', () => {
|
||||
it('list view requests the icon rendition, grid keeps preview', () => {
|
||||
expect(thumbSizeForView('list')).toBe('icon');
|
||||
expect(thumbSizeForView('grid')).toBe('preview');
|
||||
expect(fileThumbnailUrl('abc', thumbSizeForView('list'))).toBe('/api/files/abc/thumbnail/icon');
|
||||
expect(fileThumbnailUrl('abc', thumbSizeForView('grid'))).toBe(
|
||||
'/api/files/abc/thumbnail/preview'
|
||||
);
|
||||
});
|
||||
|
||||
it('icon rendition moves ~7x fewer pixels than preview for the 40px slot', () => {
|
||||
// Server renditions: icon = 150px, preview = 400px (see the photos
|
||||
// srcset: `icon 150w, preview 400w, large 800w`).
|
||||
const areaRatio = (400 * 400) / (150 * 150);
|
||||
expect(areaRatio).toBeGreaterThan(7);
|
||||
// The 40×40 slot at 2x DPR needs 80px — icon's 150px still
|
||||
// oversamples it; preview was pure waste.
|
||||
expect(150).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: SortBy;
|
||||
/** Abort the request when a newer search supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
|
||||
@@ -38,7 +40,10 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
params.append('limit', String(opts.limit ?? 100));
|
||||
params.append('offset', String(opts.offset ?? 0));
|
||||
params.append('sort_by', opts.sortBy ?? 'relevance');
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, { credentials: 'same-origin' });
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
}
|
||||
|
||||
/** A single autocomplete suggestion returned by the lightweight suggest endpoint. */
|
||||
@@ -50,6 +55,8 @@ export interface SearchSuggestions {
|
||||
export interface SuggestOptions {
|
||||
folderId?: string;
|
||||
limit?: number;
|
||||
/** Abort the request when a newer keystroke supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,13 +72,19 @@ export function searchSuggest(
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
if (opts.limit != null) params.append('limit', String(opts.limit));
|
||||
return apiJson<SearchSuggestions>(`/api/search/suggest?${params.toString()}`, {
|
||||
credentials: 'same-origin'
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
|
||||
/**
|
||||
* Clear the shared server-side search cache
|
||||
* (`DELETE /api/admin/search/cache`). Admin-only — moved from
|
||||
* `/api/search/cache` on 2026-07-17 because the underlying
|
||||
* `invalidate_all()` touches every tenant (see AuthZ audit #14).
|
||||
*/
|
||||
export async function clearSearchCache(): Promise<void> {
|
||||
const res = await apiFetch('/api/search/cache', {
|
||||
const res = await apiFetch('/api/admin/search/cache', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
@@ -111,3 +111,19 @@ export async function emptyTrash(): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/trash/drive/{drive_id}` — empty the trash within a
|
||||
* single drive. Used by the trash page's Drive group-by, where each
|
||||
* bucket header carries a per-drive Empty button so multi-drive
|
||||
* owners don't have to wipe everything at once. Refused 404 when the
|
||||
* caller lacks Delete on the named drive (anti-enum).
|
||||
*/
|
||||
export async function emptyTrashForDrive(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/trash/drive/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`empty drive trash failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,30 @@ export interface FolderItem {
|
||||
is_root: boolean;
|
||||
modified_at: number;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
// §14 provenance — who originally created the folder. `null` when
|
||||
// the creating user has since been deleted (backend FK is
|
||||
// `ON DELETE SET NULL`), or when the folder is returned to a
|
||||
// share recipient that lost provenance via
|
||||
// `FolderDto::without_hierarchy_info`. The canonical "owner"
|
||||
// signal on the Files browser / Favorites / Shared surfaces
|
||||
// (replaced the retired `owner_id` field in D7).
|
||||
created_by: string | null;
|
||||
// §14 provenance — who last touched the folder (rename / move /
|
||||
// metadata change). The canonical "who touched this recently"
|
||||
// signal on the Recent surface.
|
||||
updated_by: string | null;
|
||||
parent_id: string | null;
|
||||
path: string;
|
||||
etag: string;
|
||||
/**
|
||||
* The drive this folder belongs to (post-D0 ownership pivot per
|
||||
* `docs/plan/drive.md` §3). Populated by the backend `FolderDto`
|
||||
* on every response; the field was left out of the TS type until
|
||||
* a caller needed it. Used by `/files` to resolve the current
|
||||
* drive for the read-only banner without depending on the URL's
|
||||
* leading segment being a drive-root folder id.
|
||||
*/
|
||||
drive_id: string;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
@@ -39,7 +59,10 @@ export interface FileItem {
|
||||
mime_type: string;
|
||||
modified_at: number;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
// §14 provenance — see FolderItem for semantics. Replaced the
|
||||
// retired `owner_id` field in D7.
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
folder_id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
@@ -96,7 +119,6 @@ export interface FavoriteItem {
|
||||
icon_special_class: string;
|
||||
category: string;
|
||||
size_formatted: string;
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export interface RecentItem {
|
||||
@@ -159,6 +181,22 @@ export interface User {
|
||||
email_verified_at?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share: boolean;
|
||||
/**
|
||||
* Opaque UI preferences bag. Server-side JSONB column that persists
|
||||
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
|
||||
* across devices. The server never inspects the contents — the SPA
|
||||
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
|
||||
* typed view). Always an object on the wire (empty bag is `{}`,
|
||||
* never `null` or missing).
|
||||
*
|
||||
* When PATCHing back to the server via
|
||||
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
|
||||
* server SHALLOW-merges — only the keys present in the patch are
|
||||
* touched, so partial writes from one device don't clobber
|
||||
* preferences set on another. Set a key to `null` in the patch to
|
||||
* delete it from the bag.
|
||||
*/
|
||||
ui_preferences: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@@ -237,12 +275,63 @@ export interface Drive {
|
||||
root_folder_id: string;
|
||||
quota_bytes?: number | null;
|
||||
used_bytes: number;
|
||||
/**
|
||||
* Drive policies — raw JSONB bag from the backend. Unknown keys are
|
||||
* preserved verbatim. For the typed view used by the admin policy
|
||||
* editor, see [`DrivePolicies`].
|
||||
*/
|
||||
policies: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
caller_role?: DriveRole | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed mirror of the known drive policy keys. Every field defaults to
|
||||
* `false` (= "opted out" for the `include_in_*` keys, "allowed" for the
|
||||
* `forbid_*` keys). The wire shape returned by
|
||||
* `PATCH /api/drives/{id}/policies` carries every known key; the request
|
||||
* body uses [`DrivePoliciesPartial`] so unsupplied keys aren't disturbed
|
||||
* (the backend uses a JSONB `||` merge — see
|
||||
* `drive_pg_repository.rs::update_policies`).
|
||||
*
|
||||
* See `docs/plan/drive.md` §8 for the `forbid_*` gates and §15 for the
|
||||
* `include_in_*_index` scope flags.
|
||||
*/
|
||||
export interface DrivePolicies {
|
||||
forbid_sharing: boolean;
|
||||
forbid_external_sharing: boolean;
|
||||
forbid_public_links: boolean;
|
||||
forbid_cross_drive_move: boolean;
|
||||
forbid_owner_role_change: boolean;
|
||||
/**
|
||||
* §15 opt-in for `/api/photos` timeline scope. Default personal drives
|
||||
* are created with `true`; non-default drives (secondary personals,
|
||||
* shared) start `false` and opt in via the admin policy modal.
|
||||
*/
|
||||
include_in_photo_index: boolean;
|
||||
/**
|
||||
* §15 opt-in for the Music library surface (currently playlists;
|
||||
* future `/api/music/tracks` library view will read this too).
|
||||
* Symmetric shape to `include_in_photo_index`.
|
||||
*/
|
||||
include_in_music_index: boolean;
|
||||
/**
|
||||
* Full freeze / legal-hold. When `true`, every mutation on resources
|
||||
* in the drive is refused — user-initiated AND background alike (the
|
||||
* trash-retention purge SQL filter excludes read-only drives). Only
|
||||
* `Read` passes. Admins can un-freeze via the admin-only policy PATCH.
|
||||
* See `docs/plan/drive.md` §8 (`read_only`).
|
||||
*/
|
||||
read_only: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shape for the admin policy editor — every key optional so omitting
|
||||
* a field leaves that policy untouched (the backend uses a JSONB merge).
|
||||
*/
|
||||
export type DrivePoliciesPartial = Partial<DrivePolicies>;
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/drives` (D3a). Mirrors `CreateDriveDto` in
|
||||
* `src/interfaces/api/handlers/drive_handler.rs`. `kind: 'personal'` is a
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Two-slot action-bar layout used above every ResourceList surface.
|
||||
*
|
||||
* [ start-slot ] ← page actions / batch actions
|
||||
* [ end-slot ] ← <DisplayModeControls /> — group-by, sort, view, dotfile
|
||||
*
|
||||
* This is pure layout — no state, no visual variation per section.
|
||||
* It reuses the existing `.actions-bar` / `.action-buttons` classes
|
||||
* defined globally in `styles/ported/content.css` so it renders
|
||||
* identically to `<ListToolbar>` (the component it's replacing).
|
||||
*
|
||||
* Consumers pass whatever they want on either side; `<ResourceList>`
|
||||
* uses this internally to wire its `actions` / `batchActions` /
|
||||
* display-mode-controls snippets, and pages can also use it directly
|
||||
* when they need a bespoke layout that doesn't fit ResourceList's
|
||||
* default (e.g. `/files` upload split-button).
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Left cluster — page action buttons (or batch actions on
|
||||
* selection). Omit to render an empty placeholder that still
|
||||
* reserves the space, so the end cluster stays right-aligned. */
|
||||
start?: Snippet;
|
||||
/** Right cluster — usually a `<DisplayModeControls />` instance,
|
||||
* but any content works. */
|
||||
end?: Snippet;
|
||||
}
|
||||
|
||||
let { start, end }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="actions-bar">
|
||||
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
|
||||
{#if end}{@render end()}{/if}
|
||||
</div>
|
||||
@@ -10,10 +10,11 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { iconNameFromClass } from '$lib/utils/display';
|
||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
@@ -129,6 +130,11 @@
|
||||
let suggestOpen = $state(false);
|
||||
let suggestBusy = $state(false);
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Stale-response guard (same family as the search page): the debounce
|
||||
// spaces requests out but doesn't stop a SLOW earlier response from
|
||||
// resolving after a newer one and overwriting its suggestions.
|
||||
let suggestSeq = 0;
|
||||
let suggestInflight: AbortController | null = null;
|
||||
|
||||
function goToResults() {
|
||||
const q = searchQuery.trim();
|
||||
@@ -148,24 +154,33 @@
|
||||
if (suggestTimer) clearTimeout(suggestTimer);
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
suggestSeq++;
|
||||
suggestInflight?.abort();
|
||||
suggestInflight = null;
|
||||
suggestions = [];
|
||||
suggestOpen = false;
|
||||
return;
|
||||
}
|
||||
suggestTimer = setTimeout(async () => {
|
||||
const seq = ++suggestSeq;
|
||||
suggestInflight?.abort();
|
||||
const ctl = new AbortController();
|
||||
suggestInflight = ctl;
|
||||
suggestBusy = true;
|
||||
try {
|
||||
const r = await searchFiles(q, { recursive: true, limit: 6 });
|
||||
const r = await searchFiles(q, { recursive: true, limit: 6, signal: ctl.signal });
|
||||
if (seq !== suggestSeq) return; // superseded while awaiting
|
||||
suggestions = [
|
||||
...r.folders.slice(0, 3).map((item) => ({ kind: 'folder' as const, item })),
|
||||
...r.files.slice(0, 6).map((item) => ({ kind: 'file' as const, item }))
|
||||
];
|
||||
suggestOpen = suggestions.length > 0;
|
||||
} catch {
|
||||
if (seq !== suggestSeq || ctl.signal.aborted) return;
|
||||
suggestions = [];
|
||||
suggestOpen = false;
|
||||
} finally {
|
||||
suggestBusy = false;
|
||||
if (seq === suggestSeq) suggestBusy = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -208,6 +223,19 @@
|
||||
langOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the shortcut target is a text-input surface — <input>,
|
||||
* <textarea>, or any `contenteditable` element. Used by the
|
||||
* Cmd/Ctrl+Shift+. shortcut to defer to normal typing when the
|
||||
* user is composing text (otherwise typing `.` while holding Shift
|
||||
* in a filename dialog would fight the shortcut).
|
||||
*/
|
||||
function isTextFieldFocused(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
|
||||
}
|
||||
|
||||
async function chooseLocale(loc: Locale) {
|
||||
langOpen = false;
|
||||
await setLocale(loc);
|
||||
@@ -216,7 +244,7 @@
|
||||
const currentLang = $derived(LANGUAGES.find((l) => l.code === i18n.locale) ?? LANGUAGES[0]);
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return dateTimeFormatFor(undefined, { hour: '2-digit', minute: '2-digit' }).format(ms);
|
||||
}
|
||||
|
||||
function notifIcon(kind: string): string {
|
||||
@@ -253,6 +281,29 @@
|
||||
void palette.load();
|
||||
return;
|
||||
}
|
||||
// Cmd/Ctrl+Shift+. toggles dotfile visibility — matches macOS
|
||||
// Finder's convention. `e.code === 'Period'` targets the
|
||||
// physical key regardless of keyboard layout (Cmd+Shift+.
|
||||
// yields `.key === '>'` on some layouts). Skip when focus is
|
||||
// inside a text field so users can still type `.` in inputs.
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.shiftKey &&
|
||||
e.code === 'Period' &&
|
||||
!isTextFieldFocused(e.target)
|
||||
) {
|
||||
e.preventDefault();
|
||||
preferences.toggleHideDotfiles();
|
||||
ui.notify(
|
||||
preferences.hideDotfiles
|
||||
? t('files.dotfiles_hidden_toast', 'Dotfiles hidden')
|
||||
: t('files.dotfiles_shown_toast', 'Dotfiles shown'),
|
||||
'info',
|
||||
2000,
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
if (aboutOpen) aboutOpen = false;
|
||||
else if (searchActive) closeMobileSearch();
|
||||
@@ -821,7 +872,10 @@
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
/* Search suggestions render above `.page-sticky-header` — otherwise the
|
||||
dropdown clips under the action bar on the content pages. Design-token
|
||||
`--z-dropdown` (1000) sits above `--z-sticky` (100) by construction. */
|
||||
z-index: var(--z-dropdown);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
@@ -1066,7 +1120,8 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
/* Sits above `--z-sticky` for the same reason as `.suggest` above. */
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 12rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" module>
|
||||
/** Reuses the existing GroupOption shape from `<ListToolbar>` so
|
||||
* callers can pass the same `groupBys` arrays their pages already
|
||||
* define. Duplicated here so consumers can import a coherent set
|
||||
* without pulling in the legacy toolbar. */
|
||||
export interface GroupOption {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Right-hand cluster of display-mode controls: group-by menu,
|
||||
* sort-direction toggle, grid/list view toggle, hide-dotfiles eye.
|
||||
*
|
||||
* Every control is opt-in via its own `show…` prop so a section
|
||||
* without one (e.g. `/trash` has no dotfile toggle by design) can
|
||||
* omit the prop rather than pass an empty array or a no-op
|
||||
* callback. State bindings pass through — the parent still owns
|
||||
* `groupBy`, `reversed`, `viewMode`, etc.
|
||||
*
|
||||
* Style-wise this reuses the ported `.view-toggle` block + child
|
||||
* classes (buttons.css) so it renders identically to the
|
||||
* `<ListToolbar>` right cluster. That keeps every page's look
|
||||
* consistent across the ResourceList migration.
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
|
||||
interface Props {
|
||||
// ── Group-by ─────────────────────────────────────────────
|
||||
/** Group-by dimensions; omit or empty array = hide the control. */
|
||||
groups?: GroupOption[];
|
||||
/** Active group-by key (controlled by the parent). */
|
||||
groupBy?: string;
|
||||
/** Fired when a group-by dimension is chosen. */
|
||||
ongroup?: (key: string) => void;
|
||||
|
||||
// ── Sort direction ───────────────────────────────────────
|
||||
/** Whether the sort direction is reversed. */
|
||||
reversed?: boolean;
|
||||
/** Fired when the sort-direction toggle is clicked. */
|
||||
ondirection?: () => void;
|
||||
/** Show the sort-direction toggle. Defaults to `true` when
|
||||
* `groups` is non-empty (there's nothing to reverse otherwise). */
|
||||
showSort?: boolean;
|
||||
|
||||
// ── View mode (grid / list) ─────────────────────────────
|
||||
/** Show the grid/list view toggle. */
|
||||
showViewMode?: boolean;
|
||||
|
||||
// ── Dotfile visibility ──────────────────────────────────
|
||||
/** Show the hide-dotfiles eye toggle. Only makes sense on
|
||||
* algorithmic listings (files, recent); off by default. */
|
||||
showDotfileToggle?: boolean;
|
||||
|
||||
// ── Extension slot ──────────────────────────────────────
|
||||
/** Rendered immediately before the group-by button, still
|
||||
* inside `.view-toggle`. Kind-filter dropdowns and other
|
||||
* page-local controls that want to sit alongside the
|
||||
* built-ins land here. */
|
||||
beforeGroupBy?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
groups,
|
||||
groupBy = '',
|
||||
ongroup,
|
||||
reversed = false,
|
||||
ondirection,
|
||||
showSort,
|
||||
showViewMode = false,
|
||||
showDotfileToggle = false,
|
||||
beforeGroupBy
|
||||
}: Props = $props();
|
||||
|
||||
// Sort toggle defaults ON when a group-by list is provided —
|
||||
// there's nothing to reverse without it.
|
||||
const sortVisible = $derived(showSort ?? (groups?.length ?? 0) > 0);
|
||||
|
||||
const active = $derived(groups?.find((g) => g.key === groupBy) ?? groups?.[0]);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.group-by-selector')) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown);
|
||||
return () => window.removeEventListener('pointerdown', onDown);
|
||||
});
|
||||
|
||||
function pick(key: string) {
|
||||
menuOpen = false;
|
||||
ongroup?.(key);
|
||||
}
|
||||
|
||||
// Hide the whole cluster if nothing is enabled — the ActionBar
|
||||
// then collapses to its start-only layout without an empty
|
||||
// right block occupying space.
|
||||
const anyVisible = $derived(
|
||||
(groups?.length ?? 0) > 0 || showViewMode || showDotfileToggle || !!beforeGroupBy
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if anyVisible}
|
||||
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
|
||||
{#if beforeGroupBy}{@render beforeGroupBy()}{/if}
|
||||
{#if groups?.length}
|
||||
<div class="group-by-selector" data-testid="display-mode-groupby-menu">
|
||||
<button
|
||||
class="toggle-btn group-by-btn active"
|
||||
title={t('groupby.title', 'Group by')}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={menuOpen}
|
||||
data-testid="display-mode-groupby-btn"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<Icon name={active?.icon ?? 'layer-group'} />
|
||||
<span class="group-by-label">{active?.label ?? ''}</span>
|
||||
</button>
|
||||
{#if sortVisible}
|
||||
<button
|
||||
class="toggle-btn sort-dir-btn"
|
||||
class:active={reversed}
|
||||
title={t('sortdir.title', 'Sort direction')}
|
||||
aria-label={t('sort.direction', 'Sort direction')}
|
||||
data-testid="display-mode-sort-direction-btn"
|
||||
onclick={() => ondirection?.()}
|
||||
>
|
||||
<Icon name="arrow-up" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if menuOpen}
|
||||
<div class="group-by-menu">
|
||||
{#each groups as g (g.key)}
|
||||
<button
|
||||
class="group-by-option"
|
||||
class:active={groupBy === g.key}
|
||||
data-testid={`display-mode-groupby-${g.key}-item`}
|
||||
onclick={() => pick(g.key)}
|
||||
>
|
||||
<Icon name={g.icon ?? 'layer-group'} />
|
||||
{g.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showViewMode}<span class="view-toggle-separator"></span>{/if}
|
||||
{/if}
|
||||
{#if showViewMode}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
title={t('view.grid', 'Grid view')}
|
||||
aria-pressed={filesStore.viewMode === 'grid'}
|
||||
data-testid="display-mode-view-grid-btn"
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
>
|
||||
<Icon name="th" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
title={t('view.list', 'List view')}
|
||||
aria-pressed={filesStore.viewMode === 'list'}
|
||||
data-testid="display-mode-view-list-btn"
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
>
|
||||
<Icon name="list" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if showDotfileToggle}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={preferences.hideDotfiles}
|
||||
title={preferences.hideDotfiles
|
||||
? t('view.show_dotfiles', 'Show hidden files')
|
||||
: t('view.hide_dotfiles', 'Hide hidden files')}
|
||||
aria-pressed={preferences.hideDotfiles}
|
||||
data-testid="display-mode-dotfile-toggle-btn"
|
||||
onclick={() => preferences.toggleHideDotfiles()}
|
||||
>
|
||||
<Icon name={preferences.hideDotfiles ? 'eye-slash' : 'eye'} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -28,7 +28,8 @@ function file(over: Record<string, unknown> = {}) {
|
||||
mime_type: 'image/png',
|
||||
category: 'Image',
|
||||
folder_id: '',
|
||||
owner_id: '',
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
path: '',
|
||||
size: 1,
|
||||
modified_at: 0,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Identity chip for a group subject in share/member lists: neutral group
|
||||
* avatar, display name, "N member(s)" sublabel, and — on hover — the
|
||||
* expanded list of user members surfaced as a native `title` tooltip.
|
||||
*
|
||||
* Mirrors <UserVignette> so the two chip shapes align across share and
|
||||
* membership surfaces. Uses the preloaded group name cache
|
||||
* (`resolveRecipient` / `ensureResolvers` in `endpoints/recipients.ts`)
|
||||
* so the name lands synchronously when the parent has already primed
|
||||
* the cache; falls back to the group id while resolving.
|
||||
*
|
||||
* Members are fetched lazily on mount via `listMembers` and cached
|
||||
* per-id at the module level so multiple chips for the same group
|
||||
* share one round-trip. Nested group members (kind === 'group') expand
|
||||
* one level and surface as "+ group X" lines in the tooltip; deeper
|
||||
* expansion isn't attempted here — the read-only summary would get
|
||||
* unwieldy and the AuthZ engine expands transitively at check-time.
|
||||
*/
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { resolveRecipient } from '$lib/api/endpoints/recipients';
|
||||
import { resolveUser } from '$lib/api/endpoints/users';
|
||||
import { listMembers, type GroupMember } from '$lib/api/endpoints/groups';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
groupId: string;
|
||||
}
|
||||
let { groupId }: Props = $props();
|
||||
|
||||
const label = $derived(resolveRecipient('group', groupId).label);
|
||||
|
||||
// Module-scoped cache of resolved members per group. Chips render N
|
||||
// times on a busy /shared page; the shared cache avoids N × HTTP.
|
||||
const memberCache = new SvelteMap<string, Promise<GroupMember[]>>();
|
||||
function loadMembers(id: string): Promise<GroupMember[]> {
|
||||
let p = memberCache.get(id);
|
||||
if (!p) {
|
||||
p = listMembers(id).catch(() => [] as GroupMember[]);
|
||||
memberCache.set(id, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
let members = $state<GroupMember[]>([]);
|
||||
let memberNames = $state<string[]>([]);
|
||||
let membersLoaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
let alive = true;
|
||||
members = [];
|
||||
memberNames = [];
|
||||
membersLoaded = false;
|
||||
void loadMembers(groupId).then(async (list) => {
|
||||
if (!alive) return;
|
||||
members = list;
|
||||
// Resolve user members to display names; group members show
|
||||
// as `+ <group name>` via the recipient cache. All resolutions
|
||||
// happen in parallel; each `resolveUser` is itself cached.
|
||||
const names = await Promise.all(
|
||||
list.map(async (m) => {
|
||||
if (m.kind === 'user') {
|
||||
const u = await resolveUser(m.id).catch(() => null);
|
||||
return u?.name ?? u?.email ?? m.id;
|
||||
}
|
||||
return `+ ${resolveRecipient('group', m.id).label}`;
|
||||
})
|
||||
);
|
||||
if (!alive) return;
|
||||
memberNames = names;
|
||||
membersLoaded = true;
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
const memberCount = $derived(members.length);
|
||||
|
||||
// Native `title` tooltip carries the member list. Cheap, works on
|
||||
// keyboard focus, no popover machinery needed for a read-only chip.
|
||||
const titleText = $derived(
|
||||
!membersLoaded
|
||||
? label
|
||||
: memberNames.length === 0
|
||||
? `${label} — ${t('group.members_empty', 'No members')}`
|
||||
: `${label}\n${memberNames.join('\n')}`
|
||||
);
|
||||
|
||||
const sublabel = $derived(
|
||||
membersLoaded
|
||||
? t(
|
||||
'group.member_count',
|
||||
{ n: memberCount },
|
||||
memberCount === 1 ? '1 member' : `${memberCount} members`
|
||||
)
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="gv" title={titleText}>
|
||||
<span class="gv__avatar" aria-hidden="true">
|
||||
<Icon name="users" />
|
||||
</span>
|
||||
<span class="gv__text">
|
||||
<span class="gv__name">{label}</span>
|
||||
{#if sublabel}<span class="gv__sub">{sublabel}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.gv {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.gv__avatar {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gv__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gv__name {
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gv__sub {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Group-by dimensions; omit/empty to hide the group-by control. */
|
||||
@@ -29,6 +30,19 @@
|
||||
showViewToggle?: boolean;
|
||||
/** Left-hand actions (upload/new-folder/empty-trash/batch bar, …). */
|
||||
start?: Snippet;
|
||||
/** Right-hand extras rendered inside `.view-toggle`, immediately
|
||||
* before the group-by button. Use for page-local dropdown
|
||||
* controls (e.g. Shares' kind filter) that should sit as siblings
|
||||
* of the group-by dropdown and reuse `.toggle-btn`/`.group-by-*`
|
||||
* classes for a consistent look. */
|
||||
beforeGroupBy?: Snippet;
|
||||
/** Show the dotfile-visibility eye toggle. Opt-in per page so
|
||||
* surfaces that don't filter dotfiles (favorites, trash) don't
|
||||
* get a control that appears to do nothing. When enabled the
|
||||
* button lands at the RIGHT end of `.view-toggle` — same row as
|
||||
* grid/list — and its aria-pressed state mirrors
|
||||
* `preferences.hideDotfiles`. */
|
||||
showDotfileToggle?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +52,9 @@
|
||||
ongroup,
|
||||
ondirection,
|
||||
showViewToggle = true,
|
||||
start
|
||||
start,
|
||||
beforeGroupBy,
|
||||
showDotfileToggle = false
|
||||
}: Props = $props();
|
||||
|
||||
// The group-by button always reflects the active dimension (default = first).
|
||||
@@ -64,8 +80,9 @@
|
||||
<div class="actions-bar">
|
||||
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
|
||||
|
||||
{#if groups?.length || showViewToggle}
|
||||
{#if groups?.length || showViewToggle || beforeGroupBy || showDotfileToggle}
|
||||
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
|
||||
{#if beforeGroupBy}{@render beforeGroupBy()}{/if}
|
||||
{#if groups?.length}
|
||||
<div class="group-by-selector" data-testid="list-toolbar-groupby-menu">
|
||||
<button
|
||||
@@ -125,6 +142,28 @@
|
||||
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
|
||||
>
|
||||
{/if}
|
||||
{#if showDotfileToggle}
|
||||
<!--
|
||||
Right-most utility toggle: flip dotfile visibility for
|
||||
the current view without opening the profile page.
|
||||
`aria-pressed` reflects the persisted state (across
|
||||
sessions), matching how `preferences.hideDotfiles`
|
||||
participates in ARIA-toggle-button semantics. The
|
||||
title flips between "hide" / "show" so screen-reader
|
||||
users get an action label, not a state label.
|
||||
-->
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={preferences.hideDotfiles}
|
||||
title={preferences.hideDotfiles
|
||||
? t('view.show_dotfiles', 'Show hidden files')
|
||||
: t('view.hide_dotfiles', 'Hide hidden files')}
|
||||
aria-pressed={preferences.hideDotfiles}
|
||||
data-testid="list-toolbar-dotfile-toggle-btn"
|
||||
onclick={() => preferences.toggleHideDotfiles()}
|
||||
><Icon name={preferences.hideDotfiles ? 'eye-slash' : 'eye'} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,28 @@
|
||||
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
||||
import { moveFile } from '$lib/api/endpoints/files';
|
||||
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
||||
import type { FolderItem } from '$lib/api/types';
|
||||
import type { Drive, DriveRole, FolderItem } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
|
||||
// A drive accepts new items only if the caller can Create on its root.
|
||||
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
|
||||
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
|
||||
function isWritable(d: Drive): boolean {
|
||||
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
|
||||
}
|
||||
|
||||
// Default-personal first, then secondary personals, then shared; within
|
||||
// a group, alphabetical. Mirrors DrivePicker so the sidebar and this
|
||||
// dialog rank drives identically.
|
||||
function driveRank(d: Drive): number {
|
||||
if (d.default_for_user) return 0;
|
||||
return d.kind === 'personal' ? 1 : 2;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -34,9 +49,21 @@
|
||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||
let folders = $state<FolderItem[]>([]);
|
||||
let currentId = $state<string | null>(null);
|
||||
let selectedDriveId = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let working = $state(false);
|
||||
|
||||
const writableDrives = $derived(
|
||||
[...drivesStore.drives].filter(isWritable).sort((a, b) => {
|
||||
const r = driveRank(a) - driveRank(b);
|
||||
return r !== 0 ? r : a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
// The chip strip only earns its vertical space when there's a real
|
||||
// choice. One writable drive → identical to the single-drive UI.
|
||||
const showDriveSwitcher = $derived(writableDrives.length > 1);
|
||||
|
||||
async function loadInto(id: string) {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -50,10 +77,23 @@
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const home = await session.loadHomeFolder();
|
||||
if (!home) return;
|
||||
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
|
||||
await loadInto(home);
|
||||
await drivesStore.load();
|
||||
const home = drivesStore.findDefault();
|
||||
// Prefer the user's home drive when it's writable (covers the
|
||||
// common case: moving stuff around inside Personal). Otherwise
|
||||
// fall back to the first writable drive, sorted as above.
|
||||
const start = home && isWritable(home) ? home : writableDrives[0];
|
||||
if (!start) return;
|
||||
selectedDriveId = start.id;
|
||||
crumbs = [{ id: start.root_folder_id, name: start.name }];
|
||||
await loadInto(start.root_folder_id);
|
||||
}
|
||||
|
||||
async function switchDrive(d: Drive) {
|
||||
if (d.id === selectedDriveId) return;
|
||||
selectedDriveId = d.id;
|
||||
crumbs = [{ id: d.root_folder_id, name: d.name }];
|
||||
await loadInto(d.root_folder_id);
|
||||
}
|
||||
|
||||
function enter(f: FolderItem) {
|
||||
@@ -124,6 +164,30 @@
|
||||
|
||||
<Modal bind:open title={moveTitle}>
|
||||
<div data-testid="move-dialog">
|
||||
{#if showDriveSwitcher}
|
||||
<div
|
||||
class="mv-drives"
|
||||
role="tablist"
|
||||
aria-label={t('drive.picker', 'Drives')}
|
||||
data-testid="move-dialog-drives"
|
||||
>
|
||||
{#each writableDrives as d (d.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={d.id === selectedDriveId}
|
||||
class="mv-drive"
|
||||
class:mv-drive--active={d.id === selectedDriveId}
|
||||
data-testid={`move-dialog-drive-${d.id}`}
|
||||
onclick={() => switchDrive(d)}
|
||||
>
|
||||
<Icon name={driveIcon(d)} />
|
||||
<span>{d.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mv-nav">
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
@@ -196,6 +260,49 @@
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
/* Drive switcher: a chip strip across the top of the dialog. Hidden
|
||||
when only one writable drive is in scope (single-drive UX). */
|
||||
.mv-drives {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mv-drive {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.mv-drive:hover:not(.mv-drive--active) {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.mv-drive--active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border-color: var(--color-accent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mv-drive span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mv-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
// The dialog now sources its starting folder from the drives store
|
||||
// (D6 drive switcher), not from `session.loadHomeFolder`. We mock a
|
||||
// single default-personal drive whose `root_folder_id` is 'home' so
|
||||
// the existing assertions (listFolder('home'), moveFile('f1', 'home'))
|
||||
// stay valid without test churn.
|
||||
const { ui, drives, driveIcon } = vi.hoisted(() => {
|
||||
const homeDrive = {
|
||||
id: 'drive-home',
|
||||
root_folder_id: 'home',
|
||||
name: 'Personal',
|
||||
kind: 'personal' as const,
|
||||
default_for_user: 'user-1',
|
||||
caller_role: 'owner' as const,
|
||||
used_bytes: 0,
|
||||
quota_bytes: null
|
||||
};
|
||||
return {
|
||||
ui: { notify: vi.fn() },
|
||||
drives: {
|
||||
drives: [homeDrive],
|
||||
loaded: true,
|
||||
load: vi.fn(async () => [homeDrive]),
|
||||
findDefault: vi.fn(() => homeDrive),
|
||||
findById: vi.fn((id: string) => (id === homeDrive.id ? homeDrive : null)),
|
||||
findByRootFolderId: vi.fn((id: string) =>
|
||||
id === homeDrive.root_folder_id ? homeDrive : null
|
||||
)
|
||||
},
|
||||
driveIcon: vi.fn(() => 'home')
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/stores/drives.svelte', () => ({ drives, driveIcon }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
||||
@@ -30,7 +57,8 @@ function folder(id: string, name: string) {
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
parent_id: 'home',
|
||||
path: '/' + name,
|
||||
etag: 'e'
|
||||
|
||||
@@ -6,7 +6,10 @@ vi.mock('$lib/api/endpoints/people', () => ({
|
||||
fetchPersonPhotos: vi.fn(),
|
||||
renamePerson: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileThumbnailUrl: () => '/thumb.png' }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileThumbnailUrl: () => '/thumb.png',
|
||||
thumbSizeForView: () => 'preview' as const
|
||||
}));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() }));
|
||||
|
||||
import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import { isVideo, photoTimestamp } from '$lib/utils/media';
|
||||
|
||||
interface Props {
|
||||
@@ -47,13 +48,13 @@
|
||||
});
|
||||
|
||||
function baseMeta(p: FileItem): string {
|
||||
const dateStr = new Date(photoTimestamp(p)).toLocaleDateString(undefined, {
|
||||
const dateStr = dateTimeFormatFor(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}).format(photoTimestamp(p));
|
||||
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ vi.mock('$lib/api/endpoints/files', () => ({
|
||||
deleteFile: vi.fn(),
|
||||
fileDownloadUrl: () => '/d',
|
||||
fileInlineUrl: () => '/i',
|
||||
fileThumbnailUrl: () => '/t'
|
||||
fileThumbnailUrl: () => '/t',
|
||||
thumbSizeForView: () => 'preview' as const
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/photos', () => ({ fetchFileMetadata: vi.fn() }));
|
||||
@@ -20,7 +21,8 @@ function item(id: string) {
|
||||
mime_type: 'image/jpeg',
|
||||
category: 'Image',
|
||||
folder_id: '',
|
||||
owner_id: '',
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
path: '',
|
||||
size: 1,
|
||||
modified_at: 0,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Reusable list-of-policy-toggles.
|
||||
*
|
||||
* Consumed by:
|
||||
* - Admin "Manage policies" modal — `readonly=false`, admin edits the
|
||||
* bound `values` in place.
|
||||
* - Drive settings page (`/config/drive/{uuid}`) — `readonly=true`,
|
||||
* drive members read the currently-in-effect state.
|
||||
*
|
||||
* The shared `policyDefs` in `$lib/utils/drivePolicies` is the single
|
||||
* source of truth for label + help text + implied-by relations. Adding
|
||||
* a policy is one push there + one row in `DrivePolicies` in
|
||||
* `types.ts`; the two consuming surfaces update automatically.
|
||||
*/
|
||||
import type { DrivePoliciesPartial } from '$lib/api/types';
|
||||
import { isPolicyImplied, policyDefs, type PolicyDef } from '$lib/utils/drivePolicies';
|
||||
|
||||
interface Props {
|
||||
/** Current values displayed on each row. */
|
||||
values: Required<DrivePoliciesPartial>;
|
||||
/** `true` = display only, disables the checkboxes so members can see the
|
||||
* live state without a mutation affordance. When `true`, `onchange`
|
||||
* is ignored — the component never emits. */
|
||||
readonly?: boolean;
|
||||
/** Additional disable signal (used by the admin modal during save). */
|
||||
busy?: boolean;
|
||||
/** Prefix for the `data-testid` on each checkbox
|
||||
* (e.g. `admin-policy-…` on the admin page, `drive-policy-…` on
|
||||
* the config page). Keeps test selectors stable per surface. */
|
||||
testIdPrefix?: string;
|
||||
/** Fired when the user toggles a checkbox (mutable surface only).
|
||||
* The parent owns the storage and applies the change. Not called
|
||||
* in `readonly` mode. */
|
||||
onchange?: (key: PolicyDef['key'], next: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
values,
|
||||
readonly = false,
|
||||
busy = false,
|
||||
testIdPrefix = 'policy',
|
||||
onchange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="policy-list">
|
||||
{#each policyDefs as def (def.key)}
|
||||
{@const implied = isPolicyImplied(def, values)}
|
||||
<li class="policy-row" class:policy-row--implied={implied}>
|
||||
<label class="policy-row__label">
|
||||
<span class="policy-row__head">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`${testIdPrefix}-${def.key}`}
|
||||
checked={values[def.key]}
|
||||
disabled={readonly || busy || implied}
|
||||
onchange={(e) => onchange?.(def.key, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="policy-row__title">{def.label()}</span>
|
||||
</span>
|
||||
<span class="policy-row__help muted">
|
||||
{def.help()}
|
||||
{#if implied && def.impliedHint}
|
||||
<span class="policy-row__implied">{def.impliedHint()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
/* Ported from the admin modal's original block so the visual stays
|
||||
identical when the modal switches to this component; the read-only
|
||||
surface on `/config/drive/{uuid}` gets the same look for free. */
|
||||
.policy-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.policy-row {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.policy-row__label {
|
||||
/* Column layout: head (checkbox + title inline) on top, help
|
||||
text underneath. The checkbox + title share a row via
|
||||
`.policy-row__head` so the title sits beside the checkbox
|
||||
instead of wrapping to its own line. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policy-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.policy-row__head input[type='checkbox'] {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.policy-row__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.policy-row__help {
|
||||
/* Indent the help text under the title so the relationship is
|
||||
visually obvious. Width = checkbox width + the head's gap. */
|
||||
padding-left: calc(1rem + var(--space-2));
|
||||
}
|
||||
|
||||
/* Implied state — the row's gate is already covered by a broader
|
||||
policy (e.g. forbid_public_links when forbid_sharing is on).
|
||||
Visually dimmed so the admin understands they don't need to
|
||||
toggle it; the stored value is preserved for the moment they
|
||||
relax the parent policy. Same treatment used on the read-only
|
||||
surface so subordinate rules read as visually secondary. */
|
||||
.policy-row--implied {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.policy-row--implied .policy-row__label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.policy-row__implied {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
// Shared quota edit modal for the two admin surfaces that mutate a
|
||||
// storage cap:
|
||||
//
|
||||
// * User envelope — `PUT /api/admin/users/{id}/quota` (0 on the
|
||||
// wire = unlimited, per the backend's `check_storage_quota`
|
||||
// `quota <= 0` short-circuit).
|
||||
// * Shared drive — `PATCH /api/drives/{id}/quota` (`null` on
|
||||
// the wire = unlimited; the service also normalises 0/negative
|
||||
// to unlimited defensively).
|
||||
//
|
||||
// The wire encodings differ; the UX should not. This component
|
||||
// reuses the pre-refactor user-modal layout (single input + unit
|
||||
// dropdown + "0 for unlimited" hint) so the two surfaces share
|
||||
// their i18n keys (`admin.quota_for`, `admin.quota_label`,
|
||||
// `admin.quota_unlimited_hint`, `common.cancel`, `common.save`).
|
||||
// The save callback receives an explicit `unlimited` boolean and
|
||||
// a positive `bytes` count — the caller encodes for its own
|
||||
// endpoint (0 for users, null for drives) so the magic value
|
||||
// never spreads into the UI or shared component.
|
||||
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Modal title (e.g. "Edit quota"). */
|
||||
title: string;
|
||||
/**
|
||||
* Human-readable label of the subject whose quota is being
|
||||
* edited — a username, or a drive name. Rendered as
|
||||
* "Quota for **{subjectName}**".
|
||||
*/
|
||||
subjectName: string;
|
||||
/**
|
||||
* Current cap in bytes. `null` or `0` both render as `0`
|
||||
* in the input (which the "0 = unlimited" hint labels as
|
||||
* unlimited) — matches both endpoint conventions.
|
||||
*/
|
||||
initialBytes: number | null;
|
||||
/** Disables inputs + swaps the primary button to "Saving…". */
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onclose: () => void;
|
||||
/**
|
||||
* Called on Save. `unlimited: true` (input was `0` or
|
||||
* negative) → the caller should send whatever "unlimited"
|
||||
* means to its endpoint (0 for users, `null` for drives).
|
||||
* `unlimited: false` → `bytes` is the caller's positive
|
||||
* integer to persist verbatim.
|
||||
*/
|
||||
onsave: (result: { unlimited: boolean; bytes: number }) => void;
|
||||
/** data-testid prefix so both call-sites get stable selectors. */
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
const QUOTA_UNITS = [
|
||||
{ value: 1024 ** 2, label: 'MB' },
|
||||
{ value: 1024 ** 3, label: 'GB' },
|
||||
{ value: 1024 ** 4, label: 'TB' }
|
||||
] as const;
|
||||
|
||||
let {
|
||||
open,
|
||||
title,
|
||||
subjectName,
|
||||
initialBytes,
|
||||
busy = false,
|
||||
error = null,
|
||||
onclose,
|
||||
onsave,
|
||||
testIdPrefix = 'quota'
|
||||
}: Props = $props();
|
||||
|
||||
// Local draft state — the parent owns `initialBytes` and can pass
|
||||
// a new value on every open; `$effect` resets the draft whenever
|
||||
// `open` transitions to true so a re-open shows fresh state
|
||||
// instead of the last edit.
|
||||
let value = $state(0);
|
||||
let unit = $state<number>(1024 ** 3);
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
const bytes = initialBytes ?? 0;
|
||||
// Pick the largest unit that yields a value >= 1 so the
|
||||
// number stays readable; fall back to GB for the
|
||||
// unlimited case so the form is filled with a sane default.
|
||||
if (bytes >= 1024 ** 4) {
|
||||
unit = 1024 ** 4;
|
||||
} else if (bytes >= 1024 ** 3 || bytes === 0) {
|
||||
unit = 1024 ** 3;
|
||||
} else {
|
||||
unit = 1024 ** 2;
|
||||
}
|
||||
value = bytes > 0 ? Math.round((bytes / unit) * 10) / 10 : 0;
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (value <= 0) {
|
||||
onsave({ unlimited: true, bytes: 0 });
|
||||
} else {
|
||||
onsave({ unlimited: false, bytes: Math.round(value * unit) });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {open} {title} {onclose}>
|
||||
<form id="quota-editor-form" class="form" data-testid={`${testIdPrefix}-form`} onsubmit={submit}>
|
||||
<p class="muted">
|
||||
{t('admin.quota_for', 'Quota for')} <strong>{subjectName}</strong>
|
||||
</p>
|
||||
<label>
|
||||
<span>{t('admin.quota_label', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input
|
||||
type="number"
|
||||
data-testid={`${testIdPrefix}-value-input`}
|
||||
min="0"
|
||||
step="0.1"
|
||||
bind:value
|
||||
disabled={busy}
|
||||
/>
|
||||
<select bind:value={unit} data-testid={`${testIdPrefix}-unit-select`} disabled={busy}>
|
||||
{#each QUOTA_UNITS as u (u.label)}
|
||||
<option value={u.value}>{u.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<span class="muted">{t('admin.quota_unlimited_hint', 'Set to 0 for unlimited')}</span>
|
||||
</label>
|
||||
{#if error}
|
||||
<p class="status--error">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-cancel-btn`}
|
||||
onclick={onclose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="quota-editor-form"
|
||||
data-testid={`${testIdPrefix}-save-btn`}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
/* Svelte 5 scopes styles per component, and CSS classes used
|
||||
in this file's markup — including inside `{#snippet footer}`
|
||||
— carry this component's hash. The local `.btn` / `.form` /
|
||||
`.quota-input` / `.muted` / `.status--error` / `.btn--primary`
|
||||
rules on `/admin/+page.svelte` don't reach across; mirror them
|
||||
here so the QuotaEditor visually matches every other modal in
|
||||
the admin panel (same paddings, colours, primary button look). */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.quota-input {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.quota-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.status--error {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-light);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Read-only drive banner.
|
||||
*
|
||||
* Rendered at the top of any page whose content lives in (or is scoped
|
||||
* to) a drive whose `policies.read_only === true`. Members see the
|
||||
* banner and understand why upload / rename / delete / share
|
||||
* affordances elsewhere in the app fail with a generic error toast —
|
||||
* the backend engine gate refuses every non-`Read` permission on
|
||||
* resources in the drive.
|
||||
*
|
||||
* Only `Read` permissions pass; the banner does not need to gate any
|
||||
* behavior itself. It's pure signage. Backed by
|
||||
* `docs/plan/drive.md` §8 (`read_only`).
|
||||
*
|
||||
* Consumed by:
|
||||
* - `routes/config/drive/[uuid]/+page.svelte` — always shown when
|
||||
* the drive being configured is frozen.
|
||||
* - `routes/files/[...path]/+page.svelte` — shown when the current
|
||||
* folder's owning drive is frozen (parent looks up drive via
|
||||
* `drives.findByRootFolderId`/`findById`).
|
||||
* - Future: `/photos`, `/music`, and any other drive-scoped views.
|
||||
*/
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Drive-name shown in the body so members know which drive the
|
||||
* freeze applies to. Optional — omit on pages where the drive is
|
||||
* implicit from context (e.g. the drive's own config page). */
|
||||
driveName?: string;
|
||||
}
|
||||
|
||||
let { driveName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="read-only-banner"
|
||||
role="region"
|
||||
aria-label={t('drive.read_only_banner.aria', 'This drive is read-only')}
|
||||
data-testid="read-only-banner"
|
||||
>
|
||||
<div class="read-only-banner__icon" aria-hidden="true">
|
||||
<Icon name="lock" />
|
||||
</div>
|
||||
<div class="read-only-banner__body">
|
||||
<strong>
|
||||
{#if driveName}
|
||||
{t(
|
||||
'drive.read_only_banner.title_named',
|
||||
{ name: driveName },
|
||||
'Drive "{{name}}" is read-only'
|
||||
)}
|
||||
{:else}
|
||||
{t('drive.read_only_banner.title', 'This drive is read-only')}
|
||||
{/if}
|
||||
</strong>
|
||||
<span>
|
||||
{t(
|
||||
'drive.read_only_banner.body',
|
||||
'Uploads, edits, deletes, renames, sharing and membership changes are refused. Reads and downloads keep working. Contact an administrator to un-freeze the drive.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Shape matches the sibling upgrade-banner in
|
||||
`routes/shared-with-me/+page.svelte` so the two banners read as
|
||||
the same family; only the accent shifts to communicate "info /
|
||||
frozen" rather than "action / upgrade." */
|
||||
.read-only-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.read-only-banner__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.read-only-banner__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.read-only-banner__body strong {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.read-only-banner__body span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.read-only-banner {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,18 @@
|
||||
{#each ui.toasts as toast (toast.id)}
|
||||
<div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
|
||||
<span class="toast__msg">{toast.message}</span>
|
||||
{#if toast.action}
|
||||
<button
|
||||
class="toast__action"
|
||||
data-testid={`toaster-action-btn-${toast.id}`}
|
||||
onclick={() => {
|
||||
toast.action?.onClick();
|
||||
ui.dismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="toast__close"
|
||||
data-testid={`toaster-dismiss-btn-${toast.id}`}
|
||||
@@ -73,6 +85,22 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast__action {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast__action:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gates for two per-page derive cleanups (round 9):
|
||||
*
|
||||
* [1] ResourceList's selection-prune `$effect` built an O(N) id `Set` on
|
||||
* EVERY `items` change (every infinite-scroll page) even when nothing
|
||||
* was selected — the loop it feeds never runs in that case. The shipped
|
||||
* guard (`if (selected.size === 0) return`) makes the empty-selection
|
||||
* page append free while keeping the pruned result byte-identical when
|
||||
* a selection exists.
|
||||
*
|
||||
* [2] The photos timeline derive called `window.matchMedia(...)` on every
|
||||
* recompute (every 60-photo page append) for a boolean that changes
|
||||
* only on viewport-class crossings. The shipped code hoists it into
|
||||
* state fed by a single MediaQueryList `change` listener.
|
||||
*
|
||||
* Both are modeled as pure replicas of the effect/derive bodies (no jsdom
|
||||
* mounting needed) with instrumentation counters, mirroring the shipped
|
||||
* control flow exactly.
|
||||
*/
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const page = (start: number, n: number): Item[] =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `it-${start + i}` }));
|
||||
|
||||
/** BEFORE — verbatim effect body: unconditional Set build. */
|
||||
function pruneBefore(items: Item[], selected: Set<string>, counter: { setBuilds: number }) {
|
||||
counter.setBuilds++;
|
||||
const ids = new Set(items.map((i) => i.id));
|
||||
for (const id of [...selected]) {
|
||||
if (!ids.has(id)) selected.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** AFTER — the shipped body: skip entirely while nothing is selected. */
|
||||
function pruneAfter(items: Item[], selected: Set<string>, counter: { setBuilds: number }) {
|
||||
if (selected.size === 0) return;
|
||||
counter.setBuilds++;
|
||||
const ids = new Set(items.map((i) => i.id));
|
||||
for (const id of [...selected]) {
|
||||
if (!ids.has(id)) selected.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
describe('selection-prune guard (benchmark gate)', () => {
|
||||
it('empty selection: zero Set builds across a 100-page drain (was 100)', () => {
|
||||
const beforeCounter = { setBuilds: 0 };
|
||||
const afterCounter = { setBuilds: 0 };
|
||||
let items: Item[] = [];
|
||||
for (let p = 0; p < 100; p++) {
|
||||
items = [...items, ...page(p * 50, 50)];
|
||||
pruneBefore(items, new Set(), beforeCounter);
|
||||
pruneAfter(items, new Set(), afterCounter);
|
||||
}
|
||||
expect(beforeCounter.setBuilds).toBe(100);
|
||||
expect(afterCounter.setBuilds).toBe(0);
|
||||
});
|
||||
|
||||
it('active selection: pruned set identical to the unguarded version', () => {
|
||||
const items = page(0, 200);
|
||||
// Selection holds survivors + ids that vanished on reload.
|
||||
const seed = ['it-3', 'it-77', 'gone-1', 'it-150', 'gone-2'];
|
||||
const a = new Set(seed);
|
||||
const b = new Set(seed);
|
||||
pruneBefore(items, a, { setBuilds: 0 });
|
||||
pruneAfter(items, b, { setBuilds: 0 });
|
||||
expect([...b].sort()).toEqual([...a].sort());
|
||||
expect(b.has('gone-1')).toBe(false);
|
||||
expect(b.has('it-3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── [2] matchMedia hoist ────────────────────────────────────────────────────
|
||||
|
||||
interface MqlStub {
|
||||
matches: boolean;
|
||||
listeners: ((e: { matches: boolean }) => void)[];
|
||||
}
|
||||
|
||||
function makeMatchMedia(counter: { calls: number }, stub: MqlStub) {
|
||||
return () => {
|
||||
counter.calls++;
|
||||
return {
|
||||
get matches() {
|
||||
return stub.matches;
|
||||
},
|
||||
addEventListener: (_: 'change', fn: (e: { matches: boolean }) => void) => {
|
||||
stub.listeners.push(fn);
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe('photos matchMedia hoist (benchmark gate)', () => {
|
||||
it('P recomputes: 1 matchMedia call instead of P, identical booleans', () => {
|
||||
const P = 50;
|
||||
const stub: MqlStub = { matches: false, listeners: [] };
|
||||
|
||||
// BEFORE — the derive body queries per recompute.
|
||||
const beforeCounter = { calls: 0 };
|
||||
const mmBefore = makeMatchMedia(beforeCounter, stub);
|
||||
const beforeValues: boolean[] = [];
|
||||
for (let i = 0; i < P; i++) {
|
||||
beforeValues.push(mmBefore().matches);
|
||||
}
|
||||
expect(beforeCounter.calls).toBe(P);
|
||||
|
||||
// AFTER — one query + listener; recomputes read the state boolean.
|
||||
const afterCounter = { calls: 0 };
|
||||
const mmAfter = makeMatchMedia(afterCounter, stub);
|
||||
const mql = mmAfter();
|
||||
let isMobile = mql.matches;
|
||||
mql.addEventListener('change', (e) => {
|
||||
isMobile = e.matches;
|
||||
});
|
||||
const afterValues: boolean[] = [];
|
||||
for (let i = 0; i < P; i++) {
|
||||
afterValues.push(isMobile);
|
||||
}
|
||||
expect(afterCounter.calls).toBe(1);
|
||||
expect(afterValues).toEqual(beforeValues);
|
||||
|
||||
// A viewport-class crossing propagates through the listener.
|
||||
stub.matches = true;
|
||||
for (const fn of stub.listeners) fn({ matches: true });
|
||||
expect(isMobile).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gates for the round-11 SPA items (see benches/ROUND11.md):
|
||||
*
|
||||
* [1] `ResourceList.selectedEntries` re-filtered the ENTIRE items array on
|
||||
* every selection change once the batch toolbar was mounted — and the
|
||||
* favorites/recent hosts ignored the snippet param and recomputed their
|
||||
* own `entries.filter(...)` shadow, so each toggle ran TWO full O(N)
|
||||
* scans (O(N²)-ish across a shift-range gesture). The shipped shape
|
||||
* derives an id→index Map (rebuilt only when `items` changes) and
|
||||
* projects the selection in O(k · log k), preserving item order; hosts
|
||||
* now consume the snippet param.
|
||||
*
|
||||
* [2] The Recent page mapper baked `favoriteIds.has(id)` into every entry,
|
||||
* subscribing the whole O(N) map to the SvelteSet — one star click
|
||||
* rebuilt all N entries and re-rendered every visible row. The shipped
|
||||
* shape reads membership in the star widget via ResourceList's new
|
||||
* `favoriteIds` prop, so the mapper no longer depends on the set.
|
||||
*
|
||||
* [3] The admin "time ago" >30-day fallback called `toLocaleDateString()`
|
||||
* (a fresh Intl.DateTimeFormat per call) instead of the cached
|
||||
* `dateTimeFormatFor` the rest of the app uses.
|
||||
*
|
||||
* All modeled as pure replicas of the derive bodies with instrumentation
|
||||
* counters (the listDerives.bench.test.ts convention).
|
||||
*/
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const buildItems = (n: number): Entry[] =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `it-${i}`, name: `Item ${i}` }));
|
||||
|
||||
/** BEFORE — component derive + host shadow, each a full O(N) scan. */
|
||||
function selectedBefore(
|
||||
items: Entry[],
|
||||
selected: Set<string>,
|
||||
counter: { comparisons: number }
|
||||
): { component: Entry[]; host: Entry[] } {
|
||||
const component = items.filter((i) => {
|
||||
counter.comparisons++;
|
||||
return selected.has(i.id);
|
||||
});
|
||||
const host = items.filter((i) => {
|
||||
counter.comparisons++;
|
||||
return selected.has(i.id);
|
||||
});
|
||||
return { component, host };
|
||||
}
|
||||
|
||||
/** AFTER — id→index Map projection, index rebuilt only on items change. */
|
||||
function makeAfterProjector(items: Entry[]) {
|
||||
const indexById = new Map(items.map((i, idx) => [i.id, idx]));
|
||||
return (selected: Set<string>, counter: { comparisons: number }): Entry[] => {
|
||||
const picked: { idx: number; item: Entry }[] = [];
|
||||
for (const id of selected) {
|
||||
counter.comparisons++;
|
||||
const idx = indexById.get(id);
|
||||
if (idx !== undefined) picked.push({ idx, item: items[idx] });
|
||||
}
|
||||
picked.sort((a, b) => a.idx - b.idx);
|
||||
return picked.map((p) => p.item);
|
||||
};
|
||||
}
|
||||
|
||||
describe('ResourceList selectedEntries projection (benchmark gate)', () => {
|
||||
it('identical output (order + membership) and O(k) vs O(2N) comparisons per toggle', () => {
|
||||
const N = 2000;
|
||||
const items = buildItems(N);
|
||||
const project = makeAfterProjector(items);
|
||||
|
||||
// Model a 50-item shift-range selection built one id at a time,
|
||||
// re-deriving after each toggle (what the reactive graph does).
|
||||
const selected = new Set<string>();
|
||||
const beforeCounter = { comparisons: 0 };
|
||||
const afterCounter = { comparisons: 0 };
|
||||
// Insert in REVERSE order so selection order ≠ item order — the
|
||||
// order-preservation gate below must still hold. Each toggle
|
||||
// re-derives both shapes (what the reactive graph does).
|
||||
for (let i = 149; i >= 100; i--) {
|
||||
selected.add(`it-${i}`);
|
||||
selectedBefore(items, selected, beforeCounter);
|
||||
project(selected, afterCounter);
|
||||
}
|
||||
// Stale ids (deleted rows) must be dropped by both shapes.
|
||||
selected.add('it-ghost');
|
||||
const lastBefore = selectedBefore(items, selected, beforeCounter).component;
|
||||
const lastAfter = project(selected, afterCounter);
|
||||
|
||||
expect(lastAfter).toEqual(lastBefore); // same entries, same (item) order
|
||||
// BEFORE: 2 scans × N per toggle. AFTER: k probes per toggle.
|
||||
expect(beforeCounter.comparisons).toBe(51 * 2 * N);
|
||||
expect(afterCounter.comparisons).toBeLessThan(51 * 51 + 1);
|
||||
});
|
||||
|
||||
it('wall clock: 500-toggle sweep on a 5k list is faster with the projection', () => {
|
||||
const N = 5000;
|
||||
const items = buildItems(N);
|
||||
const project = makeAfterProjector(items);
|
||||
const selected = new Set<string>();
|
||||
const nul = { comparisons: 0 };
|
||||
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
selected.add(`it-${i}`);
|
||||
selectedBefore(items, selected, nul);
|
||||
}
|
||||
const tBefore = performance.now() - t0;
|
||||
|
||||
selected.clear();
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
selected.add(`it-${i}`);
|
||||
project(selected, nul);
|
||||
}
|
||||
const tAfter = performance.now() - t1;
|
||||
|
||||
// Generous bound to keep CI stable; locally ~10-40x.
|
||||
expect(tAfter).toBeLessThan(tBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── [2] Recent favorite-star dependency ────────────────────────────────────
|
||||
|
||||
interface RawItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** BEFORE — mapper reads the favorite set: every toggle re-maps ALL rows. */
|
||||
function entriesBefore(
|
||||
raw: RawItem[],
|
||||
favoriteIds: Set<string>,
|
||||
counter: { mapperRows: number }
|
||||
): { id: string; isFavorite: boolean }[] {
|
||||
return raw.map((it) => {
|
||||
counter.mapperRows++;
|
||||
return { id: it.id, isFavorite: favoriteIds.has(it.id) };
|
||||
});
|
||||
}
|
||||
|
||||
/** AFTER — mapper is set-independent; the star widget reads membership. */
|
||||
function entriesAfter(raw: RawItem[], counter: { mapperRows: number }): { id: string }[] {
|
||||
return raw.map((it) => {
|
||||
counter.mapperRows++;
|
||||
return { id: it.id };
|
||||
});
|
||||
}
|
||||
function starStateAfter(favoriteIds: Set<string>, id: string): boolean {
|
||||
return favoriteIds.has(id);
|
||||
}
|
||||
|
||||
describe('Recent favorite-star fine-grained dependency (benchmark gate)', () => {
|
||||
it('a star toggle re-maps 0 rows (was N) and renders the same star states', () => {
|
||||
const N = 400;
|
||||
const raw: RawItem[] = Array.from({ length: N }, (_, i) => ({
|
||||
id: `r-${i}`,
|
||||
name: `File ${i}`
|
||||
}));
|
||||
const favoriteIds = new Set<string>(['r-3']);
|
||||
|
||||
const beforeCounter = { mapperRows: 0 };
|
||||
const afterCounter = { mapperRows: 0 };
|
||||
|
||||
// Initial render: both shapes map all rows once.
|
||||
let entriesB = entriesBefore(raw, favoriteIds, beforeCounter);
|
||||
const entriesA = entriesAfter(raw, afterCounter);
|
||||
expect(beforeCounter.mapperRows).toBe(N);
|
||||
expect(afterCounter.mapperRows).toBe(N);
|
||||
|
||||
// 10 star toggles. BEFORE: the mapper depends on the set → full
|
||||
// re-map each time. AFTER: the mapper doesn't run at all.
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const id = `r-${k * 7}`;
|
||||
if (favoriteIds.has(id)) favoriteIds.delete(id);
|
||||
else favoriteIds.add(id);
|
||||
entriesB = entriesBefore(raw, favoriteIds, beforeCounter); // reactive re-run
|
||||
// AFTER: no mapper re-run; only the affected star re-reads.
|
||||
starStateAfter(favoriteIds, id);
|
||||
}
|
||||
|
||||
expect(beforeCounter.mapperRows).toBe(N + 10 * N);
|
||||
expect(afterCounter.mapperRows).toBe(N); // unchanged since initial render
|
||||
|
||||
// Gate: identical star state for every row under the AFTER shape.
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(starStateAfter(favoriteIds, entriesA[i].id)).toBe(entriesB[i].isFavorite);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── [3] admin timeAgo date fallback ────────────────────────────────────────
|
||||
|
||||
describe('admin timeAgo >30d fallback formatter cache (benchmark gate)', () => {
|
||||
it('cached formatter output is identical to toLocaleDateString()', async () => {
|
||||
const { dateTimeFormatFor } = await import('../utils/display');
|
||||
const dates = [
|
||||
new Date('2025-01-15T10:30:00Z'),
|
||||
new Date('2024-12-31T23:59:59Z'),
|
||||
new Date('2020-06-01T00:00:00Z'),
|
||||
new Date('1999-02-28T12:00:00Z')
|
||||
];
|
||||
for (const d of dates) {
|
||||
expect(dateTimeFormatFor(undefined).format(d)).toBe(d.toLocaleDateString());
|
||||
}
|
||||
});
|
||||
|
||||
it('1000 formats construct ≤1 Intl.DateTimeFormat (was 1000)', async () => {
|
||||
const { dateTimeFormatFor } = await import('../utils/display');
|
||||
const RealDTF = Intl.DateTimeFormat;
|
||||
let constructed = 0;
|
||||
// Count constructions through both paths.
|
||||
const Counting = new Proxy(RealDTF, {
|
||||
construct(target, args: [string?, Intl.DateTimeFormatOptions?]) {
|
||||
constructed++;
|
||||
return new target(...args);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Intl as any).DateTimeFormat = Counting;
|
||||
try {
|
||||
const d = new Date('2020-06-01T00:00:00Z');
|
||||
constructed = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
d.toLocaleDateString();
|
||||
}
|
||||
// jsdom implements toLocaleDateString via Intl internally in some
|
||||
// versions; count only if observable. The load-bearing assertion
|
||||
// is the cached path below.
|
||||
const beforeConstructed = constructed;
|
||||
|
||||
constructed = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
dateTimeFormatFor(undefined).format(d);
|
||||
}
|
||||
expect(constructed).toBeLessThanOrEqual(1);
|
||||
// When the environment exposes per-call constructions, require
|
||||
// the cached path to be strictly cheaper.
|
||||
if (beforeConstructed > 1) {
|
||||
expect(constructed).toBeLessThan(beforeConstructed);
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Intl as any).DateTimeFormat = RealDTF;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// Round-13 §V1 — grouped views are windowed (benches/ROUND13.md).
|
||||
//
|
||||
// Before this round, the grouped GRID path mounted EVERY card:
|
||||
// `{#each sections}{#each section.rows}{@render row}` with no windowing
|
||||
// (the grouped-by-default trash grid, and the files route's grouped grid,
|
||||
// were the last unwindowed paths). Now each swimlane feeds its own windowed
|
||||
// <VirtualList> — a flex stack of (header + windowed card grid) per section
|
||||
// — so only a viewport-bounded slice of `.file-item` cards is realized,
|
||||
// regardless of group size.
|
||||
//
|
||||
// Gate: render the real ResourceList in grouped GRID mode with N=800 items
|
||||
// in one bucket and assert the mounted card count is viewport-bounded, not
|
||||
// N. jsdom does no layout, so VirtualList's visible band is a small constant
|
||||
// — the same lever the round-12 files page test documents.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileThumbnailUrl: () => '/thumb',
|
||||
thumbSizeForView: () => 'preview' as const
|
||||
}));
|
||||
|
||||
import ResourceList from './ResourceList.svelte';
|
||||
import type { GroupByDef } from './ResourceList.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
|
||||
interface TestFile {
|
||||
category: string;
|
||||
created_at: number;
|
||||
icon_class: string;
|
||||
icon_special_class: string;
|
||||
id: string;
|
||||
mime_type: string;
|
||||
modified_at: number;
|
||||
name: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
folder_id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
size_formatted: string;
|
||||
sort_date: number;
|
||||
etag: string;
|
||||
content_hash: string;
|
||||
}
|
||||
|
||||
function fileItem(i: number): TestFile {
|
||||
return {
|
||||
category: 'Document',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id: `f${i}`,
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name: `file-${i}.txt`,
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
folder_id: 'home',
|
||||
path: `/file-${i}.txt`,
|
||||
size: 4,
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
};
|
||||
}
|
||||
|
||||
// Single bucket → one big swimlane (the worst case the old grid mounted whole).
|
||||
const groupBys: GroupByDef[] = [
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
orderBy: 'name',
|
||||
bucketOf: (item) => (item as TestFile).category ?? 'other',
|
||||
labelOf: (k) => k
|
||||
}
|
||||
];
|
||||
|
||||
describe('round13 §V1 — grouped grid is windowed', () => {
|
||||
beforeEach(() => {
|
||||
filesStore.viewMode = 'grid';
|
||||
});
|
||||
|
||||
it('mounts a viewport-bounded slice of cards, not all N, in grouped grid', () => {
|
||||
const N = 800;
|
||||
const items = Array.from({ length: N }, (_, i) => fileItem(i));
|
||||
const { container } = render(ResourceList, {
|
||||
props: {
|
||||
title: 'Round13',
|
||||
items,
|
||||
groupBys,
|
||||
groupBy: 'type',
|
||||
selectable: true,
|
||||
actions: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const mounted = container.querySelectorAll('.file-item').length;
|
||||
// A swimlane header confirms we are on the grouped path.
|
||||
expect(container.querySelectorAll('.rl-swimlane-header').length).toBeGreaterThan(0);
|
||||
// Windowed: the visible band is viewport+overscan bounded, far below N.
|
||||
// (The pre-fix grid-grouped path mounted all 800.)
|
||||
expect(mounted).toBeGreaterThan(0);
|
||||
expect(mounted).toBeLessThan(120);
|
||||
expect(mounted).toBeLessThan(N / 4);
|
||||
});
|
||||
|
||||
it('full scroll height is still reserved (windowing spacer, not truncation)', () => {
|
||||
const N = 800;
|
||||
const items = Array.from({ length: N }, (_, i) => fileItem(i));
|
||||
const { container } = render(ResourceList, {
|
||||
props: { title: 'Round13', items, groupBys, groupBy: 'type', selectable: true }
|
||||
});
|
||||
// The VirtualList reserves total height via its `.vlist` spacer so the
|
||||
// scrollbar / end-of-list sentinel keep working — height must scale with
|
||||
// N, proving cards weren't simply dropped.
|
||||
const vlist = container.querySelector('.vlist') as HTMLElement | null;
|
||||
expect(vlist).not.toBeNull();
|
||||
const reserved = parseFloat(vlist!.style.height || '0');
|
||||
expect(reserved).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Round-14 frontend micro-pack (benches/ROUND14.md §F1, §F2).
|
||||
//
|
||||
// Each section is BEFORE (verbatim replica of the shipped shape) vs AFTER
|
||||
// (proposed shape), with an equivalence gate and a wall-time perf gate — the
|
||||
// same discipline as the Rust micro-packs: an AFTER that doesn't beat its
|
||||
// BEFORE fails the gate.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [F2] favorites `favoriteIds` — rebuild-a-fresh-Set-per-page vs incremental
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Audit finding: the favorites route derived `favoriteIds = new SvelteSet(
|
||||
// items.map(i => i.id))`. Every infinite-scroll page (`raw = [...raw, ...page]`)
|
||||
// rebuilt a brand-new set over the WHOLE accumulated list — O(N) per page,
|
||||
// O(N²) across a P-page drain — and, being a new instance each page,
|
||||
// invalidated every mounted star reader. The fix keeps one persistent set and
|
||||
// `add`s only the fresh page's ids (clear on reset). Since every item on the
|
||||
// page is a favorite and removed items aren't rendered, the set only has to be
|
||||
// a superset of the displayed ids, so `add`-only is correct.
|
||||
|
||||
/** A page of ids (50/page, the default page size). */
|
||||
function pageOf(start: number, n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => `fav-${start + i}`);
|
||||
}
|
||||
|
||||
/** BEFORE: rebuild a fresh Set over the whole accumulated list each page. */
|
||||
function rebuildPerPage(pages: string[][]): Set<string> {
|
||||
let acc: string[] = [];
|
||||
let set = new Set<string>();
|
||||
for (const page of pages) {
|
||||
acc = [...acc, ...page]; // the route's `raw = [...raw, ...page]`
|
||||
set = new Set(acc.map((id) => id)); // new instance + O(N) rebuild
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** AFTER: one persistent set, add only the fresh page's ids. */
|
||||
function incrementalPerPage(pages: string[][]): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const id of page) set.add(id);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
describe('round14 §F2 — favorites favoriteIds incremental set', () => {
|
||||
it('final membership is identical (equivalence gate)', () => {
|
||||
const pages = Array.from({ length: 20 }, (_, p) => pageOf(p * 50, 50));
|
||||
const before = rebuildPerPage(pages);
|
||||
const after = incrementalPerPage(pages);
|
||||
expect(after.size).toBe(before.size);
|
||||
for (const id of before) expect(after.has(id)).toBe(true);
|
||||
for (const id of after) expect(before.has(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('a P-page drain builds the set ≥5x faster incrementally (perf gate)', () => {
|
||||
const PAGES = 40;
|
||||
const PER = 50; // 2 000 items total
|
||||
const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER));
|
||||
|
||||
const run = (f: (p: string[][]) => Set<string>): number => {
|
||||
const t0 = performance.now();
|
||||
// A few repetitions so the measurement isn't dominated by timer noise.
|
||||
for (let r = 0; r < 20; r++) f(pages);
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(rebuildPerPage);
|
||||
run(incrementalPerPage);
|
||||
const beforeMs = run(rebuildPerPage);
|
||||
const afterMs = run(incrementalPerPage);
|
||||
|
||||
console.info(
|
||||
`§F2 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [F1] t() params — throwaway `{}` per call vs a shared frozen empty object
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The ubiquitous inline-fallback form `t('k', 'Fallback')` and the bare
|
||||
// `t('k')` (default param `= {}`) allocated a fresh params object on every
|
||||
// call, though for a cache-hit string with no `{{…}}` `interpolate` returns
|
||||
// before ever reading params. t() runs ~10×/row. The fix hoists a shared
|
||||
// frozen `EMPTY_PARAMS` for both no-param branches.
|
||||
|
||||
const EMPTY_PARAMS: Record<string, unknown> = Object.freeze({});
|
||||
|
||||
/** Model of the shipped t() param selection + a representative params read
|
||||
* (interpolate's `params[name]` lookup), isolated from dictionary I/O. */
|
||||
function tBefore(paramsOrFallback: string | Record<string, unknown> = {}): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['n'];
|
||||
}
|
||||
function tAfter(paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['n'];
|
||||
}
|
||||
|
||||
describe('round14 §F1 — t() shared empty params', () => {
|
||||
it('produces identical results for the no-param call forms (equivalence gate)', () => {
|
||||
expect(tAfter()).toBe(tBefore());
|
||||
expect(tAfter('Owner')).toBe(tBefore('Owner'));
|
||||
expect(tAfter({ n: 5 })).toBe(tBefore({ n: 5 }));
|
||||
});
|
||||
|
||||
it('the string/bare forms are not slower with a shared empty (perf gate)', () => {
|
||||
const N = 4_000_000;
|
||||
const run = (f: (a?: string | Record<string, unknown>) => unknown): number => {
|
||||
let sink: unknown;
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Alternate the two no-param call forms (bare + string fallback).
|
||||
sink = i & 1 ? f('Fallback') : f();
|
||||
}
|
||||
void sink;
|
||||
return performance.now() - t0;
|
||||
};
|
||||
// Warm-up then measure (best-of-3 to damp GC/JIT noise).
|
||||
run(tBefore);
|
||||
run(tAfter);
|
||||
const beforeMs = Math.min(run(tBefore), run(tBefore), run(tBefore));
|
||||
const afterMs = Math.min(run(tAfter), run(tAfter), run(tAfter));
|
||||
console.info(
|
||||
`§F1 ${N} no-param t() calls: fresh {} ${beforeMs.toFixed(1)} ms vs shared frozen ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
||||
);
|
||||
// Zero-risk alloc reduction: the shared-empty arm must be no slower.
|
||||
expect(afterMs).toBeLessThanOrEqual(beforeMs * 1.05);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// Round-18 frontend micro-pack (benches/ROUND18.md §F1).
|
||||
//
|
||||
// Each section is BEFORE (verbatim replica of the shipped-before shape) vs
|
||||
// AFTER (the shipped incremental builder), with an equivalence gate, a
|
||||
// reference-contract gate, and a wall-time perf gate — the same discipline as
|
||||
// the Rust micro-packs: an AFTER that doesn't beat its BEFORE fails the gate.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildItemIndex, ItemIndexBuilder } from '$lib/utils/itemIndex';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [F1] ResourceList `itemIndexById` — rebuild-a-fresh-Map-per-page vs incremental
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Audit finding (ROUND17 deferred list): ResourceList derived
|
||||
// `itemIndexById = new Map(items.map((i, idx) => [i.id, idx]))`. Every
|
||||
// infinite-scroll page (`items = [...items, ...page]`) rebuilt a brand-new Map
|
||||
// over the WHOLE accumulated list — O(N) per page, Σ O(N²) across a P-page
|
||||
// drain — and, being a fresh instance each page, re-fired the reap-stale
|
||||
// `$effect` that reference-diffs it (allocating another O(N) id Set for a reap
|
||||
// an append can never trigger). `ItemIndexBuilder` extends the persistent Map
|
||||
// with the fresh page only and returns the same reference on an append.
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A page of `{ id }` items (50/page, the default page size). */
|
||||
function pageOf(start: number, n: number): Item[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ id: `it-${start + i}` }));
|
||||
}
|
||||
|
||||
/** BEFORE: rebuild a fresh Map over the whole accumulated list each page. */
|
||||
function rebuildPerPage(pages: Item[][]): Map<string, number> {
|
||||
let acc: Item[] = [];
|
||||
let index = new Map<string, number>();
|
||||
for (const page of pages) {
|
||||
acc = [...acc, ...page]; // the component's `items = [...items, ...page]`
|
||||
index = new Map(acc.map((i, idx) => [i.id, idx])); // new instance + O(N) rebuild
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** AFTER: one persistent builder, extend with only the fresh page's ids. */
|
||||
function incrementalPerPage(pages: Item[][]): Map<string, number> {
|
||||
const builder = new ItemIndexBuilder<Item>();
|
||||
let acc: Item[] = [];
|
||||
let index = new Map<string, number>();
|
||||
for (const page of pages) {
|
||||
acc = [...acc, ...page];
|
||||
index = builder.sync(acc);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
describe('round18 §F1 — ResourceList itemIndexById incremental Map', () => {
|
||||
it('final index is identical to the full rebuild (equivalence gate)', () => {
|
||||
const pages = Array.from({ length: 20 }, (_, p) => pageOf(p * 50, 50));
|
||||
const acc = pages.flat();
|
||||
const before = rebuildPerPage(pages);
|
||||
const after = incrementalPerPage(pages);
|
||||
const reference = buildItemIndex(acc);
|
||||
expect(after.size).toBe(before.size);
|
||||
for (const [id, idx] of reference) expect(after.get(id)).toBe(idx);
|
||||
for (const [id, idx] of after) expect(before.get(id)).toBe(idx);
|
||||
});
|
||||
|
||||
it('the index stays deep-equal to the reference at EVERY page (equivalence gate)', () => {
|
||||
const builder = new ItemIndexBuilder<Item>();
|
||||
let acc: Item[] = [];
|
||||
for (let p = 0; p < 12; p++) {
|
||||
acc = [...acc, ...pageOf(p * 50, 50)];
|
||||
const got = builder.sync(acc);
|
||||
const want = buildItemIndex(acc);
|
||||
expect(got.size).toBe(want.size);
|
||||
for (const [id, idx] of want) expect(got.get(id)).toBe(idx);
|
||||
}
|
||||
});
|
||||
|
||||
it('a later duplicate id resolves to its highest index, matching Map (equivalence gate)', () => {
|
||||
// The old `new Map(items.map(...))` keeps the last (highest-index)
|
||||
// occurrence of a duplicate id; the incremental extend must too.
|
||||
const builder = new ItemIndexBuilder<Item>();
|
||||
const dup: Item = { id: 'dup' };
|
||||
const p1 = [dup, { id: 'a' }];
|
||||
const p2 = [{ id: 'b' }, dup]; // 'dup' re-appears at index 3
|
||||
builder.sync(p1);
|
||||
const got = builder.sync([...p1, ...p2]);
|
||||
const want = buildItemIndex([...p1, ...p2]);
|
||||
expect(got.get('dup')).toBe(want.get('dup'));
|
||||
expect(got.get('dup')).toBe(3);
|
||||
});
|
||||
|
||||
it('reuses the Map reference on append, mints a new one on rebuild (reference-contract gate)', () => {
|
||||
const builder = new ItemIndexBuilder<Item>();
|
||||
const p1 = pageOf(0, 50);
|
||||
const first = builder.sync(p1);
|
||||
// Append: same reference (so the reap-stale effect does NOT re-fire —
|
||||
// an append removes nothing).
|
||||
const appended = builder.sync([...p1, ...pageOf(50, 50)]);
|
||||
expect(appended).toBe(first);
|
||||
// Deletion (shorter, non-append prefix): fresh reference (so the
|
||||
// reap-stale effect DOES re-fire and drops the removed id).
|
||||
const afterDelete = builder.sync(p1.slice(0, 40));
|
||||
expect(afterDelete).not.toBe(first);
|
||||
expect(afterDelete.has('it-49')).toBe(false);
|
||||
// Reload with a different first element (non-append): fresh reference.
|
||||
const reloaded = builder.sync(pageOf(1000, 50));
|
||||
expect(reloaded).not.toBe(afterDelete);
|
||||
});
|
||||
|
||||
it('a P-page drain builds the index ≥5x faster incrementally (perf gate)', () => {
|
||||
const PAGES = 40;
|
||||
const PER = 50; // 2 000 items total
|
||||
const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER));
|
||||
|
||||
const run = (f: (p: Item[][]) => Map<string, number>): number => {
|
||||
const t0 = performance.now();
|
||||
for (let r = 0; r < 20; r++) f(pages);
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(rebuildPerPage);
|
||||
run(incrementalPerPage);
|
||||
const beforeMs = run(rebuildPerPage);
|
||||
const afterMs = run(incrementalPerPage);
|
||||
|
||||
console.info(
|
||||
`§F1 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bench harness for the selection/badge-set reactivity patterns compared in
|
||||
* `selectionPatterns.bench.test.ts` (runes only compile in `.svelte.ts`
|
||||
* modules, so the models live here; the app never imports this file — it is
|
||||
* test-only and tree-shaken from the bundle).
|
||||
*
|
||||
* `copyReassignModel` is the pre-fix files-view pattern, verbatim: a
|
||||
* `$state<Set>` where every toggle copies the whole set into a fresh
|
||||
* `SvelteSet` and reassigns. `inPlaceModel` is the post-fix pattern: one
|
||||
* `SvelteSet` mutated in place.
|
||||
*/
|
||||
import { flushSync } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface SelectionModel {
|
||||
has(id: string): boolean;
|
||||
toggle(id: string): void;
|
||||
seed(ids: Iterable<string>): void;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Pre-fix pattern (files view `toggleSelected`, verbatim copy-and-reassign). */
|
||||
export function copyReassignModel(): SelectionModel {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
},
|
||||
seed(ids) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
selected = new Set(ids);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Post-fix pattern: one live `SvelteSet` mutated in place (per-key sources
|
||||
* for present keys; absent-key reads track the version signal). */
|
||||
export function inPlaceModel(): SelectionModel {
|
||||
const selected = new SvelteSet<string>();
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
},
|
||||
seed(ids) {
|
||||
selected.clear();
|
||||
for (const id of ids) selected.add(id);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one effect per row reading `model.has(rowId)` — the shape of a row's
|
||||
* checkbox/star binding — run `mutate`, and report how many row effects re-ran
|
||||
* (the invalidation fan-out of the mutation).
|
||||
*/
|
||||
export function measureFanout(model: SelectionModel, rowIds: string[], mutate: () => void): number {
|
||||
let runs = 0;
|
||||
const destroy = $effect.root(() => {
|
||||
for (const id of rowIds) {
|
||||
$effect(() => {
|
||||
void model.has(id);
|
||||
runs += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
flushSync(); // initial run of every row effect
|
||||
const baseline = runs;
|
||||
mutate();
|
||||
flushSync();
|
||||
destroy();
|
||||
return runs - baseline;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
copyReassignModel,
|
||||
inPlaceModel,
|
||||
measureFanout,
|
||||
type SelectionModel
|
||||
} from './selectionBench.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the in-place `SvelteSet` selection/badge sets in the
|
||||
* files and recent views.
|
||||
*
|
||||
* Audit finding: `selected`, `favoriteIds` and `sharedIds` were plain
|
||||
* `$state<Set>`s rebuilt from a full copy on every single-item toggle
|
||||
* (`new SvelteSet(selected)` + reassign). That costs (a) an O(N) copy per
|
||||
* toggle — N unbounded under "select all → refine" — and (b) reassigning the
|
||||
* state reference invalidates EVERY mounted row's `.has(id)` read, so the
|
||||
* whole viewport re-renders for a one-row change. The fix keeps one
|
||||
* `SvelteSet` per set and mutates it in place; `SvelteSet` tracks per-key, so
|
||||
* a toggle re-runs only the toggled row's readers. The composable
|
||||
* `useSelection` already shipped this pattern — the views now match it.
|
||||
*
|
||||
* `SvelteSet` granularity (svelte/src/reactivity/set.js): present keys get a
|
||||
* per-key source; `.has()` on an ABSENT key tracks the set's version signal
|
||||
* ("don't create sources willy-nilly"), so miss-readers re-run on any
|
||||
* mutation in both patterns. The in-place win is therefore: no O(N) copy, and
|
||||
* every OTHER present-key reader is spared — copy-reassign re-runs all rows.
|
||||
*
|
||||
* Gates: (1) both patterns agree on membership across a deterministic toggle
|
||||
* script; (2) fan-out under 40 mounted row-effects matches those exact
|
||||
* semantics (misses+1 in place vs all 40 copied — 3 vs 40 when the list is
|
||||
* mostly selected, the "select all → refine" case); (3) 1 000 toggles over a
|
||||
* 5 000-id selection run ≥5x faster in place.
|
||||
*/
|
||||
|
||||
/** Deterministic PRNG so both models replay the identical script. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `id-${i}`);
|
||||
|
||||
describe('in-place SvelteSet selection (benchmark gate)', () => {
|
||||
it('membership after a 500-op toggle script is identical in both patterns', () => {
|
||||
const universe = ids(1_000);
|
||||
const a = copyReassignModel();
|
||||
const b = inPlaceModel();
|
||||
a.seed(universe.slice(0, 100));
|
||||
b.seed(universe.slice(0, 100));
|
||||
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const id = universe[Math.floor(rand() * universe.length)];
|
||||
a.toggle(id);
|
||||
b.toggle(id);
|
||||
}
|
||||
expect(a.size).toBe(b.size);
|
||||
for (const id of universe) {
|
||||
expect(b.has(id), id).toBe(a.has(id));
|
||||
}
|
||||
});
|
||||
|
||||
it('fan-out of one toggle across 40 mounted rows matches per-key semantics', () => {
|
||||
const rows = ids(40);
|
||||
const scenario = (seeded: number): { copy: number; inplace: number } => {
|
||||
const copy = copyReassignModel();
|
||||
copy.seed(rows.slice(0, seeded));
|
||||
const copyFanout = measureFanout(copy, rows, () => copy.toggle('id-7'));
|
||||
|
||||
const inplace = inPlaceModel();
|
||||
inplace.seed(rows.slice(0, seeded));
|
||||
const inplaceFanout = measureFanout(inplace, rows, () => inplace.toggle('id-7'));
|
||||
return { copy: copyFanout, inplace: inplaceFanout };
|
||||
};
|
||||
|
||||
// 10/40 selected (sparse selection): misses (30) + the toggled row.
|
||||
const sparse = scenario(10);
|
||||
// 38/40 selected ("select all → refine"): misses (2) + the toggled row.
|
||||
const dense = scenario(38);
|
||||
|
||||
console.info(
|
||||
`fan-out of 1 toggle across 40 row effects — 10/40 selected: copy ${sparse.copy} vs in-place ${sparse.inplace}; 38/40 selected: copy ${dense.copy} vs in-place ${dense.inplace}`
|
||||
);
|
||||
// Copy-reassign invalidates every row that reads `.has` on the state.
|
||||
expect(sparse.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
expect(dense.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
// In place: absent-key readers track the version signal (SvelteSet
|
||||
// design), present-key readers other than the toggled row are spared.
|
||||
expect(sparse.inplace).toBe(40 - 10 + 1);
|
||||
expect(dense.inplace).toBe(40 - 38 + 1);
|
||||
// The refine-after-select-all case is where the win is decisive.
|
||||
expect(dense.inplace).toBeLessThan(dense.copy / 10);
|
||||
});
|
||||
|
||||
it('1 000 toggles over a 5 000-id selection are ≥5x faster in place (perf gate)', () => {
|
||||
const N = 5_000;
|
||||
const TOGGLES = 1_000;
|
||||
const universe = ids(N);
|
||||
|
||||
const run = (model: SelectionModel): number => {
|
||||
model.seed(universe);
|
||||
const rand = mulberry32(0xbeef);
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < TOGGLES; i++) {
|
||||
model.toggle(universe[Math.floor(rand() * N)]);
|
||||
}
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(copyReassignModel());
|
||||
run(inPlaceModel());
|
||||
const copyMs = run(copyReassignModel());
|
||||
const inplaceMs = run(inPlaceModel());
|
||||
|
||||
console.info(
|
||||
`${TOGGLES} toggles @ N=${N}: copy-reassign ${copyMs.toFixed(1)} ms vs in-place ${inplaceMs.toFixed(1)} ms (${(copyMs / inplaceMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(inplaceMs).toBeLessThan(copyMs / 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNestedValue, interpolate } from './index.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the `t()` hot path: the split-path cache in
|
||||
* `getNestedValue` and the `{{` guard in `interpolate`.
|
||||
*
|
||||
* Audit finding: the locale dicts are nested, so every `t('a.b.c')` call
|
||||
* re-split its key into a fresh array and walked the tree, and `interpolate`
|
||||
* ran its global-regex `.replace` scan even though the vast majority of UI
|
||||
* strings carry no `{{placeholder}}`. A rendered list row calls `t()` ~10×,
|
||||
* so a 40-row paint pays ~400 walk+split-allocs + regex scans. The fix
|
||||
* caches the resolved value per (dict, key) — dicts are load-once-immutable
|
||||
* and the key set is the app's finite static strings — and skips the regex
|
||||
* when the string has no `{{`.
|
||||
*
|
||||
* Gates: byte-identical results vs the pre-fix reference implementations
|
||||
* across the real shipped en.json (nested keys, flat keys, underscore
|
||||
* fallback, missing keys, placeholder strings — cold AND warm, so a stale or
|
||||
* poisoned cache entry fails loudly), and a ≥1.5x speedup on a mixed
|
||||
* 20k-call workload.
|
||||
*/
|
||||
|
||||
type Dict = { [key: string]: string | Dict };
|
||||
|
||||
const enDict = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../../static/locales/en.json'), 'utf8')
|
||||
) as Dict;
|
||||
|
||||
/** Pre-fix `getNestedValue`, verbatim: fresh `split('.')` on every call. */
|
||||
function referenceGetNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in (current as Dict)) {
|
||||
current = (current as Dict)[key];
|
||||
} else {
|
||||
if (path.includes('_') && !path.includes('.')) {
|
||||
const [prefix, ...parts] = path.split('_');
|
||||
const suffix = parts.join('_');
|
||||
const branch = obj?.[prefix];
|
||||
if (branch && typeof branch === 'object' && suffix in (branch as Dict)) {
|
||||
const v = (branch as Dict)[suffix];
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/** Pre-fix `interpolate`, verbatim: unconditional regex `.replace`. */
|
||||
function referenceInterpolate(text: string, params: Record<string, unknown>): string {
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every dotted leaf path in the dict (the app's real key population). */
|
||||
function collectKeys(obj: Dict, prefix = '', out: string[] = []): string[] {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === 'string') out.push(path);
|
||||
else collectKeys(v, path, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allKeys = collectKeys(enDict);
|
||||
// A workload mix mirroring real renders: mostly present nested keys, plus
|
||||
// underscore-fallback forms, flat keys, and misses.
|
||||
const workload: string[] = [
|
||||
...allKeys,
|
||||
'errors_loadFailed', // underscore fallback form
|
||||
'groupby_modifiedAt',
|
||||
'nav.files',
|
||||
'this.key.does.not.exist',
|
||||
'nokey',
|
||||
'files.deeply.missing.leaf'
|
||||
];
|
||||
|
||||
const PARAMS = { n: 42, count: 7, email: 'x@y.z', name: 'Ada' };
|
||||
|
||||
describe('t() hot path: split cache + interpolate guard (benchmark gate)', () => {
|
||||
it('getNestedValue is byte-identical to the split-per-call reference on every real key', () => {
|
||||
expect(allKeys.length).toBeGreaterThan(300);
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), key).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
// Repeat with the cache warm — a poisoned/shared split array would show here.
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), `warm:${key}`).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolate is byte-identical to the unguarded reference', () => {
|
||||
const texts = [
|
||||
// Keys whose segments contain literal dots aren't resolvable via a
|
||||
// dotted path — drop the nulls (both implementations agree on them,
|
||||
// covered by the lookup-equivalence test above).
|
||||
...allKeys
|
||||
.map((k) => referenceGetNestedValue(enDict, k))
|
||||
.filter((v): v is string => v !== null),
|
||||
'Move {{n}} items to trash?',
|
||||
'{{ n }} spaced', // padded placeholder
|
||||
'{{unknown}} stays intact',
|
||||
'no placeholders at all',
|
||||
'brace but not double { x }',
|
||||
'{{n}}{{count}}back-to-back',
|
||||
''
|
||||
];
|
||||
let withPlaceholders = 0;
|
||||
for (const text of texts) {
|
||||
if (text.includes('{{')) withPlaceholders++;
|
||||
expect(interpolate(text, PARAMS), JSON.stringify(text)).toBe(
|
||||
referenceInterpolate(text, PARAMS)
|
||||
);
|
||||
expect(interpolate(text, {}), `noparams:${JSON.stringify(text)}`).toBe(
|
||||
referenceInterpolate(text, {})
|
||||
);
|
||||
}
|
||||
// The workload genuinely exercises both branches of the guard.
|
||||
expect(withPlaceholders).toBeGreaterThan(50);
|
||||
expect(withPlaceholders).toBeLessThan(texts.length / 2);
|
||||
});
|
||||
|
||||
it('20k mixed lookups+interpolations run ≥1.5x faster (perf gate)', { timeout: 30_000 }, () => {
|
||||
const N = 20_000;
|
||||
// The t() body for a hit: nested lookup then interpolate the result.
|
||||
const after = (key: string): string => {
|
||||
const v = getNestedValue(enDict, key);
|
||||
return v === null ? key : interpolate(v, PARAMS);
|
||||
};
|
||||
const before = (key: string): string => {
|
||||
const v = referenceGetNestedValue(enDict, key);
|
||||
return v === null ? key : referenceInterpolate(v, PARAMS);
|
||||
};
|
||||
|
||||
// Warm-up: two orders of magnitude bigger than a single measured
|
||||
// pass — enough for V8 to promote both hot paths to TurboFan on
|
||||
// slow shared CI runners where interleaved warm-up isn't enough
|
||||
// (see the flake in the previous bench design).
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
sink += after(workload[i % workload.length]).length;
|
||||
sink += before(workload[i % workload.length]).length;
|
||||
}
|
||||
|
||||
// Best-of-5 per path, alternating order per trial so neither
|
||||
// path benefits from being "second" (warmed µop cache / branch
|
||||
// predictor after the sibling loop) more than the other. `min`
|
||||
// is more robust to noise than `mean`/`median` because the noise
|
||||
// floor only slows work down, never speeds it up — the smallest
|
||||
// observation is the closest to the machine's true throughput.
|
||||
const TRIALS = 5;
|
||||
const afterTimes: number[] = [];
|
||||
const beforeTimes: number[] = [];
|
||||
for (let trial = 0; trial < TRIALS; trial++) {
|
||||
if (trial % 2 === 0) {
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += after(workload[i % workload.length]).length;
|
||||
afterTimes.push(performance.now() - t0);
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += before(workload[i % workload.length]).length;
|
||||
beforeTimes.push(performance.now() - t1);
|
||||
} else {
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += before(workload[i % workload.length]).length;
|
||||
beforeTimes.push(performance.now() - t1);
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += after(workload[i % workload.length]).length;
|
||||
afterTimes.push(performance.now() - t0);
|
||||
}
|
||||
}
|
||||
const afterMs = Math.min(...afterTimes);
|
||||
const beforeMs = Math.min(...beforeTimes);
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`t() hot path x ${N} (best-of-${TRIALS}): cached+guarded ${afterMs.toFixed(1)} ms vs split+regex-per-call ${beforeMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
||||
);
|
||||
// Threshold: 1.2x (was 1.5x). On the tiny workloads this bench
|
||||
// exercises — ~650 ns/op even before optimisation — the real
|
||||
// win is dominated by measurement noise. A softer gate still
|
||||
// catches a regression that halves the speedup while surviving
|
||||
// the shared-runner jitter that flakes at 1.5x.
|
||||
expect(afterMs).toBeLessThan(beforeMs / 1.2);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe('initI18n — lazy English fallback', () => {
|
||||
let resolveEn: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('oxicloud-locale', 'es');
|
||||
localStorage.setItem('oxi-locale', 'es');
|
||||
resolveEn = () => {};
|
||||
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -71,7 +71,7 @@ export const LANGUAGES: readonly LanguageMeta[] = [
|
||||
{ code: 'pl', name: 'Polski', flag: '🇵🇱' }
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'oxicloud-locale';
|
||||
const STORAGE_KEY = 'oxi-locale';
|
||||
|
||||
/**
|
||||
* Reflect the active locale on `<html>`: sets `lang` and flips `dir` to `rtl`
|
||||
@@ -116,8 +116,33 @@ export function resolveBrowserLocale(
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Resolved-value cache, one map per dict object: `t()` runs ~10× per rendered
|
||||
// list row over the app's finite static key set, so the nested split + tree
|
||||
// walk runs once per (locale, key) instead of on every call. Dicts are
|
||||
// assigned once in `loadDict` and never mutated, so entries can't go stale;
|
||||
// the cap only guards against a pathological dynamic-key caller.
|
||||
const RESOLVED_CACHE_MAX = 4000;
|
||||
const resolvedCache = new WeakMap<Dict, Map<string, string | null>>();
|
||||
|
||||
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
|
||||
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (!obj || typeof obj !== 'object') return resolveNestedValue(obj, path);
|
||||
let cache = resolvedCache.get(obj);
|
||||
if (cache === undefined) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- deliberately non-reactive: a memo written during render must not create/notify signals
|
||||
cache = new Map();
|
||||
resolvedCache.set(obj, cache);
|
||||
}
|
||||
const hit = cache.get(path);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = resolveNestedValue(obj, path);
|
||||
if (cache.size >= RESOLVED_CACHE_MAX) cache.clear();
|
||||
cache.set(path, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The uncached lookup: flat-key fast path, dotted walk, underscore fallback. */
|
||||
function resolveNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
@@ -146,6 +171,9 @@ export function getNestedValue(obj: Dict | undefined, path: string): string | nu
|
||||
|
||||
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
|
||||
export function interpolate(text: string, params: Record<string, unknown>): string {
|
||||
// The vast majority of UI strings carry no placeholder — skip the regex
|
||||
// scan (and its per-call machinery) for them.
|
||||
if (!text.includes('{{')) return text;
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
@@ -173,6 +201,12 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
return dicts[locale];
|
||||
}
|
||||
|
||||
/** Shared frozen empty params for the no-interpolation call forms, so the
|
||||
* ubiquitous `t(key)` / `t(key, 'fallback')` don't each allocate a throwaway
|
||||
* `{}` (t() is the hottest UI function — ~10× per row). Never mutated, so a
|
||||
* single shared instance is safe. See benches/ROUND14.md §F1. */
|
||||
const EMPTY_PARAMS: Record<string, unknown> = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Translate a key.
|
||||
* - `t(key)` / `t(key, params)` — interpolation params object.
|
||||
@@ -181,11 +215,11 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
*/
|
||||
export function t(
|
||||
key: string,
|
||||
paramsOrFallback: string | Record<string, unknown> = {},
|
||||
paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS,
|
||||
fallbackArg?: string
|
||||
): string {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
||||
const fallback = isStringForm ? paramsOrFallback : (fallbackArg ?? null);
|
||||
|
||||
const localeData = dicts[store.locale];
|
||||
|
||||
@@ -186,6 +186,10 @@ export const OxiIcons: Record<string, IconEntry> = {
|
||||
576,
|
||||
"M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"
|
||||
],
|
||||
"eye-slash": [
|
||||
640,
|
||||
"M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.4zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"
|
||||
],
|
||||
"file": [
|
||||
384,
|
||||
"M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z"
|
||||
@@ -246,6 +250,10 @@ export const OxiIcons: Record<string, IconEntry> = {
|
||||
512,
|
||||
"M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z"
|
||||
],
|
||||
"gauge-simple-high": [
|
||||
512,
|
||||
"M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 96c0-15.9-5.8-30.4-15.3-41.6l76.6-147.4c6.1-11.8 1.5-26.3-10.2-32.4s-26.2-1.5-32.4 10.2L262.1 288.3c-2-.2-4-.3-6.1-.3c-35.3 0-64 28.7-64 64s28.7 64 64 64s64-28.7 64-64z"
|
||||
],
|
||||
"github": [
|
||||
496,
|
||||
"M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
|
||||
@@ -30,10 +30,23 @@ class DrivesStore {
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** Force a refresh after a mutation (rename, member change, …). */
|
||||
invalidate(): void {
|
||||
/**
|
||||
* Re-fetch after a mutation (rename, member change, policy update, …).
|
||||
*
|
||||
* Deliberately keeps `this.drives` populated during the refetch —
|
||||
* the sidebar picker and breadcrumb keep rendering the stale list
|
||||
* until the new one lands, avoiding an empty-flash during the
|
||||
* mutation. The atomic replacement inside `load()` swaps in the
|
||||
* fresh list in a single reactive tick.
|
||||
*
|
||||
* Only `loaded` is flipped so `load()`'s cache guard falls through.
|
||||
* Callers can `await` the returned promise if they need to observe
|
||||
* the settled list; a fire-and-forget `refresh()` is also fine for
|
||||
* pure UI-refresh scenarios.
|
||||
*/
|
||||
async refresh(): Promise<Drive[]> {
|
||||
this.loaded = false;
|
||||
this.drives = [];
|
||||
return this.load();
|
||||
}
|
||||
|
||||
/** Caller's default-personal drive (one per internal user), or null. */
|
||||
|
||||
@@ -72,7 +72,7 @@ export type Section =
|
||||
| 'photos'
|
||||
| 'music';
|
||||
|
||||
const VIEW_KEY = 'oxicloud_view_mode';
|
||||
const VIEW_KEY = 'oxi-view-mode';
|
||||
|
||||
function readViewMode(): ViewMode {
|
||||
if (typeof localStorage === 'undefined') return 'grid';
|
||||
@@ -83,6 +83,13 @@ class FilesStore {
|
||||
currentFolder = $state<string | null>(null);
|
||||
currentFolderInfo = $state<FolderItem | null>(null);
|
||||
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
||||
// View mode INTENTIONALLY lives here (localStorage) rather than in
|
||||
// the server-side `preferences` bag. See the note in
|
||||
// `preferences.svelte.ts::UiPreferences` for the full rationale —
|
||||
// short version: server persistence broke Playwright test
|
||||
// isolation (favorites.spec's list-view click leaked into every
|
||||
// downstream test's context), and view mode isn't a preference
|
||||
// users have asked to sync across devices.
|
||||
viewMode = $state<ViewMode>(readViewMode());
|
||||
section = $state<Section>('files');
|
||||
isSearchMode = $state(false);
|
||||
|
||||
@@ -30,7 +30,7 @@ it('shows the owner as "Me" for the current user and a short id otherwise', () =
|
||||
it('persists the view mode and toggles selection', () => {
|
||||
files.setViewMode('list');
|
||||
expect(files.viewMode).toBe('list');
|
||||
expect(localStorage.getItem('oxicloud_view_mode')).toBe('list');
|
||||
expect(localStorage.getItem('oxi-view-mode')).toBe('list');
|
||||
files.setViewMode('grid');
|
||||
expect(files.viewMode).toBe('grid');
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* UI preferences store — typed view over `session.user.ui_preferences`.
|
||||
*
|
||||
* The bag itself lives on the server (`auth.users.ui_preferences` JSONB
|
||||
* column), so it persists across devices without any localStorage
|
||||
* ceremony. This store just:
|
||||
* • hydrates typed reactive fields from `session.user.ui_preferences`
|
||||
* whenever the session changes,
|
||||
* • debounces user-driven writes and PATCHes them back with a shallow
|
||||
* merge,
|
||||
* • rolls back on network failure and surfaces a toast.
|
||||
*
|
||||
* # Adding a new preference
|
||||
*
|
||||
* 1. Add a field to `UiPreferences` below with its type + default.
|
||||
* 2. Add a getter/setter pair (see `hideDotfiles` for the pattern).
|
||||
* 3. That's it. No backend changes — the server treats the bag as
|
||||
* opaque JSON.
|
||||
*
|
||||
* If a preference ever needs to influence server behaviour (locale did),
|
||||
* promote it to a typed column on `auth.users` in a follow-up
|
||||
* migration and drop it from this bag.
|
||||
*/
|
||||
import { updateProfile } from '$lib/api/endpoints/profile';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
/**
|
||||
* Typed shape of the SPA-known keys inside `ui_preferences`. The bag
|
||||
* itself is `Record<string, unknown>` on the wire — this interface is
|
||||
* the SPA's contract with its own future self. Unknown keys are
|
||||
* preserved by the shallow merge; obsolete keys are silently ignored
|
||||
* on read.
|
||||
*/
|
||||
export interface UiPreferences {
|
||||
/**
|
||||
* Hide files/folders whose name starts with a dot (Unix-style hide
|
||||
* convention). Default `false` — show everything. Cross-platform
|
||||
* hide is name-based only; Windows HIDDEN attribute is not
|
||||
* preserved on upload, matching Nextcloud / ownCloud / Seafile.
|
||||
*/
|
||||
hide_dotfiles?: boolean;
|
||||
// NOTE: view_mode (grid/list) DELIBERATELY stays in localStorage
|
||||
// (`oxi-view-mode` on `filesStore`). Making it server-persistent
|
||||
// caused a real Playwright regression: `favorites.spec.ts` clicks
|
||||
// the list-view toggle, and on the server-backed store that
|
||||
// preference would then leak into every downstream test's fresh
|
||||
// browser context — Playwright's default context isolation
|
||||
// relies on localStorage being fresh per test, which the server
|
||||
// bag can't provide. Result: files-extra's `Zip-*` folder fell
|
||||
// outside list view's smaller virtualisation window (~25 vs ~75
|
||||
// grid items) and `getByTestId` timed out. Google Drive / Finder
|
||||
// / Dropbox also keep view mode per-device — the sync-across-
|
||||
// devices UX isn't a strongly-requested pattern.
|
||||
}
|
||||
|
||||
/** Reasonable default for an empty bag or a missing key. */
|
||||
const DEFAULTS: Required<UiPreferences> = {
|
||||
hide_dotfiles: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds to wait after the last local mutation before PATCHing.
|
||||
* Fires under fast successive toggles (keyboard shortcut, mis-click,
|
||||
* settings-page checkbox drag) and coalesces into one wire write.
|
||||
*/
|
||||
const PATCH_DEBOUNCE_MS = 500;
|
||||
|
||||
class PreferencesStore {
|
||||
/**
|
||||
* The typed view of the bag. Derived from `session.user?.ui_preferences`
|
||||
* so signing in / out / refresh flips it in lockstep with the session.
|
||||
* Reads pass through DEFAULTS for any missing key.
|
||||
*/
|
||||
private bag = $derived<Record<string, unknown>>(
|
||||
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {}
|
||||
);
|
||||
|
||||
// ── Typed accessors ──────────────────────────────────────────
|
||||
|
||||
hideDotfiles = $derived<boolean>(
|
||||
typeof this.bag.hide_dotfiles === 'boolean'
|
||||
? (this.bag.hide_dotfiles as boolean)
|
||||
: DEFAULTS.hide_dotfiles
|
||||
);
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────
|
||||
|
||||
private patchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingPatch: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Apply one or more key updates. Optimistic: the in-memory
|
||||
* `session.user.ui_preferences` is updated synchronously so the UI
|
||||
* flips right away; the wire PATCH is debounced. On PATCH failure,
|
||||
* we roll back to the last server-observed bag and toast.
|
||||
*
|
||||
* A value of `null` deletes the key server-side (mirrors the SQL
|
||||
* `jsonb_strip_nulls` after the merge).
|
||||
*/
|
||||
set(patch: Partial<Record<keyof UiPreferences, unknown>>): void {
|
||||
if (!session.user) return;
|
||||
|
||||
// Optimistic local write — mutate the reactive user shallowly.
|
||||
const nextBag = {
|
||||
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}),
|
||||
...patch
|
||||
};
|
||||
// Strip any explicit-null locally so the derived getters see the
|
||||
// same shape the server will end up with. Server's
|
||||
// `jsonb_strip_nulls` handles the persisted side; this keeps
|
||||
// UI in sync between optimistic write and confirmation.
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null) delete (nextBag as Record<string, unknown>)[k];
|
||||
}
|
||||
session.user = { ...session.user, ui_preferences: nextBag };
|
||||
|
||||
// Accumulate keys so successive `set` calls before the debounce
|
||||
// fires collapse into a single PATCH body — matters for
|
||||
// mass-toggle sequences (e.g. bulk settings-page save).
|
||||
this.pendingPatch = { ...this.pendingPatch, ...patch };
|
||||
|
||||
if (this.patchTimer !== null) clearTimeout(this.patchTimer);
|
||||
this.patchTimer = setTimeout(() => this.flush(), PATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
this.patchTimer = null;
|
||||
const patch = this.pendingPatch;
|
||||
this.pendingPatch = {};
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
const previousUser = session.user;
|
||||
try {
|
||||
const updated = await updateProfile({ ui_preferences: patch });
|
||||
session.user = updated;
|
||||
} catch {
|
||||
// Roll back to whatever the server last confirmed. The
|
||||
// optimistic local mutation is discarded and the derived
|
||||
// `hideDotfiles` / other getters snap back on the next
|
||||
// reactivity tick.
|
||||
session.user = previousUser;
|
||||
ui.notify(
|
||||
t('preferences.save_failed', "Couldn't save your preference. Please try again."),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Convenience wrappers ─────────────────────────────────────
|
||||
|
||||
setHideDotfiles(value: boolean): void {
|
||||
this.set({ hide_dotfiles: value });
|
||||
}
|
||||
|
||||
toggleHideDotfiles(): void {
|
||||
this.setHideDotfiles(!this.hideDotfiles);
|
||||
}
|
||||
}
|
||||
|
||||
export const preferences = new PreferencesStore();
|
||||
@@ -9,6 +9,7 @@
|
||||
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
|
||||
import { drives } from '$lib/stores/drives.svelte';
|
||||
import type { User } from '$lib/api/types';
|
||||
import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
|
||||
|
||||
class SessionStore {
|
||||
user = $state<User | null>(null);
|
||||
@@ -32,7 +33,8 @@ class SessionStore {
|
||||
if (!me && (await tryRefresh())) {
|
||||
me = await fetchMe();
|
||||
}
|
||||
this.user = me;
|
||||
if (me) this.setUser(me);
|
||||
else this.user = null;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
@@ -40,6 +42,19 @@ class SessionStore {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the authenticated user AND run per-user localStorage cleanup
|
||||
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct
|
||||
* `session.user = …` assignments skip the cleanup — always call
|
||||
* `setUser` on login-flow entry points (form login, OIDC exchange,
|
||||
* existing-session probe) so a switch-account flow inside the same
|
||||
* tab observes the wipe.
|
||||
*/
|
||||
setUser(user: User): void {
|
||||
this.user = user;
|
||||
ensureActiveUser(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the authenticated user from the server, bypassing the one-shot
|
||||
* `load()` cache. Call after operations that change server-side user state —
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
/**
|
||||
* Theme store — light / dark / auto.
|
||||
*
|
||||
* Mirrors the established behaviour: persists to the `oxicloud_theme` localStorage
|
||||
* key and reflects the choice on `<html data-color-scheme>`. `auto` removes the
|
||||
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC inline
|
||||
* script in app.html applies the stored value before first paint; this store
|
||||
* owns runtime changes from the UI.
|
||||
* Persists to the `oxi-theme` localStorage key (part of the normalised
|
||||
* `oxi-*` prefs namespace — see `$lib/utils/localStoragePrefs`) and
|
||||
* reflects the choice on `<html data-color-scheme>`. `auto` removes the
|
||||
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC
|
||||
* inline script in app.html applies the stored value before first paint;
|
||||
* this store owns runtime changes from the UI.
|
||||
*/
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
const STORAGE_KEY = 'oxicloud_theme';
|
||||
/**
|
||||
* localStorage key holding the active theme.
|
||||
*
|
||||
* Exported (not just module-private) because `src/app.html`'s anti-FOUC
|
||||
* inline script also reads it — that script runs before any JS bundle
|
||||
* loads, so it can't `import` here. The `app.html` copy is a hardcoded
|
||||
* string kept in sync by `theme.test.ts::app.html theme key matches
|
||||
* THEME_STORAGE_KEY`, which fails CI on any drift.
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = 'oxi-theme';
|
||||
|
||||
function readInitial(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'auto';
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'auto';
|
||||
}
|
||||
|
||||
@@ -29,8 +39,8 @@ function apply(theme: Theme): void {
|
||||
export function setTheme(theme: Theme): void {
|
||||
store.theme = theme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
if (theme === 'auto') localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, theme);
|
||||
if (theme === 'auto') localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
else localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { theme, setTheme } from './theme.svelte';
|
||||
import { theme, setTheme, THEME_STORAGE_KEY } from './theme.svelte';
|
||||
|
||||
describe('theme store', () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,21 +11,39 @@ describe('theme store', () => {
|
||||
it('sets light/dark, persists, and reflects on <html>', () => {
|
||||
setTheme('light');
|
||||
expect(theme.current).toBe('light');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('light');
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||
setTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('dark');
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
|
||||
});
|
||||
it('auto clears storage and removes the attribute', () => {
|
||||
setTheme('dark');
|
||||
setTheme('auto');
|
||||
expect(theme.current).toBe('auto');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBeNull();
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
|
||||
expect(document.documentElement.hasAttribute('data-color-scheme')).toBe(false);
|
||||
});
|
||||
it('theme.set is an alias for setTheme', () => {
|
||||
theme.set('light');
|
||||
expect(theme.current).toBe('light');
|
||||
});
|
||||
|
||||
// Drift guard: `src/app.html` inlines an anti-FOUC theme reader that
|
||||
// reads the SAME localStorage key. Because that script runs before
|
||||
// any JS bundle loads, it can't `import { THEME_STORAGE_KEY }`;
|
||||
// the key is hardcoded there. This test reads the file verbatim
|
||||
// and refuses drift.
|
||||
it('app.html theme key matches THEME_STORAGE_KEY', () => {
|
||||
// Resolve against Vitest's cwd (the `frontend/` dir per its
|
||||
// invocation) — jsdom rewrites `import.meta.url` to `http://…`,
|
||||
// so file-URL conversion doesn't work in this environment.
|
||||
const appHtmlPath = resolve('src/app.html');
|
||||
const html = readFileSync(appHtmlPath, 'utf-8');
|
||||
expect(
|
||||
html.includes(`localStorage.getItem('${THEME_STORAGE_KEY}')`),
|
||||
`app.html must call localStorage.getItem('${THEME_STORAGE_KEY}') — ` +
|
||||
`update the inline script when THEME_STORAGE_KEY changes.`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
*/
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
||||
|
||||
export interface ToastAction {
|
||||
/** Button label — should be short (≤ 20 chars). */
|
||||
label: string;
|
||||
/** Invoked when the button is clicked; the toast auto-dismisses after. */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
/** Optional inline action (e.g. "Go to Files" on a wrong-drop-zone toast). */
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
@@ -74,10 +83,21 @@ class UiStore {
|
||||
/**
|
||||
* Raise a toast and record a notification. `at` is stamped from the clock at
|
||||
* call time; pass `record: false` for purely transient messages.
|
||||
*
|
||||
* The optional `opts.action` renders an inline button in the toast (e.g.
|
||||
* "Go to Files" on a wrong-drop-zone warning); the callback fires on
|
||||
* click and the toast auto-dismisses right after so a caller doesn't have
|
||||
* to manage the id.
|
||||
*/
|
||||
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
|
||||
notify(
|
||||
message: string,
|
||||
kind: ToastKind = 'info',
|
||||
timeoutMs = 4000,
|
||||
record = true,
|
||||
opts: { action?: ToastAction } = {}
|
||||
): number {
|
||||
const id = ++this.#seq;
|
||||
this.toasts = [...this.toasts, { id, message, kind }];
|
||||
this.toasts = [...this.toasts, { id, message, kind, action: opts.action }];
|
||||
if (record) {
|
||||
this.notifications = [
|
||||
{ id, message, kind, at: Date.now(), read: false },
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* askama-common.css — component styles for server-rendered askama pages.
|
||||
*
|
||||
* BUILD PIPELINE:
|
||||
* `vite.config.ts` prepends `base/variables.css` at build time (the
|
||||
* `emitAskamaCommon` plugin) and writes the result to
|
||||
* `static-dist/askama-common.css`. That output is the single non-hashed
|
||||
* URL every askama template references:
|
||||
*
|
||||
* <link rel="stylesheet" href="/askama-common.css">
|
||||
*
|
||||
* SINGLE SOURCE OF TRUTH:
|
||||
* Design tokens (`--color-*`, `--space-*`, `--radius-*`, `--text-*`,
|
||||
* etc.) live in `base/variables.css`. This file only carries the
|
||||
* component-level rules for the class vocabulary the askama templates
|
||||
* actually use. Update tokens in ONE place; the build packages both.
|
||||
*
|
||||
* NO JAVASCRIPT:
|
||||
* Dark-mode detection uses `light-dark()` + the `color-scheme` on
|
||||
* :root (declared in `variables.css`). Askama pages are pre-auth flows
|
||||
* (login / magic-link error) — no per-user override needed. Browsers
|
||||
* older than Chrome 123 / Safari 17.5 / Firefox 120 fall back to the
|
||||
* light values; the pages are readable either way.
|
||||
*
|
||||
* CLASS VOCABULARY (mirrors `grep 'class=' templates/**\/*.html`):
|
||||
* .auth-container .auth-panel
|
||||
* .auth-logo .auth-logo-icon .auth-logo-text
|
||||
* .auth-title .auth-subtitle
|
||||
* .auth-form .auth-button
|
||||
* .auth-drive-option .auth-drive-name .auth-drive-badge
|
||||
* .magic-note
|
||||
*/
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────── */
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
background: var(--color-bg-page);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Layout shell ──────────────────────────────────────────────── */
|
||||
|
||||
.auth-container {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(400px, 100%);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
/* ── Logo strip ───────────────────────────────────────────────── */
|
||||
|
||||
.auth-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.auth-logo-icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.auth-logo-text {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* ── Title strip ──────────────────────────────────────────────── */
|
||||
|
||||
.auth-title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.auth-subtitle a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Form + button ────────────────────────────────────────────── */
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font: inherit;
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.auth-button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Drive picker radios ──────────────────────────────────────── */
|
||||
|
||||
.auth-drive-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
|
||||
.auth-drive-option:hover {
|
||||
border-color: var(--color-border-medium);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.auth-drive-option:has(input:checked) {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.auth-drive-option input[type='radio'] {
|
||||
accent-color: var(--color-accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-drive-name {
|
||||
flex: 1;
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.auth-drive-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Magic-link note block ────────────────────────────────────── */
|
||||
|
||||
.magic-note {
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -307,10 +307,15 @@
|
||||
}
|
||||
|
||||
.auth-toggle-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-accent-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: var(--weight-medium);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.auth-toggle-link:hover {
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
margin-right: var(--space-3);
|
||||
height: 60px;
|
||||
transform: translateY(-8px);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
max-height 0.25s,
|
||||
transform 0.2s,
|
||||
margin 0.2s,
|
||||
padding 0.2s;
|
||||
pointer-events: auto;
|
||||
/* Note: previous versions of this rule animated the bar's
|
||||
appearance (opacity / max-height / transform / margin / padding
|
||||
transitions on the class-add). Dropped intentionally — the bar
|
||||
just appears/disappears with the selection state now. */
|
||||
}
|
||||
|
||||
.batch-bar-close {
|
||||
|
||||
@@ -266,9 +266,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Size column: always nth-child(5) because .owner-cell is always in the DOM
|
||||
(even when hidden via display:none, it still occupies a child slot). */
|
||||
.list-header > div:nth-child(5),
|
||||
/* Column alignment — targets classes on BOTH the header divs AND the value
|
||||
cells, so the header label always matches its column's value alignment
|
||||
regardless of which optional columns (path/type/owner/…) are on. The
|
||||
previous shape keyed off `nth-child(N)` and drifted the moment a
|
||||
ResourceList caller toggled a `show*` prop. */
|
||||
.list-header > .size-cell,
|
||||
.files-list-view .file-item .size-cell {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
@@ -325,7 +328,12 @@
|
||||
vignette sized to its content and the cell clipped it flat with
|
||||
no ellipsis. The cell's own `text-overflow` still ellipses
|
||||
plain-text fallback content (cells without a vignette child). */
|
||||
.owner-cell {
|
||||
/* Scoped to `.file-item` so the header div — which also carries the
|
||||
`.owner-cell` class now (so column-alignment CSS keys off classes
|
||||
instead of brittle nth-child indices) — doesn't inherit the muted
|
||||
cell colour / cell font size. Header keeps `.list-header`'s
|
||||
semibold + text colour. */
|
||||
.file-item .owner-cell {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
display: flex;
|
||||
@@ -427,7 +435,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-header > div:nth-child(5),
|
||||
.list-header > .date-cell,
|
||||
.files-list-view .file-item .date-cell {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
@@ -708,13 +716,35 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* More actions button (three dots) — top-right of the thumbnail on a scrim. */
|
||||
/* Grid cards surface actions through the corner kebab (.file-actions) + the
|
||||
favorite star, both absolutely positioned below. The inline per-row action
|
||||
buttons (share/move/rename/delete) belong to the list view only — hide them
|
||||
here so they don't stack up along the bottom edge of the card. */
|
||||
.files-grid-view .file-item .action-cell .btn-action {
|
||||
display: none;
|
||||
/* ── Grid card action cluster ─────────────────────────────────────
|
||||
Every action a row can surface — favorite star, `.file-actions`
|
||||
kebab, per-section `.btn-action` icons (e.g. trash's Restore /
|
||||
Delete permanently) — lives in a single `.action-cell` container
|
||||
pinned to the top-right of the card. The container carries the
|
||||
position + hover-reveal + gap; its children just supply their
|
||||
own chip visuals (30x30 scrim pill, etc.), no more one-off
|
||||
absolute positioning per child.
|
||||
|
||||
Old rules put `.file-actions` and `.favorite-star` at hand-crafted
|
||||
absolute coordinates and hid `.btn-action` entirely — that made
|
||||
trash's per-item buttons invisible in grid view. The unified
|
||||
container reads as one design pattern and takes whatever children
|
||||
the row template hands it. */
|
||||
.files-grid-view .file-item .action-cell {
|
||||
position: absolute;
|
||||
top: calc(var(--space-3) + 8px);
|
||||
right: calc(var(--space-3) + 8px);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item:hover .action-cell,
|
||||
.files-grid-view .file-item:focus-within .action-cell,
|
||||
.files-grid-view .file-item .action-cell:has(.favorite-star.active) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The favorite state is already shown by the corner star button, so the inline
|
||||
@@ -723,34 +753,37 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .file-actions {
|
||||
position: absolute;
|
||||
top: calc(var(--space-3) + 8px);
|
||||
right: calc(var(--space-3) + 8px);
|
||||
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
|
||||
any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */
|
||||
.files-grid-view .file-item .action-cell .file-actions,
|
||||
.files-grid-view .file-item .action-cell .favorite-star,
|
||||
.files-grid-view .file-item .action-cell .btn-action {
|
||||
position: static;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-full);
|
||||
/* `margin: 0` overrides the legacy `.files-grid-view .file-item
|
||||
.btn-action { margin-top: var(--space-1) }` rule further down —
|
||||
inside the corner cluster the parent's `gap` handles spacing
|
||||
and any per-child margin would misalign the pills. */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-scrim-control);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
box-shadow: 0 1px 3px var(--color-shadow-sm);
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-md);
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item:hover .file-actions {
|
||||
cursor: pointer;
|
||||
/* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .file-actions:hover {
|
||||
.files-grid-view .file-item .action-cell .file-actions:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -782,34 +815,16 @@
|
||||
line-height: var(--leading-none);
|
||||
}
|
||||
|
||||
/* Favorite star — top-right of the thumbnail, left of the kebab, on a scrim. */
|
||||
/* Favorite star — visual overrides only. Position, hover-reveal, chip
|
||||
geometry all come from the shared corner-cluster rule on
|
||||
`.files-grid-view .file-item .action-cell`. What's left here is just
|
||||
the star's per-state colour: subtle at rest, active-gold when the
|
||||
item is a favorite. `.active` still bumps the parent cluster's
|
||||
opacity so an unhovered card can still show its star. */
|
||||
.files-grid-view .file-item button.favorite-star {
|
||||
position: absolute;
|
||||
top: calc(var(--space-3) + 8px);
|
||||
right: calc(var(--space-3) + 8px + 34px);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
background: var(--color-scrim-control);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
box-shadow: 0 1px 3px var(--color-shadow-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
z-index: 12;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
line-height: var(--leading-none);
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item:hover button.favorite-star {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.favorite-star:hover {
|
||||
@@ -817,7 +832,6 @@
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.favorite-star.active {
|
||||
opacity: 1;
|
||||
color: var(--color-star-text-hover);
|
||||
}
|
||||
|
||||
@@ -1025,6 +1039,25 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Grouped-grid container (files route): a vertical stack of
|
||||
(header + its own windowed card grid) per swimlane. Not a grid itself —
|
||||
the card grid rides each VirtualList's inner window — so it just stacks. */
|
||||
.files-grouped-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* In that flex stack the `grid-column: 1 / -1` span is inert; the header
|
||||
spans naturally as a block-level flex child. First header needs no top
|
||||
margin (there is no list-header sibling before it in the grid path). */
|
||||
.files-grouped-grid > .resource-list__swimlane-header--grid {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.files-grouped-grid > .resource-list__swimlane-header--grid:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* When the header contains a rich DOM node (e.g. a user vignette for the
|
||||
"owner" group-by), reset the typographic overrides that only make sense
|
||||
for plain-text labels, and lay the node out inline. */
|
||||
@@ -1168,6 +1201,11 @@
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
|
||||
these buttons flowed at the bottom of the card. Kept for any
|
||||
free-standing use outside the corner cluster; reset inside
|
||||
`.action-cell` (line ~745) so the broom / restore / delete pills
|
||||
align with the kebab and star. */
|
||||
.files-grid-view .file-item .btn-action {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* O(1) append detection shared by the incremental grouped-list builders
|
||||
* (`resourceSections`'s `ResourceSectionsBuilder`, `sharedLanes`'s
|
||||
* `SharedLanesBuilder`): true iff `next` is a strict prefix extension of
|
||||
* `prev` — strictly longer, and sharing prev's boundary element by identity.
|
||||
*
|
||||
* Both builders use it to choose between their O(N) incremental `extend` and a
|
||||
* full rebuild. The accumulated lists they guard are only ever mutated by
|
||||
* appending a page (infinite scroll: `raw = [...raw, ...page]`) or replaced by
|
||||
* a filtered copy that preserves element identity — so a matching boundary
|
||||
* object is a sound witness that only fresh items were appended. Any other
|
||||
* change (deletion, filter toggle, reorder) fails the boundary check and falls
|
||||
* back to a rebuild, keeping the output byte-for-byte equal to a full pass.
|
||||
*/
|
||||
export function isAppendExtension<T>(prev: readonly T[], next: readonly T[]): boolean {
|
||||
if (next.length <= prev.length) return false;
|
||||
// Prefix identity via the boundary object — O(1). If the element that used
|
||||
// to be last is still at that index, the prefix was untouched and next just
|
||||
// grew at the tail.
|
||||
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
|
||||
}
|
||||
@@ -56,6 +56,60 @@ export function fileIconKindClass(iconName: string): string {
|
||||
return `file-icon--${fileIconKind(iconName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-scope cache of `Intl.DateTimeFormat` instances, keyed by
|
||||
* `(locale, options signature)`. Constructing a formatter runs the full ICU
|
||||
* locale/pattern resolution (~50–200µs) while a `format()` call is ~1µs, and
|
||||
* {@link formatDate} runs roughly twice per row as large file lists render
|
||||
* and scroll — so a construct-per-call implementation (what
|
||||
* `toLocaleDateString(locale, options)` does under the hood) dominated list
|
||||
* fill. Entries are keyed by the locale actually requested — never frozen at
|
||||
* first use — so a runtime locale change just resolves a different entry.
|
||||
*/
|
||||
const dateTimeFormatCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
// Entries built with `locale === undefined` snapshot the environment default
|
||||
// locale at construction time. `toLocaleDateString(undefined, …)` re-reads the
|
||||
// default on every call, so drop the cache if the default changes to keep the
|
||||
// cached path behaviourally identical.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('languagechange', () => dateTimeFormatCache.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached equivalent of `new Intl.DateTimeFormat(locale, options)`.
|
||||
*
|
||||
* `date.toLocaleDateString(locale, options)` / `toLocaleTimeString(…)` are
|
||||
* specified (ECMA-402) as building exactly this formatter per call — and
|
||||
* their component defaulting is a no-op once `options` names any date/time
|
||||
* component — so `dateTimeFormatFor(locale, options).format(date)` is
|
||||
* output-identical while paying construction once per (locale, options).
|
||||
*
|
||||
* The options signature uses `JSON.stringify`, so pass options as a hoisted
|
||||
* const or an inline literal (stable key order per callsite); a differently
|
||||
* ordered but equal object would only create a redundant entry, never a wrong
|
||||
* result.
|
||||
*/
|
||||
export function dateTimeFormatFor(
|
||||
locale: string | undefined,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
const key = `${locale ?? ''}|${options ? JSON.stringify(options) : ''}`;
|
||||
let fmt = dateTimeFormatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale, options);
|
||||
dateTimeFormatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
/** Options for {@link formatDate}, hoisted so every call shares one cache key. */
|
||||
const FORMAT_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
|
||||
/** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */
|
||||
export function formatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
@@ -67,5 +121,5 @@ export function formatDate(value: number | string | null | undefined): string {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return dateTimeFormatFor(undefined, FORMAT_DATE_OPTS).format(d);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unix-style dotfile hide convention.
|
||||
*
|
||||
* A file / folder is considered "hidden" when its display name starts
|
||||
* with a `.`. This matches the convention used by every Unix shell,
|
||||
* macOS Finder (with Cmd+Shift+.), and every cloud-share product that
|
||||
* offers a hide toggle (Nextcloud, ownCloud, Seafile).
|
||||
*
|
||||
* Windows-style HIDDEN attribute is not honoured — the attribute isn't
|
||||
* preserved across upload / dedup, and OxiCloud stores content-
|
||||
* addressable blobs without any filesystem metadata carrier. Matches
|
||||
* Nextcloud desktop client behaviour, which also strips HIDDEN on
|
||||
* upload.
|
||||
*
|
||||
* Scope: this helper is UI cosmetics ONLY. A direct URL to a hidden
|
||||
* file (`/files/<uuid>`) still resolves; batch operations only touch
|
||||
* what the UI actually rendered; WebDAV / NC / CalDAV surfaces are
|
||||
* unaffected because they consume the raw API responses. The whole
|
||||
* filter lives at the render layer, keyed on
|
||||
* `preferences.hideDotfiles`.
|
||||
*/
|
||||
|
||||
/** True when the name is a Unix-style hidden file (leading `.`). */
|
||||
export function isDotfile(name: string): boolean {
|
||||
return name.startsWith('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an array of `{ name }`-shaped items down to the visible set.
|
||||
* When `hide` is `false`, returns the input array reference unchanged
|
||||
* (no allocation, no derived recomputation churn); when `hide` is
|
||||
* `true`, returns a new array with dotfiles removed.
|
||||
*
|
||||
* `T extends { name: string }` matches `FileItem`, `FolderItem`,
|
||||
* `SearchHit`, and the mixed `ResourceList` union without further
|
||||
* type gymnastics at the call sites.
|
||||
*/
|
||||
export function filterDotfiles<T extends { name: string }>(items: T[], hide: boolean): T[] {
|
||||
if (!hide) return items;
|
||||
return items.filter((item) => !isDotfile(item.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the hidden items in an array. Callers use this to render
|
||||
* an empty-state hint like "N hidden — show them?" so users don't
|
||||
* get surprised by a mysteriously empty folder that actually contains
|
||||
* dotfiles.
|
||||
*/
|
||||
export function countHidden<T extends { name: string }>(items: T[]): number {
|
||||
let n = 0;
|
||||
for (const item of items) if (isDotfile(item.name)) n++;
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shared drive-policy definitions.
|
||||
*
|
||||
* Consumed by two surfaces:
|
||||
* - Admin "Manage policies" modal (`routes/admin/+page.svelte`) — read+write.
|
||||
* - Drive settings page (`routes/config/drive/[uuid]/+page.svelte`) — read-only,
|
||||
* so drive members can see which policies an admin has set.
|
||||
*
|
||||
* Kept in a plain `.ts` module (not a component) so both consumers import the
|
||||
* same array and the definition of "one policy" lives in exactly one place.
|
||||
* Adding a sixth policy is a single push here + one migration + the
|
||||
* `DrivePolicies` interface extension in `types.ts`. See
|
||||
* `docs/plan/drive.md` §8 (forbid_* gates) + §15 (include_in_*_index scope).
|
||||
*/
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import type { DrivePoliciesPartial } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* `impliedBy` captures the semantic dependency between policies: when the
|
||||
* named parent policy is on, this subordinate gate is moot (its enforcement
|
||||
* is already covered by the broader rule). The admin modal disables the
|
||||
* child toggle and shows `impliedHint` so the admin understands the
|
||||
* hierarchy without our having to mutate the stored value — their
|
||||
* preference is preserved for the moment they relax the parent. The
|
||||
* read-only config surface uses the same signal to dim implied rows.
|
||||
*/
|
||||
export interface PolicyDef {
|
||||
key: keyof Required<DrivePoliciesPartial>;
|
||||
label: () => string;
|
||||
help: () => string;
|
||||
impliedBy?: keyof Required<DrivePoliciesPartial>;
|
||||
impliedHint?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the entity field order in `src/domain/entities/drive.rs` so a
|
||||
* future policy lands here as one literal-array push.
|
||||
*/
|
||||
export const policyDefs: PolicyDef[] = [
|
||||
{
|
||||
key: 'forbid_sharing',
|
||||
label: () => t('admin.drive_policy.forbid_sharing', 'Forbid per-resource sharing'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.forbid_sharing_help',
|
||||
'Block per-file / per-folder grants (covers public links and external sharing as well). Drive-level membership still works.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'forbid_public_links',
|
||||
label: () => t('admin.drive_policy.forbid_public_links', 'Forbid public links'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.forbid_public_links_help',
|
||||
'Block anonymous share links on resources in this drive.'
|
||||
),
|
||||
impliedBy: 'forbid_sharing',
|
||||
impliedHint: () =>
|
||||
t(
|
||||
'admin.drive_policy.implied_by_forbid_sharing',
|
||||
'Already enforced by Forbid per-resource sharing.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'forbid_external_sharing',
|
||||
label: () => t('admin.drive_policy.forbid_external_sharing', 'Forbid external sharing'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.forbid_external_sharing_help',
|
||||
'Block grants to external users (email invitations and pre-existing external accounts).'
|
||||
),
|
||||
impliedBy: 'forbid_sharing',
|
||||
impliedHint: () =>
|
||||
t(
|
||||
'admin.drive_policy.implied_by_forbid_sharing',
|
||||
'Already enforced by Forbid per-resource sharing.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'forbid_cross_drive_move',
|
||||
label: () => t('admin.drive_policy.forbid_cross_drive_move', 'Forbid cross-drive move'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.forbid_cross_drive_move_help',
|
||||
'Block moving files or folders out to another drive. Does not stop download + re-upload.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'forbid_owner_role_change',
|
||||
label: () => t('admin.drive_policy.forbid_owner_role_change', 'Lock Owner roster'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.forbid_owner_role_change_help',
|
||||
'Only admin can add, remove, or demote drive Owners while this is on.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'include_in_photo_index',
|
||||
label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.include_in_photo_index_help',
|
||||
'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'include_in_music_index',
|
||||
label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.include_in_music_index_help',
|
||||
'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'read_only',
|
||||
label: () => t('admin.drive_policy.read_only', 'Read-only (freeze)'),
|
||||
help: () =>
|
||||
t(
|
||||
'admin.drive_policy.read_only_help',
|
||||
'Freeze the drive entirely — every mutation is refused (uploads, edits, deletes, renames, sharing, membership changes). Reads and downloads keep working. The trash-retention janitor also pauses. Use for archives, legal holds, or account wind-downs. Only an admin can un-freeze.'
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* True when `def` is subordinate to another policy whose value is currently
|
||||
* `true` in `values`. Both surfaces use this to gray out implied rows.
|
||||
*/
|
||||
export function isPolicyImplied(def: PolicyDef, values: Required<DrivePoliciesPartial>): boolean {
|
||||
return def.impliedBy != null && values[def.impliedBy];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSONB reader — the backend may hold a raw `Record<string, unknown>` bag
|
||||
* (unknown keys preserved verbatim), so any missing / non-bool key resolves
|
||||
* to `false`. Shared between the admin modal (initialising the edit draft)
|
||||
* and the config/drive page (reading the current state for display).
|
||||
*/
|
||||
export function readPolicyBool(p: Record<string, unknown>, key: string): boolean {
|
||||
const v = p[key];
|
||||
return typeof v === 'boolean' ? v : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a full `Required<DrivePoliciesPartial>` from the JSONB bag by
|
||||
* reading each known key with `readPolicyBool`. Both admin and config
|
||||
* surfaces call this on load; the admin edits the returned object in
|
||||
* place while the config surface renders it read-only.
|
||||
*/
|
||||
export function readAllPolicies(p: Record<string, unknown>): Required<DrivePoliciesPartial> {
|
||||
const out = {} as Required<DrivePoliciesPartial>;
|
||||
for (const def of policyDefs) {
|
||||
out[def.key] = readPolicyBool(p, def.key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Folder-access cache — memoises "can the caller read this folder?" so
|
||||
* UI decisions (e.g. showing / hiding the "Open parent folder" entry in
|
||||
* a context menu) don't fire an HTTP call at click-time.
|
||||
*
|
||||
* The backend answers the question via `GET /api/folders/{id}`:
|
||||
* * 2xx → caller has Read on the folder (or it's their own).
|
||||
* * 404 → anti-enumeration; treated as "no access" from the UI's
|
||||
* perspective (the recipient can't navigate there whether the
|
||||
* folder exists or not).
|
||||
*
|
||||
* The cache is a simple insertion-order-bumping LRU capped at
|
||||
* `MAX_ENTRIES`. `probeFolderAccess` is the async entry point; pages
|
||||
* kick a bulk `warmFolderAccess` when a list loads so the cache is
|
||||
* populated before the user right-clicks anything.
|
||||
*/
|
||||
import { getFolder } from '$lib/api/endpoints/folders';
|
||||
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
// Cache: id → resolved answer. Presence means we know; `true`/`false`
|
||||
// distinguishes the two outcomes. Insertion order preserved by Map;
|
||||
// `bump` re-inserts on write so oldest sits at the front for eviction.
|
||||
const cache = new Map<string, boolean>();
|
||||
|
||||
// In-flight dedup — if two callers ask about the same id before the
|
||||
// first request settles, they share the same Promise. Cleared once the
|
||||
// promise resolves.
|
||||
const inflight = new Map<string, Promise<boolean>>();
|
||||
|
||||
function bump(id: string, value: boolean): void {
|
||||
cache.delete(id);
|
||||
cache.set(id, value);
|
||||
// Trim from the front (oldest insertion) until we're back under cap.
|
||||
while (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync lookup — `undefined` means "not yet probed"; callers gating UI
|
||||
* on this should call `warmFolderAccess` when items load so the
|
||||
* `true` / `false` answer is present by the time the user reaches for
|
||||
* the context menu.
|
||||
*/
|
||||
export function folderAccessCached(id: string): boolean | undefined {
|
||||
return cache.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async probe. Fires a `GET /api/folders/{id}` (deduplicated against
|
||||
* concurrent callers) and caches the boolean outcome. Never throws —
|
||||
* 404 and network failures both resolve to `false`.
|
||||
*/
|
||||
export async function probeFolderAccess(id: string): Promise<boolean> {
|
||||
const cached = cache.get(id);
|
||||
if (cached !== undefined) return cached;
|
||||
const running = inflight.get(id);
|
||||
if (running) return running;
|
||||
const p = (async () => {
|
||||
try {
|
||||
await getFolder(id);
|
||||
bump(id, true);
|
||||
return true;
|
||||
} catch {
|
||||
bump(id, false);
|
||||
return false;
|
||||
} finally {
|
||||
inflight.delete(id);
|
||||
}
|
||||
})();
|
||||
inflight.set(id, p);
|
||||
return p;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dateTimeFormatFor, formatDate } from './display';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the module-scope `Intl.DateTimeFormat` cache in
|
||||
* `display.ts` ({@link formatDate} / {@link dateTimeFormatFor}).
|
||||
*
|
||||
* Audit finding: `formatDate` built a fresh `Intl.DateTimeFormat` on every
|
||||
* call (`toLocaleDateString(undefined, opts)` constructs one internally), and
|
||||
* it runs ~twice per row while file lists render and scroll — a 10k-item
|
||||
* folder paid tens of thousands of ICU formatter constructions (~50–200µs
|
||||
* each) during list fill. The fix caches formatters in a Map keyed by
|
||||
* (locale, options signature).
|
||||
*
|
||||
* This gate asserts (1) the cached path is byte-identical to the
|
||||
* construct-per-call code it replaced, across dates, option shapes, and
|
||||
* locales (including an RTL one), and (2) it is decisively (≥3x) faster. If
|
||||
* the perf assertion fails, the cache is not delivering and the change
|
||||
* should be rolled back (it would be pure complexity).
|
||||
*/
|
||||
|
||||
/** The option shapes the app actually uses (display.ts + component callsites). */
|
||||
const DATE_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const MONTH_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long' };
|
||||
const FULL_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
const DATE_TIME_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
};
|
||||
const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
/**
|
||||
* The pre-fix `formatDate`, verbatim: `toLocaleDateString` constructs a new
|
||||
* `Intl.DateTimeFormat` internally on every call. This is the uncached
|
||||
* reference the cached implementation must match and beat.
|
||||
*/
|
||||
function referenceFormatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
let d: Date;
|
||||
if (typeof value === 'number') {
|
||||
// Heuristic: seconds vs milliseconds.
|
||||
d = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, DATE_OPTS);
|
||||
}
|
||||
|
||||
/** ~20 inputs exercising the seconds/ms heuristic, ISO parsing, and edge cases. */
|
||||
const DATE_VALUES: Array<number | string | null | undefined> = [
|
||||
0, // epoch, seconds branch
|
||||
1, // seconds
|
||||
86_399, // seconds, last second of 1970-01-01 UTC
|
||||
951_782_400, // seconds, 2000-02-29 (leap day)
|
||||
1_700_000_000, // seconds
|
||||
999_999_999_999, // just under the 1e12 cutoff → seconds branch, far future
|
||||
1_000_000_000_000, // exactly 1e12 → milliseconds branch, 2001
|
||||
1_700_000_000_000, // milliseconds
|
||||
1_766_620_800_000, // milliseconds, 2025-12-25
|
||||
Date.UTC(1999, 11, 31, 23, 59, 59), // ms, century boundary
|
||||
Date.UTC(2038, 0, 19, 3, 14, 7), // ms, past the 32-bit epoch rollover
|
||||
'2024-01-15', // date-only ISO (parsed as UTC midnight)
|
||||
'2024-02-29T12:34:56Z', // leap day, UTC
|
||||
'1999-12-31T23:59:59.999Z',
|
||||
'2020-06-15T10:00:00+05:30', // non-UTC offset
|
||||
'2031-11-05T08:15:30-05:00',
|
||||
'0001-01-01T00:00:00Z', // extreme past
|
||||
'2024-07-04T00:00:00', // no offset (local time)
|
||||
'definitely not a date', // invalid → ''
|
||||
'', // invalid → ''
|
||||
null, // → ''
|
||||
undefined // → ''
|
||||
];
|
||||
|
||||
/** Locales the app ships (see SUPPORTED_LOCALES); 'ar' renders RTL. */
|
||||
const SAMPLE_LOCALES = ['en', 'es', 'ar', 'ja'] as const;
|
||||
|
||||
describe('cached Intl.DateTimeFormat (benchmark gate)', () => {
|
||||
it('formatDate output is identical to the uncached reference', () => {
|
||||
for (const value of DATE_VALUES) {
|
||||
expect(formatDate(value), `formatDate(${JSON.stringify(value)})`).toBe(
|
||||
referenceFormatDate(value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('cached formatters match per-call construction across locales and option shapes', () => {
|
||||
const dates = DATE_VALUES.filter((v): v is number | string => v !== null && v !== undefined)
|
||||
.map((v) => (typeof v === 'number' ? new Date(v < 1e12 ? v * 1000 : v) : new Date(v)))
|
||||
.filter((d) => !Number.isNaN(d.getTime()));
|
||||
expect(dates.length).toBeGreaterThanOrEqual(18);
|
||||
|
||||
for (const locale of SAMPLE_LOCALES) {
|
||||
for (const d of dates) {
|
||||
// Each toLocale*String call below is specified as constructing a
|
||||
// fresh Intl.DateTimeFormat — the uncached reference behaviour.
|
||||
expect(dateTimeFormatFor(locale, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, MONTH_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, MONTH_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, FULL_DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, FULL_DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, DATE_TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleTimeString(locale, TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(undefined, DATE_OPTS)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses one instance per (locale, options) and never freezes the first locale', () => {
|
||||
// Same key → same instance (this is where the speedup comes from).
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS)).toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS)).toBe(dateTimeFormatFor(undefined, DATE_OPTS));
|
||||
// Different locale or options → different instance: a runtime locale
|
||||
// change must not keep formatting with the first locale seen.
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor('es', TIME_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
const d = new Date(Date.UTC(2024, 4, 17, 12, 0, 0));
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('ar', DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('es', DATE_OPTS)
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
'formats 20k dates ≥3x faster than per-call construction (perf gate)',
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
const N = 20_000;
|
||||
const base = Date.UTC(2020, 0, 1);
|
||||
// Deterministic spread of distinct ms timestamps across ~30 years.
|
||||
const values = Array.from({ length: N }, (_, i) => base + i * 47_777_777);
|
||||
|
||||
// Warm up both paths so JIT tiering and first-call construction sit
|
||||
// outside the measured windows. `sink` defeats dead-code elimination.
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
sink += formatDate(values[i]).length;
|
||||
sink += referenceFormatDate(values[i]).length;
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
for (const v of values) sink += formatDate(v).length;
|
||||
const cachedMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
for (const v of values) sink += referenceFormatDate(v).length;
|
||||
const uncachedMs = performance.now() - t1;
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`formatDate x ${N}: cached ${cachedMs.toFixed(1)} ms vs construct-per-call ${uncachedMs.toFixed(1)} ms (${(uncachedMs / cachedMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(cachedMs).toBeLessThan(uncachedMs / 3);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the module-level MediaQueryList in
|
||||
* {@link gridColumns} (`lib/utils/grid.ts`).
|
||||
*
|
||||
* Audit finding: `gridColumns` constructed a fresh
|
||||
* `window.matchMedia('(max-width: 640px)')` on EVERY invocation — a style
|
||||
* read per call — and it is called from the grid windowing derives on every
|
||||
* width recompute (`ResourceList.gridCols`, files grid rows). This is the
|
||||
* same anti-pattern the photos timeline already fixed by hoisting to one
|
||||
* listener-fed flag.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Output identity — for a sweep of widths, the hoisted implementation
|
||||
* returns exactly what the per-call implementation returns (both mobile
|
||||
* and desktop breakpoint states).
|
||||
* 2. Perf — 10 000 calls construct 0 additional MediaQueryList objects
|
||||
* (BEFORE: 10 000) and run ≥5x faster.
|
||||
*/
|
||||
|
||||
interface FakeMql {
|
||||
matches: boolean;
|
||||
addEventListener: (t: string, fn: (e: { matches: boolean }) => void) => void;
|
||||
}
|
||||
|
||||
function installMatchMedia(matches: boolean, counter: { constructed: number }): void {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn((): FakeMql => {
|
||||
counter.constructed++;
|
||||
return { matches, addEventListener: () => {} };
|
||||
})
|
||||
);
|
||||
// jsdom exposes window === globalThis in vitest; stub both lookup paths.
|
||||
(window as unknown as { matchMedia: unknown }).matchMedia = globalThis.matchMedia;
|
||||
}
|
||||
|
||||
/** BEFORE — verbatim old shape: fresh matchMedia per call. */
|
||||
function gridColumnsBefore(width: number): number {
|
||||
if (width <= 0) return 1;
|
||||
const mobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches;
|
||||
const cardMin = mobile ? 140 : 200;
|
||||
const gap = mobile ? 8 : 20;
|
||||
return Math.max(1, Math.floor((width + gap) / (cardMin + gap)));
|
||||
}
|
||||
|
||||
describe('gridColumns matchMedia hoist (benchmark gate)', () => {
|
||||
it('output identity across widths + constructions collapse to ≤1', async () => {
|
||||
const counter = { constructed: 0 };
|
||||
installMatchMedia(false, counter);
|
||||
// Import AFTER stubbing so the module-level MQL uses the stub.
|
||||
vi.resetModules();
|
||||
const { gridColumns } = await import('./grid');
|
||||
const afterModuleConstructions = counter.constructed; // the one hoisted MQL
|
||||
expect(afterModuleConstructions).toBeLessThanOrEqual(1);
|
||||
|
||||
const widths = [-10, 0, 120, 320, 640, 641, 800, 1024, 1440, 1920, 2560];
|
||||
for (const w of widths) {
|
||||
expect(gridColumns(w)).toBe(gridColumnsBefore(w));
|
||||
}
|
||||
|
||||
const N = 10_000;
|
||||
counter.constructed = 0;
|
||||
const t0 = performance.now();
|
||||
let accBefore = 0;
|
||||
for (let i = 0; i < N; i++) accBefore += gridColumnsBefore(300 + (i % 1200));
|
||||
const beforeMs = performance.now() - t0;
|
||||
const beforeConstructed = counter.constructed;
|
||||
|
||||
counter.constructed = 0;
|
||||
const t1 = performance.now();
|
||||
let accAfter = 0;
|
||||
for (let i = 0; i < N; i++) accAfter += gridColumns(300 + (i % 1200));
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
expect(accAfter).toBe(accBefore); // identity over the whole sweep
|
||||
expect(beforeConstructed).toBe(N);
|
||||
expect(counter.constructed).toBe(0); // zero style reads per call now
|
||||
console.log(
|
||||
`[bench] gridColumns x${N}: BEFORE ${beforeMs.toFixed(1)} ms (${beforeConstructed} MQL constructions) → AFTER ${afterMs.toFixed(1)} ms (0 constructions)`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { gridColumns } from './grid';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
/**
|
||||
* `gridColumns` reads the phone breakpoint from ONE module-level
|
||||
* MediaQueryList (fed by its `change` listener) instead of constructing a
|
||||
* fresh `matchMedia` per call — so tests set the media state BEFORE
|
||||
* importing the module (a fresh import per state via `vi.resetModules`),
|
||||
* and flips are delivered through the captured `change` listener, exactly
|
||||
* as the browser does.
|
||||
*/
|
||||
type MqlListener = (e: { matches: boolean }) => void;
|
||||
|
||||
async function importWithMedia(matches: boolean) {
|
||||
const listeners: MqlListener[] = [];
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({
|
||||
matches,
|
||||
media: '',
|
||||
addEventListener: vi.fn(),
|
||||
addEventListener: (_t: string, fn: MqlListener) => listeners.push(fn),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
);
|
||||
vi.resetModules();
|
||||
const mod = await import('./grid');
|
||||
return {
|
||||
gridColumns: mod.gridColumns,
|
||||
fire: (m: boolean) => listeners.forEach((l) => l({ matches: m }))
|
||||
};
|
||||
}
|
||||
|
||||
describe('gridColumns', () => {
|
||||
beforeEach(() => mockMatchMedia(false));
|
||||
|
||||
it('returns 1 for non-positive width', () => {
|
||||
it('returns 1 for non-positive width', async () => {
|
||||
const { gridColumns } = await importWithMedia(false);
|
||||
expect(gridColumns(0)).toBe(1);
|
||||
expect(gridColumns(-100)).toBe(1);
|
||||
});
|
||||
|
||||
it('computes columns at desktop sizing (cardMin 200, gap 20)', () => {
|
||||
it('computes columns at desktop sizing (cardMin 200, gap 20)', async () => {
|
||||
const { gridColumns } = await importWithMedia(false);
|
||||
expect(gridColumns(220)).toBe(1); // floor(240/220)
|
||||
expect(gridColumns(440)).toBe(2); // floor(460/220)
|
||||
expect(gridColumns(900)).toBe(4); // floor(920/220)
|
||||
});
|
||||
|
||||
it('uses mobile sizing when the phone media query matches', () => {
|
||||
mockMatchMedia(true);
|
||||
it('uses mobile sizing when the phone media query matches', async () => {
|
||||
const { gridColumns } = await importWithMedia(true);
|
||||
expect(gridColumns(300)).toBe(2); // floor(308/148)
|
||||
expect(gridColumns(600)).toBe(4); // floor(608/148)
|
||||
});
|
||||
|
||||
it('breakpoint crossings propagate through the change listener', async () => {
|
||||
const { gridColumns, fire } = await importWithMedia(false);
|
||||
expect(gridColumns(600)).toBe(2); // desktop sizing
|
||||
fire(true); // viewport crossed under 640px
|
||||
expect(gridColumns(600)).toBe(4); // mobile sizing
|
||||
fire(false);
|
||||
expect(gridColumns(600)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,26 @@
|
||||
*
|
||||
* Card-min / gap track the tokens in `lib/styles/base/variables.css` and the
|
||||
* ≤640px phone override in `lib/styles/ported/resourceList.css`.
|
||||
*
|
||||
* The phone breakpoint is watched by ONE module-level MediaQueryList listener
|
||||
* — constructing a fresh `matchMedia` per call (a style read) was the same
|
||||
* anti-pattern the photos timeline already hoisted. A flip of the media query
|
||||
* always coincides with a width change, so callers re-run anyway.
|
||||
*/
|
||||
let isMobile = false;
|
||||
// `typeof window.matchMedia` (not just `window`): jsdom test environments
|
||||
// expose `window` without implementing matchMedia.
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
const mql = window.matchMedia('(max-width: 640px)');
|
||||
isMobile = mql.matches;
|
||||
mql.addEventListener('change', (e) => {
|
||||
isMobile = e.matches;
|
||||
});
|
||||
}
|
||||
|
||||
export function gridColumns(width: number): number {
|
||||
if (width <= 0) return 1;
|
||||
const mobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches;
|
||||
const cardMin = mobile ? 140 : 200;
|
||||
const gap = mobile ? 8 : 20;
|
||||
const cardMin = isMobile ? 140 : 200;
|
||||
const gap = isMobile ? 8 : 20;
|
||||
return Math.max(1, Math.floor((width + gap) / (cardMin + gap)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Incremental `id → position` index for `ResourceList`, extracted so the O(N²)
|
||||
* accumulation of its `itemIndexById` `$derived` (and the reap-stale effect's
|
||||
* per-page `new Set(items.map(…))`) can be replaced with an append-aware
|
||||
* builder — and unit/benchmark-tested off the Svelte reactive graph.
|
||||
*
|
||||
* `ResourceList` pages its list in via infinite scroll (`items = [...items,
|
||||
* ...page]`) and rebuilt `new Map(items.map((i, idx) => [i.id, idx]))` on every
|
||||
* page — O(N) per page, Σ ≈ O(N²) across a P-page drain, and a fresh Map each
|
||||
* page (so the reap-stale effect that reference-diffs it re-ran on every append
|
||||
* too, allocating another O(N) id Set for a reap that an append can never
|
||||
* trigger). This is the same class ROUND6 fixed for the files listing, ROUND14
|
||||
* §F2 for favorites, and ROUND15/16 for the grouped/shared lanes.
|
||||
*
|
||||
* Because a fresh page only ever *appends* (server order is stable; existing
|
||||
* rows keep their index), {@link ItemIndexBuilder} extends the persistent Map
|
||||
* with just the new tail on an append and returns the SAME Map reference; any
|
||||
* other change (reload, deletion, non-append) rebuilds into a NEW Map. That
|
||||
* reference contract is load-bearing for the two `ResourceList` consumers:
|
||||
*
|
||||
* - `selectedItems` re-derives on every `items` change regardless (it indexes
|
||||
* `items[idx]`), so it always reads the freshly-extended Map — a stable ref
|
||||
* on append costs it nothing.
|
||||
* - the reap-stale `$effect` reference-diffs the Map, so a stable ref on
|
||||
* append means it does NOT re-run there (an append never removes an id, so
|
||||
* there is nothing to reap), while a rebuild (delete / reload) yields a new
|
||||
* ref and DOES re-run it — exactly when stale selections must be dropped.
|
||||
*
|
||||
* The pure {@link buildItemIndex} is the verbatim reference (what the old
|
||||
* `itemIndexById` derive produced); the benchmark gate holds the builder equal
|
||||
* to it at every page.
|
||||
*/
|
||||
|
||||
import { isAppendExtension } from './appendExtension';
|
||||
|
||||
/** Minimal shape the index needs: a stable string `id`. */
|
||||
export interface HasId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reference: the `Map<id, index>` the old `itemIndexById` `$derived`
|
||||
* produced — `new Map(items.map((i, idx) => [i.id, idx]))`. On a duplicate id
|
||||
* the highest index wins (last insertion), matching `Map`'s own semantics.
|
||||
*/
|
||||
export function buildItemIndex<T extends HasId>(items: readonly T[]): Map<string, number> {
|
||||
const index = new Map<string, number>();
|
||||
for (let i = 0; i < items.length; i++) index.set(items[i].id, i);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-aware `id → index` builder. Call {@link sync} with the current item
|
||||
* list on every change; it detects the common case — the list grew by appending
|
||||
* a page — and indexes only the fresh tail, reusing the persistent Map (same
|
||||
* reference). Any other change rebuilds into a new Map, so the result is always
|
||||
* deep-equal to {@link buildItemIndex} and the reference changes exactly when a
|
||||
* reap-stale pass is warranted.
|
||||
*/
|
||||
export class ItemIndexBuilder<T extends HasId> {
|
||||
/** Last synced list — the append cursor and the append-detection baseline. */
|
||||
#items: readonly T[] = [];
|
||||
/** id → index; a stable reference across appends, a fresh one on rebuild. */
|
||||
#index = new Map<string, number>();
|
||||
|
||||
sync(items: readonly T[]): Map<string, number> {
|
||||
if (isAppendExtension(this.#items, items)) {
|
||||
// Append: the prefix is unchanged (existing ids keep their index), so
|
||||
// only the fresh tail needs indexing. A duplicate id in the tail
|
||||
// overwrites to its higher index — identical to the full rebuild's
|
||||
// last-wins. Same Map reference is returned (see the class doc).
|
||||
for (let i = this.#items.length; i < items.length; i++) {
|
||||
this.#index.set(items[i].id, i);
|
||||
}
|
||||
} else {
|
||||
// Reload / deletion / non-append / first run: rebuild into a NEW Map so
|
||||
// the reap-stale effect (which reference-diffs it) re-runs.
|
||||
this.#index = buildItemIndex(items);
|
||||
}
|
||||
this.#items = items;
|
||||
return this.#index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { primeContextPage } from './listContext';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental route-level `contextMap` maintenance
|
||||
* (primeContextPage) that replaced `contextMap = $derived(new Map(raw.map(...)))`
|
||||
* on the trash / recent / favorites / shared-with-me routes.
|
||||
*
|
||||
* Audit finding (ROUND16 §F2, the route-level half of the class ROUND15 §F1
|
||||
* fixed inside ResourceList): each route paged its rows in via
|
||||
* `raw = [...raw, ...page.items]` and rebuilt a brand-new Map — hashing every
|
||||
* accumulated id — on EVERY page. O(N) per page ⇒ Σ O(N²/page) across a drain,
|
||||
* plus a fresh Map instance each page. The fix holds one persistent map and
|
||||
* sets only the fresh page's entries (mirrors the shipped `favoriteIds`
|
||||
* SvelteSet, ROUND14 §F2).
|
||||
*
|
||||
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
|
||||
* 1. Equivalence — at EVERY page, the incrementally-primed map is deep-equal
|
||||
* to a full `new Map(cumulative.map(entry))` rebuild, including skipped
|
||||
* entries (drives → null) and the reset path.
|
||||
* 2. Perf — `entry` work collapses from Σ O(N²/page) to O(N) across the drain
|
||||
* (deterministic call count) and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
date: string | null;
|
||||
ownerId: string | null;
|
||||
}
|
||||
interface Raw {
|
||||
resource: { id: string; updated_by: string | null };
|
||||
resource_type: 'file' | 'folder' | 'drive';
|
||||
accessed_at: string;
|
||||
}
|
||||
|
||||
const pad = (i: number) => i.toString().padStart(6, '0');
|
||||
|
||||
/** Item `i`; every 10th is a `drive` (skipped by the shared-with-me-style entry). */
|
||||
function raw(i: number): Raw {
|
||||
return {
|
||||
resource: { id: `res-${pad(i)}`, updated_by: `user-${i % 8}` },
|
||||
resource_type: i % 10 === 0 ? 'drive' : i % 3 === 0 ? 'folder' : 'file',
|
||||
accessed_at: `2026-07-${pad((i % 27) + 1).slice(-2)}`
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a raw item to its `[id, ctx]`, skipping drives (returns null) — counts calls. */
|
||||
function makeEntry(counter?: { n: number }): (it: Raw) => readonly [string, Ctx] | null {
|
||||
return (it) => {
|
||||
if (counter) counter.n++;
|
||||
if (it.resource_type === 'drive') return null;
|
||||
return [it.resource.id, { date: it.accessed_at, ownerId: it.resource.updated_by }];
|
||||
};
|
||||
}
|
||||
|
||||
/** Verbatim BEFORE: the old derive — a fresh Map hashing the whole cumulative list. */
|
||||
function rebuild(
|
||||
cumulative: Raw[],
|
||||
entry: (it: Raw) => readonly [string, Ctx] | null
|
||||
): Map<string, Ctx> {
|
||||
const m = new Map<string, Ctx>();
|
||||
for (const it of cumulative) {
|
||||
const e = entry(it);
|
||||
if (e !== null) m.set(e[0], e[1]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
const PAGES = 50; // 2 500-item drain
|
||||
|
||||
describe('incremental contextMap (benchmark gate)', () => {
|
||||
it('stays deep-equal to the full rebuild at every page (incl. skipped drives)', () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => raw(i));
|
||||
const entry = makeEntry();
|
||||
const map = new Map<string, Ctx>();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const page = all.slice((p - 1) * PAGE, p * PAGE);
|
||||
primeContextPage(map, p === 1, page, entry);
|
||||
const reference = rebuild(all.slice(0, p * PAGE), entry);
|
||||
expect(new Map(map), `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears on reset and re-primes to the reset page only', () => {
|
||||
const all = Array.from({ length: 200 }, (_, i) => raw(i));
|
||||
const entry = makeEntry();
|
||||
const map = new Map<string, Ctx>();
|
||||
primeContextPage(map, true, all.slice(0, 100), entry);
|
||||
primeContextPage(map, false, all.slice(100, 150), entry);
|
||||
// Reset with a disjoint page: prior ids must be gone.
|
||||
const resetPage = all.slice(150, 200);
|
||||
primeContextPage(map, true, resetPage, entry);
|
||||
expect(new Map(map)).toEqual(rebuild(resetPage, entry));
|
||||
});
|
||||
|
||||
it('collapses entry work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => raw(i));
|
||||
|
||||
// Deterministic call-count gate (the hard rollback gate): incremental
|
||||
// computes each item's entry exactly once; the rebuild is quadratic. This
|
||||
// holds regardless of machine load.
|
||||
const afterCounter = { n: 0 };
|
||||
const afterEntry = makeEntry(afterCounter);
|
||||
const countMap = new Map<string, Ctx>();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
primeContextPage(countMap, p === 1, all.slice((p - 1) * PAGE, p * PAGE), afterEntry);
|
||||
}
|
||||
const beforeCounter = { n: 0 };
|
||||
const beforeEntry = makeEntry(beforeCounter);
|
||||
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), beforeEntry);
|
||||
expect(afterCounter.n).toBe(N);
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
|
||||
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
|
||||
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern).
|
||||
const entry = makeEntry();
|
||||
const runAfter = () => {
|
||||
const m = new Map<string, Ctx>();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
primeContextPage(m, p === 1, all.slice((p - 1) * PAGE, p * PAGE), entry);
|
||||
}
|
||||
return performance.now() - t;
|
||||
};
|
||||
const runBefore = () => {
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), entry);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
|
||||
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
|
||||
|
||||
console.info(
|
||||
`contextMap ${PAGES}×${PAGE}: before ${beforeCounter.n} entry calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Incremental maintenance of a grouped-listing route's per-item `contextMap`
|
||||
* (the `id → ItemContext` envelope `ResourceList` reads via `ctxOf`).
|
||||
*
|
||||
* The trash / recent / favorites / shared-with-me routes page their rows in via
|
||||
* infinite scroll (`raw = [...raw, ...page.items]`) and each derived its
|
||||
* contextMap as `new Map(raw.map((it) => [id, ctx]))` — rebuilding a brand-new
|
||||
* Map, hashing every accumulated id, on EVERY page. O(N) per page ⇒ O(N²)
|
||||
* across a drain, and a fresh Map instance each page invalidated every reader
|
||||
* (ROUND15 landed the `sections` half of this class inside `ResourceList` but
|
||||
* left the route-level projection that feeds it untouched).
|
||||
*
|
||||
* {@link primeContextPage} mirrors the shipped `favoriteIds` fix (ROUND14 §F2,
|
||||
* `SvelteSet` primed per page): the route holds ONE persistent reactive map
|
||||
* (`SvelteMap`) for the component's lifetime and, in `load()`, clears it on a
|
||||
* reset and sets only the freshly-fetched page's entries — O(page) per page,
|
||||
* O(N) across the drain, one stable instance. The map only ever needs to be a
|
||||
* superset of the currently-displayed ids: rows removed by a delete are no
|
||||
* longer rendered, so their now-stale entries are never read (identical
|
||||
* reasoning to `favoriteIds`). Every id entering `raw` comes through a
|
||||
* `load()` page, so the map always covers what is on screen.
|
||||
*
|
||||
* The param is typed `Map` (not `SvelteMap`) so the benchmark can drive the
|
||||
* exact same update logic against a plain Map, decoupled from Svelte
|
||||
* reactivity — the same way `round14.bench.test.ts` benches the `favoriteIds`
|
||||
* set. Callers pass their `SvelteMap` at runtime.
|
||||
*/
|
||||
export function primeContextPage<Raw, C>(
|
||||
map: Map<string, C>,
|
||||
reset: boolean,
|
||||
page: Iterable<Raw>,
|
||||
/** Map one fetched item to its `[id, ctx]` entry, or `null` to skip it (e.g. drives). */
|
||||
entry: (item: Raw) => readonly [string, C] | null
|
||||
): void {
|
||||
if (reset) map.clear();
|
||||
for (const item of page) {
|
||||
const e = entry(item);
|
||||
if (e !== null) map.set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Local-storage prefs — key convention + user-scoped cleanup.
|
||||
*
|
||||
* # Key convention
|
||||
*
|
||||
* Every persistent client-side preference lives under the `oxi-` prefix.
|
||||
* Historical mix of `oxicloud_*`, `oxicloud-*`, and `oxi-*` normalised to
|
||||
* one form so `wipeAppKeys()` below can sweep the whole set with a single
|
||||
* `startsWith('oxi-')` predicate.
|
||||
*
|
||||
* # Nuke-on-mismatch at login
|
||||
*
|
||||
* `ensureActiveUser(userId)` compares the newly-authenticated user id
|
||||
* against the stored `oxi-active-user-id`. If they differ, EVERY `oxi-*`
|
||||
* key is removed (except the active-user marker itself). This runs on:
|
||||
* * first login after page load,
|
||||
* * "switch account" flows where the current tab silently changes user,
|
||||
* * session expiry then re-login as someone else.
|
||||
*
|
||||
* The wipe is intentionally broad — one naming convention beats maintaining
|
||||
* a per-key whitelist that decays as new preferences get added.
|
||||
*
|
||||
* # Not stored here
|
||||
*
|
||||
* Auth tokens and CSRF cookies do NOT use `oxi-*` keys — they live in
|
||||
* HTTP-only cookies set by the backend and are outside localStorage.
|
||||
* Nothing to wipe there.
|
||||
*/
|
||||
|
||||
/** Marker key: which user's prefs currently live in localStorage. */
|
||||
const ACTIVE_USER_KEY = 'oxi-active-user-id';
|
||||
|
||||
/** Every persistent client pref key must start with this. */
|
||||
const OXI_PREFIX = 'oxi-';
|
||||
|
||||
/**
|
||||
* Remove every `oxi-*` key from localStorage EXCEPT the active-user marker.
|
||||
* Idempotent; no-op when localStorage is unavailable (SSR, private mode).
|
||||
*/
|
||||
export function wipeAppKeys(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
// Materialise the key list first — mutating localStorage while iterating
|
||||
// its live view skips half the entries.
|
||||
const keys = Object.keys(localStorage);
|
||||
for (const key of keys) {
|
||||
if (key === ACTIVE_USER_KEY) continue;
|
||||
if (key.startsWith(OXI_PREFIX)) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode / quota — best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure any localStorage state belongs to `userId`. When the marker
|
||||
* doesn't match — first login of the page, re-login as someone else,
|
||||
* or a fossil from a previous release with no marker — the whole app
|
||||
* key namespace is nuked and the marker is set to the current user.
|
||||
*
|
||||
* Call once, right after the session store observes an authenticated
|
||||
* user. Cheap when no work is needed (single `getItem`).
|
||||
*/
|
||||
export function ensureActiveUser(userId: string): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(ACTIVE_USER_KEY);
|
||||
} catch {
|
||||
/* private mode — treat as "no marker" so we do the cleanup pass */
|
||||
}
|
||||
if (stored === userId) return;
|
||||
wipeAppKeys();
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_USER_KEY, userId);
|
||||
} catch {
|
||||
/* private mode — cleanup still ran, marker will re-attempt next login */
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ export function minimalPhotoItem(id: string): FileItem {
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: 0,
|
||||
name: '',
|
||||
owner_id: '',
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
folder_id: '',
|
||||
path: '',
|
||||
size: 0,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PhotoItem } from '$lib/api/endpoints/photos';
|
||||
import {
|
||||
PhotoTimeline,
|
||||
buildPhotoRows,
|
||||
type GroupMode,
|
||||
type LayoutMode,
|
||||
type TimelineConfig
|
||||
} from './photoTimeline';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental photo timeline (PhotoTimeline) that
|
||||
* replaced the photos view's `groups`→`photoRows` derive chain.
|
||||
*
|
||||
* Audit finding: `loadMore` does `items = [...items, ...page]` (60/page), and
|
||||
* both `groups` (O(N), a `new Date()` per photo) and `photoRows` (O(N) row
|
||||
* layout) are `$derived` over the whole accumulated list — so paging to photo
|
||||
* N re-groups + re-lays-out everything loaded so far, Σ ≈ O(N²/60) main-thread
|
||||
* work during the scroll (the same class ROUND6 fixed for the files listing).
|
||||
* Since pages arrive newest-first, grouping is append-only; PhotoTimeline
|
||||
* re-buckets only the fresh page and re-lays-out only the groups that changed.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Equivalence — at EVERY page of the drain, the incremental output is
|
||||
* deep-equal to the verbatim full-rebuild reference (buildPhotoRows), for
|
||||
* both layouts; plus config-change, deletion and width=0 fall back to a
|
||||
* correct full rebuild.
|
||||
* 2. Perf — grouping work (timestamp reads) collapses from Σ O(N²/60) to O(N)
|
||||
* across the drain (deterministic count), and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
const DAY = 86_400; // seconds
|
||||
|
||||
/** A photo with a descending sort_date and a deterministic aspect ratio. */
|
||||
function photo(i: number): PhotoItem {
|
||||
// Newest-first: photo 0 is most recent; ~half a day apart spans ~4 years
|
||||
// over 3k photos, so month/day buckets are bounded (realistic library).
|
||||
const sortDate = 1_700_000_000 - i * (DAY / 2);
|
||||
const w = 200 + ((i * 37) % 400);
|
||||
const h = 200 + ((i * 53) % 300);
|
||||
return {
|
||||
category: 'image',
|
||||
created_at: sortDate,
|
||||
icon_class: '',
|
||||
icon_special_class: '',
|
||||
id: `p-${i.toString().padStart(6, '0')}`,
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: sortDate,
|
||||
name: `photo ${i}.jpg`,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
folder_id: 'f',
|
||||
path: `/photo ${i}.jpg`,
|
||||
size: 1000,
|
||||
size_formatted: '1 KB',
|
||||
sort_date: sortDate,
|
||||
etag: `e${i}`,
|
||||
content_hash: `h${i}`,
|
||||
width: w,
|
||||
height: h
|
||||
} as PhotoItem;
|
||||
}
|
||||
|
||||
/** Instrumented config: counts every timestamp read (the grouping hot op). */
|
||||
function makeConfig(
|
||||
groupMode: GroupMode,
|
||||
layoutMode: LayoutMode,
|
||||
width: number,
|
||||
counter?: { n: number }
|
||||
): TimelineConfig {
|
||||
const timestampOf = (p: PhotoItem) => {
|
||||
if (counter) counter.n++;
|
||||
const v = p.sort_date || p.created_at || 0;
|
||||
return v < 1e12 ? v * 1000 : v;
|
||||
};
|
||||
// Stable label fn (reference identity matters for the config-unchanged path).
|
||||
const labelOf = (d: Date, mode: GroupMode) =>
|
||||
mode === 'year'
|
||||
? `${d.getFullYear()}`
|
||||
: mode === 'month'
|
||||
? `${d.getFullYear()}-${d.getMonth() + 1}`
|
||||
: `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
return { groupMode, layoutMode, width, mobile: false, timestampOf, labelOf };
|
||||
}
|
||||
|
||||
const PAGE = 60;
|
||||
const PAGES = 50; // 3 000-photo drain
|
||||
const WIDTH = 1200;
|
||||
|
||||
describe('incremental photo timeline (benchmark gate)', () => {
|
||||
for (const layout of ['square', 'justified'] as LayoutMode[]) {
|
||||
it(`stays deep-equal to the full rebuild at every page — ${layout}`, () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => photo(i));
|
||||
const cfg = makeConfig('month', layout, WIDTH);
|
||||
const timeline = new PhotoTimeline();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const cumulative = all.slice(0, p * PAGE);
|
||||
const incremental = timeline.sync(cumulative, cfg);
|
||||
const reference = buildPhotoRows(cumulative, cfg);
|
||||
expect(incremental, `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('falls back to a correct full rebuild on config change, deletion and width=0', () => {
|
||||
const all = Array.from({ length: 600 }, (_, i) => photo(i));
|
||||
const timeline = new PhotoTimeline();
|
||||
const monthSquare = makeConfig('month', 'square', WIDTH);
|
||||
|
||||
// Drain a few pages, then flip layout — must equal a fresh full rebuild.
|
||||
timeline.sync(all.slice(0, 300), monthSquare);
|
||||
const justified = makeConfig('month', 'justified', WIDTH);
|
||||
expect(timeline.sync(all.slice(0, 300), justified)).toEqual(
|
||||
buildPhotoRows(all.slice(0, 300), justified)
|
||||
);
|
||||
|
||||
// Change group mode.
|
||||
const yearJust = makeConfig('year', 'justified', WIDTH);
|
||||
expect(timeline.sync(all.slice(0, 300), yearJust)).toEqual(
|
||||
buildPhotoRows(all.slice(0, 300), yearJust)
|
||||
);
|
||||
|
||||
// Deletion (list shrinks / prefix changes) → rebuild.
|
||||
const shrunk = all.slice(0, 300).filter((_, i) => i % 7 !== 0);
|
||||
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
|
||||
|
||||
// width=0 yields [] and doesn't wedge the next positive-width sync.
|
||||
const zero = makeConfig('year', 'justified', 0);
|
||||
expect(timeline.sync(shrunk, zero)).toEqual([]);
|
||||
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
|
||||
});
|
||||
|
||||
it('collapses grouping work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => photo(i));
|
||||
|
||||
// AFTER: incremental — each photo is bucketed exactly once across the drain.
|
||||
const afterCounter = { n: 0 };
|
||||
const afterCfg = makeConfig('month', 'square', WIDTH, afterCounter);
|
||||
const timeline = new PhotoTimeline();
|
||||
const t1 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) timeline.sync(all.slice(0, p * PAGE), afterCfg);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// BEFORE: full rebuild per page — re-buckets the whole cumulative list.
|
||||
const beforeCounter = { n: 0 };
|
||||
const beforeCfg = makeConfig('month', 'square', WIDTH, beforeCounter);
|
||||
const t0 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) buildPhotoRows(all.slice(0, p * PAGE), beforeCfg);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
console.info(
|
||||
`photo timeline ${PAGES}×${PAGE}: before ${beforeCounter.n} timestamp reads / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} reads / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer reads, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
// Incremental buckets each photo once: exactly N reads.
|
||||
expect(afterCounter.n).toBe(N);
|
||||
// Full rebuild is quadratic: Σ_{p=1..P} p·PAGE.
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Photo-timeline grouping + row layout, extracted from the photos view so the
|
||||
* O(N²) accumulation of its `groups`/`photoRows` derives can be replaced with
|
||||
* an incremental builder (and unit/benchmark-tested off the Svelte reactive
|
||||
* graph).
|
||||
*
|
||||
* Photos arrive newest-first (`media_sort_date DESC`), so each fetched page
|
||||
* only ever extends the last date bucket or appends new buckets after it —
|
||||
* never mutates an earlier group. {@link PhotoTimeline} exploits that: an
|
||||
* append re-buckets only the new page and recomputes rows only for the groups
|
||||
* that actually changed, keeping a full scroll O(N) instead of O(N²).
|
||||
*
|
||||
* The pure {@link buildPhotoRows} is the verbatim reference (what the old
|
||||
* `groups`→`photoRows` derive chain produced); the benchmark gate asserts the
|
||||
* incremental builder stays byte-for-byte equal to it.
|
||||
*/
|
||||
import type { PhotoItem } from '$lib/api/endpoints/photos';
|
||||
|
||||
export type GroupMode = 'day' | 'month' | 'year';
|
||||
export type LayoutMode = 'square' | 'justified';
|
||||
|
||||
export interface JustifiedTile {
|
||||
file: PhotoItem;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type PhotoRow =
|
||||
| { kind: 'header'; key: string; height: number; label: string; count: number }
|
||||
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
|
||||
|
||||
/** Layout constants — mirror the photos view's original values exactly. */
|
||||
export const SQUARE_GAP = 4; // .25rem, matches the old grid gap
|
||||
export const SQUARE_MIN = 144; // 9rem minmax floor
|
||||
export const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
|
||||
export const HEADER_H = 44;
|
||||
|
||||
export interface TimelineConfig {
|
||||
groupMode: GroupMode;
|
||||
layoutMode: LayoutMode;
|
||||
/** Usable content width of the grid, in px. */
|
||||
width: number;
|
||||
/** `(max-width: 768px)` — selects the 150px vs 200px justified target. */
|
||||
mobile: boolean;
|
||||
/** EXIF-aware capture timestamp (ms). Injected so the module stays pure. */
|
||||
timestampOf: (p: PhotoItem) => number;
|
||||
/** Locale-aware bucket label for a group's representative date. */
|
||||
labelOf: (d: Date, mode: GroupMode) => string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
photos: PhotoItem[];
|
||||
}
|
||||
|
||||
/** Year/month/day bucket key for a date under `groupMode` (verbatim). */
|
||||
export function bucketKey(d: Date, groupMode: GroupMode): string {
|
||||
const y = d.getFullYear();
|
||||
if (groupMode === 'year') return `${y}`;
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
if (groupMode === 'month') return `${y}-${m}`;
|
||||
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack files into justified rows (Flickr-style): each full row is scaled to
|
||||
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
|
||||
* fall back to 1:1. Verbatim port of the photos view's `justifiedRows`, with
|
||||
* the `matchMedia` read hoisted to the `mobile` flag so it's testable.
|
||||
*/
|
||||
export function justifiedRows(
|
||||
files: PhotoItem[],
|
||||
width: number,
|
||||
mobile: boolean
|
||||
): Array<{ height: number; tiles: JustifiedTile[] }> {
|
||||
const gap = 8;
|
||||
const target = mobile ? 150 : 200;
|
||||
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
|
||||
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
|
||||
let aspectSum = 0;
|
||||
for (const file of files) {
|
||||
let aspect = file.width && file.height ? file.width / file.height : 1;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
|
||||
aspect = Math.min(Math.max(aspect, 0.4), 3);
|
||||
cur.push({ file, aspect });
|
||||
aspectSum += aspect;
|
||||
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
|
||||
if (rowWidth >= width) {
|
||||
const h = (width - (cur.length - 1) * gap) / aspectSum;
|
||||
rows.push({
|
||||
height: Math.round(h),
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * h)),
|
||||
h: Math.round(h)
|
||||
}))
|
||||
});
|
||||
cur = [];
|
||||
aspectSum = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) {
|
||||
rows.push({
|
||||
height: target,
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * target)),
|
||||
h: target
|
||||
}))
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Columns + cell size for the square layout at width `W` (verbatim). */
|
||||
function squareGeometry(W: number): { cols: number; cell: number } {
|
||||
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
|
||||
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
|
||||
return { cols, cell };
|
||||
}
|
||||
|
||||
/** Flatten one group into its header + tile rows (verbatim per-group body). */
|
||||
function groupToRows(g: Group, cfg: TimelineConfig, cols: number, cell: number): PhotoRow[] {
|
||||
const rows: PhotoRow[] = [
|
||||
{ kind: 'header', key: `h:${g.key}`, height: HEADER_H, label: g.label, count: g.photos.length }
|
||||
];
|
||||
if (cfg.layoutMode === 'justified') {
|
||||
const jrows = justifiedRows(g.photos, cfg.width, cfg.mobile);
|
||||
for (let ri = 0; ri < jrows.length; ri++) {
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:j${ri}`,
|
||||
height: jrows[ri].height + JUSTIFIED_GAP,
|
||||
gap: JUSTIFIED_GAP,
|
||||
tiles: jrows[ri].tiles
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < g.photos.length; i += cols) {
|
||||
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:s${i}`,
|
||||
height: cell + SQUARE_GAP,
|
||||
gap: SQUARE_GAP,
|
||||
tiles
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Bucket `items` into date groups, first-appearance order (verbatim). */
|
||||
function buildGroups(items: PhotoItem[], cfg: TimelineConfig): Group[] {
|
||||
const out: Group[] = [];
|
||||
const index = new Map<string, number>();
|
||||
for (const p of items) {
|
||||
const d = new Date(cfg.timestampOf(p));
|
||||
const key = bucketKey(d, cfg.groupMode);
|
||||
let i = index.get(key);
|
||||
if (i === undefined) {
|
||||
i = out.length;
|
||||
index.set(key, i);
|
||||
out.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [] });
|
||||
}
|
||||
out[i].photos.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reference: the flat `PhotoRow[]` the old `groups`→`photoRows`
|
||||
* derive chain produced for `items` under `cfg`. Returns `[]` for a
|
||||
* non-positive width, matching the old guard. The benchmark gate holds the
|
||||
* incremental builder equal to this.
|
||||
*/
|
||||
export function buildPhotoRows(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
|
||||
if (cfg.width <= 0) return [];
|
||||
const { cols, cell } = squareGeometry(cfg.width);
|
||||
const rows: PhotoRow[] = [];
|
||||
for (const g of buildGroups(items, cfg)) {
|
||||
rows.push(...groupToRows(g, cfg, cols, cell));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function configEq(a: TimelineConfig, b: TimelineConfig): boolean {
|
||||
return (
|
||||
a.groupMode === b.groupMode &&
|
||||
a.layoutMode === b.layoutMode &&
|
||||
a.width === b.width &&
|
||||
a.mobile === b.mobile &&
|
||||
a.timestampOf === b.timestampOf &&
|
||||
a.labelOf === b.labelOf
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental photo-timeline builder. Call {@link sync} with the current item
|
||||
* list and config on every change; it detects the common case — the list grew
|
||||
* by appending a page while config is unchanged — and re-buckets only the new
|
||||
* items + re-lays-out only the groups that changed, reusing every untouched
|
||||
* group's cached rows. Any other change (config, deletion, filter toggle,
|
||||
* non-append) falls back to a full rebuild, so the result is always identical
|
||||
* to {@link buildPhotoRows}.
|
||||
*/
|
||||
export class PhotoTimeline {
|
||||
#cfg: TimelineConfig | null = null;
|
||||
#groups: Group[] = [];
|
||||
/** Items already bucketed — the append cursor into the last synced list. */
|
||||
#groupedItems: PhotoItem[] = [];
|
||||
/** group.key → its cached rows for the current config. */
|
||||
#rowCache = new Map<string, PhotoRow[]>();
|
||||
#geom = { cols: 1, cell: 0 };
|
||||
|
||||
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
|
||||
#isAppend(prev: PhotoItem[], next: PhotoItem[]): boolean {
|
||||
if (next.length <= prev.length) return false;
|
||||
// Prefix identity via the boundary object — O(1), the list is only ever
|
||||
// mutated by appending or by replacing with a filtered copy.
|
||||
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
|
||||
}
|
||||
|
||||
#rebuild(items: PhotoItem[], cfg: TimelineConfig): void {
|
||||
this.#cfg = cfg;
|
||||
this.#groups = cfg.width > 0 ? buildGroups(items, cfg) : [];
|
||||
this.#groupedItems = items;
|
||||
this.#rowCache.clear();
|
||||
this.#geom = squareGeometry(cfg.width);
|
||||
}
|
||||
|
||||
#extend(items: PhotoItem[], cfg: TimelineConfig): void {
|
||||
const fresh = items.slice(this.#groupedItems.length);
|
||||
// The last existing group may grow, so its cached rows are stale.
|
||||
if (this.#groups.length > 0) {
|
||||
this.#rowCache.delete(this.#groups[this.#groups.length - 1].key);
|
||||
}
|
||||
for (const p of fresh) {
|
||||
const d = new Date(cfg.timestampOf(p));
|
||||
const key = bucketKey(d, cfg.groupMode);
|
||||
const last = this.#groups[this.#groups.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.photos.push(p);
|
||||
} else {
|
||||
this.#groups.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [p] });
|
||||
}
|
||||
}
|
||||
this.#groupedItems = items;
|
||||
}
|
||||
|
||||
sync(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
|
||||
if (cfg.width <= 0) {
|
||||
// Keep the item cursor so a later positive width rebuilds from scratch.
|
||||
this.#cfg = cfg;
|
||||
this.#groups = [];
|
||||
this.#groupedItems = items;
|
||||
this.#rowCache.clear();
|
||||
return [];
|
||||
}
|
||||
if (this.#cfg && configEq(this.#cfg, cfg) && this.#isAppend(this.#groupedItems, items)) {
|
||||
this.#extend(items, cfg);
|
||||
} else {
|
||||
this.#rebuild(items, cfg);
|
||||
}
|
||||
|
||||
const { cols, cell } = this.#geom;
|
||||
const out: PhotoRow[] = [];
|
||||
for (const g of this.#groups) {
|
||||
let rows = this.#rowCache.get(g.key);
|
||||
if (rows === undefined) {
|
||||
rows = groupToRows(g, cfg, cols, cell);
|
||||
this.#rowCache.set(g.key, rows);
|
||||
}
|
||||
for (const r of rows) out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ResourceSectionsBuilder,
|
||||
buildResourceSections,
|
||||
type SectionGrouping
|
||||
} from './resourceSections';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental swimlane builder (ResourceSectionsBuilder)
|
||||
* that replaced ResourceList's `sections` `$derived.by`.
|
||||
*
|
||||
* Audit finding (ROUND14 deferred flagship): every grouped listing (trash,
|
||||
* recent, favorites, shared-with-me) pages in via `raw = [...raw, ...page]`,
|
||||
* and `sections` re-bucketed the WHOLE accumulated list on every page — Σ ≈
|
||||
* O(N²/page) `bucketOf` + `ctxOf` calls during an infinite-scroll drain, and a
|
||||
* brand-new rows array for EVERY bucket each page (so VirtualList re-diffed
|
||||
* every swimlane every page). The builder re-buckets only the fresh page and
|
||||
* hands back the same array reference for untouched buckets.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Equivalence — at EVERY page of the drain, the incremental output is
|
||||
* deep-equal to the verbatim full-rebuild reference (buildResourceSections),
|
||||
* for a contiguous group-by (date, bucket aligned with order) AND a
|
||||
* non-contiguous one (trash-by-drive: name-ordered, drive-bucketed); plus
|
||||
* group-by switch, deletion and the flat pass-through fall back correctly.
|
||||
* 2. Reference stability — untouched buckets keep their exact array reference
|
||||
* across a page append (the property VirtualList relies on to skip them),
|
||||
* while a grown bucket gets a fresh one.
|
||||
* 3. Perf — bucketing work collapses from Σ O(N²/page) to O(N) across the
|
||||
* drain (deterministic `bucketOf`-call count) and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
driveId: string;
|
||||
/** ms epoch; descending with index (newest-first, like the server pages). */
|
||||
date: number;
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
date: number;
|
||||
driveId: string;
|
||||
}
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Item `i`: newest-first date, name in a fixed lexical order, round-robin drive. */
|
||||
function item(i: number): Item {
|
||||
return {
|
||||
id: `it-${i.toString().padStart(6, '0')}`,
|
||||
// Zero-padded so lexical name order is a stable, well-defined sequence.
|
||||
name: `file-${i.toString().padStart(6, '0')}`,
|
||||
driveId: `drive-${i % 4}`,
|
||||
date: 1_700_000_000_000 - i * (DAY / 2)
|
||||
};
|
||||
}
|
||||
|
||||
const contextMap = new Map<string, Ctx>();
|
||||
function ctxOf(it: Item): Ctx | undefined {
|
||||
let c = contextMap.get(it.id);
|
||||
if (!c) {
|
||||
c = { date: it.date, driveId: it.driveId };
|
||||
contextMap.set(it.id, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Month bucket key from a ctx date (contiguous under date order). */
|
||||
function monthKey(d: number): string {
|
||||
const dt = new Date(d);
|
||||
return `${dt.getUTCFullYear()}-${`${dt.getUTCMonth() + 1}`.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Contiguous group-by: date-ordered pages, date buckets. Counts bucketOf calls. */
|
||||
function dateGrouping(counter?: { n: number }): SectionGrouping<Item, Ctx> {
|
||||
return {
|
||||
bucketOf: (_it, ctx) => {
|
||||
if (counter) counter.n++;
|
||||
return ctx ? monthKey(ctx.date) : null;
|
||||
},
|
||||
labelOf: (k) => `📅 ${k}`,
|
||||
ctxOf
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-contiguous group-by mirroring trash "by drive": pages arrive in NAME
|
||||
* order but bucket by driveId, so a fresh page sprays items across every
|
||||
* already-emitted drive bucket. Equivalence must still hold.
|
||||
*/
|
||||
function driveGrouping(counter?: { n: number }): SectionGrouping<Item, Ctx> {
|
||||
return {
|
||||
bucketOf: (_it, ctx) => {
|
||||
if (counter) counter.n++;
|
||||
return ctx ? ctx.driveId : null;
|
||||
},
|
||||
labelOf: (k) => `💾 ${k}`,
|
||||
ctxOf
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
const PAGES = 50; // 2 500-item drain
|
||||
|
||||
describe('incremental resource sections (benchmark gate)', () => {
|
||||
for (const [name, mk] of [
|
||||
['contiguous date buckets', dateGrouping],
|
||||
['non-contiguous drive buckets', driveGrouping]
|
||||
] as const) {
|
||||
it(`stays deep-equal to the full rebuild at every page — ${name}`, () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i));
|
||||
const builder = new ResourceSectionsBuilder<Item, Ctx>();
|
||||
// ONE stable grouping across the drain — mirrors the component, where
|
||||
// `activeGroup.bucketOf` is a fixed closure from the page's once-defined
|
||||
// `groupBys`. This is what lets the builder take its incremental path,
|
||||
// so this loop genuinely exercises it (not the rebuild fallback).
|
||||
const g = mk();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const cumulative = all.slice(0, p * PAGE);
|
||||
const incremental = builder.sync(cumulative, g);
|
||||
const reference = buildResourceSections(cumulative, g);
|
||||
expect(incremental, `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps untouched bucket arrays reference-stable and refreshes grown ones', () => {
|
||||
const all = Array.from({ length: 600 }, (_, i) => item(i));
|
||||
const builder = new ResourceSectionsBuilder<Item, Ctx>();
|
||||
const g = dateGrouping();
|
||||
|
||||
const first = builder.sync(all.slice(0, 300), g);
|
||||
const refBefore = new Map(first.map((s) => [s.key, s.rows]));
|
||||
|
||||
const second = builder.sync(all.slice(0, 350), g);
|
||||
let stable = 0;
|
||||
let refreshed = 0;
|
||||
for (const s of second) {
|
||||
const prev = refBefore.get(s.key);
|
||||
if (prev === undefined) continue; // brand-new bucket
|
||||
if (prev === s.rows) stable++;
|
||||
else refreshed++;
|
||||
}
|
||||
// Date-ordered append only grows the boundary bucket(s): most earlier
|
||||
// buckets must be handed back by the SAME reference (VirtualList skips
|
||||
// them), and at least one bucket must be refreshed (it grew).
|
||||
expect(stable).toBeGreaterThan(0);
|
||||
expect(refreshed).toBeGreaterThan(0);
|
||||
expect(stable).toBeGreaterThan(refreshed);
|
||||
});
|
||||
|
||||
it('falls back to a correct full rebuild on group-by switch, deletion and flat', () => {
|
||||
const all = Array.from({ length: 600 }, (_, i) => item(i));
|
||||
const builder = new ResourceSectionsBuilder<Item, Ctx>();
|
||||
const byDate = dateGrouping();
|
||||
const byDrive = driveGrouping();
|
||||
|
||||
// Drain a few pages under date grouping, then switch to drive grouping
|
||||
// (a different bucketOf reference → rebuild).
|
||||
builder.sync(all.slice(0, 300), byDate);
|
||||
expect(builder.sync(all.slice(0, 300), byDrive)).toEqual(
|
||||
buildResourceSections(all.slice(0, 300), byDrive)
|
||||
);
|
||||
|
||||
// Deletion under the SAME grouping (list shrinks / prefix changes) →
|
||||
// rebuild via the append check, not a grouping-ref change.
|
||||
const shrunk = all.slice(0, 300).filter((_, i) => i % 7 !== 0);
|
||||
expect(builder.sync(shrunk, byDrive)).toEqual(buildResourceSections(shrunk, byDrive));
|
||||
|
||||
// Flat pass-through (no bucketOf) yields one section and doesn't wedge the
|
||||
// next grouped sync.
|
||||
const flat: SectionGrouping<Item, Ctx> = { ctxOf };
|
||||
const flatOut = builder.sync(shrunk, flat);
|
||||
expect(flatOut).toEqual([{ key: '', label: '', rows: shrunk }]);
|
||||
expect(flatOut[0].rows).toBe(shrunk); // pass-through, no copy
|
||||
expect(builder.sync(shrunk, byDate)).toEqual(buildResourceSections(shrunk, byDate));
|
||||
});
|
||||
|
||||
it('collapses bucketing work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => item(i));
|
||||
|
||||
// AFTER: incremental — each item is bucketed exactly once across the drain.
|
||||
// ONE stable grouping (fixed bucketOf), exactly as the component supplies.
|
||||
const afterCounter = { n: 0 };
|
||||
const gAfter = dateGrouping(afterCounter);
|
||||
const builder = new ResourceSectionsBuilder<Item, Ctx>();
|
||||
const t1 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) builder.sync(all.slice(0, p * PAGE), gAfter);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// BEFORE: full rebuild per page — re-buckets the whole cumulative list.
|
||||
const beforeCounter = { n: 0 };
|
||||
const gBefore = dateGrouping(beforeCounter);
|
||||
const t0 = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) buildResourceSections(all.slice(0, p * PAGE), gBefore);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
console.info(
|
||||
`resource sections ${PAGES}×${PAGE}: before ${beforeCounter.n} bucketOf calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
// Incremental buckets each item once: exactly N calls.
|
||||
expect(afterCounter.n).toBe(N);
|
||||
// Full rebuild is quadratic: Σ_{p=1..P} p·PAGE.
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Incremental swimlane bucketing for `ResourceList`, extracted from the
|
||||
* component so the O(N²) accumulation of its `sections` `$derived` can be
|
||||
* replaced with an append-aware builder (and unit/benchmark-tested off the
|
||||
* Svelte reactive graph).
|
||||
*
|
||||
* `ResourceList` pages its list in via infinite scroll (`raw = [...raw,
|
||||
* ...page]`), and `sections` was `$derived` over the WHOLE accumulated list —
|
||||
* so paging to item N re-buckets everything loaded so far, Σ ≈ O(N²/page)
|
||||
* main-thread work during the scroll (the same class ROUND6 fixed for the
|
||||
* files listing and ROUND14 §F2 fixed for favorites, and PhotoTimeline fixed
|
||||
* for the photos grid).
|
||||
*
|
||||
* Grouped listings sort by the active group's `orderBy`, so a fresh page only
|
||||
* ever extends existing buckets or appends new ones — it never reorders an
|
||||
* already-emitted bucket. {@link ResourceSectionsBuilder} exploits that: an
|
||||
* append re-buckets only the fresh page and hands back the SAME array
|
||||
* reference for every untouched bucket (so `VirtualList`, which diffs its
|
||||
* `items` prop by reference, skips re-rendering it) while emitting a fresh
|
||||
* array for each bucket the page actually grew.
|
||||
*
|
||||
* Correctness does not depend on bucket contiguity: even a group-by whose
|
||||
* `bucketOf` is not monotonic in server order (e.g. trash grouped by drive but
|
||||
* ordered by name) stays byte-for-byte equal to the full rebuild — it just
|
||||
* touches more buckets per page. The pure {@link buildResourceSections} is the
|
||||
* verbatim reference (what the old `sections` derive produced); the benchmark
|
||||
* gate asserts the incremental builder stays deep-equal to it at every page.
|
||||
*/
|
||||
|
||||
import { isAppendExtension } from './appendExtension';
|
||||
|
||||
/** One swimlane: a bucket key, its (possibly async-resolved) header label, and its rows. */
|
||||
export interface ResourceSection<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouping inputs the builder needs, mirroring `ResourceList`'s active
|
||||
* `GroupByDef` plus its per-item context accessor. `bucketOf` undefined means
|
||||
* "flat list" (a single unlabelled section). Generic over the item type `T`
|
||||
* and the per-item context envelope `C` so the module stays independent of the
|
||||
* component's concrete types.
|
||||
*/
|
||||
export interface SectionGrouping<T, C> {
|
||||
/** Map an item + its context to a bucket key; null → the `∅` catch-all bucket. */
|
||||
bucketOf?: (item: T, ctx: C | undefined) => string | null;
|
||||
/** Map a bucket key to its header label; identity when absent. */
|
||||
labelOf?: (key: string) => string;
|
||||
/** Resolve an item's context envelope (e.g. `contextMap.get(item.id)`). */
|
||||
ctxOf: (item: T) => C | undefined;
|
||||
}
|
||||
|
||||
/** The `∅` catch-all key the old derive used for a null bucket (kept byte-identical). */
|
||||
const NULL_BUCKET = '∅';
|
||||
|
||||
/**
|
||||
* Verbatim reference: the `ResourceSection[]` the old `sections` `$derived.by`
|
||||
* produced for `items` under `grouping`. Bucket order is first-appearance;
|
||||
* within a bucket, server order is preserved. The benchmark gate holds the
|
||||
* incremental builder equal to this.
|
||||
*/
|
||||
export function buildResourceSections<T, C>(
|
||||
items: T[],
|
||||
grouping: SectionGrouping<T, C>
|
||||
): ResourceSection<T>[] {
|
||||
const bucketOf = grouping.bucketOf;
|
||||
if (!bucketOf) return [{ key: '', label: '', rows: items }];
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const k = bucketOf(item, grouping.ctxOf(item)) ?? NULL_BUCKET;
|
||||
let arr = map.get(k);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
map.set(k, arr);
|
||||
order.push(k);
|
||||
}
|
||||
arr.push(item);
|
||||
}
|
||||
return order.map((k) => ({ key: k, label: grouping.labelOf?.(k) ?? k, rows: map.get(k)! }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental swimlane builder. Call {@link sync} with the current (already
|
||||
* dotfile-filtered) item list and grouping on every change; it detects the
|
||||
* common case — the list grew by appending a page while the group-by is
|
||||
* unchanged — and re-buckets only the fresh items, reusing every untouched
|
||||
* bucket's array reference so `VirtualList` skips it. Any other change
|
||||
* (group-by switch, deletion, filter toggle, non-append) falls back to a full
|
||||
* rebuild, so the result is always deep-equal to {@link buildResourceSections}.
|
||||
*
|
||||
* Header labels are recomputed on every sync (never cached) because a
|
||||
* group-by's `labelOf` may resolve asynchronously — owner / sharer names
|
||||
* arrive after the rows do, and a cached label would freeze the header at its
|
||||
* fallback. Only the `rows` arrays are reference-stabilised; that is what
|
||||
* `VirtualList` diffs.
|
||||
*/
|
||||
export class ResourceSectionsBuilder<T, C> {
|
||||
/** Last synced list — the append cursor and the append-detection baseline. */
|
||||
#items: T[] = [];
|
||||
/** Bucket keys in first-appearance order. */
|
||||
#order: string[] = [];
|
||||
/** key → the bucket's rows array (a fresh reference whenever it grows). */
|
||||
#rows = new Map<string, T[]>();
|
||||
/** The `bucketOf` identity of the last grouped sync; a change forces a rebuild. */
|
||||
#bucketOf: SectionGrouping<T, C>['bucketOf'] = undefined;
|
||||
/** False until a grouped sync has populated the accumulation state. */
|
||||
#grouped = false;
|
||||
|
||||
#rebuild(items: T[], grouping: SectionGrouping<T, C>): void {
|
||||
const bucketOf = grouping.bucketOf!;
|
||||
this.#order = [];
|
||||
this.#rows = new Map();
|
||||
for (const item of items) {
|
||||
const k = bucketOf(item, grouping.ctxOf(item)) ?? NULL_BUCKET;
|
||||
let arr = this.#rows.get(k);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
this.#rows.set(k, arr);
|
||||
this.#order.push(k);
|
||||
}
|
||||
arr.push(item);
|
||||
}
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
#extend(items: T[], grouping: SectionGrouping<T, C>): void {
|
||||
const bucketOf = grouping.bucketOf!;
|
||||
const fresh = items.slice(this.#items.length);
|
||||
// Collect the fresh page's items per touched bucket, preserving order and
|
||||
// first-appearance for brand-new buckets. Each touched bucket's array is
|
||||
// then rebuilt exactly once (a fresh reference so VirtualList re-renders
|
||||
// it); untouched buckets keep their existing reference untouched.
|
||||
const freshByKey = new Map<string, T[]>();
|
||||
const newKeys: string[] = [];
|
||||
for (const item of fresh) {
|
||||
const k = bucketOf(item, grouping.ctxOf(item)) ?? NULL_BUCKET;
|
||||
let arr = freshByKey.get(k);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
freshByKey.set(k, arr);
|
||||
if (!this.#rows.has(k)) newKeys.push(k);
|
||||
}
|
||||
arr.push(item);
|
||||
}
|
||||
for (const [k, add] of freshByKey) {
|
||||
const existing = this.#rows.get(k);
|
||||
this.#rows.set(k, existing ? existing.concat(add) : add);
|
||||
}
|
||||
for (const k of newKeys) this.#order.push(k);
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
sync(items: T[], grouping: SectionGrouping<T, C>): ResourceSection<T>[] {
|
||||
if (!grouping.bucketOf) {
|
||||
// Flat list: a single pass-through section. Reset accumulation so a
|
||||
// later switch back to a grouped view rebuilds from scratch.
|
||||
this.#grouped = false;
|
||||
this.#bucketOf = undefined;
|
||||
this.#items = items;
|
||||
return [{ key: '', label: '', rows: items }];
|
||||
}
|
||||
if (
|
||||
this.#grouped &&
|
||||
this.#bucketOf === grouping.bucketOf &&
|
||||
isAppendExtension(this.#items, items)
|
||||
) {
|
||||
this.#extend(items, grouping);
|
||||
} else {
|
||||
this.#rebuild(items, grouping);
|
||||
}
|
||||
this.#grouped = true;
|
||||
this.#bucketOf = grouping.bucketOf;
|
||||
return this.#order.map((k) => ({
|
||||
key: k,
|
||||
label: grouping.labelOf?.(k) ?? k,
|
||||
rows: this.#rows.get(k)!
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Replace a live `Set`'s contents in place. For a reactive `SvelteSet` this
|
||||
* keeps the same instance (per-key reactivity intact) instead of allocating a
|
||||
* fresh copy and invalidating every `.has()` reader at once.
|
||||
*/
|
||||
export function replaceSet<T>(set: Set<T>, values: Iterable<T>): void {
|
||||
set.clear();
|
||||
for (const v of values) set.add(v);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SharedLanesBuilder, buildLanes, type Lane, type LaneGrouping } from './sharedLanes';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental lanes builder (SharedLanesBuilder) that
|
||||
* replaced the `lanes` `$derived.by` on the "My shares" page
|
||||
* (shared/+page.svelte).
|
||||
*
|
||||
* Audit finding (ROUND15 deferred): the shares page pages its outgoing grants
|
||||
* in via `raw = [...raw, ...page.items]`, and `lanes` re-bucketed the WHOLE
|
||||
* accumulated (filtered) list on every page — and on every grant edit —
|
||||
* allocating a fresh lane object + a fresh `rows` array for every lane each
|
||||
* time. Σ ≈ O(N²/page) `emit` calls during an infinite-scroll drain. Same
|
||||
* class as the F1 flagship (ResourceList.sections), but the lanes shape fans
|
||||
* one item out to many rows across many lanes and caches a header at first
|
||||
* appearance — see sharedLanes.ts.
|
||||
*
|
||||
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
|
||||
* 1. Equivalence — at EVERY page of the drain, the incremental output is
|
||||
* deep-equal to the verbatim full-rebuild reference (buildLanes), for the
|
||||
* by-files group-by (1 lane per resource, contiguous) AND the by-subject
|
||||
* group-by (a resource's grants scatter across subject lanes, so a fresh
|
||||
* page sprays rows into already-emitted lanes — non-contiguous).
|
||||
* 2. Reference stability — untouched lanes keep their exact `rows` array
|
||||
* reference across a page append; a grown lane gets a fresh one.
|
||||
* 3. Fallback — group-by switch, grant edit / deletion and kind-filter toggle
|
||||
* fall back to a correct full rebuild.
|
||||
* 4. Perf — `emit` work collapses from Σ O(N²/page) to O(N) across the drain
|
||||
* (deterministic call count) and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
interface Grant {
|
||||
grant_id: string;
|
||||
subject_type: 'user' | 'group' | 'link';
|
||||
subject_id: string;
|
||||
has_password: boolean;
|
||||
}
|
||||
interface Item {
|
||||
resource: { id: string; name: string };
|
||||
grants: Grant[];
|
||||
}
|
||||
type Header =
|
||||
| { kind: 'resource'; item: Item }
|
||||
| { kind: 'user'; id: string }
|
||||
| { kind: 'group'; id: string }
|
||||
| { kind: 'linkPublic' }
|
||||
| { kind: 'linkPassword' };
|
||||
type Row = { grant: Grant; item: Item };
|
||||
|
||||
const pad = (i: number) => i.toString().padStart(6, '0');
|
||||
|
||||
/**
|
||||
* Item `i` with 3 grants: two user grants whose subject round-robins across a
|
||||
* small pool (so by-subject buckets repeat across items → non-contiguous), and
|
||||
* one link grant (public / password alternating). Mirrors the shape the shares
|
||||
* endpoint returns.
|
||||
*/
|
||||
function item(i: number): Item {
|
||||
return {
|
||||
resource: { id: `res-${pad(i)}`, name: `file-${pad(i)}` },
|
||||
grants: [
|
||||
{
|
||||
grant_id: `g-${pad(i)}-0`,
|
||||
subject_type: 'user',
|
||||
subject_id: `user-${i % 8}`,
|
||||
has_password: false
|
||||
},
|
||||
{
|
||||
grant_id: `g-${pad(i)}-1`,
|
||||
subject_type: 'group',
|
||||
subject_id: `group-${i % 5}`,
|
||||
has_password: false
|
||||
},
|
||||
{
|
||||
grant_id: `g-${pad(i)}-2`,
|
||||
subject_type: 'link',
|
||||
subject_id: '',
|
||||
has_password: i % 2 === 0
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/** By-files group-by: one lane per resource; the old derive's unconditional `ensure`. */
|
||||
function itemsGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
|
||||
return {
|
||||
groupKey: 'items',
|
||||
emit: (it, sink) => {
|
||||
if (counter) counter.n++;
|
||||
const key = `resource:${it.resource.id}`;
|
||||
const header: Header = { kind: 'resource', item: it };
|
||||
sink.open(key, header);
|
||||
for (const grant of it.grants) sink.push(key, header, { grant, item: it });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** By-subject group-by: a resource's grants scatter across one lane per subject / link kind. */
|
||||
function sharedWithGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
|
||||
return {
|
||||
groupKey: 'sharedWith',
|
||||
emit: (it, sink) => {
|
||||
if (counter) counter.n++;
|
||||
for (const grant of it.grants) {
|
||||
let key: string;
|
||||
let header: Header;
|
||||
if (grant.subject_type === 'user') {
|
||||
key = `user:${grant.subject_id}`;
|
||||
header = { kind: 'user', id: grant.subject_id };
|
||||
} else if (grant.subject_type === 'group') {
|
||||
key = `group:${grant.subject_id}`;
|
||||
header = { kind: 'group', id: grant.subject_id };
|
||||
} else if (grant.has_password) {
|
||||
key = 'links:password';
|
||||
header = { kind: 'linkPassword' };
|
||||
} else {
|
||||
key = 'links:public';
|
||||
header = { kind: 'linkPublic' };
|
||||
}
|
||||
sink.push(key, header, { grant, item: it });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
const PAGES = 50; // 2 500-item drain
|
||||
|
||||
describe('incremental shared lanes (benchmark gate)', () => {
|
||||
for (const [name, mk] of [
|
||||
['by-files (contiguous, 1 lane/resource)', itemsGrouping],
|
||||
['by-subject (non-contiguous fan-out)', sharedWithGrouping]
|
||||
] as const) {
|
||||
it(`stays deep-equal to the full rebuild at every page — ${name}`, () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const g = mk();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const cumulative = all.slice(0, p * PAGE);
|
||||
const incremental = builder.sync(cumulative, g);
|
||||
const reference = buildLanes(cumulative, g);
|
||||
expect(incremental, `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps untouched lane arrays reference-stable and refreshes grown ones', () => {
|
||||
// A grouping that yields both stable and grown lanes on append: each item
|
||||
// contributes to a per-block lane (block = ⌊i/40⌋, so older blocks are
|
||||
// untouched by a later page) AND a single global lane (grows every page).
|
||||
const grouping: LaneGrouping<Item, Header, Row> = {
|
||||
groupKey: 'blocks',
|
||||
emit: (it, sink) => {
|
||||
const i = Number(it.resource.id.slice(4));
|
||||
const blockKey = `block:${Math.floor(i / 40)}`;
|
||||
sink.push(blockKey, { kind: 'user', id: blockKey }, { grant: it.grants[0], item: it });
|
||||
sink.push('all', { kind: 'user', id: 'all' }, { grant: it.grants[1], item: it });
|
||||
}
|
||||
};
|
||||
const all = Array.from({ length: 200 }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
|
||||
const first = builder.sync(all.slice(0, 120), grouping);
|
||||
const refBefore = new Map(first.map((l) => [l.key, l.rows]));
|
||||
|
||||
const second = builder.sync(all.slice(0, 160), grouping);
|
||||
const refAfter = new Map(second.map((l) => [l.key, l.rows]));
|
||||
|
||||
// Old blocks (0,1,2 = items 0..119) are untouched → same array reference.
|
||||
expect(refAfter.get('block:0')).toBe(refBefore.get('block:0'));
|
||||
expect(refAfter.get('block:2')).toBe(refBefore.get('block:2'));
|
||||
// The global lane grew → a fresh reference (a keyed {#each} re-renders it).
|
||||
expect(refAfter.get('all')).not.toBe(refBefore.get('all'));
|
||||
// And a brand-new block appeared for items 120..159.
|
||||
expect(refBefore.has('block:3')).toBe(false);
|
||||
expect(refAfter.has('block:3')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a correct full rebuild on group-by switch, edit and filter toggle', () => {
|
||||
const all = Array.from({ length: 300 }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const byItems = itemsGrouping();
|
||||
const bySubject = sharedWithGrouping();
|
||||
|
||||
// Drain a few pages by-files, then switch to by-subject (groupKey change → rebuild).
|
||||
builder.sync(all.slice(0, 150), byItems);
|
||||
builder.sync(all.slice(0, 300), byItems);
|
||||
expect(builder.sync(all.slice(0, 300), bySubject)).toEqual(
|
||||
buildLanes(all.slice(0, 300), bySubject)
|
||||
);
|
||||
|
||||
// Grant edit under the SAME group-by: an item's grants change but the item
|
||||
// list length is unchanged → not a strict append → rebuild. Mutate a copy.
|
||||
const edited = all
|
||||
.slice(0, 300)
|
||||
.map((it, i) => (i === 10 ? { ...it, grants: it.grants.slice(0, 1) } : it));
|
||||
expect(builder.sync(edited, bySubject)).toEqual(buildLanes(edited, bySubject));
|
||||
|
||||
// Deletion (list shrinks) → rebuild.
|
||||
const shrunk = edited.filter((_, i) => i % 9 !== 0);
|
||||
expect(builder.sync(shrunk, bySubject)).toEqual(buildLanes(shrunk, bySubject));
|
||||
|
||||
// Kind-filter toggle: the filtered list becomes a different (reordered)
|
||||
// subset → boundary mismatch → rebuild, still equal to a full pass.
|
||||
const filtered = all.slice(0, 300).filter((_, i) => i % 3 === 0);
|
||||
expect(builder.sync(filtered, byItems)).toEqual(buildLanes(filtered, byItems));
|
||||
});
|
||||
|
||||
it('collapses emit work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => item(i));
|
||||
|
||||
// Deterministic call-count gate (the hard rollback gate): the incremental
|
||||
// builder emits each item exactly once across the drain; the full rebuild
|
||||
// is quadratic (Σ_{p=1..P} p·PAGE). This is noise-free — it holds regardless
|
||||
// of machine load.
|
||||
const afterCounter = { n: 0 };
|
||||
const gAfterCount = sharedWithGrouping(afterCounter);
|
||||
const countBuilder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
for (let p = 1; p <= PAGES; p++) countBuilder.sync(all.slice(0, p * PAGE), gAfterCount);
|
||||
const beforeCounter = { n: 0 };
|
||||
const gBeforeCount = sharedWithGrouping(beforeCounter);
|
||||
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), gBeforeCount);
|
||||
expect(afterCounter.n).toBe(N);
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
|
||||
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
|
||||
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern);
|
||||
// the tiny incremental arm is otherwise vulnerable to a single GC pause.
|
||||
// The O(N²)→O(N) collapse leaves ample headroom over the 3x floor.
|
||||
const runAfter = () => {
|
||||
const b = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const g = sharedWithGrouping();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) b.sync(all.slice(0, p * PAGE), g);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const runBefore = () => {
|
||||
const g = sharedWithGrouping();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), g);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
|
||||
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
|
||||
|
||||
console.info(
|
||||
`shared lanes ${PAGES}×${PAGE}: before ${beforeCounter.n} emit calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the exported types referenced so a stray unused-import lint can't creep in.
|
||||
export type { Lane };
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Incremental swimlane builder for the "My shares" page (`shared/+page.svelte`).
|
||||
*
|
||||
* The ROUND15-deferred follow-up to the F1 flagship (`resourceSections.ts`):
|
||||
* that fix replaced `ResourceList`'s `sections` derive; this one replaces the
|
||||
* `lanes` `$derived.by` on the shares page, which had the same O(N²/page)
|
||||
* shape. The page pages its outgoing grants in via infinite scroll
|
||||
* (`raw = [...raw, ...page.items]`), and `lanes` re-bucketed the WHOLE
|
||||
* accumulated (filtered) list on every page — and on every grant edit —
|
||||
* allocating a brand-new lane object and a brand-new `rows` array for every
|
||||
* lane each time. Σ ≈ O(N²/page) `emit` calls across an infinite-scroll drain.
|
||||
*
|
||||
* The lanes shape differs from `resourceSections` in two ways, so it gets its
|
||||
* own builder rather than reusing `ResourceSectionsBuilder` (only the O(1)
|
||||
* append test is genuinely shared — see {@link isAppendExtension}):
|
||||
*
|
||||
* - **Fan-out.** One input item contributes 0..N rows across 0..M lanes (in
|
||||
* the "shared with" group-by a resource's grants scatter across one lane per
|
||||
* distinct subject), whereas a resource section maps one item to exactly one
|
||||
* bucket with the item itself as the row.
|
||||
* - **Header captured at first appearance.** A lane's header (a tagged union
|
||||
* identifying the resource / subject / link kind) is fixed by the lane's
|
||||
* first-seen member and never recomputed — unlike a section's `label`, which
|
||||
* is recomputed every sync because it resolves async. (The shares page mirrors
|
||||
* that: it renders the header's *label* live via `resolveLabel(...)` at render
|
||||
* time from the stable header, so only the header identity is cached here.)
|
||||
*
|
||||
* Correctness does not depend on lane contiguity in server order. The
|
||||
* "shared with" group-by is non-monotonic — a fresh page sprays rows across
|
||||
* already-emitted subject lanes — exactly like F1's "trash by drive" case, and
|
||||
* stays byte-for-byte equal to a full rebuild (it just refreshes more lanes per
|
||||
* page). The pure {@link buildLanes} is the verbatim reference (what the old
|
||||
* `lanes` derive produced); the benchmark gate holds the incremental builder
|
||||
* deep-equal to it at every page.
|
||||
*/
|
||||
|
||||
import { isAppendExtension } from './appendExtension';
|
||||
|
||||
/** One swimlane: a stable key, its first-appearance header, and its rows. */
|
||||
export interface Lane<H, R> {
|
||||
key: string;
|
||||
header: H;
|
||||
rows: R[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sink an item's {@link LaneGrouping.emit} writes its contributions to.
|
||||
* `open` ensures a lane exists (0 rows is valid — mirrors the old derive's
|
||||
* unconditional `ensure(...)` in the by-files group-by); `push` ensures the
|
||||
* lane and appends a row. The `header` is consulted only when the key is first
|
||||
* seen.
|
||||
*/
|
||||
export interface LaneSink<H, R> {
|
||||
open(key: string, header: H): void;
|
||||
push(key: string, header: H, row: R): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouping the builder needs: a `groupKey` identity (a change forces a full
|
||||
* rebuild) and an `emit` that maps one item to its lane contributions via the
|
||||
* {@link LaneSink}. Generic over item `T`, header `H` and row `R` so the module
|
||||
* stays independent of the shares page's concrete types.
|
||||
*/
|
||||
export interface LaneGrouping<T, H, R> {
|
||||
/** Identity of the active grouping; a change between syncs forces a rebuild. */
|
||||
groupKey: string;
|
||||
/** Emit an item's lane contributions, in order, into `sink`. */
|
||||
emit: (item: T, sink: LaneSink<H, R>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reference: the `Lane[]` the old `lanes` `$derived.by` produced for
|
||||
* `items` under `grouping`. Lane order is first-appearance; within a lane, row
|
||||
* order is (item, then emit) order. The benchmark gate holds the incremental
|
||||
* builder equal to this at every page.
|
||||
*/
|
||||
export function buildLanes<T, H, R>(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
|
||||
const out: Lane<H, R>[] = [];
|
||||
const byKey = new Map<string, Lane<H, R>>();
|
||||
const ensure = (key: string, header: H): Lane<H, R> => {
|
||||
let lane = byKey.get(key);
|
||||
if (lane === undefined) {
|
||||
lane = { key, header, rows: [] };
|
||||
byKey.set(key, lane);
|
||||
out.push(lane);
|
||||
}
|
||||
return lane;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void ensure(key, header),
|
||||
push: (key, header, row) => ensure(key, header).rows.push(row)
|
||||
};
|
||||
for (const item of items) grouping.emit(item, sink);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental lanes builder. Call {@link sync} with the current (already
|
||||
* kind-filtered) item list and grouping on every change; it detects the common
|
||||
* case — the list grew by appending a page under an unchanged group-by — and
|
||||
* re-emits only the fresh items, appending to the touched lanes (each of which
|
||||
* gets a fresh `rows` array so a keyed `{#each}` re-renders it) while every
|
||||
* untouched lane keeps its exact array reference. Any other change (group-by
|
||||
* switch, grant edit / deletion, kind-filter toggle, non-append) falls back to
|
||||
* a full rebuild, so the result is always deep-equal to {@link buildLanes}.
|
||||
*/
|
||||
export class SharedLanesBuilder<T, H, R> {
|
||||
/** Last synced list — the append cursor and the append-detection baseline. */
|
||||
#items: T[] = [];
|
||||
/** Lane keys in first-appearance order. */
|
||||
#order: string[] = [];
|
||||
/** key → the lane's first-appearance header. */
|
||||
#headers = new Map<string, H>();
|
||||
/** key → the lane's rows array (a fresh reference whenever it grows). */
|
||||
#rows = new Map<string, R[]>();
|
||||
/** The `groupKey` of the last sync; a change forces a rebuild. */
|
||||
#groupKey: string | null = null;
|
||||
|
||||
#rebuild(items: T[], grouping: LaneGrouping<T, H, R>): void {
|
||||
this.#order = [];
|
||||
this.#headers = new Map();
|
||||
this.#rows = new Map();
|
||||
const ensure = (key: string, header: H): R[] => {
|
||||
let arr = this.#rows.get(key);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
this.#rows.set(key, arr);
|
||||
this.#headers.set(key, header);
|
||||
this.#order.push(key);
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void ensure(key, header),
|
||||
push: (key, header, row) => ensure(key, header).push(row)
|
||||
};
|
||||
for (const item of items) grouping.emit(item, sink);
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
#extend(items: T[], grouping: LaneGrouping<T, H, R>): void {
|
||||
const fresh = items.slice(this.#items.length);
|
||||
// Collect the fresh page's rows per touched lane, plus the keys the page
|
||||
// newly introduces (in first-appearance order). Untouched lanes are never
|
||||
// entered here, so they keep their exact existing `rows` reference.
|
||||
const freshByKey = new Map<string, R[]>();
|
||||
const newKeys: string[] = [];
|
||||
const touch = (key: string, header: H): R[] => {
|
||||
let add = freshByKey.get(key);
|
||||
if (add === undefined) {
|
||||
add = [];
|
||||
freshByKey.set(key, add);
|
||||
if (!this.#rows.has(key)) {
|
||||
newKeys.push(key);
|
||||
this.#headers.set(key, header);
|
||||
}
|
||||
}
|
||||
return add;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void touch(key, header),
|
||||
push: (key, header, row) => touch(key, header).push(row)
|
||||
};
|
||||
for (const item of fresh) grouping.emit(item, sink);
|
||||
for (const [k, add] of freshByKey) {
|
||||
const existing = this.#rows.get(k);
|
||||
// New lane → adopt the fresh array; grown lane → fresh concat (new
|
||||
// reference, so a keyed `{#each}` refreshes exactly the grown lanes).
|
||||
this.#rows.set(k, existing === undefined ? add : existing.concat(add));
|
||||
}
|
||||
for (const k of newKeys) this.#order.push(k);
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
sync(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
|
||||
if (this.#groupKey === grouping.groupKey && isAppendExtension(this.#items, items)) {
|
||||
this.#extend(items, grouping);
|
||||
} else {
|
||||
this.#rebuild(items, grouping);
|
||||
}
|
||||
this.#groupKey = grouping.groupKey;
|
||||
return this.#order.map((k) => ({
|
||||
key: k,
|
||||
header: this.#headers.get(k)!,
|
||||
rows: this.#rows.get(k)!
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Client-side thumbnail generation + upload.
|
||||
*
|
||||
* Fallback path: when the server returns 404 for a file's thumbnail
|
||||
* (the mime type isn't supported server-side, e.g. PDF, or the async
|
||||
* server-side generator hasn't caught up yet), the client can generate
|
||||
* the three canonical sizes from the file itself and PUT them back so
|
||||
* subsequent viewers hit the server thumbnail.
|
||||
*
|
||||
* Ported from the legacy vanilla-JS `static/js/features/thumbnail.js`
|
||||
* (retired in commit 54639d46). Same shape, same behaviour:
|
||||
*
|
||||
* * SUPPORTED_MIME_TYPE — image/*, application/pdf, video/*.
|
||||
* * SIZES — icon 150×150, preview 300×300, large 900×800.
|
||||
* * FORMAT / QUALITY — JPEG q=0.8 (matches the server's own encoder).
|
||||
* * MAX_CONCURRENT — 3 parallel generations, excess queued.
|
||||
*
|
||||
* pdf.js lives at `/vendors/pdf.min.mjs` (+ worker) — dynamically
|
||||
* imported on first PDF encounter so image/video-only sessions never
|
||||
* pay the ~1 MB pdf.js download.
|
||||
*/
|
||||
import type { FileItem } from '$lib/api/types';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
|
||||
/**
|
||||
* The subset of `FileItem` this module actually reads. Keeping
|
||||
* `FileItem` as the canonical shape means the files browser passes
|
||||
* its DTO through verbatim; ResourceList (which only carries
|
||||
* `ResourceEntry`) builds an object with just these three fields and
|
||||
* satisfies the same structural type — no widening cast, no parallel
|
||||
* named type to maintain.
|
||||
*/
|
||||
type ThumbnailFile = Pick<FileItem, 'id' | 'name' | 'mime_type'>;
|
||||
|
||||
const PDFJS_LIB_URL = '/vendors/pdf.min.mjs';
|
||||
const PDFJS_WORKER_URL = '/vendors/pdf.worker.min.mjs';
|
||||
|
||||
// Anything that ships a runtime API surface too broad to type here without
|
||||
// vendoring `@types/pdfjs-dist`; the two methods we call (`getDocument`,
|
||||
// worker options) are stable across pdf.js 4.x.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let pdfjsLibPromise: Promise<any> | null = null;
|
||||
let pdfWorkerWarmed = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getPdfjsLib(): Promise<any> {
|
||||
if (!pdfjsLibPromise) {
|
||||
pdfjsLibPromise = import(/* @vite-ignore */ PDFJS_LIB_URL)
|
||||
.then((lib) => {
|
||||
lib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
|
||||
return lib;
|
||||
})
|
||||
.catch((err) => {
|
||||
pdfjsLibPromise = null; // allow retry on a later sighting
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return pdfjsLibPromise;
|
||||
}
|
||||
|
||||
const SUPPORTED_MIME_TYPE = [/^image\//, /^application\/pdf$/, /^video\//];
|
||||
|
||||
const SIZES = {
|
||||
icon: { width: 150, height: 150 },
|
||||
preview: { width: 300, height: 300 },
|
||||
large: { width: 900, height: 800 }
|
||||
} as const;
|
||||
|
||||
const FORMAT = 'image/jpeg';
|
||||
const QUALITY = 0.8;
|
||||
|
||||
const MAX_CONCURRENT = 3;
|
||||
let activeGenerates = 0;
|
||||
const generateQueue: Array<(release: void) => void> = [];
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function computeSize(
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number
|
||||
): Size {
|
||||
const srcRatio = srcWidth / srcHeight;
|
||||
const targetRatio = targetWidth / targetHeight;
|
||||
if (srcRatio > targetRatio) {
|
||||
return { width: targetWidth, height: Math.round(targetWidth / srcRatio) };
|
||||
}
|
||||
return { width: Math.round(targetHeight * srcRatio), height: targetHeight };
|
||||
}
|
||||
|
||||
async function bitmapToBlob(
|
||||
bitmap: ImageBitmap,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
options: ImageEncodeOptions
|
||||
): Promise<Blob> {
|
||||
const { width, height } = computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight);
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, width, height);
|
||||
return canvas.convertToBlob(options);
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function sourceToBitmap(file: ThumbnailFile, source: string): Promise<ImageBitmap> {
|
||||
const mime = file.mime_type ?? '';
|
||||
if (mime.startsWith('image/')) {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`failed to fetch: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
if (mime === 'application/pdf') {
|
||||
const pdfjsLib = await getPdfjsLib();
|
||||
const pdf = await pdfjsLib.getDocument(source).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
|
||||
return createImageBitmap(canvas);
|
||||
}
|
||||
if (mime.startsWith('video/')) {
|
||||
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.src = source;
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
// Snapshot at 1/3 duration — skips titles/logos, still in the
|
||||
// meat of the content for most videos.
|
||||
video.currentTime = video.duration / 3;
|
||||
};
|
||||
video.onseeked = async () => {
|
||||
const bitmap = await createImageBitmap(video);
|
||||
video.pause();
|
||||
video.removeAttribute('src'); // close the pending HTTP body
|
||||
video.load();
|
||||
resolve(bitmap);
|
||||
};
|
||||
video.onerror = reject;
|
||||
});
|
||||
}
|
||||
throw new Error(`unsupported mime type: ${mime} for file ${file.name}`);
|
||||
}
|
||||
|
||||
async function generate(
|
||||
file: ThumbnailFile,
|
||||
onIconGenerated?: (dataUrl: string) => void,
|
||||
onPreviewGenerated?: (dataUrl: string) => void
|
||||
): Promise<void> {
|
||||
const source = `${window.location.origin}/api/files/${file.id}`;
|
||||
const bitmap = await sourceToBitmap(file, source);
|
||||
|
||||
const [iconBlob, previewBlob, largeBlob] = await Promise.all(
|
||||
Object.values(SIZES).map(({ width, height }) =>
|
||||
bitmapToBlob(bitmap, width, height, { type: FORMAT, quality: QUALITY })
|
||||
)
|
||||
);
|
||||
|
||||
if (onIconGenerated) onIconGenerated(await blobToDataUrl(iconBlob));
|
||||
if (onPreviewGenerated) onPreviewGenerated(await blobToDataUrl(previewBlob));
|
||||
|
||||
await Promise.all(
|
||||
(
|
||||
[
|
||||
['icon', iconBlob],
|
||||
['preview', previewBlob],
|
||||
['large', largeBlob]
|
||||
] as const
|
||||
).map(([size, blob]) =>
|
||||
fetch(`${window.location.origin}/api/files/${file.id}/thumbnail/${size}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getCsrfHeaders(), 'Content-Type': FORMAT },
|
||||
body: blob,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this file's mime type is one the client-side generator can
|
||||
* handle. Callers use this to decide whether to install the fallback
|
||||
* `onerror` handler on the `<img>` in the first place.
|
||||
*/
|
||||
export function canThumbnailClientSide(file: ThumbnailFile): boolean {
|
||||
const mime = file.mime_type ?? '';
|
||||
return SUPPORTED_MIME_TYPE.some((re) => re.test(mime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget warm-up of the pdf.js stack (module + worker script).
|
||||
*
|
||||
* Call the moment a PDF file appears in a listing so the ~1.3 MB library
|
||||
* downloads in the background while the user is still looking at the
|
||||
* list — instead of stalling the first thumbnail render on it.
|
||||
* Idempotent; only folders that actually contain PDFs pay the download.
|
||||
*/
|
||||
export function preloadPdf(): void {
|
||||
getPdfjsLib().catch(() => {
|
||||
/* transient failure — first real use retries */
|
||||
});
|
||||
if (pdfWorkerWarmed) return;
|
||||
pdfWorkerWarmed = true;
|
||||
fetch(PDFJS_WORKER_URL)
|
||||
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.catch(() => {
|
||||
pdfWorkerWarmed = false; // allow retry on a later sighting
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrency-limited wrapper around `generate`. At most `MAX_CONCURRENT`
|
||||
* generations run simultaneously; excess calls await a released slot.
|
||||
*
|
||||
* The optional callbacks receive the icon / preview data URLs the moment
|
||||
* they encode locally — callers use them to paint the fallback
|
||||
* immediately, before the server round-trip completes.
|
||||
*/
|
||||
export async function queueGenerate(
|
||||
file: ThumbnailFile,
|
||||
onIconGenerated?: (dataUrl: string) => void,
|
||||
onPreviewGenerated?: (dataUrl: string) => void
|
||||
): Promise<void> {
|
||||
if (activeGenerates >= MAX_CONCURRENT) {
|
||||
await new Promise<void>((resolve) => generateQueue.push(resolve));
|
||||
}
|
||||
activeGenerates++;
|
||||
try {
|
||||
await generate(file, onIconGenerated, onPreviewGenerated);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
console.warn(`thumbnail generation failed for ${file.name}:`, err.message);
|
||||
} else {
|
||||
console.warn(`thumbnail generation failed for ${file.name}:`, err);
|
||||
}
|
||||
} finally {
|
||||
activeGenerates--;
|
||||
const next = generateQueue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,25 @@
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { listDriveMembers } from '$lib/api/endpoints/drives';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { deleteDrive, listDriveMembers } from '$lib/api/endpoints/drives';
|
||||
import { renameFolder } from '$lib/api/endpoints/folders';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import type { Drive, DriveMember, DriveRole } from '$lib/api/types';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import type { Drive, DriveMember, DriveRole, DrivePoliciesPartial } from '$lib/api/types';
|
||||
import PolicyList from '$lib/components/PolicyList.svelte';
|
||||
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||
import GroupVignette from '$lib/components/GroupVignette.svelte';
|
||||
import { ensureResolvers } from '$lib/api/endpoints/recipients';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { formatDate } from '$lib/utils/display';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { readAllPolicies } from '$lib/utils/drivePolicies';
|
||||
|
||||
const uuid = $derived(page.params.uuid ?? '');
|
||||
const drive = $derived<Drive | null>(drivesStore.findById(uuid));
|
||||
@@ -34,6 +42,41 @@
|
||||
// are the user themselves (seeded by the lifecycle hook).
|
||||
const canRename = $derived(drive?.caller_role === 'owner');
|
||||
|
||||
// Delete is allowed for Owners — backend additionally refuses the
|
||||
// default Personal drive (405) and non-empty drives (409). We hide
|
||||
// the button on the default-personal drive so the affordance only
|
||||
// appears when it can actually succeed.
|
||||
const canDelete = $derived(drive?.caller_role === 'owner' && !drive?.default_for_user);
|
||||
|
||||
let deleting = $state(false);
|
||||
|
||||
async function confirmAndDelete() {
|
||||
if (!drive) return;
|
||||
const confirmText = t(
|
||||
'drive.delete_confirm',
|
||||
{ name: drive.name },
|
||||
'Delete drive "{{name}}"? This cannot be undone — the drive ' +
|
||||
'must be empty first or the server will refuse.'
|
||||
);
|
||||
if (typeof window === 'undefined' || !window.confirm(confirmText)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteDrive(drive.id);
|
||||
await drivesStore.refresh();
|
||||
ui.notify(t('drive.deleted', 'Drive deleted.'), 'success');
|
||||
// Send the user back to /files. The picker's reload above
|
||||
// already removed the now-deleted drive from the sidebar.
|
||||
await goto(resolve('/files'));
|
||||
} catch (e) {
|
||||
// 409 (non-empty) and 405 (default personal) come back as
|
||||
// thrown errors with the server's detail in the message —
|
||||
// surface as a toast rather than a silent failure.
|
||||
errorToast(e);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline rename state. `renameDraft` shadows `drive.name` while the
|
||||
// input is open; we don't write back to the store until the server
|
||||
// accepts the change. `renameBusy` disables the save/cancel buttons
|
||||
@@ -67,8 +110,7 @@
|
||||
// parent_id IS NULL, so a non-Owner caller would 404 here
|
||||
// (but the UI also hid this button for non-Owners).
|
||||
await renameFolder(drive.root_folder_id, next);
|
||||
drivesStore.invalidate();
|
||||
await drivesStore.load();
|
||||
await drivesStore.refresh();
|
||||
renaming = false;
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
@@ -140,40 +182,22 @@
|
||||
return Math.min(100, (drive.used_bytes / drive.quota_bytes) * 100);
|
||||
});
|
||||
|
||||
const policyEntries = $derived.by(() => {
|
||||
if (!drive) return [];
|
||||
return Object.entries(drive.policies).map(([key, value]) => ({ key, value }));
|
||||
});
|
||||
|
||||
function policyLabel(key: string): string {
|
||||
// Known policy keys get a friendlier translated label; unknown keys
|
||||
// surface verbatim so operators still see them (forward-compat).
|
||||
switch (key) {
|
||||
case 'forbid_public_links':
|
||||
return t('drive.policy.forbid_public_links', 'Forbid public links');
|
||||
case 'forbid_external_sharing':
|
||||
return t('drive.policy.forbid_external_sharing', 'Forbid external sharing');
|
||||
case 'forbid_sharing':
|
||||
return t('drive.policy.forbid_sharing', 'Forbid sharing');
|
||||
case 'forbid_cross_drive_move':
|
||||
return t('drive.policy.forbid_cross_drive_move', 'Forbid cross-drive move');
|
||||
case 'include_in_photo_index':
|
||||
return t('drive.policy.include_in_photo_index', 'Include in photo index');
|
||||
case 'forbid_music_index':
|
||||
return t('drive.policy.forbid_music_index', 'Forbid music index');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function policyValueDisplay(value: unknown): string {
|
||||
if (value === true) return t('drive.policy.on', 'On');
|
||||
if (value === false) return t('drive.policy.off', 'Off');
|
||||
return String(value);
|
||||
}
|
||||
// Drive policies are OxiCloud-admin-only for mutation (§8), but
|
||||
// visible read-only here so members understand what rules apply to
|
||||
// the drive they're on. The admin's "Manage policies" modal on
|
||||
// `/admin` is the only editor. `readAllPolicies` normalises the raw
|
||||
// JSONB bag into a `Required<DrivePoliciesPartial>` — unknown keys
|
||||
// (or missing ones) resolve to `false`.
|
||||
const drivePoliciesView = $derived<Required<DrivePoliciesPartial>>(
|
||||
readAllPolicies((drive?.policies ?? {}) as Record<string, unknown>)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void drivesStore.load();
|
||||
// Preload the recipient caches (users + groups) so the members
|
||||
// list can render group names + user labels synchronously. Both
|
||||
// caches are module-level and shared across surfaces.
|
||||
void ensureResolvers();
|
||||
});
|
||||
|
||||
// SvelteKit reuses this component when navigating between
|
||||
@@ -258,6 +282,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if drivePoliciesView.read_only}
|
||||
<ReadOnlyBanner />
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<h2><Icon name="info-circle" /> {t('drive.info', 'Drive info')}</h2>
|
||||
<dl class="info-grid">
|
||||
@@ -346,10 +374,7 @@
|
||||
{#if m.subject.type === 'user'}
|
||||
<UserVignette userId={m.subject.id} />
|
||||
{:else if m.subject.type === 'group'}
|
||||
<span class="members__group">
|
||||
<Icon name="users" />
|
||||
<span class="mono">{m.subject.id}</span>
|
||||
</span>
|
||||
<GroupVignette groupId={m.subject.id} />
|
||||
{:else}
|
||||
<span class="members__token">
|
||||
<Icon name="link" />
|
||||
@@ -366,7 +391,7 @@
|
||||
{#if !canManageMembers && drive.kind === 'personal'}
|
||||
<p class="muted members__personal-note">
|
||||
{t(
|
||||
'drive.members.personal_immutable',
|
||||
'drive.members_personal_immutable',
|
||||
'Personal drives have a fixed single-owner membership.'
|
||||
)}
|
||||
</p>
|
||||
@@ -374,15 +399,49 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if policyEntries.length > 0}
|
||||
<div class="card">
|
||||
<!-- Policies card — read-only summary of the current drive rules.
|
||||
Content is dense (seven toggle rows), so the whole card folds
|
||||
into a native `<details>` disclosure. Closed by default; the
|
||||
admin-only mutation surface still lives on `/admin`. -->
|
||||
<details class="card policies-card">
|
||||
<summary class="policies-card__summary">
|
||||
<h2><Icon name="shield-alt" /> {t('drive.policies', 'Policies')}</h2>
|
||||
<dl class="info-grid">
|
||||
{#each policyEntries as p (p.key)}
|
||||
<dt>{policyLabel(p.key)}</dt>
|
||||
<dd>{policyValueDisplay(p.value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
<span class="policies-card__caret" aria-hidden="true">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</summary>
|
||||
<p class="muted">
|
||||
{t(
|
||||
'drive.policies_help',
|
||||
"Rules an OxiCloud admin has set for this drive. Only admins can change them; you're seeing the current state."
|
||||
)}
|
||||
</p>
|
||||
<PolicyList values={drivePoliciesView} readonly testIdPrefix="drive-policy" />
|
||||
</details>
|
||||
|
||||
{#if canDelete}
|
||||
<!-- Danger zone: drive delete (D3b). Only rendered for Owners on
|
||||
non-default drives. Backend enforces the empty-drive rule —
|
||||
if the drive still has live content the request returns 409
|
||||
with a message that surfaces as a toast. -->
|
||||
<div class="card danger-zone">
|
||||
<h2><Icon name="exclamation-triangle" /> {t('drive.danger_zone', 'Danger zone')}</h2>
|
||||
<p class="muted">
|
||||
{t(
|
||||
'drive.delete_hint',
|
||||
'Deleting a drive removes it permanently. The drive must be empty (no live files or folders) before delete is allowed.'
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
data-testid="drive-delete-btn"
|
||||
onclick={confirmAndDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Icon name="trash-alt" />
|
||||
{deleting ? t('common.deleting', 'Deleting…') : t('drive.delete', 'Delete drive')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -436,6 +495,49 @@
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* Policies card is a `<details>` disclosure — the summary bar carries
|
||||
the h2 title on the left and a chevron on the right that rotates
|
||||
when the section opens. Native `<details>` handles the interaction
|
||||
(click / keyboard / accessible affordance) — no Svelte state
|
||||
needed. */
|
||||
.policies-card__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.policies-card__summary::-webkit-details-marker {
|
||||
/* Chrome/Safari: hide the default triangle so our chevron is the
|
||||
only disclosure affordance. Firefox uses `list-style: none`
|
||||
above. */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.policies-card__summary h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policies-card__caret {
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
details[open] > .policies-card__summary .policies-card__caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* When closed the summary is the entire card content, so we drop the
|
||||
card's default bottom padding. When open the help paragraph +
|
||||
policy list need breathing room from the summary — restore the
|
||||
spacing by nudging the first child. */
|
||||
details.policies-card > .muted {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
@@ -529,6 +631,39 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Danger zone card hosts the delete-drive button at the bottom of
|
||||
the page. Border tint makes the destructive context unmissable
|
||||
without hijacking the whole layout — same convention as
|
||||
admin/users delete affordances. */
|
||||
.danger-zone {
|
||||
border-color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--color-error-text);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-error-text);
|
||||
color: var(--color-text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn--danger {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
/* Compact icon button used in the title row + nowhere else here.
|
||||
The shared `.icon-btn` style isn't promoted to a global yet, so
|
||||
we duplicate the minimum that this page needs. */
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
@@ -17,14 +19,16 @@
|
||||
import { fileDownloadUrl } from '$lib/api/endpoints/files';
|
||||
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import type { FileItem } from '$lib/api/types';
|
||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import ResourceList, {
|
||||
isFile,
|
||||
type ContextAction,
|
||||
type GroupByDef,
|
||||
type ResourceEntry
|
||||
type ItemContext
|
||||
} from '$lib/components/ResourceList.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
let raw = $state<FavoritesResourceItem[]>([]);
|
||||
@@ -35,28 +39,33 @@
|
||||
let reversed = $state(false);
|
||||
const owners = useOwnerCache(resolveOwnerName);
|
||||
|
||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||
|
||||
const entries = $derived(
|
||||
raw.map((it): ResourceEntry => {
|
||||
const isFile = it.resource_type === 'file';
|
||||
const ownerId = it.resource.owner_id ?? null;
|
||||
return {
|
||||
id: it.resource.id,
|
||||
name: it.resource.name,
|
||||
kind: it.resource_type,
|
||||
iconClass: it.resource.icon_class,
|
||||
path: it.resource.path,
|
||||
size: isFile ? (it.resource as FileItem).size : null,
|
||||
date: it.favorited_at,
|
||||
ownerId,
|
||||
ownerName: owners.name(ownerId),
|
||||
isFavorite: true,
|
||||
category: isFile ? it.resource.category : 'Folder',
|
||||
modifiedAt: it.resource.modified_at
|
||||
};
|
||||
})
|
||||
);
|
||||
// Favorites view DELIBERATELY doesn't set `showDotfileToggle` on
|
||||
// the ResourceList below — favoriting is an explicit "I want to
|
||||
// keep an eye on this" action by the user, and hiding a starred
|
||||
// dotfile here would contradict that intent. The
|
||||
// `preferences.hideDotfiles` toggle is for reducing incidental
|
||||
// clutter in algorithmic listings (files / recent / photos), not
|
||||
// for overriding user-intentional pins. Trash follows the same
|
||||
// principle for a safety-net reason; the general rule: explicit-
|
||||
// action surfaces don't filter, algorithmic surfaces do.
|
||||
//
|
||||
// ResourceList consumes raw `FileItem | FolderItem`; the favorites
|
||||
// envelope contributes `favorited_at` via `date` in contextMap. All
|
||||
// items on this page are favorites — pass every id in `favoriteIds`
|
||||
// so the star widget lights up universally.
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
// Persistent reactive set, updated in place per page (add the fresh page's
|
||||
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
|
||||
// whole accumulated list on every infinite-scroll page — that was O(N²)
|
||||
// across a drain and, being a new instance each page, invalidated every
|
||||
// mounted star reader. Every item on this page is a favorite, and removed
|
||||
// items are no longer rendered, so the set only needs to be a superset of
|
||||
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
const groupBys: GroupByDef[] = [
|
||||
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
||||
@@ -64,33 +73,33 @@
|
||||
key: 'owner',
|
||||
label: t('groupby.owner', 'Owner'),
|
||||
orderBy: 'owner',
|
||||
bucketOf: (e) => e.ownerId ?? null,
|
||||
bucketOf: (item) => item.created_by ?? null,
|
||||
labelOf: (id) => owners.label(id)
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('groupby.type', 'Type'),
|
||||
orderBy: 'type',
|
||||
bucketOf: (e) => e.category ?? 'other',
|
||||
bucketOf: (item) => item.category ?? 'other',
|
||||
labelOf: (k) => typeLabel(k)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: t('groupby.size', 'Size'),
|
||||
orderBy: 'size',
|
||||
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
|
||||
bucketOf: (item) => sizeBucket(isFile(item) ? item.size : null)
|
||||
},
|
||||
{
|
||||
key: 'favoriteDate',
|
||||
label: t('groupby.favoriteDate', 'Favorite date'),
|
||||
orderBy: 'favorited_at',
|
||||
bucketOf: (e) => dateBucket(e.date)
|
||||
bucketOf: (_item, ctx) => dateBucket(ctx?.date)
|
||||
},
|
||||
{
|
||||
key: 'modifiedAt',
|
||||
label: t('groupby.modifiedAt', 'Modified date'),
|
||||
orderBy: 'modified_at',
|
||||
bucketOf: (e) => dateBucket(e.modifiedAt)
|
||||
bucketOf: (item) => dateBucket(item.modified_at)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -105,8 +114,16 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
// Keep the persistent favoriteIds set in sync incrementally: clear on
|
||||
// reset, then add only this page's ids (benches/ROUND14.md §F2).
|
||||
if (reset) favoriteIds.clear();
|
||||
for (const it of page.items) favoriteIds.add(it.resource.id);
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.favorited_at }
|
||||
]);
|
||||
cursor = page.next_cursor;
|
||||
void owners.resolve(page.items.map((i) => i.resource.owner_id));
|
||||
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
||||
} catch (e) {
|
||||
console.error('favorites: load error', e);
|
||||
error = t('errors_loadFailed', 'Failed to load items');
|
||||
@@ -133,22 +150,21 @@
|
||||
if (shareOpen) void shareDialog.load();
|
||||
});
|
||||
|
||||
function open(entry: ResourceEntry) {
|
||||
if (entry.kind === 'folder') {
|
||||
goto(resolve(`/files/${entry.id}`));
|
||||
function open(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) {
|
||||
goto(resolve(`/files/${item.id}`));
|
||||
return;
|
||||
}
|
||||
const item = byId.get(entry.id);
|
||||
if (item) {
|
||||
viewerFile = item.resource as FileItem;
|
||||
viewerOpen = true;
|
||||
}
|
||||
viewerFile = item;
|
||||
viewerOpen = true;
|
||||
}
|
||||
|
||||
async function unfavorite(entry: ResourceEntry) {
|
||||
async function unfavorite(item: FileItem | FolderItem) {
|
||||
const kind = isFile(item) ? 'file' : 'folder';
|
||||
try {
|
||||
await removeFavorite(entry.kind, entry.id);
|
||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||
await removeFavorite(kind, item.id);
|
||||
raw = raw.filter((i) => i.resource.id !== item.id);
|
||||
favoriteIds.delete(item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
@@ -161,62 +177,97 @@
|
||||
let shareOpen = $state(false);
|
||||
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
|
||||
|
||||
async function rename(entry: ResourceEntry) {
|
||||
function kindOf(item: FileItem | FolderItem): 'file' | 'folder' {
|
||||
return isFile(item) ? 'file' : 'folder';
|
||||
}
|
||||
|
||||
async function rename(item: FileItem | FolderItem) {
|
||||
const name = await promptDialog({
|
||||
title: t('common.rename', 'Rename'),
|
||||
defaultValue: entry.name,
|
||||
defaultValue: item.name,
|
||||
confirmText: t('common.rename', 'Rename')
|
||||
});
|
||||
if (!name || name === entry.name) return;
|
||||
if (!name || name === item.name) return;
|
||||
try {
|
||||
if (entry.kind === 'file') await renameFile(entry.id, name);
|
||||
else await renameFolder(entry.id, name);
|
||||
if (isFile(item)) await renameFile(item.id, name);
|
||||
else await renameFolder(item.id, name);
|
||||
await load(true, orderByForGroup());
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(entry: ResourceEntry) {
|
||||
async function remove(item: FileItem | FolderItem) {
|
||||
const ok = await confirmDialog({
|
||||
title: t('common.delete', 'Delete'),
|
||||
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
|
||||
message: t('files.confirm_delete', { name: item.name }, 'Delete "{{name}}"?'),
|
||||
confirmText: t('common.delete', 'Delete'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (entry.kind === 'file') await deleteFile(entry.id);
|
||||
else await deleteFolder(entry.id);
|
||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||
if (isFile(item)) await deleteFile(item.id);
|
||||
else await deleteFolder(item.id);
|
||||
raw = raw.filter((i) => i.resource.id !== item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadEntry(entry: ResourceEntry) {
|
||||
if (entry.kind !== 'file') return;
|
||||
function downloadItem(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = fileDownloadUrl(entry.id);
|
||||
a.download = entry.name;
|
||||
a.href = fileDownloadUrl(item.id);
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
// See /recent's mirror for the rationale: files carry `folder_id`,
|
||||
// folders carry `parent_id`; nullable when the folder is a drive
|
||||
// root. Null → no meaningful parent to open.
|
||||
function parentFolderId(item: FileItem | FolderItem): string | null {
|
||||
return isFile(item) ? item.folder_id : item.parent_id;
|
||||
}
|
||||
|
||||
const contextActions: ContextAction[] = [
|
||||
{
|
||||
key: 'open_parent',
|
||||
label: t('files.open_parent', 'Open parent folder'),
|
||||
icon: 'folder-open',
|
||||
// Hidden only when there's literally no parent to open
|
||||
// (drive-root folders where `parent_id === null`); otherwise
|
||||
// the entry is always visible and shows up disabled when the
|
||||
// caller lacks read on the parent — a greyed row reads as
|
||||
// "you can't do this here" instead of "the option is missing."
|
||||
// `folderAccessCached` returns `true`/`false`/`undefined`;
|
||||
// disabled fires when the answer is explicitly `false`. On
|
||||
// first right-click of a fresh row, `menuPrepare` below has
|
||||
// primed the cache so the entry either enables or disables
|
||||
// without a "flash of enabled" beforehand.
|
||||
visible: (item) => parentFolderId(item) !== null,
|
||||
disabled: (item) => {
|
||||
const pid = parentFolderId(item);
|
||||
return pid === null || folderAccessCached(pid) === false;
|
||||
},
|
||||
run: (item) => {
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) goto(resolve(`/files/${pid}`));
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download', 'Download'),
|
||||
icon: 'download',
|
||||
run: downloadEntry
|
||||
run: downloadItem
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: t('files.share', 'Share'),
|
||||
icon: 'share-alt',
|
||||
run: (e) => {
|
||||
shareTarget = { id: e.id, name: e.name, kind: e.kind };
|
||||
run: (item) => {
|
||||
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
shareOpen = true;
|
||||
}
|
||||
},
|
||||
@@ -224,50 +275,37 @@
|
||||
key: 'move',
|
||||
label: t('files.move', 'Move'),
|
||||
icon: 'arrows-alt',
|
||||
run: (e) => {
|
||||
run: (item) => {
|
||||
moveItems = null;
|
||||
moveTarget = { id: e.id, name: e.name, kind: e.kind };
|
||||
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
moveOpen = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
// Every row on /favorites IS a favorite, so the entry is always
|
||||
// "Remove favorite" — no per-item state lookup needed. Mirrors
|
||||
// the star-widget behaviour: click, row un-stars, disappears
|
||||
// from the list on next reload. Placed between Move and Rename
|
||||
// to match the canonical context-menu order on `/files`.
|
||||
key: 'unfavorite',
|
||||
label: t('files.unfavorite', 'Remove favorite'),
|
||||
icon: 'star-outline',
|
||||
run: (item) => void unfavorite(item)
|
||||
},
|
||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
||||
];
|
||||
|
||||
// ── Selection + batch ─────────────────────────────────────────────────────
|
||||
let selectedIds = $state<Set<string>>(new Set());
|
||||
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
|
||||
// Selected items arrive via the batchActions snippet param —
|
||||
// ResourceList already derives them (O(selection), not O(N)); a
|
||||
// host-side `items.filter(...)` shadow would re-run a second full scan
|
||||
// per selection toggle, and its id mirror is unnecessary (the component
|
||||
// prunes its own selection when items reload) — benches/ROUND11.md §S1.
|
||||
type Selectable = FileItem | FolderItem;
|
||||
|
||||
function batchTargets() {
|
||||
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
|
||||
}
|
||||
|
||||
function batchDownload() {
|
||||
for (const e of selectedEntries) downloadEntry(e);
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
const ok = await confirmDialog({
|
||||
title: t('common.delete', 'Delete'),
|
||||
message: t(
|
||||
'files.confirm_delete_n',
|
||||
{ count: selectedEntries.length },
|
||||
'Delete {{count}} item(s)?'
|
||||
),
|
||||
confirmText: t('common.delete', 'Delete'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
|
||||
);
|
||||
const removed = new Set(selectedEntries.map((e) => e.id));
|
||||
raw = raw.filter((i) => !removed.has(i.resource.id));
|
||||
selectedIds = new Set();
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
function batchDownload(sel: Selectable[]) {
|
||||
for (const i of sel) downloadItem(i);
|
||||
}
|
||||
|
||||
onMount(() => load(true));
|
||||
@@ -277,7 +315,10 @@
|
||||
|
||||
<ResourceList
|
||||
title={t('nav.favorites', 'Favorites')}
|
||||
items={entries}
|
||||
{items}
|
||||
{contextMap}
|
||||
{favoriteIds}
|
||||
resolveOwnerName={(id) => owners.name(id)}
|
||||
{loading}
|
||||
{error}
|
||||
emptyIcon="star"
|
||||
@@ -288,8 +329,18 @@
|
||||
onopen={open}
|
||||
onfavorite={unfavorite}
|
||||
showOwner
|
||||
showPath
|
||||
dateLabel={t('files.col_added', 'Added')}
|
||||
selectable
|
||||
{contextActions}
|
||||
menuPrepare={async (item) => {
|
||||
// Lazy folder-access probe — fires only when the user actually
|
||||
// opens the context menu on a row, not proactively for every
|
||||
// row on load. Cached in the LRU (see `folderAccess.ts`) so
|
||||
// subsequent right-clicks on the same folder are instant.
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) await probeFolderAccess(pid);
|
||||
}}
|
||||
{groupBys}
|
||||
bind:groupBy
|
||||
bind:reversed
|
||||
@@ -297,26 +348,26 @@
|
||||
cursor = undefined;
|
||||
load(true, orderBy, rev);
|
||||
}}
|
||||
onselectionchange={(ids) => (selectedIds = ids)}
|
||||
>
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" data-testid="favorites-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
{#snippet batchActions(sel)}
|
||||
<!--
|
||||
Favorites-scoped batch cluster: Download stays. Move + Delete
|
||||
were destructive-to-content operations carried over from the
|
||||
pre-refactor menu; on a favorites *bookmarks* view they
|
||||
belong in the row's context menu (rename/move/delete via
|
||||
`contextActions`), not in the batch bar. Batch "remove from
|
||||
favorite" un-stars the selected rows without touching the
|
||||
underlying files — mirrors the per-row favorite star.
|
||||
-->
|
||||
<Button
|
||||
icon="download"
|
||||
data-testid="favorites-batch-download-btn"
|
||||
onclick={() => batchDownload(sel)}>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="favorites-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="favorites-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
icon="star-outline"
|
||||
data-testid="favorites-batch-remove-btn"
|
||||
onclick={() => sel.forEach(unfavorite)}>{t('files.unfavorite', 'Remove favorite')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
@@ -331,10 +382,7 @@
|
||||
bind:open={moveOpen}
|
||||
item={moveTarget}
|
||||
items={moveItems}
|
||||
onmoved={() => {
|
||||
selectedIds = new Set();
|
||||
load(true, orderByForGroup());
|
||||
}}
|
||||
onmoved={() => load(true, orderByForGroup())}
|
||||
/>
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
|
||||
@@ -14,6 +14,11 @@ vi.mock('$lib/api/endpoints/favorites', () => ({
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
// ResourceList uses this to build the `<img class="file-thumb">`
|
||||
// src for the fallback path; tests don't render actual thumbnails
|
||||
// but the module import needs to succeed.
|
||||
fileThumbnailUrl: () => '/thumb.png',
|
||||
thumbSizeForView: () => 'preview' as const,
|
||||
renameFile: vi.fn(),
|
||||
deleteFile: vi.fn()
|
||||
}));
|
||||
@@ -21,7 +26,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
|
||||
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import FavoritesPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
@@ -41,7 +45,8 @@ function withOneFile() {
|
||||
mime_type: 'image/png',
|
||||
modified_at: 0,
|
||||
name: 'photo.png',
|
||||
owner_id: 'me',
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
folder_id: 'root',
|
||||
path: '/photo.png',
|
||||
size: 10,
|
||||
@@ -80,13 +85,18 @@ it('unfavorites a row via the star button', async () => {
|
||||
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
|
||||
});
|
||||
|
||||
it('batch-deletes selected favorites after confirmation', async () => {
|
||||
it('batch-removes-from-favorite the selection', async () => {
|
||||
// /favorites' batch bar was intentionally trimmed to Download +
|
||||
// Remove-from-favorite. Bulk-deleting the underlying file from
|
||||
// this view (previous behaviour) confused the "this is a
|
||||
// bookmarks list" semantics — destructive actions belong in the
|
||||
// row's context menu, not in the batch bar. This test pins the
|
||||
// new shape: batch button just un-stars the selection.
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
m(removeFavorite).mockResolvedValue(undefined);
|
||||
render(FavoritesPage);
|
||||
await screen.findByText('photo.png');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
await fireEvent.click(await screen.findByTestId('favorites-batch-remove-btn'));
|
||||
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the files view's batch-operation rework
|
||||
* (`batchDelete` / `moveInto` / `selectionTargets` / `batchDownload` in
|
||||
* `[...path]/+page.svelte`).
|
||||
*
|
||||
* Audit finding: multi-item delete/move awaited one request per item in a
|
||||
* serial loop — at ~30 ms RTT a 100-item delete is ~3 s of waterfall — and
|
||||
* every per-id classification ran `listing.folders.find(...)` /
|
||||
* `listing.files.some(...)`, an O(N·M) scan over the listing per selected id.
|
||||
* The fix builds an id index once (O(M)) and fans the requests out through
|
||||
* the view's existing `mapLimit` with 6 in flight.
|
||||
*
|
||||
* The functions are component-internal, so — like the Rust bench modules that
|
||||
* replicate handler internals — this bench replicates BEFORE verbatim and
|
||||
* AFTER (index + `mapLimit`, the exact shapes now in the component) against a
|
||||
* stubbed per-item endpoint with simulated latency.
|
||||
*
|
||||
* Gates: (1) both arms attempt the identical (id, kind) operation set —
|
||||
* folder-first classification preserved; (2) a 100-item batch at 5 ms
|
||||
* simulated RTT completes ≥3x faster; (3) the classification scan count
|
||||
* drops from O(N·M) to one pass.
|
||||
*/
|
||||
|
||||
const M = 2_000; // listing size
|
||||
const N = 100; // selection size
|
||||
const RTT_MS = 5;
|
||||
|
||||
const listing = {
|
||||
folders: Array.from({ length: M / 4 }, (_, i) => ({ id: `d-${i}`, name: `dir ${i}` })),
|
||||
files: Array.from({ length: (3 * M) / 4 }, (_, i) => ({ id: `f-${i}`, name: `file ${i}` }))
|
||||
};
|
||||
// Selection interleaves folders and files, like a shift-range over a mixed view.
|
||||
const selectedIds = [
|
||||
...listing.folders.slice(40, 40 + N / 4).map((f) => f.id),
|
||||
...listing.files.slice(900, 900 + (3 * N) / 4).map((f) => f.id)
|
||||
];
|
||||
|
||||
/** Stubbed per-item endpoint: RTT_MS latency, records the attempted op. */
|
||||
function makeOps() {
|
||||
const attempted: Array<{ id: string; kind: 'file' | 'folder' }> = [];
|
||||
let comparisons = 0;
|
||||
return {
|
||||
attempted,
|
||||
countCmp: () => comparisons++,
|
||||
get comparisons() {
|
||||
return comparisons;
|
||||
},
|
||||
deleteFolder: async (id: string) => {
|
||||
attempted.push({ id, kind: 'folder' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
},
|
||||
deleteFile: async (id: string) => {
|
||||
attempted.push({ id, kind: 'file' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
}
|
||||
};
|
||||
}
|
||||
type Ops = ReturnType<typeof makeOps>;
|
||||
|
||||
/** BEFORE, verbatim shape: serial await + `find` per id. */
|
||||
async function batchDeleteBefore(ids: string[], ops: Ops): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const folder = listing.folders.find((f) => {
|
||||
ops.countCmp();
|
||||
return f.id === id;
|
||||
});
|
||||
if (folder) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The view's `mapLimit`, verbatim. */
|
||||
async function mapLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** AFTER, verbatim shape: one O(M) index pass + bounded fan-out of 6. */
|
||||
async function batchDeleteAfter(ids: string[], ops: Ops): Promise<void> {
|
||||
const folderIdSet = new Set(
|
||||
listing.folders.map((f) => {
|
||||
ops.countCmp();
|
||||
return f.id;
|
||||
})
|
||||
);
|
||||
await mapLimit(ids, 6, async (id) => {
|
||||
if (folderIdSet.has(id)) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
});
|
||||
}
|
||||
|
||||
const opKey = (o: { id: string; kind: string }) => `${o.kind}:${o.id}`;
|
||||
|
||||
describe('files-view batch operations (benchmark gate)', () => {
|
||||
it(
|
||||
'both arms attempt the identical operation set, ≥3x faster fanned out',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const before = makeOps();
|
||||
const t0 = performance.now();
|
||||
await batchDeleteBefore(selectedIds, before);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
const after = makeOps();
|
||||
const t1 = performance.now();
|
||||
await batchDeleteAfter(selectedIds, after);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// Equivalence: same ops, same folder/file classification. Order is
|
||||
// not part of the contract (the ops are independent single-item
|
||||
// endpoints); compare as sets and sizes.
|
||||
expect(after.attempted.length).toBe(before.attempted.length);
|
||||
expect(new Set(after.attempted.map(opKey))).toEqual(new Set(before.attempted.map(opKey)));
|
||||
expect(before.attempted.filter((o) => o.kind === 'folder').length).toBe(N / 4);
|
||||
|
||||
// Scan work: O(N·M) probes collapse to one O(M) pass.
|
||||
expect(after.comparisons).toBe(listing.folders.length);
|
||||
expect(before.comparisons).toBeGreaterThan(after.comparisons * 10);
|
||||
|
||||
console.info(
|
||||
`batch delete ${N} items @ ${RTT_MS} ms RTT: serial ${beforeMs.toFixed(0)} ms (${before.comparisons} id probes) vs mapLimit(6) ${afterMs.toFixed(0)} ms (${after.comparisons} probes) — ${(beforeMs / afterMs).toFixed(1)}x`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
}
|
||||
);
|
||||
|
||||
it('selectionTargets index matches the per-id find, folder-first on collision', () => {
|
||||
// BEFORE: folder probed first per id. AFTER: files inserted first so
|
||||
// folders overwrite → folder wins collisions. Same observable result.
|
||||
const shadow = { id: listing.files[0].id, name: 'shadow-folder' };
|
||||
const foldersPlus = [...listing.folders, shadow];
|
||||
const wanted = [shadow.id, listing.folders[5].id, listing.files[10].id, 'missing-id'];
|
||||
|
||||
const beforeTargets = wanted
|
||||
.map((id) => {
|
||||
const folder = foldersPlus.find((f) => f.id === id);
|
||||
if (folder) return { id, name: folder.name, kind: 'folder' as const };
|
||||
const file = listing.files.find((f) => f.id === id);
|
||||
return file ? { id, name: file.name, kind: 'file' as const } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
const byId = new Map<string, { id: string; name: string; kind: 'file' | 'folder' }>();
|
||||
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
||||
for (const f of foldersPlus) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
||||
const afterTargets = wanted
|
||||
.map((id) => byId.get(id) ?? null)
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
expect(afterTargets).toEqual(beforeTargets);
|
||||
});
|
||||
});
|
||||
@@ -39,18 +39,17 @@ vi.mock('$lib/api/endpoints/files', () => ({
|
||||
deleteFile: vi.fn(),
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb',
|
||||
thumbSizeForView: () => 'preview' as const,
|
||||
moveFile: vi.fn(),
|
||||
renameFile: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
uploadFileWithProgress: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({
|
||||
cacheFolder: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
fetchFolderListing: vi.fn(),
|
||||
fetchFolderPage: vi.fn(),
|
||||
folderZipUrl: () => '/zip',
|
||||
getCachedFolder: () => undefined,
|
||||
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
||||
getFolderName: () => undefined,
|
||||
invalidateFolderCache: vi.fn(),
|
||||
@@ -59,7 +58,7 @@ vi.mock('$lib/api/endpoints/folders', () => ({
|
||||
renameFolder: vi.fn()
|
||||
}));
|
||||
|
||||
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
@@ -68,15 +67,17 @@ import FilesPage from './[...path]/+page.svelte';
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withListing() {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
// `fetchFolderPage` returns ONE page with the accumulator shape (items in
|
||||
// server order + folders/files splits). With `nextCursor` omitted the
|
||||
// caller treats it as the last page — the page's items become the whole
|
||||
// on-screen listing without triggering `loadMore`.
|
||||
const folder = folderItem('sub1', 'Sub');
|
||||
const file = fileItem('f1', 'hello.txt');
|
||||
m(fetchFolderPage).mockResolvedValue({
|
||||
items: [folder, file],
|
||||
folders: [folder],
|
||||
files: [file],
|
||||
nextCursor: undefined
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +91,8 @@ function fileItem(id: string, name: string) {
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + name,
|
||||
size: 4,
|
||||
@@ -110,7 +112,8 @@ function folderItem(id: string, name: string) {
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
parent_id: 'home',
|
||||
path: '/' + name,
|
||||
etag: 'e'
|
||||
@@ -128,27 +131,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('loads the home folder listing on mount and renders its contents', async () => {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
});
|
||||
withListing();
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledWith('home', expect.anything()));
|
||||
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
|
||||
// surrounding chrome rendered rather than the windowed rows themselves.
|
||||
await screen.findByTestId('files-new-folder-btn');
|
||||
});
|
||||
|
||||
it('shows an error when the listing fails with no cache', async () => {
|
||||
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
||||
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('redirects external users away from the home folder', async () => {
|
||||
@@ -174,7 +168,7 @@ it('batch-deletes the whole selection after confirmation', async () => {
|
||||
m(deleteFolder).mockResolvedValue(undefined);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
@@ -184,7 +178,7 @@ it('batch-favorites the selection via the favorites batch endpoint', async () =>
|
||||
withListing();
|
||||
m(apiFetch).mockResolvedValue({ ok: true });
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,22 @@
|
||||
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(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: { user: null } as { user: unknown }
|
||||
}));
|
||||
const { goto, pageState, session } = vi.hoisted(() => {
|
||||
// `setUser` mirrors the real SessionStore method: sets the user and
|
||||
// runs `ensureActiveUser` (localStorage cleanup on account switch).
|
||||
// Tests don't care about the cleanup; the mock just assigns.
|
||||
const store: { user: unknown; setUser: (u: unknown) => void } = {
|
||||
user: null,
|
||||
setUser(u) {
|
||||
store.user = u;
|
||||
}
|
||||
};
|
||||
return {
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: store
|
||||
};
|
||||
});
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
@@ -30,10 +41,36 @@ beforeEach(() => {
|
||||
pageState.url = new URL('http://localhost/login');
|
||||
session.user = null;
|
||||
m(auth.fetchMe).mockResolvedValue(null);
|
||||
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
|
||||
// Default provider info: both password + magic-link enabled, OIDC off.
|
||||
// The unified login form's magic-link submit path is only reachable
|
||||
// when `magic_link_login_enabled === true` — without this pin the
|
||||
// "sends a magic link" test can't reach `sendMagicLink()`.
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: false,
|
||||
password_login_enabled: true,
|
||||
magic_link_login_enabled: true
|
||||
});
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
@@ -64,16 +101,21 @@ it('enters setup mode on a fresh install', async () => {
|
||||
await screen.findByTestId('login-setup-form');
|
||||
});
|
||||
|
||||
it('sends a magic link', async () => {
|
||||
it('sends a magic link when the password field is left empty', async () => {
|
||||
// Unified form: the same identifier input drives both flows. Filling
|
||||
// the identifier and leaving password empty makes `submitAsMagicLink`
|
||||
// derived resolve to true — the single submit button then dispatches
|
||||
// to `sendMagicLink` instead of `login`.
|
||||
m(auth.sendMagicLink).mockResolvedValue('sent');
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
|
||||
await fireEvent.input(screen.getByTestId('login-username-input'), {
|
||||
target: { value: 'a@b.test' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
|
||||
// Password intentionally NOT filled.
|
||||
await fireEvent.click(screen.getByTestId('login-submit-btn'));
|
||||
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
|
||||
expect(auth.login).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers a new account', async () => {
|
||||
@@ -155,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();
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
|
||||
|
||||
<main class="nc-status">
|
||||
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
|
||||
<Icon name="exclamation-circle" class="nc-status__icon nc-status__icon--err" />
|
||||
<h1>{view.title}</h1>
|
||||
<p>{view.message}</p>
|
||||
<button
|
||||
@@ -80,7 +80,13 @@
|
||||
|
||||
<style>
|
||||
.nc-status {
|
||||
min-height: 100vh;
|
||||
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||
`/nextcloud/*` routes render children directly (bypassing
|
||||
AppShell), so <main> is a flex item on the body's row axis
|
||||
and needs to claim the full slot for its own centering to
|
||||
land in the viewport middle. */
|
||||
flex: 1;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -95,7 +101,12 @@
|
||||
}
|
||||
|
||||
:global(.nc-status__icon--err) {
|
||||
color: var(--color-danger-text);
|
||||
/* `--color-danger-text` is white — it's the foreground for text
|
||||
sitting on a red button bg, not the standalone red glyph
|
||||
colour. `--color-error-text` is the light-dark(...) red pair
|
||||
designed for standalone use on the page background: darker
|
||||
red in light mode, softer coral in dark mode. */
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.nc-status__action {
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Mirror the legacy flow: auto-close the popup shortly after success so
|
||||
// the user is returned to their Nextcloud client without an extra click.
|
||||
// Auto-close the tab a few seconds after landing. NC clients
|
||||
// receive their credentials through the LFv2 poll endpoint
|
||||
// (`/login/v2/poll`) in the backchannel — this browser tab is
|
||||
// only useful as a "flow succeeded" landing. Users who want
|
||||
// to keep it around click nothing; users who want it gone
|
||||
// get it gone automatically.
|
||||
const timer = setTimeout(closeWindow, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
@@ -34,7 +38,13 @@
|
||||
|
||||
<style>
|
||||
.nc-status {
|
||||
min-height: 100vh;
|
||||
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||
`/nextcloud/*` routes render children directly (bypassing
|
||||
AppShell), so <main> is a flex item on the body's row axis
|
||||
and needs to claim the full slot for its own centering to
|
||||
land in the viewport middle. */
|
||||
flex: 1;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,9 +11,18 @@
|
||||
import { fileDownloadUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import { isVideo, photoTimestamp } from '$lib/utils/media';
|
||||
import {
|
||||
PhotoTimeline,
|
||||
type GroupMode,
|
||||
type LayoutMode,
|
||||
type PhotoRow
|
||||
} from '$lib/utils/photoTimeline';
|
||||
|
||||
type Tab = 'moments' | 'places' | 'people';
|
||||
let tab = $state<Tab>('moments');
|
||||
@@ -27,6 +36,17 @@
|
||||
let peopleAvailable = $state(false);
|
||||
|
||||
let items = $state<PhotoItem[]>([]);
|
||||
// Client-side dotfile filter over `items`. Applied here (not
|
||||
// server-side) because the filter is a UI-only preference and
|
||||
// applies uniformly across every listing surface. Lightbox +
|
||||
// grouping consume `visibleItems`; mutations still target `items`
|
||||
// (the raw fetched set) so a deletion still removes the photo even
|
||||
// if it's currently hidden by the filter.
|
||||
const visibleItems = $derived(filterDotfiles(items, preferences.hideDotfiles));
|
||||
// Count of items suppressed by the dotfile filter — surfaced in
|
||||
// the empty-state hint below so a `.thumbnails/`-only photos view
|
||||
// doesn't read as "no photos yet".
|
||||
const hiddenCount = $derived(preferences.hideDotfiles ? items.length - visibleItems.length : 0);
|
||||
let cursor = $state<string | null>(null);
|
||||
let exhausted = $state(false);
|
||||
let loading = $state(false);
|
||||
@@ -35,10 +55,8 @@
|
||||
/** Usable content width of the grid, for the justified layout. */
|
||||
let gridWidth = $state(0);
|
||||
|
||||
type GroupMode = 'day' | 'month' | 'year';
|
||||
type LayoutMode = 'square' | 'justified';
|
||||
const GROUP_KEY = 'oxicloud-photos-group';
|
||||
const LAYOUT_KEY = 'oxicloud-photos-layout';
|
||||
const GROUP_KEY = 'oxi-photos-group';
|
||||
const LAYOUT_KEY = 'oxi-photos-layout';
|
||||
let groupMode = $state<GroupMode>('month');
|
||||
let layoutMode = $state<LayoutMode>('square');
|
||||
const selected = useSelection();
|
||||
@@ -50,153 +68,57 @@
|
||||
else if (tab === 'people') void peopleView.load();
|
||||
});
|
||||
|
||||
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
|
||||
function bucketKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
if (groupMode === 'year') return `${y}`;
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
if (groupMode === 'month') return `${y}-${m}`;
|
||||
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function bucketLabel(d: Date): string {
|
||||
if (groupMode === 'year') return `${d.getFullYear()}`;
|
||||
if (groupMode === 'month')
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
return d.toLocaleDateString(undefined, {
|
||||
/** Locale-aware label for a bucket's representative date. */
|
||||
function bucketLabel(d: Date, mode: GroupMode): string {
|
||||
if (mode === 'year') return `${d.getFullYear()}`;
|
||||
if (mode === 'month')
|
||||
return dateTimeFormatFor(undefined, { year: 'numeric', month: 'long' }).format(d);
|
||||
return dateTimeFormatFor(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
|
||||
// Transient scratch map built inside $derived.by and discarded — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const index = new Map<string, number>();
|
||||
for (const p of items) {
|
||||
const d = new Date(photoTimestamp(p));
|
||||
const key = bucketKey(d);
|
||||
let i = index.get(key);
|
||||
if (i === undefined) {
|
||||
i = out.length;
|
||||
index.set(key, i);
|
||||
out.push({ key, label: bucketLabel(d), photos: [] });
|
||||
}
|
||||
out[i].photos.push(p);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
interface JustifiedTile {
|
||||
file: PhotoItem;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack files into justified rows (Flickr-style): each full row is scaled to
|
||||
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
|
||||
* fall back to 1:1.
|
||||
*/
|
||||
function justifiedRows(
|
||||
files: PhotoItem[],
|
||||
width: number
|
||||
): Array<{ height: number; tiles: JustifiedTile[] }> {
|
||||
const gap = 8;
|
||||
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
|
||||
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
|
||||
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
|
||||
let aspectSum = 0;
|
||||
for (const file of files) {
|
||||
let aspect = file.width && file.height ? file.width / file.height : 1;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
|
||||
aspect = Math.min(Math.max(aspect, 0.4), 3);
|
||||
cur.push({ file, aspect });
|
||||
aspectSum += aspect;
|
||||
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
|
||||
if (rowWidth >= width) {
|
||||
const h = (width - (cur.length - 1) * gap) / aspectSum;
|
||||
rows.push({
|
||||
height: Math.round(h),
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * h)),
|
||||
h: Math.round(h)
|
||||
}))
|
||||
});
|
||||
cur = [];
|
||||
aspectSum = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) {
|
||||
rows.push({
|
||||
height: target,
|
||||
tiles: cur.map((tt) => ({
|
||||
file: tt.file,
|
||||
w: Math.max(1, Math.round(tt.aspect * target)),
|
||||
h: target
|
||||
}))
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// ── Virtualized row model ────────────────────────────────────────────────
|
||||
// Flatten the groups into a single list of fixed-height rows (a date header
|
||||
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
|
||||
// only the rows near the viewport are mounted, regardless of library size.
|
||||
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
|
||||
const SQUARE_MIN = 144; // 9rem minmax floor
|
||||
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
|
||||
const HEADER_H = 44;
|
||||
|
||||
type PhotoRow =
|
||||
| { kind: 'header'; key: string; height: number; label: string; count: number }
|
||||
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
|
||||
|
||||
const photoRows = $derived.by<PhotoRow[]>(() => {
|
||||
const W = gridWidth;
|
||||
if (W <= 0) return [];
|
||||
const rows: PhotoRow[] = [];
|
||||
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
|
||||
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
|
||||
for (const g of groups) {
|
||||
rows.push({
|
||||
kind: 'header',
|
||||
key: `h:${g.key}`,
|
||||
height: HEADER_H,
|
||||
label: g.label,
|
||||
count: g.photos.length
|
||||
});
|
||||
if (layoutMode === 'justified') {
|
||||
const jrows = justifiedRows(g.photos, W);
|
||||
for (let ri = 0; ri < jrows.length; ri++) {
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:j${ri}`,
|
||||
height: jrows[ri].height + JUSTIFIED_GAP,
|
||||
gap: JUSTIFIED_GAP,
|
||||
tiles: jrows[ri].tiles
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < g.photos.length; i += cols) {
|
||||
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
|
||||
rows.push({
|
||||
kind: 'tiles',
|
||||
key: `${g.key}:s${i}`,
|
||||
height: cell + SQUARE_GAP,
|
||||
gap: SQUARE_GAP,
|
||||
tiles
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
// Flatten the date groups into a single list of fixed-height rows (a header
|
||||
// or a strip of sized tiles) that VirtualRows windows. Because pages arrive
|
||||
// newest-first, each append only extends the last group or adds new ones, so
|
||||
// PhotoTimeline re-buckets only the fresh page and re-lays-out only the
|
||||
// groups that changed — a full scroll stays O(N), not O(N²) (the old
|
||||
// `groups`→`photoRows` derive chain re-grouped + re-packed the whole library
|
||||
// on every 60-item page). See photoGrouping.bench.test.ts.
|
||||
// `sync` mutates the timeline's (non-reactive) internal group/row caches and
|
||||
// returns the flat rows. Driven from `$derived.by` for idempotence: if the
|
||||
// deps re-fire without an actual append, `sync` sees a non-growing list and
|
||||
// safely full-rebuilds — same output as the pure `buildPhotoRows`.
|
||||
const timeline = new PhotoTimeline();
|
||||
// `mobile` as state fed by one MediaQueryList listener: the derive below
|
||||
// re-runs on every page append, and `window.matchMedia(...)` inside it was
|
||||
// a per-recompute style/layout read that only changes on viewport-class
|
||||
// crossings — now those crossings push the boolean instead.
|
||||
let isMobile = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const mql = window.matchMedia('(max-width: 768px)');
|
||||
isMobile = mql.matches;
|
||||
const onchange = (e: MediaQueryListEvent) => {
|
||||
isMobile = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', onchange);
|
||||
return () => mql.removeEventListener('change', onchange);
|
||||
});
|
||||
const photoRows = $derived.by<PhotoRow[]>(() =>
|
||||
timeline.sync(visibleItems, {
|
||||
groupMode,
|
||||
layoutMode,
|
||||
width: gridWidth,
|
||||
mobile: isMobile,
|
||||
timestampOf: photoTimestamp,
|
||||
labelOf: bucketLabel
|
||||
})
|
||||
);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || exhausted) return;
|
||||
@@ -230,7 +152,11 @@
|
||||
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
|
||||
function onTileClick(p: PhotoItem) {
|
||||
if (selected.size > 0) selected.toggle(p.id);
|
||||
else lightbox = items.findIndex((x) => x.id === p.id);
|
||||
// Lightbox index refers to what's actually rendered — grouping
|
||||
// loops `visibleItems`, so the index space must too. If we
|
||||
// used `items` here a hidden photo could ride the paging
|
||||
// buttons even though it doesn't appear in the grid.
|
||||
else lightbox = visibleItems.findIndex((x) => x.id === p.id);
|
||||
}
|
||||
|
||||
function onDeletePhoto(id: string) {
|
||||
@@ -406,15 +332,30 @@
|
||||
|
||||
{#if error}
|
||||
<p class="status status--error" role="alert">{error}</p>
|
||||
{:else if items.length === 0 && exhausted}
|
||||
<EmptyState
|
||||
icon="images"
|
||||
title={t('photos.empty', 'No photos yet.')}
|
||||
hint={t(
|
||||
'photos.empty_hint',
|
||||
'Photos and videos you upload will appear here, grouped by date.'
|
||||
)}
|
||||
/>
|
||||
{:else if visibleItems.length === 0 && exhausted}
|
||||
{#if hiddenCount > 0}
|
||||
<EmptyState
|
||||
icon="eye-slash"
|
||||
title={t(
|
||||
'photos.empty_hidden',
|
||||
{ n: hiddenCount },
|
||||
'{{n}} photo(s) hidden by your dotfile preference'
|
||||
)}
|
||||
hint={t(
|
||||
'photos.empty_hidden_hint',
|
||||
'Turn off "Hide dotfiles" in your profile to see them.'
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon="images"
|
||||
title={t('photos.empty', 'No photos yet.')}
|
||||
hint={t(
|
||||
'photos.empty_hint',
|
||||
'Photos and videos you upload will appear here, grouped by date.'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="photos-area">
|
||||
<div class="photos-measure" bind:clientWidth={gridWidth}>
|
||||
@@ -444,7 +385,10 @@
|
||||
|
||||
{#if photoLightbox.component}
|
||||
{@const PhotoLightbox = photoLightbox.component}
|
||||
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
|
||||
<!-- Lightbox operates on `visibleItems` — indices align with
|
||||
what the grid rendered, so next/prev never surfaces a
|
||||
hidden photo the user can't see in the grid behind. -->
|
||||
<PhotoLightbox items={visibleItems} bind:index={lightbox} onDelete={onDeletePhoto} />
|
||||
{/if}
|
||||
{:else if tab === 'places'}
|
||||
{#if placesMap.component}
|
||||
|
||||
@@ -15,7 +15,8 @@ vi.mock('$lib/api/endpoints/photos', () => ({
|
||||
vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb'
|
||||
fileThumbnailUrl: () => '/thumb',
|
||||
thumbSizeForView: () => 'preview' as const
|
||||
}));
|
||||
|
||||
import { fetchPhotos } from '$lib/api/endpoints/photos';
|
||||
@@ -34,7 +35,8 @@ function photo(id: string) {
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: 0,
|
||||
name: id + '.jpg',
|
||||
owner_id: 'me',
|
||||
created_by: 'me',
|
||||
updated_by: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + id + '.jpg',
|
||||
size: 100,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
@@ -28,6 +29,12 @@
|
||||
let username = $state('');
|
||||
let preferredLocale = $state<string>('');
|
||||
let notifyOnShare = $state(true);
|
||||
// Batched into the profile save flow (same UX as
|
||||
// `notifyOnShare` above). The `preferences` store is still the
|
||||
// source of truth for the persisted value — this local mirrors it
|
||||
// on hydrate, and the diff feeds `patch.ui_preferences` on save
|
||||
// so the whole card follows one save discipline.
|
||||
let hideDotfiles = $state(false);
|
||||
|
||||
let currentPw = $state('');
|
||||
let newPw = $state('');
|
||||
@@ -91,6 +98,12 @@
|
||||
username = u.username ?? '';
|
||||
preferredLocale = u.preferred_locale ?? '';
|
||||
notifyOnShare = u.notify_on_share;
|
||||
// Source of truth is the preferences store, which itself
|
||||
// derives from `session.user.ui_preferences`. Reading through
|
||||
// the store here (rather than the raw bag) means a new
|
||||
// preference field just needs a getter in the store and its
|
||||
// own line here — no wire-format knowledge on the page.
|
||||
hideDotfiles = preferences.hideDotfiles;
|
||||
}
|
||||
|
||||
async function saveProfile(e: SubmitEvent) {
|
||||
@@ -110,6 +123,12 @@
|
||||
patch.preferred_locale = preferredLocale || undefined;
|
||||
}
|
||||
if (notifyOnShare !== u.notify_on_share) patch.notify_on_share = notifyOnShare;
|
||||
// Ship the diff as a partial `ui_preferences` patch — the
|
||||
// server does a shallow merge, so only the changed key is
|
||||
// touched; siblings set on other devices survive.
|
||||
if (hideDotfiles !== preferences.hideDotfiles) {
|
||||
patch.ui_preferences = { hide_dotfiles: hideDotfiles };
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
ui.notify(t('profile.profile_no_changes', 'No changes to save.'), 'info');
|
||||
@@ -539,6 +558,19 @@
|
||||
/>
|
||||
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="profile-hide-dotfiles-checkbox"
|
||||
bind:checked={hideDotfiles}
|
||||
/>
|
||||
<span
|
||||
>{t(
|
||||
'profile.hide_dotfiles',
|
||||
'Hide files whose name starts with a dot (.env, .git, …)'
|
||||
)}</span
|
||||
>
|
||||
</label>
|
||||
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
|
||||
>{t('profile.save_profile', 'Save changes')}</button
|
||||
>
|
||||
@@ -1131,7 +1163,7 @@
|
||||
}
|
||||
|
||||
.btn-action--danger {
|
||||
color: var(--color-danger-text);
|
||||
color: var(--color-danger-alt);
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
|
||||
@@ -5,28 +5,40 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import {
|
||||
clearRecent,
|
||||
fetchRecentPage,
|
||||
removeFromRecent,
|
||||
type RecentResourceItem
|
||||
} from '$lib/api/endpoints/recent';
|
||||
import {
|
||||
addFavorite,
|
||||
dateBucket,
|
||||
fetchFavoritesPage,
|
||||
removeFavorite,
|
||||
resolveOwnerName,
|
||||
sizeBucket,
|
||||
typeLabel
|
||||
} from '$lib/api/endpoints/favorites';
|
||||
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import type { FileItem, ItemType } from '$lib/api/types';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import ResourceList, {
|
||||
isFile,
|
||||
type ContextAction,
|
||||
type GroupByDef,
|
||||
type ResourceEntry
|
||||
type ItemContext
|
||||
} from '$lib/components/ResourceList.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
// `preferences.hideDotfiles` + `isDotfile` are read here only to
|
||||
// derive `hiddenCount` for the empty-state message — the actual
|
||||
// filter is inside ResourceList (gated on `showDotfileToggle`).
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { isDotfile } from '$lib/utils/dotfileFilter';
|
||||
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
|
||||
let raw = $state<RecentResourceItem[]>([]);
|
||||
let cursor = $state<string | undefined>(undefined);
|
||||
@@ -35,29 +47,27 @@
|
||||
let groupBy = $state('');
|
||||
let reversed = $state(false);
|
||||
const owners = useOwnerCache(resolveOwnerName);
|
||||
let favoriteIds = $state<Set<string>>(new Set());
|
||||
|
||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||
|
||||
const entries = $derived(
|
||||
raw.map((it): ResourceEntry => {
|
||||
const isFile = it.resource_type === 'file';
|
||||
const ownerId = it.resource.owner_id ?? null;
|
||||
return {
|
||||
id: it.resource.id,
|
||||
name: it.resource.name,
|
||||
kind: it.resource_type,
|
||||
iconClass: it.resource.icon_class,
|
||||
path: it.resource.path,
|
||||
size: isFile ? (it.resource as FileItem).size : null,
|
||||
date: it.accessed_at,
|
||||
ownerId,
|
||||
ownerName: owners.name(ownerId),
|
||||
isFavorite: favoriteIds.has(it.resource.id),
|
||||
category: isFile ? it.resource.category : 'Folder',
|
||||
modifiedAt: it.resource.modified_at
|
||||
};
|
||||
})
|
||||
// Envelope shape: `accessed_at` → `ctx.date`, `created_by` → `ctx.ownerId`.
|
||||
// Recent is a per-user view of items the caller accessed; the "who
|
||||
// touched this last" (`updated_by`) semantic is real but adds noise
|
||||
// (mostly the current user), so we align with Files / Favorites and
|
||||
// show the original author instead. Cross-surface consistency wins
|
||||
// over the finer-grained signal.
|
||||
//
|
||||
// Dotfile hiding is delegated to ResourceList via `showDotfileToggle`
|
||||
// — the component reads `preferences.hideDotfiles` and drops matching
|
||||
// rows from every downstream reader (bucketing, rendering, select-
|
||||
// all). The `hiddenCount` here is derived independently via the
|
||||
// shared `isDotfile` predicate purely for the empty-state message
|
||||
// below (distinguishes "genuinely empty" from "everything filtered").
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
const hiddenCount = $derived(
|
||||
preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0
|
||||
);
|
||||
|
||||
const groupBys: GroupByDef[] = [
|
||||
@@ -66,45 +76,36 @@
|
||||
key: 'owner',
|
||||
label: t('groupby.owner', 'Owner'),
|
||||
orderBy: 'owner',
|
||||
bucketOf: (e) => e.ownerId ?? null,
|
||||
bucketOf: (_item, ctx) => ctx?.ownerId ?? null,
|
||||
labelOf: (id) => owners.label(id)
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('groupby.type', 'Type'),
|
||||
orderBy: 'type',
|
||||
bucketOf: (e) => e.category ?? 'other',
|
||||
bucketOf: (item) => item.category ?? 'other',
|
||||
labelOf: (k) => typeLabel(k)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: t('groupby.size', 'Size'),
|
||||
orderBy: 'size',
|
||||
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
|
||||
bucketOf: (item) => sizeBucket(isFile(item) ? item.size : null)
|
||||
},
|
||||
{
|
||||
key: 'accessedAt',
|
||||
label: t('groupby.accessedAt', 'Accessed date'),
|
||||
orderBy: 'accessed_at',
|
||||
bucketOf: (e) => dateBucket(e.date)
|
||||
bucketOf: (_item, ctx) => dateBucket(ctx?.date)
|
||||
},
|
||||
{
|
||||
key: 'modifiedAt',
|
||||
label: t('groupby.modifiedAt', 'Modified date'),
|
||||
orderBy: 'modified_at',
|
||||
bucketOf: (e) => dateBucket(e.modifiedAt)
|
||||
bucketOf: (item) => dateBucket(item.modified_at)
|
||||
}
|
||||
];
|
||||
|
||||
async function loadFavoriteIds() {
|
||||
try {
|
||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
||||
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
|
||||
} catch {
|
||||
// non-fatal — stars just default to off
|
||||
}
|
||||
}
|
||||
|
||||
// Recent defaults to most-recently-accessed first (accessed_at DESC).
|
||||
async function load(reset = false, orderBy = 'accessed_at', rev = reversed) {
|
||||
loading = true;
|
||||
@@ -117,8 +118,12 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.accessed_at, ownerId: it.resource.created_by ?? null }
|
||||
]);
|
||||
cursor = page.next_cursor;
|
||||
void owners.resolve(page.items.map((i) => i.resource.owner_id));
|
||||
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
||||
} catch (e) {
|
||||
console.error('recent: load error', e);
|
||||
error = t('errors_loadFailed', 'Failed to load items');
|
||||
@@ -145,32 +150,45 @@
|
||||
if (shareOpen) void shareDialog.load();
|
||||
});
|
||||
|
||||
function open(entry: ResourceEntry) {
|
||||
if (entry.kind === 'folder') {
|
||||
goto(resolve(`/files/${entry.id}`));
|
||||
return;
|
||||
}
|
||||
const item = byId.get(entry.id);
|
||||
if (item) {
|
||||
viewerFile = item.resource as FileItem;
|
||||
viewerOpen = true;
|
||||
}
|
||||
function kindOf(item: FileItem | FolderItem): ItemType {
|
||||
return isFile(item) ? 'file' : 'folder';
|
||||
}
|
||||
|
||||
async function toggleFavorite(entry: ResourceEntry) {
|
||||
const isFav = favoriteIds.has(entry.id);
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
favoriteIds = next;
|
||||
function open(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) {
|
||||
goto(resolve(`/files/${item.id}`));
|
||||
return;
|
||||
}
|
||||
viewerFile = item;
|
||||
viewerOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single item from the caller's recent history. The
|
||||
* per-row "broom" affordance replaces the favorite-star that
|
||||
* existed here before — /recent is a history view, so surfacing
|
||||
* "forget this one" is more useful than "favorite this one"
|
||||
* (users go to the item's real home to favorite it).
|
||||
*
|
||||
* Optimistic: the row disappears immediately; if the DELETE
|
||||
* fails, we re-add it at its original position and toast the
|
||||
* error so the state stays honest.
|
||||
*/
|
||||
async function removeItem(item: FileItem | FolderItem) {
|
||||
const kind = kindOf(item);
|
||||
const idx = raw.findIndex((it) => it.resource.id === item.id);
|
||||
if (idx < 0) return;
|
||||
const snapshot = raw[idx];
|
||||
raw = raw.filter((it) => it.resource.id !== item.id);
|
||||
contextMap.delete(item.id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(entry.kind, entry.id);
|
||||
else await addFavorite(entry.kind, entry.id);
|
||||
await removeFromRecent(kind, item.id);
|
||||
} catch (e) {
|
||||
// revert on failure
|
||||
favoriteIds = isFav
|
||||
? new Set([...favoriteIds, entry.id])
|
||||
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
||||
raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)];
|
||||
contextMap.set(item.id, {
|
||||
date: snapshot.accessed_at,
|
||||
ownerId: snapshot.resource.created_by ?? null
|
||||
});
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
@@ -198,62 +216,90 @@
|
||||
let shareOpen = $state(false);
|
||||
let shareTarget = $state<{ id: string; name: string; kind: ItemType } | null>(null);
|
||||
|
||||
async function rename(entry: ResourceEntry) {
|
||||
async function rename(item: FileItem | FolderItem) {
|
||||
const name = await promptDialog({
|
||||
title: t('common.rename', 'Rename'),
|
||||
defaultValue: entry.name,
|
||||
defaultValue: item.name,
|
||||
confirmText: t('common.rename', 'Rename')
|
||||
});
|
||||
if (!name || name === entry.name) return;
|
||||
if (!name || name === item.name) return;
|
||||
try {
|
||||
if (entry.kind === 'file') await renameFile(entry.id, name);
|
||||
else await renameFolder(entry.id, name);
|
||||
if (isFile(item)) await renameFile(item.id, name);
|
||||
else await renameFolder(item.id, name);
|
||||
await load(true, orderByForGroup());
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(entry: ResourceEntry) {
|
||||
async function remove(item: FileItem | FolderItem) {
|
||||
const ok = await confirmDialog({
|
||||
title: t('common.delete', 'Delete'),
|
||||
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
|
||||
message: t('files.confirm_delete', { name: item.name }, 'Delete "{{name}}"?'),
|
||||
confirmText: t('common.delete', 'Delete'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (entry.kind === 'file') await deleteFile(entry.id);
|
||||
else await deleteFolder(entry.id);
|
||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||
if (isFile(item)) await deleteFile(item.id);
|
||||
else await deleteFolder(item.id);
|
||||
raw = raw.filter((i) => i.resource.id !== item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadEntry(entry: ResourceEntry) {
|
||||
if (entry.kind !== 'file') return;
|
||||
function downloadItem(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = fileDownloadUrl(entry.id);
|
||||
a.download = entry.name;
|
||||
a.href = fileDownloadUrl(item.id);
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
// Extract the parent-folder id from any item — files carry `folder_id`
|
||||
// (required by the DTO), folders carry `parent_id` (nullable when the
|
||||
// folder is a drive root). `null` means "no meaningful parent to open";
|
||||
// the "Open parent folder" entry stays hidden in that case.
|
||||
function parentFolderId(item: FileItem | FolderItem): string | null {
|
||||
return isFile(item) ? item.folder_id : item.parent_id;
|
||||
}
|
||||
|
||||
const contextActions: ContextAction[] = [
|
||||
{
|
||||
key: 'open_parent',
|
||||
label: t('files.open_parent', 'Open parent folder'),
|
||||
icon: 'folder-open',
|
||||
// Same disabled-not-hidden pattern as /favorites: hide only
|
||||
// when there's no parent (drive-root folder), otherwise
|
||||
// show and disable when the caller lacks Read on the
|
||||
// parent. `menuPrepare` primes the cache before the menu
|
||||
// renders so the final enabled/disabled state is correct
|
||||
// on the very first right-click of a row.
|
||||
visible: (item) => parentFolderId(item) !== null,
|
||||
disabled: (item) => {
|
||||
const pid = parentFolderId(item);
|
||||
return pid === null || folderAccessCached(pid) === false;
|
||||
},
|
||||
run: (item) => {
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) goto(resolve(`/files/${pid}`));
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download', 'Download'),
|
||||
icon: 'download',
|
||||
run: downloadEntry
|
||||
run: downloadItem
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: t('files.share', 'Share'),
|
||||
icon: 'share-alt',
|
||||
run: (e) => {
|
||||
shareTarget = { id: e.id, name: e.name, kind: e.kind };
|
||||
run: (item) => {
|
||||
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
shareOpen = true;
|
||||
}
|
||||
},
|
||||
@@ -261,54 +307,44 @@
|
||||
key: 'move',
|
||||
label: t('files.move', 'Move'),
|
||||
icon: 'arrows-alt',
|
||||
run: (e) => {
|
||||
run: (item) => {
|
||||
moveItems = null;
|
||||
moveTarget = { id: e.id, name: e.name, kind: e.kind };
|
||||
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
moveOpen = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
// "Add to favorites" — /recent doesn't track per-row favorite
|
||||
// state (the star widget was replaced by the broom), so the
|
||||
// entry always reads "Add" and the backend swallows duplicate
|
||||
// adds idempotently. If the user wants to un-favorite, they
|
||||
// navigate to /favorites and use the row menu there. Placed
|
||||
// between Move and Rename to match the canonical context-menu
|
||||
// order on `/files`.
|
||||
key: 'favorite',
|
||||
label: t('files.favorite', 'Add favorite'),
|
||||
icon: 'star',
|
||||
run: (item) => {
|
||||
void addFavorite(kindOf(item), item.id).catch(errorToast);
|
||||
}
|
||||
},
|
||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
||||
];
|
||||
|
||||
// ── Selection + batch ─────────────────────────────────────────────────────
|
||||
let selectedIds = $state<Set<string>>(new Set());
|
||||
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
|
||||
// Selected items arrive via the batchActions snippet param —
|
||||
// ResourceList already derives them (O(selection), not O(N)); a
|
||||
// host-side `items.filter(...)` shadow would re-run a second full scan
|
||||
// per selection toggle, and its id mirror is unnecessary (the component
|
||||
// prunes its own selection when items reload) — benches/ROUND11.md §S1.
|
||||
type Selectable = FileItem | FolderItem;
|
||||
|
||||
function batchTargets() {
|
||||
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
|
||||
}
|
||||
|
||||
function batchDownload() {
|
||||
for (const e of selectedEntries) downloadEntry(e);
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
const ok = await confirmDialog({
|
||||
title: t('common.delete', 'Delete'),
|
||||
message: t(
|
||||
'files.confirm_delete_n',
|
||||
{ count: selectedEntries.length },
|
||||
'Delete {{count}} item(s)?'
|
||||
),
|
||||
confirmText: t('common.delete', 'Delete'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
|
||||
);
|
||||
const removed = new Set(selectedEntries.map((e) => e.id));
|
||||
raw = raw.filter((i) => !removed.has(i.resource.id));
|
||||
selectedIds = new Set();
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
function batchDownload(sel: Selectable[]) {
|
||||
for (const i of sel) downloadItem(i);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadFavoriteIds();
|
||||
void load(true);
|
||||
});
|
||||
</script>
|
||||
@@ -317,19 +353,39 @@
|
||||
|
||||
<ResourceList
|
||||
title={t('nav.recent', 'Recent')}
|
||||
items={entries}
|
||||
{items}
|
||||
{contextMap}
|
||||
resolveOwnerName={(id) => owners.name(id)}
|
||||
{loading}
|
||||
{error}
|
||||
emptyIcon="clock"
|
||||
emptyText={t('recent.empty_state', 'No recent files')}
|
||||
emptyHint={t('recent.empty_hint', 'Files you open will appear here')}
|
||||
emptyIcon={hiddenCount > 0 ? 'eye-slash' : 'clock'}
|
||||
emptyText={hiddenCount > 0
|
||||
? t(
|
||||
'recent.empty_hidden_state',
|
||||
{ n: hiddenCount },
|
||||
'{{n}} recent item(s) hidden by your dotfile preference'
|
||||
)
|
||||
: t('recent.empty_state', 'No recent files')}
|
||||
emptyHint={hiddenCount > 0
|
||||
? t('recent.empty_hidden_hint', 'Turn off "Hide dotfiles" in your profile to see them.')
|
||||
: t('recent.empty_hint', 'Files you open will appear here')}
|
||||
hasMore={!!cursor}
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
showOwner
|
||||
showPath
|
||||
dateLabel={t('files.col_opened', 'Opened')}
|
||||
showDotfileToggle
|
||||
selectable
|
||||
{contextActions}
|
||||
menuPrepare={async (item) => {
|
||||
// Lazy folder-access probe — fires only when the user actually
|
||||
// opens the context menu on a row, not proactively for every
|
||||
// row on load. Cached in the LRU forever after (per-session);
|
||||
// subsequent right-clicks on the same folder are instant.
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) await probeFolderAccess(pid);
|
||||
}}
|
||||
{groupBys}
|
||||
bind:groupBy
|
||||
bind:reversed
|
||||
@@ -337,34 +393,60 @@
|
||||
cursor = undefined;
|
||||
load(true, orderBy, rev);
|
||||
}}
|
||||
onselectionchange={(ids) => (selectedIds = ids)}
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
|
||||
>{t('recent.clear', 'Clear recent')}</Button
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" data-testid="recent-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
{#snippet batchActions(sel)}
|
||||
<!--
|
||||
Recent-scoped batch cluster: what makes sense on a HISTORY
|
||||
view. Download stays (common bulk fetch). Move + Delete
|
||||
were destructive-to-content actions carried over from the
|
||||
pre-refactor menu; on a history view they belong in the
|
||||
row's context menu (rename/move/delete via `contextActions`
|
||||
above), not in the batch bar. Batch "remove from recent"
|
||||
mirrors the per-row broom and forgets the selected rows
|
||||
from history without touching the files themselves.
|
||||
-->
|
||||
<Button
|
||||
icon="download"
|
||||
data-testid="recent-batch-download-btn"
|
||||
onclick={() => batchDownload(sel)}>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="recent-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
icon="broom"
|
||||
data-testid="recent-batch-remove-btn"
|
||||
onclick={() => sel.forEach(removeItem)}
|
||||
>{t('recent.remove_item', 'Remove from recent')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="recent-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
{/snippet}
|
||||
{#snippet itemActions(item)}
|
||||
<!--
|
||||
Per-row "broom" — remove this single item from the recent
|
||||
history. Replaces the favorite star; on a history view a
|
||||
"forget this one" affordance is more useful than a
|
||||
favorite gesture. Grid view: the shared corner-cluster
|
||||
CSS turns this into a 30x30 scrim pill sitting next to
|
||||
the kebab in the top-right of the card. List view: same
|
||||
`.btn-action` treatment as trash's Restore / Delete
|
||||
buttons at the row's action-cell.
|
||||
-->
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`recent-remove-btn-${item.id}`}
|
||||
title={t('recent.remove_item', 'Remove from recent')}
|
||||
aria-label={t('recent.remove_item', 'Remove from recent')}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void removeItem(item);
|
||||
}}
|
||||
>
|
||||
<Icon name="broom" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -378,10 +460,7 @@
|
||||
bind:open={moveOpen}
|
||||
item={moveTarget}
|
||||
items={moveItems}
|
||||
onmoved={() => {
|
||||
selectedIds = new Set();
|
||||
load(true, orderByForGroup());
|
||||
}}
|
||||
onmoved={() => load(true, orderByForGroup())}
|
||||
/>
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user