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:
adlee-was-taken
2026-05-31 17:28:57 -04:00
parent 68cada5593
commit 7f076b49ac
3 changed files with 174 additions and 129 deletions

View 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);
});
});

View 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;
}

View File

@@ -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<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
// ---------------------------------------------------------------------------
@@ -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);