Vault.tsx

29.7 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 } 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  // Note/passkey entries keep their semantic glyphs. Login entries get a16  // colored-initials chip when the real favicon hasn't loaded — better than17  // a black square on freshly-imported entries the browser hasn't visited18  // yet (Chrome's `_favicon/` cache is empty for unvisited domains).19  const fallback = props.isPasskey20    ? <PasskeyIcon />21    : props.isNote22      ? <NoteIcon />23      : <InitialsChip title={props.title ?? props.domain ?? ""} />;24  const [failed, setFailed] = createSignal(false);25  return (26    <Show when={src && !failed()} fallback={fallback}>27      <img28        src={src!}29        width="16"30        height="16"31        alt=""32        style="border-radius:3px;"33        onError={() => setFailed(true)}34        onLoad={(e) => {35          // Chrome's _favicon/ API returns the default globe at its native36          // 16×16 even when size=32 is requested. A real cached favicon37          // arrives at ≥32px naturally. Treat ≤16 as "no favicon" so the38          // InitialsChip renders instead of the globe.39          if ((e.currentTarget as HTMLImageElement).naturalWidth <= 16) setFailed(true);40        }}41      />42    </Show>43  );44}4546/**47 * Title → "PM"-style initials. First letters of the first two words,48 * falling back to the first two characters of a single-word title. Empty49 * titles get a small dot. Deterministic so the same entry always renders50 * the same chip.51 */52function titleInitials(source: string): string {53  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();54  if (!cleaned) return "·";55  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);56  if (words.length >= 2 && words[0]![0] && words[1]![0]) {57    return (words[0]![0]! + words[1]![0]!).toUpperCase();58  }59  const single = words[0] ?? cleaned;60  return single.slice(0, 2).toUpperCase();61}6263/**64 * Hash a string to an HSL background. Fixed saturation + lightness so the65 * palette stays muted and dark enough that white initials read clearly on66 * top, while still giving each entry a recognisable colour signature.67 */68function titleBackground(source: string): string {69  let hash = 0;70  for (let i = 0; i < source.length; i++) {71    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;72  }73  const hue = ((hash % 360) + 360) % 360;74  return `hsl(${hue}, 32%, 26%)`;75}7677function InitialsChip(props: { title: string }) {78  return (79    <span80      class="peach-favicon-initials"81      style={{ background: titleBackground(props.title) }}82      aria-hidden="true"83    >84      {titleInitials(props.title)}85    </span>86  );87}888990const PasskeyIcon = () => (91  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">92    <circle cx="4" cy="4.5" r="3" />93    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />94  </svg>95);9697const NoteIcon = () => (98  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">99    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />100    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />101  </svg>102);103104export type SortOrder = "url-first" | "az" | "za" | "recent";105106interface VaultProps {107  entries: VaultEntry[];108  selectedEntryId?: string;109  currentTabDomain?: string;110  sortOrder: SortOrder;111  searchSeed?: string;112  searchSeedNonce?: number;113  // When true, render the mobile flat top bar: no back arrow (use swipe-down114  // on the bottom sheet), search-glass icon on the left, counter on the115  // right. Matches the extension's `.peach-search-bar` look.116  isNative?: boolean;117  onSelectEntry: (entry: VaultEntry) => void;118  onSortOrderChange: (order: SortOrder) => void;119  onAdd: () => void;120  onClose: () => void;121  onOpenTrash?: () => void;122  onDeleteMany?: (ids: string[]) => Promise<void>;123}124125export function VaultView(props: VaultProps) {126  const [search, setSearch] = createSignal("");127  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());128  const [notesOnly, setNotesOnly] = createSignal(false);129  const [selecting, setSelecting] = createSignal(false);130  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());131  const [deleting, setDeleting] = createSignal(false);132  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);133  const [flashId, setFlashId] = createSignal<string | null>(null);134  const [scrollTop, setScrollTop] = createSignal(0);135  const [viewportHeight, setViewportHeight] = createSignal(600);136  let longPressTimer: number | null = null;137  let touchStart: { x: number; y: number; t: number } | null = null;138  let suppressNextClick = false;139140  function clearLongPressTimer() {141    if (longPressTimer !== null) {142      window.clearTimeout(longPressTimer);143      longPressTimer = null;144    }145  }146147  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {148    if (selecting()) return;149    const t = e.touches[0];150    if (!t) return;151    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };152    clearLongPressTimer();153    longPressTimer = window.setTimeout(() => {154      suppressNextClick = true;155      vibrate(20);156      setContextEntry(entry);157      longPressTimer = null;158    }, 500);159  }160161  function handleRowTouchMove(e: TouchEvent) {162    if (!touchStart) return;163    const t = e.touches[0];164    if (!t) return;165    const dx = Math.abs(t.clientX - touchStart.x);166    const dy = Math.abs(t.clientY - touchStart.y);167    if (dx > 8 || dy > 8) clearLongPressTimer();168  }169170  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {171    clearLongPressTimer();172    if (!touchStart) return;173    const t = e.changedTouches[0];174    if (!t) { touchStart = null; return; }175    const dx = t.clientX - touchStart.x;176    const dy = Math.abs(t.clientY - touchStart.y);177    const dt = Date.now() - touchStart.t;178    touchStart = null;179    // Right-swipe-to-copy: only on compact + login entries with a password180    const isCompact = !document.body.classList.contains("layout-medium")181      && !document.body.classList.contains("layout-expanded");182    if (183      isCompact184      && !selecting()185      && dx > 80186      && dy < 30187      && dt < 400188      && entry.type === "login"189      && entry.password190    ) {191      suppressNextClick = true;192      vibrate(15);193      setFlashId(entry.id);194      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);195      await popupCopy(entry.password, "password");196    }197  }198199  let searchRef: HTMLInputElement | undefined;200  let listRef: HTMLDivElement | undefined;201202  const focusSearch = (el: HTMLInputElement) => {203    searchRef = el;204    setTimeout(() => el.focus(), 50);205  };206207  createEffect(() => {208    void props.searchSeedNonce;209    const seed = props.searchSeed;210    if (!seed) return;211    setSearch(seed);212    requestAnimationFrame(() => {213      searchRef?.focus();214      searchRef?.setSelectionRange(seed.length, seed.length);215    });216  });217218  function enterSelectMode() {219    setSelecting(true);220    setSelectedIds(new Set<string>());221  }222223  function exitSelectMode() {224    setSelecting(false);225    setSelectedIds(new Set<string>());226  }227228  function toggleId(id: string) {229    const s = new Set<string>(selectedIds());230    if (s.has(id)) s.delete(id); else s.add(id);231    setSelectedIds(s);232  }233234  const allTags = createMemo(() => {235    const tagSet = new Set<string>();236    for (const entry of props.entries) {237      for (const tag of entry.tags ?? []) {238        tagSet.add(tag);239      }240    }241    return Array.from(tagSet).sort();242  });243244  const hasTags = createMemo(() => allTags().length > 0);245246  function toggleTag(tag: string) {247    const current = new Set(selectedTags());248    if (current.has(tag)) {249      current.delete(tag);250    } else {251      current.add(tag);252    }253    setSelectedTags(current);254  }255256  function fuzzyMatch(query: string, target: string): boolean {257    query = query.toLowerCase();258    target = target.toLowerCase();259    let qi = 0;260    for (let i = 0; i < target.length && qi < query.length; i++) {261      if (target[i] === query[qi]) qi++;262    }263    return qi === query.length;264  }265266  function isExactPrefix(query: string, target: string): boolean {267    return target.toLowerCase().startsWith(query.toLowerCase());268  }269270  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {271    if (!domain) return false;272    if (entry.passkey?.rpId === domain) return true;273    if (entry.type !== "login") return false;274    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of275    // each entry URL too so subdomain entries also match.276    if (entry.url && extractDomain(entry.url) === domain) return true;277    for (const extra of entry.additionalUrls ?? []) {278      if (extractDomain(extra) === domain) return true;279    }280    return false;281  }282283  const filteredEntries = createMemo(() => {284    let result = props.entries;285286    if (notesOnly()) {287      result = result.filter(entry => entry.type === "note");288    }289290    if (selectedTags().size > 0) {291      result = result.filter(entry =>292        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))293      );294    }295296    const q = search().trim();297    if (q) {298      const searchableFor = (e: VaultEntry): string => {299        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;300        const passkeyText = e.passkey301          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`302          : "";303        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;304      };305      result = result306        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))307        .sort((a, b) => {308          const aSearchable = searchableFor(a).toLowerCase();309          const bSearchable = searchableFor(b).toLowerCase();310          const qLower = q.toLowerCase();311          const aExact = isExactPrefix(qLower, aSearchable);312          const bExact = isExactPrefix(qLower, bSearchable);313          if (aExact && !bExact) return -1;314          if (!aExact && bExact) return 1;315          return 0;316        });317      return result;318    }319320    const tabDomain = props.currentTabDomain ?? "";321    const order = props.sortOrder;322323    if (order === "az") {324      return [...result].sort((a, b) => a.title.localeCompare(b.title));325    }326    if (order === "za") {327      return [...result].sort((a, b) => b.title.localeCompare(a.title));328    }329    if (order === "recent") {330      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));331    }332    if (tabDomain) {333      return [...result].sort((a, b) => {334        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;335        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;336        return aMatch - bMatch;337      });338    }339    return result;340  });341342  // Declaration order matters: `allVisibleIds` and `allSelected` read343  // `filteredEntries` eagerly on component mount via createMemo. Moving344  // `filteredEntries` below them throws ReferenceError under the production345  // minifier and freezes the vault list on mount (v1.4.34 regression).346  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));347  const allSelected = createMemo(() =>348    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))349  );350351  function toggleSelectAll() {352    if (allSelected()) {353      setSelectedIds(new Set<string>());354    } else {355      setSelectedIds(new Set<string>(allVisibleIds()));356    }357  }358359  async function handleDeleteSelected() {360    const ids = Array.from(selectedIds());361    if (!ids.length || !props.onDeleteMany) return;362    setDeleting(true);363    await props.onDeleteMany(ids);364    setDeleting(false);365    exitSelectMode();366  }367368  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.369  // Above it, we render only a window around the visible viewport plus a370  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty371  // space. Row height is approximate (variable padding for badges) but372  // bounded — the buffer absorbs over/undershoot.373  const VIRTUAL_THRESHOLD = 100;374  const ROW_HEIGHT = 56;375  const BUFFER_ROWS = 20;376377  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);378379  const visibleRange = createMemo(() => {380    const total = filteredEntries().length;381    if (!isVirtualized()) return { start: 0, end: total };382    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);383    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);384    return { start, end };385  });386387  const windowedEntries = createMemo(() => {388    const { start, end } = visibleRange();389    return filteredEntries().slice(start, end);390  });391392  function handleListScroll(e: Event) {393    if (!isVirtualized()) return;394    const target = e.currentTarget as HTMLDivElement;395    setScrollTop(target.scrollTop);396    setViewportHeight(target.clientHeight);397  }398399  function handleSearchKeydown(e: KeyboardEvent) {400    if (e.key === "ArrowDown") {401      e.preventDefault();402      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");403      firstBtn?.focus();404    } else if (e.key === "ArrowUp") {405      e.preventDefault();406      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");407      allBtns?.[allBtns.length - 1]?.focus();408    } else if (e.key === "Enter") {409      e.preventDefault();410      const firstEntry = filteredEntries()[0];411      if (firstEntry) props.onSelectEntry(firstEntry);412    } else if (e.key === "Escape" && selecting()) {413      exitSelectMode();414    }415  }416417  function handleListKeydown(e: KeyboardEvent) {418    if (!listRef) return;419    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));420    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);421    if (e.key === "ArrowDown") {422      e.preventDefault();423      if (idx < btns.length - 1) btns[idx + 1]?.focus();424    } else if (e.key === "ArrowUp") {425      e.preventDefault();426      if (idx === 0) { searchRef?.focus(); }427      else btns[idx - 1]?.focus();428    } else if (e.key === "Escape") {429      if (selecting()) exitSelectMode();430      else props.onClose();431    }432  }433434  const sortLabels: { key: SortOrder; label: string }[] = [435    { key: "url-first", label: "smart" },436    { key: "az", label: "A–Z" },437    { key: "za", label: "Z–A" },438    { key: "recent", label: "newest" },439  ];440441  return (442    <div class="peach-overlay-view ov-enter-up" style="display:flex">443      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>444        <Show445          when={!props.isNative}446          fallback={447            <span class="peach-search-icon" aria-hidden="true">448              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">449                <circle cx="5.5" cy="5.5" r="3.75" />450                <path d="M8.75 8.75L11.5 11.5" />451              </svg>452            </span>453          }454        >455          <button456            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"457            onClick={props.onClose}458            aria-label="Back"459          >460            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">461              <path d="M6 1L1 6L6 11" />462            </svg>463          </button>464        </Show>465        <input466          class="peach-search-input"467          placeholder="search vault..."468          autocomplete="off"469          style={props.isNative ? undefined : "font-size:14px"}470          ref={focusSearch}471          value={search()}472          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}473          onKeyDown={handleSearchKeydown}474        />475        <span class="peach-search-counter peach-search-counter-list">476          {search() || selectedTags().size > 0477            ? `${filteredEntries().length} of ${props.entries.length}`478            : props.isNative479              ? `${props.entries.length}`480              : `${props.entries.length} entries`}481        </span>482      </div>483484      {/* Sort bar — morphs into select-mode controls */}485      <div class="peach-sort-bar">486        <Show487          when={selecting()}488          fallback={489            <>490              <For each={sortLabels}>491                {({ key, label }) => (492                  <button493                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}494                    onClick={() => props.onSortOrderChange(key)}495                  >496                    {label}497                  </button>498                )}499              </For>500              <span style="flex:1" />501              <button502                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}503                onClick={() => setNotesOnly(v => !v)}504                aria-label="Notes only"505                title="Show notes only"506              >507                <NoteIcon />508              </button>509              <Show when={props.onOpenTrash}>510                <button511                  class="peach-sort-trash-btn"512                  onClick={props.onOpenTrash}513                  aria-label="View trash"514                  title="View deleted entries"515                >516                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">517                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />518                  </svg>519                </button>520              </Show>521              <Show when={props.onDeleteMany}>522                <button523                  class="peach-sort-trash-btn"524                  onClick={enterSelectMode}525                  aria-label="Select entries"526                  title="Select entries"527                >528                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">529                    <circle cx="4" cy="4" r="2.5" />530                    <circle cx="9" cy="4" r="2.5" />531                    <circle cx="4" cy="9" r="2.5" />532                    <circle cx="9" cy="9" r="2.5" />533                  </svg>534                </button>535              </Show>536            </>537          }538        >539          {/* Select mode sort bar */}540          <button541            class="peach-select-cancel-btn"542            onClick={exitSelectMode}543          >544            cancel545          </button>546          <span class="peach-select-count-label">547            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}548          </span>549          <button550            class="peach-select-all-btn"551            onClick={toggleSelectAll}552          >553            {allSelected() ? "none" : "all"}554          </button>555        </Show>556      </div>557558      <Show when={hasTags() && !selecting()}>559        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">560          <For each={allTags()}>561            {(tag) => (562              <button563                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}564                onClick={() => toggleTag(tag)}565              >566                {tag}567              </button>568            )}569          </For>570        </div>571      </Show>572573      <div574        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}575        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}576        onKeyDown={handleListKeydown}577        onScroll={handleListScroll}578      >579        <Show580          when={filteredEntries().length > 0}581          fallback={582            <div class="peach-list-empty">583              <span>no entries yet</span>584            </div>585          }586        >587          <Show when={isVirtualized()}>588            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />589          </Show>590          <For each={windowedEntries()}>591            {(entry, localIndex) => {592              const index = () => visibleRange().start + localIndex();593              const isNote = entry.type === "note";594              const isPasskey = entry.passkey !== undefined;595              const domain = entry.type === "note"596                ? (entry.notes?.slice(0, 40) ?? "note")597                : entry.url598                  ? getDisplayDomain(entry.url)599                  : entry.passkey?.rpId600                    ? entry.passkey.rpId601                  : "saved entry";602              const isTabMatch = () =>603                props.currentTabDomain !== undefined &&604                props.currentTabDomain !== "" &&605                entryMatchesDomain(entry, props.currentTabDomain);606              const isSelected = () => selectedIds().has(entry.id);607608              const ariaLabel = (() => {609                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";610                const sub = entry.username || domain || "";611                return sub612                  ? `${entry.title} — ${sub} (${tag})`613                  : `${entry.title} (${tag})`;614              })();615              return (616                <button617                  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" : ""}`}618                  style={isVirtualized()619                    ? { animation: "none" }620                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }621                  }622                  aria-label={ariaLabel}623                  aria-pressed={selecting() ? isSelected() : undefined}624                  onTouchStart={(e) => handleRowTouchStart(entry, e)}625                  onTouchMove={handleRowTouchMove}626                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}627                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}628                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}629                  onClick={() => {630                    if (suppressNextClick) { suppressNextClick = false; return; }631                    if (selecting()) {632                      toggleId(entry.id);633                    } else {634                      props.onSelectEntry(entry);635                    }636                  }}637                >638                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>639                    <Show640                      when={selecting()}641                      fallback={642                        <FaviconIcon643                          domain={entry.url ? getDisplayDomain(entry.url) : ""}644                          title={entry.title}645                          isNote={isNote}646                          isPasskey={isPasskey}647                        />648                      }649                    >650                      {/* Check indicator */}651                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">652                        <circle653                          cx="8" cy="8" r="7"654                          stroke="currentColor"655                          stroke-width="1"656                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}657                          opacity={isSelected() ? "1" : "0.3"}658                        />659                        <Show when={isSelected()}>660                          <path661                            d="M5 8.5L7 10.5L11 6"662                            stroke="white"663                            stroke-width="1.2"664                            stroke-linecap="round"665                            stroke-linejoin="round"666                          />667                        </Show>668                      </svg>669                    </Show>670                  </div>671                  <div class="peach-entry-row-info">672                    <span class="peach-entry-row-name">673                      {entry.title}674                      <Show when={isPasskey && !selecting()}>675                        <span class="peach-passkey-badge">passkey</span>676                      </Show>677                      <Show when={isTabMatch() && !selecting()}>678                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>679                      </Show>680                    </span>681                    <span class="peach-entry-row-meta">{domain}</span>682                  </div>683                  <Show when={entry.breach && !selecting()}>684                    <span class="peach-entry-row-dot" aria-label="breach"></span>685                  </Show>686                  <Show when={entry.canary && !selecting()}>687                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>688                  </Show>689                </button>690              );691            }}692          </For>693          <Show when={isVirtualized()}>694            <div695              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}696              aria-hidden="true"697            />698          </Show>699        </Show>700      </div>701702      {/* Bottom bar — new entry normally, delete action in select mode */}703      <Show704        when={selecting()}705        fallback={706          <button class="peach-new-entry-row" onClick={props.onAdd}>707            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">708              <path d="M5 1v8M1 5h8" />709            </svg>710            new entry711          </button>712        }713      >714        <button715          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}716          disabled={selectedIds().size === 0 || deleting()}717          onClick={() => void handleDeleteSelected()}718        >719          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">720            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />721          </svg>722          <Show723            when={selectedIds().size > 0}724            fallback={<span>select entries to delete</span>}725          >726            <span>727              {deleting()728                ? "moving to trash…"729                : `move ${selectedIds().size} to trash`}730            </span>731          </Show>732        </button>733      </Show>734735      <Show when={contextEntry() !== null}>736        <div737          class="peach-context-menu-backdrop"738          onClick={() => setContextEntry(null)}739          role="presentation"740        >741          <div742            class="peach-context-menu"743            role="menu"744            aria-label="Entry actions"745            onClick={(e) => e.stopPropagation()}746          >747            <Show when={contextEntry()!.username}>748              <button749                role="menuitem"750                class="peach-context-menu-item"751                onClick={async () => {752                  const e = contextEntry();753                  if (!e?.username) return;754                  await popupCopy(e.username, "username");755                  setContextEntry(null);756                }}757              >758                Copy username759              </button>760            </Show>761            <Show when={contextEntry()!.password}>762              <button763                role="menuitem"764                class="peach-context-menu-item"765                onClick={async () => {766                  const e = contextEntry();767                  if (!e?.password) return;768                  await popupCopy(e.password, "password");769                  setContextEntry(null);770                }}771              >772                Copy password773              </button>774            </Show>775            <Show when={contextEntry()!.totp}>776              <button777                role="menuitem"778                class="peach-context-menu-item"779                onClick={async () => {780                  const e = contextEntry();781                  if (!e?.totp) return;782                  await popupCopy(e.totp, "TOTP secret");783                  setContextEntry(null);784                }}785              >786                Copy TOTP secret787              </button>788            </Show>789            <button790              role="menuitem"791              class="peach-context-menu-item"792              onClick={() => {793                const e = contextEntry();794                if (e) props.onSelectEntry(e);795                setContextEntry(null);796              }}797            >798              Open799            </button>800            <Show when={props.onDeleteMany}>801              <button802                role="menuitem"803                class="peach-context-menu-item peach-context-menu-item--danger"804                onClick={async () => {805                  const e = contextEntry();806                  if (!e) return;807                  setContextEntry(null);808                  await props.onDeleteMany?.([e.id]);809                }}810              >811                Move to trash812              </button>813            </Show>814            <button815              role="menuitem"816              class="peach-context-menu-item peach-context-menu-item--cancel"817              onClick={() => setContextEntry(null)}818            >819              Cancel820            </button>821          </div>822        </div>823      </Show>824    </div>825  );826}