refactor(ext/vault): extract vault-drawer.ts + ensureDrawerClosedForRoute (Plan C Phase 4)
Moves the drawer (open/close/render + getDrawerCoreFields + selectItemForDrawer) out of vault.ts into vault-drawer.ts, taking the VaultController ctx. Adds ensureDrawerClosedForRoute(state, route) — called in renderPane before the view switch — so drawer state cannot leak across navigation to non-list/detail routes (P2 safety net). New drawer-state.test.ts covers it (TDD). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
extension/src/vault/vault-drawer.ts
Normal file
138
extension/src/vault/vault-drawer.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 = `
|
||||||
|
<div class="vault-drawer__header">
|
||||||
|
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||||
|
<div class="vault-drawer__actions">
|
||||||
|
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||||
|
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vault-drawer__body">
|
||||||
|
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||||
|
${item.type === 'login' && (item.core as { url?: string }).url
|
||||||
|
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||||
|
: ''}
|
||||||
|
<div class="vault-drawer__field-grid">
|
||||||
|
${coreFields.map(([label, value, full]) => `
|
||||||
|
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||||
|
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<string> = new Set(['list', 'detail']);
|
||||||
|
|
||||||
|
export function ensureDrawerClosedForRoute(
|
||||||
|
state: Pick<VaultState, 'drawerOpen'>,
|
||||||
|
route: Pick<HashRoute, 'view'>,
|
||||||
|
): void {
|
||||||
|
if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false;
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ import {
|
|||||||
} from './vault-shell';
|
} from './vault-shell';
|
||||||
import { wireSidebar, renderSidebarCategories } from './vault-sidebar';
|
import { wireSidebar, renderSidebarCategories } from './vault-sidebar';
|
||||||
import { renderListPane } from './vault-list';
|
import { renderListPane } from './vault-list';
|
||||||
|
import {
|
||||||
|
openDrawer, closeDrawer, renderDrawer, selectItemForDrawer,
|
||||||
|
ensureDrawerClosedForRoute,
|
||||||
|
} from './vault-drawer';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -146,12 +150,12 @@ const ctx: VaultController = {
|
|||||||
renderPane: () => renderPane(),
|
renderPane: () => renderPane(),
|
||||||
renderListPane: () => renderListPane(ctx),
|
renderListPane: () => renderListPane(ctx),
|
||||||
renderSidebarCategories: () => renderSidebarCategories(ctx),
|
renderSidebarCategories: () => renderSidebarCategories(ctx),
|
||||||
renderDrawer: (item) => renderDrawer(item),
|
renderDrawer: (item) => renderDrawer(ctx, item),
|
||||||
applyShellViewClass: () => applyShellViewClass(ctx),
|
applyShellViewClass: () => applyShellViewClass(ctx),
|
||||||
setHash,
|
setHash,
|
||||||
openDrawer: () => openDrawer(),
|
openDrawer: () => openDrawer(),
|
||||||
closeDrawer: () => closeDrawer(),
|
closeDrawer: () => closeDrawer(ctx),
|
||||||
selectItemForDrawer: (id) => selectItemForDrawer(id),
|
selectItemForDrawer: (id) => selectItemForDrawer(ctx, id),
|
||||||
openTypePanel: () => openTypePanel(ctx),
|
openTypePanel: () => openTypePanel(ctx),
|
||||||
closeTypePanel: () => closeTypePanel(ctx),
|
closeTypePanel: () => closeTypePanel(ctx),
|
||||||
wireSidebar: () => wireSidebar(ctx),
|
wireSidebar: () => wireSidebar(ctx),
|
||||||
@@ -183,132 +187,6 @@ registerHost({
|
|||||||
openVaultTab: () => {},
|
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<string, unknown>;
|
|
||||||
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 = `
|
|
||||||
<div class="vault-drawer__header">
|
|
||||||
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
|
||||||
<div class="vault-drawer__actions">
|
|
||||||
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
|
||||||
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vault-drawer__body">
|
|
||||||
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
|
||||||
${item.type === 'login' && (item.core as { url?: string }).url
|
|
||||||
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
|
||||||
: ''}
|
|
||||||
<div class="vault-drawer__field-grid">
|
|
||||||
${coreFields.map(([label, value, full]) => `
|
|
||||||
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
|
||||||
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
|
||||||
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
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
|
// Platform-aware save hint
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -395,6 +273,7 @@ function renderPane(): void {
|
|||||||
teardownPaneComponents();
|
teardownPaneComponents();
|
||||||
|
|
||||||
const route = parseHash();
|
const route = parseHash();
|
||||||
|
ensureDrawerClosedForRoute(state, route);
|
||||||
// Keep state.view in sync with hash for components that read it
|
// Keep state.view in sync with hash for components that read it
|
||||||
state.view = route.view;
|
state.view = route.view;
|
||||||
applyShellViewClass(ctx);
|
applyShellViewClass(ctx);
|
||||||
|
|||||||
Reference in New Issue
Block a user