/// Typed-item list view — toolbar (search, new, sync, lock, settings) +
/// type-iconed rows. Clicking a row fetches the full Item and navigates
/// to the detail view.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import { showToast } from '../../shared/toast';
import {
GLYPH_VAULT_TAB,
GLYPH_DEVICES, GLYPH_LOCK,
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';
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
/// Extract the display hostname from an icon_hint or fallback to the first tag.
function metaLine(e: ManifestEntry): string {
if (e.icon_hint) return e.icon_hint;
if (e.tags.length > 0) return e.tags.join(', ');
return '';
}
/// Glyph icon per item type.
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;
}
}
function buildRowsHtml(): string {
const state = getState();
const filtered = getFilteredEntries();
if (filtered.length > 0) {
return filtered.map(([id, e], i) => `
${typeIcon(e.type)} ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' ⊕' : ''}
${escapeHtml(metaLine(e))}
`).join('');
}
if (state.searchQuery) {
return `
⊘
No results for "${escapeHtml(state.searchQuery)}"
Try a shorter search term.
`;
}
return `
◈
No items yet
Press + to add your first item.
`;
}
function updateItemList(): void {
const list = document.getElementById('item-list');
if (list) {
list.innerHTML = buildRowsHtml();
wireRowClicks();
}
}
function wireRowClicks(): void {
document.querySelectorAll('.entry-row').forEach(row => {
row.addEventListener('click', async () => {
const id = (row as HTMLElement).dataset.id!;
document.removeEventListener('keydown', handleListKeydown);
await openItem(id);
});
});
}
export function renderItemList(app: HTMLElement): void {
const state = getState();
app.innerHTML = `
${buildRowsHtml()}
/ search
+ new
↑↓ nav
Enter open
`;
// --- Event listeners ---
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
searchInput?.addEventListener('input', () => {
const state2 = getState();
state2.searchQuery = searchInput.value;
state2.selectedIndex = 0;
const list = document.getElementById('item-list');
if (list) list.innerHTML = buildRowsHtml();
wireRowClicks();
});
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
document.getElementById('new-btn')?.addEventListener('click', () => {
setState({ newType: null });
navigate('add');
});
document.getElementById('sync-btn')?.addEventListener('click', async () => {
setState({ loading: true, error: null });
const resp = await sendMessage({ type: 'sync' });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
setState({ entries: data.items, loading: false });
showToast('Synced', 'success');
return;
}
setState({ loading: false, error: listResp.error });
showToast(listResp.error ?? 'Sync failed', 'error');
} else {
setState({ loading: false, error: resp.error });
showToast(resp.error ?? 'Sync failed', 'error');
}
});
document.getElementById('lock-btn')?.addEventListener('click', async () => {
await sendMessage({ type: 'lock' });
navigate('locked');
});
document.getElementById('settings-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
showSettingsPicker(e.currentTarget as HTMLElement);
});
wireRowClicks();
document.addEventListener('keydown', handleListKeydown);
}
async function openItem(id: ItemId): Promise {
setState({ loading: true });
const resp = await sendMessage({ type: 'get_item', id });
if (resp.ok) {
const data = resp.data as { item: Item };
navigate('detail', {
selectedId: id,
selectedItem: data.item,
});
} else {
setState({ loading: false, error: resp.error });
}
}
/// 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 = 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;
}
/// True if the event target is an editable field (input/textarea/contenteditable).
/// Global shortcut handlers should bail when the user is typing into a field —
/// otherwise printable characters like "/" and "+" get eaten by the shortcut
/// routing and never reach the input.
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;
}
function handleListKeydown(e: KeyboardEvent): void {
const state = getState();
const target = e.target as HTMLElement;
const isSearch = target.id === 'search-input';
// If the user is typing into any input/textarea (other than the list's own
// search field, which we want to focus on "/" even from outside it), let the
// keystroke through. The "/" shortcut below is specifically "jump to search
// from the list," not "steal printable characters while typing."
if (isEditableTarget(target) && !isSearch) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleListKeydown);
window.close();
}
return;
}
if (e.key === '/' && !isSearch) {
e.preventDefault();
(document.getElementById('search-input') as HTMLInputElement | null)?.focus();
return;
}
if (e.key === '+' && !isSearch) {
e.preventDefault();
document.removeEventListener('keydown', handleListKeydown);
navigate('add');
return;
}
if (e.key === 'F' && e.shiftKey) {
e.preventDefault();
openVaultTab();
return;
}
const filtered = getFilteredEntries();
if (e.key === 'ArrowDown') {
e.preventDefault();
const max = Math.max(filtered.length - 1, 0);
state.selectedIndex = Math.min(state.selectedIndex + 1, max);
updateItemList();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
state.selectedIndex = Math.max(state.selectedIndex - 1, 0);
updateItemList();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const selected = filtered[state.selectedIndex];
if (selected) {
document.removeEventListener('keydown', handleListKeydown);
void openItem(selected[0]);
}
return;
}
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleListKeydown);
window.close();
return;
}
}
// ----------------------------------------------------------------------
// ----------------------------------------------------------------------
// Settings picker popover (device vs vault)
// ----------------------------------------------------------------------
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
];
function showSettingsPicker(anchor: HTMLElement): void {
document.querySelectorAll('.settings-picker').forEach((el) => el.remove());
const picker = document.createElement('div');
picker.className = 'settings-picker';
Object.assign(picker.style, {
position: 'absolute',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
padding: '4px',
minWidth: '170px',
zIndex: '999999',
fontSize: '12px',
});
const rect = anchor.getBoundingClientRect();
picker.style.top = `${rect.bottom + 4}px`;
picker.style.left = `${rect.left}px`;
for (const opt of SETTINGS_OPTIONS) {
const row = document.createElement('div');
Object.assign(row.style, {
padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9',
borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px',
});
const iconSpan = document.createElement('span');
iconSpan.textContent = opt.icon;
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' });
const labelSpan = document.createElement('span');
labelSpan.textContent = opt.label;
row.appendChild(iconSpan);
row.appendChild(labelSpan);
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', (ev) => {
ev.stopPropagation();
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
navigate(opt.view);
});
picker.appendChild(row);
}
document.body.appendChild(picker);
const closeOnOutside = (ev: MouseEvent) => {
if (!picker.contains(ev.target as Node)) {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
const closeOnEsc = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutside);
document.addEventListener('keydown', closeOnEsc);
}, 0);
}