diff --git a/apps/ui/src/components/ui/json-syntax-editor.tsx b/apps/ui/src/components/ui/json-syntax-editor.tsx new file mode 100644 index 00000000..236b265a --- /dev/null +++ b/apps/ui/src/components/ui/json-syntax-editor.tsx @@ -0,0 +1,140 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { StreamLanguage } from '@codemirror/language'; +import { javascript } from '@codemirror/legacy-modes/mode/javascript'; +import { EditorView } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { cn } from '@/lib/utils'; + +interface JsonSyntaxEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + minHeight?: string; + maxHeight?: string; + readOnly?: boolean; + 'data-testid'?: string; +} + +// Syntax highlighting using CSS variables for theme compatibility +const syntaxColors = HighlightStyle.define([ + // Property names (keys) + { tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + + // Strings (values) + { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + + // Numbers + { tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + + // Booleans and null + { tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + { tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + { tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + + // Brackets and punctuation + { tag: t.bracket, color: 'var(--muted-foreground)' }, + { tag: t.punctuation, color: 'var(--muted-foreground)' }, + + // Default text + { tag: t.content, color: 'var(--foreground)' }, +]); + +// Editor theme using CSS variables +const editorTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '0.8125rem', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + backgroundColor: 'transparent', + color: 'var(--foreground)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + }, + '.cm-content': { + padding: '0.75rem', + minHeight: '100%', + caretColor: 'var(--primary)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--primary)', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--accent)', + opacity: '0.3', + }, + '.cm-line': { + padding: '0 0.25rem', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + border: 'none', + paddingRight: '0.5rem', + }, + '.cm-lineNumbers .cm-gutterElement': { + minWidth: '2.5rem', + textAlign: 'right', + paddingRight: '0.5rem', + }, + '.cm-placeholder': { + color: 'var(--muted-foreground)', + fontStyle: 'italic', + }, +}); + +// JavaScript language in JSON mode +const jsonLanguage = StreamLanguage.define(javascript); + +// Combine all extensions +const extensions: Extension[] = [jsonLanguage, syntaxHighlighting(syntaxColors), editorTheme]; + +export function JsonSyntaxEditor({ + value, + onChange, + placeholder, + className, + minHeight = '300px', + maxHeight, + readOnly = false, + 'data-testid': testId, +}: JsonSyntaxEditorProps) { + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index ff614302..1ddf0a39 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -29,6 +29,7 @@ import { import { MCPServersSection } from './settings-view/mcp-servers'; import { PromptCustomizationSection } from './settings-view/prompts'; import { EventHooksSection } from './settings-view/event-hooks'; +import { ImportExportDialog } from './settings-view/components/import-export-dialog'; import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Project as ElectronProject } from '@/lib/electron'; @@ -114,6 +115,7 @@ export function SettingsView() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); + const [showImportExportDialog, setShowImportExportDialog] = useState(false); // Mobile navigation state - default to showing on desktop, hidden on mobile const [showNavigation, setShowNavigation] = useState(() => { @@ -239,6 +241,7 @@ export function SettingsView() { setShowNavigation(!showNavigation)} + onImportExportClick={() => setShowImportExportDialog(true)} /> {/* Content Area with Sidebar */} @@ -269,6 +272,9 @@ export function SettingsView() { project={currentProject} onConfirm={moveProjectToTrash} /> + + {/* Import/Export Settings Dialog */} + ); } diff --git a/apps/ui/src/components/views/settings-view/components/import-export-dialog.tsx b/apps/ui/src/components/views/settings-view/components/import-export-dialog.tsx new file mode 100644 index 00000000..9d609f71 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/components/import-export-dialog.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import { Copy, Check, AlertCircle, Save } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { JsonSyntaxEditor } from '@/components/ui/json-syntax-editor'; +import { apiGet, apiPut } from '@/lib/api-fetch'; +import { toast } from 'sonner'; +import type { GlobalSettings } from '@automaker/types'; + +interface ImportExportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface SettingsResponse { + success: boolean; + settings: GlobalSettings; +} + +export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogProps) { + const [jsonValue, setJsonValue] = useState(''); + const [originalValue, setOriginalValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [copied, setCopied] = useState(false); + const [parseError, setParseError] = useState(null); + + // Load current settings when dialog opens + useEffect(() => { + if (open) { + loadSettings(); + } + }, [open]); + + const loadSettings = async () => { + setIsLoading(true); + try { + const response = await apiGet('/api/settings/global'); + if (response.success) { + const formatted = JSON.stringify(response.settings, null, 2); + setJsonValue(formatted); + setOriginalValue(formatted); + setParseError(null); + } + } catch (error) { + toast.error('Failed to load settings'); + console.error('Failed to load settings:', error); + } finally { + setIsLoading(false); + } + }; + + // Validate JSON on change + const handleJsonChange = (value: string) => { + setJsonValue(value); + try { + JSON.parse(value); + setParseError(null); + } catch { + setParseError('Invalid JSON syntax'); + } + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(jsonValue); + setCopied(true); + toast.success('Settings copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Failed to copy to clipboard'); + } + }; + + const handleSave = async () => { + if (parseError) { + toast.error('Please fix JSON syntax errors before saving'); + return; + } + + setIsSaving(true); + try { + const settings = JSON.parse(jsonValue); + const response = await apiPut('/api/settings/global', settings); + if (response.success) { + const formatted = JSON.stringify(response.settings, null, 2); + setJsonValue(formatted); + setOriginalValue(formatted); + toast.success('Settings saved successfully', { + description: 'Your changes have been applied. Some settings may require a refresh.', + }); + onOpenChange(false); + } + } catch (error) { + toast.error('Failed to save settings'); + console.error('Failed to save settings:', error); + } finally { + setIsSaving(false); + } + }; + + const handleReset = () => { + setJsonValue(originalValue); + setParseError(null); + }; + + const hasChanges = jsonValue !== originalValue; + + return ( + + + + Import / Export Settings + + Copy your settings to transfer to another machine, or paste settings from another + installation. + + + +
+ {/* Action Buttons */} +
+
+ + +
+
+ {hasChanges && ( + + )} + +
+
+ + {/* Error Message */} + {parseError && ( +
+ + {parseError} +
+ )} + + {/* JSON Editor */} +
+ {isLoading ? ( +
+ Loading settings... +
+ ) : ( + + )} +
+ + {/* Help Text */} +

+ To import settings, paste the JSON content into the editor and click "Save Changes". +

+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/components/settings-header.tsx b/apps/ui/src/components/views/settings-view/components/settings-header.tsx index 9d1b9ff5..25cf8dc5 100644 --- a/apps/ui/src/components/views/settings-view/components/settings-header.tsx +++ b/apps/ui/src/components/views/settings-view/components/settings-header.tsx @@ -1,4 +1,4 @@ -import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react'; +import { Settings, PanelLeft, PanelLeftClose, FileJson } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -7,6 +7,7 @@ interface SettingsHeaderProps { description?: string; showNavigation?: boolean; onToggleNavigation?: () => void; + onImportExportClick?: () => void; } export function SettingsHeader({ @@ -14,6 +15,7 @@ export function SettingsHeader({ description = 'Configure your API keys and preferences', showNavigation, onToggleNavigation, + onImportExportClick, }: SettingsHeaderProps) { return (
-
- {/* Mobile menu toggle button - only visible on mobile */} - {onToggleNavigation && ( - + )} +
+ +
+
+

+ {title} +

+

{description}

+
+
+ {/* Import/Export button */} + {onImportExportClick && ( + )} -
- -
-
-

- {title} -

-

{description}

-