From 31913b864851f67835c83635cdd4fa3f0504f4f0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 20:13:09 -0400 Subject: [PATCH] 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; - } -}