diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..7df5b046
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run VS Code Extension",
+ "type": "extensionHost",
+ "request": "launch",
+ "args": ["--extensionDevelopmentPath=${workspaceFolder}/apps/vscode"],
+ "outFiles": ["${workspaceFolder}/apps/vscode/dist/**/*.js"],
+ "preLaunchTask": "build:vscode"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000..af96e80d
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,14 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build:vscode",
+ "type": "shell",
+ "command": "npm run build",
+ "options": { "cwd": "${workspaceFolder}/apps/vscode" },
+ "group": { "kind": "build", "isDefault": true },
+ "presentation": { "reveal": "always" },
+ "problemMatcher": []
+ }
+ ]
+}
diff --git a/apps/extension-webview/.gitignore b/apps/extension-webview/.gitignore
new file mode 100644
index 00000000..99550615
--- /dev/null
+++ b/apps/extension-webview/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+df/
diff --git a/apps/extension-webview/ai-agent.html b/apps/extension-webview/ai-agent.html
new file mode 100644
index 00000000..09e10d63
--- /dev/null
+++ b/apps/extension-webview/ai-agent.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vespertide AI Agent
+
+
+
+
+
+
diff --git a/apps/extension-webview/devup.json b/apps/extension-webview/devup.json
new file mode 100644
index 00000000..e436dc09
--- /dev/null
+++ b/apps/extension-webview/devup.json
@@ -0,0 +1,106 @@
+{
+ "theme": {
+ "colors": {
+ "dark": {
+ "fg": "var(--vscode-foreground, #cccccc)",
+ "sidebarBg": "var(--vscode-sideBar-background, #252526)",
+ "editorBg": "var(--vscode-editor-background, #1e1e1e)",
+ "tabsBg": "var(--vscode-editorGroupHeader-tabsBackground, #2d2d2d)",
+ "border": "var(--vscode-panel-border, rgba(255,255,255,0.1))",
+ "focusBorder": "var(--vscode-focusBorder, #007acc)",
+ "inactiveFg": "var(--vscode-tab-inactiveForeground, #8e8e8e)",
+ "errBg": "var(--vscode-inputValidation-errorBackground, rgba(90,29,29,0.9))",
+ "errFg": "var(--vscode-inputValidation-errorForeground, #f48771)",
+ "btnBg": "var(--vscode-button-background, #0e639c)",
+ "btnFg": "var(--vscode-button-foreground, #fff)",
+ "inputBg": "var(--vscode-input-background, rgba(255,255,255,0.06))",
+ "inputBorder": "var(--vscode-input-border, rgba(255,255,255,0.15))",
+ "widgetBg": "var(--vscode-editorWidget-background, rgba(255,255,255,0.05))",
+ "editorFg": "var(--vscode-editor-foreground, #d4d4d4)",
+ "editorFont": "var(--vscode-editor-font-family, Consolas, 'Courier New', monospace)",
+ "appFont": "var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)",
+ "chartsGreen": "var(--vscode-charts-green, #4caf50)",
+ "btnSecBg": "var(--vscode-button-secondaryBackground, #3a3a3a)",
+ "scrollbarThumb": "rgba(121,121,121,0.4)",
+ "scrollbarThumbHover": "rgba(121,121,121,0.7)",
+ "diffBg": "#1e1e1e",
+ "diffText": "rgba(212, 212, 212, 0.93)",
+ "diffLinenum": "rgba(255, 255, 255, 0.25)",
+ "diffAddBg": "rgba(34, 197, 94, 0.1)",
+ "diffAddBorder": "rgba(34, 197, 94, 0.55)",
+ "diffAddText": "#86efac",
+ "diffAddSign": "#4ade80",
+ "diffRmBg": "rgba(239, 68, 68, 0.1)",
+ "diffRmBorder": "rgba(239, 68, 68, 0.55)",
+ "diffRmText": "#fca5a5",
+ "diffRmSign": "#f87171",
+ "diffSidebarBg": "#252526",
+ "diffSidebarText": "#cccccc",
+ "diffHeaderBg": "#2d2d2d",
+ "diffHeaderText": "#cccccc",
+ "nodeBg": "#1a1b2e",
+ "nodeBorder": "rgba(255, 255, 255, 0.11)",
+ "nodeFieldDivider": "rgba(255, 255, 255, 0.055)",
+ "nodeShadow": "0 4px 20px rgba(0, 0, 0, 0.5), 0 0 0 0.5px rgba(255, 255, 255, 0.07)",
+ "nodeTypeColor": "rgba(180, 185, 220, 0.5)",
+ "nodeText": "rgba(220, 222, 240, 0.92)",
+ "nodeTextDim": "rgba(180, 185, 220, 0.5)",
+ "canvasDot": "rgba(255, 255, 255, 0.055)",
+ "edgeColor": "rgba(99, 102, 241, 0.7)",
+ "edgeArrow": "rgba(99, 102, 241, 0.85)",
+ "navbarBg": "rgba(18, 18, 30, 0.94)",
+ "navbarBorder": "rgba(255, 255, 255, 0.1)"
+ },
+ "light": {
+ "fg": "#1a1a2e",
+ "sidebarBg": "#e9eaf2",
+ "editorBg": "#f1f2f7",
+ "tabsBg": "#d8daea",
+ "border": "rgba(0, 0, 0, 0.12)",
+ "focusBorder": "#4f46e5",
+ "inactiveFg": "#5a5c72",
+ "errBg": "rgba(180, 30, 30, 0.08)",
+ "errFg": "#a80000",
+ "btnBg": "#4f46e5",
+ "btnFg": "#ffffff",
+ "inputBg": "var(--vscode-input-background, rgba(255,255,255,0.06))",
+ "inputBorder": "rgba(0, 0, 0, 0.18)",
+ "widgetBg": "#e1e3ef",
+ "editorFg": "#1a1a2e",
+ "editorFont": "var(--vscode-editor-font-family, Consolas, 'Courier New', monospace)",
+ "appFont": "var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)",
+ "chartsGreen": "var(--vscode-charts-green, #4caf50)",
+ "btnSecBg": "var(--vscode-button-secondaryBackground, #3a3a3a)",
+ "scrollbarThumb": "rgba(0, 0, 0, 0.18)",
+ "scrollbarThumbHover": "rgba(0, 0, 0, 0.32)",
+ "diffBg": "#f8f9fc",
+ "diffText": "#1a1a2e",
+ "diffLinenum": "rgba(0, 0, 0, 0.3)",
+ "diffAddBg": "rgba(22, 163, 74, 0.08)",
+ "diffAddBorder": "rgba(22, 163, 74, 0.5)",
+ "diffAddText": "#14532d",
+ "diffAddSign": "#16a34a",
+ "diffRmBg": "rgba(220, 38, 38, 0.07)",
+ "diffRmBorder": "rgba(220, 38, 38, 0.45)",
+ "diffRmText": "#7f1d1d",
+ "diffRmSign": "#dc2626",
+ "diffSidebarBg": "#e9eaf2",
+ "diffSidebarText": "#1a1a2e",
+ "diffHeaderBg": "#d8daea",
+ "diffHeaderText": "#1a1a2e",
+ "nodeBg": "#ffffff",
+ "nodeBorder": "rgba(0, 0, 0, 0.1)",
+ "nodeFieldDivider": "rgba(0, 0, 0, 0.065)",
+ "nodeShadow": "0 2px 16px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.07)",
+ "nodeTypeColor": "rgba(80, 85, 115, 0.6)",
+ "nodeText": "#111827",
+ "nodeTextDim": "rgba(55, 60, 90, 0.68)",
+ "canvasDot": "rgba(0, 0, 0, 0.075)",
+ "edgeColor": "rgba(79, 70, 229, 0.6)",
+ "edgeArrow": "rgba(79, 70, 229, 0.8)",
+ "navbarBg": "rgba(255, 255, 255, 0.96)",
+ "navbarBorder": "rgba(0, 0, 0, 0.1)"
+ }
+ }
+ }
+}
diff --git a/apps/extension-webview/index.html b/apps/extension-webview/index.html
new file mode 100644
index 00000000..d6e691fb
--- /dev/null
+++ b/apps/extension-webview/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vespertide
+
+
+
+
+
+
diff --git a/apps/extension-webview/package.json b/apps/extension-webview/package.json
new file mode 100644
index 00000000..3db30399
--- /dev/null
+++ b/apps/extension-webview/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "extension-webview",
+ "private": true,
+ "version": "0.1.61",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@devup-ui/react": "^1.0.36",
+ "@devup-ui/vite-plugin": "^1.0.59",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.3.0",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/apps/extension-webview/src/AIAgentApp.tsx b/apps/extension-webview/src/AIAgentApp.tsx
new file mode 100644
index 00000000..63008506
--- /dev/null
+++ b/apps/extension-webview/src/AIAgentApp.tsx
@@ -0,0 +1,759 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Box, Flex, VStack, setTheme as setDevupTheme } from '@devup-ui/react';
+import { postMessage, onMessage } from './vscode';
+import type { ConnectorService, ConnectorStatus, ChatMessage } from './vscode';
+
+interface ConnectorMeta {
+ service: ConnectorService;
+ label: string;
+ icon: string;
+ subtitle: string;
+ isAI: boolean;
+ keyLabel: string;
+ keyPlaceholder: string;
+ getKeyUrl: string;
+ getKeyLabel: string;
+ steps: string[];
+}
+
+const CONNECTORS: ConnectorMeta[] = [
+ {
+ service: 'claude', label: 'Claude', icon: 'π€', subtitle: 'Anthropic', isAI: true,
+ keyLabel: 'API Key', keyPlaceholder: 'sk-ant-api03-...',
+ getKeyUrl: 'https://console.anthropic.com/settings/keys',
+ getKeyLabel: 'API ν€ λ°κΈ β',
+ steps: ['console.anthropic.com λ‘κ·ΈμΈ', 'Settings β API Keys', 'Create Key ν΄λ¦ ν 볡μ¬'],
+ },
+ {
+ service: 'openai', label: 'OpenAI / GPT', icon: 'π§ ', subtitle: 'OpenAI', isAI: true,
+ keyLabel: 'API Key', keyPlaceholder: 'sk-proj-...',
+ getKeyUrl: 'https://platform.openai.com/api-keys',
+ getKeyLabel: 'API ν€ λ°κΈ β',
+ steps: ['platform.openai.com λ‘κ·ΈμΈ', 'API Keys λ©λ΄ μ ν', 'Create new secret key ν΄λ¦ ν 볡μ¬'],
+ },
+ {
+ service: 'gemini', label: 'Gemini', icon: 'β¦', subtitle: 'Google', isAI: true,
+ keyLabel: 'API Key', keyPlaceholder: 'AIzaSy...',
+ getKeyUrl: 'https://aistudio.google.com/app/apikey',
+ getKeyLabel: 'API ν€ λ°κΈ β',
+ steps: ['aistudio.google.com λ°©λ¬Έ', 'Get API Key ν΄λ¦', 'Create API key ν 볡μ¬'],
+ },
+ {
+ service: 'ollama', label: 'Ollama', icon: 'π¦', subtitle: 'Local Β· μμ 무λ£', isAI: true,
+ keyLabel: '', keyPlaceholder: '',
+ getKeyUrl: 'https://ollama.com/download',
+ getKeyLabel: 'Ollama λ€μ΄λ‘λ β',
+ steps: ['ollama.comμμ μ€μΉ', 'ν°λ―Έλμμ λͺ¨λΈ pull (μ: ollama pull qwen2.5-coder)', 'Ollamaκ° λ°±κ·ΈλΌμ΄λμμ μ€ν μ€μΈμ§ νμΈ'],
+ },
+ {
+ service: 'slack', label: 'Slack', icon: 'π¬', subtitle: 'Workspace', isAI: false,
+ keyLabel: 'Webhook URL', keyPlaceholder: 'https://hooks.slack.com/services/...',
+ getKeyUrl: 'https://api.slack.com/apps',
+ getKeyLabel: 'Webhook μμ± β',
+ steps: ['api.slack.com/apps β Create App', 'Incoming Webhooks νμ±ν', 'Add New Webhook to Workspace', 'Webhook URL 볡μ¬'],
+ },
+ {
+ service: 'notion', label: 'Notion', icon: 'π', subtitle: 'Workspace', isAI: false,
+ keyLabel: 'Integration Token', keyPlaceholder: 'secret_...',
+ getKeyUrl: 'https://www.notion.so/my-integrations',
+ getKeyLabel: 'Integration μμ± β',
+ steps: ['notion.so/my-integrations λ°©λ¬Έ', 'New integration μμ±', 'Internal Integration Token 볡μ¬'],
+ },
+ {
+ service: 'jira', label: 'Jira', icon: 'π―', subtitle: 'Atlassian', isAI: false,
+ keyLabel: 'Email:API Token', keyPlaceholder: 'user@example.com:ATATT...',
+ getKeyUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
+ getKeyLabel: 'API ν ν° λ°κΈ β',
+ steps: ['Atlassian κ³μ λ‘κ·ΈμΈ', 'API tokens β Create API token', '"μ΄λ©μΌ:ν ν°" νμμΌλ‘ μ
λ ₯\nμ) user@example.com:ATATT3xFfGF0...'],
+ },
+];
+
+type View = 'chat' | 'connectors';
+
+export default function AIAgentApp() {
+ const [theme, setTheme] = useState<'dark' | 'light'>('dark');
+ const [view, setView] = useState('chat');
+ const [connectors, setConnectors] = useState([]);
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [activeAI, setActiveAI] = useState('claude');
+ const [configuring, setConfiguring] = useState(null);
+ const [keyInputs, setKeyInputs] = useState>>({});
+ const [saving, setSaving] = useState(null);
+ const [showKey, setShowKey] = useState>>({});
+ const [ollamaStatus, setOllamaStatus] = useState<{ available: boolean; models: string[] } | null>(null);
+ const [ollamaChecking, setOllamaChecking] = useState(false);
+ const [toolActivity, setToolActivity] = useState<{ tool: string; detail: string; count: number } | null>(null);
+ const endRef = useRef(null) as React.RefObject;
+
+ const statusMap = Object.fromEntries(
+ connectors.map((c) => [c.service, c.connected])
+ ) as Record;
+
+ const connectedAIs = CONNECTORS.filter((c) => c.isAI && statusMap[c.service]);
+
+ useEffect(() => { postMessage({ type: 'connector_load' }); }, []);
+
+ useEffect(() => {
+ return onMessage((msg) => {
+ if (msg.type === 'connector_status') setConnectors(msg.connectors);
+ if (msg.type === 'ai_response' && msg.done) {
+ setMessages((prev) => [...prev, { role: 'assistant', content: msg.content }]);
+ setLoading(false);
+ setToolActivity(null);
+ }
+ if (msg.type === 'ai_cancelled') {
+ setLoading(false);
+ setToolActivity(null);
+ }
+ if (msg.type === 'ai_tool_call') {
+ setToolActivity((prev) => ({
+ tool: msg.tool,
+ detail: msg.detail,
+ count: prev?.tool === msg.tool ? prev.count + 1 : 1,
+ }));
+ }
+ if (msg.type === 'ollama_status') {
+ setOllamaStatus({ available: msg.available, models: msg.models });
+ setOllamaChecking(false);
+ }
+ if (msg.type === 'error') {
+ setMessages((prev) => [...prev, { role: 'assistant', content: `μ€λ₯: ${msg.message}` }]);
+ setLoading(false);
+ setToolActivity(null);
+ }
+ });
+ }, []);
+
+ useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => { setDevupTheme(theme); }, [theme]);
+
+ // νμ¬ activeAIκ° μ°κ²°λμ΄ μμ§ μμΌλ©΄ μ°κ²°λ 첫 AIλ‘ μλ μ ννλ€.
+ useEffect(() => {
+ if (connectors.length === 0) return;
+ const connectedServices = new Set(connectors.filter((c) => c.connected).map((c) => c.service));
+ if (connectedServices.has(activeAI)) return;
+ const firstConnected = CONNECTORS.find((c) => c.isAI && connectedServices.has(c.service));
+ if (firstConnected) setActiveAI(firstConnected.service);
+ }, [connectors, activeAI]);
+
+ function openBrowser(url: string) { postMessage({ type: 'open_external', url }); }
+
+ function checkOllama() {
+ setOllamaChecking(true);
+ postMessage({ type: 'ollama_check' });
+ }
+
+ function saveKey(service: ConnectorService) {
+ const key = keyInputs[service]?.trim();
+ if (!key) return;
+ setSaving(service);
+ postMessage({ type: 'connector_save', service, key });
+ setKeyInputs((prev) => ({ ...prev, [service]: '' }));
+ setTimeout(() => { setSaving(null); setConfiguring(null); }, 1200);
+ }
+
+ function disconnect(service: ConnectorService) {
+ postMessage({ type: 'connector_delete', service });
+ // activeAIκ° λ°©κΈ λκΈ΄ μλΉμ€λ₯Ό κ°λ¦¬ν€λ κ²½μ°, μ°κ²° μν κ°±μ ν
+ // μ useEffectκ° μ°κ²°λ λ€λ₯Έ AIλ‘ μλ μ ννλ€.
+ }
+
+ function cancelChat() {
+ postMessage({ type: 'ai_chat_cancel' });
+ }
+
+ function sendChat() {
+ const text = input.trim();
+ if (!text || loading) return;
+ setInput('');
+ if (connectedAIs.length === 0) {
+ setMessages((prev) => [
+ ...prev,
+ { role: 'user', content: text },
+ { role: 'assistant', content: 'μ€λ₯Έμͺ½ Connectors νμμ AI μλΉμ€λ₯Ό λ¨Όμ μ°κ²°ν΄μ£ΌμΈμ.' },
+ ]);
+ return;
+ }
+ const newMessages: ChatMessage[] = [...messages, { role: 'user', content: text }];
+ setMessages(newMessages);
+ setLoading(true);
+ setToolActivity(null);
+ postMessage({ type: 'ai_chat', service: activeAI, messages: newMessages, context: '' });
+ }
+
+ return (
+
+ {/* ββ Header / Tab bar ββ */}
+
+
+ π€
+ AI Agent
+
+
+
+ {(['chat', 'connectors'] as View[]).map((v) => (
+ setView(v)}
+ py="8px"
+ px="16px"
+ border="none"
+ cursor="pointer"
+ fontFamily="inherit"
+ bg="transparent"
+ borderBottom={view === v ? '2px solid $focusBorder' : '2px solid transparent'}
+ color={view === v ? '$nodeText' : '$nodeTextDim'}
+ fontSize="12px"
+ fontWeight={view === v ? 600 : 400}
+ >
+ {v === 'chat' ? 'Chat' : 'Connectors'}
+ {v === 'connectors' && connectedAIs.length > 0 && (
+ {connectedAIs.length}
+ )}
+
+ ))}
+
+
+ {view === 'chat' && connectedAIs.length > 0 && (
+
+ {connectedAIs.map((ai) => (
+ setActiveAI(ai.service)}
+ py="4px"
+ px="10px"
+ borderRadius="10px"
+ fontSize="11px"
+ cursor="pointer"
+ fontFamily="inherit"
+ bg={activeAI === ai.service ? 'rgba(99,102,241,0.28)' : 'transparent'}
+ border={`1px solid ${activeAI === ai.service ? 'rgba(99,102,241,0.6)' : 'var(--nodeBorder)'}`}
+ color={activeAI === ai.service ? '#818cf8' : '$nodeTextDim'}
+ >
+ {ai.icon} {ai.label}
+
+ ))}
+
+ )}
+
+ setTheme((t) => t === 'dark' ? 'light' : 'dark')}
+ title={theme === 'dark' ? 'λΌμ΄νΈ λͺ¨λλ‘ μ ν' : 'λ€ν¬ λͺ¨λλ‘ μ ν'}
+ w="36px"
+ flexShrink={0}
+ border="none"
+ borderBottom="2px solid transparent"
+ bg="transparent"
+ color="$nodeTextDim"
+ cursor="pointer"
+ fontFamily="inherit"
+ display="flex"
+ alignItems="center"
+ justifyContent="center"
+ >
+ {theme === 'dark'
+ ?
+ :
+ }
+
+
+
+ {/* ββ Chat view ββ */}
+ {view === 'chat' && (
+
+
+ {messages.length === 0 && (
+
+ π€
+ Vespertide AI Agent
+ {connectedAIs.length === 0 ? (
+
+ AIκ° μ°κ²°λμ§ μμμ΅λλ€.
+
+ setView('connectors')}
+ mt="10px"
+ py="6px"
+ px="14px"
+ borderRadius="6px"
+ border="none"
+ fontFamily="inherit"
+ bg="rgba(99,102,241,0.2)"
+ color="#818cf8"
+ cursor="pointer"
+ fontSize="12px"
+ >
+ Connectorsμμ AI μΆκ°νκΈ° β
+
+
+ ) : (
+
+ "Post ν
μ΄λΈμ μΈλ±μ€λ₯Ό μΆκ°νλ©΄ μ’μκΉ?"
+ "μ΄ μ€ν€λ§μ μ κ·ν μμ€μ κ²ν ν΄μ€"
+ "N+1 쿼리λ₯Ό νΌνλ €λ©΄ μ΄λ»κ² μ€κ³ν΄μΌ ν κΉ?"
+
+ )}
+
+ )}
+
+ {messages.map((m, i) => {
+ const isUser = m.role === 'user';
+ return (
+
+ {!isUser && (
+ π€
+ )}
+
+ {m.content}
+
+
+ );
+ })}
+
+ {loading && (
+
+ π€
+
+ {toolActivity ? `π§ ${toolActivity.detail}` : 'μλ΅ μμ± μ€...'}
+
+
+ )}
+
+
+
+
+ ) => setInput(e.target.value)}
+ onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } }}
+ placeholder="μ§λ¬ΈνμΈμ... (Shift+Enter: μ€λ°κΏ)"
+ disabled={loading}
+ rows={1}
+ flex={1}
+ py="8px"
+ px="12px"
+ borderRadius="6px"
+ fontSize="12px"
+ bg={theme === 'light' ? '#ffffff' : '$inputBg'}
+ border={`1px solid ${theme === 'light' ? 'rgba(0,0,0,0.2)' : 'var(--inputBorder)'}`}
+ color="$nodeText"
+ outline="none"
+ resize="none"
+ lineHeight={1.6}
+ fontFamily="inherit"
+ maxHeight="120px"
+ overflowY="auto"
+ />
+ {loading ? 'μ·¨μ' : 'μ μ‘'}
+
+
+ )}
+
+ {/* ββ Connectors view ββ */}
+ {view === 'connectors' && (
+
+
+ {CONNECTORS.filter((c) => c.isAI).map((meta) => (
+ { setActiveAI(meta.service); setView('chat'); }}
+ onConfigure={() => {
+ const next = configuring === meta.service ? null : meta.service;
+ setConfiguring(next);
+ if (next === 'ollama') checkOllama();
+ }}
+ onKeyChange={(v) => setKeyInputs((p) => ({ ...p, [meta.service]: v }))}
+ onToggleShow={() => setShowKey((p) => ({ ...p, [meta.service]: !p[meta.service] }))}
+ onSave={() => saveKey(meta.service)}
+ onDisconnect={() => disconnect(meta.service)}
+ onOpenBrowser={() => openBrowser(meta.getKeyUrl)}
+ />
+ ))}
+
+
+ {CONNECTORS.filter((c) => !c.isAI).map((meta) => (
+ {}}
+ onConfigure={() => setConfiguring(configuring === meta.service ? null : meta.service)}
+ onKeyChange={(v) => setKeyInputs((p) => ({ ...p, [meta.service]: v }))}
+ onToggleShow={() => setShowKey((p) => ({ ...p, [meta.service]: !p[meta.service] }))}
+ onSave={() => saveKey(meta.service)}
+ onDisconnect={() => disconnect(meta.service)}
+ onOpenBrowser={() => openBrowser(meta.getKeyUrl)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+// ββ Sub-components ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function SectionLabel({ label, mt }: { label: string; mt?: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function ConnectorItem({
+ meta, connected, isActive, configuring,
+ keyValue, saving, showKey, theme,
+ ollamaStatus, ollamaChecking,
+ onSetActive, onConfigure, onKeyChange, onToggleShow, onSave, onDisconnect, onOpenBrowser,
+}: {
+ meta: ConnectorMeta; connected: boolean; isActive: boolean; configuring: boolean;
+ keyValue: string; saving: boolean; showKey: boolean; theme: 'dark' | 'light';
+ ollamaStatus?: { available: boolean; models: string[] } | null;
+ ollamaChecking?: boolean;
+ onSetActive: () => void; onConfigure: () => void;
+ onKeyChange: (v: string) => void; onToggleShow: () => void;
+ onSave: () => void; onDisconnect: () => void; onOpenBrowser: () => void;
+}) {
+ const isOllama = meta.service === 'ollama';
+ return (
+
+
+ {meta.icon}
+
+
+
+ {meta.label}
+ {isActive && (
+ μ¬μ© μ€
+ )}
+
+
+ {connected
+ ? β μ°κ²°λ¨
+ : {meta.subtitle}
+ }
+
+
+
+
+ {connected && meta.isAI && !isActive && (
+ μ¬μ©
+ )}
+ {connected ? (
+
+ {configuring ? 'λ«κΈ°' : 'μ€μ '}
+
+ ) : (
+
+ {configuring ? 'λ«κΈ°' : '+ μ°κ²°'}
+
+ )}
+
+
+
+ {configuring && (
+
+
+
+ ν€ λ°κΈ λ°©λ²
+
+ {meta.steps.map((step, i) => (
+
+ {i + 1}.
+ {step}
+
+ ))}
+
+ π {meta.getKeyLabel}
+
+
+
+ {isOllama ? (
+
+ ) : (
+ <>
+
+ {meta.keyLabel}
+
+
+
+ ) => onKeyChange(e.target.value)}
+ placeholder={meta.keyPlaceholder}
+ onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && onSave()}
+ flex={1}
+ py="8px"
+ px="10px"
+ borderRadius="4px"
+ fontSize="12px"
+ fontFamily="inherit"
+ bg={theme === 'light' ? '#ffffff' : '$inputBg'}
+ border={`1px solid ${theme === 'light' ? 'rgba(0,0,0,0.2)' : 'var(--inputBorder)'}`}
+ color="$nodeText"
+ outline="none"
+ />
+
+ {showKey ? 'μ¨κΉ' : 'νμ'}
+
+
+ >
+ )}
+
+
+
+ {saving ? 'μ μ₯ μ€...' : connected ? 'Update' : 'Connect'}
+
+ {connected && (
+ μ°κ²° ν΄μ
+ )}
+
+
+ )}
+
+ );
+}
+
+function OllamaConfigPanel({
+ status, checking, selected, onSelect,
+}: {
+ status: { available: boolean; models: string[] } | null | undefined;
+ checking: boolean | undefined;
+ selected: string;
+ onSelect: (model: string) => void;
+}) {
+ if (checking || !status) {
+ return (
+
+ Ollama μν νμΈ μ€...
+
+ );
+ }
+
+ if (!status.available) {
+ return (
+
+ Ollamaκ° μ€μΉλμ΄ μμ§ μκ±°λ μ€ν μ€μ΄ μλλλ€. μ μλ΄λ₯Ό λ°λΌ μ€μΉΒ·μ€νν λ€ λ€μ μ΄μ΄μ£ΌμΈμ.
+
+ );
+ }
+
+ if (status.models.length === 0) {
+ return (
+
+ Ollamaλ μ€ν μ€μ΄μ§λ§ pullλ λͺ¨λΈμ΄ μμ΅λλ€.
+ ν°λ―Έλμμ μ: ollama pull qwen2.5-coder μ€ν ν λ€μ μ΄μ΄μ£ΌμΈμ.
+
+ );
+ }
+
+ return (
+
+
+ μ¬μ©ν λͺ¨λΈ
+
+
+ {status.models.map((m) => (
+ onSelect(m)}
+ py="6px"
+ px="10px"
+ borderRadius="4px"
+ fontSize="12px"
+ textAlign="left"
+ cursor="pointer"
+ fontFamily="inherit"
+ bg={selected === m ? 'rgba(99,102,241,0.2)' : 'transparent'}
+ border={`1px solid ${selected === m ? 'rgba(99,102,241,0.5)' : 'var(--nodeBorder)'}`}
+ color="$nodeText"
+ >
+ {m}
+
+ ))}
+
+
+ );
+}
+
+function BtnBox({
+ variant, children, onClick, disabled, px,
+}: {
+ variant: 'primary' | 'ghost' | 'active' | 'danger';
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ px?: string;
+}) {
+ const styles = {
+ primary: { bg: '$btnBg', color: '$btnFg', border: 'none' },
+ ghost: { bg: 'transparent', color: '$nodeText', border: '1px solid $nodeBorder' },
+ active: { bg: 'rgba(99,102,241,0.2)', color: '#818cf8', border: '1px solid rgba(99,102,241,0.4)' },
+ danger: { bg: 'rgba(239,68,68,0.12)', color: '$diffRmSign', border: '1px solid rgba(239,68,68,0.2)' },
+ }[variant];
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/extension-webview/src/App.tsx b/apps/extension-webview/src/App.tsx
new file mode 100644
index 00000000..d0225dc4
--- /dev/null
+++ b/apps/extension-webview/src/App.tsx
@@ -0,0 +1,154 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Flex, VStack, setTheme } from '@devup-ui/react';
+import { onMessage } from './vscode';
+import type { HostMessage, OrmType, Schema } from './vscode';
+import OrmEditor from './tabs/OrmEditor';
+import MigrationDiff from './tabs/MigrationDiff';
+import Export from './tabs/Export';
+import defaultSchemasJson from './defaultSchemas.json';
+
+type Tab = 'editor' | 'migration' | 'export';
+
+export const DEFAULT_SCHEMAS: Record = defaultSchemasJson;
+
+const TABS: { id: Tab; label: string }[] = [
+ { id: 'editor', label: 'ORM Editor' },
+ { id: 'migration', label: 'Migration' },
+ { id: 'export', label: 'Export' },
+];
+
+export interface AppState {
+ ormSource: string;
+ ormType: OrmType;
+ svg: string;
+ schema: Schema;
+ postgres: string;
+ mysql: string;
+ sqlite: string;
+ error: string | null;
+ theme: 'dark' | 'light';
+}
+
+const INITIAL: AppState = {
+ ormSource: DEFAULT_SCHEMAS.prisma,
+ ormType: 'prisma',
+ svg: '',
+ schema: {},
+ postgres: '',
+ mysql: '',
+ sqlite: '',
+ error: null,
+ theme: 'dark',
+};
+
+export default function App() {
+ const [tab, setTab] = useState('editor');
+ const [state, setState] = useState(INITIAL);
+
+ useEffect(() => {
+ return onMessage((msg: HostMessage) => {
+ setState((prev) => {
+ switch (msg.type) {
+ case 'erd_updated':
+ return { ...prev, svg: msg.svg, error: null };
+ case 'migration_updated':
+ return { ...prev, postgres: msg.postgres, mysql: msg.mysql, sqlite: msg.sqlite, error: null };
+ case 'export_done':
+ return { ...prev, error: null };
+ case 'error':
+ return { ...prev, error: msg.message };
+ default:
+ return prev;
+ }
+ });
+ });
+ }, []);
+
+ useEffect(() => { setTheme(state.theme); }, [state.theme]);
+
+ return (
+
+
+ {TABS.map(({ id, label }) => (
+ setTab(id)}
+ flex={1}
+ py="8px"
+ px="4px"
+ border="none"
+ borderBottom={tab === id ? '2px solid $focusBorder' : '2px solid transparent'}
+ bg="transparent"
+ color={tab === id ? '$fg' : '$inactiveFg'}
+ fontSize="11px"
+ fontWeight={tab === id ? 600 : 400}
+ cursor="pointer"
+ fontFamily="inherit"
+ transition="color 0.1s, border-color 0.1s"
+ >
+ {label}
+
+ ))}
+
+ {/* Theme toggle */}
+ setState((p) => ({ ...p, theme: p.theme === 'dark' ? 'light' : 'dark' }))}
+ title={state.theme === 'dark' ? 'λΌμ΄νΈ λͺ¨λλ‘ μ ν' : 'λ€ν¬ λͺ¨λλ‘ μ ν'}
+ w="36px"
+ flexShrink={0}
+ border="none"
+ borderBottom="2px solid transparent"
+ bg="transparent"
+ color="$inactiveFg"
+ cursor="pointer"
+ fontFamily="inherit"
+ display="flex"
+ alignItems="center"
+ justifyContent="center"
+ >
+ {state.theme === 'dark'
+ ?
+ :
+ }
+
+
+
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+
+ {tab === 'editor' && }
+ {tab === 'migration' && }
+ {tab === 'export' && }
+
+
+ );
+}
diff --git a/apps/extension-webview/src/ai-agent.tsx b/apps/extension-webview/src/ai-agent.tsx
new file mode 100644
index 00000000..bdeb726e
--- /dev/null
+++ b/apps/extension-webview/src/ai-agent.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './theme';
+import AIAgentApp from './AIAgentApp';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/apps/extension-webview/src/defaultSchemas.json b/apps/extension-webview/src/defaultSchemas.json
new file mode 100644
index 00000000..9588f06c
--- /dev/null
+++ b/apps/extension-webview/src/defaultSchemas.json
@@ -0,0 +1,8 @@
+{
+ "prisma": "model User {\n id Int @id @default(autoincrement())\n email String @unique\n name String?\n createdAt DateTime @default(now())\n posts Post[]\n profile Profile?\n}\n\nmodel Profile {\n id Int @id @default(autoincrement())\n bio String\n userId Int @unique\n user User @relation(fields: [userId], references: [id])\n}\n\nmodel Post {\n id Int @id @default(autoincrement())\n title String\n content String?\n published Boolean @default(false)\n createdAt DateTime @default(now())\n authorId Int\n author User @relation(fields: [authorId], references: [id])\n tags TagOnPost[]\n}\n\nmodel Tag {\n id Int @id @default(autoincrement())\n name String @unique\n posts TagOnPost[]\n}\n\nmodel TagOnPost {\n postId Int\n tagId Int\n post Post @relation(fields: [postId], references: [id])\n tag Tag @relation(fields: [tagId], references: [id])\n @@id([postId, tagId])\n}",
+ "typeorm": "@Entity()\nexport class User {\n @PrimaryGeneratedColumn() id: number;\n @Column({ unique: true }) email: string;\n @Column({ nullable: true }) name: string;\n @OneToMany(() => Post, post => post.author) posts: Post[];\n}\n\n@Entity()\nexport class Post {\n @PrimaryGeneratedColumn() id: number;\n @Column() title: string;\n @Column({ nullable: true }) content: string;\n @Column({ default: false }) published: boolean;\n @ManyToOne(() => User, user => user.posts) author: User;\n}",
+ "drizzle": "export const users = pgTable('users', {\n id: serial('id').primaryKey(),\n email: text('email').notNull().unique(),\n name: text('name'),\n createdAt: timestamp('created_at').defaultNow(),\n});\n\nexport const posts = pgTable('posts', {\n id: serial('id').primaryKey(),\n title: text('title').notNull(),\n content: text('content'),\n published: boolean('published').default(false),\n authorId: integer('author_id').references(() => users.id),\n});",
+ "jpa": "@Entity\npublic class User {\n @Id @GeneratedValue(strategy = GenerationType.IDENTITY)\n private Long id;\n @Column(unique = true) private String email;\n private String name;\n @OneToMany(mappedBy = \"author\") private List posts;\n}\n\n@Entity\npublic class Post {\n @Id @GeneratedValue(strategy = GenerationType.IDENTITY)\n private Long id;\n private String title;\n private String content;\n private Boolean published = false;\n @ManyToOne @JoinColumn(name = \"author_id\") private User author;\n}",
+ "sqlalchemy": "class User(Base):\n __tablename__ = 'users'\n id = Column(Integer, primary_key=True)\n email = Column(String, unique=True, nullable=False)\n name = Column(String)\n posts = relationship('Post', back_populates='author')\n\nclass Post(Base):\n __tablename__ = 'posts'\n id = Column(Integer, primary_key=True)\n title = Column(String, nullable=False)\n content = Column(String)\n published = Column(Boolean, default=False)\n author_id = Column(Integer, ForeignKey('users.id'))\n author = relationship('User', back_populates='posts')",
+ "gorm": "type User struct {\n gorm.Model\n Email string `gorm:\"uniqueIndex;not null\"`\n Name string\n Posts []Post\n}\n\ntype Post struct {\n gorm.Model\n Title string `gorm:\"not null\"`\n Content string\n Published bool `gorm:\"default:false\"`\n AuthorID uint\n Author User\n}"
+}
diff --git a/apps/extension-webview/src/main.tsx b/apps/extension-webview/src/main.tsx
new file mode 100644
index 00000000..51fac67f
--- /dev/null
+++ b/apps/extension-webview/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './theme';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/apps/extension-webview/src/tabs/Export.tsx b/apps/extension-webview/src/tabs/Export.tsx
new file mode 100644
index 00000000..8cba3103
--- /dev/null
+++ b/apps/extension-webview/src/tabs/Export.tsx
@@ -0,0 +1,328 @@
+import React, { useState } from 'react';
+import { Box, Flex, Text, VStack } from '@devup-ui/react';
+import { postMessage } from '../vscode';
+import type { AppState } from '../App';
+import { DEFAULT_SCHEMAS } from '../App';
+import type { OrmType, WebviewMessage } from '../vscode';
+
+// ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface ExportFile {
+ id: string;
+ label: string;
+ ext: string;
+ lang: string;
+ content: string;
+ isDummy: boolean;
+}
+
+// μ€μ λ§μ΄κ·Έλ μ΄μ
/ERDκ° μμ§ μμ λ Export νμ΄ λΉ νλ©΄μΌλ‘ 보μ΄μ§ μλλ‘ μ°λ
+// νλ¦¬λ·°μ© λλ―Έ λ°μ΄ν° (files λ°°μ΄μ isDummy νλκ·Έλ‘ PREVIEW λ°°μ§μ ν¨κ» νμλ¨).
+// μλμ μΌλ‘ μ μ§ β μ κ±° λμ μλ.
+const DUMMY_SQL_PG = `-- PostgreSQL migration (preview)\n\nCREATE TABLE "users" (\n "id" SERIAL NOT NULL,\n "email" TEXT NOT NULL,\n "name" TEXT,\n "created_at" TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),\n CONSTRAINT "pk_users" PRIMARY KEY ("id"),\n CONSTRAINT "uq_users__email" UNIQUE ("email")\n);\n\nCREATE TABLE "posts" (\n "id" SERIAL NOT NULL,\n "title" TEXT NOT NULL,\n "content" TEXT,\n "published" BOOLEAN NOT NULL DEFAULT false,\n "author_id" INTEGER NOT NULL,\n CONSTRAINT "pk_posts" PRIMARY KEY ("id"),\n CONSTRAINT "fk_posts__author_id" FOREIGN KEY ("author_id") REFERENCES "users" ("id")\n);\n\nCREATE INDEX "ix_posts__author_id" ON "posts" ("author_id");`;
+
+const DUMMY_SQL_MY = `-- MySQL migration (preview)\n\nCREATE TABLE \`users\` (\n \`id\` INT NOT NULL AUTO_INCREMENT,\n \`email\` VARCHAR(191) NOT NULL,\n \`name\` VARCHAR(191),\n PRIMARY KEY (\`id\`),\n CONSTRAINT \`uq_users__email\` UNIQUE (\`email\`)\n) ENGINE=InnoDB;\n\nCREATE TABLE \`posts\` (\n \`id\` INT NOT NULL AUTO_INCREMENT,\n \`title\` VARCHAR(191) NOT NULL,\n \`author_id\` INT NOT NULL,\n PRIMARY KEY (\`id\`),\n CONSTRAINT \`fk_posts__author_id\` FOREIGN KEY (\`author_id\`) REFERENCES \`users\` (\`id\`)\n) ENGINE=InnoDB;`;
+
+const DUMMY_SQL_SQ = `-- SQLite migration (preview)\n\nCREATE TABLE "users" (\n "id" INTEGER NOT NULL,\n "email" TEXT NOT NULL,\n "name" TEXT,\n CONSTRAINT "pk_users" PRIMARY KEY ("id" AUTOINCREMENT),\n CONSTRAINT "uq_users__email" UNIQUE ("email")\n);\n\nCREATE TABLE "posts" (\n "id" INTEGER NOT NULL,\n "title" TEXT NOT NULL,\n "author_id" INTEGER NOT NULL,\n CONSTRAINT "pk_posts" PRIMARY KEY ("id" AUTOINCREMENT),\n CONSTRAINT "fk_posts__author_id" FOREIGN KEY ("author_id") REFERENCES "users" ("id")\n);`;
+
+const DUMMY_SVG = `\n`;
+
+// state.svgμ²λΌ dangerouslySetInnerHTMLλ‘ μ£Όμ
νμ§ μκ³ JSXλ‘ μ§μ λ λλ§λλ ERD ν리뷰.
+// DUMMY_SVG(μ)λ 볡μ¬/μ μ₯/μμ€λ³΄κΈ°μ© λ¬Έμμ΄λ‘ λ³λ μ μ§.
+function DummyErdSvg() {
+ return (
+
+ );
+}
+
+// ββ Export actions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const EXPORT_ACTIONS = {
+ 'sql-pg': (file: ExportFile) => ({ type: 'export_sql', content: file.content, dialect: 'postgres' }) as WebviewMessage,
+ 'sql-my': (file: ExportFile) => ({ type: 'export_sql', content: file.content, dialect: 'mysql' }) as WebviewMessage,
+ 'sql-sq': (file: ExportFile) => ({ type: 'export_sql', content: file.content, dialect: 'sqlite' }) as WebviewMessage,
+ 'orm-src': (file: ExportFile, ormType: OrmType) => ({ type: 'export_schema', content: file.content, ormType }) as WebviewMessage,
+ 'schema-json': (file: ExportFile) => ({ type: 'export_schema', content: file.content, ormType: 'prisma' }) as WebviewMessage,
+ 'erd-svg': () => ({ type: 'export_svg' }) as WebviewMessage,
+ 'erd-pdf': () => ({ type: 'export_pdf' }) as WebviewMessage,
+} as const;
+
+function saveFile(file: ExportFile | undefined, ormType: OrmType) {
+ if (!file) return;
+ const buildMessage = EXPORT_ACTIONS[file.id as keyof typeof EXPORT_ACTIONS] as
+ | ((file: ExportFile, ormType: OrmType) => WebviewMessage)
+ | undefined;
+ if (buildMessage) postMessage(buildMessage(file, ormType));
+}
+
+// ββ Component βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface Props { state: AppState; setState: React.Dispatch> }
+
+export default function Export({ state }: Props) {
+ const [panelId, setPanelId] = useState('sql-pg');
+ const [copied, setCopied] = useState(null);
+
+ const hasSql = !!(state.postgres || state.mysql || state.sqlite);
+ const hasSvg = !!state.svg;
+ const hasSchema = Object.keys(state.schema ?? {}).length > 0;
+ const ormLabel = state.ormType.charAt(0).toUpperCase() + state.ormType.slice(1);
+
+ const files: ExportFile[] = [
+ { id: 'sql-pg', label: 'migration.postgres', ext: '.sql', lang: 'SQL', content: state.postgres || DUMMY_SQL_PG, isDummy: !hasSql },
+ { id: 'sql-my', label: 'migration.mysql', ext: '.sql', lang: 'SQL', content: state.mysql || DUMMY_SQL_MY, isDummy: !hasSql },
+ { id: 'sql-sq', label: 'migration.sqlite', ext: '.sql', lang: 'SQL', content: state.sqlite || DUMMY_SQL_SQ, isDummy: !hasSql },
+ { id: 'orm-src', label: `schema.${state.ormType}`, ext: state.ormType === 'prisma' ? '.prisma' : state.ormType === 'drizzle' || state.ormType === 'typeorm' ? '.ts' : state.ormType === 'gorm' ? '.go' : '.java', lang: ormLabel, content: state.ormSource || DEFAULT_SCHEMAS[state.ormType], isDummy: !state.ormSource },
+ { id: 'schema-json', label: 'schema', ext: '.json', lang: 'JSON', content: hasSchema ? JSON.stringify(state.schema, null, 2) : '{}', isDummy: !hasSchema },
+ { id: 'erd-svg', label: 'erd-diagram', ext: '.svg', lang: 'SVG', content: state.svg || DUMMY_SVG, isDummy: !hasSvg },
+ { id: 'erd-pdf', label: 'erd-diagram', ext: '.pdf', lang: 'PDF', content: '', isDummy: !hasSvg },
+ ];
+
+ const selectedFile = files.find((f) => f.id === panelId);
+
+ function copyContent(content: string, id: string) {
+ navigator.clipboard.writeText(content).then(() => {
+ setCopied(id);
+ setTimeout(() => setCopied(null), 1500);
+ });
+ }
+
+ return (
+
+
+ {/* ββ Left sidebar ββ */}
+
+
+
+
+
+ {files.filter((f) => f.id.startsWith('sql-')).map((f) => (
+ setPanelId(f.id)} />
+ ))}
+
+
+ {files.filter((f) => f.id === 'orm-src' || f.id === 'schema-json').map((f) => (
+ setPanelId(f.id)} />
+ ))}
+
+
+ {files.filter((f) => f.id.startsWith('erd-')).map((f) => (
+ setPanelId(f.id)} />
+ ))}
+
+
+
+ {/* ββ Right panel ββ */}
+
+ {selectedFile && (
+ <>
+ copyContent(selectedFile.content, selectedFile.id)}
+ onSave={() => saveFile(selectedFile, state.ormType)}
+ />
+
+ >
+ )}
+
+
+ );
+}
+
+// ββ Sub-components βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function SectionHeader({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function GroupLabel({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function FileRow({ f, active, onClick }: { f: ExportFile; active: boolean; onClick: () => void }) {
+ return (
+
+
+ {f.ext === '.sql' ? 'β‘' : f.ext === '.svg' || f.ext === '.pdf' ? 'β«' : '{ }'}
+
+
+ {f.label}{f.ext}
+
+ {f.isDummy && ~}
+
+ );
+}
+
+function FileHeader({ file, copied, onCopy, onSave }: {
+ file: ExportFile; copied: boolean; onCopy: () => void; onSave: () => void;
+}) {
+ return (
+
+ {file.label}
+ {file.ext}
+ {file.lang}
+ {file.isDummy && (
+ PREVIEW
+ )}
+
+
+ {copied ? 'β 볡μ¬λ¨' : '볡μ¬'}
+
+ μ μ₯
+
+ );
+}
+
+function FilePreviewBody({ file }: { file: ExportFile }) {
+ if (file.id === 'erd-pdf') {
+ return (
+
+
+ PDF export converts the ERD diagram SVG to a portable document.{'\n\n'}
+ Click "μ μ₯" to generate the PDF file.{'\n'}
+ {file.isDummy ? 'β ORM Editorμμ μ€ν€λ§λ₯Ό λ¨Όμ μ
λ ₯νμΈμ.' : 'β ERD μ€λΉ μλ£.'}
+
+
+ );
+ }
+
+ if (file.id === 'erd-svg' && (file.isDummy || file.content.startsWith('