feat(ext): shared state host — decouple components from popup.ts

Introduce shared/state.ts as a service-locator so popup components
(item-detail, item-form, trash, devices, settings, etc.) work in both
the popup and vault tab bundles. Both entry points register themselves
as the host; components import from shared/state instead of popup.ts.
Vault.ts now delegates to the real popup components, removing ~300 lines
of placeholder renderers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 16:38:06 -04:00
parent 6c8ebb3548
commit ce59223fc0
38 changed files with 259 additions and 441 deletions

View File

@@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../popup', async () => {
vi.mock('../../../shared/state', async () => {
const sendMessage = vi.fn();
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
return { sendMessage, escapeHtml };
return { sendMessage, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure';
import { sendMessage } from '../../popup';
import { sendMessage } from '../../../shared/state';
import type { AttachmentRef } from '../../../shared/types';
const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 };

View File

@@ -12,14 +12,17 @@ globalThis.chrome = {
};
// Mock popup module
vi.mock('../../popup', () => ({
vi.mock('../../../shared/state', () => ({
setState: vi.fn(),
sendMessage: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: vi.fn(() => false),
openVaultTab: vi.fn(),
}));
import { sendMessage, navigate } from '../../popup';
import { sendMessage, navigate } from '../../../shared/state';
describe('devices view', () => {
let app: HTMLElement;

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderFieldHistory, teardown } from '../field-history';
// Mock popup module
vi.mock('../../popup', () => ({
vi.mock('../../../shared/state', () => ({
getState: vi.fn(() => ({
historyItemId: 'item123',
selectedItem: { id: 'item123', title: 'Test Item', modified: 1000 },
@@ -11,9 +11,12 @@ vi.mock('../../popup', () => ({
sendMessage: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: vi.fn(() => false),
openVaultTab: vi.fn(),
}));
import { sendMessage, navigate } from '../../popup';
import { sendMessage, navigate } from '../../../shared/state';
describe('field-history view', () => {
let app: HTMLElement;

View File

@@ -1,4 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../shared/state', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import {
renderRow,
renderConcealedRow,

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../popup', async () => {
vi.mock('../../../shared/state', async () => {
const sendMessage = vi.fn();
return { sendMessage };
return { sendMessage, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
import { sendMessage } from '../../popup';
import { sendMessage } from '../../../shared/state';
import type { GeneratorRequest } from '../../../shared/types';
const DEFAULT_REQ: GeneratorRequest = {

View File

@@ -1,4 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../shared/state', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields';
import type { Section } from '../../../shared/types';

View File

@@ -1,4 +1,12 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../shared/state', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderSections } from '../fields';
import type { Item } from '../../../shared/types';

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../popup', async () => {
vi.mock('../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -25,7 +25,7 @@ vi.mock('../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
vi.mock('../generator-panel', () => ({
@@ -34,7 +34,7 @@ vi.mock('../generator-panel', () => ({
}));
import { renderVaultSettings } from '../settings-vault';
import { sendMessage } from '../../popup';
import { sendMessage } from '../../../shared/state';
describe('settings-vault', () => {
beforeEach(() => {

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderTrash } from '../trash';
// Mock popup module
vi.mock('../../popup', () => ({
vi.mock('../../../shared/state', () => ({
getState: vi.fn(() => ({
vaultSettings: { trash_retention: { kind: 'days', value: 30 } },
})),
@@ -10,9 +10,12 @@ vi.mock('../../popup', () => ({
sendMessage: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: vi.fn(() => false),
openVaultTab: vi.fn(),
}));
import { sendMessage, navigate } from '../../popup';
import { sendMessage, navigate } from '../../../shared/state';
describe('trash view', () => {
let app: HTMLElement;

View File

@@ -5,7 +5,7 @@
/// Image-mime rows lazy-load 16×16 thumbnails via object URLs;
/// teardownAttachmentsDisclosure() revokes them on view exit.
import { sendMessage, escapeHtml } from '../popup';
import { sendMessage, escapeHtml } from '../../shared/state';
import type { AttachmentRef, VaultSettings } from '../../shared/types';
export type DisclosureMode = 'edit' | 'view';

View File

@@ -1,6 +1,6 @@
/// Device management view — list devices with revoke actions.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
function relativeTime(unixSec: number): string {

View File

@@ -1,6 +1,6 @@
/// Field history view — shows password/concealed field history for an item.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { FieldHistoryView } from '../../shared/types';
function relativeTime(unixSec: number): string {

View File

@@ -5,7 +5,7 @@
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
/// copy click handlers on any rendered rows.
import { escapeHtml } from '../popup';
import { escapeHtml } from '../../shared/state';
import type { Item, Section, Field, FieldValue } from '../../shared/types';
export interface RowOpts {

View File

@@ -4,7 +4,7 @@
/// between Random + BIP39 knob sets. Action row varies by context:
/// fill-field shows cancel+use; configure-defaults shows only save-default.
import { sendMessage } from '../popup';
import { sendMessage } from '../../shared/state';
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
interface UiKnobs {

View File

@@ -1,9 +1,8 @@
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate } from '../popup';
import { navigate, getState } from '../../shared/state';
import type { Item } from '../../shared/types';
import { getState } from '../popup';
import * as login from './types/login';
import * as secureNote from './types/secure-note';
import * as identity from './types/identity';

View File

@@ -1,7 +1,7 @@
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate, getState, setState, escapeHtml, popOutToTab } from '../popup';
import { navigate, getState, setState, escapeHtml, popOutToTab } from '../../shared/state';
import type { Item, ItemType } from '../../shared/types';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [

View File

@@ -2,7 +2,7 @@
/// type-iconed rows. Clicking a row fetches the full Item and navigates
/// to the detail view.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
/// Extract the display hostname from an icon_hint or fallback to the first tag.
@@ -148,8 +148,9 @@ async function openItem(id: ItemId): Promise<void> {
/// Compute the visible (filtered) entry list from current state.
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
const state = getState();
const entries: Array<[ItemId, ManifestEntry]> = state.entries;
// Hide trashed items from the main list.
let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null);
let filtered = entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null);
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
filtered = filtered.filter(([, e]) => {

View File

@@ -2,7 +2,7 @@
/// generator defaults (preview + "configure" → opens popover), and
/// autofill origin-ack revocation.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types';

View File

@@ -1,6 +1,6 @@
/// Settings view — capture toggle, prompt style, and blacklist management.
import { sendMessage, navigate, escapeHtml } from '../popup';
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {

View File

@@ -1,6 +1,6 @@
/// Trash view — lists soft-deleted items with restore/purge actions.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
const TYPE_ICONS: Record<string, string> = {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -11,11 +11,11 @@ vi.mock('../../../popup', async () => {
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../card';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
describe('Card save shape', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -12,11 +12,11 @@ vi.mock('../../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../document';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
import type { Item, AttachmentRef } from '../../../../shared/types';
const PRIMARY: AttachmentRef = {
@@ -43,7 +43,7 @@ describe('Document form save', () => {
await new Promise((r) => setTimeout(r, 50));
expect(alertSpy).not.toHaveBeenCalled();
// setState called with the error
const { setState } = await import('../../../popup');
const { setState } = await import('../../../../shared/state');
expect(vi.mocked(setState)).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('primary attachment') }),
);

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -12,11 +12,11 @@ vi.mock('../../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../identity';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
describe('Identity save shape', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -11,11 +11,11 @@ vi.mock('../../../popup', async () => {
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../key';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
describe('Key save shape', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -13,11 +13,11 @@ vi.mock('../../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../login';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
describe('Login form packs sectionsDraft into Item.sections', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -12,11 +12,11 @@ vi.mock('../../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../secure-note';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
describe('SecureNote save shape', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
@@ -12,11 +12,11 @@ vi.mock('../../../popup', async () => {
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
});
import { renderForm } from '../totp';
import { sendMessage } from '../../../popup';
import { sendMessage } from '../../../../shared/state';
import { base32Decode } from '../../../../shared/base32';
describe('Totp save shape', () => {

View File

@@ -1,7 +1,7 @@
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
/// Detail view has a styled card-silhouette signature block.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,

View File

@@ -2,7 +2,7 @@
/// notes/tags + optional supplementary attachments.
/// Primary attachment is referenced by ID from the item's attachments array.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderSectionsEditor, wireSectionsEditor,

View File

@@ -1,7 +1,7 @@
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
/// Detail view shows a "profile card" signature block + plain rows.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,

View File

@@ -2,7 +2,7 @@
/// Form's key_material textarea uses CSS text-security to mask characters
/// since <textarea type="password"> isn't a thing.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,

View File

@@ -1,7 +1,7 @@
/// Login type detail + form. Reference implementation for the shared
/// field helpers introduced in Slice 2.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';

View File

@@ -1,7 +1,7 @@
/// SecureNote: a single multiline body field. Concealed by default in the
/// detail view; the form is just a big <textarea>.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,

View File

@@ -2,7 +2,7 @@
/// signature block with a thin SVG countdown ring; form has a kind toggle
/// (TOTP vs Steam Guard) and a single secret input.
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {

View File

@@ -1,6 +1,6 @@
/// Unlock view — passphrase input with ENTER to submit.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import type { ItemId, ManifestEntry } from '../../shared/types';
export function renderUnlock(app: HTMLElement): void {

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from '../shared/messages';
import type { ItemId, ManifestEntry, Item } from '../shared/types';
import { registerHost } from '../shared/state';
import { renderUnlock } from './components/unlock';
import { renderItemList } from './components/item-list';
import { renderItemDetail } from './components/item-detail';
@@ -164,6 +165,19 @@ export function navigate(view: View, extras?: Partial<PopupState>): void {
setState({ view, error: null, loading: false, ...extras });
}
// --- Register as state host so shared components can call back ---
registerHost({
getState: () => currentState,
setState,
navigate,
sendMessage,
escapeHtml,
popOutToTab,
isInTab,
openVaultTab,
});
// --- Render ---
function render(): void {

View File

@@ -0,0 +1,62 @@
/// Service-locator for cross-bundle state access.
///
/// Both popup.ts and vault.ts register themselves as the "host".
/// All popup components import from here instead of from popup.ts,
/// so the same component code works in either bundle.
import type { Request, Response } from './messages';
export interface StateHost {
getState(): any;
setState(partial: any): void;
navigate(view: string, extras?: any): void;
sendMessage(request: Request): Promise<Response>;
escapeHtml(s: string): string;
popOutToTab(): void;
isInTab(): boolean;
openVaultTab(hash?: string): void;
}
let host: StateHost | null = null;
export function registerHost(h: StateHost): void { host = h; }
export function getState(): any {
if (!host) throw new Error('No state host registered');
return host.getState();
}
export function setState(partial: any): void {
if (!host) throw new Error('No state host registered');
host.setState(partial);
}
export function navigate(view: string, extras?: any): void {
if (!host) throw new Error('No state host registered');
host.navigate(view, extras);
}
export function sendMessage(request: Request): Promise<Response> {
if (!host) throw new Error('No state host registered');
return host.sendMessage(request);
}
export function escapeHtml(s: string): string {
if (!host) throw new Error('No state host registered');
return host.escapeHtml(s);
}
export function popOutToTab(): void {
if (!host) throw new Error('No state host registered');
host.popOutToTab();
}
export function isInTab(): boolean {
if (!host) return false;
return host.isInTab();
}
export function openVaultTab(hash?: string): void {
if (!host) throw new Error('No state host registered');
host.openVaultTab(hash);
}

View File

@@ -1,13 +1,21 @@
/// Vault tab entry point — full "desktop-like" sidebar + pane layout.
///
/// This is a standalone entry point with its own state and renderers.
/// Task 4 will wire shared popup components; for now all pane renderers
/// are placeholder implementations.
/// 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 type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings,
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
} from '../shared/types';
import { registerHost } from '../shared/state';
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 } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
// ---------------------------------------------------------------------------
// Helpers
@@ -58,7 +66,7 @@ function typeLabel(t: ItemType): string {
// Hash routing
// ---------------------------------------------------------------------------
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings';
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history';
interface HashRoute {
view: VaultView;
@@ -82,6 +90,8 @@ function parseHash(): HashRoute {
case 'trash':
case 'devices':
case 'settings':
case 'settings-vault':
case 'field-history':
return { view };
default:
return { view: 'list' };
@@ -99,26 +109,65 @@ function setHash(view: VaultView, param?: string): void {
interface VaultState {
unlocked: boolean;
view: VaultView;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
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',
entries: [],
selectedId: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
vaultSettings: null,
generatorDefaults: null,
error: null,
loading: false,
newType: null,
capturedTabId: null,
capturedUrl: '',
historyItemId: null,
};
// ---------------------------------------------------------------------------
// Register as shared state host
// ---------------------------------------------------------------------------
registerHost({
getState: () => state,
setState: (partial: any) => {
Object.assign(state, partial);
renderPane();
},
navigate: (view: string, extras?: any) => {
Object.assign(state, { view, error: null, loading: false, ...extras });
setHash(view as VaultView);
renderSidebarList();
renderPane();
},
sendMessage,
escapeHtml,
popOutToTab: () => {},
isInTab: () => true,
openVaultTab: () => {},
});
// ---------------------------------------------------------------------------
// Render entry point
// ---------------------------------------------------------------------------
@@ -240,6 +289,7 @@ function wireSidebar(): void {
if (nav === 'add') {
state.selectedId = null;
state.selectedItem = null;
state.newType = null;
setHash('add');
renderPane();
return;
@@ -247,6 +297,7 @@ function wireSidebar(): void {
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
state.selectedId = null;
state.selectedItem = null;
state.newType = null;
setHash(nav);
renderPane();
return;
@@ -360,409 +411,66 @@ async function selectItem(id: ItemId): Promise<void> {
}
// ---------------------------------------------------------------------------
// Pane rendering
// Pane rendering — delegates to shared popup components
// ---------------------------------------------------------------------------
function teardownPaneComponents(): void {
teardownTrash();
teardownDevices();
teardownFieldHistory();
}
function renderPane(): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
teardownPaneComponents();
const route = parseHash();
// Keep state.view in sync with hash for components that read it
state.view = route.view;
pane.className = 'vault-pane';
switch (route.view) {
case 'detail':
renderDetailPane(pane);
if (state.selectedItem) {
renderItemDetail(pane);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
renderAddPane(pane, route.type);
// Sync newType from hash for the item-form component
state.newType = (route.type as ItemType) ?? null;
renderItemForm(pane, 'add');
break;
case 'edit':
renderEditPane(pane);
renderItemForm(pane, 'edit');
break;
case 'trash':
renderTrashPane(pane);
renderTrash(pane);
break;
case 'devices':
renderDevicesPane(pane);
renderDevices(pane);
break;
case 'settings':
renderSettingsPane(pane);
renderSettings(pane);
break;
case 'settings-vault':
renderVaultSettingsView(pane);
break;
case 'field-history':
renderFieldHistory(pane);
break;
default:
renderEmptyPane(pane);
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
break;
}
}
function renderEmptyPane(pane: HTMLElement): void {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
// ---------------------------------------------------------------------------
// Detail pane (placeholder — Task 4 wires real popup components)
// ---------------------------------------------------------------------------
function renderDetailPane(pane: HTMLElement): void {
pane.className = 'vault-pane';
const item = state.selectedItem;
if (!item) {
renderEmptyPane(pane);
return;
}
let fieldsHtml = '';
// Core fields based on type
switch (item.core.type) {
case 'login': {
const c = item.core;
if (c.username) fieldsHtml += fieldRow('username', c.username);
if (c.password) fieldsHtml += fieldRow('password', '••••••••', true);
if (c.url) fieldsHtml += fieldRow('url', c.url);
break;
}
case 'secure_note': {
fieldsHtml += fieldRow('body', item.core.body);
break;
}
case 'identity': {
const c = item.core;
if (c.full_name) fieldsHtml += fieldRow('name', c.full_name);
if (c.email) fieldsHtml += fieldRow('email', c.email);
if (c.phone) fieldsHtml += fieldRow('phone', c.phone);
if (c.address) fieldsHtml += fieldRow('address', c.address);
break;
}
case 'card': {
const c = item.core;
if (c.number) fieldsHtml += fieldRow('number', '•••• ' + c.number.slice(-4));
if (c.holder) fieldsHtml += fieldRow('holder', c.holder);
if (c.expiry) fieldsHtml += fieldRow('expiry', `${c.expiry.month}/${c.expiry.year}`);
break;
}
case 'key': {
const c = item.core;
if (c.label) fieldsHtml += fieldRow('label', c.label);
if (c.algorithm) fieldsHtml += fieldRow('algorithm', c.algorithm);
fieldsHtml += fieldRow('key', '••••••••', true);
break;
}
case 'document': {
const c = item.core;
fieldsHtml += fieldRow('filename', c.filename);
fieldsHtml += fieldRow('mime', c.mime_type);
break;
}
case 'totp': {
const c = item.core;
if (c.issuer) fieldsHtml += fieldRow('issuer', c.issuer);
if (c.label) fieldsHtml += fieldRow('label', c.label);
fieldsHtml += fieldRow('digits', String(c.config.digits));
break;
}
}
// Custom sections
if (item.sections.length > 0) {
for (const section of item.sections) {
const sectionName = section.name || '(unnamed section)';
fieldsHtml += `<div class="section-header">${escapeHtml(sectionName)}</div>`;
for (const field of section.fields) {
const val = field.value.kind === 'month_year'
? `${(field.value.value as { month: number; year: number }).month}/${(field.value.value as { month: number; year: number }).year}`
: String(field.value.value);
const hidden = field.hidden_by_default || field.kind === 'password' || field.kind === 'concealed';
fieldsHtml += fieldRow(field.label, hidden ? '••••••••' : val, hidden);
}
}
}
// Notes
if (item.notes) {
fieldsHtml += `<div class="section-header">notes</div>`;
fieldsHtml += `<div class="field-row"><div class="field-row__label"></div><div class="field-row__value"><pre>${escapeHtml(item.notes)}</pre></div></div>`;
}
const modified = new Date(item.modified * 1000).toLocaleDateString();
pane.innerHTML = `
<div class="detail-header" style="padding:0 0 12px; border-bottom:1px solid #21262d; margin-bottom:16px;">
<div>
<span style="font-size:18px; margin-right:8px;">${typeIcon(item.type)}</span>
<span class="detail-title">${escapeHtml(item.title)}</span>
</div>
<div style="display:flex; gap:8px;">
<button class="btn" id="pane-edit-btn">edit</button>
<button class="btn btn-danger" id="pane-delete-btn">delete</button>
</div>
</div>
<div class="sig-block sig-block--gold">
${fieldsHtml}
</div>
<div class="muted" style="margin-top:16px;">modified ${escapeHtml(modified)}</div>
${item.tags.length > 0 ? `<div class="muted" style="margin-top:4px;">tags: ${item.tags.map(t => escapeHtml(t)).join(', ')}</div>` : ''}
`;
document.getElementById('pane-edit-btn')?.addEventListener('click', () => {
setHash('edit', state.selectedId!);
renderPane();
});
document.getElementById('pane-delete-btn')?.addEventListener('click', async () => {
if (!state.selectedId) return;
const resp = await sendMessage({ type: 'delete_item', id: state.selectedId });
if (resp.ok) {
state.selectedId = null;
state.selectedItem = null;
await loadManifest();
setHash('list');
render();
}
});
}
function fieldRow(label: string, value: string, concealed = false): string {
return `
<div class="field-row">
<div class="field-row__label">${escapeHtml(label)}</div>
<div class="field-row__value${concealed ? '' : ''}">${escapeHtml(value)}</div>
<div class="field-row__actions">
<button onclick="navigator.clipboard.writeText(this.closest('.field-row').querySelector('.field-row__value').textContent.trim())" title="copy">copy</button>
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Add pane (placeholder)
// ---------------------------------------------------------------------------
function renderAddPane(pane: HTMLElement, itemType?: string): void {
pane.className = 'vault-pane';
if (!itemType) {
// Show type picker
const types: Array<{ type: ItemType; icon: string; label: string }> = [
{ type: 'login', icon: '\u{1F511}', label: 'Login' },
{ type: 'secure_note', icon: '\u{1F4DD}', label: 'Secure Note' },
{ type: 'identity', icon: '\u{1FAAA}', label: 'Identity' },
{ type: 'card', icon: '\u{1F4B3}', label: 'Card' },
{ type: 'key', icon: '\u{1F5DD}', label: 'Key' },
{ type: 'document', icon: '\u{1F4C4}', label: 'Document' },
{ type: 'totp', icon: '⏱', label: 'TOTP' },
];
pane.innerHTML = `
<h3 style="margin-bottom:16px; font-size:15px;">new item</h3>
<div class="type-select-list">
${types.map(t => `
<button class="type-select-row" data-type="${t.type}">
<span class="type-select-icon">${t.icon}</span>
${escapeHtml(t.label)}
</button>
`).join('')}
</div>
`;
pane.querySelectorAll('.type-select-row').forEach((btn) => {
btn.addEventListener('click', () => {
const t = (btn as HTMLElement).dataset.type!;
setHash('add', t);
renderPane();
});
});
return;
}
// Placeholder form — Task 4 will wire real popup components
pane.innerHTML = `
<h3 style="margin-bottom:16px; font-size:15px;">
${typeIcon(itemType as ItemType)} new ${escapeHtml(itemType)}
</h3>
<p class="muted" style="margin-bottom:16px;">
Full form will be wired in Task 4 (shared state host).
</p>
<div class="form-actions">
<button class="btn" id="pane-back-btn">back</button>
</div>
`;
document.getElementById('pane-back-btn')?.addEventListener('click', () => {
setHash('list');
renderPane();
});
}
// ---------------------------------------------------------------------------
// Edit pane (placeholder)
// ---------------------------------------------------------------------------
function renderEditPane(pane: HTMLElement): void {
pane.className = 'vault-pane';
const item = state.selectedItem;
if (!item) {
renderEmptyPane(pane);
return;
}
pane.innerHTML = `
<h3 style="margin-bottom:16px; font-size:15px;">
${typeIcon(item.type)} edit: ${escapeHtml(item.title)}
</h3>
<p class="muted" style="margin-bottom:16px;">
Full edit form will be wired in Task 4 (shared state host).
</p>
<div class="form-actions">
<button class="btn" id="pane-cancel-btn">cancel</button>
</div>
`;
document.getElementById('pane-cancel-btn')?.addEventListener('click', () => {
setHash('detail', state.selectedId!);
renderPane();
});
}
// ---------------------------------------------------------------------------
// Trash pane (placeholder)
// ---------------------------------------------------------------------------
function renderTrashPane(pane: HTMLElement): void {
pane.className = 'vault-pane';
const trashedEntries = state.entries.filter(
([, e]) => e.trashed_at !== undefined && e.trashed_at !== null,
);
pane.innerHTML = `
<div class="trash-header">
<button class="btn" id="pane-trash-back">←</button>
<h3 style="font-size:15px;">\u{1F5D1} trash</h3>
</div>
${trashedEntries.length === 0
? '<div class="empty">trash is empty</div>'
: trashedEntries.map(([id, e]) => `
<div class="trash-row">
<span class="trash-row__icon">${typeIcon(e.type)}</span>
<div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(e.title)}</span>
<span class="trash-row__meta">${e.type}</span>
</div>
<button class="trash-row__restore" data-id="${escapeHtml(id)}">restore</button>
</div>
`).join('')
}
`;
document.getElementById('pane-trash-back')?.addEventListener('click', () => {
setHash('list');
renderPane();
});
pane.querySelectorAll('.trash-row__restore').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = (btn as HTMLElement).dataset.id!;
const resp = await sendMessage({ type: 'restore_item', id });
if (resp.ok) {
await loadManifest();
renderSidebarList();
renderTrashPane(pane);
}
});
});
}
// ---------------------------------------------------------------------------
// Devices pane (placeholder)
// ---------------------------------------------------------------------------
function renderDevicesPane(pane: HTMLElement): void {
pane.className = 'vault-pane';
pane.innerHTML = `
<div class="devices-header">
<button class="btn" id="pane-devices-back">←</button>
<h3 style="font-size:15px;">\u{1F4F1} devices</h3>
</div>
<p class="muted">loading devices...</p>
`;
document.getElementById('pane-devices-back')?.addEventListener('click', () => {
setHash('list');
renderPane();
});
// Fetch and render devices
sendMessage({ type: 'list_devices' }).then((resp) => {
if (!resp.ok) return;
const data = resp.data as { devices: Array<{ name: string; public_key: string; added_at: number }> };
const devicesContainer = pane.querySelector('.muted');
if (!devicesContainer) return;
if (data.devices.length === 0) {
devicesContainer.outerHTML = '<div class="empty">no devices registered</div>';
return;
}
devicesContainer.outerHTML = data.devices.map((d) => `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}</span>
<span class="device-row__meta">added ${new Date(d.added_at * 1000).toLocaleDateString()}</span>
</div>
</div>
`).join('');
});
}
// ---------------------------------------------------------------------------
// Settings pane (placeholder)
// ---------------------------------------------------------------------------
function renderSettingsPane(pane: HTMLElement): void {
pane.className = 'vault-pane';
pane.innerHTML = `
<div class="settings-header">
<button class="btn" id="pane-settings-back">←</button>
<h3 style="font-size:15px;">⚙ vault settings</h3>
</div>
<p class="muted" style="margin-bottom:16px;">
Full settings view will be wired in Task 4 (shared state host).
</p>
`;
if (state.vaultSettings) {
const vs = state.vaultSettings;
const trashRetention = vs.trash_retention.kind === 'forever'
? 'forever'
: `${(vs.trash_retention as { kind: 'days'; value: number }).value} days`;
const historyRetention = vs.field_history_retention.kind === 'forever'
? 'forever'
: vs.field_history_retention.kind === 'last_n'
? `last ${(vs.field_history_retention as { kind: 'last_n'; value: number }).value}`
: `${(vs.field_history_retention as { kind: 'days'; value: number }).value} days`;
pane.innerHTML += `
<div class="settings-section">
<div class="settings-section__title">retention</div>
<div class="settings-row">
<span class="settings-row__label">trash</span>
<span>${escapeHtml(trashRetention)}</span>
</div>
<div class="settings-row">
<span class="settings-row__label">history</span>
<span>${escapeHtml(historyRetention)}</span>
</div>
</div>
`;
}
document.getElementById('pane-settings-back')?.addEventListener('click', () => {
setHash('list');
renderPane();
});
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
@@ -778,6 +486,7 @@ async function loadManifest(): Promise<void> {
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