From 635ef8baf6d5dbe1b7dbae1665c5160c96f15623 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:39:56 +0000 Subject: [PATCH] perf(files): breadcrumb no longer blocks the listing; ancestors cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every folder navigation awaited rebuildBreadCrumb() — one sequential fetch per ancestor level — before requesting the first page, so a depth-8 folder paid ~8 RTTs of dead time before the content even started loading. And nothing was cached: navigating between sibling folders re-fetched the same ancestors every time. - filesModel: session cache id → FolderItem for breadcrumb resolution (warm navigations rebuild the trail with zero fetches), invalidated on rename/move (single + batch) via invalidateFolderMeta. The trail is now built locally and committed atomically, guarded by a generation token so a rebuild superseded by a faster follow-up navigation can no longer interleave writes into the newer trail. - filesView.loadFiles: the rebuild runs concurrently with the first page fetch; crumbs, history and title update when it resolves. Navigation latency becomes max(listing, ancestor chain) instead of their sum — and ~equal to the listing alone once the cache is warm. The home-folder fallback for inaccessible targets is preserved by reloading the listing when the rebuild had to reset app.currentPath. https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx --- static/js/app/filesView.js | 25 +++++++- static/js/features/files/fileOperations.js | 7 +++ static/js/model/filesModel.js | 69 +++++++++++++++++++--- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 62769d41..8324c36d 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -438,9 +438,17 @@ async function loadFiles(options = { insertHistory: true }) { } } - await rebuildBreadCrumb(); - ui.updateBreadcrumb(); - updateHistory(options.insertHistory ?? true); + // Rebuild the breadcrumb concurrently with the first page fetch — + // the listing does not depend on it, and awaiting the ancestor + // chain first added one round-trip per depth level before the + // content even started loading. Crumbs, history and title update + // when it resolves (skipped when superseded by a newer navigation). + const requestedPath = app.currentPath; + const breadcrumbReady = rebuildBreadCrumb().then((committed) => { + if (!committed) return; + ui.updateBreadcrumb(); + updateHistory(options.insertHistory ?? true); + }); clearTimeout(spinnerTimeout); @@ -457,6 +465,17 @@ async function loadFiles(options = { insertHistory: true }) { // Hand off to _loadPage (re-use cursor/groupBy state just reset above). _loading = false; // _loadPage sets its own guard await _loadPage({ isFirstPage: true }); + await breadcrumbReady; + + // rebuildBreadCrumb falls back to the home folder when the + // requested folder is inaccessible (deleted / permission revoked). + // The old sequential flow got the home listing for free; reload to + // match it. + if (app.currentPath !== requestedPath) { + ui.resetFilesList(); + _nextCursor = null; + await _loadPage({ isFirstPage: true }); + } // Deep-link: open a specific file if requested via app.viewFile. // We don't have a flat file list anymore (cursor pages), so only try diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index 1a26584d..87f7578d 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { notifications } from '../../core/notifications.js'; +import { invalidateFolderMeta } from '../../model/filesModel.js'; import { triggerBrowserDownload } from '../../utils/download.js'; /** @@ -892,6 +893,8 @@ const fileOps = { }); if (response.ok) { + // Parent changed — drop the cached breadcrumb metadata + invalidateFolderMeta(folderId); // Reload files after moving await loadFiles(); ui.showNotification('Folder moved', 'Folder moved successfully'); @@ -955,6 +958,8 @@ const fileOps = { const data = await res.json(); success += data.stats?.successful || 0; errors += data.stats?.failed || 0; + // Parents changed — drop the cached breadcrumb metadata + for (const id of folderIds) invalidateFolderMeta(id); } } catch (err) { console.error('Batch move error:', err); @@ -1147,6 +1152,8 @@ const fileOps = { console.log('Response status:', response.status); if (response.ok) { + // Name changed — drop the cached breadcrumb metadata + invalidateFolderMeta(folderId); ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`); } else { const errorText = await response.text(); diff --git a/static/js/model/filesModel.js b/static/js/model/filesModel.js index f483b5e9..9b6abf96 100644 --- a/static/js/model/filesModel.js +++ b/static/js/model/filesModel.js @@ -33,6 +33,48 @@ async function getFolder(id) { return Promise.reject(null); } +/** + * Session cache of folder metadata for breadcrumb resolution (`id → + * FolderItem`). Ancestors of the current folder have almost always been + * visited already, so a warm navigation rebuilds the whole crumb trail + * with zero fetches instead of one round-trip per depth level. + * Invalidated on rename/move via {@link invalidateFolderMeta}. + * @type {Map} + */ +const _folderMetaCache = new Map(); + +/** + * Resolve breadcrumb metadata for one folder, consulting the session + * cache first. + * @param {string} id + * @returns {Promise} + */ +async function _getFolderMeta(id) { + const cached = _folderMetaCache.get(id); + if (cached) return cached; + const info = await getFolder(id); + _folderMetaCache.set(id, info); + return info; +} + +/** + * Drop the cached breadcrumb metadata for a folder. Call after any + * operation that changes its name or parent (rename, move) so the next + * breadcrumb rebuild re-fetches the fresh row. + * @param {string} folderId + */ +function invalidateFolderMeta(folderId) { + _folderMetaCache.delete(folderId); +} + +/** + * Monotonic token identifying the most recent {@link rebuildBreadCrumb} + * call. Rebuilds now run concurrently with the listing fetch, so a rapid + * second navigation can supersede one still in flight — the superseded + * run must not commit its (stale) trail over the newer one. + */ +let _breadcrumbGeneration = 0; + /** * Walk up the folder hierarchy to rebuild `app.breadcrumbPath`. * @@ -41,29 +83,39 @@ async function getFolder(id) { * handles shared folders the user cannot traverse beyond. * * An error on the target folder itself is treated as a real error and falls - * back to the home folder. + * back to the home folder (resetting `app.currentPath`). * - * @returns {Promise} + * The trail is built locally and committed to `app` atomically at the end, + * and only when this call is still the most recent one — callers run this + * concurrently with the listing fetch. + * + * @returns {Promise} `true` when the trail was committed; `false` + * when this rebuild was superseded by a newer navigation. */ async function rebuildBreadCrumb() { + const generation = ++_breadcrumbGeneration; + /** @type {FolderItem|null} */ let currentFolderInfo = null; - app.breadcrumbPath = []; + /** @type {Array<{id: string, name: string}>} */ + const crumbs = []; /** @type {string|null} */ let id = app.currentPath; while (id !== null) { try { - const folderInfo = await getFolder(id); + const folderInfo = await _getFolderMeta(id); + if (generation !== _breadcrumbGeneration) return false; if (currentFolderInfo === null) currentFolderInfo = folderInfo; - app.breadcrumbPath.unshift({ id: folderInfo.id, name: folderInfo.name }); + crumbs.unshift({ id: folderInfo.id, name: folderInfo.name }); id = folderInfo.parent_id; } catch (_e) { + if (generation !== _breadcrumbGeneration) return false; if (currentFolderInfo === null) { console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); - app.breadcrumbPath = []; + crumbs.length = 0; id = app.userHomeFolderId; if (id) app.currentPath = id; } else { @@ -73,7 +125,10 @@ async function rebuildBreadCrumb() { } } + if (generation !== _breadcrumbGeneration) return false; + app.breadcrumbPath = crumbs; app.currentFolderInfo = currentFolderInfo; + return true; } /** @@ -175,4 +230,4 @@ async function fetchResourcesPage(folderId, { cursor = null, orderBy = 'name', l return { items, nextCursor: data.next_cursor ?? null }; } -export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb }; +export { fetchListing, fetchResourcesPage, getFolder, invalidateFolderMeta, rebuildBreadCrumb };