import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { extractDomain, getDisplayDomain } from "../../shared/domain"; import { faviconUrl } from "../../shared/favicon"; import { popupCopy } from "../copy"; function vibrate(ms: number) { try { navigator.vibrate?.(ms); } catch { /* unsupported */ } } function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) { const src = props.domain && !props.isNote && !props.isPasskey ? faviconUrl(props.domain) : null; // Note/passkey entries keep their semantic glyphs. Login entries get a // colored-initials chip when the real favicon hasn't loaded — better than // a black square on freshly-imported entries the browser hasn't visited // yet (Chrome's `_favicon/` cache is empty for unvisited domains). const fallback = props.isPasskey ? : props.isNote ? : ; const [failed, setFailed] = createSignal(false); return ( setFailed(true)} /> ); } /** * Title → "PM"-style initials. First letters of the first two words, * falling back to the first two characters of a single-word title. Empty * titles get a small dot. Deterministic so the same entry always renders * the same chip. */ function titleInitials(source: string): string { const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim(); if (!cleaned) return "·"; const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean); if (words.length >= 2 && words[0]![0] && words[1]![0]) { return (words[0]![0]! + words[1]![0]!).toUpperCase(); } const single = words[0] ?? cleaned; return single.slice(0, 2).toUpperCase(); } /** * Hash a string to an HSL background. Fixed saturation + lightness so the * palette stays muted and dark enough that white initials read clearly on * top, while still giving each entry a recognisable colour signature. */ function titleBackground(source: string): string { let hash = 0; for (let i = 0; i < source.length; i++) { hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; } const hue = ((hash % 360) + 360) % 360; return `hsl(${hue}, 32%, 26%)`; } function InitialsChip(props: { title: string }) { return ( ); } const LoginIcon = () => ( ); const PasskeyIcon = () => ( ); const NoteIcon = () => ( ); export type SortOrder = "url-first" | "az" | "za" | "recent"; interface VaultProps { entries: VaultEntry[]; selectedEntryId?: string; currentTabDomain?: string; sortOrder: SortOrder; searchSeed?: string; searchSeedNonce?: number; onSelectEntry: (entry: VaultEntry) => void; onSortOrderChange: (order: SortOrder) => void; onAdd: () => void; onClose: () => void; onOpenTrash?: () => void; onDeleteMany?: (ids: string[]) => Promise; } export function VaultView(props: VaultProps) { const [search, setSearch] = createSignal(""); const [selectedTags, setSelectedTags] = createSignal>(new Set()); const [notesOnly, setNotesOnly] = createSignal(false); const [selecting, setSelecting] = createSignal(false); const [selectedIds, setSelectedIds] = createSignal>(new Set()); const [deleting, setDeleting] = createSignal(false); const [contextEntry, setContextEntry] = createSignal(null); const [flashId, setFlashId] = createSignal(null); const [scrollTop, setScrollTop] = createSignal(0); const [viewportHeight, setViewportHeight] = createSignal(600); let longPressTimer: number | null = null; let touchStart: { x: number; y: number; t: number } | null = null; let suppressNextClick = false; function clearLongPressTimer() { if (longPressTimer !== null) { window.clearTimeout(longPressTimer); longPressTimer = null; } } function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) { if (selecting()) return; const t = e.touches[0]; if (!t) return; touchStart = { x: t.clientX, y: t.clientY, t: Date.now() }; clearLongPressTimer(); longPressTimer = window.setTimeout(() => { suppressNextClick = true; vibrate(20); setContextEntry(entry); longPressTimer = null; }, 500); } function handleRowTouchMove(e: TouchEvent) { if (!touchStart) return; const t = e.touches[0]; if (!t) return; const dx = Math.abs(t.clientX - touchStart.x); const dy = Math.abs(t.clientY - touchStart.y); if (dx > 8 || dy > 8) clearLongPressTimer(); } async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) { clearLongPressTimer(); if (!touchStart) return; const t = e.changedTouches[0]; if (!t) { touchStart = null; return; } const dx = t.clientX - touchStart.x; const dy = Math.abs(t.clientY - touchStart.y); const dt = Date.now() - touchStart.t; touchStart = null; // Right-swipe-to-copy: only on compact + login entries with a password const isCompact = !document.body.classList.contains("layout-medium") && !document.body.classList.contains("layout-expanded"); if ( isCompact && !selecting() && dx > 80 && dy < 30 && dt < 400 && entry.type === "login" && entry.password ) { suppressNextClick = true; vibrate(15); setFlashId(entry.id); window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600); await popupCopy(entry.password, "password"); } } let searchRef: HTMLInputElement | undefined; let listRef: HTMLDivElement | undefined; const focusSearch = (el: HTMLInputElement) => { searchRef = el; setTimeout(() => el.focus(), 50); }; createEffect(() => { void props.searchSeedNonce; const seed = props.searchSeed; if (!seed) return; setSearch(seed); requestAnimationFrame(() => { searchRef?.focus(); searchRef?.setSelectionRange(seed.length, seed.length); }); }); function enterSelectMode() { setSelecting(true); setSelectedIds(new Set()); } function exitSelectMode() { setSelecting(false); setSelectedIds(new Set()); } function toggleId(id: string) { const s = new Set(selectedIds()); if (s.has(id)) s.delete(id); else s.add(id); setSelectedIds(s); } const allTags = createMemo(() => { const tagSet = new Set(); for (const entry of props.entries) { for (const tag of entry.tags ?? []) { tagSet.add(tag); } } return Array.from(tagSet).sort(); }); const hasTags = createMemo(() => allTags().length > 0); function toggleTag(tag: string) { const current = new Set(selectedTags()); if (current.has(tag)) { current.delete(tag); } else { current.add(tag); } setSelectedTags(current); } function fuzzyMatch(query: string, target: string): boolean { query = query.toLowerCase(); target = target.toLowerCase(); let qi = 0; for (let i = 0; i < target.length && qi < query.length; i++) { if (target[i] === query[qi]) qi++; } return qi === query.length; } function isExactPrefix(query: string, target: string): boolean { return target.toLowerCase().startsWith(query.toLowerCase()); } function entryDomain(entry: VaultEntry): string { if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url); if (entry.passkey?.rpId) return entry.passkey.rpId; return ""; } function entryMatchesDomain(entry: VaultEntry, domain: string): boolean { if (!domain) return false; if (entry.passkey?.rpId === domain) return true; if (entry.type !== "login") return false; // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of // each entry URL too so subdomain entries also match. if (entry.url && extractDomain(entry.url) === domain) return true; for (const extra of entry.additionalUrls ?? []) { if (extractDomain(extra) === domain) return true; } return false; } const filteredEntries = createMemo(() => { let result = props.entries; if (notesOnly()) { result = result.filter(entry => entry.type === "note"); } if (selectedTags().size > 0) { result = result.filter(entry => Array.from(selectedTags()).every(tag => entry.tags?.includes(tag)) ); } const q = search().trim(); if (q) { const searchableFor = (e: VaultEntry): string => { if (e.type === "note") return `${e.title} ${e.notes ?? ""}`; const passkeyText = e.passkey ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}` : ""; return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`; }; result = result .filter((entry) => fuzzyMatch(q, searchableFor(entry))) .sort((a, b) => { const aSearchable = searchableFor(a).toLowerCase(); const bSearchable = searchableFor(b).toLowerCase(); const qLower = q.toLowerCase(); const aExact = isExactPrefix(qLower, aSearchable); const bExact = isExactPrefix(qLower, bSearchable); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return 0; }); return result; } const tabDomain = props.currentTabDomain ?? ""; const order = props.sortOrder; if (order === "az") { return [...result].sort((a, b) => a.title.localeCompare(b.title)); } if (order === "za") { return [...result].sort((a, b) => b.title.localeCompare(a.title)); } if (order === "recent") { return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); } if (tabDomain) { return [...result].sort((a, b) => { const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1; const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1; return aMatch - bMatch; }); } return result; }); // Declaration order matters: `allVisibleIds` and `allSelected` read // `filteredEntries` eagerly on component mount via createMemo. Moving // `filteredEntries` below them throws ReferenceError under the production // minifier and freezes the vault list on mount (v1.4.34 regression). const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id)); const allSelected = createMemo(() => allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id)) ); function toggleSelectAll() { if (allSelected()) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(allVisibleIds())); } } async function handleDeleteSelected() { const ids = Array.from(selectedIds()); if (!ids.length || !props.onDeleteMany) return; setDeleting(true); await props.onDeleteMany(ids); setDeleting(false); exitSelectMode(); } // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list. // Above it, we render only a window around the visible viewport plus a // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty // space. Row height is approximate (variable padding for badges) but // bounded — the buffer absorbs over/undershoot. const VIRTUAL_THRESHOLD = 100; const ROW_HEIGHT = 56; const BUFFER_ROWS = 20; const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD); const visibleRange = createMemo(() => { const total = filteredEntries().length; if (!isVirtualized()) return { start: 0, end: total }; const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS); const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS); return { start, end }; }); const windowedEntries = createMemo(() => { const { start, end } = visibleRange(); return filteredEntries().slice(start, end); }); function handleListScroll(e: Event) { if (!isVirtualized()) return; const target = e.currentTarget as HTMLDivElement; setScrollTop(target.scrollTop); setViewportHeight(target.clientHeight); } function handleSearchKeydown(e: KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); const firstBtn = listRef?.querySelector(".peach-entry-row"); firstBtn?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const allBtns = listRef?.querySelectorAll(".peach-entry-row"); allBtns?.[allBtns.length - 1]?.focus(); } else if (e.key === "Enter") { e.preventDefault(); const firstEntry = filteredEntries()[0]; if (firstEntry) props.onSelectEntry(firstEntry); } else if (e.key === "Escape" && selecting()) { exitSelectMode(); } } function handleListKeydown(e: KeyboardEvent) { if (!listRef) return; const btns = Array.from(listRef.querySelectorAll(".peach-entry-row")); const idx = btns.indexOf(document.activeElement as HTMLButtonElement); if (e.key === "ArrowDown") { e.preventDefault(); if (idx < btns.length - 1) btns[idx + 1]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); if (idx === 0) { searchRef?.focus(); } else btns[idx - 1]?.focus(); } else if (e.key === "Escape") { if (selecting()) exitSelectMode(); else props.onClose(); } } const sortLabels: { key: SortOrder; label: string }[] = [ { key: "url-first", label: "smart" }, { key: "az", label: "A–Z" }, { key: "za", label: "Z–A" }, { key: "recent", label: "newest" }, ]; return (
setSearch((e.currentTarget as HTMLInputElement).value)} onKeyDown={handleSearchKeydown} /> {search() || selectedTags().size > 0 ? `${filteredEntries().length} of ${props.entries.length}` : `${props.entries.length} entries`}
{/* Sort bar — morphs into select-mode controls */}
{({ key, label }) => ( )} } > {/* Select mode sort bar */} {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}
{(tag) => ( )}
{ listRef = el; setViewportHeight(el.clientHeight); }} onKeyDown={handleListKeydown} onScroll={handleListScroll} > 0} fallback={
no entries yet
} > ); }