/** * Client-side read of the user-facing "Side card" preferences. The host owns * the namespace through the settings seam (in-process); the DSH settings RPC * domain only serves allowlisted namespaces to configuration clients, so the * client reads and writes THIS namespace through the plugin's own fenced * /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure * (route rejected, namespace absent, a field of the wrong type, a value out * of the contract range) falls back to the schema defaults — the side card * must keep working exactly as composed when the settings surface is missing. */ import type { api } from './api.ts' import { clampTerminalFontSize, clampWidthPercent, SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs, } from '../prefs-shared.ts' export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent } export type { SidebarPrefs } /** The settings wire face the preferences need (a subset of the plugin api). */ export type SidebarSettingsClient = Pick /** Validate one raw resolved value into {@link SidebarPrefs}. Used for the * settings.get payload AND the settings.update response (both carry the * layered resolved value); any malformed field falls back to its default. * @param value - the raw resolved section from the settings wire. * @returns validated prefs (always well-formed). */ export function parsePrefs(value: unknown): SidebarPrefs { if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS } const record = value as Record return { openByDefault: typeof record.openByDefault === 'boolean' ? record.openByDefault : SIDEBAR_PREFS_DEFAULTS.openByDefault, defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent) ? clampWidthPercent(record.defaultWidthPercent) : SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent, autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' ? record.autoOpenSubagent : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, autoOpenJobs: typeof record.autoOpenJobs === 'boolean' ? record.autoOpenJobs : SIDEBAR_PREFS_DEFAULTS.autoOpenJobs, agentTerminalTools: typeof record.agentTerminalTools === 'boolean' ? record.agentTerminalTools : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean' ? record.bottomPanelAutoTerminal : SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal, terminalFontFamily: typeof record.terminalFontFamily === 'string' ? record.terminalFontFamily : SIDEBAR_PREFS_DEFAULTS.terminalFontFamily, terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize) ? clampTerminalFontSize(record.terminalFontSize) : SIDEBAR_PREFS_DEFAULTS.terminalFontSize, interceptOpenPath: typeof record.interceptOpenPath === 'boolean' ? record.interceptOpenPath : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean' ? record.htmlViewerNoSandbox : SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox, htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean' ? record.htmlViewerDefaultUnsafe : SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe, browserNoSandbox: typeof record.browserNoSandbox === 'boolean' ? record.browserNoSandbox : SIDEBAR_PREFS_DEFAULTS.browserNoSandbox, browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean' ? record.browserInterceptLinks : SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks, tabsEnabled: booleanMapOf(record.tabsEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled), pluginSettings: pluginSettingsMapOf(record.pluginSettings), } } /** * Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key: * value } }`, nested open maps. Any non-object value (or a malformed whole) * falls back to the empty map — the schema defaults already guard the wire * shape, this is the client's second line. */ function pluginSettingsMapOf(value: unknown): Record> { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} const out: Record> = {} for (const [id, blob] of Object.entries(value as Record)) { if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) { out[id] = blob as Record } } return out } /** * Validate one enable-switch map (per-tab / per-viewer). Only boolean values * survive; a non-object or a non-boolean entry falls back to the empty map / * drops the entry — an absent key means the feature stays enabled. */ function booleanMapOf(value: unknown): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} const out: Record = {} for (const [key, item] of Object.entries(value as Record)) { if (typeof item === 'boolean') out[key] = item } return out } /** * Read the resolved side card preferences through the plugin's settings route. * @param settings - the settings wire face (the plugin api by default). * @returns validated prefs, or the schema defaults when the route rejects, * the namespace is absent, or a stored value violates the contract. */ export async function loadPrefs(settings: SidebarSettingsClient): Promise { try { const view = await settings.settingsGet() return parsePrefs(view.value) } catch { // Transport/fence rejection or a malformed response: keep the defaults. return { ...SIDEBAR_PREFS_DEFAULTS } } }