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 } 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}5859606162636465function titleInitials(source: string): string {66 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();67 if (!cleaned) return "·";68 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);69 if (words.length >= 2 && words[0]![0] && words[1]![0]) {70 return (words[0]![0]! + words[1]![0]!).toUpperCase();71 }72 const single = words[0] ?? cleaned;73 return single.slice(0, 2).toUpperCase();74}75767778798081function titleBackground(source: string): string {82 let hash = 0;83 for (let i = 0; i < source.length; i++) {84 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;85 }86 const hue = ((hash % 360) + 360) % 360;87 return `hsl(${hue}, 32%, 26%)`;88}8990function InitialsChip(props: { title: string }) {91 return (92 <span93 class="peach-favicon-initials"94 style={{ background: titleBackground(props.title) }}95 aria-hidden="true"96 >97 {titleInitials(props.title)}98 </span>99 );100}101102103const PasskeyIcon = () => (104 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">105 <circle cx="4" cy="4.5" r="3" />106 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />107 </svg>108);109110const NoteIcon = () => (111 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">112 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />113 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />114 </svg>115);116117export type SortOrder = "url-first" | "az" | "za" | "recent";118119interface VaultProps {120 entries: VaultEntry[];121 selectedEntryId?: string;122 currentTabDomain?: string;123 sortOrder: SortOrder;124 searchSeed?: string;125 searchSeedNonce?: number;126 127 128 129 isNative?: boolean;130 onSelectEntry: (entry: VaultEntry) => void;131 onSortOrderChange: (order: SortOrder) => void;132 onAdd: () => void;133 onClose: () => void;134 onOpenTrash?: () => void;135 onDeleteMany?: (ids: string[]) => Promise<void>;136}137138export function VaultView(props: VaultProps) {139 const [search, setSearch] = createSignal("");140 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());141 const [notesOnly, setNotesOnly] = createSignal(false);142 const [selecting, setSelecting] = createSignal(false);143 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());144 const [deleting, setDeleting] = createSignal(false);145 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);146 const [flashId, setFlashId] = createSignal<string | null>(null);147 const [scrollTop, setScrollTop] = createSignal(0);148 const [viewportHeight, setViewportHeight] = createSignal(600);149 let longPressTimer: number | null = null;150 let touchStart: { x: number; y: number; t: number } | null = null;151 let suppressNextClick = false;152153 function clearLongPressTimer() {154 if (longPressTimer !== null) {155 window.clearTimeout(longPressTimer);156 longPressTimer = null;157 }158 }159160 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {161 if (selecting()) return;162 const t = e.touches[0];163 if (!t) return;164 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };165 clearLongPressTimer();166 longPressTimer = window.setTimeout(() => {167 suppressNextClick = true;168 vibrate(20);169 setContextEntry(entry);170 longPressTimer = null;171 }, 500);172 }173174 function handleRowTouchMove(e: TouchEvent) {175 if (!touchStart) return;176 const t = e.touches[0];177 if (!t) return;178 const dx = Math.abs(t.clientX - touchStart.x);179 const dy = Math.abs(t.clientY - touchStart.y);180 if (dx > 8 || dy > 8) clearLongPressTimer();181 }182183 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {184 clearLongPressTimer();185 if (!touchStart) return;186 const t = e.changedTouches[0];187 if (!t) { touchStart = null; return; }188 const dx = t.clientX - touchStart.x;189 const dy = Math.abs(t.clientY - touchStart.y);190 const dt = Date.now() - touchStart.t;191 touchStart = null;192 193 const isCompact = !document.body.classList.contains("layout-medium")194 && !document.body.classList.contains("layout-expanded");195 if (196 isCompact197 && !selecting()198 && dx > 80199 && dy < 30200 && dt < 400201 && entry.type === "login"202 && entry.password203 ) {204 suppressNextClick = true;205 vibrate(15);206 setFlashId(entry.id);207 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);208 await popupCopy(entry.password, "password");209 }210 }211212 let searchRef: HTMLInputElement | undefined;213 let listRef: HTMLDivElement | undefined;214215 const focusSearch = (el: HTMLInputElement) => {216 searchRef = el;217 setTimeout(() => el.focus(), 50);218 };219220 createEffect(() => {221 void props.searchSeedNonce;222 const seed = props.searchSeed;223 if (!seed) return;224 setSearch(seed);225 requestAnimationFrame(() => {226 searchRef?.focus();227 searchRef?.setSelectionRange(seed.length, seed.length);228 });229 });230231 function enterSelectMode() {232 setSelecting(true);233 setSelectedIds(new Set<string>());234 }235236 function exitSelectMode() {237 setSelecting(false);238 setSelectedIds(new Set<string>());239 }240241 function toggleId(id: string) {242 const s = new Set<string>(selectedIds());243 if (s.has(id)) s.delete(id); else s.add(id);244 setSelectedIds(s);245 }246247 const allTags = createMemo(() => {248 const tagSet = new Set<string>();249 for (const entry of props.entries) {250 for (const tag of entry.tags ?? []) {251 tagSet.add(tag);252 }253 }254 return Array.from(tagSet).sort();255 });256257 const hasTags = createMemo(() => allTags().length > 0);258259 function toggleTag(tag: string) {260 const current = new Set(selectedTags());261 if (current.has(tag)) {262 current.delete(tag);263 } else {264 current.add(tag);265 }266 setSelectedTags(current);267 }268269 function fuzzyMatch(query: string, target: string): boolean {270 query = query.toLowerCase();271 target = target.toLowerCase();272 let qi = 0;273 for (let i = 0; i < target.length && qi < query.length; i++) {274 if (target[i] === query[qi]) qi++;275 }276 return qi === query.length;277 }278279 function isExactPrefix(query: string, target: string): boolean {280 return target.toLowerCase().startsWith(query.toLowerCase());281 }282283 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {284 if (!domain) return false;285 if (entry.passkey?.rpId === domain) return true;286 if (entry.type !== "login") return false;287 288 289 if (entry.url && extractDomain(entry.url) === domain) return true;290 for (const extra of entry.additionalUrls ?? []) {291 if (extractDomain(extra) === domain) return true;292 }293 return false;294 }295296 const filteredEntries = createMemo(() => {297 let result = props.entries;298299 if (notesOnly()) {300 result = result.filter(entry => entry.type === "note");301 }302303 if (selectedTags().size > 0) {304 result = result.filter(entry =>305 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))306 );307 }308309 const q = search().trim();310 if (q) {311 const searchableFor = (e: VaultEntry): string => {312 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;313 const passkeyText = e.passkey314 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`315 : "";316 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;317 };318 result = result319 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))320 .sort((a, b) => {321 const aSearchable = searchableFor(a).toLowerCase();322 const bSearchable = searchableFor(b).toLowerCase();323 const qLower = q.toLowerCase();324 const aExact = isExactPrefix(qLower, aSearchable);325 const bExact = isExactPrefix(qLower, bSearchable);326 if (aExact && !bExact) return -1;327 if (!aExact && bExact) return 1;328 return 0;329 });330 return result;331 }332333 const tabDomain = props.currentTabDomain ?? "";334 const order = props.sortOrder;335336 if (order === "az") {337 return [...result].sort((a, b) => a.title.localeCompare(b.title));338 }339 if (order === "za") {340 return [...result].sort((a, b) => b.title.localeCompare(a.title));341 }342 if (order === "recent") {343 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));344 }345 if (tabDomain) {346 return [...result].sort((a, b) => {347 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;348 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;349 return aMatch - bMatch;350 });351 }352 return result;353 });354355 356 357 358 359 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));360 const allSelected = createMemo(() =>361 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))362 );363364 function toggleSelectAll() {365 if (allSelected()) {366 setSelectedIds(new Set<string>());367 } else {368 setSelectedIds(new Set<string>(allVisibleIds()));369 }370 }371372 async function handleDeleteSelected() {373 const ids = Array.from(selectedIds());374 if (!ids.length || !props.onDeleteMany) return;375 setDeleting(true);376 await props.onDeleteMany(ids);377 setDeleting(false);378 exitSelectMode();379 }380381 382 383 384 385 386 const VIRTUAL_THRESHOLD = 100;387 const ROW_HEIGHT = 56;388 const BUFFER_ROWS = 20;389390 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);391392 const visibleRange = createMemo(() => {393 const total = filteredEntries().length;394 if (!isVirtualized()) return { start: 0, end: total };395 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);396 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);397 return { start, end };398 });399400 const windowedEntries = createMemo(() => {401 const { start, end } = visibleRange();402 return filteredEntries().slice(start, end);403 });404405 function handleListScroll(e: Event) {406 if (!isVirtualized()) return;407 const target = e.currentTarget as HTMLDivElement;408 setScrollTop(target.scrollTop);409 setViewportHeight(target.clientHeight);410 }411412 function handleSearchKeydown(e: KeyboardEvent) {413 if (e.key === "ArrowDown") {414 e.preventDefault();415 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");416 firstBtn?.focus();417 } else if (e.key === "ArrowUp") {418 e.preventDefault();419 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");420 allBtns?.[allBtns.length - 1]?.focus();421 } else if (e.key === "Enter") {422 e.preventDefault();423 const firstEntry = filteredEntries()[0];424 if (firstEntry) props.onSelectEntry(firstEntry);425 } else if (e.key === "Escape" && selecting()) {426 exitSelectMode();427 }428 }429430 function handleListKeydown(e: KeyboardEvent) {431 if (!listRef) return;432 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));433 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);434 if (e.key === "ArrowDown") {435 e.preventDefault();436 if (idx < btns.length - 1) btns[idx + 1]?.focus();437 } else if (e.key === "ArrowUp") {438 e.preventDefault();439 if (idx === 0) { searchRef?.focus(); }440 else btns[idx - 1]?.focus();441 } else if (e.key === "Escape") {442 if (selecting()) exitSelectMode();443 else props.onClose();444 }445 }446447 const sortLabels: { key: SortOrder; label: string }[] = [448 { key: "url-first", label: "smart" },449 { key: "az", label: "A–Z" },450 { key: "za", label: "Z–A" },451 { key: "recent", label: "newest" },452 ];453454 return (455 <div class="peach-overlay-view ov-enter-up" style="display:flex">456 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>457 <Show458 when={!props.isNative}459 fallback={460 <span class="peach-search-icon" aria-hidden="true">461 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">462 <circle cx="5.5" cy="5.5" r="3.75" />463 <path d="M8.75 8.75L11.5 11.5" />464 </svg>465 </span>466 }467 >468 <button469 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"470 onClick={props.onClose}471 aria-label="Back"472 >473 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">474 <path d="M6 1L1 6L6 11" />475 </svg>476 </button>477 </Show>478 <input479 class="peach-search-input"480 placeholder="search vault..."481 autocomplete="off"482 style={props.isNative ? undefined : "font-size:14px"}483 ref={focusSearch}484 value={search()}485 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}486 onKeyDown={handleSearchKeydown}487 />488 <span class="peach-search-counter peach-search-counter-list">489 {search() || selectedTags().size > 0490 ? `${filteredEntries().length} of ${props.entries.length}`491 : props.isNative492 ? `${props.entries.length}`493 : `${props.entries.length} entries`}494 </span>495 </div>496497 {}498 <div class="peach-sort-bar">499 <Show500 when={selecting()}501 fallback={502 <>503 <For each={sortLabels}>504 {({ key, label }) => (505 <button506 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}507 onClick={() => props.onSortOrderChange(key)}508 >509 {label}510 </button>511 )}512 </For>513 <span style="flex:1" />514 <button515 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}516 onClick={() => setNotesOnly(v => !v)}517 aria-label="Notes only"518 title="Show notes only"519 >520 <NoteIcon />521 </button>522 <Show when={props.onOpenTrash}>523 <button524 class="peach-sort-trash-btn"525 onClick={props.onOpenTrash}526 aria-label="View trash"527 title="View deleted entries"528 >529 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">530 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />531 </svg>532 </button>533 </Show>534 <Show when={props.onDeleteMany}>535 <button536 class="peach-sort-trash-btn"537 onClick={enterSelectMode}538 aria-label="Select entries"539 title="Select entries"540 >541 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">542 <circle cx="4" cy="4" r="2.5" />543 <circle cx="9" cy="4" r="2.5" />544 <circle cx="4" cy="9" r="2.5" />545 <circle cx="9" cy="9" r="2.5" />546 </svg>547 </button>548 </Show>549 </>550 }551 >552 {}553 <button554 class="peach-select-cancel-btn"555 onClick={exitSelectMode}556 >557 cancel558 </button>559 <span class="peach-select-count-label">560 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}561 </span>562 <button563 class="peach-select-all-btn"564 onClick={toggleSelectAll}565 >566 {allSelected() ? "none" : "all"}567 </button>568 </Show>569 </div>570571 <Show when={hasTags() && !selecting()}>572 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">573 <For each={allTags()}>574 {(tag) => (575 <button576 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}577 onClick={() => toggleTag(tag)}578 >579 {tag}580 </button>581 )}582 </For>583 </div>584 </Show>585586 <div587 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}588 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}589 onKeyDown={handleListKeydown}590 onScroll={handleListScroll}591 >592 <Show593 when={filteredEntries().length > 0}594 fallback={595 <div class="peach-list-empty">596 <span>no entries yet</span>597 </div>598 }599 >600 <Show when={isVirtualized()}>601 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />602 </Show>603 <For each={windowedEntries()}>604 {(entry, localIndex) => {605 const index = () => visibleRange().start + localIndex();606 const isNote = entry.type === "note";607 const isPasskey = entry.passkey !== undefined;608 const domain = entry.type === "note"609 ? (entry.notes?.slice(0, 40) ?? "note")610 : entry.url611 ? getDisplayDomain(entry.url)612 : entry.passkey?.rpId613 ? entry.passkey.rpId614 : "saved entry";615 const isTabMatch = () =>616 props.currentTabDomain !== undefined &&617 props.currentTabDomain !== "" &&618 entryMatchesDomain(entry, props.currentTabDomain);619 const isSelected = () => selectedIds().has(entry.id);620621 const ariaLabel = (() => {622 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";623 const sub = entry.username || domain || "";624 return sub625 ? `${entry.title} — ${sub} (${tag})`626 : `${entry.title} (${tag})`;627 })();628 return (629 <button630 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" : ""}`}631 style={isVirtualized()632 ? { animation: "none" }633 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }634 }635 aria-label={ariaLabel}636 aria-pressed={selecting() ? isSelected() : undefined}637 onTouchStart={(e) => handleRowTouchStart(entry, e)}638 onTouchMove={handleRowTouchMove}639 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}640 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}641 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}642 onClick={() => {643 if (suppressNextClick) { suppressNextClick = false; return; }644 if (selecting()) {645 toggleId(entry.id);646 } else {647 props.onSelectEntry(entry);648 }649 }}650 >651 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>652 <Show653 when={selecting()}654 fallback={655 <FaviconIcon656 domain={entry.url ? getDisplayDomain(entry.url) : ""}657 title={entry.title}658 isNote={isNote}659 isPasskey={isPasskey}660 />661 }662 >663 {}664 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">665 <circle666 cx="8" cy="8" r="7"667 stroke="currentColor"668 stroke-width="1"669 fill={isSelected() ? "var(--peach-accent)" : "transparent"}670 opacity={isSelected() ? "1" : "0.3"}671 />672 <Show when={isSelected()}>673 <path674 d="M5 8.5L7 10.5L11 6"675 stroke="white"676 stroke-width="1.2"677 stroke-linecap="round"678 stroke-linejoin="round"679 />680 </Show>681 </svg>682 </Show>683 </div>684 <div class="peach-entry-row-info">685 <span class="peach-entry-row-name">686 {entry.title}687 <Show when={isPasskey && !selecting()}>688 <span class="peach-passkey-badge">passkey</span>689 </Show>690 <Show when={isTabMatch() && !selecting()}>691 <span class="peach-tab-match-badge" title="Matches current tab">●</span>692 </Show>693 </span>694 <span class="peach-entry-row-meta">{domain}</span>695 </div>696 <Show when={entry.breach && !selecting()}>697 <span class="peach-entry-row-dot" aria-label="breach"></span>698 </Show>699 <Show when={entry.canary && !selecting()}>700 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>701 </Show>702 </button>703 );704 }}705 </For>706 <Show when={isVirtualized()}>707 <div708 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}709 aria-hidden="true"710 />711 </Show>712 </Show>713 </div>714715 {}716 <Show717 when={selecting()}718 fallback={719 <button class="peach-new-entry-row" onClick={props.onAdd}>720 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">721 <path d="M5 1v8M1 5h8" />722 </svg>723 new entry724 </button>725 }726 >727 <button728 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}729 disabled={selectedIds().size === 0 || deleting()}730 onClick={() => void handleDeleteSelected()}731 >732 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">733 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />734 </svg>735 <Show736 when={selectedIds().size > 0}737 fallback={<span>select entries to delete</span>}738 >739 <span>740 {deleting()741 ? "moving to trash…"742 : `move ${selectedIds().size} to trash`}743 </span>744 </Show>745 </button>746 </Show>747748 <Show when={contextEntry() !== null}>749 <div750 class="peach-context-menu-backdrop"751 onClick={() => setContextEntry(null)}752 role="presentation"753 >754 <div755 class="peach-context-menu"756 role="menu"757 aria-label="Entry actions"758 onClick={(e) => e.stopPropagation()}759 >760 <Show when={contextEntry()!.username}>761 <button762 role="menuitem"763 class="peach-context-menu-item"764 onClick={async () => {765 const e = contextEntry();766 if (!e?.username) return;767 await popupCopy(e.username, "username");768 setContextEntry(null);769 }}770 >771 Copy username772 </button>773 </Show>774 <Show when={contextEntry()!.password}>775 <button776 role="menuitem"777 class="peach-context-menu-item"778 onClick={async () => {779 const e = contextEntry();780 if (!e?.password) return;781 await popupCopy(e.password, "password");782 setContextEntry(null);783 }}784 >785 Copy password786 </button>787 </Show>788 <Show when={contextEntry()!.totp}>789 <button790 role="menuitem"791 class="peach-context-menu-item"792 onClick={async () => {793 const e = contextEntry();794 if (!e?.totp) return;795 await popupCopy(e.totp, "TOTP secret");796 setContextEntry(null);797 }}798 >799 Copy TOTP secret800 </button>801 </Show>802 <button803 role="menuitem"804 class="peach-context-menu-item"805 onClick={() => {806 const e = contextEntry();807 if (e) props.onSelectEntry(e);808 setContextEntry(null);809 }}810 >811 Open812 </button>813 <Show when={props.onDeleteMany}>814 <button815 role="menuitem"816 class="peach-context-menu-item peach-context-menu-item--danger"817 onClick={async () => {818 const e = contextEntry();819 if (!e) return;820 setContextEntry(null);821 await props.onDeleteMany?.([e.id]);822 }}823 >824 Move to trash825 </button>826 </Show>827 <button828 role="menuitem"829 class="peach-context-menu-item peach-context-menu-item--cancel"830 onClick={() => setContextEntry(null)}831 >832 Cancel833 </button>834 </div>835 </div>836 </Show>837 </div>838 );839}