refactor(ext/vault): extract vault-router.ts; trim vault.ts to entry point (Plan C Phase 4)

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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-31 20:13:09 -04:00
parent fecf58e54a
commit 31913b8648
2 changed files with 215 additions and 205 deletions

View File

@@ -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/<id> → #history/<id>
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<void> {
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<void> {
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;
}
}

View File

@@ -5,23 +5,10 @@
/// vault tab's pane area. /// vault tab's pane area.
import type { Request, Response } from '../shared/messages'; import type { Request, Response } from '../shared/messages';
import type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
} from '../shared/types';
import { registerHost } from '../shared/state'; import { registerHost } from '../shared/state';
import { type ErrorCta } from '../shared/error-copy'; 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 { import {
type VaultController, type VaultState, type VaultView, type HashRoute, type VaultController, type VaultState, type VaultView,
escapeHtml, escapeHtml,
} from './vault-context'; } from './vault-context';
import { import {
@@ -33,9 +20,8 @@ import { wireSidebar, renderSidebarCategories } from './vault-sidebar';
import { renderListPane } from './vault-list'; import { renderListPane } from './vault-list';
import { import {
openDrawer, closeDrawer, renderDrawer, selectItemForDrawer, openDrawer, closeDrawer, renderDrawer, selectItemForDrawer,
ensureDrawerClosedForRoute,
} from './vault-drawer'; } from './vault-drawer';
import { renderFormWrapped } from './vault-form-wrapper'; import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vault-router';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -70,51 +56,6 @@ function sendMessage(request: Request): Promise<Response> {
}); });
} }
// ---------------------------------------------------------------------------
// Hash routing
// ---------------------------------------------------------------------------
function parseHash(): HashRoute {
let raw = window.location.hash.replace(/^#\/?/, '');
if (!raw) return { view: 'list' };
// Normalize legacy bookmarks: #field-history/<id> → #history/<id>
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 // State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -148,7 +89,7 @@ const ctx: VaultController = {
state, state,
sendMessage, sendMessage,
render: () => render(ctx), render: () => render(ctx),
renderPane: () => renderPane(), renderPane: () => renderPane(ctx),
renderListPane: () => renderListPane(ctx), renderListPane: () => renderListPane(ctx),
renderSidebarCategories: () => renderSidebarCategories(ctx), renderSidebarCategories: () => renderSidebarCategories(ctx),
renderDrawer: (item) => renderDrawer(ctx, item), renderDrawer: (item) => renderDrawer(ctx, item),
@@ -160,7 +101,7 @@ const ctx: VaultController = {
openTypePanel: () => openTypePanel(ctx), openTypePanel: () => openTypePanel(ctx),
closeTypePanel: () => closeTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx),
wireSidebar: () => wireSidebar(ctx), wireSidebar: () => wireSidebar(ctx),
loadManifest: () => loadManifest(), loadManifest: () => loadManifest(ctx),
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -171,7 +112,7 @@ registerHost({
getState: () => state, getState: () => state,
setState: (partial) => { setState: (partial) => {
Object.assign(state, partial); Object.assign(state, partial);
renderPane(); renderPane(ctx);
}, },
navigate: (view, extras) => { navigate: (view, extras) => {
Object.assign(state, { view, error: null, loading: false, ...extras }); Object.assign(state, { view, error: null, loading: false, ...extras });
@@ -179,7 +120,7 @@ registerHost({
applyShellViewClass(ctx); applyShellViewClass(ctx);
renderSidebarCategories(ctx); renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx); if (state.view === 'list') renderListPane(ctx);
renderPane(); renderPane(ctx);
}, },
sendMessage, sendMessage,
escapeHtml, escapeHtml,
@@ -188,120 +129,6 @@ registerHost({
openVaultTab: () => {}, 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<void> {
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 // Init
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -337,7 +164,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const data = resp.data as { unlocked: boolean }; const data = resp.data as { unlocked: boolean };
if (data.unlocked) { if (data.unlocked) {
state.unlocked = true; 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 navigating to a detail/edit view for an item we already have loaded
if ((route.view === 'detail' || route.view === 'edit') && route.id) { if ((route.view === 'detail' || route.view === 'edit') && route.id) {
if (state.selectedId === route.id && state.selectedItem) { if (state.selectedId === route.id && state.selectedItem) {
renderPane(); renderPane(ctx);
renderSidebarCategories(ctx); renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx); if (state.view === 'list') renderListPane(ctx);
return; return;
} }
// Need to fetch the item // Need to fetch the item
selectItem(route.id); selectItem(ctx, route.id);
return; return;
} }
@@ -371,28 +198,6 @@ document.addEventListener('DOMContentLoaded', async () => {
state.selectedItem = null; state.selectedItem = null;
renderSidebarCategories(ctx); renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx); if (state.view === 'list') renderListPane(ctx);
renderPane(); renderPane(ctx);
}); });
}); });
// ---------------------------------------------------------------------------
// Legacy selectItem — used by hash-change deep linking
// ---------------------------------------------------------------------------
async function selectItem(id: ItemId): Promise<void> {
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;
}
}