diff --git a/extension/src/vault/__tests__/sidebar-glyphs.test.ts b/extension/src/vault/__tests__/sidebar-glyphs.test.ts index 98415db..586a2b1 100644 --- a/extension/src/vault/__tests__/sidebar-glyphs.test.ts +++ b/extension/src/vault/__tests__/sidebar-glyphs.test.ts @@ -4,7 +4,7 @@ import * as path from 'path'; describe('vault sidebar glyphs', () => { const vaultSrc = fs.readFileSync( - path.resolve(__dirname, '../vault-shell.ts'), + path.resolve(__dirname, '../vault-sidebar.ts'), 'utf-8', ); diff --git a/extension/src/vault/vault-shell.ts b/extension/src/vault/vault-shell.ts index fb5f34c..a7e1b7a 100644 --- a/extension/src/vault/vault-shell.ts +++ b/extension/src/vault/vault-shell.ts @@ -6,13 +6,11 @@ import type { ItemType } from '../shared/types'; import { lookupErrorCopy } from '../shared/error-copy'; -import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY, -} from '../shared/glyphs'; import { applyColorScheme } from '../shared/color-scheme'; import { type VaultController, escapeHtml, typeIcon, } from './vault-context'; +import { renderSidebarShell } from './vault-sidebar'; // --------------------------------------------------------------------------- // Type picker (right side panel) @@ -110,24 +108,7 @@ export function renderShell(ctx: VaultController, app: HTMLElement): void { if (!app.querySelector('.vault-shell')) { app.innerHTML = `
-
-
- - Relicario -
- - -
- - - - - - -
-
+ ${renderSidebarShell()}
diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts new file mode 100644 index 0000000..b7db7b7 --- /dev/null +++ b/extension/src/vault/vault-sidebar.ts @@ -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 ` +
+
+ + Relicario +
+ + +
+ + + + + + +
+ +
`; +} + +// --------------------------------------------------------------------------- +// 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 = ` + + `; + + 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 += ` + + `; + } + + container.innerHTML = html; + + container.querySelectorAll('.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(); + }); + }); +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 26c86c0..1521dec 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -23,13 +23,14 @@ import { renderBackupPanel, teardown as teardownBackup } from './components/back import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { type VaultController, type VaultState, type VaultView, type HashRoute, - escapeHtml, typeIcon, typeLabel, getFilteredEntries, + escapeHtml, typeIcon, getFilteredEntries, } from './vault-context'; import { render, applyShellViewClass, openTypePanel, closeTypePanel, applyVaultColorScheme, wireSessionExpiredListener, } from './vault-shell'; +import { wireSidebar, renderSidebarCategories } from './vault-sidebar'; // --------------------------------------------------------------------------- // Helpers @@ -144,7 +145,7 @@ const ctx: VaultController = { render: () => render(ctx), renderPane: () => renderPane(), renderListPane: () => renderListPane(), - renderSidebarCategories: () => renderSidebarCategories(), + renderSidebarCategories: () => renderSidebarCategories(ctx), renderDrawer: (item) => renderDrawer(item), applyShellViewClass: () => applyShellViewClass(ctx), setHash, @@ -153,7 +154,7 @@ const ctx: VaultController = { selectItemForDrawer: (id) => selectItemForDrawer(id), openTypePanel: () => openTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx), - wireSidebar: () => wireSidebar(), + wireSidebar: () => wireSidebar(ctx), loadManifest: () => loadManifest(), }; @@ -171,7 +172,7 @@ registerHost({ Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass(ctx); - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); renderPane(); }, @@ -302,137 +303,12 @@ async function selectItemForDrawer(id: string): Promise { state.selectedId = id; state.selectedItem = data.item; state.drawerOpen = true; - renderSidebarCategories(); + renderSidebarCategories(ctx); renderListPane(); renderDrawer(data.item); openDrawer(); } -// --------------------------------------------------------------------------- -// Sidebar wiring -// --------------------------------------------------------------------------- - -function wireSidebar(): void { - // Search - const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; - searchInput?.addEventListener('input', () => { - state.searchQuery = searchInput.value; - renderSidebarCategories(); - renderListPane(); - }); - - // 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 sendMessage({ type: 'lock' }); - state.unlocked = false; - state.selectedId = null; - state.selectedItem = null; - state.entries = []; - render(ctx); - return; - } - if (nav === 'add') { - state.selectedId = null; - state.selectedItem = null; - state.newType = null; - state.drawerOpen = false; - closeDrawer(); - openTypePanel(ctx); - return; - } - if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { - state.selectedId = null; - state.selectedItem = null; - state.newType = null; - state.drawerOpen = false; - state.view = nav; - setHash(nav); - 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' && state.drawerOpen) { - closeDrawer(); - 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 -// --------------------------------------------------------------------------- - -function renderSidebarCategories(): void { - const container = document.getElementById('vault-categories'); - if (!container) return; - - const filtered = getFilteredEntries(state); - const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; - - const allCount = filtered.length; - const isAllActive = !state.activeGroup && state.view === 'list'; - - let html = ` - - `; - - 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 = state.activeGroup === t; - html += ` - - `; - } - - container.innerHTML = html; - - container.querySelectorAll('.vault-category-row').forEach((btn) => { - btn.addEventListener('click', () => { - state.activeGroup = btn.dataset.group || null; - state.drawerOpen = false; - state.selectedId = null; - state.selectedItem = null; - state.view = 'list'; - setHash('list'); - applyShellViewClass(ctx); - renderSidebarCategories(); - renderListPane(); - closeDrawer(); - }); - }); -} - // --------------------------------------------------------------------------- // List pane // --------------------------------------------------------------------------- @@ -712,7 +588,7 @@ document.addEventListener('DOMContentLoaded', async () => { if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { renderPane(); - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); return; } @@ -724,7 +600,7 @@ document.addEventListener('DOMContentLoaded', async () => { // For non-item views, just re-render the pane state.selectedId = null; state.selectedItem = null; - renderSidebarCategories(); + renderSidebarCategories(ctx); if (state.view === 'list') renderListPane(); renderPane(); }); @@ -743,7 +619,7 @@ async function selectItem(id: ItemId): Promise { state.selectedItem = data.item; state.loading = false; setHash('detail', id); - renderSidebarCategories(); + renderSidebarCategories(ctx); renderListPane(); renderPane(); } else {