perf(files): breadcrumb no longer blocks the listing; ancestors cached
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
This commit is contained in:
@@ -438,9 +438,17 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildBreadCrumb();
|
// Rebuild the breadcrumb concurrently with the first page fetch —
|
||||||
ui.updateBreadcrumb();
|
// the listing does not depend on it, and awaiting the ancestor
|
||||||
updateHistory(options.insertHistory ?? true);
|
// 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);
|
clearTimeout(spinnerTimeout);
|
||||||
|
|
||||||
@@ -457,6 +465,17 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||||||
_loading = false; // _loadPage sets its own guard
|
_loading = false; // _loadPage sets its own guard
|
||||||
await _loadPage({ isFirstPage: true });
|
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.
|
// 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
|
// We don't have a flat file list anymore (cursor pages), so only try
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
|
|||||||
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { notifications } from '../../core/notifications.js';
|
import { notifications } from '../../core/notifications.js';
|
||||||
|
import { invalidateFolderMeta } from '../../model/filesModel.js';
|
||||||
import { triggerBrowserDownload } from '../../utils/download.js';
|
import { triggerBrowserDownload } from '../../utils/download.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -892,6 +893,8 @@ const fileOps = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
// Parent changed — drop the cached breadcrumb metadata
|
||||||
|
invalidateFolderMeta(folderId);
|
||||||
// Reload files after moving
|
// Reload files after moving
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
ui.showNotification('Folder moved', 'Folder moved successfully');
|
ui.showNotification('Folder moved', 'Folder moved successfully');
|
||||||
@@ -955,6 +958,8 @@ const fileOps = {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
success += data.stats?.successful || 0;
|
success += data.stats?.successful || 0;
|
||||||
errors += data.stats?.failed || 0;
|
errors += data.stats?.failed || 0;
|
||||||
|
// Parents changed — drop the cached breadcrumb metadata
|
||||||
|
for (const id of folderIds) invalidateFolderMeta(id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Batch move error:', err);
|
console.error('Batch move error:', err);
|
||||||
@@ -1147,6 +1152,8 @@ const fileOps = {
|
|||||||
console.log('Response status:', response.status);
|
console.log('Response status:', response.status);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
// Name changed — drop the cached breadcrumb metadata
|
||||||
|
invalidateFolderMeta(folderId);
|
||||||
ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`);
|
ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`);
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|||||||
@@ -33,6 +33,48 @@ async function getFolder(id) {
|
|||||||
return Promise.reject(null);
|
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<string, FolderItem>}
|
||||||
|
*/
|
||||||
|
const _folderMetaCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve breadcrumb metadata for one folder, consulting the session
|
||||||
|
* cache first.
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<FolderItem>}
|
||||||
|
*/
|
||||||
|
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`.
|
* 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.
|
* handles shared folders the user cannot traverse beyond.
|
||||||
*
|
*
|
||||||
* An error on the target folder itself is treated as a real error and falls
|
* 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<void>}
|
* 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<boolean>} `true` when the trail was committed; `false`
|
||||||
|
* when this rebuild was superseded by a newer navigation.
|
||||||
*/
|
*/
|
||||||
async function rebuildBreadCrumb() {
|
async function rebuildBreadCrumb() {
|
||||||
|
const generation = ++_breadcrumbGeneration;
|
||||||
|
|
||||||
/** @type {FolderItem|null} */
|
/** @type {FolderItem|null} */
|
||||||
let currentFolderInfo = null;
|
let currentFolderInfo = null;
|
||||||
app.breadcrumbPath = [];
|
/** @type {Array<{id: string, name: string}>} */
|
||||||
|
const crumbs = [];
|
||||||
|
|
||||||
/** @type {string|null} */
|
/** @type {string|null} */
|
||||||
let id = app.currentPath;
|
let id = app.currentPath;
|
||||||
|
|
||||||
while (id !== null) {
|
while (id !== null) {
|
||||||
try {
|
try {
|
||||||
const folderInfo = await getFolder(id);
|
const folderInfo = await _getFolderMeta(id);
|
||||||
|
if (generation !== _breadcrumbGeneration) return false;
|
||||||
if (currentFolderInfo === null) currentFolderInfo = folderInfo;
|
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;
|
id = folderInfo.parent_id;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
if (generation !== _breadcrumbGeneration) return false;
|
||||||
if (currentFolderInfo === null) {
|
if (currentFolderInfo === null) {
|
||||||
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
|
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');
|
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;
|
id = app.userHomeFolderId;
|
||||||
if (id) app.currentPath = id;
|
if (id) app.currentPath = id;
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +125,10 @@ async function rebuildBreadCrumb() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== _breadcrumbGeneration) return false;
|
||||||
|
app.breadcrumbPath = crumbs;
|
||||||
app.currentFolderInfo = currentFolderInfo;
|
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 };
|
return { items, nextCursor: data.next_cursor ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb };
|
export { fetchListing, fetchResourcesPage, getFolder, invalidateFolderMeta, rebuildBreadCrumb };
|
||||||
|
|||||||
Reference in New Issue
Block a user