/** * Shared staged-form model for the Netx Ops Plugins settings card. * Self-contained (cannot value-import ui-settings-plugins — client bundle purity). */ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts' export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' } export interface CardFieldSpec { field: string format: (value: unknown) => string parse: (text: string) => FieldWrite | undefined } export interface CardSecretSpec { field: string write: (text: string) => Promise } export interface CardFieldState { text: string overridden: boolean invalid: boolean } export interface CardShell { available: boolean writable: boolean dirty: boolean invalid: boolean saving: boolean failed: boolean } export interface CardActions { edit: (field: string, text: string) => void resetField: (field: string) => void save: () => void discard: () => void } interface StagedEdit { text: string clear: boolean } interface PlannedWrite { field: string run: (() => Promise) | undefined } export function textField(field: string): CardFieldSpec { return { field, format: value => typeof value === 'string' ? value : '', parse: (text) => { const trimmed = text.trim() return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed } }, } } /** Boolean settings field rendered as a checkbox (stores true / clears when false). */ export function booleanField(field: string): CardFieldSpec { return { field, format: value => (value === true ? 'true' : 'false'), parse: (text) => { const normalized = text.trim().toLowerCase() if (normalized === 'true' || normalized === '1' || normalized === 'yes') { return { kind: 'set', value: true } } if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === '') { return { kind: 'clear' } } return undefined }, } } /** * Boolean that persists `false` explicitly (use when schema default is `true`). * Clearing would restore the default and make "off" impossible. */ export function booleanFieldPersistFalse(field: string): CardFieldSpec { return { field, format: value => (value === true ? 'true' : 'false'), parse: (text) => { const normalized = text.trim().toLowerCase() if (normalized === 'true' || normalized === '1' || normalized === 'yes') { return { kind: 'set', value: true } } if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === '') { return { kind: 'set', value: false } } return undefined }, } } export class CardForm { private readonly specs: Map private readonly secretSpecs: Map private readonly staged = new Map() private readonly listeners = new Set<() => void>() private saving = false private failed = false constructor( private readonly scope: SettingsScope, specs: CardFieldSpec[], secrets: CardSecretSpec[] = [], ) { this.specs = new Map(specs.map(spec => [spec.field, spec])) this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec])) scope.subscribe(() => { this.publish() }) } bind(project: () => S): SnapshotStore { const store = createSnapshotStore(project()) this.listeners.add(() => { store.set(project()) }) return store } shell(): CardShell { const snapshot = this.scope.getSnapshot() const plan = this.plan() return { available: snapshot.status === 'ready', writable: snapshot.writable, dirty: plan.length > 0, invalid: plan.some(item => item.run === undefined), saving: this.saving, failed: this.failed, } } field(field: string): CardFieldState { const staged = this.staged.get(field) if (this.secretSpecs.has(field)) { return { text: staged?.text ?? '', overridden: false, invalid: false } } const spec = this.spec(field) if (staged === undefined) { return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false } } const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text) return { text: staged.text, overridden: write?.kind === 'set', invalid: write === undefined, } } actions(): CardActions { return { edit: (field, text) => { this.stage(field, { text, clear: false }) }, resetField: (field) => { this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }) }, save: () => { void this.save() }, discard: () => { if (this.staged.size === 0 && !this.failed) return this.staged.clear() this.failed = false this.publish() }, } } async save(): Promise { const plan = this.plan() const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run]) if (plan.length === 0 || this.saving || writes.length !== plan.length) return this.saving = true this.failed = false this.publish() let landed = true for (const write of writes) { landed = await write() && landed } if (landed) this.staged.clear() this.saving = false this.failed = !landed this.publish() } private plan(): PlannedWrite[] { const plan: PlannedWrite[] = [] for (const [field, staged] of this.staged) { const secret = this.secretSpecs.get(field) if (secret !== undefined) { const value = staged.text.trim() if (value !== '') plan.push({ field, run: () => secret.write(value) }) continue } const spec = this.spec(field) if (staged.clear) { if (this.stored(field)) plan.push({ field, run: () => this.clear(field) }) continue } if (staged.text === spec.format(this.sectionValue(field))) continue const write = spec.parse(staged.text) if (write === undefined) plan.push({ field, run: undefined }) else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) }) else plan.push({ field, run: () => this.store(field, write.value) }) } return plan } private async clear(field: string): Promise { await this.scope.unset(field) return !this.stored(field) } private async store(field: string, value: unknown): Promise { await this.scope.set(field, value) // `settings.mutate` can settle without throwing on a refused write; confirm // via the resolved section value (user layer alone can lag or omit keys). return this.sectionValue(field) === value || this.userLayer()?.[field] === value } private stage(field: string, edit: StagedEdit): void { this.staged.set(field, edit) this.failed = false this.publish() } private spec(field: string): CardFieldSpec { const spec = this.specs.get(field) if (spec === undefined) throw new Error(`netxops card has no field ${field}`) return spec } private snapshotOf(): SettingsScopeSnapshot { return this.scope.getSnapshot() } private sectionValue(field: string): unknown { return (this.snapshotOf().value as Record | undefined)?.[field] } private baseValue(field: string): unknown { return (this.snapshotOf().base as Record | undefined)?.[field] } private userLayer(): Record | undefined { return this.snapshotOf().user as Record | undefined } private stored(field: string): boolean { const user = this.userLayer() return user !== undefined && Object.hasOwn(user, field) } private publish(): void { for (const listener of this.listeners) listener() } }