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:
205
extension/src/vault/vault-router.ts
Normal file
205
extension/src/vault/vault-router.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user