From 299e7db1ab1cac2ea3871447fa747e55f8735d0d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 30 May 2026 01:25:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(extension):=20settings=20pane=20revamp=20?= =?UTF-8?q?=E2=80=94=20synced/local=20split=20+=20session=20timeout=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/settings-vault.test.ts | 43 +++- .../src/popup/components/settings-vault.ts | 209 +++++++++++------- extension/src/popup/styles.css | 15 ++ extension/src/vault/vault.css | 15 ++ 4 files changed, 204 insertions(+), 78 deletions(-) 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 = `
-

vault settings

+

settings

+ ${escapeHtml(subtitle)}
-
-
retention
-
- trash - +
VAULT SETTINGS · synced
+ +
+
+
RETENTION
+
+ trash + +
+
+ history + +
-
- field history - + +
+
GENERATOR
+

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

+
-
generator
-

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

- -
- -
-
autofill origins
- ${acksEntries.length === 0 - ? `

No origins acknowledged yet.

` - : acksEntries.map(([host, ts]) => ` -
- ${escapeHtml(host)} - ${escapeHtml(relativeTime(ts))} - -
- `).join('')} -
- -
-
attachments
+
ATTACHMENTS
- max file size + max size lock every time +
+
+ +
+
ACTIONS
+
-
import
- + +
`; - // Set current select values (document.getElementById('trash-retention') as HTMLSelectElement).value = trashRetentionToValue(pendingSettings.trash_retention); (document.getElementById('history-retention') as HTMLSelectElement).value = historyRetentionToValue(pendingSettings.field_history_retention); const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760; (document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue); + (document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes); wireHandlers(); - updateSaveEnabled(); - } - - function updateSaveEnabled(): void { - const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null; - if (!saveBtn || !pendingSettings || !base) return; - const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base); - saveBtn.disabled = !changed; } function wireHandlers(): void { @@ -198,13 +231,13 @@ export function renderVaultSettings(app: HTMLElement): void { document.getElementById('trash-retention')?.addEventListener('change', (e) => { if (!pendingSettings) return; pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); - updateSaveEnabled(); + rerender(); }); document.getElementById('history-retention')?.addEventListener('change', (e) => { if (!pendingSettings) return; pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); - updateSaveEnabled(); + rerender(); }); document.getElementById('attachment-cap')?.addEventListener('change', (e) => { @@ -214,7 +247,7 @@ export function renderVaultSettings(app: HTMLElement): void { ...pendingSettings.attachment_caps, per_attachment_max_bytes: bytes, }; - updateSaveEnabled(); + rerender(); }); document.querySelectorAll('[data-revoke]').forEach((btn) => { @@ -245,18 +278,46 @@ export function renderVaultSettings(app: HTMLElement): void { document.getElementById('save-btn')?.addEventListener('click', async () => { if (!pendingSettings) return; const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); - if (resp.ok) { - // Refresh cached state and navigate back. - const refreshed = await sendMessage({ type: 'get_vault_settings' }); - if (refreshed.ok && refreshed.data) { - const vs = (refreshed.data as { settings: VaultSettings }).settings; - if (vs) { - setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); - } - } - navigate('list'); - } else { + if (!resp.ok) { setState({ error: resp.error }); + return; + } + if (pendingSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)) { + const sessResp = await sendMessage({ type: 'update_session_config', config: pendingSession }); + if (!sessResp.ok) { + setState({ error: sessResp.error }); + return; + } + baseSession = JSON.parse(JSON.stringify(pendingSession)) as SessionTimeoutConfig; + } + const refreshed = await sendMessage({ type: 'get_vault_settings' }); + if (refreshed.ok && refreshed.data) { + const vs = (refreshed.data as { settings: VaultSettings }).settings; + if (vs) { + setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); + } + } + navigate('list'); + }); + + document.querySelectorAll('input[name="session-mode"]').forEach((el) => { + el.addEventListener('change', () => { + const mode = (document.querySelector('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity'; + if (mode === 'every_time') { + pendingSession = { mode: 'every_time' }; + } else { + const mins = Number((document.getElementById('session-minutes') as HTMLSelectElement).value); + pendingSession = { mode: 'inactivity', minutes: mins }; + } + rerender(); + }); + }); + + document.getElementById('session-minutes')?.addEventListener('change', (e) => { + const mins = Number((e.target as HTMLSelectElement).value); + if (pendingSession?.mode === 'inactivity') { + pendingSession = { mode: 'inactivity', minutes: mins }; + rerender(); } }); } diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 5afc198..5527f4a 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1812,3 +1812,18 @@ textarea { .setting-card__status { font-size: 13px; margin-bottom: 8px; } .setting-card__actions { display: flex; gap: 8px; } + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} +@media (max-width: 720px) { + .settings-grid { grid-template-columns: 1fr; } +} + +.settings-header__sub { + margin-left: auto; + font-size: 11px; +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 45994b6..98d72a2 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -2180,3 +2180,18 @@ textarea { word-break: break-all; /* wraps to two lines in popup (~360px) */ line-height: 1.4; } + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} +@media (max-width: 720px) { + .settings-grid { grid-template-columns: 1fr; } +} + +.settings-header__sub { + margin-left: auto; + font-size: 11px; +}