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
1 file src/popup/views/Vault.tsx+47 −3
src/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 

view file