From dc8097589eb67bd95605ea40d109d8e6cacf14c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:09:28 -0400 Subject: [PATCH] feat(ext/popup): typed-item list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-list.ts to render the typed-item ManifestEntry v2 surface: title + type-icon emoji (๐Ÿ”‘/๐Ÿ“/๐Ÿชช/๐Ÿ’ณ/๐Ÿ—/๐Ÿ“„/โฑ) + icon_hint as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard nav unchanged (/, +, arrows, Enter). Clicking a row fires list_items โ†’ get_item (the new typed-item messages) and stores the full Item in state.selectedItem before navigating to 'detail'. Also updates popup.ts PopupState: - entries now typed Array<[ItemId, ManifestEntry]> - selectedEntry โ†’ selectedItem (Item) - init() uses list_items not list_entries Trashed items (trashed_at set) are filtered out of the visible list. @ts-nocheck removed from item-list.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-list.ts | 141 +++++++++++--------- extension/src/popup/popup.ts | 16 +-- 2 files changed, 86 insertions(+), 71 deletions(-) diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts index ca637ff..8e3a569 100644 --- a/extension/src/popup/components/item-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -1,62 +1,60 @@ -// @ts-nocheck โ€” transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry list view โ€” search bar, group tabs, scrollable entry list with keyboard nav. +/// 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 } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; -/// Extract the domain from a URL for display. -function domainOf(url: string | undefined): string { - if (!url) return ''; - try { - return new URL(url).hostname; - } catch { - return ''; - } +/// 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 ''; } -/// Derive unique group names from the current entries. -function getGroups(entries: Array<[string, ManifestEntry]>): string[] { - const groups = new Set(); - for (const [, e] of entries) { - if (e.group) groups.add(e.group); +/// Emoji icon per item type. Placeholder until we ship real SVG icons. +function typeIcon(t: ItemType): string { + switch (t) { + case 'login': return '๐Ÿ”‘'; + case 'secure_note': return '๐Ÿ“'; + case 'identity': return '๐Ÿชช'; + case 'card': return '๐Ÿ’ณ'; + case 'key': return '๐Ÿ—'; + case 'document': return '๐Ÿ“„'; + case 'totp': return 'โฑ'; } - return Array.from(groups).sort(); } export function renderItemList(app: HTMLElement): void { const state = getState(); - const groups = getGroups(state.entries); const filtered = getFilteredEntries(); - const groupTabsHtml = groups.length > 0 - ? `
- - ${groups.map(g => - `` - ).join('')} -
` - : ''; - - const entriesHtml = filtered.length > 0 + const rowsHtml = filtered.length > 0 ? filtered.map(([id, e], i) => `
- ${escapeHtml(e.name)} - + ${escapeHtml(e.title)} +
`).join('') - : '
no entries
'; + : '
no items
'; app.innerHTML = ` - ${groupTabsHtml} -
- ${entriesHtml} +
+ + + + + +
+
+ ${rowsHtml}
/ search - + add + + new ↑↓ nav Enter open
@@ -64,44 +62,60 @@ export function renderItemList(app: HTMLElement): void { // --- Event listeners --- - const searchInput = document.getElementById('search-input') as HTMLInputElement; + const searchInput = document.getElementById('search-input') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { setState({ searchQuery: searchInput.value, selectedIndex: 0 }); }); - // Group tab clicks. - const groupTabs = app.querySelectorAll('.group-tab'); - groupTabs.forEach(tab => { - tab.addEventListener('click', () => { - const group = (tab as HTMLElement).dataset.group || null; - setState({ activeGroup: group, selectedIndex: 0 }); - }); + document.getElementById('new-btn')?.addEventListener('click', () => 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 }); + } }); - // Entry row clicks. + document.getElementById('lock-btn')?.addEventListener('click', async () => { + await sendMessage({ type: 'lock' }); + navigate('locked'); + }); + + document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); + + // Item row clicks. const rows = app.querySelectorAll('.entry-row'); rows.forEach(row => { row.addEventListener('click', async () => { const id = (row as HTMLElement).dataset.id!; - await openEntry(id); + await openItem(id); }); }); // Keyboard navigation. document.addEventListener('keydown', handleListKeydown); - // Focus search on / key (unless already focused). + // Focus search on open. searchInput?.focus(); } -async function openEntry(id: string): Promise { +async function openItem(id: ItemId): Promise { setState({ loading: true }); - const resp = await sendMessage({ type: 'get_entry', id }); + const resp = await sendMessage({ type: 'get_item', id }); if (resp.ok) { - const data = resp.data as { entry: import('../../shared/types').Entry }; + const data = resp.data as { item: Item }; navigate('detail', { selectedId: id, - selectedEntry: data.entry, + selectedItem: data.item, }); } else { setState({ loading: false, error: resp.error }); @@ -109,23 +123,21 @@ async function openEntry(id: string): Promise { } /// Compute the visible (filtered) entry list from current state. -function getFilteredEntries(): Array<[string, ManifestEntry]> { +function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { const state = getState(); - let filtered = state.entries; - if (state.activeGroup) { - const g = state.activeGroup.toLowerCase(); - filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g); - } + // Hide trashed items from the main list. + let filtered = state.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.name.toLowerCase().includes(q)) return true; - if (e.url?.toLowerCase().includes(q)) return true; - if (e.username?.toLowerCase().includes(q)) return true; + 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].name.localeCompare(b[1].name)); + filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); return filtered; } @@ -136,12 +148,13 @@ function handleListKeydown(e: KeyboardEvent): void { if (e.key === '/' && !isSearch) { e.preventDefault(); - (document.getElementById('search-input') as HTMLInputElement)?.focus(); + (document.getElementById('search-input') as HTMLInputElement | null)?.focus(); return; } if (e.key === '+' && !isSearch) { e.preventDefault(); + document.removeEventListener('keydown', handleListKeydown); navigate('add'); return; } @@ -163,8 +176,10 @@ function handleListKeydown(e: KeyboardEvent): void { if (e.key === 'Enter' && !isSearch) { e.preventDefault(); - if (filtered[state.selectedIndex]) { - openEntry(filtered[state.selectedIndex][0]); + const selected = filtered[state.selectedIndex]; + if (selected) { + document.removeEventListener('keydown', handleListKeydown); + void openItem(selected[0]); } return; } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 2116f1d..04ef6df 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -5,7 +5,7 @@ /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; -import type { ManifestEntry, Entry } from '../shared/types'; +import type { ItemId, ManifestEntry, Item } from '../shared/types'; import { renderUnlock } from './components/unlock'; import { renderItemList } from './components/item-list'; import { renderItemDetail } from './components/item-detail'; @@ -25,9 +25,9 @@ export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; export interface PopupState { view: View; - entries: Array<[string, ManifestEntry]>; - selectedId: string | null; - selectedEntry: Entry | null; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; selectedIndex: number; searchQuery: string; activeGroup: string | null; @@ -45,7 +45,7 @@ let currentState: PopupState = { view: 'locked', entries: [], selectedId: null, - selectedEntry: null, + selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, @@ -135,10 +135,10 @@ async function init(): Promise { const data = unlockResp.data as { unlocked: boolean }; if (data.unlocked) { // Load entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); + const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { - const listData = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: listData.entries }); + const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: listData.items }); return; } }