/** * ConfigEditorDialog — inline JSON config editor powered by CodeMirror. * * Opens as a dialog, shows the full app config as formatted JSON. * Auto-saves on changes with debounce. Shows validation errors for malformed JSON. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { json } from '@codemirror/lang-json'; import { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, } from '@codemirror/language'; import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; import { search, searchKeymap } from '@codemirror/search'; import { EditorState } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, } from '@codemirror/view'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; import { AlertTriangle, Check, Loader2, X } from 'lucide-react'; import type { AppConfig } from '@renderer/types/data'; // ============================================================================= // Constants // ============================================================================= const SAVE_DEBOUNCE_MS = 800; // ============================================================================= // JSON Linter // ============================================================================= const jsonLinter = linter((view: EditorView) => { const diagnostics: Diagnostic[] = []; const text = view.state.doc.toString(); try { JSON.parse(text); } catch (e) { if (e instanceof SyntaxError) { const match = /position (\d+)/.exec(e.message); const pos = match ? parseInt(match[1], 10) : 0; const safePos = Math.min(pos, text.length); diagnostics.push({ from: safePos, to: Math.min(safePos + 1, text.length), severity: 'error', message: e.message, }); } } return diagnostics; }); // ============================================================================= // Types // ============================================================================= interface ConfigEditorDialogProps { open: boolean; onClose: () => void; onConfigSaved: (config: AppConfig) => void; } type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; // ============================================================================= // Component // ============================================================================= export const ConfigEditorDialog = ({ open, onClose, onConfigSaved, }: ConfigEditorDialogProps): React.JSX.Element | null => { const editorRef = useRef(null); const viewRef = useRef(null); const saveTimerRef = useRef>(); const savedRevertTimerRef = useRef>(); const [saveStatus, setSaveStatus] = useState('idle'); const [jsonError, setJsonError] = useState(null); const [loading, setLoading] = useState(true); const initialConfigRef = useRef(''); const saveConfig = useCallback( async (jsonText: string) => { try { const parsed = JSON.parse(jsonText) as AppConfig; setJsonError(null); setSaveStatus('saving'); // Save each section separately via existing API if (parsed.general) { await api.config.update('general', parsed.general); } if (parsed.notifications) { await api.config.update('notifications', parsed.notifications); } if (parsed.display) { await api.config.update('display', parsed.display); } if (parsed.sessions) { await api.config.update('sessions', parsed.sessions); } // Re-fetch to get the canonical saved state const fresh = await api.config.get(); onConfigSaved(fresh); useStore.setState({ appConfig: fresh }); initialConfigRef.current = JSON.stringify(fresh, null, 2); setSaveStatus('saved'); if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); savedRevertTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); } catch (e) { if (e instanceof SyntaxError) { setJsonError(e.message); setSaveStatus('idle'); } else { setSaveStatus('error'); setJsonError(e instanceof Error ? e.message : 'Failed to save config'); if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); savedRevertTimerRef.current = setTimeout(() => { setSaveStatus('idle'); setJsonError(null); }, 4000); } } }, [onConfigSaved] ); const scheduleSave = useCallback( (jsonText: string) => { // Validate JSON before scheduling save try { JSON.parse(jsonText); setJsonError(null); } catch (e) { if (e instanceof SyntaxError) { setJsonError(e.message); } return; } if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { void saveConfig(jsonText); }, SAVE_DEBOUNCE_MS); }, [saveConfig] ); // Initialize CodeMirror when dialog opens useEffect(() => { if (!open) return; let destroyed = false; // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); setSaveStatus('idle'); setJsonError(null); const init = async (): Promise => { try { const config = await api.config.get(); if (destroyed) return; const jsonText = JSON.stringify(config, null, 2); initialConfigRef.current = jsonText; setLoading(false); // Wait for DOM render requestAnimationFrame(() => { if (destroyed || !editorRef.current) return; // Clean up existing view if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } const state = EditorState.create({ doc: jsonText, extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), history(), foldGutter(), indentOnInput(), bracketMatching(), json(), syntaxHighlighting(oneDarkHighlightStyle), jsonLinter, lintGutter(), search(), keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), baseEditorTheme, configEditorTheme, // eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup EditorView.updateListener.of((update) => { if (update.docChanged) { const text = update.state.doc.toString(); scheduleSave(text); } }), ], }); const view = new EditorView({ state, parent: editorRef.current, }); viewRef.current = view; }); } catch (e) { if (destroyed) return; setLoading(false); setJsonError(e instanceof Error ? e.message : 'Failed to load config'); } }; void init(); return () => { destroyed = true; if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); }; }, [open, scheduleSave]); // Escape key handler useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}

Edit Configuration

{/* Editor */}
{loading ? (
Loading config...
) : (
)}
{/* Footer */}

Changes auto-save after editing

Esc to close
); }; // ============================================================================= // Save Status Badge // ============================================================================= const SaveStatusBadge = ({ status, error, }: { status: SaveStatus; error: string | null; }): React.JSX.Element | null => { if (status === 'idle' && !error) return null; if (error && status !== 'saving') { return ( {status === 'error' ? 'Save failed' : 'Invalid JSON'} ); } if (status === 'saving') { return ( Saving... ); } if (status === 'saved') { return ( Saved ); } return null; }; // ============================================================================= // Editor Theme Override // ============================================================================= const configEditorTheme = EditorView.theme({ '&': { height: '100%', maxHeight: 'calc(85vh - 100px)', }, '.cm-scroller': { overflow: 'auto', padding: '8px 0', }, '.cm-content': { padding: '0 8px', }, '.cm-gutters': { paddingLeft: '4px', }, });