/// Typed-item list view — toolbar (search, new, sync, lock, settings) + /// type-iconed rows. Clicking a row fetches the full Item and navigates /// to the detail view. import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; import { GLYPH_VAULT_TAB, GLYPH_DEVICES, GLYPH_LOCK, GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, } from '../../shared/glyphs'; import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; /// Extract the display hostname from an icon_hint or fallback to the first tag. function metaLine(e: ManifestEntry): string { if (e.icon_hint) return e.icon_hint; if (e.tags.length > 0) return e.tags.join(', '); return ''; } /// Glyph icon per item type. function typeIcon(t: ItemType): string { switch (t) { case 'login': return GLYPH_TYPE_LOGIN; case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; case 'identity': return GLYPH_TYPE_IDENTITY; case 'card': return GLYPH_TYPE_CARD; case 'key': return GLYPH_TYPE_KEY; case 'document': return GLYPH_TYPE_DOCUMENT; case 'totp': return GLYPH_TYPE_TOTP; } } function buildRowsHtml(): string { const state = getState(); const filtered = getFilteredEntries(); return filtered.length > 0 ? filtered.map(([id, e], i) => `
${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' ' : ''} ${escapeHtml(metaLine(e))}
`).join('') : '
no items
'; } function updateItemList(): void { const list = document.getElementById('item-list'); if (list) { list.innerHTML = buildRowsHtml(); wireRowClicks(); } } function wireRowClicks(): void { document.querySelectorAll('.entry-row').forEach(row => { row.addEventListener('click', async () => { const id = (row as HTMLElement).dataset.id!; document.removeEventListener('keydown', handleListKeydown); await openItem(id); }); }); } export function renderItemList(app: HTMLElement): void { const state = getState(); app.innerHTML = `
${buildRowsHtml()}
/ search + new ↑↓ nav Enter open
`; // --- Event listeners --- const searchInput = document.getElementById('search-input') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { const state2 = getState(); state2.searchQuery = searchInput.value; state2.selectedIndex = 0; const list = document.getElementById('item-list'); if (list) list.innerHTML = buildRowsHtml(); wireRowClicks(); }); document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); document.getElementById('new-btn')?.addEventListener('click', () => { setState({ newType: null }); navigate('add'); }); document.getElementById('sync-btn')?.addEventListener('click', async () => { setState({ loading: true, error: null }); const resp = await sendMessage({ type: 'sync' }); if (resp.ok) { const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; setState({ entries: data.items, loading: false }); return; } setState({ loading: false, error: listResp.error }); } else { setState({ loading: false, error: resp.error }); } }); document.getElementById('lock-btn')?.addEventListener('click', async () => { await sendMessage({ type: 'lock' }); navigate('locked'); }); document.getElementById('settings-btn')?.addEventListener('click', (e) => { e.stopPropagation(); showSettingsPicker(e.currentTarget as HTMLElement); }); wireRowClicks(); document.addEventListener('keydown', handleListKeydown); } async function openItem(id: ItemId): Promise { setState({ loading: true }); const resp = await sendMessage({ type: 'get_item', id }); if (resp.ok) { const data = resp.data as { item: Item }; navigate('detail', { selectedId: id, selectedItem: data.item, }); } else { setState({ loading: false, error: resp.error }); } } /// Compute the visible (filtered) entry list from current state. function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { const state = getState(); const entries: Array<[ItemId, ManifestEntry]> = state.entries; // Hide trashed items from the main list. let filtered = entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null); if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); filtered = filtered.filter(([, e]) => { if (e.title.toLowerCase().includes(q)) return true; if (e.icon_hint?.toLowerCase().includes(q)) return true; if (e.group?.toLowerCase().includes(q)) return true; if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; return false; }); } filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); return filtered; } /// True if the event target is an editable field (input/textarea/contenteditable). /// Global shortcut handlers should bail when the user is typing into a field — /// otherwise printable characters like "/" and "+" get eaten by the shortcut /// routing and never reach the input. function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (target.isContentEditable) return true; return false; } function handleListKeydown(e: KeyboardEvent): void { const state = getState(); const target = e.target as HTMLElement; const isSearch = target.id === 'search-input'; // If the user is typing into any input/textarea (other than the list's own // search field, which we want to focus on "/" even from outside it), let the // keystroke through. The "/" shortcut below is specifically "jump to search // from the list," not "steal printable characters while typing." if (isEditableTarget(target) && !isSearch) { if (e.key === 'Escape') { document.removeEventListener('keydown', handleListKeydown); window.close(); } return; } if (e.key === '/' && !isSearch) { e.preventDefault(); (document.getElementById('search-input') as HTMLInputElement | null)?.focus(); return; } if (e.key === '+' && !isSearch) { e.preventDefault(); document.removeEventListener('keydown', handleListKeydown); navigate('add'); return; } if (e.key === 'F' && e.shiftKey) { e.preventDefault(); openVaultTab(); return; } const filtered = getFilteredEntries(); if (e.key === 'ArrowDown') { e.preventDefault(); const max = Math.max(filtered.length - 1, 0); state.selectedIndex = Math.min(state.selectedIndex + 1, max); updateItemList(); return; } if (e.key === 'ArrowUp') { e.preventDefault(); state.selectedIndex = Math.max(state.selectedIndex - 1, 0); updateItemList(); return; } if (e.key === 'Enter') { e.preventDefault(); const selected = filtered[state.selectedIndex]; if (selected) { document.removeEventListener('keydown', handleListKeydown); void openItem(selected[0]); } return; } if (e.key === 'Escape') { document.removeEventListener('keydown', handleListKeydown); window.close(); return; } } // ---------------------------------------------------------------------- // ---------------------------------------------------------------------- // Settings picker popover (device vs vault) // ---------------------------------------------------------------------- const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [ { view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' }, { view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' }, ]; function showSettingsPicker(anchor: HTMLElement): void { document.querySelectorAll('.settings-picker').forEach((el) => el.remove()); const picker = document.createElement('div'); picker.className = 'settings-picker'; Object.assign(picker.style, { position: 'absolute', background: '#161b22', border: '1px solid #30363d', borderRadius: '6px', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', padding: '4px', minWidth: '170px', zIndex: '999999', fontSize: '12px', }); const rect = anchor.getBoundingClientRect(); picker.style.top = `${rect.bottom + 4}px`; picker.style.left = `${rect.left}px`; for (const opt of SETTINGS_OPTIONS) { const row = document.createElement('div'); Object.assign(row.style, { padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px', }); const iconSpan = document.createElement('span'); iconSpan.textContent = opt.icon; Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' }); const labelSpan = document.createElement('span'); labelSpan.textContent = opt.label; row.appendChild(iconSpan); row.appendChild(labelSpan); row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); row.addEventListener('click', (ev) => { ev.stopPropagation(); picker.remove(); document.removeEventListener('click', closeOnOutside); document.removeEventListener('keydown', closeOnEsc); navigate(opt.view); }); picker.appendChild(row); } document.body.appendChild(picker); const closeOnOutside = (ev: MouseEvent) => { if (!picker.contains(ev.target as Node)) { picker.remove(); document.removeEventListener('click', closeOnOutside); document.removeEventListener('keydown', closeOnEsc); } }; const closeOnEsc = (ev: KeyboardEvent) => { if (ev.key === 'Escape') { picker.remove(); document.removeEventListener('click', closeOnOutside); document.removeEventListener('keydown', closeOnEsc); } }; setTimeout(() => { document.addEventListener('click', closeOnOutside); document.addEventListener('keydown', closeOnEsc); }, 0); }