1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { popupCopy } from "../copy";56function vibrate(ms: number) {7 try { navigator.vibrate?.(ms); } catch { }8}910function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {11 if (props.isPasskey) return <PasskeyIcon />;12 if (props.isNote) return <NoteIcon />;13 return <InitialsChip title={props.title ?? props.domain ?? ""} />;14}1516171819202122function titleInitials(source: string): string {23 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();24 if (!cleaned) return "·";25 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);26 if (words.length >= 2 && words[0]![0] && words[1]![0]) {27 return (words[0]![0]! + words[1]![0]!).toUpperCase();28 }29 const single = words[0] ?? cleaned;30 return single.slice(0, 2).toUpperCase();31}32333435363738function titleBackground(source: string): string {39 let hash = 0;40 for (let i = 0; i < source.length; i++) {41 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;42 }43 const hue = ((hash % 360) + 360) % 360;44 return `hsl(${hue}, 32%, 26%)`;45}4647function InitialsChip(props: { title: string }) {48 return (49 <span50 class="peach-favicon-initials"51 style={{ background: titleBackground(props.title) }}52 aria-hidden="true"53 >54 {titleInitials(props.title)}55 </span>56 );57}585960const PasskeyIcon = () => (61 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">62 <circle cx="4" cy="4.5" r="3" />63 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />64 </svg>65);6667const NoteIcon = () => (68 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">69 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />70 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />71 </svg>72);7374export type SortOrder = "url-first" | "az" | "za" | "recent";7576interface VaultProps {77 entries: VaultEntry[];78 selectedEntryId?: string;79 currentTabDomain?: string;80 sortOrder: SortOrder;81 searchSeed?: string;82 searchSeedNonce?: number;83 84 85 86 isNative?: boolean;87 onSelectEntry: (entry: VaultEntry) => void;88 onSortOrderChange: (order: SortOrder) => void;89 onAdd: () => void;90 onClose: () => void;91 onOpenTrash?: () => void;92 onDeleteMany?: (ids: string[]) => Promise<void>;93}9495export function VaultView(props: VaultProps) {96 const [search, setSearch] = createSignal("");97 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());98 const [notesOnly, setNotesOnly] = createSignal(false);99 const [selecting, setSelecting] = createSignal(false);100 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());101 const [deleting, setDeleting] = createSignal(false);102 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);103 const [flashId, setFlashId] = createSignal<string | null>(null);104 const [scrollTop, setScrollTop] = createSignal(0);105 const [viewportHeight, setViewportHeight] = createSignal(600);106 let longPressTimer: number | null = null;107 let touchStart: { x: number; y: number; t: number } | null = null;108 let suppressNextClick = false;109110 function clearLongPressTimer() {111 if (longPressTimer !== null) {112 window.clearTimeout(longPressTimer);113 longPressTimer = null;114 }115 }116117 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {118 if (selecting()) return;119 const t = e.touches[0];120 if (!t) return;121 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };122 clearLongPressTimer();123 longPressTimer = window.setTimeout(() => {124 suppressNextClick = true;125 vibrate(20);126 setContextEntry(entry);127 longPressTimer = null;128 }, 500);129 }130131 function handleRowTouchMove(e: TouchEvent) {132 if (!touchStart) return;133 const t = e.touches[0];134 if (!t) return;135 const dx = Math.abs(t.clientX - touchStart.x);136 const dy = Math.abs(t.clientY - touchStart.y);137 if (dx > 8 || dy > 8) clearLongPressTimer();138 }139140 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {141 clearLongPressTimer();142 if (!touchStart) return;143 const t = e.changedTouches[0];144 if (!t) { touchStart = null; return; }145 const dx = t.clientX - touchStart.x;146 const dy = Math.abs(t.clientY - touchStart.y);147 const dt = Date.now() - touchStart.t;148 touchStart = null;149 150 const isCompact = !document.body.classList.contains("layout-medium")151 && !document.body.classList.contains("layout-expanded");152 if (153 isCompact154 && !selecting()155 && dx > 80156 && dy < 30157 && dt < 400158 && entry.type === "login"159 && entry.password160 ) {161 suppressNextClick = true;162 vibrate(15);163 setFlashId(entry.id);164 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);165 await popupCopy(entry.password, "password");166 }167 }168169 let searchRef: HTMLInputElement | undefined;170 let listRef: HTMLDivElement | undefined;171172 const focusSearch = (el: HTMLInputElement) => {173 searchRef = el;174 setTimeout(() => el.focus(), 50);175 };176177 createEffect(() => {178 void props.searchSeedNonce;179 const seed = props.searchSeed;180 if (!seed) return;181 setSearch(seed);182 requestAnimationFrame(() => {183 searchRef?.focus();184 searchRef?.setSelectionRange(seed.length, seed.length);185 });186 });187188 function enterSelectMode() {189 setSelecting(true);190 setSelectedIds(new Set<string>());191 }192193 function exitSelectMode() {194 setSelecting(false);195 setSelectedIds(new Set<string>());196 }197198 function toggleId(id: string) {199 const s = new Set<string>(selectedIds());200 if (s.has(id)) s.delete(id); else s.add(id);201 setSelectedIds(s);202 }203204 const allTags = createMemo(() => {205 const tagSet = new Set<string>();206 for (const entry of props.entries) {207 for (const tag of entry.tags ?? []) {208 tagSet.add(tag);209 }210 }211 return Array.from(tagSet).sort();212 });213214 const hasTags = createMemo(() => allTags().length > 0);215216 function toggleTag(tag: string) {217 const current = new Set(selectedTags());218 if (current.has(tag)) {219 current.delete(tag);220 } else {221 current.add(tag);222 }223 setSelectedTags(current);224 }225226 function fuzzyMatch(query: string, target: string): boolean {227 query = query.toLowerCase();228 target = target.toLowerCase();229 let qi = 0;230 for (let i = 0; i < target.length && qi < query.length; i++) {231 if (target[i] === query[qi]) qi++;232 }233 return qi === query.length;234 }235236 function isExactPrefix(query: string, target: string): boolean {237 return target.toLowerCase().startsWith(query.toLowerCase());238 }239240 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {241 if (!domain) return false;242 if (entry.passkey?.rpId === domain) return true;243 if (entry.type !== "login") return false;244 245 246 if (entry.url && extractDomain(entry.url) === domain) return true;247 for (const extra of entry.additionalUrls ?? []) {248 if (extractDomain(extra) === domain) return true;249 }250 return false;251 }252253 const filteredEntries = createMemo(() => {254 let result = props.entries;255256 if (notesOnly()) {257 result = result.filter(entry => entry.type === "note");258 }259260 if (selectedTags().size > 0) {261 result = result.filter(entry =>262 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))263 );264 }265266 const q = search().trim();267 if (q) {268 const searchableFor = (e: VaultEntry): string => {269 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;270 const passkeyText = e.passkey271 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`272 : "";273 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;274 };275 result = result276 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))277 .sort((a, b) => {278 const aSearchable = searchableFor(a).toLowerCase();279 const bSearchable = searchableFor(b).toLowerCase();280 const qLower = q.toLowerCase();281 const aExact = isExactPrefix(qLower, aSearchable);282 const bExact = isExactPrefix(qLower, bSearchable);283 if (aExact && !bExact) return -1;284 if (!aExact && bExact) return 1;285 return 0;286 });287 return result;288 }289290 const tabDomain = props.currentTabDomain ?? "";291 const order = props.sortOrder;292293 if (order === "az") {294 return [...result].sort((a, b) => a.title.localeCompare(b.title));295 }296 if (order === "za") {297 return [...result].sort((a, b) => b.title.localeCompare(a.title));298 }299 if (order === "recent") {300 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));301 }302 if (tabDomain) {303 return [...result].sort((a, b) => {304 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;305 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;306 return aMatch - bMatch;307 });308 }309 return result;310 });311312 313 314 315 316 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));317 const allSelected = createMemo(() =>318 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))319 );320321 function toggleSelectAll() {322 if (allSelected()) {323 setSelectedIds(new Set<string>());324 } else {325 setSelectedIds(new Set<string>(allVisibleIds()));326 }327 }328329 async function handleDeleteSelected() {330 const ids = Array.from(selectedIds());331 if (!ids.length || !props.onDeleteMany) return;332 setDeleting(true);333 await props.onDeleteMany(ids);334 setDeleting(false);335 exitSelectMode();336 }337338 339 340 341 342 343 const VIRTUAL_THRESHOLD = 100;344 const ROW_HEIGHT = 56;345 const BUFFER_ROWS = 20;346347 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);348349 const visibleRange = createMemo(() => {350 const total = filteredEntries().length;351 if (!isVirtualized()) return { start: 0, end: total };352 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);353 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);354 return { start, end };355 });356357 const windowedEntries = createMemo(() => {358 const { start, end } = visibleRange();359 return filteredEntries().slice(start, end);360 });361362 function handleListScroll(e: Event) {363 if (!isVirtualized()) return;364 const target = e.currentTarget as HTMLDivElement;365 setScrollTop(target.scrollTop);366 setViewportHeight(target.clientHeight);367 }368369 function handleSearchKeydown(e: KeyboardEvent) {370 if (e.key === "ArrowDown") {371 e.preventDefault();372 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");373 firstBtn?.focus();374 } else if (e.key === "ArrowUp") {375 e.preventDefault();376 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");377 allBtns?.[allBtns.length - 1]?.focus();378 } else if (e.key === "Enter") {379 e.preventDefault();380 const firstEntry = filteredEntries()[0];381 if (firstEntry) props.onSelectEntry(firstEntry);382 } else if (e.key === "Escape" && selecting()) {383 exitSelectMode();384 }385 }386387 function handleListKeydown(e: KeyboardEvent) {388 if (!listRef) return;389 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));390 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);391 if (e.key === "ArrowDown") {392 e.preventDefault();393 if (idx < btns.length - 1) btns[idx + 1]?.focus();394 } else if (e.key === "ArrowUp") {395 e.preventDefault();396 if (idx === 0) { searchRef?.focus(); }397 else btns[idx - 1]?.focus();398 } else if (e.key === "Escape") {399 if (selecting()) exitSelectMode();400 else props.onClose();401 }402 }403404 const sortLabels: { key: SortOrder; label: string }[] = [405 { key: "url-first", label: "smart" },406 { key: "az", label: "A–Z" },407 { key: "za", label: "Z–A" },408 { key: "recent", label: "newest" },409 ];410411 return (412 <div class="peach-overlay-view ov-enter-up" style="display:flex">413 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>414 <Show415 when={!props.isNative}416 fallback={417 <span class="peach-search-icon" aria-hidden="true">418 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">419 <circle cx="5.5" cy="5.5" r="3.75" />420 <path d="M8.75 8.75L11.5 11.5" />421 </svg>422 </span>423 }424 >425 <button426 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"427 onClick={props.onClose}428 aria-label="Back"429 >430 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">431 <path d="M6 1L1 6L6 11" />432 </svg>433 </button>434 </Show>435 <input436 class="peach-search-input"437 placeholder="search vault..."438 autocomplete="off"439 style={props.isNative ? undefined : "font-size:14px"}440 ref={focusSearch}441 value={search()}442 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}443 onKeyDown={handleSearchKeydown}444 />445 <span class="peach-search-counter peach-search-counter-list">446 {search() || selectedTags().size > 0447 ? `${filteredEntries().length} of ${props.entries.length}`448 : props.isNative449 ? `${props.entries.length}`450 : `${props.entries.length} entries`}451 </span>452 </div>453454 {}455 <div class="peach-sort-bar">456 <Show457 when={selecting()}458 fallback={459 <>460 <For each={sortLabels}>461 {({ key, label }) => (462 <button463 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}464 onClick={() => props.onSortOrderChange(key)}465 >466 {label}467 </button>468 )}469 </For>470 <span style="flex:1" />471 <button472 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}473 onClick={() => setNotesOnly(v => !v)}474 aria-label="Notes only"475 title="Show notes only"476 >477 <NoteIcon />478 </button>479 <Show when={props.onOpenTrash}>480 <button481 class="peach-sort-trash-btn"482 onClick={props.onOpenTrash}483 aria-label="View trash"484 title="View deleted entries"485 >486 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">487 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />488 </svg>489 </button>490 </Show>491 <Show when={props.onDeleteMany}>492 <button493 class="peach-sort-trash-btn"494 onClick={enterSelectMode}495 aria-label="Select entries"496 title="Select entries"497 >498 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">499 <circle cx="4" cy="4" r="2.5" />500 <circle cx="9" cy="4" r="2.5" />501 <circle cx="4" cy="9" r="2.5" />502 <circle cx="9" cy="9" r="2.5" />503 </svg>504 </button>505 </Show>506 </>507 }508 >509 {}510 <button511 class="peach-select-cancel-btn"512 onClick={exitSelectMode}513 >514 cancel515 </button>516 <span class="peach-select-count-label">517 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}518 </span>519 <button520 class="peach-select-all-btn"521 onClick={toggleSelectAll}522 >523 {allSelected() ? "none" : "all"}524 </button>525 </Show>526 </div>527528 <Show when={hasTags() && !selecting()}>529 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">530 <For each={allTags()}>531 {(tag) => (532 <button533 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}534 onClick={() => toggleTag(tag)}535 >536 {tag}537 </button>538 )}539 </For>540 </div>541 </Show>542543 <div544 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}545 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}546 onKeyDown={handleListKeydown}547 onScroll={handleListScroll}548 >549 <Show550 when={filteredEntries().length > 0}551 fallback={552 <div class="peach-list-empty">553 <span>no entries yet</span>554 </div>555 }556 >557 <Show when={isVirtualized()}>558 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />559 </Show>560 <For each={windowedEntries()}>561 {(entry, localIndex) => {562 const index = () => visibleRange().start + localIndex();563 const isNote = entry.type === "note";564 const isPasskey = entry.passkey !== undefined;565 const domain = entry.type === "note"566 ? (entry.notes?.slice(0, 40) ?? "note")567 : entry.url568 ? getDisplayDomain(entry.url)569 : entry.passkey?.rpId570 ? entry.passkey.rpId571 : "saved entry";572 const isTabMatch = () =>573 props.currentTabDomain !== undefined &&574 props.currentTabDomain !== "" &&575 entryMatchesDomain(entry, props.currentTabDomain);576 const isSelected = () => selectedIds().has(entry.id);577578 const ariaLabel = (() => {579 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";580 const sub = entry.username || domain || "";581 return sub582 ? `${entry.title} — ${sub} (${tag})`583 : `${entry.title} (${tag})`;584 })();585 return (586 <button587 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" : ""}`}588 style={isVirtualized()589 ? { animation: "none" }590 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }591 }592 aria-label={ariaLabel}593 aria-pressed={selecting() ? isSelected() : undefined}594 onTouchStart={(e) => handleRowTouchStart(entry, e)}595 onTouchMove={handleRowTouchMove}596 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}597 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}598 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}599 onClick={() => {600 if (suppressNextClick) { suppressNextClick = false; return; }601 if (selecting()) {602 toggleId(entry.id);603 } else {604 props.onSelectEntry(entry);605 }606 }}607 >608 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>609 <Show610 when={selecting()}611 fallback={612 <FaviconIcon613 domain={entry.url ? getDisplayDomain(entry.url) : ""}614 title={entry.title}615 isNote={isNote}616 isPasskey={isPasskey}617 />618 }619 >620 {}621 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">622 <circle623 cx="8" cy="8" r="7"624 stroke="currentColor"625 stroke-width="1"626 fill={isSelected() ? "var(--peach-accent)" : "transparent"}627 opacity={isSelected() ? "1" : "0.3"}628 />629 <Show when={isSelected()}>630 <path631 d="M5 8.5L7 10.5L11 6"632 stroke="white"633 stroke-width="1.2"634 stroke-linecap="round"635 stroke-linejoin="round"636 />637 </Show>638 </svg>639 </Show>640 </div>641 <div class="peach-entry-row-info">642 <span class="peach-entry-row-name">643 {entry.title}644 <Show when={isPasskey && !selecting()}>645 <span class="peach-passkey-badge">passkey</span>646 </Show>647 <Show when={isTabMatch() && !selecting()}>648 <span class="peach-tab-match-badge" title="Matches current tab">●</span>649 </Show>650 </span>651 <span class="peach-entry-row-meta">{domain}</span>652 </div>653 <Show when={entry.breach && !selecting()}>654 <span class="peach-entry-row-dot" aria-label="breach"></span>655 </Show>656 <Show when={entry.canary && !selecting()}>657 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>658 </Show>659 </button>660 );661 }}662 </For>663 <Show when={isVirtualized()}>664 <div665 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}666 aria-hidden="true"667 />668 </Show>669 </Show>670 </div>671672 {}673 <Show674 when={selecting()}675 fallback={676 <button class="peach-new-entry-row" onClick={props.onAdd}>677 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">678 <path d="M5 1v8M1 5h8" />679 </svg>680 new entry681 </button>682 }683 >684 <button685 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}686 disabled={selectedIds().size === 0 || deleting()}687 onClick={() => void handleDeleteSelected()}688 >689 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">690 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />691 </svg>692 <Show693 when={selectedIds().size > 0}694 fallback={<span>select entries to delete</span>}695 >696 <span>697 {deleting()698 ? "moving to trash…"699 : `move ${selectedIds().size} to trash`}700 </span>701 </Show>702 </button>703 </Show>704705 <Show when={contextEntry() !== null}>706 <div707 class="peach-context-menu-backdrop"708 onClick={() => setContextEntry(null)}709 role="presentation"710 >711 <div712 class="peach-context-menu"713 role="menu"714 aria-label="Entry actions"715 onClick={(e) => e.stopPropagation()}716 >717 <Show when={contextEntry()!.username}>718 <button719 role="menuitem"720 class="peach-context-menu-item"721 onClick={async () => {722 const e = contextEntry();723 if (!e?.username) return;724 await popupCopy(e.username, "username");725 setContextEntry(null);726 }}727 >728 Copy username729 </button>730 </Show>731 <Show when={contextEntry()!.password}>732 <button733 role="menuitem"734 class="peach-context-menu-item"735 onClick={async () => {736 const e = contextEntry();737 if (!e?.password) return;738 await popupCopy(e.password, "password");739 setContextEntry(null);740 }}741 >742 Copy password743 </button>744 </Show>745 <Show when={contextEntry()!.totp}>746 <button747 role="menuitem"748 class="peach-context-menu-item"749 onClick={async () => {750 const e = contextEntry();751 if (!e?.totp) return;752 await popupCopy(e.totp, "TOTP secret");753 setContextEntry(null);754 }}755 >756 Copy TOTP secret757 </button>758 </Show>759 <button760 role="menuitem"761 class="peach-context-menu-item"762 onClick={() => {763 const e = contextEntry();764 if (e) props.onSelectEntry(e);765 setContextEntry(null);766 }}767 >768 Open769 </button>770 <Show when={props.onDeleteMany}>771 <button772 role="menuitem"773 class="peach-context-menu-item peach-context-menu-item--danger"774 onClick={async () => {775 const e = contextEntry();776 if (!e) return;777 setContextEntry(null);778 await props.onDeleteMany?.([e.id]);779 }}780 >781 Move to trash782 </button>783 </Show>784 <button785 role="menuitem"786 class="peach-context-menu-item peach-context-menu-item--cancel"787 onClick={() => setContextEntry(null)}788 >789 Cancel790 </button>791 </div>792 </div>793 </Show>794 </div>795 );796}