Files
relicario/extension/src/vault/vault.ts
adlee-was-taken 0c722b3a9d 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 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00

195 lines
6.1 KiB
TypeScript

/// Vault tab entry point — full "desktop-like" sidebar + pane layout.
///
/// Registers as the shared state host so popup components (item-detail,
/// item-form, trash, devices, settings, etc.) render natively in the
/// vault tab's pane area.
import type { Request, Response } from '../shared/messages';
import { registerHost, sendMessage } from '../shared/state';
import { type ErrorCta } from '../shared/error-copy';
import {
type VaultController, type VaultState, type VaultView,
escapeHtml,
} from './vault-context';
import {
render, applyShellViewClass,
openTypePanel, closeTypePanel, applyVaultColorScheme,
wireSessionExpiredListener,
} from './vault-shell';
import { wireSidebar, renderSidebarCategories } from './vault-sidebar';
import { renderListPane } from './vault-list';
import {
openDrawer, closeDrawer, renderDrawer, selectItemForDrawer,
} from './vault-drawer';
import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vault-router';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// 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<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => resolve(response));
});
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state: VaultState = {
unlocked: false,
view: 'list',
entries: [],
selectedId: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
drawerOpen: false,
typePanelOpen: false,
vaultSettings: null,
generatorDefaults: null,
error: null,
loading: false,
newType: null,
capturedTabId: null,
capturedUrl: '',
historyItemId: null,
};
// ---------------------------------------------------------------------------
// Controller — carries state + cross-module re-render hooks
// ---------------------------------------------------------------------------
const ctx: VaultController = {
state,
sendMessage,
render: () => render(ctx),
renderPane: () => renderPane(ctx),
renderListPane: () => renderListPane(ctx),
renderSidebarCategories: () => renderSidebarCategories(ctx),
renderDrawer: (item) => renderDrawer(ctx, item),
applyShellViewClass: () => applyShellViewClass(ctx),
setHash,
openDrawer: () => openDrawer(),
closeDrawer: () => closeDrawer(ctx),
selectItemForDrawer: (id) => selectItemForDrawer(ctx, id),
openTypePanel: () => openTypePanel(ctx),
closeTypePanel: () => closeTypePanel(ctx),
wireSidebar: () => wireSidebar(ctx),
loadManifest: () => loadManifest(ctx),
};
// ---------------------------------------------------------------------------
// Register as shared state host
// ---------------------------------------------------------------------------
registerHost({
getState: () => state,
setState: (partial) => {
Object.assign(state, partial);
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);
renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx);
renderPane(ctx);
},
sendMessage: postToServiceWorker,
escapeHtml,
popOutToTab: () => {},
isInTab: () => true,
openVaultTab: () => {},
});
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', async () => {
await applyVaultColorScheme();
// Delegated handler for .error-cta buttons — set up once on the stable root.
const app = document.getElementById('vault-app')!;
app.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
if (!btn) return;
const cta = btn.dataset.cta as ErrorCta['action'];
switch (cta) {
case 'unlock': {
document.getElementById('vault-passphrase')?.focus();
break;
}
case 'open_setup': {
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
break;
}
case 'reload_extension': {
chrome.runtime.reload();
break;
}
}
});
// Check if already unlocked
const resp = await sendMessage({ type: 'is_unlocked' });
if (resp.ok) {
const data = resp.data as { unlocked: boolean };
if (data.unlocked) {
state.unlocked = true;
await loadManifest(ctx);
}
}
render(ctx);
wireSessionExpiredListener(ctx);
// Hash change listener
window.addEventListener('hashchange', () => {
if (!state.unlocked) return;
const route = parseHash();
state.view = route.view;
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) {
if (state.selectedId === route.id && state.selectedItem) {
renderPane(ctx);
renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx);
return;
}
// Need to fetch the item
selectItem(ctx, route.id);
return;
}
// For non-item views, just re-render the pane
state.selectedId = null;
state.selectedItem = null;
renderSidebarCategories(ctx);
if (state.view === 'list') renderListPane(ctx);
renderPane(ctx);
});
});