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}8182const LoginIcon = () => (83 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">84 <rect x="2" y="5" width="7" height="5" rx="1.2" />85 <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />86 </svg>87);8889const PasskeyIcon = () => (90 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">91 <circle cx="4" cy="4.5" r="3" />92 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />93 </svg>94);9596const NoteIcon = () => (97 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">98 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />99 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />100 </svg>101);102103export type SortOrder = "url-first" | "az" | "za" | "recent";104105interface VaultProps {106 entries: VaultEntry[];107 selectedEntryId?: string;108 currentTabDomain?: string;109 sortOrder: SortOrder;110 searchSeed?: string;111 searchSeedNonce?: number;112 onSelectEntry: (entry: VaultEntry) => void;113 onSortOrderChange: (order: SortOrder) => void;114 onAdd: () => void;115 onClose: () => void;116 onOpenTrash?: () => void;117 onDeleteMany?: (ids: string[]) => Promise<void>;118}119120export function VaultView(props: VaultProps) {121 const [search, setSearch] = createSignal("");122 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());123 const [notesOnly, setNotesOnly] = createSignal(false);124 const [selecting, setSelecting] = createSignal(false);125 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());126 const [deleting, setDeleting] = createSignal(false);127 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128 const [flashId, setFlashId] = createSignal<string | null>(null);129 const [scrollTop, setScrollTop] = createSignal(0);130 const [viewportHeight, setViewportHeight] = createSignal(600);131 let longPressTimer: number | null = null;132 let touchStart: { x: number; y: number; t: number } | null = null;133 let suppressNextClick = false;134135 function clearLongPressTimer() {136 if (longPressTimer !== null) {137 window.clearTimeout(longPressTimer);138 longPressTimer = null;139 }140 }141142 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {143 if (selecting()) return;144 const t = e.touches[0];145 if (!t) return;146 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };147 clearLongPressTimer();148 longPressTimer = window.setTimeout(() => {149 suppressNextClick = true;150 vibrate(20);151 setContextEntry(entry);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 175 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 function fuzzyMatch(query: string, target: string): boolean {252 query = query.toLowerCase();253 target = target.toLowerCase();254 let qi = 0;255 for (let i = 0; i < target.length && qi < query.length; i++) {256 if (target[i] === query[qi]) qi++;257 }258 return qi === query.length;259 }260261 function isExactPrefix(query: string, target: string): boolean {262 return target.toLowerCase().startsWith(query.toLowerCase());263 }264265 function entryDomain(entry: VaultEntry): string {266 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);267 if (entry.passkey?.rpId) return entry.passkey.rpId;268 return "";269 }270271 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {272 if (!domain) return false;273 if (entry.passkey?.rpId === domain) return true;274 if (entry.type !== "login") return false;275 276 277 if (entry.url && extractDomain(entry.url) === domain) return true;278 for (const extra of entry.additionalUrls ?? []) {279 if (extractDomain(extra) === domain) return true;280 }281 return false;282 }283284 const filteredEntries = createMemo(() => {285 let result = props.entries;286287 if (notesOnly()) {288 result = result.filter(entry => entry.type === "note");289 }290291 if (selectedTags().size > 0) {292 result = result.filter(entry =>293 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))294 );295 }296297 const q = search().trim();298 if (q) {299 const searchableFor = (e: VaultEntry): string => {300 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;301 const passkeyText = e.passkey302 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`303 : "";304 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;305 };306 result = result307 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))308 .sort((a, b) => {309 const aSearchable = searchableFor(a).toLowerCase();310 const bSearchable = searchableFor(b).toLowerCase();311 const qLower = q.toLowerCase();312 const aExact = isExactPrefix(qLower, aSearchable);313 const bExact = isExactPrefix(qLower, bSearchable);314 if (aExact && !bExact) return -1;315 if (!aExact && bExact) return 1;316 return 0;317 });318 return result;319 }320321 const tabDomain = props.currentTabDomain ?? "";322 const order = props.sortOrder;323324 if (order === "az") {325 return [...result].sort((a, b) => a.title.localeCompare(b.title));326 }327 if (order === "za") {328 return [...result].sort((a, b) => b.title.localeCompare(a.title));329 }330 if (order === "recent") {331 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));332 }333 if (tabDomain) {334 return [...result].sort((a, b) => {335 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;336 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;337 return aMatch - bMatch;338 });339 }340 return result;341 });342343 344 345 346 347 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));348 const allSelected = createMemo(() =>349 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))350 );351352 function toggleSelectAll() {353 if (allSelected()) {354 setSelectedIds(new Set<string>());355 } else {356 setSelectedIds(new Set<string>(allVisibleIds()));357 }358 }359360 async function handleDeleteSelected() {361 const ids = Array.from(selectedIds());362 if (!ids.length || !props.onDeleteMany) return;363 setDeleting(true);364 await props.onDeleteMany(ids);365 setDeleting(false);366 exitSelectMode();367 }368369 370 371 372 373 374 const VIRTUAL_THRESHOLD = 100;375 const ROW_HEIGHT = 56;376 const BUFFER_ROWS = 8;377378 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);379380 const visibleRange = createMemo(() => {381 const total = filteredEntries().length;382 if (!isVirtualized()) return { start: 0, end: total };383 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);384 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);385 return { start, end };386 });387388 const windowedEntries = createMemo(() => {389 const { start, end } = visibleRange();390 return filteredEntries().slice(start, end);391 });392393 function handleListScroll(e: Event) {394 if (!isVirtualized()) return;395 const target = e.currentTarget as HTMLDivElement;396 setScrollTop(target.scrollTop);397 setViewportHeight(target.clientHeight);398 }399400 function handleSearchKeydown(e: KeyboardEvent) {401 if (e.key === "ArrowDown") {402 e.preventDefault();403 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");404 firstBtn?.focus();405 } else if (e.key === "ArrowUp") {406 e.preventDefault();407 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");408 allBtns?.[allBtns.length - 1]?.focus();409 } else if (e.key === "Enter") {410 e.preventDefault();411 const firstEntry = filteredEntries()[0];412 if (firstEntry) props.onSelectEntry(firstEntry);413 } else if (e.key === "Escape" && selecting()) {414 exitSelectMode();415 }416 }417418 function handleListKeydown(e: KeyboardEvent) {419 if (!listRef) return;420 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));421 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);422 if (e.key === "ArrowDown") {423 e.preventDefault();424 if (idx < btns.length - 1) btns[idx + 1]?.focus();425 } else if (e.key === "ArrowUp") {426 e.preventDefault();427 if (idx === 0) { searchRef?.focus(); }428 else btns[idx - 1]?.focus();429 } else if (e.key === "Escape") {430 if (selecting()) exitSelectMode();431 else props.onClose();432 }433 }434435 const sortLabels: { key: SortOrder; label: string }[] = [436 { key: "url-first", label: "smart" },437 { key: "az", label: "A–Z" },438 { key: "za", label: "Z–A" },439 { key: "recent", label: "newest" },440 ];441442 return (443 <div class="peach-overlay-view ov-enter-up" style="display:flex">444 <div class="peach-list-top">445 <button446 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"447 onClick={props.onClose}448 aria-label="Back"449 >450 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">451 <path d="M6 1L1 6L6 11" />452 </svg>453 </button>454 <input455 class="peach-search-input"456 placeholder="search vault..."457 autocomplete="off"458 style="font-size:14px"459 ref={focusSearch}460 value={search()}461 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}462 onKeyDown={handleSearchKeydown}463 />464 <span class="peach-search-counter peach-search-counter-list">465 {search() || selectedTags().size > 0466 ? `${filteredEntries().length} of ${props.entries.length}`467 : `${props.entries.length} entries`}468 </span>469 </div>470471 {}472 <div class="peach-sort-bar">473 <Show474 when={selecting()}475 fallback={476 <>477 <For each={sortLabels}>478 {({ key, label }) => (479 <button480 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}481 onClick={() => props.onSortOrderChange(key)}482 >483 {label}484 </button>485 )}486 </For>487 <span style="flex:1" />488 <button489 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}490 onClick={() => setNotesOnly(v => !v)}491 aria-label="Notes only"492 title="Show notes only"493 >494 <NoteIcon />495 </button>496 <Show when={props.onOpenTrash}>497 <button498 class="peach-sort-trash-btn"499 onClick={props.onOpenTrash}500 aria-label="View trash"501 title="View deleted entries"502 >503 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">504 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />505 </svg>506 </button>507 </Show>508 <Show when={props.onDeleteMany}>509 <button510 class="peach-sort-trash-btn"511 onClick={enterSelectMode}512 aria-label="Select entries"513 title="Select entries"514 >515 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">516 <circle cx="4" cy="4" r="2.5" />517 <circle cx="9" cy="4" r="2.5" />518 <circle cx="4" cy="9" r="2.5" />519 <circle cx="9" cy="9" r="2.5" />520 </svg>521 </button>522 </Show>523 </>524 }525 >526 {}527 <button528 class="peach-select-cancel-btn"529 onClick={exitSelectMode}530 >531 cancel532 </button>533 <span class="peach-select-count-label">534 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}535 </span>536 <button537 class="peach-select-all-btn"538 onClick={toggleSelectAll}539 >540 {allSelected() ? "none" : "all"}541 </button>542 </Show>543 </div>544545 <Show when={hasTags() && !selecting()}>546 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">547 <For each={allTags()}>548 {(tag) => (549 <button550 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}551 onClick={() => toggleTag(tag)}552 >553 {tag}554 </button>555 )}556 </For>557 </div>558 </Show>559560 <div561 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}562 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}563 onKeyDown={handleListKeydown}564 onScroll={handleListScroll}565 >566 <Show567 when={filteredEntries().length > 0}568 fallback={569 <div class="peach-list-empty">570 <span>no entries yet</span>571 </div>572 }573 >574 <Show when={isVirtualized()}>575 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />576 </Show>577 <For each={windowedEntries()}>578 {(entry, localIndex) => {579 const index = () => visibleRange().start + localIndex();580 const isNote = entry.type === "note";581 const isPasskey = entry.passkey !== undefined;582 const domain = entry.type === "note"583 ? (entry.notes?.slice(0, 40) ?? "note")584 : entry.url585 ? getDisplayDomain(entry.url)586 : entry.passkey?.rpId587 ? entry.passkey.rpId588 : "saved entry";589 const isTabMatch = () =>590 props.currentTabDomain !== undefined &&591 props.currentTabDomain !== "" &&592 entryMatchesDomain(entry, props.currentTabDomain);593 const isSelected = () => selectedIds().has(entry.id);594595 const ariaLabel = (() => {596 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";597 const sub = entry.username || domain || "";598 return sub599 ? `${entry.title} — ${sub} (${tag})`600 : `${entry.title} (${tag})`;601 })();602 return (603 <button604 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" : ""}`}605 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}606 aria-label={ariaLabel}607 aria-pressed={selecting() ? isSelected() : undefined}608 onTouchStart={(e) => handleRowTouchStart(entry, e)}609 onTouchMove={handleRowTouchMove}610 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}611 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}612 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}613 onClick={() => {614 if (suppressNextClick) { suppressNextClick = false; return; }615 if (selecting()) {616 toggleId(entry.id);617 } else {618 props.onSelectEntry(entry);619 }620 }}621 >622 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>623 <Show624 when={selecting()}625 fallback={626 <FaviconIcon627 domain={entry.url ? getDisplayDomain(entry.url) : ""}628 title={entry.title}629 isNote={isNote}630 isPasskey={isPasskey}631 />632 }633 >634 {}635 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">636 <circle637 cx="8" cy="8" r="7"638 stroke="currentColor"639 stroke-width="1"640 fill={isSelected() ? "var(--peach-accent)" : "transparent"}641 opacity={isSelected() ? "1" : "0.3"}642 />643 <Show when={isSelected()}>644 <path645 d="M5 8.5L7 10.5L11 6"646 stroke="white"647 stroke-width="1.2"648 stroke-linecap="round"649 stroke-linejoin="round"650 />651 </Show>652 </svg>653 </Show>654 </div>655 <div class="peach-entry-row-info">656 <span class="peach-entry-row-name">657 {entry.title}658 <Show when={isPasskey && !selecting()}>659 <span class="peach-passkey-badge">passkey</span>660 </Show>661 <Show when={isTabMatch() && !selecting()}>662 <span class="peach-tab-match-badge" title="Matches current tab">●</span>663 </Show>664 </span>665 <span class="peach-entry-row-meta">{domain}</span>666 </div>667 <Show when={entry.breach && !selecting()}>668 <span class="peach-entry-row-dot" aria-label="breach"></span>669 </Show>670 <Show when={entry.canary && !selecting()}>671 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>672 </Show>673 </button>674 );675 }}676 </For>677 <Show when={isVirtualized()}>678 <div679 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}680 aria-hidden="true"681 />682 </Show>683 </Show>684 </div>685686 {}687 <Show688 when={selecting()}689 fallback={690 <button class="peach-new-entry-row" onClick={props.onAdd}>691 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">692 <path d="M5 1v8M1 5h8" />693 </svg>694 new entry695 </button>696 }697 >698 <button699 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}700 disabled={selectedIds().size === 0 || deleting()}701 onClick={() => void handleDeleteSelected()}702 >703 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">704 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />705 </svg>706 <Show707 when={selectedIds().size > 0}708 fallback={<span>select entries to delete</span>}709 >710 <span>711 {deleting()712 ? "moving to trash…"713 : `move ${selectedIds().size} to trash`}714 </span>715 </Show>716 </button>717 </Show>718719 <Show when={contextEntry() !== null}>720 <div721 class="peach-context-menu-backdrop"722 onClick={() => setContextEntry(null)}723 role="presentation"724 >725 <div726 class="peach-context-menu"727 role="menu"728 aria-label="Entry actions"729 onClick={(e) => e.stopPropagation()}730 >731 <Show when={contextEntry()!.username}>732 <button733 role="menuitem"734 class="peach-context-menu-item"735 onClick={async () => {736 const e = contextEntry();737 if (!e?.username) return;738 await popupCopy(e.username, "username");739 setContextEntry(null);740 }}741 >742 Copy username743 </button>744 </Show>745 <Show when={contextEntry()!.password}>746 <button747 role="menuitem"748 class="peach-context-menu-item"749 onClick={async () => {750 const e = contextEntry();751 if (!e?.password) return;752 await popupCopy(e.password, "password");753 setContextEntry(null);754 }}755 >756 Copy password757 </button>758 </Show>759 <Show when={contextEntry()!.totp}>760 <button761 role="menuitem"762 class="peach-context-menu-item"763 onClick={async () => {764 const e = contextEntry();765 if (!e?.totp) return;766 await popupCopy(e.totp, "TOTP secret");767 setContextEntry(null);768 }}769 >770 Copy TOTP secret771 </button>772 </Show>773 <button774 role="menuitem"775 class="peach-context-menu-item"776 onClick={() => {777 const e = contextEntry();778 if (e) props.onSelectEntry(e);779 setContextEntry(null);780 }}781 >782 Open783 </button>784 <Show when={props.onDeleteMany}>785 <button786 role="menuitem"787 class="peach-context-menu-item peach-context-menu-item--danger"788 onClick={async () => {789 const e = contextEntry();790 if (!e) return;791 setContextEntry(null);792 await props.onDeleteMany?.([e.id]);793 }}794 >795 Move to trash796 </button>797 </Show>798 <button799 role="menuitem"800 class="peach-context-menu-item peach-context-menu-item--cancel"801 onClick={() => setContextEntry(null)}802 >803 Cancel804 </button>805 </div>806 </div>807 </Show>808 </div>809 );810}