feat(extension): field-history pane visual polish — section headers + glyph buttons
This commit is contained in:
@@ -38,7 +38,7 @@ describe('field-history view', () => {
|
|||||||
expect(app.innerHTML).toContain('No history available');
|
expect(app.innerHTML).toContain('No history available');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders history entries masked by default', async () => {
|
it('renders history entries masked by default with section-header and glyph buttons', async () => {
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -53,9 +53,17 @@ describe('field-history view', () => {
|
|||||||
|
|
||||||
await renderFieldHistory(app);
|
await renderFieldHistory(app);
|
||||||
|
|
||||||
|
// Masked by default
|
||||||
expect(app.innerHTML).toContain('••••••••••••');
|
expect(app.innerHTML).toContain('••••••••••••');
|
||||||
expect(app.innerHTML).not.toContain('secret123');
|
expect(app.innerHTML).not.toContain('secret123');
|
||||||
expect(app.innerHTML).toContain('current');
|
// Section-header per field with uppercase name + entry count
|
||||||
|
expect(app.innerHTML).toContain('section-header');
|
||||||
|
expect(app.innerHTML).toContain('PASSWORD · 2 entries');
|
||||||
|
// Current entry annotation
|
||||||
|
expect(app.innerHTML).toContain('current · ');
|
||||||
|
// Explicit glyph buttons (reveal + copy) on each entry
|
||||||
|
expect(app.querySelectorAll('[data-entry-reveal]').length).toBe(2);
|
||||||
|
expect(app.querySelectorAll('[data-entry-copy]').length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('back button navigates to detail', async () => {
|
it('back button navigates to detail', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml } from '../../sha
|
|||||||
import { colorizePassword } from '../../shared/password-coloring';
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { FieldHistoryView } from '../../shared/types';
|
import type { FieldHistoryView } from '../../shared/types';
|
||||||
import { relativeTime } from '../../shared/relative-time';
|
import { relativeTime } from '../../shared/relative-time';
|
||||||
import { GLYPH_COPY } from '../../shared/glyphs';
|
import { GLYPH_COPY, GLYPH_REVEAL, GLYPH_HIDE } from '../../shared/glyphs';
|
||||||
|
|
||||||
const revealedSet = new Set<string>();
|
const revealedSet = new Set<string>();
|
||||||
|
|
||||||
@@ -58,27 +58,28 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
const isRevealed = revealedSet.has(entryKey);
|
const isRevealed = revealedSet.has(entryKey);
|
||||||
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
|
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
|
||||||
valueStore.set(entryKey, value);
|
valueStore.set(entryKey, value);
|
||||||
|
const revealGlyph = isRevealed ? GLYPH_HIDE : GLYPH_REVEAL;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
||||||
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
||||||
<div class="history-entry__meta">
|
<div class="history-entry__meta muted">
|
||||||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
${isCurrent ? '<span class="history-entry__current">current · </span>' : ''}
|
||||||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
${isCurrent ? 'set' : 'changed'} ${escapeHtml(relativeTime(timestamp))}
|
||||||
|
</div>
|
||||||
|
<div class="history-entry__actions">
|
||||||
|
<button class="glyph-btn" data-entry-reveal="${escapeHtml(entryKey)}" title="${isRevealed ? 'hide' : 'reveal'}" aria-label="${isRevealed ? 'hide' : 'reveal'}">${revealGlyph}</button>
|
||||||
|
<button class="glyph-btn" data-entry-copy="${escapeHtml(entryKey)}" title="copy" aria-label="copy">${GLYPH_COPY}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
for (const field of history) {
|
for (const field of history) {
|
||||||
if (history.length > 1) {
|
const entryCount = field.entries.length + 1; // +1 for current
|
||||||
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
|
content += `<div class="section-header">${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}</div>`;
|
||||||
}
|
|
||||||
// Current value first
|
|
||||||
content += renderEntry(field.field_id, field.current_value, item.modified, true);
|
content += renderEntry(field.field_id, field.current_value, item.modified, true);
|
||||||
// Historical values
|
|
||||||
for (const entry of field.entries) {
|
for (const entry of field.entries) {
|
||||||
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
|
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
|
||||||
}
|
}
|
||||||
@@ -108,17 +109,14 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
// Wire handlers
|
// Wire handlers
|
||||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
|
||||||
// Toggle reveal on click
|
// Reveal toggle via explicit glyph button (decoupled from row click)
|
||||||
app.querySelectorAll<HTMLElement>('.history-entry').forEach((el) => {
|
app.querySelectorAll<HTMLButtonElement>('[data-entry-reveal]').forEach((btn) => {
|
||||||
el.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return;
|
e.stopPropagation();
|
||||||
const key = el.dataset.entry;
|
const key = btn.dataset.entryReveal;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (revealedSet.has(key)) {
|
if (revealedSet.has(key)) revealedSet.delete(key);
|
||||||
revealedSet.delete(key);
|
else revealedSet.add(key);
|
||||||
} else {
|
|
||||||
revealedSet.add(key);
|
|
||||||
}
|
|
||||||
renderFieldHistory(app);
|
renderFieldHistory(app);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1191,66 +1191,28 @@ textarea {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-field-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8b949e;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 12px 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry {
|
.history-entry {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
padding: 8px 0;
|
||||||
padding: 10px;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
border-radius: 4px;
|
|
||||||
background: #161b22;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-entry:hover {
|
|
||||||
background: #1c2128;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__value {
|
.history-entry__value {
|
||||||
flex: 1;
|
font-family: ui-monospace, monospace;
|
||||||
font-family: monospace;
|
word-break: break-all;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
.history-entry__value.masked { letter-spacing: 1px; }
|
||||||
.history-entry__value.masked {
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__value.revealed {
|
|
||||||
color: #c9d1d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__meta {
|
.history-entry__meta {
|
||||||
display: flex;
|
grid-column: 1 / 2;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #8b949e;
|
|
||||||
}
|
}
|
||||||
|
.history-entry__actions {
|
||||||
.history-entry__current {
|
grid-row: 1 / 3;
|
||||||
color: #58a6ff;
|
grid-column: 2 / 3;
|
||||||
font-weight: 500;
|
display: flex;
|
||||||
}
|
gap: 4px;
|
||||||
|
|
||||||
.history-entry__copy {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__copy:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Type selection --- */
|
/* --- Type selection --- */
|
||||||
|
|||||||
@@ -1111,66 +1111,28 @@ textarea {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-field-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8b949e;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 12px 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry {
|
.history-entry {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
padding: 8px 0;
|
||||||
padding: 10px;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
border-radius: 4px;
|
|
||||||
background: #161b22;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-entry:hover {
|
|
||||||
background: #1c2128;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__value {
|
.history-entry__value {
|
||||||
flex: 1;
|
font-family: ui-monospace, monospace;
|
||||||
font-family: monospace;
|
word-break: break-all;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
.history-entry__value.masked { letter-spacing: 1px; }
|
||||||
.history-entry__value.masked {
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__value.revealed {
|
|
||||||
color: #c9d1d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__meta {
|
.history-entry__meta {
|
||||||
display: flex;
|
grid-column: 1 / 2;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #8b949e;
|
|
||||||
}
|
}
|
||||||
|
.history-entry__actions {
|
||||||
.history-entry__current {
|
grid-row: 1 / 3;
|
||||||
color: #58a6ff;
|
grid-column: 2 / 3;
|
||||||
font-weight: 500;
|
display: flex;
|
||||||
}
|
gap: 4px;
|
||||||
|
|
||||||
.history-entry__copy {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry__copy:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Type selection --- */
|
/* --- Type selection --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user