Renders the status indicator into #vault-status-slot on sidebar mount and on a manual ↻ button. No timer polling — get_vault_status returns cached state and sync is user-initiated. Closes the relicario status CLI/extension parity gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
// 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, plus the leaf renderer vault-status — never from vault-shell
|
|
// or vault.ts.
|
|
|
|
import type { ItemType } from '../shared/types';
|
|
import {
|
|
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH,
|
|
} from '../shared/glyphs';
|
|
import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
|
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">
|
|
<div id="vault-status-slot"></div>
|
|
<button class="vault-status-refresh" id="status-refresh-btn" type="button" title="Refresh status" aria-label="Refresh status">${GLYPH_REFRESH}</button>
|
|
</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();
|
|
}
|
|
});
|
|
|
|
// Vault status indicator — refresh on mount + on the manual button only.
|
|
// No timer polling: get_vault_status returns cached state and sync is
|
|
// user-initiated (spec 2026-05-04, Phase 6).
|
|
const refreshStatus = async (): Promise<void> => {
|
|
const resp = await ctx.sendMessage({ type: 'get_vault_status' });
|
|
if (!resp.ok) return;
|
|
const slot = document.getElementById('vault-status-slot');
|
|
if (slot) renderStatusIndicator(slot, resp.data as VaultStatus);
|
|
};
|
|
void refreshStatus();
|
|
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
|
|
void refreshStatus();
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
}
|