diff --git a/extension/src/popup/components/__tests__/settings-vault.test.ts b/extension/src/popup/components/__tests__/settings-vault.test.ts index eedcc5e..82d268f 100644 --- a/extension/src/popup/components/__tests__/settings-vault.test.ts +++ b/extension/src/popup/components/__tests__/settings-vault.test.ts @@ -40,19 +40,30 @@ describe('settings-vault', () => { beforeEach(() => { document.body.innerHTML = '
'; vi.mocked(sendMessage).mockReset(); - vi.mocked(sendMessage).mockResolvedValue({ ok: true }); + // Default: get_session_config returns inactivity/15, everything else ok + vi.mocked(sendMessage).mockImplementation(async (msg: any) => { + if (msg.type === 'get_session_config') { + return { ok: true, data: { config: { mode: 'inactivity', minutes: 15 } } }; + } + return { ok: true }; + }); }); - it('renders with seeded vault-settings values', () => { + it('renders with seeded vault-settings values', async () => { const app = document.getElementById('app')!; renderVaultSettings(app); - expect(app.textContent).toContain('vault settings'); + // Initial synchronous render paints the vault settings section headers + expect(app.querySelector('.section-header')?.textContent).toContain('VAULT SETTINGS'); expect(app.textContent).toContain('github.com'); expect(app.textContent).toContain('example.com'); const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; expect(trashSel.value).toBe('days:30'); const histSel = document.getElementById('history-retention') as HTMLSelectElement; expect(histSel.value).toBe('forever'); + // After get_session_config resolves, SESSION row appears + await new Promise((r) => setTimeout(r, 10)); + expect(app.textContent).toContain('SESSION'); + expect(app.textContent).toContain('after inactivity'); }); it('renders origin acks sorted by recency (descending)', () => { @@ -70,7 +81,7 @@ describe('settings-vault', () => { const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; trashSel.value = 'forever'; trashSel.dispatchEvent(new Event('change', { bubbles: true })); - expect(saveBtn.disabled).toBe(false); + expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false); }); it('revoke button removes origin from pending and enables save', () => { @@ -95,4 +106,28 @@ describe('settings-vault', () => { expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com'); expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com'); }); + + it('section headers render in correct order: VAULT SETTINGS, THIS DEVICE, ACTIONS', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const headers = Array.from(document.querySelectorAll('.section-header')).map((e) => e.textContent?.trim()); + expect(headers[0]).toContain('VAULT SETTINGS'); + expect(headers[1]).toContain('THIS DEVICE'); + expect(headers[2]).toContain('ACTIONS'); + }); + + it('subtitle shows "no changes" initially', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + expect(app.querySelector('.settings-header__sub')?.textContent).toBe('no changes'); + }); + + it('subtitle shows "unsaved · esc to cancel" after making a change', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + trashSel.value = 'forever'; + trashSel.dispatchEvent(new Event('change', { bubbles: true })); + expect(document.querySelector('.settings-header__sub')?.textContent).toContain('unsaved'); + }); }); diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts index 14749c6..cc5038e 100644 --- a/extension/src/popup/components/settings-vault.ts +++ b/extension/src/popup/components/settings-vault.ts @@ -6,12 +6,15 @@ import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } f import type { VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, } from '../../shared/types'; +import type { SessionTimeoutConfig } from '../../shared/messages'; import { relativeTime } from '../../shared/relative-time'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; import { GLYPH_NEXT } from '../../shared/glyphs'; let pendingSettings: VaultSettings | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; +let pendingSession: SessionTimeoutConfig | null = null; +let baseSession: SessionTimeoutConfig | null = null; export function teardown(): void { closeGeneratorPanel(); @@ -20,6 +23,8 @@ export function teardown(): void { activeKeyHandler = null; } pendingSettings = null; + pendingSession = null; + baseSession = null; } // --- Retention helpers --- @@ -77,69 +82,79 @@ export function renderVaultSettings(app: HTMLElement): void { } pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; + sendMessage({ type: 'get_session_config' }).then((resp) => { + // Guard against clobbering the user's in-flight edits if they tap a radio + // before the SW responds — tiny window but real. + if (resp.ok && !pendingSession) { + baseSession = (resp.data as { config: SessionTimeoutConfig }).config; + pendingSession = JSON.parse(JSON.stringify(baseSession)) as SessionTimeoutConfig; + rerender(); + } + }); + function rerender(): void { if (!pendingSettings) return; const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) .sort(([, a], [, b]) => b - a); + const dirty: boolean = JSON.stringify(pendingSettings) !== JSON.stringify(base) + || !!(baseSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)); + const subtitle = dirty ? 'unsaved · esc to cancel' : 'no changes'; + + const sessionMode = pendingSession?.mode ?? 'inactivity'; + const sessionMinutes = pendingSession && pendingSession.mode === 'inactivity' + ? pendingSession.minutes : 15; + app.innerHTML = `${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}
+${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}
- -No origins acknowledged yet.
` - : acksEntries.map(([host, ts]) => ` -No origins acknowledged yet.
` + : acksEntries.map(([host, ts]) => ` +