From 05af819b3ec36335b8781ec3d47fca5b9b45d496 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:03:43 +0300 Subject: [PATCH] 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=), 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 --- frontend/src/routes/files/+page.svelte | 116 ++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index dd72e48..aa0d4bf 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -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 { - const params = baseListParams(); - params.set('cursor', prevCursor); - params.set('direction', 'backward'); - const res = await api.get(`/files?${params}`); - const items = res.items ?? []; + 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(`/files?${params}`); + 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 { + const a = baseListParams(); + a.set('anchor', anchorId); + return api.get(`/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 { - const params = baseListParams(); - if (nextCursor) params.set('cursor', nextCursor); - const res = await api.get(`/files?${params}`); - files = [...files, ...(res.items ?? [])]; - nextCursor = res.next_cursor ?? null; - hasMore = !!res.next_cursor; + 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(`/files?${params}`); + 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;