feat(ext): list/detail load + render org items in org context (normalized, read-only)

This commit is contained in:
adlee-was-taken
2026-06-27 11:47:50 -04:00
parent 183a3f0a81
commit c327c21fdc
2 changed files with 60 additions and 8 deletions

View File

@@ -3,6 +3,7 @@
/// to the detail view. /// to the detail view.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import { currentContext, messageForList, messageForGet } from '../../shared/org-context';
import { showToast } from '../../shared/toast'; import { showToast } from '../../shared/toast';
import { import {
GLYPH_VAULT_TAB, GLYPH_VAULT_TAB,
@@ -10,7 +11,7 @@ import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../../shared/glyphs'; } from '../../shared/glyphs';
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; import type { ItemId, ItemType, ManifestEntry, OrgManifestEntry, Item } from '../../shared/types';
/// Extract the display hostname from an icon_hint or fallback to the first tag. /// Extract the display hostname from an icon_hint or fallback to the first tag.
function metaLine(e: ManifestEntry): string { function metaLine(e: ManifestEntry): string {
@@ -32,6 +33,39 @@ function typeIcon(t: ItemType): string {
} }
} }
/// Normalize OrgManifestEntry[] (leaner org shape; no attachment_summaries /
/// favorite / icon_hint / group) into the Array<[ItemId, ManifestEntry]> that
/// buildRowsHtml and getFilteredEntries expect. Used by BOTH the sync handler
/// and the context-aware initial load.
function normalizeOrgEntries(data: OrgManifestEntry[]): Array<[ItemId, ManifestEntry]> {
return data.map(e => [e.id, {
id: e.id,
type: e.type,
title: e.title,
tags: e.tags,
modified: e.modified,
trashed_at: e.trashed_at,
favorite: false,
attachment_summaries: [],
// icon_hint, group: undefined (optional in ManifestEntry; omitted for org items)
}]);
}
/// Context-aware item list loader. Sends list_items or org_list_items depending
/// on the current context, then normalizes the response into entries and calls
/// setState. Called once per context by renderItemList (loop-safe guard).
async function loadEntriesForContext(): Promise<void> {
const resp = await sendMessage(messageForList());
if (!resp.ok) {
setState({ error: resp.error ?? 'Failed to load items' });
return;
}
const entries: Array<[ItemId, ManifestEntry]> = currentContext() === 'personal'
? (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items
: normalizeOrgEntries(resp.data as OrgManifestEntry[]);
setState({ entries });
}
function buildRowsHtml(): string { function buildRowsHtml(): string {
const state = getState(); const state = getState();
const filtered = getFilteredEntries(); const filtered = getFilteredEntries();
@@ -81,12 +115,13 @@ function wireRowClicks(): void {
export function renderItemList(app: HTMLElement): void { export function renderItemList(app: HTMLElement): void {
const state = getState(); const state = getState();
const isOrg = currentContext() !== 'personal';
app.innerHTML = ` app.innerHTML = `
<div class="search-bar"> <div class="search-bar">
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}"> <input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
</div> </div>
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;"> <div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button> ${isOrg ? '' : '<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>'}
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button> <button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button> <button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button>
@@ -98,7 +133,7 @@ export function renderItemList(app: HTMLElement): void {
</div> </div>
<div class="keyhints"> <div class="keyhints">
<span><kbd>/</kbd> search</span> <span><kbd>/</kbd> search</span>
<span><kbd>+</kbd> new</span> ${isOrg ? '' : '<span><kbd>+</kbd> new</span>'}
<span><kbd>&uarr;&darr;</kbd> nav</span> <span><kbd>&uarr;&darr;</kbd> nav</span>
<span><kbd>Enter</kbd> open</span> <span><kbd>Enter</kbd> open</span>
</div> </div>
@@ -127,10 +162,12 @@ export function renderItemList(app: HTMLElement): void {
setState({ loading: true, error: null }); setState({ loading: true, error: null });
const resp = await sendMessage({ type: 'sync' }); const resp = await sendMessage({ type: 'sync' });
if (resp.ok) { if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' }); const listResp = await sendMessage(messageForList());
if (listResp.ok) { if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; const entries: Array<[ItemId, ManifestEntry]> = currentContext() === 'personal'
setState({ entries: data.items, loading: false }); ? (listResp.data as { items: Array<[ItemId, ManifestEntry]> }).items
: normalizeOrgEntries(listResp.data as OrgManifestEntry[]);
setState({ entries, loading: false });
showToast('Synced', 'success'); showToast('Synced', 'success');
return; return;
} }
@@ -155,11 +192,21 @@ export function renderItemList(app: HTMLElement): void {
wireRowClicks(); wireRowClicks();
document.addEventListener('keydown', handleListKeydown); document.addEventListener('keydown', handleListKeydown);
// Context-aware initial load (org only; personal uses manual sync).
// Loop-safe guard: setState({loadedContext}) triggers a re-render that calls
// renderItemList again, but the guard now matches so no second load fires.
// When loadEntriesForContext() later resolves and calls setState({entries}),
// loadedContext still matches so still no loop.
if (isOrg && getState().loadedContext !== currentContext()) {
setState({ loadedContext: currentContext() });
void loadEntriesForContext();
}
} }
async function openItem(id: ItemId): Promise<void> { async function openItem(id: ItemId): Promise<void> {
setState({ loading: true }); setState({ loading: true });
const resp = await sendMessage({ type: 'get_item', id }); const resp = await sendMessage(messageForGet(id));
if (resp.ok) { if (resp.ok) {
const data = resp.data as { item: Item }; const data = resp.data as { item: Item };
navigate('detail', { navigate('detail', {
@@ -226,7 +273,7 @@ function handleListKeydown(e: KeyboardEvent): void {
return; return;
} }
if (e.key === '+' && !isSearch) { if (e.key === '+' && !isSearch && currentContext() === 'personal') {
e.preventDefault(); e.preventDefault();
document.removeEventListener('keydown', handleListKeydown); document.removeEventListener('keydown', handleListKeydown);
navigate('add'); navigate('add');

View File

@@ -57,6 +57,11 @@ export interface PopupState {
orgOffline?: boolean; orgOffline?: boolean;
orgConfigs?: OrgConfigSummary[]; orgConfigs?: OrgConfigSummary[];
orgCollections?: Collection[]; orgCollections?: Collection[];
// Tracks which context's items were last loaded into `entries`. Used by
// renderItemList to fire a context-aware load exactly ONCE per context
// switch (loop-safe guard: setState({loadedContext}) re-renders, but the
// guard then matches so no second load fires).
loadedContext?: string;
// Vault-tab-only fields. The popup surface leaves these at their defaults // Vault-tab-only fields. The popup surface leaves these at their defaults
// (unlocked=false implicit via separate lock-screen view, drawer/panel false). // (unlocked=false implicit via separate lock-screen view, drawer/panel false).
// Kept on the shared shape so VaultState satisfies StateHost.getState() // Kept on the shared shape so VaultState satisfies StateHost.getState()