From 32e1632c423a6d4a5ae59577901f4c7dfe397bcc Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 30 May 2026 10:35:36 -0400 Subject: [PATCH] feat(extension): add item-history-index pane (lists items with field history) --- .../popup/components/item-history-index.ts | 130 ++++++++++++++++++ extension/src/popup/styles.css | 14 ++ extension/src/vault/vault.css | 14 ++ 3 files changed, 158 insertions(+) create mode 100644 extension/src/popup/components/item-history-index.ts diff --git a/extension/src/popup/components/item-history-index.ts b/extension/src/popup/components/item-history-index.ts new file mode 100644 index 0000000..7932fd4 --- /dev/null +++ b/extension/src/popup/components/item-history-index.ts @@ -0,0 +1,130 @@ +/// History index — lists items that have any field history, sorted by most-recent +/// change. Clicking a row drills into the per-item view (field-history.ts). +/// +/// Implementation: iterate manifest, fetch each item via get_field_history, check +/// for ≥1 non-empty history-tracked field, emit an entry per qualifying item. + +import { getState, sendMessage, navigate, setState, escapeHtml } from '../../shared/state'; +import type { Item, ItemId, ManifestEntry } from '../../shared/types'; +import { relativeTime } from '../../shared/relative-time'; +import { + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD, + GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP, +} from '../../shared/glyphs'; + +const TYPE_ICONS: Record = { + login: GLYPH_TYPE_LOGIN, + secure_note: GLYPH_TYPE_SECURE_NOTE, + identity: GLYPH_TYPE_IDENTITY, + card: GLYPH_TYPE_CARD, + key: GLYPH_TYPE_KEY, + document: GLYPH_TYPE_DOCUMENT, + totp: GLYPH_TYPE_TOTP, +}; + +interface HistoryIndexEntry { + id: ItemId; + type: string; + title: string; + changeCount: number; + lastChangedAt: number; +} + +export function teardown(): void { + // No persistent state. +} + +export async function renderItemHistoryIndex(app: HTMLElement): Promise { + const state = getState(); + const manifest: Array<[ItemId, ManifestEntry]> = state.entries ?? []; + + app.innerHTML = ` +
+
+ +

history

+
+

Scanning items…

+
+ `; + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + + const entries: HistoryIndexEntry[] = []; + await Promise.all(manifest.map(async ([id, manifestEntry]) => { + if (manifestEntry.trashed_at !== undefined && manifestEntry.trashed_at !== null) return; + const resp = await sendMessage({ type: 'get_field_history', id }); + if (!resp.ok) return; + const history = (resp.data as { history: Array<{ entries: Array<{ changed_at: number }> }> }).history; + let totalCount = 0; + let mostRecent = 0; + for (const field of history) { + totalCount += field.entries.length; + for (const e of field.entries) { + if (e.changed_at > mostRecent) mostRecent = e.changed_at; + } + } + if (totalCount > 0) { + entries.push({ + id, + type: manifestEntry.type, + title: manifestEntry.title, + changeCount: totalCount, + lastChangedAt: mostRecent, + }); + } + })); + + entries.sort((a, b) => b.lastChangedAt - a.lastChangedAt); + + if (entries.length === 0) { + app.innerHTML = ` +
+
+ +

history

+
+

+ No field history yet.
+ Edits to passwords, TOTP secrets, and concealed fields will appear here. +

+
+ `; + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + return; + } + + app.innerHTML = ` +
+
+ +

history

+
+

${entries.length} item${entries.length === 1 ? '' : 's'} have field history

+
 
+ ${entries.map((e) => ` +
+ ${TYPE_ICONS[e.type] ?? '◻'} +
+ ${escapeHtml(e.title)} + ${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))} +
+
+ `).join('')} +
+ `; + + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + app.querySelectorAll('.history-index-row').forEach((row) => { + row.addEventListener('click', async () => { + const id = row.dataset.id as ItemId; + const itemResp = await sendMessage({ type: 'get_item', id }); + if (!itemResp.ok) { + setState({ error: 'Failed to load item' }); + return; + } + const item = (itemResp.data as { item: Item }).item; + setState({ selectedId: id, selectedItem: item, historyItemId: id }); + navigate('field-history'); + }); + }); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 0ce2d18..a7a5d50 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1731,3 +1731,17 @@ textarea { margin-left: auto; font-size: 11px; } + +.history-index-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; +} +.history-index-row:hover { background: var(--bg-input); } +.history-index-row__icon { font-size: 14px; } +.history-index-row__info { flex: 1; display: flex; flex-direction: column; } +.history-index-row__title { color: var(--text); } +.history-index-row__meta { font-size: 11px; } diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 23a5e28..8e7ac7d 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -2099,3 +2099,17 @@ textarea { margin-left: auto; font-size: 11px; } + +.history-index-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; +} +.history-index-row:hover { background: var(--bg-input); } +.history-index-row__icon { font-size: 14px; } +.history-index-row__info { flex: 1; display: flex; flex-direction: column; } +.history-index-row__title { color: var(--text); } +.history-index-row__meta { font-size: 11px; }