96bcd2845cab
vault virtualization: windowing on >100 entries with top/bottom spacers, ROW_HEIGHT=56, 8-row buffer; scroll listener computes visible slice from filteredEntries
- time
- 2026-05-01 04:29 UTC · 5 months ago
- machine
- koh2
- parent
- d31f384002e3
↓ peach-browser-96bcd284.tar.gzatlas at this save
files changed1 file
src/popup/views/Vault.tsx+47 −3src/popup/views/Vault.tsx+47 −3
@@ -126,6 +126,8 @@126126 const [deleting, setDeleting] = createSignal(false);127127 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128128 const [flashId, setFlashId] = createSignal<string | null>(null);129+ const [scrollTop, setScrollTop] = createSignal(0);130+ const [viewportHeight, setViewportHeight] = createSignal(600);129131 let longPressTimer: number | null = null;130132 let touchStart: { x: number; y: number; t: number } | null = null;131133 let suppressNextClick = false;@@ -364,6 +366,37 @@364366 exitSelectMode();365367 }366368 369+ // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.370+ // Above it, we render only a window around the visible viewport plus a371+ // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty372+ // space. Row height is approximate (variable padding for badges) but373+ // bounded — the buffer absorbs over/undershoot.374+ const VIRTUAL_THRESHOLD = 100;375+ const ROW_HEIGHT = 56;376+ const BUFFER_ROWS = 8;377+378+ const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);379+380+ const visibleRange = createMemo(() => {381+ const total = filteredEntries().length;382+ if (!isVirtualized()) return { start: 0, end: total };383+ const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);384+ const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);385+ return { start, end };386+ });387+388+ const windowedEntries = createMemo(() => {389+ const { start, end } = visibleRange();390+ return filteredEntries().slice(start, end);391+ });392+393+ function handleListScroll(e: Event) {394+ if (!isVirtualized()) return;395+ const target = e.currentTarget as HTMLDivElement;396+ setScrollTop(target.scrollTop);397+ setViewportHeight(target.clientHeight);398+ }399+367400 function handleSearchKeydown(e: KeyboardEvent) {368401 if (e.key === "ArrowDown") {369402 e.preventDefault();@@ -526,8 +559,9 @@526559 527560 <div528561 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}529- ref={listRef}562+ ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}530563 onKeyDown={handleListKeydown}564+ onScroll={handleListScroll}531565 >532566 <Show533567 when={filteredEntries().length > 0}@@ -537,8 +571,12 @@537571 </div>538572 }539573 >540- <For each={filteredEntries()}>541- {(entry, index) => {574+ <Show when={isVirtualized()}>575+ <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />576+ </Show>577+ <For each={windowedEntries()}>578+ {(entry, localIndex) => {579+ const index = () => visibleRange().start + localIndex();542580 const isNote = entry.type === "note";543581 const isPasskey = entry.passkey !== undefined;544582 const domain = entry.type === "note"@@ -636,6 +674,12 @@636674 );637675 }}638676 </For>677+ <Show when={isVirtualized()}>678+ <div679+ style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}680+ aria-hidden="true"681+ />682+ </Show>639683 </Show>640684 </div>641685