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 onSelectEntry: (entry: VaultEntry) => void;107 onSortOrderChange: (order: SortOrder) => void;108 onAdd: () => void;109 onClose: () => void;110 onOpenTrash?: () => void;111 onDeleteMany?: (ids: string[]) => Promise<void>;112}113114export function VaultView(props: VaultProps) {115 const [search, setSearch] = createSignal("");116 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());117 const [notesOnly, setNotesOnly] = createSignal(false);118 const [selecting, setSelecting] = createSignal(false);119 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());120 const [deleting, setDeleting] = createSignal(false);121 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);122 const [flashId, setFlashId] = createSignal<string | null>(null);123 const [scrollTop, setScrollTop] = createSignal(0);124 const [viewportHeight, setViewportHeight] = createSignal(600);125 let longPressTimer: number | null = null;126 let touchStart: { x: number; y: number; t: number } | null = null;127 let suppressNextClick = false;128129 function clearLongPressTimer() {130 if (longPressTimer !== null) {131 window.clearTimeout(longPressTimer);132 longPressTimer = null;133 }134 }135136 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {137 if (selecting()) return;138 const t = e.touches[0];139 if (!t) return;140 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };141 clearLongPressTimer();142 longPressTimer = window.setTimeout(() => {143 suppressNextClick = true;144 vibrate(20);145 setContextEntry(entry);146 longPressTimer = null;147 }, 500);148 }149150 function handleRowTouchMove(e: TouchEvent) {151 if (!touchStart) return;152 const t = e.touches[0];153 if (!t) return;154 const dx = Math.abs(t.clientX - touchStart.x);155 const dy = Math.abs(t.clientY - touchStart.y);156 if (dx > 8 || dy > 8) clearLongPressTimer();157 }158159 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {160 clearLongPressTimer();161 if (!touchStart) return;162 const t = e.changedTouches[0];163 if (!t) { touchStart = null; return; }164 const dx = t.clientX - touchStart.x;165 const dy = Math.abs(t.clientY - touchStart.y);166 const dt = Date.now() - touchStart.t;167 touchStart = null;168 169 const isCompact = !document.body.classList.contains("layout-medium")170 && !document.body.classList.contains("layout-expanded");171 if (172 isCompact173 && !selecting()174 && dx > 80175 && dy < 30176 && dt < 400177 && entry.type === "login"178 && entry.password179 ) {180 suppressNextClick = true;181 vibrate(15);182 setFlashId(entry.id);183 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);184 await popupCopy(entry.password, "password");185 }186 }187188 let searchRef: HTMLInputElement | undefined;189 let listRef: HTMLDivElement | undefined;190191 const focusSearch = (el: HTMLInputElement) => {192 searchRef = el;193 setTimeout(() => el.focus(), 50);194 };195196 createEffect(() => {197 void props.searchSeedNonce;198 const seed = props.searchSeed;199 if (!seed) return;200 setSearch(seed);201 requestAnimationFrame(() => {202 searchRef?.focus();203 searchRef?.setSelectionRange(seed.length, seed.length);204 });205 });206207 function enterSelectMode() {208 setSelecting(true);209 setSelectedIds(new Set<string>());210 }211212 function exitSelectMode() {213 setSelecting(false);214 setSelectedIds(new Set<string>());215 }216217 function toggleId(id: string) {218 const s = new Set<string>(selectedIds());219 if (s.has(id)) s.delete(id); else s.add(id);220 setSelectedIds(s);221 }222223 const allTags = createMemo(() => {224 const tagSet = new Set<string>();225 for (const entry of props.entries) {226 for (const tag of entry.tags ?? []) {227 tagSet.add(tag);228 }229 }230 return Array.from(tagSet).sort();231 });232233 const hasTags = createMemo(() => allTags().length > 0);234235 function toggleTag(tag: string) {236 const current = new Set(selectedTags());237 if (current.has(tag)) {238 current.delete(tag);239 } else {240 current.add(tag);241 }242 setSelectedTags(current);243 }244245 function fuzzyMatch(query: string, target: string): boolean {246 query = query.toLowerCase();247 target = target.toLowerCase();248 let qi = 0;249 for (let i = 0; i < target.length && qi < query.length; i++) {250 if (target[i] === query[qi]) qi++;251 }252 return qi === query.length;253 }254255 function isExactPrefix(query: string, target: string): boolean {256 return target.toLowerCase().startsWith(query.toLowerCase());257 }258259 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {260 if (!domain) return false;261 if (entry.passkey?.rpId === domain) return true;262 if (entry.type !== "login") return false;263 264 265 if (entry.url && extractDomain(entry.url) === domain) return true;266 for (const extra of entry.additionalUrls ?? []) {267 if (extractDomain(extra) === domain) return true;268 }269 return false;270 }271272 const filteredEntries = createMemo(() => {273 let result = props.entries;274275 if (notesOnly()) {276 result = result.filter(entry => entry.type === "note");277 }278279 if (selectedTags().size > 0) {280 result = result.filter(entry =>281 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))282 );283 }284285 const q = search().trim();286 if (q) {287 const searchableFor = (e: VaultEntry): string => {288 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;289 const passkeyText = e.passkey290 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`291 : "";292 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;293 };294 result = result295 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))296 .sort((a, b) => {297 const aSearchable = searchableFor(a).toLowerCase();298 const bSearchable = searchableFor(b).toLowerCase();299 const qLower = q.toLowerCase();300 const aExact = isExactPrefix(qLower, aSearchable);301 const bExact = isExactPrefix(qLower, bSearchable);302 if (aExact && !bExact) return -1;303 if (!aExact && bExact) return 1;304 return 0;305 });306 return result;307 }308309 const tabDomain = props.currentTabDomain ?? "";310 const order = props.sortOrder;311312 if (order === "az") {313 return [...result].sort((a, b) => a.title.localeCompare(b.title));314 }315 if (order === "za") {316 return [...result].sort((a, b) => b.title.localeCompare(a.title));317 }318 if (order === "recent") {319 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));320 }321 if (tabDomain) {322 return [...result].sort((a, b) => {323 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;324 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;325 return aMatch - bMatch;326 });327 }328 return result;329 });330331 332 333 334 335 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));336 const allSelected = createMemo(() =>337 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))338 );339340 function toggleSelectAll() {341 if (allSelected()) {342 setSelectedIds(new Set<string>());343 } else {344 setSelectedIds(new Set<string>(allVisibleIds()));345 }346 }347348 async function handleDeleteSelected() {349 const ids = Array.from(selectedIds());350 if (!ids.length || !props.onDeleteMany) return;351 setDeleting(true);352 await props.onDeleteMany(ids);353 setDeleting(false);354 exitSelectMode();355 }356357 358 359 360 361 362 const VIRTUAL_THRESHOLD = 100;363 const ROW_HEIGHT = 56;364 const BUFFER_ROWS = 20;365366 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);367368 const visibleRange = createMemo(() => {369 const total = filteredEntries().length;370 if (!isVirtualized()) return { start: 0, end: total };371 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);372 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);373 return { start, end };374 });375376 const windowedEntries = createMemo(() => {377 const { start, end } = visibleRange();378 return filteredEntries().slice(start, end);379 });380381 function handleListScroll(e: Event) {382 if (!isVirtualized()) return;383 const target = e.currentTarget as HTMLDivElement;384 setScrollTop(target.scrollTop);385 setViewportHeight(target.clientHeight);386 }387388 function handleSearchKeydown(e: KeyboardEvent) {389 if (e.key === "ArrowDown") {390 e.preventDefault();391 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");392 firstBtn?.focus();393 } else if (e.key === "ArrowUp") {394 e.preventDefault();395 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");396 allBtns?.[allBtns.length - 1]?.focus();397 } else if (e.key === "Enter") {398 e.preventDefault();399 const firstEntry = filteredEntries()[0];400 if (firstEntry) props.onSelectEntry(firstEntry);401 } else if (e.key === "Escape" && selecting()) {402 exitSelectMode();403 }404 }405406 function handleListKeydown(e: KeyboardEvent) {407 if (!listRef) return;408 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));409 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);410 if (e.key === "ArrowDown") {411 e.preventDefault();412 if (idx < btns.length - 1) btns[idx + 1]?.focus();413 } else if (e.key === "ArrowUp") {414 e.preventDefault();415 if (idx === 0) { searchRef?.focus(); }416 else btns[idx - 1]?.focus();417 } else if (e.key === "Escape") {418 if (selecting()) exitSelectMode();419 else props.onClose();420 }421 }422423 const sortLabels: { key: SortOrder; label: string }[] = [424 { key: "url-first", label: "smart" },425 { key: "az", label: "A–Z" },426 { key: "za", label: "Z–A" },427 { key: "recent", label: "newest" },428 ];429430 return (431 <div class="peach-overlay-view ov-enter-up" style="display:flex">432 <div class="peach-list-top">433 <button434 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"435 onClick={props.onClose}436 aria-label="Back"437 >438 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">439 <path d="M6 1L1 6L6 11" />440 </svg>441 </button>442 <input443 class="peach-search-input"444 placeholder="search vault..."445 autocomplete="off"446 style="font-size:14px"447 ref={focusSearch}448 value={search()}449 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}450 onKeyDown={handleSearchKeydown}451 />452 <span class="peach-search-counter peach-search-counter-list">453 {search() || selectedTags().size > 0454 ? `${filteredEntries().length} of ${props.entries.length}`455 : `${props.entries.length} entries`}456 </span>457 </div>458459 {}460 <div class="peach-sort-bar">461 <Show462 when={selecting()}463 fallback={464 <>465 <For each={sortLabels}>466 {({ key, label }) => (467 <button468 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}469 onClick={() => props.onSortOrderChange(key)}470 >471 {label}472 </button>473 )}474 </For>475 <span style="flex:1" />476 <button477 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}478 onClick={() => setNotesOnly(v => !v)}479 aria-label="Notes only"480 title="Show notes only"481 >482 <NoteIcon />483 </button>484 <Show when={props.onOpenTrash}>485 <button486 class="peach-sort-trash-btn"487 onClick={props.onOpenTrash}488 aria-label="View trash"489 title="View deleted entries"490 >491 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">492 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />493 </svg>494 </button>495 </Show>496 <Show when={props.onDeleteMany}>497 <button498 class="peach-sort-trash-btn"499 onClick={enterSelectMode}500 aria-label="Select entries"501 title="Select entries"502 >503 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">504 <circle cx="4" cy="4" r="2.5" />505 <circle cx="9" cy="4" r="2.5" />506 <circle cx="4" cy="9" r="2.5" />507 <circle cx="9" cy="9" r="2.5" />508 </svg>509 </button>510 </Show>511 </>512 }513 >514 {}515 <button516 class="peach-select-cancel-btn"517 onClick={exitSelectMode}518 >519 cancel520 </button>521 <span class="peach-select-count-label">522 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}523 </span>524 <button525 class="peach-select-all-btn"526 onClick={toggleSelectAll}527 >528 {allSelected() ? "none" : "all"}529 </button>530 </Show>531 </div>532533 <Show when={hasTags() && !selecting()}>534 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">535 <For each={allTags()}>536 {(tag) => (537 <button538 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}539 onClick={() => toggleTag(tag)}540 >541 {tag}542 </button>543 )}544 </For>545 </div>546 </Show>547548 <div549 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}550 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}551 onKeyDown={handleListKeydown}552 onScroll={handleListScroll}553 >554 <Show555 when={filteredEntries().length > 0}556 fallback={557 <div class="peach-list-empty">558 <span>no entries yet</span>559 </div>560 }561 >562 <Show when={isVirtualized()}>563 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />564 </Show>565 <For each={windowedEntries()}>566 {(entry, localIndex) => {567 const index = () => visibleRange().start + localIndex();568 const isNote = entry.type === "note";569 const isPasskey = entry.passkey !== undefined;570 const domain = entry.type === "note"571 ? (entry.notes?.slice(0, 40) ?? "note")572 : entry.url573 ? getDisplayDomain(entry.url)574 : entry.passkey?.rpId575 ? entry.passkey.rpId576 : "saved entry";577 const isTabMatch = () =>578 props.currentTabDomain !== undefined &&579 props.currentTabDomain !== "" &&580 entryMatchesDomain(entry, props.currentTabDomain);581 const isSelected = () => selectedIds().has(entry.id);582583 const ariaLabel = (() => {584 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";585 const sub = entry.username || domain || "";586 return sub587 ? `${entry.title} — ${sub} (${tag})`588 : `${entry.title} (${tag})`;589 })();590 return (591 <button592 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" : ""}`}593 style={isVirtualized()594 ? { animation: "none" }595 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }596 }597 aria-label={ariaLabel}598 aria-pressed={selecting() ? isSelected() : undefined}599 onTouchStart={(e) => handleRowTouchStart(entry, e)}600 onTouchMove={handleRowTouchMove}601 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}602 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}603 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}604 onClick={() => {605 if (suppressNextClick) { suppressNextClick = false; return; }606 if (selecting()) {607 toggleId(entry.id);608 } else {609 props.onSelectEntry(entry);610 }611 }}612 >613 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>614 <Show615 when={selecting()}616 fallback={617 <FaviconIcon618 domain={entry.url ? getDisplayDomain(entry.url) : ""}619 title={entry.title}620 isNote={isNote}621 isPasskey={isPasskey}622 />623 }624 >625 {}626 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">627 <circle628 cx="8" cy="8" r="7"629 stroke="currentColor"630 stroke-width="1"631 fill={isSelected() ? "var(--peach-accent)" : "transparent"}632 opacity={isSelected() ? "1" : "0.3"}633 />634 <Show when={isSelected()}>635 <path636 d="M5 8.5L7 10.5L11 6"637 stroke="white"638 stroke-width="1.2"639 stroke-linecap="round"640 stroke-linejoin="round"641 />642 </Show>643 </svg>644 </Show>645 </div>646 <div class="peach-entry-row-info">647 <span class="peach-entry-row-name">648 {entry.title}649 <Show when={isPasskey && !selecting()}>650 <span class="peach-passkey-badge">passkey</span>651 </Show>652 <Show when={isTabMatch() && !selecting()}>653 <span class="peach-tab-match-badge" title="Matches current tab">●</span>654 </Show>655 </span>656 <span class="peach-entry-row-meta">{domain}</span>657 </div>658 <Show when={entry.breach && !selecting()}>659 <span class="peach-entry-row-dot" aria-label="breach"></span>660 </Show>661 <Show when={entry.canary && !selecting()}>662 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>663 </Show>664 </button>665 );666 }}667 </For>668 <Show when={isVirtualized()}>669 <div670 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}671 aria-hidden="true"672 />673 </Show>674 </Show>675 </div>676677 {}678 <Show679 when={selecting()}680 fallback={681 <button class="peach-new-entry-row" onClick={props.onAdd}>682 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">683 <path d="M5 1v8M1 5h8" />684 </svg>685 new entry686 </button>687 }688 >689 <button690 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}691 disabled={selectedIds().size === 0 || deleting()}692 onClick={() => void handleDeleteSelected()}693 >694 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">695 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />696 </svg>697 <Show698 when={selectedIds().size > 0}699 fallback={<span>select entries to delete</span>}700 >701 <span>702 {deleting()703 ? "moving to trash…"704 : `move ${selectedIds().size} to trash`}705 </span>706 </Show>707 </button>708 </Show>709710 <Show when={contextEntry() !== null}>711 <div712 class="peach-context-menu-backdrop"713 onClick={() => setContextEntry(null)}714 role="presentation"715 >716 <div717 class="peach-context-menu"718 role="menu"719 aria-label="Entry actions"720 onClick={(e) => e.stopPropagation()}721 >722 <Show when={contextEntry()!.username}>723 <button724 role="menuitem"725 class="peach-context-menu-item"726 onClick={async () => {727 const e = contextEntry();728 if (!e?.username) return;729 await popupCopy(e.username, "username");730 setContextEntry(null);731 }}732 >733 Copy username734 </button>735 </Show>736 <Show when={contextEntry()!.password}>737 <button738 role="menuitem"739 class="peach-context-menu-item"740 onClick={async () => {741 const e = contextEntry();742 if (!e?.password) return;743 await popupCopy(e.password, "password");744 setContextEntry(null);745 }}746 >747 Copy password748 </button>749 </Show>750 <Show when={contextEntry()!.totp}>751 <button752 role="menuitem"753 class="peach-context-menu-item"754 onClick={async () => {755 const e = contextEntry();756 if (!e?.totp) return;757 await popupCopy(e.totp, "TOTP secret");758 setContextEntry(null);759 }}760 >761 Copy TOTP secret762 </button>763 </Show>764 <button765 role="menuitem"766 class="peach-context-menu-item"767 onClick={() => {768 const e = contextEntry();769 if (e) props.onSelectEntry(e);770 setContextEntry(null);771 }}772 >773 Open774 </button>775 <Show when={props.onDeleteMany}>776 <button777 role="menuitem"778 class="peach-context-menu-item peach-context-menu-item--danger"779 onClick={async () => {780 const e = contextEntry();781 if (!e) return;782 setContextEntry(null);783 await props.onDeleteMany?.([e.id]);784 }}785 >786 Move to trash787 </button>788 </Show>789 <button790 role="menuitem"791 class="peach-context-menu-item peach-context-menu-item--cancel"792 onClick={() => setContextEntry(null)}793 >794 Cancel795 </button>796 </div>797 </div>798 </Show>799 </div>800 );801}