feat(extension): add item-history-index pane (lists items with field history)

This commit is contained in:
adlee-was-taken
2026-05-30 10:35:36 -04:00
parent 32e674eb40
commit 32e1632c42
3 changed files with 158 additions and 0 deletions

View File

@@ -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<string, string> = {
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<void> {
const state = getState();
const manifest: Array<[ItemId, ManifestEntry]> = state.entries ?? [];
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="margin:8px 0;">Scanning items…</p>
</div>
`;
app.querySelector<HTMLButtonElement>('#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 = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="text-align:center;margin-top:32px;">
No field history yet.<br>
Edits to passwords, TOTP secrets, and concealed fields will appear here.
</p>
</div>
`;
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
return;
}
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="margin:8px 0;">${entries.length} item${entries.length === 1 ? '' : 's'} have field history</p>
<div class="section-header">&nbsp;</div>
${entries.map((e) => `
<div class="history-index-row" data-id="${escapeHtml(e.id)}">
<span class="history-index-row__icon">${TYPE_ICONS[e.type] ?? '◻'}</span>
<div class="history-index-row__info">
<span class="history-index-row__title">${escapeHtml(e.title)}</span>
<span class="history-index-row__meta muted">${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))}</span>
</div>
</div>
`).join('')}
</div>
`;
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
app.querySelectorAll<HTMLElement>('.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');
});
});
}

View File

@@ -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; }