Files
zeroclaw/web/src/components/config/useConfigForm.ts
T
2026-02-28 17:30:21 -05:00

308 lines
8.6 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { parse, stringify } from 'smol-toml';
import { getConfig, putConfig } from '@/lib/api';
const MASKED = '***MASKED***';
type ParsedConfig = Record<string, unknown>;
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
/** Recursively scan for MASKED strings and collect their dotted paths. */
function scanMasked(obj: unknown, prefix: string, out: Set<string>) {
if (obj === null || obj === undefined) return;
if (typeof obj === 'string' && obj === MASKED) {
out.add(prefix);
return;
}
if (Array.isArray(obj)) {
obj.forEach((item, i) => {
scanMasked(item, `${prefix}.${i}`, out);
});
return;
}
if (typeof obj === 'object') {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
scanMasked(v, prefix ? `${prefix}.${k}` : k, out);
}
}
}
/** Navigate into an object by dotted path segments, returning the value. */
function getNestedValue(obj: unknown, segments: string[]): unknown {
let current: unknown = obj;
for (const seg of segments) {
if (current === null || current === undefined || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[seg];
}
return current;
}
/** Set a value in an object by dotted path segments, creating intermediates. */
function setNestedValue(obj: Record<string, unknown>, segments: string[], value: unknown) {
if (segments.length === 0) return;
let current: Record<string, unknown> = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg: string = segments[i]!;
if (current[seg] === undefined || current[seg] === null || typeof current[seg] !== 'object') {
current[seg] = {};
}
current = current[seg] as Record<string, unknown>;
}
const lastSeg: string = segments[segments.length - 1]!;
if (value === undefined || value === '') {
delete current[lastSeg];
} else {
current[lastSeg] = value;
}
}
export type EditorMode = 'form' | 'raw';
export interface ConfigFormState {
loading: boolean;
saving: boolean;
error: string | null;
success: string | null;
mode: EditorMode;
rawToml: string;
parsed: ParsedConfig;
maskedPaths: Set<string>;
dirtyPaths: Set<string>;
setMode: (mode: EditorMode) => boolean;
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
isFieldDirty: (sectionPath: string, fieldKey: string) => boolean;
setRawToml: (raw: string) => void;
save: () => Promise<void>;
reload: () => Promise<void>;
clearMessages: () => void;
}
export function useConfigForm(): ConfigFormState {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [mode, setModeState] = useState<EditorMode>('form');
const [rawToml, setRawTomlState] = useState('');
const [parsed, setParsed] = useState<ParsedConfig>({});
const maskedPathsRef = useRef<Set<string>>(new Set());
const dirtyPathsRef = useRef<Set<string>>(new Set());
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceRender] = useState(0);
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getConfig();
const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
setRawTomlState(raw);
try {
const obj = parse(raw) as ParsedConfig;
setParsed(obj);
const masked = new Set<string>();
scanMasked(obj, '', masked);
maskedPathsRef.current = masked;
} catch {
// If TOML parse fails, start in raw mode
setParsed({});
maskedPathsRef.current = new Set();
setModeState('raw');
}
dirtyPathsRef.current = new Set();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configuration');
} finally {
setLoading(false);
}
}, []);
// Load once on mount.
const hasLoaded = useRef(false);
useEffect(() => {
if (!hasLoaded.current) {
hasLoaded.current = true;
void loadConfig();
}
}, [loadConfig]);
useEffect(() => {
return () => {
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
};
}, []);
const fieldPath = (sectionPath: string, fieldKey: string) =>
sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey;
const fieldSegments = (sectionPath: string, fieldKey: string) => {
const full = fieldPath(sectionPath, fieldKey);
return full.split('.').filter(Boolean);
};
const getFieldValue = useCallback(
(sectionPath: string, fieldKey: string): unknown => {
const segments = fieldSegments(sectionPath, fieldKey);
return getNestedValue(parsed, segments);
},
[parsed],
);
const setFieldValue = useCallback(
(sectionPath: string, fieldKey: string, value: unknown) => {
const fp = fieldPath(sectionPath, fieldKey);
const segments = fieldSegments(sectionPath, fieldKey);
setParsed((prev) => {
const next = deepClone(prev);
setNestedValue(next, segments, value);
return next;
});
dirtyPathsRef.current.add(fp);
forceRender((n) => n + 1);
},
[],
);
const isFieldMasked = useCallback(
(sectionPath: string, fieldKey: string): boolean => {
const fp = fieldPath(sectionPath, fieldKey);
return maskedPathsRef.current.has(fp) && !dirtyPathsRef.current.has(fp);
},
[],
);
const isFieldDirty = useCallback(
(sectionPath: string, fieldKey: string): boolean => {
const fp = fieldPath(sectionPath, fieldKey);
return dirtyPathsRef.current.has(fp);
},
[],
);
const syncFormToRaw = useCallback((): string => {
try {
const toml = stringify(parsed);
return toml;
} catch {
return rawToml;
}
}, [parsed, rawToml]);
const syncRawToForm = useCallback(
(raw: string): boolean => {
try {
const obj = parse(raw) as ParsedConfig;
setParsed(obj);
// Re-scan masked paths from fresh parse, preserving dirty overrides
const masked = new Set<string>();
scanMasked(obj, '', masked);
maskedPathsRef.current = masked;
return true;
} catch {
return false;
}
},
[],
);
const setMode = useCallback(
(newMode: EditorMode): boolean => {
if (newMode === mode) return true;
if (newMode === 'raw') {
// form → raw: serialize parsed to TOML
const toml = syncFormToRaw();
setRawTomlState(toml);
setModeState('raw');
return true;
} else {
// raw → form: parse TOML
if (syncRawToForm(rawToml)) {
setModeState('form');
return true;
} else {
setError('Invalid TOML syntax. Fix errors before switching to Form view.');
return false;
}
}
},
[mode, syncFormToRaw, syncRawToForm, rawToml],
);
const setRawToml = useCallback((raw: string) => {
setRawTomlState(raw);
}, []);
const save = useCallback(async () => {
setSaving(true);
setError(null);
setSuccess(null);
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
try {
let toml: string;
if (mode === 'form') {
toml = syncFormToRaw();
} else {
toml = rawToml;
}
await putConfig(toml);
setSuccess('Configuration saved successfully.');
// Auto-dismiss success after 4 seconds
successTimeoutRef.current = setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
}, [mode, syncFormToRaw, rawToml]);
const reload = useCallback(async () => {
await loadConfig();
}, [loadConfig]);
const clearMessages = useCallback(() => {
setError(null);
setSuccess(null);
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
}, []);
return {
loading,
saving,
error,
success,
mode,
rawToml,
parsed,
maskedPaths: maskedPathsRef.current,
dirtyPaths: dirtyPathsRef.current,
setMode,
getFieldValue,
setFieldValue,
isFieldMasked,
isFieldDirty,
setRawToml,
save,
reload,
clearMessages,
};
}