Fix list performance for large buckets: delimiter-aware shallow listing, cache TTL increase, UI delimiter streaming. header badge shows total bucket objects, fix status bar text concatenation

This commit is contained in:
2026-02-26 16:29:28 +08:00
parent 5bf7962c04
commit 1c328ee3af
9 changed files with 387 additions and 118 deletions

View File

@@ -167,6 +167,8 @@
let pageSize = 5000;
let currentPrefix = '';
let allObjects = [];
let streamFolders = [];
let useDelimiterMode = true;
let urlTemplates = null;
let streamAbortController = null;
let useStreaming = !!objectsStreamUrl;
@@ -186,7 +188,7 @@
let renderedRange = { start: 0, end: 0 };
let memoizedVisibleItems = null;
let memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
let memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
const createObjectRow = (obj, displayKey = null) => {
const tr = document.createElement('tr');
@@ -319,10 +321,13 @@
`;
};
const bucketTotalObjects = objectsContainer ? parseInt(objectsContainer.dataset.bucketTotalObjects || '0', 10) : 0;
const updateObjectCountBadge = () => {
if (!objectCountBadge) return;
if (totalObjectCount === 0) {
objectCountBadge.textContent = '0 objects';
if (useDelimiterMode) {
const total = bucketTotalObjects || totalObjectCount;
objectCountBadge.textContent = `${total.toLocaleString()} object${total !== 1 ? 's' : ''}`;
} else {
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
}
@@ -349,6 +354,7 @@
const computeVisibleItems = (forceRecompute = false) => {
const currentInputs = {
objectCount: allObjects.length,
folderCount: streamFolders.length,
prefix: currentPrefix,
filterTerm: currentFilterTerm,
sortField: currentSortField,
@@ -358,6 +364,7 @@
if (!forceRecompute &&
memoizedVisibleItems !== null &&
memoizedInputs.objectCount === currentInputs.objectCount &&
memoizedInputs.folderCount === currentInputs.folderCount &&
memoizedInputs.prefix === currentInputs.prefix &&
memoizedInputs.filterTerm === currentInputs.filterTerm &&
memoizedInputs.sortField === currentInputs.sortField &&
@@ -366,36 +373,53 @@
}
const items = [];
const folders = new Set();
allObjects.forEach(obj => {
if (!obj.key.startsWith(currentPrefix)) return;
const remainder = obj.key.slice(currentPrefix.length);
if (!remainder) return;
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
const slashIndex = remainder.indexOf('/');
if (slashIndex === -1 && !isFolderMarker) {
if (useDelimiterMode && streamFolders.length > 0) {
streamFolders.forEach(folderPath => {
const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '');
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
}
});
allObjects.forEach(obj => {
const remainder = obj.key.slice(currentPrefix.length);
if (!remainder) return;
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
items.push({ type: 'file', data: obj, displayKey: remainder });
}
} else {
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
? slashIndex
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
const folderName = remainder.slice(0, effectiveSlashIndex);
const folderPath = currentPrefix + folderName + '/';
if (!folders.has(folderPath)) {
folders.add(folderPath);
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
});
} else {
const folders = new Set();
allObjects.forEach(obj => {
if (!obj.key.startsWith(currentPrefix)) return;
const remainder = obj.key.slice(currentPrefix.length);
if (!remainder) return;
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
const slashIndex = remainder.indexOf('/');
if (slashIndex === -1 && !isFolderMarker) {
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
items.push({ type: 'file', data: obj, displayKey: remainder });
}
} else {
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
? slashIndex
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
const folderName = remainder.slice(0, effectiveSlashIndex);
const folderPath = currentPrefix + folderName + '/';
if (!folders.has(folderPath)) {
folders.add(folderPath);
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
}
}
}
}
});
});
}
items.sort((a, b) => {
if (a.type === 'folder' && b.type === 'file') return -1;
@@ -471,7 +495,7 @@
renderedRange = { start: -1, end: -1 };
if (visibleItems.length === 0) {
if (allObjects.length === 0 && !hasMoreObjects) {
if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) {
showEmptyState();
} else {
objectsTableBody.innerHTML = `
@@ -500,15 +524,7 @@
const updateFolderViewStatus = () => {
const folderViewStatusEl = document.getElementById('folder-view-status');
if (!folderViewStatusEl) return;
if (currentPrefix) {
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
const fileCount = visibleItems.filter(i => i.type === 'file').length;
folderViewStatusEl.innerHTML = `<span class="text-muted">${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view</span>`;
folderViewStatusEl.classList.remove('d-none');
} else {
folderViewStatusEl.classList.add('d-none');
}
folderViewStatusEl.classList.add('d-none');
};
const processStreamObject = (obj) => {
@@ -536,21 +552,30 @@
let lastStreamRenderTime = 0;
const STREAM_RENDER_THROTTLE_MS = 500;
const buildBottomStatusText = (complete) => {
if (!complete) {
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
return `${loadedObjectCount.toLocaleString()}${countText} loading...`;
}
const parts = [];
if (useDelimiterMode && streamFolders.length > 0) {
parts.push(`${streamFolders.length.toLocaleString()} folder${streamFolders.length !== 1 ? 's' : ''}`);
}
parts.push(`${loadedObjectCount.toLocaleString()} object${loadedObjectCount !== 1 ? 's' : ''}`);
return parts.join(', ');
};
const flushPendingStreamObjects = () => {
if (pendingStreamObjects.length === 0) return;
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
batch.forEach(obj => {
loadedObjectCount++;
allObjects.push(obj);
});
if (pendingStreamObjects.length > 0) {
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
batch.forEach(obj => {
loadedObjectCount++;
allObjects.push(obj);
});
}
updateObjectCountBadge();
if (loadMoreStatus) {
if (streamingComplete) {
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
} else {
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
}
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
}
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
const loadingText = objectsLoadingRow.querySelector('p');
@@ -585,8 +610,9 @@
loadedObjectCount = 0;
totalObjectCount = 0;
allObjects = [];
streamFolders = [];
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
pendingStreamObjects = [];
lastStreamRenderTime = 0;
@@ -595,6 +621,7 @@
try {
const params = new URLSearchParams();
if (currentPrefix) params.set('prefix', currentPrefix);
if (useDelimiterMode) params.set('delimiter', '/');
const response = await fetch(`${objectsStreamUrl}?${params}`, {
signal: streamAbortController.signal
@@ -639,6 +666,10 @@
if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`;
}
break;
case 'folder':
streamFolders.push(msg.prefix);
scheduleStreamRender();
break;
case 'object':
pendingStreamObjects.push(processStreamObject(msg));
if (pendingStreamObjects.length >= STREAM_RENDER_BATCH) {
@@ -682,7 +713,7 @@
}
if (loadMoreStatus) {
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
loadMoreStatus.textContent = buildBottomStatusText(true);
}
refreshVirtualList();
renderBreadcrumb(currentPrefix);
@@ -710,8 +741,9 @@
loadedObjectCount = 0;
totalObjectCount = 0;
allObjects = [];
streamFolders = [];
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
}
if (append && loadMoreSpinner) {
@@ -913,7 +945,7 @@
});
}
const hasFolders = () => allObjects.some(obj => obj.key.includes('/'));
const hasFolders = () => streamFolders.length > 0 || allObjects.some(obj => obj.key.includes('/'));
const getFoldersAtPrefix = (prefix) => {
const folders = new Set();
@@ -940,6 +972,9 @@
};
const countObjectsInFolder = (folderPrefix) => {
if (useDelimiterMode) {
return { count: 0, mayHaveMore: true };
}
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
return { count, mayHaveMore: hasMoreObjects };
};
@@ -1018,7 +1053,13 @@
const createFolderRow = (folderPath, displayName = null) => {
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
let countLine = '';
if (useDelimiterMode) {
countLine = '';
} else {
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
countLine = `<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>`;
}
const tr = document.createElement('tr');
tr.className = 'folder-row';
@@ -1036,7 +1077,7 @@
</svg>
<span>${escapeHtml(folderName)}/</span>
</div>
<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>
${countLine}
</td>
<td class="text-end text-nowrap">
<span class="text-muted small">—</span>