mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(dashboard): add code editor for raw JSON settings editing
- Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73
This commit is contained in:
@@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
|
||||
keys: Object.keys(secrets), // Only key names, not values
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Generic File API (Issue #73) ====================
|
||||
|
||||
/**
|
||||
* Security: Validate file path is within allowed directories
|
||||
* - ~/.ccs/ directory: read/write allowed
|
||||
* - ~/.claude/settings.json: read-only
|
||||
*/
|
||||
function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } {
|
||||
const expandedPath = expandPath(filePath);
|
||||
const normalizedPath = path.normalize(expandedPath);
|
||||
const ccsDir = getCcsDir();
|
||||
const claudeSettingsPath = expandPath('~/.claude/settings.json');
|
||||
|
||||
// Check if path is within ~/.ccs/
|
||||
if (normalizedPath.startsWith(ccsDir)) {
|
||||
// Block access to sensitive subdirectories
|
||||
const relativePath = normalizedPath.slice(ccsDir.length);
|
||||
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
|
||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||
}
|
||||
return { valid: true, readonly: false };
|
||||
}
|
||||
|
||||
// Allow read-only access to ~/.claude/settings.json
|
||||
if (normalizedPath === claudeSettingsPath) {
|
||||
return { valid: true, readonly: true };
|
||||
}
|
||||
|
||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/file - Read a file with path validation
|
||||
* Query params: path (required)
|
||||
* Returns: { content: string, mtime: number, readonly: boolean, path: string }
|
||||
*/
|
||||
apiRoutes.get('/file', (req: Request, res: Response): void => {
|
||||
const filePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing required query parameter: path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.valid) {
|
||||
res.status(403).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(filePath);
|
||||
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(expandedPath);
|
||||
const content = fs.readFileSync(expandedPath, 'utf8');
|
||||
|
||||
res.json({
|
||||
content,
|
||||
mtime: stat.mtime.getTime(),
|
||||
readonly: validation.readonly,
|
||||
path: expandedPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/file - Write a file with conflict detection and backup
|
||||
* Query params: path (required)
|
||||
* Body: { content: string, expectedMtime?: number }
|
||||
* Returns: { success: true, mtime: number, backupPath?: string }
|
||||
*/
|
||||
apiRoutes.put('/file', (req: Request, res: Response): void => {
|
||||
const filePath = req.query.path as string;
|
||||
const { content, expectedMtime } = req.body;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing required query parameter: path' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: content' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.valid) {
|
||||
res.status(403).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.readonly) {
|
||||
res.status(403).json({ error: 'File is read-only' });
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(filePath);
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Conflict detection (if file exists and expectedMtime provided)
|
||||
if (fs.existsSync(expandedPath) && expectedMtime !== undefined) {
|
||||
const stat = fs.statSync(expandedPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Create backup if file exists
|
||||
let backupPath: string | undefined;
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const filename = path.basename(expandedPath);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`);
|
||||
fs.copyFileSync(expandedPath, backupPath);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(expandedPath);
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
const tempPath = expandedPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, content);
|
||||
fs.renameSync(tempPath, expandedPath);
|
||||
|
||||
const newStat = fs.statSync(expandedPath);
|
||||
res.json({
|
||||
success: true,
|
||||
mtime: newStat.mtime.getTime(),
|
||||
backupPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files - List editable files in ~/.ccs/
|
||||
* Returns: { files: Array<{ name: string, path: string, mtime: number }> }
|
||||
*/
|
||||
apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
res.json({ files: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||
.map((entry) => {
|
||||
const filePath = path.join(ccsDir, entry.name);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
name: entry.name,
|
||||
path: `~/.ccs/${entry.name}`,
|
||||
mtime: stat.mtime.getTime(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -387,6 +389,8 @@
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
@@ -677,6 +681,8 @@
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@@ -701,6 +707,8 @@
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
|
||||
|
||||
"react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
@@ -34,11 +34,13 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Code Editor Component
|
||||
* Lightweight JSON editor with syntax highlighting, line numbers, and validation
|
||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language?: 'json' | 'yaml';
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON and extract error location
|
||||
*/
|
||||
function validateJson(code: string): ValidationResult {
|
||||
if (!code.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(code);
|
||||
return { valid: true };
|
||||
} catch (e) {
|
||||
const error = e as SyntaxError;
|
||||
const message = error.message;
|
||||
|
||||
// Try to extract line number from error message
|
||||
// Format: "... at position X" or "... at line Y column Z"
|
||||
const posMatch = message.match(/position (\d+)/);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
const lines = code.substring(0, pos).split('\n');
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
line: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'json',
|
||||
readonly = false,
|
||||
className,
|
||||
minHeight = '300px',
|
||||
}: CodeEditorProps) {
|
||||
const { isDark } = useTheme();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Validate on every change for JSON
|
||||
const validation = useMemo(() => {
|
||||
if (language === 'json') {
|
||||
return validateJson(value);
|
||||
}
|
||||
return { valid: true };
|
||||
}, [value, language]);
|
||||
|
||||
// Highlight function using prism-react-renderer
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => (
|
||||
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
className={cn('table-row', validation.line === i + 1 && 'bg-destructive/20')}
|
||||
>
|
||||
<span className="table-cell pr-4 text-right text-muted-foreground select-none opacity-50 text-xs w-8">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="table-cell">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
),
|
||||
[isDark, language, validation.line]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{/* Editor container */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border overflow-hidden',
|
||||
'bg-muted/30',
|
||||
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
||||
readonly && 'opacity-70 cursor-not-allowed',
|
||||
!validation.valid && 'border-destructive'
|
||||
)}
|
||||
style={{ minHeight }}
|
||||
>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={readonly ? () => {} : onChange}
|
||||
highlight={highlightCode}
|
||||
padding={12}
|
||||
disabled={readonly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
textareaClassName={cn(
|
||||
'focus:outline-none font-mono text-sm',
|
||||
readonly && 'cursor-not-allowed'
|
||||
)}
|
||||
preClassName="font-mono text-sm"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
minHeight,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation status */}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||
{validation.valid ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
Valid {language.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{validation.error}
|
||||
{validation.line && ` (line ${validation.line})`}
|
||||
</span>
|
||||
)}
|
||||
{readonly && <span className="ml-auto text-muted-foreground">(Read-only)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Settings Dialog Component
|
||||
* Reusable dialog for editing profile environment variables
|
||||
* Features: masked inputs for sensitive keys, conflict detection, save/cancel
|
||||
* Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,9 +18,14 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { MaskedInput } from '@/components/ui/masked-input';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Save, X, Loader2 } from 'lucide-react';
|
||||
import { Save, X, Loader2, Code2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Lazy load CodeEditor to reduce initial bundle size
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
|
||||
);
|
||||
|
||||
interface Settings {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
@@ -55,6 +60,8 @@ function SettingsDialogContent({
|
||||
}) {
|
||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('env');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch settings for selected profile
|
||||
@@ -63,6 +70,23 @@ function SettingsDialogContent({
|
||||
queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()),
|
||||
});
|
||||
|
||||
// Derive raw JSON content: use edits if available, otherwise serialize from data
|
||||
const settings = data?.settings;
|
||||
const rawJsonContent = useMemo(() => {
|
||||
if (rawJsonEdits !== null) {
|
||||
return rawJsonEdits;
|
||||
}
|
||||
if (settings) {
|
||||
return JSON.stringify(settings, null, 2);
|
||||
}
|
||||
return '';
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
// Update raw JSON when user edits
|
||||
const handleRawJsonChange = useCallback((value: string) => {
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
// Derive current settings by merging original data with local edits
|
||||
const currentSettings = useMemo((): Settings | undefined => {
|
||||
const settings = data?.settings;
|
||||
@@ -76,16 +100,39 @@ function SettingsDialogContent({
|
||||
};
|
||||
}, [data?.settings, localEdits]);
|
||||
|
||||
// Check if raw JSON is valid
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(rawJsonContent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [rawJsonContent]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const settingsToSave: Settings = {
|
||||
...data?.settings,
|
||||
env: {
|
||||
...data?.settings?.env,
|
||||
...localEdits,
|
||||
},
|
||||
};
|
||||
let settingsToSave: Settings;
|
||||
|
||||
// Determine what to save based on active tab
|
||||
if (activeTab === 'raw') {
|
||||
// Parse raw JSON content
|
||||
try {
|
||||
settingsToSave = JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON');
|
||||
}
|
||||
} else {
|
||||
// Use form-based edits
|
||||
settingsToSave = {
|
||||
...data?.settings,
|
||||
env: {
|
||||
...data?.settings?.env,
|
||||
...localEdits,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/settings/${profileName}`, {
|
||||
method: 'PUT',
|
||||
@@ -174,7 +221,11 @@ function SettingsDialogContent({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<Tabs defaultValue="env" className="flex-1 flex flex-col overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start border-b rounded-none p-0 h-auto bg-transparent">
|
||||
<TabsTrigger
|
||||
value="env"
|
||||
@@ -182,6 +233,13 @@ function SettingsDialogContent({
|
||||
>
|
||||
Environment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
<Code2 className="w-4 h-4 mr-1" />
|
||||
Raw JSON
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="general"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
@@ -222,6 +280,24 @@ function SettingsDialogContent({
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="flex-1 overflow-hidden p-4 pt-4 m-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="calc(60vh - 120px)"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="general" className="p-4 m-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -252,7 +328,10 @@ function SettingsDialogContent({
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="w-4 h-4 mr-2" /> Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || (activeTab === 'raw' && !isRawJsonValid)}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...
|
||||
|
||||
Reference in New Issue
Block a user