Vault.tsx

30.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl, faviconImgSig, globeFaviconSig } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }9}1011function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {12  const src = props.domain && !props.isNote && !props.isPasskey13    ? faviconUrl(props.domain)14    : null;15  const [isReal, setIsReal] = createSignal(false);1617  // Show InitialsChip (or passkey/note icon) first — no globe flash.18  // Load the favicon invisibly in the background; switch to it only after19  // confirming it differs from Chrome's default "no favicon" globe image.20  return (21    <Show22      when={isReal()}23      fallback={24        <>25          {props.isPasskey26            ? <PasskeyIcon />27            : props.isNote28              ? <NoteIcon />29              : <InitialsChip title={props.title ?? props.domain ?? ""} />}30          {src && (31            <img32              src={src}33              style="display:none"34              onError={() => {}}35              onLoad={async (e) => {36                const img = e.currentTarget as HTMLImageElement;37                // Old Chrome returns the globe at native 16×16 even when38                // size=32 is requested — treat as no favicon.39                if (img.naturalWidth <= 16) return;40                // New Chrome returns the globe scaled to the requested size.41                // Compare pixel data against the known "no favicon" reference.42                const sig = faviconImgSig(img);43                if (sig !== null) {44                  const gSig = await globeFaviconSig();45                  if (gSig !== null && sig === gSig) return;46                }47                setIsReal(true);48              }}49            />50          )}51        </>52      }53    >54      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />55    </Show>56  );57}5859/**60 * Title → "PM"-style initials. First letters of the first two words,61 * falling back to the first two characters of a single-word title. Empty62 * titles get a small dot. Deterministic so the same entry always renders63 * the same chip.64 */65function titleInitials(source: string): string {66  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();67  if (!cleaned) return "·";68  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);69  if (words.length >= 2 && words[0]![0] && words[1]![0]) {70    return (words[0]![0]! + words[1]![0]!).toUpperCase();71  }72  const single = words[0] ?? cleaned;73  return single.slice(0, 2).toUpperCase();74}7576/**77 * Hash a string to an HSL background. Fixed saturation + lightness so the78 * palette stays muted and dark enough that white initials read clearly on79 * top, while still giving each entry a recognisable colour signature.80 */81function titleBackground(source: string): string {82  let hash = 0;83  for (let i = 0; i < source.length; i++) {84    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;85  }86  const hue = ((hash % 360) + 360) % 360;87  return `hsl(${hue}, 32%, 26%)`;88}8990function InitialsChip(props: { title: string }) {91  return (92    <span93      class="peach-favicon-initials"94      style={{ background: titleBackground(props.title) }}95      aria-hidden="true"96    >97      {titleInitials(props.title)}98    </span>99  );100}101102103const PasskeyIcon = () => (104  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">105    <circle cx="4" cy="4.5" r="3" />106    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />107  </svg>108);109110const NoteIcon = () => (111  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">112    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />113    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />114  </svg>115);116117export type SortOrder = "url-first" | "az" | "za" | "recent";118119interface VaultProps {120  entries: VaultEntry[];121  selectedEntryId?: string;122  currentTabDomain?: string;123  sortOrder: SortOrder;124  searchSeed?: string;125  searchSeedNonce?: number;126  // When true, render the mobile flat top bar: no back arrow (use swipe-down127  // on the bottom sheet), search-glass icon on the left, counter on the128  // right. Matches the extension's `.peach-search-bar` look.129  isNative?: boolean;130  onSelectEntry: (entry: VaultEntry) => void;131  onSortOrderChange: (order: SortOrder) => void;132  onAdd: () => void;133  onClose: () => void;134  onOpenTrash?: () => void;135  onDeleteMany?: (ids: string[]) => Promise<void>;136}137138export function VaultView(props: VaultProps) {139  const [search, setSearch] = createSignal("");140  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());141  const [notesOnly, setNotesOnly] = createSignal(false);142  const [selecting, setSelecting] = createSignal(false);143  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());144  const [deleting, setDeleting] = createSignal(false);145  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);146  const [flashId, setFlashId] = createSignal<string | null>(null);147  const [scrollTop, setScrollTop] = createSignal(0);148  const [viewportHeight, setViewportHeight] = createSignal(600);149  let longPressTimer: number | null = null;150  let touchStart: { x: number; y: number; t: number } | null = null;151  let suppressNextClick = false;152153  function clearLongPressTimer() {154    if (longPressTimer !== null) {155      window.clearTimeout(longPressTimer);156      longPressTimer = null;157    }158  }159160  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {161    if (selecting()) return;162    const t = e.touches[0];163    if (!t) return;164    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };165    clearLongPressTimer();166    longPressTimer = window.setTimeout(() => {167      suppressNextClick = true;168      vibrate(20);169      setContextEntry(entry);170      longPressTimer = null;171    }, 500);172  }173174  function handleRowTouchMove(e: TouchEvent) {175    if (!touchStart) return;176    const t = e.touches[0];177    if (!t) return;178    const dx = Math.abs(t.clientX - touchStart.x);179    const dy = Math.abs(t.clientY - touchStart.y);180    if (dx > 8 || dy > 8) clearLongPressTimer();181  }182183  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {184    clearLongPressTimer();185    if (!touchStart) return;186    const t = e.changedTouches[0];187    if (!t) { touchStart = null; return; }188    const dx = t.clientX - touchStart.x;189    const dy = Math.abs(t.clientY - touchStart.y);190    const dt = Date.now() - touchStart.t;191    touchStart = null;192    // Right-swipe-to-copy: only on compact + login entries with a password193    const isCompact = !document.body.classList.contains("layout-medium")194      && !document.body.classList.contains("layout-expanded");195    if (196      isCompact197      && !selecting()198      && dx > 80199      && dy < 30200      && dt < 400201      && entry.type === "login"202      && entry.password203    ) {204      suppressNextClick = true;205      vibrate(15);206      setFlashId(entry.id);207      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);208      await popupCopy(entry.password, "password");209    }210  }211212  let searchRef: HTMLInputElement | undefined;213  let listRef: HTMLDivElement | undefined;214215  const focusSearch = (el: HTMLInputElement) => {216    searchRef = el;217    setTimeout(() => el.focus(), 50);218  };219220  createEffect(() => {221    void props.searchSeedNonce;222    const seed = props.searchSeed;223    if (!seed) return;224    setSearch(seed);225    requestAnimationFrame(() => {226      searchRef?.focus();227      searchRef?.setSelectionRange(seed.length, seed.length);228    });229  });230231  function enterSelectMode() {232    setSelecting(true);233    setSelectedIds(new Set<string>());234  }235236  function exitSelectMode() {237    setSelecting(false);238    setSelectedIds(new Set<string>());239  }240241  function toggleId(id: string) {242    const s = new Set<string>(selectedIds());243    if (s.has(id)) s.delete(id); else s.add(id);244    setSelectedIds(s);245  }246247  const allTags = createMemo(() => {248    const tagSet = new Set<string>();249    for (const entry of props.entries) {250      for (const tag of entry.tags ?? []) {251        tagSet.add(tag);252      }253    }254    return Array.from(tagSet).sort();255  });256257  const hasTags = createMemo(() => allTags().length > 0);258259  function toggleTag(tag: string) {260    const current = new Set(selectedTags());261    if (current.has(tag)) {262      current.delete(tag);263    } else {264      current.add(tag);265    }266    setSelectedTags(current);267  }268269  function fuzzyMatch(query: string, target: string): boolean {270    query = query.toLowerCase();271    target = target.toLowerCase();272    let qi = 0;273    for (let i = 0; i < target.length && qi < query.length; i++) {274      if (target[i] === query[qi]) qi++;275    }276    return qi === query.length;277  }278279  function isExactPrefix(query: string, target: string): boolean {280    return target.toLowerCase().startsWith(query.toLowerCase());281  }282283  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {284    if (!domain) return false;285    if (entry.passkey?.rpId === domain) return true;286    if (entry.type !== "login") return false;287    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of288    // each entry URL too so subdomain entries also match.289    if (entry.url && extractDomain(entry.url) === domain) return true;290    for (const extra of entry.additionalUrls ?? []) {291      if (extractDomain(extra) === domain) return true;292    }293    return false;294  }295296  const filteredEntries = createMemo(() => {297    let result = props.entries;298299    if (notesOnly()) {300      result = result.filter(entry => entry.type === "note");301    }302303    if (selectedTags().size > 0) {304      result = result.filter(entry =>305        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))306      );307    }308309    const q = search().trim();310    if (q) {311      const searchableFor = (e: VaultEntry): string => {312        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;313        const passkeyText = e.passkey314          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`315          : "";316        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;317      };318      result = result319        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))320        .sort((a, b) => {321          const aSearchable = searchableFor(a).toLowerCase();322          const bSearchable = searchableFor(b).toLowerCase();323          const qLower = q.toLowerCase();324          const aExact = isExactPrefix(qLower, aSearchable);325          const bExact = isExactPrefix(qLower, bSearchable);326          if (aExact && !bExact) return -1;327          if (!aExact && bExact) return 1;328          return 0;329        });330      return result;331    }332333    const tabDomain = props.currentTabDomain ?? "";334    const order = props.sortOrder;335336    if (order === "az") {337      return [...result].sort((a, b) => a.title.localeCompare(b.title));338    }339    if (order === "za") {340      return [...result].sort((a, b) => b.title.localeCompare(a.title));341    }342    if (order === "recent") {343      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));344    }345    if (tabDomain) {346      return [...result].sort((a, b) => {347        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;348        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;349        return aMatch - bMatch;350      });351    }352    return result;353  });354355  // Declaration order matters: `allVisibleIds` and `allSelected` read356  // `filteredEntries` eagerly on component mount via createMemo. Moving357  // `filteredEntries` below them throws ReferenceError under the production358  // minifier and freezes the vault list on mount (v1.4.34 regression).359  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));360  const allSelected = createMemo(() =>361    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))362  );363364  function toggleSelectAll() {365    if (allSelected()) {366      setSelectedIds(new Set<string>());367    } else {368      setSelectedIds(new Set<string>(allVisibleIds()));369    }370  }371372  async function handleDeleteSelected() {373    const ids = Array.from(selectedIds());374    if (!ids.length || !props.onDeleteMany) return;375    setDeleting(true);376    await props.onDeleteMany(ids);377    setDeleting(false);378    exitSelectMode();379  }380381  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.382  // Above it, we render only a window around the visible viewport plus a383  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty384  // space. Row height is approximate (variable padding for badges) but385  // bounded — the buffer absorbs over/undershoot.386  const VIRTUAL_THRESHOLD = 100;387  const ROW_HEIGHT = 56;388  const BUFFER_ROWS = 20;389390  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);391392  const visibleRange = createMemo(() => {393    const total = filteredEntries().length;394    if (!isVirtualized()) return { start: 0, end: total };395    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);396    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);397    return { start, end };398  });399400  const windowedEntries = createMemo(() => {401    const { start, end } = visibleRange();402    return filteredEntries().slice(start, end);403  });404405  function handleListScroll(e: Event) {406    if (!isVirtualized()) return;407    const target = e.currentTarget as HTMLDivElement;408    setScrollTop(target.scrollTop);409    setViewportHeight(target.clientHeight);410  }411412  function handleSearchKeydown(e: KeyboardEvent) {413    if (e.key === "ArrowDown") {414      e.preventDefault();415      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");416      firstBtn?.focus();417    } else if (e.key === "ArrowUp") {418      e.preventDefault();419      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");420      allBtns?.[allBtns.length - 1]?.focus();421    } else if (e.key === "Enter") {422      e.preventDefault();423      const firstEntry = filteredEntries()[0];424      if (firstEntry) props.onSelectEntry(firstEntry);425    } else if (e.key === "Escape" && selecting()) {426      exitSelectMode();427    }428  }429430  function handleListKeydown(e: KeyboardEvent) {431    if (!listRef) return;432    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));433    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);434    if (e.key === "ArrowDown") {435      e.preventDefault();436      if (idx < btns.length - 1) btns[idx + 1]?.focus();437    } else if (e.key === "ArrowUp") {438      e.preventDefault();439      if (idx === 0) { searchRef?.focus(); }440      else btns[idx - 1]?.focus();441    } else if (e.key === "Escape") {442      if (selecting()) exitSelectMode();443      else props.onClose();444    }445  }446447  const sortLabels: { key: SortOrder; label: string }[] = [448    { key: "url-first", label: "smart" },449    { key: "az", label: "A–Z" },450    { key: "za", label: "Z–A" },451    { key: "recent", label: "newest" },452  ];453454  return (455    <div class="peach-overlay-view ov-enter-up" style="display:flex">456      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>457        <Show458          when={!props.isNative}459          fallback={460            <span class="peach-search-icon" aria-hidden="true">461              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">462                <circle cx="5.5" cy="5.5" r="3.75" />463                <path d="M8.75 8.75L11.5 11.5" />464              </svg>465            </span>466          }467        >468          <button469            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"470            onClick={props.onClose}471            aria-label="Back"472          >473            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">474              <path d="M6 1L1 6L6 11" />475            </svg>476          </button>477        </Show>478        <input479          class="peach-search-input"480          placeholder="search vault..."481          autocomplete="off"482          style={props.isNative ? undefined : "font-size:14px"}483          ref={focusSearch}484          value={search()}485          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}486          onKeyDown={handleSearchKeydown}487        />488        <span class="peach-search-counter peach-search-counter-list">489          {search() || selectedTags().size > 0490            ? `${filteredEntries().length} of ${props.entries.length}`491            : props.isNative492              ? `${props.entries.length}`493              : `${props.entries.length} entries`}494        </span>495      </div>496497      {/* Sort bar — morphs into select-mode controls */}498      <div class="peach-sort-bar">499        <Show500          when={selecting()}501          fallback={502            <>503              <For each={sortLabels}>504                {({ key, label }) => (505                  <button506                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}507                    onClick={() => props.onSortOrderChange(key)}508                  >509                    {label}510                  </button>511                )}512              </For>513              <span style="flex:1" />514              <button515                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}516                onClick={() => setNotesOnly(v => !v)}517                aria-label="Notes only"518                title="Show notes only"519              >520                <NoteIcon />521              </button>522              <Show when={props.onOpenTrash}>523                <button524                  class="peach-sort-trash-btn"525                  onClick={props.onOpenTrash}526                  aria-label="View trash"527                  title="View deleted entries"528                >529                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">530                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />531                  </svg>532                </button>533              </Show>534              <Show when={props.onDeleteMany}>535                <button536                  class="peach-sort-trash-btn"537                  onClick={enterSelectMode}538                  aria-label="Select entries"539                  title="Select entries"540                >541                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">542                    <circle cx="4" cy="4" r="2.5" />543                    <circle cx="9" cy="4" r="2.5" />544                    <circle cx="4" cy="9" r="2.5" />545                    <circle cx="9" cy="9" r="2.5" />546                  </svg>547                </button>548              </Show>549            </>550          }551        >552          {/* Select mode sort bar */}553          <button554            class="peach-select-cancel-btn"555            onClick={exitSelectMode}556          >557            cancel558          </button>559          <span class="peach-select-count-label">560            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}561          </span>562          <button563            class="peach-select-all-btn"564            onClick={toggleSelectAll}565          >566            {allSelected() ? "none" : "all"}567          </button>568        </Show>569      </div>570571      <Show when={hasTags() && !selecting()}>572        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">573          <For each={allTags()}>574            {(tag) => (575              <button576                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}577                onClick={() => toggleTag(tag)}578              >579                {tag}580              </button>581            )}582          </For>583        </div>584      </Show>585586      <div587        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}588        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}589        onKeyDown={handleListKeydown}590        onScroll={handleListScroll}591      >592        <Show593          when={filteredEntries().length > 0}594          fallback={595            <div class="peach-list-empty">596              <span>no entries yet</span>597            </div>598          }599        >600          <Show when={isVirtualized()}>601            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />602          </Show>603          <For each={windowedEntries()}>604            {(entry, localIndex) => {605              const index = () => visibleRange().start + localIndex();606              const isNote = entry.type === "note";607              const isPasskey = entry.passkey !== undefined;608              const domain = entry.type === "note"609                ? (entry.notes?.slice(0, 40) ?? "note")610                : entry.url611                  ? getDisplayDomain(entry.url)612                  : entry.passkey?.rpId613                    ? entry.passkey.rpId614                  : "saved entry";615              const isTabMatch = () =>616                props.currentTabDomain !== undefined &&617                props.currentTabDomain !== "" &&618                entryMatchesDomain(entry, props.currentTabDomain);619              const isSelected = () => selectedIds().has(entry.id);620621              const ariaLabel = (() => {622                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";623                const sub = entry.username || domain || "";624                return sub625                  ? `${entry.title} — ${sub} (${tag})`626                  : `${entry.title} (${tag})`;627              })();628              return (629                <button630                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}631                  style={isVirtualized()632                    ? { animation: "none" }633                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }634                  }635                  aria-label={ariaLabel}636                  aria-pressed={selecting() ? isSelected() : undefined}637                  onTouchStart={(e) => handleRowTouchStart(entry, e)}638                  onTouchMove={handleRowTouchMove}639                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}640                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}641                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}642                  onClick={() => {643                    if (suppressNextClick) { suppressNextClick = false; return; }644                    if (selecting()) {645                      toggleId(entry.id);646                    } else {647                      props.onSelectEntry(entry);648                    }649                  }}650                >651                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>652                    <Show653                      when={selecting()}654                      fallback={655                        <FaviconIcon656                          domain={entry.url ? getDisplayDomain(entry.url) : ""}657                          title={entry.title}658                          isNote={isNote}659                          isPasskey={isPasskey}660                        />661                      }662                    >663                      {/* Check indicator */}664                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">665                        <circle666                          cx="8" cy="8" r="7"667                          stroke="currentColor"668                          stroke-width="1"669                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}670                          opacity={isSelected() ? "1" : "0.3"}671                        />672                        <Show when={isSelected()}>673                          <path674                            d="M5 8.5L7 10.5L11 6"675                            stroke="white"676                            stroke-width="1.2"677                            stroke-linecap="round"678                            stroke-linejoin="round"679                          />680                        </Show>681                      </svg>682                    </Show>683                  </div>684                  <div class="peach-entry-row-info">685                    <span class="peach-entry-row-name">686                      {entry.title}687                      <Show when={isPasskey && !selecting()}>688                        <span class="peach-passkey-badge">passkey</span>689                      </Show>690                      <Show when={isTabMatch() && !selecting()}>691                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>692                      </Show>693                    </span>694                    <span class="peach-entry-row-meta">{domain}</span>695                  </div>696                  <Show when={entry.breach && !selecting()}>697                    <span class="peach-entry-row-dot" aria-label="breach"></span>698                  </Show>699                  <Show when={entry.canary && !selecting()}>700                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>701                  </Show>702                </button>703              );704            }}705          </For>706          <Show when={isVirtualized()}>707            <div708              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}709              aria-hidden="true"710            />711          </Show>712        </Show>713      </div>714715      {/* Bottom bar — new entry normally, delete action in select mode */}716      <Show717        when={selecting()}718        fallback={719          <button class="peach-new-entry-row" onClick={props.onAdd}>720            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">721              <path d="M5 1v8M1 5h8" />722            </svg>723            new entry724          </button>725        }726      >727        <button728          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}729          disabled={selectedIds().size === 0 || deleting()}730          onClick={() => void handleDeleteSelected()}731        >732          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">733            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />734          </svg>735          <Show736            when={selectedIds().size > 0}737            fallback={<span>select entries to delete</span>}738          >739            <span>740              {deleting()741                ? "moving to trash…"742                : `move ${selectedIds().size} to trash`}743            </span>744          </Show>745        </button>746      </Show>747748      <Show when={contextEntry() !== null}>749        <div750          class="peach-context-menu-backdrop"751          onClick={() => setContextEntry(null)}752          role="presentation"753        >754          <div755            class="peach-context-menu"756            role="menu"757            aria-label="Entry actions"758            onClick={(e) => e.stopPropagation()}759          >760            <Show when={contextEntry()!.username}>761              <button762                role="menuitem"763                class="peach-context-menu-item"764                onClick={async () => {765                  const e = contextEntry();766                  if (!e?.username) return;767                  await popupCopy(e.username, "username");768                  setContextEntry(null);769                }}770              >771                Copy username772              </button>773            </Show>774            <Show when={contextEntry()!.password}>775              <button776                role="menuitem"777                class="peach-context-menu-item"778                onClick={async () => {779                  const e = contextEntry();780                  if (!e?.password) return;781                  await popupCopy(e.password, "password");782                  setContextEntry(null);783                }}784              >785                Copy password786              </button>787            </Show>788            <Show when={contextEntry()!.totp}>789              <button790                role="menuitem"791                class="peach-context-menu-item"792                onClick={async () => {793                  const e = contextEntry();794                  if (!e?.totp) return;795                  await popupCopy(e.totp, "TOTP secret");796                  setContextEntry(null);797                }}798              >799                Copy TOTP secret800              </button>801            </Show>802            <button803              role="menuitem"804              class="peach-context-menu-item"805              onClick={() => {806                const e = contextEntry();807                if (e) props.onSelectEntry(e);808                setContextEntry(null);809              }}810            >811              Open812            </button>813            <Show when={props.onDeleteMany}>814              <button815                role="menuitem"816                class="peach-context-menu-item peach-context-menu-item--danger"817                onClick={async () => {818                  const e = contextEntry();819                  if (!e) return;820                  setContextEntry(null);821                  await props.onDeleteMany?.([e.id]);822                }}823              >824                Move to trash825              </button>826            </Show>827            <button828              role="menuitem"829              class="peach-context-menu-item peach-context-menu-item--cancel"830              onClick={() => setContextEntry(null)}831            >832              Cancel833            </button>834          </div>835        </div>836      </Show>837    </div>838  );839}