1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8 try { navigator.vibrate?.(ms); } catch { }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 16 17 18 19 const fallback = props.isPasskey20 ? <PasskeyIcon />21 : props.isNote22 ? <NoteIcon />23 : <InitialsChip title={props.title ?? props.domain ?? ""} />;24 const [failed, setFailed] = createSignal(false);25 return (26 <Show when={src && !failed()} fallback={fallback}>27 <img28 src={src!}29 width="16"30 height="16"31 alt=""32 style="border-radius:3px;"33 onError={() => setFailed(true)}34 />35 </Show>36 );37}3839404142434445function titleInitials(source: string): string {46 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();47 if (!cleaned) return "·";48 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);49 if (words.length >= 2 && words[0]![0] && words[1]![0]) {50 return (words[0]![0]! + words[1]![0]!).toUpperCase();51 }52 const single = words[0] ?? cleaned;53 return single.slice(0, 2).toUpperCase();54}55565758596061function titleBackground(source: string): string {62 let hash = 0;63 for (let i = 0; i < source.length; i++) {64 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;65 }66 const hue = ((hash % 360) + 360) % 360;67 return `hsl(${hue}, 32%, 26%)`;68}6970function InitialsChip(props: { title: string }) {71 return (72 <span73 class="peach-favicon-initials"74 style={{ background: titleBackground(props.title) }}75 aria-hidden="true"76 >77 {titleInitials(props.title)}78 </span>79 );80}818283const PasskeyIcon = () => (84 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">85 <circle cx="4" cy="4.5" r="3" />86 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />87 </svg>88);8990const NoteIcon = () => (91 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">92 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />93 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />94 </svg>95);9697export type SortOrder = "url-first" | "az" | "za" | "recent";9899interface VaultProps {100 entries: VaultEntry[];101 selectedEntryId?: string;102 currentTabDomain?: string;103 sortOrder: SortOrder;104 searchSeed?: string;105 searchSeedNonce?: number;106 107 108 109 isNative?: boolean;110 onSelectEntry: (entry: VaultEntry) => void;111 onSortOrderChange: (order: SortOrder) => void;112 onAdd: () => void;113 onClose: () => void;114 onOpenTrash?: () => void;115 onDeleteMany?: (ids: string[]) => Promise<void>;116}117118export function VaultView(props: VaultProps) {119 const [search, setSearch] = createSignal("");120 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());121 const [notesOnly, setNotesOnly] = createSignal(false);122 const [selecting, setSelecting] = createSignal(false);123 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());124 const [deleting, setDeleting] = createSignal(false);125 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);126 const [flashId, setFlashId] = createSignal<string | null>(null);127 const [scrollTop, setScrollTop] = createSignal(0);128 const [viewportHeight, setViewportHeight] = createSignal(600);129 let longPressTimer: number | null = null;130 let touchStart: { x: number; y: number; t: number } | null = null;131 let suppressNextClick = false;132133 function clearLongPressTimer() {134 if (longPressTimer !== null) {135 window.clearTimeout(longPressTimer);136 longPressTimer = null;137 }138 }139140 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {141 if (selecting()) return;142 const t = e.touches[0];143 if (!t) return;144 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };145 clearLongPressTimer();146 longPressTimer = window.setTimeout(() => {147 suppressNextClick = true;148 vibrate(20);149 setContextEntry(entry);150 longPressTimer = null;151 }, 500);152 }153154 function handleRowTouchMove(e: TouchEvent) {155 if (!touchStart) return;156 const t = e.touches[0];157 if (!t) return;158 const dx = Math.abs(t.clientX - touchStart.x);159 const dy = Math.abs(t.clientY - touchStart.y);160 if (dx > 8 || dy > 8) clearLongPressTimer();161 }162163 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {164 clearLongPressTimer();165 if (!touchStart) return;166 const t = e.changedTouches[0];167 if (!t) { touchStart = null; return; }168 const dx = t.clientX - touchStart.x;169 const dy = Math.abs(t.clientY - touchStart.y);170 const dt = Date.now() - touchStart.t;171 touchStart = null;172 173 const isCompact = !document.body.classList.contains("layout-medium")174 && !document.body.classList.contains("layout-expanded");175 if (176 isCompact177 && !selecting()178 && dx > 80179 && dy < 30180 && dt < 400181 && entry.type === "login"182 && entry.password183 ) {184 suppressNextClick = true;185 vibrate(15);186 setFlashId(entry.id);187 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);188 await popupCopy(entry.password, "password");189 }190 }191192 let searchRef: HTMLInputElement | undefined;193 let listRef: HTMLDivElement | undefined;194195 const focusSearch = (el: HTMLInputElement) => {196 searchRef = el;197 setTimeout(() => el.focus(), 50);198 };199200 createEffect(() => {201 void props.searchSeedNonce;202 const seed = props.searchSeed;203 if (!seed) return;204 setSearch(seed);205 requestAnimationFrame(() => {206 searchRef?.focus();207 searchRef?.setSelectionRange(seed.length, seed.length);208 });209 });210211 function enterSelectMode() {212 setSelecting(true);213 setSelectedIds(new Set<string>());214 }215216 function exitSelectMode() {217 setSelecting(false);218 setSelectedIds(new Set<string>());219 }220221 function toggleId(id: string) {222 const s = new Set<string>(selectedIds());223 if (s.has(id)) s.delete(id); else s.add(id);224 setSelectedIds(s);225 }226227 const allTags = createMemo(() => {228 const tagSet = new Set<string>();229 for (const entry of props.entries) {230 for (const tag of entry.tags ?? []) {231 tagSet.add(tag);232 }233 }234 return Array.from(tagSet).sort();235 });236237 const hasTags = createMemo(() => allTags().length > 0);238239 function toggleTag(tag: string) {240 const current = new Set(selectedTags());241 if (current.has(tag)) {242 current.delete(tag);243 } else {244 current.add(tag);245 }246 setSelectedTags(current);247 }248249 function fuzzyMatch(query: string, target: string): boolean {250 query = query.toLowerCase();251 target = target.toLowerCase();252 let qi = 0;253 for (let i = 0; i < target.length && qi < query.length; i++) {254 if (target[i] === query[qi]) qi++;255 }256 return qi === query.length;257 }258259 function isExactPrefix(query: string, target: string): boolean {260 return target.toLowerCase().startsWith(query.toLowerCase());261 }262263 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {264 if (!domain) return false;265 if (entry.passkey?.rpId === domain) return true;266 if (entry.type !== "login") return false;267 268 269 if (entry.url && extractDomain(entry.url) === domain) return true;270 for (const extra of entry.additionalUrls ?? []) {271 if (extractDomain(extra) === domain) return true;272 }273 return false;274 }275276 const filteredEntries = createMemo(() => {277 let result = props.entries;278279 if (notesOnly()) {280 result = result.filter(entry => entry.type === "note");281 }282283 if (selectedTags().size > 0) {284 result = result.filter(entry =>285 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))286 );287 }288289 const q = search().trim();290 if (q) {291 const searchableFor = (e: VaultEntry): string => {292 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;293 const passkeyText = e.passkey294 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`295 : "";296 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;297 };298 result = result299 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))300 .sort((a, b) => {301 const aSearchable = searchableFor(a).toLowerCase();302 const bSearchable = searchableFor(b).toLowerCase();303 const qLower = q.toLowerCase();304 const aExact = isExactPrefix(qLower, aSearchable);305 const bExact = isExactPrefix(qLower, bSearchable);306 if (aExact && !bExact) return -1;307 if (!aExact && bExact) return 1;308 return 0;309 });310 return result;311 }312313 const tabDomain = props.currentTabDomain ?? "";314 const order = props.sortOrder;315316 if (order === "az") {317 return [...result].sort((a, b) => a.title.localeCompare(b.title));318 }319 if (order === "za") {320 return [...result].sort((a, b) => b.title.localeCompare(a.title));321 }322 if (order === "recent") {323 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));324 }325 if (tabDomain) {326 return [...result].sort((a, b) => {327 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;328 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;329 return aMatch - bMatch;330 });331 }332 return result;333 });334335 336 337 338 339 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));340 const allSelected = createMemo(() =>341 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))342 );343344 function toggleSelectAll() {345 if (allSelected()) {346 setSelectedIds(new Set<string>());347 } else {348 setSelectedIds(new Set<string>(allVisibleIds()));349 }350 }351352 async function handleDeleteSelected() {353 const ids = Array.from(selectedIds());354 if (!ids.length || !props.onDeleteMany) return;355 setDeleting(true);356 await props.onDeleteMany(ids);357 setDeleting(false);358 exitSelectMode();359 }360361 362 363 364 365 366 const VIRTUAL_THRESHOLD = 100;367 const ROW_HEIGHT = 56;368 const BUFFER_ROWS = 20;369370 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);371372 const visibleRange = createMemo(() => {373 const total = filteredEntries().length;374 if (!isVirtualized()) return { start: 0, end: total };375 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);376 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);377 return { start, end };378 });379380 const windowedEntries = createMemo(() => {381 const { start, end } = visibleRange();382 return filteredEntries().slice(start, end);383 });384385 function handleListScroll(e: Event) {386 if (!isVirtualized()) return;387 const target = e.currentTarget as HTMLDivElement;388 setScrollTop(target.scrollTop);389 setViewportHeight(target.clientHeight);390 }391392 function handleSearchKeydown(e: KeyboardEvent) {393 if (e.key === "ArrowDown") {394 e.preventDefault();395 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");396 firstBtn?.focus();397 } else if (e.key === "ArrowUp") {398 e.preventDefault();399 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");400 allBtns?.[allBtns.length - 1]?.focus();401 } else if (e.key === "Enter") {402 e.preventDefault();403 const firstEntry = filteredEntries()[0];404 if (firstEntry) props.onSelectEntry(firstEntry);405 } else if (e.key === "Escape" && selecting()) {406 exitSelectMode();407 }408 }409410 function handleListKeydown(e: KeyboardEvent) {411 if (!listRef) return;412 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));413 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);414 if (e.key === "ArrowDown") {415 e.preventDefault();416 if (idx < btns.length - 1) btns[idx + 1]?.focus();417 } else if (e.key === "ArrowUp") {418 e.preventDefault();419 if (idx === 0) { searchRef?.focus(); }420 else btns[idx - 1]?.focus();421 } else if (e.key === "Escape") {422 if (selecting()) exitSelectMode();423 else props.onClose();424 }425 }426427 const sortLabels: { key: SortOrder; label: string }[] = [428 { key: "url-first", label: "smart" },429 { key: "az", label: "A–Z" },430 { key: "za", label: "Z–A" },431 { key: "recent", label: "newest" },432 ];433434 return (435 <div class="peach-overlay-view ov-enter-up" style="display:flex">436 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>437 <Show438 when={!props.isNative}439 fallback={440 <span class="peach-search-icon" aria-hidden="true">441 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">442 <circle cx="5.5" cy="5.5" r="3.75" />443 <path d="M8.75 8.75L11.5 11.5" />444 </svg>445 </span>446 }447 >448 <button449 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"450 onClick={props.onClose}451 aria-label="Back"452 >453 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">454 <path d="M6 1L1 6L6 11" />455 </svg>456 </button>457 </Show>458 <input459 class="peach-search-input"460 placeholder="search vault..."461 autocomplete="off"462 style={props.isNative ? undefined : "font-size:14px"}463 ref={focusSearch}464 value={search()}465 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}466 onKeyDown={handleSearchKeydown}467 />468 <span class="peach-search-counter peach-search-counter-list">469 {search() || selectedTags().size > 0470 ? `${filteredEntries().length} of ${props.entries.length}`471 : props.isNative472 ? `${props.entries.length}`473 : `${props.entries.length} entries`}474 </span>475 </div>476477 {}478 <div class="peach-sort-bar">479 <Show480 when={selecting()}481 fallback={482 <>483 <For each={sortLabels}>484 {({ key, label }) => (485 <button486 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}487 onClick={() => props.onSortOrderChange(key)}488 >489 {label}490 </button>491 )}492 </For>493 <span style="flex:1" />494 <button495 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}496 onClick={() => setNotesOnly(v => !v)}497 aria-label="Notes only"498 title="Show notes only"499 >500 <NoteIcon />501 </button>502 <Show when={props.onOpenTrash}>503 <button504 class="peach-sort-trash-btn"505 onClick={props.onOpenTrash}506 aria-label="View trash"507 title="View deleted entries"508 >509 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">510 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />511 </svg>512 </button>513 </Show>514 <Show when={props.onDeleteMany}>515 <button516 class="peach-sort-trash-btn"517 onClick={enterSelectMode}518 aria-label="Select entries"519 title="Select entries"520 >521 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">522 <circle cx="4" cy="4" r="2.5" />523 <circle cx="9" cy="4" r="2.5" />524 <circle cx="4" cy="9" r="2.5" />525 <circle cx="9" cy="9" r="2.5" />526 </svg>527 </button>528 </Show>529 </>530 }531 >532 {}533 <button534 class="peach-select-cancel-btn"535 onClick={exitSelectMode}536 >537 cancel538 </button>539 <span class="peach-select-count-label">540 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}541 </span>542 <button543 class="peach-select-all-btn"544 onClick={toggleSelectAll}545 >546 {allSelected() ? "none" : "all"}547 </button>548 </Show>549 </div>550551 <Show when={hasTags() && !selecting()}>552 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">553 <For each={allTags()}>554 {(tag) => (555 <button556 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}557 onClick={() => toggleTag(tag)}558 >559 {tag}560 </button>561 )}562 </For>563 </div>564 </Show>565566 <div567 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}568 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}569 onKeyDown={handleListKeydown}570 onScroll={handleListScroll}571 >572 <Show573 when={filteredEntries().length > 0}574 fallback={575 <div class="peach-list-empty">576 <span>no entries yet</span>577 </div>578 }579 >580 <Show when={isVirtualized()}>581 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />582 </Show>583 <For each={windowedEntries()}>584 {(entry, localIndex) => {585 const index = () => visibleRange().start + localIndex();586 const isNote = entry.type === "note";587 const isPasskey = entry.passkey !== undefined;588 const domain = entry.type === "note"589 ? (entry.notes?.slice(0, 40) ?? "note")590 : entry.url591 ? getDisplayDomain(entry.url)592 : entry.passkey?.rpId593 ? entry.passkey.rpId594 : "saved entry";595 const isTabMatch = () =>596 props.currentTabDomain !== undefined &&597 props.currentTabDomain !== "" &&598 entryMatchesDomain(entry, props.currentTabDomain);599 const isSelected = () => selectedIds().has(entry.id);600601 const ariaLabel = (() => {602 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";603 const sub = entry.username || domain || "";604 return sub605 ? `${entry.title} — ${sub} (${tag})`606 : `${entry.title} (${tag})`;607 })();608 return (609 <button610 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" : ""}`}611 style={isVirtualized()612 ? { animation: "none" }613 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }614 }615 aria-label={ariaLabel}616 aria-pressed={selecting() ? isSelected() : undefined}617 onTouchStart={(e) => handleRowTouchStart(entry, e)}618 onTouchMove={handleRowTouchMove}619 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}620 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}621 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}622 onClick={() => {623 if (suppressNextClick) { suppressNextClick = false; return; }624 if (selecting()) {625 toggleId(entry.id);626 } else {627 props.onSelectEntry(entry);628 }629 }}630 >631 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>632 <Show633 when={selecting()}634 fallback={635 <FaviconIcon636 domain={entry.url ? getDisplayDomain(entry.url) : ""}637 title={entry.title}638 isNote={isNote}639 isPasskey={isPasskey}640 />641 }642 >643 {}644 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">645 <circle646 cx="8" cy="8" r="7"647 stroke="currentColor"648 stroke-width="1"649 fill={isSelected() ? "var(--peach-accent)" : "transparent"}650 opacity={isSelected() ? "1" : "0.3"}651 />652 <Show when={isSelected()}>653 <path654 d="M5 8.5L7 10.5L11 6"655 stroke="white"656 stroke-width="1.2"657 stroke-linecap="round"658 stroke-linejoin="round"659 />660 </Show>661 </svg>662 </Show>663 </div>664 <div class="peach-entry-row-info">665 <span class="peach-entry-row-name">666 {entry.title}667 <Show when={isPasskey && !selecting()}>668 <span class="peach-passkey-badge">passkey</span>669 </Show>670 <Show when={isTabMatch() && !selecting()}>671 <span class="peach-tab-match-badge" title="Matches current tab">●</span>672 </Show>673 </span>674 <span class="peach-entry-row-meta">{domain}</span>675 </div>676 <Show when={entry.breach && !selecting()}>677 <span class="peach-entry-row-dot" aria-label="breach"></span>678 </Show>679 <Show when={entry.canary && !selecting()}>680 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>681 </Show>682 </button>683 );684 }}685 </For>686 <Show when={isVirtualized()}>687 <div688 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}689 aria-hidden="true"690 />691 </Show>692 </Show>693 </div>694695 {}696 <Show697 when={selecting()}698 fallback={699 <button class="peach-new-entry-row" onClick={props.onAdd}>700 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">701 <path d="M5 1v8M1 5h8" />702 </svg>703 new entry704 </button>705 }706 >707 <button708 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}709 disabled={selectedIds().size === 0 || deleting()}710 onClick={() => void handleDeleteSelected()}711 >712 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">713 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />714 </svg>715 <Show716 when={selectedIds().size > 0}717 fallback={<span>select entries to delete</span>}718 >719 <span>720 {deleting()721 ? "moving to trash…"722 : `move ${selectedIds().size} to trash`}723 </span>724 </Show>725 </button>726 </Show>727728 <Show when={contextEntry() !== null}>729 <div730 class="peach-context-menu-backdrop"731 onClick={() => setContextEntry(null)}732 role="presentation"733 >734 <div735 class="peach-context-menu"736 role="menu"737 aria-label="Entry actions"738 onClick={(e) => e.stopPropagation()}739 >740 <Show when={contextEntry()!.username}>741 <button742 role="menuitem"743 class="peach-context-menu-item"744 onClick={async () => {745 const e = contextEntry();746 if (!e?.username) return;747 await popupCopy(e.username, "username");748 setContextEntry(null);749 }}750 >751 Copy username752 </button>753 </Show>754 <Show when={contextEntry()!.password}>755 <button756 role="menuitem"757 class="peach-context-menu-item"758 onClick={async () => {759 const e = contextEntry();760 if (!e?.password) return;761 await popupCopy(e.password, "password");762 setContextEntry(null);763 }}764 >765 Copy password766 </button>767 </Show>768 <Show when={contextEntry()!.totp}>769 <button770 role="menuitem"771 class="peach-context-menu-item"772 onClick={async () => {773 const e = contextEntry();774 if (!e?.totp) return;775 await popupCopy(e.totp, "TOTP secret");776 setContextEntry(null);777 }}778 >779 Copy TOTP secret780 </button>781 </Show>782 <button783 role="menuitem"784 class="peach-context-menu-item"785 onClick={() => {786 const e = contextEntry();787 if (e) props.onSelectEntry(e);788 setContextEntry(null);789 }}790 >791 Open792 </button>793 <Show when={props.onDeleteMany}>794 <button795 role="menuitem"796 class="peach-context-menu-item peach-context-menu-item--danger"797 onClick={async () => {798 const e = contextEntry();799 if (!e) return;800 setContextEntry(null);801 await props.onDeleteMany?.([e.id]);802 }}803 >804 Move to trash805 </button>806 </Show>807 <button808 role="menuitem"809 class="peach-context-menu-item peach-context-menu-item--cancel"810 onClick={() => setContextEntry(null)}811 >812 Cancel813 </button>814 </div>815 </div>816 </Show>817 </div>818 );819}