perf(files): resolve breadcrumbs from a name cache, not N getFolder calls

Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.

Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.

`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.

The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).

Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 16:12:20 +00:00
parent 3125c866c7
commit afbc0ba515
3 changed files with 83 additions and 12 deletions
@@ -12,8 +12,10 @@
fetchFolderListing,
getCachedFolder,
getFolder,
getFolderName,
invalidateFolderCache,
moveFolder,
rememberFolderName,
renameFolder,
type FolderListing
} from '$lib/api/endpoints/folders';
@@ -125,15 +127,21 @@
}
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// Names for each id in the trail; tolerate failures with a fallback label.
const metas = await Promise.all(
segments.map((id) =>
getFolder(id)
.then((f) => ({ id, name: f.name }))
.catch(() => ({ id, name: '…' }))
)
// Names come from the cache first (every listing names its children, so
// step-by-step navigation needs zero requests); only ids we've never seen
// — a cold deep-link's ancestors — are fetched, in parallel.
return Promise.all(
segments.map(async (id) => {
const known = getFolderName(id);
if (known !== undefined) return { id, name: known };
try {
const f = await getFolder(id);
return { id, name: f.name };
} catch {
return { id, name: '…' };
}
})
);
return metas;
}
// Bumped on every load; a stale in-flight response checks this before it
@@ -329,7 +337,10 @@
if (!name || name === current) return;
try {
if (kind === 'file') await renameFile(id, name);
else await renameFolder(id, name);
else {
await renameFolder(id, name);
rememberFolderName(id, name); // keep breadcrumbs current immediately
}
await reload();
} catch (e) {
errorToast(e);