Vault.tsx

27.2 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.isNote && !props.isPasskey21    ? faviconUrl(props.domain)22    : null;23  const [isReal, setIsReal] = createSignal(false);2425  // Show InitialsChip (or passkey/note icon) first — no globe flash.26  // Load the favicon invisibly in the background; switch to it only after27  // confirming it differs from Chrome's default "no favicon" globe image.28  return (29    <Show30      when={isReal()}31      fallback={32        <>33          {props.isPasskey34            ? <PasskeyIcon />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 PasskeyIcon = () => (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    <circle cx="4" cy="4.5" r="3" />84    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />85  </svg>86);8788const NoteIcon = () => (89  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">90    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />91    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />92  </svg>93);9495interface VaultProps {96  entries: VaultEntry[];97  selectedEntryId?: string;98  currentTabDomain?: string;99  sortOrder: SortOrder;100  searchSeed?: string;101  searchSeedNonce?: number;102  // When true, render the mobile flat top bar: no back arrow (use swipe-down103  // on the bottom sheet), search-glass icon on the left, counter on the104  // right. Matches the extension's `.peach-search-bar` look.105  isNative?: boolean;106  onSelectEntry: (entry: VaultEntry) => void;107  onSortOrderChange: (order: SortOrder) => void;108  onAdd: () => void;109  onClose: () => void;110  onOpenTrash?: () => void;111  onDeleteMany?: (ids: string[]) => Promise<void>;112}113114export function VaultView(props: VaultProps) {115  const [search, setSearch] = createSignal("");116  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());117  const [notesOnly, setNotesOnly] = createSignal(false);118  const [selecting, setSelecting] = createSignal(false);119  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());120  const [deleting, setDeleting] = createSignal(false);121  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);122  const [flashId, setFlashId] = createSignal<string | null>(null);123  const [scrollTop, setScrollTop] = createSignal(0);124  const [viewportHeight, setViewportHeight] = createSignal(600);125  let longPressTimer: number | null = null;126  let touchStart: { x: number; y: number; t: number } | null = null;127  let suppressNextClick = false;128129  function clearLongPressTimer() {130    if (longPressTimer !== null) {131      window.clearTimeout(longPressTimer);132      longPressTimer = null;133    }134  }135136  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {137    if (selecting()) return;138    const t = e.touches[0];139    if (!t) return;140    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };141    clearLongPressTimer();142    longPressTimer = window.setTimeout(() => {143      suppressNextClick = true;144      // Touch-and-hold = multi-select. Pre-select the row so the action bar145      // lights up immediately. The context menu still opens on desktop146      // right-click (and on mobile if the browser synthesises its own long-147      // press contextmenu event — that path is filtered by `touchStart` so148      // both don't fire on a single gesture).149      enterSelectMode();150      setSelectedIds(new Set([entry.id]));151      vibrate(20);152      longPressTimer = null;153    }, 500);154  }155156  function handleRowTouchMove(e: TouchEvent) {157    if (!touchStart) return;158    const t = e.touches[0];159    if (!t) return;160    const dx = Math.abs(t.clientX - touchStart.x);161    const dy = Math.abs(t.clientY - touchStart.y);162    if (dx > 8 || dy > 8) clearLongPressTimer();163  }164165  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {166    clearLongPressTimer();167    if (!touchStart) return;168    const t = e.changedTouches[0];169    if (!t) { touchStart = null; return; }170    const dx = t.clientX - touchStart.x;171    const dy = Math.abs(t.clientY - touchStart.y);172    const dt = Date.now() - touchStart.t;173    touchStart = null;174    // Right-swipe-to-copy: only on compact + login entries with a password175    const isCompact = !document.body.classList.contains("layout-medium")176      && !document.body.classList.contains("layout-expanded");177    if (178      isCompact179      && !selecting()180      && dx > 80181      && dy < 30182      && dt < 400183      && entry.type === "login"184      && entry.password185    ) {186      suppressNextClick = true;187      vibrate(15);188      setFlashId(entry.id);189      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);190      await popupCopy(entry.password, "password");191    }192  }193194  let searchRef: HTMLInputElement | undefined;195  let listRef: HTMLDivElement | undefined;196197  const focusSearch = (el: HTMLInputElement) => {198    searchRef = el;199    setTimeout(() => el.focus(), 50);200  };201202  createEffect(() => {203    void props.searchSeedNonce;204    const seed = props.searchSeed;205    if (!seed) return;206    setSearch(seed);207    requestAnimationFrame(() => {208      searchRef?.focus();209      searchRef?.setSelectionRange(seed.length, seed.length);210    });211  });212213  function enterSelectMode() {214    setSelecting(true);215    setSelectedIds(new Set<string>());216  }217218  function exitSelectMode() {219    setSelecting(false);220    setSelectedIds(new Set<string>());221  }222223  function toggleId(id: string) {224    const s = new Set<string>(selectedIds());225    if (s.has(id)) s.delete(id); else s.add(id);226    setSelectedIds(s);227  }228229  const allTags = createMemo(() => {230    const tagSet = new Set<string>();231    for (const entry of props.entries) {232      for (const tag of entry.tags ?? []) {233        tagSet.add(tag);234      }235    }236    return Array.from(tagSet).sort();237  });238239  const hasTags = createMemo(() => allTags().length > 0);240241  function toggleTag(tag: string) {242    const current = new Set(selectedTags());243    if (current.has(tag)) {244      current.delete(tag);245    } else {246      current.add(tag);247    }248    setSelectedTags(current);249  }250251  const filteredEntries = createMemo(() => {252    return filterAndSortVaultEntries(props.entries, {253      notesOnly: notesOnly(),254      selectedTags: selectedTags(),255      query: search(),256      searchScope: "both",257      sortOrder: props.sortOrder,258      currentTabDomain: props.currentTabDomain,259    });260  });261262  // Declaration order matters: `allVisibleIds` and `allSelected` read263  // `filteredEntries` eagerly on component mount via createMemo. Moving264  // `filteredEntries` below them throws ReferenceError under the production265  // minifier and freezes the vault list on mount (v1.4.34 regression).266  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));267  const allSelected = createMemo(() =>268    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))269  );270271  function toggleSelectAll() {272    if (allSelected()) {273      setSelectedIds(new Set<string>());274    } else {275      setSelectedIds(new Set<string>(allVisibleIds()));276    }277  }278279  async function handleDeleteSelected() {280    const ids = Array.from(selectedIds());281    if (!ids.length || !props.onDeleteMany) return;282    setDeleting(true);283    await props.onDeleteMany(ids);284    setDeleting(false);285    exitSelectMode();286  }287288  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.289  // Above it, we render only a window around the visible viewport plus a290  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty291  // space. Row height is approximate (variable padding for badges) but292  // bounded — the buffer absorbs over/undershoot.293  const VIRTUAL_THRESHOLD = 100;294  const ROW_HEIGHT = 56;295  const BUFFER_ROWS = 20;296297  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);298299  const visibleRange = createMemo(() => {300    const total = filteredEntries().length;301    if (!isVirtualized()) return { start: 0, end: total };302    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);303    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);304    return { start, end };305  });306307  const windowedEntries = createMemo(() => {308    const { start, end } = visibleRange();309    return filteredEntries().slice(start, end);310  });311312  function handleListScroll(e: Event) {313    if (!isVirtualized()) return;314    const target = e.currentTarget as HTMLDivElement;315    setScrollTop(target.scrollTop);316    setViewportHeight(target.clientHeight);317  }318319  function handleSearchKeydown(e: KeyboardEvent) {320    if (e.key === "ArrowDown") {321      e.preventDefault();322      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");323      firstBtn?.focus();324    } else if (e.key === "ArrowUp") {325      e.preventDefault();326      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");327      allBtns?.[allBtns.length - 1]?.focus();328    } else if (e.key === "Enter") {329      e.preventDefault();330      const firstEntry = filteredEntries()[0];331      if (firstEntry) props.onSelectEntry(firstEntry);332    } else if (e.key === "Escape" && selecting()) {333      exitSelectMode();334    }335  }336337  function handleListKeydown(e: KeyboardEvent) {338    if (!listRef) return;339    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));340    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);341    if (e.key === "ArrowDown") {342      e.preventDefault();343      if (idx < btns.length - 1) btns[idx + 1]?.focus();344    } else if (e.key === "ArrowUp") {345      e.preventDefault();346      if (idx === 0) { searchRef?.focus(); }347      else btns[idx - 1]?.focus();348    } else if (e.key === "Escape") {349      if (selecting()) exitSelectMode();350      else props.onClose();351    }352  }353354  return (355    <div class="peach-overlay-view ov-enter-up" style="display:flex">356      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>357        <Show358          when={!props.isNative}359          fallback={360            <span class="peach-search-icon" aria-hidden="true">361              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">362                <circle cx="5.5" cy="5.5" r="3.75" />363                <path d="M8.75 8.75L11.5 11.5" />364              </svg>365            </span>366          }367        >368          <button369            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"370            onClick={props.onClose}371            aria-label="Back"372          >373            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">374              <path d="M6 1L1 6L6 11" />375            </svg>376          </button>377        </Show>378        <input379          class="peach-search-input"380          placeholder="search vault..."381          autocomplete="off"382          style={props.isNative ? undefined : "font-size:14px"}383          ref={focusSearch}384          value={search()}385          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}386          onKeyDown={handleSearchKeydown}387        />388        <span class="peach-search-counter peach-search-counter-list">389          {search() || selectedTags().size > 0390            ? `${filteredEntries().length} of ${props.entries.length}`391            : props.isNative392              ? `${props.entries.length}`393              : `${props.entries.length} entries`}394        </span>395      </div>396397      {/* Sort bar — morphs into select-mode controls */}398      <div class="peach-sort-bar">399        <Show400          when={selecting()}401          fallback={402            <>403              <For each={VAULT_SORT_OPTIONS}>404                {({ key, label }) => (405                  <button406                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}407                    onClick={() => props.onSortOrderChange(key)}408                  >409                    {label}410                  </button>411                )}412              </For>413              <span style="flex:1" />414              <button415                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}416                onClick={() => setNotesOnly(v => !v)}417                aria-label="Notes only"418                title="Show notes only"419              >420                <NoteIcon />421              </button>422              <Show when={props.onOpenTrash}>423                <button424                  class="peach-sort-trash-btn"425                  onClick={props.onOpenTrash}426                  aria-label="View trash"427                  title="View deleted entries"428                >429                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">430                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />431                  </svg>432                </button>433              </Show>434              <Show when={props.onDeleteMany}>435                <button436                  class="peach-sort-trash-btn"437                  onClick={enterSelectMode}438                  aria-label="Select entries"439                  title="Select entries"440                >441                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">442                    <circle cx="4" cy="4" r="2.5" />443                    <circle cx="9" cy="4" r="2.5" />444                    <circle cx="4" cy="9" r="2.5" />445                    <circle cx="9" cy="9" r="2.5" />446                  </svg>447                </button>448              </Show>449            </>450          }451        >452          {/* Select mode sort bar */}453          <button454            class="peach-select-cancel-btn"455            onClick={exitSelectMode}456          >457            cancel458          </button>459          <span class="peach-select-count-label">460            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}461          </span>462          <button463            class="peach-select-all-btn"464            onClick={toggleSelectAll}465          >466            {allSelected() ? "none" : "all"}467          </button>468        </Show>469      </div>470471      <Show when={hasTags() && !selecting()}>472        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">473          <For each={allTags()}>474            {(tag) => (475              <button476                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}477                onClick={() => toggleTag(tag)}478              >479                {tag}480              </button>481            )}482          </For>483        </div>484      </Show>485486      <div487        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}488        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}489        onKeyDown={handleListKeydown}490        onScroll={handleListScroll}491      >492        <Show493          when={filteredEntries().length > 0}494          fallback={495            <div class="peach-list-empty">496              <span>no entries yet</span>497            </div>498          }499        >500          <Show when={isVirtualized()}>501            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />502          </Show>503          <For each={windowedEntries()}>504            {(entry, localIndex) => {505              const index = () => visibleRange().start + localIndex();506              const isNote = entry.type === "note";507              const isPasskey = entry.passkey !== undefined;508              const domain = entry.type === "note"509                ? (entry.notes?.slice(0, 40) ?? "note")510                : entry.url511                  ? getDisplayDomain(entry.url)512                  : entry.passkey?.rpId513                    ? entry.passkey.rpId514                  : "saved entry";515              const isTabMatch = () =>516                props.currentTabDomain !== undefined &&517                props.currentTabDomain !== "" &&518                entryMatchesDomain(entry, props.currentTabDomain);519              const isSelected = () => selectedIds().has(entry.id);520521              const ariaLabel = (() => {522                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";523                const sub = entry.username || domain || "";524                return sub525                  ? `${entry.title} — ${sub} (${tag})`526                  : `${entry.title} (${tag})`;527              })();528              return (529                <button530                  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" : ""}`}531                  style={isVirtualized()532                    ? { animation: "none" }533                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }534                  }535                  aria-label={ariaLabel}536                  aria-pressed={selecting() ? isSelected() : undefined}537                  onTouchStart={(e) => handleRowTouchStart(entry, e)}538                  onTouchMove={handleRowTouchMove}539                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}540                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}541                  onContextMenu={(e) => {542                    e.preventDefault();543                    // A real touch long-press already entered multi-select544                    // via the 500ms timer; the browser's synthesised545                    // contextmenu event would otherwise reopen the menu and546                    // undo the gesture. Desktop right-click has no `touchStart`547                    // to consume, so it still opens the menu.548                    if (touchStart !== null) { touchStart = null; return; }549                    if (!selecting()) setContextEntry(entry);550                  }}551                  onClick={() => {552                    if (suppressNextClick) { suppressNextClick = false; return; }553                    if (selecting()) {554                      toggleId(entry.id);555                    } else {556                      props.onSelectEntry(entry);557                    }558                  }}559                >560                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>561                    <Show562                      when={selecting()}563                      fallback={564                        <FaviconIcon565                          domain={entry.url ? getDisplayDomain(entry.url) : ""}566                          title={entry.title}567                          isNote={isNote}568                          isPasskey={isPasskey}569                        />570                      }571                    >572                      {/* Check indicator */}573                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">574                        <circle575                          cx="8" cy="8" r="7"576                          stroke="currentColor"577                          stroke-width="1"578                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}579                          opacity={isSelected() ? "1" : "0.3"}580                        />581                        <Show when={isSelected()}>582                          <path583                            d="M5 8.5L7 10.5L11 6"584                            stroke="white"585                            stroke-width="1.2"586                            stroke-linecap="round"587                            stroke-linejoin="round"588                          />589                        </Show>590                      </svg>591                    </Show>592                  </div>593                  <div class="peach-entry-row-info">594                    <span class="peach-entry-row-name">595                      {entry.title}596                      <Show when={isPasskey && !selecting()}>597                        <span class="peach-passkey-badge">passkey</span>598                      </Show>599                      <Show when={isTabMatch() && !selecting()}>600                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>601                      </Show>602                    </span>603                    <span class="peach-entry-row-meta">{domain}</span>604                  </div>605                  <Show when={entry.breach && !selecting()}>606                    <span class="peach-entry-row-dot" aria-label="breach"></span>607                  </Show>608                  <Show when={entry.canary && !selecting()}>609                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>610                  </Show>611                </button>612              );613            }}614          </For>615          <Show when={isVirtualized()}>616            <div617              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}618              aria-hidden="true"619            />620          </Show>621        </Show>622      </div>623624      {/* Bottom bar — new entry normally, delete action in select mode */}625      <Show626        when={selecting()}627        fallback={628          <button class="peach-new-entry-row" onClick={props.onAdd}>629            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">630              <path d="M5 1v8M1 5h8" />631            </svg>632            new entry633          </button>634        }635      >636        <button637          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}638          disabled={selectedIds().size === 0 || deleting()}639          onClick={() => void handleDeleteSelected()}640        >641          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">642            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />643          </svg>644          <Show645            when={selectedIds().size > 0}646            fallback={<span>select entries to delete</span>}647          >648            <span>649              {deleting()650                ? "moving to trash…"651                : `move ${selectedIds().size} to trash`}652            </span>653          </Show>654        </button>655      </Show>656657      <Show when={contextEntry() !== null}>658        <div659          class="peach-context-menu-backdrop"660          onClick={() => setContextEntry(null)}661          role="presentation"662        >663          <div664            class="peach-context-menu"665            role="menu"666            aria-label="Entry actions"667            onClick={(e) => e.stopPropagation()}668          >669            <Show when={contextEntry()!.username}>670              <button671                role="menuitem"672                class="peach-context-menu-item"673                onClick={async () => {674                  const e = contextEntry();675                  if (!e?.username) return;676                  await popupCopy(e.username, "username");677                  setContextEntry(null);678                }}679              >680                Copy username681              </button>682            </Show>683            <Show when={contextEntry()!.password}>684              <button685                role="menuitem"686                class="peach-context-menu-item"687                onClick={async () => {688                  const e = contextEntry();689                  if (!e?.password) return;690                  await popupCopy(e.password, "password");691                  setContextEntry(null);692                }}693              >694                Copy password695              </button>696            </Show>697            <Show when={contextEntry()!.totp}>698              <button699                role="menuitem"700                class="peach-context-menu-item"701                onClick={async () => {702                  const e = contextEntry();703                  if (!e?.totp) return;704                  await popupCopy(e.totp, "TOTP secret");705                  setContextEntry(null);706                }}707              >708                Copy TOTP secret709              </button>710            </Show>711            <button712              role="menuitem"713              class="peach-context-menu-item"714              onClick={() => {715                const e = contextEntry();716                if (e) props.onSelectEntry(e);717                setContextEntry(null);718              }}719            >720              Open721            </button>722            <Show when={props.onDeleteMany}>723              <button724                role="menuitem"725                class="peach-context-menu-item peach-context-menu-item--danger"726                onClick={async () => {727                  const e = contextEntry();728                  if (!e) return;729                  setContextEntry(null);730                  await props.onDeleteMany?.([e.id]);731                }}732              >733                Move to trash734              </button>735            </Show>736            <button737              role="menuitem"738              class="peach-context-menu-item peach-context-menu-item--cancel"739              onClick={() => setContextEntry(null)}740            >741              Cancel742            </button>743          </div>744        </div>745      </Show>746    </div>747  );748}