Merge phase-c-4-vault-split: Plan C Phase 4 (vault.ts split + vault_locked lift)
Splits the 1037-LOC vault.ts monolith into focused modules: vault.ts trims to 194 LOC of routing+state, with vault-shell, vault-sidebar, vault-list, vault-drawer, vault-form-wrapper extracted, plus two support modules (vault-context — the VaultController contract + shared helpers; vault-router — hash routing + pane dispatch, extracted to hit the <=250 LOC target). Lifts the vault_locked RPC intercept out of vault.ts into shared/state.ts's sendMessage wrapper. Adds 80ms debounced sidebar search, ensureDrawerClosedForRoute, and the #vault-status-slot footer that Dev-C wires in Phase 6 Task 6.3. Tasks 4.1-4.7. vault_locked count in vault.ts == 0. 407 vitest green, build:all clean. Unblocks Dev-C Task 6.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerHost, __resetHostForTests, sendMessage } from '../state';
|
||||
import type { StateHost } from '../state';
|
||||
import type { Response } from '../messages';
|
||||
|
||||
function makeHost(response: { ok: boolean; error?: string }): StateHost {
|
||||
return {
|
||||
getState: () => ({ view: 'list' } as never),
|
||||
setState: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue(response as Response),
|
||||
escapeHtml: (s) => s,
|
||||
popOutToTab: vi.fn(),
|
||||
isInTab: () => false,
|
||||
openVaultTab: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shared/state sendMessage vault_locked intercept', () => {
|
||||
beforeEach(() => __resetHostForTests());
|
||||
|
||||
it('navigates to the lock screen on a vault_locked response', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).toHaveBeenCalledWith(
|
||||
'locked',
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT intercept the unlock request itself', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'unlock', passphrase: 'x' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT intercept is_unlocked', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'is_unlocked' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a successful response', async () => {
|
||||
const host = makeHost({ ok: true });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a non-vault_locked error', async () => {
|
||||
const host = makeHost({ ok: false, error: 'something_else' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the response unchanged', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
const resp = await sendMessage({ type: 'list_items' });
|
||||
expect(resp).toEqual({ ok: false, error: 'vault_locked' });
|
||||
});
|
||||
});
|
||||
@@ -50,13 +50,33 @@ export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||
host.navigate(view, extras);
|
||||
}
|
||||
|
||||
// Requests that must NOT trigger the lock screen on a vault_locked response:
|
||||
// they run during cold start / unlock, before a session exists, so a
|
||||
// vault_locked here is expected rather than a lost session.
|
||||
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
|
||||
'unlock', 'is_unlocked',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Phase 4 will add a vault_locked intercept here. For now, this is a pure
|
||||
* pass-through so the signature is stable for Phase 4 to fill.
|
||||
* Dispatches a request to the service worker and intercepts the `vault_locked`
|
||||
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
|
||||
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
|
||||
* or vault tab) that gets that on a non-bypassed request treats it as "session
|
||||
* lost" and navigates to the lock screen so the user can re-enter their
|
||||
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
|
||||
* so both surfaces share one channel.
|
||||
*/
|
||||
export async function sendMessage(request: Request): Promise<Response> {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
return host.sendMessage(request);
|
||||
const response = await host.sendMessage(request);
|
||||
if (
|
||||
!response.ok &&
|
||||
response.error === 'vault_locked' &&
|
||||
!VAULT_LOCKED_BYPASS.has(request.type)
|
||||
) {
|
||||
host.navigate('locked', { error: 'Session expired — please unlock again.' });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
|
||||
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ensureDrawerClosedForRoute } from '../vault-drawer';
|
||||
|
||||
describe('ensureDrawerClosedForRoute', () => {
|
||||
it('closes the drawer when navigating to trash', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'trash' });
|
||||
expect(state.drawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the drawer open when navigating to detail', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'detail' });
|
||||
expect(state.drawerOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the drawer open in list view', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'list' });
|
||||
expect(state.drawerOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the drawer is already closed', () => {
|
||||
const state = { drawerOpen: false };
|
||||
ensureDrawerClosedForRoute(state, { view: 'devices' });
|
||||
expect(state.drawerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
|
||||
describe('fullscreen form dirty subtitle', () => {
|
||||
const vaultSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, '../vault.ts'),
|
||||
path.resolve(__dirname, '../vault-form-wrapper.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
|
||||
describe('vault sidebar glyphs', () => {
|
||||
const vaultSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, '../vault.ts'),
|
||||
path.resolve(__dirname, '../vault-sidebar.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
|
||||
122
extension/src/vault/vault-context.ts
Normal file
122
extension/src/vault/vault-context.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Shared contract for the vault-tab modules. vault.ts owns the state
|
||||
// singleton and assembles the VaultController; each vault-* module receives
|
||||
// it as `ctx`. This module sits at the bottom of the dependency graph —
|
||||
// it imports only from shared/, never from vault.ts or its sibling modules.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type {
|
||||
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
||||
} from '../shared/types';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||
} from '../shared/glyphs';
|
||||
|
||||
export type VaultView =
|
||||
| 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings'
|
||||
| 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import';
|
||||
|
||||
export interface HashRoute {
|
||||
view: VaultView;
|
||||
id?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface VaultState {
|
||||
unlocked: boolean;
|
||||
view: VaultView;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
drawerOpen: boolean;
|
||||
typePanelOpen: boolean;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
newType: ItemType | null;
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
historyItemId: ItemId | null;
|
||||
}
|
||||
|
||||
// The controller passed to every vault-* module. vault.ts builds one instance
|
||||
// and wires each hook to the function that currently lives in vault.ts (later
|
||||
// Phase-4 tasks repoint individual hooks at the extracted module functions).
|
||||
export interface VaultController {
|
||||
readonly state: VaultState;
|
||||
sendMessage(request: Request): Promise<Response>;
|
||||
render(): void;
|
||||
renderPane(): void;
|
||||
renderListPane(): void;
|
||||
renderSidebarCategories(): void;
|
||||
renderDrawer(item: Item): void;
|
||||
applyShellViewClass(): void;
|
||||
setHash(view: VaultView, param?: string): void;
|
||||
openDrawer(): void;
|
||||
closeDrawer(): void;
|
||||
selectItemForDrawer(id: string): Promise<void>;
|
||||
openTypePanel(): void;
|
||||
closeTypePanel(): void;
|
||||
wireSidebar(): void;
|
||||
loadManifest(): Promise<void>;
|
||||
}
|
||||
|
||||
// --- pure helpers (no state, no DOM dependencies beyond the args) ---
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
export function typeLabel(t: ItemType): string {
|
||||
const labels: Record<ItemType, string> = {
|
||||
login: 'Login',
|
||||
secure_note: 'Secure Note',
|
||||
identity: 'Identity',
|
||||
card: 'Card',
|
||||
key: 'SSH / API Key',
|
||||
document: 'Document',
|
||||
totp: 'TOTP',
|
||||
};
|
||||
return labels[t];
|
||||
}
|
||||
|
||||
export function getFilteredEntries(
|
||||
state: VaultState,
|
||||
): Array<[ItemId, ManifestEntry]> {
|
||||
let filtered = state.entries.filter(
|
||||
([, e]) => e.trashed_at === undefined || e.trashed_at === null,
|
||||
);
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => {
|
||||
if (e.title.toLowerCase().includes(q)) return true;
|
||||
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||
if (e.group?.toLowerCase().includes(q)) return true;
|
||||
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||
return filtered;
|
||||
}
|
||||
138
extension/src/vault/vault-drawer.ts
Normal file
138
extension/src/vault/vault-drawer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Vault-tab drawer: the right-hand overlay that previews a selected item
|
||||
// (open/close/render + item selection). Receives the VaultController (`ctx`)
|
||||
// and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. Imports only from shared/ and vault-context.
|
||||
|
||||
import type { Item } from '../shared/types';
|
||||
import {
|
||||
type VaultController, type VaultState, type HashRoute, escapeHtml,
|
||||
} from './vault-context';
|
||||
|
||||
export function openDrawer(): void {
|
||||
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
||||
}
|
||||
|
||||
export function closeDrawer(ctx: VaultController): void {
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
||||
}
|
||||
|
||||
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
||||
const core = item.core as unknown as Record<string, unknown>;
|
||||
if (!core) return [];
|
||||
const fields: Array<[string, string, boolean]> = [];
|
||||
|
||||
switch (item.type) {
|
||||
case 'login':
|
||||
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
||||
if ('password' in core) fields.push(['password', '••••••••', false]);
|
||||
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
||||
break;
|
||||
case 'card': {
|
||||
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
||||
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
||||
if ('expiry' in core && core.expiry) {
|
||||
const exp = core.expiry as { month: number; year: number };
|
||||
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
||||
}
|
||||
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
||||
if ('pin' in core) fields.push(['pin', '••••', false]);
|
||||
break;
|
||||
}
|
||||
case 'identity':
|
||||
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
||||
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
||||
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
||||
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
||||
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
||||
break;
|
||||
case 'key':
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
||||
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
||||
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
||||
break;
|
||||
case 'secure_note':
|
||||
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
||||
break;
|
||||
case 'totp':
|
||||
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
||||
break;
|
||||
case 'document':
|
||||
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
||||
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.notes) fields.push(['notes', item.notes, true]);
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function renderDrawer(ctx: VaultController, item: Item): void {
|
||||
const drawer = document.getElementById('vault-drawer');
|
||||
if (!drawer) return;
|
||||
|
||||
const coreFields = getDrawerCoreFields(item);
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="vault-drawer__header">
|
||||
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||
<div class="vault-drawer__actions">
|
||||
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-drawer__body">
|
||||
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||
${item.type === 'login' && (item.core as { url?: string }).url
|
||||
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||
: ''}
|
||||
<div class="vault-drawer__field-grid">
|
||||
${coreFields.map(([label, value, full]) => `
|
||||
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
||||
closeDrawer(ctx);
|
||||
ctx.renderListPane();
|
||||
});
|
||||
|
||||
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
||||
if (ctx.state.selectedId) {
|
||||
ctx.setHash('edit', ctx.state.selectedId);
|
||||
ctx.renderPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectItemForDrawer(ctx: VaultController, id: string): Promise<void> {
|
||||
const resp = await ctx.sendMessage({ type: 'get_item', id });
|
||||
if (!resp.ok) return;
|
||||
const data = resp.data as { item: Item };
|
||||
ctx.state.selectedId = id;
|
||||
ctx.state.selectedItem = data.item;
|
||||
ctx.state.drawerOpen = true;
|
||||
ctx.renderSidebarCategories();
|
||||
ctx.renderListPane();
|
||||
renderDrawer(ctx, data.item);
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
// Drawer is an overlay only meaningful on the list/detail surfaces; any
|
||||
// other route must clear it so it doesn't leak across navigation (P2 fix).
|
||||
const DRAWER_KEEPING_VIEWS: ReadonlySet<string> = new Set(['list', 'detail']);
|
||||
|
||||
export function ensureDrawerClosedForRoute(
|
||||
state: Pick<VaultState, 'drawerOpen'>,
|
||||
route: Pick<HashRoute, 'view'>,
|
||||
): void {
|
||||
if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false;
|
||||
}
|
||||
72
extension/src/vault/vault-form-wrapper.ts
Normal file
72
extension/src/vault/vault-form-wrapper.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Fullscreen form wrapper for the vault tab: sticky save bar + scrollable
|
||||
// content + header with a live dirty-state subtitle. Receives the
|
||||
// VaultController (`ctx`) for the item-type read; imports only from shared/,
|
||||
// the popup item-form component, and vault-context.
|
||||
|
||||
import { renderItemForm } from '../popup/components/item-form';
|
||||
import { type VaultController } from './vault-context';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-aware save hint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen form wrapper — sticky save bar + scrollable content + header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderFormWrapped(ctx: VaultController, app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const itemType = ctx.state.selectedItem?.type ?? ctx.state.newType ?? 'login';
|
||||
const typeLabelText = itemType.replace('_', ' ');
|
||||
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-pane';
|
||||
wrapper.innerHTML = `
|
||||
<div class="fullscreen-form-header">
|
||||
<div>
|
||||
<div class="title">${titleText}</div>
|
||||
<div class="sub" id="form-dirty-sub">no changes</div>
|
||||
</div>
|
||||
<div class="hint">${SAVE_HINT}</div>
|
||||
</div>
|
||||
<div class="form-scroll" id="form-scroll"></div>
|
||||
<div class="sticky-save-bar">
|
||||
<button class="btn-secondary" id="form-cancel">cancel</button>
|
||||
<button class="btn-primary" id="form-save">save</button>
|
||||
</div>
|
||||
`;
|
||||
// Remove pane padding so form-pane can fill height cleanly
|
||||
app.style.padding = '0';
|
||||
app.style.overflow = 'hidden';
|
||||
app.replaceChildren(wrapper);
|
||||
|
||||
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
|
||||
renderItemForm(scrollEl, mode);
|
||||
|
||||
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
|
||||
let isDirty = false;
|
||||
const markDirty = () => {
|
||||
if (isDirty) return;
|
||||
isDirty = true;
|
||||
subEl.textContent = 'unsaved · esc to cancel';
|
||||
};
|
||||
const markClean = () => {
|
||||
isDirty = false;
|
||||
subEl.textContent = 'no changes';
|
||||
};
|
||||
scrollEl.addEventListener('input', markDirty, true);
|
||||
scrollEl.addEventListener('change', markDirty, true);
|
||||
|
||||
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
|
||||
markClean();
|
||||
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
|
||||
markClean();
|
||||
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
}
|
||||
|
||||
export const __test__ = { renderFormWrapped };
|
||||
52
extension/src/vault/vault-list.ts
Normal file
52
extension/src/vault/vault-list.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Vault-tab list column: renders the middle list pane (row markup, empty
|
||||
// state, and the row-click → drawer selection). Receives the VaultController
|
||||
// (`ctx`) and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. Imports only from shared/ and vault-context.
|
||||
|
||||
import type { ItemId, ManifestEntry, ItemType } from '../shared/types';
|
||||
import { relativeTime } from '../shared/relative-time';
|
||||
import {
|
||||
type VaultController, escapeHtml, typeIcon, getFilteredEntries,
|
||||
} from './vault-context';
|
||||
|
||||
export function renderListPane(ctx: VaultController): void {
|
||||
const pane = document.getElementById('vault-list-pane');
|
||||
if (!pane) return;
|
||||
|
||||
const group = ctx.state.activeGroup as ItemType | null;
|
||||
let items = getFilteredEntries(ctx.state);
|
||||
if (group) items = items.filter(([, e]) => e.type === group);
|
||||
|
||||
if (items.length === 0) {
|
||||
pane.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">${ctx.state.searchQuery ? '⊘' : '◈'}</span>
|
||||
<div class="empty-state__title">${ctx.state.searchQuery ? `No results for "${escapeHtml(ctx.state.searchQuery)}"` : 'No items yet'}</div>
|
||||
<div class="empty-state__hint">${ctx.state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
pane.innerHTML = items.map(([id, e]: [ItemId, ManifestEntry]) => {
|
||||
const sel = id === ctx.state.selectedId ? ' vault-list-row--selected' : '';
|
||||
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
|
||||
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
|
||||
return `
|
||||
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
||||
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
||||
<div class="vault-list-row__text">
|
||||
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
||||
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
||||
</div>
|
||||
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
await ctx.selectItemForDrawer(row.dataset.id!);
|
||||
});
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
236
extension/src/vault/vault-shell.ts
Normal file
236
extension/src/vault/vault-shell.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Vault-tab shell: render entry point, lock screen, 3-column shell
|
||||
// scaffolding, the right-side type-picker panel, color-scheme apply, and the
|
||||
// session_expired listener. Each function receives the VaultController (`ctx`)
|
||||
// and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. vault.ts owns the state singleton and assembles the ctx.
|
||||
|
||||
import type { ItemType } from '../shared/types';
|
||||
import { lookupErrorCopy } from '../shared/error-copy';
|
||||
import { applyColorScheme } from '../shared/color-scheme';
|
||||
import {
|
||||
type VaultController, escapeHtml, typeIcon,
|
||||
} from './vault-context';
|
||||
import { renderSidebarShell } from './vault-sidebar';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type picker (right side panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
|
||||
{ type: 'login', label: 'Login' },
|
||||
{ type: 'secure_note', label: 'Secure Note' },
|
||||
{ type: 'totp', label: 'TOTP' },
|
||||
{ type: 'card', label: 'Card' },
|
||||
{ type: 'identity', label: 'Identity' },
|
||||
{ type: 'key', label: 'SSH / API Key' },
|
||||
{ type: 'document', label: 'Document' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function render(ctx: VaultController): void {
|
||||
const app = document.getElementById('vault-app');
|
||||
if (!app) return;
|
||||
|
||||
if (!ctx.state.unlocked) {
|
||||
renderLockScreen(ctx, app);
|
||||
} else {
|
||||
renderShell(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lock screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderErrorBlock(code: string | null | undefined): string {
|
||||
if (!code) return '';
|
||||
const copy = lookupErrorCopy(code);
|
||||
const ctaHtml = copy.cta
|
||||
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="error error-block">
|
||||
<div class="error-title">${escapeHtml(copy.title)}</div>
|
||||
<div class="error-body">${escapeHtml(copy.body)}</div>
|
||||
${ctaHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLockScreen(ctx: VaultController, app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="vault-lock-screen">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
<div class="vault-lock-screen__form">
|
||||
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||
${renderErrorBlock(ctx.state.error)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
|
||||
const btn = document.getElementById('vault-unlock-btn')!;
|
||||
|
||||
const doUnlock = async () => {
|
||||
const passphrase = input.value;
|
||||
if (!passphrase) return;
|
||||
btn.textContent = 'unlocking...';
|
||||
btn.setAttribute('disabled', 'true');
|
||||
const resp = await ctx.sendMessage({ type: 'unlock', passphrase });
|
||||
if (resp.ok) {
|
||||
ctx.state.unlocked = true;
|
||||
ctx.state.error = null;
|
||||
await ctx.loadManifest();
|
||||
render(ctx);
|
||||
} else {
|
||||
ctx.state.error = resp.error ?? 'unlock failed';
|
||||
render(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('click', doUnlock);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doUnlock();
|
||||
});
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (3-column: sidebar + list pane + drawer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderShell(ctx: VaultController, app: HTMLElement): void {
|
||||
if (!app.querySelector('.vault-shell')) {
|
||||
app.innerHTML = `
|
||||
<div class="vault-shell">
|
||||
${renderSidebarShell()}
|
||||
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||
<div class="vault-pane" id="vault-pane"></div>
|
||||
<div class="vault-drawer" id="vault-drawer"></div>
|
||||
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
|
||||
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
|
||||
</div>
|
||||
`;
|
||||
ctx.wireSidebar();
|
||||
wireTypePanel(ctx);
|
||||
}
|
||||
|
||||
applyShellViewClass(ctx);
|
||||
ctx.renderSidebarCategories();
|
||||
if (ctx.state.view === 'list') {
|
||||
ctx.renderListPane();
|
||||
if (ctx.state.drawerOpen && ctx.state.selectedItem) {
|
||||
ctx.renderDrawer(ctx.state.selectedItem);
|
||||
}
|
||||
} else {
|
||||
ctx.renderPane();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle which middle column is visible based on the current view.
|
||||
// list view → list-pane (+ optional drawer); other views → vault-pane.
|
||||
export function applyShellViewClass(ctx: VaultController): void {
|
||||
const shell = document.querySelector('.vault-shell');
|
||||
if (!shell) return;
|
||||
shell.classList.toggle('vault-shell--list', ctx.state.view === 'list');
|
||||
shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right-side type picker panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function wireTypePanel(ctx: VaultController): void {
|
||||
document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
export function openTypePanel(ctx: VaultController): void {
|
||||
const panel = document.getElementById('vault-type-panel');
|
||||
const scrim = document.getElementById('vault-type-scrim');
|
||||
if (!panel || !scrim) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="vault-type-panel__head">
|
||||
<div class="vault-type-panel__title">New item</div>
|
||||
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="vault-type-panel__hint">Choose a type</div>
|
||||
<div class="vault-type-list" role="menu">
|
||||
${PICKER_TYPES.map((t) => `
|
||||
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
|
||||
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.classList.add('vault-type-panel--open');
|
||||
scrim.classList.add('vault-type-panel-scrim--visible');
|
||||
ctx.state.typePanelOpen = true;
|
||||
|
||||
panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||
|
||||
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
closeTypePanel(ctx);
|
||||
// Use the host's navigate hook so view + hash + visibility all update
|
||||
// together. This was the bug: bare setHash + renderPane left the
|
||||
// shell stuck in list view with #vault-pane hidden.
|
||||
ctx.state.newType = type;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.view = 'add';
|
||||
ctx.setHash('add', type);
|
||||
applyShellViewClass(ctx);
|
||||
ctx.renderSidebarCategories();
|
||||
ctx.renderPane();
|
||||
});
|
||||
});
|
||||
|
||||
// Focus first item for keyboard users
|
||||
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
export function closeTypePanel(ctx: VaultController): void {
|
||||
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
|
||||
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
|
||||
ctx.state.typePanelOpen = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color scheme + session-expired wiring (bootstrap helpers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function applyVaultColorScheme(): Promise<void> {
|
||||
await applyColorScheme();
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||
void applyColorScheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function wireSessionExpiredListener(ctx: VaultController): void {
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'session_expired') {
|
||||
ctx.state.unlocked = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.entries = [];
|
||||
ctx.state.error = null;
|
||||
render(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
174
extension/src/vault/vault-sidebar.ts
Normal file
174
extension/src/vault/vault-sidebar.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Vault-tab sidebar column: its static markup, the category nav rendering,
|
||||
// nav-button wiring, and the (now debounced) search input. Each function
|
||||
// receives the VaultController (`ctx`) and reaches sibling concerns through it;
|
||||
// pure helpers come from vault-context. Imports only from shared/ and
|
||||
// vault-context — never from vault-shell or vault.ts.
|
||||
|
||||
import type { ItemType } from '../shared/types';
|
||||
import {
|
||||
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK,
|
||||
} from '../shared/glyphs';
|
||||
import {
|
||||
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
||||
} from './vault-context';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 80;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar markup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderSidebarShell(): string {
|
||||
return `
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||
</div>
|
||||
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
||||
<div class="vault-sidebar__nav">
|
||||
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||
</div>
|
||||
<div class="vault-sidebar__footer">
|
||||
<!-- Phase 6 (Dev-C Task 6.3) wires the sync-status indicator into this slot. -->
|
||||
<div id="vault-status-slot"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function wireSidebar(ctx: VaultController): void {
|
||||
// Search (debounced — trailing edge)
|
||||
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||
let searchTimer: number | undefined;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
if (searchTimer !== undefined) clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
ctx.state.searchQuery = searchInput.value;
|
||||
renderSidebarCategories(ctx);
|
||||
ctx.renderListPane();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
// Nav buttons
|
||||
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const nav = (btn as HTMLElement).dataset.nav;
|
||||
if (nav === 'lock') {
|
||||
await ctx.sendMessage({ type: 'lock' });
|
||||
ctx.state.unlocked = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.entries = [];
|
||||
ctx.render();
|
||||
return;
|
||||
}
|
||||
if (nav === 'add') {
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.newType = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.closeDrawer();
|
||||
ctx.openTypePanel();
|
||||
return;
|
||||
}
|
||||
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.newType = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.view = nav;
|
||||
ctx.setHash(nav);
|
||||
ctx.applyShellViewClass();
|
||||
ctx.renderPane();
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Global "/" shortcut to focus search; Esc to close drawer
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
searchInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && ctx.state.drawerOpen) {
|
||||
ctx.closeDrawer();
|
||||
ctx.renderListPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar category nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderSidebarCategories(ctx: VaultController): void {
|
||||
const container = document.getElementById('vault-categories');
|
||||
if (!container) return;
|
||||
|
||||
const filtered = getFilteredEntries(ctx.state);
|
||||
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||
|
||||
const allCount = filtered.length;
|
||||
const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list';
|
||||
|
||||
let html = `
|
||||
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
||||
<span class="vault-category-row__icon">◈</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
for (const t of typeOrder) {
|
||||
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||
// Always show Login (staple type); hide other types when empty.
|
||||
if (count === 0 && t !== 'login') continue;
|
||||
const isActive = ctx.state.activeGroup === t;
|
||||
html += `
|
||||
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
ctx.state.activeGroup = btn.dataset.group || null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.view = 'list';
|
||||
ctx.setHash('list');
|
||||
ctx.applyShellViewClass();
|
||||
renderSidebarCategories(ctx);
|
||||
ctx.renderListPane();
|
||||
ctx.closeDrawer();
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user