From cde18d4b2bec60e0732f26840080aba970614ac0 Mon Sep 17 00:00:00 2001 From: Eunseo Song <162149585+eunseo9311@users.noreply.github.com> Date: Sat, 9 May 2026 17:31:17 +0900 Subject: [PATCH 01/43] feat(vscode): add extension manifest and build config --- apps/vscode/.gitignore | 3 ++ apps/vscode/.vscodeignore | 7 ++++ apps/vscode/package.json | 60 +++++++++++++++++++++++++++++++++++ apps/vscode/tsconfig.json | 15 +++++++++ apps/vscode/webpack.config.js | 31 ++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 apps/vscode/.gitignore create mode 100644 apps/vscode/.vscodeignore create mode 100644 apps/vscode/package.json create mode 100644 apps/vscode/tsconfig.json create mode 100644 apps/vscode/webpack.config.js diff --git a/apps/vscode/.gitignore b/apps/vscode/.gitignore new file mode 100644 index 0000000..49cdee3 --- /dev/null +++ b/apps/vscode/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.vsix diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 0000000..9ef8435 --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,7 @@ +.vscode/** +webview/** +src/** +node_modules/** +**/*.map +tsconfig.json +webpack.config.js diff --git a/apps/vscode/package.json b/apps/vscode/package.json new file mode 100644 index 0000000..9db0e12 --- /dev/null +++ b/apps/vscode/package.json @@ -0,0 +1,60 @@ +{ + "name": "vespertide", + "displayName": "Vespertide", + "description": "Database schema management — ORM editor, ERD viewer, migration diff, and ORM converter", + "version": "0.1.61", + "publisher": "vespertide", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other"], + "activationEvents": [], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "vespertide", + "title": "Vespertide", + "icon": "assets/icon.svg" + } + ] + }, + "views": { + "vespertide": [ + { + "type": "webview", + "id": "vespertide.mainPanel", + "name": "Vespertide" + } + ] + }, + "configuration": { + "title": "Vespertide", + "properties": { + "vespertide.mcpEndpoint": { + "type": "string", + "default": "http://localhost:3456", + "description": "MCP 서버 엔드포인트 URL" + } + } + } + }, + "scripts": { + "build": "webpack --mode production && npm run build:webview", + "build:ext": "webpack --mode production", + "build:webview": "cd webview && npm run build", + "dev": "webpack --mode development --watch", + "dev:webview": "cd webview && npm run dev", + "package": "vsce package", + "compile": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "ts-loader": "^9.5.1", + "typescript": "^5.3.0", + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4" + } +} diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json new file mode 100644 index 0000000..b74fa38 --- /dev/null +++ b/apps/vscode/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2022", + "lib": ["es2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "webview"] +} diff --git a/apps/vscode/webpack.config.js b/apps/vscode/webpack.config.js new file mode 100644 index 0000000..5ca3661 --- /dev/null +++ b/apps/vscode/webpack.config.js @@ -0,0 +1,31 @@ +// @ts-check +const path = require('path'); + +/** @type {import('webpack').Configuration} */ +module.exports = { + target: 'node', + mode: 'none', + entry: './src/extension.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'extension.js', + libraryTarget: 'commonjs2', + }, + externals: { + vscode: 'commonjs vscode', + // jspdf is an optional runtime dependency — users install it themselves + jspdf: 'commonjs jspdf', + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: 'ts-loader', + }, + ], + }, +}; From 00798d074a7b13f2f178874fe762cb398efa6767 Mon Sep 17 00:00:00 2001 From: Eunseo Song <162149585+eunseo9311@users.noreply.github.com> Date: Sat, 9 May 2026 17:31:24 +0900 Subject: [PATCH 02/43] feat(vscode): add extension host source files - extension.ts: activate() and WebviewViewProvider registration - webview-provider.ts: HTML loading and message routing - wasm-host.ts: WASM wrapper stubs (connect later) - export.ts: SVG save, PDF convert, MCP POST - messages.ts: shared Webview <-> Host message types - assets/icon.svg: Activity Bar icon --- apps/vscode/assets/icon.svg | 11 ++ apps/vscode/src/export.ts | 117 ++++++++++++++++++ apps/vscode/src/extension.ts | 16 +++ apps/vscode/src/messages.ts | 22 ++++ apps/vscode/src/wasm-host.ts | 73 ++++++++++++ apps/vscode/src/webview-provider.ts | 178 ++++++++++++++++++++++++++++ 6 files changed, 417 insertions(+) create mode 100644 apps/vscode/assets/icon.svg create mode 100644 apps/vscode/src/export.ts create mode 100644 apps/vscode/src/extension.ts create mode 100644 apps/vscode/src/messages.ts create mode 100644 apps/vscode/src/wasm-host.ts create mode 100644 apps/vscode/src/webview-provider.ts diff --git a/apps/vscode/assets/icon.svg b/apps/vscode/assets/icon.svg new file mode 100644 index 0000000..bc08ad1 --- /dev/null +++ b/apps/vscode/assets/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/apps/vscode/src/export.ts b/apps/vscode/src/export.ts new file mode 100644 index 0000000..8b1213c --- /dev/null +++ b/apps/vscode/src/export.ts @@ -0,0 +1,117 @@ +import * as vscode from 'vscode'; +import * as http from 'http'; +import * as https from 'https'; +import type { Schema } from './messages'; +import { svgToPdf } from './wasm-host'; + +let currentSchema: Schema = {}; +let currentSvg = ''; + +export function setCurrentSchema(schema: Schema): void { + currentSchema = schema; +} + +export function setCurrentSvg(svg: string): void { + currentSvg = svg; +} + +export async function exportSvg(): Promise { + if (!currentSvg) { + vscode.window.showErrorMessage('Vespertide: ERD SVG가 아직 생성되지 않았습니다.'); + return undefined; + } + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file('erd.svg'), + filters: { 'SVG 파일': ['svg'] }, + }); + if (!uri) return undefined; + + await vscode.workspace.fs.writeFile(uri, Buffer.from(currentSvg, 'utf-8')); + vscode.window.showInformationMessage(`Vespertide: SVG 저장 완료 — ${uri.fsPath}`); + return uri.fsPath; +} + +export async function exportPdf(): Promise { + if (!currentSvg) { + vscode.window.showErrorMessage('Vespertide: ERD SVG가 아직 생성되지 않았습니다.'); + return undefined; + } + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file('erd.pdf'), + filters: { 'PDF 파일': ['pdf'] }, + }); + if (!uri) return undefined; + + // 1) Try WASM svg_to_pdf + let pdfBuffer = await svgToPdf(currentSvg); + + // 2) Fallback: jsPDF + if (!pdfBuffer) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const { jsPDF } = require('jspdf') as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const doc = new jsPDF({ orientation: 'landscape', unit: 'px', format: [800, 600] }) as any; + const svgDataUrl = + 'data:image/svg+xml;base64,' + Buffer.from(currentSvg).toString('base64'); + doc.addImage(svgDataUrl, 'JPEG', 0, 0, 800, 600); + pdfBuffer = Buffer.from(doc.output('arraybuffer') as ArrayBuffer); + } catch { + vscode.window.showErrorMessage( + 'Vespertide: PDF 변환 실패 — jspdf가 설치되어 있지 않습니다. (npm install jspdf)' + ); + return undefined; + } + } + + await vscode.workspace.fs.writeFile(uri, pdfBuffer); + vscode.window.showInformationMessage(`Vespertide: PDF 저장 완료 — ${uri.fsPath}`); + return uri.fsPath; +} + +export async function exportMcp(schema: Schema): Promise { + const config = vscode.workspace.getConfiguration('vespertide'); + const endpoint: string = config.get('mcpEndpoint') ?? 'http://localhost:3456'; + + const body = JSON.stringify(schema); + + let url: URL; + try { + url = new URL(endpoint); + } catch { + vscode.window.showErrorMessage(`Vespertide: 잘못된 MCP 엔드포인트 URL — ${endpoint}`); + return; + } + + const options: http.RequestOptions = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname || '/', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + await new Promise((resolve, reject) => { + const transport = url.protocol === 'https:' ? https : http; + const req = transport.request(options, (res) => { + const { statusCode = 0 } = res; + if (statusCode >= 200 && statusCode < 300) { + resolve(); + } else { + reject(new Error(`HTTP ${statusCode}`)); + } + }); + req.on('error', reject); + req.write(body); + req.end(); + }).then( + () => vscode.window.showInformationMessage(`Vespertide: MCP 전송 완료 → ${endpoint}`), + (err: Error) => + vscode.window.showErrorMessage(`Vespertide: MCP 전송 실패 — ${err.message}`) + ); +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts new file mode 100644 index 0000000..1eb8be8 --- /dev/null +++ b/apps/vscode/src/extension.ts @@ -0,0 +1,16 @@ +import * as vscode from 'vscode'; +import { VespertideWebviewProvider } from './webview-provider'; + +export function activate(context: vscode.ExtensionContext): void { + const provider = new VespertideWebviewProvider(context.extensionUri); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + VespertideWebviewProvider.viewType, + provider, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); +} + +export function deactivate(): void {} diff --git a/apps/vscode/src/messages.ts b/apps/vscode/src/messages.ts new file mode 100644 index 0000000..861ae2d --- /dev/null +++ b/apps/vscode/src/messages.ts @@ -0,0 +1,22 @@ +export type OrmType = 'prisma' | 'typeorm' | 'drizzle' | 'jpa' | 'sqlalchemy' | 'gorm'; +export type DbDialect = 'postgres' | 'mysql' | 'sqlite'; + +/** Opaque schema object — passed between host and webview */ +export type Schema = Record; + +// Webview → Host +export type WebviewMessage = + | { type: 'parse_orm'; source: string; orm: OrmType } + | { type: 'convert_orm'; source: string; from: OrmType; to: OrmType } + | { type: 'generate_migration'; schema: Schema; db: DbDialect } + | { type: 'export_svg' } + | { type: 'export_pdf' } + | { type: 'export_mcp'; schema: Schema }; + +// Host → Webview +export type HostMessage = + | { type: 'erd_updated'; svg: string } + | { type: 'orm_converted'; source: string } + | { type: 'migration_updated'; postgres: string; mysql: string; sqlite: string } + | { type: 'export_done'; path?: string } + | { type: 'error'; message: string }; diff --git a/apps/vscode/src/wasm-host.ts b/apps/vscode/src/wasm-host.ts new file mode 100644 index 0000000..71f528c --- /dev/null +++ b/apps/vscode/src/wasm-host.ts @@ -0,0 +1,73 @@ +/** + * WASM host wrapper. + * + * Stubs are active until packages/wasm is built and linked. + * To connect real WASM: + * 1. Build: `cargo build -p vespertide-wasm --target wasm32-unknown-unknown` + * 2. Run wasm-bindgen to produce JS glue + .wasm file + * 3. Copy output into `dist/wasm/` + * 4. Uncomment the import below and replace stub bodies + */ + +// TODO: uncomment when packages/wasm is ready +// import init, { parse_orm, render_erd, convert_orm, generate_migration, svg_to_pdf } +// from '../../packages/wasm/pkg'; + +import type { OrmType, DbDialect, Schema } from './messages'; + +let initialized = false; + +export async function initWasm(_extensionPath: string): Promise { + if (initialized) return; + // TODO: const wasmPath = path.join(_extensionPath, 'dist', 'wasm', 'vespertide_bg.wasm'); + // await init(wasmPath); + initialized = true; +} + +export async function parseOrm(source: string, orm: OrmType): Promise { + // TODO: return parse_orm(source, orm); + return { _stub: true, orm, source } as Schema; +} + +export async function renderErd(_schema: Schema): Promise { + // TODO: return render_erd(_schema); + return ` + + + User + + id: Int + name: String + + Post + + id: Int + userId: Int + + WASM 연결 전 미리보기 +`; +} + +export async function convertOrm(source: string, from: OrmType, to: OrmType): Promise { + // TODO: return convert_orm(source, from, to); + return `// [stub] ${from} → ${to} 변환\n// packages/wasm 연결 후 실제 변환이 동작합니다\n\n${source}`; +} + +export async function generateMigration(schema: Schema, db: DbDialect): Promise { + // TODO: return generate_migration(schema, db); + const dialect = { postgres: 'PostgreSQL', mysql: 'MySQL', sqlite: 'SQLite' }[db]; + return `-- [stub] ${dialect} 마이그레이션\n-- packages/wasm 연결 후 실제 SQL이 생성됩니다\n\nCREATE TABLE IF NOT EXISTS example (\n id SERIAL PRIMARY KEY,\n created_at TIMESTAMP NOT NULL DEFAULT NOW()\n);`; +} + +/** Returns null if WASM svg_to_pdf is not available (fallback handled in export.ts) */ +export async function svgToPdf(_svg: string): Promise { + // TODO: return Buffer.from(svg_to_pdf(_svg)); + return null; +} diff --git a/apps/vscode/src/webview-provider.ts b/apps/vscode/src/webview-provider.ts new file mode 100644 index 0000000..d031c9b --- /dev/null +++ b/apps/vscode/src/webview-provider.ts @@ -0,0 +1,178 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { WebviewMessage, HostMessage } from './messages'; +import { initWasm, parseOrm, renderErd, convertOrm, generateMigration } from './wasm-host'; +import { + exportSvg, + exportPdf, + exportMcp, + setCurrentSchema, + setCurrentSvg, +} from './export'; + +export class VespertideWebviewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'vespertide.mainPanel'; + + private _view?: vscode.WebviewView; + + constructor(private readonly _extensionUri: vscode.Uri) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview'), + vscode.Uri.joinPath(this._extensionUri, 'assets'), + ], + }; + + webviewView.webview.html = this._buildHtml(webviewView.webview); + + webviewView.webview.onDidReceiveMessage(async (msg: WebviewMessage) => { + await this._handleMessage(msg); + }); + + // Initialize WASM after panel resolves + initWasm(this._extensionUri.fsPath).catch(console.error); + } + + // ── Message handler ──────────────────────────────────────────────────────── + + private async _handleMessage(msg: WebviewMessage): Promise { + try { + switch (msg.type) { + case 'parse_orm': { + const schema = await parseOrm(msg.source, msg.orm); + setCurrentSchema(schema); + const svg = await renderErd(schema); + setCurrentSvg(svg); + this._post({ type: 'erd_updated', svg }); + break; + } + + case 'convert_orm': { + const source = await convertOrm(msg.source, msg.from, msg.to); + this._post({ type: 'orm_converted', source }); + break; + } + + case 'generate_migration': { + const [postgres, mysql, sqlite] = await Promise.all([ + generateMigration(msg.schema, 'postgres'), + generateMigration(msg.schema, 'mysql'), + generateMigration(msg.schema, 'sqlite'), + ]); + this._post({ type: 'migration_updated', postgres, mysql, sqlite }); + break; + } + + case 'export_svg': { + const filePath = await exportSvg(); + this._post({ type: 'export_done', path: filePath }); + break; + } + + case 'export_pdf': { + const filePath = await exportPdf(); + this._post({ type: 'export_done', path: filePath }); + break; + } + + case 'export_mcp': { + await exportMcp(msg.schema); + this._post({ type: 'export_done' }); + break; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._post({ type: 'error', message }); + } + } + + private _post(msg: HostMessage): void { + this._view?.webview.postMessage(msg); + } + + // ── HTML construction ────────────────────────────────────────────────────── + + private _buildHtml(webview: vscode.Webview): string { + const webviewDist = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview'); + const indexPath = path.join(this._extensionUri.fsPath, 'dist', 'webview', 'index.html'); + + if (fs.existsSync(indexPath)) { + const nonce = randomNonce(); + let html = fs.readFileSync(indexPath, 'utf-8'); + + // Rewrite asset paths to webview URIs + html = html.replace(/(src|href)="(\.\/[^"]+|\/[^"]+)"/g, (_m, attr, val) => { + const relative = val.replace(/^\//, '').replace(/^\.\//, ''); + const uri = webview.asWebviewUri(vscode.Uri.joinPath(webviewDist, relative)); + return `${attr}="${uri}"`; + }); + + // Inject CSP + const csp = [ + `default-src 'none'`, + `script-src 'nonce-${nonce}' 'unsafe-eval'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `img-src ${webview.cspSource} data:`, + `font-src ${webview.cspSource}`, + ].join('; '); + + html = html.replace('', ``); + // Add nonce to all inline scripts + html = html.replace(/ chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +function buildFallbackHtml(): string { + return /* html */ ` + + + + + Vespertide + + + +

Vespertide

+

Webview 빌드가 필요합니다.

+

1. cd apps/vscode/webview && npm install && npm run build

+

2. cd apps/vscode && npm install && npm run build:ext

+

3. VS Code에서 F5 로 Extension 재시작

+ +`; +} From faf8776f1c0c0e3f2a398947debf1d6d1c15221a Mon Sep 17 00:00:00 2001 From: Eunseo Song <162149585+eunseo9311@users.noreply.github.com> Date: Sat, 9 May 2026 17:31:35 +0900 Subject: [PATCH 03/43] feat(vscode): add webview React app scaffold - package.json + tsconfig.json + vite.config.ts - index.html entry point - vscode.ts: acquireVsCodeApi shim and message type definitions - main.tsx: React root - global.css: VS Code CSS variable theming (dark/light auto) --- apps/vscode/webview/index.html | 12 ++++++ apps/vscode/webview/package.json | 22 ++++++++++ apps/vscode/webview/src/main.tsx | 10 +++++ apps/vscode/webview/src/styles/global.css | 40 ++++++++++++++++++ apps/vscode/webview/src/vscode.ts | 51 +++++++++++++++++++++++ apps/vscode/webview/tsconfig.json | 17 ++++++++ apps/vscode/webview/vite.config.ts | 20 +++++++++ 7 files changed, 172 insertions(+) create mode 100644 apps/vscode/webview/index.html create mode 100644 apps/vscode/webview/package.json create mode 100644 apps/vscode/webview/src/main.tsx create mode 100644 apps/vscode/webview/src/styles/global.css create mode 100644 apps/vscode/webview/src/vscode.ts create mode 100644 apps/vscode/webview/tsconfig.json create mode 100644 apps/vscode/webview/vite.config.ts diff --git a/apps/vscode/webview/index.html b/apps/vscode/webview/index.html new file mode 100644 index 0000000..973f27f --- /dev/null +++ b/apps/vscode/webview/index.html @@ -0,0 +1,12 @@ + + + + + + Vespertide + + +
+ + + diff --git a/apps/vscode/webview/package.json b/apps/vscode/webview/package.json new file mode 100644 index 0000000..6d7528a --- /dev/null +++ b/apps/vscode/webview/package.json @@ -0,0 +1,22 @@ +{ + "name": "vespertide-webview", + "private": true, + "version": "0.1.61", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "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/vscode/webview/src/main.tsx b/apps/vscode/webview/src/main.tsx new file mode 100644 index 0000000..dc3f4b5 --- /dev/null +++ b/apps/vscode/webview/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/global.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/vscode/webview/src/styles/global.css b/apps/vscode/webview/src/styles/global.css new file mode 100644 index 0000000..a7ecf1f --- /dev/null +++ b/apps/vscode/webview/src/styles/global.css @@ -0,0 +1,40 @@ +*, *::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; +} + +/* 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/vscode/webview/src/vscode.ts b/apps/vscode/webview/src/vscode.ts new file mode 100644 index 0000000..d9236f7 --- /dev/null +++ b/apps/vscode/webview/src/vscode.ts @@ -0,0 +1,51 @@ +/** + * VS Code Webview API shim. + * acquireVsCodeApi() is injected by VS Code at runtime. + * In browser dev mode this gracefully no-ops. + */ + +declare function acquireVsCodeApi(): { + postMessage(message: unknown): void; + getState(): unknown; + setState(state: unknown): void; +}; + +export type OrmType = 'prisma' | 'typeorm' | 'drizzle' | 'jpa' | 'sqlalchemy' | 'gorm'; +export type DbDialect = 'postgres' | 'mysql' | 'sqlite'; +export type Schema = Record; + +// Webview → Host +export type WebviewMessage = + | { type: 'parse_orm'; source: string; orm: OrmType } + | { type: 'convert_orm'; source: string; from: OrmType; to: OrmType } + | { type: 'generate_migration'; schema: Schema; db: DbDialect } + | { type: 'export_svg' } + | { type: 'export_pdf' } + | { type: 'export_mcp'; schema: Schema }; + +// Host → Webview +export type HostMessage = + | { type: 'erd_updated'; svg: string } + | { type: 'orm_converted'; source: string } + | { type: 'migration_updated'; postgres: string; mysql: string; sqlite: string } + | { type: 'export_done'; path?: string } + | { type: 'error'; message: string }; + +// Singleton API handle +let _api: ReturnType | undefined; +function getApi() { + if (!_api && typeof acquireVsCodeApi === 'function') { + _api = acquireVsCodeApi(); + } + return _api; +} + +export function postMessage(msg: WebviewMessage): void { + getApi()?.postMessage(msg); +} + +export function onMessage(handler: (msg: HostMessage) => void): () => void { + const listener = (event: MessageEvent) => handler(event.data as HostMessage); + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +} diff --git a/apps/vscode/webview/tsconfig.json b/apps/vscode/webview/tsconfig.json new file mode 100644 index 0000000..42650fe --- /dev/null +++ b/apps/vscode/webview/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/apps/vscode/webview/vite.config.ts b/apps/vscode/webview/vite.config.ts new file mode 100644 index 0000000..8004fe0 --- /dev/null +++ b/apps/vscode/webview/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: resolve(__dirname, '../dist/webview'), + emptyOutDir: true, + rollupOptions: { + output: { + // Stable filenames — required for webview URI mapping + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + }, + base: './', +}); From 53ede7e59109a4d6a6653e6c167971307c69edf7 Mon Sep 17 00:00:00 2001 From: Eunseo Song <162149585+eunseo9311@users.noreply.github.com> Date: Sat, 9 May 2026 17:31:42 +0900 Subject: [PATCH 04/43] feat(vscode): add webview React tab components - App.tsx: tab bar routing and HostMessage subscription - OrmEditor: code textarea + ERD SVG panel (300ms debounce) - OrmConverter: ORM selector buttons + conversion confirmation - MigrationDiff: 3-column side-by-side SQL view with copy buttons - Export: SVG / PDF / MCP export action cards --- apps/vscode/webview/src/App.tsx | 135 ++++++++++++++ apps/vscode/webview/src/tabs/Export.tsx | 137 ++++++++++++++ .../vscode/webview/src/tabs/MigrationDiff.tsx | 146 +++++++++++++++ apps/vscode/webview/src/tabs/OrmConverter.tsx | 169 ++++++++++++++++++ apps/vscode/webview/src/tabs/OrmEditor.tsx | 149 +++++++++++++++ 5 files changed, 736 insertions(+) create mode 100644 apps/vscode/webview/src/App.tsx create mode 100644 apps/vscode/webview/src/tabs/Export.tsx create mode 100644 apps/vscode/webview/src/tabs/MigrationDiff.tsx create mode 100644 apps/vscode/webview/src/tabs/OrmConverter.tsx create mode 100644 apps/vscode/webview/src/tabs/OrmEditor.tsx diff --git a/apps/vscode/webview/src/App.tsx b/apps/vscode/webview/src/App.tsx new file mode 100644 index 0000000..69fa7e2 --- /dev/null +++ b/apps/vscode/webview/src/App.tsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react'; +import { onMessage } from './vscode'; +import type { HostMessage, OrmType, Schema } from './vscode'; +import OrmEditor from './tabs/OrmEditor'; +import OrmConverter from './tabs/OrmConverter'; +import MigrationDiff from './tabs/MigrationDiff'; +import Export from './tabs/Export'; + +type Tab = 'editor' | 'converter' | 'migration' | 'export'; + +const TABS: { id: Tab; label: string }[] = [ + { id: 'editor', label: 'ORM Editor' }, + { id: 'converter', label: 'Converter' }, + { 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; +}; + +const INITIAL: AppState = { + ormSource: '', + ormType: 'prisma', + svg: '', + schema: {}, + postgres: '', + mysql: '', + sqlite: '', + error: null, +}; + +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 'orm_converted': + return { ...prev, ormSource: msg.source, 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 ( +
+ {/* ── Tab bar ── */} +
+ {TABS.map(({ id, label }) => ( + + ))} +
+ + {/* ── Error banner ── */} + {state.error && ( +
+ {state.error} +
+ )} + + {/* ── Tab content ── */} +
+ {tab === 'editor' && } + {tab === 'converter' && } + {tab === 'migration' && } + {tab === 'export' && } +
+
+ ); +} diff --git a/apps/vscode/webview/src/tabs/Export.tsx b/apps/vscode/webview/src/tabs/Export.tsx new file mode 100644 index 0000000..c4261ba --- /dev/null +++ b/apps/vscode/webview/src/tabs/Export.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import { postMessage } from '../vscode'; +import type { AppState } from '../App'; + +type Props = { + state: AppState; + setState: React.Dispatch>; +}; + +type Status = 'idle' | 'loading' | 'done'; + +type ExportItem = { + key: string; + title: string; + description: string; + onClick: () => void; + disabled?: boolean; +}; + +export default function Export({ state }: Props) { + const [statuses, setStatuses] = useState>({}); + + const setStatus = (key: string, s: Status) => + setStatuses((prev) => ({ ...prev, [key]: s })); + + const trigger = (key: string, send: () => void) => { + setStatus(key, 'loading'); + send(); + // Reset after 3 s (actual completion is reflected via HostMessage) + setTimeout(() => setStatus(key, 'idle'), 3000); + }; + + const hasErD = !!state.svg; + const hasSchema = Object.keys(state.schema).length > 0; + + const items: ExportItem[] = [ + { + key: 'svg', + title: 'SVG 다운로드', + description: 'ERD 다이어그램을 SVG 벡터 파일로 저장합니다.', + onClick: () => trigger('svg', () => postMessage({ type: 'export_svg' })), + disabled: !hasErD, + }, + { + key: 'pdf', + title: 'PDF 변환', + description: 'ERD 다이어그램을 PDF 파일로 변환합니다.', + onClick: () => trigger('pdf', () => postMessage({ type: 'export_pdf' })), + disabled: !hasErD, + }, + { + key: 'mcp', + title: 'MCP 내보내기', + description: + 'Schema JSON을 MCP 서버 엔드포인트로 전송합니다. (설정: vespertide.mcpEndpoint)', + onClick: () => + trigger('mcp', () => postMessage({ type: 'export_mcp', schema: state.schema })), + disabled: !hasSchema, + }, + ]; + + return ( +
+

+ ERD 및 스키마를 다양한 형식으로 내보냅니다. +

+ + {items.map(({ key, title, description, onClick, disabled }) => ( +
+
+
{title}
+
{description}
+
+ +
+ ))} + + {!hasErD && !hasSchema && ( +
+ ORM Editor 탭에서 스키마를 입력하면 내보내기 기능이 활성화됩니다. +
+ )} +
+ ); +} diff --git a/apps/vscode/webview/src/tabs/MigrationDiff.tsx b/apps/vscode/webview/src/tabs/MigrationDiff.tsx new file mode 100644 index 0000000..e1e8182 --- /dev/null +++ b/apps/vscode/webview/src/tabs/MigrationDiff.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useRef } from 'react'; +import { postMessage } from '../vscode'; +import type { AppState } from '../App'; + +type Props = { + state: AppState; + setState: React.Dispatch>; +}; + +type Column = { label: string; sql: string }; + +export default function MigrationDiff({ state, setState: _setState }: Props) { + const lastSchemaRef = useRef(''); + + // Re-generate when schema changes (or on first mount if schema exists) + useEffect(() => { + const key = JSON.stringify(state.schema); + if (key === lastSchemaRef.current) return; + if (!Object.keys(state.schema).length) return; + lastSchemaRef.current = key; + + // Send one message; host will call generateMigration for all 3 dialects in parallel + postMessage({ type: 'generate_migration', schema: state.schema, db: 'postgres' }); + }, [state.schema]); + + const columns: Column[] = [ + { label: 'PostgreSQL', sql: state.postgres }, + { label: 'MySQL', sql: state.mysql }, + { label: 'SQLite', sql: state.sqlite }, + ]; + + const copy = (text: string) => navigator.clipboard.writeText(text).catch(console.error); + + const empty = !state.postgres && !state.mysql && !state.sqlite; + + return ( +
+ {/* Header */} +
+ 현재 스키마 기준 마이그레이션 SQL — 3개 DB 방언 비교 +
+ + {empty && ( +
+
+
ORM Editor 탭에서 스키마를 입력하면
+
마이그레이션 SQL이 여기에 표시됩니다
+
+
+ )} + + {/* 3-column side-by-side */} + {!empty && ( +
+ {columns.map(({ label, sql }, i) => ( + + {i > 0 && ( +
+ )} +
+ {/* Column header */} +
+ {label} + {sql && ( + + )} +
+ + {/* SQL body */} +
+                  {sql || '—'}
+                
+
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/vscode/webview/src/tabs/OrmConverter.tsx b/apps/vscode/webview/src/tabs/OrmConverter.tsx new file mode 100644 index 0000000..6331653 --- /dev/null +++ b/apps/vscode/webview/src/tabs/OrmConverter.tsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +import { postMessage } from '../vscode'; +import type { OrmType } from '../vscode'; +import type { AppState } from '../App'; + +type Props = { + state: AppState; + setState: React.Dispatch>; +}; + +const ORM_LABELS: Record = { + prisma: 'Prisma', + typeorm: 'TypeORM', + drizzle: 'Drizzle', + jpa: 'JPA', + sqlalchemy: 'SQLAlchemy', + gorm: 'GORM', +}; + +const ORM_TYPES = Object.keys(ORM_LABELS) as OrmType[]; + +export default function OrmConverter({ state, setState }: Props) { + const [target, setTarget] = useState(null); + + const canConvert = target !== null && target !== state.ormType && !!state.ormSource; + + const handleConvert = () => { + if (!canConvert || !target) return; + postMessage({ type: 'convert_orm', source: state.ormSource, from: state.ormType, to: target }); + setState((prev) => ({ ...prev, ormType: target })); + setTarget(null); + }; + + return ( +
+ {/* ORM buttons */} +
+
+ 변환할 대상 ORM 선택 +
+
+ {ORM_TYPES.map((orm) => { + const isCurrent = orm === state.ormType; + const isTarget = orm === target; + return ( + + ); + })} +
+
+ + {/* Conversion confirmation bar */} + {target && target !== state.ormType && ( +
+ + {ORM_LABELS[state.ormType]} + {' → '} + {ORM_LABELS[target]} + {' 으로 변환'} + + +
+ )} + + {!state.ormSource && ( +

+ ORM Editor 탭에서 먼저 스키마 코드를 입력하세요. +

+ )} + + {/* Current source preview */} + {state.ormSource && ( +
+
+ 현재 스키마 ({ORM_LABELS[state.ormType]}) +
+
+            {state.ormSource}
+          
+
+ )} +
+ ); +} diff --git a/apps/vscode/webview/src/tabs/OrmEditor.tsx b/apps/vscode/webview/src/tabs/OrmEditor.tsx new file mode 100644 index 0000000..54d2224 --- /dev/null +++ b/apps/vscode/webview/src/tabs/OrmEditor.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useRef } from 'react'; +import { postMessage } from '../vscode'; +import type { OrmType } from '../vscode'; +import type { AppState } from '../App'; + +type Props = { + state: AppState; + setState: React.Dispatch>; +}; + +const ORM_TYPES: OrmType[] = ['prisma', 'typeorm', 'drizzle', 'jpa', 'sqlalchemy', 'gorm']; + +const PLACEHOLDER: Record = { + prisma: `model User {\n id Int @id @default(autoincrement())\n name String\n posts Post[]\n}\n\nmodel Post {\n id Int @id @default(autoincrement())\n userId Int\n user User @relation(fields: [userId], references: [id])\n}`, + typeorm: `@Entity()\nexport class User {\n @PrimaryGeneratedColumn() id: number;\n @Column() name: string;\n}`, + drizzle: `export const users = pgTable('users', {\n id: serial('id').primaryKey(),\n name: text('name').notNull(),\n});`, + jpa: `@Entity\npublic class User {\n @Id @GeneratedValue\n private Long id;\n private String name;\n}`, + sqlalchemy: `class User(Base):\n __tablename__ = 'users'\n id = Column(Integer, primary_key=True)\n name = Column(String, nullable=False)`, + gorm: `type User struct {\n gorm.Model\n Name string\n}`, +}; + +export default function OrmEditor({ state, setState }: Props) { + const debounceRef = useRef | null>(null); + + const triggerParse = useCallback( + (source: string, orm: OrmType) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + postMessage({ type: 'parse_orm', source, orm }); + }, 300); + }, + [] + ); + + const handleChange = (source: string) => { + setState((prev) => ({ ...prev, ormSource: source })); + triggerParse(source, state.ormType); + }; + + const handleOrmType = (orm: OrmType) => { + setState((prev) => ({ ...prev, ormType: orm })); + if (state.ormSource) triggerParse(state.ormSource, orm); + }; + + return ( +
+ {/* ORM selector */} +
+ {ORM_TYPES.map((orm) => ( + + ))} +
+ + {/* Split: editor | ERD */} +
+ {/* Code textarea */} +