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..18220a26
--- /dev/null
+++ b/apps/extension-webview/ai-agent.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Vespertide AI Agent
+
+
+
+
+
+
+
diff --git a/apps/extension-webview/index.html b/apps/extension-webview/index.html
new file mode 100644
index 00000000..80ff6a8d
--- /dev/null
+++ b/apps/extension-webview/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+ 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..bd82c498
--- /dev/null
+++ b/apps/extension-webview/src/AIAgentApp.tsx
@@ -0,0 +1,623 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Box, Flex } from '@devup-ui/react';
+import { postMessage, onMessage } from './vscode';
+import type { ConnectorService, ConnectorStatus, ChatMessage } from './vscode';
+
+type 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: '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 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);
+ }
+ if (msg.type === 'error') {
+ setMessages((prev) => [...prev, { role: 'assistant', content: `μ€λ₯: ${msg.message}` }]);
+ setLoading(false);
+ }
+ });
+ }, []);
+
+ useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
+
+ function openBrowser(url: string) { postMessage({ type: 'open_external', url }); }
+
+ 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 });
+ if (activeAI === service) setActiveAI('claude');
+ }
+
+ 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);
+ 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"
+ bg="transparent"
+ borderBottom={view === v ? '2px solid var(--vscode-focusBorder, #007acc)' : '2px solid transparent'}
+ color={view === v ? 'var(--node-text)' : 'var(--node-text-dim)'}
+ 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="3px"
+ px="10px"
+ borderRadius="10px"
+ fontSize="11px"
+ cursor="pointer"
+ bg={activeAI === ai.service ? 'rgba(99,102,241,0.28)' : 'transparent'}
+ border={`1px solid ${activeAI === ai.service ? 'rgba(99,102,241,0.6)' : 'var(--node-border)'}`}
+ color={activeAI === ai.service ? '#818cf8' : 'var(--node-text-dim)'}
+ >
+ {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="var(--node-text-dim)"
+ cursor="pointer"
+ 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"
+ bg="rgba(99,102,241,0.2)"
+ color="#818cf8"
+ cursor="pointer"
+ fontSize="12px"
+ >
+ Connectorsμμ AI μΆκ°νκΈ° β
+
+
+ ) : (
+
+ "μ΄ μ€ν€λ§λ₯Ό Notionμ μ 리ν΄μ€"
+ "λ§μ΄κ·Έλ μ΄μ
λ³κ²½μ¬νμ SlackμΌλ‘ 보λ΄μ€"
+ "Post ν
μ΄λΈμ μΈλ±μ€λ₯Ό μΆκ°νλ©΄ μ’μκΉ?"
+
+ )}
+
+ )}
+
+ {messages.map((m, i) => {
+ const isUser = m.role === 'user';
+ return (
+
+ {!isUser && (
+ π€
+ )}
+
+ {m.content}
+
+
+ );
+ })}
+
+ {loading && (
+
+ π€
+ μλ΅ μμ± μ€...
+
+ )}
+
+
+
+
+ ) => 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' : 'var(--vscode-input-background, rgba(255,255,255,0.06))'}
+ border={`1px solid ${theme === 'light' ? 'rgba(0,0,0,0.2)' : 'var(--vscode-input-border, rgba(255,255,255,0.15))'}`}
+ color="var(--node-text)"
+ outline="none"
+ resize="none"
+ lineHeight={1.6}
+ fontFamily="inherit"
+ maxHeight="120px"
+ overflowY="auto"
+ />
+ μ μ‘
+
+
+ )}
+
+ {/* ββ Connectors view ββ */}
+ {view === 'connectors' && (
+
+
+ {CONNECTORS.filter((c) => c.isAI).map((meta) => (
+ { setActiveAI(meta.service); setView('chat'); }}
+ 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)}
+ />
+ ))}
+
+
+ {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,
+ onSetActive, onConfigure, onKeyChange, onToggleShow, onSave, onDisconnect, onOpenBrowser,
+}: {
+ meta: ConnectorMeta; connected: boolean; isActive: boolean; configuring: boolean;
+ keyValue: string; saving: boolean; showKey: boolean; theme: 'dark' | 'light';
+ onSetActive: () => void; onConfigure: () => void;
+ onKeyChange: (v: string) => void; onToggleShow: () => void;
+ onSave: () => void; onDisconnect: () => void; onOpenBrowser: () => void;
+}) {
+ 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}
+
+
+
+
+ {meta.keyLabel}
+
+
+
+ ) => onKeyChange(e.target.value)}
+ placeholder={meta.keyPlaceholder}
+ onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && onSave()}
+ flex={1}
+ py="7px"
+ px="10px"
+ borderRadius="4px"
+ fontSize="12px"
+ bg={theme === 'light' ? '#ffffff' : 'var(--vscode-input-background, rgba(255,255,255,0.07))'}
+ border={`1px solid ${theme === 'light' ? 'rgba(0,0,0,0.2)' : 'var(--vscode-input-border, rgba(255,255,255,0.15))'}`}
+ color="var(--node-text)"
+ outline="none"
+ />
+
+ {showKey ? 'μ¨κΉ' : 'νμ'}
+
+
+
+
+
+ {saving ? 'μ μ₯ μ€...' : connected ? 'Update' : 'Connect'}
+
+ {connected && (
+ μ°κ²° ν΄μ
+ )}
+
+
+ )}
+
+ );
+}
+
+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: 'var(--vscode-button-background, #0e639c)', color: 'var(--vscode-button-foreground, #fff)', border: 'none' },
+ ghost: { bg: 'transparent', color: 'var(--node-text)', border: '1px solid var(--node-border)' },
+ 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: 'var(--diff-rm-sign)', 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..7c0df036
--- /dev/null
+++ b/apps/extension-webview/src/App.tsx
@@ -0,0 +1,269 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Flex } 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';
+
+type Tab = 'editor' | 'migration' | 'export';
+
+export const DEFAULT_SCHEMAS: Record = {
+ prisma: `model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ name String?
+ createdAt DateTime @default(now())
+ posts Post[]
+ profile Profile?
+}
+
+model Profile {
+ id Int @id @default(autoincrement())
+ bio String
+ userId Int @unique
+ user User @relation(fields: [userId], references: [id])
+}
+
+model Post {
+ id Int @id @default(autoincrement())
+ title String
+ content String?
+ published Boolean @default(false)
+ createdAt DateTime @default(now())
+ authorId Int
+ author User @relation(fields: [authorId], references: [id])
+ tags TagOnPost[]
+}
+
+model Tag {
+ id Int @id @default(autoincrement())
+ name String @unique
+ posts TagOnPost[]
+}
+
+model TagOnPost {
+ postId Int
+ tagId Int
+ post Post @relation(fields: [postId], references: [id])
+ tag Tag @relation(fields: [tagId], references: [id])
+ @@id([postId, tagId])
+}`,
+
+ typeorm: `@Entity()
+export class User {
+ @PrimaryGeneratedColumn() id: number;
+ @Column({ unique: true }) email: string;
+ @Column({ nullable: true }) name: string;
+ @OneToMany(() => Post, post => post.author) posts: Post[];
+}
+
+@Entity()
+export class Post {
+ @PrimaryGeneratedColumn() id: number;
+ @Column() title: string;
+ @Column({ nullable: true }) content: string;
+ @Column({ default: false }) published: boolean;
+ @ManyToOne(() => User, user => user.posts) author: User;
+}`,
+
+ drizzle: `export const users = pgTable('users', {
+ id: serial('id').primaryKey(),
+ email: text('email').notNull().unique(),
+ name: text('name'),
+ createdAt: timestamp('created_at').defaultNow(),
+});
+
+export const posts = pgTable('posts', {
+ id: serial('id').primaryKey(),
+ title: text('title').notNull(),
+ content: text('content'),
+ published: boolean('published').default(false),
+ authorId: integer('author_id').references(() => users.id),
+});`,
+
+ jpa: `@Entity
+public class User {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(unique = true) private String email;
+ private String name;
+ @OneToMany(mappedBy = "author") private List posts;
+}
+
+@Entity
+public class Post {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ private String title;
+ private String content;
+ private Boolean published = false;
+ @ManyToOne @JoinColumn(name = "author_id") private User author;
+}`,
+
+ sqlalchemy: `class User(Base):
+ __tablename__ = 'users'
+ id = Column(Integer, primary_key=True)
+ email = Column(String, unique=True, nullable=False)
+ name = Column(String)
+ posts = relationship('Post', back_populates='author')
+
+class Post(Base):
+ __tablename__ = 'posts'
+ id = Column(Integer, primary_key=True)
+ title = Column(String, nullable=False)
+ content = Column(String)
+ published = Column(Boolean, default=False)
+ author_id = Column(Integer, ForeignKey('users.id'))
+ author = relationship('User', back_populates='posts')`,
+
+ gorm: `type User struct {
+ gorm.Model
+ Email string \`gorm:"uniqueIndex;not null"\`
+ Name string
+ Posts []Post
+}
+
+type Post struct {
+ gorm.Model
+ Title string \`gorm:"not null"\`
+ Content string
+ Published bool \`gorm:"default:false"\`
+ AuthorID uint
+ Author User
+}`,
+};
+
+const TABS: { id: Tab; label: string }[] = [
+ { id: 'editor', label: 'ORM Editor' },
+ { id: 'migration', label: 'Migration' },
+ { id: 'export', label: 'Export' },
+];
+
+export type 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;
+ }
+ });
+ });
+ }, []);
+
+ return (
+
+
+ {TABS.map(({ id, label }) => (
+ setTab(id)}
+ flex={1}
+ py="8px"
+ px="4px"
+ border="none"
+ borderBottom={tab === id
+ ? '2px solid var(--vscode-focusBorder, #007acc)'
+ : '2px solid transparent'}
+ bg="transparent"
+ color={tab === id
+ ? 'var(--vscode-foreground)'
+ : 'var(--vscode-tab-inactiveForeground, #8e8e8e)'}
+ fontSize="11px"
+ fontWeight={tab === id ? 600 : 400}
+ cursor="pointer"
+ 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="var(--vscode-tab-inactiveForeground, #8e8e8e)"
+ cursor="pointer"
+ 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..ea7f2936
--- /dev/null
+++ b/apps/extension-webview/src/ai-agent.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import AIAgentApp from './AIAgentApp';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/apps/extension-webview/src/main.tsx b/apps/extension-webview/src/main.tsx
new file mode 100644
index 00000000..9707d827
--- /dev/null
+++ b/apps/extension-webview/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/apps/extension-webview/src/styles/global.css b/apps/extension-webview/src/styles/global.css
new file mode 100644
index 00000000..d6f3fc26
--- /dev/null
+++ b/apps/extension-webview/src/styles/global.css
@@ -0,0 +1,135 @@
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+html, body, #root {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
+ font-size: var(--vscode-font-size, 13px);
+ font-weight: var(--vscode-font-weight, normal);
+ color: var(--vscode-foreground, #cccccc);
+ background: var(--vscode-sideBar-background, #252526);
+ overflow: hidden;
+}
+
+button {
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+}
+
+textarea, input {
+ font-family: inherit;
+ font-size: inherit;
+}
+
+/* ββ Diff viewer tokens β dark (default) ββββββββββββββββββββββββββββββ */
+:root {
+ --diff-bg: #1e1e1e;
+ --diff-text: rgba(212, 212, 212, 0.93);
+ --diff-linenum: rgba(255, 255, 255, 0.25);
+ --diff-add-bg: rgba(34, 197, 94, 0.1);
+ --diff-add-border: rgba(34, 197, 94, 0.55);
+ --diff-add-text: #86efac;
+ --diff-add-sign: #4ade80;
+ --diff-rm-bg: rgba(239, 68, 68, 0.1);
+ --diff-rm-border: rgba(239, 68, 68, 0.55);
+ --diff-rm-text: #fca5a5;
+ --diff-rm-sign: #f87171;
+ --diff-sidebar-bg: #252526;
+ --diff-sidebar-text:#cccccc;
+ --diff-header-bg: #2d2d2d;
+ --diff-header-text: #cccccc;
+}
+
+/* ββ Design tokens β dark (default) βββββββββββββββββββββββββββββββββββ */
+:root {
+ --node-bg: #1a1b2e;
+ --node-border: rgba(255, 255, 255, 0.11);
+ --node-field-divider: rgba(255, 255, 255, 0.055);
+ --node-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 0 0.5px rgba(255, 255, 255, 0.07);
+ --node-type-color: rgba(180, 185, 220, 0.5);
+ --node-text: rgba(220, 222, 240, 0.92);
+ --node-text-dim: rgba(180, 185, 220, 0.5);
+ --canvas-dot: rgba(255, 255, 255, 0.055);
+ --edge-color: rgba(99, 102, 241, 0.7);
+ --edge-arrow: rgba(99, 102, 241, 0.85);
+ --navbar-bg: rgba(18, 18, 30, 0.94);
+ --navbar-border: rgba(255, 255, 255, 0.1);
+}
+
+/* ββ Design tokens β light βββββββββββββββββββββββββββββββββββββββββββββ */
+[data-theme="light"] {
+ --vscode-editor-background: #f1f2f7;
+ --vscode-editor-foreground: #1a1a2e;
+ --vscode-foreground: #1a1a2e;
+ --vscode-sideBar-background: #e9eaf2;
+ --vscode-editorWidget-background: #e1e3ef;
+ --vscode-editorGroupHeader-tabsBackground: #d8daea;
+ --vscode-panel-border: rgba(0, 0, 0, 0.12);
+ --vscode-tab-inactiveForeground: #5a5c72;
+ --vscode-button-background: #4f46e5;
+ --vscode-button-foreground: #ffffff;
+ --vscode-focusBorder: #4f46e5;
+ --vscode-input-border: rgba(0, 0, 0, 0.18);
+ --vscode-scrollbarSlider-background: rgba(0, 0, 0, 0.18);
+ --vscode-scrollbarSlider-hoverBackground: rgba(0, 0, 0, 0.32);
+ --vscode-inputValidation-errorBackground: rgba(180, 30, 30, 0.08);
+ --vscode-inputValidation-errorForeground: #a80000;
+
+ --node-bg: #ffffff;
+ --node-border: rgba(0, 0, 0, 0.1);
+ --node-field-divider: rgba(0, 0, 0, 0.065);
+ --node-shadow: 0 2px 16px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.07);
+ --node-type-color: rgba(80, 85, 115, 0.6);
+ --node-text: #111827;
+ --node-text-dim: rgba(55, 60, 90, 0.68);
+ --canvas-dot: rgba(0, 0, 0, 0.075);
+ --edge-color: rgba(79, 70, 229, 0.6);
+ --edge-arrow: rgba(79, 70, 229, 0.8);
+ --navbar-bg: rgba(255, 255, 255, 0.96);
+ --navbar-border: rgba(0, 0, 0, 0.1);
+
+ --diff-bg: #f8f9fc;
+ --diff-text: #1a1a2e;
+ --diff-linenum: rgba(0, 0, 0, 0.3);
+ --diff-add-bg: rgba(22, 163, 74, 0.08);
+ --diff-add-border: rgba(22, 163, 74, 0.5);
+ --diff-add-text: #14532d;
+ --diff-add-sign: #16a34a;
+ --diff-rm-bg: rgba(220, 38, 38, 0.07);
+ --diff-rm-border: rgba(220, 38, 38, 0.45);
+ --diff-rm-text: #7f1d1d;
+ --diff-rm-sign: #dc2626;
+ --diff-sidebar-bg: #e9eaf2;
+ --diff-sidebar-text:#1a1a2e;
+ --diff-header-bg: #d8daea;
+ --diff-header-text: #1a1a2e;
+}
+
+/* Animations */
+@keyframes slideInRight {
+ from { transform: translateX(20px); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+}
+
+@keyframes slideInUp {
+ from { transform: translateY(10px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* Thin scrollbars */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+ background: var(--vscode-scrollbarSlider-background, rgba(121,121,121,0.4));
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--vscode-scrollbarSlider-hoverBackground, rgba(121,121,121,0.7));
+}
diff --git a/apps/extension-webview/src/tabs/Export.tsx b/apps/extension-webview/src/tabs/Export.tsx
new file mode 100644
index 00000000..6b73faa8
--- /dev/null
+++ b/apps/extension-webview/src/tabs/Export.tsx
@@ -0,0 +1,669 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Box, Flex } from '@devup-ui/react';
+import { postMessage, onMessage } from '../vscode';
+import type { AppState } from '../App';
+import { DEFAULT_SCHEMAS } from '../App';
+import type { ConnectorService, ConnectorStatus, ChatMessage } from '../vscode';
+
+// ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+type RightPanel =
+ | { kind: 'file'; id: string }
+ | { kind: 'connector'; service: ConnectorService }
+ | { kind: 'chat' };
+
+type ExportFile = {
+ id: string;
+ label: string;
+ ext: string;
+ lang: string;
+ content: string;
+ isDummy: boolean;
+};
+
+type ConnectorMeta = {
+ service: ConnectorService;
+ label: string;
+ icon: string;
+ subtitle?: string;
+ keyLabel: string;
+ keyPlaceholder: string;
+ keyHelp?: string;
+ isAI: boolean;
+};
+
+const CONNECTORS: ConnectorMeta[] = [
+ {
+ service: 'claude', label: 'Claude', icon: 'π€', subtitle: 'Anthropic',
+ keyLabel: 'API Key', keyPlaceholder: 'sk-ant-api03-...', isAI: true,
+ keyHelp: 'console.anthropic.comμμ λ°κΈ',
+ },
+ {
+ service: 'openai', label: 'OpenAI / GPT', icon: 'π§ ', subtitle: 'OpenAI',
+ keyLabel: 'API Key', keyPlaceholder: 'sk-proj-...', isAI: true,
+ keyHelp: 'platform.openai.comμμ λ°κΈ',
+ },
+ {
+ service: 'gemini', label: 'Gemini', icon: 'β¦', subtitle: 'Google',
+ keyLabel: 'API Key', keyPlaceholder: 'AIzaSy...', isAI: true,
+ keyHelp: 'aistudio.google.comμμ λ°κΈ',
+ },
+ {
+ service: 'slack', label: 'Slack', icon: 'π¬', subtitle: 'Workspace',
+ keyLabel: 'Webhook URL', keyPlaceholder: 'https://hooks.slack.com/services/...', isAI: false,
+ keyHelp: 'Slack μ± μ€μ β Incoming Webhooks',
+ },
+ {
+ service: 'notion', label: 'Notion', icon: 'π', subtitle: 'Workspace',
+ keyLabel: 'Integration Token', keyPlaceholder: 'secret_...', isAI: false,
+ keyHelp: 'notion.so/my-integrationsμμ λ°κΈ',
+ },
+ {
+ service: 'jira', label: 'Jira', icon: 'π―', subtitle: 'Atlassian',
+ keyLabel: 'Email:API Token', keyPlaceholder: 'user@example.com:token...', isAI: false,
+ keyHelp: 'id.atlassian.com/manage-profile/security/api-tokens',
+ },
+];
+
+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`;
+
+// ββ Component βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+type Props = { state: AppState; setState: React.Dispatch> };
+
+export default function Export({ state }: Props) {
+ const [panel, setPanel] = useState({ kind: 'file', id: 'sql-pg' });
+ const [connectors, setConnectors] = useState([]);
+ const [expandedConn, setExpandedConn] = useState(null);
+ const [keyInputs, setKeyInputs] = useState>>({});
+ const [saving, setSaving] = useState(null);
+ const [copied, setCopied] = useState(null);
+
+ const [chatMessages, setChatMessages] = useState([]);
+ const [chatInput, setChatInput] = useState('');
+ const [chatLoading, setChatLoading] = useState(false);
+ const [activeAI, setActiveAI] = useState('claude');
+ const chatEndRef = useRef(null) as React.RefObject;
+
+ 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);
+
+ useEffect(() => {
+ postMessage({ type: 'connector_load' });
+ }, []);
+
+ useEffect(() => {
+ const off = onMessage((msg) => {
+ if (msg.type === 'connector_status') setConnectors(msg.connectors);
+ if (msg.type === 'ai_response' && msg.done) {
+ setChatMessages((prev) => [...prev, { role: 'assistant', content: msg.content }]);
+ setChatLoading(false);
+ }
+ if (msg.type === 'error') {
+ setChatMessages((prev) => [...prev, { role: 'assistant', content: `μ€λ₯: ${msg.message}` }]);
+ setChatLoading(false);
+ }
+ });
+ return off;
+ }, []);
+
+ useEffect(() => {
+ chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [chatMessages]);
+
+ 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) => panel.kind === 'file' && f.id === panel.id);
+
+ const connectedAIs = CONNECTORS.filter(
+ (c) => c.isAI && connectors.find((s) => s.service === c.service)?.connected,
+ );
+
+ function saveFile() {
+ if (!selectedFile) return;
+ const id = selectedFile.id;
+ if (id === 'sql-pg') postMessage({ type: 'export_sql', content: selectedFile.content, dialect: 'postgres' });
+ else if (id === 'sql-my') postMessage({ type: 'export_sql', content: selectedFile.content, dialect: 'mysql' });
+ else if (id === 'sql-sq') postMessage({ type: 'export_sql', content: selectedFile.content, dialect: 'sqlite' });
+ else if (id === 'orm-src') postMessage({ type: 'export_schema', content: selectedFile.content, ormType: state.ormType });
+ else if (id === 'erd-svg') postMessage({ type: 'export_svg' });
+ else if (id === 'erd-pdf') postMessage({ type: 'export_pdf' });
+ else if (id === 'schema-json') postMessage({ type: 'export_schema', content: selectedFile.content, ormType: 'prisma' });
+ }
+
+ function copyContent(content: string, id: string) {
+ navigator.clipboard.writeText(content).then(() => {
+ setCopied(id);
+ setTimeout(() => setCopied(null), 1500);
+ }).catch(console.error);
+ }
+
+ function toggleConnector(service: ConnectorService) {
+ if (panel.kind === 'connector' && panel.service === service) {
+ setPanel({ kind: 'file', id: 'sql-pg' });
+ } else {
+ setPanel({ kind: 'connector', service });
+ setExpandedConn(service);
+ }
+ }
+
+ function saveConnectorKey(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), 1500);
+ }
+
+ function disconnectConnector(service: ConnectorService) {
+ postMessage({ type: 'connector_delete', service });
+ }
+
+ function sendChat() {
+ const text = chatInput.trim();
+ if (!text || chatLoading) return;
+ setChatInput('');
+
+ if (connectedAIs.length === 0) {
+ setChatMessages((prev) => [
+ ...prev,
+ { role: 'user', content: text },
+ { role: 'assistant', content: 'μΌμͺ½ CONNECTIONSμμ AI μλΉμ€(Claude, OpenAI, Gemini)λ₯Ό λ¨Όμ μ°κ²°ν΄μ£ΌμΈμ.' },
+ ]);
+ return;
+ }
+
+ const newMessages: ChatMessage[] = [...chatMessages, { role: 'user', content: text }];
+ setChatMessages(newMessages);
+ setChatLoading(true);
+ const ctx = [
+ state.ormSource ? `ORM Source:\n${state.ormSource}` : '',
+ state.postgres ? `PostgreSQL Migration:\n${state.postgres}` : '',
+ ].filter(Boolean).join('\n\n') || '(μ€ν€λ§ μμ)';
+ postMessage({ type: 'ai_chat', service: activeAI, messages: newMessages, context: ctx });
+ }
+
+ const connStatusMap = Object.fromEntries(connectors.map((c) => [c.service, c.connected])) as Record;
+
+ return (
+
+
+ {/* ββ Left sidebar ββ */}
+
+
+
+
+
+ {files.filter((f) => f.id.startsWith('sql-')).map((f) => (
+ setPanel({ kind: 'file', id: f.id })} />
+ ))}
+
+
+ {files.filter((f) => f.id === 'orm-src' || f.id === 'schema-json').map((f) => (
+ setPanel({ kind: 'file', id: f.id })} />
+ ))}
+
+
+ {files.filter((f) => f.id.startsWith('erd-')).map((f) => (
+ setPanel({ kind: 'file', id: f.id })} />
+ ))}
+
+
+
+ {/* ββ Right panel ββ */}
+
+
+ {panel.kind === 'file' && selectedFile && (
+ <>
+ copyContent(selectedFile.content, selectedFile.id)}
+ onSave={saveFile}
+ />
+
+ >
+ )}
+
+
+
+ );
+}
+
+// ββ 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 ConnectorRow({ meta, connected, active, onClick }: {
+ meta: ConnectorMeta; connected: boolean; active: boolean; onClick: () => void;
+}) {
+ return (
+
+ {meta.icon}
+
+
+ {meta.label}
+
+
+ {connected ? (
+ Connected
+ ) : (
+ Connect
+ )}
+
+ {active ? 'β²' : 'βΌ'}
+
+ );
+}
+
+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.content.startsWith('