feat(extension): add item-history-index pane (lists items with field history)
This commit is contained in:
130
extension/src/popup/components/item-history-index.ts
Normal file
130
extension/src/popup/components/item-history-index.ts
Normal 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"> </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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1731,3 +1731,17 @@ textarea {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 11px;
|
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; }
|
||||||
|
|||||||
@@ -2099,3 +2099,17 @@ textarea {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 11px;
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user