diff --git a/extension/src/vault/__tests__/drawer-state.test.ts b/extension/src/vault/__tests__/drawer-state.test.ts new file mode 100644 index 0000000..b71d002 --- /dev/null +++ b/extension/src/vault/__tests__/drawer-state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { ensureDrawerClosedForRoute } from '../vault-drawer'; + +describe('ensureDrawerClosedForRoute', () => { + it('closes the drawer when navigating to trash', () => { + const state = { drawerOpen: true }; + ensureDrawerClosedForRoute(state, { view: 'trash' }); + expect(state.drawerOpen).toBe(false); + }); + + it('leaves the drawer open when navigating to detail', () => { + const state = { drawerOpen: true }; + ensureDrawerClosedForRoute(state, { view: 'detail' }); + expect(state.drawerOpen).toBe(true); + }); + + it('leaves the drawer open in list view', () => { + const state = { drawerOpen: true }; + ensureDrawerClosedForRoute(state, { view: 'list' }); + expect(state.drawerOpen).toBe(true); + }); + + it('does nothing when the drawer is already closed', () => { + const state = { drawerOpen: false }; + ensureDrawerClosedForRoute(state, { view: 'devices' }); + expect(state.drawerOpen).toBe(false); + }); +}); diff --git a/extension/src/vault/vault-drawer.ts b/extension/src/vault/vault-drawer.ts new file mode 100644 index 0000000..602794e --- /dev/null +++ b/extension/src/vault/vault-drawer.ts @@ -0,0 +1,138 @@ +// Vault-tab drawer: the right-hand overlay that previews a selected item +// (open/close/render + item selection). Receives the VaultController (`ctx`) +// and reaches sibling concerns through it; pure helpers come from +// vault-context. Imports only from shared/ and vault-context. + +import type { Item } from '../shared/types'; +import { + type VaultController, type VaultState, type HashRoute, escapeHtml, +} from './vault-context'; + +export function openDrawer(): void { + document.getElementById('vault-drawer')?.classList.add('vault-drawer--open'); +} + +export function closeDrawer(ctx: VaultController): void { + ctx.state.drawerOpen = false; + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); +} + +function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> { + const core = item.core as unknown as Record; + if (!core) return []; + const fields: Array<[string, string, boolean]> = []; + + switch (item.type) { + case 'login': + if ('username' in core) fields.push(['username', String(core.username ?? ''), false]); + if ('password' in core) fields.push(['password', '••••••••', false]); + if ('url' in core) fields.push(['url', String(core.url ?? ''), true]); + break; + case 'card': { + if ('number' in core) fields.push(['number', String(core.number ?? ''), false]); + if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]); + if ('expiry' in core && core.expiry) { + const exp = core.expiry as { month: number; year: number }; + fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]); + } + if ('cvv' in core) fields.push(['cvv', '•••', false]); + if ('pin' in core) fields.push(['pin', '••••', false]); + break; + } + case 'identity': + if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]); + if ('email' in core) fields.push(['email', String(core.email ?? ''), true]); + if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]); + if ('address' in core) fields.push(['address', String(core.address ?? ''), true]); + if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]); + break; + case 'key': + if ('label' in core) fields.push(['label', String(core.label ?? ''), true]); + if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]); + if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]); + break; + case 'secure_note': + if ('body' in core) fields.push(['body', String(core.body ?? ''), true]); + break; + case 'totp': + if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]); + if ('label' in core) fields.push(['label', String(core.label ?? ''), false]); + break; + case 'document': + if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]); + if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]); + break; + } + + if (item.notes) fields.push(['notes', item.notes, true]); + return fields; +} + +export function renderDrawer(ctx: VaultController, item: Item): void { + const drawer = document.getElementById('vault-drawer'); + if (!drawer) return; + + const coreFields = getDrawerCoreFields(item); + + drawer.innerHTML = ` +
+ ${item.type.replace('_', ' ').toUpperCase()} +
+ + +
+
+
+
${escapeHtml(item.title)}
+ ${item.type === 'login' && (item.core as { url?: string }).url + ? `
${escapeHtml((item.core as { url?: string }).url ?? '')}
` + : ''} +
+ ${coreFields.map(([label, value, full]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `).join('')} +
+
+ `; + + document.getElementById('drawer-close-btn')?.addEventListener('click', () => { + closeDrawer(ctx); + ctx.renderListPane(); + }); + + document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { + if (ctx.state.selectedId) { + ctx.setHash('edit', ctx.state.selectedId); + ctx.renderPane(); + } + }); +} + +export async function selectItemForDrawer(ctx: VaultController, id: string): Promise { + const resp = await ctx.sendMessage({ type: 'get_item', id }); + if (!resp.ok) return; + const data = resp.data as { item: Item }; + ctx.state.selectedId = id; + ctx.state.selectedItem = data.item; + ctx.state.drawerOpen = true; + ctx.renderSidebarCategories(); + ctx.renderListPane(); + renderDrawer(ctx, data.item); + openDrawer(); +} + +// Drawer is an overlay only meaningful on the list/detail surfaces; any +// other route must clear it so it doesn't leak across navigation (P2 fix). +const DRAWER_KEEPING_VIEWS: ReadonlySet = new Set(['list', 'detail']); + +export function ensureDrawerClosedForRoute( + state: Pick, + route: Pick, +): void { + if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false; +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 825ef9d..19c22f4 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -31,6 +31,10 @@ import { } from './vault-shell'; import { wireSidebar, renderSidebarCategories } from './vault-sidebar'; import { renderListPane } from './vault-list'; +import { + openDrawer, closeDrawer, renderDrawer, selectItemForDrawer, + ensureDrawerClosedForRoute, +} from './vault-drawer'; // --------------------------------------------------------------------------- // Helpers @@ -146,12 +150,12 @@ const ctx: VaultController = { renderPane: () => renderPane(), renderListPane: () => renderListPane(ctx), renderSidebarCategories: () => renderSidebarCategories(ctx), - renderDrawer: (item) => renderDrawer(item), + renderDrawer: (item) => renderDrawer(ctx, item), applyShellViewClass: () => applyShellViewClass(ctx), setHash, openDrawer: () => openDrawer(), - closeDrawer: () => closeDrawer(), - selectItemForDrawer: (id) => selectItemForDrawer(id), + closeDrawer: () => closeDrawer(ctx), + selectItemForDrawer: (id) => selectItemForDrawer(ctx, id), openTypePanel: () => openTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx), wireSidebar: () => wireSidebar(ctx), @@ -183,132 +187,6 @@ registerHost({ openVaultTab: () => {}, }); -// --------------------------------------------------------------------------- -// Drawer (implemented in Task 10) -// --------------------------------------------------------------------------- - -function openDrawer(): void { - document.getElementById('vault-drawer')?.classList.add('vault-drawer--open'); -} - -function closeDrawer(): void { - state.drawerOpen = false; - state.selectedId = null; - state.selectedItem = null; - document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); -} - -function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> { - const core = item.core as unknown as Record; - if (!core) return []; - const fields: Array<[string, string, boolean]> = []; - - switch (item.type) { - case 'login': - if ('username' in core) fields.push(['username', String(core.username ?? ''), false]); - if ('password' in core) fields.push(['password', '••••••••', false]); - if ('url' in core) fields.push(['url', String(core.url ?? ''), true]); - break; - case 'card': { - if ('number' in core) fields.push(['number', String(core.number ?? ''), false]); - if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]); - if ('expiry' in core && core.expiry) { - const exp = core.expiry as { month: number; year: number }; - fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]); - } - if ('cvv' in core) fields.push(['cvv', '•••', false]); - if ('pin' in core) fields.push(['pin', '••••', false]); - break; - } - case 'identity': - if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]); - if ('email' in core) fields.push(['email', String(core.email ?? ''), true]); - if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]); - if ('address' in core) fields.push(['address', String(core.address ?? ''), true]); - if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]); - break; - case 'key': - if ('label' in core) fields.push(['label', String(core.label ?? ''), true]); - if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]); - if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]); - break; - case 'secure_note': - if ('body' in core) fields.push(['body', String(core.body ?? ''), true]); - break; - case 'totp': - if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]); - if ('label' in core) fields.push(['label', String(core.label ?? ''), false]); - break; - case 'document': - if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]); - if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]); - break; - } - - if (item.notes) fields.push(['notes', item.notes, true]); - return fields; -} - -function renderDrawer(item: Item): void { - const drawer = document.getElementById('vault-drawer'); - if (!drawer) return; - - const coreFields = getDrawerCoreFields(item); - - drawer.innerHTML = ` -
- ${item.type.replace('_', ' ').toUpperCase()} -
- - -
-
-
-
${escapeHtml(item.title)}
- ${item.type === 'login' && (item.core as { url?: string }).url - ? `
${escapeHtml((item.core as { url?: string }).url ?? '')}
` - : ''} -
- ${coreFields.map(([label, value, full]) => ` -
-
${escapeHtml(label)}
-
${escapeHtml(value)}
-
- `).join('')} -
-
- `; - - document.getElementById('drawer-close-btn')?.addEventListener('click', () => { - closeDrawer(); - renderListPane(ctx); - }); - - document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { - if (state.selectedId) { - setHash('edit', state.selectedId); - renderPane(); - } - }); -} - -// --------------------------------------------------------------------------- -// Item selection (implemented in Task 10) -// --------------------------------------------------------------------------- - -async function selectItemForDrawer(id: string): Promise { - const resp = await sendMessage({ type: 'get_item', id }); - if (!resp.ok) return; - const data = resp.data as { item: Item }; - state.selectedId = id; - state.selectedItem = data.item; - state.drawerOpen = true; - renderSidebarCategories(ctx); - renderListPane(ctx); - renderDrawer(data.item); - openDrawer(); -} - // --------------------------------------------------------------------------- // Platform-aware save hint // --------------------------------------------------------------------------- @@ -395,6 +273,7 @@ function renderPane(): void { teardownPaneComponents(); const route = parseHash(); + ensureDrawerClosedForRoute(state, route); // Keep state.view in sync with hash for components that read it state.view = route.view; applyShellViewClass(ctx);