feat: multi-format env var editor for deployment config
Replace simple key-value rows with EnvEditor component that supports editing variables as Table, Properties, YAML, or .env format. Switching views converts data seamlessly. Includes file import (drag-and-drop .properties/.yaml/.env) with auto-detect and merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
ui/package-lock.json
generated
19
ui/package-lock.json
generated
@@ -9,8 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.42",
|
"@cameleer/design-system": "^0.1.43",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -279,9 +281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cameleer/design-system": {
|
"node_modules/@cameleer/design-system": {
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.42/design-system-0.1.42.tgz",
|
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.43/design-system-0.1.43.tgz",
|
||||||
"integrity": "sha512-Cyy+2HsbBPLKRZaSGFxMUvIwI+g8ocdjcojFTGgtq5vrpE/8IYJLgxdtM9+eDoF2Zewk7MrBzfpNqjEYlQO3ng==",
|
"integrity": "sha512-eDCCFaS7DSRFRoWUWL1t256mdvIpL8XNbgRMgOWpZY7PWd162T1NESuzDn8PUAphC4jXtdLoHqQ+kihd+AA/hg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -1137,6 +1139,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-yaml": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -1581,7 +1590,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
@@ -2501,7 +2509,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer3-logo.svg','public/favicon.svg')\""
|
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer3-logo.svg','public/favicon.svg')\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.42",
|
"@cameleer/design-system": "^0.1.43",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
91
ui/src/components/EnvEditor.module.css
Normal file
91
ui/src/components/EnvEditor.module.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowKey {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowValue {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textEditor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textEditor:focus {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.importZone {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
245
ui/src/components/EnvEditor.tsx
Normal file
245
ui/src/components/EnvEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import { Button, Input, SegmentedTabs, FileInput } from '@cameleer/design-system';
|
||||||
|
import type { FileInputHandle } from '@cameleer/design-system';
|
||||||
|
import { Plus, Copy, Trash2, FileUp } from 'lucide-react';
|
||||||
|
import styles from './EnvEditor.module.css';
|
||||||
|
import {
|
||||||
|
type EnvEntry,
|
||||||
|
type Format,
|
||||||
|
parseText,
|
||||||
|
toText,
|
||||||
|
detectFormat,
|
||||||
|
} from './env-convert';
|
||||||
|
|
||||||
|
interface EnvEditorProps {
|
||||||
|
value: EnvEntry[];
|
||||||
|
onChange: (entries: EnvEntry[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_TABS = [
|
||||||
|
{ label: 'Table', value: 'table' as const },
|
||||||
|
{ label: 'Properties', value: 'properties' as const },
|
||||||
|
{ label: 'YAML', value: 'yaml' as const },
|
||||||
|
{ label: '.env', value: 'env' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ViewMode = 'table' | Format;
|
||||||
|
|
||||||
|
export function EnvEditor({ value, onChange, disabled }: EnvEditorProps) {
|
||||||
|
const [view, setView] = useState<ViewMode>('table');
|
||||||
|
const [textDraft, setTextDraft] = useState<string | null>(null);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const fileRef = useRef<FileInputHandle>(null);
|
||||||
|
|
||||||
|
// When switching views, convert between formats
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
const nextView = next as ViewMode;
|
||||||
|
|
||||||
|
// If leaving a text view, commit the draft
|
||||||
|
if (textDraft !== null && view !== 'table') {
|
||||||
|
const parsed = parseText(textDraft, view as Format);
|
||||||
|
onChange(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If entering a text view, generate text from current entries
|
||||||
|
if (nextView !== 'table') {
|
||||||
|
setTextDraft(toText(value, nextView as Format));
|
||||||
|
} else {
|
||||||
|
setTextDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(nextView);
|
||||||
|
},
|
||||||
|
[view, textDraft, value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Text for current format (memoized from entries when no draft)
|
||||||
|
const displayText = useMemo(() => {
|
||||||
|
if (textDraft !== null) return textDraft;
|
||||||
|
if (view === 'table') return '';
|
||||||
|
return toText(value, view as Format);
|
||||||
|
}, [textDraft, value, view]);
|
||||||
|
|
||||||
|
// Commit text on blur
|
||||||
|
const handleTextBlur = useCallback(() => {
|
||||||
|
if (textDraft !== null && view !== 'table') {
|
||||||
|
const parsed = parseText(textDraft, view as Format);
|
||||||
|
onChange(parsed);
|
||||||
|
}
|
||||||
|
}, [textDraft, view, onChange]);
|
||||||
|
|
||||||
|
// Table row handlers
|
||||||
|
const updateRow = useCallback(
|
||||||
|
(i: number, field: 'key' | 'value', val: string) => {
|
||||||
|
const next = [...value];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
onChange(next);
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteRow = useCallback(
|
||||||
|
(i: number) => onChange(value.filter((_, j) => j !== i)),
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addRow = useCallback(
|
||||||
|
() => onChange([...value, { key: '', value: '' }]),
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
const text = view === 'table' ? toText(value, 'env') : displayText;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}, [view, value, displayText]);
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onChange([]);
|
||||||
|
setTextDraft(view !== 'table' ? '' : null);
|
||||||
|
}, [view, onChange]);
|
||||||
|
|
||||||
|
// File import
|
||||||
|
const handleImportFile = useCallback(() => {
|
||||||
|
const file = fileRef.current?.file;
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const text = reader.result as string;
|
||||||
|
const format = detectFormat(text);
|
||||||
|
const parsed = parseText(text, format);
|
||||||
|
// Merge with existing (dedupe by key, new values win)
|
||||||
|
const existing = new Map(value.map((e) => [e.key, e]));
|
||||||
|
for (const entry of parsed) {
|
||||||
|
existing.set(entry.key, entry);
|
||||||
|
}
|
||||||
|
const merged = Array.from(existing.values());
|
||||||
|
onChange(merged);
|
||||||
|
fileRef.current?.clear();
|
||||||
|
setShowImport(false);
|
||||||
|
// If in text view, update the draft
|
||||||
|
if (view !== 'table') {
|
||||||
|
setTextDraft(toText(merged, view as Format));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}, [value, onChange, view]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<SegmentedTabs
|
||||||
|
tabs={FORMAT_TABS}
|
||||||
|
active={view}
|
||||||
|
onChange={handleViewChange}
|
||||||
|
/>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setShowImport(!showImport)}
|
||||||
|
title="Import from file"
|
||||||
|
>
|
||||||
|
<FileUp size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleClear}
|
||||||
|
title="Clear all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<div className={styles.importZone}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<FileInput
|
||||||
|
ref={fileRef}
|
||||||
|
accept=".properties,.yml,.yaml,.env,.cfg,.txt"
|
||||||
|
placeholder="Drop .properties, .yaml, or .env file"
|
||||||
|
icon={<FileUp size={16} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleImportFile}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'table' ? (
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{value.map((entry, i) => (
|
||||||
|
<div key={i} className={styles.row}>
|
||||||
|
<Input
|
||||||
|
disabled={disabled}
|
||||||
|
value={entry.key}
|
||||||
|
onChange={(e) => updateRow(i, 'key', e.target.value)}
|
||||||
|
className={styles.rowKey}
|
||||||
|
placeholder="KEY"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
disabled={disabled}
|
||||||
|
value={entry.value}
|
||||||
|
onChange={(e) => updateRow(i, 'value', e.target.value)}
|
||||||
|
className={styles.rowValue}
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.deleteBtn}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => deleteRow(i)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{value.length === 0 && !disabled && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
No variables configured. Add one or import from a file.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!disabled && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={addRow}>
|
||||||
|
<Plus size={14} style={{ marginRight: 4 }} />
|
||||||
|
Add Variable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className={styles.textEditor}
|
||||||
|
value={displayText}
|
||||||
|
onChange={(e) => setTextDraft(e.target.value)}
|
||||||
|
onBlur={handleTextBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={
|
||||||
|
view === 'yaml'
|
||||||
|
? 'spring:\n datasource:\n url: jdbc:...'
|
||||||
|
: view === 'properties'
|
||||||
|
? 'server.port=8080\nspring.datasource.url=jdbc:...'
|
||||||
|
: 'SERVER_PORT=8080\nSPRING_DATASOURCE_URL=jdbc:...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
ui/src/components/env-convert.ts
Normal file
130
ui/src/components/env-convert.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
export type EnvEntry = { key: string; value: string };
|
||||||
|
|
||||||
|
export type Format = 'env' | 'properties' | 'yaml';
|
||||||
|
|
||||||
|
/** Convert KEY=value env pairs to Spring-style properties (lowercase, _ -> .) */
|
||||||
|
export function envToProperties(entries: EnvEntry[]): string {
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.key.trim())
|
||||||
|
.map((e) => `${e.key.toLowerCase().replace(/_/g, '.')}=${e.value}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert KEY=value env pairs to nested YAML */
|
||||||
|
export function envToYaml(entries: EnvEntry[]): string {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (const { key, value } of entries) {
|
||||||
|
if (!key.trim()) continue;
|
||||||
|
const path = key.toLowerCase().replace(/_/g, '.').split('.');
|
||||||
|
let cur: Record<string, unknown> = obj;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
if (typeof cur[path[i]] !== 'object' || cur[path[i]] === null) {
|
||||||
|
cur[path[i]] = {};
|
||||||
|
}
|
||||||
|
cur = cur[path[i]] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
cur[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
return yaml.dump(obj, { lineWidth: -1, noRefs: true }).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert KEY=value env pairs to .env format */
|
||||||
|
export function envToEnvText(entries: EnvEntry[]): string {
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.key.trim())
|
||||||
|
.map((e) => `${e.key}=${e.value}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a key to UPPER_SNAKE_CASE */
|
||||||
|
function toEnvKey(key: string): string {
|
||||||
|
return key.replace(/[.\-]/g, '_').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse .properties text to env entries */
|
||||||
|
export function parseProperties(text: string): EnvEntry[] {
|
||||||
|
const entries: EnvEntry[] = [];
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
entries.push({
|
||||||
|
key: toEnvKey(trimmed.slice(0, eq).trim()),
|
||||||
|
value: trimmed.slice(eq + 1).trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse YAML text to env entries (flattens nested keys) */
|
||||||
|
export function parseYaml(text: string): EnvEntry[] {
|
||||||
|
const doc = yaml.load(text);
|
||||||
|
if (!doc || typeof doc !== 'object') return [];
|
||||||
|
const entries: EnvEntry[] = [];
|
||||||
|
function walk(obj: Record<string, unknown>, prefix: string) {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const path = prefix ? `${prefix}_${k}` : k;
|
||||||
|
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
walk(v as Record<string, unknown>, path);
|
||||||
|
} else {
|
||||||
|
entries.push({ key: toEnvKey(path), value: String(v ?? '') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc as Record<string, unknown>, '');
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse .env text to env entries */
|
||||||
|
export function parseEnvText(text: string): EnvEntry[] {
|
||||||
|
const entries: EnvEntry[] = [];
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
entries.push({
|
||||||
|
key: trimmed.slice(0, eq).trim(),
|
||||||
|
value: trimmed.slice(eq + 1).trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-detect format from text content */
|
||||||
|
export function detectFormat(text: string): Format {
|
||||||
|
const lines = text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
|
||||||
|
if (lines.length === 0) return 'env';
|
||||||
|
|
||||||
|
// YAML: has indented lines or lines ending with : (no =)
|
||||||
|
const hasIndent = lines.some((l) => /^\s{2,}\S/.test(l));
|
||||||
|
const hasColonNoEq = lines.some((l) => l.includes(':') && !l.includes('='));
|
||||||
|
if (hasIndent || (hasColonNoEq && !lines.some((l) => /^[A-Z_]+=/.test(l)))) return 'yaml';
|
||||||
|
|
||||||
|
// Properties: lowercase keys with dots
|
||||||
|
const hasPropertyKeys = lines.some((l) => /^[a-z][a-z0-9.\-]+=/.test(l));
|
||||||
|
if (hasPropertyKeys) return 'properties';
|
||||||
|
|
||||||
|
return 'env';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse text in a given format to env entries */
|
||||||
|
export function parseText(text: string, format: Format): EnvEntry[] {
|
||||||
|
switch (format) {
|
||||||
|
case 'properties': return parseProperties(text);
|
||||||
|
case 'yaml': return parseYaml(text);
|
||||||
|
case 'env': return parseEnvText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert env entries to text in a given format */
|
||||||
|
export function toText(entries: EnvEntry[], format: Format): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'properties': return envToProperties(entries);
|
||||||
|
case 'yaml': return envToYaml(entries);
|
||||||
|
case 'env': return envToEnvText(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { EnvEditor } from '../../components/EnvEditor';
|
||||||
import { useEnvironmentStore } from '../../api/environment-store';
|
import { useEnvironmentStore } from '../../api/environment-store';
|
||||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||||
import {
|
import {
|
||||||
@@ -366,19 +367,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
|||||||
{configTab === 'variables' && (
|
{configTab === 'variables' && (
|
||||||
<div className={sectionStyles.section}>
|
<div className={sectionStyles.section}>
|
||||||
<SectionHeader>Variables</SectionHeader>
|
<SectionHeader>Variables</SectionHeader>
|
||||||
{envVars.map((v, i) => (
|
<EnvEditor value={envVars} onChange={setEnvVars} disabled={busy} />
|
||||||
<div key={i} className={styles.envVarRow}>
|
|
||||||
<Input disabled={busy} value={v.key} onChange={(e) => {
|
|
||||||
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
|
|
||||||
}} className={styles.envVarKey} placeholder="KEY" />
|
|
||||||
<Input disabled={busy} value={v.value} onChange={(e) => {
|
|
||||||
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
|
|
||||||
}} className={styles.envVarValue} placeholder="value" />
|
|
||||||
<button className={styles.envVarDelete} disabled={busy}
|
|
||||||
onClick={() => !busy && setEnvVars(envVars.filter((_, j) => j !== i))}>×</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1015,22 +1004,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
|||||||
{configTab === 'variables' && (
|
{configTab === 'variables' && (
|
||||||
<div className={sectionStyles.section}>
|
<div className={sectionStyles.section}>
|
||||||
<SectionHeader>Variables</SectionHeader>
|
<SectionHeader>Variables</SectionHeader>
|
||||||
{envVars.map((v, i) => (
|
<EnvEditor value={envVars} onChange={setEnvVars} disabled={!editing} />
|
||||||
<div key={i} className={styles.envVarRow}>
|
|
||||||
<Input disabled={!editing} value={v.key} onChange={(e) => {
|
|
||||||
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
|
|
||||||
}} className={styles.envVarKey} placeholder="KEY" />
|
|
||||||
<Input disabled={!editing} value={v.value} onChange={(e) => {
|
|
||||||
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
|
|
||||||
}} className={styles.envVarValue} placeholder="value" />
|
|
||||||
<button className={styles.envVarDelete} disabled={!editing}
|
|
||||||
onClick={() => editing && setEnvVars(envVars.filter((_, j) => j !== i))}>×</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{editing && (
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
|
||||||
)}
|
|
||||||
{envVars.length === 0 && !editing && <EmptyState title="No variables" description="No environment variables configured." />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user