perf(listing): return per-item is_favorite/is_shared, drop client badge fetches

The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 15:21:01 +00:00
parent 546dcef305
commit 9ccaeef0ab
9 changed files with 156 additions and 174 deletions
+13 -2
View File
@@ -13,6 +13,10 @@ const NO_CACHE: RequestInit = {
export interface FolderListing {
folders: FolderItem[];
files: FileItem[];
/** Ids in this listing the caller has favorited (server-computed badge set). */
favoriteIds: string[];
/** Ids in this listing the caller has an outgoing share/grant on. */
sharedIds: string[];
}
/** Top-level folders for the user; the first entry is the home folder. */
@@ -37,10 +41,17 @@ export async function listFolder(folderId: string, forceRefresh = false): Promis
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const listing = (await res.json()) as Partial<FolderListing>;
const listing = (await res.json()) as {
folders?: FolderItem[];
files?: FileItem[];
favorite_ids?: string[];
shared_ids?: string[];
};
return {
folders: Array.isArray(listing.folders) ? listing.folders : [],
files: Array.isArray(listing.files) ? listing.files : []
files: Array.isArray(listing.files) ? listing.files : [],
favoriteIds: Array.isArray(listing.favorite_ids) ? listing.favorite_ids : [],
sharedIds: Array.isArray(listing.shared_ids) ? listing.shared_ids : []
};
}
-73
View File
@@ -1,73 +0,0 @@
/**
* Session-scoped favorite / outgoing-share badge sets.
*
* The files browser shows a star (favorite) and a link (shared) badge per row.
* Previously every folder navigation re-fetched the first 200 favorites AND the
* first 200 shares — two round-trips per navigation, for data that barely
* changes. This caches both id sets once per session (`ensureLoaded`, deduped)
* and keeps them in sync via optimistic mutations from the views that toggle
* them, so navigating folders costs zero extra requests.
*
* (The 200-item ceiling is inherited from the previous implementation; the truly
* complete fix is to have the listing endpoint return per-item flags, a backend
* change tracked separately.)
*/
import { fetchFavoritesPage } from '$lib/api/endpoints/favorites';
import { fetchMyShares } from '$lib/api/endpoints/grants';
class BadgesStore {
#favorites = $state<Set<string>>(new Set());
#shared = $state<Set<string>>(new Set());
#loaded = false;
#inflight: Promise<void> | null = null;
isFavorite(id: string): boolean {
return this.#favorites.has(id);
}
isShared(id: string): boolean {
return this.#shared.has(id);
}
/** Load both id sets once per session. Concurrent callers share one fetch. */
ensureLoaded(): Promise<void> {
if (this.#loaded) return Promise.resolve();
if (this.#inflight) return this.#inflight;
this.#inflight = (async () => {
const [favs, shares] = await Promise.all([
fetchFavoritesPage({ limit: 200 }).catch(() => null),
fetchMyShares({ limit: 200 }).catch(() => null)
]);
if (favs) this.#favorites = new Set(favs.items.map((f) => f.resource.id));
if (shares) this.#shared = new Set(shares.items.map((s) => s.resource.id));
this.#loaded = true;
this.#inflight = null;
})();
return this.#inflight;
}
/** Optimistically reflect a favorite toggle (no refetch). */
setFavorite(id: string, on: boolean): void {
if (on === this.#favorites.has(id)) return;
const next = new Set(this.#favorites);
if (on) next.add(id);
else next.delete(id);
this.#favorites = next;
}
/** Mark an item as having an outgoing share (after one is created). */
markShared(id: string): void {
if (this.#shared.has(id)) return;
this.#shared = new Set(this.#shared).add(id);
}
/** Drop the cache (e.g. on logout) so the next session reloads fresh. */
reset(): void {
this.#favorites = new Set();
this.#shared = new Set();
this.#loaded = false;
this.#inflight = null;
}
}
export const badges = new BadgesStore();
-75
View File
@@ -1,75 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/endpoints/favorites', () => ({ fetchFavoritesPage: vi.fn() }));
vi.mock('$lib/api/endpoints/grants', () => ({ fetchMyShares: vi.fn() }));
import { fetchFavoritesPage } from '$lib/api/endpoints/favorites';
import { fetchMyShares } from '$lib/api/endpoints/grants';
import { badges } from './badges.svelte';
const favPage = (...ids: string[]) =>
({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited<
ReturnType<typeof fetchFavoritesPage>
>;
const sharePage = (...ids: string[]) =>
({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited<
ReturnType<typeof fetchMyShares>
>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchFavoritesPage).mockResolvedValue(favPage('f1', 'f2'));
vi.mocked(fetchMyShares).mockResolvedValue(sharePage('s1'));
badges.reset();
});
describe('badges store', () => {
it('loads once and serves every later navigation from cache', async () => {
// Five "folder navigations" each call ensureLoaded.
for (let i = 0; i < 5; i++) await badges.ensureLoaded();
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
expect(fetchMyShares).toHaveBeenCalledTimes(1);
expect(badges.isFavorite('f1')).toBe(true);
expect(badges.isFavorite('f2')).toBe(true);
expect(badges.isShared('s1')).toBe(true);
expect(badges.isFavorite('nope')).toBe(false);
});
it('collapses concurrent loads into a single fetch', async () => {
await Promise.all([
badges.ensureLoaded(),
badges.ensureLoaded(),
badges.ensureLoaded(),
badges.ensureLoaded()
]);
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
expect(fetchMyShares).toHaveBeenCalledTimes(1);
});
it('reflects favorite toggles optimistically without refetching', async () => {
await badges.ensureLoaded();
badges.setFavorite('x', true);
expect(badges.isFavorite('x')).toBe(true);
badges.setFavorite('x', false);
expect(badges.isFavorite('x')).toBe(false);
// No extra network for optimistic updates.
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
});
it('marks an item shared after a share is created', async () => {
await badges.ensureLoaded();
expect(badges.isShared('new')).toBe(false);
badges.markShared('new');
expect(badges.isShared('new')).toBe(true);
});
it('reset() clears the cache and allows a fresh reload', async () => {
await badges.ensureLoaded();
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
badges.reset();
expect(badges.isFavorite('f1')).toBe(false);
await badges.ensureLoaded();
expect(fetchFavoritesPage).toHaveBeenCalledTimes(2);
});
});
@@ -39,7 +39,6 @@
import WopiEditor from '$lib/components/WopiEditor.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { badges } from '$lib/stores/badges.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
@@ -58,7 +57,7 @@
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
let listing = $state<FolderListing>({ folders: [], files: [] });
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
@@ -80,6 +79,12 @@
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badge sets for the current folder, seeded directly from
// the listing response (server-computed, scoped to these items — no extra
// per-navigation fetch) and updated optimistically on mutation.
let favoriteIds = $state<Set<string>>(new Set());
let sharedIds = $state<Set<string>>(new Set());
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
moveItems = null;
@@ -98,15 +103,21 @@
}
async function toggleFavorite(kind: ItemType, id: string) {
const isFav = badges.isFavorite(id);
const isFav = favoriteIds.has(id);
// Optimistic toggle, reverted on failure.
badges.setFavorite(id, !isFav);
const next = new Set(favoriteIds);
if (isFav) next.delete(id);
else next.add(id);
favoriteIds = next;
try {
if (isFav) await removeFavorite(kind, id);
else await addFavorite(kind, id);
} catch (e) {
errorToast(e);
badges.setFavorite(id, isFav);
const reverted = new Set(favoriteIds);
if (isFav) reverted.add(id);
else reverted.delete(id);
favoriteIds = reverted;
}
}
@@ -147,7 +158,8 @@
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
listing = data;
crumbs = trail;
void badges.ensureLoaded();
favoriteIds = new Set(data.favoriteIds);
sharedIds = new Set(data.sharedIds);
maybeOpenDeepLink();
} catch (e) {
// 403 → friendly message rather than the raw "Forbidden" error string.
@@ -423,7 +435,7 @@
/** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() {
const items = selectionTargets().filter((it) => !badges.isFavorite(it.id));
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection();
@@ -439,7 +451,7 @@
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) badges.setFavorite(it.id, true);
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
@@ -1196,13 +1208,13 @@
<div class="name-cell">
<div class="file-icon"><Icon name="folder" /></div>
<span title={folder.name}>{folder.name}</span>
{#if badges.isFavorite(folder.id)}<div
{#if favoriteIds.has(folder.id)}<div
class="item-badge item-badge--fav"
title={t('files.favorited', 'Favorite')}
>
<Icon name="star" />
</div>{/if}
{#if badges.isShared(folder.id)}<div
{#if sharedIds.has(folder.id)}<div
class="file-badge file-badge-shared"
title={t('files.shared', 'Shared')}
>
@@ -1219,15 +1231,15 @@
<div class="action-cell">
<button
class="favorite-star"
class:active={badges.isFavorite(folder.id)}
title={badges.isFavorite(folder.id)
class:active={favoriteIds.has(folder.id)}
title={favoriteIds.has(folder.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={badges.isFavorite(folder.id)}
aria-pressed={favoriteIds.has(folder.id)}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('folder', folder.id);
}}><Icon name={badges.isFavorite(folder.id) ? 'star' : 'star-outline'} /></button
}}><Icon name={favoriteIds.has(folder.id) ? 'star' : 'star-outline'} /></button
>
<button
class="btn-action"
@@ -1314,13 +1326,13 @@
{/if}
</div>
<span title={file.name}>{file.name}</span>
{#if badges.isFavorite(file.id)}<div
{#if favoriteIds.has(file.id)}<div
class="item-badge item-badge--fav"
title={t('files.favorited', 'Favorite')}
>
<Icon name="star" />
</div>{/if}
{#if badges.isShared(file.id)}<div
{#if sharedIds.has(file.id)}<div
class="file-badge file-badge-shared"
title={t('files.shared', 'Shared')}
>
@@ -1338,15 +1350,15 @@
<div class="action-cell">
<button
class="favorite-star"
class:active={badges.isFavorite(file.id)}
title={badges.isFavorite(file.id)
class:active={favoriteIds.has(file.id)}
title={favoriteIds.has(file.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={badges.isFavorite(file.id)}
aria-pressed={favoriteIds.has(file.id)}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('file', file.id);
}}><Icon name={badges.isFavorite(file.id) ? 'star' : 'star-outline'} /></button
}}><Icon name={favoriteIds.has(file.id) ? 'star' : 'star-outline'} /></button
>
<button
class="btn-action"
@@ -1409,7 +1421,11 @@
void load();
}}
/>
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => badges.markShared(id)} />
<ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
/>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<WopiEditor
bind:open={wopiOpen}
@@ -1540,7 +1556,7 @@
}}
>
<Icon name="star" />
{badges.isFavorite(ctxTarget.id)
{favoriteIds.has(ctxTarget.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
</button>