├── .github └── workflows │ └── publish.yaml ├── .gitignore ├── BUILD.md ├── LICENSE ├── README.md ├── api ├── .gitignore ├── .npmignore ├── jest.config.js ├── package.json ├── src │ ├── cli.ts │ ├── run.ts │ ├── runtime │ │ ├── index.ts │ │ ├── run.ts │ │ ├── server.ts │ │ └── zmq-utils.ts │ ├── server.ts │ ├── spawner │ │ ├── spawner_native.ts │ │ ├── trpc.ts │ │ └── yjs_runtime.ts │ └── yjs │ │ ├── y-websocket.js │ │ ├── yjs-blob.ts │ │ └── yjs-setupWS.ts └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── screenshot-canvas.png ├── screenshot.png └── ui ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── index.html ├── package.json ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── tree-sitter-javascript.wasm ├── tree-sitter-python.wasm └── tree-sitter.wasm ├── set-env.sh ├── src ├── App.css ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── Canvas.tsx │ ├── CanvasContextMenu.tsx │ ├── Header.tsx │ ├── HelperLines.tsx │ ├── MyKBar.tsx │ ├── MyMonaco.tsx │ ├── MyXTerm.tsx │ ├── ShareProjDialog.tsx │ ├── Sidebar.tsx │ └── nodes │ │ ├── Code.tsx │ │ ├── CustomConnectionLine.tsx │ │ ├── FloatingEdge.tsx │ │ ├── Rich.tsx │ │ ├── Scope.tsx │ │ ├── extensions │ │ ├── YjsRemirror.tsx │ │ ├── blockHandle.ts │ │ ├── codepodSync.ts │ │ ├── link.tsx │ │ ├── list.ts │ │ ├── mathExtension.ts │ │ ├── mathParseRules.ts │ │ ├── slash.tsx │ │ └── useSlash.tsx │ │ ├── remirror-size.css │ │ └── utils.tsx ├── custom.css ├── hooks │ └── useLocalStorage.ts ├── index.css ├── lib │ ├── auth.tsx │ ├── parser.ts │ ├── prompt.tsx │ ├── store │ │ ├── canvasSlice.ts │ │ ├── index.ts │ │ ├── podSlice.ts │ │ ├── repoSlice.ts │ │ ├── runtimeSlice.ts │ │ ├── settingSlice.ts │ │ └── yjsSlice.ts │ ├── trpc.ts │ └── utils │ │ ├── python-keywords.ts │ │ ├── rich-schema.ts │ │ ├── utils.ts │ │ └── y-utils.ts ├── main.tsx ├── pages │ └── repo.tsx ├── tests │ ├── parser.test.tsx.txt │ ├── python3-grammar.py │ └── python3.8-grammar.py ├── types │ └── index.d.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build-and-publish: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: "20.x" # Change this to your desired Node.js version 17 | registry-url: "https://registry.npmjs.org" 18 | 19 | - name: Install pnpm 20 | run: npm install -g pnpm 21 | 22 | - name: Build UI 23 | working-directory: ui 24 | run: | 25 | pnpm install 26 | pnpm build 27 | 28 | - name: Build API 29 | working-directory: api 30 | run: | 31 | pnpm install 32 | pnpm build 33 | 34 | - name: Publish to npm 35 | working-directory: api 36 | run: npm publish 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # NPM_TOKEN is a GitHub secret containing your npm token 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | .env 7 | 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | build/ 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .pnpm-store 29 | 30 | src/tailwind.output.css 31 | 32 | .eslintcache 33 | 34 | *-checkpoint* 35 | 36 | back/ 37 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Building CodePod 2 | 3 | First build the UI: 4 | 5 | ``` 6 | cd ui 7 | pnpm bulid 8 | ``` 9 | 10 | This will generate the frontend html/js files into `api/public` folder. Then build the app in `api/` folder: 11 | 12 | ``` 13 | cd api 14 | pnpm build 15 | ``` 16 | 17 | This will generate `api/build/cli.js`. This is the binary executable. You can 18 | install and test the app locally: 19 | 20 | ``` 21 | cd api 22 | npm install -g . 23 | ``` 24 | 25 | Now the `codepod` command is available. Test: 26 | 27 | ``` 28 | > which codepod 29 | # /opt/homebrew/bin/codepod 30 | > npm list --global 31 | # /opt/homebrew/lib 32 | # ├── codepod@0.0.4 -> ./../../../Users/xxx/git/codepod/api 33 | > codepod /path/to/repo 34 | # ... 🚀 Server ready at http://localhost:4001 35 | ``` 36 | 37 | Remove the globally installed local package: 38 | 39 | ``` 40 | npm remove -g codepod 41 | ``` 42 | 43 | Now it's ready to publish. We will first publish to npm registry. First login to 44 | npm-cli, upgrade the version in `api/package.json` then: 45 | 46 | ``` 47 | npm publish 48 | ``` 49 | 50 | Now it is in npm at https://www.npmjs.com/package/codepod. Install it from npm: 51 | 52 | ``` 53 | # option 1: install 54 | npm install -g codepod 55 | codepod /path/to/repo 56 | 57 | # option 2: run with npx without install 58 | npx codepod /path/to/repo 59 | ``` 60 | 61 | # Publish using GitHub CI 62 | 63 | The CI is triggered by v*.*.\* tags. Update the version in `api/package.json`, then push a new tag to trigger the CI. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CodePod Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodePod: coding on a canvas, organized. 2 | 3 | Codepod provides the interactive coding experience popularized by Jupyter, but 4 | with scalability and production-readiness. Users can still incrementally build 5 | up code by trying out a small code snippet each time. But they would not be 6 | overwhelmed by the great number of code snippets as the projects grow. Learn 7 | more on our website at https://codepod.io. 8 | 9 | ![screenshot](./screenshot-canvas.png) 10 | 11 | # Install 12 | 13 | You can [use CodePod online](https://app.codepod.io) without installing it 14 | locally. To install it on your computer: 15 | 16 | Step 1: install prerequisite: [nodejs](https://nodejs.org/en/download) runtime 17 | and python & ipykernel: 18 | 19 | ``` 20 | brew install node # example for MacOS 21 | pip3 install ipykernel 22 | ``` 23 | 24 | Step 2: Install codepod CLI app from [npm registry](https://www.npmjs.com/package/codepod): 25 | 26 | ``` 27 | > npm install -g codepod 28 | > codepod --version 29 | # 0.0.7 30 | ``` 31 | 32 | Step 3: launch CodePod from terminal: 33 | 34 | ``` 35 | > codepod /path/to/local/repo 36 | # ... 🚀 Server ready at http://localhost:4001 37 | ``` 38 | 39 | Open this URL in your browser to see the app. The files will be saved to the 40 | directory `/path/to/repo/codepod.bin|json`. The `codepod.bin` is the source of 41 | truth, and `codepod.json` is for human-readability only. 42 | 43 | In the future, you can update the app: 44 | 45 | ``` 46 | > npm update -g codepod 47 | ``` 48 | 49 | # Develop 50 | 51 | Open two terminals. On one: 52 | 53 | ``` 54 | cd apps/api 55 | pnpm dev 56 | ``` 57 | 58 | On the other: 59 | 60 | ``` 61 | cd apps/ui 62 | pnpm dev 63 | ``` 64 | 65 | Now go to `http://localhost:3000` to see the app. 66 | 67 | # Contributing 68 | 69 | CodePod is open-source under an MIT license. Feel free to contribute to make 70 | it better together with us. You can contribute by [creating awesome showcases](#gallery), 71 | [reporting a bug, suggesting a feature](https://github.com/codepod-io/codepod/issues), 72 | or submitting a pull request. 73 | Do use [Prettier](https://prettier.io/) (e.g., [its VSCode 74 | plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) 75 | to format your code before checking in. 76 | 77 | # Citation 78 | 79 | https://arxiv.org/abs/2301.02410 80 | 81 | ``` 82 | @misc{https://doi.org/10.48550/arxiv.2301.02410, 83 | doi = {10.48550/ARXIV.2301.02410}, 84 | url = {https://arxiv.org/abs/2301.02410}, 85 | author = {Li, Hebi and Bao, Forrest Sheng and Xiao, Qi and Tian, Jin}, 86 | title = {Codepod: A Namespace-Aware, Hierarchical Jupyter for Interactive Development at Scale}, 87 | publisher = {arXiv}, 88 | year = {2023}, 89 | copyright = {Creative Commons Attribution 4.0 International} 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | conns/conn-*.json 3 | prisma/dev.db 4 | example-repo/ 5 | public/ -------------------------------------------------------------------------------- /api/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/api/.npmignore -------------------------------------------------------------------------------- /api/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | transform: { 6 | "^.+\\.ts?$": "ts-jest", 7 | }, 8 | transformIgnorePatterns: ["/node_modules/"], 9 | }; 10 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codepod", 3 | "version": "0.0.8", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "tsc", 7 | "start": "node build/run.js", 8 | "dev": "ts-node-dev src/run.ts", 9 | "test": "jest --config jest.config.js" 10 | }, 11 | "bin": { 12 | "codepod": "./build/cli.js" 13 | }, 14 | "dependencies": { 15 | "@trpc/server": "^10.43.0", 16 | "commander": "^11.0.0", 17 | "cors": "^2.8.5", 18 | "express": "^4.18.2", 19 | "jest": "^29.0.3", 20 | "lib0": "^0.2.83", 21 | "lodash": "^4.17.21", 22 | "uuid": "^9.0.0", 23 | "ws": "^8.2.3", 24 | "y-protocols": "^1.0.5", 25 | "yjs": "^13.6.7", 26 | "zeromq": "6.0.0-beta.6", 27 | "zod": "^3.22.4" 28 | }, 29 | "devDependencies": { 30 | "@types/express": "^4.17.14", 31 | "@types/node": "^18.11.2", 32 | "@types/ws": "^8.5.3", 33 | "ts-jest": "^29.0.1", 34 | "ts-node-dev": "^2.0.0", 35 | "typescript": "^4.4.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { program } from "commander"; 4 | import { startServer } from "./server"; 5 | 6 | // This is a binary executable to run the server. 7 | 8 | // First, parse the command line arguments. 9 | // CMD: codepod /path/to/repo 10 | 11 | program 12 | // get the version from package.json 13 | .version(require("../package.json").version) 14 | .arguments("") 15 | .action(function (repoPath) { 16 | console.log("repoPath", repoPath); 17 | // start the server 18 | startServer({ port: 4001, repoDir: repoPath }); 19 | }); 20 | 21 | program.parse(process.argv); 22 | -------------------------------------------------------------------------------- /api/src/run.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from "./server"; 2 | 3 | const repoDir = `${process.cwd()}/example-repo`; 4 | console.log("repoDir", repoDir); 5 | 6 | startServer({ port: 4000, repoDir }); 7 | -------------------------------------------------------------------------------- /api/src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./server"; 2 | -------------------------------------------------------------------------------- /api/src/runtime/run.ts: -------------------------------------------------------------------------------- 1 | import { KernelSpec, startServer } from "./server"; 2 | 3 | let host = process.env.ZMQ_HOST; 4 | if (!host) { 5 | throw Error("ZMQ_HOST not set"); 6 | } 7 | 8 | let spec: KernelSpec = { 9 | shell_port: 55692, 10 | iopub_port: 55693, 11 | stdin_port: 55694, 12 | control_port: 55695, 13 | hb_port: 55696, 14 | // ip: "0.0.0.0", 15 | ip: host, 16 | key: "", 17 | transport: "tcp", 18 | kernel_name: "", 19 | }; 20 | 21 | startServer({ spec, port: 4020 }); 22 | -------------------------------------------------------------------------------- /api/src/runtime/server.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket, WebSocketServer } from "ws"; 2 | 3 | import express from "express"; 4 | import http from "http"; 5 | 6 | import { 7 | ZmqWire, 8 | constructExecuteRequest, 9 | constructMessage, 10 | handleIOPub_status, 11 | handleIOPub_execute_result, 12 | handleIOPub_stdout, 13 | handleIOPub_error, 14 | handleIOPub_stream, 15 | handleIOPub_display_data, 16 | } from "./zmq-utils"; 17 | 18 | function bindZMQ(zmq_wire: ZmqWire, socket: WebSocket) { 19 | zmq_wire.setOnIOPub((topic, msgs) => { 20 | // console.log("-----", topic, msgs); 21 | // iracket's topic seems to be an ID. I should use msg type instead 22 | switch (msgs.header.msg_type) { 23 | case "status": 24 | handleIOPub_status({ msgs, socket, lang: "python" }); 25 | break; 26 | case "execute_result": 27 | handleIOPub_execute_result({ 28 | msgs, 29 | socket, 30 | }); 31 | break; 32 | case "stdout": 33 | handleIOPub_stdout({ msgs, socket }); 34 | break; 35 | case "error": 36 | handleIOPub_error({ msgs, socket }); 37 | break; 38 | case "stream": 39 | handleIOPub_stream({ 40 | msgs, 41 | socket, 42 | }); 43 | break; 44 | case "display_data": 45 | handleIOPub_display_data({ 46 | msgs, 47 | socket, 48 | }); 49 | break; 50 | default: 51 | console.log( 52 | "Message Not handled", 53 | msgs.header.msg_type, 54 | "topic:", 55 | topic 56 | ); 57 | // console.log("Message body:", msgs); 58 | break; 59 | } 60 | }); 61 | 62 | zmq_wire.setOnShell((msgs) => { 63 | // DEBUG 64 | // socket = this.mq_socket; 65 | // socket = this.socket; 66 | switch (msgs.header.msg_type) { 67 | case "execute_reply": 68 | { 69 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 70 | let payload = { 71 | podId, 72 | name, 73 | // content: { 74 | // status: 'ok', 75 | // payload: [], 76 | // user_expressions: { x: [Object] }, 77 | // execution_count: 2 78 | // }, 79 | result: msgs.content.status, 80 | count: msgs.content.execution_count, 81 | }; 82 | if (name) { 83 | console.log("emitting IO execute_reply"); 84 | socket.send(JSON.stringify({ type: "IO:execute_reply", payload })); 85 | } else { 86 | console.log("emitting execute_reply"); 87 | socket.send(JSON.stringify({ type: "execute_reply", payload })); 88 | } 89 | } 90 | break; 91 | case "interrupt_reply": 92 | { 93 | socket.send( 94 | JSON.stringify({ 95 | type: "interrupt_reply", 96 | payload: { 97 | status: msgs.content, 98 | lang: "python", 99 | }, 100 | }) 101 | ); 102 | } 103 | break; 104 | default: { 105 | console.log("Unhandled shell message", msgs.header.msg_type); 106 | } 107 | } 108 | }); 109 | } 110 | 111 | function runCode(wire, { code, msg_id }) { 112 | wire.sendShellMessage( 113 | constructExecuteRequest({ 114 | code, 115 | msg_id, 116 | }) 117 | ); 118 | } 119 | 120 | function requestKernelStatus(wire) { 121 | wire.sendShellMessage(constructMessage({ msg_type: "kernel_info_request" })); 122 | } 123 | function interrupt(wire) { 124 | wire.sendControlMessage(constructMessage({ msg_type: "interrupt_request" })); 125 | } 126 | 127 | export type KernelSpec = { 128 | shell_port: number; 129 | iopub_port: number; 130 | stdin_port: number; 131 | control_port: number; 132 | hb_port: number; 133 | ip: string; 134 | key: string; 135 | transport: string; 136 | kernel_name: string; 137 | }; 138 | 139 | export function startServer({ 140 | spec, 141 | port, 142 | }: { 143 | spec: KernelSpec; 144 | port: number; 145 | }) { 146 | const expapp = express(); 147 | const http_server = http.createServer(expapp); 148 | const wss = new WebSocketServer({ server: http_server }); 149 | 150 | wss.on("connection", (socket) => { 151 | console.log("a user connected"); 152 | // 1. connect ZMQ wire to the kernel 153 | // Assume only one connection, from the spawner service. The user browser doesn't connect to kernel directly. 154 | const zmq_wire = new ZmqWire(spec); 155 | // Listen on ZMQWire replies (wire.setOnIOPub and wire.setOnShell) and send back to websocket. 156 | bindZMQ(zmq_wire, socket); 157 | socket.on("close", () => { 158 | console.log("user disconnected"); 159 | }); 160 | // Listen on WS and send commands to kernels through ZMQWire. 161 | socket.on("message", async (msg) => { 162 | let { type, payload } = JSON.parse(msg.toString()); 163 | if (type === "ping") return; 164 | let { sessionId, lang } = payload; 165 | switch (type) { 166 | case "runCode": 167 | { 168 | let { sessionId, lang, raw, code, podId, namespace, midports } = 169 | payload; 170 | if (!code) { 171 | console.log("Code is empty"); 172 | return; 173 | } 174 | runCode(zmq_wire, { 175 | code, 176 | msg_id: podId, 177 | }); 178 | } 179 | break; 180 | case "requestKernelStatus": 181 | console.log("requestKernelStatus", sessionId, lang); 182 | requestKernelStatus(zmq_wire); 183 | break; 184 | case "interruptKernel": 185 | { 186 | interrupt(zmq_wire); 187 | } 188 | break; 189 | default: 190 | console.log("WARNING unhandled message", { type, payload }); 191 | } 192 | }); 193 | }); 194 | 195 | http_server.listen({ port }, () => { 196 | console.log(`🚀 WS_server ready at http://localhost:${port}`); 197 | }); 198 | return http_server; 199 | } 200 | -------------------------------------------------------------------------------- /api/src/runtime/zmq-utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import * as zmq from "zeromq"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | function serializeMsg(msg, key) { 6 | // return a list of message parts 7 | // 4. header 8 | let part4 = JSON.stringify(msg.header); 9 | // 5. parent header 10 | let part5 = JSON.stringify({}); 11 | // 6. meta data 12 | let part6 = JSON.stringify({}); 13 | // 7. content 14 | let part7 = JSON.stringify(msg.content); 15 | 16 | return [ 17 | // 1. the id 18 | msg.header.msg_id, 19 | // 2. "" 20 | "", 21 | // 3. HMAC 22 | // "", 23 | crypto 24 | .createHmac("sha256", key) 25 | .update(part4) 26 | .update(part5) 27 | .update(part6) 28 | .update(part7) 29 | .digest("hex"), 30 | part4, 31 | part5, 32 | part6, 33 | part7, 34 | // 8. extra raw buffers] 35 | // I'm not sending this, because iracket crashes on this 36 | // JSON.stringify({}), 37 | ]; 38 | } 39 | 40 | function deserializeMsg(frames, key = null) { 41 | var i = 0; 42 | var idents: any[] = []; 43 | for (i = 0; i < frames.length; i++) { 44 | var frame = frames[i]; 45 | // console.log(i); 46 | // console.log(toJSON(frame)); 47 | if (frame.toString() === "") { 48 | break; 49 | } 50 | idents.push(frame); 51 | } 52 | if (frames.length - i < 5) { 53 | console.log("MESSAGE: DECODE: Not enough message frames", frames); 54 | return null; 55 | } 56 | 57 | if (frames[i].toString() !== "") { 58 | console.log("MESSAGE: DECODE: Missing delimiter", frames); 59 | return null; 60 | } 61 | 62 | if (key) { 63 | var obtainedSignature = frames[i + 1].toString(); 64 | 65 | var hmac = crypto.createHmac("sha256", key); 66 | hmac.update(frames[i + 2]); 67 | hmac.update(frames[i + 3]); 68 | hmac.update(frames[i + 4]); 69 | hmac.update(frames[i + 5]); 70 | var expectedSignature = hmac.digest("hex"); 71 | 72 | if (expectedSignature !== obtainedSignature) { 73 | console.log( 74 | "MESSAGE: DECODE: Incorrect message signature:", 75 | "Obtained = " + obtainedSignature, 76 | "Expected = " + expectedSignature 77 | ); 78 | return null; 79 | } 80 | } 81 | 82 | function toJSON(value) { 83 | return JSON.parse(value.toString()); 84 | } 85 | 86 | var message = { 87 | idents: idents, 88 | header: toJSON(frames[i + 2]), 89 | parent_header: toJSON(frames[i + 3]), 90 | content: toJSON(frames[i + 5]), 91 | metadata: toJSON(frames[i + 4]), 92 | buffers: Array.prototype.slice.apply(frames, [i + 6]), 93 | }; 94 | 95 | return message; 96 | } 97 | 98 | export function constructMessage({ 99 | msg_type, 100 | content = {}, 101 | msg_id = uuidv4(), 102 | }) { 103 | // TODO I should probably switch to Typescript just to avoid writing such checks 104 | if (!msg_type) { 105 | throw new Error("msg_type is undefined"); 106 | } 107 | return { 108 | header: { 109 | msg_id: msg_id, 110 | msg_type: msg_type, 111 | session: uuidv4(), 112 | username: "dummy_user", 113 | date: new Date().toISOString(), 114 | version: "5.0", 115 | }, 116 | parent_header: {}, 117 | metadata: {}, 118 | buffers: [], 119 | content: content, 120 | }; 121 | } 122 | 123 | export function constructExecuteRequest({ code, msg_id, cp = {} }) { 124 | if (!code || !msg_id) { 125 | throw new Error("Must provide code and msg_id"); 126 | } 127 | return constructMessage({ 128 | msg_type: "execute_request", 129 | msg_id, 130 | content: { 131 | // Source code to be executed by the kernel, one or more lines. 132 | code, 133 | cp, 134 | // FIXME if this is true, no result is returned! 135 | silent: false, 136 | store_history: true, 137 | // XXX this does not seem to be used 138 | user_expressions: { 139 | x: "3+4", 140 | }, 141 | allow_stdin: false, 142 | stop_on_error: false, 143 | }, 144 | }); 145 | } 146 | 147 | export class ZmqWire { 148 | kernelSpec; 149 | onshell; 150 | oniopub; 151 | shell; 152 | control; 153 | iopub; 154 | kernelStatus; 155 | results; 156 | 157 | constructor(spec) { 158 | this.kernelSpec = spec; 159 | this.onshell = (msgs) => { 160 | console.log("Default OnShell:", msgs); 161 | }; 162 | this.oniopub = (topic, msgs) => { 163 | console.log("Default OnIOPub:", topic, "msgs:", msgs); 164 | }; 165 | 166 | // Pub/Sub Router/Dealer 167 | this.shell = new zmq.Dealer(); 168 | // FIXME this is not actually connected. I need to check the real status 169 | // There does not seem to have any method to check connection status 170 | // console.log("=== connecting to shell port"); 171 | // console.log(this.kernelSpec); 172 | // console.log(`tcp://${this.kernelSpec.ip}:${this.kernelSpec.shell_port}`); 173 | this.shell.connect( 174 | `tcp://${this.kernelSpec.ip}:${this.kernelSpec.shell_port}` 175 | ); 176 | // FIXME this is not actually connected. I need to check the real status 177 | // There does not seem to have any method to check connection status 178 | console.log("connected to shell port"); 179 | 180 | console.log("connecting to control port "); 181 | this.control = new zmq.Dealer(); 182 | this.control.connect( 183 | `tcp://${this.kernelSpec.ip}:${this.kernelSpec.control_port}` 184 | ); 185 | this.iopub = new zmq.Subscriber(); 186 | console.log("connecting IOPub"); 187 | this.iopub.connect( 188 | `tcp://${this.kernelSpec.ip}:${this.kernelSpec.iopub_port}` 189 | ); 190 | this.iopub.subscribe(); 191 | this.listenOnShell(); 192 | this.listenOnControl(); 193 | this.listenOnIOPub(); 194 | 195 | this.kernelStatus = "uknown"; 196 | this.results = {}; 197 | } 198 | 199 | // getKernelStatus() { 200 | // return kernelStatus; 201 | // } 202 | 203 | // Send code to kernel. Return the ID of the execute_request 204 | // The front-end will listen to IOPub and display result accordingly based on 205 | // this ID. 206 | sendShellMessage(msg) { 207 | // bind zeromq socket to the ports 208 | console.log("sending shell mesasge .."); 209 | // console.log(msg); 210 | // FIXME how to receive the message? 211 | // sock.on("message", (msg) => { 212 | // console.log("sock on:", msg); 213 | // }); 214 | // FIXME I probably need to wait until the server is started 215 | // sock.send(msg); 216 | // FIXME Error: Socket temporarily unavailable 217 | this.shell.send(serializeMsg(msg, this.kernelSpec.key)); 218 | } 219 | sendControlMessage(msg) { 220 | this.control.send(serializeMsg(msg, this.kernelSpec.key)); 221 | } 222 | 223 | setOnShell(func) { 224 | this.onshell = func; 225 | } 226 | setOnIOPub(func) { 227 | this.oniopub = func; 228 | } 229 | 230 | async listenOnShell() { 231 | for await (const [...frames] of this.shell) { 232 | let msgs = deserializeMsg(frames, this.kernelSpec.key); 233 | this.onshell(msgs); 234 | } 235 | } 236 | async listenOnControl() { 237 | for await (const [...frames] of this.control) { 238 | let msgs = deserializeMsg(frames, this.kernelSpec.key); 239 | // FIXME for now, just use the onshell callback 240 | this.onshell(msgs); 241 | } 242 | } 243 | 244 | async listenOnIOPub() { 245 | // if (this.iopub && !this.iopub.closed) { 246 | // console.log("disconnecting previous iopub .."); 247 | // this.iopub.close(); 248 | // } 249 | // console.log("waiting for iopub"); 250 | 251 | // let msgs = await pubsock.receive(); 252 | // console.log(msgs); 253 | // FIXME this socket can only be listened here once! 254 | for await (const [topic, ...frames] of this.iopub) { 255 | // func(topic, frames); 256 | let msgs = deserializeMsg(frames, this.kernelSpec.key); 257 | this.oniopub(topic.toString(), msgs); 258 | } 259 | } 260 | } 261 | 262 | export function handleIOPub_status({ msgs, socket, lang }) { 263 | console.log("emitting status ..", msgs.content.execution_state, "for", lang); 264 | // console.log("msg", msgs); 265 | socket.send( 266 | JSON.stringify({ 267 | type: "status", 268 | payload: { 269 | lang: lang, 270 | status: msgs.content.execution_state, 271 | // This is for use with racket kernel to check the finish of running 272 | id: msgs.parent_header.msg_id, 273 | }, 274 | }) 275 | ); 276 | } 277 | 278 | export function handleIOPub_display_data({ msgs, socket }) { 279 | console.log("emitting display data .."); 280 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 281 | let payload = { 282 | podId, 283 | //name, 284 | // content is a dict of 285 | // { 286 | // data: {'text/plain': ..., 'image/png': ...}, 287 | // metadata: {needs_background: 'light'}, 288 | // transient: ... 289 | // } 290 | content: msgs.content, 291 | }; 292 | socket.send(JSON.stringify({ type: "display_data", payload })); 293 | } 294 | 295 | export function handleIOPub_execute_result({ msgs, socket }) { 296 | console.log("emitting execute_result .."); 297 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 298 | let payload = { 299 | podId, 300 | name, 301 | // result: msgs.content.data["text/plain"], 302 | // This might contain text/plain, or text/html that contains image 303 | content: msgs.content, 304 | count: msgs.content.execution_count, 305 | }; 306 | if (name) { 307 | console.log("emitting IO result"); 308 | socket.send(JSON.stringify({ type: "IO:execute_result", payload })); 309 | } else { 310 | socket.send(JSON.stringify({ type: "execute_result", payload })); 311 | } 312 | } 313 | 314 | export function handleIOPub_stdout({ msgs, socket }) { 315 | console.log("emitting stdout .."); 316 | if (msgs.content.text.startsWith("base64 binary data")) { 317 | console.log("warning: base64 encoded stdout"); 318 | } else { 319 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 320 | let payload = { 321 | podId, 322 | name, 323 | stdout: msgs.content.text, 324 | }; 325 | if (name) { 326 | // this is Import/Export cmd 327 | socket.send(JSON.stringify({ type: "IO:stdout", payload })); 328 | } else { 329 | socket.send(JSON.stringify({ type: "stdout", payload })); 330 | } 331 | } 332 | } 333 | 334 | export function handleIOPub_error({ msgs, socket }) { 335 | console.log("emitting error .."); 336 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 337 | let payload = { 338 | podId, 339 | name, 340 | stacktrace: msgs.content.traceback, 341 | ename: msgs.content.ename, 342 | evalue: msgs.content.evalue, 343 | }; 344 | if (name) { 345 | socket.send(JSON.stringify({ type: "IO:error", payload })); 346 | } else { 347 | socket.send(JSON.stringify({ type: "error", payload })); 348 | } 349 | } 350 | 351 | export function handleIOPub_stream({ msgs, socket }) { 352 | if (!msgs.parent_header.msg_id) { 353 | console.log("No msg_id, skipped"); 354 | console.log(msgs.parent_header); 355 | return; 356 | } 357 | let [podId, name] = msgs.parent_header.msg_id.split("#"); 358 | // iracket use this to send stderr 359 | // FIXME there are many frames 360 | if (msgs.content.name === "stdout") { 361 | // console.log("ignore stdout stream"); 362 | console.log("emitting stdout stream ..", msgs); 363 | socket.send( 364 | JSON.stringify({ 365 | type: "stream", 366 | payload: { 367 | podId, 368 | // name: stdout or stderr 369 | // text: 370 | content: msgs.content, 371 | }, 372 | }) 373 | ); 374 | } else if (msgs.content.name === "stderr") { 375 | console.log("emitting error stream .."); 376 | if (!name) { 377 | socket.send( 378 | JSON.stringify({ 379 | type: "stream", 380 | payload: { 381 | podId, 382 | content: msgs.content, 383 | }, 384 | }) 385 | ); 386 | } else { 387 | // FIXME this is stream for import/export. I should move it somewhere 388 | console.log("emitting other stream .."); 389 | socket.send( 390 | JSON.stringify({ 391 | type: "stream", 392 | payload: { 393 | podId, 394 | content: msgs.content, 395 | }, 396 | }) 397 | ); 398 | } 399 | } else { 400 | console.log(msgs); 401 | throw new Error(`Invalid stream type: ${msgs.content.name}`); 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /api/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import http from "http"; 3 | import { WebSocketServer } from "ws"; 4 | 5 | import * as trpcExpress from "@trpc/server/adapters/express"; 6 | 7 | import { createSetupWSConnection } from "./yjs/yjs-setupWS"; 8 | import { bindState, writeState } from "./yjs/yjs-blob"; 9 | 10 | import cors from "cors"; 11 | import { createSpawnerRouter, router } from "./spawner/trpc"; 12 | 13 | export async function startServer({ port, repoDir }) { 14 | console.log("starting server .."); 15 | const app = express(); 16 | app.use(express.json({ limit: "20mb" })); 17 | // support cors 18 | app.use(cors()); 19 | // serve static files generated from UI 20 | const path = `${__dirname}/../public`; 21 | console.log("html path: ", path); 22 | app.use(express.static(path)); 23 | 24 | const yjsServerUrl = `ws://localhost:${port}/socket`; 25 | 26 | app.use( 27 | "/trpc", 28 | trpcExpress.createExpressMiddleware({ 29 | router: router({ 30 | spawner: createSpawnerRouter(yjsServerUrl), 31 | }), 32 | }) 33 | ); 34 | 35 | const http_server = http.createServer(app); 36 | 37 | // Yjs websocket 38 | const wss = new WebSocketServer({ noServer: true }); 39 | 40 | wss.on("connection", (...args) => 41 | createSetupWSConnection( 42 | (doc, repoId) => bindState(doc, repoId, repoDir), 43 | writeState 44 | )(...args) 45 | ); 46 | 47 | http_server.on("upgrade", async (request, socket, head) => { 48 | // You may check auth of request here.. 49 | // See https://github.com/websockets/ws#client-authentication 50 | if (request.url) { 51 | wss.handleUpgrade(request, socket, head, function done(ws) { 52 | wss.emit("connection", ws, request, { readOnly: false }); 53 | }); 54 | return; 55 | } 56 | socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); 57 | socket.destroy(); 58 | return; 59 | }); 60 | 61 | http_server.listen({ port }, () => { 62 | console.log(`🚀 Server ready at http://localhost:${port}`); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /api/src/spawner/spawner_native.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from "child_process"; 2 | import { writeFileSync } from "fs"; 3 | import net from "net"; 4 | import http from "http"; 5 | 6 | import { startServer } from "../runtime/server"; 7 | 8 | type KernelInfo = { 9 | // the kernel 10 | zmq_proc: ChildProcessWithoutNullStreams; 11 | spec: KernelSpec; 12 | // the WS gateway 13 | ws_server: http.Server; 14 | ws_port: number; 15 | }; 16 | 17 | const id2KernelInfo = new Map(); 18 | 19 | const usedPorts = new Set(); 20 | 21 | async function getFreePort(): Promise { 22 | return new Promise((resolve, reject) => { 23 | // get one free pod 24 | const srv = net.createServer(); 25 | srv.listen(0, () => { 26 | const addr = srv.address() as net.AddressInfo; 27 | const port = addr.port; 28 | srv.close(); 29 | resolve(port); 30 | }); 31 | }); 32 | } 33 | 34 | async function getAvailablePort() { 35 | while (true) { 36 | let port = await getFreePort(); 37 | if (!usedPorts.has(port)) { 38 | usedPorts.add(port); 39 | return port; 40 | } 41 | } 42 | } 43 | 44 | type KernelSpec = { 45 | shell_port: number; 46 | iopub_port: number; 47 | stdin_port: number; 48 | control_port: number; 49 | hb_port: number; 50 | ip: string; 51 | key: string; 52 | transport: string; 53 | kernel_name: string; 54 | }; 55 | 56 | async function createNewConnSpec() { 57 | let spec: KernelSpec = { 58 | shell_port: await getAvailablePort(), 59 | iopub_port: await getAvailablePort(), 60 | stdin_port: await getAvailablePort(), 61 | control_port: await getAvailablePort(), 62 | hb_port: await getAvailablePort(), 63 | ip: "127.0.0.1", 64 | key: "", 65 | transport: "tcp", 66 | kernel_name: "", 67 | }; 68 | return spec; 69 | } 70 | 71 | /** 72 | * 73 | * @returns target url: ws://container:port 74 | */ 75 | export async function spawnRuntime(runtimeId) { 76 | // 1. launch the kernel with some connection file 77 | const spec = await createNewConnSpec(); 78 | console.log("=== spec", spec); 79 | // This file is only used once. 80 | writeFileSync("/tmp/conn.json", JSON.stringify(spec)); 81 | // create a new process 82 | // "python3", "-m", "ipykernel_launcher", "-f", "/conn.json" 83 | const proc = spawn("python3", [ 84 | "-m", 85 | "ipykernel_launcher", 86 | "-f", 87 | "/tmp/conn.json", 88 | ]); 89 | proc.stdout.on("data", (data) => { 90 | console.log(`child stdout:\n${data}`); 91 | }); 92 | proc.stderr.on("data", (data) => { 93 | console.error(`child stderr:\n${data}`); 94 | }); 95 | 96 | // 2. launch the WS gateway server, at some port 97 | const port = await getAvailablePort(); 98 | console.log("=== ws port", port); 99 | const server = startServer({ spec, port }); 100 | // 3. return the runtimeInfo 101 | // add the process PID to the runtimeInfo 102 | const runtimeInfo: KernelInfo = { 103 | spec, 104 | zmq_proc: proc, 105 | ws_server: server, 106 | ws_port: port, 107 | }; 108 | id2KernelInfo.set(runtimeId, runtimeInfo); 109 | return `ws://localhost:${port}`; 110 | } 111 | 112 | export async function killRuntime(runtimeId) { 113 | // free resources 114 | const info = id2KernelInfo.get(runtimeId); 115 | if (!info) { 116 | console.warn(`WARN Runtime ${runtimeId} not found`); 117 | return; 118 | } 119 | // kill the kernel process and free the ports 120 | info.zmq_proc.kill(); 121 | usedPorts.delete(info.spec.shell_port); 122 | usedPorts.delete(info.spec.iopub_port); 123 | usedPorts.delete(info.spec.stdin_port); 124 | usedPorts.delete(info.spec.control_port); 125 | usedPorts.delete(info.spec.hb_port); 126 | // kill the ws server and free its port 127 | info.ws_server.close(); 128 | usedPorts.delete(info.ws_port); 129 | } 130 | -------------------------------------------------------------------------------- /api/src/spawner/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | const t = initTRPC.create(); 3 | export const router = t.router; 4 | export const publicProcedure = t.procedure; 5 | 6 | import Y from "yjs"; 7 | import WebSocket from "ws"; 8 | import { z } from "zod"; 9 | 10 | // import { WebsocketProvider } from "../../ui/src/lib/y-websocket"; 11 | import { WebsocketProvider } from "../yjs/y-websocket"; 12 | 13 | import { killRuntime, spawnRuntime } from "./spawner_native"; 14 | 15 | import { connectSocket, runtime2socket, RuntimeInfo } from "./yjs_runtime"; 16 | 17 | // FIXME need to have a TTL to clear the ydoc. 18 | const docs: Map = new Map(); 19 | 20 | async function getMyYDoc({ repoId, yjsServerUrl }): Promise { 21 | return new Promise((resolve, reject) => { 22 | const oldydoc = docs.get(repoId); 23 | if (oldydoc) { 24 | resolve(oldydoc); 25 | return; 26 | } 27 | const ydoc = new Y.Doc(); 28 | // connect to primary database 29 | console.log("connecting to y-websocket provider", yjsServerUrl); 30 | const provider = new WebsocketProvider(yjsServerUrl, repoId, ydoc, { 31 | // resyncInterval: 2000, 32 | // 33 | // BC is more complex to track our custom Uploading status and SyncDone events. 34 | disableBc: true, 35 | params: { 36 | role: "runtime", 37 | }, 38 | // IMPORTANT: import websocket, because we're running it in node.js 39 | WebSocketPolyfill: WebSocket as any, 40 | }); 41 | provider.on("status", ({ status }) => { 42 | console.log("provider status", status); 43 | }); 44 | provider.once("synced", () => { 45 | console.log("Provider synced"); 46 | docs.set(repoId, ydoc); 47 | resolve(ydoc); 48 | }); 49 | provider.connect(); 50 | }); 51 | } 52 | 53 | const routingTable: Map = new Map(); 54 | 55 | export function createSpawnerRouter(yjsServerUrl) { 56 | return router({ 57 | spawnRuntime: publicProcedure 58 | .input(z.object({ runtimeId: z.string(), repoId: z.string() })) 59 | .mutation(async ({ input: { runtimeId, repoId } }) => { 60 | console.log("spawnRuntime", runtimeId, repoId); 61 | // create the runtime container 62 | const wsUrl = await spawnRuntime(runtimeId); 63 | console.log("Runtime spawned at", wsUrl); 64 | routingTable.set(runtimeId, wsUrl); 65 | // set initial runtimeMap info for this runtime 66 | console.log("Loading yDoc .."); 67 | const doc = await getMyYDoc({ repoId, yjsServerUrl }); 68 | console.log("yDoc loaded"); 69 | const rootMap = doc.getMap("rootMap"); 70 | const runtimeMap = rootMap.get("runtimeMap") as Y.Map; 71 | runtimeMap.set(runtimeId, {}); 72 | // console.log("=== runtimeMap", runtimeMap); 73 | let values = Array.from(runtimeMap.values()); 74 | const keys = Array.from(runtimeMap.keys()); 75 | console.log("all runtimes", keys); 76 | const nodesMap = rootMap.get("nodesMap") as Y.Map; 77 | const nodes = Array.from(nodesMap.values()); 78 | console.log("all nodes", nodes); 79 | return true; 80 | }), 81 | killRuntime: publicProcedure 82 | .input(z.object({ runtimeId: z.string(), repoId: z.string() })) 83 | .mutation(async ({ input: { runtimeId, repoId } }) => { 84 | await killRuntime(runtimeId); 85 | console.log("Removing route .."); 86 | // remove from runtimeMap 87 | const doc = await getMyYDoc({ repoId, yjsServerUrl }); 88 | const rootMap = doc.getMap("rootMap"); 89 | const runtimeMap = rootMap.get("runtimeMap") as Y.Map; 90 | runtimeMap.delete(runtimeId); 91 | routingTable.delete(runtimeId); 92 | return true; 93 | }), 94 | 95 | connectRuntime: publicProcedure 96 | .input(z.object({ runtimeId: z.string(), repoId: z.string() })) 97 | .mutation(async ({ input: { runtimeId, repoId } }) => { 98 | console.log("=== connectRuntime", runtimeId, repoId); 99 | // assuming doc is already loaded. 100 | // FIXME this socket/ is the prefix of url. This is very prone to errors. 101 | const doc = await getMyYDoc({ repoId, yjsServerUrl }); 102 | const rootMap = doc.getMap("rootMap"); 103 | console.log("rootMap", Array.from(rootMap.keys())); 104 | const runtimeMap = rootMap.get("runtimeMap") as any; 105 | const resultMap = rootMap.get("resultMap") as any; 106 | await connectSocket({ 107 | runtimeId, 108 | runtimeMap, 109 | resultMap, 110 | routingTable, 111 | }); 112 | }), 113 | disconnectRuntime: publicProcedure 114 | .input(z.object({ runtimeId: z.string(), repoId: z.string() })) 115 | .mutation(async ({ input: { runtimeId, repoId } }) => { 116 | console.log("=== disconnectRuntime", runtimeId); 117 | // get socket 118 | const socket = runtime2socket.get(runtimeId); 119 | if (socket) { 120 | socket.close(); 121 | runtime2socket.delete(runtimeId); 122 | } 123 | 124 | const doc = await getMyYDoc({ repoId, yjsServerUrl }); 125 | const rootMap = doc.getMap("rootMap"); 126 | const runtimeMap = rootMap.get("runtimeMap") as Y.Map; 127 | runtimeMap.set(runtimeId, {}); 128 | }), 129 | runCode: publicProcedure 130 | .input( 131 | z.object({ 132 | runtimeId: z.string(), 133 | spec: z.object({ code: z.string(), podId: z.string() }), 134 | }) 135 | ) 136 | .mutation( 137 | async ({ 138 | input: { 139 | runtimeId, 140 | spec: { code, podId }, 141 | }, 142 | }) => { 143 | console.log("runCode", runtimeId, podId); 144 | const socket = runtime2socket.get(runtimeId); 145 | if (!socket) return false; 146 | // clear old results 147 | // TODO move this to frontend, because it is hard to get ydoc in GraphQL handler. 148 | // 149 | // console.log("clear old result"); 150 | // console.log("old", resultMap.get(runtimeId)); 151 | // resultMap.set(podId, { data: [] }); 152 | // console.log("new", resultMap.get(runtimeId)); 153 | // console.log("send new result"); 154 | socket.send( 155 | JSON.stringify({ 156 | type: "runCode", 157 | payload: { 158 | lang: "python", 159 | code: code, 160 | raw: true, 161 | podId: podId, 162 | sessionId: runtimeId, 163 | }, 164 | }) 165 | ); 166 | return true; 167 | } 168 | ), 169 | runChain: publicProcedure 170 | .input( 171 | z.object({ 172 | runtimeId: z.string(), 173 | specs: z.array(z.object({ code: z.string(), podId: z.string() })), 174 | }) 175 | ) 176 | .mutation(async ({ input: { runtimeId, specs } }) => { 177 | console.log("runChain", runtimeId); 178 | const socket = runtime2socket.get(runtimeId); 179 | if (!socket) return false; 180 | specs.forEach(({ code, podId }) => { 181 | socket.send( 182 | JSON.stringify({ 183 | type: "runCode", 184 | payload: { 185 | lang: "python", 186 | code: code, 187 | raw: true, 188 | podId: podId, 189 | sessionId: runtimeId, 190 | }, 191 | }) 192 | ); 193 | }); 194 | return true; 195 | }), 196 | interruptKernel: publicProcedure 197 | .input(z.object({ runtimeId: z.string() })) 198 | .mutation(async ({ input: { runtimeId } }) => { 199 | const socket = runtime2socket.get(runtimeId); 200 | if (!socket) return false; 201 | socket.send( 202 | JSON.stringify({ 203 | type: "interruptKernel", 204 | payload: { 205 | sessionId: runtimeId, 206 | }, 207 | }) 208 | ); 209 | return true; 210 | }), 211 | requestKernelStatus: publicProcedure 212 | .input(z.object({ runtimeId: z.string() })) 213 | .mutation(async ({ input: { runtimeId } }) => { 214 | console.log("requestKernelStatus", runtimeId); 215 | const socket = runtime2socket.get(runtimeId); 216 | if (!socket) { 217 | console.log("WARN: socket not found"); 218 | return false; 219 | } 220 | socket.send( 221 | JSON.stringify({ 222 | type: "requestKernelStatus", 223 | payload: { 224 | sessionId: runtimeId, 225 | }, 226 | }) 227 | ); 228 | return true; 229 | }), 230 | }); 231 | } 232 | 233 | // This is only used for frontend to get the type of router. 234 | const _appRouter_for_type = router({ 235 | spawner: createSpawnerRouter(null), // put procedures under "post" namespace 236 | }); 237 | export type AppRouter = typeof _appRouter_for_type; 238 | -------------------------------------------------------------------------------- /api/src/spawner/yjs_runtime.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import WebSocket from "ws"; 3 | 4 | export type PodResult = { 5 | exec_count?: number; 6 | data: { 7 | type: string; 8 | html?: string; 9 | text?: string; 10 | image?: string; 11 | }[]; 12 | running?: boolean; 13 | lastExecutedAt?: number; 14 | error?: { ename: string; evalue: string; stacktrace: string[] } | null; 15 | }; 16 | 17 | export type RuntimeInfo = { 18 | status?: string; 19 | wsStatus?: string; 20 | }; 21 | 22 | export const runtime2socket = new Map(); 23 | 24 | export async function setupRuntimeSocket({ 25 | socket, 26 | sessionId, 27 | runtimeMap, 28 | resultMap, 29 | }: { 30 | resultMap: Y.Map; 31 | runtimeMap: Y.Map; 32 | sessionId: string; 33 | socket: WebSocket; 34 | }) { 35 | console.log("--- setupRuntimeSocket", sessionId); 36 | socket.onopen = () => { 37 | console.log("socket connected for runtime", sessionId); 38 | runtimeMap.set(sessionId, { 39 | wsStatus: "connected", 40 | }); 41 | runtime2socket.set(sessionId, socket); 42 | // request kernel status 43 | socket.send( 44 | JSON.stringify({ 45 | type: "requestKernelStatus", 46 | payload: { 47 | sessionId, 48 | }, 49 | }) 50 | ); 51 | }; 52 | socket.onclose = () => { 53 | console.log("Socket closed for runtime", sessionId); 54 | runtimeMap.set(sessionId, { 55 | wsStatus: "disconnected", 56 | }); 57 | runtime2socket.delete(sessionId); 58 | }; 59 | socket.onerror = (err) => { 60 | console.error("[ERROR] Got error", err.message); 61 | // if error is 404, try create the kernel 62 | }; 63 | socket.onmessage = (msg) => { 64 | // FIXME is msg.data a string? 65 | let { type, payload } = JSON.parse(msg.data as string); 66 | // console.debug("got message", type, payload); 67 | 68 | switch (type) { 69 | case "stream": 70 | { 71 | let { podId, content } = payload; 72 | const oldresult: PodResult = resultMap.get(podId) || { data: [] }; 73 | // FIXME if I modify the object, would it modify resultMap as well? 74 | oldresult.data.push({ 75 | type: `${type}_${content.name}`, 76 | text: content.text, 77 | }); 78 | resultMap.set(podId, oldresult); 79 | } 80 | break; 81 | case "execute_result": 82 | { 83 | let { podId, content, count } = payload; 84 | const oldresult: PodResult = resultMap.get(podId) || { data: [] }; 85 | oldresult.data.push({ 86 | type, 87 | text: content.data["text/plain"], 88 | html: content.data["text/html"], 89 | }); 90 | resultMap.set(podId, oldresult); 91 | } 92 | break; 93 | case "display_data": 94 | { 95 | let { podId, content } = payload; 96 | const oldresult: PodResult = resultMap.get(podId) || { data: [] }; 97 | oldresult.data.push({ 98 | type, 99 | text: content.data["text/plain"], 100 | image: content.data["image/png"], 101 | html: content.data["text/html"], 102 | }); 103 | resultMap.set(podId, oldresult); 104 | } 105 | break; 106 | case "execute_reply": 107 | { 108 | let { podId, result, count } = payload; 109 | const oldresult: PodResult = resultMap.get(podId) || { data: [] }; 110 | oldresult.running = false; 111 | oldresult.lastExecutedAt = Date.now(); 112 | oldresult.exec_count = count; 113 | resultMap.set(podId, oldresult); 114 | } 115 | break; 116 | case "error": 117 | { 118 | let { podId, ename, evalue, stacktrace } = payload; 119 | const oldresult: PodResult = resultMap.get(podId) || { data: [] }; 120 | oldresult.error = { ename, evalue, stacktrace }; 121 | } 122 | break; 123 | case "status": 124 | { 125 | const { lang, status, id } = payload; 126 | // listen to messages 127 | runtimeMap.set(sessionId, { ...runtimeMap.get(sessionId), status }); 128 | } 129 | break; 130 | case "interrupt_reply": 131 | // console.log("got interrupt_reply", payload); 132 | break; 133 | default: 134 | console.warn("WARNING unhandled message", { type, payload }); 135 | } 136 | }; 137 | } 138 | 139 | /** 140 | * Get the active socket. Create if not connected. 141 | */ 142 | export async function connectSocket({ 143 | runtimeId, 144 | runtimeMap, 145 | resultMap, 146 | routingTable, 147 | }: { 148 | runtimeId: string; 149 | runtimeMap: Y.Map; 150 | resultMap: Y.Map; 151 | routingTable: Map; 152 | }) { 153 | console.log("connectSocket"); 154 | const runtime = runtimeMap.get(runtimeId)!; 155 | switch (runtime.wsStatus) { 156 | case "connecting": 157 | // FIXME connecting status could cause dead lock. 158 | console.log("socket was connecting, skip"); 159 | return; 160 | case "connected": 161 | console.log("socket was connected, skip"); 162 | return; 163 | case "disconnected": 164 | case undefined: 165 | { 166 | const url = routingTable.get(runtimeId); 167 | if (!url) throw new Error(`cannot find url for runtime ${runtimeId}`); 168 | console.log("connecting to websocket url", url); 169 | runtimeMap.set(runtimeId, { 170 | ...runtime, 171 | wsStatus: "connecting", 172 | }); 173 | let socket = new WebSocket(url); 174 | await setupRuntimeSocket({ 175 | socket, 176 | sessionId: runtimeId, 177 | runtimeMap, 178 | resultMap, 179 | }); 180 | } 181 | break; 182 | default: 183 | throw new Error(`unknown wsStatus ${runtime.wsStatus}`); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /api/src/yjs/yjs-blob.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is an alternative to yjs-plain. The persietence layer here saves a 3 | * binary blob to the DB. 4 | * 5 | * Cons (and the reason why I'm not using this): 6 | * - This requires DB schame change and manual DB migration. 7 | * 8 | * Pros: 9 | * - Support history. 10 | * - The logic is simpler than yjs-plain, no need to save each entries to the 11 | * DB, just the single entire Y.Doc blob. 12 | * - The plain version seems to have trouble syncing with reconnected clients. 13 | * I.e., if a client disconnects, make some offline edits, and connect back, 14 | * those offline edits are not synced. THIS is the reason why I'm using this 15 | * binary blob version. 16 | */ 17 | 18 | // throw new Error("Experimental not implemented."); 19 | 20 | import fs from "fs"; 21 | import Y from "yjs"; 22 | 23 | import debounce from "lodash/debounce"; 24 | 25 | const debounceRegistry = new Map(); 26 | /** 27 | * Invoke the callback that debounce w.r.t. the key. Also register the callback 28 | * in the registry to make sure it's called before the connection is closed.. 29 | */ 30 | function getDebouncedCallback(key) { 31 | if (!debounceRegistry.has(key)) { 32 | console.log("registering for", key); 33 | debounceRegistry.set( 34 | key, 35 | debounce( 36 | (cb) => { 37 | console.log("debounced callback for", key); 38 | cb(); 39 | }, 40 | // write if no new activity in 10s 41 | 1000, 42 | { 43 | // write at least every 20s 44 | maxWait: 5000, 45 | } 46 | ) 47 | ); 48 | } 49 | // 2. call it 50 | return debounceRegistry.get(key); 51 | } 52 | 53 | function handleSaveBlob({ repoId, yDocBlob, repoDir }) { 54 | console.log("save blob", repoId, yDocBlob.length); 55 | // create the yjs-blob folder if not exists 56 | if (!fs.existsSync(repoDir)) { 57 | fs.mkdirSync(repoDir, { recursive: true }); 58 | } 59 | // save the blob to file system 60 | fs.writeFileSync(`${repoDir}/codepod.bin`, yDocBlob); 61 | } 62 | 63 | function handleSavePlain({ repoId, ydoc, repoDir }) { 64 | console.log("save plain", repoId); 65 | // save the plain to file system 66 | const rootMap = ydoc.getMap("rootMap"); 67 | const nodesMap = rootMap.get("nodesMap") as Y.Map; 68 | const edgesMap = rootMap.get("edgesMap") as Y.Map; 69 | const codeMap = rootMap.get("codeMap") as Y.Map; 70 | const richMap = rootMap.get("richMap") as Y.Map; 71 | const resultMap = rootMap.get("resultMap") as Y.Map; 72 | const runtimeMap = rootMap.get("runtimeMap") as Y.Map; 73 | const metaMap = rootMap.get("metaMap") as Y.Map; 74 | const plain = { 75 | lastUpdate: new Date().toISOString(), 76 | metaMap: metaMap.toJSON(), 77 | nodesMap: nodesMap.toJSON(), 78 | edgesMap: edgesMap.toJSON(), 79 | codeMap: codeMap.toJSON(), 80 | richMap: richMap.toJSON(), 81 | resultMap: resultMap.toJSON(), 82 | runtimeMap: runtimeMap.toJSON(), 83 | }; 84 | if (!fs.existsSync(repoDir)) { 85 | fs.mkdirSync(repoDir, { recursive: true }); 86 | } 87 | fs.writeFileSync(`${repoDir}/codepod.json`, JSON.stringify(plain, null, 2)); 88 | } 89 | 90 | /** 91 | * This function is called when setting up the WS connection, after the loadFromCodePod step. 92 | * TODO need to make sure this is only called once per repo, regardless of how many users are connected later. 93 | */ 94 | function setupObserversToDB(ydoc: Y.Doc, repoId: string, repoDir: string) { 95 | console.log("setupObserversToDB for repo", repoId); 96 | // just observe and save the entire doc 97 | function observer(_, transaction) { 98 | if (transaction.local) { 99 | // There shouldn't be local updates. 100 | console.log("[WARNING] Local update"); 101 | return; 102 | } 103 | // FIXME the waiting time could be used to reduce the cost of saving to DB. 104 | getDebouncedCallback(`update-blob-${repoId}`)(() => { 105 | // encode state as update 106 | // FIXME it may be too expensive to update the entire doc. 107 | // FIXME history is discarded 108 | const update = Y.encodeStateAsUpdate(ydoc); 109 | handleSaveBlob({ repoId, yDocBlob: Buffer.from(update), repoDir }); 110 | handleSavePlain({ repoId, ydoc, repoDir }); 111 | }); 112 | } 113 | const rootMap = ydoc.getMap("rootMap"); 114 | const nodesMap = rootMap.get("nodesMap") as Y.Map; 115 | nodesMap.observe(observer); 116 | const edgesMap = rootMap.get("edgesMap") as Y.Map; 117 | edgesMap.observe(observer); 118 | const codeMap = rootMap.get("codeMap") as Y.Map; 119 | codeMap.observeDeep(observer); 120 | const richMap = rootMap.get("richMap") as Y.Map; 121 | richMap.observeDeep(observer); 122 | const resultMap = rootMap.get("resultMap") as Y.Map; 123 | resultMap.observeDeep(observer); 124 | } 125 | 126 | /** 127 | * This function is called when setting up the WS connection, as a first step. 128 | */ 129 | async function loadFromFS(ydoc: Y.Doc, repoId: string, repoDir: string) { 130 | // load from the database and write to the ydoc 131 | console.log("=== loadFromFS"); 132 | // read the blob from file system 133 | const binFile = `${repoDir}/codepod.bin`; 134 | if (fs.existsSync(binFile)) { 135 | const yDocBlob = fs.readFileSync(binFile); 136 | Y.applyUpdate(ydoc, yDocBlob); 137 | } else { 138 | // init the ydoc 139 | const rootMap = ydoc.getMap("rootMap"); 140 | rootMap.set("nodesMap", new Y.Map()); 141 | rootMap.set("edgesMap", new Y.Map()); 142 | rootMap.set("codeMap", new Y.Map()); 143 | rootMap.set("richMap", new Y.Map()); 144 | rootMap.set("resultMap", new Y.Map()); 145 | rootMap.set("runtimeMap", new Y.Map()); 146 | const metaMap = new Y.Map(); 147 | metaMap.set("version", "v0.0.1"); 148 | rootMap.set("metaMap", metaMap); 149 | } 150 | } 151 | 152 | export async function bindState(doc: Y.Doc, repoId: string, repoDir: string) { 153 | // Load persisted document state from the database. 154 | await loadFromFS(doc, repoId, repoDir); 155 | // Observe changes and write to the database. 156 | setupObserversToDB(doc, repoId, repoDir); 157 | // setupObserversToRuntime(doc, repoId); 158 | // reset runtime status 159 | // clear runtimeMap status/commands but keep the ID 160 | const rootMap = doc.getMap("rootMap"); 161 | if (rootMap.get("runtimeMap") === undefined) { 162 | rootMap.set("runtimeMap", new Y.Map()); 163 | } 164 | const runtimeMap = rootMap.get("runtimeMap") as Y.Map; 165 | for (let key of runtimeMap.keys()) { 166 | runtimeMap.set(key, {}); 167 | } 168 | } 169 | 170 | export function writeState() { 171 | // FIXME IMPORTANT make sure the observer events are finished. 172 | console.log("=== flushing allDebouncedCallbacks", debounceRegistry.size); 173 | debounceRegistry.forEach((cb) => cb.flush()); 174 | } 175 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "outDir": "./build", 5 | "target": "esnext", 6 | "allowJs": true, 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "lib": ["esnext"], 10 | "moduleResolution": "node", 11 | "noFallthroughCasesInSwitch": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "isolatedModules": true, 16 | "noImplicitAny": false 17 | }, 18 | "ts-node": { 19 | // these options are overrides used only by ts-node 20 | // same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable 21 | "compilerOptions": { 22 | "module": "commonjs" 23 | } 24 | }, 25 | "include": ["src", "src/yjs/y-websocket.js"] 26 | } 27 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "api" 3 | - "ui" 4 | -------------------------------------------------------------------------------- /screenshot-canvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/screenshot-canvas.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/screenshot.png -------------------------------------------------------------------------------- /ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /ui/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS builder 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | COPY yarn.lock . 6 | RUN yarn install --frozen-lockfile 7 | 8 | # FIXME would this copy node_modules? 9 | COPY . . 10 | 11 | ENV NODE_OPTIONS="--max_old_space_size=4096" 12 | RUN yarn build 13 | 14 | FROM nginx:1.19-alpine AS server 15 | 16 | # https://stackoverflow.com/questions/45598779/react-router-browserrouter-leads-to-404-not-found-nginx-error-when-going-to 17 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 18 | COPY --from=builder ./app/build /usr/share/nginx/html 19 | COPY ./set-env.sh /usr/share/nginx/html/set-env.sh 20 | CMD ["sh", "-c", "cd /usr/share/nginx/html/ && ./set-env.sh && nginx -g 'daemon off;'"] 21 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CodePod IDE: Interactive Development At Scale 8 | 9 | 10 |
11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codepod/ui", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "main": "src/index.tsx", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@apollo/client": "^3.8.1", 14 | "@benrbray/prosemirror-math": "^0.2.2", 15 | "@emotion/react": "^11.11.1", 16 | "@emotion/styled": "^11.11.0", 17 | "@mui/icons-material": "^5.10.9", 18 | "@mui/lab": "^5.0.0-alpha.140", 19 | "@mui/material": "^5.14.5", 20 | "@remirror/core": "^2.0.19", 21 | "@remirror/core-utils": "^2.0.13", 22 | "@remirror/extension-mention": "^2.0.15", 23 | "@remirror/extension-react-tables": "^2.2.18", 24 | "@remirror/messages": "^2.0.6", 25 | "@remirror/pm": "^2.0.5", 26 | "@remirror/react": "^2.0.28", 27 | "@remirror/react-core": "^2.0.21", 28 | "@remirror/theme": "^2.0.9", 29 | "@tanstack/react-query": "^4.18.0", 30 | "@trpc/client": "^10.43.0", 31 | "@trpc/react-query": "^10.43.0", 32 | "@trpc/server": "^10.43.0", 33 | "ansi-to-react": "^6.1.6", 34 | "d3-force": "^3.0.0", 35 | "d3-quadtree": "^3.0.1", 36 | "graphql": "^16.8.0", 37 | "html-to-image": "^1.11.11", 38 | "immer": "^10.0.2", 39 | "katex": "0.13.0", 40 | "kbar": "^0.1.0-beta.40", 41 | "lib0": "^0.2.83", 42 | "monaco-editor": "^0.34.1", 43 | "nanoid": "^3.0.0", 44 | "nanoid-dictionary": "^4.3.0", 45 | "notistack": "^2.0.8", 46 | "prosemirror-model": "^1.19.3", 47 | "prosemirror-view": "^1.31.7", 48 | "react": "^18.2.0", 49 | "react-copy-to-clipboard": "^5.1.0", 50 | "react-dom": "^18.2.0", 51 | "react-monaco-editor": "^0.50.1", 52 | "react-resizable": "^3.0.4", 53 | "react-router-dom": "^6.4.2", 54 | "reactflow": "11.8", 55 | "remirror": "^2.0.31", 56 | "svgmoji": "^3.2.0", 57 | "ts-pattern": "^4.0.6", 58 | "turndown": "^7.1.2", 59 | "web-tree-sitter": "^0.20.8", 60 | "xterm": "^5.2.1", 61 | "xterm-addon-fit": "^0.7.0", 62 | "y-monaco": "^0.1.4", 63 | "y-prosemirror": "^1.2.1", 64 | "y-protocols": "^1.0.5", 65 | "yjs": "^13.6.7", 66 | "zustand": "^4.4.1" 67 | }, 68 | "devDependencies": { 69 | "@types/node": "^20.5.6", 70 | "@types/react": "^18.2.15", 71 | "@types/react-dom": "^18.2.7", 72 | "@typescript-eslint/eslint-plugin": "^6.0.0", 73 | "@typescript-eslint/parser": "^6.0.0", 74 | "@vitejs/plugin-react-swc": "^3.3.2", 75 | "eslint": "^8.45.0", 76 | "eslint-plugin-react-hooks": "^4.6.0", 77 | "eslint-plugin-react-refresh": "^0.4.3", 78 | "typescript": "^5.0.2", 79 | "vite": "^4.4.5", 80 | "vite-plugin-checker": "^0.6.2" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/logo192.png -------------------------------------------------------------------------------- /ui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/logo512.png -------------------------------------------------------------------------------- /ui/public/tree-sitter-javascript.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/tree-sitter-javascript.wasm -------------------------------------------------------------------------------- /ui/public/tree-sitter-python.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/tree-sitter-python.wasm -------------------------------------------------------------------------------- /ui/public/tree-sitter.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codepod-io/codepod/b178052f6248602834295dcf186f188b6b749290/ui/public/tree-sitter.wasm -------------------------------------------------------------------------------- /ui/set-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # from https://www.bencode.net/posts/react-build/ 4 | 5 | # Substitute container environment into production packaged react app 6 | # CRA does have some support for managing .env files, but not as an `npm build` output 7 | 8 | # To test: 9 | # docker run --rm -e API_URI=http://localhost:5000/api -e CONFLUENCE_URI=https://confluence.evilcorp.org -e INTRANET_URI=https://intranet.evilcorp.org -it -p 3000:80/tcp dam-frontend:latest 10 | 11 | cp -f /usr/share/nginx/html/index.html /tmp 12 | 13 | if [ -n "$GOOGLE_CLIENT_ID" ]; then 14 | sed -i -e "s|REPLACE_GOOGLE_CLIENT_ID|$GOOGLE_CLIENT_ID|g" /tmp/index.html 15 | fi 16 | 17 | cat /tmp/index.html > /usr/share/nginx/html/index.html -------------------------------------------------------------------------------- /ui/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | /* max-width: 1280px; */ 3 | margin: 0 auto; 4 | /* padding: 2rem; */ 5 | /* text-align: center; */ 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import "./custom.css"; 3 | 4 | import { 5 | createBrowserRouter, 6 | createRoutesFromElements, 7 | Route, 8 | RouterProvider, 9 | } from "react-router-dom"; 10 | 11 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 12 | 13 | import { Repo } from "./pages/repo"; 14 | 15 | import { AuthProvider } from "./lib/auth"; 16 | 17 | import Link from "@mui/material/Link"; 18 | import { Link as ReactLink } from "react-router-dom"; 19 | 20 | import Box from "@mui/material/Box"; 21 | import { SnackbarProvider } from "notistack"; 22 | import { Typography } from "@mui/material"; 23 | 24 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 25 | import { httpBatchLink } from "@trpc/client"; 26 | import React, { useState } from "react"; 27 | 28 | import { trpc } from "./lib/trpc"; 29 | 30 | let remoteUrl; 31 | 32 | if (import.meta.env.DEV) { 33 | remoteUrl = `localhost:4000`; 34 | } else { 35 | remoteUrl = `${window.location.hostname}:${window.location.port}`; 36 | } 37 | let trpcUrl = `http://${remoteUrl}/trpc`; 38 | // the url should be ws://:/socket 39 | let yjsWsUrl = `ws://${remoteUrl}/socket`; 40 | 41 | export function TrpcProvider({ children }) { 42 | const [queryClient] = useState(() => new QueryClient()); 43 | const [trpcClient] = useState(() => 44 | trpc.createClient({ 45 | links: [ 46 | httpBatchLink({ 47 | url: trpcUrl, 48 | }), 49 | ], 50 | }) 51 | ); 52 | return ( 53 | 54 | {children} 55 | 56 | ); 57 | } 58 | 59 | const apiUrl = null; 60 | const spawnerApiUrl = null; 61 | 62 | const theme = createTheme({ 63 | typography: { 64 | button: { 65 | textTransform: "none", 66 | }, 67 | }, 68 | }); 69 | 70 | const router = createBrowserRouter([ 71 | { 72 | path: "/", 73 | element: ( 74 | 75 | 76 | 77 | ), 78 | }, 79 | ]); 80 | 81 | export default function App() { 82 | return ( 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /ui/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/CanvasContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from "zustand"; 2 | import { RepoContext } from "../lib/store"; 3 | import Box from "@mui/material/Box"; 4 | import ListItemIcon from "@mui/material/ListItemIcon"; 5 | import ListItemText from "@mui/material/ListItemText"; 6 | import MenuList from "@mui/material/MenuList"; 7 | import MenuItem from "@mui/material/MenuItem"; 8 | import React, { useContext } from "react"; 9 | import CodeIcon from "@mui/icons-material/Code"; 10 | import PostAddIcon from "@mui/icons-material/PostAdd"; 11 | import NoteIcon from "@mui/icons-material/Note"; 12 | import FileUploadTwoToneIcon from "@mui/icons-material/FileUploadTwoTone"; 13 | 14 | const paneMenuStyle = (left, top) => { 15 | return { 16 | left: `${left}px`, 17 | top: `${top}px`, 18 | zIndex: 100, 19 | position: "absolute", 20 | boxShadow: "0px 1px 8px 0px rgba(0, 0, 0, 0.1)", 21 | // width: '200px', 22 | backgroundColor: "#fff", 23 | borderRadius: "5px", 24 | boxSizing: "border-box", 25 | } as React.CSSProperties; 26 | }; 27 | 28 | const ItemStyle = { 29 | "&:hover": { 30 | background: "#f1f3f7", 31 | color: "#4b00ff", 32 | }, 33 | }; 34 | 35 | export function CanvasContextMenu(props) { 36 | const store = useContext(RepoContext)!; 37 | 38 | const editMode = useStore(store, (state) => state.editMode); 39 | const editing = editMode === "edit"; 40 | return ( 41 | 42 | 43 | {editing && ( 44 | 45 | 46 | 47 | 48 | New Code 49 | 50 | )} 51 | {editing && ( 52 | 53 | 54 | 55 | 56 | New Note 57 | 58 | )} 59 | {editing && ( 60 | 61 | 62 | 63 | 64 | New Scope 65 | 66 | )} 67 | {editing && ( 68 | 69 | 70 | 71 | 72 | Import Code 73 | 74 | )} 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /ui/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Link as ReactLink, useLocation } from "react-router-dom"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | import Box from "@mui/material/Box"; 8 | import MenuIcon from "@mui/icons-material/Menu"; 9 | import Menu from "@mui/material/Menu"; 10 | import MenuItem from "@mui/material/MenuItem"; 11 | import Avatar from "@mui/material/Avatar"; 12 | import Breadcrumbs from "@mui/material/Breadcrumbs"; 13 | import Link from "@mui/material/Link"; 14 | import Button from "@mui/material/Button"; 15 | 16 | import Toolbar from "@mui/material/Toolbar"; 17 | import IconButton from "@mui/material/IconButton"; 18 | import Typography from "@mui/material/Typography"; 19 | import Container from "@mui/material/Container"; 20 | import Tooltip from "@mui/material/Tooltip"; 21 | import OpenInNewIcon from "@mui/icons-material/OpenInNew"; 22 | import AppBar from "@mui/material/AppBar"; 23 | 24 | export const Header = ({ children }: { children?: any }) => { 25 | return ( 26 | 35 | 36 | 43 | {children} 44 | 45 | {/* The navigation on desktop */} 46 | 52 | 64 | {/* Docs */} 65 | Docs 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | export function Footer() { 75 | return ( 76 | 88 | 89 | 90 | CodePod 91 | 92 | 93 | 94 | 95 | 96 | Copyright © CodePod Inc 97 | 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /ui/src/components/HelperLines.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, useEffect, useRef } from "react"; 2 | import { ReactFlowState, useStore } from "reactflow"; 3 | 4 | const canvasStyle: CSSProperties = { 5 | width: "100%", 6 | height: "100%", 7 | position: "absolute", 8 | zIndex: 10, 9 | pointerEvents: "none", 10 | }; 11 | 12 | const storeSelector = (state: ReactFlowState) => ({ 13 | width: state.width, 14 | height: state.height, 15 | transform: state.transform, 16 | }); 17 | 18 | export type HelperLinesProps = { 19 | horizontal?: number; 20 | vertical?: number; 21 | }; 22 | 23 | // a simple component to display the helper lines 24 | // it puts a canvas on top of the React Flow pane and draws the lines using the canvas API 25 | function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) { 26 | const { width, height, transform } = useStore(storeSelector); 27 | 28 | const canvasRef = useRef(null); 29 | 30 | useEffect(() => { 31 | const canvas = canvasRef.current; 32 | const ctx = canvas?.getContext("2d"); 33 | 34 | if (!ctx || !canvas) { 35 | return; 36 | } 37 | 38 | const dpi = window.devicePixelRatio; 39 | canvas.width = width * dpi; 40 | canvas.height = height * dpi; 41 | 42 | ctx.scale(dpi, dpi); 43 | ctx.clearRect(0, 0, width, height); 44 | ctx.strokeStyle = "#0041d0"; 45 | 46 | if (typeof vertical === "number") { 47 | ctx.moveTo(vertical * transform[2] + transform[0], 0); 48 | ctx.lineTo(vertical * transform[2] + transform[0], height); 49 | ctx.stroke(); 50 | } 51 | 52 | if (typeof horizontal === "number") { 53 | ctx.moveTo(0, horizontal * transform[2] + transform[1]); 54 | ctx.lineTo(width, horizontal * transform[2] + transform[1]); 55 | ctx.stroke(); 56 | } 57 | }, [width, height, transform, horizontal, vertical]); 58 | 59 | return ( 60 | 65 | ); 66 | } 67 | 68 | export default HelperLinesRenderer; 69 | -------------------------------------------------------------------------------- /ui/src/components/MyKBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | KBarProvider, 3 | KBarPortal, 4 | KBarPositioner, 5 | KBarAnimator, 6 | KBarSearch, 7 | KBarResults, 8 | useMatches, 9 | NO_GROUP, 10 | } from "kbar"; 11 | 12 | import { useStore } from "zustand"; 13 | import { RepoContext } from "../lib/store"; 14 | import { useContext } from "react"; 15 | 16 | function RenderResults() { 17 | const { results } = useMatches(); 18 | 19 | return ( 20 | 23 | typeof item === "string" ? ( 24 |
{item}
25 | ) : ( 26 |
31 | {item.name} 32 |
33 | ) 34 | } 35 | /> 36 | ); 37 | } 38 | 39 | export function MyKBar() { 40 | const store = useContext(RepoContext)!; 41 | const autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); 42 | const actions = [ 43 | { 44 | id: "auto-force", 45 | name: "Auto Force", 46 | keywords: "auto force", 47 | perform: () => { 48 | autoLayoutROOT(); 49 | }, 50 | }, 51 | // { 52 | // id: "blog", 53 | // name: "Blog", 54 | // shortcut: ["b"], 55 | // keywords: "writing words", 56 | // perform: () => (window.location.pathname = "blog"), 57 | // }, 58 | // { 59 | // id: "contact", 60 | // name: "Contact", 61 | // shortcut: ["c"], 62 | // keywords: "email", 63 | // perform: () => (window.location.pathname = "contact"), 64 | // }, 65 | ]; 66 | return ( 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /ui/src/components/MyXTerm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { FitAddon } from "xterm-addon-fit"; 3 | import { Terminal } from "xterm"; 4 | import "xterm/css/xterm.css"; 5 | 6 | export function DummyTerm() { 7 | let term = new Terminal(); 8 | function prompt() { 9 | var shellprompt = "$ "; 10 | term.write("\r\n" + shellprompt); 11 | } 12 | term.write("Hello from \x1B[1;3;31mxterm.js\x1B[0m \r\n"); 13 | term.write("This is a dummy terminal.\r\n$ "); 14 | term.onKey((key) => { 15 | const char = key.domEvent.key; 16 | if (char === "Enter") { 17 | prompt(); 18 | } else if (char === "Backspace") { 19 | term.write("\b \b"); 20 | } else { 21 | term.write(char); 22 | // fitAddon.fit(); 23 | } 24 | }); 25 | return term; 26 | } 27 | 28 | export function XTerm({ term = DummyTerm() }) { 29 | // initXTerm(term); 30 | const theterm = useRef(null); 31 | const fitAddon = new FitAddon(); 32 | term.loadAddon(fitAddon); 33 | useEffect(() => { 34 | if (theterm.current) { 35 | term.open(theterm.current); 36 | // term.write("Hello from \x1B[1;3;31mxterm.js\x1B[0m \r\n$ "); 37 | term.focus(); 38 | fitAddon.fit(); 39 | } 40 | return () => { 41 | term.dispose(); 42 | }; 43 | }, []); 44 | 45 | // Add logic around `term` 46 | // FIXME still a small margin on bottom, not sure where it came from 47 | return
; 48 | } 49 | 50 | // DEPRECATED 51 | function MyXTerm({ onData = (data) => {} }) { 52 | const theterm = useRef(null); 53 | const term = new Terminal(); 54 | // term.setOption("theme", { background: "#fdf6e3" }); 55 | const fitAddon = new FitAddon(); 56 | term.loadAddon(fitAddon); 57 | function prompt() { 58 | var shellprompt = "$ "; 59 | term.write("\r\n" + shellprompt); 60 | } 61 | term.onKey((key) => { 62 | const char = key.domEvent.key; 63 | if (char === "Enter") { 64 | prompt(); 65 | } else if (char === "Backspace") { 66 | term.write("\b \b"); 67 | } else { 68 | term.write(char); 69 | // fitAddon.fit(); 70 | } 71 | }); 72 | 73 | term.onData(onData); 74 | 75 | // useRef? 76 | useEffect(() => { 77 | if (theterm.current) { 78 | // console.log(term); 79 | // console.log(theterm.current); 80 | // console.log("..."); 81 | term.open(theterm.current); 82 | term.write("Hello from \x1B[1;3;31mxterm.js\x1B[0m \r\n$ "); 83 | term.focus(); 84 | fitAddon.fit(); 85 | // term.onData((data) => { 86 | // console.log("On data", data); 87 | // term.write(data); 88 | // }); 89 | // term.onKey(() => { 90 | // console.log("key"); 91 | // }); 92 | } 93 | }, []); 94 | 95 | // Add logic around `term` 96 | // FIXME still a small margin on bottom, not sure where it came from 97 | return
; 98 | } 99 | -------------------------------------------------------------------------------- /ui/src/components/nodes/CustomConnectionLine.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectionLineComponentProps, getStraightPath } from "reactflow"; 2 | 3 | function CustomConnectionLine({ 4 | fromX, 5 | fromY, 6 | toX, 7 | toY, 8 | connectionLineStyle, 9 | }: ConnectionLineComponentProps) { 10 | const [edgePath] = getStraightPath({ 11 | sourceX: fromX, 12 | sourceY: fromY, 13 | targetX: toX, 14 | targetY: toY, 15 | }); 16 | 17 | return ( 18 | 19 | 20 | 28 | 29 | ); 30 | } 31 | 32 | export default CustomConnectionLine; 33 | -------------------------------------------------------------------------------- /ui/src/components/nodes/FloatingEdge.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useStore, EdgeProps, getBezierPath } from "reactflow"; 3 | 4 | import { getEdgeParams } from "./utils"; 5 | 6 | function FloatingEdge({ 7 | id, 8 | source, 9 | target, 10 | markerEnd, 11 | style, 12 | selected, 13 | }: EdgeProps) { 14 | const sourceNode = useStore( 15 | useCallback((store) => store.nodeInternals.get(source), [source]) 16 | ); 17 | const targetNode = useStore( 18 | useCallback((store) => store.nodeInternals.get(target), [target]) 19 | ); 20 | 21 | if (!sourceNode || !targetNode) { 22 | return null; 23 | } 24 | 25 | const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( 26 | sourceNode, 27 | targetNode 28 | ); 29 | 30 | const [edgePath] = getBezierPath({ 31 | sourceX: sx, 32 | sourceY: sy, 33 | sourcePosition: sourcePos, 34 | targetPosition: targetPos, 35 | targetX: tx, 36 | targetY: ty, 37 | }); 38 | 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | export default FloatingEdge; 52 | -------------------------------------------------------------------------------- /ui/src/components/nodes/Scope.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useState, 4 | useRef, 5 | useContext, 6 | useEffect, 7 | memo, 8 | } from "react"; 9 | import * as React from "react"; 10 | import ReactFlow, { 11 | addEdge, 12 | applyEdgeChanges, 13 | applyNodeChanges, 14 | Background, 15 | MiniMap, 16 | Controls, 17 | Handle, 18 | useReactFlow, 19 | Position, 20 | ConnectionMode, 21 | MarkerType, 22 | Node, 23 | NodeProps, 24 | useStore as useReactFlowStore, 25 | } from "reactflow"; 26 | 27 | import Box from "@mui/material/Box"; 28 | import InputBase from "@mui/material/InputBase"; 29 | import CircularProgress from "@mui/material/CircularProgress"; 30 | import Tooltip from "@mui/material/Tooltip"; 31 | import IconButton from "@mui/material/IconButton"; 32 | import Grid from "@mui/material/Grid"; 33 | import ContentCutIcon from "@mui/icons-material/ContentCut"; 34 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 35 | import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; 36 | import ViewTimelineOutlinedIcon from "@mui/icons-material/ViewTimelineOutlined"; 37 | import CompressIcon from "@mui/icons-material/Compress"; 38 | import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; 39 | 40 | import { useStore } from "zustand"; 41 | import { shallow } from "zustand/shallow"; 42 | 43 | import { RepoContext } from "../../lib/store"; 44 | 45 | import { NodeResizer, NodeResizeControl } from "reactflow"; 46 | import { 47 | ConfirmDeleteButton, 48 | Handles, 49 | ResizeIcon, 50 | level2fontsize, 51 | } from "./utils"; 52 | import { CopyToClipboard } from "react-copy-to-clipboard"; 53 | import { useApolloClient } from "@apollo/client"; 54 | import { trpc } from "../../lib/trpc"; 55 | 56 | function MyFloatingToolbar({ id }: { id: string }) { 57 | const store = useContext(RepoContext)!; 58 | const reactFlowInstance = useReactFlow(); 59 | const editMode = useStore(store, (state) => state.editMode); 60 | const preprocessChain = useStore(store, (state) => state.preprocessChain); 61 | const getScopeChain = useStore(store, (state) => state.getScopeChain); 62 | 63 | const runChain = trpc.spawner.runChain.useMutation(); 64 | const activeRuntime = useStore(store, (state) => state.activeRuntime); 65 | 66 | const autoLayout = useStore(store, (state) => state.autoLayout); 67 | 68 | const zoomLevel = useReactFlowStore((s) => s.transform[2]); 69 | const iconFontSize = zoomLevel < 1 ? `${1.5 * (1 / zoomLevel)}rem` : `1.5rem`; 70 | 71 | return ( 72 | 78 | 87 | 88 | 89 | {editMode === "edit" && ( 90 | 91 | { 93 | if (activeRuntime) { 94 | const chain = getScopeChain(id); 95 | const specs = preprocessChain(chain); 96 | if (specs) runChain.mutate({ runtimeId: activeRuntime, specs }); 97 | } 98 | }} 99 | > 100 | 101 | 102 | 103 | )} 104 | {/* auto force layout */} 105 | {editMode === "edit" && ( 106 | 107 | { 109 | autoLayout(id); 110 | }} 111 | > 112 | 113 | 114 | 115 | )} 116 | {editMode === "edit" && ( 117 | 122 | { 124 | // This does not work, will throw "Parent node 125 | // jqgdsz2ns6k57vich0bf not found" when deleting a scope. 126 | // 127 | // nodesMap.delete(id); 128 | // 129 | // But this works: 130 | reactFlowInstance.deleteElements({ nodes: [{ id }] }); 131 | }} 132 | /> 133 | 134 | )} 135 | 144 | 145 | 146 | 147 | ); 148 | } 149 | 150 | export const ScopeNode = memo(function ScopeNode({ 151 | data, 152 | id, 153 | isConnectable, 154 | selected, 155 | xPos, 156 | yPos, 157 | }) { 158 | // add resize to the node 159 | const ref = useRef(null); 160 | const store = useContext(RepoContext)!; 161 | const setPodName = useStore(store, (state) => state.setPodName); 162 | const nodesMap = useStore(store, (state) => state.getNodesMap()); 163 | const editMode = useStore(store, (state) => state.editMode); 164 | const inputRef = useRef(null); 165 | 166 | const devMode = useStore(store, (state) => state.devMode); 167 | 168 | useEffect(() => { 169 | if (!data.name) return; 170 | setPodName({ id, name: data.name || "" }); 171 | if (inputRef?.current) { 172 | inputRef.current.value = data.name; 173 | } 174 | }, [data.name, id, setPodName]); 175 | 176 | const [showToolbar, setShowToolbar] = useState(false); 177 | 178 | const { width, height, parent } = useReactFlowStore((s) => { 179 | const node = s.nodeInternals.get(id)!; 180 | 181 | return { 182 | width: node.width, 183 | height: node.height, 184 | parent: node.parentNode, 185 | }; 186 | }, shallow); 187 | 188 | const contextualZoom = useStore(store, (state) => state.contextualZoom); 189 | const contextualZoomParams = useStore( 190 | store, 191 | (state) => state.contextualZoomParams 192 | ); 193 | const threshold = useStore( 194 | store, 195 | (state) => state.contextualZoomParams.threshold 196 | ); 197 | const zoomLevel = useReactFlowStore((s) => s.transform[2]); 198 | const node = nodesMap.get(id); 199 | if (!node) return null; 200 | 201 | const fontSize = level2fontsize( 202 | node?.data.level, 203 | contextualZoomParams, 204 | contextualZoom 205 | ); 206 | 207 | if (contextualZoom && fontSize * zoomLevel < threshold) { 208 | // Return a collapsed blcok. 209 | let text = node?.data.name ? `${node?.data.name}` : "A Scope"; 210 | return ( 211 | 224 | 232 | {text} 233 | 234 | 235 | ); 236 | } 237 | 238 | return ( 239 | { 251 | setShowToolbar(true); 252 | }} 253 | onMouseLeave={() => { 254 | setShowToolbar(false); 255 | }} 256 | className="custom-drag-handle" 257 | > 258 | {/* */} 259 | 260 | 268 | 269 | 270 | 271 | 272 | 286 | 287 | 288 | 293 | 300 | 301 | {/* The header of scope nodes. */} 302 | 306 | {devMode && ( 307 | 315 | {id} at ({xPos}, {yPos}), w: {width}, h: {height} parent: {parent}{" "} 316 | level: {data.level} fontSize: {fontSize} 317 | 318 | )} 319 | 320 | 321 | {/* 322 | 323 | */} 324 | 325 | 326 | 334 | { 338 | const name = e.target.value; 339 | if (name === data.name) return; 340 | const node = nodesMap.get(id); 341 | if (node) { 342 | nodesMap.set(id, { 343 | ...node, 344 | data: { ...node.data, name }, 345 | }); 346 | } 347 | // setPodName({ id, name }); 348 | }} 349 | inputRef={inputRef} 350 | disabled={editMode === "view"} 351 | inputProps={{ 352 | style: { 353 | padding: "0px", 354 | textAlign: "center", 355 | textOverflow: "ellipsis", 356 | fontSize, 357 | width: width ? width : undefined, 358 | }, 359 | }} 360 | > 361 | 362 | 363 | 364 | 365 | 366 | 367 | ); 368 | }); 369 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/YjsRemirror.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/remirror/remirror/blob/main/packages/remirror__extension-yjs/src/yjs-extension.ts 2 | // Added node ID to bind a different room for each rich-text instance. 3 | 4 | import { 5 | defaultCursorBuilder, 6 | defaultDeleteFilter, 7 | defaultSelectionBuilder, 8 | redo, 9 | undo, 10 | yCursorPlugin, 11 | ySyncPlugin, 12 | ySyncPluginKey, 13 | yUndoPlugin, 14 | yUndoPluginKey, 15 | } from "y-prosemirror"; 16 | import type { Doc, XmlFragment } from "yjs"; 17 | import { Awareness } from "y-protocols/awareness"; 18 | import { UndoManager } from "yjs"; 19 | import { 20 | AcceptUndefined, 21 | command, 22 | convertCommand, 23 | EditorState, 24 | ErrorConstant, 25 | extension, 26 | ExtensionPriority, 27 | invariant, 28 | isEmptyObject, 29 | isFunction, 30 | keyBinding, 31 | KeyBindingProps, 32 | NamedShortcut, 33 | nonChainable, 34 | NonChainableCommandFunction, 35 | OnSetOptionsProps, 36 | PlainExtension, 37 | ProsemirrorPlugin, 38 | Selection, 39 | Shape, 40 | Static, 41 | } from "@remirror/core"; 42 | import { ExtensionHistoryMessages as Messages } from "@remirror/messages"; 43 | import { DecorationAttrs } from "@remirror/pm/view"; 44 | 45 | export interface ColorDef { 46 | light: string; 47 | dark: string; 48 | } 49 | 50 | export interface YSyncOpts { 51 | colors?: ColorDef[]; 52 | colorMapping?: Map; 53 | permanentUserData?: any | null; 54 | } 55 | 56 | export interface YjsOptions { 57 | yXml?: AcceptUndefined; 58 | awareness?: AcceptUndefined; 59 | 60 | /** 61 | * The options which are passed through to the Yjs sync plugin. 62 | */ 63 | syncPluginOptions?: AcceptUndefined; 64 | 65 | /** 66 | * Take the user data and transform it into a html element which is used for 67 | * the cursor. This is passed into the cursor builder. 68 | * 69 | * See https://github.com/yjs/y-prosemirror#remote-cursors 70 | */ 71 | cursorBuilder?: (user: Shape) => HTMLElement; 72 | 73 | /** 74 | * Generator for the selection attributes 75 | */ 76 | selectionBuilder?: (user: Shape) => DecorationAttrs; 77 | 78 | /** 79 | * By default all editor bindings use the awareness 'cursor' field to 80 | * propagate cursor information. 81 | * 82 | * @defaultValue 'cursor' 83 | */ 84 | cursorStateField?: string; 85 | 86 | /** 87 | * Get the current editor selection. 88 | * 89 | * @defaultValue `(state) => state.selection` 90 | */ 91 | getSelection?: (state: EditorState) => Selection; 92 | 93 | disableUndo?: Static; 94 | 95 | /** 96 | * Names of nodes in the editor which should be protected. 97 | * 98 | * @defaultValue `new Set('paragraph')` 99 | */ 100 | protectedNodes?: Static>; 101 | trackedOrigins?: Static; 102 | } 103 | 104 | /** 105 | * The YJS extension is the recommended extension for creating a collaborative 106 | * editor. 107 | */ 108 | @extension({ 109 | defaultOptions: { 110 | yXml: undefined, 111 | awareness: undefined, 112 | syncPluginOptions: undefined, 113 | cursorBuilder: defaultCursorBuilder, 114 | selectionBuilder: defaultSelectionBuilder, 115 | cursorStateField: "cursor", 116 | getSelection: (state) => state.selection, 117 | disableUndo: false, 118 | protectedNodes: new Set("paragraph"), 119 | trackedOrigins: [], 120 | }, 121 | staticKeys: ["disableUndo", "protectedNodes", "trackedOrigins"], 122 | defaultPriority: ExtensionPriority.High, 123 | }) 124 | export class MyYjsExtension extends PlainExtension { 125 | get name() { 126 | return "yjs" as const; 127 | } 128 | 129 | getBinding(): { mapping: Map } | undefined { 130 | const state = this.store.getState(); 131 | const { binding } = ySyncPluginKey.getState(state); 132 | return binding; 133 | } 134 | 135 | /** 136 | * Create the yjs plugins. 137 | */ 138 | createExternalPlugins(): ProsemirrorPlugin[] { 139 | const { 140 | syncPluginOptions, 141 | cursorBuilder, 142 | getSelection, 143 | cursorStateField, 144 | disableUndo, 145 | protectedNodes, 146 | trackedOrigins, 147 | selectionBuilder, 148 | } = this.options; 149 | 150 | const type = this.options.yXml; 151 | 152 | const plugins = [ 153 | ySyncPlugin(type, syncPluginOptions), 154 | yCursorPlugin( 155 | this.options.awareness, 156 | { cursorBuilder, getSelection, selectionBuilder }, 157 | cursorStateField 158 | ), 159 | ]; 160 | 161 | if (!disableUndo) { 162 | const undoManager = new UndoManager(type, { 163 | trackedOrigins: new Set([ySyncPluginKey, ...trackedOrigins]), 164 | deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes), 165 | }); 166 | plugins.push(yUndoPlugin({ undoManager })); 167 | } 168 | 169 | return plugins; 170 | } 171 | 172 | /** 173 | * Undo that last Yjs transaction(s) 174 | * 175 | * This command does **not** support chaining. 176 | * This command is a no-op and always returns `false` when the `disableUndo` option is set. 177 | */ 178 | @command({ 179 | disableChaining: true, 180 | description: ({ t }) => t(Messages.UNDO_DESCRIPTION), 181 | label: ({ t }) => t(Messages.UNDO_LABEL), 182 | icon: "arrowGoBackFill", 183 | }) 184 | yUndo(): NonChainableCommandFunction { 185 | return nonChainable((props) => { 186 | if (this.options.disableUndo) { 187 | return false; 188 | } 189 | 190 | const { state, dispatch } = props; 191 | const undoManager: UndoManager = 192 | yUndoPluginKey.getState(state).undoManager; 193 | 194 | if (undoManager.undoStack.length === 0) { 195 | return false; 196 | } 197 | 198 | if (!dispatch) { 199 | return true; 200 | } 201 | 202 | return convertCommand(undo)(props); 203 | }); 204 | } 205 | 206 | /** 207 | * Redo the last transaction undone with a previous `yUndo` command. 208 | * 209 | * This command does **not** support chaining. 210 | * This command is a no-op and always returns `false` when the `disableUndo` option is set. 211 | */ 212 | @command({ 213 | disableChaining: true, 214 | description: ({ t }) => t(Messages.REDO_DESCRIPTION), 215 | label: ({ t }) => t(Messages.REDO_LABEL), 216 | icon: "arrowGoForwardFill", 217 | }) 218 | yRedo(): NonChainableCommandFunction { 219 | return nonChainable((props) => { 220 | if (this.options.disableUndo) { 221 | return false; 222 | } 223 | 224 | const { state, dispatch } = props; 225 | const undoManager: UndoManager = 226 | yUndoPluginKey.getState(state).undoManager; 227 | 228 | if (undoManager.redoStack.length === 0) { 229 | return false; 230 | } 231 | 232 | if (!dispatch) { 233 | return true; 234 | } 235 | 236 | return convertCommand(redo)(props); 237 | }); 238 | } 239 | 240 | /** 241 | * Handle the undo keybinding. 242 | */ 243 | @keyBinding({ shortcut: NamedShortcut.Undo, command: "yUndo" }) 244 | undoShortcut(props: KeyBindingProps): boolean { 245 | return this.yUndo()(props); 246 | } 247 | 248 | /** 249 | * Handle the redo keybinding for the editor. 250 | */ 251 | @keyBinding({ shortcut: NamedShortcut.Redo, command: "yRedo" }) 252 | redoShortcut(props: KeyBindingProps): boolean { 253 | return this.yRedo()(props); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/blockHandle.ts: -------------------------------------------------------------------------------- 1 | // Code developed based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 2 | 3 | import { NodeSelection } from "@remirror/pm/state"; 4 | // @ts-ignore 5 | import { __serializeForClipboard as serializeForClipboard } from "prosemirror-view"; 6 | 7 | import { PlainExtension, extension } from "remirror"; 8 | 9 | function removeNode(node) { 10 | node.parentNode.removeChild(node); 11 | } 12 | 13 | function absoluteRect(node) { 14 | const data = node.getBoundingClientRect(); 15 | 16 | return { 17 | top: data.top, 18 | left: data.left, 19 | width: data.width, 20 | }; 21 | } 22 | 23 | function blockPosAtCoords(coords, view) { 24 | const pos = view.posAtCoords(coords); 25 | if (!pos) return null; 26 | let node = view.domAtPos(pos.pos); 27 | 28 | node = node.node; 29 | 30 | if (!node) return; 31 | // Text node is not draggable. Must be a element node. 32 | if (node.nodeType === node.TEXT_NODE) { 33 | node = node.parentNode; 34 | } 35 | // Support bullet list and ordered list. 36 | if (["LI", "UL"].includes(node.parentNode.tagName)) { 37 | node = node.parentNode; 38 | } 39 | // Support task list. 40 | // li[data-task-list-item] > div > p 41 | if ( 42 | node.parentNode.parentNode 43 | .getAttributeNames() 44 | .includes("data-task-list-item") 45 | ) { 46 | node = node.parentNode.parentNode; 47 | } 48 | 49 | if (node && node.nodeType === 1) { 50 | const desc = view.docView.nearestDesc(node, true); 51 | 52 | if (!(!desc || desc === view.docView)) { 53 | return desc.posBefore; 54 | } 55 | } 56 | return null; 57 | } 58 | 59 | function dragStart(e, view) { 60 | if (!e.dataTransfer) { 61 | return; 62 | } 63 | 64 | const coords = { left: e.clientX + 50, top: e.clientY }; 65 | const pos = blockPosAtCoords(coords, view); 66 | 67 | if (pos != null) { 68 | view.dispatch( 69 | view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)) 70 | ); 71 | 72 | const slice = view.state.selection.content(); 73 | const { dom, text } = serializeForClipboard(view, slice); 74 | 75 | e.dataTransfer.clearData(); 76 | e.dataTransfer.setData("text/html", dom.innerHTML); 77 | e.dataTransfer.setData("text/plain", text); 78 | 79 | const el = document.querySelector(".ProseMirror-selectednode"); 80 | 81 | e.dataTransfer?.setDragImage(el, 0, 0); 82 | view.dragging = { slice, move: true }; 83 | } 84 | } 85 | 86 | @extension({}) 87 | export class BlockHandleExtension extends PlainExtension { 88 | get name(): string { 89 | return "block-handle"; 90 | } 91 | 92 | createPlugin() { 93 | let dropElement; 94 | const WIDTH = 28; 95 | return { 96 | view(editorView) { 97 | const element = document.createElement("div"); 98 | 99 | element.draggable = true; 100 | element.classList.add("global-drag-handle"); 101 | element.addEventListener("dragstart", (e) => dragStart(e, editorView)); 102 | dropElement = element; 103 | document.body.appendChild(dropElement); 104 | 105 | return { 106 | destroy() { 107 | removeNode(dropElement); 108 | dropElement = null; 109 | }, 110 | }; 111 | }, 112 | props: { 113 | handleDOMEvents: { 114 | mousemove(view, event) { 115 | const coords = { 116 | left: event.clientX + WIDTH + 50, 117 | top: event.clientY, 118 | }; 119 | const pos = view.posAtCoords(coords); 120 | 121 | if (pos) { 122 | let node = view.domAtPos(pos?.pos); 123 | 124 | if (node) { 125 | node = node.node; 126 | // Text node is not draggable. Must be a element node. 127 | if (node.nodeType === node.TEXT_NODE) { 128 | node = node.parentNode; 129 | } 130 | // Locate the actual node instead of a . 131 | while (node.tagName === "MARK") { 132 | node = node.parentNode; 133 | } 134 | 135 | if (node instanceof Element) { 136 | const cstyle = window.getComputedStyle(node); 137 | const lineHeight = parseInt(cstyle.lineHeight, 10); 138 | // const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10) 139 | const top = 0; 140 | const rect = absoluteRect(node); 141 | const win = node.ownerDocument.defaultView; 142 | 143 | rect.top += win!.pageYOffset + (lineHeight - 24) / 2 + top; 144 | rect.left += win!.pageXOffset; 145 | rect.width = `${WIDTH}px`; 146 | 147 | // The default X offset 148 | let offset = -8; 149 | if ( 150 | node.parentNode && 151 | (node.parentNode as HTMLElement).classList.contains( 152 | "ProseMirror" 153 | ) 154 | ) { 155 | // The X offset for top-level nodes. 156 | offset = 8; 157 | } 158 | 159 | dropElement.style.left = `${-WIDTH + rect.left + offset}px`; 160 | dropElement.style.top = `${rect.top}px`; 161 | dropElement.style.display = "block"; 162 | } 163 | } 164 | } 165 | }, 166 | }, 167 | }, 168 | }; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/codepodSync.ts: -------------------------------------------------------------------------------- 1 | import TurndownService from "turndown"; 2 | 3 | import { PlainExtension, StateUpdateLifecycleProps, extension } from "remirror"; 4 | 5 | interface CodePodSyncOptions { 6 | // Options are `Dynamic` by default. 7 | id: string; 8 | setPodContent: any; 9 | setPodRichContent: any; 10 | } 11 | 12 | @extension({ 13 | defaultOptions: { 14 | id: "defaultId", 15 | setPodContent: () => {}, 16 | setPodRichContent: () => {}, 17 | }, 18 | staticKeys: [], 19 | handlerKeys: [], 20 | customHandlerKeys: [], 21 | }) 22 | /** 23 | * This extension is used to sync the content of the editor with the pod. 24 | */ 25 | export class CodePodSyncExtension extends PlainExtension { 26 | firstUpdate = true; 27 | turndownService = new TurndownService(); 28 | get name(): string { 29 | return "codepod-sync"; 30 | } 31 | onStateUpdate({ state, tr }: StateUpdateLifecycleProps) { 32 | if (tr?.docChanged) { 33 | this.options.setPodContent( 34 | { 35 | id: this.options.id, 36 | content: state.doc.toJSON(), 37 | }, 38 | // The first onChange event is triggered wehn the content is the same. 39 | // Skip it. 40 | this.firstUpdate 41 | ); 42 | this.firstUpdate = false; 43 | 44 | var markdown = this.turndownService.turndown( 45 | this.store.helpers.getHTML(state) 46 | ); 47 | this.options.setPodRichContent({ 48 | id: this.options.id, 49 | richContent: markdown, 50 | }); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/link.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useState, 4 | useRef, 5 | useContext, 6 | useEffect, 7 | memo, 8 | } from "react"; 9 | import * as React from "react"; 10 | 11 | import { useStore } from "zustand"; 12 | 13 | import { 14 | LinkExtension as RemirrorLinkExtension, 15 | ShortcutHandlerProps, 16 | createMarkPositioner, 17 | } from "remirror/extensions"; 18 | 19 | import { 20 | useCommands, 21 | CommandButton, 22 | CommandButtonProps, 23 | useChainedCommands, 24 | useCurrentSelection, 25 | useAttrs, 26 | useUpdateReason, 27 | FloatingWrapper, 28 | } from "@remirror/react"; 29 | import { FloatingToolbar, useExtensionEvent } from "@remirror/react"; 30 | 31 | import { InputRule } from "@remirror/pm"; 32 | import { markInputRule } from "@remirror/core-utils"; 33 | 34 | import { RepoContext } from "../../../lib/store"; 35 | 36 | export class LinkExtension extends RemirrorLinkExtension { 37 | createInputRules(): InputRule[] { 38 | return [ 39 | markInputRule({ 40 | regexp: /\[([^\]]+)\]\(([^)]+)\)/, 41 | type: this.type, 42 | getAttributes: (matches: string[]) => { 43 | const [_, text, href] = matches; 44 | return { text: text, href: href }; 45 | }, 46 | }), 47 | ]; 48 | } 49 | } 50 | 51 | function useLinkShortcut() { 52 | const [linkShortcut, setLinkShortcut] = useState< 53 | ShortcutHandlerProps | undefined 54 | >(); 55 | const [isEditing, setIsEditing] = useState(false); 56 | 57 | useExtensionEvent( 58 | LinkExtension, 59 | "onShortcut", 60 | useCallback( 61 | (props) => { 62 | if (!isEditing) { 63 | setIsEditing(true); 64 | } 65 | 66 | return setLinkShortcut(props); 67 | }, 68 | [isEditing] 69 | ) 70 | ); 71 | 72 | return { linkShortcut, isEditing, setIsEditing }; 73 | } 74 | 75 | function useFloatingLinkState() { 76 | const chain = useChainedCommands(); 77 | const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut(); 78 | const { to, empty } = useCurrentSelection(); 79 | 80 | const url = (useAttrs().link()?.href as string) ?? ""; 81 | const [href, setHref] = useState(url); 82 | 83 | // A positioner which only shows for links. 84 | const linkPositioner = React.useMemo( 85 | () => createMarkPositioner({ type: "link" }), 86 | [] 87 | ); 88 | 89 | const onRemove = useCallback(() => { 90 | return chain.removeLink().focus().run(); 91 | }, [chain]); 92 | 93 | const updateReason = useUpdateReason(); 94 | 95 | React.useLayoutEffect(() => { 96 | if (!isEditing) { 97 | return; 98 | } 99 | 100 | if (updateReason.doc || updateReason.selection) { 101 | setIsEditing(false); 102 | } 103 | }, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]); 104 | 105 | useEffect(() => { 106 | setHref(url); 107 | }, [url]); 108 | 109 | const submitHref = useCallback(() => { 110 | setIsEditing(false); 111 | const range = linkShortcut ?? undefined; 112 | 113 | if (href === "") { 114 | chain.removeLink(); 115 | } else { 116 | chain.updateLink({ href, auto: false }, range); 117 | } 118 | 119 | chain.focus(range?.to ?? to).run(); 120 | }, [setIsEditing, linkShortcut, chain, href, to]); 121 | 122 | const cancelHref = useCallback(() => { 123 | setIsEditing(false); 124 | }, [setIsEditing]); 125 | 126 | const clickEdit = useCallback(() => { 127 | if (empty) { 128 | chain.selectLink(); 129 | } 130 | 131 | setIsEditing(true); 132 | }, [chain, empty, setIsEditing]); 133 | 134 | return React.useMemo( 135 | () => ({ 136 | href, 137 | setHref, 138 | linkShortcut, 139 | linkPositioner, 140 | isEditing, 141 | clickEdit, 142 | onRemove, 143 | submitHref, 144 | cancelHref, 145 | }), 146 | [ 147 | href, 148 | linkShortcut, 149 | linkPositioner, 150 | isEditing, 151 | clickEdit, 152 | onRemove, 153 | submitHref, 154 | cancelHref, 155 | ] 156 | ); 157 | } 158 | 159 | const DelayAutoFocusInput = ({ 160 | autoFocus, 161 | ...rest 162 | }: React.HTMLProps) => { 163 | const inputRef = useRef(null); 164 | 165 | useEffect(() => { 166 | if (!autoFocus) { 167 | return; 168 | } 169 | 170 | const frame = window.requestAnimationFrame(() => { 171 | inputRef.current?.focus(); 172 | }); 173 | 174 | return () => { 175 | window.cancelAnimationFrame(frame); 176 | }; 177 | }, [autoFocus]); 178 | 179 | return ; 180 | }; 181 | 182 | function useUpdatePositionerOnMove() { 183 | // Update (all) the positioners whenever there's a move (pane) on reactflow, 184 | // so that the toolbar moves with the Rich pod and content. 185 | const { forceUpdatePositioners, emptySelection } = useCommands(); 186 | const store = useContext(RepoContext)!; 187 | const moved = useStore(store, (state) => state.moved); 188 | const paneClicked = useStore(store, (state) => state.paneClicked); 189 | useEffect(() => { 190 | forceUpdatePositioners(); 191 | }, [moved]); 192 | useEffect(() => { 193 | emptySelection(); 194 | }, [paneClicked]); 195 | return; 196 | } 197 | 198 | /** 199 | * This is a two-buttons toolbar when user click on a link. The first button 200 | * edits the link, the second button opens the link. 201 | */ 202 | export const LinkToolbar = () => { 203 | const { 204 | isEditing, 205 | linkPositioner, 206 | clickEdit, 207 | onRemove, 208 | submitHref, 209 | href, 210 | setHref, 211 | cancelHref, 212 | } = useFloatingLinkState(); 213 | useUpdatePositionerOnMove(); 214 | const { empty } = useCurrentSelection(); 215 | 216 | const handleClickEdit = useCallback(() => { 217 | clickEdit(); 218 | }, [clickEdit]); 219 | 220 | return ( 221 | <> 222 | {!isEditing && empty && ( 223 | // By default, MUI's Popper creates a Portal, which is a ROOT html 224 | // elements that prevents paning on reactflow canvas. Therefore, we 225 | // disable the portal behavior. 226 | 246 | 253 | { 257 | window.open(href, "_blank"); 258 | }} 259 | icon="externalLinkFill" 260 | enabled 261 | /> 262 | 263 | )} 264 | 265 | 271 | ) => 276 | setHref(event.target.value) 277 | } 278 | value={href} 279 | onKeyPress={(event: React.KeyboardEvent) => { 280 | const { code } = event; 281 | 282 | if (code === "Enter") { 283 | submitHref(); 284 | } 285 | 286 | if (code === "Escape") { 287 | cancelHref(); 288 | } 289 | }} 290 | /> 291 | 292 | 293 | ); 294 | }; 295 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/list.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BulletListExtension as RemirrorBulletListExtension, 3 | OrderedListExtension as RemirrorOrderedListExtension, 4 | TaskListExtension as RemirrorTaskListExtension, 5 | } from "remirror/extensions"; 6 | 7 | import { ExtensionListTheme } from "@remirror/theme"; 8 | import { NodeViewMethod, ProsemirrorNode } from "@remirror/core"; 9 | 10 | function addSpine(dom, view, getPos) { 11 | const pos = (getPos as () => number)(); 12 | const $pos = view.state.doc.resolve(pos + 1); 13 | 14 | const parentListItemNode: ProsemirrorNode | undefined = $pos.node( 15 | $pos.depth - 1 16 | ); 17 | 18 | const isNotFirstLevel = ["listItem", "taskListItem"].includes( 19 | parentListItemNode?.type?.name || "" 20 | ); 21 | 22 | if (isNotFirstLevel) { 23 | const spine = document.createElement("div"); 24 | spine.contentEditable = "false"; 25 | spine.classList.add(ExtensionListTheme.LIST_SPINE); 26 | dom.append(spine); 27 | } 28 | } 29 | 30 | /** 31 | * Add spline but not listener. 32 | */ 33 | export class BulletListExtension extends RemirrorBulletListExtension { 34 | createNodeViews(): NodeViewMethod | Record { 35 | return (_, view, getPos) => { 36 | const dom = document.createElement("div"); 37 | dom.style.position = "relative"; 38 | 39 | addSpine(dom, view, getPos); 40 | 41 | const contentDOM = document.createElement("ul"); 42 | dom.append(contentDOM); 43 | 44 | return { 45 | dom, 46 | contentDOM, 47 | }; 48 | }; 49 | } 50 | } 51 | 52 | export class OrderedListExtension extends RemirrorOrderedListExtension { 53 | createNodeViews(): NodeViewMethod | Record { 54 | return (_, view, getPos) => { 55 | const dom = document.createElement("div"); 56 | dom.style.position = "relative"; 57 | 58 | addSpine(dom, view, getPos); 59 | 60 | const contentDOM = document.createElement("ol"); 61 | dom.append(contentDOM); 62 | 63 | return { 64 | dom, 65 | contentDOM, 66 | }; 67 | }; 68 | } 69 | } 70 | 71 | export class TaskListExtension extends RemirrorTaskListExtension { 72 | createNodeViews(): NodeViewMethod | Record { 73 | return (_, view, getPos) => { 74 | const dom = document.createElement("div"); 75 | dom.style.position = "relative"; 76 | 77 | addSpine(dom, view, getPos); 78 | 79 | const contentDOM = document.createElement("ul"); 80 | dom.append(contentDOM); 81 | 82 | return { 83 | dom, 84 | contentDOM, 85 | }; 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/mathExtension.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/phxtho/poche/blob/main/src/components/remirror-editor/extensions/math-inline-extension/math-inline-extension.ts 2 | 3 | import { 4 | ApplySchemaAttributes, 5 | command, 6 | CommandFunction, 7 | extension, 8 | ExtensionTag, 9 | NodeExtension, 10 | NodeExtensionSpec, 11 | NodeSpecOverride, 12 | PrioritizedKeyBindings, 13 | } from "@remirror/core"; 14 | import { chainKeyBindingCommands, convertCommand } from "@remirror/core-utils"; 15 | import { InputRule, ProsemirrorPlugin } from "@remirror/pm"; 16 | import { 17 | deleteSelection, 18 | selectNodeBackward, 19 | joinBackward, 20 | } from "@remirror/pm/commands"; 21 | import { 22 | REGEX_INLINE_MATH_DOLLARS_ESCAPED, 23 | REGEX_BLOCK_MATH_DOLLARS, 24 | insertMathCmd, 25 | mathBackspaceCmd, 26 | mathPlugin, 27 | makeInlineMathInputRule, 28 | makeBlockMathInputRule, 29 | } from "@benrbray/prosemirror-math"; 30 | import { 31 | defaultInlineMathParseRules, 32 | defaultBlockMathParseRules, 33 | } from "./mathParseRules"; 34 | // CSS 35 | import "@benrbray/prosemirror-math/style/math.css"; 36 | import "katex/dist/katex.min.css"; 37 | 38 | export interface MathInlineOptions {} 39 | export interface MathBlockOptions {} 40 | 41 | @extension({ 42 | defaultOptions: {}, 43 | }) 44 | export class MathInlineExtension extends NodeExtension { 45 | get name() { 46 | return "math_inline" as const; 47 | } 48 | 49 | createTags() { 50 | return [ExtensionTag.InlineNode]; 51 | } 52 | 53 | createNodeSpec( 54 | extra: ApplySchemaAttributes, 55 | override: NodeSpecOverride 56 | ): NodeExtensionSpec { 57 | return { 58 | group: "math", 59 | content: "text*", 60 | inline: true, 61 | atom: true, 62 | ...override, 63 | attrs: { 64 | ...extra.defaults(), 65 | }, 66 | parseDOM: [ 67 | { 68 | tag: "math-inline", 69 | }, 70 | ...defaultInlineMathParseRules, 71 | ], 72 | toDOM: () => ["math-inline", { class: "math-node" }, 0], 73 | }; 74 | } 75 | 76 | createInputRules(): InputRule[] { 77 | return [ 78 | makeInlineMathInputRule(REGEX_INLINE_MATH_DOLLARS_ESCAPED, this.type), 79 | ]; 80 | } 81 | 82 | createExternalPlugins(): ProsemirrorPlugin[] { 83 | return [mathPlugin]; 84 | } 85 | 86 | createKeymap( 87 | extractShortcutNames: (shortcut: string) => string[] 88 | ): PrioritizedKeyBindings { 89 | const command = chainKeyBindingCommands( 90 | convertCommand(deleteSelection), 91 | convertCommand(mathBackspaceCmd), 92 | convertCommand(joinBackward), 93 | convertCommand(selectNodeBackward) 94 | ); 95 | return { Backspace: command }; 96 | } 97 | 98 | @command() 99 | insertMathInline(): CommandFunction { 100 | return (props) => { 101 | try { 102 | insertMathCmd(this.type); 103 | return true; 104 | } catch (e) { 105 | console.log(e); 106 | return false; 107 | } 108 | }; 109 | } 110 | } 111 | 112 | @extension({ 113 | defaultOptions: {}, 114 | }) 115 | export class MathBlockExtension extends NodeExtension { 116 | get name() { 117 | return "math_display" as const; 118 | } 119 | 120 | createTags() { 121 | return [ExtensionTag.BlockNode]; 122 | } 123 | 124 | createNodeSpec( 125 | extra: ApplySchemaAttributes, 126 | override: NodeSpecOverride 127 | ): NodeExtensionSpec { 128 | return { 129 | group: "block math", 130 | content: "text*", 131 | atom: true, 132 | code: true, 133 | ...override, 134 | attrs: { 135 | ...extra.defaults(), 136 | }, 137 | parseDOM: [ 138 | { 139 | tag: "math-display", 140 | }, 141 | ...defaultBlockMathParseRules, 142 | ], 143 | toDOM: () => ["math-display", { class: "math-node" }, 0], 144 | }; 145 | } 146 | 147 | createInputRules(): InputRule[] { 148 | return [makeBlockMathInputRule(REGEX_BLOCK_MATH_DOLLARS, this.type)]; 149 | } 150 | 151 | createExternalPlugins(): ProsemirrorPlugin[] { 152 | // IMPORTANT: mathPlugin should be imported only once, if it is imported by MathInlineExtension, it will fail 153 | return []; 154 | } 155 | 156 | createKeymap( 157 | extractShortcutNames: (shortcut: string) => string[] 158 | ): PrioritizedKeyBindings { 159 | const command = chainKeyBindingCommands( 160 | convertCommand(deleteSelection), 161 | convertCommand(mathBackspaceCmd), 162 | convertCommand(joinBackward), 163 | convertCommand(selectNodeBackward) 164 | ); 165 | return { Backspace: command }; 166 | } 167 | 168 | @command() 169 | insertMathBlock(): CommandFunction { 170 | return (props) => { 171 | try { 172 | insertMathCmd(this.type); 173 | return true; 174 | } catch (e) { 175 | console.log(e); 176 | return false; 177 | } 178 | }; 179 | } 180 | } 181 | 182 | declare global { 183 | namespace Remirror { 184 | interface AllExtensions { 185 | math_inline: MathInlineExtension; 186 | block_math: MathBlockExtension; 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/mathParseRules.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Author: Benjamin R. Bray 3 | * License: MIT 4 | *--------------------------------------------------------*/ 5 | 6 | // TODO: Export parse rules benrbray's source repo 7 | 8 | /** 9 | * Note that for some of the `ParseRule`s defined below, 10 | * we define a `getAttrs` function, which, other than 11 | * defining node attributes, can be used to describe complex 12 | * match conditions for a rule. 13 | 14 | * Returning `false` from `ParseRule.getAttrs` prevents the 15 | * rule from matching, while returning `null` indicates that 16 | * the default set of note attributes should be used. 17 | */ 18 | 19 | import { 20 | Node as ProseNode, 21 | Fragment, 22 | ParseRule, 23 | Schema, 24 | } from "prosemirror-model"; 25 | 26 | //////////////////////////////////////////////////////////// 27 | 28 | function getFirstMatch( 29 | root: Element, 30 | rules: ((root: Element) => false | string)[] 31 | ): false | string { 32 | for (let rule of rules) { 33 | let match: false | string = rule(root); 34 | if (match !== false) { 35 | return match; 36 | } 37 | } 38 | return false; 39 | } 40 | 41 | function makeTextFragment>( 42 | text: string, 43 | schema: S 44 | ): Fragment { 45 | return Fragment.from(schema.text(text) as ProseNode); 46 | } 47 | 48 | //////////////////////////////////////////////////////////// 49 | 50 | // -- Wikipedia ----------------------------------------- // 51 | 52 | /** 53 | * Look for a child node that matches the following template: 54 | * ... 57 | */ 58 | function texFromMediaWikiFallbackImage(root: Element): false | string { 59 | let match = root.querySelector("img.mwe-math-fallback-image-inline[alt]"); 60 | return match?.getAttribute("alt") ?? false; 61 | } 62 | 63 | /** 64 | * Look for a child node that matches the following template: 65 | * 66 | */ 67 | function texFromMathML_01(root: Element): false | string { 68 | let match = root.querySelector("math[alttext]"); 69 | return match?.getAttribute("alttext") ?? false; 70 | } 71 | 72 | /** 73 | * Look for a child node that matches the following template: 74 | * 75 | */ 76 | function texFromMathML_02(root: Element): false | string { 77 | let match = root.querySelector( 78 | "math annotation[encoding='application/x-tex'" 79 | ); 80 | return match?.textContent ?? false; 81 | } 82 | 83 | /** 84 | * Look for a child node that matches the following template: 85 | * 86 | */ 87 | function texFromScriptTag(root: Element): false | string { 88 | console.log("texFromScriptTag ::", root); 89 | let match = root.querySelector("script[type*='math/tex']"); 90 | return match?.textContent ?? false; 91 | } 92 | 93 | function matchWikipedia(root: Element): false | string { 94 | let match: false | string = getFirstMatch(root, [ 95 | texFromMediaWikiFallbackImage, 96 | texFromMathML_01, 97 | texFromMathML_02, 98 | ]); 99 | // TODO: if no tex string was found, but we have MathML, try to parse it 100 | return match; 101 | } 102 | 103 | /** 104 | * Wikipedia formats block math inside a
...
element, as below. 105 | * 106 | * - Evidently no CSS class is used to distinguish inline vs block math 107 | * - Sometimes the `\displaystyle` TeX command is present even in inline math 108 | * 109 | * ```html 110 | *
111 | * 112 | * 113 | * 114 | * ... 115 | * ... 116 | * 117 | * 118 | * ... 121 | * 122 | *
123 | * ``` 124 | */ 125 | export const wikipediaBlockMathParseRule: ParseRule = { 126 | tag: "dl", 127 | getAttrs(p: Node | string): false | null { 128 | let dl = p as HTMLDListElement; 129 | 130 | //
must contain exactly one child 131 | if (dl.childElementCount !== 1) { 132 | return false; 133 | } 134 | let dd = dl.firstChild as Element; 135 | if (dd.tagName !== "DD") { 136 | return false; 137 | } 138 | 139 | //
must contain exactly one child 140 | if (dd.childElementCount !== 1) { 141 | return false; 142 | } 143 | let mweElt = dd.firstChild as Element; 144 | if (!mweElt.classList.contains("mwe-math-element")) { 145 | return false; 146 | } 147 | 148 | // success! proceed to `getContent` for further processing 149 | return null; 150 | }, 151 | getContent>(p: Node, schema: S): Fragment { 152 | // search the matched element for a TeX string 153 | let match: false | string = matchWikipedia(p as Element); 154 | // return a fragment representing the math node's children 155 | let texString: string = match || "\\text{\\color{red}(paste error)}"; 156 | return makeTextFragment(texString, schema); 157 | }, 158 | }; 159 | 160 | /** 161 | * Parse rule for inline math content on Wikipedia of the following form: 162 | * 163 | * ```html 164 | * 165 | * 166 | * 167 | * 168 | * ... 169 | * ... 170 | * 171 | * 172 | * ... 175 | * 176 | * 177 | * ``` 178 | */ 179 | export const wikipediaInlineMathParseRule: ParseRule = { 180 | tag: "span", 181 | getAttrs(p: Node | string): false | null { 182 | let span = p as HTMLSpanElement; 183 | if (!span.classList.contains("mwe-math-element")) { 184 | return false; 185 | } 186 | // success! proceed to `getContent` for further processing 187 | return null; 188 | }, 189 | getContent>(p: Node, schema: S): Fragment { 190 | // search the matched element for a TeX string 191 | let match: false | string = matchWikipedia(p as Element); 192 | // return a fragment representing the math node's children 193 | let texString: string = match || "\\text{\\color{red}(paste error)}"; 194 | return makeTextFragment(texString, schema); 195 | }, 196 | }; 197 | 198 | // -- MathJax ------------------------------------------- // 199 | 200 | //////////////////////////////////////////////////////////// 201 | 202 | export const defaultInlineMathParseRules: ParseRule[] = [ 203 | wikipediaInlineMathParseRule, 204 | ]; 205 | 206 | export const defaultBlockMathParseRules: ParseRule[] = [ 207 | wikipediaBlockMathParseRule, 208 | ]; 209 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/slash.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/remirror/remirror/blob/main/packages/remirror__extension-mention/src/mention-extension.ts 2 | 3 | import { 4 | command, 5 | CommandFunction, 6 | extension, 7 | FromToProps, 8 | pick, 9 | } from "@remirror/core"; 10 | 11 | import { 12 | DEFAULT_SUGGESTER, 13 | isSelectionExitReason, 14 | isSplitReason, 15 | SuggestChangeHandlerProps, 16 | Suggester, 17 | } from "@remirror/pm/suggest"; 18 | import { PlainExtension } from "remirror"; 19 | import { 20 | MentionChangeHandlerCommandAttributes, 21 | MentionOptions, 22 | NamedMentionExtensionAttributes, 23 | } from "remirror/extensions"; 24 | 25 | @extension({ 26 | defaultOptions: { 27 | mentionTag: "a" as const, 28 | matchers: [], 29 | appendText: "", 30 | suggestTag: "a" as const, 31 | disableDecorations: false, 32 | invalidMarks: [], 33 | invalidNodes: [], 34 | isValidPosition: () => true, 35 | validMarks: null, 36 | validNodes: null, 37 | isMentionValid: isMentionValidDefault, 38 | }, 39 | handlerKeyOptions: { onClick: { earlyReturnValue: true } }, 40 | handlerKeys: ["onChange", "onClick"], 41 | staticKeys: ["mentionTag", "matchers"], 42 | }) 43 | export class SlashExtension extends PlainExtension { 44 | get name() { 45 | return "slash" as const; 46 | } 47 | 48 | /** 49 | * Create the suggesters from the matchers that were passed into the editor. 50 | */ 51 | createSuggesters(): Suggester[] { 52 | let cachedRange: FromToProps | undefined; 53 | 54 | const options = pick(this.options, [ 55 | "invalidMarks", 56 | "invalidNodes", 57 | "isValidPosition", 58 | "validMarks", 59 | "validNodes", 60 | "suggestTag", 61 | "disableDecorations", 62 | ]); 63 | 64 | return this.options.matchers.map((matcher) => ({ 65 | ...DEFAULT_MATCHER, 66 | ...options, 67 | ...matcher, 68 | onChange: (props) => { 69 | const command = (attrs: MentionChangeHandlerCommandAttributes = {}) => { 70 | this.mentionExitHandler( 71 | props, 72 | attrs 73 | )(this.store.helpers.getCommandProp()); 74 | }; 75 | 76 | this.options.onChange( 77 | { ...props, defaultAppendTextValue: this.options.appendText }, 78 | command 79 | ); 80 | }, 81 | })); 82 | } 83 | 84 | /** 85 | * This is the command which can be called from the `onChange` handler to 86 | * automatically handle exits for you. It decides whether a mention should 87 | * be updated, removed or created and also handles invalid splits. 88 | * 89 | * It does nothing for changes and only acts when an exit occurred. 90 | * 91 | * @param handler - the parameter that was passed through to the 92 | * `onChange` handler. 93 | * @param attrs - the options which set the values that will be used (in 94 | * case you want to override the defaults). 95 | */ 96 | @command() 97 | mentionExitHandler( 98 | handler: SuggestChangeHandlerProps, 99 | attrs: MentionChangeHandlerCommandAttributes = {} 100 | ): CommandFunction { 101 | return (props) => { 102 | const reason = handler.exitReason ?? handler.changeReason; 103 | 104 | const { tr } = props; 105 | const { range, text, query, name } = handler; 106 | const { from, to } = range; 107 | 108 | // const command = this.createMention.bind(this); 109 | const command = this.cancelMention.bind(this); 110 | 111 | // Destructure the `attrs` and using the defaults. 112 | const { 113 | replacementType = isSplitReason(reason) ? "partial" : "full", 114 | id = query[replacementType], 115 | label = text[replacementType], 116 | appendText = this.options.appendText, 117 | ...rest 118 | } = attrs; 119 | 120 | // Make sure to preserve the selection, if the reason for the exit was a 121 | // cursor movement and not due to text being added to the document. 122 | const keepSelection = isSelectionExitReason(reason); 123 | 124 | return command({ 125 | name, 126 | id, 127 | label, 128 | appendText, 129 | replacementType, 130 | range, 131 | keepSelection, 132 | ...rest, 133 | })(props); 134 | }; 135 | } 136 | 137 | @command() 138 | cancelMention(config: NamedMentionExtensionAttributes): CommandFunction { 139 | const { 140 | range, 141 | appendText, 142 | replacementType, 143 | keepSelection, 144 | name, 145 | ...attributes 146 | } = config; 147 | return (props) => { 148 | const { tr, dispatch } = props; 149 | const { from, to } = { 150 | from: range?.from ?? tr.selection.from, 151 | to: range?.cursor ?? tr.selection.to, 152 | }; 153 | 154 | dispatch?.(tr.delete(from, to)); 155 | return true; 156 | }; 157 | } 158 | } 159 | 160 | /** 161 | * The default matcher to use when none is provided in options 162 | */ 163 | const DEFAULT_MATCHER = { 164 | ...pick(DEFAULT_SUGGESTER, [ 165 | "startOfLine", 166 | "supportedCharacters", 167 | "validPrefixCharacters", 168 | "invalidPrefixCharacters", 169 | "suggestClassName", 170 | ]), 171 | appendText: "", 172 | matchOffset: 1, 173 | mentionClassName: "mention", 174 | }; 175 | 176 | /** 177 | * Checks whether the mention is valid and hasn't been edited since being 178 | * created. 179 | */ 180 | export function isMentionValidDefault( 181 | attrs: NamedMentionExtensionAttributes, 182 | text: string 183 | ): boolean { 184 | return attrs.label === text; 185 | } 186 | -------------------------------------------------------------------------------- /ui/src/components/nodes/extensions/useSlash.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/remirror/remirror/blob/main/packages/remirror__react-hooks/src/use-mention.ts 2 | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; 4 | import type { 5 | MentionChangeHandler, 6 | MentionChangeHandlerCommand, 7 | MentionExtensionAttributes, 8 | } from "@remirror/extension-mention"; 9 | // import { MentionExtension } from "@remirror/extension-mention"; 10 | import { SlashExtension } from "./slash"; 11 | import { ChangeReason } from "@remirror/pm/suggest"; 12 | import { useExtensionEvent, useHelpers } from "@remirror/react-core"; 13 | 14 | import { 15 | FloatingWrapper, 16 | MentionState, 17 | useCommands, 18 | UseMentionProps, 19 | UseMentionReturn, 20 | useMenuNavigation, 21 | } from "@remirror/react"; 22 | import { cx } from "remirror"; 23 | 24 | function useSlash< 25 | Data extends MentionExtensionAttributes = MentionExtensionAttributes 26 | >(props: UseMentionProps): UseMentionReturn { 27 | const { 28 | items, 29 | ignoreMatchesOnDismiss = true, 30 | onExit, 31 | direction, 32 | dismissKeys, 33 | focusOnClick, 34 | submitKeys, 35 | } = props; 36 | const [state, setState] = useState(null); 37 | const helpers = useHelpers(); 38 | const isOpen = !!state; 39 | 40 | const onDismiss = useCallback(() => { 41 | if (!state) { 42 | return false; 43 | } 44 | 45 | const { range, name } = state; 46 | 47 | // TODO Revisit to see if the following is too extreme 48 | if (ignoreMatchesOnDismiss) { 49 | // Ignore the current mention so that it doesn't show again for this 50 | // matching area 51 | helpers 52 | .getSuggestMethods() 53 | .addIgnored({ from: range.from, name, specific: true }); 54 | } 55 | 56 | // Remove the matches. 57 | setState(null); 58 | 59 | return true; 60 | }, [helpers, ignoreMatchesOnDismiss, state]); 61 | 62 | const onSubmit = useCallback( 63 | (item: Data) => { 64 | if (!state) { 65 | // When there is no state, defer to the next keybinding. 66 | return false; 67 | } 68 | 69 | const { command } = state; 70 | 71 | // Call the command with the item (including all the provided attributes 72 | // which it includes). 73 | command(item); 74 | 75 | return true; 76 | }, 77 | [state] 78 | ); 79 | 80 | const menu = useMenuNavigation({ 81 | items, 82 | isOpen, 83 | onDismiss, 84 | onSubmit, 85 | direction, 86 | dismissKeys, 87 | focusOnClick, 88 | submitKeys, 89 | }); 90 | const { setIndex } = menu; 91 | 92 | const { createTable, toggleHeading } = useCommands(); 93 | 94 | /** 95 | * The is the callback for when a suggestion is changed. 96 | */ 97 | const onChange: MentionChangeHandler = useCallback( 98 | (props, cmd) => { 99 | const { 100 | query, 101 | text, 102 | range, 103 | ignoreNextExit, 104 | name, 105 | exitReason, 106 | changeReason, 107 | textAfter, 108 | defaultAppendTextValue, 109 | } = props; 110 | 111 | // Ignore the next exit since it has been triggered manually but only when 112 | // this is caused by a change. This is because the command might be setup 113 | // to automatically be created on an exit. 114 | if (changeReason) { 115 | const command: MentionChangeHandlerCommand = (attrs) => { 116 | // Ignore the next exit since this exit is artificially being 117 | // generated. 118 | ignoreNextExit(); 119 | if (!attrs) return; 120 | 121 | const regex = /^\s+/; 122 | 123 | const appendText = regex.test(textAfter) 124 | ? "" 125 | : defaultAppendTextValue; 126 | 127 | // Default to append text only when the textAfter the match does not 128 | // start with a whitespace character. However, this can be overridden 129 | // by the user. 130 | cmd({ appendText, ...attrs }); 131 | 132 | // create the table here. 133 | // TODO different commands for different mentions. 134 | const { id } = attrs; 135 | switch (id) { 136 | case "table": 137 | createTable({ 138 | rowsCount: 3, 139 | columnsCount: 3, 140 | withHeaderRow: false, 141 | }); 142 | break; 143 | case "heading1": 144 | toggleHeading({ level: 1 }); 145 | break; 146 | case "heading2": 147 | toggleHeading({ level: 2 }); 148 | break; 149 | case "heading3": 150 | toggleHeading({ level: 3 }); 151 | break; 152 | default: 153 | break; 154 | } 155 | 156 | // Reset the state, since the query has been exited. 157 | setState(null); 158 | }; 159 | 160 | if (changeReason !== ChangeReason.Move) { 161 | setIndex(0); 162 | } 163 | 164 | // Update the active state after the change providing the command and 165 | // potentially updated index. 166 | setState({ reason: changeReason, name, query, text, range, command }); 167 | 168 | return; 169 | } 170 | 171 | if (!exitReason || !onExit) { 172 | // Reset the state and do nothing when no onExit handler provided 173 | setState(null); 174 | return; 175 | } 176 | 177 | const exitCommand: MentionChangeHandlerCommand = (attrs) => { 178 | cmd({ appendText: "", ...attrs }); 179 | }; 180 | 181 | // Call the onExit handler. 182 | onExit({ reason: exitReason, name, query, text, range }, exitCommand); 183 | 184 | // Reset the state to remove the active query return. 185 | setState(null); 186 | }, 187 | [setIndex, onExit] 188 | ); 189 | 190 | // Add the handlers to the `MentionExtension` 191 | useExtensionEvent(SlashExtension, "onChange", onChange); 192 | 193 | return useMemo(() => ({ ...menu, state }), [menu, state]); 194 | } 195 | 196 | export function SlashSuggestor(): JSX.Element { 197 | const [users, setUsers] = useState([]); 198 | const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = 199 | useSlash({ 200 | items: users, 201 | }); 202 | 203 | const allUsers = [ 204 | { id: "table", label: "Insert Table" }, 205 | { id: "heading1", label: "Heading 1" }, 206 | { id: "heading2", label: "Heading 2" }, 207 | { id: "heading3", label: "Heading 3" }, 208 | ]; 209 | 210 | useEffect(() => { 211 | if (!state) { 212 | return; 213 | } 214 | 215 | const searchTerm = state.query.full.toLowerCase(); 216 | const filteredUsers = allUsers 217 | .filter((user) => user.label.toLowerCase().includes(searchTerm)) 218 | .sort() 219 | .slice(0, 5); 220 | setUsers(filteredUsers); 221 | }, [state]); 222 | 223 | const enabled = !!state; 224 | 225 | return ( 226 | 231 |
232 | {enabled && 233 | users.map((user, index) => { 234 | const isHighlighted = indexIsSelected(index); 235 | const isHovered = indexIsHovered(index); 236 | 237 | return ( 238 |
250 | {user.label} 251 |
252 | ); 253 | })} 254 |
255 |
256 | ); 257 | } 258 | -------------------------------------------------------------------------------- /ui/src/components/nodes/remirror-size.css: -------------------------------------------------------------------------------- 1 | 2 | .remirror-theme h1 { 3 | font-size: 1.5em; 4 | font-weight: 600; 5 | margin: 0.5em 0; 6 | } 7 | 8 | .remirror-theme h2 { 9 | font-size: 1.25em; 10 | font-weight: 600; 11 | margin: 0.5em 0; 12 | } 13 | 14 | .remirror-theme h3 { 15 | font-size: 1.1em; 16 | font-weight: 600; 17 | margin: 0.5em 0; 18 | } 19 | 20 | .remirror-theme h4 { 21 | font-size: 1em; 22 | font-weight: 600; 23 | margin: 0.5em 0; 24 | } 25 | 26 | 27 | .remirror-theme h5 { 28 | font-size: 0.9em; 29 | font-weight: 600; 30 | margin: 0.5em 0; 31 | } 32 | 33 | 34 | .remirror-theme h6 { 35 | font-size: 0.8em; 36 | font-weight: 600; 37 | margin: 0.5em 0; 38 | } 39 | 40 | .remirror-theme p { 41 | margin: 0.5em 0; 42 | } 43 | 44 | .remirror-theme ul { 45 | margin: 0.5em 0; 46 | } 47 | 48 | .remirror-theme ol { 49 | margin: 0.5em 0; 50 | } 51 | 52 | .remirror-theme li { 53 | margin: 0.5em 0; 54 | } 55 | 56 | .remirror-theme blockquote { 57 | margin: 0.5em 0; 58 | } 59 | 60 | .remirror-theme pre { 61 | margin: 0.5em 0; 62 | } 63 | 64 | 65 | .remirror-theme code { 66 | font-family: monospace; 67 | font-size: 0.9em; 68 | background: #f5f5f5; 69 | padding: 0.1em 0.2em; 70 | border-radius: 4px; 71 | } 72 | 73 | 74 | .remirror-theme hr { 75 | border: none; 76 | border-top: 1px solid #ddd; 77 | margin: 0.5em 0; 78 | } 79 | 80 | 81 | .remirror-theme table { 82 | border-collapse: collapse; 83 | border-spacing: 0; 84 | margin: 0.5em 0; 85 | } 86 | 87 | 88 | .remirror-theme table td, 89 | .remirror-theme table th { 90 | border: 1px solid #ddd; 91 | padding: 0.5em 1em; 92 | } 93 | 94 | 95 | -------------------------------------------------------------------------------- /ui/src/custom.css: -------------------------------------------------------------------------------- 1 | .myLineDecoration-modified { 2 | background: lightblue; 3 | width: 5px !important; 4 | margin-left: 3px; 5 | } 6 | 7 | .myLineDecoration-add { 8 | background: green; 9 | width: 5px !important; 10 | margin-left: 3px; 11 | } 12 | 13 | .myLineDecoration-delete { 14 | background: red; 15 | width: 5px !important; 16 | margin-left: 3px; 17 | } 18 | 19 | .myDecoration-function { 20 | background: yellow; 21 | } 22 | 23 | .myDecoration-callsite { 24 | background: orange; 25 | } 26 | 27 | .myDecoration-vardef { 28 | background: cyan; 29 | } 30 | 31 | .myDecoration-varuse { 32 | background: rgb(221, 247, 255); 33 | } 34 | 35 | .my-underline { 36 | text-decoration: wavy underline red; 37 | } 38 | 39 | .react-flow__node-SCOPE.active { 40 | box-shadow: 2px 2px 2px 2px rgba(208, 2, 27, 1), 41 | 8px 8px 8px 8px rgba(218, 102, 123, 1); 42 | } 43 | 44 | .react-flow__node-SCOPE.selected { 45 | box-shadow: 0px 0px 8px 0px rgba(100, 100, 100, 0.5); 46 | } 47 | 48 | .react-flow__node-SCOPE.selected { 49 | border-width: 2px; 50 | } 51 | 52 | .react-flow__handle { 53 | z-index: 100; 54 | } 55 | 56 | /* Remove the right scrollbar on Remirror. */ 57 | .remirror-editor.ProseMirror { 58 | overflow-y: hidden !important; 59 | } 60 | 61 | .mention { 62 | background: #7963d266; 63 | padding: 2px 4px; 64 | border-radius: 4px; 65 | } 66 | 67 | .remirror-theme { 68 | /* Provide sufficient space to see the popup */ 69 | --rmr-space-6: 400px; 70 | } 71 | .suggestions { 72 | border: 1px solid darkgray; 73 | border-radius: 4px; 74 | background: white; 75 | cursor: pointer; 76 | } 77 | .suggestion { 78 | padding: 2px 8px; 79 | } 80 | .highlighted { 81 | background: #7963d233; 82 | } 83 | .hovered { 84 | background: #7963d222; 85 | } 86 | 87 | .react-flow__edges { 88 | z-index: 9999 !important; 89 | } 90 | 91 | /* some scope nodes have z-index: 1000. This is set internally by reactflow, we 92 | have to force set the collapsed scope block (when contextual zoomed) to be above 93 | it, so that we can drag the block. */ 94 | .react-flow__node:has(.scope-block) { 95 | z-index: 1001 !important; 96 | } 97 | 98 | .global-drag-handle { 99 | position: absolute; 100 | 101 | &::after { 102 | display: flex; 103 | align-items: center; 104 | justify-content: center; 105 | width: 1rem; 106 | height: 1.25rem; 107 | content: "⠿"; 108 | font-weight: 700; 109 | cursor: grab; 110 | background: #0d0d0d10; 111 | color: #0d0d0d50; 112 | border-radius: 0.25rem; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /ui/src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | // ref: https://usehooks.com/useLocalStorage 4 | export function useLocalStorage(key: string, initialValue: T) { 5 | // State to store our value 6 | // Pass initial state function to useState so logic is only executed once 7 | const [storedValue, setStoredValue] = useState(() => { 8 | if (typeof window === "undefined") { 9 | return initialValue; 10 | } 11 | try { 12 | // Get from local storage by key 13 | const item = window.localStorage.getItem(key); 14 | // Parse stored json or if none return initialValue 15 | return item ? JSON.parse(item) : initialValue; 16 | } catch (error) { 17 | // If error also return initialValue 18 | console.log(error); 19 | return initialValue; 20 | } 21 | }); 22 | // Return a wrapped version of useState's setter function that ... 23 | // ... persists the new value to localStorage. 24 | const setValue = (value: T | ((val: T) => T)) => { 25 | try { 26 | // Allow value to be a function so we have same API as useState 27 | const valueToStore = 28 | value instanceof Function ? value(storedValue) : value; 29 | // Save state 30 | setStoredValue(valueToStore); 31 | // Save to local storage 32 | if (typeof window !== "undefined") { 33 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 34 | } 35 | } catch (error) { 36 | // A more advanced implementation would handle the error case 37 | console.log(error); 38 | } 39 | }; 40 | return [storedValue, setValue] as const; 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | /* display: flex; */ 29 | place-items: center; 30 | min-width: 320px; 31 | /* min-height: 100vh; */ 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | 54 | /* Do not show outline for button on click. */ 55 | /* button:focus, 56 | button:focus-visible { 57 | outline: 4px auto -webkit-focus-ring-color; 58 | } */ 59 | 60 | @media (prefers-color-scheme: light) { 61 | :root { 62 | color: #213547; 63 | background-color: #ffffff; 64 | } 65 | a:hover { 66 | color: #747bff; 67 | } 68 | button { 69 | background-color: #f9f9f9; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/lib/auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect, createContext } from "react"; 2 | import { 3 | ApolloProvider, 4 | ApolloClient, 5 | InMemoryCache, 6 | HttpLink, 7 | gql, 8 | useQuery, 9 | split, 10 | } from "@apollo/client"; 11 | 12 | type AuthContextType = ReturnType; 13 | 14 | const authContext = createContext(null); 15 | 16 | export function AuthProvider({ children, apiUrl, spawnerApiUrl }) { 17 | const auth = useProvideAuth({ apiUrl, spawnerApiUrl }); 18 | 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | 28 | export const useAuth = () => { 29 | return useContext(authContext)!; 30 | }; 31 | 32 | function useProvideAuth({ apiUrl, spawnerApiUrl }) { 33 | function createApolloClient(auth = true) { 34 | const link = new HttpLink({ 35 | uri: apiUrl, 36 | }); 37 | const yjslink = new HttpLink({ 38 | uri: spawnerApiUrl, 39 | }); 40 | 41 | return new ApolloClient({ 42 | link: split( 43 | (operation) => operation.getContext().clientName === "spawner", 44 | yjslink, 45 | link 46 | ), 47 | cache: new InMemoryCache(), 48 | }); 49 | } 50 | 51 | return { 52 | createApolloClient, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /ui/src/lib/prompt.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Prompts a user when they exit the page 3 | * 4 | * References: 5 | * - The up-to-date solution: https://gist.github.com/MarksCode/64e438c82b0b2a1161e01c88ca0d0355 6 | * - https://reactrouter.com/en/v6.3.0/upgrading/v5#prompt-is-not-currently-supported 7 | * - https://gist.github.com/rmorse/426ffcc579922a82749934826fa9f743 8 | * - https://github.com/remix-run/react-router/issues/9262 9 | * - The mega thread: https://github.com/remix-run/react-router/issues/8139 10 | */ 11 | 12 | import { useCallback, useContext, useEffect } from "react"; 13 | import { UNSAFE_NavigationContext as NavigationContext } from "react-router-dom"; 14 | 15 | function useConfirmExit(confirmExit, when = true) { 16 | const { navigator } = useContext(NavigationContext); 17 | 18 | useEffect(() => { 19 | if (!when) { 20 | return; 21 | } 22 | 23 | const push = navigator.push; 24 | 25 | navigator.push = (...args) => { 26 | const result = confirmExit(); 27 | if (result !== false) { 28 | push(...args); 29 | } 30 | }; 31 | 32 | return () => { 33 | navigator.push = push; 34 | }; 35 | }, [navigator, confirmExit, when]); 36 | } 37 | 38 | export function usePrompt(message, when = true) { 39 | useEffect(() => { 40 | if (when) { 41 | window.onbeforeunload = function () { 42 | return message; 43 | }; 44 | } 45 | 46 | return () => { 47 | window.onbeforeunload = null; 48 | }; 49 | }, [message, when]); 50 | 51 | const confirmExit = useCallback(() => { 52 | const confirm = window.confirm(message); 53 | return confirm; 54 | }, [message]); 55 | useConfirmExit(confirmExit, when); 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/lib/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StateCreator, StoreApi } from "zustand"; 2 | import { devtools } from "zustand/middleware"; 3 | import { createContext } from "react"; 4 | 5 | import { Annotation } from "../parser"; 6 | import { PodSlice, createPodSlice } from "./podSlice"; 7 | import { RepoSlice, createRepoSlice } from "./repoSlice"; 8 | import { SettingSlice, createSettingSlice } from "./settingSlice"; 9 | import { YjsSlice, createYjsSlice } from "./yjsSlice"; 10 | import { RuntimeSlice, createRuntimeSlice } from "./runtimeSlice"; 11 | import { CanvasSlice, createCanvasSlice } from "./canvasSlice"; 12 | 13 | import { enableMapSet } from "immer"; 14 | 15 | enableMapSet(); 16 | 17 | export type Pod = { 18 | id: string; 19 | name?: string; 20 | type: "CODE" | "SCOPE" | "RICH"; 21 | content?: string; 22 | richContent?: string; 23 | dirty?: boolean; 24 | // A temporary dirty status used during remote API syncing, so that new dirty 25 | // status is not cleared by API returns. 26 | dirtyPending?: boolean; 27 | isSyncing?: boolean; 28 | children: { id: string; type: string }[]; 29 | parent: string; 30 | result?: { 31 | type?: string; 32 | html?: string; 33 | text?: string; 34 | image?: string; 35 | }[]; 36 | exec_count?: number; 37 | last_exec_end?: boolean; 38 | status?: string; 39 | stdout?: string; 40 | stderr?: string; 41 | error?: { ename: string; evalue: string; stacktrace: string[] } | null; 42 | lastExecutedAt?: Date; 43 | lang: string; 44 | column?: number; 45 | raw?: boolean; 46 | fold?: boolean; 47 | symbolTable?: { [key: string]: string }; 48 | annotations?: Annotation[]; 49 | ispublic?: boolean; 50 | isbridge?: boolean; 51 | x: number; 52 | y: number; 53 | width?: number; 54 | height?: number; 55 | ns?: string; 56 | running?: boolean; 57 | focus?: boolean; 58 | pending?: boolean; 59 | }; 60 | 61 | export type MyState = PodSlice & 62 | RepoSlice & 63 | YjsSlice & 64 | RuntimeSlice & 65 | SettingSlice & 66 | CanvasSlice; 67 | 68 | export const RepoContext = createContext | null>(null); 69 | 70 | export const createRepoStore = () => 71 | createStore( 72 | devtools((...a) => ({ 73 | ...createPodSlice(...a), 74 | ...createRepoSlice(...a), 75 | ...createYjsSlice(...a), 76 | ...createSettingSlice(...a), 77 | ...createRuntimeSlice(...a), 78 | ...createCanvasSlice(...a), 79 | })) 80 | ); 81 | -------------------------------------------------------------------------------- /ui/src/lib/store/podSlice.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StateCreator, StoreApi } from "zustand"; 2 | import { produce } from "immer"; 3 | 4 | import { Pod, MyState } from "."; 5 | 6 | export interface PodSlice { 7 | // local reactive variable for pod result 8 | podNames: Record; 9 | setPodName: ({ id, name }: { id: string; name: string }) => void; 10 | } 11 | 12 | export const createPodSlice: StateCreator = ( 13 | set, 14 | get 15 | ) => ({ 16 | podNames: {}, 17 | setPodName: ({ id, name }) => { 18 | set( 19 | produce((state: MyState) => { 20 | state.podNames[id] = name; 21 | }) 22 | ); 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /ui/src/lib/store/repoSlice.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StateCreator, StoreApi } from "zustand"; 2 | import { produce } from "immer"; 3 | 4 | import { MyState } from "."; 5 | import { gql } from "@apollo/client"; 6 | 7 | export interface RepoSlice { 8 | repoName: string | null; 9 | repoNameSyncing: boolean; 10 | repoNameDirty: boolean; 11 | repoId: string; 12 | editMode: "view" | "edit"; 13 | setEditMode: (mode: "view" | "edit") => void; 14 | setRepoName: (name: string) => void; 15 | remoteUpdateRepoName: (client) => void; 16 | setRepoData: (repo: { 17 | id: string; 18 | name: string; 19 | userId: string; 20 | public: boolean; 21 | collaborators: [ 22 | { 23 | id: string; 24 | email: string; 25 | firstname: string; 26 | lastname: string; 27 | } 28 | ]; 29 | }) => void; 30 | collaborators: any[]; 31 | shareOpen: boolean; 32 | setShareOpen: (open: boolean) => void; 33 | isPublic: boolean; 34 | } 35 | 36 | export const createRepoSlice: StateCreator = ( 37 | set, 38 | get 39 | ) => ({ 40 | repoId: "DUMMY", 41 | repoName: null, 42 | repoNameSyncing: false, 43 | repoNameDirty: false, 44 | collaborators: [], 45 | isPublic: false, 46 | shareOpen: false, 47 | setShareOpen: (open: boolean) => set({ shareOpen: open }), 48 | 49 | editMode: "view", 50 | setEditMode: (mode) => set({ editMode: mode }), 51 | 52 | setRepoName: (name) => { 53 | set( 54 | produce((state: MyState) => { 55 | state.repoName = name; 56 | state.repoNameDirty = true; 57 | }) 58 | ); 59 | }, 60 | remoteUpdateRepoName: async (client) => { 61 | if (get().repoNameSyncing) return; 62 | if (!get().repoNameDirty) return; 63 | let { repoId, repoName } = get(); 64 | if (!repoId) return; 65 | // Prevent double syncing. 66 | set( 67 | produce((state: MyState) => { 68 | state.repoNameSyncing = true; 69 | }) 70 | ); 71 | // Do the actual syncing. 72 | await client.mutate({ 73 | mutation: gql` 74 | mutation UpdateRepo($id: ID!, $name: String!) { 75 | updateRepo(id: $id, name: $name) 76 | } 77 | `, 78 | variables: { 79 | id: repoId, 80 | name: repoName, 81 | }, 82 | }); 83 | set((state) => 84 | produce(state, (state) => { 85 | state.repoNameSyncing = false; 86 | // Set it as synced IF the name is still the same. 87 | if (state.repoName === repoName) { 88 | state.repoNameDirty = false; 89 | } 90 | }) 91 | ); 92 | }, 93 | // FIXME refactor out this function 94 | setRepoData: (repo) => 95 | set( 96 | produce((state: MyState) => { 97 | state.repoName = repo.name; 98 | state.isPublic = repo.public; 99 | state.collaborators = repo.collaborators; 100 | }) 101 | ), 102 | }); 103 | -------------------------------------------------------------------------------- /ui/src/lib/store/settingSlice.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StateCreator, StoreApi } from "zustand"; 2 | import { MyState } from "."; 3 | 4 | export interface SettingSlice { 5 | scopedVars?: boolean; 6 | setScopedVars: (b: boolean) => void; 7 | showAnnotations?: boolean; 8 | setShowAnnotations: (b: boolean) => void; 9 | devMode?: boolean; 10 | setDevMode: (b: boolean) => void; 11 | autoRunLayout?: boolean; 12 | setAutoRunLayout: (b: boolean) => void; 13 | contextualZoomParams: Record; 14 | setContextualZoomParams: ( 15 | r: Record, 16 | n: number, 17 | n1: number 18 | ) => void; 19 | restoreParamsDefault: () => void; 20 | contextualZoom: boolean; 21 | setContextualZoom: (b: boolean) => void; 22 | showLineNumbers?: boolean; 23 | setShowLineNumbers: (b: boolean) => void; 24 | isSidebarOnLeftHand: boolean; 25 | setIsSidebarOnLeftHand: (b: boolean) => void; 26 | } 27 | 28 | export const createSettingSlice: StateCreator = ( 29 | set, 30 | get 31 | ) => ({ 32 | scopedVars: localStorage.getItem("scopedVars") 33 | ? JSON.parse(localStorage.getItem("scopedVars")!) 34 | : true, 35 | showAnnotations: localStorage.getItem("showAnnotations") 36 | ? JSON.parse(localStorage.getItem("showAnnotations")!) 37 | : false, 38 | setScopedVars: (b: boolean) => { 39 | // set it 40 | set({ scopedVars: b }); 41 | // also write to local storage 42 | localStorage.setItem("scopedVars", JSON.stringify(b)); 43 | }, 44 | setShowAnnotations: (b: boolean) => { 45 | // set it 46 | set({ showAnnotations: b }); 47 | // also write to local storage 48 | localStorage.setItem("showAnnotations", JSON.stringify(b)); 49 | }, 50 | devMode: localStorage.getItem("devMode") 51 | ? JSON.parse(localStorage.getItem("devMode")!) 52 | : false, 53 | setDevMode: (b: boolean) => { 54 | // set it 55 | set({ devMode: b }); 56 | // also write to local storage 57 | localStorage.setItem("devMode", JSON.stringify(b)); 58 | }, 59 | autoRunLayout: localStorage.getItem("autoRunLayout") 60 | ? JSON.parse(localStorage.getItem("autoRunLayout")!) 61 | : true, 62 | setAutoRunLayout: (b: boolean) => { 63 | set({ autoRunLayout: b }); 64 | // also write to local storage 65 | localStorage.setItem("autoRunLayout", JSON.stringify(b)); 66 | }, 67 | 68 | contextualZoom: localStorage.getItem("contextualZoom") 69 | ? JSON.parse(localStorage.getItem("contextualZoom")!) 70 | : false, 71 | setContextualZoom: (b: boolean) => { 72 | set({ contextualZoom: b }); 73 | // also write to local storage 74 | localStorage.setItem("contextualZoom", JSON.stringify(b)); 75 | }, 76 | showLineNumbers: localStorage.getItem("showLineNumbers") 77 | ? JSON.parse(localStorage.getItem("showLineNumbers")!) 78 | : false, 79 | setShowLineNumbers: (b: boolean) => { 80 | // set it 81 | set({ showLineNumbers: b }); 82 | // also write to local storage 83 | localStorage.setItem("showLineNumbers", JSON.stringify(b)); 84 | }, 85 | // TODO Make it configurable. 86 | contextualZoomParams: localStorage.getItem("contextualZoomParams") 87 | ? JSON.parse(localStorage.getItem("contextualZoomParams")!) 88 | : { 89 | 0: 48, 90 | 1: 32, 91 | 2: 24, 92 | 3: 16, 93 | next: 8, 94 | threshold: 16, 95 | }, 96 | setContextualZoomParams: ( 97 | contextualZoomParams: Record, 98 | level: number, 99 | newSize: number 100 | ) => { 101 | let updatedParams; 102 | switch (level) { 103 | case 0: 104 | updatedParams = { ...contextualZoomParams, 0: newSize }; 105 | break; 106 | case 1: 107 | updatedParams = { ...contextualZoomParams, 1: newSize }; 108 | break; 109 | case 2: 110 | updatedParams = { ...contextualZoomParams, 2: newSize }; 111 | break; 112 | case 3: 113 | updatedParams = { ...contextualZoomParams, 3: newSize }; 114 | break; 115 | case 4: 116 | updatedParams = { ...contextualZoomParams, next: newSize }; 117 | break; 118 | } 119 | set((state) => ({ 120 | contextualZoomParams: { 121 | ...updatedParams, 122 | }, 123 | })); 124 | localStorage.setItem( 125 | "contextualZoomParams", 126 | JSON.stringify({ ...updatedParams }) 127 | ); 128 | }, 129 | restoreParamsDefault: () => { 130 | const updatedParams = { 131 | 0: 48, 132 | 1: 32, 133 | 2: 24, 134 | 3: 16, 135 | next: 8, 136 | threshold: 16, 137 | }; 138 | set((state) => ({ 139 | contextualZoomParams: { 140 | ...updatedParams, 141 | }, 142 | })); 143 | localStorage.setItem( 144 | "contextualZoomParams", 145 | JSON.stringify({ ...updatedParams }) 146 | ); 147 | }, 148 | isSidebarOnLeftHand: localStorage.getItem("isSidebarOnLeftHand") 149 | ? JSON.parse(localStorage.getItem("isSidebarOnLeftHand")!) 150 | : false, 151 | setIsSidebarOnLeftHand: (b: boolean) => { 152 | set({ isSidebarOnLeftHand: b }); 153 | localStorage.setItem("isSidebarOnLeftHand", JSON.stringify(b)); 154 | }, 155 | }); 156 | -------------------------------------------------------------------------------- /ui/src/lib/store/yjsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StateCreator, StoreApi } from "zustand"; 2 | import { produce } from "immer"; 3 | 4 | // import { IndexeddbPersistence } from "y-indexeddb"; 5 | 6 | import { Doc, Transaction } from "yjs"; 7 | import * as Y from "yjs"; 8 | // import { WebsocketProvider } from "../y-websocket"; 9 | import { WebsocketProvider } from "../../../../api/src/yjs/y-websocket"; 10 | import { addAwarenessStyle } from "../utils/utils"; 11 | import { MyState, Pod } from "."; 12 | 13 | export interface YjsSlice { 14 | addError: (error: { type: string; msg: string }) => void; 15 | clearError: () => void; 16 | error: { type: string; msg: string } | null; 17 | provider?: WebsocketProvider | null; 18 | clients: Map; 19 | ydoc: Doc; 20 | addClient: (clientId: any, name, color) => void; 21 | deleteClient: (clientId: any) => void; 22 | // A variable to avoid duplicate connection requests. 23 | yjsConnecting: boolean; 24 | // The status of yjs connection. 25 | yjsStatus?: string; 26 | connectYjs: ({ yjsWsUrl, name }: { yjsWsUrl: string; name: string }) => void; 27 | disconnectYjs: () => void; 28 | // The status of the uploading and syncing of actual Y.Doc. 29 | yjsSyncStatus?: string; 30 | setYjsSyncStatus: (status: string) => void; 31 | providerSynced: boolean; 32 | setProviderSynced: (synced: boolean) => void; 33 | runtimeChanged: boolean; 34 | toggleRuntimeChanged: () => void; 35 | resultChanged: Record; 36 | toggleResultChanged: (id: string) => void; 37 | } 38 | 39 | export const createYjsSlice: StateCreator = ( 40 | set, 41 | get 42 | ) => ({ 43 | error: null, 44 | 45 | ydoc: new Doc(), 46 | provider: null, 47 | // keep different seletced info on each user themselves 48 | //TODO: all presence information are now saved in clients map for future usage. create a modern UI to show those information from clients (e.g., online users) 49 | clients: new Map(), 50 | addError: (error) => set({ error }), 51 | clearError: () => set({ error: null }), 52 | 53 | addClient: (clientID, name, color) => 54 | set((state) => { 55 | if (!state.clients.has(clientID)) { 56 | addAwarenessStyle(clientID, color, name); 57 | return { 58 | clients: new Map(state.clients).set(clientID, { 59 | name: name, 60 | color: color, 61 | }), 62 | }; 63 | } 64 | return { clients: state.clients }; 65 | }), 66 | deleteClient: (clientID) => 67 | set((state) => { 68 | const clients = new Map(state.clients); 69 | clients.delete(clientID); 70 | return { clients: clients }; 71 | }), 72 | 73 | yjsConnecting: false, 74 | yjsStatus: undefined, 75 | yjsSyncStatus: undefined, 76 | providerSynced: false, 77 | setProviderSynced: (synced) => set({ providerSynced: synced }), 78 | setYjsSyncStatus: (status) => set({ yjsSyncStatus: status }), 79 | runtimeChanged: false, 80 | toggleRuntimeChanged: () => 81 | set((state) => ({ runtimeChanged: !state.runtimeChanged })), 82 | resultChanged: {}, 83 | toggleResultChanged: (id) => 84 | set( 85 | produce((state: MyState) => { 86 | state.resultChanged[id] = !state.resultChanged[id]; 87 | }) 88 | ), 89 | connectYjs: ({ yjsWsUrl, name }) => { 90 | if (get().yjsConnecting) return; 91 | if (get().provider) return; 92 | set({ yjsConnecting: true }); 93 | console.log(`connecting yjs socket ${yjsWsUrl} ..`); 94 | const ydoc = new Doc(); 95 | 96 | // TODO offline support 97 | // const persistence = new IndexeddbPersistence(get().repoId!, ydoc); 98 | // persistence.once("synced", () => { 99 | // console.log("=== initial content loaded from indexedDB"); 100 | // }); 101 | 102 | // connect to primary database 103 | const provider = new WebsocketProvider(yjsWsUrl, get().repoId!, ydoc, { 104 | // resyncInterval: 2000, 105 | // 106 | // BC is more complex to track our custom Uploading status and SyncDone events. 107 | disableBc: true, 108 | params: { 109 | token: localStorage.getItem("token") || "", 110 | }, 111 | }); 112 | const color = "#" + Math.floor(Math.random() * 16777215).toString(16); 113 | provider.awareness.setLocalStateField("user", { 114 | name, 115 | color, 116 | }); 117 | provider.awareness.on("update", (change) => { 118 | const states = provider.awareness.getStates(); 119 | const nodes = change.added.concat(change.updated); 120 | nodes.forEach((clientID) => { 121 | const user = states.get(clientID)?.user; 122 | if (user) { 123 | get().addClient(clientID, user.name, user.color); 124 | } 125 | }); 126 | change.removed.forEach((clientID) => { 127 | get().deleteClient(clientID); 128 | }); 129 | }); 130 | provider.on("status", ({ status }) => { 131 | set({ yjsStatus: status }); 132 | // FIXME need to show an visual indicator about this, e.g., prevent user 133 | // from editing if WS is not connected. 134 | // 135 | // FIXME do I need a hard disconnect to ensure the doc is always reloaded 136 | // from server when WS is re-connected? 137 | // 138 | // if (status === "disconnected") { // get().disconnectYjs(); // 139 | // get().connectYjs(); 140 | // } 141 | }); 142 | provider.on("mySync", (status: "uploading" | "synced") => { 143 | set({ yjsSyncStatus: status }); 144 | }); 145 | // provider.on("connection-close", () => { 146 | // console.log("connection-close"); 147 | // // set({ yjsStatus: "connection-close" }); 148 | // }); 149 | // provider.on("connection-error", () => { 150 | // console.log("connection-error"); 151 | // set({ yjsStatus: "connection-error" }); 152 | // }); 153 | // provider.on("sync", (isSynced) => { 154 | // console.log("=== syncing", isSynced); 155 | // // set({ yjsStatus: "syncing" }); 156 | // }); 157 | // provider.on("synced", () => { 158 | // console.log("=== synced"); 159 | // // set({ yjsStatus: "synced" }); 160 | // }); 161 | // max retry time: 10s 162 | provider.maxBackoffTime = 10000; 163 | provider.once("synced", () => { 164 | console.log("Provider synced, setting initial content ..."); 165 | get().adjustLevel(); 166 | get().updateView(); 167 | // Trigger initial results rendering. 168 | const resultMap = get().getResultMap(); 169 | // Initialize node2children 170 | get().buildNode2Children(); 171 | Array.from(resultMap.keys()).forEach((key) => { 172 | get().toggleResultChanged(key); 173 | }); 174 | // Set observers to trigger future results rendering. 175 | resultMap.observe( 176 | (YMapEvent: Y.YEvent, transaction: Y.Transaction) => { 177 | // clearResults and setRunning is local change. 178 | // if (transaction.local) return; 179 | YMapEvent.changes.keys.forEach((change, key) => { 180 | // refresh result for pod key 181 | // FIXME performance on re-rendering: would it trigger re-rendering for all pods? 182 | get().toggleResultChanged(key); 183 | }); 184 | } 185 | ); 186 | const nodesMap = get().getNodesMap(); 187 | // FIXME do I need to unobserve it when disconnecting? 188 | nodesMap.observe( 189 | (YMapEvent: Y.YEvent, transaction: Y.Transaction) => { 190 | if (transaction.local) return; 191 | get().updateView(); 192 | } 193 | ); 194 | const edgesMap = get().getEdgesMap(); 195 | edgesMap.observe( 196 | (YMapEvent: Y.YEvent, transaction: Y.Transaction) => { 197 | if (transaction.local) return; 198 | get().updateView(); 199 | } 200 | ); 201 | // Set active runtime to the first one. 202 | const runtimeMap = get().getRuntimeMap(); 203 | if (runtimeMap.size > 0) { 204 | get().setActiveRuntime(Array.from(runtimeMap.keys())[0]); 205 | } 206 | // Set up observers to trigger future runtime status changes. 207 | runtimeMap.observe( 208 | (YMapEvent: Y.YEvent, transaction: Y.Transaction) => { 209 | if (transaction.local) return; 210 | YMapEvent.changes.keys.forEach((change, key) => { 211 | if (change.action === "add") { 212 | } else if (change.action === "update") { 213 | } else if (change.action === "delete") { 214 | // If it was the active runtime, reset it. 215 | if (get().activeRuntime === key) { 216 | get().setActiveRuntime(undefined); 217 | } 218 | } 219 | }); 220 | // Set active runtime if it is not set 221 | if (runtimeMap.size > 0 && !get().activeRuntime) { 222 | get().setActiveRuntime(Array.from(runtimeMap.keys())[0]); 223 | } 224 | get().toggleRuntimeChanged(); 225 | } 226 | ); 227 | // Set synced flag to be used to ensure canvas rendering after yjs synced. 228 | get().setProviderSynced(true); 229 | }); 230 | provider.connect(); 231 | set( 232 | produce((state: MyState) => { 233 | state.ydoc = ydoc; 234 | state.provider = provider; 235 | state.yjsConnecting = false; 236 | }) 237 | ); 238 | }, 239 | disconnectYjs: () => 240 | set( 241 | // clean up the connected provider after exiting the page 242 | produce((state: MyState) => { 243 | console.log("disconnecting yjs socket .."); 244 | if (state.provider) { 245 | state.provider.destroy(); 246 | // just for debug usage, remove it later 247 | state.provider = null; 248 | } 249 | state.ydoc.destroy(); 250 | state.providerSynced = false; 251 | }) 252 | ), 253 | }); 254 | -------------------------------------------------------------------------------- /ui/src/lib/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import type { AppRouter } from "../../../api/src/spawner/trpc"; 3 | export const trpc = createTRPCReact(); 4 | -------------------------------------------------------------------------------- /ui/src/lib/utils/python-keywords.ts: -------------------------------------------------------------------------------- 1 | const keywords = new Set([ 2 | // Additional 3 | "random", 4 | "numpy", 5 | // This section is the result of running 6 | // `import keyword; for k in sorted(keyword.kwlist + keyword.softkwlist): print(" '" + k + "',")` 7 | // in a Python REPL, 8 | // though note that the output from Python 3 is not a strict superset of the 9 | // output from Python 2. 10 | "False", // promoted to keyword.kwlist in Python 3 11 | "None", // promoted to keyword.kwlist in Python 3 12 | "True", // promoted to keyword.kwlist in Python 3 13 | "_", // new in Python 3.10 14 | "and", 15 | "as", 16 | "assert", 17 | "async", // new in Python 3 18 | "await", // new in Python 3 19 | "break", 20 | "case", // new in Python 3.10 21 | "class", 22 | "continue", 23 | "def", 24 | "del", 25 | "elif", 26 | "else", 27 | "except", 28 | "exec", // Python 2, but not 3. 29 | "finally", 30 | "for", 31 | "from", 32 | "global", 33 | "if", 34 | "import", 35 | "in", 36 | "is", 37 | "lambda", 38 | "match", // new in Python 3.10 39 | "nonlocal", // new in Python 3 40 | "not", 41 | "or", 42 | "pass", 43 | "print", // Python 2, but not 3. 44 | "raise", 45 | "return", 46 | "try", 47 | "while", 48 | "with", 49 | "yield", 50 | 51 | "int", 52 | "float", 53 | "long", 54 | "complex", 55 | "hex", 56 | 57 | "abs", 58 | "all", 59 | "any", 60 | "apply", 61 | "basestring", 62 | "bin", 63 | "bool", 64 | "buffer", 65 | "bytearray", 66 | "callable", 67 | "chr", 68 | "classmethod", 69 | "cmp", 70 | "coerce", 71 | "compile", 72 | "complex", 73 | "delattr", 74 | "dict", 75 | "dir", 76 | "divmod", 77 | "enumerate", 78 | "eval", 79 | "execfile", 80 | "file", 81 | "filter", 82 | "format", 83 | "frozenset", 84 | "getattr", 85 | "globals", 86 | "hasattr", 87 | "hash", 88 | "help", 89 | "id", 90 | "input", 91 | "intern", 92 | "isinstance", 93 | "issubclass", 94 | "iter", 95 | "len", 96 | "locals", 97 | "list", 98 | "map", 99 | "max", 100 | "memoryview", 101 | "min", 102 | "next", 103 | "object", 104 | "oct", 105 | "open", 106 | "ord", 107 | "pow", 108 | "print", 109 | "property", 110 | "reversed", 111 | "range", 112 | "raw_input", 113 | "reduce", 114 | "reload", 115 | "repr", 116 | "reversed", 117 | "round", 118 | "self", 119 | "set", 120 | "setattr", 121 | "slice", 122 | "sorted", 123 | "staticmethod", 124 | "str", 125 | "sum", 126 | "super", 127 | "tuple", 128 | "type", 129 | "unichr", 130 | "unicode", 131 | "vars", 132 | "xrange", 133 | "zip", 134 | 135 | "__dict__", 136 | "__methods__", 137 | "__members__", 138 | "__class__", 139 | "__bases__", 140 | "__name__", 141 | "__mro__", 142 | "__subclasses__", 143 | "__init__", 144 | "__import__", 145 | ]); 146 | 147 | export default keywords; 148 | -------------------------------------------------------------------------------- /ui/src/lib/utils/rich-schema.ts: -------------------------------------------------------------------------------- 1 | const spec = { 2 | nodes: { 3 | tableControllerCell: { 4 | atom: true, 5 | isolating: true, 6 | content: "block*", 7 | attrs: { 8 | colspan: { default: 1 }, 9 | rowspan: { default: 1 }, 10 | colwidth: { default: null }, 11 | background: { default: null }, 12 | }, 13 | tableRole: "header_cell", 14 | parseDOM: [{ tag: "th[data-controller-cell]" }], 15 | allowGapCursor: false, 16 | }, 17 | doc: { attrs: {}, content: "block+" }, 18 | text: { group: "inline" }, 19 | paragraph: { 20 | content: "inline*", 21 | draggable: false, 22 | attrs: { 23 | dir: { default: null }, 24 | ignoreBidiAutoUpdate: { default: null }, 25 | }, 26 | parseDOM: [{ tag: "p" }], 27 | group: "lastNodeCompatible textBlock block formattingNode", 28 | }, 29 | table: { 30 | isolating: true, 31 | attrs: { 32 | dir: { default: null }, 33 | ignoreBidiAutoUpdate: { default: null }, 34 | isControllersInjected: { default: false }, 35 | insertButtonAttrs: { default: null }, 36 | }, 37 | content: "tableRow+", 38 | tableRole: "table", 39 | parseDOM: [{ tag: "table" }], 40 | allowGapCursor: false, 41 | group: "block", 42 | }, 43 | math_inline: { 44 | group: "math inline", 45 | content: "text*", 46 | inline: true, 47 | atom: true, 48 | attrs: {}, 49 | parseDOM: [{ tag: "math-inline" }, { tag: "span" }], 50 | }, 51 | math_display: { 52 | group: "block math block", 53 | content: "text*", 54 | atom: true, 55 | code: true, 56 | attrs: { 57 | dir: { default: null }, 58 | ignoreBidiAutoUpdate: { default: null }, 59 | }, 60 | parseDOM: [{ tag: "math-display" }, { tag: "dl" }], 61 | }, 62 | image: { 63 | inline: true, 64 | draggable: true, 65 | selectable: false, 66 | attrs: { 67 | alt: { default: "" }, 68 | crop: { default: null }, 69 | height: { default: null }, 70 | width: { default: null }, 71 | rotate: { default: null }, 72 | src: { default: null }, 73 | title: { default: "" }, 74 | fileName: { default: null }, 75 | resizable: { default: false }, 76 | }, 77 | parseDOM: [{ tag: "img[src]" }], 78 | group: "inline media", 79 | }, 80 | horizontalRule: { 81 | attrs: { 82 | dir: { default: null }, 83 | ignoreBidiAutoUpdate: { default: null }, 84 | }, 85 | parseDOM: [{ tag: "hr" }], 86 | group: "block", 87 | }, 88 | blockquote: { 89 | content: "block+", 90 | defining: true, 91 | draggable: false, 92 | attrs: { 93 | dir: { default: null }, 94 | ignoreBidiAutoUpdate: { default: null }, 95 | }, 96 | parseDOM: [{ tag: "blockquote", priority: 100 }], 97 | group: "block formattingNode", 98 | }, 99 | codeBlock: { 100 | content: "text*", 101 | marks: "", 102 | defining: true, 103 | isolating: true, 104 | draggable: false, 105 | code: true, 106 | attrs: { 107 | dir: { default: null }, 108 | ignoreBidiAutoUpdate: { default: null }, 109 | language: { default: "markup" }, 110 | wrap: { default: false }, 111 | }, 112 | // parseDOM: [ 113 | // { tag: "div.highlight", preserveWhitespace: "full" }, 114 | // { tag: "pre", preserveWhitespace: "full" }, 115 | // ], 116 | group: "block code", 117 | }, 118 | heading: { 119 | content: "inline*", 120 | defining: true, 121 | draggable: false, 122 | attrs: { 123 | dir: { default: null }, 124 | ignoreBidiAutoUpdate: { default: null }, 125 | level: { default: 1 }, 126 | }, 127 | parseDOM: [ 128 | { tag: "h1" }, 129 | { tag: "h2" }, 130 | { tag: "h3" }, 131 | { tag: "h4" }, 132 | { tag: "h5" }, 133 | { tag: "h6" }, 134 | ], 135 | group: "block textBlock formattingNode", 136 | }, 137 | iframe: { 138 | selectable: false, 139 | attrs: { 140 | dir: { default: null }, 141 | ignoreBidiAutoUpdate: { default: null }, 142 | src: {}, 143 | allowFullScreen: { default: true }, 144 | frameBorder: { default: 0 }, 145 | type: { default: "custom" }, 146 | width: { default: null }, 147 | height: { default: null }, 148 | }, 149 | parseDOM: [{ tag: "iframe" }], 150 | group: "block", 151 | }, 152 | bulletList: { 153 | content: "listItem+", 154 | attrs: { 155 | dir: { default: null }, 156 | ignoreBidiAutoUpdate: { default: null }, 157 | }, 158 | parseDOM: [{ tag: "ul" }], 159 | group: "block listContainer", 160 | }, 161 | orderedList: { 162 | content: "listItem+", 163 | attrs: { 164 | dir: { default: null }, 165 | ignoreBidiAutoUpdate: { default: null }, 166 | order: { default: 1 }, 167 | }, 168 | parseDOM: [{ tag: "ol" }], 169 | group: "block listContainer", 170 | }, 171 | taskList: { 172 | content: "taskListItem+", 173 | attrs: { 174 | dir: { default: null }, 175 | ignoreBidiAutoUpdate: { default: null }, 176 | }, 177 | parseDOM: [{ tag: "ul[data-task-list]", priority: 1000 }], 178 | group: "block listContainer", 179 | }, 180 | taskListItem: { 181 | content: "paragraph block*", 182 | defining: true, 183 | draggable: false, 184 | attrs: { checked: { default: false } }, 185 | parseDOM: [{ tag: "li[data-task-list-item]", priority: 1000 }], 186 | group: "listItemNode", 187 | }, 188 | emoji: { 189 | selectable: true, 190 | draggable: false, 191 | inline: true, 192 | atom: true, 193 | attrs: { code: {} }, 194 | parseDOM: [{ tag: "span[data-remirror-emoji" }], 195 | group: "inline", 196 | }, 197 | tableRow: { 198 | attrs: {}, 199 | content: "(tableCell | tableHeaderCell | tableControllerCell)*", 200 | tableRole: "row", 201 | parseDOM: [{ tag: "tr" }], 202 | allowGapCursor: false, 203 | }, 204 | tableCell: { 205 | isolating: true, 206 | content: "block+", 207 | attrs: { 208 | colspan: { default: 1 }, 209 | rowspan: { default: 1 }, 210 | colwidth: { default: null }, 211 | background: { default: null }, 212 | }, 213 | tableRole: "cell", 214 | parseDOM: [{ tag: "td" }], 215 | allowGapCursor: false, 216 | }, 217 | tableHeaderCell: { 218 | isolating: true, 219 | content: "block+", 220 | attrs: { 221 | colspan: { default: 1 }, 222 | rowspan: { default: 1 }, 223 | colwidth: { default: null }, 224 | background: { default: null }, 225 | }, 226 | tableRole: "header_cell", 227 | parseDOM: [{ tag: "th" }], 228 | allowGapCursor: false, 229 | }, 230 | hardBreak: { 231 | inline: true, 232 | selectable: false, 233 | atom: true, 234 | attrs: {}, 235 | parseDOM: [{ tag: "br" }], 236 | group: "inline", 237 | }, 238 | listItem: { 239 | content: "paragraph block*", 240 | defining: true, 241 | draggable: false, 242 | attrs: { 243 | closed: { default: false }, 244 | nested: { default: false }, 245 | }, 246 | parseDOM: [{ tag: "li", priority: 0 }], 247 | group: "listItemNode", 248 | }, 249 | }, 250 | marks: { 251 | link: { 252 | inclusive: false, 253 | excludes: "_", 254 | attrs: { 255 | href: {}, 256 | target: { default: null }, 257 | auto: { default: false }, 258 | }, 259 | parseDOM: [{ tag: "a[href]" }], 260 | group: "link excludeFromInputRules", 261 | }, 262 | textHighlight: { 263 | attrs: { highlight: { default: "" } }, 264 | parseDOM: [ 265 | { tag: "span[data-text-highlight-mark]" }, 266 | { tag: "span[data-text-highlight-mark]" }, 267 | { style: "background-color", priority: 10 }, 268 | ], 269 | group: "formattingMark fontStyle", 270 | }, 271 | sup: { 272 | attrs: {}, 273 | parseDOM: [{ tag: "sup" }], 274 | group: "formattingMark fontStyle", 275 | }, 276 | sub: { 277 | attrs: {}, 278 | parseDOM: [{ tag: "sub" }], 279 | group: "formattingMark fontStyle", 280 | }, 281 | bold: { 282 | attrs: {}, 283 | parseDOM: [{ tag: "strong" }, { tag: "b" }, { style: "font-weight" }], 284 | group: "formattingMark fontStyle", 285 | }, 286 | code: { 287 | excludes: "_", 288 | attrs: {}, 289 | parseDOM: [{ tag: "code" }], 290 | group: "code excludeFromInputRules", 291 | }, 292 | strike: { 293 | attrs: {}, 294 | parseDOM: [ 295 | { tag: "s" }, 296 | { tag: "del" }, 297 | { tag: "strike" }, 298 | { style: "text-decoration" }, 299 | ], 300 | group: "fontStyle formattingMark", 301 | }, 302 | italic: { 303 | attrs: {}, 304 | parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], 305 | group: "fontStyle formattingMark", 306 | }, 307 | underline: { 308 | attrs: {}, 309 | parseDOM: [{ tag: "u" }, { style: "text-decoration" }], 310 | group: "fontStyle formattingMark", 311 | }, 312 | }, 313 | }; 314 | 315 | export default spec; 316 | -------------------------------------------------------------------------------- /ui/src/lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | import { lowercase, numbers } from "nanoid-dictionary"; 3 | 4 | // FIXME performance for reading this from localstorage 5 | export const getAuthHeaders = () => { 6 | let authToken = localStorage.getItem("token") || null; 7 | if (!authToken) return null; 8 | return { 9 | authorization: `Bearer ${authToken}`, 10 | }; 11 | }; 12 | 13 | export function timeDifference(current, previous) { 14 | const msPerMinute = 60 * 1000; 15 | const msPerHour = msPerMinute * 60; 16 | const msPerDay = msPerHour * 24; 17 | const msPerMonth = msPerDay * 30; 18 | const msPerYear = msPerDay * 365; 19 | const elapsed = current - previous; 20 | 21 | if (elapsed < msPerMinute) { 22 | return Math.round(elapsed / 1000) + " seconds ago"; 23 | } else if (elapsed < msPerHour) { 24 | return Math.round(elapsed / msPerMinute) + " minutes ago"; 25 | } else if (elapsed < msPerDay) { 26 | return Math.round(elapsed / msPerHour) + " hours ago"; 27 | } else if (elapsed < msPerMonth) { 28 | return Math.round(elapsed / msPerDay) + " days ago"; 29 | } else if (elapsed < msPerYear) { 30 | return Math.round(elapsed / msPerMonth) + " months ago"; 31 | } else { 32 | return Math.round(elapsed / msPerYear) + " years ago"; 33 | } 34 | } 35 | 36 | // pretty print the time difference 37 | export function prettyPrintTime(d) { 38 | let year = d.getUTCFullYear() - 1970; 39 | let month = d.getUTCMonth(); 40 | let day = d.getUTCDate() - 1; 41 | let hour = d.getUTCHours(); 42 | let minute = d.getUTCMinutes(); 43 | let second = d.getUTCSeconds(); 44 | return ( 45 | (year > 0 ? year + "y" : "") + 46 | (month > 0 ? month + "m" : "") + 47 | (day > 0 ? day + "d" : "") + 48 | (hour > 0 ? hour + "h" : "") + 49 | (minute >= 0 ? minute + "m" : "") + 50 | (second > 0 ? second + "s" : "") 51 | ); 52 | } 53 | 54 | export function getUpTime(startedAt: string) { 55 | let d1 = new Date(parseInt(startedAt)); 56 | let now = new Date(); 57 | let diff = new Date(now.getTime() - d1.getTime()); 58 | let prettyTime = prettyPrintTime(diff); 59 | return prettyTime; 60 | } 61 | 62 | export const myNanoId = customAlphabet(lowercase + numbers, 20); 63 | 64 | export const level2color = { 65 | 0: "rgba(187, 222, 251, 0.5)", 66 | 1: "rgba(144, 202, 249, 0.5)", 67 | 2: "rgba(100, 181, 246, 0.5)", 68 | 3: "rgba(66, 165, 245, 0.5)", 69 | 4: "rgba(33, 150, 243, 0.5)", 70 | // default: "rgba(255, 255, 255, 0.2)", 71 | default: "rgba(240,240,240,0.25)", 72 | }; 73 | 74 | const yRemoteSelectionStyle = (clientID: string, color: string) => { 75 | return `.yRemoteSelection-${clientID} 76 | { background-color: ${color}; opacity: 0.5;} `; 77 | }; 78 | 79 | const yRemoteSelectionHeadStyle = (clientID: string, color: string) => { 80 | return `.yRemoteSelectionHead-${clientID} { 81 | position: absolute; 82 | border-left: ${color} solid 2px; 83 | border-top: ${color} solid 2px; 84 | border-bottom: ${color} solid 2px; 85 | height: 100%; 86 | box-sizing: border-box;}`; 87 | }; 88 | 89 | const yRemoteSelectionHeadHoverStyle = ( 90 | clientID: string, 91 | color: string, 92 | name: string 93 | ) => { 94 | return `.yRemoteSelectionHead-${clientID}:hover::after { 95 | content: "${name}"; 96 | background-color: ${color}; 97 | box-shadow: 0 0 0 2px ${color}; 98 | border: 1px solid ${color}; 99 | color: white; 100 | opacity: 1; }`; 101 | }; 102 | 103 | export function addAwarenessStyle( 104 | clientID: string, 105 | color: string, 106 | name: string 107 | ) { 108 | const styles = document.createElement("style"); 109 | styles.append(yRemoteSelectionStyle(clientID, color)); 110 | styles.append(yRemoteSelectionHeadStyle(clientID, color)); 111 | styles.append(yRemoteSelectionHeadHoverStyle(clientID, color, name)); 112 | document.head.append(styles); 113 | } 114 | -------------------------------------------------------------------------------- /ui/src/lib/utils/y-utils.ts: -------------------------------------------------------------------------------- 1 | import myspec from "./rich-schema"; 2 | import { Schema, Node as PMNode } from "prosemirror-model"; 3 | import { 4 | prosemirrorToYDoc, 5 | prosemirrorToYXmlFragment, 6 | yXmlFragmentToProsemirrorJSON, 7 | } from "y-prosemirror"; 8 | 9 | /** 10 | * From prosemirror json to Y.XmlFragment. 11 | * @param json Parsed json object. 12 | * @returns 13 | */ 14 | export function json2yxml(json: Object) { 15 | const myschema = new Schema(myspec); 16 | const doc2 = PMNode.fromJSON(myschema, json); 17 | // console.log("PMDoc2", doc2); 18 | const yxml = prosemirrorToYXmlFragment(doc2); 19 | // console.log("Ydoc2", ydoc2.toJSON()); 20 | return yxml; 21 | } 22 | 23 | export function yxml2json(yxml) { 24 | return yXmlFragmentToProsemirrorJSON(yxml); 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /ui/src/pages/repo.tsx: -------------------------------------------------------------------------------- 1 | import { useParams, useNavigate } from "react-router-dom"; 2 | import { Link as ReactLink } from "react-router-dom"; 3 | import Box from "@mui/material/Box"; 4 | import Link from "@mui/material/Link"; 5 | import Alert from "@mui/material/Alert"; 6 | import AlertTitle from "@mui/material/AlertTitle"; 7 | import ShareIcon from "@mui/icons-material/Share"; 8 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 9 | import Button from "@mui/material/Button"; 10 | import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client"; 11 | 12 | import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; 13 | import ChevronRightIcon from "@mui/icons-material/ChevronRight"; 14 | 15 | import { useEffect, useState, useRef, useContext, memo } from "react"; 16 | 17 | import * as React from "react"; 18 | 19 | import { useStore } from "zustand"; 20 | 21 | import { createRepoStore, RepoContext } from "../lib/store"; 22 | 23 | import { Canvas } from "../components/Canvas"; 24 | import { Header } from "../components/Header"; 25 | import { Sidebar } from "../components/Sidebar"; 26 | import { useLocalStorage } from "../hooks/useLocalStorage"; 27 | import { 28 | Breadcrumbs, 29 | Drawer, 30 | IconButton, 31 | Stack, 32 | TextField, 33 | Tooltip, 34 | Typography, 35 | } from "@mui/material"; 36 | import { initParser } from "../lib/parser"; 37 | 38 | import { usePrompt } from "../lib/prompt"; 39 | 40 | const HeaderItem = memo(() => { 41 | const store = useContext(RepoContext)!; 42 | const repoName = useStore(store, (state) => state.repoName); 43 | const repoNameDirty = useStore(store, (state) => state.repoNameDirty); 44 | const setRepoName = useStore(store, (state) => state.setRepoName); 45 | const apolloClient = useApolloClient(); 46 | const remoteUpdateRepoName = useStore( 47 | store, 48 | (state) => state.remoteUpdateRepoName 49 | ); 50 | const editMode = useStore(store, (state) => state.editMode); 51 | 52 | usePrompt( 53 | "Repo name not saved. Do you want to leave this page?", 54 | repoNameDirty 55 | ); 56 | 57 | useEffect(() => { 58 | remoteUpdateRepoName(apolloClient); 59 | let intervalId = setInterval(() => { 60 | remoteUpdateRepoName(apolloClient); 61 | }, 1000); 62 | return () => { 63 | clearInterval(intervalId); 64 | }; 65 | // eslint-disable-next-line react-hooks/exhaustive-deps 66 | }, []); 67 | 68 | const [focus, setFocus] = useState(false); 69 | const [enter, setEnter] = useState(false); 70 | 71 | const textfield = ( 72 | { 79 | setFocus(true); 80 | }} 81 | onKeyDown={(e) => { 82 | if (["Enter", "Escape"].includes(e.key)) { 83 | e.preventDefault(); 84 | setFocus(false); 85 | } 86 | }} 87 | onMouseEnter={() => { 88 | setEnter(true); 89 | }} 90 | onMouseLeave={() => { 91 | setEnter(false); 92 | }} 93 | autoFocus={focus ? true : false} 94 | onBlur={() => { 95 | setFocus(false); 96 | }} 97 | InputProps={{ 98 | ...(focus 99 | ? {} 100 | : { 101 | disableUnderline: true, 102 | }), 103 | }} 104 | sx={{ 105 | // Try to compute a correct width so that the textfield size changes 106 | // according to content size. 107 | width: `${((repoName?.length || 0) + 6) * 6}px`, 108 | minWidth: "100px", 109 | maxWidth: "500px", 110 | border: "none", 111 | }} 112 | disabled={editMode === "view"} 113 | onChange={(e) => { 114 | const name = e.target.value; 115 | setRepoName(name); 116 | }} 117 | /> 118 | ); 119 | 120 | return ( 121 | 128 | {!focus && enter ? ( 129 | 138 | {textfield} 139 | 140 | ) : ( 141 | textfield 142 | )} 143 | {repoNameDirty && saving..} 144 | 145 | ); 146 | }); 147 | 148 | function RepoHeader() { 149 | const store = useContext(RepoContext)!; 150 | 151 | const setShareOpen = useStore(store, (state) => state.setShareOpen); 152 | const navigate = useNavigate(); 153 | return ( 154 |
155 | 163 | 164 | CodePod 165 | 166 | {/* */} 167 | 168 | 175 | 182 | 183 |
184 | ); 185 | } 186 | 187 | /** 188 | * Wrap the repo page with a header, a sidebar and a canvas. 189 | */ 190 | function HeaderWrapper({ children }) { 191 | const store = useContext(RepoContext)!; 192 | const isSidebarOnLeftHand = useStore( 193 | store, 194 | (state) => state.isSidebarOnLeftHand 195 | ); 196 | const [open, setOpen] = useState(true); 197 | let sidebar_width = "240px"; 198 | let header_height = "50px"; 199 | 200 | return ( 201 | 206 | {/* The header. */} 207 | 208 | {/* The sidebar */} 209 | 222 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | {/* The button to toggle sidebar. */} 237 | 248 | { 250 | setOpen(!open); 251 | }} 252 | size="small" 253 | color="primary" 254 | > 255 | {isSidebarOnLeftHand ? ( 256 | open ? ( 257 | 258 | ) : ( 259 | 260 | ) 261 | ) : open ? ( 262 | 263 | ) : ( 264 | 265 | )} 266 | 267 | 268 | 269 | {/* The Canvas */} 270 | 281 | 290 | {children} 291 | 292 | 293 | 294 | ); 295 | } 296 | 297 | function NotFoundAlert({}) { 298 | return ( 299 | 300 | 301 | Error 302 | The repo you are looking for is not found. Please check the URL. Go back 303 | your{" "} 304 | 305 | dashboard 306 | 307 | 308 | 309 | ); 310 | } 311 | 312 | function RepoLoader({ children }) { 313 | const store = useContext(RepoContext)!; 314 | const setEditMode = useStore(store, (state) => state.setEditMode); 315 | setEditMode("edit"); 316 | return children; 317 | } 318 | 319 | /** 320 | * This loads repo metadata. 321 | */ 322 | function ParserWrapper({ children }) { 323 | const store = useContext(RepoContext)!; 324 | const parseAllPods = useStore(store, (state) => state.parseAllPods); 325 | const resolveAllPods = useStore(store, (state) => state.resolveAllPods); 326 | const [parserLoaded, setParserLoaded] = useState(false); 327 | const scopedVars = useStore(store, (state) => state.scopedVars); 328 | 329 | useEffect(() => { 330 | initParser("/", () => { 331 | setParserLoaded(true); 332 | }); 333 | }, []); 334 | 335 | useEffect(() => { 336 | if (parserLoaded) { 337 | parseAllPods(); 338 | resolveAllPods(); 339 | } 340 | }, [parseAllPods, parserLoaded, resolveAllPods, scopedVars]); 341 | 342 | return children; 343 | } 344 | 345 | function WaitForProvider({ children, yjsWsUrl }) { 346 | const store = useContext(RepoContext)!; 347 | const providerSynced = useStore(store, (state) => state.providerSynced); 348 | const disconnectYjs = useStore(store, (state) => state.disconnectYjs); 349 | const connectYjs = useStore(store, (state) => state.connectYjs); 350 | useEffect(() => { 351 | connectYjs({ yjsWsUrl, name: "Local" }); 352 | return () => { 353 | disconnectYjs(); 354 | }; 355 | }, [connectYjs, disconnectYjs]); 356 | if (!providerSynced) return Loading Yjs Doc ..; 357 | return children; 358 | } 359 | 360 | export function Repo({ yjsWsUrl }) { 361 | const store = useRef(createRepoStore()).current; 362 | 363 | return ( 364 | 365 | 366 | 367 | 368 | 369 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | ); 385 | } 386 | -------------------------------------------------------------------------------- /ui/src/tests/parser.test.tsx.txt: -------------------------------------------------------------------------------- 1 | // import { describe, expect, test } from "@jest/globals"; 2 | import { analyzeCode, initParserForTest } from "../lib/parser"; 3 | import * as fs from "fs"; 4 | 5 | describe("sum module", () => { 6 | test("adds 1 + 2 to equal 3", () => { 7 | expect(1 + 2).toBe(3); 8 | }); 9 | 10 | test("initparser", async () => { 11 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 12 | let res = await initParserForTest(); 13 | expect(res).toBe(true); 14 | }); 15 | 16 | test.skip("parse a simple code", async () => { 17 | const code = ` 18 | x = 1 19 | y = x + 1 20 | def foo(a,b): 21 | return a+b 22 | `; 23 | analyzeCode(code); 24 | }); 25 | 26 | test("parse python3.8.py", async () => { 27 | let code = fs.readFileSync("./src/tests/python3.8-grammar.py", { 28 | encoding: "utf8", 29 | flag: "r", 30 | }); 31 | let { error_messages } = analyzeCode(code); 32 | expect(error_messages).toStrictEqual([]); 33 | }); 34 | 35 | test.skip("parse all py files", async () => { 36 | let files = fs.readdirSync("./src/tests/"); 37 | files 38 | .filter((f) => f.endsWith(".py")) 39 | .forEach((f) => { 40 | console.log("=== testing", f); 41 | let code = fs.readFileSync("./src/tests/" + f, { 42 | encoding: "utf8", 43 | flag: "r", 44 | }); 45 | let { errors } = analyzeCode(code); 46 | expect(errors).toStrictEqual([]); 47 | }); 48 | }); 49 | 50 | test("parse subscript LHS", async () => { 51 | let { annotations, errors } = analyzeCode(` 52 | x = [1,2,3] 53 | x[0] = 1 54 | `); 55 | expect(annotations).toStrictEqual([ 56 | { 57 | name: "x", 58 | type: "vardef", 59 | startIndex: 8, 60 | endIndex: 9, 61 | startPosition: { row: 1, column: 7 }, 62 | endPosition: { row: 1, column: 8 }, 63 | }, 64 | { 65 | name: "x", 66 | type: "varuse", 67 | startIndex: 27, 68 | endIndex: 28, 69 | startPosition: { row: 2, column: 7 }, 70 | endPosition: { row: 2, column: 8 }, 71 | }, 72 | ]); 73 | }); 74 | 75 | test("parse recursive funciton", async () => { 76 | // Here, the recursive function's call site should be recorgnized, not to be 77 | // parsed as a bound-variable. Ref: 78 | // https://github.com/codepod-io/codepod/issues/366 79 | let { annotations, errors } = analyzeCode(` 80 | def recur(x): 81 | if x == 1: 82 | return 0 83 | else: 84 | return 1 + recur(x-1) 85 | `); 86 | expect(annotations).toStrictEqual([ 87 | { 88 | name: "recur", 89 | type: "function", 90 | startIndex: 5, 91 | endIndex: 10, 92 | startPosition: { row: 1, column: 4 }, 93 | endPosition: { row: 1, column: 9 }, 94 | }, 95 | { 96 | name: "recur", 97 | type: "varuse", 98 | startIndex: 68, 99 | endIndex: 73, 100 | startPosition: { row: 5, column: 17 }, 101 | endPosition: { row: 5, column: 22 }, 102 | }, 103 | ]); 104 | }); 105 | 106 | test("parse attribute LHS", async () => { 107 | let { annotations } = analyzeCode(` 108 | a.b = 3 109 | `); 110 | expect(annotations).toStrictEqual([ 111 | { 112 | name: "a", 113 | type: "varuse", 114 | startIndex: 5, 115 | endIndex: 6, 116 | startPosition: { row: 1, column: 4 }, 117 | endPosition: { row: 1, column: 5 }, 118 | }, 119 | ]); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /ui/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface Window { 5 | GOOGLE_CLIENT_ID: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable", 9 | // support for String.replaceAll 10 | "ES2021.String" 11 | ], 12 | "module": "ESNext", 13 | "skipLibCheck": true, 14 | 15 | /* Bundler mode */ 16 | "moduleResolution": "bundler", 17 | "allowImportingTsExtensions": true, 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | // FIXME remove this once we have fixed all the errors. 26 | "noImplicitAny": false, 27 | // "noUnusedLocals": true, 28 | // "noUnusedParameters": true, 29 | "noFallthroughCasesInSwitch": true, 30 | // For Remirror to work 31 | "experimentalDecorators": true 32 | }, 33 | "include": ["src"], 34 | "references": [{ "path": "./tsconfig.node.json" }] 35 | } 36 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | // import react from "@vitejs/plugin-react"; 4 | import checker from "vite-plugin-checker"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | server: { port: 3000 }, 9 | build: { 10 | outDir: "../api/public", 11 | }, 12 | plugins: [ 13 | react({ tsDecorators: true }), 14 | checker({ 15 | // e.g. use TypeScript check 16 | typescript: true, 17 | }), 18 | ], 19 | }); 20 | --------------------------------------------------------------------------------