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, titleInitials, titleBackground } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8 try { navigator.vibrate?.(ms); } catch { }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 18 19 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 38 39 if (img.naturalWidth <= 16) return;40 41 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}585960function InitialsChip(props: { title: string }) {61 return (62 <span63 class="peach-favicon-initials"64 style={{ background: titleBackground(props.title) }}65 aria-hidden="true"66 >67 {titleInitials(props.title)}68 </span>69 );70}717273const PasskeyIcon = () => (74 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">75 <circle cx="4" cy="4.5" r="3" />76 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />77 </svg>78);7980const NoteIcon = () => (81 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">82 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />83 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />84 </svg>85);8687export type SortOrder = "url-first" | "az" | "za" | "recent";8889interface VaultProps {90 entries: VaultEntry[];91 selectedEntryId?: string;92 currentTabDomain?: string;93 sortOrder: SortOrder;94 searchSeed?: string;95 searchSeedNonce?: number;96 97 98 99 isNative?: boolean;100 onSelectEntry: (entry: VaultEntry) => void;101 onSortOrderChange: (order: SortOrder) => void;102 onAdd: () => void;103 onClose: () => void;104 onOpenTrash?: () => void;105 onDeleteMany?: (ids: string[]) => Promise<void>;106}107108export function VaultView(props: VaultProps) {109 const [search, setSearch] = createSignal("");110 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());111 const [notesOnly, setNotesOnly] = createSignal(false);112 const [selecting, setSelecting] = createSignal(false);113 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());114 const [deleting, setDeleting] = createSignal(false);115 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);116 const [flashId, setFlashId] = createSignal<string | null>(null);117 const [scrollTop, setScrollTop] = createSignal(0);118 const [viewportHeight, setViewportHeight] = createSignal(600);119 let longPressTimer: number | null = null;120 let touchStart: { x: number; y: number; t: number } | null = null;121 let suppressNextClick = false;122123 function clearLongPressTimer() {124 if (longPressTimer !== null) {125 window.clearTimeout(longPressTimer);126 longPressTimer = null;127 }128 }129130 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {131 if (selecting()) return;132 const t = e.touches[0];133 if (!t) return;134 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };135 clearLongPressTimer();136 longPressTimer = window.setTimeout(() => {137 suppressNextClick = true;138 vibrate(20);139 setContextEntry(entry);140 longPressTimer = null;141 }, 500);142 }143144 function handleRowTouchMove(e: TouchEvent) {145 if (!touchStart) return;146 const t = e.touches[0];147 if (!t) return;148 const dx = Math.abs(t.clientX - touchStart.x);149 const dy = Math.abs(t.clientY - touchStart.y);150 if (dx > 8 || dy > 8) clearLongPressTimer();151 }152153 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {154 clearLongPressTimer();155 if (!touchStart) return;156 const t = e.changedTouches[0];157 if (!t) { touchStart = null; return; }158 const dx = t.clientX - touchStart.x;159 const dy = Math.abs(t.clientY - touchStart.y);160 const dt = Date.now() - touchStart.t;161 touchStart = null;162 163 const isCompact = !document.body.classList.contains("layout-medium")164 && !document.body.classList.contains("layout-expanded");165 if (166 isCompact167 && !selecting()168 && dx > 80169 && dy < 30170 && dt < 400171 && entry.type === "login"172 && entry.password173 ) {174 suppressNextClick = true;175 vibrate(15);176 setFlashId(entry.id);177 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);178 await popupCopy(entry.password, "password");179 }180 }181182 let searchRef: HTMLInputElement | undefined;183 let listRef: HTMLDivElement | undefined;184185 const focusSearch = (el: HTMLInputElement) => {186 searchRef = el;187 setTimeout(() => el.focus(), 50);188 };189190 createEffect(() => {191 void props.searchSeedNonce;192 const seed = props.searchSeed;193 if (!seed) return;194 setSearch(seed);195 requestAnimationFrame(() => {196 searchRef?.focus();197 searchRef?.setSelectionRange(seed.length, seed.length);198 });199 });200201 function enterSelectMode() {202 setSelecting(true);203 setSelectedIds(new Set<string>());204 }205206 function exitSelectMode() {207 setSelecting(false);208 setSelectedIds(new Set<string>());209 }210211 function toggleId(id: string) {212 const s = new Set<string>(selectedIds());213 if (s.has(id)) s.delete(id); else s.add(id);214 setSelectedIds(s);215 }216217 const allTags = createMemo(() => {218 const tagSet = new Set<string>();219 for (const entry of props.entries) {220 for (const tag of entry.tags ?? []) {221 tagSet.add(tag);222 }223 }224 return Array.from(tagSet).sort();225 });226227 const hasTags = createMemo(() => allTags().length > 0);228229 function toggleTag(tag: string) {230 const current = new Set(selectedTags());231 if (current.has(tag)) {232 current.delete(tag);233 } else {234 current.add(tag);235 }236 setSelectedTags(current);237 }238239 function fuzzyMatch(query: string, target: string): boolean {240 query = query.toLowerCase();241 target = target.toLowerCase();242 let qi = 0;243 for (let i = 0; i < target.length && qi < query.length; i++) {244 if (target[i] === query[qi]) qi++;245 }246 return qi === query.length;247 }248249 function isExactPrefix(query: string, target: string): boolean {250 return target.toLowerCase().startsWith(query.toLowerCase());251 }252253 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {254 if (!domain) return false;255 if (entry.passkey?.rpId === domain) return true;256 if (entry.type !== "login") return false;257 258 259 if (entry.url && extractDomain(entry.url) === domain) return true;260 for (const extra of entry.additionalUrls ?? []) {261 if (extractDomain(extra) === domain) return true;262 }263 return false;264 }265266 const filteredEntries = createMemo(() => {267 let result = props.entries;268269 if (notesOnly()) {270 result = result.filter(entry => entry.type === "note");271 }272273 if (selectedTags().size > 0) {274 result = result.filter(entry =>275 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))276 );277 }278279 const q = search().trim();280 if (q) {281 const searchableFor = (e: VaultEntry): string => {282 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;283 const passkeyText = e.passkey284 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`285 : "";286 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;287 };288 result = result289 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))290 .sort((a, b) => {291 const aSearchable = searchableFor(a).toLowerCase();292 const bSearchable = searchableFor(b).toLowerCase();293 const qLower = q.toLowerCase();294 const aExact = isExactPrefix(qLower, aSearchable);295 const bExact = isExactPrefix(qLower, bSearchable);296 if (aExact && !bExact) return -1;297 if (!aExact && bExact) return 1;298 return 0;299 });300 return result;301 }302303 const tabDomain = props.currentTabDomain ?? "";304 const order = props.sortOrder;305306 if (order === "az") {307 return [...result].sort((a, b) => a.title.localeCompare(b.title));308 }309 if (order === "za") {310 return [...result].sort((a, b) => b.title.localeCompare(a.title));311 }312 if (order === "recent") {313 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));314 }315 if (tabDomain) {316 return [...result].sort((a, b) => {317 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;318 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;319 return aMatch - bMatch;320 });321 }322 return result;323 });324325 326 327 328 329 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));330 const allSelected = createMemo(() =>331 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))332 );333334 function toggleSelectAll() {335 if (allSelected()) {336 setSelectedIds(new Set<string>());337 } else {338 setSelectedIds(new Set<string>(allVisibleIds()));339 }340 }341342 async function handleDeleteSelected() {343 const ids = Array.from(selectedIds());344 if (!ids.length || !props.onDeleteMany) return;345 setDeleting(true);346 await props.onDeleteMany(ids);347 setDeleting(false);348 exitSelectMode();349 }350351 352 353 354 355 356 const VIRTUAL_THRESHOLD = 100;357 const ROW_HEIGHT = 56;358 const BUFFER_ROWS = 20;359360 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);361362 const visibleRange = createMemo(() => {363 const total = filteredEntries().length;364 if (!isVirtualized()) return { start: 0, end: total };365 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);366 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);367 return { start, end };368 });369370 const windowedEntries = createMemo(() => {371 const { start, end } = visibleRange();372 return filteredEntries().slice(start, end);373 });374375 function handleListScroll(e: Event) {376 if (!isVirtualized()) return;377 const target = e.currentTarget as HTMLDivElement;378 setScrollTop(target.scrollTop);379 setViewportHeight(target.clientHeight);380 }381382 function handleSearchKeydown(e: KeyboardEvent) {383 if (e.key === "ArrowDown") {384 e.preventDefault();385 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");386 firstBtn?.focus();387 } else if (e.key === "ArrowUp") {388 e.preventDefault();389 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");390 allBtns?.[allBtns.length - 1]?.focus();391 } else if (e.key === "Enter") {392 e.preventDefault();393 const firstEntry = filteredEntries()[0];394 if (firstEntry) props.onSelectEntry(firstEntry);395 } else if (e.key === "Escape" && selecting()) {396 exitSelectMode();397 }398 }399400 function handleListKeydown(e: KeyboardEvent) {401 if (!listRef) return;402 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));403 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);404 if (e.key === "ArrowDown") {405 e.preventDefault();406 if (idx < btns.length - 1) btns[idx + 1]?.focus();407 } else if (e.key === "ArrowUp") {408 e.preventDefault();409 if (idx === 0) { searchRef?.focus(); }410 else btns[idx - 1]?.focus();411 } else if (e.key === "Escape") {412 if (selecting()) exitSelectMode();413 else props.onClose();414 }415 }416417 const sortLabels: { key: SortOrder; label: string }[] = [418 { key: "url-first", label: "smart" },419 { key: "az", label: "A–Z" },420 { key: "za", label: "Z–A" },421 { key: "recent", label: "newest" },422 ];423424 return (425 <div class="peach-overlay-view ov-enter-up" style="display:flex">426 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>427 <Show428 when={!props.isNative}429 fallback={430 <span class="peach-search-icon" aria-hidden="true">431 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">432 <circle cx="5.5" cy="5.5" r="3.75" />433 <path d="M8.75 8.75L11.5 11.5" />434 </svg>435 </span>436 }437 >438 <button439 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"440 onClick={props.onClose}441 aria-label="Back"442 >443 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">444 <path d="M6 1L1 6L6 11" />445 </svg>446 </button>447 </Show>448 <input449 class="peach-search-input"450 placeholder="search vault..."451 autocomplete="off"452 style={props.isNative ? undefined : "font-size:14px"}453 ref={focusSearch}454 value={search()}455 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}456 onKeyDown={handleSearchKeydown}457 />458 <span class="peach-search-counter peach-search-counter-list">459 {search() || selectedTags().size > 0460 ? `${filteredEntries().length} of ${props.entries.length}`461 : props.isNative462 ? `${props.entries.length}`463 : `${props.entries.length} entries`}464 </span>465 </div>466467 {}468 <div class="peach-sort-bar">469 <Show470 when={selecting()}471 fallback={472 <>473 <For each={sortLabels}>474 {({ key, label }) => (475 <button476 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}477 onClick={() => props.onSortOrderChange(key)}478 >479 {label}480 </button>481 )}482 </For>483 <span style="flex:1" />484 <button485 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}486 onClick={() => setNotesOnly(v => !v)}487 aria-label="Notes only"488 title="Show notes only"489 >490 <NoteIcon />491 </button>492 <Show when={props.onOpenTrash}>493 <button494 class="peach-sort-trash-btn"495 onClick={props.onOpenTrash}496 aria-label="View trash"497 title="View deleted entries"498 >499 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">500 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />501 </svg>502 </button>503 </Show>504 <Show when={props.onDeleteMany}>505 <button506 class="peach-sort-trash-btn"507 onClick={enterSelectMode}508 aria-label="Select entries"509 title="Select entries"510 >511 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">512 <circle cx="4" cy="4" r="2.5" />513 <circle cx="9" cy="4" r="2.5" />514 <circle cx="4" cy="9" r="2.5" />515 <circle cx="9" cy="9" r="2.5" />516 </svg>517 </button>518 </Show>519 </>520 }521 >522 {}523 <button524 class="peach-select-cancel-btn"525 onClick={exitSelectMode}526 >527 cancel528 </button>529 <span class="peach-select-count-label">530 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}531 </span>532 <button533 class="peach-select-all-btn"534 onClick={toggleSelectAll}535 >536 {allSelected() ? "none" : "all"}537 </button>538 </Show>539 </div>540541 <Show when={hasTags() && !selecting()}>542 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">543 <For each={allTags()}>544 {(tag) => (545 <button546 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}547 onClick={() => toggleTag(tag)}548 >549 {tag}550 </button>551 )}552 </For>553 </div>554 </Show>555556 <div557 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}558 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}559 onKeyDown={handleListKeydown}560 onScroll={handleListScroll}561 >562 <Show563 when={filteredEntries().length > 0}564 fallback={565 <div class="peach-list-empty">566 <span>no entries yet</span>567 </div>568 }569 >570 <Show when={isVirtualized()}>571 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />572 </Show>573 <For each={windowedEntries()}>574 {(entry, localIndex) => {575 const index = () => visibleRange().start + localIndex();576 const isNote = entry.type === "note";577 const isPasskey = entry.passkey !== undefined;578 const domain = entry.type === "note"579 ? (entry.notes?.slice(0, 40) ?? "note")580 : entry.url581 ? getDisplayDomain(entry.url)582 : entry.passkey?.rpId583 ? entry.passkey.rpId584 : "saved entry";585 const isTabMatch = () =>586 props.currentTabDomain !== undefined &&587 props.currentTabDomain !== "" &&588 entryMatchesDomain(entry, props.currentTabDomain);589 const isSelected = () => selectedIds().has(entry.id);590591 const ariaLabel = (() => {592 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";593 const sub = entry.username || domain || "";594 return sub595 ? `${entry.title} — ${sub} (${tag})`596 : `${entry.title} (${tag})`;597 })();598 return (599 <button600 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" : ""}`}601 style={isVirtualized()602 ? { animation: "none" }603 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }604 }605 aria-label={ariaLabel}606 aria-pressed={selecting() ? isSelected() : undefined}607 onTouchStart={(e) => handleRowTouchStart(entry, e)}608 onTouchMove={handleRowTouchMove}609 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}610 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}611 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}612 onClick={() => {613 if (suppressNextClick) { suppressNextClick = false; return; }614 if (selecting()) {615 toggleId(entry.id);616 } else {617 props.onSelectEntry(entry);618 }619 }}620 >621 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>622 <Show623 when={selecting()}624 fallback={625 <FaviconIcon626 domain={entry.url ? getDisplayDomain(entry.url) : ""}627 title={entry.title}628 isNote={isNote}629 isPasskey={isPasskey}630 />631 }632 >633 {}634 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">635 <circle636 cx="8" cy="8" r="7"637 stroke="currentColor"638 stroke-width="1"639 fill={isSelected() ? "var(--peach-accent)" : "transparent"}640 opacity={isSelected() ? "1" : "0.3"}641 />642 <Show when={isSelected()}>643 <path644 d="M5 8.5L7 10.5L11 6"645 stroke="white"646 stroke-width="1.2"647 stroke-linecap="round"648 stroke-linejoin="round"649 />650 </Show>651 </svg>652 </Show>653 </div>654 <div class="peach-entry-row-info">655 <span class="peach-entry-row-name">656 {entry.title}657 <Show when={isPasskey && !selecting()}>658 <span class="peach-passkey-badge">passkey</span>659 </Show>660 <Show when={isTabMatch() && !selecting()}>661 <span class="peach-tab-match-badge" title="Matches current tab">●</span>662 </Show>663 </span>664 <span class="peach-entry-row-meta">{domain}</span>665 </div>666 <Show when={entry.breach && !selecting()}>667 <span class="peach-entry-row-dot" aria-label="breach"></span>668 </Show>669 <Show when={entry.canary && !selecting()}>670 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>671 </Show>672 </button>673 );674 }}675 </For>676 <Show when={isVirtualized()}>677 <div678 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}679 aria-hidden="true"680 />681 </Show>682 </Show>683 </div>684685 {}686 <Show687 when={selecting()}688 fallback={689 <button class="peach-new-entry-row" onClick={props.onAdd}>690 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">691 <path d="M5 1v8M1 5h8" />692 </svg>693 new entry694 </button>695 }696 >697 <button698 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}699 disabled={selectedIds().size === 0 || deleting()}700 onClick={() => void handleDeleteSelected()}701 >702 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">703 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />704 </svg>705 <Show706 when={selectedIds().size > 0}707 fallback={<span>select entries to delete</span>}708 >709 <span>710 {deleting()711 ? "moving to trash…"712 : `move ${selectedIds().size} to trash`}713 </span>714 </Show>715 </button>716 </Show>717718 <Show when={contextEntry() !== null}>719 <div720 class="peach-context-menu-backdrop"721 onClick={() => setContextEntry(null)}722 role="presentation"723 >724 <div725 class="peach-context-menu"726 role="menu"727 aria-label="Entry actions"728 onClick={(e) => e.stopPropagation()}729 >730 <Show when={contextEntry()!.username}>731 <button732 role="menuitem"733 class="peach-context-menu-item"734 onClick={async () => {735 const e = contextEntry();736 if (!e?.username) return;737 await popupCopy(e.username, "username");738 setContextEntry(null);739 }}740 >741 Copy username742 </button>743 </Show>744 <Show when={contextEntry()!.password}>745 <button746 role="menuitem"747 class="peach-context-menu-item"748 onClick={async () => {749 const e = contextEntry();750 if (!e?.password) return;751 await popupCopy(e.password, "password");752 setContextEntry(null);753 }}754 >755 Copy password756 </button>757 </Show>758 <Show when={contextEntry()!.totp}>759 <button760 role="menuitem"761 class="peach-context-menu-item"762 onClick={async () => {763 const e = contextEntry();764 if (!e?.totp) return;765 await popupCopy(e.totp, "TOTP secret");766 setContextEntry(null);767 }}768 >769 Copy TOTP secret770 </button>771 </Show>772 <button773 role="menuitem"774 class="peach-context-menu-item"775 onClick={() => {776 const e = contextEntry();777 if (e) props.onSelectEntry(e);778 setContextEntry(null);779 }}780 >781 Open782 </button>783 <Show when={props.onDeleteMany}>784 <button785 role="menuitem"786 class="peach-context-menu-item peach-context-menu-item--danger"787 onClick={async () => {788 const e = contextEntry();789 if (!e) return;790 setContextEntry(null);791 await props.onDeleteMany?.([e.id]);792 }}793 >794 Move to trash795 </button>796 </Show>797 <button798 role="menuitem"799 class="peach-context-menu-item peach-context-menu-item--cancel"800 onClick={() => setContextEntry(null)}801 >802 Cancel803 </button>804 </div>805 </div>806 </Show>807 </div>808 );809}