├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── biome.json ├── media ├── logo_dark.svg ├── logo_light.svg ├── readme │ ├── banner.jpg │ ├── example.gif │ ├── logo.png │ └── usage.jpg └── uml.svg ├── package.json ├── src ├── core │ └── render.ts ├── extension.ts ├── panels │ └── prisma-uml-panel.ts └── utilities │ ├── getNonce.ts │ └── getUri.ts ├── test ├── run-test.js └── suite │ ├── extension.test.js │ └── index.js ├── tsconfig.json └── webview-ui ├── .gitignore ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── src ├── App.tsx ├── components │ ├── EnumNode.tsx │ ├── ModelNode.tsx │ ├── SchemaVisualizer.tsx │ └── icons │ │ ├── IDownload.tsx │ │ └── props.ts ├── globals.css ├── index.tsx ├── lib │ ├── contexts │ │ └── theme.tsx │ ├── hooks │ │ └── useGraph.ts │ ├── types │ │ └── schema.ts │ └── utils │ │ ├── colots.ts │ │ ├── layout-utils.ts │ │ └── screnshot.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.app.tsbuildinfo ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.node.tsbuildinfo └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode-test/ 3 | *.vsix 4 | 5 | package-lock.json 6 | dist -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "ms-vscode.extension-test-runner" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that launches the extension inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": [ 21 | "--extensionDevelopmentPath=${workspaceFolder}", 22 | "--extensionTestsPath=${workspaceFolder}/test/suite/index" 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "editor.defaultFormatter": "biomejs.biome", 12 | "editor.formatOnSave": true 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | test/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/jsconfig.json 8 | **/*.map 9 | **/.eslintrc.json 10 | 11 | .prettierrc 12 | node_modules 13 | package-lock.json 14 | pnpm-lock.yaml 15 | src/** 16 | 17 | webview-ui/** 18 | !webview-ui/build/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Abian Suarez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | logo 4 |
5 |
6 | Prisma Generate UML is a VSCode extension that quickly creates UML diagrams from Prisma schemas with a single click, offering easy visualization. 7 |
8 |
9 |

10 | 11 | > _You can download final bundles from the [Releases](https://github.com/AbianS/prisma-generate-uml/releases) section._ 12 | 13 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) ![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white) ![Prisma ORM](https://img.shields.io/badge/Prisma-2D3748?style=for-the-badge&logo=prisma&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) ![esbuild](https://img.shields.io/badge/esbuild-FFCF00?style=for-the-badge&logo=esbuild&logoColor=white) ![Biome](https://img.shields.io/badge/Biome-009688?style=for-the-badge&logo=biome&logoColor=white) 14 | 15 | > [!NOTE] 16 | > 🚧 17 | > **Prisma Generate UML** is currently under development. Stay tuned for more updates! 18 | 19 | ## ✨ Features 20 | 21 | - 🔥 **Instant UML Diagrams**: Generate UML diagrams from Prisma schemas with a single click. 22 | - 🖼 **Easy Visualization**: Simplify data architecture visualization in an exciting way. 23 | - 🛠 **Seamless Integration**: Works seamlessly within VSCode, no extra configuration required. 24 | - 📂 **Multi-file Prisma Schema Support**: We fully support Prisma's `prismaSchemaFolder` feature, allowing you to split your schema into multiple files while still generating a complete UML diagram of your entire database. 25 | - 🔃 **Automatic Updates**: We'll keep your UML diagrams up-to-date with the latest changes to your Prisma schema. 26 | 27 | ## 🔍 What It Does 28 | 29 | Get ready to breathe life into your data models! ✨ With our extension, creating UML diagrams from your Prisma files is as easy as it gets. 30 | 31 | 🚀 When you open your Prisma schema, the UML icon at the top of the editor becomes your magic wand. A simple click, and presto! Your UML model springs to life in an instant. 32 | 33 | Say goodbye to boring documentation and hello to the dazzling representation of your database architecture. 34 | 35 | Transform your Prisma definitions into a stunning UML diagram with ease and dive into the excitement of data visualization! 🪄💎 36 | 37 | ![Example](media/readme/example.gif) 38 | 39 | ## 🚀 How to Use 40 | 41 | Generate UML diagrams with a single click: 42 | 43 | 1. Open your Prisma file. 44 | 2. Look for the UML icon at the top of the editor. 45 | 3. Click it, and you're done! Your UML diagram will be created instantly. 46 | 47 | Simplify data architecture visualization in an exciting way! 🚀 48 | 49 | ![usage](media/readme/usage.jpg) 50 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "formatter": { 4 | "ignore": [ 5 | "node_modules", 6 | "dist", 7 | ".next", 8 | "logs", 9 | "postgres", 10 | ".turbo", 11 | "build" 12 | ], 13 | "enabled": true, 14 | "formatWithErrors": false, 15 | "indentStyle": "space", 16 | "indentWidth": 2, 17 | "lineEnding": "lf", 18 | "lineWidth": 80, 19 | "attributePosition": "auto" 20 | }, 21 | "organizeImports": { 22 | "enabled": true, 23 | "ignore": [ 24 | "node_modules", 25 | "dist", 26 | ".next", 27 | "logs", 28 | "postgres", 29 | ".turbo", 30 | "build" 31 | ] 32 | }, 33 | "linter": { 34 | "enabled": true, 35 | "rules": { 36 | "recommended": true, 37 | "correctness": { 38 | "noChildrenProp": "off", 39 | "useExhaustiveDependencies": "off", 40 | "noUnsafeOptionalChaining": "off", 41 | "noSwitchDeclarations": "off", 42 | "useJsxKeyInIterable": "off", 43 | "noUnusedImports": "warn" 44 | }, 45 | "performance": { 46 | "noAccumulatingSpread": "off" 47 | }, 48 | "style": { 49 | "useFilenamingConvention": { 50 | "level": "error", 51 | "options": { 52 | "strictCase": true, 53 | "filenameCases": ["kebab-case", "export"], 54 | "requireAscii": true 55 | } 56 | }, 57 | "noNonNullAssertion": "off", 58 | "noParameterAssign": "off", 59 | "useTemplate": "off", 60 | "useImportType": "off", 61 | "noUselessElse": "off", 62 | "useShorthandFunctionType": "off", 63 | "useEnumInitializers": "off", 64 | "useDefaultParameterLast": "off" 65 | }, 66 | "complexity": { 67 | "noStaticOnlyClass": "off", 68 | "noBannedTypes": "off", 69 | "noForEach": "off", 70 | "useLiteralKeys": "off" 71 | }, 72 | "a11y": { 73 | "useButtonType": "off", 74 | "useKeyWithClickEvents": "off", 75 | "noSvgWithoutTitle": "off" 76 | }, 77 | "suspicious": { 78 | "noExplicitAny": "off", 79 | "noArrayIndexKey": "off", 80 | "noPrototypeBuiltins": "off" 81 | } 82 | }, 83 | "ignore": [ 84 | "node_modules", 85 | "dist", 86 | ".next", 87 | "logs", 88 | "postgres", 89 | ".turbo", 90 | "build" 91 | ] 92 | }, 93 | "javascript": { 94 | "parser": { 95 | "unsafeParameterDecoratorsEnabled": true 96 | }, 97 | "formatter": { 98 | "jsxQuoteStyle": "double", 99 | "quoteProperties": "asNeeded", 100 | "trailingCommas": "all", 101 | "semicolons": "always", 102 | "arrowParentheses": "always", 103 | "bracketSpacing": true, 104 | "bracketSameLine": false, 105 | "quoteStyle": "single", 106 | "attributePosition": "auto" 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /media/logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/logo_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/readme/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbianS/prisma-generate-uml/5bab480b7835c90c153f1283d1a9a9579d55a47c/media/readme/banner.jpg -------------------------------------------------------------------------------- /media/readme/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbianS/prisma-generate-uml/5bab480b7835c90c153f1283d1a9a9579d55a47c/media/readme/example.gif -------------------------------------------------------------------------------- /media/readme/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbianS/prisma-generate-uml/5bab480b7835c90c153f1283d1a9a9579d55a47c/media/readme/logo.png -------------------------------------------------------------------------------- /media/readme/usage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbianS/prisma-generate-uml/5bab480b7835c90c153f1283d1a9a9579d55a47c/media/readme/usage.jpg -------------------------------------------------------------------------------- /media/uml.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-generate-uml", 3 | "displayName": "Prisma Generate UML", 4 | "description": "Generate UML Diagram from prisma schema", 5 | "version": "3.4.0", 6 | "icon": "media/readme/logo.png", 7 | "repository": "https://github.com/AbianS/prisma-generate-uml", 8 | "publisher": "AbianS", 9 | "engines": { 10 | "vscode": "^1.83.0" 11 | }, 12 | "categories": ["Other"], 13 | "activationEvents": ["onStartupFinished"], 14 | "main": "./dist/extension.js", 15 | "browser": "./dist/extension.js", 16 | "contributes": { 17 | "commands": [ 18 | { 19 | "command": "prisma-generate-uml.generateUML", 20 | "title": "Generate Prisma UML", 21 | "icon": { 22 | "light": "./media/logo_light.svg", 23 | "dark": "./media/logo_dark.svg" 24 | } 25 | }, 26 | { 27 | "command": "hello-world.showHelloWorld", 28 | "title": "Hello World (React + Vite): Show" 29 | } 30 | ], 31 | "menus": { 32 | "editor/title": [ 33 | { 34 | "when": "editorLangId == prisma", 35 | "command": "prisma-generate-uml.generateUML", 36 | "group": "navigation" 37 | } 38 | ] 39 | } 40 | }, 41 | "scripts": { 42 | "install:all": "npm install && cd webview-ui && npm install", 43 | "start:webview": "cd webview-ui && npm run dev", 44 | "build:webview": "cd webview-ui && npm run build", 45 | "vscode:prepublish": "npm run esbuild -- --minify && npm run copy", 46 | "copy": "shx cp node_modules/@prisma/prisma-schema-wasm/src/prisma_schema_build_bg.wasm dist/", 47 | "esbuild": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", 48 | "compile": "tsc -p ./", 49 | "watch": "tsc -watch -p ./", 50 | "pretest": "npm run compile && npm run lint", 51 | "lint": "biome lint .", 52 | "lint:fix": "biome check . --write", 53 | "test": "vscode-test" 54 | }, 55 | "devDependencies": { 56 | "@biomejs/biome": "1.9.4", 57 | "@types/mocha": "^10.0.10", 58 | "@types/node": "22.x", 59 | "@types/vscode": "^1.83.0", 60 | "@vscode/test-cli": "^0.0.10", 61 | "@vscode/test-electron": "^2.4.1", 62 | "esbuild": "^0.24.2", 63 | "shx": "^0.3.4", 64 | "typescript": "^5.7.2" 65 | }, 66 | "dependencies": { 67 | "@microsoft/vscode-file-downloader-api": "^1.0.1", 68 | "@prisma/internals": "^6.1.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/render.ts: -------------------------------------------------------------------------------- 1 | import { DMMF } from '@prisma/generator-helper'; 2 | 3 | export type Model = { 4 | name: string; 5 | fields: { 6 | name: string; 7 | type: string; 8 | hasConnections?: boolean; 9 | }[]; 10 | isChild?: boolean; 11 | }; 12 | 13 | export type Enum = { 14 | name: string; 15 | values: string[]; 16 | }; 17 | 18 | export type ModelConnection = { 19 | target: string; 20 | source: string; 21 | name: string; 22 | }; 23 | 24 | /** 25 | * Transforms the Prisma DMMF (Data Model Meta Format) into a structure that can be used by the React application. 26 | * This function generates a list of models, enums, and connections based on the DMMF document. 27 | * 28 | * @param {DMMF.Document} dmmf - The Prisma DMMF document containing the schema information. 29 | * @returns {{ models: Model[], enums: Enum[], connections: ModelConnection[] }} An object containing the transformed models, enums, and connections. 30 | */ 31 | export function transformDmmfToModelsAndConnections(dmmf: DMMF.Document): { 32 | models: Model[]; 33 | enums: Enum[]; 34 | connections: ModelConnection[]; 35 | } { 36 | const models = generateModels(dmmf.datamodel.models); 37 | const enums = generateEnums(dmmf.datamodel.enums); 38 | const connections = generateModelConnections(dmmf.datamodel.models); 39 | 40 | return { models, enums, connections }; 41 | } 42 | 43 | /** 44 | * Generates an array of `Model` objects based on the models defined in the DMMF. 45 | * Each model includes its fields and a flag indicating whether it has relationships with other models. 46 | * 47 | * @param {readonly DMMF.Model[]} models - The list of models from the DMMF document. 48 | * @returns {Model[]} An array of `Model` objects containing their respective fields and relationship data. 49 | */ 50 | export function generateModels(models: readonly DMMF.Model[]): Model[] { 51 | return models.map((model) => ({ 52 | name: model.name, 53 | fields: model.fields.map((field) => ({ 54 | name: field.name, 55 | type: field.isList ? `${field.type}[]` : field.type, 56 | hasConnections: 57 | field.kind === 'object' || (field.relationFromFields?.length ?? 0) > 0, 58 | })), 59 | isChild: model.fields.some( 60 | (field) => (field.relationFromFields?.length ?? 0) > 0, 61 | ), 62 | })); 63 | } 64 | 65 | /** 66 | * Generates an array of `Enum` objects based on the enums defined in the DMMF. 67 | * Each enum includes its name and its possible values. 68 | * 69 | * @param {readonly DMMF.DatamodelEnum[]} enums - The list of enums from the DMMF document. 70 | * @returns {Enum[]} An array of `Enum` objects with their respective values. 71 | */ 72 | export function generateEnums(enums: readonly DMMF.DatamodelEnum[]): Enum[] { 73 | return enums.map((enumItem) => ({ 74 | name: enumItem.name, 75 | values: enumItem.values.map((v) => v.name), 76 | })); 77 | } 78 | 79 | /** 80 | * Generates connections between models based on relationships defined in the DMMF. 81 | * Each connection represents a relationship between two models, identified by source and target handles. 82 | * 83 | * @param {readonly DMMF.Model[]} models - The list of models from the DMMF document. 84 | * @returns {ModelConnection[]} An array of `ModelConnection` objects representing relationships between models. 85 | */ 86 | export function generateModelConnections( 87 | models: readonly DMMF.Model[], 88 | ): ModelConnection[] { 89 | const connections: ModelConnection[] = []; 90 | 91 | models.forEach((model) => { 92 | model.fields.forEach((field) => { 93 | const targetModelName = field.type; 94 | const connectionName = field.relationName || field.name; 95 | 96 | // If the field type is another model, create a connection 97 | const isConnectedToOtherModel = models.some( 98 | (m) => m.name === targetModelName, 99 | ); 100 | 101 | if (isConnectedToOtherModel) { 102 | connections.push({ 103 | source: `${model.name}-${field.name}-source`, 104 | target: `${targetModelName}-target`, 105 | name: connectionName, 106 | }); 107 | } 108 | }); 109 | }); 110 | 111 | return connections; 112 | } 113 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { getDMMF, getSchemaWithPath } from '@prisma/internals'; 2 | import * as vscode from 'vscode'; 3 | import { transformDmmfToModelsAndConnections } from './core/render'; 4 | import { PrismaUMLPanel } from './panels/prisma-uml-panel'; 5 | let outputChannel: vscode.OutputChannel; 6 | 7 | export function activate(context: vscode.ExtensionContext) { 8 | outputChannel = vscode.window.createOutputChannel('Prisma Generate UML'); 9 | outputChannel.appendLine('Prisma Generate UML extension activated'); 10 | 11 | const disposable = vscode.commands.registerCommand( 12 | 'prisma-generate-uml.generateUML', 13 | async () => { 14 | const editor = vscode.window.activeTextEditor; 15 | 16 | if (editor && editor.document.languageId === 'prisma') { 17 | const currentFileUri = editor.document.uri; 18 | 19 | await generateUMLForPrismaFile(context, currentFileUri); 20 | } else { 21 | vscode.window.showInformationMessage( 22 | 'Open a .prisma file to use this command', 23 | ); 24 | } 25 | }, 26 | ); 27 | 28 | const onDidSaveDisposable = vscode.workspace.onDidSaveTextDocument( 29 | async (document) => { 30 | if (document.languageId === 'prisma' && PrismaUMLPanel.currentPanel) { 31 | await generateUMLForPrismaFile(context, document.uri); 32 | } 33 | }, 34 | ); 35 | 36 | context.subscriptions.push(disposable); 37 | context.subscriptions.push(onDidSaveDisposable); 38 | } 39 | 40 | async function generateUMLForPrismaFile( 41 | context: vscode.ExtensionContext, 42 | fileUri: vscode.Uri, 43 | ) { 44 | const folderUri = vscode.Uri.joinPath(fileUri, '..'); 45 | 46 | let response: Awaited> | null = null; 47 | 48 | try { 49 | const schemaResultFromFile = await getSchemaWithPath(fileUri.fsPath); 50 | response = await getDMMF({ datamodel: schemaResultFromFile.schemas }); 51 | } catch (err) { 52 | console.error( 53 | `[prisma-generate-uml] Tried reading schema from file: ${err}`, 54 | ); 55 | } 56 | 57 | if (!response) { 58 | try { 59 | const schemaResultFromDir = await getSchemaWithPath(folderUri.fsPath); 60 | response = await getDMMF({ datamodel: schemaResultFromDir.schemas }); 61 | } catch (err) { 62 | console.error( 63 | `[prisma-generate-uml] Tried reading schema from directory: ${err}`, 64 | ); 65 | } 66 | } 67 | 68 | if (!response) { 69 | throw new Error('no schema found'); 70 | } 71 | 72 | const { models, connections, enums } = 73 | transformDmmfToModelsAndConnections(response); 74 | PrismaUMLPanel.render( 75 | context.extensionUri, 76 | models, 77 | connections, 78 | enums, 79 | fileUri, 80 | ); 81 | } 82 | 83 | export function deactivate() {} 84 | -------------------------------------------------------------------------------- /src/panels/prisma-uml-panel.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Enum, Model, ModelConnection } from '../core/render'; 3 | import { getNonce } from '../utilities/getNonce'; 4 | import { getUri } from '../utilities/getUri'; 5 | 6 | export class PrismaUMLPanel { 7 | public static currentPanel: PrismaUMLPanel | undefined; 8 | public static readonly viewType = 'prismaUML'; 9 | private readonly _panel: vscode.WebviewPanel; 10 | private _disposables: vscode.Disposable[] = []; 11 | 12 | private constructor( 13 | panel: vscode.WebviewPanel, 14 | private readonly _extensionUri: vscode.Uri, 15 | private readonly _currentFileUri: vscode.Uri, 16 | models: Model[], 17 | connections: ModelConnection[], 18 | enums: Enum[], 19 | ) { 20 | this._panel = panel; 21 | 22 | this._panel.onDidDispose(() => this.dispose(), null, this._disposables); 23 | 24 | this._panel.webview.html = this._getWebviewContent(this._panel.webview); 25 | 26 | this._panel.iconPath = vscode.Uri.joinPath( 27 | this._extensionUri, 28 | 'media/uml.svg', 29 | ); 30 | 31 | this._panel.webview.postMessage({ 32 | command: 'setData', 33 | models, 34 | connections, 35 | enums, 36 | }); 37 | 38 | this._panel.webview.postMessage({ 39 | command: 'setTheme', 40 | theme: vscode.window.activeColorTheme.kind, 41 | }); 42 | 43 | this._panel.webview.onDidReceiveMessage( 44 | async (message) => { 45 | switch (message.command) { 46 | case 'saveImage': 47 | await this._saveImage(message.data); 48 | return; 49 | } 50 | }, 51 | null, 52 | this._disposables, 53 | ); 54 | } 55 | 56 | public static render( 57 | extensionUri: vscode.Uri, 58 | models: Model[], 59 | connections: ModelConnection[], 60 | enums: Enum[], 61 | currentFileUri: vscode.Uri, 62 | ) { 63 | if (PrismaUMLPanel.currentPanel) { 64 | PrismaUMLPanel.currentPanel.updateData(models, connections, enums); 65 | } else { 66 | const panel = vscode.window.createWebviewPanel( 67 | PrismaUMLPanel.viewType, 68 | 'Prisma Schema UML', 69 | vscode.ViewColumn.Two, 70 | { 71 | enableScripts: true, 72 | retainContextWhenHidden: true, 73 | localResourceRoots: [ 74 | vscode.Uri.joinPath(extensionUri, 'out'), 75 | vscode.Uri.joinPath(extensionUri, 'webview-ui/build'), 76 | ], 77 | }, 78 | ); 79 | PrismaUMLPanel.currentPanel = new PrismaUMLPanel( 80 | panel, 81 | extensionUri, 82 | currentFileUri, 83 | models, 84 | connections, 85 | enums, 86 | ); 87 | } 88 | } 89 | 90 | private async _saveImage(data: { format: string; dataUrl: string }) { 91 | const base64Data = data.dataUrl.replace(/^data:image\/\w+;base64,/, ''); 92 | const buffer = Buffer.from(base64Data, 'base64'); 93 | 94 | const uri = await vscode.window.showSaveDialog({ 95 | filters: { Images: [data.format] }, 96 | defaultUri: vscode.Uri.file(`prisma-uml.${data.format}`), 97 | }); 98 | 99 | if (uri) { 100 | try { 101 | await vscode.workspace.fs.writeFile(uri, buffer); 102 | vscode.window.showInformationMessage(`Image saved to ${uri.fsPath}`); 103 | } catch (error) { 104 | vscode.window.showErrorMessage(`Failed to save image: ${error}`); 105 | } 106 | } 107 | } 108 | 109 | public updateData( 110 | models: Model[], 111 | connections: ModelConnection[], 112 | enums: Enum[], 113 | ) { 114 | this._panel.webview.postMessage({ 115 | command: 'setData', 116 | models, 117 | connections, 118 | enums, 119 | }); 120 | } 121 | 122 | public dispose() { 123 | PrismaUMLPanel.currentPanel = undefined; 124 | this._panel.dispose(); 125 | while (this._disposables.length) { 126 | const disposable = this._disposables.pop(); 127 | if (disposable) { 128 | disposable.dispose(); 129 | } 130 | } 131 | } 132 | 133 | private _getWebviewContent(webview: vscode.Webview) { 134 | const stylesUri = getUri(webview, this._extensionUri, [ 135 | 'webview-ui', 136 | 'build', 137 | 'assets', 138 | 'index.css', 139 | ]); 140 | const scriptUri = getUri(webview, this._extensionUri, [ 141 | 'webview-ui', 142 | 'build', 143 | 'assets', 144 | 'index.js', 145 | ]); 146 | const nonce = getNonce(); 147 | 148 | return /*html*/ ` 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | Prisma UML 157 | 158 | 159 |
160 | 161 | 162 | 163 | `; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/utilities/getNonce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A helper function that returns a unique alphanumeric identifier called a nonce. 3 | * 4 | * @remarks This function is primarily used to help enforce content security 5 | * policies for resources/scripts being executed in a webview context. 6 | * 7 | * @returns A nonce 8 | */ 9 | export function getNonce() { 10 | let text = ''; 11 | const possible = 12 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 13 | for (let i = 0; i < 32; i++) { 14 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 15 | } 16 | return text; 17 | } 18 | -------------------------------------------------------------------------------- /src/utilities/getUri.ts: -------------------------------------------------------------------------------- 1 | import { Uri, Webview } from 'vscode'; 2 | 3 | /** 4 | * A helper function which will get the webview URI of a given file or resource. 5 | * 6 | * @remarks This URI can be used within a webview's HTML as a link to the 7 | * given file/resource. 8 | * 9 | * @param webview A reference to the extension webview 10 | * @param extensionUri The URI of the directory containing the extension 11 | * @param pathList An array of strings representing the path to a file/resource 12 | * @returns A URI pointing to the file/resource 13 | */ 14 | export function getUri( 15 | webview: Webview, 16 | extensionUri: Uri, 17 | pathList: string[], 18 | ) { 19 | return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList)); 20 | } 21 | -------------------------------------------------------------------------------- /test/run-test.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | 3 | const { runTests } = require('@vscode/test-electron'); 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../'); 10 | 11 | // The path to the extension test script 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests', err); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /test/suite/extension.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('node:assert'); 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | const vscode = require('vscode'); 6 | // const myExtension = require('../extension'); 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/suite/index.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const Mocha = require('mocha'); 3 | const glob = require('glob'); 4 | 5 | function run() { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true, 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | const testFiles = new glob.Glob('**/**.test.js', { cwd: testsRoot }); 16 | const testFileStream = testFiles.stream(); 17 | 18 | testFileStream.on('data', (file) => { 19 | // Add files to the test suite 20 | mocha.addFile(path.resolve(testsRoot, file)); 21 | }); 22 | testFileStream.on('error', (err) => { 23 | e(err); 24 | }); 25 | testFileStream.on('end', () => { 26 | try { 27 | // Run the mocha test 28 | mocha.run((failures) => { 29 | if (failures > 0) { 30 | e(new Error(`${failures} tests failed.`)); 31 | } else { 32 | c(); 33 | } 34 | }); 35 | } catch (err) { 36 | console.error(err); 37 | e(err); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | module.exports = { 44 | run, 45 | }; 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2020", 5 | "outDir": "dist", 6 | "lib": ["ES2020"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true /* enable all strict type-checking options */, 10 | "skipLibCheck": true /* Skip type checking of declaration files. */, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | }, 17 | "exclude": ["webview-ui"] 18 | } 19 | -------------------------------------------------------------------------------- /webview-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | build -------------------------------------------------------------------------------- /webview-ui/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /webview-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + React + TS 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /webview-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webview-ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@dagrejs/dagre": "^1.1.4", 13 | "@xyflow/react": "^12.3.6", 14 | "fast-deep-equal": "^3.1.3", 15 | "html-to-image": "^1.11.11", 16 | "lucide-react": "^0.469.0", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^19.0.2", 22 | "@types/react-dom": "^19.0.2", 23 | "@vitejs/plugin-react": "^4.3.4", 24 | "autoprefixer": "^10.4.20", 25 | "globals": "^15.14.0", 26 | "postcss": "^8.4.49", 27 | "tailwindcss": "^3.4.17", 28 | "typescript": "^5.7.2", 29 | "vite": "^6.0.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /webview-ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /webview-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { SchemaVisualizer } from './components/SchemaVisualizer'; 3 | import { ThemeProvider } from './lib/contexts/theme'; 4 | import { 5 | ColorThemeKind, 6 | Enum, 7 | Model, 8 | ModelConnection, 9 | } from './lib/types/schema'; 10 | import { ReactFlowProvider } from '@xyflow/react'; 11 | 12 | function App() { 13 | const [models, setModels] = useState([]); 14 | const [enums, setEnums] = useState([]); 15 | const [theme, setTheme] = useState(ColorThemeKind.Dark); 16 | const [connections, setConnections] = useState([]); 17 | 18 | useEffect(() => { 19 | function handleMessage(event: MessageEvent) { 20 | const message = event.data; 21 | 22 | if (message.command === 'setData') { 23 | setModels(message.models); 24 | setConnections(message.connections); 25 | setEnums(message.enums); 26 | } 27 | 28 | if (message.command === 'setTheme') { 29 | setTheme(message.theme); 30 | } 31 | } 32 | 33 | window.addEventListener('message', handleMessage); 34 | 35 | return () => { 36 | window.removeEventListener('message', handleMessage); 37 | }; 38 | }, []); 39 | 40 | return ( 41 | models.length > 0 && 42 | connections.length > 0 && ( 43 | 44 | 45 | 50 | 51 | 52 | ) 53 | ); 54 | } 55 | 56 | export default App; 57 | -------------------------------------------------------------------------------- /webview-ui/src/components/EnumNode.tsx: -------------------------------------------------------------------------------- 1 | import { NodeProps } from '@xyflow/react'; 2 | import { memo } from 'react'; 3 | import { useTheme } from '../lib/contexts/theme'; 4 | import { EnumNodeTye } from '../lib/types/schema'; 5 | 6 | export const EnumNode = memo(({ data }: NodeProps) => { 7 | const { isDarkMode } = useTheme(); 8 | 9 | return ( 10 |
23 |
33 |

40 |

{data.name}
41 |

42 |
43 | 44 |
45 | {data.values.map((value, index) => ( 46 |
64 |
{value}
65 |
66 | ))} 67 |
68 |
69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /webview-ui/src/components/ModelNode.tsx: -------------------------------------------------------------------------------- 1 | import { Handle, NodeProps, Position } from '@xyflow/react'; 2 | import { JSX, memo } from 'react'; 3 | import { useTheme } from '../lib/contexts/theme'; 4 | import { ModelNodeTye } from '../lib/types/schema'; 5 | 6 | import { 7 | Calculator, 8 | Calendar, 9 | CheckSquare, 10 | File, 11 | FileText, 12 | Hash, 13 | Link2, 14 | List, 15 | Type, 16 | } from 'lucide-react'; 17 | 18 | const typeIcons: Record = { 19 | string: , 20 | int: , 21 | float: , 22 | double: , 23 | date: , 24 | datetime: , 25 | boolean: , 26 | text: , 27 | file: , 28 | enum: , 29 | }; 30 | 31 | const getIconForType = (type: string) => { 32 | return typeIcons[type.toLowerCase()] || ; 33 | }; 34 | 35 | export const ModelNode = memo(({ data }: NodeProps) => { 36 | const { isDarkMode } = useTheme(); 37 | 38 | return ( 39 |
52 | {data.isChild && ( 53 | 58 | )} 59 | 60 |
70 |

77 |

{data.name}
78 |

79 |
80 | 81 |
82 | {data.fields.map(({ type, name, hasConnections }, index) => ( 83 |
103 |
104 | {getIconForType(type)} 105 | {name} 106 |
107 |
108 |
{type}
109 |
110 | 111 | {hasConnections && ( 112 | 120 | )} 121 |
122 | ))} 123 |
124 |
125 | ); 126 | }); 127 | -------------------------------------------------------------------------------- /webview-ui/src/components/SchemaVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Background, 3 | BackgroundVariant, 4 | ConnectionLineType, 5 | ControlButton, 6 | Controls, 7 | Edge, 8 | MiniMap, 9 | Panel, 10 | ReactFlow, 11 | useReactFlow, 12 | } from '@xyflow/react'; 13 | import { useTheme } from '../lib/contexts/theme'; 14 | import { useGraph } from '../lib/hooks/useGraph'; 15 | import { Enum, Model, ModelConnection } from '../lib/types/schema'; 16 | import { 17 | getButtonStyle, 18 | maskColor, 19 | nodeColor, 20 | nodeStrokeColor, 21 | } from '../lib/utils/colots'; 22 | import { screenshot } from '../lib/utils/screnshot'; 23 | import { EnumNode } from './EnumNode'; 24 | import { ModelNode } from './ModelNode'; 25 | import { IDownload } from './icons/IDownload'; 26 | import { useMemo } from 'react'; 27 | 28 | interface Props { 29 | models: Model[]; 30 | connections: ModelConnection[]; 31 | enums: Enum[]; 32 | } 33 | 34 | export const SchemaVisualizer = ({ connections, models, enums }: Props) => { 35 | const { isDarkMode } = useTheme(); 36 | const { getNodes } = useReactFlow(); 37 | 38 | const modelNodes = useMemo(() => { 39 | return models.map((model) => ({ 40 | id: model.name, 41 | data: model, 42 | type: 'model', 43 | position: { x: 0, y: 0 }, 44 | })); 45 | }, [models]); 46 | 47 | const enumNodes = useMemo(() => { 48 | return enums.map((enumItem) => ({ 49 | id: enumItem.name, 50 | data: enumItem, 51 | type: 'enum', 52 | position: { x: 0, y: 0 }, 53 | })); 54 | }, [enums]); 55 | 56 | const edges: Edge[] = useMemo(() => { 57 | return connections.map((connection) => ({ 58 | id: `${connection.source}-${connection.target}`, 59 | source: connection.source.split('-')[0], 60 | target: connection.target.split('-')[0], 61 | sourceHandle: connection.source, 62 | targetHandle: connection.target, 63 | animated: false, 64 | 65 | style: { 66 | stroke: isDarkMode ? '#ffffff' : '#000000', 67 | strokeWidth: 2, 68 | strokeOpacity: 0.5, 69 | strokeLinejoin: 'round', 70 | strokeLinecap: 'round', 71 | strokeDasharray: '5', 72 | strokeDashoffset: 0, 73 | fill: 'none', 74 | }, 75 | })); 76 | }, [connections]); 77 | 78 | const { 79 | nodes, 80 | edges: edgesState, 81 | onNodesChange, 82 | onEdgesChange, 83 | onConnect, 84 | onLayout, 85 | selectedLayout, 86 | } = useGraph([...modelNodes, ...enumNodes], edges); 87 | 88 | return ( 89 |
94 | 107 | 108 | screenshot(getNodes as any)} 111 | > 112 | 113 | 114 | 115 | 124 | 128 | 129 | 135 | 141 | 142 | 143 |
144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /webview-ui/src/components/icons/IDownload.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from './props'; 2 | 3 | export const IDownload = ({ 4 | color = 'black', 5 | size = 16, 6 | ...attributes 7 | }: IconProps) => ( 8 | 16 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /webview-ui/src/components/icons/props.ts: -------------------------------------------------------------------------------- 1 | export type IconProps = { 2 | color?: string; 3 | size?: string | number; 4 | } & React.SVGAttributes; 5 | -------------------------------------------------------------------------------- /webview-ui/src/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply p-0; 7 | } 8 | -------------------------------------------------------------------------------- /webview-ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import '@xyflow/react/dist/style.css'; 3 | import { createRoot } from 'react-dom/client'; 4 | import App from './App'; 5 | import './globals.css'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /webview-ui/src/lib/contexts/theme.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, createContext, useContext } from 'react'; 2 | import { ColorThemeKind } from '../types/schema'; 3 | 4 | type ThemeContextType = { 5 | theme: ColorThemeKind; 6 | isDarkMode: boolean; 7 | }; 8 | 9 | const ThemeContext = createContext(undefined); 10 | 11 | export const useTheme = () => { 12 | const context = useContext(ThemeContext); 13 | if (!context) { 14 | throw new Error('useTheme debe ser usado dentro de un ThemeProvider'); 15 | } 16 | return context; 17 | }; 18 | 19 | export const ThemeProvider = ({ 20 | children, 21 | theme, 22 | }: { 23 | children: ReactNode; 24 | theme: ColorThemeKind; 25 | }) => { 26 | const isDarkMode = 27 | theme === ColorThemeKind.Dark || theme === ColorThemeKind.HighContrast; 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /webview-ui/src/lib/hooks/useGraph.ts: -------------------------------------------------------------------------------- 1 | import equal from 'fast-deep-equal'; 2 | import { useCallback, useEffect, useRef, useState } from 'react'; 3 | import { getLayoutedElements } from '../utils/layout-utils'; 4 | import { 5 | addEdge, 6 | Connection, 7 | ConnectionLineType, 8 | Edge, 9 | useEdgesState, 10 | useNodesState, 11 | useReactFlow, 12 | } from '@xyflow/react'; 13 | import { MyNode } from '../types/schema'; 14 | 15 | const DEFAULT_LAYOUT = 'TB'; 16 | 17 | export const useGraph = (initialNodes: MyNode[], initialEdges: Edge[]) => { 18 | const { fitView } = useReactFlow(); 19 | 20 | const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); 21 | const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); 22 | 23 | const [selectedLayout, setSelectedLayout] = useState(DEFAULT_LAYOUT); 24 | 25 | const [shouldFitView, setShouldFitView] = useState(false); 26 | 27 | const isFirstRender = useRef(true); 28 | 29 | const applyLayout = useCallback( 30 | (layoutDirection: string, fromNodes = nodes, fromEdges = edges) => { 31 | const { nodes: layoutedNodes, edges: layoutedEdges } = 32 | getLayoutedElements(fromNodes, fromEdges, layoutDirection); 33 | setNodes(layoutedNodes); 34 | setEdges(layoutedEdges); 35 | setShouldFitView(true); 36 | }, 37 | [nodes, edges, setNodes, setEdges], 38 | ); 39 | 40 | const onLayout = useCallback( 41 | (direction: string) => { 42 | applyLayout(direction); 43 | setSelectedLayout(direction); 44 | }, 45 | [applyLayout], 46 | ); 47 | 48 | const onConnect = useCallback( 49 | (params: Connection) => { 50 | setEdges((eds) => 51 | addEdge( 52 | { 53 | ...params, 54 | type: ConnectionLineType.SmoothStep, 55 | animated: true, 56 | }, 57 | eds, 58 | ), 59 | ); 60 | }, 61 | [setEdges], 62 | ); 63 | 64 | useEffect(() => { 65 | if (!initialNodes?.length && !initialEdges?.length) return; 66 | 67 | if (isFirstRender.current) { 68 | isFirstRender.current = false; 69 | applyLayout(DEFAULT_LAYOUT, initialNodes, initialEdges); 70 | return; 71 | } 72 | 73 | const nodesChanged = !equal( 74 | initialNodes.map((n) => n.data), 75 | nodes.map((n) => n.data), 76 | ); 77 | const edgesChanged = !equal( 78 | initialEdges.map((e) => ({ source: e.source, target: e.target })), 79 | edges.map((e) => ({ source: e.source, target: e.target })), 80 | ); 81 | 82 | if (nodesChanged || edgesChanged) { 83 | applyLayout(selectedLayout, initialNodes, initialEdges); 84 | } 85 | }, [initialNodes, initialEdges, applyLayout, selectedLayout, nodes, edges]); 86 | 87 | useEffect(() => { 88 | if (shouldFitView) { 89 | fitView(); 90 | setShouldFitView(false); 91 | } 92 | }, [shouldFitView, fitView]); 93 | 94 | useEffect(() => { 95 | const deleteDiv = document.getElementsByClassName( 96 | 'react-flow__panel react-flow__attribution bottom right', 97 | ); 98 | 99 | if (deleteDiv.length > 0) { 100 | deleteDiv[0].remove(); 101 | } 102 | }, []); 103 | 104 | return { 105 | nodes, 106 | edges, 107 | onNodesChange, 108 | onEdgesChange, 109 | onConnect, 110 | onLayout, 111 | setNodes, 112 | setEdges, 113 | selectedLayout, 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /webview-ui/src/lib/types/schema.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@xyflow/react'; 2 | 3 | export type Model = { 4 | name: string; 5 | fields: { 6 | name: string; 7 | type: string; 8 | hasConnections?: boolean; 9 | }[]; 10 | isChild?: boolean; 11 | }; 12 | 13 | export type ModelConnection = { 14 | target: string; 15 | source: string; 16 | name: string; 17 | }; 18 | 19 | export type Enum = { 20 | name: string; 21 | values: string[]; 22 | }; 23 | 24 | export enum ColorThemeKind { 25 | Light = 1, 26 | Dark = 2, 27 | HighContrast = 3, 28 | HighContrastLight = 4, 29 | } 30 | 31 | export type EnumNodeTye = Node; 32 | export type ModelNodeTye = Node; 33 | 34 | type NodeData = Model | Enum; 35 | export type MyNode = Node; 36 | -------------------------------------------------------------------------------- /webview-ui/src/lib/utils/colots.ts: -------------------------------------------------------------------------------- 1 | export const nodeColor = (isDarkMode: boolean) => 2 | isDarkMode ? '#3d5797' : '#8b9dc3'; 3 | 4 | export const nodeStrokeColor = (isDarkMode: boolean) => 5 | isDarkMode ? '#282828' : '#e0e0e0'; 6 | 7 | export const maskColor = (isDarkMode: boolean) => 8 | isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.5)'; 9 | 10 | export const getButtonStyle = (selectedLayout: string, layout: string) => { 11 | return selectedLayout === layout 12 | ? 'bg-blue-500 text-white px-4 py-2 rounded' 13 | : 'bg-gray-300 text-black px-4 py-2 rounded'; 14 | }; 15 | -------------------------------------------------------------------------------- /webview-ui/src/lib/utils/layout-utils.ts: -------------------------------------------------------------------------------- 1 | import dagre from '@dagrejs/dagre'; 2 | import { Edge, Position } from '@xyflow/react'; 3 | import { MyNode } from '../types/schema'; 4 | 5 | const dagreGraph = new dagre.graphlib.Graph(); 6 | dagreGraph.setDefaultEdgeLabel(() => ({})); 7 | 8 | const nodeWidth = 250; 9 | const nodeHeight = 400; 10 | 11 | export const getLayoutedElements = ( 12 | nodes: MyNode[], 13 | edges: Edge[], 14 | direction = 'TB', 15 | ) => { 16 | const isHorizontal = direction === 'LR'; 17 | dagreGraph.setGraph({ rankdir: direction }); 18 | 19 | nodes.forEach((node) => { 20 | dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); 21 | }); 22 | 23 | edges.forEach((edge) => { 24 | dagreGraph.setEdge(edge.source, edge.target); 25 | }); 26 | 27 | dagre.layout(dagreGraph); 28 | 29 | const newNodes = nodes.map((node) => { 30 | const nodeWithPosition = dagreGraph.node(node.id); 31 | return { 32 | ...node, 33 | targetPosition: isHorizontal ? Position.Left : Position.Top, 34 | sourcePosition: isHorizontal ? Position.Right : Position.Bottom, 35 | position: { 36 | x: nodeWithPosition.x - nodeWidth / 2, 37 | y: nodeWithPosition.y - nodeHeight / 2, 38 | }, 39 | }; 40 | }); 41 | 42 | return { nodes: newNodes, edges }; 43 | }; 44 | -------------------------------------------------------------------------------- /webview-ui/src/lib/utils/screnshot.ts: -------------------------------------------------------------------------------- 1 | import { getNodesBounds, getViewportForBounds } from '@xyflow/react'; 2 | import { toPng } from 'html-to-image'; 3 | import { MyNode } from '../types/schema'; 4 | 5 | interface VSCodeAPI { 6 | postMessage(message: SaveImageMessage): void; 7 | getState(): unknown; 8 | setState(state: unknown): void; 9 | } 10 | 11 | interface SaveImageMessage { 12 | command: 'saveImage'; 13 | data: { 14 | format: 'png'; 15 | dataUrl: string; 16 | }; 17 | } 18 | 19 | declare function acquireVsCodeApi(): VSCodeAPI; 20 | 21 | const vscode = acquireVsCodeApi(); 22 | 23 | export const screenshot = (getNodes: () => MyNode[]) => { 24 | const nodesBounds = getNodesBounds(getNodes()); 25 | 26 | // 8k resolution 27 | const imageWidth = 7680; 28 | const imageHeight = 4320; 29 | 30 | const transform = getViewportForBounds( 31 | nodesBounds, 32 | imageWidth, 33 | imageHeight, 34 | 0, 35 | 2, 36 | 0, 37 | ); 38 | 39 | toPng(document.querySelector('.react-flow__viewport') as HTMLElement, { 40 | filter: (node) => { 41 | const exclude = ['react-flow__minimap', 'react-flow__controls']; 42 | return !exclude.some((className) => node.classList?.contains(className)); 43 | }, 44 | backgroundColor: 'transparent', 45 | width: imageWidth, 46 | height: imageHeight, 47 | style: { 48 | transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`, 49 | }, 50 | }) 51 | .then((dataUrl) => { 52 | vscode.postMessage({ 53 | command: 'saveImage', 54 | data: { format: 'png', dataUrl }, 55 | }); 56 | }) 57 | .catch((error) => { 58 | console.error('Error generating image:', error); 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /webview-ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /webview-ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /webview-ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /webview-ui/tsconfig.app.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/app.tsx","./src/index.tsx","./src/vite-env.d.ts","./src/components/enumnode.tsx","./src/components/modelnode.tsx","./src/components/schemavisualizer.tsx","./src/components/icons/idownload.tsx","./src/components/icons/props.ts","./src/lib/contexts/theme.tsx","./src/lib/hooks/usegraph.ts","./src/lib/types/schema.ts","./src/lib/utils/colots.ts","./src/lib/utils/layout-utils.ts","./src/lib/utils/screnshot.ts"],"version":"5.7.2"} -------------------------------------------------------------------------------- /webview-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /webview-ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /webview-ui/tsconfig.node.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./vite.config.ts"],"version":"5.7.2"} -------------------------------------------------------------------------------- /webview-ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | build: { 8 | outDir: 'build', 9 | rollupOptions: { 10 | output: { 11 | entryFileNames: 'assets/[name].js', 12 | chunkFileNames: 'assets/[name].js', 13 | assetFileNames: 'assets/[name].[ext]', 14 | }, 15 | }, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------