├── .github ├── renovate.json └── workflows │ ├── bump-version.yml │ └── ci.yml ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── demo ├── README.md ├── api │ └── run.ts ├── build.sh ├── index.html ├── index.tsx ├── package.json ├── prisma │ └── schema.prisma └── yarn.lock ├── package.json ├── playwright.config.ts ├── scripts ├── build-lib.mjs ├── build-types.mjs └── start-test-server.mjs ├── src ├── editor │ ├── base-editor.ts │ ├── index.ts │ ├── json-editor.ts │ ├── prisma-schema-editor.ts │ ├── sql-editor.ts │ └── ts-editor.ts ├── extensions │ ├── appearance │ │ ├── base-colors.ts │ │ ├── dark-colors.ts │ │ ├── index.ts │ │ └── light-colors.ts │ ├── behaviour.ts │ ├── change-callback.ts │ ├── keymap.ts │ ├── prisma-query │ │ ├── find-cursor.ts │ │ ├── find-queries.ts │ │ ├── gutter.ts │ │ ├── highlight.ts │ │ ├── index.ts │ │ ├── keymap.ts │ │ ├── line-numbers.ts │ │ ├── log.ts │ │ └── state.ts │ └── typescript │ │ ├── index.ts │ │ ├── log.ts │ │ ├── project.ts │ │ └── tsfs.ts ├── lib.ts ├── logger.ts ├── react │ └── Editor.tsx └── vite-env.d.ts ├── tests ├── find-cursor.spec.ts ├── find-queries.spec.ts ├── fold.spec.ts ├── keymap.spec.ts ├── offline.spec.ts ├── prisma.spec.ts └── typescript.spec.ts ├── tsconfig.json ├── vite.config.ts ├── vite.demo.config.ts ├── wiki └── demo-app.png └── yarn.lock /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "semanticCommits": "enabled", 4 | "dependencyDashboard": true, 5 | "rebaseWhen": "behind-base-branch", 6 | "ignoreDeps": ["react", "react-dom"], 7 | "timezone": "Europe/Berlin", 8 | "schedule": ["before 3am on Monday"], 9 | "packageRules": [ 10 | { 11 | "matchDepTypes": ["devDependencies"], 12 | "matchUpdateTypes": ["minor", "patch"], 13 | "groupName": "devDependencies", 14 | "groupSlug": "dev-dependencies", 15 | "schedule": ["before 3am on Monday"] 16 | }, 17 | { 18 | "matchDepTypes": ["dependencies"], 19 | "matchUpdateTypes": ["minor", "patch"], 20 | "groupName": "dependencies", 21 | "groupSlug": "dependencies", 22 | "schedule": ["before 3am on Monday"] 23 | }, 24 | { 25 | "matchPackagePatterns": ["@codemirror/*"], 26 | "matchUpdateTypes": ["minor", "patch"], 27 | "groupName": "CodeMirror", 28 | "groupSlug": "codemirror", 29 | "schedule": ["before 3am on Monday"] 30 | }, 31 | { 32 | "matchPackagePatterns": ["@types/*"], 33 | "matchUpdateTypes": ["minor", "patch"], 34 | "groupName": "Types", 35 | "groupSlug": "types", 36 | "schedule": ["before 3am on Monday"] 37 | }, 38 | { 39 | "matchPackagePatterns": ["@prisma/*"], 40 | "matchPackageNames": ["prisma"], 41 | "matchUpdateTypes": ["minor", "patch"], 42 | "groupName": "Prisma Dependencies", 43 | "groupSlug": "prisma", 44 | "schedule": ["after 6pm on Tuesday"] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | bump-version: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | # The default Github token will not trigger other workflows (https://github.community/t/commit-generated-in-one-workflow-does-not-trigger-pull-request-workflow/147696) 12 | # So we will use a PAT to do git operations. This PAT currently belongs to `prisma-bot` 13 | token: ${{ secrets.GH_PAT }} 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: 16 17 | - name: Bump version 18 | # Only bump up the version and push the git tag. That will trigger tests.yml & publish.yml, which will run tests and publish 19 | run: | 20 | git config user.name "GitHub Actions Bot" 21 | git config user.email "<>" 22 | git reset --hard 23 | yarn version --patch 24 | git push --follow-tags 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | 4 | jobs: 5 | prettier: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-node@v2 10 | with: 11 | node-version: 16 12 | - name: Install dependencies 13 | run: yarn install --frozen-lockfile 14 | - name: Run Prettier 15 | run: yarn format:check 16 | 17 | tests: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: 16 24 | - name: Install dependencies 25 | run: | 26 | yarn install --frozen-lockfile 27 | yarn playwright install-deps 28 | yarn playwright install 29 | - name: Run tests 30 | run: yarn test 31 | 32 | publish: 33 | needs: [prettier, tests] 34 | if: startsWith(github.ref, 'refs/tags') # Only run this job for tags, and after tests pass 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: actions/setup-node@v2 39 | with: 40 | node-version: 16 41 | - name: Install dependencies 42 | run: | 43 | yarn install --frozen-lockfile 44 | git reset --hard 45 | - name: Publish 46 | # The version bump has already happened at this point (via bump-version.yml), so we just publish 47 | uses: JS-DevTools/npm-publish@v1 48 | with: 49 | token: ${{ secrets.NPM_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | yarn-error.log 7 | .vercel 8 | .env 9 | 10 | src/extensions/typescript/types 11 | demo/public 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "dev", 6 | "detail": "Start development server", 7 | "type": "npm", 8 | "script": "dev", 9 | "presentation": { 10 | "echo": true, 11 | "reveal": "always", 12 | "focus": false, 13 | "panel": "dedicated", 14 | "group": "dev", 15 | "showReuseMessage": true, 16 | "clear": true 17 | }, 18 | "problemMatcher": [] 19 | }, 20 | { 21 | "label": "test", 22 | "detail": "Run tests", 23 | "type": "npm", 24 | "script": "test", 25 | "problemMatcher": [] 26 | }, 27 | { 28 | "label": "test:watch", 29 | "detail": "Start TDD session", 30 | "type": "npm", 31 | "script": "test:watch", 32 | "presentation": { 33 | "echo": true, 34 | "reveal": "always", 35 | "focus": false, 36 | "panel": "dedicated", 37 | "group": "dev", 38 | "showReuseMessage": true, 39 | "clear": true 40 | }, 41 | "problemMatcher": [] 42 | }, 43 | { 44 | "label": "tdd", 45 | "detail": "Start TDD session", 46 | "dependsOn": ["dev", "test:watch"], 47 | "problemMatcher": [] 48 | }, 49 | { 50 | "label": "build:lib", 51 | "detail": "Build library", 52 | "type": "npm", 53 | "script": "build:lib", 54 | "problemMatcher": [] 55 | }, 56 | { 57 | "label": "build:demo", 58 | "detail": "Build demo", 59 | "type": "npm", 60 | "script": "build", 61 | "path": "demo", 62 | "problemMatcher": [] 63 | }, 64 | { 65 | "label": "deploy", 66 | "detail": "Deploy demo", 67 | "type": "npm", 68 | "script": "deploy", 69 | "problemMatcher": [] 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @prisma/text-editors 2 | 3 | ![tests](https://github.com/prisma/text-editors/actions/workflows/ci.yml/badge.svg) ![npm-version](https://badgen.net/npm/v/@prisma/text-editors) 4 | 5 | This package exports a bunch of batteries-included code editors for Typescript, JSON, SQL & Prisma Schemas. The goal is to be a zero-configuration component that is quick to load and that you just dump into your codebase so it "just works". 6 | 7 | ### Demo 8 | 9 | A demo of the editors is [here](https://qc.prisma-adp.vercel.app). Note that these editors were built for the [Prisma Data Platform](https://cloud.prisma.io)'s Query Console, so head over there to see them in action in a real app. 10 | 11 | ### Installation 12 | 13 | ``` 14 | npm i @prisma/text-editors 15 | 16 | yarn add @prisma/text-editors 17 | ``` 18 | 19 | ### Usage 20 | 21 | The editors are currently only exported as a React component, but support for other frameworks should be trivial to implement, since all of editor functionality is written in vanilla JS. 22 | 23 | Usage with React: 24 | 25 | ```typescript 26 | import React, { useState } from "react"; 27 | import { Editor } from "@prisma/text-editors"; 28 | 29 | // ..snip 30 | 31 | const [code, setCode] = useState(""); 32 | 33 | return ; 34 | ``` 35 | 36 | This gives you an editor that includes Typescript syntax highlighting, typechecking, auto-complete & quickinfo on token hover. 37 | 38 | **Editor props** 39 | 40 | - `lang` (required): Controls what language the editor's `value` will be. This enables or disables certain feature, depending on the language. Currently supported languages are: Typescript (`ts`), JSON (`json`), SQL (`sql`) and Prisma Schema Language (`prisma`) 41 | 42 | - `value` (required): The text / code that will be shown in the editor. In general, it is recommended to pass in a controlled React prop here and update its value whenever the editor calls `onChange`. Changing this value directly will cause the editor to recreate its own internal state from scratch, which can be expensive. 43 | 44 | - `readonly`: Controls if the editor will allow changes to the `value` 45 | 46 | - `theme`: Controls the editor theme, Currently supported themes are `light` & `dark` 47 | 48 | - `style`: Any CSS properties passed here will be spread on to the editor container 49 | 50 | - `classNames`: Any class names pass here will be applied to the editor container 51 | 52 | - `types` (only valid when `lang=ts`): Key value pairs of additional Typescript types that will be injected into the editor lazily. The key must be the "location" of this types (common values are `/node_modules/your-package/index.d.ts`), and the value must be the actual types (such as `export type MyType = string`). These can be useful to fake custom imports in the editor, and affect auto-complete (among other things custom types let you do in VSCode, for example) 53 | 54 | --- 55 | 56 | ### Contributing 57 | 58 | Please read through the [Wiki](https://github.com/prisma/text-editors/wiki) to learn more about how this package works, and how to contribute. 59 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # query-console-demo 2 | -------------------------------------------------------------------------------- /demo/api/run.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { VercelRequest, VercelResponse } from "@vercel/node"; 3 | import { brotliCompressSync, deflateSync, gzipSync } from "zlib"; 4 | import { PrismaQuery } from "../../src/lib"; 5 | 6 | type RequestBody = { 7 | query: PrismaQuery; 8 | }; 9 | 10 | const allowedOrigins = [ 11 | "http://localhost:3000", 12 | "https://qc.prisma-adp.vercel.app", 13 | ]; 14 | 15 | export default async function types(req: VercelRequest, res: VercelResponse) { 16 | if (allowedOrigins.includes(req.headers.origin || "")) { 17 | res.setHeader("Access-Control-Allow-Origin", req.headers.origin!); 18 | } 19 | 20 | res.setHeader("Access-Control-Allow-Methods", "OPTIONS,POST"); 21 | res.setHeader( 22 | "Access-Control-Allow-Headers", 23 | "Content-Type, Accept-Encoding" 24 | ); 25 | 26 | if (req.method === "OPTIONS") { 27 | return res.end(); 28 | } 29 | 30 | if (req.method !== "POST") { 31 | return res.status(400).send("Bad Request"); 32 | } 33 | 34 | const { query } = req.body as RequestBody; 35 | console.log(query); 36 | 37 | const queryResponse = { 38 | error: null, 39 | data: null, 40 | }; 41 | 42 | const prisma = new PrismaClient(); 43 | try { 44 | if (query.model) { 45 | queryResponse.data = await prisma[query.model][query.operation].apply( 46 | null, 47 | query.args 48 | ); 49 | } else if ( 50 | query.operation === "$queryRaw" || 51 | query.operation === "$executeRaw" 52 | ) { 53 | queryResponse.data = await prisma[query.operation].apply( 54 | null, 55 | query.args 56 | ); 57 | } else { 58 | queryResponse.data = await prisma[query.operation](query.args); 59 | } 60 | } catch (e) { 61 | console.error("Error executing query", e.message); 62 | queryResponse.error = e.message; 63 | } 64 | await prisma.$disconnect(); 65 | 66 | const responseBody = JSON.stringify({ 67 | query, 68 | response: queryResponse, 69 | }); 70 | 71 | res.setHeader("Content-Type", "application/json"); 72 | 73 | // Naive implementation, but good enough for a demo lol 74 | const acceptsEncoding = req.headers["accept-encoding"]; 75 | if (acceptsEncoding?.includes("br")) { 76 | return res 77 | .setHeader("Content-Encoding", "br") 78 | .send(brotliCompressSync(Buffer.from(responseBody, "utf-8"))); 79 | } else if (acceptsEncoding?.includes("gzip")) { 80 | return res 81 | .setHeader("Content-Encoding", "gzip") 82 | .send(gzipSync(Buffer.from(responseBody, "utf-8"))); 83 | } else if (acceptsEncoding?.includes("gzip")) { 84 | return res 85 | .setHeader("Content-Encoding", "deflate") 86 | .send(deflateSync(Buffer.from(responseBody, "utf-8"))); 87 | } else { 88 | return res.json({ query, response: responseBody }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /demo/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Cleanup 6 | rm -rf public 7 | 8 | # Build the Query Console 9 | cd .. 10 | yarn 11 | yarn tsc 12 | yarn vite build -c vite.demo.config.ts 13 | cd demo 14 | 15 | # Move index.html from public/demo to public so that the Vercel deployment is available at root 16 | mv public/demo/index.html public 17 | rm -rf public/demo 18 | 19 | # Make Prisma Client types accessible to the Vercel CDN so they can fetched at runtime 20 | rm -rf public/types 21 | mkdir -p public/types 22 | cp node_modules/.prisma/client/index.d.ts public/types/prisma-client.d.ts -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Query Console 7 | 8 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Editor, FileMap, PrismaQuery } from "../src/lib"; 4 | 5 | const tsCode: string = `import { PrismaClient } from "@prisma/client" 6 | const prisma = new PrismaClient() 7 | 8 | await prisma.artist.findMany() 9 | 10 | await prisma.artist.findMany({ 11 | where: { 12 | Name: { 13 | startsWith: "F" 14 | } 15 | } 16 | }) 17 | 18 | const fn = async (value: string) => { 19 | \tconst x = 1 20 | \tawait prisma.album.findUnique({ where: { error: 1 } }) 21 | } 22 | 23 | await prisma.$executeRaw(\`SELECT * FROM "Album"\`) 24 | 25 | async function fn(value: string) { 26 | \tconst x = 1 27 | 28 | } 29 | 30 | await prisma.$disconnect() 31 | `; 32 | 33 | const ReactDemo = () => { 34 | const [code, setCode] = useState(tsCode); 35 | const [types, setTypes] = useState({}); 36 | useEffect(() => { 37 | fetch("https://qc.prisma-adp.vercel.app/types/prisma-client.d.ts") 38 | .then(r => r.text()) 39 | .then(fileContent => { 40 | console.log("Fetched Prisma Client types, injecting"); 41 | setTypes({ 42 | "/node_modules/@prisma/client/index.d.ts": fileContent, 43 | }); 44 | }) 45 | .catch(e => { 46 | console.log("Failed to fetch Prisma Client types:", e); 47 | }); 48 | }, []); 49 | 50 | const [response, setResponse] = useState(""); 51 | const runPrismaClientQuery = async (query: PrismaQuery) => { 52 | setResponse(JSON.stringify([{ loading: true }], null, 2)); 53 | 54 | // If ever changing the backend, run `yarn dev:api` to launch the backend in development mode, and replace the URL here with `http://localhost:3001/api/run` 55 | const res = await fetch("http://localhost:3001/api/run", { 56 | method: "POST", 57 | headers: { 58 | "Content-Type": "application/json", 59 | }, 60 | body: JSON.stringify({ query, variables: { prisma: "prisma" } }), 61 | }).then(r => r.json()); 62 | 63 | console.log("Received response", res.response); 64 | if (res.response.error) { 65 | setResponse(JSON.stringify([{ error: res.response.error }], null, 2)); 66 | } else { 67 | setResponse(JSON.stringify(res.response.data, null, 2)); 68 | } 69 | }; 70 | 71 | return ( 72 |
82 |
94 |

95 | This is only a demo of Prisma's text editors. To try out the query 96 | console, head over to the{" "} 97 | 98 | Prisma Data Platform 99 | 100 |

101 |
102 | 103 |
112 | 126 | 127 | 133 |
134 | ); 135 | }; 136 | 137 | ReactDOM.render( 138 | 139 | 140 | , 141 | document.getElementById("root") 142 | ); 143 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "build": "./build.sh", 8 | "deploy": "vercel", 9 | "preview": "yarn build && serve public" 10 | }, 11 | "dependencies": { 12 | "@prisma/client": "3.0.2" 13 | }, 14 | "devDependencies": { 15 | "@vercel/node": "1.12.1", 16 | "prisma": "3.0.2", 17 | "serve": "12.0.1", 18 | "vercel": "23.1.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demo/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DB_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model Album { 11 | AlbumId Int @id 12 | Title String @db.VarChar(160) 13 | ArtistId Int 14 | Artist Artist @relation(fields: [ArtistId], references: [ArtistId]) 15 | Track Track[] 16 | 17 | @@index([ArtistId], name: "IFK_AlbumArtistId") 18 | } 19 | 20 | model Artist { 21 | ArtistId Int @id 22 | Name String? @db.VarChar(120) 23 | Album Album[] 24 | } 25 | 26 | model Customer { 27 | CustomerId Int @id 28 | FirstName String @db.VarChar(40) 29 | LastName String @db.VarChar(20) 30 | Company String? @db.VarChar(80) 31 | Address String? @db.VarChar(70) 32 | City String? @db.VarChar(40) 33 | State String? @db.VarChar(40) 34 | Country String? @db.VarChar(40) 35 | PostalCode String? @db.VarChar(10) 36 | Phone String? @db.VarChar(24) 37 | Fax String? @db.VarChar(24) 38 | Email String @db.VarChar(60) 39 | SupportRepId Int? 40 | Employee Employee? @relation(fields: [SupportRepId], references: [EmployeeId]) 41 | Invoice Invoice[] 42 | 43 | @@index([SupportRepId], name: "IFK_CustomerSupportRepId") 44 | } 45 | 46 | model Employee { 47 | EmployeeId Int @id 48 | LastName String @db.VarChar(20) 49 | FirstName String @db.VarChar(20) 50 | Title String? @db.VarChar(30) 51 | ReportsTo Int? 52 | BirthDate DateTime? @db.Timestamp(6) 53 | HireDate DateTime? @db.Timestamp(6) 54 | Address String? @db.VarChar(70) 55 | City String? @db.VarChar(40) 56 | State String? @db.VarChar(40) 57 | Country String? @db.VarChar(40) 58 | PostalCode String? @db.VarChar(10) 59 | Phone String? @db.VarChar(24) 60 | Fax String? @db.VarChar(24) 61 | Email String? @db.VarChar(60) 62 | Employee Employee? @relation("EmployeeToEmployee_ReportsTo", fields: [ReportsTo], references: [EmployeeId]) 63 | Customer Customer[] 64 | other_Employee Employee[] @relation("EmployeeToEmployee_ReportsTo") 65 | 66 | @@index([ReportsTo], name: "IFK_EmployeeReportsTo") 67 | } 68 | 69 | model Genre { 70 | GenreId Int @id 71 | Name String? @db.VarChar(120) 72 | Track Track[] 73 | } 74 | 75 | model Invoice { 76 | InvoiceId Int @id 77 | CustomerId Int 78 | InvoiceDate DateTime @db.Timestamp(6) 79 | BillingAddress String? @db.VarChar(70) 80 | BillingCity String? @db.VarChar(40) 81 | BillingState String? @db.VarChar(40) 82 | BillingCountry String? @db.VarChar(40) 83 | BillingPostalCode String? @db.VarChar(10) 84 | Total Decimal @db.Decimal(10, 2) 85 | Customer Customer @relation(fields: [CustomerId], references: [CustomerId]) 86 | InvoiceLine InvoiceLine[] 87 | 88 | @@index([CustomerId], name: "IFK_InvoiceCustomerId") 89 | } 90 | 91 | model InvoiceLine { 92 | InvoiceLineId Int @id 93 | InvoiceId Int 94 | TrackId Int 95 | UnitPrice Decimal @db.Decimal(10, 2) 96 | Quantity Int 97 | Invoice Invoice @relation(fields: [InvoiceId], references: [InvoiceId]) 98 | Track Track @relation(fields: [TrackId], references: [TrackId]) 99 | 100 | @@index([InvoiceId], name: "IFK_InvoiceLineInvoiceId") 101 | @@index([TrackId], name: "IFK_InvoiceLineTrackId") 102 | } 103 | 104 | model MediaType { 105 | MediaTypeId Int @id 106 | Name String? @db.VarChar(120) 107 | Track Track[] 108 | } 109 | 110 | model Playlist { 111 | PlaylistId Int @id 112 | Name String? @db.VarChar(120) 113 | PlaylistTrack PlaylistTrack[] 114 | } 115 | 116 | model PlaylistTrack { 117 | PlaylistId Int 118 | TrackId Int 119 | Playlist Playlist @relation(fields: [PlaylistId], references: [PlaylistId]) 120 | Track Track @relation(fields: [TrackId], references: [TrackId]) 121 | 122 | @@id([PlaylistId, TrackId]) 123 | @@index([TrackId], name: "IFK_PlaylistTrackTrackId") 124 | } 125 | 126 | model Track { 127 | TrackId Int @id 128 | Name String @db.VarChar(200) 129 | AlbumId Int? 130 | MediaTypeId Int 131 | GenreId Int? 132 | Composer String? @db.VarChar(220) 133 | Milliseconds Int 134 | Bytes Int? 135 | UnitPrice Decimal @db.Decimal(10, 2) 136 | Album Album? @relation(fields: [AlbumId], references: [AlbumId]) 137 | Genre Genre? @relation(fields: [GenreId], references: [GenreId]) 138 | MediaType MediaType @relation(fields: [MediaTypeId], references: [MediaTypeId]) 139 | InvoiceLine InvoiceLine[] 140 | PlaylistTrack PlaylistTrack[] 141 | 142 | @@index([AlbumId], name: "IFK_TrackAlbumId") 143 | @@index([GenreId], name: "IFK_TrackGenreId") 144 | @@index([MediaTypeId], name: "IFK_TrackMediaTypeId") 145 | } 146 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@prisma/text-editors", 3 | "version": "0.0.22", 4 | "repository": { 5 | "type": "git", 6 | "url": "git://github.com/prisma/text-editors.git" 7 | }, 8 | "license": "Apache-2.0", 9 | "scripts": { 10 | "prepare": "yarn build:types", 11 | "dev": "vite", 12 | "dev:api": "vercel dev --listen 3001", 13 | "test": "playwright test", 14 | "test:watch": "chokidar \"**/*.ts\" -c \"clear && yarn test --timeout 0\" --silent --initial", 15 | "build": "yarn build:types && yarn build:lib", 16 | "build:types": "zx ./scripts/build-types.mjs", 17 | "build:lib": "zx ./scripts/build-lib.mjs", 18 | "format": "prettier -w . --ignore-path=.gitignore", 19 | "format:check": "prettier -c . --ignore-path=.gitignore", 20 | "prepublishOnly": "yarn build" 21 | }, 22 | "files": [ 23 | "dist" 24 | ], 25 | "main": "./dist/editors.cjs.js", 26 | "module": "./dist/editors.es.js", 27 | "types": "./dist/types/lib.d.ts", 28 | "dependencies": {}, 29 | "devDependencies": { 30 | "@codemirror/autocomplete": "0.19.3", 31 | "@codemirror/closebrackets": "0.19.0", 32 | "@codemirror/commands": "0.19.3", 33 | "@codemirror/comment": "0.19.0", 34 | "@codemirror/fold": "0.19.0", 35 | "@codemirror/gutter": "0.19.1", 36 | "@codemirror/highlight": "0.19.4", 37 | "@codemirror/history": "0.19.0", 38 | "@codemirror/lang-javascript": "0.19.1", 39 | "@codemirror/lang-json": "0.19.1", 40 | "@codemirror/lang-sql": "0.19.3", 41 | "@codemirror/language": "0.19.2", 42 | "@codemirror/lint": "0.19.0", 43 | "@codemirror/matchbrackets": "0.19.1", 44 | "@codemirror/rangeset": "0.19.1", 45 | "@codemirror/state": "0.19.1", 46 | "@codemirror/theme-one-dark": "0.19.0", 47 | "@codemirror/view": "0.19.6", 48 | "@fontsource/jetbrains-mono": "4.5.0", 49 | "@playwright/test": "1.17.2", 50 | "@types/lodash": "4.14.178", 51 | "@types/node": "17.0.8", 52 | "@types/react": "17.0.38", 53 | "@types/react-dom": "17.0.11", 54 | "@types/relaxed-json": "1.0.1", 55 | "@typescript/vfs": "1.3.5", 56 | "@vitejs/plugin-react-refresh": "1.3.6", 57 | "chokidar-cli": "3.0.0", 58 | "fast-glob": "3.2.10", 59 | "localforage": "1.10.0", 60 | "lodash": "4.17.21", 61 | "prettier": "2.5.1", 62 | "react": "17.0.2", 63 | "react-dom": "17.0.2", 64 | "relaxed-json": "1.0.3", 65 | "typescript": "4.5.4", 66 | "vercel": "23.1.2", 67 | "vite": "2.7.10", 68 | "zx": "4.2.0" 69 | }, 70 | "peerDependencies": { 71 | "react": "17.0.0", 72 | "react-dom": "17.0.0" 73 | }, 74 | "prettier": { 75 | "trailingComma": "es5", 76 | "tabWidth": 2, 77 | "semi": true, 78 | "singleQuote": false, 79 | "arrowParens": "avoid" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig } from "@playwright/test"; 2 | 3 | const isCI = !!process.env.CI; 4 | 5 | export default { 6 | timeout: isCI ? 10_000 : Math.pow(2, 30), 7 | retries: isCI ? 2 : 0, 8 | forbidOnly: isCI, 9 | reporter: "list", 10 | workers: isCI ? undefined : 4, // Limit parallelism locally to avoid fan noise 11 | use: { 12 | baseURL: "http://localhost:3000", 13 | headless: isCI || true, // Change to `false` for debugging 14 | viewport: { width: 1280, height: 720 }, 15 | video: "retain-on-failure", 16 | trace: "retain-on-failure", 17 | }, 18 | webServer: { 19 | command: "yarn zx ./scripts/start-test-server.mjs", 20 | port: 3000, 21 | timeout: 100_000, // This is a long timeout because the command will also build all packages if needed 22 | reuseExistingServer: true, 23 | }, 24 | } as PlaywrightTestConfig; 25 | -------------------------------------------------------------------------------- /scripts/build-lib.mjs: -------------------------------------------------------------------------------- 1 | import "zx/globals"; 2 | 3 | await $`rm -rf dist`; 4 | 5 | // Typecheck 6 | await $`yarn tsc`; 7 | 8 | // Build 9 | await $`yarn vite build`; 10 | 11 | // Build type declarations 12 | await $`yarn tsc --noEmit false --declaration --emitDeclarationOnly --isolatedModules false --outDir dist/types`; 13 | -------------------------------------------------------------------------------- /scripts/build-types.mjs: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import "zx/globals"; 5 | 6 | // The goal of this build step is to generate two artifacts (per dependency): 7 | // 1. Metadata: version + file list of dependency (meta.js) 8 | // 2. Data: A key-value store from file names to file content (data.js) 9 | // 10 | // Both of these will be dynamically required by the TS editor. 11 | 12 | // Dependencies that artifacts need to be generated for 13 | const dependencies = { 14 | // Core TS libs 15 | typescript: { 16 | version: "4.5.4", 17 | src: ["lib/*.d.ts"], 18 | }, 19 | // Node libs 20 | "@types/node": { 21 | version: "14", // Because this is the version of Node on Vercel 22 | src: ["*.d.ts"], 23 | }, 24 | }; 25 | 26 | const DEST_ROOT = path.resolve("./src/extensions/typescript/types"); 27 | const DISCLAIMER = "// This file was generated, do not edit manually\n\n"; 28 | 29 | // Clean out the destination 30 | await $`rm -rf ${DEST_ROOT}`; 31 | await $`mkdir -p ${DEST_ROOT}`; 32 | 33 | console.log("Prebuilding types"); 34 | 35 | for (const [dep, { version, src }] of Object.entries(dependencies)) { 36 | console.log(`Using ${dep} version: ${version}`); 37 | 38 | // Prepare destination for this dependency 39 | await $`mkdir -p ${DEST_ROOT}/${dep}`; 40 | 41 | // Get a list of files in this dependency 42 | const files = await glob( 43 | src.map(g => `./node_modules/${dep}/${g}`), 44 | { absolute: true } 45 | ); 46 | 47 | // Generate artifact 1: Metadata 48 | const metaStream = fs.createWriteStream(`${DEST_ROOT}/${dep}/meta.js`); 49 | metaStream.write(DISCLAIMER); 50 | metaStream.write(`export const version = "${version}"\n\n`); 51 | metaStream.write("export const files = ["); 52 | files.forEach(f => { 53 | const name = path.basename(f); 54 | metaStream.write(`\n "${name}",`); 55 | }); 56 | metaStream.write("\n]\n"); 57 | metaStream.end(); 58 | // Generate typedefs so Vite can import it with types 59 | fs.writeFileSync( 60 | `${DEST_ROOT}/${dep}/meta.d.ts`, 61 | `${DISCLAIMER}export const version: string;\nexport const files: string[];` 62 | ); 63 | 64 | // Generate artifact 2: A KV pair from file names to file content 65 | const dataStream = fs.createWriteStream(`${DEST_ROOT}/${dep}/data.js`); 66 | dataStream.write(DISCLAIMER); 67 | dataStream.write(`export const version = "${version}"\n\n`); 68 | dataStream.write("export const files = {"); 69 | files.forEach(f => { 70 | const name = path.basename(f); 71 | const content = fs.readFileSync(path.resolve(f), "utf8"); 72 | dataStream.write(`\n"${name}": `); 73 | dataStream.write(`${JSON.stringify(content)},`); 74 | }); 75 | dataStream.write("\n}\n"); 76 | dataStream.end(); 77 | // Generate typedefs so Vite can import it with types 78 | fs.writeFileSync( 79 | `${DEST_ROOT}/${dep}/data.d.ts`, 80 | `${DISCLAIMER}export const version: string;\nexport const files: Record;` 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /scripts/start-test-server.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Playwright will only run this script if it finds that there isn't already a web server running on port `PORT` 3 | * This allows Playwright to reuse the `dev` server if it is already running, or start it if it isn't. 4 | */ 5 | 6 | import "zx/globals"; 7 | 8 | let devProcess; 9 | 10 | // Start the dev server 11 | devProcess = $`yarn dev`; 12 | 13 | process.on("exit", () => { 14 | devProcess && devProcess.stop(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/editor/base-editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import throttle from "lodash/throttle"; 3 | import { 4 | setDimensions, 5 | setHighlightStyle, 6 | setReadOnly, 7 | setTheme, 8 | ThemeName, 9 | } from "../extensions/appearance"; 10 | import { logger } from "../logger"; 11 | 12 | const log = logger("base-editor", "black"); 13 | 14 | type BaseEditorParams = { 15 | domElement: Element; 16 | }; 17 | 18 | export abstract class BaseEditor { 19 | private domElement: Element; 20 | protected abstract view: EditorView; 21 | 22 | constructor(params: BaseEditorParams) { 23 | this.domElement = params.domElement; 24 | 25 | const onResizeThrottled = throttle(this.setDimensions, 50); 26 | window.addEventListener("resize", onResizeThrottled); 27 | } 28 | 29 | public get state() { 30 | return this.view.state; 31 | } 32 | 33 | public setDimensions = () => { 34 | const dimensions = this.domElement.getBoundingClientRect(); 35 | this.view.dispatch(setDimensions(dimensions.width, dimensions.height)); 36 | }; 37 | 38 | public setTheme = (theme?: ThemeName) => { 39 | this.view.dispatch(setTheme(theme)); 40 | this.view.dispatch(setHighlightStyle(theme)); 41 | }; 42 | 43 | public setReadOnly = (readOnly: boolean) => { 44 | this.view.dispatch(setReadOnly(readOnly)); 45 | }; 46 | 47 | public forceUpdate = (code: string = "") => { 48 | log("Force updating", { code }); 49 | this.view.dispatch({ 50 | changes: [ 51 | { from: 0, to: this.state.doc.length }, 52 | { from: 0, insert: code }, 53 | ], 54 | }); 55 | }; 56 | 57 | public destroy = () => { 58 | this.view.destroy(); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | export type { ThemeName } from "../extensions/appearance"; 2 | export type { PrismaQuery } from "../extensions/prisma-query"; 3 | export type { FileMap } from "../extensions/typescript"; 4 | export { JSONEditor } from "./json-editor"; 5 | export { PrismaSchemaEditor } from "./prisma-schema-editor"; 6 | export { SQLEditor } from "./sql-editor"; 7 | export type { SQLDialect } from "./sql-editor"; 8 | export { TSEditor } from "./ts-editor"; 9 | -------------------------------------------------------------------------------- /src/editor/json-editor.ts: -------------------------------------------------------------------------------- 1 | import { json, jsonParseLinter } from "@codemirror/lang-json"; 2 | import { linter } from "@codemirror/lint"; 3 | import { EditorState } from "@codemirror/state"; 4 | import { EditorView } from "@codemirror/view"; 5 | import { appearance, ThemeName } from "../extensions/appearance"; 6 | import { behaviour } from "../extensions/behaviour"; 7 | import { keymap } from "../extensions/keymap"; 8 | import { logger } from "../logger"; 9 | import { BaseEditor } from "./base-editor"; 10 | 11 | const log = logger("json-editor", "salmon"); 12 | 13 | type JSONEditorParams = { 14 | domElement: Element; 15 | code?: string; 16 | readonly?: boolean; 17 | theme?: ThemeName; 18 | onChange?: (value: string) => void; 19 | }; 20 | 21 | export class JSONEditor extends BaseEditor { 22 | protected view: EditorView; 23 | 24 | /** 25 | * Returns a state-only version of the editor, without mounting the actual view anywhere. Useful for testing. 26 | */ 27 | static state(params: JSONEditorParams) { 28 | return EditorState.create({ 29 | doc: params.code || "[]", 30 | 31 | extensions: [ 32 | EditorView.editable.of(!params.readonly), 33 | json(), 34 | linter(jsonParseLinter()), 35 | 36 | appearance({ domElement: params.domElement, theme: params.theme }), 37 | behaviour({ onChange: params.onChange }), 38 | keymap(), 39 | ], 40 | }); 41 | } 42 | 43 | constructor(params: JSONEditorParams) { 44 | super(params); 45 | 46 | this.view = new EditorView({ 47 | parent: params.domElement, 48 | state: JSONEditor.state(params), 49 | }); 50 | 51 | log("Initialized"); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/editor/prisma-schema-editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { appearance, ThemeName } from "../extensions/appearance"; 4 | import { behaviour } from "../extensions/behaviour"; 5 | import { keymap } from "../extensions/keymap"; 6 | import { logger } from "../logger"; 7 | import { BaseEditor } from "./base-editor"; 8 | 9 | const log = logger("prisma-schema-editor", "salmon"); 10 | 11 | type PrismaSchemaEditorParams = { 12 | domElement: Element; 13 | code?: string; 14 | readonly?: boolean; 15 | theme?: ThemeName; 16 | onChange?: (value: string) => void; 17 | }; 18 | 19 | export class PrismaSchemaEditor extends BaseEditor { 20 | protected view: EditorView; 21 | 22 | /** 23 | * Returns a state-only version of the editor, without mounting the actual view anywhere. Useful for testing. 24 | */ 25 | static state(params: PrismaSchemaEditorParams) { 26 | return EditorState.create({ 27 | doc: params.code || "", 28 | 29 | extensions: [ 30 | EditorView.editable.of(!params.readonly), 31 | 32 | appearance({ domElement: params.domElement, theme: params.theme }), 33 | behaviour({ onChange: params.onChange }), 34 | keymap(), 35 | ], 36 | }); 37 | } 38 | 39 | constructor(params: PrismaSchemaEditorParams) { 40 | super(params); 41 | 42 | this.view = new EditorView({ 43 | parent: params.domElement, 44 | state: PrismaSchemaEditor.state(params), 45 | }); 46 | 47 | log("Initialized"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/editor/sql-editor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | keywordCompletion, 3 | MSSQL, 4 | MySQL, 5 | PostgreSQL, 6 | schemaCompletion, 7 | sql, 8 | StandardSQL, 9 | } from "@codemirror/lang-sql"; 10 | import { EditorState } from "@codemirror/state"; 11 | import { EditorView } from "@codemirror/view"; 12 | import { appearance, ThemeName } from "../extensions/appearance"; 13 | import { behaviour } from "../extensions/behaviour"; 14 | import { keymap } from "../extensions/keymap"; 15 | import { logger } from "../logger"; 16 | import { BaseEditor } from "./base-editor"; 17 | 18 | const log = logger("sql-editor", "aquamarine"); 19 | 20 | export type SQLDialect = "postgresql" | "mysql" | "sqlserver"; 21 | 22 | type SQLEditorParams = { 23 | domElement: Element; 24 | code?: string; 25 | dialect?: SQLDialect; 26 | readonly?: boolean; 27 | theme?: ThemeName; 28 | onChange?: (value: string) => void; 29 | onExecuteQuery?: (query: string) => void; 30 | }; 31 | 32 | export class SQLEditor extends BaseEditor { 33 | protected view: EditorView; 34 | 35 | /** 36 | * Returns a state-only version of the editor, without mounting the actual view anywhere. Useful for testing. 37 | */ 38 | static state(params: SQLEditorParams) { 39 | const sqlDialect = getSqlDialect(params.dialect); 40 | 41 | return EditorState.create({ 42 | doc: params.code || "", 43 | 44 | extensions: [ 45 | EditorView.editable.of(!params.readonly), 46 | sql(), 47 | schemaCompletion({ 48 | dialect: sqlDialect, 49 | tables: [], 50 | }), 51 | keywordCompletion(sqlDialect, true), 52 | 53 | appearance({ domElement: params.domElement, theme: params.theme }), 54 | behaviour({ onChange: params.onChange }), 55 | keymap(), 56 | ], 57 | }); 58 | } 59 | 60 | constructor(params: SQLEditorParams) { 61 | super(params); 62 | 63 | this.view = new EditorView({ 64 | parent: params.domElement, 65 | state: SQLEditor.state(params), 66 | }); 67 | 68 | log("Initialized"); 69 | } 70 | } 71 | 72 | const getSqlDialect = (dialect?: SQLDialect) => { 73 | switch (dialect) { 74 | case "postgresql": 75 | return PostgreSQL; 76 | case "mysql": 77 | return MySQL; 78 | case "sqlserver": 79 | return MSSQL; 80 | default: 81 | return StandardSQL; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/editor/ts-editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { 4 | appearance, 5 | editable, 6 | setReadOnly, 7 | setTheme, 8 | ThemeName, 9 | } from "../extensions/appearance"; 10 | import { behaviour } from "../extensions/behaviour"; 11 | import { keymap as defaultKeymap } from "../extensions/keymap"; 12 | import * as PrismaQuery from "../extensions/prisma-query"; 13 | import { 14 | FileMap, 15 | injectTypes, 16 | setDiagnostics, 17 | typescript, 18 | } from "../extensions/typescript"; 19 | import { logger } from "../logger"; 20 | import { BaseEditor } from "./base-editor"; 21 | 22 | const log = logger("ts-editor", "limegreen"); 23 | 24 | type TSEditorParams = { 25 | domElement: Element; 26 | code?: string; 27 | readonly?: boolean; 28 | types?: FileMap; 29 | theme?: ThemeName; 30 | onChange?: (value: string) => void; 31 | onExecuteQuery?: (query: PrismaQuery.PrismaQuery) => void; 32 | onEnterQuery?: (query: PrismaQuery.PrismaQuery) => void; 33 | onLeaveQuery?: () => void; 34 | }; 35 | 36 | export class TSEditor extends BaseEditor { 37 | protected view: EditorView; 38 | 39 | /** 40 | * Returns a state-only version of the editor, without mounting the actual view anywhere. Useful for testing. 41 | */ 42 | 43 | static state(params: TSEditorParams) { 44 | return EditorState.create({ 45 | doc: params.code || "", 46 | 47 | extensions: [ 48 | editable({ readOnly: !params.readonly }), 49 | 50 | appearance({ 51 | domElement: params.domElement, 52 | theme: params.theme, 53 | highlightStyle: "none", // We'll let the prismaQuery extension handle the highlightStyle 54 | }), 55 | 56 | PrismaQuery.gutter(), 57 | behaviour({ 58 | lineNumbers: false, // We'll let the prismaQuery extension handle line numbers 59 | onChange: params.onChange, 60 | }), 61 | defaultKeymap(), 62 | PrismaQuery.lineNumbers(), 63 | 64 | typescript(), 65 | PrismaQuery.state({ 66 | onExecute: params.onExecuteQuery, 67 | onEnterQuery: params.onEnterQuery, 68 | onLeaveQuery: params.onLeaveQuery, 69 | }), 70 | PrismaQuery.highlightStyle(), 71 | PrismaQuery.keymap(), 72 | ], 73 | }); 74 | } 75 | 76 | constructor(params: TSEditorParams) { 77 | super(params); 78 | 79 | this.view = new EditorView({ 80 | parent: params.domElement, 81 | state: TSEditor.state(params), 82 | }); 83 | 84 | log("Initialized"); 85 | } 86 | 87 | /** @override */ 88 | public setTheme = (theme?: ThemeName) => { 89 | // Override the `setTheme` method to make sure `highlightStyle` never changes from "none" 90 | this.view.dispatch(setTheme(theme)); 91 | }; 92 | 93 | /** @override */ 94 | public setReadOnly = (readOnly: boolean) => { 95 | this.view.dispatch(setReadOnly(readOnly)); 96 | }; 97 | 98 | public injectTypes = async (types: FileMap) => { 99 | this.view.dispatch(injectTypes(types)); 100 | this.view.dispatch(await setDiagnostics(this.view.state)); 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /src/extensions/appearance/base-colors.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | export const base: Extension = [ 5 | EditorView.theme({ 6 | "&": { 7 | fontSize: "14px", 8 | fontFamily: "JetBrains Mono", 9 | }, 10 | ".cm-scroller": { overflow: "auto" }, 11 | ".cm-content": {}, 12 | ".cm-gutters": { border: "none" }, 13 | ".cm-foldMarker": { 14 | width: "12px", 15 | height: "12px", 16 | marginLeft: "8px", 17 | 18 | "&.folded": { 19 | transform: "rotate(-90deg)", 20 | }, 21 | }, 22 | ".cm-foldPlaceholder": { background: "transparent", border: "none" }, 23 | 24 | ".cm-tooltip": { 25 | maxWidth: "800px", 26 | zIndex: "999", 27 | }, 28 | 29 | // Autocomplete 30 | ".cm-tooltip.cm-tooltip-autocomplete > ul": { 31 | minWidth: "250px", 32 | }, 33 | ".cm-tooltip.cm-tooltip-autocomplete > ul > li": { 34 | display: "flex", 35 | alignItems: "center", 36 | padding: "2px", 37 | }, 38 | ".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {}, 39 | ".cm-completionLabel": {}, // Unmatched text 40 | ".cm-completionMatchedText": { 41 | textDecoration: "none", 42 | fontWeight: 600, 43 | color: "#00B4D4", 44 | }, 45 | ".cm-completionDetail": { 46 | fontStyle: "initial", 47 | color: "#ABABAB", 48 | marginLeft: "2rem", 49 | }, // Text to the right of tooltip 50 | ".cm-completionInfo": {}, // "Additional" text that shows up in a panel on the right of the tolltip 51 | 52 | ".cm-completionIcon": { 53 | padding: "0", 54 | marginRight: "4px", 55 | width: "16px", 56 | height: "16px", 57 | backgroundRepeat: "no-repeat", 58 | // 'snippet' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 59 | // acts as a fallback icon 60 | backgroundImage: 61 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2.5 1L2 1.5V13H3V2H14V13H15V1.5L14.5 1H2.5ZM2 15V14H3V15H2ZM5 14.0001H4V15.0001H5V14.0001ZM6 14.0001H7V15.0001H6V14.0001ZM9 14.0001H8V15.0001H9V14.0001ZM10 14.0001H11V15.0001H10V14.0001ZM15 15.0001V14.0001H14V15.0001H15ZM12 14.0001H13V15.0001H12V14.0001Z' fill='%23424242' /%3E%3C/svg%3E\")", 62 | "&:after": { 63 | content: "' '", 64 | }, 65 | 66 | "&.cm-completionIcon-var, &.cm-completionIcon-let, &.cm-completionIcon-const": 67 | { 68 | // 'variable' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 69 | backgroundImage: 70 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 4C1 3.72386 1.21614 3.5 1.48276 3.5H3.89655C4.16317 3.5 4.37931 3.72386 4.37931 4C4.37931 4.27614 4.16317 4.5 3.89655 4.5H1.96552V11.5H3.89655C4.16317 11.5 4.37931 11.7239 4.37931 12C4.37931 12.2761 4.16317 12.5 3.89655 12.5H1.48276C1.21614 12.5 1 12.2761 1 12V4ZM8.97252 4.57125C8.83764 4.48744 8.6718 4.47693 8.52807 4.54309L4.18324 6.5431C4.04909 6.60485 3.95163 6.72512 3.91384 6.86732C3.90234 6.91044 3.89668 6.95446 3.89655 6.9982L3.89655 7V9.5C3.89655 9.67563 3.98552 9.83839 4.13093 9.92875L6.53533 11.4229C6.60993 11.4718 6.69836 11.5001 6.79318 11.5001C6.86852 11.5001 6.93983 11.4823 7.00337 11.4504L11.334 9.45691C11.5083 9.37666 11.6207 9.1976 11.6207 9V6.51396C11.6227 6.44159 11.6094 6.36764 11.5792 6.29706C11.5404 6.20686 11.4791 6.13438 11.4052 6.0836C11.399 6.07935 11.3927 6.07523 11.3863 6.07125L8.97252 4.57125ZM10.0932 6.43389L8.69092 5.56245L5.42408 7.06623L6.8264 7.93767L10.0932 6.43389ZM4.86207 7.88317V9.21691L6.31042 10.117V8.78322L4.86207 7.88317ZM7.27594 10.2306L10.6552 8.67506V7.26954L7.27594 8.82506V10.2306ZM14.5172 12.5C14.7839 12.5 15 12.2761 15 12L15 4C15 3.72386 14.7839 3.5 14.5172 3.5H12.1034C11.8368 3.5 11.6207 3.72386 11.6207 4C11.6207 4.27614 11.8368 4.5 12.1034 4.5L14.0345 4.5L14.0345 11.5L12.1034 11.5C11.8368 11.5 11.6207 11.7239 11.6207 12C11.6207 12.2761 11.8368 12.5 12.1034 12.5H14.5172Z' fill='%23805AD5' /%3E%3C/svg%3E\")", 71 | "&:after": { 72 | content: "' '", 73 | }, 74 | }, 75 | 76 | "&.cm-completionIcon-function, &.cm-completionIcon-method": { 77 | // 'method' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 78 | backgroundImage: 79 | "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 1C7.7033 1 7.41182 1.08255 7.15479 1.23938L7.15385 1.23995L2.84793 3.84401L2.84615 3.8451C2.58915 4.00214 2.37568 4.22795 2.22716 4.49987C2.07864 4.77179 2.0003 5.08077 2 5.39485V10.6056C2.0003 10.9197 2.07864 11.2282 2.22716 11.5001C2.37568 11.772 2.58915 11.9979 2.84615 12.1549L2.84794 12.156L7.15385 14.76L7.15466 14.7605C7.41173 14.9174 7.70325 15 8 15C8.29675 15 8.58828 14.9174 8.84534 14.7605L8.84615 14.76L13.1521 12.156L13.1538 12.1549C13.4109 11.9979 13.6243 11.772 13.7728 11.5001C13.9214 11.2282 13.9997 10.9192 14 10.6051V5.39435C13.9997 5.08027 13.9214 4.77179 13.7728 4.49987C13.6243 4.22795 13.4109 4.00214 13.1538 3.8451L8.84615 1.23995L8.84521 1.23938C8.58818 1.08255 8.2967 1 8 1ZM7.61538 2.086C7.73232 2.01455 7.86497 1.97693 8 1.97693C8.13503 1.97693 8.26768 2.01455 8.38461 2.086L12.6931 4.69163C12.6992 4.69534 12.7052 4.69914 12.7111 4.70302L7.99996 7.42924L3.28893 4.70303C3.29512 4.69898 3.30138 4.69502 3.30769 4.69115L7.61361 2.08709L7.61538 2.086ZM2.92308 5.64668V10.6049C2.92325 10.7476 2.95886 10.8877 3.02633 11.0112C3.0937 11.1346 3.19047 11.237 3.30698 11.3084L7.53857 13.8675V8.31761L2.92308 5.64668ZM8.46165 13.8674L12.6923 11.3089C12.8088 11.2375 12.9063 11.1346 12.9737 11.0112C13.0412 10.8876 13.0768 10.7474 13.0769 10.6046V5.6467L8.46165 8.31743V13.8674Z' fill='%23DD6B20' /%3E%3C/svg%3E\")", 80 | "&:after": { 81 | content: "' '", 82 | }, 83 | }, 84 | "&.cm-completionIcon-property, &.cm-completionIcon-getter": { 85 | // 'field' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 86 | backgroundImage: 87 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 6.39443L1.55279 5.5L8.55279 2H9.44721L14.4472 4.5L15 5.39443V9.89443L14.4472 10.7889L7.44721 14.2889H6.55279L1.55279 11.7889L1 10.8944V6.39443ZM6.5 13.1444L2 10.8944V7.17094L6.5 9.21639V13.1444ZM7.5 13.1444L14 9.89443V6.17954L7.5 9.21287V13.1444ZM9 2.89443L2.33728 6.22579L6.99725 8.34396L13.6706 5.22973L9 2.89443Z' fill='%23805AD5' /%3E%3C/svg%3E\")", 88 | "&:after": { 89 | content: "' '", 90 | }, 91 | }, 92 | 93 | "&.cm-completionIcon-enum, &.cm-completionIcon-enum-member, &.cm-completionIcon-string": 94 | { 95 | // 'constant' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 96 | backgroundImage: 97 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.00024 6H12.0002V7H4.00024V6ZM12.0002 9H4.00024V10H12.0002V9Z' fill='%23424242' /%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1.00024 4L2.00024 3H14.0002L15.0002 4V12L14.0002 13H2.00024L1.00024 12V4ZM2.00024 4V12H14.0002V4H2.00024Z' fill='%23424242' /%3E%3C/svg%3E\")", 98 | "&:after": { 99 | content: "' '", 100 | }, 101 | }, 102 | "&.cm-completionIcon-keyword": { 103 | // 'keyword' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 104 | backgroundImage: 105 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M15 4H10V3H15V4ZM14 7H12V8H14V7ZM10 7H1V8H10V7ZM12 13H1V14H12V13ZM7 10H1V11H7V10ZM15 10H10V11H15V10ZM8 2V5H1V2H8ZM7 3H2V4H7V3Z' fill='%23424242' /%3E%3C/svg%3E\")", 106 | "&:after": { 107 | content: "' '", 108 | }, 109 | }, 110 | "&.cm-completionIcon-class, &.cm-completionIcon-interface, &.cm-completionIcon-alias": 111 | { 112 | // 'class' icon from https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions 113 | backgroundImage: 114 | "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3.35356 6.64642L2.06066 5.35353L5.35356 2.06065L6.64645 3.35354L3.35356 6.64642ZM5 1L1 4.99998V5.70708L3 7.70707H3.70711L4.85355 6.56063V12.3535L5.35355 12.8535H10.0097V13.3741L11.343 14.7074H12.0501L14.7168 12.0407V11.3336L13.3835 10.0003H12.6763L10.8231 11.8535H5.85355V7.89355H10.0097V8.37401L11.343 9.70734H12.0501L14.7168 7.04068V6.33357L13.3835 5.00024H12.6763L10.863 6.81356H5.85355V5.56064L7.70711 3.70709V2.99999L5.70711 1H5ZM11.0703 8.02046L11.6966 8.64668L13.6561 6.68713L13.0299 6.0609L11.0703 8.02046ZM11.0703 13.0205L11.6966 13.6467L13.6561 11.6872L13.0299 11.061L11.0703 13.0205Z' fill='%23DD6B20' /%3E%3C/svg%3E\")", 115 | "&:after": { 116 | content: "' '", 117 | }, 118 | }, 119 | }, 120 | 121 | // Diagnostics (Lint issues) & Quickinfo (Hover tooltips) 122 | ".cm-diagnostic, .cm-quickinfo-tooltip": { 123 | padding: "0.5rem", 124 | fontFamily: "JetBrains Mono", 125 | }, 126 | }), 127 | ]; 128 | -------------------------------------------------------------------------------- /src/extensions/appearance/dark-colors.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@codemirror/state"; 2 | import { 3 | oneDarkHighlightStyle, 4 | oneDarkTheme, 5 | } from "@codemirror/theme-one-dark"; 6 | import { base } from "./base-colors"; 7 | 8 | export const theme = [base, oneDarkTheme]; 9 | 10 | export const highlightStyle: Extension = [oneDarkHighlightStyle]; 11 | -------------------------------------------------------------------------------- /src/extensions/appearance/index.ts: -------------------------------------------------------------------------------- 1 | import { Compartment, Extension, TransactionSpec } from "@codemirror/state"; 2 | import { 3 | EditorView, 4 | highlightActiveLine, 5 | highlightSpecialChars, 6 | } from "@codemirror/view"; 7 | import merge from "lodash/merge"; 8 | import { 9 | highlightStyle as darkHighlightStyle, 10 | theme as darkTheme, 11 | } from "./dark-colors"; 12 | import { 13 | highlightStyle as lightHighlightStyle, 14 | theme as lightTheme, 15 | } from "./light-colors"; 16 | 17 | export type ThemeName = "light" | "dark"; 18 | export type HighlightStyle = "light" | "dark" | "none"; 19 | 20 | const dimensionsCompartment = new Compartment(); 21 | const themeCompartment = new Compartment(); 22 | const highlightStyleCompartment = new Compartment(); 23 | const editableCompartment = new Compartment(); 24 | 25 | const getThemeExtension = (t: ThemeName = "light"): Extension => { 26 | if (t === "light") { 27 | return lightTheme; 28 | } else { 29 | return darkTheme; 30 | } 31 | }; 32 | 33 | const getHighlightStyleExtension = (h: HighlightStyle = "light"): Extension => { 34 | if (h === "light") { 35 | return lightHighlightStyle; 36 | } else if (h === "dark") { 37 | return darkHighlightStyle; 38 | } else { 39 | return []; 40 | } 41 | }; 42 | 43 | export const setTheme = (theme?: ThemeName): TransactionSpec => { 44 | return { 45 | effects: themeCompartment.reconfigure(getThemeExtension(theme)), 46 | }; 47 | }; 48 | 49 | export const setReadOnly = (readOnly: boolean): TransactionSpec => { 50 | return { 51 | effects: editableCompartment.reconfigure(EditorView.editable.of(!readOnly)), 52 | }; 53 | }; 54 | 55 | export const setHighlightStyle = ( 56 | highlightStyle?: HighlightStyle 57 | ): TransactionSpec => { 58 | return { 59 | effects: highlightStyleCompartment.reconfigure( 60 | getHighlightStyleExtension(highlightStyle) 61 | ), 62 | }; 63 | }; 64 | 65 | export const setDimensions = ( 66 | width: number, 67 | height: number 68 | ): TransactionSpec => { 69 | return { 70 | effects: dimensionsCompartment.reconfigure( 71 | EditorView.editorAttributes.of({ 72 | style: `width: ${width || 300}px; height: ${height || 300}px`, 73 | }) 74 | ), 75 | }; 76 | }; 77 | 78 | export const editable = ({ readOnly }: { readOnly: boolean }): Extension => 79 | editableCompartment.of(EditorView.editable.of(readOnly)); 80 | 81 | export const appearance = ({ 82 | domElement, 83 | theme, 84 | highlightStyle, 85 | }: { 86 | domElement?: Element; 87 | theme?: ThemeName; 88 | highlightStyle?: HighlightStyle; 89 | }): Extension => { 90 | const { width, height } = merge( 91 | { width: 300, height: 300 }, 92 | domElement?.getBoundingClientRect() 93 | ); 94 | 95 | return [ 96 | dimensionsCompartment.of( 97 | EditorView.editorAttributes.of({ 98 | style: `width: ${width || 300}px; height: ${height || 300}px`, 99 | }) 100 | ), 101 | themeCompartment.of(getThemeExtension(theme)), 102 | highlightStyleCompartment.of( 103 | getHighlightStyleExtension(highlightStyle || theme) 104 | ), 105 | highlightSpecialChars(), 106 | highlightActiveLine(), 107 | ]; 108 | }; 109 | -------------------------------------------------------------------------------- /src/extensions/appearance/light-colors.ts: -------------------------------------------------------------------------------- 1 | import { HighlightStyle, tags } from "@codemirror/highlight"; 2 | import { Extension } from "@codemirror/state"; 3 | import { EditorView } from "@codemirror/view"; 4 | import { base } from "./base-colors"; 5 | 6 | export const theme: Extension = [ 7 | base, 8 | // Overall editor theme 9 | EditorView.theme( 10 | { 11 | "&": { 12 | background: "#FFFFFF", 13 | }, 14 | ".cm-scroller": { overflow: "auto" }, 15 | ".cm-gutters": { background: "#FFFFFF" }, 16 | ".cm-gutterElement": { color: "#CBD5E1" /* blueGray-300 */ }, 17 | ".cm-foldMarker": { 18 | color: "#94A3B8" /* blueGray-400 */, 19 | }, 20 | ".cm-activeLine, .cm-activeLineGutter": { 21 | background: "#F1F5F9" /* blueGray-100 */, 22 | }, 23 | 24 | // Autocomplete 25 | ".cm-tooltip-autocomplete": { 26 | background: "#E2E8F0" /* blueGray-200 */, 27 | }, 28 | ".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": { 29 | background: "#CBD5E1" /* blueGray-300 */, 30 | color: "#1E293B" /* blueGray-800 */, 31 | }, 32 | ".cm-completionLabel": {}, // Unmatched text 33 | ".cm-completionMatchedText": { 34 | color: "#00B4D4", 35 | }, 36 | ".cm-completionDetail": { 37 | color: "#ABABAB", 38 | }, // Text to the right of tooltip 39 | 40 | // Diagnostics (Lint issues) & Quickinfo (Hover tooltips) 41 | ".cm-diagnostic, .cm-quickinfo-tooltip": { 42 | background: "#E2E8F0" /* blueGray-200 */, 43 | border: "1px solid #CBD5E1" /* blueGray-300 */, 44 | color: "#1E293B" /* blueGray-800 */, 45 | }, 46 | }, 47 | { dark: false } 48 | ), 49 | ]; 50 | 51 | export const highlightStyle: Extension = [ 52 | EditorView.theme({ 53 | // Color for everything that isn't covered down below 54 | ".cm-line": { color: "#1E293B" /* blueGray-800 */ }, 55 | }), 56 | HighlightStyle.define( 57 | [ 58 | // Keywords 59 | { tag: tags.keyword, color: "#BE185D" /* pink-700 */ }, 60 | 61 | // Literals 62 | { tag: [tags.literal, tags.bool], color: "#0F766E" /* teal-700 */ }, 63 | { 64 | tag: [tags.string, tags.special(tags.string), tags.regexp], 65 | color: "#0F766E" /* teal-700 */, 66 | }, 67 | { 68 | tag: tags.escape, 69 | color: "#22C55E" /* green-500 */, 70 | }, 71 | 72 | // Variables 73 | { 74 | tag: [ 75 | tags.definition(tags.variableName), 76 | tags.definition(tags.typeName), 77 | tags.definition(tags.namespace), 78 | tags.definition(tags.className), 79 | ], 80 | color: "#1D4ED8" /* blue-700 */, 81 | }, 82 | { 83 | tag: [ 84 | tags.variableName, 85 | tags.typeName, 86 | tags.namespace, 87 | tags.className, 88 | tags.operator, 89 | tags.bracket, 90 | ], 91 | color: "#1E293B" /* blueGray-800 */, 92 | }, 93 | { 94 | tag: [tags.propertyName, tags.definition(tags.propertyName)], 95 | color: "#9333EA" /* purple-700 */, 96 | }, 97 | { 98 | tag: [ 99 | tags.function(tags.variableName), 100 | tags.function(tags.propertyName), 101 | ], 102 | color: "#EA580C" /* orange-600 */, 103 | }, 104 | 105 | // Misc 106 | { 107 | tag: tags.comment, 108 | color: "#52525B" /* blueGray-600 */, 109 | opacity: 0.5, 110 | }, 111 | { tag: tags.invalid, color: "#000000" /* */ }, 112 | ], 113 | {} 114 | ), 115 | ]; 116 | -------------------------------------------------------------------------------- /src/extensions/behaviour.ts: -------------------------------------------------------------------------------- 1 | import { closeBrackets } from "@codemirror/closebrackets"; 2 | import { codeFolding, foldGutter } from "@codemirror/fold"; 3 | import { 4 | highlightActiveLineGutter, 5 | lineNumbers as lineNumbersGutter, 6 | } from "@codemirror/gutter"; 7 | import { history } from "@codemirror/history"; 8 | import { indentOnInput } from "@codemirror/language"; 9 | import { bracketMatching } from "@codemirror/matchbrackets"; 10 | import { EditorState, Extension } from "@codemirror/state"; 11 | import merge from "lodash/merge"; 12 | import { OnChange, onChangeCallback } from "./change-callback"; 13 | 14 | const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; 15 | 16 | /** 17 | * Convenient bag of useful extensions that control behaviour 18 | */ 19 | export const behaviour = (config: { 20 | lineNumbers?: boolean; 21 | onChange?: OnChange; 22 | }): Extension => { 23 | config = merge({ lineNumbers: true }, config); 24 | 25 | return [ 26 | EditorState.tabSize.of(2), 27 | bracketMatching(), 28 | closeBrackets(), 29 | indentOnInput(), 30 | codeFolding(), 31 | 32 | highlightActiveLineGutter(), 33 | foldGutter({ 34 | markerDOM: isOpen => { 35 | // Feathericons: chevron-down 36 | const svg = document.createElementNS(SVG_NAMESPACE, "svg"); 37 | svg.setAttribute("xmlns", SVG_NAMESPACE); 38 | svg.setAttribute("viewBox", "0 0 24 24"); 39 | svg.setAttribute("fill", "none"); 40 | svg.setAttribute("stroke", "currentColor"); 41 | svg.setAttribute("stroke-width", "2"); 42 | svg.setAttribute("stroke-linecap", "round"); 43 | svg.setAttribute("stroke-linejoin", "round"); 44 | 45 | const polyline = document.createElementNS(SVG_NAMESPACE, "polyline"); 46 | polyline.setAttribute("points", "6 9 12 15 18 9"); 47 | 48 | svg.appendChild(polyline); 49 | 50 | svg.classList.add("cm-foldMarker"); 51 | if (!isOpen) { 52 | svg.classList.add("folded"); 53 | } 54 | 55 | return svg as any; 56 | }, 57 | }), 58 | [config.lineNumbers ? lineNumbersGutter({}) : []], 59 | 60 | history(), 61 | onChangeCallback(config.onChange), 62 | ]; 63 | }; 64 | -------------------------------------------------------------------------------- /src/extensions/change-callback.ts: -------------------------------------------------------------------------------- 1 | import { Extension, Facet } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | import debounce from "lodash/debounce"; 4 | import noop from "lodash/noop"; 5 | import over from "lodash/over"; 6 | 7 | /** 8 | * A Facet that stores all registered `onChange` callbacks 9 | */ 10 | export type OnChange = (code: string, view: EditorView) => void; 11 | const OnChangeFacet = Facet.define({ 12 | combine: input => { 13 | // If multiple `onChange` callbacks are registered, chain them (call them one after another) 14 | return over(input); 15 | }, 16 | }); 17 | 18 | /** 19 | * An extension that calls a (debounced) function when the editor content changes 20 | */ 21 | export const onChangeCallback = (onChange?: OnChange): Extension => { 22 | return [ 23 | OnChangeFacet.of(debounce(onChange || noop, 300)), 24 | EditorView.updateListener.of(({ view, docChanged }) => { 25 | if (docChanged) { 26 | // Call the onChange callback 27 | const content = view.state.sliceDoc(0); 28 | const onChange = view.state.facet(OnChangeFacet); 29 | onChange(content, view); 30 | } 31 | }), 32 | ]; 33 | }; 34 | -------------------------------------------------------------------------------- /src/extensions/keymap.ts: -------------------------------------------------------------------------------- 1 | import { completionKeymap } from "@codemirror/autocomplete"; 2 | import { closeBracketsKeymap } from "@codemirror/closebrackets"; 3 | import { defaultKeymap, indentWithTab } from "@codemirror/commands"; 4 | import { commentKeymap } from "@codemirror/comment"; 5 | import { foldKeymap } from "@codemirror/fold"; 6 | import { undo } from "@codemirror/history"; 7 | import { Extension } from "@codemirror/state"; 8 | import { keymap as keymapFacet } from "@codemirror/view"; 9 | 10 | export const keymap = (): Extension => [ 11 | keymapFacet.of([ 12 | indentWithTab, 13 | ...defaultKeymap, 14 | ...closeBracketsKeymap, 15 | ...commentKeymap, 16 | ...completionKeymap, 17 | ...foldKeymap, 18 | { 19 | key: "Ctrl-z", 20 | mac: "Mod-z", 21 | run: undo, 22 | }, 23 | ]), 24 | ]; 25 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/find-cursor.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | 3 | export function findFirstCursor(state: EditorState) { 4 | // A SelectionRange is a cursor. Even if the user has a "selection", their cursor is still at the edge of the selection 5 | const cursors = state.selection.asSingle().ranges; 6 | return { pos: cursors[0].head }; 7 | } 8 | 9 | export function isCursorInRange(state: EditorState, from: number, to: number) { 10 | const cursor = findFirstCursor(state); 11 | return (cursor?.pos >= from && cursor?.pos <= to) || false; 12 | } 13 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/find-queries.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from "@codemirror/language"; 2 | import { RangeSet, RangeSetBuilder, RangeValue } from "@codemirror/rangeset"; 3 | import { EditorState } from "@codemirror/state"; 4 | import RJSON from "relaxed-json"; 5 | 6 | export type PrismaQuery = { 7 | model?: string; 8 | operation: string; 9 | args: (string | Record)[]; 10 | }; 11 | 12 | /** A Range representing a single PrismaClient query */ 13 | export class PrismaQueryRangeValue extends RangeValue { 14 | public query: PrismaQuery; 15 | 16 | constructor({ 17 | model, 18 | operation, 19 | args, 20 | }: Omit & { args?: string[] }) { 21 | super(); 22 | 23 | this.query = { 24 | model, 25 | operation, 26 | args: [], 27 | }; 28 | 29 | if (args) { 30 | // Try to parse arguments (they will be an object for `prisma.user.findMany({ ... }))`-type queries) 31 | this.query.args = args.map(a => { 32 | try { 33 | return RJSON.parse(a.trim()); // Need a more relaxed JSON.parse (read `https://github.com/phadej/relaxed-json` to understand why) 34 | } catch (_) { 35 | return a.trim(); 36 | } 37 | }); 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * Given an EditorState, returns positions of and decorations associated with all Prisma Client queries 44 | */ 45 | export function findQueries( 46 | state: EditorState 47 | ): RangeSet { 48 | // Terminology here is a mix of JS grammar from ASTExplorer and CodeMirror's Lezer grammar: https://github.com/lezer-parser/javascript/blob/main/src/javascript.grammar 49 | // It is worth it to get familiar with JS grammar and the tree structure before attempting to understand this function 50 | const syntax = syntaxTree(state); 51 | 52 | let prismaVariableName: string; 53 | let queries = new RangeSetBuilder(); 54 | 55 | syntax.iterate({ 56 | enter(type, from) { 57 | // We assume that the PrismaClient instantiation happens before queries are made 58 | // We will traverse the syntax tree and find: 59 | // 1. The name of the variable holding the PrismaClient instance 60 | // 2. All AwaitExpressions that use this instance 61 | // These two _should_ be sufficient in identifying queries 62 | // 63 | // If debugging, it is possible to see what SyntaxNode you're on by slicing the state doc around it like so: 64 | // log({ text: state.sliceDoc(variableDeclaration.from, variableDeclaration.to) }); 65 | // This will give you a readable representation of the node you're working with 66 | 67 | if (type.name === "NewExpression") { 68 | // This `if` branch finds the PrismaClient instance variable name 69 | 70 | // Check if this `new` expressions is for the PrismaClient constructor 71 | 72 | // First, get the `new` keyword in question 73 | // const prisma = new PrismaClient() 74 | // |-| 75 | const newKeyword = syntax.resolve(from, 1); 76 | if (newKeyword?.name !== "new") return; 77 | 78 | // Next, make sure the `new` keyword is initializing a variable 79 | // const prisma = new PrismaClient() 80 | // |------------| 81 | const identifier = newKeyword.nextSibling; 82 | if (identifier?.name !== "VariableName") return; 83 | 84 | // Then, we can find the name of the identifier, which is the name of the class the `new` keyword is instantiating 85 | // const prisma = new PrismaClient() 86 | // |----------| 87 | const identifierName = state.sliceDoc(identifier.from, identifier.to); 88 | // If the identifier isn't `PrismaClient`, it means this `new` keyword is instantiating an irrelevant class 89 | if (identifierName !== "PrismaClient") return; 90 | 91 | // If this is a `new PrismaClient` call, find the name of the variable so we can use it to identify PrismaClient calls 92 | 93 | // First, we try to go two parents up, to find the VariableDeclaration 94 | // const prisma = new PrismaClient() 95 | // |-------------------------------| 96 | const variableDeclaration = newKeyword.parent?.parent; 97 | if (variableDeclaration?.name !== "VariableDeclaration") return; 98 | 99 | // Then, we find its first child, which should be the variable name 100 | // const prisma = new PrismaClient() 101 | // |---| 102 | const constDeclaration = variableDeclaration.firstChild; 103 | if (constDeclaration?.name !== "const") return; 104 | 105 | // Then, we find the ConstDeclaration's sibling 106 | // const prisma = new PrismaClient() 107 | // |----| 108 | const variableName = constDeclaration.nextSibling; 109 | if (variableName?.name !== "VariableDefinition") return; 110 | 111 | // Now that we know the bounds of the variable name, we can slice the doc to find out what its value is 112 | prismaVariableName = state.sliceDoc(variableName.from, variableName.to); 113 | } else if (type.name === "UnaryExpression") { 114 | // This branch finds actual queries using the PrismaClient instance variable name 115 | 116 | // If a PrismaClient instance variable hasn't been found yet, bail, because we cannot possibly find queries 117 | if (!prismaVariableName) return; 118 | 119 | // We need to find two kinds of Prisma Client calls: 120 | // `await prisma.user.findMany({})` 121 | // `await prisma.$connect()` 122 | // Over the course of this function, we'll try to aggresively return early as soon as we discover that the syntax node is not of interest to us 123 | 124 | // A Prisma Client query has three parts: 125 | let model: string | undefined = undefined; // A model (self explanatory) if it is of the form `prisma.user.findMany()`. Optional. 126 | let operation: string | undefined = undefined; // Like `findMany` / `count` / `$queryRaw` etc. Required. 127 | let args: string[] = []; // Arguments passed to the operation function call. Optional. 128 | 129 | // First, make sure this UnaryExpression is an AwaitExpression 130 | // This bails if this syntax node does not have an `await` keyword 131 | // We want this because both queries we're trying to parse have `await`s 132 | // await prisma.user.findMany() OR await prisma.$queryRaw() 133 | // |---| |---| 134 | const awaitKeyword = syntax.resolve(from, 1); 135 | if (awaitKeyword.name !== "await") return; 136 | 137 | // Next, make sure this is a CallExpression 138 | // This bails if the await is not followed by a function call expression 139 | // We want this because both queries we're trying to parse have a function call 140 | // await prisma.user.findMany() OR await prisma.$queryRaw() 141 | // |--------------------| |----------------| 142 | const callExpression = awaitKeyword.nextSibling; 143 | if (callExpression?.name !== "CallExpression") return; 144 | 145 | if (callExpression.lastChild) { 146 | const argsExpression = callExpression.getChild("ArgList"); 147 | 148 | if (argsExpression) { 149 | let arg = argsExpression.firstChild; 150 | while (arg) { 151 | // Skip over unnecessary tokens 152 | if (arg.type.name !== ",") 153 | args.push(state.sliceDoc(arg.from, arg.to)); 154 | 155 | arg = arg.nextSibling; 156 | } 157 | 158 | args = args.slice(1, -1); // Ignore away the parenthesis (first and last child of `argsExpression`) 159 | } 160 | } 161 | 162 | // Next, make sure the CallExpression's first child is a MemberExpression 163 | // This bails if the function call expression does not have a member expression inside it. 164 | // We want this because both kinds of queries we're trying to parse have a member expression inside a call expression. 165 | // await prisma.user.findMany() OR await prisma.$queryRaw() 166 | // |---------| |----| 167 | const memberExpression = callExpression?.firstChild; 168 | if (memberExpression?.name !== "MemberExpression") return; 169 | 170 | if (memberExpression.lastChild) { 171 | operation = state.sliceDoc( 172 | memberExpression.lastChild.from, 173 | memberExpression.lastChild.to 174 | ); 175 | } 176 | 177 | // If the MemberExpression's first child is a VariableName, we might have found a query like: `prisma.$queryRaw` 178 | const maybeVariableNameInsideMemberExpression = 179 | memberExpression.firstChild; 180 | // If the MemberExpression does not have a child at all, then it cannot be of either form, so bail 181 | if (!maybeVariableNameInsideMemberExpression) return; 182 | 183 | // If the MemberExpression's first child is a VariableName, we might have found a query like: `prisma.$queryRaw` 184 | if (maybeVariableNameInsideMemberExpression?.name === "VariableName") { 185 | // But if the variable name is not `prismaVariableName`, then this is a dud. It cannot be of the form `prisma.user.findMany()` either, so we bail 186 | if ( 187 | state.sliceDoc( 188 | maybeVariableNameInsideMemberExpression.from, 189 | maybeVariableNameInsideMemberExpression.to 190 | ) !== prismaVariableName 191 | ) 192 | return; 193 | 194 | // Add query of form `prisma.$queryRaw(...)` 195 | if (operation) { 196 | queries.add( 197 | callExpression.from, 198 | callExpression.to, 199 | new PrismaQueryRangeValue({ model, operation, args }) 200 | ); 201 | } 202 | 203 | return; 204 | } 205 | 206 | // The only kind of query this can be at this point is of the form `prisma.user.findMany()` 207 | // If the MemberExpression's first child was not a VariableName (previous `if` statement), then its grandchild must be. 208 | // await prisma.user.findMany() 209 | // |----| 210 | const maybeVariableNameInsideMemberExpressionInsideMemberExpression = 211 | maybeVariableNameInsideMemberExpression.firstChild; 212 | if ( 213 | maybeVariableNameInsideMemberExpressionInsideMemberExpression?.name !== 214 | "VariableName" 215 | ) 216 | return; 217 | 218 | // But if the variable name is not `prismaVariableName`, then this is a dud. It cannot be of any other form, so bail 219 | if ( 220 | state.sliceDoc( 221 | maybeVariableNameInsideMemberExpressionInsideMemberExpression.from, 222 | maybeVariableNameInsideMemberExpressionInsideMemberExpression.to 223 | ) !== prismaVariableName 224 | ) 225 | return; 226 | 227 | if (maybeVariableNameInsideMemberExpression.lastChild) { 228 | model = state.sliceDoc( 229 | maybeVariableNameInsideMemberExpression.lastChild.from, 230 | maybeVariableNameInsideMemberExpression.lastChild.to 231 | ); 232 | } 233 | 234 | // Add query of form `prisma.user.findMany({ ... })` 235 | if (model && operation) { 236 | queries.add( 237 | callExpression.from, 238 | callExpression.to, 239 | new PrismaQueryRangeValue({ 240 | model, 241 | operation, 242 | args, 243 | }) 244 | ); 245 | } 246 | 247 | return; 248 | } 249 | }, 250 | }); 251 | 252 | return queries.finish(); 253 | } 254 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/gutter.ts: -------------------------------------------------------------------------------- 1 | import { gutter as cmGutter, GutterMarker } from "@codemirror/gutter"; 2 | import { Extension } from "@codemirror/state"; 3 | import { EditorView } from "@codemirror/view"; 4 | import { isCursorInRange } from "./find-cursor"; 5 | import { prismaQueryStateField } from "./state"; 6 | 7 | /** 8 | * invisible = there's no Prisma query on this line 9 | * inactive = there's a Prisma query on this line, but your cursor is not on it 10 | * active = there's a Prisma query on this line, and your cursor is on it 11 | */ 12 | type QueryGutterType = "invisible" | "inactive" | "active"; 13 | 14 | /** 15 | * A GutterMarker that marks lines that have valid PrismaClient queries 16 | */ 17 | class QueryGutterMarker extends GutterMarker { 18 | type: QueryGutterType; 19 | 20 | constructor(type: QueryGutterType) { 21 | super(); 22 | this.type = type; 23 | } 24 | 25 | toDOM() { 26 | const div = document.createElement("div"); 27 | div.classList.add("cm-prismaQuery"); 28 | div.classList.add(this.type); 29 | 30 | return div; 31 | } 32 | } 33 | 34 | export function gutter(): Extension { 35 | return [ 36 | cmGutter({ 37 | /** 38 | * Add a line marker that adds a green, grey or transparent line to the gutter: 39 | * 40 | * 1. An invisible line if this line is not part of a Prisma query 41 | * 2. A grey line if this line is part of a Prisma Query, but your cursor is not on it 42 | * 3. A green line if this line is part of a Prisma Query, and your cursor is on it 43 | */ 44 | lineMarker: (view, line) => { 45 | // If (beginning of) selection range (aka the cursor) is inside the query, add (visible) markers for all lines in query (and invisible ones for others) 46 | // Toggling between visible/invisible instead of adding/removing markers makes it so the editor does not jump when a marker is shown as your cursor moves around 47 | let marker: QueryGutterMarker = new QueryGutterMarker("invisible"); 48 | view.state 49 | .field(prismaQueryStateField) 50 | .between(line.from, line.to, (from, to) => { 51 | const queryLineStart = view.state.doc.lineAt(from); // Get line where this range starts 52 | const queryLineEnd = view.state.doc.lineAt(to); // Get line where this range ends 53 | 54 | // If the cursor is anywhere between the lines that the query starts and ends at, then the green bar should be "active" 55 | marker = new QueryGutterMarker( 56 | isCursorInRange(view.state, queryLineStart.from, queryLineEnd.to) 57 | ? "active" 58 | : "inactive" 59 | ); 60 | }); 61 | 62 | return marker; 63 | }, 64 | }), 65 | // Gutter line marker styles 66 | EditorView.baseTheme({ 67 | ".cm-gutterElement .cm-prismaQuery": { 68 | height: "100%", 69 | 70 | "&.invisible": { 71 | borderLeft: "3px solid transparent", 72 | }, 73 | "&.inactive": { 74 | borderLeft: "3px solid #E2E8F0" /* blueGray-200 */, 75 | }, 76 | "&.active": { 77 | borderLeft: "3px solid #22C55E" /* green-500 */, 78 | }, 79 | }, 80 | }), 81 | ]; 82 | } 83 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/highlight.ts: -------------------------------------------------------------------------------- 1 | import { 2 | classHighlightStyle, 3 | HighlightStyle, 4 | tags, 5 | } from "@codemirror/highlight"; 6 | import { RangeSetBuilder } from "@codemirror/rangeset"; 7 | import { Extension } from "@codemirror/state"; 8 | import { Line } from "@codemirror/text"; 9 | import { 10 | Decoration, 11 | DecorationSet, 12 | EditorView, 13 | ViewPlugin, 14 | ViewUpdate, 15 | } from "@codemirror/view"; 16 | import { prismaQueryStateField } from "./state"; 17 | 18 | type Range = { 19 | start: Line; 20 | length: number; 21 | }; 22 | 23 | /** 24 | * This is a custom highlight style that only highlights Prisma Queries 25 | */ 26 | export const queryHighlightStyle = [ 27 | classHighlightStyle, 28 | HighlightStyle.define([ 29 | // `classHighlightStyle` is a little too generic for some things, so override it in those places 30 | { 31 | tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], 32 | class: "cmt-function", 33 | }, 34 | ]), 35 | EditorView.baseTheme({ 36 | "&light": { 37 | // Dim everything first, then selectively add colors to tokens 38 | "& .cm-line": { 39 | color: "#94A3B8" /* blueGray-400 */, 40 | }, 41 | 42 | "& .cm-prismaQuery": { 43 | // Keywords 44 | "& .cmt-keyword": { color: "#BE185D" /* pink-700 */ }, 45 | 46 | // Literals 47 | "& .cmt-literal, & .cmt-bool": { color: "#0F766E" /* teal-700 */ }, 48 | "& .cmt-string, & .cmt-string2": { 49 | color: "#0F766E" /* teal-700 */, 50 | }, 51 | 52 | // Variables 53 | "& .cmt-definition.cmt-variableName": { 54 | color: "#1D4ED8" /* blue-700 */, 55 | }, 56 | "& .cmt-variableName, & .cmt-typeName, & .cmt-namespace, & .cmt-className, & .cmt-punctuation, & .cmt-operator": 57 | { 58 | color: "#1E293B" /* blueGray-800 */, 59 | }, 60 | "& .cmt-propertyName": { 61 | color: "#9333EA" /* purple-700 */, 62 | }, 63 | "& .cmt-function": { 64 | color: "#EA580C" /* orange-600 */, 65 | }, 66 | 67 | // Misc 68 | "& .cmt-comment": { 69 | color: "#52525B" /* blueGray-600 */, 70 | }, 71 | }, 72 | }, 73 | 74 | // TODO:: Dark base theme 75 | "&dark": {}, 76 | }), 77 | ]; 78 | 79 | /** 80 | * Plugin that adds a special class to all lines that are part of a Prisma Query 81 | */ 82 | const queryHighlightPlugin = ViewPlugin.fromClass( 83 | class { 84 | decorations: DecorationSet; 85 | 86 | constructor(view: EditorView) { 87 | this.decorations = this.buildDecorations(view); 88 | } 89 | 90 | update(viewUpdate: ViewUpdate) { 91 | if (viewUpdate.viewportChanged || viewUpdate.docChanged) { 92 | this.decorations = this.buildDecorations(viewUpdate.view); 93 | } 94 | } 95 | 96 | buildDecorations(view: EditorView) { 97 | let decorations = new RangeSetBuilder(); 98 | const ranges: Range[] = []; 99 | 100 | view.state 101 | .field(prismaQueryStateField) 102 | .between(view.viewport.from, view.viewport.to, (from, to) => { 103 | const start = view.state.doc.lineAt(from); 104 | const end = view.state.doc.lineAt(to); 105 | ranges.push({ 106 | start, 107 | length: end.number - start.number + 1, 108 | }); 109 | }); 110 | 111 | // `between` does not guarantee the order of the ranges, 112 | // but `decorations.add` requires the correct order, so 113 | // we need to manually sort the ranges. 114 | ranges.sort((a, b) => a.start.number - b.start.number); 115 | 116 | ranges.forEach(range => { 117 | for (let x = 0; x < range.length; x += 1) { 118 | const line = view.state.doc.line(range.start.number + x); 119 | decorations.add( 120 | line.from, 121 | line.from, 122 | Decoration.line({ 123 | attributes: { 124 | class: "cm-prismaQuery", 125 | }, 126 | }) 127 | ); 128 | } 129 | }); 130 | 131 | return decorations.finish(); 132 | } 133 | }, 134 | { 135 | decorations: value => value.decorations, 136 | } 137 | ); 138 | 139 | export function highlightStyle(): Extension { 140 | return [queryHighlightPlugin, queryHighlightStyle]; 141 | } 142 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the entrypoint for the PrismaQuery extension. It exports multiple extensions that make Prisma Query functionality work. This includes: 3 | * 4 | * STATE: 5 | * 1. A StateField that will hold ranges and values of PrismaClient queries 6 | * 2. A Facet that will be used to register one or more `onExecute` handlers. This facet's value will be accessible by the StateField 7 | * 3. A Facet that will be used to register one or more `onEnterQuery` handlers. This facet's value will be accessible by the StateField 8 | * 4. A Facet that will be used to register one or more `onLeaveQuery` handlers. This facet's value will be accessible by the StateField 9 | * 5. A `state` extension that tracks Prisma Client queries in the editor 10 | * 11 | * KEYMAP: 12 | * 1. A keyMap that finds the query under the user's cursor and runs it 13 | * 14 | * GUTTER: 15 | * 1. A GutterMarker that displays an element in the gutter for all lines that are valid PrismaClient queries 16 | * 2. An extension that enables this element and styles it 17 | * 18 | * LINE NUMBERS: 19 | * 1. A GutterMarker that displays a run button in the gutter for all lines that are valid PrismaClient queries 20 | * 2. An extension that enables this functionality 21 | * 22 | * HIGHLIGHT: 23 | * 1. A custom highlight style that dims all lines that aren't PrismaClient queries 24 | * 2. An extension that enables it 25 | * 26 | * The "correct" way to read these files is in the order they're mentioned up above 27 | */ 28 | 29 | export type { PrismaQuery } from "./find-queries"; 30 | export { gutter } from "./gutter"; 31 | export { highlightStyle } from "./highlight"; 32 | export { keymap } from "./keymap"; 33 | export { lineNumbers } from "./line-numbers"; 34 | export { state } from "./state"; 35 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/keymap.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Extension } from "@codemirror/state"; 2 | import { keymap as keymapFacet } from "@codemirror/view"; 3 | import { findFirstCursor } from "./find-cursor"; 4 | import { PrismaQuery } from "./find-queries"; 5 | import { log } from "./log"; 6 | import { OnExecuteFacet, prismaQueryStateField } from "./state"; 7 | 8 | export function runQueryUnderCursor(state: EditorState) { 9 | const onExecute = state.facet(OnExecuteFacet); 10 | if (!onExecute) { 11 | log("No OnExecute facet value found, bailing"); 12 | return false; 13 | } 14 | 15 | const firstCursor = findFirstCursor(state); 16 | if (!firstCursor) { 17 | log("Unable to find cursors, bailing"); 18 | return true; 19 | } 20 | 21 | let query: PrismaQuery | null = null; 22 | state 23 | .field(prismaQueryStateField) 24 | .between(firstCursor.pos, firstCursor.pos, (from, to, q) => { 25 | query = q.query; 26 | return false; 27 | }); 28 | 29 | if (!query) { 30 | log("Unable to find relevant query, bailing"); 31 | return true; 32 | } 33 | 34 | log("Running query", query); 35 | onExecute(query); 36 | return true; 37 | } 38 | 39 | /** 40 | * Shortcuts relating to the Prisma Query extension 41 | */ 42 | export function keymap(): Extension { 43 | return [ 44 | keymapFacet.of([ 45 | { 46 | key: "Ctrl-Enter", 47 | mac: "Mod-Enter", 48 | run: ({ state }) => { 49 | runQueryUnderCursor(state); 50 | return true; 51 | }, 52 | }, 53 | ]), 54 | ]; 55 | } 56 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/line-numbers.ts: -------------------------------------------------------------------------------- 1 | import { gutter, GutterMarker } from "@codemirror/gutter"; 2 | import { EditorSelection, Extension } from "@codemirror/state"; 3 | import { EditorView } from "@codemirror/view"; 4 | import { isCursorInRange } from "./find-cursor"; 5 | import { PrismaQuery } from "./find-queries"; 6 | import { log } from "./log"; 7 | import { OnExecuteFacet, prismaQueryStateField } from "./state"; 8 | 9 | const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; 10 | 11 | /** 12 | * A GutterMarker that shows line numbers 13 | */ 14 | class LineNumberMarker extends GutterMarker { 15 | private number: number; 16 | public elementClass: string; 17 | 18 | constructor(number: number) { 19 | super(); 20 | 21 | this.number = number; 22 | this.elementClass = "cm-lineNumbers"; 23 | } 24 | 25 | eq(other: LineNumberMarker) { 26 | return this.number === other.number; 27 | } 28 | 29 | toDOM(view: EditorView) { 30 | const widget = document.createElement("div"); 31 | widget.classList.add("cm-gutterElement"); 32 | widget.textContent = `${this.number}`; 33 | return widget; 34 | } 35 | } 36 | 37 | /** 38 | * A GutterMarker that shows a "run query" button 39 | */ 40 | class RunQueryMarker extends GutterMarker { 41 | private number: number; 42 | private active: boolean; 43 | public elementClass: string; 44 | 45 | constructor(number: number, active: boolean) { 46 | super(); 47 | 48 | this.number = number; 49 | this.active = active; 50 | this.elementClass = "cm-lineNumbers"; 51 | } 52 | 53 | eq(other: RunQueryMarker) { 54 | return this.number === other.number && this.active === other.active; 55 | } 56 | 57 | toDOM(view: EditorView) { 58 | // Feathericons: play-circle 59 | const svg = document.createElementNS(SVG_NAMESPACE, "svg"); 60 | svg.setAttribute("xmlns", SVG_NAMESPACE); 61 | svg.setAttribute("viewBox", "0 0 24 24"); 62 | svg.setAttribute("fill", "none"); 63 | 64 | const circle = document.createElementNS(SVG_NAMESPACE, "path"); 65 | circle.setAttribute("fill-rule", "evenodd"); 66 | circle.setAttribute("clip-rule", "evenodd"); 67 | circle.setAttribute( 68 | "d", 69 | "M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12Z" 70 | ); 71 | circle.setAttribute("fill", "currentColor"); 72 | svg.appendChild(circle); 73 | 74 | // Play button 75 | const path = document.createElementNS(SVG_NAMESPACE, "path"); 76 | path.setAttribute("fill-rule", "evenodd"); 77 | path.setAttribute("clip-rule", "evenodd"); 78 | path.setAttribute( 79 | "d", 80 | "M9.52814 7.11833C9.8533 6.94431 10.2478 6.96338 10.5547 7.16795L16.5547 11.168C16.8329 11.3534 17 11.6656 17 12C17 12.3344 16.8329 12.6466 16.5547 12.8321L10.5547 16.8321C10.2478 17.0366 9.8533 17.0557 9.52814 16.8817C9.20298 16.7077 9 16.3688 9 16V8C9 7.63121 9.20298 7.29235 9.52814 7.11833Z" 81 | ); 82 | path.setAttribute("fill", "white"); 83 | svg.appendChild(path); 84 | 85 | svg.classList.add("cm-gutterElement"); 86 | svg.classList.add("cm-prismaQueryRunButton"); 87 | if (this.active) { 88 | svg.classList.add("active"); 89 | } 90 | 91 | return svg as any; 92 | } 93 | } 94 | 95 | export function lineNumbers(): Extension { 96 | return [ 97 | gutter({ 98 | lineMarker: (view, _line) => { 99 | const line = view.state.doc.lineAt(_line.from); 100 | 101 | // Assume this line should have a line number 102 | let marker: LineNumberMarker | RunQueryMarker = new LineNumberMarker( 103 | line.number 104 | ); 105 | 106 | view.state 107 | .field(prismaQueryStateField) 108 | .between(line.from, line.to, (from, to) => { 109 | // If this is the first line of a query, change the line number to a button 110 | const queryLineStart = view.state.doc.lineAt(from); 111 | if (queryLineStart.number === line.number) { 112 | marker = new RunQueryMarker( 113 | queryLineStart.number, 114 | isCursorInRange(view.state, line.from, to) // If the cursor is inside a query, change the button to `active` 115 | ); 116 | } 117 | }); 118 | 119 | return marker; 120 | }, 121 | domEventHandlers: { 122 | click: (view, line, event) => { 123 | const targetParentElement = (event.target as HTMLDivElement) 124 | .parentNode as HTMLDivElement; 125 | if (targetParentElement?.classList?.contains("cm-lineNumbers")) { 126 | // Clicking on a line number should not execute the query 127 | return false; 128 | } 129 | 130 | // Make cursor jump to this line 131 | if (!isCursorInRange(view.state, line.from, line.to)) { 132 | view.dispatch({ 133 | selection: EditorSelection.single(line.from, line.from), 134 | }); 135 | } 136 | 137 | const onExecute = view.state.facet(OnExecuteFacet); 138 | if (!onExecute) { 139 | log("No OnExecute facet value found, bailing"); 140 | return false; 141 | } 142 | 143 | let query: PrismaQuery | null = null; 144 | view.state 145 | .field(prismaQueryStateField) 146 | .between(line.from, line.to, (from, to, q) => { 147 | query = q.query; 148 | return false; 149 | }); 150 | 151 | if (!query) { 152 | log("Unable to find relevant query, bailing"); 153 | return false; 154 | } 155 | 156 | log("Running query", query); 157 | onExecute(query); 158 | return true; 159 | }, 160 | }, 161 | }), 162 | 163 | // Gutter line marker styles 164 | EditorView.baseTheme({ 165 | ".cm-lineNumbers": { 166 | display: "flex", 167 | 168 | "& .cm-gutterElement": { 169 | padding: "0 8px 0 0", 170 | }, 171 | }, 172 | ".cm-gutterElement": { userSelect: "none" }, 173 | ".cm-prismaQueryRunButton": { 174 | cursor: "pointer", 175 | width: "24px", 176 | height: "24", 177 | color: "#E2E8F0" /* blueGray-200 */, 178 | 179 | "&:hover": { 180 | color: "#16A34A" /* green-600 */, 181 | }, 182 | "&.active": { 183 | color: "#22C55E" /* green-500 */, 184 | 185 | "&:hover": { 186 | color: "#16A34A" /* green-600 */, 187 | }, 188 | }, 189 | }, 190 | }), 191 | ]; 192 | } 193 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/log.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../../logger"; 2 | 3 | export const log = logger("prisma-query-extension", "grey"); 4 | -------------------------------------------------------------------------------- /src/extensions/prisma-query/state.ts: -------------------------------------------------------------------------------- 1 | import { RangeSet } from "@codemirror/rangeset"; 2 | import { Extension, Facet, StateField } from "@codemirror/state"; 3 | import { EditorView } from "@codemirror/view"; 4 | import noop from "lodash/noop"; 5 | import over from "lodash/over"; 6 | import { findFirstCursor } from "./find-cursor"; 7 | import { 8 | findQueries, 9 | PrismaQuery, 10 | PrismaQueryRangeValue, 11 | } from "./find-queries"; 12 | 13 | /** 14 | * Facet to allow configuring query execution callback 15 | */ 16 | export type OnExecute = (query: PrismaQuery) => void; 17 | export const OnExecuteFacet = Facet.define({ 18 | combine: input => { 19 | // If multiple `onExecute` callbacks are registered, chain them (call them one after another) 20 | return over(input); 21 | }, 22 | }); 23 | 24 | /** 25 | * Facet to allow configuring query enter callback 26 | */ 27 | export type OnEnterQuery = (query: PrismaQuery) => void; 28 | export const OnEnterQueryFacet = Facet.define({ 29 | combine: input => { 30 | // If multiple `onEnterQuery` callbacks are registered, chain them (call them one after another) 31 | return over(input); 32 | }, 33 | }); 34 | 35 | /** 36 | * Facet to allow configuring query leave callback 37 | */ 38 | export type OnLeaveQuery = () => void; 39 | export const OnLeaveQueryFacet = Facet.define({ 40 | combine: input => { 41 | // If multiple `onLeaveQuery` callbacks are registered, chain them (call them one after another) 42 | return over(input); 43 | }, 44 | }); 45 | 46 | /** 47 | * State field that tracks which ranges are PrismaClient queries. 48 | * We don't store a DecorationSet directly in the StateField because we need to be able to find the `text` of a query 49 | */ 50 | export const prismaQueryStateField = StateField.define< 51 | RangeSet 52 | >({ 53 | create(state) { 54 | return findQueries(state); 55 | }, 56 | 57 | update(value, transaction) { 58 | if (transaction.docChanged) { 59 | return findQueries(transaction.state); 60 | } 61 | 62 | return value; 63 | }, 64 | }); 65 | 66 | /** 67 | * An extension that enables Prisma Client Query tracking 68 | */ 69 | export function state(config: { 70 | onExecute?: OnExecute; 71 | onEnterQuery?: OnEnterQuery; 72 | onLeaveQuery?: OnLeaveQuery; 73 | }): Extension { 74 | return [ 75 | OnExecuteFacet.of(config.onExecute || noop), 76 | OnEnterQueryFacet.of(config.onEnterQuery || noop), 77 | OnLeaveQueryFacet.of(config.onLeaveQuery || noop), 78 | prismaQueryStateField, 79 | EditorView.updateListener.of(({ view, docChanged }) => { 80 | const onEnterQuery = view.state.facet(OnEnterQueryFacet); 81 | const onLeaveQuery = view.state.facet(OnLeaveQueryFacet); 82 | 83 | const cursor = findFirstCursor(view.state); 84 | const line = view.state.doc.lineAt(cursor.pos); 85 | 86 | let lineHasQuery = false; 87 | view.state 88 | .field(prismaQueryStateField) 89 | .between(line.from, line.to, (from, to, value) => { 90 | lineHasQuery = true; 91 | onEnterQuery(value.query); 92 | }); 93 | 94 | if (!lineHasQuery) { 95 | onLeaveQuery(); 96 | } 97 | }), 98 | ]; 99 | } 100 | -------------------------------------------------------------------------------- /src/extensions/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autocompletion, 3 | completeFromList, 4 | CompletionContext, 5 | CompletionResult, 6 | } from "@codemirror/autocomplete"; 7 | import { javascript } from "@codemirror/lang-javascript"; 8 | import { 9 | Diagnostic, 10 | linter, 11 | setDiagnostics as cmSetDiagnostics, 12 | } from "@codemirror/lint"; 13 | import { 14 | EditorState, 15 | Extension, 16 | StateEffect, 17 | StateField, 18 | TransactionSpec, 19 | } from "@codemirror/state"; 20 | import { hoverTooltip, Tooltip } from "@codemirror/tooltip"; 21 | import { EditorView } from "@codemirror/view"; 22 | import throttle from "lodash/throttle"; 23 | import { 24 | DiagnosticCategory, 25 | displayPartsToString, 26 | flattenDiagnosticMessageText, 27 | } from "typescript"; 28 | import { onChangeCallback } from "../change-callback"; 29 | import { log } from "./log"; 30 | import { FileMap, TypescriptProject } from "./project"; 31 | 32 | export { TypescriptProject }; 33 | export type { FileMap }; 34 | 35 | /** 36 | * This file exports an extension that makes Typescript language services work. This includes: 37 | * 38 | * 1. A StateField that holds an instance of a `TypescriptProject` (used to communicate with tsserver) 39 | * 2. A StateField that stores ranges for lint diagostics (used to cancel hover tooltips if a lint diagnistic is also present at the position) 40 | * 3. A `javascript` extension, that provides syntax highlighting and other simple JS features. 41 | * 4. An `autocomplete` extension that provides tsserver-backed completions, powered by the `completionSource` function 42 | * 5. A `linter` extension that provides tsserver-backed type errors, powered by the `lintDiagnostics` function 43 | * 6. A `hoverTooltip` extension that provides tsserver-backed type information on hover, powered by the `hoverTooltip` function 44 | * 7. An `updateListener` (facet) extension, that ensures that the editor's view is kept in sync with tsserver's view of the file 45 | * 8. A StateEffect that lets a consumer inject custom types into the `TypescriptProject` 46 | * 47 | * The "correct" way to read this file is from bottom to top. 48 | */ 49 | 50 | /** 51 | * A State field that represents the Typescript project that is currently "open" in the EditorView 52 | */ 53 | const tsStateField = StateField.define({ 54 | create(state) { 55 | return new TypescriptProject(state.sliceDoc(0)); 56 | }, 57 | 58 | update(ts, transaction) { 59 | // For all transactions that run, this state field's value will only "change" if a `injectTypesEffect` StateEffect is attached to the transaction 60 | transaction.effects.forEach(e => { 61 | if (e.is(injectTypesEffect)) { 62 | ts.injectTypes(e.value); 63 | } 64 | }); 65 | 66 | return ts; 67 | }, 68 | 69 | compare() { 70 | // There must never be two instances of this state field 71 | return true; 72 | }, 73 | }); 74 | 75 | /** 76 | * A CompletionSource that returns completions to show at the current cursor position (via tsserver) 77 | */ 78 | const completionSource = async ( 79 | ctx: CompletionContext 80 | ): Promise => { 81 | const { state, pos } = ctx; 82 | const ts = state.field(tsStateField); 83 | 84 | try { 85 | const completions = (await ts.lang()).getCompletionsAtPosition( 86 | ts.entrypoint, 87 | pos, 88 | {} 89 | ); 90 | if (!completions) { 91 | log("Unable to get completions", { pos }); 92 | return null; 93 | } 94 | 95 | return completeFromList( 96 | completions.entries.map((c, i) => ({ 97 | type: c.kind, 98 | label: c.name, 99 | boost: 1 / Number(c.sortText), 100 | })) 101 | )(ctx); 102 | } catch (e) { 103 | log("Unable to get completions", { pos, error: e }); 104 | return null; 105 | } 106 | }; 107 | 108 | /** 109 | * A LintSource that returns lint diagnostics across the current editor view (via tsserver) 110 | */ 111 | const lintDiagnostics = async (state: EditorState): Promise => { 112 | const ts = state.field(tsStateField); 113 | const diagnostics = (await ts.lang()).getSemanticDiagnostics(ts.entrypoint); 114 | 115 | return diagnostics 116 | .filter(d => d.start !== undefined && d.length !== undefined) 117 | .map(d => { 118 | let severity: "info" | "warning" | "error" = "info"; 119 | if (d.category === DiagnosticCategory.Error) { 120 | severity = "error"; 121 | } else if (d.category === DiagnosticCategory.Warning) { 122 | severity = "warning"; 123 | } 124 | 125 | return { 126 | from: d.start!, // `!` is fine because of the `.filter()` before the `.map()` 127 | to: d.start! + d.length!, // `!` is fine because of the `.filter()` before the `.map()` 128 | severity, 129 | message: flattenDiagnosticMessageText(d.messageText, "\n", 0), 130 | }; 131 | }); 132 | }; 133 | 134 | /** 135 | * A HoverTooltipSource that returns a Tooltip to show at a given cursor position (via tsserver) 136 | */ 137 | const hoverTooltipSource = async ( 138 | state: EditorState, 139 | pos: number 140 | ): Promise => { 141 | const ts = state.field(tsStateField); 142 | 143 | const quickInfo = (await ts.lang()).getQuickInfoAtPosition( 144 | ts.entrypoint, 145 | pos 146 | ); 147 | if (!quickInfo) { 148 | return null; 149 | } 150 | 151 | return { 152 | pos, 153 | create() { 154 | const dom = document.createElement("div"); 155 | dom.setAttribute("class", "cm-quickinfo-tooltip"); 156 | dom.textContent = 157 | displayPartsToString(quickInfo.displayParts) + 158 | (quickInfo.documentation?.length 159 | ? "\n" + displayPartsToString(quickInfo.documentation) 160 | : ""); 161 | 162 | return { 163 | dom, 164 | }; 165 | }, 166 | above: false, // HACK: This makes it so lint errors show up on TOP of this, so BOTH quickInfo and lint tooltips don't show up at the same time 167 | }; 168 | }; 169 | 170 | /** 171 | * A TransactionSpec that can be dispatched to add new types to the underlying tsserver instance 172 | */ 173 | const injectTypesEffect = StateEffect.define(); 174 | export function injectTypes(types: FileMap): TransactionSpec { 175 | return { 176 | effects: [injectTypesEffect.of(types)], 177 | }; 178 | } 179 | 180 | /** 181 | * A TransactionSpec that can be dispatched to force re-calculation of lint diagnostics 182 | */ 183 | export async function setDiagnostics( 184 | state: EditorState 185 | ): Promise { 186 | const diagnostics = await lintDiagnostics(state); 187 | return cmSetDiagnostics(state, diagnostics); 188 | } 189 | 190 | /** 191 | * A (throttled) function that updates the view of the currently open "file" on TSServer 192 | */ 193 | const updateTSFileThrottled = throttle((code: string, view: EditorView) => { 194 | const ts = view.state.field(tsStateField); 195 | 196 | // Don't `await` because we do not want to block 197 | ts.env().then(env => env.updateFile(ts.entrypoint, code || " ")); // tsserver deletes the file if the text content is empty; we can't let that happen 198 | }, 100); 199 | 200 | // Export a function that will build & return an Extension 201 | export function typescript(): Extension { 202 | return [ 203 | tsStateField, 204 | javascript({ typescript: true, jsx: false }), 205 | autocompletion({ 206 | activateOnTyping: true, 207 | maxRenderedOptions: 30, 208 | override: [completionSource], 209 | }), 210 | linter(view => lintDiagnostics(view.state)), 211 | hoverTooltip((view, pos) => hoverTooltipSource(view.state, pos), { 212 | hideOnChange: true, 213 | }), 214 | EditorView.updateListener.of(({ view, docChanged }) => { 215 | // We're not doing this in the `onChangeCallback` extension because we do not want TS file updates to be debounced (we want them throttled) 216 | 217 | if (docChanged) { 218 | // Update tsserver's view of this file 219 | updateTSFileThrottled(view.state.sliceDoc(0), view); 220 | } 221 | }), 222 | onChangeCallback(async (_code, view) => { 223 | // No need to debounce here because this callback is already debounced 224 | 225 | // Re-compute lint diagnostics via tsserver 226 | view.dispatch(await setDiagnostics(view.state)); 227 | }), 228 | ]; 229 | } 230 | -------------------------------------------------------------------------------- /src/extensions/typescript/log.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../../logger"; 2 | 3 | export const log = logger("typescript-extension", "skyblue"); 4 | -------------------------------------------------------------------------------- /src/extensions/typescript/project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSystem, 3 | createVirtualTypeScriptEnvironment, 4 | VirtualTypeScriptEnvironment, 5 | } from "@typescript/vfs"; 6 | import typescript from "typescript"; 7 | import { log } from "./log"; 8 | import { TSFS } from "./tsfs"; 9 | 10 | const TS_PROJECT_ENTRYPOINT = "index.ts"; 11 | export type FileMap = Record; 12 | 13 | /** 14 | * A representation of a Typescript project. Only supports single-file projects currently. 15 | */ 16 | export class TypescriptProject { 17 | private fs: TSFS; 18 | 19 | /** 20 | * Since this module is lazily initialized, this serves as a way to throttle multiple consecutive `init` requests. 21 | * We want to avoid initializing `tsserver` multiple times. 22 | * 23 | * After construction, it stays in the `procrastinating` state until someone requests something of it. 24 | * Once that happens, it goes through the `initializing` & `ready` states. 25 | */ 26 | private state: "procrastinating" | "initializing" | "ready"; 27 | /** When initialization starts, the promise it returns is stored here so that future `init` requests can be throttled */ 28 | private initPromise?: Promise; 29 | private tsserver?: VirtualTypeScriptEnvironment; 30 | 31 | constructor(entrypointFileContent: string) { 32 | this.fs = new TSFS(); 33 | this.fs.fs.set(TS_PROJECT_ENTRYPOINT, entrypointFileContent || " "); // tsserver ignores files with empty content, so give it something in case `entrypointFileContent` is empty 34 | this.state = "procrastinating"; 35 | this.initPromise = undefined; 36 | } 37 | 38 | get entrypoint() { 39 | return TS_PROJECT_ENTRYPOINT; 40 | } 41 | 42 | private async init(): Promise { 43 | this.state = "initializing"; 44 | await this.fs.injectCoreLibs(); 45 | 46 | const system = createSystem(this.fs.fs); 47 | this.tsserver = createVirtualTypeScriptEnvironment( 48 | system, 49 | [TS_PROJECT_ENTRYPOINT], 50 | typescript, 51 | { 52 | target: typescript.ScriptTarget.ESNext, 53 | } 54 | ); 55 | 56 | log("Initialized"); 57 | window.ts = this.tsserver; 58 | this.state = "ready"; 59 | } 60 | 61 | public async injectTypes(types: FileMap) { 62 | const ts = await this.env(); 63 | for (const [name, content] of Object.entries(types)) { 64 | if (!content.trim()) { 65 | continue; 66 | } 67 | log(`Injecting types for ${name} to tsserver`); 68 | // if tsserver has initialized, we must add files to it, modifying the FS will do nothing 69 | ts.createFile(name, content); 70 | } 71 | } 72 | 73 | public async env(): Promise { 74 | // If this is the first time someone has requested something, start initialization 75 | if (this.state === "procrastinating") { 76 | this.initPromise = this.init(); 77 | await this.initPromise; 78 | return this.tsserver!; 79 | } 80 | 81 | // If this is already initializing, return the initPromise so avoid double initialization 82 | if (this.state === "initializing") { 83 | await this.initPromise; 84 | return this.tsserver!; 85 | } 86 | 87 | // If this is ready, you're good to go 88 | return this.tsserver!; 89 | } 90 | 91 | public async lang(): Promise< 92 | VirtualTypeScriptEnvironment["languageService"] 93 | > { 94 | const env = await this.env(); 95 | return env.languageService; 96 | } 97 | 98 | public destroy() { 99 | log("Destroying language service"); 100 | this.tsserver?.languageService.dispose(); 101 | 102 | log("Destroying tsserver"); 103 | this.state = "procrastinating"; 104 | this.initPromise = undefined; 105 | this.tsserver = undefined; 106 | } 107 | } 108 | 109 | interface ExtendedWindow extends Window { 110 | ts?: VirtualTypeScriptEnvironment; 111 | } 112 | declare const window: ExtendedWindow; 113 | -------------------------------------------------------------------------------- /src/extensions/typescript/tsfs.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage"; 2 | import { log } from "./log"; 3 | 4 | type LibName = "typescript" | "@types/node"; 5 | type FileName = string; 6 | type FileContent = string; 7 | 8 | /** 9 | * A virtual file-system to manage files for the TS Language server 10 | */ 11 | export class TSFS { 12 | /** Internal map of file names to their content */ 13 | public fs: Map; 14 | 15 | constructor() { 16 | this.fs = new Map(); 17 | } 18 | 19 | /** 20 | * Given a lib name, runs a callback function for every file in that library. 21 | * It might fetch files in the library from a cache or from the network 22 | */ 23 | private forFileInLib = async ( 24 | libName: LibName, 25 | cb: (name: string, content: string) => void 26 | ) => { 27 | // First, we fetch metadata about the library 28 | 29 | // Rollup needs us to use static strings for dynamic imports 30 | const meta = 31 | libName === "typescript" 32 | ? await import("./types/typescript/meta.js") 33 | : await import("./types/@types/node/meta.js"); 34 | 35 | // The metadata tells us the version of the library, and gives us a list of file names in the library. 36 | // If our cache already has this version of the library: 37 | // 1. We iterate over the file names in that library and fetch file contents from DB (compressed). 38 | // 2. We iterate over the file names in taht library and call the callback function for every (fileName, fileContent) pair 39 | // 40 | // If our cache does not have this version of the library: 41 | // 1. We fetch all files of this library via the network 42 | // 2. We remove any other versions of the library that were cached (to conserve space) 43 | // 3. We iterate over the files we just fetched and cache them (compressed) 44 | // 4. We iterate over the files we just fetched and call the callback for every (fileName, fileContent) pair 45 | 46 | const isCached = 47 | (await localforage.getItem(`ts-lib/${libName}/_version`)) === 48 | meta.version; 49 | 50 | // TODO:: Integrity checks? 51 | if (isCached) { 52 | log(`Injecting ${libName} ${meta.version} from cache`); 53 | const fileNames = meta.files; 54 | const fileContents = (await Promise.all( 55 | fileNames.map(f => 56 | localforage.getItem(`ts-lib/${libName}/${meta.version}/${f}`) 57 | ) 58 | )) as string[]; // type-cast is olay because we know this file should exist 59 | 60 | fileNames.forEach((name, i) => cb(name, fileContents[i])); 61 | } else { 62 | // Remove everything, we'll download types and cache them 63 | await localforage.clear(); 64 | 65 | log(`Downloading & Injecting ${libName} ${meta.version}`); 66 | 67 | // Rollup needs us to use static strings for dynamic imports 68 | const data = 69 | libName === "typescript" 70 | ? await import("./types/typescript/data.js") 71 | : await import("./types/@types/node/data.js"); 72 | 73 | // Add new things to DB 74 | const files = Object.entries(data.files); 75 | 76 | // First, call the callback for all these files to unblock the caller 77 | files.forEach(([name, content]) => cb(name, content)); 78 | 79 | // Then, persist these file contents in DB 80 | await Promise.all([ 81 | localforage.setItem(`ts-lib/${libName}/_version`, meta.version), 82 | ...files.map(([name, content]) => 83 | localforage.setItem( 84 | `ts-lib/${libName}/${data.version}/${name}`, 85 | content 86 | ) 87 | ), 88 | ]); 89 | } 90 | }; 91 | 92 | injectCoreLibs = async () => { 93 | await Promise.all([ 94 | this.forFileInLib("typescript", (name, content) => { 95 | // TS Core libs need to be available at the root path `/` (TSServer requires this) 96 | this.fs.set("/" + name, content); 97 | }), 98 | this.forFileInLib("@types/node", (name, content) => { 99 | // Additional libs need to be faked so they look like they're coming from node_modules (TSServer requires this) 100 | this.fs.set("/node_modules/@types/node/" + name, content); 101 | }), 102 | ]); 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import "@fontsource/jetbrains-mono"; 2 | import localforage from "localforage"; 3 | 4 | localforage.config({ 5 | name: "@prisma/text-editors", 6 | storeName: "types", 7 | }); 8 | 9 | window.localforage = localforage; 10 | 11 | interface ExtendedWindow extends Window { 12 | localforage?: typeof localforage; 13 | } 14 | declare const window: ExtendedWindow; 15 | 16 | export type { FileMap, PrismaQuery, SQLDialect, ThemeName } from "./editor"; 17 | export { Editor } from "./react/Editor"; 18 | export type { EditorProps } from "./react/Editor"; 19 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export function logger(namespace: string, color: string = "orange") { 2 | return function (...args: any[]) { 3 | console.groupCollapsed(`%c[${namespace}]`, `color:${color};`, ...args); 4 | console.trace(); 5 | console.groupEnd(); 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/react/Editor.tsx: -------------------------------------------------------------------------------- 1 | import isEqual from "lodash/isEqual"; 2 | import isEqualWith from "lodash/isEqualWith"; 3 | import React, { CSSProperties } from "react"; 4 | import { 5 | FileMap, 6 | JSONEditor, 7 | PrismaQuery, 8 | PrismaSchemaEditor, 9 | SQLEditor, 10 | ThemeName, 11 | TSEditor, 12 | } from "../editor"; 13 | 14 | type LangEditor = TSEditor | JSONEditor | SQLEditor | PrismaSchemaEditor; 15 | 16 | export type EditorProps = { 17 | /** (Controlled) Value of the editor. 18 | * 19 | * Typically, you should only react to changes to the value by subscribing to `onChange`, and let the editor own the `value`. 20 | * Changing this value on your own will force the editor to be redrawn from scratch. 21 | */ 22 | value?: string; 23 | /** Controls if the editor is readonly */ 24 | readonly?: boolean; 25 | /** Theme for the editor */ 26 | theme?: ThemeName; 27 | /** Additional styles for the editor container */ 28 | style?: CSSProperties; 29 | /** Additional classes for the editor container */ 30 | className?: string; 31 | /** Callback called when the value of the editor changes (debounced) */ 32 | onChange?: (value: string) => void; 33 | } & ( 34 | | { 35 | /** Language to syntax highlight text as */ 36 | lang: "ts"; 37 | /** Additional Typescript types to load into the editor */ 38 | types?: FileMap; 39 | /** Callback called when the user requests a query to be run */ 40 | onExecuteQuery?: (query: PrismaQuery) => void; 41 | /** Callback called every time the user's cursor moves inside a query */ 42 | onEnterQuery?: (query: PrismaQuery) => void; 43 | /** Callback called every time the user's cursor moves outside a query */ 44 | onLeaveQuery?: () => void; 45 | } 46 | | { 47 | lang: "json"; 48 | } 49 | | { 50 | lang: "sql"; 51 | } 52 | | { 53 | lang: "prisma"; 54 | } 55 | ); 56 | 57 | // This component is deliberately not a function component because hooks complicate the logic we need for it 58 | 59 | export class Editor extends React.Component { 60 | private ref = React.createRef(); 61 | private editor?: LangEditor; 62 | private resizeObserver?: ResizeObserver; 63 | 64 | componentDidMount() { 65 | switch (this.props.lang) { 66 | case "ts": 67 | this.editor = new TSEditor({ 68 | domElement: this.ref.current!, // `!` is fine because this will run after the component has mounted 69 | code: this.props.value, 70 | readonly: this.props.readonly, 71 | theme: this.props.theme, 72 | types: this.props.types, 73 | onChange: this.props.onChange, 74 | onExecuteQuery: this.props.onExecuteQuery, 75 | onEnterQuery: this.props.onEnterQuery, 76 | onLeaveQuery: this.props.onLeaveQuery, 77 | }); 78 | break; 79 | 80 | case "json": 81 | this.editor = new JSONEditor({ 82 | domElement: this.ref.current!, // `!` is fine because this will run after the component has mounted 83 | code: this.props.value, 84 | readonly: this.props.readonly, 85 | theme: this.props.theme, 86 | onChange: this.props.onChange, 87 | }); 88 | break; 89 | 90 | case "sql": 91 | this.editor = new SQLEditor({ 92 | domElement: this.ref.current!, // `!` is fine because this will run after the component has mounted 93 | code: this.props.value, 94 | readonly: this.props.readonly, 95 | theme: this.props.theme, 96 | onChange: this.props.onChange, 97 | }); 98 | break; 99 | 100 | case "prisma": 101 | this.editor = new PrismaSchemaEditor({ 102 | domElement: this.ref.current!, // `!` is fine because this will run after the component has mounted 103 | code: this.props.value, 104 | readonly: this.props.readonly, 105 | theme: this.props.theme, 106 | onChange: this.props.onChange, 107 | }); 108 | break; 109 | 110 | default: 111 | throw new Error("Unknown `lang` prop provided to Editor"); 112 | } 113 | 114 | this.resizeObserver = new ResizeObserver(() => 115 | this.editor?.setDimensions() 116 | ); 117 | this.resizeObserver.observe(this.ref.current!); 118 | } 119 | 120 | shouldComponentUpdate(nextProps: EditorProps) { 121 | // Do a deep comparison check for props 122 | // We need this because `types` is an object 123 | return !isEqualWith(this.props, nextProps, (a, b) => { 124 | if (typeof a === "function" || typeof b === "function") { 125 | // Do not compare functions 126 | return true; 127 | } 128 | 129 | // Let lodash handle comparing the rest 130 | return undefined; 131 | }); 132 | } 133 | 134 | componentDidUpdate(prevProps: EditorProps) { 135 | if (!this.editor) { 136 | return; 137 | } 138 | if ( 139 | typeof this.props.readonly !== "undefined" && 140 | prevProps.readonly !== this.props.readonly 141 | ) { 142 | this.editor.setReadOnly(this.props.readonly); 143 | } 144 | 145 | // Ensures `value` given to this component is always reflected in the editor 146 | // Only execute forceUpdate actions on readonly instances 147 | if ( 148 | this.props.value !== this.editor.state.sliceDoc(0) && 149 | this.props.readonly 150 | ) { 151 | this.editor.forceUpdate(this.props.value); 152 | } 153 | 154 | // Ensures `types` given to this component are always reflected in the editor 155 | if (prevProps.lang === "ts" && this.props.lang === "ts") { 156 | if (this.props.types && !isEqual(prevProps.types, this.props.types)) { 157 | (this.editor as TSEditor).injectTypes(this.props.types); 158 | } 159 | } 160 | 161 | // Ensures `theme` given to this component is always reflected in the editor 162 | if (this.props.theme && prevProps.theme !== this.props.theme) { 163 | this.editor.setTheme(this.props.theme); 164 | } 165 | } 166 | 167 | componentWillUnmount() { 168 | this.resizeObserver?.disconnect(); 169 | } 170 | 171 | render() { 172 | return ( 173 |
179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/find-cursor.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorSelection, 3 | EditorState, 4 | Extension, 5 | SelectionRange, 6 | } from "@codemirror/state"; 7 | import { expect, test } from "@playwright/test"; 8 | import { 9 | findFirstCursor, 10 | isCursorInRange, 11 | } from "../src/extensions/prisma-query/find-cursor"; 12 | 13 | function editorState(extensions: Extension = []) { 14 | return EditorState.create({ 15 | extensions, 16 | }); 17 | } 18 | 19 | test.describe.parallel("findFirstCursor", () => { 20 | test("can find cursor when there is none", () => { 21 | const state = EditorState.create(); 22 | const cursor = findFirstCursor(state); 23 | 24 | expect(cursor).not.toBe(null); 25 | expect(cursor).toHaveProperty("pos", 0); 26 | }); 27 | 28 | test("can find cursor when there is one", () => { 29 | const state = EditorState.create({ 30 | doc: "Some text", 31 | selection: EditorSelection.cursor(4), 32 | }); 33 | const cursor = findFirstCursor(state); 34 | 35 | expect(cursor).not.toBe(null); 36 | expect(cursor).toHaveProperty("pos", 4); 37 | }); 38 | 39 | test("can find cursor when there are multiple", () => { 40 | // Multiple selection ranges are unsupported 41 | const state = EditorState.create({ 42 | doc: "Some text", 43 | extensions: [EditorState.allowMultipleSelections.of(true)], 44 | selection: EditorSelection.create([ 45 | SelectionRange.fromJSON({ 46 | anchor: 5, 47 | head: 5, 48 | }), 49 | SelectionRange.fromJSON({ 50 | anchor: 4, 51 | head: 4, 52 | }), 53 | ]), 54 | }); 55 | const cursor = findFirstCursor(state); 56 | 57 | expect(cursor).not.toBe(null); 58 | // Ranges will be sorted internally 59 | // Last selection is considered 60 | // Selection heads are treated as cursors 61 | expect(cursor).toHaveProperty("pos", 5); 62 | }); 63 | 64 | test("can find cursor when there is a selection range only", () => { 65 | const state = EditorState.create({ 66 | doc: "Some text", 67 | extensions: [EditorState.allowMultipleSelections.of(true)], 68 | selection: EditorSelection.create([ 69 | SelectionRange.fromJSON({ 70 | anchor: 5, 71 | head: 7, 72 | }), 73 | ]), 74 | }); 75 | const cursor = findFirstCursor(state); 76 | 77 | expect(cursor).not.toBe(null); 78 | expect(cursor).toHaveProperty("pos", 7); 79 | }); 80 | 81 | test("can find cursor when there are multiple selection ranges", () => { 82 | // Multiple selection ranges are unsupported 83 | const state = EditorState.create({ 84 | doc: "Some text", 85 | extensions: [EditorState.allowMultipleSelections.of(true)], 86 | selection: EditorSelection.create([ 87 | SelectionRange.fromJSON({ 88 | anchor: 5, 89 | head: 7, 90 | }), 91 | SelectionRange.fromJSON({ 92 | anchor: 0, 93 | head: 4, 94 | }), 95 | ]), 96 | }); 97 | const cursor = findFirstCursor(state); 98 | 99 | expect(cursor).not.toBe(null); 100 | // Ranges will be sorted internally 101 | // Last selection is considered 102 | // Selection heads are treated as cursors 103 | expect(cursor).toHaveProperty("pos", 7); 104 | }); 105 | }); 106 | 107 | test.describe("isCursorInRange", () => { 108 | test("works when there is no cursor", () => { 109 | const state = editorState(); 110 | const isInRange = isCursorInRange(state, 0, 0); 111 | }); 112 | test("works when there is one cursor", () => {}); 113 | test("works when there are multiple cursors", () => {}); 114 | test("works when there is a selection range only", () => {}); 115 | test("works when there are multiple selection ranges", () => {}); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/find-queries.spec.ts: -------------------------------------------------------------------------------- 1 | import { javascript } from "@codemirror/lang-javascript"; 2 | import { EditorState } from "@codemirror/state"; 3 | import { expect, test } from "@playwright/test"; 4 | import { findQueries } from "../src/extensions/prisma-query/find-queries"; 5 | 6 | const prismaClientImport = `import { PrismaClient } from '@prisma/client'\nconst prisma = new PrismaClient()\n`; 7 | 8 | const modelQuery = `prisma.user.findUnique({ where: { id: 1 } })`; 9 | const modelQueryModel = "user"; 10 | const modelQueryOperation = "findUnique"; 11 | const modelQueryArgs = [{ where: { id: 1 } }]; 12 | 13 | const genericQuery = `prisma.$executeRaw(\`SELECT * FROM "Album"\`)`; 14 | const genericQueryModel = undefined; 15 | const genericQueryOperation = "$executeRaw"; 16 | const genericQueryArgs = ['`SELECT * FROM "Album"`']; 17 | 18 | test.describe.parallel("findQueries", () => { 19 | test("does not include the `await` keyword in model query ranges", () => { 20 | const state = EditorState.create({ 21 | doc: `${prismaClientImport}\nawait ${modelQuery}`, 22 | extensions: [javascript({ typescript: true })], 23 | }); 24 | 25 | const queries = findQueries(state).iter(0); 26 | expect(queries).toHaveProperty("from", 87); 27 | expect(queries).toHaveProperty("to", 87 + modelQuery.length); 28 | expect(queries.value).not.toBe(null); 29 | expect(queries.value).toHaveProperty("query", { 30 | model: modelQueryModel, 31 | operation: modelQueryOperation, 32 | args: modelQueryArgs, 33 | }); 34 | 35 | queries.next(); 36 | expect(queries.value).toBe(null); 37 | }); 38 | test("does not include the `await` keyword in generic query ranges", () => { 39 | const state = EditorState.create({ 40 | doc: `${prismaClientImport}\nawait ${genericQuery}`, 41 | extensions: [javascript({ typescript: true })], 42 | }); 43 | 44 | const queries = findQueries(state).iter(0); 45 | expect(queries).toHaveProperty("from", 87); 46 | expect(queries).toHaveProperty("to", 87 + genericQuery.length); 47 | expect(queries.value).not.toBe(null); 48 | expect(queries.value).toHaveProperty("query", { 49 | model: genericQueryModel, 50 | operation: genericQueryOperation, 51 | args: genericQueryArgs, 52 | }); 53 | 54 | queries.next(); 55 | expect(queries.value).toBe(null); 56 | }); 57 | 58 | test("can find top-level, model queries", () => { 59 | const state = EditorState.create({ 60 | doc: `${prismaClientImport}\nawait ${modelQuery}\n\nawait ${modelQuery}`, 61 | extensions: [javascript({ typescript: true })], 62 | }); 63 | 64 | const queries = findQueries(state).iter(0); 65 | expect(queries).toHaveProperty("from", 87); 66 | expect(queries).toHaveProperty("to", 87 + modelQuery.length); 67 | expect(queries.value).not.toBe(null); 68 | expect(queries.value).toHaveProperty("query", { 69 | model: modelQueryModel, 70 | operation: modelQueryOperation, 71 | args: modelQueryArgs, 72 | }); 73 | 74 | queries.next(); 75 | expect(queries).toHaveProperty("from", 139); 76 | expect(queries).toHaveProperty("to", 139 + modelQuery.length); 77 | expect(queries.value).not.toBe(null); 78 | expect(queries.value).toHaveProperty("query", { 79 | model: modelQueryModel, 80 | operation: modelQueryOperation, 81 | args: modelQueryArgs, 82 | }); 83 | 84 | queries.next(); 85 | expect(queries.value).toBe(null); 86 | }); 87 | test("can find top-level, generic queries", () => { 88 | const state = EditorState.create({ 89 | doc: `${prismaClientImport}\nawait ${genericQuery}\n\nawait ${genericQuery}`, 90 | extensions: [javascript({ typescript: true })], 91 | }); 92 | 93 | const queries = findQueries(state).iter(0); 94 | expect(queries).toHaveProperty("from", 87); 95 | expect(queries).toHaveProperty("to", 87 + genericQuery.length); 96 | expect(queries.value).not.toBe(null); 97 | expect(queries.value).toHaveProperty("query", { 98 | model: genericQueryModel, 99 | operation: genericQueryOperation, 100 | args: genericQueryArgs, 101 | }); 102 | 103 | queries.next(); 104 | expect(queries).toHaveProperty("from", 138); 105 | expect(queries).toHaveProperty("to", 138 + genericQuery.length); 106 | expect(queries.value).not.toBe(null); 107 | expect(queries.value).toHaveProperty("query", { 108 | model: genericQueryModel, 109 | operation: genericQueryOperation, 110 | args: genericQueryArgs, 111 | }); 112 | 113 | queries.next(); 114 | expect(queries.value).toBe(null); 115 | }); 116 | 117 | test("can find model queries inside regular functions", () => { 118 | const state = EditorState.create({ 119 | doc: `${prismaClientImport}\nasync function fn(value: string) {\n\tconst x = 1\n\tawait ${modelQuery}\n\tawait ${modelQuery}}`, 120 | extensions: [javascript({ typescript: true })], 121 | }); 122 | 123 | const queries = findQueries(state).iter(0); 124 | expect(queries).toHaveProperty("from", 136); 125 | expect(queries).toHaveProperty("to", 136 + modelQuery.length); 126 | expect(queries.value).not.toBe(null); 127 | expect(queries.value).toHaveProperty("query", { 128 | model: modelQueryModel, 129 | operation: modelQueryOperation, 130 | args: modelQueryArgs, 131 | }); 132 | 133 | queries.next(); 134 | expect(queries).toHaveProperty("from", 188); 135 | expect(queries).toHaveProperty("to", 188 + modelQuery.length); 136 | expect(queries.value).not.toBe(null); 137 | expect(queries.value).toHaveProperty("query", { 138 | model: modelQueryModel, 139 | operation: modelQueryOperation, 140 | args: modelQueryArgs, 141 | }); 142 | 143 | queries.next(); 144 | expect(queries.value).toBe(null); 145 | }); 146 | test("can find generic queries inside regular functions", () => { 147 | const state = EditorState.create({ 148 | doc: `${prismaClientImport}\nasync function fn(value: string) {\n\tconst x = 1\n\tawait ${genericQuery}\n\tawait ${genericQuery}}`, 149 | extensions: [javascript({ typescript: true })], 150 | }); 151 | 152 | const queries = findQueries(state).iter(0); 153 | expect(queries).toHaveProperty("from", 136); 154 | expect(queries).toHaveProperty("to", 136 + genericQuery.length); 155 | expect(queries.value).not.toBe(null); 156 | expect(queries.value).toHaveProperty("query", { 157 | model: genericQueryModel, 158 | operation: genericQueryOperation, 159 | args: genericQueryArgs, 160 | }); 161 | 162 | queries.next(); 163 | expect(queries).toHaveProperty("from", 187); 164 | expect(queries).toHaveProperty("to", 187 + genericQuery.length); 165 | expect(queries.value).not.toBe(null); 166 | expect(queries.value).toHaveProperty("query", { 167 | model: genericQueryModel, 168 | operation: genericQueryOperation, 169 | args: genericQueryArgs, 170 | }); 171 | 172 | queries.next(); 173 | expect(queries.value).toBe(null); 174 | }); 175 | 176 | test("can find model queries inside arrow functions", () => { 177 | const state = EditorState.create({ 178 | doc: `${prismaClientImport}\nconst fn = async (value: string) => {\n\tconst x = 1\n\tawait ${modelQuery}\n\tawait ${modelQuery}}`, 179 | extensions: [javascript({ typescript: true })], 180 | }); 181 | 182 | const queries = findQueries(state).iter(0); 183 | expect(queries).toHaveProperty("from", 139); 184 | expect(queries).toHaveProperty("to", 139 + modelQuery.length); 185 | expect(queries.value).not.toBe(null); 186 | expect(queries.value).toHaveProperty("query", { 187 | model: modelQueryModel, 188 | operation: modelQueryOperation, 189 | args: modelQueryArgs, 190 | }); 191 | 192 | queries.next(); 193 | expect(queries).toHaveProperty("from", 191); 194 | expect(queries).toHaveProperty("to", 191 + modelQuery.length); 195 | expect(queries.value).not.toBe(null); 196 | expect(queries.value).toHaveProperty("query", { 197 | model: modelQueryModel, 198 | operation: modelQueryOperation, 199 | args: modelQueryArgs, 200 | }); 201 | 202 | queries.next(); 203 | expect(queries.value).toBe(null); 204 | }); 205 | test("can find generic queries inside arrow functions", () => { 206 | const state = EditorState.create({ 207 | doc: `${prismaClientImport}\nconst fn = async (value: string) => {\n\tconst x = 1\n\tawait ${genericQuery}\n\tawait ${genericQuery}}`, 208 | extensions: [javascript({ typescript: true })], 209 | }); 210 | 211 | const queries = findQueries(state).iter(0); 212 | expect(queries).toHaveProperty("from", 139); 213 | expect(queries).toHaveProperty("to", 139 + genericQuery.length); 214 | expect(queries.value).not.toBe(null); 215 | expect(queries.value).toHaveProperty("query", { 216 | model: genericQueryModel, 217 | operation: genericQueryOperation, 218 | args: genericQueryArgs, 219 | }); 220 | 221 | queries.next(); 222 | expect(queries).toHaveProperty("from", 190); 223 | expect(queries).toHaveProperty("to", 190 + genericQuery.length); 224 | expect(queries.value).not.toBe(null); 225 | expect(queries.value).toHaveProperty("query", { 226 | model: genericQueryModel, 227 | operation: genericQueryOperation, 228 | args: genericQueryArgs, 229 | }); 230 | 231 | queries.next(); 232 | expect(queries.value).toBe(null); 233 | }); 234 | 235 | test("can mark correct ranges when model queries span multiple lines", () => { 236 | const query = `prisma.user.findMany({\n\twhere: {\n\t\tid: 1\n\t}\n})`; 237 | const state = EditorState.create({ 238 | doc: `${prismaClientImport}\nawait ${query}\nawait ${query}`, 239 | extensions: [javascript({ typescript: true })], 240 | }); 241 | 242 | const queries = findQueries(state).iter(0); 243 | expect(queries).toHaveProperty("from", 87); 244 | expect(queries).toHaveProperty("to", 87 + query.length); 245 | expect(queries.value).not.toBe(null); 246 | expect(queries.value).toHaveProperty("query", { 247 | model: "user", 248 | operation: "findMany", 249 | args: [{ where: { id: 1 } }], 250 | }); 251 | 252 | queries.next(); 253 | expect(queries).toHaveProperty("from", 140); 254 | expect(queries).toHaveProperty("to", 140 + query.length); 255 | expect(queries.value).not.toBe(null); 256 | expect(queries.value).toHaveProperty("query", { 257 | model: "user", 258 | operation: "findMany", 259 | args: [{ where: { id: 1 } }], 260 | }); 261 | 262 | queries.next(); 263 | expect(queries.value).toBe(null); 264 | }); 265 | 266 | test("can mark correct ranges when generic queries span multiple lines", () => { 267 | const query = `prisma.$queryRaw(\n\t\`SELECT * FROM "User"\`\n)`; 268 | const state = EditorState.create({ 269 | doc: `${prismaClientImport}\nawait ${query}\nawait ${query}`, 270 | extensions: [javascript({ typescript: true })], 271 | }); 272 | 273 | const queries = findQueries(state).iter(0); 274 | expect(queries).toHaveProperty("from", 87); 275 | expect(queries).toHaveProperty("to", 87 + query.length); 276 | expect(queries.value).not.toBe(null); 277 | expect(queries.value).toHaveProperty("query", { 278 | model: undefined, 279 | operation: "$queryRaw", 280 | args: ['`SELECT * FROM "User"`'], 281 | }); 282 | 283 | queries.next(); 284 | expect(queries).toHaveProperty("from", 137); 285 | expect(queries).toHaveProperty("to", 137 + query.length); 286 | expect(queries.value).not.toBe(null); 287 | expect(queries.value).toHaveProperty("query", { 288 | model: undefined, 289 | operation: "$queryRaw", 290 | args: ['`SELECT * FROM "User"`'], 291 | }); 292 | 293 | queries.next(); 294 | expect(queries.value).toBe(null); 295 | }); 296 | 297 | test("does not include variable assignments in model query ranges", () => { 298 | const state = EditorState.create({ 299 | doc: `${prismaClientImport}\nlet result = await ${modelQuery}`, 300 | extensions: [javascript({ typescript: true })], 301 | }); 302 | 303 | const queries = findQueries(state).iter(0); 304 | expect(queries).toHaveProperty("from", 100); 305 | expect(queries).toHaveProperty("to", 100 + modelQuery.length); 306 | expect(queries.value).not.toBe(null); 307 | expect(queries.value).toHaveProperty("query", { 308 | model: modelQueryModel, 309 | operation: modelQueryOperation, 310 | args: modelQueryArgs, 311 | }); 312 | 313 | queries.next(); 314 | expect(queries.value).toBe(null); 315 | }); 316 | 317 | test("does not include variable assignments in generic query ranges", () => { 318 | const state = EditorState.create({ 319 | doc: `${prismaClientImport}\nlet result = await ${genericQuery}`, 320 | extensions: [javascript({ typescript: true })], 321 | }); 322 | 323 | const queries = findQueries(state).iter(0); 324 | expect(queries).toHaveProperty("from", 100); 325 | expect(queries).toHaveProperty("to", 100 + genericQuery.length); 326 | expect(queries.value).not.toBe(null); 327 | expect(queries.value).toHaveProperty("query", { 328 | model: genericQueryModel, 329 | operation: genericQueryOperation, 330 | args: genericQueryArgs, 331 | }); 332 | 333 | queries.next(); 334 | expect(queries.value).toBe(null); 335 | }); 336 | 337 | test("does not find any queries when PrismaClient variable name does not match", () => { 338 | const query = `prisma2.user.findMany({})`; 339 | const state = EditorState.create({ 340 | doc: `${prismaClientImport}\nlet result = await ${query}`, 341 | extensions: [javascript({ typescript: true })], 342 | }); 343 | 344 | const queries = findQueries(state).iter(0); 345 | expect(queries.value).toBe(null); 346 | }); 347 | test("does not find any queries when PrismaClient construction does not exist", () => { 348 | const query = `prisma2.$queryRaw(\`SELECT 1;\`)`; 349 | const state = EditorState.create({ 350 | doc: `${prismaClientImport}\nlet result = await ${query}`, 351 | extensions: [javascript({ typescript: true })], 352 | }); 353 | 354 | const queries = findQueries(state).iter(0); 355 | expect(queries.value).toBe(null); 356 | }); 357 | 358 | test("does not qualify a model query if it does not start with `await`", () => { 359 | const query = `prisma2.user.findMany({})`; 360 | const state = EditorState.create({ 361 | doc: `${prismaClientImport}\nlet result = ${query}`, 362 | extensions: [javascript({ typescript: true })], 363 | }); 364 | 365 | const queries = findQueries(state).iter(0); 366 | expect(queries.value).toBe(null); 367 | }); 368 | test("does not qualify a generic query if it does not start with `await`", () => { 369 | const query = `prisma2.$queryRaw(\`SELECT 1;\`)`; 370 | const state = EditorState.create({ 371 | doc: `${prismaClientImport}\nlet result = ${query}`, 372 | extensions: [javascript({ typescript: true })], 373 | }); 374 | 375 | const queries = findQueries(state).iter(0); 376 | expect(queries.value).toBe(null); 377 | }); 378 | }); 379 | 380 | test.describe("[operations]", () => { 381 | test("can find aggregate queries", () => { 382 | const query = `prisma.user.aggregate({ _count: true })`; 383 | const state = EditorState.create({ 384 | doc: `${prismaClientImport}\nawait ${query}`, 385 | extensions: [javascript({ typescript: true })], 386 | }); 387 | 388 | const queries = findQueries(state).iter(0); 389 | expect(queries).toHaveProperty("from", 87); 390 | expect(queries).toHaveProperty("to", 87 + query.length); 391 | expect(queries.value).not.toBe(null); 392 | expect(queries.value).toHaveProperty("query", { 393 | model: "user", 394 | operation: "aggregate", 395 | args: [{ _count: true }], 396 | }); 397 | 398 | queries.next(); 399 | expect(queries.value).toBe(null); 400 | }); 401 | 402 | test("can find count queries", () => { 403 | const query = `prisma.user.count({ where: { id: 3 } })`; 404 | const state = EditorState.create({ 405 | doc: `${prismaClientImport}\nawait ${query}`, 406 | extensions: [javascript({ typescript: true })], 407 | }); 408 | 409 | const queries = findQueries(state).iter(0); 410 | expect(queries).toHaveProperty("from", 87); 411 | expect(queries).toHaveProperty("to", 87 + query.length); 412 | expect(queries.value).not.toBe(null); 413 | expect(queries.value).toHaveProperty("query", { 414 | model: "user", 415 | operation: "count", 416 | args: [{ where: { id: 3 } }], 417 | }); 418 | 419 | queries.next(); 420 | expect(queries.value).toBe(null); 421 | }); 422 | 423 | test("can find create queries", () => { 424 | const query = `prisma.user.create({ data: { id: 1, name: "test" } })`; 425 | const state = EditorState.create({ 426 | doc: `${prismaClientImport}\nawait ${query}`, 427 | extensions: [javascript({ typescript: true })], 428 | }); 429 | 430 | const queries = findQueries(state).iter(0); 431 | expect(queries).toHaveProperty("from", 87); 432 | expect(queries).toHaveProperty("to", 87 + query.length); 433 | expect(queries.value).not.toBe(null); 434 | expect(queries.value).toHaveProperty("query", { 435 | model: "user", 436 | operation: "create", 437 | args: [{ data: { id: 1, name: "test" } }], 438 | }); 439 | 440 | queries.next(); 441 | expect(queries.value).toBe(null); 442 | }); 443 | 444 | test("can find createMany queries", () => { 445 | const query = `prisma.user.createMany({ data: [{ id: 1, name: "test1" }, { id: 2, name: "test2" }] })`; 446 | const state = EditorState.create({ 447 | doc: `${prismaClientImport}\nawait ${query}`, 448 | extensions: [javascript({ typescript: true })], 449 | }); 450 | 451 | const queries = findQueries(state).iter(0); 452 | expect(queries).toHaveProperty("from", 87); 453 | expect(queries).toHaveProperty("to", 87 + query.length); 454 | expect(queries.value).not.toBe(null); 455 | expect(queries.value).toHaveProperty("query", { 456 | model: "user", 457 | operation: "createMany", 458 | args: [ 459 | { 460 | data: [ 461 | { id: 1, name: "test1" }, 462 | { id: 2, name: "test2" }, 463 | ], 464 | }, 465 | ], 466 | }); 467 | 468 | queries.next(); 469 | expect(queries.value).toBe(null); 470 | }); 471 | 472 | test("can find delete queries", () => { 473 | const query = `prisma.user.delete({ where: { id: 2 } })`; 474 | const state = EditorState.create({ 475 | doc: `${prismaClientImport}\nawait ${query}`, 476 | extensions: [javascript({ typescript: true })], 477 | }); 478 | 479 | const queries = findQueries(state).iter(0); 480 | expect(queries).toHaveProperty("from", 87); 481 | expect(queries).toHaveProperty("to", 87 + query.length); 482 | expect(queries.value).not.toBe(null); 483 | expect(queries.value).toHaveProperty("query", { 484 | model: "user", 485 | operation: "delete", 486 | args: [{ where: { id: 2 } }], 487 | }); 488 | 489 | queries.next(); 490 | expect(queries.value).toBe(null); 491 | }); 492 | 493 | test("can find deleteMany queries", () => { 494 | const query = `prisma.user.deleteMany({ where: { name: { contains: "S" } } })`; 495 | const state = EditorState.create({ 496 | doc: `${prismaClientImport}\nawait ${query}`, 497 | extensions: [javascript({ typescript: true })], 498 | }); 499 | 500 | const queries = findQueries(state).iter(0); 501 | expect(queries).toHaveProperty("from", 87); 502 | expect(queries).toHaveProperty("to", 87 + query.length); 503 | expect(queries.value).not.toBe(null); 504 | expect(queries.value).toHaveProperty("query", { 505 | model: "user", 506 | operation: "deleteMany", 507 | args: [{ where: { name: { contains: "S" } } }], 508 | }); 509 | 510 | queries.next(); 511 | expect(queries.value).toBe(null); 512 | }); 513 | 514 | test("can find findFirst queries", () => { 515 | const query = `prisma.user.findFirst({ where: { name: { contains: "S" } } })`; 516 | const state = EditorState.create({ 517 | doc: `${prismaClientImport}\nawait ${query}`, 518 | extensions: [javascript({ typescript: true })], 519 | }); 520 | 521 | const queries = findQueries(state).iter(0); 522 | expect(queries).toHaveProperty("from", 87); 523 | expect(queries).toHaveProperty("to", 87 + query.length); 524 | expect(queries.value).not.toBe(null); 525 | expect(queries.value).toHaveProperty("query", { 526 | model: "user", 527 | operation: "findFirst", 528 | args: [{ where: { name: { contains: "S" } } }], 529 | }); 530 | 531 | queries.next(); 532 | expect(queries.value).toBe(null); 533 | }); 534 | 535 | test("can find findMany queries", () => { 536 | const query = `prisma.user.findMany({ where: { name: { contains: "S" } } })`; 537 | const state = EditorState.create({ 538 | doc: `${prismaClientImport}\nawait ${query}`, 539 | extensions: [javascript({ typescript: true })], 540 | }); 541 | 542 | const queries = findQueries(state).iter(0); 543 | expect(queries).toHaveProperty("from", 87); 544 | expect(queries).toHaveProperty("to", 87 + query.length); 545 | expect(queries.value).not.toBe(null); 546 | expect(queries.value).toHaveProperty("query", { 547 | model: "user", 548 | operation: "findMany", 549 | args: [{ where: { name: { contains: "S" } } }], 550 | }); 551 | 552 | queries.next(); 553 | expect(queries.value).toBe(null); 554 | }); 555 | 556 | test("can find findUnique queries", () => { 557 | const query = `prisma.user.findUnique({ where: { name: { contains: "S" } } })`; 558 | const state = EditorState.create({ 559 | doc: `${prismaClientImport}\nawait ${query}`, 560 | extensions: [javascript({ typescript: true })], 561 | }); 562 | 563 | const queries = findQueries(state).iter(0); 564 | expect(queries).toHaveProperty("from", 87); 565 | expect(queries).toHaveProperty("to", 87 + query.length); 566 | expect(queries.value).not.toBe(null); 567 | expect(queries.value).toHaveProperty("query", { 568 | model: "user", 569 | operation: "findUnique", 570 | args: [{ where: { name: { contains: "S" } } }], 571 | }); 572 | 573 | queries.next(); 574 | expect(queries.value).toBe(null); 575 | }); 576 | 577 | test("can find groupBy queries", () => { 578 | const query = `prisma.user.groupBy({ by: ['id', 'name'] })`; 579 | const state = EditorState.create({ 580 | doc: `${prismaClientImport}\nawait ${query}`, 581 | extensions: [javascript({ typescript: true })], 582 | }); 583 | 584 | const queries = findQueries(state).iter(0); 585 | expect(queries).toHaveProperty("from", 87); 586 | expect(queries).toHaveProperty("to", 87 + query.length); 587 | expect(queries.value).not.toBe(null); 588 | expect(queries.value).toHaveProperty("query", { 589 | model: "user", 590 | operation: "groupBy", 591 | args: [{ by: ["id", "name"] }], 592 | }); 593 | 594 | queries.next(); 595 | expect(queries.value).toBe(null); 596 | }); 597 | 598 | test("can find update queries", () => { 599 | const query = `prisma.user.update({ where: { id: 1 }, data: { name: "updated" } })`; 600 | const state = EditorState.create({ 601 | doc: `${prismaClientImport}\nawait ${query}`, 602 | extensions: [javascript({ typescript: true })], 603 | }); 604 | 605 | const queries = findQueries(state).iter(0); 606 | expect(queries).toHaveProperty("from", 87); 607 | expect(queries).toHaveProperty("to", 87 + query.length); 608 | expect(queries.value).not.toBe(null); 609 | expect(queries.value).toHaveProperty("query", { 610 | model: "user", 611 | operation: "update", 612 | args: [{ where: { id: 1 }, data: { name: "updated" } }], 613 | }); 614 | 615 | queries.next(); 616 | expect(queries.value).toBe(null); 617 | }); 618 | 619 | test("can find updateMany queries", () => { 620 | const query = `prisma.user.updateMany({ where: { name: { startsWith: "A" } }, data: { name: "updated" } })`; 621 | const state = EditorState.create({ 622 | doc: `${prismaClientImport}\nawait ${query}`, 623 | extensions: [javascript({ typescript: true })], 624 | }); 625 | 626 | const queries = findQueries(state).iter(0); 627 | expect(queries).toHaveProperty("from", 87); 628 | expect(queries).toHaveProperty("to", 87 + query.length); 629 | expect(queries.value).not.toBe(null); 630 | expect(queries.value).toHaveProperty("query", { 631 | model: "user", 632 | operation: "updateMany", 633 | args: [ 634 | { where: { name: { startsWith: "A" } }, data: { name: "updated" } }, 635 | ], 636 | }); 637 | 638 | queries.next(); 639 | expect(queries.value).toBe(null); 640 | }); 641 | 642 | test("can find upsert queries", () => { 643 | const query = `prisma.user.upsert({ where: { id: 1 }, data: { name: "test" } })`; 644 | const state = EditorState.create({ 645 | doc: `${prismaClientImport}\nawait ${query}`, 646 | extensions: [javascript({ typescript: true })], 647 | }); 648 | 649 | const queries = findQueries(state).iter(0); 650 | expect(queries).toHaveProperty("from", 87); 651 | expect(queries).toHaveProperty("to", 87 + query.length); 652 | expect(queries.value).not.toBe(null); 653 | expect(queries.value).toHaveProperty("query", { 654 | model: "user", 655 | operation: "upsert", 656 | args: [{ where: { id: 1 }, data: { name: "test" } }], 657 | }); 658 | 659 | queries.next(); 660 | expect(queries.value).toBe(null); 661 | }); 662 | 663 | test("can find $connect queries", () => { 664 | const query = `prisma.$connect()`; 665 | const state = EditorState.create({ 666 | doc: `${prismaClientImport}\nawait ${query}`, 667 | extensions: [javascript({ typescript: true })], 668 | }); 669 | 670 | const queries = findQueries(state).iter(0); 671 | expect(queries).toHaveProperty("from", 87); 672 | expect(queries).toHaveProperty("to", 87 + query.length); 673 | expect(queries.value).not.toBe(null); 674 | expect(queries.value).toHaveProperty("query", { 675 | model: undefined, 676 | operation: "$connect", 677 | args: [], 678 | }); 679 | 680 | queries.next(); 681 | expect(queries.value).toBe(null); 682 | }); 683 | 684 | test("can find $disconnect queries", () => { 685 | const query = `prisma.$disconnect()`; 686 | const state = EditorState.create({ 687 | doc: `${prismaClientImport}\nawait ${query}`, 688 | extensions: [javascript({ typescript: true })], 689 | }); 690 | 691 | const queries = findQueries(state).iter(0); 692 | expect(queries).toHaveProperty("from", 87); 693 | expect(queries).toHaveProperty("to", 87 + query.length); 694 | expect(queries.value).not.toBe(null); 695 | expect(queries.value).toHaveProperty("query", { 696 | model: undefined, 697 | operation: "$disconnect", 698 | args: [], 699 | }); 700 | 701 | queries.next(); 702 | expect(queries.value).toBe(null); 703 | }); 704 | 705 | test("can find $executeRaw queries", () => { 706 | const query = `prisma.$executeRaw(\`SELECT 1;\`)`; 707 | const state = EditorState.create({ 708 | doc: `${prismaClientImport}\nawait ${query}`, 709 | extensions: [javascript({ typescript: true })], 710 | }); 711 | 712 | const queries = findQueries(state).iter(0); 713 | expect(queries).toHaveProperty("from", 87); 714 | expect(queries).toHaveProperty("to", 87 + query.length); 715 | expect(queries.value).not.toBe(null); 716 | expect(queries.value).toHaveProperty("query", { 717 | model: undefined, 718 | operation: "$executeRaw", 719 | args: ["`SELECT 1;`"], 720 | }); 721 | 722 | queries.next(); 723 | expect(queries.value).toBe(null); 724 | }); 725 | 726 | test("can find $queryRaw queries", () => { 727 | const query = `prisma.$queryRaw(\`SELECT 1;\`)`; 728 | const state = EditorState.create({ 729 | doc: `${prismaClientImport}\nawait ${query}`, 730 | extensions: [javascript({ typescript: true })], 731 | }); 732 | 733 | const queries = findQueries(state).iter(0); 734 | expect(queries).toHaveProperty("from", 87); 735 | expect(queries).toHaveProperty("to", 87 + query.length); 736 | expect(queries.value).not.toBe(null); 737 | expect(queries.value).toHaveProperty("query", { 738 | model: undefined, 739 | operation: "$queryRaw", 740 | args: ["`SELECT 1;`"], 741 | }); 742 | 743 | queries.next(); 744 | expect(queries.value).toBe(null); 745 | }); 746 | 747 | test("can find $on statements", () => { 748 | const internalQuery = `prisma.user.deleteMany()`; 749 | const query = `prisma.$on('beforeExit', async (e) => { await ${internalQuery} })`; 750 | const state = EditorState.create({ 751 | doc: `${prismaClientImport}\nawait ${query}`, 752 | extensions: [javascript({ typescript: true })], 753 | }); 754 | 755 | const queries = findQueries(state).iter(0); 756 | expect(queries).toHaveProperty("from", 87); 757 | expect(queries).toHaveProperty("to", 87 + query.length); 758 | expect(queries.value).not.toBe(null); 759 | expect(queries.value).toHaveProperty("query", { 760 | model: undefined, 761 | operation: "$on", 762 | args: ["beforeExit", "async (e) => { await prisma.user.deleteMany() }"], 763 | }); 764 | 765 | queries.next(); 766 | expect(queries).toHaveProperty("from", 133); 767 | expect(queries).toHaveProperty("to", 133 + internalQuery.length); 768 | expect(queries.value).not.toBe(null); 769 | expect(queries.value).toHaveProperty("query", { 770 | model: "user", 771 | operation: "deleteMany", 772 | args: [], 773 | }); 774 | 775 | queries.next(); 776 | expect(queries.value).toBe(null); 777 | }); 778 | 779 | test("can find $transaction queries", () => { 780 | const query = `prisma.$transaction([\nprisma.user.create({ data: { id: 1 } }),\nprisma.post.create({ data: { title: "test" } })\n])`; 781 | const state = EditorState.create({ 782 | doc: `${prismaClientImport}\nawait ${query}`, 783 | extensions: [javascript({ typescript: true })], 784 | }); 785 | 786 | const queries = findQueries(state).iter(0); 787 | expect(queries).toHaveProperty("from", 87); 788 | expect(queries).toHaveProperty("to", 87 + query.length); 789 | expect(queries.value).not.toBe(null); 790 | expect(queries.value).toHaveProperty("query", { 791 | model: undefined, 792 | operation: "$transaction", 793 | args: [ 794 | '[\nprisma.user.create({ data: { id: 1 } }),\nprisma.post.create({ data: { title: "test" } })\n]', 795 | ], 796 | }); 797 | 798 | queries.next(); 799 | expect(queries.value).toBe(null); 800 | }); 801 | 802 | test("can find $use statements", () => { 803 | const query = `prisma.$use((params, next) => {\n\tconsole.log(params)\n\treturn next(params)})`; 804 | const state = EditorState.create({ 805 | doc: `${prismaClientImport}\nawait ${query}`, 806 | extensions: [javascript({ typescript: true })], 807 | }); 808 | 809 | const queries = findQueries(state).iter(0); 810 | expect(queries).toHaveProperty("from", 87); 811 | expect(queries).toHaveProperty("to", 87 + query.length); 812 | expect(queries.value).not.toBe(null); 813 | expect(queries.value).toHaveProperty("query", { 814 | model: undefined, 815 | operation: "$use", 816 | args: [ 817 | "(params, next) => {\n\tconsole.log(params)\n\treturn next(params)}", 818 | ], 819 | }); 820 | 821 | queries.next(); 822 | expect(queries.value).toBe(null); 823 | }); 824 | }); 825 | -------------------------------------------------------------------------------- /tests/fold.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | test.describe.parallel("Fold gutter", () => { 4 | test("shows fold SVGs on lines that can be folded", async ({ page }) => { 5 | test.fixme(); 6 | }); 7 | test("shows unfold SVGs on lines that are already folded", async ({ 8 | page, 9 | }) => { 10 | test.fixme(); 11 | }); 12 | 13 | test("can fold even if editor is readonly", async ({ page }) => { 14 | test.fixme(); 15 | }); 16 | test("can unfold even if editor is readonly", async ({ page }) => { 17 | test.fixme(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/keymap.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | test.describe.parallel("Keymap", () => { 4 | test("can move down a line using the down arrow", async ({ page }) => { 5 | test.fixme(); 6 | }); 7 | test("can move up a line using the up arrow", ({ page }) => { 8 | test.fixme(); 9 | }); 10 | test("can move to the left of a character by using the left arrow", async ({ 11 | page, 12 | }) => { 13 | test.fixme(); 14 | }); 15 | test("can move to the right of a character by using the right arrow", async ({ 16 | page, 17 | }) => { 18 | test.fixme(); 19 | }); 20 | test("cannot move around when the editor is readonly", async ({ page }) => { 21 | test.fixme(); 22 | }); 23 | 24 | test("grey bar lights up green when you enter a standalone prisma query block that spans a single line", async ({ 25 | page, 26 | }) => { 27 | test.fixme(); 28 | }); 29 | test("grey run button lights up green when you enter a standalone prisma query block that spans a single line", async ({ 30 | page, 31 | }) => { 32 | test.fixme(); 33 | }); 34 | 35 | test("grey bar lights up green when you enter a standalone prisma query block that spans multiple lines", async ({ 36 | page, 37 | }) => { 38 | test.fixme(); 39 | }); 40 | test("grey run button lights up green when you enter a standalone prisma query block that spans multiple lines", async ({ 41 | page, 42 | }) => { 43 | test.fixme(); 44 | }); 45 | 46 | test("grey bar lights up green when you enter a prisma query block with a variable assignment", async ({ 47 | page, 48 | }) => { 49 | test.fixme(); 50 | }); 51 | test("grey run button lights up green when you enter a prisma query block with a variable assignment", async ({ 52 | page, 53 | }) => { 54 | test.fixme(); 55 | }); 56 | 57 | test("noop when Cmd+Enter is pressed when the cursor is not under a query", async ({ 58 | page, 59 | }) => { 60 | test.fixme(); 61 | }); 62 | test("calls callback when Cmd+Enter is pressed when the cursor is under a query", async ({ 63 | page, 64 | }) => { 65 | test.fixme(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/offline.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | test.use({ offline: true }); 4 | 5 | test("can load up offline", async ({ page }) => { 6 | test.fixme(); 7 | }); 8 | -------------------------------------------------------------------------------- /tests/prisma.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | test.describe.parallel("Prisma Query extension", () => { 4 | test("dims all lines that aren't part of a query", async ({ page }) => { 5 | test.fixme(); 6 | }); 7 | test("changes dimmed lines when the PrismaClinet variable name is changed", async ({ 8 | page, 9 | }) => { 10 | test.fixme(); 11 | }); 12 | test("shows grey bars next to inactive queries", async ({ page }) => { 13 | test.fixme(); 14 | }); 15 | test("shows green bars next to active queries", async ({ page }) => { 16 | test.fixme(); 17 | }); 18 | test("shows grey run buttons next to inactive queries", async ({ page }) => { 19 | test.fixme(); 20 | }); 21 | test("shows green run buttons next to active queries", async ({ page }) => { 22 | test.fixme(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/typescript.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { TSEditor } from "../src/editor/ts-editor"; 3 | 4 | test.describe.parallel("Typescript extension", () => { 5 | test("does not crash when content is empty", async ({ page }) => { 6 | await page.goto("/demo/"); 7 | const editor = await page.$("#ts-editor .cm-content"); 8 | page.on("console", msg => { 9 | expect(msg.type()).not.toBe("error"); 10 | }); 11 | await editor.selectText(); 12 | await page.keyboard.press("Backspace"); 13 | await page.waitForTimeout(3000); // Wait a few seconds to see if any errors show up in the console 14 | }); 15 | 16 | test("can autocomplete empty lines", async () => { 17 | test.fixme(); 18 | 19 | const code = "const x = 1\n\n"; 20 | let editorState = TSEditor.state({ 21 | domElement: null, 22 | code, 23 | }); 24 | editorState = editorState.update({ 25 | selection: { 26 | anchor: 3, //code.length, 27 | }, 28 | }).state; 29 | 30 | expect(false).toBe(true); 31 | }); 32 | 33 | test("can autocomplete Node built-ins", async ({ page }) => { 34 | test.fixme(); 35 | }); 36 | 37 | test("can autocomplete prisma variable", async ({ page }) => { 38 | test.fixme(); 39 | }); 40 | 41 | test("can autocomplete prisma model property", async ({ page }) => { 42 | test.fixme(); 43 | }); 44 | 45 | test("can autocomplete prisma model operations", async ({ page }) => { 46 | test.fixme(); 47 | }); 48 | 49 | test("can autocomplete prisma generic operations", async ({ page }) => { 50 | test.fixme(); 51 | }); 52 | 53 | test("can autocomplete prisma model operation arguments", async ({ 54 | page, 55 | }) => { 56 | test.fixme(); 57 | }); 58 | 59 | test("can autocomplete prisma generic operation arguments", async ({ 60 | page, 61 | }) => { 62 | test.fixme(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import reactRefresh from "@vitejs/plugin-react-refresh"; 2 | import path from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | const port = 3000; 6 | 7 | export default defineConfig({ 8 | server: { 9 | port, 10 | strictPort: true, 11 | }, 12 | base: "./", 13 | plugins: [reactRefresh()], 14 | build: { 15 | lib: { 16 | entry: path.resolve(__dirname, "src/lib.ts"), 17 | formats: ["es", "cjs"], 18 | fileName: format => `editors.${format}.js`, 19 | }, 20 | rollupOptions: { 21 | external: ["react", "react-dom"], 22 | output: { 23 | globals: { 24 | react: "React", 25 | "react-dom": "ReactDOM", 26 | }, 27 | }, 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /vite.demo.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | build: { 5 | emptyOutDir: true, 6 | rollupOptions: { 7 | input: "demo/index.html", 8 | output: { 9 | dir: "demo/public", 10 | }, 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /wiki/demo-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prisma/text-editors/d73c4b67ec0087d74228548c791ace1f06604cac/wiki/demo-app.png --------------------------------------------------------------------------------