feat(ext): list/detail load + render org items in org context (normalized, read-only)
This commit is contained in:
@@ -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>↑↓</kbd> nav</span>
|
<span><kbd>↑↓</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');
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user