diff --git a/examples/bench_resource_row_map.rs b/examples/bench_resource_row_map.rs index ae069dba..64077274 100644 --- a/examples/bench_resource_row_map.rs +++ b/examples/bench_resource_row_map.rs @@ -95,6 +95,8 @@ fn rows(n: usize) -> Vec { } else { Some("a".repeat(64)) }, + created_by: Some(Uuid::new_v4()), + updated_by: Some(Uuid::new_v4()), sort_str: format!("row {i}"), type_order: 0, folder_first: if is_folder { 0 } else { 1 }, @@ -262,6 +264,8 @@ fn fav_rows(n: usize) -> Vec { } else { Some("a".repeat(64)) }, + created_by: Some(Uuid::new_v4()), + updated_by: Some(Uuid::new_v4()), is_owner: true, favorited_at: ts, path: Some(format!("Documents/Work/item-{i:05}")), diff --git a/frontend/src/lib/api/endpoints/folders.bench.test.ts b/frontend/src/lib/api/endpoints/folders.bench.test.ts deleted file mode 100644 index d62372db..00000000 --- a/frontend/src/lib/api/endpoints/folders.bench.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); - -import { apiFetch } from '$lib/api/client'; -import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; -import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders'; - -/** - * Benchmark gate for the coalesced progressive-render emissions in - * {@link fetchFolderListing}. - * - * Audit finding: the loader invoked `onPage` after EVERY 200-item page with a - * fresh copy of the whole accumulated listing, and the files view re-derives - * its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from - * each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200) - * elements re-sorted on the main thread during a single load — hundreds of ms - * of jank on exactly the large folders progressive rendering was meant to - * help. The fix emits page one (first paint) and the final page always, and - * intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS. - * - * Gates: - * 1. Equivalence — final listing identical to the emit-every-page reference, - * first emission still after page one (first paint preserved), last - * emission still `done === true` with the complete listing. - * 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side - * derive work collapses from 25 full re-sorts to ≤3; wall time of the - * load+derive cycle must drop accordingly (≥3x on the derive term). - */ - -type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } }; -type ResourcePage = { items?: ResourceItem[]; next_cursor?: string }; - -const PAGE_SIZE = 200; -const PAGES = 25; // 5 000-item folder - -/** Deterministic shuffled names so the consumer sort actually works. */ -function pageBody(page: number): ResourcePage { - const items: ResourceItem[] = []; - for (let i = 0; i < PAGE_SIZE; i++) { - const n = page * PAGE_SIZE + i; - const id = `f-${n.toString().padStart(5, '0')}`; - // Mix folders into the first page like a real listing (folders first). - const isFolder = page === 0 && i < 20; - items.push({ - resource_type: isFolder ? 'folder' : 'file', - resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` } - }); - } - return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined }; -} - -function fakeRes(body: ResourcePage): Response { - return { - status: 200, - ok: true, - json: async () => body, - headers: { get: () => null } - } as unknown as Response; -} - -function mockPagedFetch(): void { - let call = 0; - vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++))); -} - -/** - * The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy - * of the whole accumulated listing after every page. - */ -async function referenceFetchFolderListing( - folderId: string, - onPage: (partial: FolderListing, done: boolean) => void -): Promise { - const folders: FolderItem[] = []; - const files: FileItem[] = []; - let cursor: string | undefined; - do { - const params = new URLSearchParams({ order_by: 'name', limit: '200' }); - if (cursor) params.set('cursor', cursor); - const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { - credentials: 'same-origin', - cache: 'no-store' - }); - if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const page = (await res.json()) as ResourcePage; - 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; - onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor); - } while (cursor); - return { folders, files, favoriteIds: [], sharedIds: [] }; -} - -/** - * The files view's per-emission derive chain, reduced to its dominant costs: - * dotfile filter pass + two localeCompare sorts + ordered-entry rebuild - * (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte). - * Returns the number of elements that went through the sort — the O(N²) term. - */ -function consumerDerive(partial: FolderListing): number { - const visF = partial.folders.filter((f) => !f.name.startsWith('.')); - const visX = partial.files.filter((f) => !f.name.startsWith('.')); - const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name)); - const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name)); - const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)]; - return orderedIds.length; -} - -beforeEach(() => { - vi.clearAllMocks(); - invalidateFolderCache(); -}); - -describe('coalesced progressive listing emissions (benchmark gate)', () => { - it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => { - mockPagedFetch(); - const refEmits: Array<{ n: number; done: boolean }> = []; - const refFinal = await referenceFetchFolderListing('bench', (p, done) => - refEmits.push({ n: p.folders.length + p.files.length, done }) - ); - - mockPagedFetch(); - const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = []; - const r = await fetchFolderListing('bench', { - onPage: (partial, done) => - emits.push({ n: partial.folders.length + partial.files.length, done, partial }) - }); - - // Identical complete listing. - expect(r.listing).toEqual(refFinal); - // First paint unchanged: the first emission is still page one. - expect(emits[0].n).toBe(refEmits[0].n); - expect(emits[0].n).toBe(PAGE_SIZE); - // Exactly one done emission, last, carrying the full listing — as before. - expect(emits.filter((e) => e.done).length).toBe(1); - expect(emits[emits.length - 1].done).toBe(true); - expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE); - expect(refEmits[refEmits.length - 1].done).toBe(true); - // Emissions are a subset of what the reference produced (never more). - expect(emits.length).toBeLessThanOrEqual(refEmits.length); - // Every emitted partial is a prefix-accumulation (monotone growth). - for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n); - }); - - it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => { - vi.mocked(apiFetch).mockResolvedValue( - fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor - ); - const emits: boolean[] = []; - await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) }); - expect(emits).toEqual([true]); - }); - - it( - `collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`, - { timeout: 30_000 }, - async () => { - // Warm-up both paths, twice each, so V8's tiering has fully - // settled before we measure. A single warm-up was enough on - // developer laptops but bursty CPU steals on shared CI - // runners can leave one path un-tiered during measurement, - // skewing the wall-time ratio at line ~202 below. - for (let i = 0; i < 2; i++) { - mockPagedFetch(); - await referenceFetchFolderListing('warm', (p) => consumerDerive(p)); - mockPagedFetch(); - await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) }); - } - - mockPagedFetch(); - let refSorted = 0; - let refEmits = 0; - const t0 = performance.now(); - await referenceFetchFolderListing('bench', (p) => { - refEmits++; - refSorted += consumerDerive(p); - }); - const refMs = performance.now() - t0; - - mockPagedFetch(); - let sorted = 0; - let emitsN = 0; - const t1 = performance.now(); - await fetchFolderListing('bench', { - onPage: (p) => { - emitsN++; - sorted += consumerDerive(p); - } - }); - const ms = performance.now() - t1; - - console.info( - `progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)` - ); - - // The reference re-derived every page: Σ = P(P+1)/2 pages of elements. - expect(refEmits).toBe(PAGES); - expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2); - // Coalesced: page 1 + final (+ occasionally one mid emission if the - // stubbed pages ever take >150 ms — they don't on any healthy runner). - expect(emitsN).toBeLessThanOrEqual(3); - // ≥5x less consumer sort work is the point of the change. - // This is a pure DETERMINISTIC count (sum of `consumerDerive` - // return values) — hardware-independent, so catches an - // actual O(N²) → O(N) regression cleanly. - expect(sorted).toBeLessThan(refSorted / 5); - // And it must show up as wall time on the combined load+ - // derive cycle. 2x floor (loosened from 3x on 2026-07-18 - // after a shared-CI-runner false alarm at 2.63x — bursty - // CPU steals eat headroom on the fine-grained - // `performance.now()` measurements). Still catches an - // O(N²) regression (which would be ~10x slower, not 2x) - // — the deterministic count above at line 200 is the real - // algorithmic gate. - expect(ms).toBeLessThan(refMs / 2); - } - ); -}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 19d1f947..0ceb996e 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -110,86 +110,109 @@ export function getFolder(id: string): Promise { return request; } -/** - * Minimum spacing between intermediate progressive-render emissions of - * {@link fetchFolderListing}. Each emission hands the consumer the WHOLE - * accumulated listing, and the files view re-derives its filtered + sorted - * view from it (O(accumulated · log) with `localeCompare`), so emitting every - * page made a large-folder load Σ O(N²/page) of main-thread sort work. Page - * one and the final page always emit; pages in between only emit after this - * much time has passed since the previous emission. - */ -export const PAGE_EMIT_MIN_INTERVAL_MS = 150; +/** 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 { + 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, - opts: { - etag?: string; - forceRefresh?: boolean; - /** - * Progressive render hook: invoked with the accumulated listing so - * far (the arrays are fresh copies — safe to hand to reactive - * state). Without it, a 2,000-item folder waited for all ⌈N/200⌉ - * sequential round-trips before the first row painted; with it the - * view paints after page one (~200 items) and fills in as the tail - * pages land. Emissions are coalesced to at most one per - * {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final - * page — the hook is always called for page one and always called - * once more with `done === true` and the complete listing. - */ - onPage?: (partial: FolderListing, done: boolean) => void; - } = {} + opts: { etag?: string; forceRefresh?: boolean } = {} ): Promise { const folders: FolderItem[] = []; const files: FileItem[] = []; let cursor: string | undefined; - let firstPage = true; - let lastEmit = 0; 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; - const done = !cursor; - if ( - opts.onPage && - (done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS) - ) { - lastEmit = performance.now(); - opts.onPage( - { folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, - done - ); - } - firstPage = false; + folders.push(...page.folders); + files.push(...page.files); + cursor = page.nextCursor; } while (cursor); - return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } }; } diff --git a/frontend/src/lib/api/endpoints/recent.ts b/frontend/src/lib/api/endpoints/recent.ts index ce5caa34..77820cee 100644 --- a/frontend/src/lib/api/endpoints/recent.ts +++ b/frontend/src/lib/api/endpoints/recent.ts @@ -30,3 +30,20 @@ export async function clearRecent(): Promise { }); 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 { + 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}`); + } +} diff --git a/frontend/src/lib/components/ActionBar.svelte b/frontend/src/lib/components/ActionBar.svelte new file mode 100644 index 00000000..45812645 --- /dev/null +++ b/frontend/src/lib/components/ActionBar.svelte @@ -0,0 +1,37 @@ + + +
+ {#if start}{@render start()}{:else}
{/if} + {#if end}{@render end()}{/if} +
diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 0abff3e4..fc2c335a 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -872,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; @@ -1117,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; diff --git a/frontend/src/lib/components/DisplayModeControls.svelte b/frontend/src/lib/components/DisplayModeControls.svelte new file mode 100644 index 00000000..e2582da5 --- /dev/null +++ b/frontend/src/lib/components/DisplayModeControls.svelte @@ -0,0 +1,194 @@ + + + + +{#if anyVisible} +
+ {#if beforeGroupBy}{@render beforeGroupBy()}{/if} + {#if groups?.length} +
+ + {#if sortVisible} + + {/if} + {#if menuOpen} +
+ {#each groups as g (g.key)} + + {/each} +
+ {/if} +
+ {#if showViewMode}{/if} + {/if} + {#if showViewMode} + + + {/if} + {#if showDotfileToggle} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 9818c4bc..3914150c 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -50,6 +50,25 @@ label: string; icon: string; danger?: boolean; + /** + * Optional per-item visibility gate. Called at menu-open time + * with the target item + context; return `false` to hide the + * entry entirely for that row (e.g. `open_parent` on a drive- + * root folder that has no parent to open). Prefer `disabled?` + * over hiding when the action *could* apply but the caller + * lacks the required permission — a greyed entry answers + * "this option exists" for the user instead of leaving a hole + * that reads as a forgotten feature. + */ + visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean; + /** + * Optional per-item disabled gate. Called at menu-open time; + * `true` renders the entry non-interactive (dimmed, no click). + * Kept sync by the same contract as `visible?` — use the + * `menuPrepare` prop to prime any cache the predicate depends + * on before the menu renders. + */ + disabled?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean; run: (item: FileItem | FolderItem, ctx?: ItemContext) => void; } @@ -69,10 +88,14 @@ import Icon from '$lib/icons/Icon.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import SkeletonList from '$lib/components/SkeletonList.svelte'; - import ListToolbar from '$lib/components/ListToolbar.svelte'; + import ActionBar from '$lib/components/ActionBar.svelte'; + import DisplayModeControls from '$lib/components/DisplayModeControls.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; import { t } from '$lib/i18n/index.svelte'; + import { ui } from '$lib/stores/ui.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { preferences } from '$lib/stores/preferences.svelte'; import { formatBytes } from '$lib/utils/format'; @@ -118,6 +141,14 @@ emptyHint?: string; /** Empty-state icon-registry name (e.g. "star", "clock", "trash"). */ emptyIcon?: string; + /** + * Call-to-action rendered inside the empty state. Used by + * `/files` to surface a "Show hidden files" button when the + * folder holds only dotfiles the user has chosen to hide — the + * page-specific hint stays in `emptyHint`, the action goes + * here. `` renders it below the hint text. + */ + emptyAction?: Snippet; hasMore?: boolean; onloadmore?: () => void; /** Show the path/location column (list view only). */ @@ -141,6 +172,14 @@ bucketAction?: Snippet<[string]>; /** Show the owner column + vignette (list view) and hover tooltip. */ showOwner?: boolean; + /** + * Override the owner column header (and the hover-tooltip prefix). The + * default reads "Created by", matching the semantic of `created_by` used + * on /files, /favorites, /recent. /shared-with-me overrides to + * "Shared by" since the column there actually renders `granted_by` + * (the sharer, not the resource author). + */ + ownerLabel?: string; /** Allow grid/list toggle (shares the app-wide view mode). */ showViewToggle?: boolean; /** Show the dotfile-visibility eye toggle in the toolbar AND @@ -176,10 +215,87 @@ onfavorite?: (item: FileItem | FolderItem) => void; /** Selection changed (set of selected item ids). */ onselectionchange?: (ids: Set) => void; - actions?: Snippet<[FileItem | FolderItem]>; - toolbar?: Snippet; - /** Batch toolbar shown when items are selected; receives selected items. */ - batchToolbar?: Snippet<[Array]>; + /** + * Right-click / long-press handler. When provided, ResourceList + * forwards the row's `contextmenu` event to this callback and + * SKIPS its built-in menu — the page renders and positions its + * own. Useful when the page needs conditional entries (WOPI + * editability, audio-only actions) that don't fit the flat + * `contextActions` array. If both `oncontextmenu` and + * `contextActions` are provided, `oncontextmenu` wins. + */ + oncontextmenu?: (e: MouseEvent, item: FileItem | FolderItem) => void; + /** + * Optional async pre-open hook. When provided, ResourceList + * awaits it before the built-in context menu appears — so a + * page can lazily prime any per-item cache the menu's + * `visible?` predicates depend on WITHOUT the page having to + * pre-warm every row at load time (which would fire N HTTP + * calls for a feature the user may never invoke). + * + * Reference use: `/recent` / `/favorites` probe folder-access + * for the row's parent inside `menuPrepare` so the "Open parent + * folder" entry shows up on the first right-click of a + * previously-unseen row. Short-typically-cached call; typical + * menu-open latency stays well under a UI frame. + */ + menuPrepare?: (item: FileItem | FolderItem, ctx?: ItemContext) => Promise; + /** + * Per-item action cell (renders at the end of a row). Kept as a + * distinct slot from the action-bar snippets below so callers + * that want an item-scoped affordance (a per-row overflow menu) + * don't have to piggyback on the bar. + */ + itemActions?: Snippet<[FileItem | FolderItem]>; + /** + * Action-bar left cluster — always-visible page action buttons + * (Upload / New folder / Empty trash / Clear recent / …). Swaps + * to `batchActions` when the selection is non-empty. Every + * section provides its own buttons; ResourceList doesn't ship + * any defaults. + */ + actions?: Snippet; + /** + * Action-bar left cluster when selection is non-empty — + * replaces `actions`. Receives the selected items so buttons + * can be scoped to the batch. Replaces the phase-1 + * `batchToolbar` floating strip pattern. + */ + batchActions?: Snippet<[Array]>; + /** + * Rendered next to the item name in each row. `/trash` uses + * this for its expiration badge; other sections omit it. + * ResourceList stays ignorant of what the badge means — the + * page decides. Empty return = no badge. + */ + rowBadge?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>; + /** + * Rendered above the toolbar in the sticky header. Only + * `/files` wires this today; every other section leaves the + * snippet undefined so no breadcrumb strip appears. Kept as a + * snippet (not a boolean) so the page owns crumb rendering and + * their click / drag-drop behavior. + */ + breadcrumb?: Snippet; + /** + * When true, drops from the OS file system on the ResourceList + * wrapper are forwarded to `onsystemdrop` (upload path). When + * false (default), the wrapper still intercepts the OS drop — + * `preventDefault` so the browser doesn't navigate to the file + * — and fires a "wrong section" `ui.notify()` pointing the user + * at the Files section (the legacy behaviour). Item-drag drops + * (row → folder) are unaffected either way; those go through + * `onitemdrop` per the existing row hooks. + */ + enableSystemDrop?: boolean; + /** + * Called with the OS-dropped files when `enableSystemDrop` is + * true. The page keeps ownership of the upload code (walking + * webkitGetAsEntry trees, chunked uploader, etc.) — this + * component just delivers the payload. Ignored when + * `enableSystemDrop` is false. + */ + onsystemdrop?: (e: DragEvent) => void; /** * Render `` thumbnails on file rows and fall back to * client-side generation when the server doesn't have one @@ -244,6 +360,7 @@ emptyText, emptyHint, emptyIcon, + emptyAction, hasMore = false, onloadmore, showPath = true, @@ -255,6 +372,7 @@ dateCell, bucketAction, showOwner = false, + ownerLabel, showViewToggle = true, showDotfileToggle = false, selectable = false, @@ -266,9 +384,15 @@ onopen, onfavorite, onselectionchange, + oncontextmenu: onContextMenuOverride, + menuPrepare, + itemActions, actions, - toolbar, - batchToolbar, + batchActions, + rowBadge, + breadcrumb, + enableSystemDrop = false, + onsystemdrop, enableThumbnails = true, isDraggable, isDropTarget, @@ -335,6 +459,13 @@ let gridWidth = $state(0); const gridCols = $derived(gridColumns(gridWidth)); + // Whether an action-cell renders per row — matches the row-template + // gate below. Feeds both the list-view column track and the header + // row's trailing placeholder so the layout stays in sync. + const hasActionCell = $derived( + !!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length + ); + // Build the list-view column track from the enabled cells. const columns = $derived( [ @@ -345,7 +476,7 @@ showType ? '120px' : '', showSize ? '110px' : '', showDate ? '160px' : '', - actions ? '120px' : '' + hasActionCell ? '120px' : '' ] .filter(Boolean) .join(' ') @@ -420,27 +551,57 @@ for (let i = lo; i <= hi; i++) selected.add(order[i]); onselectionchange?.(selected); } + // True when the client is macOS. Sets which modifier toggles a row + // on click: + // * macOS: ⌘ (metaKey) — because Ctrl+Click is reserved by the + // OS/browser for the native contextmenu event. Intercepting + // Ctrl+Click here would collide with the right-click menu; the + // browser fires `contextmenu` BEFORE `click`, so both would run + // and the user would see the menu AND a rogue toggle. + // * Windows / Linux: Ctrl (ctrlKey) — standard file-manager + // convention (Explorer, Nautilus, etc.). ⌘ (Win/Super key) also + // accepted defensively; it never collides with anything on the + // row itself. + const IS_MAC = + typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || ''); + + function isToggleModifier(e: MouseEvent | KeyboardEvent): boolean { + return IS_MAC ? e.metaKey : e.ctrlKey || e.metaKey; + } + /** * Left-click handler that either navigates (`onopen`) or manages * selection depending on modifiers + config. Returns `true` when * the click was consumed by selection, so callers can suppress the - * open. Enabled only for `selectable + shiftRangeSelect` callers. + * open. + * + * Selection gestures: + * * Shift+Click — range selection between the anchor and this row + * (requires `shiftRangeSelect` opt-in — the anchor is only + * tracked when that flag is on). + * * ⌘+Click (Mac) / Ctrl+Click (Win/Linux) — toggle a single row. + * Available whenever `selectable` is on; no `shiftRangeSelect` + * required, so sections that just want checkboxes get the + * shortcut too. See `IS_MAC` note above for why Ctrl+Click is + * NOT intercepted on macOS (native contextmenu conflict). */ function handleRowClick(e: MouseEvent, id: string): boolean { - if (!selectable || !shiftRangeSelect) return false; - if (e.shiftKey && selectionAnchor) { + if (!selectable) return false; + if (shiftRangeSelect && e.shiftKey && selectionAnchor) { e.preventDefault(); selectRange(selectionAnchor, id); return true; } - if (e.metaKey || e.ctrlKey) { + if (isToggleModifier(e)) { e.preventDefault(); toggleSelected(id); - selectionAnchor = id; + if (shiftRangeSelect) selectionAnchor = id; return true; } - // Plain click: only sets the anchor; open (if any) still fires. - selectionAnchor = id; + // Plain click: only sets the anchor (when range-select is on); + // `onopen` still fires so navigation works normally. + if (shiftRangeSelect) selectionAnchor = id; return false; } function clearSelection() { @@ -464,6 +625,148 @@ onselectionchange?.(selected); } } + + // Ctrl+A (Linux / Windows) / ⌘+A (macOS) selects every visible row. + // Handled here rather than in each page's own `svelte:window` so all + // consumers get the shortcut for free — /files, /trash, /favorites, + // /recent, /shared-with-me — with identical semantics. Only fires + // when `selectable` is on, and only when the focused element isn't + // a text input (typing inside a search box shouldn't hijack it). + // The modifier check goes through `isToggleModifier` so keyboard + // and mouse gestures agree on the platform (⌘ on Mac; Ctrl or ⌘ + // on Win/Linux). + function onSelectAllShortcut(e: KeyboardEvent) { + if (!selectable) return; + if (!isToggleModifier(e)) return; + if (e.key.toLowerCase() !== 'a') return; + const tag = (e.target as HTMLElement | null)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Also skip when the focus is inside a contentEditable region + // (rich-text popups, name inline-edit if ever added). + if ((e.target as HTMLElement | null)?.isContentEditable) return; + e.preventDefault(); + toggleSelectAll(); + } + + // ── Rubberband (marquee) selection ──────────────────────────────────────── + // + // Click-and-drag on empty space draws a translucent rectangle; every row + // whose bounding box intersects the rectangle joins the selection. Behavior: + // * Plain drag → replace the current selection with what the box covers. + // * Shift+drag → add to the current selection (union). + // * ⌘/Ctrl+drag → toggle: rows inside the box flip their state relative + // to the pre-drag baseline. + // + // The gesture only starts when the mousedown lands on truly empty space — + // mousedowns on `.file-item`, links, buttons, or the checkbox pass through + // to their own handlers. This keeps row-drag (files browser) uncontested. + // + // Intersections are computed via `getBoundingClientRect()` on every mouse + // move, so this only sees VISIBLE rows — which is what a user in a + // virtualized list expects anyway ("I can't rubberband something I can't + // see"). No auto-scroll during drag today; the user can release, scroll, + // then start another gesture with Shift held. + let rlRoot = $state(null); + let rubberband = $state<{ + startX: number; + startY: number; + x: number; + y: number; + w: number; + h: number; + mode: 'replace' | 'add' | 'toggle'; + baseline: Set; + } | null>(null); + + function onRootPointerDown(e: PointerEvent) { + if (!selectable) return; + if (e.button !== 0) return; // Left button only + const target = e.target as HTMLElement | null; + if (!target || !rlRoot) return; + // Ignore mousedowns on interactive descendants or on a row. + if ( + target.closest('.file-item') || + target.closest('a, button, input, select, textarea, [role="menuitem"]') + ) { + return; + } + // Ignore when the click landed on the sticky header (bar + breadcrumb). + if (target.closest('.page-sticky-header')) return; + + const rect = rlRoot.getBoundingClientRect(); + const startX = e.clientX - rect.left; + const startY = e.clientY - rect.top; + const mode: 'replace' | 'add' | 'toggle' = e.shiftKey + ? 'add' + : isToggleModifier(e) + ? 'toggle' + : 'replace'; + const baseline = mode === 'replace' ? new Set() : new Set(selected); + + rubberband = { startX, startY, x: startX, y: startY, w: 0, h: 0, mode, baseline }; + + if (mode === 'replace') selected.clear(); + + // preventDefault so text under the drag doesn't get selected as we drag. + e.preventDefault(); + window.addEventListener('pointermove', onRubberbandMove); + window.addEventListener('pointerup', onRubberbandUp, { once: true }); + } + + function onRubberbandMove(e: PointerEvent) { + if (!rubberband || !rlRoot) return; + const rect = rlRoot.getBoundingClientRect(); + const curX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const curY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); + rubberband.x = Math.min(rubberband.startX, curX); + rubberband.y = Math.min(rubberband.startY, curY); + rubberband.w = Math.abs(curX - rubberband.startX); + rubberband.h = Math.abs(curY - rubberband.startY); + applyRubberbandSelection(); + } + + function onRubberbandUp() { + window.removeEventListener('pointermove', onRubberbandMove); + rubberband = null; + } + + function applyRubberbandSelection() { + if (!rubberband || !rlRoot) return; + const rootRect = rlRoot.getBoundingClientRect(); + // Absolute viewport rect of the current band. + const bandLeft = rootRect.left + rubberband.x; + const bandTop = rootRect.top + rubberband.y; + const bandRight = bandLeft + rubberband.w; + const bandBottom = bandTop + rubberband.h; + + const rows = rlRoot.querySelectorAll('.file-item[data-item-id]'); + // Transient scratch set for computing the diff before mutating + // `selected`. `SvelteSet` (not plain `Set`) per the codebase's + // `svelte/prefer-svelte-reactivity` convention — the lint rule + // exists so a future refactor that stashes this in `$state` + // can't silently break reactivity. + const nextSelection = new SvelteSet(rubberband.baseline); + for (const row of rows) { + const id = row.dataset.itemId; + if (!id) continue; + const b = row.getBoundingClientRect(); + const overlaps = + b.left < bandRight && b.right > bandLeft && b.top < bandBottom && b.bottom > bandTop; + if (overlaps) { + if (rubberband.mode === 'toggle') { + if (rubberband.baseline.has(id)) nextSelection.delete(id); + else nextSelection.add(id); + } else { + nextSelection.add(id); + } + } + } + // Rewrite the `selected` set in place — SvelteSet is reactive on + // per-key operations, so we only mutate the diff. + for (const id of selected) if (!nextSelection.has(id)) selected.delete(id); + for (const id of nextSelection) if (!selected.has(id)) selected.add(id); + onselectionchange?.(selected); + } // `selectedItems` and the reap-stale effect below stay on the RAW // items — selection persists across a display-filter toggle, and // stale-selection cleanup only fires when items truly leave the @@ -520,13 +823,30 @@ let ctxY = $state(0); let ctxItem = $state(null); - function openContext(e: MouseEvent, item: FileItem | FolderItem) { + async function openContext(e: MouseEvent, item: FileItem | FolderItem) { if (!contextActions?.length) return; e.preventDefault(); e.stopPropagation(); + // Snapshot the pointer coords now — after an `await menuPrepare` + // tick the event object may be reused / stale, and reading + // `e.clientX` post-await could pin the menu to the wrong spot. + const x = Math.min(e.clientX, window.innerWidth - 220); + const y = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24)); + // Give the page a chance to prime any per-item cache the + // `visible?` predicates read (e.g. folder-access on /recent + + // /favorites for the "Open parent folder" entry). Awaited so the + // menu opens with the final visibility state — avoids a + // flash-of-hidden-then-shown when the probe resolves. + if (menuPrepare) { + try { + await menuPrepare(item, ctxOf(item.id)); + } catch { + /* prepare failures degrade to the sync-only visibility */ + } + } ctxItem = item; - ctxX = Math.min(e.clientX, window.innerWidth - 220); - ctxY = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24)); + ctxX = x; + ctxY = y; ctxOpen = true; } function closeContext() { @@ -556,12 +876,103 @@ const owner = ownerId ? (resolveOwnerName?.(ownerId) ?? ownerId) : ''; const path = item.path ?? ''; return [ - owner && `${t('files.col_owner', 'Owner')}: ${owner}`, + owner && `${ownerLabel ?? t('files.col_created_by', 'Created by')}: ${owner}`, path && `${t('files.col_path', 'Location')}: ${path}` ] .filter(Boolean) .join('\n'); } + + // ── System-drop handling (OS files onto the wrapper) ─────────────────────── + // Two modes: + // + // * `enableSystemDrop = true`: the page has an upload code path + // ready (the `/files` browser). We `preventDefault` the browser's + // default (which would open the dragged file as a top-level + // navigation), highlight the drop zone, and hand the DragEvent + // off to the page via `onsystemdrop`. The page walks the entries + // (webkitGetAsEntry / DataTransferItemList) and drives the upload. + // + // * `enableSystemDrop = false` (default): the page has no upload + // path. Still `preventDefault` so the browser doesn't navigate + // away, but instead of forwarding, fire a `ui.notify()` that + // points the user at `/files` — this restores the legacy vanilla + // frontend's "wrong drop zone" behaviour so users don't wonder + // why their drag was silently ignored. + // + // Row-scoped drops (dragging an in-app row onto a folder row / the + // breadcrumb) are handled by the existing `onitemdrop` hooks and use + // a private `application/x-oxi-item` MIME so the `Files` type check + // below never matches them. + // Drag-enter/leave chatter is unavoidable when the drag pointer moves + // between the wrapper and its descendants — the browser fires + // `dragleave` on the parent BEFORE firing `dragenter` on the child, + // so a naive `systemDropOver = false` in the leave handler produces + // a false→true flash on every row hover during the drag. Counter + // approach: increment on every dragenter, decrement on every + // dragleave; the overlay is visible when the count is positive. The + // count zeroes only when the drag has truly left the wrapper (or + // hit `drop`/`dragend`), so the overlay stays stable throughout. + let systemDragDepth = $state(0); + const systemDropOver = $derived(systemDragDepth > 0); + function isSystemDrag(e: DragEvent): boolean { + return !!e.dataTransfer?.types?.includes('Files'); + } + function onSystemDragEnter(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + systemDragDepth++; + } + function onSystemDragOver(e: DragEvent) { + if (!isSystemDrag(e)) return; + // preventDefault on `dragover` is what tells the browser this + // element accepts drops — without it, `drop` never fires and + // the pointer shows the OS "no-drop" cursor. + e.preventDefault(); + // `dropEffect = 'none'` would tell the browser to REJECT the + // drop before `drop` fires — the toast/notification path in + // `onSystemDrop` would never run for wrong-zone drops. Always + // accept at the pointer level; the drop handler decides + // whether to upload (`enableSystemDrop`) or fire the + // "go to Files" toast. + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + } + function onSystemDragLeave(e: DragEvent) { + if (!isSystemDrag(e)) return; + if (systemDragDepth > 0) systemDragDepth--; + } + function onSystemDrop(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + // Drop ends the drag; force-clear regardless of counter state + // (a stray unbalanced dragenter would otherwise leave the + // overlay stuck on). + systemDragDepth = 0; + if (enableSystemDrop && onsystemdrop) { + onsystemdrop(e); + } else if (!enableSystemDrop) { + ui.notify( + t( + 'resource_list.wrong_drop_zone_msg', + 'Uploads only work in Files — open the Files section and drop there.' + ), + 'warning', + 6000, + true, + { + action: { + label: t('resource_list.wrong_drop_zone_action', 'Go to Files'), + // One-click recovery from a mis-drop: land the user in + // /files so they can re-drag from the OS. We don't + // re-attach the dropped files (browsers throw away + // DataTransfer once the drop event returns), so this + // is the best we can offer without a second drag. + onClick: () => goto(resolve('/files')) + } + } + ); + } + } {#snippet row(item: FileItem | FolderItem)} @@ -578,32 +989,39 @@
onitemdragstart(e, item) : undefined} ondragover={dropTarget && onitemdragover ? (e) => onitemdragover(e, item) : undefined} ondragleave={dropTarget && onitemdragleave ? (e) => onitemdragleave(e, item) : undefined} ondrop={dropTarget && onitemdrop ? (e) => onitemdrop(e, item) : undefined} - onclick={onopen + onclick={onopen || selectable ? (e) => { // Selection-first for shift/meta clicks; only "open" fires on a - // plain click when the click wasn't consumed by selection. + // plain click when the click wasn't consumed by selection. The + // handler runs even without `onopen` so ⌘/Ctrl+Click still + // toggles the row on selection-only surfaces (no navigation). if (handleRowClick(e, item.id)) return; - if (!openOnDoubleClick) onopen(item); + if (onopen && !openOnDoubleClick) onopen(item); } : undefined} ondblclick={onopen && openOnDoubleClick ? () => onopen(item) : undefined} onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(item) : undefined} - oncontextmenu={contextActions?.length ? (e) => openContext(e, item) : undefined} + oncontextmenu={onContextMenuOverride + ? (e) => onContextMenuOverride(e, item) + : contextActions?.length + ? (e) => void openContext(e, item) + : undefined} > {#if selectable} - {/if}
- {#if showDate && dateCell}{@render dateCell(item, ctx)}{/if} + {#if sizeVal != null}{formatBytes(sizeVal)}{/if} {#if dateVal != null}{formatDate(dateVal)}{/if}
- {#if onfavorite} - - {/if} - {#if actions} -
{@render actions(item)}
+ + {#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length} +
+ {#if onfavorite} + + {/if} + {#if itemActions}{@render itemActions(item)}{/if} + {#if onContextMenuOverride || contextActions?.length} + + {/if} +
{/if}
{/snippet} -
+ + + + + +
+

{title}

- - {#snippet start()} -
{@render toolbar?.()}
- {/snippet} -
-
- -{#if selectable && selected.size > 0 && batchToolbar} -
- - {t('files.selected_count', { count: selected.size }, '{{count}} selected')} -
{@render batchToolbar(selectedItems)}
+
+ + {#snippet start()} + +
0 && batchActions} + > + {#if selectable && selected.size > 0 && batchActions} + + {t('files.selected_count', { count: selected.size }, '{{count}} selected')} +
+ {@render batchActions(selectedItems)} +
+ {:else if actions} + {@render actions()} + {/if} +
+ {/snippet} + {#snippet end()} + + {/snippet} +
+ {#if breadcrumb} + +
{@render breadcrumb()}
+ {/if}
-{/if} -{#if error} - -{:else if loading && isEmpty} - -{:else if isEmpty} - -{:else} -
- {#if grouped && filesStore.viewMode === 'list'} -
- {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} - {#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - + {#if error} + + {:else if loading && isEmpty} + + {:else if isEmpty} + + {#if emptyAction}{@render emptyAction()}{/if} + + {:else} +
+ {#if grouped && filesStore.viewMode === 'list'} +
+ {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} + {#each sections as section (section.key)} + {#if section.label} +
+ {section.label} + {#if bucketAction} + + {@render bucketAction(section.key)} + + {/if} +
{/if} -
- - e.id} {row} /> - {/each} -
- {:else if grouped} - -
- {#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - +
+ {#each sections as section (section.key)} + {#if section.label} +
+ {section.label} + {#if bucketAction} + + {@render bucketAction(section.key)} + + {/if} +
{/if} -
- e.id} - {row} - /> - {/each} -
- {:else if filesStore.viewMode === 'list'} - -
- {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} - e.id} {row} /> -
- {:else} - - e.id} - {row} - /> - {/if} +
+ {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} + e.id} {row} /> +
+ {:else} + + e.id} + {row} + /> + {/if} - {#if hasMore} - - {/if} - - -
-{/if} + {#if hasMore} + + {/if} + + +
+ {/if} + {#if rubberband} + + + {/if} + + + {#if systemDropOver && enableSystemDrop} + + {/if} +
+ {#snippet listHeader()}
{#if selectable} -
+
{/if} -
{t('files.col_name', 'Name')}
- {#if showOwner}
{t('files.col_owner', 'Owner')}
{/if} - {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} - {#if showType}
{t('files.col_type', 'Type')}
{/if} - {#if showSize}
{t('files.col_size', 'Size')}
{/if} - {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if onfavorite || actions}
{/if} +
{t('files.col_name', 'Name')}
+ {#if showOwner}
+ {ownerLabel ?? t('files.col_created_by', 'Created by')} +
{/if} + {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} + {#if showType}
{t('files.col_type', 'Type')}
{/if} + {#if showSize}
{t('files.col_size', 'Size')}
{/if} + {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} + {#if hasActionCell}
{/if}
{/snippet} {#if ctxOpen && ctxItem && contextActions} + {@const visibleActions = contextActions.filter( + (a) => a.visible?.(ctxItem!, ctxOf(ctxItem!.id)) !== false + )}