feat(frontend): cap the Files grid at ~4 viewports with edge windowing
The grid grew without bound as you scrolled (and the section cache then snapshotted the whole thing). It now keeps at most ~4 viewports of rows: once it grows past the cap on one end, loadMore/loadPrev trim the off-screen rows on the other end. The trimmed boundary cursor is dropped and the opposite has-more flag is raised, so scrolling back refills that side from an anchored window (?anchor=<file>), reusing the existing prepend scroll-compensation. This bounds both live memory and the cached snapshot regardless of how deep you scroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -339,14 +339,32 @@
|
||||
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
||||
// so the two never mutate files concurrently.
|
||||
async function loadPrev() {
|
||||
if (loading || !hasPrev || !prevCursor) return;
|
||||
if (loading || !hasPrev) return;
|
||||
loading = true;
|
||||
try {
|
||||
let items: File[];
|
||||
let newPrevCursor: string | null;
|
||||
if (prevCursor) {
|
||||
const params = baseListParams();
|
||||
params.set('cursor', prevCursor);
|
||||
params.set('direction', 'backward');
|
||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||
const items = res.items ?? [];
|
||||
items = res.items ?? [];
|
||||
newPrevCursor = res.prev_cursor ?? null;
|
||||
} else {
|
||||
// The head cursor was dropped when the window trimmed its top. Refetch
|
||||
// the rows just before the current first file from an anchored window.
|
||||
const firstId = files[0]?.id;
|
||||
if (!firstId) {
|
||||
hasPrev = false;
|
||||
return;
|
||||
}
|
||||
const res = await fetchAnchorWindow(firstId);
|
||||
const all = res.items ?? [];
|
||||
const idx = all.findIndex((f) => f.id === firstId);
|
||||
items = idx > 0 ? all.slice(0, idx) : [];
|
||||
newPrevCursor = res.prev_cursor ?? null;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
hasPrev = false;
|
||||
return;
|
||||
@@ -359,11 +377,13 @@
|
||||
const beforeHeight = scroller.scrollHeight;
|
||||
|
||||
files = [...items, ...files];
|
||||
prevCursor = res.prev_cursor ?? null;
|
||||
hasPrev = !!res.prev_cursor;
|
||||
prevCursor = newPrevCursor;
|
||||
hasPrev = !!newPrevCursor;
|
||||
|
||||
flushSync(); // apply the prepend now, before the browser paints
|
||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||
|
||||
trimTail();
|
||||
} catch {
|
||||
hasPrev = false;
|
||||
} finally {
|
||||
@@ -385,17 +405,85 @@
|
||||
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
||||
}
|
||||
|
||||
// ---- Windowing -----------------------------------------------------------
|
||||
// The grid keeps at most ~4 viewports of rows in memory. As it grows past the
|
||||
// cap on one end, the off-screen rows on the other end are trimmed; the cursor
|
||||
// for the trimmed boundary is dropped (set null) and the opposite `has*` flag
|
||||
// is raised, so scrolling back refills that side from an anchored window.
|
||||
|
||||
const CARD_PITCH = 162; // 160px thumbnail + 2px grid gap
|
||||
|
||||
function windowCap(): number {
|
||||
const scroller = getScroller();
|
||||
const w = scroller.clientWidth || 390;
|
||||
const h = scroller.clientHeight || 700;
|
||||
const cols = Math.max(1, Math.floor(w / CARD_PITCH));
|
||||
const rows = Math.max(1, Math.ceil(h / CARD_PITCH));
|
||||
return Math.max(4 * cols * rows, 2 * LIMIT);
|
||||
}
|
||||
|
||||
// Fetch a window centred on a file (with its boundary cursors), used to refill
|
||||
// a trimmed edge where the original cursor is no longer held.
|
||||
function fetchAnchorWindow(anchorId: string): Promise<FileCursorPage> {
|
||||
const a = baseListParams();
|
||||
a.set('anchor', anchorId);
|
||||
return api.get<FileCursorPage>(`/files?${a}`);
|
||||
}
|
||||
|
||||
// Drop the off-screen rows above the viewport once the grid grew past the cap.
|
||||
// Run after appended rows have painted so the height delta measures only the
|
||||
// removed top; scroll is compensated so the visible rows don't jump.
|
||||
function trimHead() {
|
||||
const cap = windowCap();
|
||||
if (files.length <= cap) return;
|
||||
flushSync(); // paint the just-appended (below-fold) rows before measuring
|
||||
const scroller = getScroller();
|
||||
const beforeTop = scroller.scrollTop;
|
||||
const beforeHeight = scroller.scrollHeight;
|
||||
files = files.slice(files.length - cap);
|
||||
prevCursor = null;
|
||||
hasPrev = true;
|
||||
flushSync();
|
||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||
}
|
||||
|
||||
// Symmetric to trimHead for upward growth: drop the off-screen rows below the
|
||||
// viewport. No scroll compensation — the removed rows are past the fold.
|
||||
function trimTail() {
|
||||
const cap = windowCap();
|
||||
if (files.length <= cap) return;
|
||||
files = files.slice(0, cap);
|
||||
nextCursor = null;
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
let newItems: File[];
|
||||
let newNextCursor: string | null;
|
||||
if (nextCursor == null && files.length > 0) {
|
||||
// The tail cursor was dropped when the window trimmed its bottom.
|
||||
// Refetch the rows after the current last file from an anchored window.
|
||||
const lastId = files[files.length - 1]?.id;
|
||||
const res = await fetchAnchorWindow(lastId!);
|
||||
const all = res.items ?? [];
|
||||
const idx = all.findIndex((f) => f.id === lastId);
|
||||
newItems = idx >= 0 ? all.slice(idx + 1) : [];
|
||||
newNextCursor = res.next_cursor ?? null;
|
||||
} else {
|
||||
const params = baseListParams();
|
||||
if (nextCursor) params.set('cursor', nextCursor);
|
||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||
files = [...files, ...(res.items ?? [])];
|
||||
nextCursor = res.next_cursor ?? null;
|
||||
hasMore = !!res.next_cursor;
|
||||
newItems = res.items ?? [];
|
||||
newNextCursor = res.next_cursor ?? null;
|
||||
}
|
||||
files = [...files, ...newItems];
|
||||
nextCursor = newNextCursor;
|
||||
hasMore = !!newNextCursor;
|
||||
trimHead();
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||
hasMore = false;
|
||||
|
||||
Reference in New Issue
Block a user