From 51255b3583dacb39430bec546157d091c488e0d9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 11:44:03 -0400 Subject: [PATCH 1/7] refactor(ext/vault): extract vault-shell.ts + introduce VaultController ctx (Plan C Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces vault-context.ts (VaultView/HashRoute/VaultState types, the VaultController contract, and the pure helpers escapeHtml/typeIcon/typeLabel/ getFilteredEntries). Extracts the shell concerns — render entry, lock screen, 3-column shell scaffolding, type picker panel, color-scheme apply, and the session_expired listener — into vault-shell.ts. vault.ts now assembles the ctx object and delegates shell rendering through it. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../vault/__tests__/sidebar-glyphs.test.ts | 2 +- extension/src/vault/vault-context.ts | 122 ++++++ extension/src/vault/vault-shell.ts | 255 ++++++++++++ extension/src/vault/vault.ts | 376 +++--------------- 4 files changed, 424 insertions(+), 331 deletions(-) create mode 100644 extension/src/vault/vault-context.ts create mode 100644 extension/src/vault/vault-shell.ts diff --git a/extension/src/vault/__tests__/sidebar-glyphs.test.ts b/extension/src/vault/__tests__/sidebar-glyphs.test.ts index 1800bcd..98415db 100644 --- a/extension/src/vault/__tests__/sidebar-glyphs.test.ts +++ b/extension/src/vault/__tests__/sidebar-glyphs.test.ts @@ -4,7 +4,7 @@ import * as path from 'path'; describe('vault sidebar glyphs', () => { const vaultSrc = fs.readFileSync( - path.resolve(__dirname, '../vault.ts'), + path.resolve(__dirname, '../vault-shell.ts'), 'utf-8', ); diff --git a/extension/src/vault/vault-context.ts b/extension/src/vault/vault-context.ts new file mode 100644 index 0000000..8d38d0a --- /dev/null +++ b/extension/src/vault/vault-context.ts @@ -0,0 +1,122 @@ +// Shared contract for the vault-tab modules. vault.ts owns the state +// singleton and assembles the VaultController; each vault-* module receives +// it as `ctx`. This module sits at the bottom of the dependency graph — +// it imports only from shared/, never from vault.ts or its sibling modules. + +import type { Request, Response } from '../shared/messages'; +import type { + ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, +} from '../shared/types'; +import { + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, + GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, +} from '../shared/glyphs'; + +export type VaultView = + | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' + | 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import'; + +export interface HashRoute { + view: VaultView; + id?: string; + type?: string; +} + +export interface VaultState { + unlocked: boolean; + view: VaultView; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; + selectedIndex: number; + searchQuery: string; + activeGroup: string | null; + drawerOpen: boolean; + typePanelOpen: boolean; + vaultSettings: VaultSettings | null; + generatorDefaults: GeneratorRequest | null; + error: string | null; + loading: boolean; + newType: ItemType | null; + capturedTabId: number | null; + capturedUrl: string; + historyItemId: ItemId | null; +} + +// The controller passed to every vault-* module. vault.ts builds one instance +// and wires each hook to the function that currently lives in vault.ts (later +// Phase-4 tasks repoint individual hooks at the extracted module functions). +export interface VaultController { + readonly state: VaultState; + sendMessage(request: Request): Promise; + render(): void; + renderPane(): void; + renderListPane(): void; + renderSidebarCategories(): void; + renderDrawer(item: Item): void; + applyShellViewClass(): void; + setHash(view: VaultView, param?: string): void; + openDrawer(): void; + closeDrawer(): void; + selectItemForDrawer(id: string): Promise; + openTypePanel(): void; + closeTypePanel(): void; + wireSidebar(): void; + loadManifest(): Promise; +} + +// --- pure helpers (no state, no DOM dependencies beyond the args) --- + +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function typeIcon(t: ItemType): string { + switch (t) { + case 'login': return GLYPH_TYPE_LOGIN; + case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; + case 'identity': return GLYPH_TYPE_IDENTITY; + case 'card': return GLYPH_TYPE_CARD; + case 'key': return GLYPH_TYPE_KEY; + case 'document': return GLYPH_TYPE_DOCUMENT; + case 'totp': return GLYPH_TYPE_TOTP; + } +} + +export function typeLabel(t: ItemType): string { + const labels: Record = { + login: 'Login', + secure_note: 'Secure Note', + identity: 'Identity', + card: 'Card', + key: 'SSH / API Key', + document: 'Document', + totp: 'TOTP', + }; + return labels[t]; +} + +export function getFilteredEntries( + state: VaultState, +): Array<[ItemId, ManifestEntry]> { + let filtered = state.entries.filter( + ([, e]) => e.trashed_at === undefined || e.trashed_at === null, + ); + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = filtered.filter(([, e]) => { + if (e.title.toLowerCase().includes(q)) return true; + if (e.icon_hint?.toLowerCase().includes(q)) return true; + if (e.group?.toLowerCase().includes(q)) return true; + if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; + return false; + }); + } + filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); + return filtered; +} diff --git a/extension/src/vault/vault-shell.ts b/extension/src/vault/vault-shell.ts new file mode 100644 index 0000000..fb5f34c --- /dev/null +++ b/extension/src/vault/vault-shell.ts @@ -0,0 +1,255 @@ +// Vault-tab shell: render entry point, lock screen, 3-column shell +// scaffolding, the right-side type-picker panel, color-scheme apply, and the +// session_expired listener. Each function receives the VaultController (`ctx`) +// and reaches sibling concerns through it; pure helpers come from +// vault-context. vault.ts owns the state singleton and assembles the ctx. + +import type { ItemType } from '../shared/types'; +import { lookupErrorCopy } from '../shared/error-copy'; +import { + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY, +} from '../shared/glyphs'; +import { applyColorScheme } from '../shared/color-scheme'; +import { + type VaultController, escapeHtml, typeIcon, +} from './vault-context'; + +// --------------------------------------------------------------------------- +// Type picker (right side panel) +// --------------------------------------------------------------------------- + +const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [ + { type: 'login', label: 'Login' }, + { type: 'secure_note', label: 'Secure Note' }, + { type: 'totp', label: 'TOTP' }, + { type: 'card', label: 'Card' }, + { type: 'identity', label: 'Identity' }, + { type: 'key', label: 'SSH / API Key' }, + { type: 'document', label: 'Document' }, +]; + +// --------------------------------------------------------------------------- +// Render entry point +// --------------------------------------------------------------------------- + +export function render(ctx: VaultController): void { + const app = document.getElementById('vault-app'); + if (!app) return; + + if (!ctx.state.unlocked) { + renderLockScreen(ctx, app); + } else { + renderShell(ctx, app); + } +} + +// --------------------------------------------------------------------------- +// Lock screen +// --------------------------------------------------------------------------- + +function renderErrorBlock(code: string | null | undefined): string { + if (!code) return ''; + const copy = lookupErrorCopy(code); + const ctaHtml = copy.cta + ? `` + : ''; + return ` +
+
${escapeHtml(copy.title)}
+
${escapeHtml(copy.body)}
+ ${ctaHtml} +
+ `; +} + +export function renderLockScreen(ctx: VaultController, app: HTMLElement): void { + app.innerHTML = ` +
+ + Relicario +
+ + + ${renderErrorBlock(ctx.state.error)} +
+
+ `; + + const input = document.getElementById('vault-passphrase') as HTMLInputElement; + const btn = document.getElementById('vault-unlock-btn')!; + + const doUnlock = async () => { + const passphrase = input.value; + if (!passphrase) return; + btn.textContent = 'unlocking...'; + btn.setAttribute('disabled', 'true'); + const resp = await ctx.sendMessage({ type: 'unlock', passphrase }); + if (resp.ok) { + ctx.state.unlocked = true; + ctx.state.error = null; + await ctx.loadManifest(); + render(ctx); + } else { + ctx.state.error = resp.error ?? 'unlock failed'; + render(ctx); + } + }; + + btn.addEventListener('click', doUnlock); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doUnlock(); + }); + input.focus(); +} + +// --------------------------------------------------------------------------- +// Shell (3-column: sidebar + list pane + drawer) +// --------------------------------------------------------------------------- + +export function renderShell(ctx: VaultController, app: HTMLElement): void { + if (!app.querySelector('.vault-shell')) { + app.innerHTML = ` +
+
+
+ + Relicario +
+ + +
+ + + + + + +
+
+
+
+
+
+ +
+ `; + ctx.wireSidebar(); + wireTypePanel(ctx); + } + + applyShellViewClass(ctx); + ctx.renderSidebarCategories(); + if (ctx.state.view === 'list') { + ctx.renderListPane(); + if (ctx.state.drawerOpen && ctx.state.selectedItem) { + ctx.renderDrawer(ctx.state.selectedItem); + } + } else { + ctx.renderPane(); + } +} + +// Toggle which middle column is visible based on the current view. +// list view → list-pane (+ optional drawer); other views → vault-pane. +export function applyShellViewClass(ctx: VaultController): void { + const shell = document.querySelector('.vault-shell'); + if (!shell) return; + shell.classList.toggle('vault-shell--list', ctx.state.view === 'list'); + shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list'); +} + +// --------------------------------------------------------------------------- +// Right-side type picker panel +// --------------------------------------------------------------------------- + +export function wireTypePanel(ctx: VaultController): void { + document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx)); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx); + }); +} + +export function openTypePanel(ctx: VaultController): void { + const panel = document.getElementById('vault-type-panel'); + const scrim = document.getElementById('vault-type-scrim'); + if (!panel || !scrim) return; + + panel.innerHTML = ` +
+
New item
+ +
+
Choose a type
+ + `; + + panel.classList.add('vault-type-panel--open'); + scrim.classList.add('vault-type-panel-scrim--visible'); + ctx.state.typePanelOpen = true; + + panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx)); + + panel.querySelectorAll('[data-type]').forEach((btn) => { + btn.addEventListener('click', () => { + const type = btn.dataset.type as ItemType; + closeTypePanel(ctx); + // Use the host's navigate hook so view + hash + visibility all update + // together. This was the bug: bare setHash + renderPane left the + // shell stuck in list view with #vault-pane hidden. + ctx.state.newType = type; + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.drawerOpen = false; + ctx.state.view = 'add'; + ctx.setHash('add', type); + applyShellViewClass(ctx); + ctx.renderSidebarCategories(); + ctx.renderPane(); + }); + }); + + // Focus first item for keyboard users + (panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus(); +} + +export function closeTypePanel(ctx: VaultController): void { + document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open'); + document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible'); + ctx.state.typePanelOpen = false; +} + +// --------------------------------------------------------------------------- +// Color scheme + session-expired wiring (bootstrap helpers) +// --------------------------------------------------------------------------- + +export async function applyVaultColorScheme(): Promise { + await applyColorScheme(); + + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && 'password_display_scheme' in changes) { + void applyColorScheme(); + } + }); +} + +export function wireSessionExpiredListener(ctx: VaultController): void { + chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'session_expired') { + ctx.state.unlocked = false; + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.entries = []; + ctx.state.error = null; + render(ctx); + } + }); +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 421c918..26c86c0 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -9,13 +9,8 @@ import type { ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, } from '../shared/types'; import { registerHost } from '../shared/state'; -import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy'; +import { type ErrorCta } from '../shared/error-copy'; import { relativeTime } from '../shared/relative-time'; -import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY, - GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, - GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, -} from '../shared/glyphs'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; @@ -26,21 +21,15 @@ import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/c import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index'; import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; -import { applyColorScheme } from '../shared/color-scheme'; - -// --------------------------------------------------------------------------- -// Type picker (right side panel) -// --------------------------------------------------------------------------- - -const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [ - { type: 'login', label: 'Login' }, - { type: 'secure_note', label: 'Secure Note' }, - { type: 'totp', label: 'TOTP' }, - { type: 'card', label: 'Card' }, - { type: 'identity', label: 'Identity' }, - { type: 'key', label: 'SSH / API Key' }, - { type: 'document', label: 'Document' }, -]; +import { + type VaultController, type VaultState, type VaultView, type HashRoute, + escapeHtml, typeIcon, typeLabel, getFilteredEntries, +} from './vault-context'; +import { + render, applyShellViewClass, + openTypePanel, closeTypePanel, applyVaultColorScheme, + wireSessionExpiredListener, +} from './vault-shell'; // --------------------------------------------------------------------------- // Helpers @@ -68,74 +57,17 @@ function sendMessage(request: Request): Promise { state.selectedItem = null; state.entries = []; state.error = 'Session expired — please unlock again.'; - render(); + render(ctx); } resolve(response); }); }); } -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function renderErrorBlock(code: string | null | undefined): string { - if (!code) return ''; - const copy = lookupErrorCopy(code); - const ctaHtml = copy.cta - ? `` - : ''; - return ` -
-
${escapeHtml(copy.title)}
-
${escapeHtml(copy.body)}
- ${ctaHtml} -
- `; -} - -function typeIcon(t: ItemType): string { - switch (t) { - case 'login': return GLYPH_TYPE_LOGIN; - case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; - case 'identity': return GLYPH_TYPE_IDENTITY; - case 'card': return GLYPH_TYPE_CARD; - case 'key': return GLYPH_TYPE_KEY; - case 'document': return GLYPH_TYPE_DOCUMENT; - case 'totp': return GLYPH_TYPE_TOTP; - } -} - -function typeLabel(t: ItemType): string { - const labels: Record = { - login: 'Login', - secure_note: 'Secure Note', - identity: 'Identity', - card: 'Card', - key: 'SSH / API Key', - document: 'Document', - totp: 'TOTP', - }; - return labels[t]; -} - // --------------------------------------------------------------------------- // Hash routing // --------------------------------------------------------------------------- -type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import'; - -interface HashRoute { - view: VaultView; - id?: string; - type?: string; -} - function parseHash(): HashRoute { let raw = window.location.hash.replace(/^#\/?/, ''); if (!raw) return { view: 'list' }; @@ -181,27 +113,6 @@ function setHash(view: VaultView, param?: string): void { // State // --------------------------------------------------------------------------- -interface VaultState { - unlocked: boolean; - view: VaultView; - entries: Array<[ItemId, ManifestEntry]>; - selectedId: ItemId | null; - selectedItem: Item | null; - selectedIndex: number; - searchQuery: string; - activeGroup: string | null; - drawerOpen: boolean; - typePanelOpen: boolean; - vaultSettings: VaultSettings | null; - generatorDefaults: GeneratorRequest | null; - error: string | null; - loading: boolean; - newType: ItemType | null; - capturedTabId: number | null; - capturedUrl: string; - historyItemId: ItemId | null; -} - const state: VaultState = { unlocked: false, view: 'list', @@ -223,6 +134,29 @@ const state: VaultState = { historyItemId: null, }; +// --------------------------------------------------------------------------- +// Controller — carries state + cross-module re-render hooks +// --------------------------------------------------------------------------- + +const ctx: VaultController = { + state, + sendMessage, + render: () => render(ctx), + renderPane: () => renderPane(), + renderListPane: () => renderListPane(), + renderSidebarCategories: () => renderSidebarCategories(), + renderDrawer: (item) => renderDrawer(item), + applyShellViewClass: () => applyShellViewClass(ctx), + setHash, + openDrawer: () => openDrawer(), + closeDrawer: () => closeDrawer(), + selectItemForDrawer: (id) => selectItemForDrawer(id), + openTypePanel: () => openTypePanel(ctx), + closeTypePanel: () => closeTypePanel(ctx), + wireSidebar: () => wireSidebar(), + loadManifest: () => loadManifest(), +}; + // --------------------------------------------------------------------------- // Register as shared state host // --------------------------------------------------------------------------- @@ -236,7 +170,7 @@ registerHost({ navigate: (view, extras) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); - applyShellViewClass(); + applyShellViewClass(ctx); renderSidebarCategories(); if (state.view === 'list') renderListPane(); renderPane(); @@ -248,190 +182,6 @@ registerHost({ openVaultTab: () => {}, }); -// --------------------------------------------------------------------------- -// Render entry point -// --------------------------------------------------------------------------- - -function render(): void { - const app = document.getElementById('vault-app'); - if (!app) return; - - if (!state.unlocked) { - renderLockScreen(app); - } else { - renderShell(app); - } -} - -// --------------------------------------------------------------------------- -// Lock screen -// --------------------------------------------------------------------------- - -function renderLockScreen(app: HTMLElement): void { - app.innerHTML = ` -
- - Relicario -
- - - ${renderErrorBlock(state.error)} -
-
- `; - - const input = document.getElementById('vault-passphrase') as HTMLInputElement; - const btn = document.getElementById('vault-unlock-btn')!; - - const doUnlock = async () => { - const passphrase = input.value; - if (!passphrase) return; - btn.textContent = 'unlocking...'; - btn.setAttribute('disabled', 'true'); - const resp = await sendMessage({ type: 'unlock', passphrase }); - if (resp.ok) { - state.unlocked = true; - state.error = null; - await loadManifest(); - render(); - } else { - state.error = resp.error ?? 'unlock failed'; - render(); - } - }; - - btn.addEventListener('click', doUnlock); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') doUnlock(); - }); - input.focus(); -} - -// --------------------------------------------------------------------------- -// Shell (3-column: sidebar + list pane + drawer) -// --------------------------------------------------------------------------- - -function renderShell(app: HTMLElement): void { - if (!app.querySelector('.vault-shell')) { - app.innerHTML = ` -
-
-
- - Relicario -
- - -
- - - - - - -
-
-
-
-
-
- -
- `; - wireSidebar(); - wireTypePanel(); - } - - applyShellViewClass(); - renderSidebarCategories(); - if (state.view === 'list') { - renderListPane(); - if (state.drawerOpen && state.selectedItem) { - renderDrawer(state.selectedItem); - } - } else { - renderPane(); - } -} - -// Toggle which middle column is visible based on the current view. -// list view → list-pane (+ optional drawer); other views → vault-pane. -function applyShellViewClass(): void { - const shell = document.querySelector('.vault-shell'); - if (!shell) return; - shell.classList.toggle('vault-shell--list', state.view === 'list'); - shell.classList.toggle('vault-shell--pane', state.view !== 'list'); -} - -// --------------------------------------------------------------------------- -// Right-side type picker panel -// --------------------------------------------------------------------------- - -function wireTypePanel(): void { - document.getElementById('vault-type-scrim')?.addEventListener('click', closeTypePanel); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && state.typePanelOpen) closeTypePanel(); - }); -} - -function openTypePanel(): void { - const panel = document.getElementById('vault-type-panel'); - const scrim = document.getElementById('vault-type-scrim'); - if (!panel || !scrim) return; - - panel.innerHTML = ` -
-
New item
- -
-
Choose a type
- - `; - - panel.classList.add('vault-type-panel--open'); - scrim.classList.add('vault-type-panel-scrim--visible'); - state.typePanelOpen = true; - - panel.querySelector('#vault-type-close')?.addEventListener('click', closeTypePanel); - - panel.querySelectorAll('[data-type]').forEach((btn) => { - btn.addEventListener('click', () => { - const type = btn.dataset.type as ItemType; - closeTypePanel(); - // Use the host's navigate hook so view + hash + visibility all update - // together. This was the bug: bare setHash + renderPane left the - // shell stuck in list view with #vault-pane hidden. - state.newType = type; - state.selectedId = null; - state.selectedItem = null; - state.drawerOpen = false; - state.view = 'add'; - setHash('add', type); - applyShellViewClass(); - renderSidebarCategories(); - renderPane(); - }); - }); - - // Focus first item for keyboard users - (panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus(); -} - -function closeTypePanel(): void { - document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open'); - document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible'); - state.typePanelOpen = false; -} - // --------------------------------------------------------------------------- // Drawer (implemented in Task 10) // --------------------------------------------------------------------------- @@ -581,7 +331,7 @@ function wireSidebar(): void { state.selectedId = null; state.selectedItem = null; state.entries = []; - render(); + render(ctx); return; } if (nav === 'add') { @@ -590,7 +340,7 @@ function wireSidebar(): void { state.newType = null; state.drawerOpen = false; closeDrawer(); - openTypePanel(); + openTypePanel(ctx); return; } if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { @@ -600,7 +350,7 @@ function wireSidebar(): void { state.drawerOpen = false; state.view = nav; setHash(nav); - applyShellViewClass(); + applyShellViewClass(ctx); renderPane(); return; } @@ -633,29 +383,11 @@ function isEditableTarget(target: EventTarget | null): boolean { // Sidebar category nav // --------------------------------------------------------------------------- -function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { - let filtered = state.entries.filter( - ([, e]) => e.trashed_at === undefined || e.trashed_at === null, - ); - if (state.searchQuery) { - const q = state.searchQuery.toLowerCase(); - filtered = filtered.filter(([, e]) => { - if (e.title.toLowerCase().includes(q)) return true; - if (e.icon_hint?.toLowerCase().includes(q)) return true; - if (e.group?.toLowerCase().includes(q)) return true; - if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; - return false; - }); - } - filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); - return filtered; -} - function renderSidebarCategories(): void { const container = document.getElementById('vault-categories'); if (!container) return; - const filtered = getFilteredEntries(); + const filtered = getFilteredEntries(state); const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; const allCount = filtered.length; @@ -693,7 +425,7 @@ function renderSidebarCategories(): void { state.selectedItem = null; state.view = 'list'; setHash('list'); - applyShellViewClass(); + applyShellViewClass(ctx); renderSidebarCategories(); renderListPane(); closeDrawer(); @@ -710,7 +442,7 @@ function renderListPane(): void { if (!pane) return; const group = state.activeGroup as ItemType | null; - let items = getFilteredEntries(); + let items = getFilteredEntries(state); if (group) items = items.filter(([, e]) => e.type === group); if (items.length === 0) { @@ -835,7 +567,7 @@ function renderPane(): void { const route = parseHash(); // Keep state.view in sync with hash for components that read it state.view = route.view; - applyShellViewClass(); + applyShellViewClass(ctx); pane.className = 'vault-pane'; @@ -930,13 +662,7 @@ async function loadManifest(): Promise { // --------------------------------------------------------------------------- document.addEventListener('DOMContentLoaded', async () => { - await applyColorScheme(); - - chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'sync' && 'password_display_scheme' in changes) { - void applyColorScheme(); - } - }); + await applyVaultColorScheme(); // Delegated handler for .error-cta buttons — set up once on the stable root. const app = document.getElementById('vault-app')!; @@ -970,19 +696,9 @@ document.addEventListener('DOMContentLoaded', async () => { } } - render(); + render(ctx); - // Session expired listener - chrome.runtime.onMessage.addListener((msg) => { - if (msg.type === 'session_expired') { - state.unlocked = false; - state.selectedId = null; - state.selectedItem = null; - state.entries = []; - state.error = null; - render(); - } - }); + wireSessionExpiredListener(ctx); // Hash change listener window.addEventListener('hashchange', () => { @@ -990,7 +706,7 @@ document.addEventListener('DOMContentLoaded', async () => { const route = parseHash(); state.view = route.view; - applyShellViewClass(); + applyShellViewClass(ctx); // If navigating to a detail/edit view for an item we already have loaded if ((route.view === 'detail' || route.view === 'edit') && route.id) { From 9049512e0dad5a6d16d03551f3db55bfe6ad2d82 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 15:39:26 -0400 Subject: [PATCH 2/7] refactor(ext/vault): extract vault-sidebar.ts with debounced search (Plan C Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the sidebar column out of vault.ts/vault-shell.ts into vault-sidebar.ts: its markup (now incl. an empty #vault-status-slot footer for Phase 6), the category nav rendering, nav-button wiring, and search. The search input gains an 80ms trailing-edge debounce (P2 fix — it re-filtered on every keystroke). Co-Authored-By: Claude Opus 4.8 --- .../vault/__tests__/sidebar-glyphs.test.ts | 2 +- extension/src/vault/vault-shell.ts | 23 +-- extension/src/vault/vault-sidebar.ts | 174 ++++++++++++++++++ extension/src/vault/vault.ts | 142 +------------- 4 files changed, 186 insertions(+), 155 deletions(-) create mode 100644 extension/src/vault/vault-sidebar.ts diff --git a/extension/src/vault/__tests__/sidebar-glyphs.test.ts b/extension/src/vault/__tests__/sidebar-glyphs.test.ts index 98415db..586a2b1 100644 --- a/extension/src/vault/__tests__/sidebar-glyphs.test.ts +++ b/extension/src/vault/__tests__/sidebar-glyphs.test.ts @@ -4,7 +4,7 @@ import * as path from 'path'; describe('vault sidebar glyphs', () => { const vaultSrc = fs.readFileSync( - path.resolve(__dirname, '../vault-shell.ts'), + path.resolve(__dirname, '../vault-sidebar.ts'), 'utf-8', ); diff --git a/extension/src/vault/vault-shell.ts b/extension/src/vault/vault-shell.ts index fb5f34c..a7e1b7a 100644 --- a/extension/src/vault/vault-shell.ts +++ b/extension/src/vault/vault-shell.ts @@ -6,13 +6,11 @@ import type { ItemType } from '../shared/types'; import { lookupErrorCopy } from '../shared/error-copy'; -import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY, -} from '../shared/glyphs'; import { applyColorScheme } from '../shared/color-scheme'; import { type VaultController, escapeHtml, typeIcon, } from './vault-context'; +import { renderSidebarShell } from './vault-sidebar'; // --------------------------------------------------------------------------- // Type picker (right side panel) @@ -110,24 +108,7 @@ export function renderShell(ctx: VaultController, app: HTMLElement): void { if (!app.querySelector('.vault-shell')) { app.innerHTML = `
-
-
- - Relicario -
- - -
- - - - - - -
-
+ ${renderSidebarShell()}
diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts new file mode 100644 index 0000000..b7db7b7 --- /dev/null +++ b/extension/src/vault/vault-sidebar.ts @@ -0,0 +1,174 @@ +// Vault-tab sidebar column: its static markup, the category nav rendering, +// nav-button wiring, and the (now debounced) search input. Each function +// receives the VaultController (`ctx`) and reaches sibling concerns through it; +// pure helpers come from vault-context. Imports only from shared/ and +// vault-context — never from vault-shell or vault.ts. + +import type { ItemType } from '../shared/types'; +import { + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, +} from '../shared/glyphs'; +import { + type VaultController, typeIcon, typeLabel, getFilteredEntries, +} from './vault-context'; + +const SEARCH_DEBOUNCE_MS = 80; + +// --------------------------------------------------------------------------- +// Sidebar markup +// --------------------------------------------------------------------------- + +export function renderSidebarShell(): string { + return ` +
+
+ + Relicario +
+ + +
+ + + + + + +
+ +
`; +} + +// --------------------------------------------------------------------------- +// Sidebar wiring +// --------------------------------------------------------------------------- + +export function wireSidebar(ctx: VaultController): void { + // Search (debounced — trailing edge) + const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; + let searchTimer: number | undefined; + searchInput?.addEventListener('input', () => { + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + ctx.state.searchQuery = searchInput.value; + renderSidebarCategories(ctx); + ctx.renderListPane(); + }, SEARCH_DEBOUNCE_MS); + }); + + // Nav buttons + document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => { + btn.addEventListener('click', async () => { + const nav = (btn as HTMLElement).dataset.nav; + if (nav === 'lock') { + await ctx.sendMessage({ type: 'lock' }); + ctx.state.unlocked = false; + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.entries = []; + ctx.render(); + return; + } + if (nav === 'add') { + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.newType = null; + ctx.state.drawerOpen = false; + ctx.closeDrawer(); + ctx.openTypePanel(); + return; + } + if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.newType = null; + ctx.state.drawerOpen = false; + ctx.state.view = nav; + ctx.setHash(nav); + ctx.applyShellViewClass(); + ctx.renderPane(); + return; + } + }); + }); + + // Global "/" shortcut to focus search; Esc to close drawer + document.addEventListener('keydown', (e) => { + if (e.key === '/' && !isEditableTarget(e.target)) { + e.preventDefault(); + searchInput?.focus(); + return; + } + if (e.key === 'Escape' && ctx.state.drawerOpen) { + ctx.closeDrawer(); + ctx.renderListPane(); + } + }); +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (target.isContentEditable) return true; + return false; +} + +// --------------------------------------------------------------------------- +// Sidebar category nav +// --------------------------------------------------------------------------- + +export function renderSidebarCategories(ctx: VaultController): void { + const container = document.getElementById('vault-categories'); + if (!container) return; + + const filtered = getFilteredEntries(ctx.state); + const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; + + const allCount = filtered.length; + const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list'; + + let html = ` + + `; + + for (const t of typeOrder) { + const count = filtered.filter(([, e]) => e.type === t).length; + // Always show Login (staple type); hide other types when empty. + if (count === 0 && t !== 'login') continue; + const isActive = ctx.state.activeGroup === t; + html += ` + + `; + } + + container.innerHTML = html; + + container.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + ctx.state.activeGroup = btn.dataset.group || null; + ctx.state.drawerOpen = false; + ctx.state.selectedId = null; + ctx.state.selectedItem = null; + ctx.state.view = 'list'; + ctx.setHash('list'); + ctx.applyShellViewClass(); + renderSidebarCategories(ctx); + ctx.renderListPane(); + ctx.closeDrawer(); + }); + }); +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 26c86c0..1521dec 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -23,13 +23,14 @@ import { renderBackupPanel, teardown as teardownBackup } from './components/back import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { type VaultController, type VaultState, type VaultView, type HashRoute, - escapeHtml, typeIcon, typeLabel, getFilteredEntries, + escapeHtml, typeIcon, getFilteredEntries, } from './vault-context'; import { render, applyShellViewClass, openTypePanel, closeTypePanel, applyVaultColorScheme, wireSessionExpiredListener, } from './vault-shell'; +import { wireSidebar, renderSidebarCategories } from './vault-sidebar'; // --------------------------------------------------------------------------- // Helpers @@ -144,7 +145,7 @@ const ctx: VaultController = { render: () => render(ctx), renderPane: () => renderPane(), renderListPane: () => renderListPane(), - renderSidebarCategories: () => renderSidebarCategories(), + renderSidebarCategories: () => renderSidebarCategories(ctx), renderDrawer: (item) => renderDrawer(item), applyShellViewClass: () => applyShellViewClass(ctx), setHash, @@ -153,7 +154,7 @@ const ctx: VaultController = { selectItemForDrawer: (id) => selectItemForDrawer(id), openTypePanel: () => openTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx), - wireSidebar: () => wireSidebar(), + wireSidebar: () => wireSidebar(ctx), loadManifest: () => loadManifest(), }; @@ -171,7 +172,7 @@ registerHost({ Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass(ctx); - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); renderPane(); }, @@ -302,137 +303,12 @@ async function selectItemForDrawer(id: string): Promise { state.selectedId = id; state.selectedItem = data.item; state.drawerOpen = true; - renderSidebarCategories(); + renderSidebarCategories(ctx); renderListPane(); renderDrawer(data.item); openDrawer(); } -// --------------------------------------------------------------------------- -// Sidebar wiring -// --------------------------------------------------------------------------- - -function wireSidebar(): void { - // Search - const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; - searchInput?.addEventListener('input', () => { - state.searchQuery = searchInput.value; - renderSidebarCategories(); - renderListPane(); - }); - - // Nav buttons - document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => { - btn.addEventListener('click', async () => { - const nav = (btn as HTMLElement).dataset.nav; - if (nav === 'lock') { - await sendMessage({ type: 'lock' }); - state.unlocked = false; - state.selectedId = null; - state.selectedItem = null; - state.entries = []; - render(ctx); - return; - } - if (nav === 'add') { - state.selectedId = null; - state.selectedItem = null; - state.newType = null; - state.drawerOpen = false; - closeDrawer(); - openTypePanel(ctx); - return; - } - if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { - state.selectedId = null; - state.selectedItem = null; - state.newType = null; - state.drawerOpen = false; - state.view = nav; - setHash(nav); - applyShellViewClass(ctx); - renderPane(); - return; - } - }); - }); - - // Global "/" shortcut to focus search; Esc to close drawer - document.addEventListener('keydown', (e) => { - if (e.key === '/' && !isEditableTarget(e.target)) { - e.preventDefault(); - searchInput?.focus(); - return; - } - if (e.key === 'Escape' && state.drawerOpen) { - closeDrawer(); - renderListPane(); - } - }); -} - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - const tag = target.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; - if (target.isContentEditable) return true; - return false; -} - -// --------------------------------------------------------------------------- -// Sidebar category nav -// --------------------------------------------------------------------------- - -function renderSidebarCategories(): void { - const container = document.getElementById('vault-categories'); - if (!container) return; - - const filtered = getFilteredEntries(state); - const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; - - const allCount = filtered.length; - const isAllActive = !state.activeGroup && state.view === 'list'; - - let html = ` - - `; - - for (const t of typeOrder) { - const count = filtered.filter(([, e]) => e.type === t).length; - // Always show Login (staple type); hide other types when empty. - if (count === 0 && t !== 'login') continue; - const isActive = state.activeGroup === t; - html += ` - - `; - } - - container.innerHTML = html; - - container.querySelectorAll('.vault-category-row').forEach((btn) => { - btn.addEventListener('click', () => { - state.activeGroup = btn.dataset.group || null; - state.drawerOpen = false; - state.selectedId = null; - state.selectedItem = null; - state.view = 'list'; - setHash('list'); - applyShellViewClass(ctx); - renderSidebarCategories(); - renderListPane(); - closeDrawer(); - }); - }); -} - // --------------------------------------------------------------------------- // List pane // --------------------------------------------------------------------------- @@ -712,7 +588,7 @@ document.addEventListener('DOMContentLoaded', async () => { if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { renderPane(); - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); return; } @@ -724,7 +600,7 @@ document.addEventListener('DOMContentLoaded', async () => { // For non-item views, just re-render the pane state.selectedId = null; state.selectedItem = null; - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); renderPane(); }); @@ -743,7 +619,7 @@ async function selectItem(id: ItemId): Promise { state.selectedItem = data.item; state.loading = false; setHash('detail', id); - renderSidebarCategories(); + renderSidebarCategories(ctx); renderListPane(); renderPane(); } else { From 68cada559375d9067200d2d892636867240822ed Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 17:09:45 -0400 Subject: [PATCH 3/7] refactor(ext/vault): extract vault-list.ts (Plan C Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the list-pane rendering (renderListPane: row markup, empty state, and row-click → selectItemForDrawer) out of vault.ts into vault-list.ts, taking the VaultController ctx. No behavior change. Co-Authored-By: Claude Opus 4.8 --- extension/src/vault/vault-list.ts | 52 +++++++++++++++++++++++++ extension/src/vault/vault.ts | 64 +++++-------------------------- 2 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 extension/src/vault/vault-list.ts diff --git a/extension/src/vault/vault-list.ts b/extension/src/vault/vault-list.ts new file mode 100644 index 0000000..7e6b39c --- /dev/null +++ b/extension/src/vault/vault-list.ts @@ -0,0 +1,52 @@ +// Vault-tab list column: renders the middle list pane (row markup, empty +// state, and the row-click → drawer 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 { ItemId, ManifestEntry, ItemType } from '../shared/types'; +import { relativeTime } from '../shared/relative-time'; +import { + type VaultController, escapeHtml, typeIcon, getFilteredEntries, +} from './vault-context'; + +export function renderListPane(ctx: VaultController): void { + const pane = document.getElementById('vault-list-pane'); + if (!pane) return; + + const group = ctx.state.activeGroup as ItemType | null; + let items = getFilteredEntries(ctx.state); + if (group) items = items.filter(([, e]) => e.type === group); + + if (items.length === 0) { + pane.innerHTML = ` +
+ +
${ctx.state.searchQuery ? `No results for "${escapeHtml(ctx.state.searchQuery)}"` : 'No items yet'}
+
${ctx.state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
+
+ `; + return; + } + + pane.innerHTML = items.map(([id, e]: [ItemId, ManifestEntry]) => { + const sel = id === ctx.state.selectedId ? ' vault-list-row--selected' : ''; + const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : ''); + const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; + return ` +
+ +
+
${escapeHtml(e.title)}
+ ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''} +
+ `; + }).join(''); + + pane.querySelectorAll('.vault-list-row').forEach((row) => { + row.addEventListener('click', async () => { + await ctx.selectItemForDrawer(row.dataset.id!); + }); + }); +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 1521dec..825ef9d 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -10,7 +10,6 @@ import type { } from '../shared/types'; import { registerHost } from '../shared/state'; import { type ErrorCta } from '../shared/error-copy'; -import { relativeTime } from '../shared/relative-time'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; @@ -23,7 +22,7 @@ import { renderBackupPanel, teardown as teardownBackup } from './components/back import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { type VaultController, type VaultState, type VaultView, type HashRoute, - escapeHtml, typeIcon, getFilteredEntries, + escapeHtml, } from './vault-context'; import { render, applyShellViewClass, @@ -31,6 +30,7 @@ import { wireSessionExpiredListener, } from './vault-shell'; import { wireSidebar, renderSidebarCategories } from './vault-sidebar'; +import { renderListPane } from './vault-list'; // --------------------------------------------------------------------------- // Helpers @@ -144,7 +144,7 @@ const ctx: VaultController = { sendMessage, render: () => render(ctx), renderPane: () => renderPane(), - renderListPane: () => renderListPane(), + renderListPane: () => renderListPane(ctx), renderSidebarCategories: () => renderSidebarCategories(ctx), renderDrawer: (item) => renderDrawer(item), applyShellViewClass: () => applyShellViewClass(ctx), @@ -173,7 +173,7 @@ registerHost({ setHash(view as VaultView); applyShellViewClass(ctx); renderSidebarCategories(ctx); - if (state.view === 'list') renderListPane(); + if (state.view === 'list') renderListPane(ctx); renderPane(); }, sendMessage, @@ -281,7 +281,7 @@ function renderDrawer(item: Item): void { document.getElementById('drawer-close-btn')?.addEventListener('click', () => { closeDrawer(); - renderListPane(); + renderListPane(ctx); }); document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { @@ -304,57 +304,11 @@ async function selectItemForDrawer(id: string): Promise { state.selectedItem = data.item; state.drawerOpen = true; renderSidebarCategories(ctx); - renderListPane(); + renderListPane(ctx); renderDrawer(data.item); openDrawer(); } -// --------------------------------------------------------------------------- -// List pane -// --------------------------------------------------------------------------- - -function renderListPane(): void { - const pane = document.getElementById('vault-list-pane'); - if (!pane) return; - - const group = state.activeGroup as ItemType | null; - let items = getFilteredEntries(state); - if (group) items = items.filter(([, e]) => e.type === group); - - if (items.length === 0) { - pane.innerHTML = ` -
- -
${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}
-
${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
-
- `; - return; - } - - pane.innerHTML = items.map(([id, e]) => { - const sel = id === state.selectedId ? ' vault-list-row--selected' : ''; - const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : ''); - const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; - return ` -
- -
-
${escapeHtml(e.title)}
- ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} -
- ${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''} -
- `; - }).join(''); - - pane.querySelectorAll('.vault-list-row').forEach((row) => { - row.addEventListener('click', async () => { - await selectItemForDrawer(row.dataset.id!); - }); - }); -} - // --------------------------------------------------------------------------- // Platform-aware save hint // --------------------------------------------------------------------------- @@ -589,7 +543,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (state.selectedId === route.id && state.selectedItem) { renderPane(); renderSidebarCategories(ctx); - if (state.view === 'list') renderListPane(); + if (state.view === 'list') renderListPane(ctx); return; } // Need to fetch the item @@ -601,7 +555,7 @@ document.addEventListener('DOMContentLoaded', async () => { state.selectedId = null; state.selectedItem = null; renderSidebarCategories(ctx); - if (state.view === 'list') renderListPane(); + if (state.view === 'list') renderListPane(ctx); renderPane(); }); }); @@ -620,7 +574,7 @@ async function selectItem(id: ItemId): Promise { state.loading = false; setHash('detail', id); renderSidebarCategories(ctx); - renderListPane(); + renderListPane(ctx); renderPane(); } else { state.loading = false; From 7f076b49ac8d4ba8b48327dd961f239788426d15 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 17:28:57 -0400 Subject: [PATCH 4/7] refactor(ext/vault): extract vault-drawer.ts + ensureDrawerClosedForRoute (Plan C Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/vault/__tests__/drawer-state.test.ts | 28 ++++ extension/src/vault/vault-drawer.ts | 138 ++++++++++++++++++ extension/src/vault/vault.ts | 137 +---------------- 3 files changed, 174 insertions(+), 129 deletions(-) create mode 100644 extension/src/vault/__tests__/drawer-state.test.ts create mode 100644 extension/src/vault/vault-drawer.ts 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); From fecf58e54ac478bb986b3bf858a6c3257866a252 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 20:02:25 -0400 Subject: [PATCH 5/7] refactor(ext/vault): extract vault-form-wrapper.ts (Plan C Phase 4) Moves renderFormWrapped (sticky save bar + header + dirty-state wiring), the SAVE_HINT/isMac consts, and the __test__ export out of vault.ts into vault-form-wrapper.ts, taking the VaultController ctx. Repoints the source-text form-wrapper test to read the new module. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../src/vault/__tests__/form-wrapper.test.ts | 2 +- extension/src/vault/vault-form-wrapper.ts | 72 +++++++++++++++++++ extension/src/vault/vault.ts | 70 +----------------- 3 files changed, 76 insertions(+), 68 deletions(-) create mode 100644 extension/src/vault/vault-form-wrapper.ts diff --git a/extension/src/vault/__tests__/form-wrapper.test.ts b/extension/src/vault/__tests__/form-wrapper.test.ts index 791c34d..270300f 100644 --- a/extension/src/vault/__tests__/form-wrapper.test.ts +++ b/extension/src/vault/__tests__/form-wrapper.test.ts @@ -4,7 +4,7 @@ import * as path from 'path'; describe('fullscreen form dirty subtitle', () => { const vaultSrc = fs.readFileSync( - path.resolve(__dirname, '../vault.ts'), + path.resolve(__dirname, '../vault-form-wrapper.ts'), 'utf-8', ); diff --git a/extension/src/vault/vault-form-wrapper.ts b/extension/src/vault/vault-form-wrapper.ts new file mode 100644 index 0000000..cf1d9b5 --- /dev/null +++ b/extension/src/vault/vault-form-wrapper.ts @@ -0,0 +1,72 @@ +// Fullscreen form wrapper for the vault tab: sticky save bar + scrollable +// content + header with a live dirty-state subtitle. Receives the +// VaultController (`ctx`) for the item-type read; imports only from shared/, +// the popup item-form component, and vault-context. + +import { renderItemForm } from '../popup/components/item-form'; +import { type VaultController } from './vault-context'; + +// --------------------------------------------------------------------------- +// Platform-aware save hint +// --------------------------------------------------------------------------- + +const isMac = navigator.platform.toLowerCase().includes('mac'); +const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; + +// --------------------------------------------------------------------------- +// Fullscreen form wrapper — sticky save bar + scrollable content + header +// --------------------------------------------------------------------------- + +export function renderFormWrapped(ctx: VaultController, app: HTMLElement, mode: 'add' | 'edit'): void { + const itemType = ctx.state.selectedItem?.type ?? ctx.state.newType ?? 'login'; + const typeLabelText = itemType.replace('_', ' '); + const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`; + const wrapper = document.createElement('div'); + wrapper.className = 'form-pane'; + wrapper.innerHTML = ` +
+
+
${titleText}
+
no changes
+
+
${SAVE_HINT}
+
+
+
+ + +
+ `; + // Remove pane padding so form-pane can fill height cleanly + app.style.padding = '0'; + app.style.overflow = 'hidden'; + app.replaceChildren(wrapper); + + const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; + renderItemForm(scrollEl, mode); + + const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; + let isDirty = false; + const markDirty = () => { + if (isDirty) return; + isDirty = true; + subEl.textContent = 'unsaved · esc to cancel'; + }; + const markClean = () => { + isDirty = false; + subEl.textContent = 'no changes'; + }; + scrollEl.addEventListener('input', markDirty, true); + scrollEl.addEventListener('change', markDirty, true); + + wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click(); + }); + wrapper.querySelector('#form-save')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click(); + }); +} + +export const __test__ = { renderFormWrapped }; diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 19c22f4..97285bf 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -35,6 +35,7 @@ import { openDrawer, closeDrawer, renderDrawer, selectItemForDrawer, ensureDrawerClosedForRoute, } from './vault-drawer'; +import { renderFormWrapped } from './vault-form-wrapper'; // --------------------------------------------------------------------------- // Helpers @@ -187,71 +188,6 @@ registerHost({ openVaultTab: () => {}, }); -// --------------------------------------------------------------------------- -// Platform-aware save hint -// --------------------------------------------------------------------------- - -const isMac = navigator.platform.toLowerCase().includes('mac'); -const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; - -// --------------------------------------------------------------------------- -// Fullscreen form wrapper — sticky save bar + scrollable content + header -// --------------------------------------------------------------------------- - -function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { - const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; - const typeLabelText = itemType.replace('_', ' '); - const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`; - const wrapper = document.createElement('div'); - wrapper.className = 'form-pane'; - wrapper.innerHTML = ` -
-
-
${titleText}
-
no changes
-
-
${SAVE_HINT}
-
-
-
- - -
- `; - // Remove pane padding so form-pane can fill height cleanly - app.style.padding = '0'; - app.style.overflow = 'hidden'; - app.replaceChildren(wrapper); - - const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; - renderItemForm(scrollEl, mode); - - const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; - let isDirty = false; - const markDirty = () => { - if (isDirty) return; - isDirty = true; - subEl.textContent = 'unsaved · esc to cancel'; - }; - const markClean = () => { - isDirty = false; - subEl.textContent = 'no changes'; - }; - scrollEl.addEventListener('input', markDirty, true); - scrollEl.addEventListener('change', markDirty, true); - - wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { - markClean(); - (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click(); - }); - wrapper.querySelector('#form-save')?.addEventListener('click', () => { - markClean(); - (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click(); - }); -} - -export const __test__ = { renderFormWrapped }; - // --------------------------------------------------------------------------- // Pane rendering — delegates to shared popup components // --------------------------------------------------------------------------- @@ -297,13 +233,13 @@ function renderPane(): void { // Use the form wrapper (sticky bar + header) when a type is already chosen. // Without a type the type-selection screen renders — no sticky bar needed. if (state.newType) { - renderFormWrapped(pane, 'add'); + renderFormWrapped(ctx, pane, 'add'); } else { renderItemForm(pane, 'add'); } break; case 'edit': - renderFormWrapped(pane, 'edit'); + renderFormWrapped(ctx, pane, 'edit'); break; case 'trash': renderTrash(pane); From 31913b864851f67835c83635cdd4fa3f0504f4f0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 20:13:09 -0400 Subject: [PATCH 6/7] refactor(ext/vault): extract vault-router.ts; trim vault.ts to entry point (Plan C Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the routing core — parseHash/setHash, the renderPane pane-dispatch + teardownPaneComponents, loadManifest, and selectItem — out of vault.ts into vault-router.ts (carrying the popup-component imports with it). vault.ts is now just the entry point: state singleton, the VaultController assembly, the StateHost registration, and the DOMContentLoaded bootstrap (1037 -> 203 LOC). No behavior change. Co-Authored-By: Claude Opus 4.8 --- extension/src/vault/vault-router.ts | 205 ++++++++++++++++++++++++++ extension/src/vault/vault.ts | 215 ++-------------------------- 2 files changed, 215 insertions(+), 205 deletions(-) create mode 100644 extension/src/vault/vault-router.ts diff --git a/extension/src/vault/vault-router.ts b/extension/src/vault/vault-router.ts new file mode 100644 index 0000000..406b6f4 --- /dev/null +++ b/extension/src/vault/vault-router.ts @@ -0,0 +1,205 @@ +// Vault-tab routing core: hash parsing/serialization, pane dispatch (delegating +// to the shared popup components), and data loading. Receives the +// VaultController (`ctx`) and reaches sibling concerns through it. Imports only +// from shared/, the popup components, vault-context, vault-drawer, and +// vault-form-wrapper — never from vault.ts or the shell/sidebar/list modules. + +import type { + ItemId, ItemType, ManifestEntry, Item, VaultSettings, +} from '../shared/types'; +import { renderItemDetail } from '../popup/components/item-detail'; +import { renderItemForm } from '../popup/components/item-form'; +import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; +import { renderDevices, teardown as teardownDevices } from '../popup/components/devices'; +import { renderSettings, teardownSettings } from '../popup/components/settings'; +import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; +import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; +import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index'; +import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; +import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; +import { + type VaultController, type VaultView, type HashRoute, +} from './vault-context'; +import { ensureDrawerClosedForRoute } from './vault-drawer'; +import { renderFormWrapped } from './vault-form-wrapper'; + +// --------------------------------------------------------------------------- +// Hash routing +// --------------------------------------------------------------------------- + +export function parseHash(): HashRoute { + let raw = window.location.hash.replace(/^#\/?/, ''); + if (!raw) return { view: 'list' }; + + // Normalize legacy bookmarks: #field-history/ → #history/ + if (raw.startsWith('field-history/')) { + raw = 'history/' + raw.slice('field-history/'.length); + window.location.hash = raw; + } + + const parts = raw.split('/'); + const view = parts[0] as VaultView; + + switch (view) { + case 'detail': + case 'edit': + return { view, id: parts[1] }; + case 'add': + return { view, type: parts[1] }; + case 'history': + return parts[1] + ? { view: 'field-history', id: parts[1] } + : { view: 'history' }; + case 'trash': + case 'devices': + case 'settings': + case 'settings-vault': + case 'field-history': + case 'backup': + case 'import': + return { view }; + default: + return { view: 'list' }; + } +} + +export function setHash(view: VaultView, param?: string): void { + const fragment = param ? `${view}/${param}` : view; + window.location.hash = fragment === 'list' ? '' : fragment; +} + +// --------------------------------------------------------------------------- +// Pane rendering — delegates to shared popup components +// --------------------------------------------------------------------------- + +function teardownPaneComponents(): void { + teardownTrash(); + teardownDevices(); + teardownSettings(); + teardownFieldHistory(); + teardownHistoryIndex(); + teardownBackup(); + teardownImport(); +} + +export function renderPane(ctx: VaultController): void { + const pane = document.getElementById('vault-pane'); + if (!pane) return; + + teardownPaneComponents(); + + const route = parseHash(); + ensureDrawerClosedForRoute(ctx.state, route); + // Keep state.view in sync with hash for components that read it + ctx.state.view = route.view; + ctx.applyShellViewClass(); + + pane.className = 'vault-pane'; + + switch (route.view) { + case 'detail': + if (ctx.state.selectedItem) { + renderItemDetail(pane); + } else { + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + } + break; + case 'add': + // Prefer hash type for deep-links; otherwise keep the in-memory value + // set by the type-selection click handler (which calls setState → + // renderPane before the URL hash has been updated to include the type). + ctx.state.newType = (route.type as ItemType) ?? ctx.state.newType ?? null; + // Use the form wrapper (sticky bar + header) when a type is already chosen. + // Without a type the type-selection screen renders — no sticky bar needed. + if (ctx.state.newType) { + renderFormWrapped(ctx, pane, 'add'); + } else { + renderItemForm(pane, 'add'); + } + break; + case 'edit': + renderFormWrapped(ctx, pane, 'edit'); + break; + case 'trash': + renderTrash(pane); + break; + case 'devices': + renderDevices(pane); + break; + case 'settings': + void renderSettings(pane); + break; + case 'settings-vault': + renderVaultSettingsView(pane); + break; + case 'field-history': + renderFieldHistory(pane); + break; + case 'history': + renderItemHistoryIndex(pane); + break; + case 'backup': + renderBackupPanel(pane); + break; + case 'import': + renderImportPanel(pane); + break; + default: + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + break; + } +} + +// --------------------------------------------------------------------------- +// Data loading +// --------------------------------------------------------------------------- + +export async function loadManifest(ctx: VaultController): Promise { + const listResp = await ctx.sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + ctx.state.entries = data.items; + } + + const vsResp = await ctx.sendMessage({ type: 'get_vault_settings' }); + if (vsResp.ok) { + const data = vsResp.data as { settings: VaultSettings }; + ctx.state.vaultSettings = data.settings; + ctx.state.generatorDefaults = data.settings.generator_defaults; + } + + // Handle deep link from hash + const route = parseHash(); + if (route.view === 'detail' && route.id) { + const itemResp = await ctx.sendMessage({ type: 'get_item', id: route.id }); + if (itemResp.ok) { + const data = itemResp.data as { item: Item }; + ctx.state.selectedId = route.id; + ctx.state.selectedItem = data.item; + } + } +} + +// --------------------------------------------------------------------------- +// Legacy selectItem — used by hash-change deep linking +// --------------------------------------------------------------------------- + +export async function selectItem(ctx: VaultController, id: ItemId): Promise { + ctx.state.loading = true; + const resp = await ctx.sendMessage({ type: 'get_item', id }); + if (resp.ok) { + const data = resp.data as { item: Item }; + ctx.state.selectedId = id; + ctx.state.selectedItem = data.item; + ctx.state.loading = false; + setHash('detail', id); + ctx.renderSidebarCategories(); + ctx.renderListPane(); + renderPane(ctx); + } else { + ctx.state.loading = false; + ctx.state.error = (resp as { error: string }).error; + } +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 97285bf..f07434c 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -5,23 +5,10 @@ /// vault tab's pane area. import type { Request, Response } from '../shared/messages'; -import type { - ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, -} from '../shared/types'; import { registerHost } from '../shared/state'; import { type ErrorCta } from '../shared/error-copy'; -import { renderItemDetail } from '../popup/components/item-detail'; -import { renderItemForm } from '../popup/components/item-form'; -import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; -import { renderDevices, teardown as teardownDevices } from '../popup/components/devices'; -import { renderSettings, teardownSettings } from '../popup/components/settings'; -import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; -import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; -import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index'; -import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; -import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { - type VaultController, type VaultState, type VaultView, type HashRoute, + type VaultController, type VaultState, type VaultView, escapeHtml, } from './vault-context'; import { @@ -33,9 +20,8 @@ import { wireSidebar, renderSidebarCategories } from './vault-sidebar'; import { renderListPane } from './vault-list'; import { openDrawer, closeDrawer, renderDrawer, selectItemForDrawer, - ensureDrawerClosedForRoute, } from './vault-drawer'; -import { renderFormWrapped } from './vault-form-wrapper'; +import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vault-router'; // --------------------------------------------------------------------------- // Helpers @@ -70,51 +56,6 @@ function sendMessage(request: Request): Promise { }); } -// --------------------------------------------------------------------------- -// Hash routing -// --------------------------------------------------------------------------- - -function parseHash(): HashRoute { - let raw = window.location.hash.replace(/^#\/?/, ''); - if (!raw) return { view: 'list' }; - - // Normalize legacy bookmarks: #field-history/ → #history/ - if (raw.startsWith('field-history/')) { - raw = 'history/' + raw.slice('field-history/'.length); - window.location.hash = raw; - } - - const parts = raw.split('/'); - const view = parts[0] as VaultView; - - switch (view) { - case 'detail': - case 'edit': - return { view, id: parts[1] }; - case 'add': - return { view, type: parts[1] }; - case 'history': - return parts[1] - ? { view: 'field-history', id: parts[1] } - : { view: 'history' }; - case 'trash': - case 'devices': - case 'settings': - case 'settings-vault': - case 'field-history': - case 'backup': - case 'import': - return { view }; - default: - return { view: 'list' }; - } -} - -function setHash(view: VaultView, param?: string): void { - const fragment = param ? `${view}/${param}` : view; - window.location.hash = fragment === 'list' ? '' : fragment; -} - // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -148,7 +89,7 @@ const ctx: VaultController = { state, sendMessage, render: () => render(ctx), - renderPane: () => renderPane(), + renderPane: () => renderPane(ctx), renderListPane: () => renderListPane(ctx), renderSidebarCategories: () => renderSidebarCategories(ctx), renderDrawer: (item) => renderDrawer(ctx, item), @@ -160,7 +101,7 @@ const ctx: VaultController = { openTypePanel: () => openTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx), wireSidebar: () => wireSidebar(ctx), - loadManifest: () => loadManifest(), + loadManifest: () => loadManifest(ctx), }; // --------------------------------------------------------------------------- @@ -171,7 +112,7 @@ registerHost({ getState: () => state, setState: (partial) => { Object.assign(state, partial); - renderPane(); + renderPane(ctx); }, navigate: (view, extras) => { Object.assign(state, { view, error: null, loading: false, ...extras }); @@ -179,7 +120,7 @@ registerHost({ applyShellViewClass(ctx); renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(ctx); - renderPane(); + renderPane(ctx); }, sendMessage, escapeHtml, @@ -188,120 +129,6 @@ registerHost({ openVaultTab: () => {}, }); -// --------------------------------------------------------------------------- -// Pane rendering — delegates to shared popup components -// --------------------------------------------------------------------------- - -function teardownPaneComponents(): void { - teardownTrash(); - teardownDevices(); - teardownSettings(); - teardownFieldHistory(); - teardownHistoryIndex(); - teardownBackup(); - teardownImport(); -} - -function renderPane(): void { - const pane = document.getElementById('vault-pane'); - if (!pane) return; - - 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); - - pane.className = 'vault-pane'; - - switch (route.view) { - case 'detail': - if (state.selectedItem) { - renderItemDetail(pane); - } else { - pane.className = 'vault-pane vault-pane--empty'; - pane.innerHTML = 'select an item'; - } - break; - case 'add': - // Prefer hash type for deep-links; otherwise keep the in-memory value - // set by the type-selection click handler (which calls setState → - // renderPane before the URL hash has been updated to include the type). - state.newType = (route.type as ItemType) ?? state.newType ?? null; - // Use the form wrapper (sticky bar + header) when a type is already chosen. - // Without a type the type-selection screen renders — no sticky bar needed. - if (state.newType) { - renderFormWrapped(ctx, pane, 'add'); - } else { - renderItemForm(pane, 'add'); - } - break; - case 'edit': - renderFormWrapped(ctx, pane, 'edit'); - break; - case 'trash': - renderTrash(pane); - break; - case 'devices': - renderDevices(pane); - break; - case 'settings': - void renderSettings(pane); - break; - case 'settings-vault': - renderVaultSettingsView(pane); - break; - case 'field-history': - renderFieldHistory(pane); - break; - case 'history': - renderItemHistoryIndex(pane); - break; - case 'backup': - renderBackupPanel(pane); - break; - case 'import': - renderImportPanel(pane); - break; - default: - pane.className = 'vault-pane vault-pane--empty'; - pane.innerHTML = 'select an item'; - break; - } -} - -// --------------------------------------------------------------------------- -// Data loading -// --------------------------------------------------------------------------- - -async function loadManifest(): Promise { - const listResp = await sendMessage({ type: 'list_items' }); - if (listResp.ok) { - const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; - state.entries = data.items; - } - - const vsResp = await sendMessage({ type: 'get_vault_settings' }); - if (vsResp.ok) { - const data = vsResp.data as { settings: VaultSettings }; - state.vaultSettings = data.settings; - state.generatorDefaults = data.settings.generator_defaults; - } - - // Handle deep link from hash - const route = parseHash(); - if (route.view === 'detail' && route.id) { - const itemResp = await sendMessage({ type: 'get_item', id: route.id }); - if (itemResp.ok) { - const data = itemResp.data as { item: Item }; - state.selectedId = route.id; - state.selectedItem = data.item; - } - } -} - // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- @@ -337,7 +164,7 @@ document.addEventListener('DOMContentLoaded', async () => { const data = resp.data as { unlocked: boolean }; if (data.unlocked) { state.unlocked = true; - await loadManifest(); + await loadManifest(ctx); } } @@ -356,13 +183,13 @@ document.addEventListener('DOMContentLoaded', async () => { // If navigating to a detail/edit view for an item we already have loaded if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { - renderPane(); + renderPane(ctx); renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(ctx); return; } // Need to fetch the item - selectItem(route.id); + selectItem(ctx, route.id); return; } @@ -371,28 +198,6 @@ document.addEventListener('DOMContentLoaded', async () => { state.selectedItem = null; renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(ctx); - renderPane(); + renderPane(ctx); }); }); - -// --------------------------------------------------------------------------- -// Legacy selectItem — used by hash-change deep linking -// --------------------------------------------------------------------------- - -async function selectItem(id: ItemId): Promise { - state.loading = true; - const resp = await sendMessage({ type: 'get_item', id }); - if (resp.ok) { - const data = resp.data as { item: Item }; - state.selectedId = id; - state.selectedItem = data.item; - state.loading = false; - setHash('detail', id); - renderSidebarCategories(ctx); - renderListPane(ctx); - renderPane(); - } else { - state.loading = false; - state.error = (resp as { error: string }).error; - } -} From 0c722b3a9de22ee24d0240c4ec0eb5e6473ea1d6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 20:20:52 -0400 Subject: [PATCH 7/7] refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4) The session-lost intercept lived in vault.ts's local sendMessage; both surfaces now consume it through the shared sendMessage() wrapper. On a vault_locked response to any non-bypassed request, the wrapper calls host.navigate('locked'). The vault host's navigate gains a 'locked' branch (it shows its lock screen off state.unlocked); the popup's navigate already handles 'locked'. vault.ts routes ctx.sendMessage through the shared wrapper and registers a plain transport as host.sendMessage, so internal RPCs keep the intercept without recursion. grep -c vault_locked vault.ts == 0. New state-vault-locked.test.ts (TDD, 6 cases). Co-Authored-By: Claude Opus 4.8 --- .../__tests__/state-vault-locked.test.ts | 66 +++++++++++++++++++ extension/src/shared/state.ts | 26 +++++++- extension/src/vault/vault.ts | 45 +++++-------- 3 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 extension/src/shared/__tests__/state-vault-locked.test.ts diff --git a/extension/src/shared/__tests__/state-vault-locked.test.ts b/extension/src/shared/__tests__/state-vault-locked.test.ts new file mode 100644 index 0000000..16003ad --- /dev/null +++ b/extension/src/shared/__tests__/state-vault-locked.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerHost, __resetHostForTests, sendMessage } from '../state'; +import type { StateHost } from '../state'; +import type { Response } from '../messages'; + +function makeHost(response: { ok: boolean; error?: string }): StateHost { + return { + getState: () => ({ view: 'list' } as never), + setState: vi.fn(), + navigate: vi.fn(), + sendMessage: vi.fn().mockResolvedValue(response as Response), + escapeHtml: (s) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), + }; +} + +describe('shared/state sendMessage vault_locked intercept', () => { + beforeEach(() => __resetHostForTests()); + + it('navigates to the lock screen on a vault_locked response', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).toHaveBeenCalledWith( + 'locked', + expect.objectContaining({ error: expect.any(String) }), + ); + }); + + it('does NOT intercept the unlock request itself', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'unlock', passphrase: 'x' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does NOT intercept is_unlocked', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'is_unlocked' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does not intercept a successful response', async () => { + const host = makeHost({ ok: true }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does not intercept a non-vault_locked error', async () => { + const host = makeHost({ ok: false, error: 'something_else' }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('returns the response unchanged', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + const resp = await sendMessage({ type: 'list_items' }); + expect(resp).toEqual({ ok: false, error: 'vault_locked' }); + }); +}); diff --git a/extension/src/shared/state.ts b/extension/src/shared/state.ts index 30deeaf..68dfa04 100644 --- a/extension/src/shared/state.ts +++ b/extension/src/shared/state.ts @@ -50,13 +50,33 @@ export function navigate(view: View, extras?: Partial): void { host.navigate(view, extras); } +// Requests that must NOT trigger the lock screen on a vault_locked response: +// they run during cold start / unlock, before a session exists, so a +// vault_locked here is expected rather than a lost session. +const VAULT_LOCKED_BYPASS: ReadonlySet = new Set([ + 'unlock', 'is_unlocked', +]); + /** - * Phase 4 will add a vault_locked intercept here. For now, this is a pure - * pass-through so the signature is stable for Phase 4 to fill. + * Dispatches a request to the service worker and intercepts the `vault_locked` + * response. MV3 evicts the service worker after ~30s idle, wiping the in-memory + * session/manifest; the next RPC comes back `vault_locked`. Any surface (popup + * or vault tab) that gets that on a non-bypassed request treats it as "session + * lost" and navigates to the lock screen so the user can re-enter their + * passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4 + * so both surfaces share one channel. */ export async function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); - return host.sendMessage(request); + const response = await host.sendMessage(request); + if ( + !response.ok && + response.error === 'vault_locked' && + !VAULT_LOCKED_BYPASS.has(request.type) + ) { + host.navigate('locked', { error: 'Session expired — please unlock again.' }); + } + return response; } export function escapeHtml(s: string): string { diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index f07434c..82b11e1 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -5,7 +5,7 @@ /// vault tab's pane area. import type { Request, Response } from '../shared/messages'; -import { registerHost } from '../shared/state'; +import { registerHost, sendMessage } from '../shared/state'; import { type ErrorCta } from '../shared/error-copy'; import { type VaultController, type VaultState, type VaultView, @@ -27,32 +27,12 @@ import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vaul // Helpers // --------------------------------------------------------------------------- -function sendMessage(request: Request): Promise { +// Plain transport to the service worker, registered as the host's sendMessage. +// The shared sendMessage() wrapper (shared/state.ts) layers the session-lost +// → lock-screen intercept on top of this for every UI RPC. +function postToServiceWorker(request: Request): Promise { return new Promise((resolve) => { - chrome.runtime.sendMessage(request, (response: Response) => { - // MV3 service workers are evicted after ~30s idle, which wipes the - // in-memory session/manifest/gitHost. The fullscreen tab stays open - // and has no signal that the SW restarted — the next RPC just comes - // back `vault_locked`. Treat that as "session lost" and force the - // lock screen so the user can re-enter their passphrase. Skip for - // is_unlocked / unlock themselves to avoid loops on cold start. - if ( - response && - !response.ok && - response.error === 'vault_locked' && - request.type !== 'is_unlocked' && - request.type !== 'unlock' && - state.unlocked - ) { - state.unlocked = false; - state.selectedId = null; - state.selectedItem = null; - state.entries = []; - state.error = 'Session expired — please unlock again.'; - render(ctx); - } - resolve(response); - }); + chrome.runtime.sendMessage(request, (response: Response) => resolve(response)); }); } @@ -115,6 +95,17 @@ registerHost({ renderPane(ctx); }, navigate: (view, extras) => { + if (view === 'locked') { + // Session lost (SW evicted mid-session). The vault shows its lock + // screen off state.unlocked, so flip it and drop the in-memory data. + state.unlocked = false; + state.selectedId = null; + state.selectedItem = null; + state.entries = []; + state.error = (extras?.error as string | undefined) ?? null; + render(ctx); + return; + } Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass(ctx); @@ -122,7 +113,7 @@ registerHost({ if (state.view === 'list') renderListPane(ctx); renderPane(ctx); }, - sendMessage, + sendMessage: postToServiceWorker, escapeHtml, popOutToTab: () => {}, isInTab: () => true,