├── .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 |  
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
--------------------------------------------------------------------------------