Vault.tsx

27.0 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";5import { popupCopy } from "../copy";6import {7  filterAndSortVaultEntries,8  entryMatchesDomain,9  VAULT_SORT_OPTIONS,10  type SortOrder,11} from "./vault-search";1213export type { SortOrder } from "./vault-search";1415function vibrate(ms: number) {16  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }17}1819function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {20  const src = props.domain && !props.isNote21    ? faviconUrl(props.domain)22    : null;23  const [isReal, setIsReal] = createSignal(false);2425  // Show InitialsChip (or note icon) first — no globe flash. Passkeys use the26  // same coloured initials / real favicon as logins; the row's "passkey" text27  // badge still distinguishes them.28  // Load the favicon invisibly in the background; switch to it only after29  // confirming it differs from Chrome's default "no favicon" globe image.30  return (31    <Show32      when={isReal()}33      fallback={34        <>35          {props.isNote36            ? <NoteIcon />37            : <InitialsChip title={props.title ?? props.domain ?? ""} />}38          {src && (39            <img40              src={src}41              style="display:none"42              onError={() => {}}43              onLoad={async (e) => {44                const img = e.currentTarget as HTMLImageElement;45                // Old Chrome returns the globe at native 16×16 even when46                // size=32 is requested — treat as no favicon.47                if (img.naturalWidth <= 16) return;48                // New Chrome returns the globe scaled to the requested size.49                // Compare pixel data against the known "no favicon" reference.50                const sig = faviconImgSig(img);51                if (sig !== null) {52                  const gSig = await globeFaviconSig();53                  if (gSig !== null && sig === gSig) return;54                }55                setIsReal(true);56              }}57            />58          )}59        </>60      }61    >62      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />63    </Show>64  );65}666768function InitialsChip(props: { title: string }) {69  return (70    <span71      class="peach-favicon-initials"72      style={{ background: titleBackground(props.title) }}73      aria-hidden="true"74    >75      {titleInitials(props.title)}76    </span>77  );78}798081const NoteIcon = () => (82  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">83    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />84    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />85  </svg>86);8788interface VaultProps {89  entries: VaultEntry[];90  selectedEntryId?: string;91  currentTabDomain?: string;92  sortOrder: SortOrder;93  searchSeed?: string;94  searchSeedNonce?: number;95  // When true, render the mobile flat top bar: no back arrow (use swipe-down96  // on the bottom sheet), search-glass icon on the left, counter on the97  // right. Matches the extension's `.peach-search-bar` look.98  isNative?: boolean;99  onSelectEntry: (entry: VaultEntry) => void;100  onSortOrderChange: (order: SortOrder) => void;101  onAdd: () => void;102  onClose: () => void;103  onOpenTrash?: () => void;104  onDeleteMany?: (ids: string[]) => Promise<void>;105}106107export function VaultView(props: VaultProps) {108  const [search, setSearch] = createSignal("");109  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());110  const [notesOnly, setNotesOnly] = createSignal(false);111  const [selecting, setSelecting] = createSignal(false);112  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());113  const [deleting, setDeleting] = createSignal(false);114  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);115  const [flashId, setFlashId] = createSignal<string | null>(null);116  const [scrollTop, setScrollTop] = createSignal(0);117  const [viewportHeight, setViewportHeight] = createSignal(600);118  let longPressTimer: number | null = null;119  let touchStart: { x: number; y: number; t: number } | null = null;120  let suppressNextClick = false;121122  function clearLongPressTimer() {123    if (longPressTimer !== null) {124      window.clearTimeout(longPressTimer);125      longPressTimer = null;126    }127  }128129  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {130    if (selecting()) return;131    const t = e.touches[0];132    if (!t) return;133    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };134    clearLongPressTimer();135    longPressTimer = window.setTimeout(() => {136      suppressNextClick = true;137      // Touch-and-hold = multi-select. Pre-select the row so the action bar138      // lights up immediately. The context menu still opens on desktop139      // right-click (and on mobile if the browser synthesises its own long-140      // press contextmenu event — that path is filtered by `touchStart` so141      // both don't fire on a single gesture).142      enterSelectMode();143      setSelectedIds(new Set([entry.id]));144      vibrate(20);145      longPressTimer = null;146    }, 500);147  }148149  function handleRowTouchMove(e: TouchEvent) {150    if (!touchStart) return;151    const t = e.touches[0];152    if (!t) return;153    const dx = Math.abs(t.clientX - touchStart.x);154    const dy = Math.abs(t.clientY - touchStart.y);155    if (dx > 8 || dy > 8) clearLongPressTimer();156  }157158  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {159    clearLongPressTimer();160    if (!touchStart) return;161    const t = e.changedTouches[0];162    if (!t) { touchStart = null; return; }163    const dx = t.clientX - touchStart.x;164    const dy = Math.abs(t.clientY - touchStart.y);165    const dt = Date.now() - touchStart.t;166    touchStart = null;167    // Right-swipe-to-copy: only on compact + login entries with a password168    const isCompact = !document.body.classList.contains("layout-medium")169      && !document.body.classList.contains("layout-expanded");170    if (171      isCompact172      && !selecting()173      && dx > 80174      && dy < 30175      && dt < 400176      && entry.type === "login"177      && entry.password178    ) {179      suppressNextClick = true;180      vibrate(15);181      setFlashId(entry.id);182      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);183      await popupCopy(entry.password, "password");184    }185  }186187  let searchRef: HTMLInputElement | undefined;188  let listRef: HTMLDivElement | undefined;189190  const focusSearch = (el: HTMLInputElement) => {191    searchRef = el;192    setTimeout(() => el.focus(), 50);193  };194195  createEffect(() => {196    void props.searchSeedNonce;197    const seed = props.searchSeed;198    if (!seed) return;199    setSearch(seed);200    requestAnimationFrame(() => {201      searchRef?.focus();202      searchRef?.setSelectionRange(seed.length, seed.length);203    });204  });205206  function enterSelectMode() {207    setSelecting(true);208    setSelectedIds(new Set<string>());209  }210211  function exitSelectMode() {212    setSelecting(false);213    setSelectedIds(new Set<string>());214  }215216  function toggleId(id: string) {217    const s = new Set<string>(selectedIds());218    if (s.has(id)) s.delete(id); else s.add(id);219    setSelectedIds(s);220  }221222  const allTags = createMemo(() => {223    const tagSet = new Set<string>();224    for (const entry of props.entries) {225      for (const tag of entry.tags ?? []) {226        tagSet.add(tag);227      }228    }229    return Array.from(tagSet).sort();230  });231232  const hasTags = createMemo(() => allTags().length > 0);233234  function toggleTag(tag: string) {235    const current = new Set(selectedTags());236    if (current.has(tag)) {237      current.delete(tag);238    } else {239      current.add(tag);240    }241    setSelectedTags(current);242  }243244  const filteredEntries = createMemo(() => {245    return filterAndSortVaultEntries(props.entries, {246      notesOnly: notesOnly(),247      selectedTags: selectedTags(),248      query: search(),249      searchScope: "both",250      sortOrder: props.sortOrder,251      currentTabDomain: props.currentTabDomain,252    });253  });254255  // Declaration order matters: `allVisibleIds` and `allSelected` read256  // `filteredEntries` eagerly on component mount via createMemo. Moving257  // `filteredEntries` below them throws ReferenceError under the production258  // minifier and freezes the vault list on mount (v1.4.34 regression).259  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));260  const allSelected = createMemo(() =>261    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))262  );263264  function toggleSelectAll() {265    if (allSelected()) {266      setSelectedIds(new Set<string>());267    } else {268      setSelectedIds(new Set<string>(allVisibleIds()));269    }270  }271272  async function handleDeleteSelected() {273    const ids = Array.from(selectedIds());274    if (!ids.length || !props.onDeleteMany) return;275    setDeleting(true);276    await props.onDeleteMany(ids);277    setDeleting(false);278    exitSelectMode();279  }280281  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.282  // Above it, we render only a window around the visible viewport plus a283  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty284  // space. Row height is approximate (variable padding for badges) but285  // bounded — the buffer absorbs over/undershoot.286  const VIRTUAL_THRESHOLD = 100;287  const ROW_HEIGHT = 56;288  const BUFFER_ROWS = 20;289290  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);291292  const visibleRange = createMemo(() => {293    const total = filteredEntries().length;294    if (!isVirtualized()) return { start: 0, end: total };295    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);296    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);297    return { start, end };298  });299300  const windowedEntries = createMemo(() => {301    const { start, end } = visibleRange();302    return filteredEntries().slice(start, end);303  });304305  function handleListScroll(e: Event) {306    if (!isVirtualized()) return;307    const target = e.currentTarget as HTMLDivElement;308    setScrollTop(target.scrollTop);309    setViewportHeight(target.clientHeight);310  }311312  function handleSearchKeydown(e: KeyboardEvent) {313    if (e.key === "ArrowDown") {314      e.preventDefault();315      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");316      firstBtn?.focus();317    } else if (e.key === "ArrowUp") {318      e.preventDefault();319      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");320      allBtns?.[allBtns.length - 1]?.focus();321    } else if (e.key === "Enter") {322      e.preventDefault();323      const firstEntry = filteredEntries()[0];324      if (firstEntry) props.onSelectEntry(firstEntry);325    } else if (e.key === "Escape" && selecting()) {326      exitSelectMode();327    }328  }329330  function handleListKeydown(e: KeyboardEvent) {331    if (!listRef) return;332    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));333    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);334    if (e.key === "ArrowDown") {335      e.preventDefault();336      if (idx < btns.length - 1) btns[idx + 1]?.focus();337    } else if (e.key === "ArrowUp") {338      e.preventDefault();339      if (idx === 0) { searchRef?.focus(); }340      else btns[idx - 1]?.focus();341    } else if (e.key === "Escape") {342      if (selecting()) exitSelectMode();343      else props.onClose();344    }345  }346347  return (348    <div class="peach-overlay-view ov-enter-up" style="display:flex">349      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>350        <Show351          when={!props.isNative}352          fallback={353            <span class="peach-search-icon" aria-hidden="true">354              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">355                <circle cx="5.5" cy="5.5" r="3.75" />356                <path d="M8.75 8.75L11.5 11.5" />357              </svg>358            </span>359          }360        >361          <button362            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"363            onClick={props.onClose}364            aria-label="Back"365          >366            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">367              <path d="M6 1L1 6L6 11" />368            </svg>369          </button>370        </Show>371        <input372          class="peach-search-input"373          placeholder="search vault..."374          autocomplete="off"375          style={props.isNative ? undefined : "font-size:14px"}376          ref={focusSearch}377          value={search()}378          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}379          onKeyDown={handleSearchKeydown}380        />381        <span class="peach-search-counter peach-search-counter-list">382          {search() || selectedTags().size > 0383            ? `${filteredEntries().length} of ${props.entries.length}`384            : props.isNative385              ? `${props.entries.length}`386              : `${props.entries.length} entries`}387        </span>388      </div>389390      {/* Sort bar — morphs into select-mode controls */}391      <div class="peach-sort-bar">392        <Show393          when={selecting()}394          fallback={395            <>396              <For each={VAULT_SORT_OPTIONS}>397                {({ key, label }) => (398                  <button399                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}400                    onClick={() => props.onSortOrderChange(key)}401                  >402                    {label}403                  </button>404                )}405              </For>406              <span style="flex:1" />407              <button408                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}409                onClick={() => setNotesOnly(v => !v)}410                aria-label="Notes only"411                title="Show notes only"412              >413                <NoteIcon />414              </button>415              <Show when={props.onOpenTrash}>416                <button417                  class="peach-sort-trash-btn"418                  onClick={props.onOpenTrash}419                  aria-label="View trash"420                  title="View deleted entries"421                >422                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">423                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />424                  </svg>425                </button>426              </Show>427              <Show when={props.onDeleteMany}>428                <button429                  class="peach-sort-trash-btn"430                  onClick={enterSelectMode}431                  aria-label="Select entries"432                  title="Select entries"433                >434                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">435                    <circle cx="4" cy="4" r="2.5" />436                    <circle cx="9" cy="4" r="2.5" />437                    <circle cx="4" cy="9" r="2.5" />438                    <circle cx="9" cy="9" r="2.5" />439                  </svg>440                </button>441              </Show>442            </>443          }444        >445          {/* Select mode sort bar */}446          <button447            class="peach-select-cancel-btn"448            onClick={exitSelectMode}449          >450            cancel451          </button>452          <span class="peach-select-count-label">453            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}454          </span>455          <button456            class="peach-select-all-btn"457            onClick={toggleSelectAll}458          >459            {allSelected() ? "none" : "all"}460          </button>461        </Show>462      </div>463464      <Show when={hasTags() && !selecting()}>465        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">466          <For each={allTags()}>467            {(tag) => (468              <button469                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}470                onClick={() => toggleTag(tag)}471              >472                {tag}473              </button>474            )}475          </For>476        </div>477      </Show>478479      <div480        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}481        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}482        onKeyDown={handleListKeydown}483        onScroll={handleListScroll}484      >485        <Show486          when={filteredEntries().length > 0}487          fallback={488            <div class="peach-list-empty">489              <span>no entries yet</span>490            </div>491          }492        >493          <Show when={isVirtualized()}>494            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />495          </Show>496          <For each={windowedEntries()}>497            {(entry, localIndex) => {498              const index = () => visibleRange().start + localIndex();499              const isNote = entry.type === "note";500              const isPasskey = entry.passkey !== undefined;501              const domain = entry.type === "note"502                ? (entry.notes?.slice(0, 40) ?? "note")503                : entry.url504                  ? getDisplayDomain(entry.url)505                  : entry.passkey?.rpId506                    ? entry.passkey.rpId507                  : "saved entry";508              const isTabMatch = () =>509                props.currentTabDomain !== undefined &&510                props.currentTabDomain !== "" &&511                entryMatchesDomain(entry, props.currentTabDomain);512              const isSelected = () => selectedIds().has(entry.id);513514              const ariaLabel = (() => {515                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";516                const sub = entry.username || domain || "";517                return sub518                  ? `${entry.title} — ${sub} (${tag})`519                  : `${entry.title} (${tag})`;520              })();521              return (522                <button523                  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" : ""}`}524                  style={isVirtualized()525                    ? { animation: "none" }526                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }527                  }528                  aria-label={ariaLabel}529                  aria-pressed={selecting() ? isSelected() : undefined}530                  onTouchStart={(e) => handleRowTouchStart(entry, e)}531                  onTouchMove={handleRowTouchMove}532                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}533                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}534                  onContextMenu={(e) => {535                    e.preventDefault();536                    // A real touch long-press already entered multi-select537                    // via the 500ms timer; the browser's synthesised538                    // contextmenu event would otherwise reopen the menu and539                    // undo the gesture. Desktop right-click has no `touchStart`540                    // to consume, so it still opens the menu.541                    if (touchStart !== null) { touchStart = null; return; }542                    if (!selecting()) setContextEntry(entry);543                  }}544                  onClick={() => {545                    if (suppressNextClick) { suppressNextClick = false; return; }546                    if (selecting()) {547                      toggleId(entry.id);548                    } else {549                      props.onSelectEntry(entry);550                    }551                  }}552                >553                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>554                    <Show555                      when={selecting()}556                      fallback={557                        <FaviconIcon558                          domain={entry.type === "note" ? "" : (entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ?? "")}559                          title={entry.title}560                          isNote={isNote}561                          isPasskey={isPasskey}562                        />563                      }564                    >565                      {/* Check indicator */}566                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">567                        <circle568                          cx="8" cy="8" r="7"569                          stroke="currentColor"570                          stroke-width="1"571                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}572                          opacity={isSelected() ? "1" : "0.3"}573                        />574                        <Show when={isSelected()}>575                          <path576                            d="M5 8.5L7 10.5L11 6"577                            stroke="white"578                            stroke-width="1.2"579                            stroke-linecap="round"580                            stroke-linejoin="round"581                          />582                        </Show>583                      </svg>584                    </Show>585                  </div>586                  <div class="peach-entry-row-info">587                    <span class="peach-entry-row-name">588                      {entry.title}589                      <Show when={isPasskey && !selecting()}>590                        <span class="peach-passkey-badge">passkey</span>591                      </Show>592                      <Show when={isTabMatch() && !selecting()}>593                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>594                      </Show>595                    </span>596                    <span class="peach-entry-row-meta">{domain}</span>597                  </div>598                  <Show when={entry.breach && !selecting()}>599                    <span class="peach-entry-row-dot" aria-label="breach"></span>600                  </Show>601                  <Show when={entry.canary && !selecting()}>602                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>603                  </Show>604                </button>605              );606            }}607          </For>608          <Show when={isVirtualized()}>609            <div610              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}611              aria-hidden="true"612            />613          </Show>614        </Show>615      </div>616617      {/* Bottom bar — new entry normally, delete action in select mode */}618      <Show619        when={selecting()}620        fallback={621          <button class="peach-new-entry-row" onClick={props.onAdd}>622            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">623              <path d="M5 1v8M1 5h8" />624            </svg>625            new entry626          </button>627        }628      >629        <button630          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}631          disabled={selectedIds().size === 0 || deleting()}632          onClick={() => void handleDeleteSelected()}633        >634          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">635            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />636          </svg>637          <Show638            when={selectedIds().size > 0}639            fallback={<span>select entries to delete</span>}640          >641            <span>642              {deleting()643                ? "moving to trash…"644                : `move ${selectedIds().size} to trash`}645            </span>646          </Show>647        </button>648      </Show>649650      <Show when={contextEntry() !== null}>651        <div652          class="peach-context-menu-backdrop"653          onClick={() => setContextEntry(null)}654          role="presentation"655        >656          <div657            class="peach-context-menu"658            role="menu"659            aria-label="Entry actions"660            onClick={(e) => e.stopPropagation()}661          >662            <Show when={contextEntry()!.username}>663              <button664                role="menuitem"665                class="peach-context-menu-item"666                onClick={async () => {667                  const e = contextEntry();668                  if (!e?.username) return;669                  await popupCopy(e.username, "username");670                  setContextEntry(null);671                }}672              >673                Copy username674              </button>675            </Show>676            <Show when={contextEntry()!.password}>677              <button678                role="menuitem"679                class="peach-context-menu-item"680                onClick={async () => {681                  const e = contextEntry();682                  if (!e?.password) return;683                  await popupCopy(e.password, "password");684                  setContextEntry(null);685                }}686              >687                Copy password688              </button>689            </Show>690            <Show when={contextEntry()!.totp}>691              <button692                role="menuitem"693                class="peach-context-menu-item"694                onClick={async () => {695                  const e = contextEntry();696                  if (!e?.totp) return;697                  await popupCopy(e.totp, "TOTP secret");698                  setContextEntry(null);699                }}700              >701                Copy TOTP secret702              </button>703            </Show>704            <button705              role="menuitem"706              class="peach-context-menu-item"707              onClick={() => {708                const e = contextEntry();709                if (e) props.onSelectEntry(e);710                setContextEntry(null);711              }}712            >713              Open714            </button>715            <Show when={props.onDeleteMany}>716              <button717                role="menuitem"718                class="peach-context-menu-item peach-context-menu-item--danger"719                onClick={async () => {720                  const e = contextEntry();721                  if (!e) return;722                  setContextEntry(null);723                  await props.onDeleteMany?.([e.id]);724                }}725              >726                Move to trash727              </button>728            </Show>729            <button730              role="menuitem"731              class="peach-context-menu-item peach-context-menu-item--cancel"732              onClick={() => setContextEntry(null)}733            >734              Cancel735            </button>736          </div>737        </div>738      </Show>739    </div>740  );741}