diff --git a/forge.config.ts b/forge.config.ts index 385b02f96..c9fbaf19e 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -10,7 +10,7 @@ import type { ForgeConfig, ForgeMakeResult } from '@electron-forge/shared-types' import path from 'path' import { CUSTOM_APP_PROTOCOL } from './src/main/deepLinks.constants' -import { getPlatform, getArch } from './src/utils/electron' +import { getPlatform, getArch } from './src/utils/platform' import { windowsSign } from './windowsSign' import { spawnSignFile } from './windowsSignHook' diff --git a/package-lock.json b/package-lock.json index 6258b0a01..3ef7de4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@emotion/styled": "^11.13.0", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^3.9.0", + "@iarna/toml": "^2.2.5", "@medv/finder": "^4.0.2", "@microlink/react-json-view": "^1.27.0", "@monaco-editor/react": "^4.7.0", @@ -61,17 +62,20 @@ "express": "^5.2.1", "find-process": "^1.4.7", "fuse.js": "^7.0.0", + "graceful-fs": "^4.2.11", "https-proxy-agent": "^7.0.6", "immer": "^10.1.1", "jsonrepair": "^3.8.0", "keyboardjs": "^2.7.0", "lodash-es": "^4.17.21", "lucide-react": "^0.562.0", + "micromatch": "^4.0.8", "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "node-forge": "^1.3.2", "openid-client": "^6.1.7", "papaparse": "^5.5.1", + "pathe": "^2.0.3", "plist": "^3.1.0", "prettier": "^3.3.2", "react": "^19.2.4", @@ -81,6 +85,7 @@ "react-router-dom": "^6.30.3", "react-select": "^5.10.2", "react-use": "^17.5.1", + "readdirp": "^4.0.1", "rrweb": "^2.0.0-alpha.18", "tiny-invariant": "^1.3.3", "tree-kill": "^1.2.2", @@ -112,9 +117,11 @@ "@types/diff": "^7.0.2", "@types/electron-squirrel-startup": "^1.0.2", "@types/express": "^5.0.3", + "@types/graceful-fs": "^4.1.9", "@types/har-format": "^1.2.16", "@types/k6": "^1.3.1", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.10", "@types/node-forge": "^1.3.11", "@types/papaparse": "^5.3.15", "@types/react": "^19.2.10", @@ -330,7 +337,6 @@ "version": "7.24.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -733,7 +739,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1259,7 +1264,6 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -1702,7 +1706,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2387,6 +2390,12 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@inquirer/checkbox": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", @@ -2665,7 +2674,6 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -3002,7 +3010,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3313,7 +3320,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3335,7 +3341,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -3372,7 +3377,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -3903,7 +3907,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.31.0.tgz", "integrity": "sha512-cYJeP+6qN0UnBv1r09hXl0YorB8kXHv61BC0NUlBA8vxrylZ4/C8lnva3gd1E8n33DNYSaiGW+DuGoSt0QQ7Dw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -6698,6 +6701,13 @@ "@types/node": "*" } }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -6850,6 +6860,16 @@ "@types/node": "*" } }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/har-format": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", @@ -6919,6 +6939,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -6957,7 +6987,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7034,7 +7063,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7045,7 +7073,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7150,7 +7177,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz", "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.16.1", @@ -7731,6 +7757,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/snapshot": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", @@ -7746,6 +7779,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/spy": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", @@ -8030,7 +8070,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9233,7 +9272,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11448,6 +11486,16 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -11798,7 +11846,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11937,7 +11984,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -13463,7 +13509,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, "node_modules/graceful-readlink": { "version": "1.0.1", @@ -13825,7 +13872,6 @@ "version": "10.1.1", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -15788,6 +15834,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -16011,7 +16058,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -17225,10 +17271,9 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, "node_modules/pathval": { @@ -17914,7 +17959,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17939,7 +17983,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17951,7 +17994,6 @@ "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18904,7 +18946,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20414,8 +20455,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "peer": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/type-check": { "version": "0.4.0", @@ -20570,7 +20610,6 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21178,7 +21217,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -21281,6 +21319,13 @@ "dev": true, "license": "MIT" }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite-plugin-web-extension": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/vite-plugin-web-extension/-/vite-plugin-web-extension-4.4.3.tgz", @@ -21460,6 +21505,13 @@ "dev": true, "license": "MIT" }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -22301,7 +22353,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1d1ed7bf3..06686310f 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,11 @@ "@types/diff": "^7.0.2", "@types/electron-squirrel-startup": "^1.0.2", "@types/express": "^5.0.3", + "@types/graceful-fs": "^4.1.9", "@types/har-format": "^1.2.16", "@types/k6": "^1.3.1", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.10", "@types/node-forge": "^1.3.11", "@types/papaparse": "^5.3.15", "@types/react": "^19.2.10", @@ -92,6 +94,7 @@ "@emotion/styled": "^11.13.0", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^3.9.0", + "@iarna/toml": "^2.2.5", "@medv/finder": "^4.0.2", "@microlink/react-json-view": "^1.27.0", "@monaco-editor/react": "^4.7.0", @@ -133,17 +136,20 @@ "express": "^5.2.1", "find-process": "^1.4.7", "fuse.js": "^7.0.0", + "graceful-fs": "^4.2.11", "https-proxy-agent": "^7.0.6", "immer": "^10.1.1", "jsonrepair": "^3.8.0", "keyboardjs": "^2.7.0", "lodash-es": "^4.17.21", "lucide-react": "^0.562.0", + "micromatch": "^4.0.8", "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "node-forge": "^1.3.2", "openid-client": "^6.1.7", "papaparse": "^5.5.1", + "pathe": "^2.0.3", "plist": "^3.1.0", "prettier": "^3.3.2", "react": "^19.2.4", @@ -153,6 +159,7 @@ "react-router-dom": "^6.30.3", "react-select": "^5.10.2", "react-use": "^17.5.1", + "readdirp": "^4.0.1", "rrweb": "^2.0.0-alpha.18", "tiny-invariant": "^1.3.3", "tree-kill": "^1.2.2", diff --git a/src/App.tsx b/src/App.tsx index 9e9ae2895..efe68c933 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import { DevToolsDialog } from '@/components/DevToolsDialog' import { SettingsDialog } from '@/components/Settings/SettingsDialog' import { Toasts } from '@/components/Toast/Toasts' import { Theme as StudioTheme } from '@/components/primitives/Theme' -import { useCloseSplashScreen } from '@/hooks/useCloseSplashScreen' +import { WorkspaceProvider, useWorkspace } from '@/contexts/WorkspaceContext' import { useTheme } from '@/hooks/useTheme' import { queryClient } from '@/utils/query' @@ -16,9 +16,9 @@ import { globalStyles } from './globalStyles' enableMapSet() -export function App() { +function AppContent() { const theme = useTheme() - useCloseSplashScreen() + const workspace = useWorkspace() return ( @@ -26,10 +26,18 @@ export function App() { - + ) } + +export function App() { + return ( + + + + ) +} diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index c5221d75e..47820f143 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -9,16 +9,14 @@ import { } from 'react-router-dom' import { Layout } from '@/components/Layout/Layout' -import { BrowserTestEditor } from '@/views/BrowserTestEditor' +import { EditorView } from '@/views/EditorView' import { Home } from '@/views/Home' import { Recorder } from '@/views/Recorder' -import { RecordingPreviewer } from '@/views/RecordingPreviewer' -import { Validator } from '@/views/Validator' import { ErrorElement } from './ErrorElement' import { routeMap } from './routeMap' -import { DataFile } from './views/DataFile' -import { Generator } from './views/Generator' +import { NewBrowserTestView } from './views/BrowserTestEditor/NewBrowserTestView' +import { NewGeneratorView } from './views/Generator/NewGeneratorView' const router = createHashRouter( createRoutesFromChildren( @@ -29,17 +27,9 @@ const router = createHashRouter( > } /> } /> - } - /> - } /> - } - /> - } /> - } /> + } /> + } /> + } /> } /> ) diff --git a/src/codegen/codegen.test.ts b/src/codegen/codegen.test.ts index e843ec535..11603f2d9 100644 --- a/src/codegen/codegen.test.ts +++ b/src/codegen/codegen.test.ts @@ -77,9 +77,10 @@ describe('Code generation', () => { expect( generateScript({ + scriptPath: '/root/script.js', recording: [], generator: { - version: '2.0', + version: '3.0', recordingPath: 'test', options: { loadProfile: { @@ -118,7 +119,7 @@ describe('Code generation', () => { describe('generateImports', () => { const generator: GeneratorFileData = { - version: '2.0', + version: '3.0', recordingPath: 'test', options: { loadProfile: { @@ -162,7 +163,7 @@ describe('Code generation', () => { }) it('should generate imports with data files', async () => { - const files = [{ name: 'users.csv' }, { name: 'products.json' }] + const files = [{ path: 'users.csv' }, { path: 'products.json' }] const expectedResult = await prettify(` import { group, sleep, check } from 'k6' import http from 'k6/http' @@ -221,7 +222,10 @@ describe('Code generation', () => { describe('generateDataFileDeclarations', () => { it('should generate file declarations', async () => { - const files = [{ name: 'users.csv' }, { name: 'products.json' }] + const files = [ + { path: '/Root/Data/users.csv' }, + { path: '/Root/Data/products.json' }, + ] const expectedResult = await prettify(` const FILES = { @@ -233,9 +237,11 @@ describe('Code generation', () => { }), };`) - expect(await prettify(generateDataFileDeclarations(files))).toBe( - expectedResult - ) + expect( + await prettify( + generateDataFileDeclarations(files, '/Root/Scripts/script.js') + ) + ).toBe(expectedResult) }) }) diff --git a/src/codegen/codegen.ts b/src/codegen/codegen.ts index 06c0e071e..5186f5255 100644 --- a/src/codegen/codegen.ts +++ b/src/codegen/codegen.ts @@ -1,3 +1,5 @@ +import * as pathe from 'pathe' + import { K6_EXPORTS, REQUIRED_IMPORTS } from '@/constants/imports' import { getCustomCodeSnippet } from '@/rules/parameterization' import { applyRules } from '@/rules/rules' @@ -8,6 +10,7 @@ import { DataFile, Variable } from '@/types/testData' import { ThinkTime } from '@/types/testOptions' import { getFileNameWithoutExtension } from '@/utils/file' import { safeBtoa } from '@/utils/format' +import { makeRelativePath } from '@/utils/fs/path' import { groupProxyData } from '@/utils/groups' import { getContentTypeWithCharsetHeader } from '@/utils/headers' import { exhaustive } from '@/utils/typescript' @@ -22,11 +25,13 @@ import { generateImportStatement } from './imports' import { generateOptions } from './options' interface GenerateScriptParams { + scriptPath: string recording: ProxyData[] generator: GeneratorFileData } export function generateScript({ + scriptPath, recording, generator, }: GenerateScriptParams): string { @@ -42,7 +47,7 @@ export function generateScript({ export const options = ${generateOptions(generator.options)} ${generateVariableDeclarations(generator.testData.variables)} - ${generateDataFileDeclarations(generator.testData.files)} + ${generateDataFileDeclarations(generator.testData.files, scriptPath)} ${generateGetUniqueItemFunction(generator.testData.files)} export default function() { @@ -59,11 +64,11 @@ export function generateImports( generator: GeneratorFileData, options: GenerateImportsOptions = {} ): string { - const hasCSVDataFiles = generator.testData.files.some(({ name }) => - name.toLowerCase().endsWith('csv') + const hasCSVDataFiles = generator.testData.files.some((file: DataFile) => + pathe.basename(file.path).toLowerCase().endsWith('csv') ) - const hasJSONDataFiles = generator.testData.files.some(({ name }) => - name.toLowerCase().endsWith('json') + const hasJSONDataFiles = generator.testData.files.some((file: DataFile) => + pathe.basename(file.path).toLowerCase().endsWith('json') ) const imports = [ ...REQUIRED_IMPORTS, @@ -93,24 +98,33 @@ export function generateVariableDeclarations(variables: Variable[]): string { return `const VARS = {\n${variableKeyValuePairs}\n};` } -export function generateDataFileDeclarations(files: DataFile[]): string { +export function generateDataFileDeclarations( + files: DataFile[], + scriptPath: string | undefined +): string { if (files.length === 0) { return '' } const fileKeyValuePairs = files - .map(({ name }) => { + .map((file: DataFile) => { + const name = pathe.basename(file.path) const displayName = getFileNameWithoutExtension(name) const isCSV = name.toLowerCase().endsWith('csv') + // If the script path is not provided, just assume that the script is in the same + // directory as the data file. + const scriptDir = pathe.dirname(scriptPath ?? file.path) + const relativePath = makeRelativePath(scriptDir, file.path) + if (isCSV) { return ` - "${displayName}": await csv.parse(await fs.open('../Data/${name}'), { asObjects: true })` + "${displayName}": await csv.parse(await fs.open('${relativePath}'), { asObjects: true })` } return ` "${displayName}": new SharedArray("${displayName}", () => { - const data = JSON.parse(open('../Data/${name}')); + const data = JSON.parse(open('${relativePath}')); return Array.isArray(data) ? data : [data]; })` }) diff --git a/src/components/FileTree/File.tsx b/src/components/FileTree/File.tsx index 4144f780f..40ae7d325 100644 --- a/src/components/FileTree/File.tsx +++ b/src/components/FileTree/File.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react' -import { Grid, Tooltip } from '@radix-ui/themes' +import { Flex, Grid, Tooltip } from '@radix-ui/themes' import { useRef } from 'react' import { NavLink } from 'react-router-dom' import { useBoolean } from 'react-use' @@ -11,6 +11,7 @@ import { getFileExtension, getViewPath } from '@/utils/file' import { HighlightedText } from '../HighlightedText' import { FileActionsMenu, FileContextMenu } from './FileContextMenu' +import { FileEntryIcon } from './FileEntryIcon' import { InlineEditor } from './InlineEditor' import { FileItem } from './types' @@ -19,14 +20,22 @@ interface FileProps { isSelected: boolean } -const fileStyle = css` - display: block; - padding: var(--space-1) var(--space-2) var(--space-1) var(--space-5); +const fileRowStyle = css` + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + padding: var(--file-entry-spacing) var(--file-entry-spacing) + var(--file-entry-spacing) var(--space-4); font-size: 12px; - line-height: 22px; color: var(--gray-11); ` +const fileStyle = css` + flex: 1 1 0; + min-width: 0; +` + export function File({ file, isSelected }: FileProps) { const [editMode, setEditMode] = useBoolean(false) @@ -37,9 +46,9 @@ export function File({ file, isSelected }: FileProps) { onRename={() => setEditMode(true)} > button { opacity: 0; @@ -55,6 +64,7 @@ export function File({ file, isSelected }: FileProps) { background-color: var(--gray-4); } `} + pr="1" > setEditMode(false)} - style={fileStyle} - /> + + + setEditMode(false)} + style={[ + fileStyle, + css` + flex: 1 1 0; + min-width: 0; + `, + ]} + /> + ) } @@ -112,13 +137,11 @@ function EditableFile({ - + + + + ) } export function NoFileMessage({ message }: { message: string }) { - return {message} + return ( + + {message} + + ) } diff --git a/src/components/FileTree/FileEntryIcon.tsx b/src/components/FileTree/FileEntryIcon.tsx new file mode 100644 index 000000000..ff7e76be8 --- /dev/null +++ b/src/components/FileTree/FileEntryIcon.tsx @@ -0,0 +1,67 @@ +import { css } from '@emotion/react' +import { + DatabaseIcon, + FileArchiveIcon, + FileCodeIcon, + FileCogIcon, + FileIcon, + LucideProps, + MonitorIcon, +} from 'lucide-react' + +import { FileType } from '@/types' + +export interface FileEntryIconProps extends LucideProps { + fileType: FileType | undefined +} + +export function FileEntryIcon({ fileType, ...props }: FileEntryIconProps) { + switch (fileType) { + case 'script': + return ( + + ) + + case 'recording': + return ( + + ) + + case 'browser-test': + return ( + + ) + + case 'generator': + return ( + + ) + + case 'json': + case 'csv': + return + + default: + return + } +} diff --git a/src/components/FileTree/FileList.tsx b/src/components/FileTree/FileList.tsx index 9882e0b6e..e011a6184 100644 --- a/src/components/FileTree/FileList.tsx +++ b/src/components/FileTree/FileList.tsx @@ -11,7 +11,7 @@ interface FileListProps { } export function FileList({ files, noFilesMessage }: FileListProps) { - const { fileName: currentFile } = useParams() + const { path: currentPath } = useParams() if (files.length === 0) { return @@ -22,12 +22,18 @@ export function FileList({ files, noFilesMessage }: FileListProps) { css={css` list-style: none; padding: 0; - margin: var(--space-1) 0 0; + margin: 0; `} > {files.map((file) => ( -
  • - +
  • +
  • ))} diff --git a/src/components/FileTree/FileTree.tsx b/src/components/FileTree/FileTree.tsx index 1b0def5ca..a9fde40c9 100644 --- a/src/components/FileTree/FileTree.tsx +++ b/src/components/FileTree/FileTree.tsx @@ -48,7 +48,13 @@ export function FileTree({ {actions} - + diff --git a/src/components/Layout/ActivityBar/ActivityBar.tsx b/src/components/Layout/ActivityBar/ActivityBar.tsx index 5e78078c2..a1eb29c72 100644 --- a/src/components/Layout/ActivityBar/ActivityBar.tsx +++ b/src/components/Layout/ActivityBar/ActivityBar.tsx @@ -1,14 +1,21 @@ import { css } from '@emotion/react' import { Flex, Grid, Separator } from '@radix-ui/themes' +import { + FileVideoCamera, + FolderTreeIcon, + HammerIcon, + PlayIcon, +} from 'lucide-react' import { Link } from 'react-router-dom' import k6LogoDark from '@/assets/logo-dark.svg' import k6Logo from '@/assets/logo.svg' import { ThemeSwitcher } from '@/components/ThemeSwitcher' -import { HomeIcon } from '@/components/icons' import { useTheme } from '@/hooks/useTheme' import { getRoutePath } from '@/routeMap' +import type { SidebarView } from '../Layout' + import { HelpButton } from './HelpButton' import { NavIconButton } from './NavIconButton' import { Profile } from './Profile' @@ -16,7 +23,15 @@ import { ProxyStatusIndicator } from './ProxyStatusIndicator' import { SettingsButton } from './SettingsButton' import { VersionLabel } from './VersionLabel' -export function ActivityBar() { +interface ActivityBarProps { + sidebarView: SidebarView + onSidebarViewChange: (view: SidebarView) => void +} + +export function ActivityBar({ + sidebarView, + onSidebarViewChange, +}: ActivityBarProps) { const theme = useTheme() return ( @@ -43,12 +58,35 @@ export function ActivityBar() { width="32" /> - + } - tooltip="Home" - /> + tooltip="Workspace" + active={sidebarView === 'workspace'} + onClick={() => onSidebarViewChange('workspace')} + > + + + onSidebarViewChange('record')} + > + + + onSidebarViewChange('build')} + > + + + onSidebarViewChange('debug')} + > + + diff --git a/src/components/Layout/ActivityBar/NavIconButton.tsx b/src/components/Layout/ActivityBar/NavIconButton.tsx index d4765e8d4..de5b2ceb1 100644 --- a/src/components/Layout/ActivityBar/NavIconButton.tsx +++ b/src/components/Layout/ActivityBar/NavIconButton.tsx @@ -1,30 +1,53 @@ +import { css } from '@emotion/react' import { IconButton, Tooltip } from '@radix-ui/themes' import { ReactNode } from 'react' import { Link } from 'react-router-dom' interface NavIconButtonProps { to?: string - icon: ReactNode tooltip: string + active?: boolean + children: ReactNode onClick?: () => void } export function NavIconButton({ to, - icon, tooltip, + active, + children, onClick, }: NavIconButtonProps) { return ( - {to ? {icon} : icon} + {to !== undefined ? ( + + {children} + + ) : ( + children + )} ) diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index b6ec2d64e..af50b2d07 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react' import { Box, IconButton } from '@radix-ui/themes' import { Allotment } from 'allotment' import { PanelLeftOpenIcon } from 'lucide-react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Outlet, useLocation } from 'react-router-dom' import { useLocalStorage } from 'react-use' @@ -11,11 +11,14 @@ import { useListenDeepLinks } from '@/hooks/useListenDeepLinks' import { ActivityBar } from './ActivityBar' import { Sidebar } from './Sidebar' +export type SidebarView = 'workspace' | 'record' | 'build' | 'debug' + export function Layout() { const [isSidebarExpanded, setIsSidebarExpanded] = useLocalStorage( 'isSidebarExpanded', true ) + const [sidebarView, setSidebarView] = useState('workspace') const location = useLocation() useListenDeepLinks() @@ -62,7 +65,10 @@ export function Layout() { )} - + setIsSidebarExpanded(false)} + view={sidebarView} /> diff --git a/src/components/Layout/Sidebar/Sidebar.hooks.ts b/src/components/Layout/Sidebar/Sidebar.hooks.ts index 0df6b84e6..9a716d559 100644 --- a/src/components/Layout/Sidebar/Sidebar.hooks.ts +++ b/src/components/Layout/Sidebar/Sidebar.hooks.ts @@ -2,25 +2,36 @@ import Fuse, { IFuseOptions } from 'fuse.js' import { orderBy } from 'lodash-es' import { useEffect, useMemo } from 'react' +import type { FileItem } from '@/components/FileTree/types' import { useStudioUIStore } from '@/store/ui' import { StudioFile } from '@/types' import { withMatches } from '@/utils/fuse' -function orderByFileName(files: Map) { +export function orderByFileName(files: Map) { return orderBy([...files.values()], (s) => s.displayName) } function toFileMap(files: StudioFile[]) { - return new Map(files.map((file) => [file.fileName, file])) + return new Map(files.map((file) => [file.path, file])) } -function useFolderContent() { - const recordings = useStudioUIStore((s) => orderByFileName(s.recordings)) - const generators = useStudioUIStore((s) => orderByFileName(s.generators)) - const browserTests = useStudioUIStore((s) => orderByFileName(s.browserTests)) - const scripts = useStudioUIStore((s) => orderByFileName(s.scripts)) - const dataFiles = useStudioUIStore((s) => orderByFileName(s.dataFiles)) +const fuseListOptions: IFuseOptions = { + includeMatches: true, + // Though not perfect, these settings seem + // to yield pretty good results. It should allow + // single character typos anywhere in the string. + ignoreLocation: true, + distance: 1, + + keys: ['displayName'], +} + +/** + * Loads folder listings and subscribes to workspace file add/remove events. Call once + * (e.g. from the sidebar shell) so file lists stay in sync. + */ +export function useWorkspaceFolderSync() { const addFile = useStudioUIStore((s) => s.addFile) const removeFile = useStudioUIStore((s) => s.removeFile) const setFolderContent = useStudioUIStore((s) => s.setFolderContent) @@ -41,62 +52,30 @@ function useFolderContent() { useEffect( () => - window.studio.ui.onAddFile((file) => { + window.studio.workspace.onAddFile((file) => { addFile(file) }), [addFile] ) useEffect(() => { - window.studio.ui.onRemoveFile((file) => { + window.studio.workspace.onRemoveFile((file) => { removeFile(file) }) }, [removeFile]) - - return { - recordings, - tests: [...generators, ...browserTests].sort((a, b) => - a.displayName.localeCompare(b.displayName) - ), - scripts, - dataFiles, - } } -export function useFiles(searchTerm: string) { - const files = useFolderContent() - - const searchIndex = useMemo(() => { - const options: IFuseOptions = { - includeMatches: true, - - // Though not perfect, these settings seem - // to yield pretty good results. It should allow - // single character typos anywhere in the string. - ignoreLocation: true, - distance: 1, - - keys: ['displayName'], - } - - return { - recordings: new Fuse(files.recordings, options), - tests: new Fuse(files.tests, options), - scripts: new Fuse(files.scripts, options), - dataFiles: new Fuse(files.dataFiles, options), - } - }, [files]) +export function useFuzzyFileList( + files: StudioFile[], + searchTerm: string +): FileItem[] { + const fuse = useMemo(() => new Fuse(files, fuseListOptions), [files]) return useMemo(() => { if (searchTerm.match(/^\s*$/)) { return files } - return { - recordings: searchIndex.recordings.search(searchTerm).map(withMatches), - tests: searchIndex.tests.search(searchTerm).map(withMatches), - scripts: searchIndex.scripts.search(searchTerm).map(withMatches), - dataFiles: searchIndex.dataFiles.search(searchTerm).map(withMatches), - } - }, [files, searchIndex, searchTerm]) + return fuse.search(searchTerm).map(withMatches) + }, [files, fuse, searchTerm]) } diff --git a/src/components/Layout/Sidebar/Sidebar.tsx b/src/components/Layout/Sidebar/Sidebar.tsx index a78ff04ce..386c65057 100644 --- a/src/components/Layout/Sidebar/Sidebar.tsx +++ b/src/components/Layout/Sidebar/Sidebar.tsx @@ -1,33 +1,28 @@ import { css } from '@emotion/react' -import { Box, Flex, IconButton, ScrollArea, Tooltip } from '@radix-ui/themes' -import { FilePlusIcon, PanelLeftCloseIcon, PlusIcon } from 'lucide-react' -import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Box, Flex } from '@radix-ui/themes' -import { FileTree } from '@/components/FileTree' -import { NewTestMenu } from '@/components/NewTestMenu' -import { SearchField } from '@/components/SearchField' -import { useImportDataFile } from '@/hooks/useImportDataFile' -import { getRoutePath } from '@/routeMap' -import { useFeaturesStore } from '@/store/features' +import type { SidebarView } from '../Layout' -import { useFiles } from './Sidebar.hooks' +import { useWorkspaceFolderSync } from './Sidebar.hooks' +import { SidebarBuildView } from './SidebarBuildView' +import { SidebarDebugView } from './SidebarDebugView' +import { SidebarRecordView } from './SidebarRecordView' +import { SidebarWorkspaceView } from './SidebarWorkspaceView' interface SidebarProps { + view: SidebarView isExpanded?: boolean onCollapseSidebar: () => void } -export function Sidebar({ isExpanded, onCollapseSidebar }: SidebarProps) { - const [searchTerm, setSearchTerm] = useState('') - const { recordings, tests, scripts, dataFiles } = useFiles(searchTerm) - const handleImportDataFile = useImportDataFile() - const isBrowserEditorEnabled = useFeaturesStore( - (state) => state.features['browser-test-editor'] - ) +export function Sidebar({ view, onCollapseSidebar }: SidebarProps) { + useWorkspaceFolderSync() return ( - - + - - {isExpanded && ( - - - - )} - - - - - - - - - - - - - } - /> - } - /> - - - - - - - } - /> - - + aria-hidden={view !== 'workspace'} + > + + + + + + + + + + + + ) diff --git a/src/components/Layout/Sidebar/SidebarBuildView.tsx b/src/components/Layout/Sidebar/SidebarBuildView.tsx new file mode 100644 index 000000000..edde1516b --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarBuildView.tsx @@ -0,0 +1,110 @@ +import { css } from '@emotion/react' +import { Flex, ScrollArea } from '@radix-ui/themes' +import { HammerIcon } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { FileList } from '@/components/FileTree/FileList' +import { Group, Panel, Separator } from '@/components/primitives/ResizablePanel' +import { useFeaturesStore } from '@/store/features' +import { useStudioUIStore } from '@/store/ui' + +import { orderByFileName, useFuzzyFileList } from './Sidebar.hooks' +import { SidebarPanelHeading } from './SidebarPanelHeading' +import { SidebarSearchField } from './SidebarSearchField' +import { SidebarViewLayout } from './SidebarViewLayout' + +interface SidebarBuildViewProps { + onCollapseSidebar: () => void +} + +export function SidebarBuildView({ onCollapseSidebar }: SidebarBuildViewProps) { + const isBrowserEditorEnabled = useFeaturesStore( + (state) => state.features['browser-test-editor'] + ) + + const [searchTerm, setSearchTerm] = useState('') + + const generators = useStudioUIStore((s) => orderByFileName(s.generators)) + const browserTests = useStudioUIStore((s) => orderByFileName(s.browserTests)) + const dataFiles = useStudioUIStore((s) => orderByFileName(s.dataFiles)) + + const tests = useMemo( + () => + [...generators, ...browserTests].sort((a, b) => + a.displayName.localeCompare(b.displayName) + ), + [generators, browserTests] + ) + + const filteredTests = useFuzzyFileList(tests, searchTerm) + const filteredDataFiles = useFuzzyFileList(dataFiles, searchTerm) + + return ( + } + heading="Build" + onCollapseSidebar={onCollapseSidebar} + > + + + + + + + + + + + + + Data files + + + + + + + + + + + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarDebugView.tsx b/src/components/Layout/Sidebar/SidebarDebugView.tsx new file mode 100644 index 000000000..5be5b7f60 --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarDebugView.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/react' +import { Flex, ScrollArea } from '@radix-ui/themes' +import { PlayIcon } from 'lucide-react' +import { useState } from 'react' + +import { FileList } from '@/components/FileTree/FileList' +import { useStudioUIStore } from '@/store/ui' + +import { orderByFileName, useFuzzyFileList } from './Sidebar.hooks' +import { SidebarSearchField } from './SidebarSearchField' +import { SidebarViewLayout } from './SidebarViewLayout' + +interface SidebarDebugViewProps { + onCollapseSidebar: () => void +} + +export function SidebarDebugView({ onCollapseSidebar }: SidebarDebugViewProps) { + const [searchTerm, setSearchTerm] = useState('') + + const scripts = useStudioUIStore((s) => orderByFileName(s.scripts)) + const filteredScripts = useFuzzyFileList(scripts, searchTerm) + + return ( + } + heading="Debug" + onCollapseSidebar={onCollapseSidebar} + > + + + + + + + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarPanelHeading.tsx b/src/components/Layout/Sidebar/SidebarPanelHeading.tsx new file mode 100644 index 000000000..2c444efb5 --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarPanelHeading.tsx @@ -0,0 +1,43 @@ +import { css } from '@emotion/react' +import { Flex, Text, Box } from '@radix-ui/themes' +import { ReactNode } from 'react' + +interface SidebarPanelHeadingProps { + count?: number + actions?: ReactNode + children: ReactNode +} + +export function SidebarPanelHeading({ + count, + actions, + children, +}: SidebarPanelHeadingProps) { + return ( + + + + {children} {count !== undefined && `(${count})`} + + + {actions} + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarRecentURLs.tsx b/src/components/Layout/Sidebar/SidebarRecentURLs.tsx new file mode 100644 index 000000000..59aa34d0c --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarRecentURLs.tsx @@ -0,0 +1,85 @@ +import { css } from '@emotion/react' +import { IconButton, Tooltip } from '@radix-ui/themes' +import { DiscIcon } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { getRoutePath } from '@/routeMap' + +interface SidebarRecentURLsProps { + urls: string[] +} + +export function SidebarRecentURLs({ urls }: SidebarRecentURLsProps) { + const navigate = useNavigate() + + const handleStartRecording = (url: string) => { + navigate(getRoutePath('recorder'), { state: { startUrl: url } }) + } + + if (urls.length === 0) { + return ( + + No recent URLs + + ) + } + + return ( +
      + {urls.map((url) => ( +
    • + + {url} + + + handleStartRecording(url)} + > + + + +
    • + ))} +
    + ) +} diff --git a/src/components/Layout/Sidebar/SidebarRecordView.tsx b/src/components/Layout/Sidebar/SidebarRecordView.tsx new file mode 100644 index 000000000..38d77e7ed --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarRecordView.tsx @@ -0,0 +1,89 @@ +import { css } from '@emotion/react' +import { Flex, ScrollArea } from '@radix-ui/themes' +import { FileVideoCamera } from 'lucide-react' +import { useState } from 'react' + +import { FileList } from '@/components/FileTree/FileList' +import { Group, Panel, Separator } from '@/components/primitives/ResizablePanel' +import { useRecentURLs } from '@/hooks/useRecentURLs' +import { useStudioUIStore } from '@/store/ui' + +import { orderByFileName, useFuzzyFileList } from './Sidebar.hooks' +import { SidebarPanelHeading } from './SidebarPanelHeading' +import { SidebarRecentURLs } from './SidebarRecentURLs' +import { SidebarSearchField } from './SidebarSearchField' +import { SidebarViewLayout } from './SidebarViewLayout' + +interface SidebarRecordViewProps { + onCollapseSidebar: () => void +} + +export function SidebarRecordView({ + onCollapseSidebar, +}: SidebarRecordViewProps) { + const { recentURLs } = useRecentURLs() + const [searchTerm, setSearchTerm] = useState('') + + const recordings = useStudioUIStore((s) => orderByFileName(s.recordings)) + const filteredRecordings = useFuzzyFileList(recordings, searchTerm) + + return ( + } + heading="Record" + onCollapseSidebar={onCollapseSidebar} + > + + + + + + + + + + + Recent URLs + + + + + + + + + + + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarSearchField.tsx b/src/components/Layout/Sidebar/SidebarSearchField.tsx new file mode 100644 index 000000000..419a8ad65 --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarSearchField.tsx @@ -0,0 +1,33 @@ +import { css } from '@emotion/react' +import { Flex } from '@radix-ui/themes' +import type { ComponentProps } from 'react' + +import { SearchField } from '@/components/SearchField' + +export type SidebarSearchFieldProps = Omit< + ComponentProps, + 'css' +> + +export function SidebarSearchField({ ...props }: SidebarSearchFieldProps) { + return ( + + + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarViewLayout.tsx b/src/components/Layout/Sidebar/SidebarViewLayout.tsx new file mode 100644 index 000000000..25492753d --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarViewLayout.tsx @@ -0,0 +1,113 @@ +import { css } from '@emotion/react' +import { Box, Flex, IconButton, Text } from '@radix-ui/themes' +import { PanelLeftCloseIcon } from 'lucide-react' +import { ReactNode } from 'react' + +export interface SidebarViewLayoutProps { + icon: ReactNode + heading: ReactNode + actions?: ReactNode + children: ReactNode + onCollapseSidebar: () => void +} + +export function SidebarViewLayout({ + icon, + heading, + actions, + onCollapseSidebar, + children, +}: SidebarViewLayoutProps) { + return ( + + + + + {icon} + + + {heading} + + + + {actions} + + + + + + + {children} + + + ) +} diff --git a/src/components/Layout/Sidebar/SidebarWorkspaceView.tsx b/src/components/Layout/Sidebar/SidebarWorkspaceView.tsx new file mode 100644 index 000000000..dff56ff2e --- /dev/null +++ b/src/components/Layout/Sidebar/SidebarWorkspaceView.tsx @@ -0,0 +1,52 @@ +import { css } from '@emotion/react' +import { Flex, ScrollArea } from '@radix-ui/themes' +import { FolderTreeIcon } from 'lucide-react' +import { useState } from 'react' + +import { WorkspaceFileTree } from '@/components/WorkspaceFileTree' + +import { SidebarSearchField } from './SidebarSearchField' +import { SidebarViewLayout } from './SidebarViewLayout' + +interface SidebarWorkspaceViewProps { + onCollapseSidebar: () => void +} + +export function SidebarWorkspaceView({ + onCollapseSidebar, +}: SidebarWorkspaceViewProps) { + const [searchTerm, setSearchTerm] = useState('') + + return ( + } + heading="Workspace" + onCollapseSidebar={onCollapseSidebar} + > + + + + + + + + ) +} diff --git a/src/components/WorkspaceFileTree/WorkspaceFileTree.hooks.ts b/src/components/WorkspaceFileTree/WorkspaceFileTree.hooks.ts new file mode 100644 index 000000000..d35b74e25 --- /dev/null +++ b/src/components/WorkspaceFileTree/WorkspaceFileTree.hooks.ts @@ -0,0 +1,371 @@ +import { useCallback, useMemo, useRef, useState } from 'react' + +type NodeId = string | number | symbol + +export interface UseTreeOptions { + /** Root node of the tree */ + root: T + /** All nodes in the tree, indexed by their parent id */ + nodes: Record + /** Unique identifier for each node */ + getId: (item: T) => NodeId + /** Whether the node can be expanded. Defaults to checking if item has directory-like structure */ + isFolder: (item: T) => boolean + /** Called when a folder is expanded. Use to fetch children asynchronously */ + onExpand?: (item: T) => Promise | void + /** Called when a folder is collapsed */ + onCollapse?: (item: T) => Promise | void +} + +export interface TreeItem { + /** The node data */ + node: T + /** Nesting level (0 for root) */ + level: number + /** Whether the node is expanded */ + expanded: boolean + /** True if children are being loaded (expanded but getChildren returns empty) */ + isLoading: boolean + /** Children of the node */ + children: T[] | null + /** Toggle expand/collapse state */ + toggle: (expanded?: boolean) => Promise | void + /** ARIA and DOM props to spread on the treeitem element */ + props: { + aria: { + role: 'treeitem' + tabIndex: number + 'aria-expanded'?: boolean + 'aria-level': number + 'aria-posinset': number + 'aria-setsize': number + } + control: { + ref: (element: HTMLElement | null) => void + onKeyDown: (event: React.KeyboardEvent) => void + } + } +} + +function buildTree( + includeRoot: boolean, + root: T, + nodes: Record, + expandedIds: Set, + getId: UseTreeOptions['getId'], + isFolder: UseTreeOptions['isFolder'] +) { + const result: Array<{ + item: T + level: number + posInSet: number + setSize: number + }> = [] + + function traverseChildren(parents: T[], item: T) { + const id = getId(item) + + if (!isFolder(item) || !expandedIds.has(id)) { + return + } + + const children = nodes[id] ?? null + + if (children === null) { + return + } + + const newParents = [...parents, item] + + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child === undefined) { + continue + } + + result.push({ + item: child, + level: newParents.length, + posInSet: i + 1, + setSize: children.length, + }) + + traverseChild(newParents, child) + } + } + + function traverseChild(parents: T[], item: T) { + const id = getId(item) + + if (!isFolder(item) || !expandedIds.has(id)) { + return + } + + const newParents = [...parents, item] + + traverseChildren(newParents, item) + } + + if (includeRoot) { + result.push({ + item: root, + level: 0, + posInSet: 1, + setSize: 1, + }) + + traverseChildren([], root) + } else { + traverseChild([], root) + } + + return result +} + +export function useTree(options: UseTreeOptions) { + const { root, nodes, getId, isFolder, onExpand, onCollapse } = options + + const [expandedIds, setExpandedIds] = useState>( + new Set([getId(root)]) + ) + + const [focusedId, setFocusedId] = useState(null) + const itemRefsMap = useRef>(new Map()) + + const focusItem = useCallback((id: NodeId) => { + setFocusedId(id) + + itemRefsMap.current.get(id)?.focus() + }, []) + + const registerItemRef = useCallback( + (id: NodeId, element: HTMLElement | null) => { + if (element === null) { + itemRefsMap.current.delete(id) + + return + } + + itemRefsMap.current.set(id, element) + }, + [] + ) + + const toggle = useCallback( + (item: T, expanded?: boolean) => { + const id = getId(item) + + if (!isFolder(item)) { + return + } + + const shouldExpand = expanded ?? !expandedIds.has(id) + + if (shouldExpand) { + setExpandedIds((prev) => { + const next = new Set(prev) + next.add(id) + return next + }) + + return onExpand?.(item) + } + + setExpandedIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + + return onCollapse?.(item) + }, + [expandedIds, getId, isFolder, onExpand, onCollapse] + ) + + const flatItems = useMemo(() => { + return buildTree(true, root, nodes, expandedIds, getId, isFolder) + }, [root, expandedIds, nodes, getId, isFolder]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, currentItem: T, currentIndex: number) => { + const id = getId(currentItem) + const children = nodes[id] ?? null + const isFolderItem = isFolder(currentItem) + const isExpanded = expandedIds.has(id) + const isRoot = currentIndex === 0 + + const firstChild = children?.[0] + + const hasVisibleChildren = + isFolderItem && isExpanded && firstChild !== undefined + + switch (event.key) { + case 'ArrowRight': { + event.preventDefault() + + if (!isFolderItem) { + break + } + + if (isExpanded && hasVisibleChildren) { + focusItem(getId(firstChild)) + + break + } + + if (!isExpanded) { + void toggle(currentItem) + + break + } + + break + } + + case 'ArrowLeft': { + event.preventDefault() + if (isFolderItem && isExpanded) { + void toggle(currentItem) + + break + } + + if (!isRoot) { + const currentLevel = flatItems[currentIndex]!.level + + for (let i = currentIndex - 1; i >= 0; i--) { + if (flatItems[i]!.level < currentLevel) { + focusItem(getId(flatItems[i]!.item)) + + break + } + } + } + + break + } + + case 'ArrowDown': { + event.preventDefault() + + if (currentIndex < flatItems.length - 1) { + focusItem(getId(flatItems[currentIndex + 1]!.item)) + } + + break + } + + case 'ArrowUp': { + event.preventDefault() + + if (currentIndex > 0) { + focusItem(getId(flatItems[currentIndex - 1]!.item)) + } + + break + } + + case 'Home': { + event.preventDefault() + + if (flatItems.length > 0) { + focusItem(getId(flatItems[0]!.item)) + } + + break + } + + case 'End': { + event.preventDefault() + + if (flatItems.length > 0) { + focusItem(getId(flatItems[flatItems.length - 1]!.item)) + } + + break + } + + case 'Enter': + case ' ': { + event.preventDefault() + + if (isFolderItem) { + void toggle(currentItem) + } + + break + } + } + }, + [expandedIds, flatItems, nodes, focusItem, getId, isFolder, toggle] + ) + + const items: Array> = useMemo(() => { + return flatItems.map(({ item, level, posInSet, setSize }, index) => { + const id = getId(item) + + const children = nodes[id] ?? null + const isFolderItem = isFolder(item) + const isExpanded = expandedIds.has(id) + + const isLoading = isFolderItem && isExpanded && children === null + + const props: TreeItem['props'] = { + control: { + ref: (el) => registerItemRef(id, el), + onKeyDown: (e) => { + if (e.defaultPrevented) { + return + } + + handleKeyDown(e, item, index) + }, + }, + aria: { + role: 'treeitem', + tabIndex: focusedId === id ? 0 : -1, + 'aria-level': level + 1, + 'aria-posinset': posInSet, + 'aria-setsize': setSize, + 'aria-expanded': isFolderItem ? isExpanded : undefined, + }, + } + + return { + node: item, + level, + expanded: isExpanded, + isLoading, + children, + props, + toggle: (expanded) => toggle(item, expanded), + } + }) + }, [ + nodes, + flatItems, + expandedIds, + focusedId, + getId, + isFolder, + handleKeyDown, + registerItemRef, + toggle, + ]) + + const handleContainerFocus = useCallback(() => { + if (focusedId === null && flatItems.length > 0) { + focusItem(getId(flatItems[0]!.item)) + } + }, [flatItems, focusItem, focusedId, getId]) + + return { + items, + props: { + role: 'tree' as const, + tabIndex: 0, + onFocus: handleContainerFocus, + }, + } +} diff --git a/src/components/WorkspaceFileTree/WorkspaceFileTree.tsx b/src/components/WorkspaceFileTree/WorkspaceFileTree.tsx new file mode 100644 index 000000000..cb6c8c3cd --- /dev/null +++ b/src/components/WorkspaceFileTree/WorkspaceFileTree.tsx @@ -0,0 +1,1185 @@ +import { css } from '@emotion/react' +import { + ContextMenu, + DropdownMenu, + Flex, + IconButton, + Reset, +} from '@radix-ui/themes' +import Fuse, { IFuseOptions } from 'fuse.js' +import { FolderClosedIcon, FolderOpenIcon, PlusIcon } from 'lucide-react' +import * as pathe from 'pathe' +import { + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { NavLink, useNavigate, useParams } from 'react-router-dom' + +import { DeleteFileDialog } from '@/components/DeleteFileDialog' +import { FileEntryIcon } from '@/components/FileTree/FileEntryIcon' +import { useWorkspace } from '@/contexts/WorkspaceContext' +import type { + DirectoryEntry, + FileContent, + FileEntry, + FileOnDisk, + SubDirectoryEntry, +} from '@/handlers/file/types' +import { useDeleteFile } from '@/hooks/useDeleteFile' +import { useFeaturesStore } from '@/store/features' +import { FileType } from '@/types' +import { getViewPath, inferFileTypeFromExtension } from '@/utils/file' +import { createNewGeneratorFile } from '@/utils/generator' + +import { TreeItem, useTree } from './WorkspaceFileTree.hooks' + +const entryStyles = css` + --spacing: calc(var(--space-1) * 1.5); + + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--file-entry-spacing) var(--file-entry-spacing) + var(--file-entry-spacing) var(--space-4); + + font-size: 12px; + color: var(--gray-11); + + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + + &:hover { + background-color: var(--gray-4); + } + + &:focus-visible { + outline: 2px solid var(--focus-8); + outline-offset: -1px; + } +` + +interface WorkspaceFileTreeInputProps { + value: string + selectionRange: [number, number] + onChange: (value: string) => void + onCommit: (value: string) => void + onCancel: () => void +} + +function WorkspaceFileTreeInput({ + value, + selectionRange, + onChange, + onCommit, + onCancel, +}: WorkspaceFileTreeInputProps) { + const isInitializedRef = useRef(false) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + + onCancel() + + return + } + + if (event.key === 'Enter') { + event.preventDefault() + + onCommit(value) + + return + } + } + + const handleBlur = () => { + if (value.trim() === '') { + onCancel() + + return + } + + onCommit(value) + } + + const handleMount = (el: HTMLInputElement | null) => { + if (el === null || isInitializedRef.current) { + return + } + + el.setSelectionRange(...selectionRange) + el.focus() + + isInitializedRef.current = true + } + + return ( + { + onChange(event.target.value) + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ) +} + +const createContentForType = (type: FileType): FileContent | null => { + switch (type) { + case 'browser-test': { + return { + type: 'browser-test', + data: { + version: '1.0', + actions: [], + }, + } + } + + case 'generator': { + return { + type: 'generator', + data: createNewGeneratorFile(), + } + } + + default: { + return null + } + } +} + +function newFilePlaceholderPath(dirname: string) { + return `__new-file__:${dirname}` +} + +function newFolderPlaceholderPath(dirname: string) { + return `__new-folder__:${dirname}` +} + +interface NewFileEntry { + type: 'new-file' + path: string + hint: string + dirname: string + fileType: 'browser-test' | 'generator' +} + +interface NewFolderEntry { + type: 'new-folder' + path: string + hint: string + dirname: string +} + +type FileTreeEntry = DirectoryEntry | NewFileEntry | NewFolderEntry + +interface NewFileItemProps { + item: TreeItem + entry: NewFileEntry + onCreate: (content: { + entry: NewFileEntry + name: string + content: FileContent + }) => void + onCancel: (entry: NewFileEntry) => void +} + +function NewFileItem({ item, entry, onCreate, onCancel }: NewFileItemProps) { + const [value, setValue] = useState(entry.hint) + + const nameWithoutExtension = pathe.basename( + entry.hint, + pathe.extname(entry.hint) + ) + + const inferredFileType = inferFileTypeFromExtension(value) + + const handleCreate = () => { + const content = createContentForType(inferredFileType) + + if (content === null) { + return + } + + onCreate({ entry, name: value, content }) + } + + return ( +
    + + + onCancel(entry)} + /> + +
    + ) +} + +interface NewTestMenuProps { + onNewFile: (fileType: 'browser-test' | 'generator') => void + onNewFolder: () => void +} + +export function NewTestMenu({ onNewFile, onNewFolder }: NewTestMenuProps) { + const [open, setOpen] = useState(false) + + const isBrowserEditorEnabled = useFeaturesStore( + (state) => state.features['browser-test-editor'] + ) + + const folderItem = ( + { + event.preventDefault() + + onNewFolder() + setOpen(false) + }} + > + New folder + + ) + + if (!isBrowserEditorEnabled) { + return ( + + + + + + + + { + event.preventDefault() + + onNewFile('generator') + setOpen(false) + }} + > + HTTP test + + + {folderItem} + + + ) + } + + return ( + + + + + + + + { + event.preventDefault() + + onNewFile('generator') + setOpen(false) + }} + > + HTTP test + + { + event.preventDefault() + + onNewFile('browser-test') + setOpen(false) + }} + > + Browser test + + + {folderItem} + + + ) +} + +interface DirectoryEntryProps { + entry: SubDirectoryEntry + item: TreeItem + onNewFile: (item: TreeItem, entry: NewFileEntry) => void + onNewFolder: (item: TreeItem, entry: NewFolderEntry) => void + onRefreshDirectory: (path: string) => void | Promise +} + +function DirectoryItem({ + entry, + item, + onNewFile, + onNewFolder, + onRefreshDirectory, +}: DirectoryEntryProps) { + const [isRenaming, setIsRenaming] = useState(false) + const [value, setValue] = useState(entry.basename) + + const parentPath = pathe.dirname(entry.path) + + const handleRename = async () => { + const trimmed = value.trim() + + if (trimmed === '' || trimmed === entry.basename) { + setIsRenaming(false) + setValue(entry.basename) + + return + } + + try { + await window.studio.ui.renameFile(entry.path, trimmed) + await onRefreshDirectory(parentPath) + setIsRenaming(false) + } catch (error) { + console.error(error) + } + } + + if (isRenaming) { + return ( +
    + + {item.expanded ? ( + + ) : ( + + )} + setIsRenaming(false)} + /> + +
    + ) + } + + return ( + <> + + +
    .dropdown-menu-trigger { + visibility: hidden; + } + + &:hover > .dropdown-menu-trigger, + &:focus-within > .dropdown-menu-trigger, + & > .dropdown-menu-trigger[data-state='open'] { + visibility: visible; + } + `, + ]} + {...item.props.aria} + {...item.props.control} + > + + + + { + onNewFile(item, { + type: 'new-file', + path: newFilePlaceholderPath(entry.path), + hint: + type === 'browser-test' + ? 'browser-test.k6b' + : 'generator.k6g', + dirname: entry.path, + fileType: type, + }) + }} + onNewFolder={() => { + onNewFolder(item, { + type: 'new-folder', + path: newFolderPlaceholderPath(entry.path), + hint: 'New folder', + dirname: entry.path, + }) + }} + /> +
    +
    + + setIsRenaming(true)}> + Rename + + + window.studio.ui.openContainingFolder({ + type: 'recording', + path: entry.path, + fileName: entry.basename, + displayName: entry.basename, + }) + } + > + Open containing folder + + +
    + {item.expanded && item.children?.length === 0 && ( +
    + Directory is empty +
    + )} + + ) +} + +interface FileEntryProps { + entry: FileEntry + item: TreeItem + selected: boolean + onRefreshDirectory: (path: string) => void | Promise +} + +function FileItem({ + entry, + item, + selected, + onRefreshDirectory, +}: FileEntryProps) { + const navigate = useNavigate() + const [isRenaming, setIsRenaming] = useState(false) + const [value, setValue] = useState(entry.basename) + const inputRef = useRef(null) + + const parentPath = pathe.dirname(entry.path) + + const studioFile = entry.file ?? { + type: inferFileTypeFromExtension(entry.path), + path: entry.path, + fileName: entry.basename, + displayName: entry.basename, + } + + const nameWithoutExtension = pathe.basename( + entry.path, + pathe.extname(entry.path) + ) + + const deleteFile = useDeleteFile({ + file: studioFile, + navigateHomeOnDelete: selected, + }) + + const handleDelete = async () => { + await deleteFile() + await onRefreshDirectory(parentPath) + } + + useEffect(() => { + if (isRenaming) { + inputRef.current?.focus() + inputRef.current?.setSelectionRange( + 0, + pathe.basename(entry.path, pathe.extname(entry.path)).length + ) + } + }, [isRenaming, entry]) + + const handleRename = async () => { + const trimmed = value.trim() + + if (trimmed === '' || trimmed === entry.basename) { + setIsRenaming(false) + setValue(entry.basename) + + return + } + + try { + await window.studio.ui.renameFile(entry.path, value) + await onRefreshDirectory(parentPath) + + if (selected) { + navigate(getViewPath(pathe.join(parentPath, value)), { + replace: true, + }) + } + + setIsRenaming(false) + } catch (error) { + console.error(error) + } + } + + if (isRenaming) { + return ( +
    + + + setIsRenaming(false)} + /> + +
    + ) + } + + return ( + + + + item.toggle()} + {...item.props.aria} + {...item.props.control} + > + + + {entry.basename} + + + + + + setIsRenaming(true)}> + Rename + + window.studio.ui.openContainingFolder(studioFile)} + > + Open containing folder + + e.preventDefault()}> + Delete + + } + /> + + + ) +} + +interface NewFolderItemProps { + item: TreeItem + entry: NewFolderEntry + onCreate: (payload: { entry: NewFolderEntry; name: string }) => void + onCancel: (entry: NewFolderEntry) => void +} + +function NewFolderItem({ + item, + entry, + onCreate, + onCancel, +}: NewFolderItemProps) { + const [value, setValue] = useState(entry.hint) + + const handleCreate = () => { + if (!value.trim()) { + return + } + + onCreate({ entry, name: value }) + } + + return ( +
    + + + onCancel(entry)} + /> + +
    + ) +} + +const workspaceNameFuseOptions: IFuseOptions<{ + path: string + basename: string +}> = { + ignoreLocation: true, + distance: 1, + keys: ['basename'], +} + +function isDescendantPath(descendantPath: string, ancestorPath: string) { + if (descendantPath === ancestorPath) { + return false + } + + const prefix = ancestorPath.endsWith(pathe.sep) + ? ancestorPath + : `${ancestorPath}${pathe.sep}` + + return descendantPath.startsWith(prefix) +} + +function pathIsWorkspaceDescendant( + candidatePath: string, + workspaceRoot: string +): boolean { + const normalizedPath = pathe.normalize(candidatePath) + + if (normalizedPath === workspaceRoot) { + return true + } + + const normalizedRoot = pathe.normalize(workspaceRoot) + + const prefix = !normalizedRoot.endsWith(pathe.sep) + ? `${normalizedRoot}${pathe.sep}` + : normalizedRoot + + return normalizedPath.startsWith(prefix) +} + +/** Drop cached directory listings that are the removed path or under it. */ +function pruneLoadedDirectoryKeys( + prev: Record, + removedPath: string +): Record { + const next: Record = {} + + const normalized = pathe.normalize(removedPath) + + const removedPrefix = !normalized.endsWith(pathe.sep) + ? `${normalized}${pathe.sep}` + : normalized + + for (const [key, value] of Object.entries(prev)) { + const normalizedKey = pathe.normalize(key) + + if ( + normalizedKey === normalized || + normalizedKey.startsWith(removedPrefix) + ) { + continue + } + + next[key] = value + } + + return next +} + +interface WorkspaceFileTreeProps { + /** When non-empty, only entries matching this filter (loaded folders/files) are shown. */ + nameFilter?: string +} + +export function WorkspaceFileTree({ nameFilter = '' }: WorkspaceFileTreeProps) { + const navigate = useNavigate() + + const workspace = useWorkspace() + const { path } = useParams<{ path: string }>() + + const [entries, setEntries] = useState>({}) + + const loadDirectory = useCallback((dirPath: string) => { + return window.studio.file.listDirectory({ path: dirPath }).then((list) => { + setEntries((prev) => ({ ...prev, [dirPath]: list })) + }) + }, []) + + useEffect(() => { + if (workspace?.path === undefined) { + return + } + + setEntries({}) + + loadDirectory(workspace.path).catch((error) => { + console.error(error) + }) + }, [workspace?.path, loadDirectory]) + + const entriesRef = useRef(entries) + + useEffect(() => { + entriesRef.current = entries + }) + + const reloadListedDirectory = useCallback( + (root: string, changedPath: string) => { + const dirPath = pathe.dirname(changedPath) + + if (dirPath !== root && entriesRef.current[dirPath] === undefined) { + return + } + + loadDirectory(dirPath).catch((error) => { + console.error(error) + }) + }, + [loadDirectory] + ) + + useEffect(() => { + const root = workspace?.path + + if (root === undefined) { + return + } + + return window.studio.workspace.onAddFile(({ path }) => { + if (!pathIsWorkspaceDescendant(path, root)) { + return + } + + reloadListedDirectory(root, path) + }) + }, [workspace?.path, reloadListedDirectory]) + + useEffect(() => { + const root = workspace?.path + + if (root === undefined) { + return + } + + return window.studio.workspace.onRemoveFile(({ path }) => { + if (!pathIsWorkspaceDescendant(path, root)) { + return + } + + setEntries((prev) => pruneLoadedDirectoryKeys(prev, path)) + + reloadListedDirectory(root, path) + }) + }, [workspace?.path, reloadListedDirectory]) + + const tree = useTree({ + root: { + type: 'directory', + basename: pathe.basename(workspace?.path ?? ''), + path: workspace?.path ?? '', + }, + + nodes: entries, + + getId(item) { + return item.path + }, + + isFolder(item) { + return item.type === 'directory' + }, + + onExpand(item) { + if (item.type !== 'directory') { + return + } + + return loadDirectory(item.path).catch((error) => { + console.error(error) + }) + }, + + onCollapse(item) { + if (item.type !== 'directory') { + return + } + + setEntries((prev) => { + const { [item.path]: _, ...rest } = prev + + return rest + }) + }, + }) + + const handleStartCreateFile = ( + item: TreeItem, + entry: NewFileEntry + ) => { + Promise.resolve(item.toggle(true)) + .then(() => { + setEntries((prev) => { + const list = prev[entry.dirname] ?? [] + + return { + ...prev, + [entry.dirname]: [ + ...list.filter((e) => e.type !== 'new-file'), + entry, + ], + } + }) + }) + .catch((error) => { + console.error(error) + }) + } + + const handleStartCreateFolder = ( + item: TreeItem, + entry: NewFolderEntry + ) => { + Promise.resolve(item.toggle(true)) + .then(() => { + setEntries((prev) => { + const list = prev[entry.dirname] ?? [] + + return { + ...prev, + [entry.dirname]: [ + ...list.filter((e) => e.type !== 'new-folder'), + entry, + ], + } + }) + }) + .catch((error) => { + console.error(error) + }) + } + + const removeNewFileEntry = (entry: NewFileEntry) => { + setEntries((prev) => { + const entries = prev[entry.dirname] ?? [] + + return { + ...prev, + [entry.dirname]: entries.filter((e) => e.type !== 'new-file'), + } + }) + } + + const removeNewFolderEntry = (entry: NewFolderEntry) => { + setEntries((prev) => { + const list = prev[entry.dirname] ?? [] + + return { + ...prev, + [entry.dirname]: list.filter((e) => e.type !== 'new-folder'), + } + }) + } + + const handleCreateFile = ({ + entry, + name, + content, + }: { + entry: NewFileEntry + name: string + content: FileContent + }) => { + const location: FileOnDisk = { + type: 'path', + path: pathe.join(entry.dirname, name), + } + + window.studio.file + .save({ content, location }) + .then(() => loadDirectory(entry.dirname)) + .then(() => { + navigate(getViewPath(location.path)) + }) + .catch((error) => { + console.error(error) + }) + + removeNewFileEntry(entry) + } + + const handleCancelCreateFile = (entry: NewFileEntry) => { + removeNewFileEntry(entry) + } + + const handleCreateFolder = ({ + entry, + name, + }: { + entry: NewFolderEntry + name: string + }) => { + window.studio.file + .createDirectory({ parentPath: entry.dirname, name }) + .then(() => loadDirectory(entry.dirname)) + .catch((error) => { + console.error(error) + }) + + removeNewFolderEntry(entry) + } + + const handleCancelCreateFolder = (entry: NewFolderEntry) => { + removeNewFolderEntry(entry) + } + + const matchedPaths = useMemo(() => { + if (nameFilter.match(/^\s*$/)) { + return null + } + + const q = nameFilter.trim() + const searchable: { path: string; basename: string }[] = [] + + for (const list of Object.values(entries)) { + for (const e of list ?? []) { + if (e.type === 'file' || e.type === 'directory') { + searchable.push({ path: e.path, basename: e.basename }) + } + } + } + + if (searchable.length === 0) { + return new Set() + } + + const fuse = new Fuse(searchable, workspaceNameFuseOptions) + + return new Set(fuse.search(q).map((r) => r.item.path)) + }, [entries, nameFilter]) + + const visibleTreeItems = useMemo(() => { + if (matchedPaths === null) { + return tree.items + } + + return tree.items.filter((treeItem) => { + const node = treeItem.node + + if (node.type === 'new-file' || node.type === 'new-folder') { + return true + } + + if (node.type === 'file') { + return matchedPaths.has(node.path) + } + + if (matchedPaths.has(node.path)) { + return true + } + + for (const p of matchedPaths) { + if (isDescendantPath(p, node.path)) { + return true + } + } + + return false + }) + }, [tree.items, matchedPaths]) + + if (workspace === null) { + return null + } + + return ( + + {visibleTreeItems.map((item) => { + if (item.node.type === 'directory') { + return ( + + ) + } + + if (item.node.type === 'new-file') { + return ( + + ) + } + + if (item.node.type === 'new-folder') { + return ( + + ) + } + + return ( + + ) + })} + + ) +} diff --git a/src/components/WorkspaceFileTree/index.ts b/src/components/WorkspaceFileTree/index.ts new file mode 100644 index 000000000..eeee78c16 --- /dev/null +++ b/src/components/WorkspaceFileTree/index.ts @@ -0,0 +1 @@ +export { WorkspaceFileTree } from './WorkspaceFileTree' diff --git a/src/constants/files.ts b/src/constants/files.ts index c34082f5a..7abef416b 100644 --- a/src/constants/files.ts +++ b/src/constants/files.ts @@ -3,6 +3,20 @@ import { StudioFile } from '@/types' // eslint-disable-next-line no-control-regex export const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1F]/ +/** System-generated files to ignore when listing directories */ +const IGNORED_SYSTEM_FILES = new Set([ + '.DS_Store', // macOS + 'Thumbs.db', // Windows + 'desktop.ini', // Windows + 'ehthumbs.db', // Windows thumbnail cache + '.directory', // KDE Dolphin +]) + +/** AppleDouble/resource fork files (macOS) */ +export function isIgnoredSystemFile(basename: string): boolean { + return IGNORED_SYSTEM_FILES.has(basename) || basename.startsWith('._') +} + // TODO: Find a better way to handle large files // Limit the file size for data files to avoid performance issues export const MAX_DATA_FILE_SIZE = 1024 * 1024 * 10 @@ -14,6 +28,8 @@ export const FileTypeToLabel: Record = { recording: 'recording', generator: 'generator', script: 'script', - 'data-file': 'data file', + json: 'data file', + csv: 'data file', 'browser-test': 'browser test', + unsupported: 'unsupported', } diff --git a/src/constants/workspace.ts b/src/constants/workspace.ts index 920a7a443..d21b3d535 100644 --- a/src/constants/workspace.ts +++ b/src/constants/workspace.ts @@ -11,7 +11,3 @@ export const DATA_FILES_PATH = path.join(PROJECT_PATH, 'Data') export const TEMP_PATH = path.join(app.getPath('temp'), 'k6-studio') export const TEMP_SCRIPT_SUFFIX = '__tmp-k6studio__.js' export const TEMP_K6_ARCHIVE_PATH = path.join(TEMP_PATH, 'k6-studio-test.tar') -export const TEMP_GENERATOR_SCRIPT_PATH = path.join( - SCRIPTS_PATH, - 'k6-studio-generator-script' + TEMP_SCRIPT_SUFFIX -) diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx new file mode 100644 index 000000000..df0c5eea8 --- /dev/null +++ b/src/contexts/WorkspaceContext.tsx @@ -0,0 +1,46 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react' + +import type { Workspace } from '@/types/workspace' + +const WorkspaceContext = createContext(null) + +interface WorkspaceProviderProps { + children: ReactNode +} + +export function WorkspaceProvider({ children }: WorkspaceProviderProps) { + const [workspace, setWorkspace] = useState(null) + + useEffect(() => { + window.studio.workspace + .getWorkspace() + .then((workspaceData) => { + setWorkspace(workspaceData) + + window.studio.app.closeSplashscreen() + }) + .catch(console.error) + }, []) + + useEffect(() => { + return window.studio.workspace.onChangeWorkspace(() => { + void window.studio.workspace.getWorkspace().then(setWorkspace) + }) + }, []) + + return ( + + {children} + + ) +} + +export function useWorkspace(): Workspace | null { + return useContext(WorkspaceContext) +} diff --git a/src/handlers/app/preload.ts b/src/handlers/app/preload.ts index 562452229..d7012e110 100644 --- a/src/handlers/app/preload.ts +++ b/src/handlers/app/preload.ts @@ -31,3 +31,13 @@ export function trackEvent(event: UsageEvent) { export function onDeepLink(callback: (url: string) => void) { return createListener(AppHandler.DeepLink, callback) } + +export interface SaveRequestedPayload { + saveAs?: boolean +} + +export function onSaveRequested( + callback: (payload: SaveRequestedPayload | undefined) => void +): () => void { + return createListener(AppHandler.SaveRequested, callback) +} diff --git a/src/handlers/app/types.ts b/src/handlers/app/types.ts index af05e8768..181dfa467 100644 --- a/src/handlers/app/types.ts +++ b/src/handlers/app/types.ts @@ -3,5 +3,6 @@ export enum AppHandler { ChangeRoute = 'app:change-route', SplashscreenClose = 'app:splashscreen-close', DeepLink = 'app:deep-link', + SaveRequested = 'app:save-requested', TrackEvent = 'app:track-event', } diff --git a/src/handlers/browser/recorders/utils.ts b/src/handlers/browser/recorders/utils.ts index 5f0fd9b46..861cad7ec 100644 --- a/src/handlers/browser/recorders/utils.ts +++ b/src/handlers/browser/recorders/utils.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, writeFile } from 'fs/promises' -import os from 'os' import path from 'path' +import { TEMP_PATH } from '@/constants/workspace' import { getProxyArguments } from '@/main/proxy' import { AppSettings } from '@/types/settings' import { getBrowserPath } from '@/utils/browser' @@ -22,7 +22,7 @@ const CHROME_DEV_PREFERENCES = JSON.stringify({ }) const createUserDataDir = async () => { - const userDataDir = await mkdtemp(path.join(os.tmpdir(), 'k6-studio-')) + const userDataDir = await mkdtemp(path.join(TEMP_PATH, 'browser-')) // If we're in development mode, we create a default Chrome profile // with some preferences that make developing the extension easier diff --git a/src/handlers/browserTest/index.ts b/src/handlers/browserTest/index.ts index 69ad669e0..def3192ae 100644 --- a/src/handlers/browserTest/index.ts +++ b/src/handlers/browserTest/index.ts @@ -1,24 +1,22 @@ import { ipcMain } from 'electron' -import { readFile, writeFile } from 'fs/promises' import path from 'path' import { z } from 'zod' import { K6_BROWSER_TEST_FILE_EXTENSION } from '@/constants/files' -import { BROWSER_TESTS_PATH } from '@/constants/workspace' -import { - BrowserTestFile, - BrowserTestFileSchema, -} from '@/schemas/browserTest/v1' +import { BrowserTestFileSchema } from '@/schemas/browserTest/v1' import { trackEvent } from '@/services/usageTracking' import { UsageEventName } from '@/services/usageTracking/types' +import { browserWindowFromEvent } from '@/utils/electron' import { createFileWithUniqueName } from '@/utils/fileSystem' import { BrowserTestHandler } from './types' export function initialize() { - ipcMain.handle(BrowserTestHandler.Create, async () => { + ipcMain.handle(BrowserTestHandler.Create, async (event) => { console.info(`${BrowserTestHandler.Create} event received`) + const browserWindow = browserWindowFromEvent(event) + const emptyBrowserTest: z.infer = { version: '1.0', actions: [], @@ -26,7 +24,7 @@ export function initialize() { const fileName = await createFileWithUniqueName({ data: JSON.stringify(emptyBrowserTest, null, 2), - directory: BROWSER_TESTS_PATH, + directory: browserWindow.workspace.paths.browserTests, ext: K6_BROWSER_TEST_FILE_EXTENSION, prefix: 'Browser', }) @@ -35,33 +33,6 @@ export function initialize() { event: UsageEventName.BrowserTestCreated, }) - return fileName - }) - - ipcMain.handle(BrowserTestHandler.Open, async (_, fileName: string) => { - console.info(`${BrowserTestHandler.Open} event received`) - - const data = await readFile(path.join(BROWSER_TESTS_PATH, fileName), { - encoding: 'utf-8', - flag: 'r', - }) - - return BrowserTestFileSchema.parse(JSON.parse(data)) + return path.join(browserWindow.workspace.paths.browserTests, fileName) }) - - ipcMain.handle( - BrowserTestHandler.Save, - async (_, fileName: string, data: BrowserTestFile) => { - console.info(`${BrowserTestHandler.Save} event received`) - - await writeFile( - path.join(BROWSER_TESTS_PATH, fileName), - JSON.stringify(data, null, 2) - ) - - trackEvent({ - event: UsageEventName.BrowserTestUpdated, - }) - } - ) } diff --git a/src/handlers/browserTest/preload.ts b/src/handlers/browserTest/preload.ts index 971ed6298..879a7f0ff 100644 --- a/src/handlers/browserTest/preload.ts +++ b/src/handlers/browserTest/preload.ts @@ -2,23 +2,17 @@ import { ipcRenderer } from 'electron' import { BrowserTestFile } from '@/schemas/browserTest/v1' +import { save as saveFile } from '../file/preload' + import { BrowserTestHandler } from './types' export function create() { return ipcRenderer.invoke(BrowserTestHandler.Create) as Promise } -export function open(fileName: string) { - return ipcRenderer.invoke( - BrowserTestHandler.Open, - fileName - ) as Promise -} - -export function save(fileName: string, data: BrowserTestFile) { - return ipcRenderer.invoke( - BrowserTestHandler.Save, - fileName, - data - ) as Promise +export function save(filePath: string, data: BrowserTestFile) { + return saveFile({ + content: { type: 'browser-test', data }, + location: { type: 'path', path: filePath }, + }) } diff --git a/src/handlers/cloud/index.ts b/src/handlers/cloud/index.ts index 6dddc3420..d57cf2dde 100644 --- a/src/handlers/cloud/index.ts +++ b/src/handlers/cloud/index.ts @@ -1,47 +1,13 @@ import { ipcMain, shell } from 'electron' -import { rm, writeFile } from 'fs/promises' -import { basename, extname, isAbsolute, join } from 'path' -import { SCRIPTS_PATH } from '@/constants/workspace' -import { getTempScriptName } from '@/main/script' import { trackEvent } from '@/services/usageTracking' import { UsageEventName } from '@/services/usageTracking/types' import { browserWindowFromEvent } from '@/utils/electron' import { logError } from '@/utils/errors' +import { toScriptFile } from '@/utils/fs/scripts' import { RunInCloudStateMachine } from './states' -import { CloudHandlers, RawScript, Script } from './types' - -async function createTempFile(script: RawScript) { - const tempFileName = getTempScriptName() - const tempFilePath = join(SCRIPTS_PATH, tempFileName) - - await writeFile(tempFilePath, script.content) - - return { - name: basename(script.name, extname(script.name)), - path: tempFilePath, - dispose() { - try { - return rm(tempFilePath) - } catch { - return - } - }, - } -} - -function toScriptFile(script: Script) { - if (script.type === 'raw') { - return createTempFile(script) - } - - return { - name: basename(script.path), - path: script.path, - dispose() {}, - } -} +import { CloudHandlers, Script } from './types' export function initialize() { let stateMachine: RunInCloudStateMachine | null = null @@ -50,16 +16,12 @@ export function initialize() { const browserWindow = browserWindowFromEvent(event) const file = await toScriptFile(script) - const absolutePath = !isAbsolute(file.path) - ? join(SCRIPTS_PATH, file.path) - : file.path - try { if (stateMachine !== null) { stateMachine.abort() } - stateMachine = new RunInCloudStateMachine(absolutePath, file.name) + stateMachine = new RunInCloudStateMachine(file.path, file.name) stateMachine.on('state-change', (state) => { browserWindow.webContents.send('cloud:state-change', state) diff --git a/src/handlers/cloud/types.ts b/src/handlers/cloud/types.ts index 5ca578bce..76e8aa2d3 100644 --- a/src/handlers/cloud/types.ts +++ b/src/handlers/cloud/types.ts @@ -21,6 +21,7 @@ export interface ScriptFile { export interface RawScript { type: 'raw' name: string + path?: string content: string } diff --git a/src/handlers/dataFiles/index.ts b/src/handlers/dataFiles/index.ts deleted file mode 100644 index c2c79b1ef..000000000 --- a/src/handlers/dataFiles/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { COPYFILE_EXCL } from 'constants' -import { ipcMain, dialog } from 'electron' -import { stat, copyFile, readFile } from 'fs/promises' -import path from 'path' -import invariant from 'tiny-invariant' - -import { MAX_DATA_FILE_SIZE } from '@/constants/files' -import { DATA_FILES_PATH } from '@/constants/workspace' -import { DataFilePreview } from '@/types/testData' -import { parseDataFile } from '@/utils/dataFile' -import { browserWindowFromEvent } from '@/utils/electron' - -import { DataFileHandler } from './types' - -export function initialize() { - ipcMain.handle(DataFileHandler.Import, async (event) => { - const browserWindow = browserWindowFromEvent(event) - - const dialogResult = await dialog.showOpenDialog(browserWindow, { - message: 'Import data file', - properties: ['openFile'], - filters: [{ name: 'Supported data files', extensions: ['csv', 'json'] }], - }) - - const filePath = dialogResult.filePaths[0] - - if (dialogResult.canceled || !filePath) { - return - } - - const { size } = await stat(filePath) - invariant(size <= MAX_DATA_FILE_SIZE, 'File is too large') - - await copyFile( - filePath, - path.join(DATA_FILES_PATH, path.basename(filePath)), - COPYFILE_EXCL - ) - - return path.basename(filePath) - }) - - ipcMain.handle( - DataFileHandler.LoadPreview, - async (_, fileName: string): Promise => { - const fileType = fileName.split('.').pop() - const filePath = path.join(DATA_FILES_PATH, fileName) - - invariant( - fileType === 'csv' || fileType === 'json', - 'Unsupported file type' - ) - - const data = await readFile(filePath, { - flag: 'r', - encoding: 'utf-8', - }) - - const parsedData = parseDataFile(data, fileType) - - return { - type: fileType, - data: parsedData.slice(0, 20), - props: parsedData[0] ? Object.keys(parsedData[0]) : [], - total: parsedData.length, - } - } - ) -} diff --git a/src/handlers/dataFiles/preload.ts b/src/handlers/dataFiles/preload.ts deleted file mode 100644 index ea7c3172d..000000000 --- a/src/handlers/dataFiles/preload.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ipcRenderer } from 'electron' - -import { DataFilePreview } from '@/types/testData' - -import { DataFileHandler } from './types' - -export function importFile() { - return ipcRenderer.invoke(DataFileHandler.Import) as Promise< - string | undefined - > -} - -export function loadPreview(filePath: string) { - return ipcRenderer.invoke( - DataFileHandler.LoadPreview, - filePath - ) as Promise -} diff --git a/src/handlers/dataFiles/types.ts b/src/handlers/dataFiles/types.ts deleted file mode 100644 index 250c4eb5f..000000000 --- a/src/handlers/dataFiles/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum DataFileHandler { - Import = 'data-file:import', - LoadPreview = 'data-file:load-preview', -} diff --git a/src/handlers/file/index.ts b/src/handlers/file/index.ts new file mode 100644 index 000000000..eff397689 --- /dev/null +++ b/src/handlers/file/index.ts @@ -0,0 +1,500 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import log from 'electron-log/main' +import { mkdir, readFile, writeFile } from 'fs/promises' +import { parse as parseCSV } from 'papaparse' +import path from 'path' +import { EntryInfo, readdirp, ReaddirpOptions } from 'readdirp' + +import { INVALID_FILENAME_CHARS, isIgnoredSystemFile } from '@/constants/files' +import { TEMP_PATH } from '@/constants/workspace' +import { BrowserTestFileSchema } from '@/schemas/browserTest/v1' +import { GeneratorFileDataSchema } from '@/schemas/generator' +import { RecordingSchema } from '@/schemas/recording' +import { trackEvent } from '@/services/usageTracking' +import { UsageEvent, UsageEventName } from '@/services/usageTracking/types' +import { FileType } from '@/types' +import { DataRecord } from '@/types/testData' +import { browserWindowFromEvent } from '@/utils/electron' +import { createStudioFile, inferFileTypeFromExtension } from '@/utils/file' +import { harToProxyData } from '@/utils/harToProxyData' +import { JsonObject } from '@/utils/json' +import { proxyDataToHar } from '@/utils/proxyDataToHar' +import { exhaustive } from '@/utils/typescript' +import { Workspace } from '@/utils/workspace' + +import { + type CreateDirectoryArgs, + type DirectoryEntry, + FileContent, + FileHandler, + FileLocation, + FileOnDisk, + GetTempPathArgs, + type ListDirectoryArgs, + OpenFileResult, + SaveFilePayload, +} from './types' + +async function save( + browserWindow: BrowserWindow, + location: FileLocation, + content: FileContent +): Promise { + switch (location.type) { + case 'path': { + const serialized = serializeContent(content, location.path) + + await writeFile(location.path, serialized) + + trackSaveFile(content) + + return location + } + + case 'new': { + return saveAs(browserWindow, content, location.hint) + } + + default: { + return exhaustive(location) + } + } +} + +async function saveAs( + browserWindow: BrowserWindow, + content: FileContent, + hint?: string +): Promise { + const { filePath } = await dialog.showSaveDialog(browserWindow, { + title: 'Save file', + defaultPath: hint, + filters: [{ name: 'All files', extensions: ['*'] }], + properties: ['showOverwriteConfirmation'], + }) + + if (filePath === '') { + return null + } + + return save(browserWindow, { type: 'path', path: filePath }, content) +} + +export function initialize() { + ipcMain.handle( + FileHandler.Save, + async ( + event, + { content, location }: SaveFilePayload + ): Promise => { + console.info(`${FileHandler.Save} event received`) + + const browserWindow = browserWindowFromEvent(event) + + try { + return await save(browserWindow, location, content) + } catch (error) { + log.error(error) + + throw error + } + } + ) + + ipcMain.handle( + FileHandler.Open, + async (event, path: string): Promise => { + console.info(`${FileHandler.Open} event received`) + + const browserWindow = browserWindowFromEvent(event) + + const fileType = inferFileTypeFromExtension(path) + + if (fileType === null) { + return { type: 'unsupported-format' } + } + + const raw = await readFile(path, { + encoding: 'utf-8', + flag: 'r', + }) + + return parseOpenResult(browserWindow.workspace, path, fileType, raw) + } + ) + + ipcMain.handle( + FileHandler.PickOpenFile, + async (event): Promise => { + console.info(`${FileHandler.PickOpenFile} event received`) + + const browserWindow = browserWindowFromEvent(event) + + const dialogResult = await dialog.showOpenDialog(browserWindow, { + title: 'Open HAR file', + properties: ['openFile'], + defaultPath: browserWindow.workspace.paths.recordings, + filters: [{ name: 'HAR', extensions: ['har'] }], + }) + + const filePath = dialogResult.filePaths[0] + + if (dialogResult.canceled || filePath === undefined) { + return null + } + + return filePath + } + ) + + ipcMain.handle( + FileHandler.GetTempPath, + (_event, { prefix = 'k6s', extension }: GetTempPathArgs = {}): string => { + const ext = extension?.replace(/^\.?/, '.') ?? '' + const basename = `${prefix}-${crypto.randomUUID()}${ext}` + + return path.join(TEMP_PATH, basename) + } + ) + + ipcMain.handle( + FileHandler.CreateDirectory, + async (event, payload: CreateDirectoryArgs): Promise => { + console.info(`${FileHandler.CreateDirectory} event received`) + + const browserWindow = browserWindowFromEvent(event) + const workspacePath = browserWindow.workspace.path + + const name = payload.name.trim() + + if (name === '' || name === '.' || name === '..') { + throw new Error('Invalid folder name') + } + + if (INVALID_FILENAME_CHARS.test(name)) { + throw new Error('Invalid folder name') + } + + if (name !== path.basename(name)) { + throw new Error('Invalid folder name') + } + + const resolvedParent = path.resolve(payload.parentPath) + const relativeParent = path.relative(workspacePath, resolvedParent) + + if (relativeParent.startsWith('..') || path.isAbsolute(relativeParent)) { + throw new Error('Path is outside workspace') + } + + const newDirPath = path.join(resolvedParent, name) + const resolvedNewDir = path.resolve(newDirPath) + const relativeNew = path.relative(workspacePath, resolvedNewDir) + + if (relativeNew.startsWith('..') || path.isAbsolute(relativeNew)) { + throw new Error('Path is outside workspace') + } + + await mkdir(resolvedNewDir, { recursive: false }) + } + ) + + ipcMain.handle( + FileHandler.ListDirectory, + async ( + event, + { path: requestedPath }: ListDirectoryArgs + ): Promise => { + console.info(`${FileHandler.ListDirectory} event received`) + + const browserWindow = browserWindowFromEvent(event) + const workspacePath = browserWindow.workspace.path + + const resolvedPath = path.resolve(requestedPath) + const relativePath = path.relative(workspacePath, resolvedPath) + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error('Path is outside workspace') + } + + const options: Partial = { + depth: 0, + type: 'all', + directoryFilter: (entry: { basename: string }) => { + return entry.basename !== 'node_modules' + }, + fileFilter: (entry: { basename: string }) => { + return !isIgnoredSystemFile(entry.basename) + }, + } + + const entries: DirectoryEntry[] = [] + + for await (const entry of readdirp( + resolvedPath, + options + ) as AsyncIterable) { + const fullPath = entry.fullPath + + if (entry.dirent === undefined) { + continue + } + + if (entry.dirent.isDirectory()) { + entries.push({ + type: 'directory', + basename: entry.basename, + path: fullPath, + }) + + continue + } + + entries.push({ + type: 'file', + basename: entry.basename, + path: fullPath, + file: createStudioFile(fullPath), + }) + } + + const sortEntries = (a: DirectoryEntry, b: DirectoryEntry) => { + const aIsDir = a.type === 'directory' + const bIsDir = b.type === 'directory' + if (aIsDir !== bIsDir) { + return aIsDir ? -1 : 1 + } + return a.basename.localeCompare(b.basename, undefined, { + sensitivity: 'base', + }) + } + + return entries.sort(sortEntries) + } + ) +} + +async function parseOpenResult( + workspace: Workspace, + filePath: string, + fileType: FileType, + raw: string +): Promise { + switch (fileType) { + case 'generator': { + const data = GeneratorFileDataSchema.parse(JSON.parse(raw)) + const generatorDir = path.dirname(filePath) + + const absoluteRecordingPath = + data.recordingPath !== '' + ? path.resolve(generatorDir, data.recordingPath) + : '' + + const absoluteFiles = data.testData.files.map((file) => { + return { + path: file.path !== '' ? path.resolve(generatorDir, file.path) : '', + } + }) + + const absoluteRules = data.rules.map((rule) => { + if ( + rule.type === 'parameterization' && + rule.value.type === 'dataFileValue' && + rule.value.fileName !== '' + ) { + return { + ...rule, + value: { + ...rule.value, + fileName: path.resolve(generatorDir, rule.value.fileName), + }, + } + } + return rule + }) + + return { + type: 'generator', + data: { + ...data, + recordingPath: absoluteRecordingPath, + testData: { ...data.testData, files: absoluteFiles }, + rules: absoluteRules, + }, + } + } + + case 'browser-test': { + const data = BrowserTestFileSchema.parse(JSON.parse(raw)) + + return { type: 'browser-test', data } + } + + case 'recording': { + const har = RecordingSchema.parse(JSON.parse(raw)) + const requests = harToProxyData(har) + const browserEvents = har.log._browserEvents?.events ?? [] + + return { + type: 'recording', + data: { requests, browserEvents }, + } + } + + case 'script': + return { + type: 'script', + content: raw, + isExternal: !workspace.isInside(filePath), + } + + case 'json': { + // We should use zod to parse the json, so that we know that the format is correct. + const parsed = JSON.parse(raw) as JsonObject | JsonObject[] + const array = Array.isArray(parsed) ? parsed : [parsed] + + return { + type: 'json', + props: array[0] ? Object.keys(array[0]) : [], + data: array.slice(0, 20), + total: array.length, + } + } + + case 'csv': { + const parsed = parseCSV(raw, { + header: true, + delimiter: ',', + skipEmptyLines: true, + }) + + return { + type: 'csv', + props: parsed.meta.fields ?? [], + data: parsed.data.slice(0, 20), + total: parsed.data.length, + } + } + + case 'unsupported': { + return { + type: 'unsupported-format', + } + } + + default: + return exhaustive(fileType) + } +} + +function serializeContent(content: FileContent, filePath: string): string { + switch (content.type) { + case 'generator': { + const generatorDir = path.dirname(filePath) + + const relativeRecordingPath = + content.data.recordingPath !== '' + ? path.relative(generatorDir, content.data.recordingPath) + : '' + + const relativeFiles = content.data.testData.files.map((file) => { + return { + path: file.path !== '' ? path.relative(generatorDir, file.path) : '', + } + }) + + const relativeRules = content.data.rules.map((rule) => { + if ( + rule.type === 'parameterization' && + rule.value.type === 'dataFileValue' && + rule.value.fileName !== '' + ) { + return { + ...rule, + value: { + ...rule.value, + fileName: path.relative(generatorDir, rule.value.fileName), + }, + } + } + return rule + }) + + const dataToWrite = { + ...content.data, + recordingPath: relativeRecordingPath, + testData: { ...content.data.testData, files: relativeFiles }, + rules: relativeRules, + } + return JSON.stringify(dataToWrite, null, 2) + } + + case 'browser-test': + return JSON.stringify(content.data, null, 2) + + case 'recording': { + const har = proxyDataToHar( + content.data.requests, + content.data.browserEvents + ) + return JSON.stringify(har, null, 2) + } + + case 'script': + return content.content + + default: + return exhaustive(content) + } +} + +function trackSaveFile(content: FileContent) { + const trackingEvent = getTrackingEvent(content) + + if (trackingEvent === null) { + return + } + + trackEvent(trackingEvent) +} + +function getTrackingEvent(content: FileContent): UsageEvent | null { + switch (content.type) { + case 'generator': + return { + event: UsageEventName.GeneratorUpdated, + payload: { + rules: { + correlation: content.data.rules.filter( + (rule) => rule.type === 'correlation' + ).length, + parameterization: content.data.rules.filter( + (rule) => rule.type === 'parameterization' + ).length, + verification: content.data.rules.filter( + (rule) => rule.type === 'verification' + ).length, + customCode: content.data.rules.filter( + (rule) => rule.type === 'customCode' + ).length, + disabled: content.data.rules.filter((rule) => !rule.enabled).length, + }, + }, + } + + case 'browser-test': + return { + event: UsageEventName.BrowserTestUpdated, + } + + case 'script': + return { + event: UsageEventName.ScriptExported, + } + + case 'recording': + return null + + default: + return content satisfies never + } +} diff --git a/src/handlers/file/preload.ts b/src/handlers/file/preload.ts new file mode 100644 index 000000000..05062631a --- /dev/null +++ b/src/handlers/file/preload.ts @@ -0,0 +1,41 @@ +import { ipcRenderer } from 'electron' + +import { + type CreateDirectoryArgs, + type DirectoryEntry, + FileHandler, + FileOnDisk, + GetTempPathArgs, + type ListDirectoryArgs, + OpenFileResult, + SaveFilePayload, +} from './types' + +export function listDirectory(args: ListDirectoryArgs) { + return ipcRenderer.invoke(FileHandler.ListDirectory, args) as Promise< + DirectoryEntry[] + > +} + +export function createDirectory(args: CreateDirectoryArgs) { + return ipcRenderer.invoke(FileHandler.CreateDirectory, args) as Promise +} + +export function save(payload: SaveFilePayload) { + return ipcRenderer.invoke( + FileHandler.Save, + payload + ) as Promise +} + +export function open(path: string) { + return ipcRenderer.invoke(FileHandler.Open, path) as Promise +} + +export function pickOpenFile() { + return ipcRenderer.invoke(FileHandler.PickOpenFile) as Promise +} + +export function getTempPath(payload?: GetTempPathArgs) { + return ipcRenderer.invoke(FileHandler.GetTempPath, payload) as Promise +} diff --git a/src/handlers/file/types.ts b/src/handlers/file/types.ts new file mode 100644 index 000000000..ec0dc6f7e --- /dev/null +++ b/src/handlers/file/types.ts @@ -0,0 +1,116 @@ +import { BrowserTestFile } from '@/schemas/browserTest/v1' +import { StudioFile } from '@/types' +import { GeneratorFileData } from '@/types/generator' +import { RecordingData } from '@/types/recordingData' +import { DataRecord } from '@/types/testData' +import { JsonObject } from '@/utils/json' + +export enum FileHandler { + Save = 'file:save', + Open = 'file:open', + PickOpenFile = 'file:pick-open', + GetTempPath = 'file:get-temp-path', + ListDirectory = 'file:list-directory', + CreateDirectory = 'file:create-directory', +} + +export interface FileEntry { + type: 'file' + basename: string + path: string + file: StudioFile | null +} + +export interface SubDirectoryEntry { + type: 'directory' + basename: string + path: string +} + +export type DirectoryEntry = FileEntry | SubDirectoryEntry + +export interface ListDirectoryArgs { + path: string +} + +export interface CreateDirectoryArgs { + parentPath: string + name: string +} + +export interface GetTempPathArgs { + prefix?: string + extension?: string +} + +export type FileContent = + | GeneratorFileContent + | BrowserTestFileContent + | RecordingFileContent + | ScriptFileContent + +export type FileContentType = FileContent['type'] + +export interface FileOnDisk { + type: 'path' + path: string +} + +export interface UnsavedFile { + type: 'new' + hint: string +} + +export type FileLocation = FileOnDisk | UnsavedFile + +export interface SaveFilePayload { + content: FileContent + location: FileLocation +} + +export interface GeneratorFileContent { + type: 'generator' + data: GeneratorFileData +} + +export interface BrowserTestFileContent { + type: 'browser-test' + data: BrowserTestFile +} + +export interface RecordingFileContent { + type: 'recording' + data: RecordingData +} + +export interface ScriptFileContent { + type: 'script' + content: string +} + +export interface JsonFileContent { + type: 'json' + props: string[] + data: JsonObject[] + total: number +} + +export interface CsvFileContent { + type: 'csv' + props: string[] + data: DataRecord[] + total: number +} + +export interface UnsupportedFileContent { + type: 'unsupported-format' +} + +export type OpenFileResult = + | GeneratorFileContent + | BrowserTestFileContent + | RecordingFileContent + | (ScriptFileContent & { isExternal: boolean }) + | JsonFileContent + | CsvFileContent + | UnsupportedFileContent diff --git a/src/handlers/generator/index.ts b/src/handlers/generator/index.ts index 23c23d584..3fddecfde 100644 --- a/src/handlers/generator/index.ts +++ b/src/handlers/generator/index.ts @@ -1,83 +1,49 @@ import { ipcMain } from 'electron' -import { writeFile, readFile } from 'fs/promises' +import { writeFile } from 'fs/promises' import path from 'path' -import invariant from 'tiny-invariant' -import { - INVALID_FILENAME_CHARS, - K6_GENERATOR_FILE_EXTENSION, -} from '@/constants/files' -import { GENERATORS_PATH } from '@/constants/workspace' -import { GeneratorFileDataSchema } from '@/schemas/generator' +import { K6_GENERATOR_FILE_EXTENSION } from '@/constants/files' import { trackEvent } from '@/services/usageTracking' import { UsageEventName } from '@/services/usageTracking/types' -import { GeneratorFileData } from '@/types/generator' +import { browserWindowFromEvent } from '@/utils/electron' import { createFileWithUniqueName } from '@/utils/fileSystem' import { createNewGeneratorFile } from '@/utils/generator' import { GeneratorHandler } from './types' export function initialize() { - ipcMain.handle(GeneratorHandler.Create, async (_, recordingPath: string) => { - console.log(`${GeneratorHandler.Create} event received`) - const generator = createNewGeneratorFile(recordingPath) - const fileName = await createFileWithUniqueName({ - data: JSON.stringify(generator, null, 2), - directory: GENERATORS_PATH, - ext: K6_GENERATOR_FILE_EXTENSION, - prefix: 'Generator', - }) - - trackEvent({ - event: UsageEventName.GeneratorCreated, - }) - - return fileName - }) - ipcMain.handle( - GeneratorHandler.Save, - async (_, generator: GeneratorFileData, fileName: string) => { - console.log(`${GeneratorHandler.Save} event received`) - invariant(!INVALID_FILENAME_CHARS.test(fileName), 'Invalid file name') + GeneratorHandler.Create, + async (event, recordingPath: string) => { + console.log(`${GeneratorHandler.Create} event received`) + const browserWindow = browserWindowFromEvent(event) + + const placeholderGenerator = createNewGeneratorFile('') + const fileName = await createFileWithUniqueName({ + data: JSON.stringify(placeholderGenerator, null, 2), + directory: browserWindow.workspace.paths.generators, + ext: K6_GENERATOR_FILE_EXTENSION, + prefix: 'Generator', + }) - await writeFile( - path.join(GENERATORS_PATH, fileName), - JSON.stringify(generator, null, 2) + const newGeneratorPath = path.join( + browserWindow.workspace.paths.generators, + fileName ) + const relativeRecordingPath = + recordingPath !== '' + ? path.relative(path.dirname(newGeneratorPath), recordingPath) + : '' - trackGeneratorUpdated(generator) - } - ) + const generator = createNewGeneratorFile(relativeRecordingPath) - ipcMain.handle( - GeneratorHandler.Open, - async (_, fileName: string): Promise => { - console.log(`${GeneratorHandler.Open} event received`) - const data = await readFile(path.join(GENERATORS_PATH, fileName), { - encoding: 'utf-8', - flag: 'r', + await writeFile(newGeneratorPath, JSON.stringify(generator, null, 2)) + + trackEvent({ + event: UsageEventName.GeneratorCreated, }) - return GeneratorFileDataSchema.parse(JSON.parse(data)) + return newGeneratorPath } ) } - -function trackGeneratorUpdated({ rules }: GeneratorFileData) { - trackEvent({ - event: UsageEventName.GeneratorUpdated, - payload: { - rules: { - correlation: rules.filter((rule) => rule.type === 'correlation').length, - parameterization: rules.filter( - (rule) => rule.type === 'parameterization' - ).length, - verification: rules.filter((rule) => rule.type === 'verification') - .length, - customCode: rules.filter((rule) => rule.type === 'customCode').length, - disabled: rules.filter((rule) => !rule.enabled).length, - }, - }, - }) -} diff --git a/src/handlers/generator/preload.ts b/src/handlers/generator/preload.ts index e4651daeb..c70332de9 100644 --- a/src/handlers/generator/preload.ts +++ b/src/handlers/generator/preload.ts @@ -2,6 +2,8 @@ import { ipcRenderer } from 'electron' import { GeneratorFileData } from '@/types/generator' +import { save } from '../file/preload' + import { GeneratorHandler } from './types' export function createGenerator(recordingPath: string) { @@ -11,17 +13,9 @@ export function createGenerator(recordingPath: string) { ) as Promise } -export function saveGenerator(generator: GeneratorFileData, fileName: string) { - return ipcRenderer.invoke( - GeneratorHandler.Save, - generator, - fileName - ) as Promise -} - -export function loadGenerator(fileName: string) { - return ipcRenderer.invoke( - GeneratorHandler.Open, - fileName - ) as Promise +export function saveGenerator(generator: GeneratorFileData, filePath: string) { + return save({ + content: { type: 'generator', data: generator }, + location: { type: 'path', path: filePath }, + }) } diff --git a/src/handlers/har/index.ts b/src/handlers/har/index.ts index 5ee89a515..f700d01b4 100644 --- a/src/handlers/har/index.ts +++ b/src/handlers/har/index.ts @@ -1,25 +1,27 @@ -import { ipcMain, dialog } from 'electron' -import { readFile, copyFile } from 'fs/promises' +import { ipcMain } from 'electron' import path from 'path' -import { RECORDINGS_PATH } from '@/constants/workspace' -import { Recording, RecordingSchema } from '@/schemas/recording' import { trackEvent } from '@/services/usageTracking' import { UsageEventName } from '@/services/usageTracking/types' +import { RecordingData } from '@/types/recordingData' import { browserWindowFromEvent } from '@/utils/electron' import { createFileWithUniqueName } from '@/utils/fileSystem' +import { proxyDataToHar } from '@/utils/proxyDataToHar' import { HarHandler } from './types' export function initialize() { ipcMain.handle( HarHandler.SaveFile, - async (_, data: Recording, prefix: string) => { + async (event, data: RecordingData, prefix: string) => { console.info(`${HarHandler.SaveFile} event received`) + const browserWindow = browserWindowFromEvent(event) + + const har = proxyDataToHar(data.requests, data.browserEvents) const fileName = await createFileWithUniqueName({ - data: JSON.stringify(data, null, 2), - directory: RECORDINGS_PATH, + data: JSON.stringify(har, null, 2), + directory: browserWindow.workspace.paths.recordings, ext: '.har', prefix, }) @@ -28,51 +30,7 @@ export function initialize() { event: UsageEventName.RecordingCreated, }) - return fileName + return path.join(browserWindow.workspace.paths.recordings, fileName) } ) - - ipcMain.handle( - HarHandler.OpenFile, - async (_, fileName: string): Promise => { - console.info(`${HarHandler.OpenFile} event received`) - - const data = await readFile(path.join(RECORDINGS_PATH, fileName), { - encoding: 'utf-8', - flag: 'r', - }) - - return RecordingSchema.parse(JSON.parse(data)) - } - ) - - ipcMain.handle(HarHandler.ImportFile, async (event) => { - console.info(`${HarHandler.ImportFile} event received`) - - const browserWindow = browserWindowFromEvent(event) - - const dialogResult = await dialog.showOpenDialog(browserWindow, { - message: 'Import HAR file', - properties: ['openFile'], - defaultPath: RECORDINGS_PATH, - filters: [{ name: 'HAR', extensions: ['har'] }], - }) - - const filePath = dialogResult.filePaths[0] - - if (dialogResult.canceled || !filePath) { - return - } - - await copyFile( - filePath, - path.join(RECORDINGS_PATH, path.basename(filePath)) - ) - - trackEvent({ - event: UsageEventName.RecordingImported, - }) - - return path.basename(filePath) - }) } diff --git a/src/handlers/har/preload.ts b/src/handlers/har/preload.ts index 1ffc568b3..bc784f189 100644 --- a/src/handlers/har/preload.ts +++ b/src/handlers/har/preload.ts @@ -1,23 +1,13 @@ import { ipcRenderer } from 'electron' -import { Recording } from '@/schemas/recording' +import { RecordingData } from '@/types/recordingData' import { HarHandler } from './types' -export function saveFile(data: Recording, prefix: string) { +export function saveFile(data: RecordingData, prefix: string) { return ipcRenderer.invoke( HarHandler.SaveFile, data, prefix ) as Promise } - -export function openFile(filePath: string) { - return ipcRenderer.invoke(HarHandler.OpenFile, filePath) as Promise -} - -export function importFile() { - return ipcRenderer.invoke(HarHandler.ImportFile) as Promise< - string | undefined - > -} diff --git a/src/handlers/har/types.ts b/src/handlers/har/types.ts index af7a3e1e2..000b3e577 100644 --- a/src/handlers/har/types.ts +++ b/src/handlers/har/types.ts @@ -1,5 +1,3 @@ export enum HarHandler { SaveFile = 'har:save', - OpenFile = 'har:open', - ImportFile = 'har:import', } diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 3d8cb2f73..565d78f7b 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -5,7 +5,7 @@ import * as browser from './browser' import * as browserRemote from './browserRemote' import * as browserTest from './browserTest' import * as cloud from './cloud' -import * as dataFiles from './dataFiles' +import * as file from './file' import * as generator from './generator' import * as har from './har' import * as log from './log' @@ -13,11 +13,13 @@ import * as proxy from './proxy' import * as script from './script' import * as settings from './settings' import * as ui from './ui' +import * as workspace from './workspace' export function initialize() { browserRemote.initialize() auth.initialize() cloud.initialize() + file.initialize() har.initialize() browser.initialize() script.initialize() @@ -26,8 +28,8 @@ export function initialize() { ui.initialize() generator.initialize() browserTest.initialize() - dataFiles.initialize() log.initialize() app.initialize() ai.initialize() + workspace.initialize() } diff --git a/src/handlers/script/index.ts b/src/handlers/script/index.ts index b509d92cc..0917bbaec 100644 --- a/src/handlers/script/index.ts +++ b/src/handlers/script/index.ts @@ -1,153 +1,111 @@ -import { ipcMain } from 'electron' +import { dialog, ipcMain } from 'electron' import log from 'electron-log/main' -import { readFile, writeFile, unlink } from 'fs/promises' -import path from 'path' -import { SCRIPTS_PATH, TEMP_GENERATOR_SCRIPT_PATH } from '@/constants/workspace' import { waitForProxy } from '@/main/proxy' import { showScriptSelectDialog, runScript } from '@/main/script' import { trackEvent } from '@/services/usageTracking' import { UsageEventName } from '@/services/usageTracking/types' -import { browserWindowFromEvent, sendToast } from '@/utils/electron' +import { browserWindowFromEvent } from '@/utils/electron' +import { toScriptFile } from '@/utils/fs/scripts' import { K6Client } from '@/utils/k6/client' import { TestRun } from '@/utils/k6/testRun' -import { isExternalScript } from '@/utils/workspace' + +import { Script } from '../cloud/types' import { ScriptHandler } from './types' export function initialize() { let currentTestRun: TestRun | null - ipcMain.handle(ScriptHandler.Select, async (event) => { - console.info(`${ScriptHandler.Select} event received`) - const browserWindow = browserWindowFromEvent(event) - const scriptPath = await showScriptSelectDialog(browserWindow) + ipcMain.handle(ScriptHandler.Analyze, async (_, scriptPath: string) => { + console.info(`${ScriptHandler.Analyze} event received`) - if (scriptPath) { - trackEvent({ - event: UsageEventName.ScriptOpenedExternal, - }) - } + const options = await new K6Client() + .inspect({ scriptPath }) + .catch(() => ({})) - return scriptPath + return options ?? {} }) - ipcMain.handle(ScriptHandler.Open, async (_, scriptPath: string) => { - console.log(`${ScriptHandler.Open} event received`) - - const absolute = path.isAbsolute(scriptPath) + ipcMain.handle( + ScriptHandler.ShowSaveDialog, + async (event, fileName: string): Promise => { + console.info(`${ScriptHandler.ShowSaveDialog} event received`) - const resolvedScriptPath = absolute - ? scriptPath - : path.join(SCRIPTS_PATH, scriptPath) + const browserWindow = browserWindowFromEvent(event) - const script = await readFile(resolvedScriptPath, { - encoding: 'utf-8', - flag: 'r', - }) + const { filePath } = await dialog.showSaveDialog(browserWindow, { + title: 'Save file', + filters: [{ name: 'All files', extensions: ['*'] }], + defaultPath: fileName, + properties: ['showOverwriteConfirmation'], + }) - const options = await new K6Client() - .inspect({ scriptPath: resolvedScriptPath }) - .catch(() => ({})) + if (filePath === '') { + return null + } - return { - script, - options: options ?? {}, - isExternal: isExternalScript(resolvedScriptPath), + return filePath } - }) - - ipcMain.handle(ScriptHandler.Run, async (event, scriptPath: string) => { - console.info(`${ScriptHandler.Run} event received`) - await waitForProxy() + ) + ipcMain.handle(ScriptHandler.Select, async (event) => { + console.info(`${ScriptHandler.Select} event received`) const browserWindow = browserWindowFromEvent(event) + const scriptPath = await showScriptSelectDialog(browserWindow) - const absolute = path.isAbsolute(scriptPath) - const resolvedScriptPath = absolute - ? scriptPath - : path.join(SCRIPTS_PATH, scriptPath) - - currentTestRun = await runScript({ - browserWindow, - scriptPath: resolvedScriptPath, - proxySettings: k6StudioState.appSettings.proxy, - usageReport: k6StudioState.appSettings.telemetry.usageReport, - }) - - trackEvent({ - event: UsageEventName.ScriptValidated, - payload: { - isExternal: isExternalScript(resolvedScriptPath), - }, - }) - }) - - ipcMain.on(ScriptHandler.Stop, (event) => { - console.info(`${ScriptHandler.Stop} event received`) - if (currentTestRun) { - currentTestRun.stop().catch((error) => { - log.error('Failed to stop the test run', error) + if (scriptPath) { + trackEvent({ + event: UsageEventName.ScriptOpenedExternal, }) - - currentTestRun = null } - const browserWindow = browserWindowFromEvent(event) - browserWindow.webContents.send(ScriptHandler.Stopped) + return scriptPath }) ipcMain.handle( - ScriptHandler.RunFromGenerator, - async (event, script: string, shouldTrack = true) => { - console.info(`${ScriptHandler.RunFromGenerator} event received`) - await writeFile(TEMP_GENERATOR_SCRIPT_PATH, script) + ScriptHandler.Run, + async (event, script: Script, shouldTrack = true) => { + console.info(`${ScriptHandler.Run} event received`) + await waitForProxy() + + const file = await toScriptFile(script) const browserWindow = browserWindowFromEvent(event) currentTestRun = await runScript({ browserWindow, - scriptPath: TEMP_GENERATOR_SCRIPT_PATH, + scriptPath: file.path, proxySettings: k6StudioState.appSettings.proxy, usageReport: k6StudioState.appSettings.telemetry.usageReport, }) + currentTestRun.on('stop', async () => { + await file.dispose() + }) + if (shouldTrack) { trackEvent({ event: UsageEventName.ScriptValidated, payload: { - isExternal: false, + isExternal: !browserWindow.workspace.isInside(file.path), }, }) } - - await unlink(TEMP_GENERATOR_SCRIPT_PATH) } ) - ipcMain.handle( - ScriptHandler.Save, - async (event, script: string, fileName: string = 'script.js') => { - console.info(`${ScriptHandler.Save} event received`) - const browserWindow = browserWindowFromEvent(event) - try { - const filePath = path.join(SCRIPTS_PATH, fileName) - await writeFile(filePath, script) + ipcMain.on(ScriptHandler.Stop, (event) => { + console.info(`${ScriptHandler.Stop} event received`) + if (currentTestRun) { + currentTestRun.stop().catch((error) => { + log.error('Failed to stop the test run', error) + }) - trackEvent({ - event: UsageEventName.ScriptExported, - }) - sendToast(browserWindow.webContents, { - title: 'Script exported successfully', - status: 'success', - }) - } catch (error) { - sendToast(browserWindow.webContents, { - title: 'Failed to export the script', - status: 'error', - }) - log.error(error) - } + currentTestRun = null } - ) + + const browserWindow = browserWindowFromEvent(event) + browserWindow.webContents.send(ScriptHandler.Stopped) + }) } diff --git a/src/handlers/script/preload.ts b/src/handlers/script/preload.ts index c26239db3..d35a2808a 100644 --- a/src/handlers/script/preload.ts +++ b/src/handlers/script/preload.ts @@ -3,6 +3,9 @@ import { ipcRenderer } from 'electron' import { BrowserActionEvent, BrowserReplayEvent } from '@/main/runner/schema' import { Check, LogEntry } from '@/schemas/k6' +import { Script } from '../cloud/types' +import { save } from '../file/preload' +import { FileLocation } from '../file/types' import { createListener } from '../utils' import { OpenScriptResult, ScriptHandler } from './types' @@ -11,33 +14,33 @@ export function showScriptSelectDialog() { return ipcRenderer.invoke(ScriptHandler.Select) as Promise } -export function openScript(scriptPath: string) { - return ipcRenderer.invoke( - ScriptHandler.Open, - scriptPath - ) as Promise +export function showSaveDialog(fileName: string) { + return ipcRenderer.invoke(ScriptHandler.ShowSaveDialog, fileName) as Promise< + string | null + > } -export function runScriptFromGenerator(script: string, shouldTrack = true) { - return ipcRenderer.invoke( - ScriptHandler.RunFromGenerator, - script, - shouldTrack - ) as Promise +export function analyzeScript(location: FileLocation) { + return ipcRenderer.invoke(ScriptHandler.Analyze, location) as Promise< + OpenScriptResult['options'] + > } -export function saveScript(script: string, fileName: string) { +export function saveScript(script: string, filePath: string) { + return save({ + content: { type: 'script', content: script }, + location: { type: 'path', path: filePath }, + }) +} + +export function runScript(script: Script, shouldTrack = true) { return ipcRenderer.invoke( - ScriptHandler.Save, + ScriptHandler.Run, script, - fileName + shouldTrack ) as Promise } -export function runScript(scriptPath: string) { - return ipcRenderer.invoke(ScriptHandler.Run, scriptPath) as Promise -} - export function stopScript() { ipcRenderer.send(ScriptHandler.Stop) } diff --git a/src/handlers/script/types.ts b/src/handlers/script/types.ts index 830338e74..4d2fb352f 100644 --- a/src/handlers/script/types.ts +++ b/src/handlers/script/types.ts @@ -1,7 +1,9 @@ import { K6TestOptions } from '@/utils/k6/schema' export enum ScriptHandler { + Analyze = 'script:analyze', Select = 'script:select', + ShowSaveDialog = 'script:show-save-dialog', Open = 'script:open', Run = 'script:run', Stop = 'script:stop', @@ -12,7 +14,6 @@ export enum ScriptHandler { Finished = 'script:finished', Failed = 'script:failed', Check = 'script:check', - RunFromGenerator = 'script:run-from-generator', BrowserAction = 'script:browser-action', BrowserReplay = 'script:browser-replay', } diff --git a/src/handlers/ui/index.ts b/src/handlers/ui/index.ts index 3cd3ca573..ada937039 100644 --- a/src/handlers/ui/index.ts +++ b/src/handlers/ui/index.ts @@ -1,23 +1,16 @@ import { ipcMain, nativeTheme, shell, BrowserWindow } from 'electron' import log from 'electron-log/main' -import { unlink, readdir, access, rename } from 'fs/promises' +import { unlink, access, rename } from 'fs/promises' import path from 'path' +import { readdirp, EntryInfo, ReaddirpOptions } from 'readdirp' import invariant from 'tiny-invariant' -import { INVALID_FILENAME_CHARS } from '@/constants/files' -import { - RECORDINGS_PATH, - GENERATORS_PATH, - SCRIPTS_PATH, - TEMP_SCRIPT_SUFFIX, - DATA_FILES_PATH, - BROWSER_TESTS_PATH, -} from '@/constants/workspace' -import { getFilePath, getStudioFileFromPath } from '@/main/file' +import { INVALID_FILENAME_CHARS, isIgnoredSystemFile } from '@/constants/files' import { StudioFile } from '@/types' import { getBrowserPath } from '@/utils/browser' import { reportNewIssue } from '@/utils/bugReport' -import { sendToast } from '@/utils/electron' +import { sendToast, browserWindowFromEvent } from '@/utils/electron' +import { createStudioFile } from '@/utils/file' import { isNodeJsErrnoException } from '@/utils/typescript' import { UIHandler } from './types' @@ -45,50 +38,72 @@ export function initialize() { ipcMain.handle(UIHandler.DeleteFile, async (_, file: StudioFile) => { console.info(`${UIHandler.DeleteFile} event received`) - const filePath = getFilePath(file) - return unlink(filePath) + return unlink(file.path) }) ipcMain.on(UIHandler.OpenFolder, (_, file: StudioFile) => { console.info(`${UIHandler.OpenFolder} event received`) - const filePath = getFilePath(file) - return shell.showItemInFolder(filePath) + + return shell.showItemInFolder(file.path) }) ipcMain.handle(UIHandler.OpenFileInDefaultApp, (_, file: StudioFile) => { console.info(`${UIHandler.OpenFileInDefaultApp} event received`) - const filePath = getFilePath(file) - return shell.openPath(filePath) + + return shell.openPath(file.path) }) - ipcMain.handle(UIHandler.GetFiles, async () => { + ipcMain.handle(UIHandler.GetFiles, async (event) => { console.info(`${UIHandler.GetFiles} event received`) - const recordings = (await readdir(RECORDINGS_PATH, { withFileTypes: true })) - .filter((f) => f.isFile()) - .map((f) => getStudioFileFromPath(path.join(RECORDINGS_PATH, f.name))) - .filter((f) => typeof f !== 'undefined') - - const generators = (await readdir(GENERATORS_PATH, { withFileTypes: true })) - .filter((f) => f.isFile()) - .map((f) => getStudioFileFromPath(path.join(GENERATORS_PATH, f.name))) - .filter((f) => typeof f !== 'undefined') - - const browserTests = ( - await readdir(BROWSER_TESTS_PATH, { withFileTypes: true }) - ) - .filter((f) => f.isFile()) - .map((f) => getStudioFileFromPath(path.join(BROWSER_TESTS_PATH, f.name))) - .filter((f) => typeof f !== 'undefined') - - const scripts = (await readdir(SCRIPTS_PATH, { withFileTypes: true })) - .filter((f) => f.isFile() && !f.name.endsWith(TEMP_SCRIPT_SUFFIX)) - .map((f) => getStudioFileFromPath(path.join(SCRIPTS_PATH, f.name))) - .filter((f) => typeof f !== 'undefined') - - const dataFiles = (await readdir(DATA_FILES_PATH, { withFileTypes: true })) - .filter((f) => f.isFile()) - .map((f) => getStudioFileFromPath(path.join(DATA_FILES_PATH, f.name))) - .filter((f) => typeof f !== 'undefined') + + const browserWindow = browserWindowFromEvent(event) + + const recordings: StudioFile[] = [] + const generators: StudioFile[] = [] + const browserTests: StudioFile[] = [] + const scripts: StudioFile[] = [] + const dataFiles: StudioFile[] = [] + + const options: Partial = { + type: 'files', + directoryFilter: (entry: { basename: string }) => { + return entry.basename !== 'node_modules' + }, + fileFilter: (entry: { basename: string }) => { + return !isIgnoredSystemFile(entry.basename) + }, + } + + for await (const entry of readdirp( + browserWindow.workspace.path, + options + ) as AsyncIterable) { + const fullPath = entry.fullPath + const file = createStudioFile(fullPath) + + switch (file.type) { + case 'recording': + recordings.push(file) + break + + case 'generator': + generators.push(file) + break + + case 'browser-test': + browserTests.push(file) + break + + case 'script': + scripts.push(file) + break + + case 'csv': + case 'json': + dataFiles.push(file) + break + } + } return { recordings, @@ -106,33 +121,18 @@ export function initialize() { ipcMain.handle( UIHandler.RenameFile, - async ( - e, - oldFileName: string, - newFileName: string, - type: StudioFile['type'] - ) => { + async (e, oldPath: string, newName: string) => { console.info(`${UIHandler.RenameFile} event received`) const browserWindow = BrowserWindow.fromWebContents(e.sender) try { - invariant( - !INVALID_FILENAME_CHARS.test(newFileName), - 'Invalid file name' - ) - - const oldPath = getFilePath({ - type, - fileName: oldFileName, - }) - const newPath = getFilePath({ - type, - fileName: newFileName, - }) + invariant(!INVALID_FILENAME_CHARS.test(newName), 'Invalid file name') + + const newPath = path.join(path.dirname(oldPath), newName) try { await access(newPath) - throw new Error(`File with name ${newFileName} already exists`) + throw new Error(`File with name ${newName} already exists`) } catch (error) { // Only rename if the error code is ENOENT (file does not exist) if (isNodeJsErrnoException(error) && error.code === 'ENOENT') { diff --git a/src/handlers/ui/preload.ts b/src/handlers/ui/preload.ts index 9d30a7036..3027868b8 100644 --- a/src/handlers/ui/preload.ts +++ b/src/handlers/ui/preload.ts @@ -34,16 +34,11 @@ export function getFiles() { return ipcRenderer.invoke(UIHandler.GetFiles) as Promise } -export function renameFile( - oldFileName: string, - newFileName: string, - type: StudioFile['type'] -) { +export function renameFile(oldPath: string, newName: string) { return ipcRenderer.invoke( UIHandler.RenameFile, - oldFileName, - newFileName, - type + oldPath, + newName ) as Promise } @@ -51,14 +46,6 @@ export function reportIssue() { return ipcRenderer.invoke(UIHandler.ReportIssue) as Promise } -export function onAddFile(callback: (file: StudioFile) => void) { - return createListener(UIHandler.AddFile, callback) -} - -export function onRemoveFile(callback: (file: StudioFile) => void) { - return createListener(UIHandler.RemoveFile, callback) -} - export function onToast(callback: (toast: AddToastPayload) => void) { return createListener(UIHandler.Toast, callback) } diff --git a/src/handlers/ui/types.ts b/src/handlers/ui/types.ts index d03a36319..a01107619 100644 --- a/src/handlers/ui/types.ts +++ b/src/handlers/ui/types.ts @@ -17,7 +17,5 @@ export enum UIHandler { GetFiles = 'ui:get-files', RenameFile = 'ui:rename-file', ReportIssue = 'ui:report-issue', - AddFile = 'ui:add-file', - RemoveFile = 'ui:remove-file', Toast = 'ui:toast', } diff --git a/src/handlers/workspace/index.ts b/src/handlers/workspace/index.ts new file mode 100644 index 000000000..0db77e8da --- /dev/null +++ b/src/handlers/workspace/index.ts @@ -0,0 +1,16 @@ +import { ipcMain } from 'electron' + +import { browserWindowFromEvent } from '@/utils/electron' + +import { WorkspaceHandler } from './types' + +export function initialize() { + ipcMain.handle(WorkspaceHandler.GetWorkspace, (event) => { + const window = browserWindowFromEvent(event) + + return { + path: window.workspace.path, + config: window.workspace.config, + } + }) +} diff --git a/src/handlers/workspace/preload.ts b/src/handlers/workspace/preload.ts new file mode 100644 index 000000000..4a65bd881 --- /dev/null +++ b/src/handlers/workspace/preload.ts @@ -0,0 +1,24 @@ +import { ipcRenderer } from 'electron' + +import type { StudioFile } from '@/types' +import type { Workspace } from '@/types/workspace' + +import { createListener } from '../utils' + +import { WorkspaceHandler } from './types' + +export function onAddFile(callback: (path: StudioFile) => void) { + return createListener(WorkspaceHandler.OnAddFile, callback) +} + +export function onRemoveFile(callback: (path: StudioFile) => void) { + return createListener(WorkspaceHandler.OnRemoveFile, callback) +} + +export function onChangeWorkspace(callback: (path: string) => void) { + return createListener(WorkspaceHandler.OnChangeWorkspace, callback) +} + +export function getWorkspace(): Promise { + return ipcRenderer.invoke(WorkspaceHandler.GetWorkspace) as Promise +} diff --git a/src/handlers/workspace/types.ts b/src/handlers/workspace/types.ts new file mode 100644 index 000000000..3f8fc6940 --- /dev/null +++ b/src/handlers/workspace/types.ts @@ -0,0 +1,7 @@ +export enum WorkspaceHandler { + GetWorkspace = 'workspace:get', + OnAddFile = 'workspace:add-file', + OnRemoveFile = 'workspace:remove-file', + OnChangeFile = 'workspace:change-file', + OnChangeWorkspace = 'workspace:change', +} diff --git a/src/hooks/useCloseSplashScreen.test.ts b/src/hooks/useCloseSplashScreen.test.ts deleted file mode 100644 index c7650dcbb..000000000 --- a/src/hooks/useCloseSplashScreen.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useCloseSplashScreen } from './useCloseSplashScreen' - -const closeSplashscreen = vi.fn() - -beforeAll(() => { - vi.stubGlobal('studio', { - app: { - closeSplashscreen, - }, - }) -}) - -describe('useCloseSplashScreen', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should call closeSplashscreen on mount', () => { - renderHook(() => useCloseSplashScreen()) - - expect(closeSplashscreen).toHaveBeenCalled() - }) -}) diff --git a/src/hooks/useCloseSplashScreen.ts b/src/hooks/useCloseSplashScreen.ts deleted file mode 100644 index d2fde0d06..000000000 --- a/src/hooks/useCloseSplashScreen.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useEffect } from 'react' - -export function useCloseSplashScreen() { - useEffect(() => { - window.studio.app.closeSplashscreen() - }, []) -} diff --git a/src/hooks/useCreateBrowserTest.ts b/src/hooks/useCreateBrowserTest.ts index 28d7717fa..114d7723d 100644 --- a/src/hooks/useCreateBrowserTest.ts +++ b/src/hooks/useCreateBrowserTest.ts @@ -2,26 +2,11 @@ import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { getRoutePath } from '@/routeMap' -import { useToast } from '@/store/ui/useToast' export function useCreateBrowserTest() { - const showToast = useToast() const navigate = useNavigate() - return useCallback(async () => { - try { - const fileName = await window.studio.browserTest.create() - - navigate( - getRoutePath('browserTestEditor', { - fileName: encodeURIComponent(fileName), - }) - ) - } catch { - showToast({ - status: 'error', - title: 'Failed to create browser test', - }) - } - }, [navigate, showToast]) + return useCallback(() => { + navigate(getRoutePath('newBrowserTest')) + }, [navigate]) } diff --git a/src/hooks/useCreateGenerator.test.ts b/src/hooks/useCreateGenerator.test.ts index 892c6a376..ad7c3bb8f 100644 --- a/src/hooks/useCreateGenerator.test.ts +++ b/src/hooks/useCreateGenerator.test.ts @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getRoutePath } from '@/routeMap' -import { useToast } from '@/store/ui/useToast' import { useCreateGenerator } from './useCreateGenerator' @@ -14,66 +13,39 @@ vi.mock('react-router-dom', () => ({ vi.mock('@/routeMap', () => ({ getRoutePath: vi.fn(), })) -vi.mock('@/store/ui/useToast', () => ({ - useToast: vi.fn(), -})) -vi.mock('electron-log/renderer', () => ({ - default: { - error: vi.fn(), - }, -})) describe('useCreateGenerator', () => { const navigate = vi.fn() - const showToast = vi.fn() beforeEach(() => { vi.mocked(useNavigate).mockReturnValue(navigate) - vi.mocked(useToast).mockReturnValue(showToast) + vi.mocked(getRoutePath).mockImplementation(((name: string) => { + if (name === 'newGenerator') return '/new/generator' + return '/file/' + }) as typeof getRoutePath) vi.clearAllMocks() }) - it('should navigate to the correct path on successful generator creation', async () => { - const fileName = 'test-file.json' - const routePath = '/generator/test-file.json' - - vi.mocked(getRoutePath).mockReturnValue(routePath) - vi.stubGlobal('studio', { - generator: { - createGenerator: vi.fn().mockResolvedValue(fileName), - saveGenerator: vi.fn(), - loadGenerator: vi.fn(), - }, - }) - + it('should navigate to new generator route when called without recording path', () => { const { result } = renderHook(() => useCreateGenerator()) - await act(async () => { - await result.current() + act(() => { + result.current() }) - expect(window.studio.generator.createGenerator).toHaveBeenCalledWith('') - expect(navigate).toHaveBeenCalledWith(routePath) + expect(navigate).toHaveBeenCalledWith('/new/generator') }) - it('should show a toast message on failure', async () => { - const error = new Error('Test error') - vi.stubGlobal('studio', { - generator: { - saveGenerator: vi.fn().mockRejectedValue(error), - loadGenerator: vi.fn(), - }, - }) - + it('should navigate to new generator route with recording query param when called with recording path', () => { const { result } = renderHook(() => useCreateGenerator()) + const recordingPath = '/path/to/recording.har' - await act(async () => { - await result.current() + act(() => { + result.current(recordingPath) }) - expect(showToast).toHaveBeenCalledWith({ - status: 'error', - title: 'Failed to create generator', - }) + expect(navigate).toHaveBeenCalledWith( + '/new/generator?recording=' + encodeURIComponent(recordingPath) + ) }) }) diff --git a/src/hooks/useCreateGenerator.ts b/src/hooks/useCreateGenerator.ts index 362ef93a3..311eec492 100644 --- a/src/hooks/useCreateGenerator.ts +++ b/src/hooks/useCreateGenerator.ts @@ -1,32 +1,20 @@ -import log from 'electron-log/renderer' import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { getRoutePath } from '@/routeMap' -import { useToast } from '@/store/ui/useToast' export function useCreateGenerator() { const navigate = useNavigate() - const showToast = useToast() const createTestGenerator = useCallback( - async (recordingPath = '') => { - try { - const fileName = - await window.studio.generator.createGenerator(recordingPath) + (recordingPath = '') => { + const url = recordingPath + ? `${getRoutePath('newGenerator')}?recording=${encodeURIComponent(recordingPath)}` + : getRoutePath('newGenerator') - navigate( - getRoutePath('generator', { fileName: encodeURIComponent(fileName) }) - ) - } catch (error) { - showToast({ - status: 'error', - title: 'Failed to create generator', - }) - log.error(error) - } + navigate(url) }, - [navigate, showToast] + [navigate] ) return createTestGenerator diff --git a/src/hooks/useDeleteFile.test.ts b/src/hooks/useDeleteFile.test.ts index 217c3f30f..1734ae6e4 100644 --- a/src/hooks/useDeleteFile.test.ts +++ b/src/hooks/useDeleteFile.test.ts @@ -18,8 +18,9 @@ describe('useDeleteFile', () => { const showToast = vi.fn() const file: StudioFile = { type: 'recording', - fileName: 'file-name', - displayName: 'test-file', + path: '/recordings/file-name.har', + fileName: 'file-name.har', + displayName: 'file-name', } beforeEach(() => { @@ -44,7 +45,7 @@ describe('useDeleteFile', () => { expect(window.studio.ui.deleteFile).toHaveBeenCalledWith(file) expect(showToast).toHaveBeenCalledWith({ title: 'Recording deleted', - description: 'test-file', + description: 'file-name', status: 'success', }) expect(navigate).not.toHaveBeenCalled() @@ -76,7 +77,7 @@ describe('useDeleteFile', () => { expect(showToast).toHaveBeenCalledWith({ title: 'Failed to delete recording', - description: 'test-file', + description: 'file-name', status: 'error', }) }) diff --git a/src/hooks/useFileNameParam.ts b/src/hooks/useFileNameParam.ts new file mode 100644 index 000000000..3eb601739 --- /dev/null +++ b/src/hooks/useFileNameParam.ts @@ -0,0 +1,28 @@ +import * as pathe from 'pathe' +import { useParams } from 'react-router-dom' +import invariant from 'tiny-invariant' + +import { StudioFile, FileType } from '@/types' + +export function useCurrentFile(type: FileType): StudioFile { + const { path: pathParam } = useParams() + + invariant(pathParam, 'path is required') + + const path = decodeURIComponent(pathParam) + const fileName = pathe.basename(path) + const displayName = pathe.basename(path, pathe.extname(path)) + + return { + type, + path, + fileName, + displayName, + } +} + +export function useCurrentPath(): string | undefined { + const { path } = useParams<{ path: string }>() + + return path +} diff --git a/src/hooks/useImportDataFile.ts b/src/hooks/useImportDataFile.ts deleted file mode 100644 index 71a092ab1..000000000 --- a/src/hooks/useImportDataFile.ts +++ /dev/null @@ -1,41 +0,0 @@ -import log from 'electron-log/renderer' -import { useCallback } from 'react' - -import { useStudioUIStore } from '@/store/ui' -import { useToast } from '@/store/ui/useToast' -import { getFileNameWithoutExtension } from '@/utils/file' - -export function useImportDataFile() { - const showToast = useToast() - const addFile = useStudioUIStore((state) => state.addFile) - - return useCallback(async () => { - try { - const fileName = await window.studio.data.importFile() - - if (fileName) { - showToast({ - title: `Imported ${fileName}`, - status: 'success', - }) - - // There's a slight delay between the import handler and the add callback being triggered, - // causing the UI in Test data options to flicker because it thinks the imported file - // is actually missing. To prevent this, we optimistically update the file list. - addFile({ - type: 'data-file', - fileName, - displayName: getFileNameWithoutExtension(fileName), - }) - } - - return fileName - } catch (error) { - showToast({ - title: 'Failed to import data file', - status: 'error', - }) - log.error(error) - } - }, [addFile, showToast]) -} diff --git a/src/hooks/useRenameFile.ts b/src/hooks/useRenameFile.ts index fac8da5c0..391a83d5e 100644 --- a/src/hooks/useRenameFile.ts +++ b/src/hooks/useRenameFile.ts @@ -1,4 +1,5 @@ import { useMutation } from '@tanstack/react-query' +import * as pathe from 'pathe' import { useNavigate, useParams } from 'react-router-dom' import { useStudioUIStore } from '@/store/ui' @@ -7,20 +8,22 @@ import { getFileNameWithoutExtension, getViewPath } from '@/utils/file' import { queryClient } from '@/utils/query' export function useRenameFile(file: StudioFile) { - const { fileName: selectedFileName } = useParams() + // We don't want to use the useFileNameParam hook here because we might be in + // a view that doesn't have a file name parameter e.g. the home page. + const { path: currentPath } = useParams<{ path?: string }>() + const navigate = useNavigate() const addFile = useStudioUIStore((state) => state.addFile) const removeFile = useStudioUIStore((state) => state.removeFile) return useMutation({ mutationFn: (newName: string) => - window.studio.ui.renameFile(file.fileName, newName, file.type), + window.studio.ui.renameFile(file.path, newName), onSuccess: (_, newName) => { - // There's a slight delay between the add and remove callbacks being triggered, - // causing the UI to flicker because it thinks the renamed file is actually - // a new file. To prevent this, we optimistically update the file list. - const updatedFile = { + const newPath = pathe.join(pathe.dirname(file.path), newName) + const updatedFile: StudioFile = { ...file, + path: newPath, displayName: getFileNameWithoutExtension(newName), fileName: newName, } @@ -28,18 +31,21 @@ export function useRenameFile(file: StudioFile) { removeFile(file) addFile(updatedFile) - if (selectedFileName !== file.fileName) { + if ( + currentPath === undefined || + decodeURIComponent(currentPath) !== file.path + ) { return } if (file.type === 'generator') { queryClient.setQueryData( - ['generator', newName], - queryClient.getQueryData(['generator', file.fileName]) + ['generator', newPath], + queryClient.getQueryData(['generator', file.path]) ) } - navigate(getViewPath(file.type, newName), { replace: true }) + navigate(getViewPath(newPath), { replace: true }) }, }) } diff --git a/src/hooks/useSaveRequested.ts b/src/hooks/useSaveRequested.ts new file mode 100644 index 000000000..a1928625a --- /dev/null +++ b/src/hooks/useSaveRequested.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react' + +import type { SaveRequestedPayload } from '@/handlers/app/preload' + +export function useSaveRequested( + callback: (payload: SaveRequestedPayload | undefined) => void +) { + useEffect(() => { + return window.studio.app.onSaveRequested(callback) + }, [callback]) +} diff --git a/src/hooks/useScriptPreview.test.ts b/src/hooks/useScriptPreview.test.ts deleted file mode 100644 index 9728aa64e..000000000 --- a/src/hooks/useScriptPreview.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react' -import { Dictionary } from 'lodash' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - useGeneratorStore, - selectFilteredRequests, - selectGeneratorData, -} from '@/store/generator' -import { - createGeneratorData, - createGeneratorState, -} from '@/test/factories/generator' -import { ProxyData } from '@/types' -import { groupProxyData } from '@/utils/groups' -import { generateScriptPreview } from '@/views/Generator/Generator.utils' - -import { useScriptPreview } from './useScriptPreview' - -vi.mock('lodash-es', () => ({ - debounce: vi.fn((fn: () => void) => fn), -})) -vi.mock('@/store/generator', () => ({ - useGeneratorStore: { - getState: vi.fn(), - subscribe: vi.fn(), - }, - selectFilteredRequests: vi.fn(), - selectGeneratorData: vi.fn(), -})) -vi.mock('@/utils/groups', () => ({ - groupProxyData: vi.fn(), -})) -vi.mock('@/views/Generator/Generator.utils', () => ({ - generateScriptPreview: vi.fn(), -})) - -describe('useScriptPreview', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should initialize with an empty preview and no error', () => { - const mockState = createGeneratorState() - vi.mocked(useGeneratorStore.getState).mockReturnValue(mockState) - const { result } = renderHook(() => useScriptPreview()) - - expect(result.current.preview).toBe('') - expect(result.current.error).toBeUndefined() - expect(result.current.hasError).toBe(false) - }) - - it('should update the preview when the store state changes', async () => { - const mockState = createGeneratorState() - const mockGeneratorData = createGeneratorData() - const mockRequests: ProxyData[] = [] - const mockGroupedRequests: Dictionary = {} - const mockScript = 'mock script' - - vi.mocked(useGeneratorStore.getState).mockReturnValue(mockState) - vi.mocked(selectGeneratorData).mockReturnValue(mockGeneratorData) - vi.mocked(selectFilteredRequests).mockReturnValue(mockRequests) - vi.mocked(groupProxyData).mockReturnValue(mockGroupedRequests) - vi.mocked(generateScriptPreview).mockResolvedValue(mockScript) - - const { result } = renderHook(() => useScriptPreview()) - - await waitFor(() => { - expect(result.current.preview).toBe(mockScript) - expect(result.current.error).toBeUndefined() - expect(result.current.hasError).toBe(false) - }) - }) - - it('should set an error when generateScriptPreview throws an error', async () => { - const mockState = createGeneratorState() - const mockGeneratorData = createGeneratorData() - const mockRequests: ProxyData[] = [] - const mockGroupedRequests: Dictionary = {} - const mockError = new Error('mock error') - - vi.spyOn(console, 'error').mockImplementation(() => undefined) - vi.mocked(useGeneratorStore.getState).mockReturnValue(mockState) - vi.mocked(selectGeneratorData).mockReturnValue(mockGeneratorData) - vi.mocked(selectFilteredRequests).mockReturnValue(mockRequests) - vi.mocked(groupProxyData).mockReturnValue(mockGroupedRequests) - vi.mocked(generateScriptPreview).mockRejectedValue(mockError) - - const { result } = renderHook(() => useScriptPreview()) - - await waitFor(() => { - expect(result.current.preview).toBe('') - expect(result.current.error).toBe(mockError) - expect(result.current.hasError).toBe(true) - }) - }) -}) diff --git a/src/hooks/useScriptPreview.ts b/src/hooks/useScriptPreview.ts deleted file mode 100644 index 23b798ff0..000000000 --- a/src/hooks/useScriptPreview.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { debounce } from 'lodash-es' -import { useEffect, useState } from 'react' - -import { - selectFilteredRequests, - selectGeneratorData, - useGeneratorStore, - GeneratorStore, -} from '@/store/generator' -import { generateScriptPreview } from '@/views/Generator/Generator.utils' - -export function useScriptPreview() { - const [preview, setPreview] = useState('') - const [error, setError] = useState() - - // Connect to the store on mount, disconnect on unmount, regenerate preview on state change - useEffect(() => { - const updatePreview = debounce(async (state: GeneratorStore) => { - try { - setError(undefined) - const generator = selectGeneratorData(state) - const requests = selectFilteredRequests(state) - - const script = await generateScriptPreview(generator, requests) - setPreview(script) - } catch (e) { - console.error(e) - setError(e as Error) - } - }, 100) - - // Initial preview generation - // TODO: https://github.com/grafana/k6-studio/issues/277 - // eslint-disable-next-line @typescript-eslint/no-floating-promises - updatePreview(useGeneratorStore.getState()) - - const unsubscribe = useGeneratorStore.subscribe((state) => - updatePreview(state) - ) - return unsubscribe - }, []) - - return { preview, error, hasError: !!error } -} diff --git a/src/main.ts b/src/main.ts index 117cac88f..6416e4221 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,8 +5,10 @@ import isSquirrelStartup from 'electron-squirrel-startup' import path from 'path' import { updateElectronApp } from 'update-electron-app' +import { PROJECT_PATH } from './constants/workspace' import * as handlers from './handlers' import { ProxyHandler } from './handlers/proxy/types' +import { WorkspaceHandler } from './handlers/workspace/types' import { initializeDeepLinks } from './main/deepLinks' import * as mainState from './main/k6StudioState' import { initializeLogger } from './main/logger' @@ -17,13 +19,13 @@ import { stopProxyProcess, } from './main/proxy' import { getSettings, initSettings } from './main/settings' -import { closeWatcher, configureWatcher } from './main/watcher' import { showWindow, trackWindowState } from './main/window' import { configureSystemProxy } from './services/http' import { initEventTracking } from './services/usageTracking' import { ProxyStatus } from './types' -import { getAppIcon, getPlatform } from './utils/electron' -import { setupProjectStructure } from './utils/workspace' +import { getAppIcon, getPlatform, sendToast } from './utils/electron' +import { createStudioFile } from './utils/file' +import { setupProjectStructure, Workspace } from './utils/workspace' if (process.env.NODE_ENV !== 'development') { // handle auto updates @@ -50,8 +52,8 @@ if (isSquirrelStartup) { } initializeLogger() -handlers.initialize() mainState.initialize() +handlers.initialize() initializeDeepLinks() const createSplashWindow = async () => { @@ -97,14 +99,18 @@ const createSplashWindow = async () => { const createWindow = async () => { const icon = getAppIcon(process.env.NODE_ENV === 'development') + if (getPlatform() === 'mac') { app.dock?.setIcon(icon) } + app.setName('Grafana k6 Studio') // clean leftover proxies if any, this might happen on windows await cleanUpProxies() + const workspace = await Workspace.create(PROJECT_PATH) + const { width, height, x, y } = k6StudioState.appSettings.windowState // Create the browser window. @@ -125,8 +131,40 @@ const createWindow = async () => { }, }) + mainWindow.workspace = workspace + + mainWindow.workspace.on('file:add', (event) => { + mainWindow.webContents.send( + WorkspaceHandler.OnAddFile, + createStudioFile(event.path) + ) + }) + + mainWindow.workspace.on('file:remove', (event) => { + mainWindow.webContents.send( + WorkspaceHandler.OnRemoveFile, + createStudioFile(event.path) + ) + }) + + mainWindow.workspace.on('workspace:change', (event) => { + mainWindow.webContents.send(WorkspaceHandler.OnChangeWorkspace, event.path) + }) + + app.on('open-file', (event, path) => { + event.preventDefault() + + mainWindow.workspace.switch(path).catch((error) => { + log.error(error) + + sendToast(mainWindow.webContents, { + title: 'Failed to open workspace', + status: 'error', + }) + }) + }) + configureApplicationMenu() - configureWatcher(mainWindow) k6StudioState.wasAppClosedByClient = false k6StudioState.proxyEmitter.on('status:change', (status: ProxyStatus) => { @@ -164,7 +202,9 @@ const createWindow = async () => { mainWindow.on('close', (event) => { mainWindow.webContents.send('app:close') if ( - k6StudioState.currentClientRoute.startsWith('/generator') && + (k6StudioState.currentClientRoute.startsWith('/generator') || + (k6StudioState.currentClientRoute.startsWith('/file/') && + k6StudioState.currentClientRoute.includes('.k6g'))) && !k6StudioState.wasAppClosedByClient ) { event.preventDefault() @@ -200,13 +240,11 @@ app.whenReady().then( // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. -app.on('window-all-closed', async () => { +app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() return } - - await closeWatcher() }) app.on('activate', async () => { @@ -214,12 +252,12 @@ app.on('activate', async () => { // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { const mainWindow = await createWindow() + showWindow(mainWindow) } }) app.on('before-quit', async () => { k6StudioState.appShuttingDown = true - await closeWatcher() return stopProxyProcess() }) diff --git a/src/main/file.ts b/src/main/file.ts index 0653e19e9..ca999c85a 100644 --- a/src/main/file.ts +++ b/src/main/file.ts @@ -1,95 +1,6 @@ import os from 'os' import path from 'path' -import { - K6_BROWSER_TEST_FILE_EXTENSION, - K6_GENERATOR_FILE_EXTENSION, -} from '@/constants/files' -import { - RECORDINGS_PATH, - GENERATORS_PATH, - SCRIPTS_PATH, - DATA_FILES_PATH, - BROWSER_TESTS_PATH, -} from '@/constants/workspace' -import { StudioFile } from '@/types' -import { exhaustive } from '@/utils/typescript' - -export function getStudioFileFromPath( - filePath: string -): StudioFile | undefined { - const file = { - displayName: path.parse(filePath).name, - fileName: path.basename(filePath), - } - - if ( - filePath.startsWith(RECORDINGS_PATH) && - path.extname(filePath) === '.har' - ) { - return { - type: 'recording', - ...file, - } - } - - if ( - filePath.startsWith(BROWSER_TESTS_PATH) && - path.extname(filePath) === K6_BROWSER_TEST_FILE_EXTENSION - ) { - return { - type: 'browser-test', - ...file, - } - } - - if ( - filePath.startsWith(GENERATORS_PATH) && - path.extname(filePath) === K6_GENERATOR_FILE_EXTENSION - ) { - return { - type: 'generator', - ...file, - } - } - - if (filePath.startsWith(SCRIPTS_PATH) && path.extname(filePath) === '.js') { - return { - type: 'script', - ...file, - } - } - - if ( - filePath.startsWith(DATA_FILES_PATH) && - (path.extname(filePath) === '.json' || path.extname(filePath) === '.csv') - ) { - return { - type: 'data-file', - ...file, - } - } -} - -export function getFilePath( - file: Partial & Pick -) { - switch (file.type) { - case 'recording': - return path.join(RECORDINGS_PATH, file.fileName) - case 'generator': - return path.join(GENERATORS_PATH, file.fileName) - case 'browser-test': - return path.join(BROWSER_TESTS_PATH, file.fileName) - case 'script': - return path.join(SCRIPTS_PATH, file.fileName) - case 'data-file': - return path.join(DATA_FILES_PATH, file.fileName) - default: - return exhaustive(file.type) - } -} - export function expandHomeDir(inputPath?: string) { if (!inputPath) return inputPath if (inputPath.startsWith('~')) { diff --git a/src/main/menu.ts b/src/main/menu.ts index 577de79ca..d47112b66 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,52 +1,229 @@ -import { Menu, shell } from 'electron' +import { app, BrowserWindow, dialog, Menu, shell } from 'electron' +import log from 'electron-log/main' +import path from 'path' +import { AppHandler } from '../handlers/app/types' import { reportNewIssue } from '../utils/bugReport' -import { getPlatform } from '../utils/electron' +import { getPlatform, sendToast } from '../utils/electron' import { openLogFolder } from './logger' const isDevEnv = process.env.NODE_ENV === 'development' const isMac = getPlatform() === 'mac' +function getOpenRecentSubmenu(): Electron.MenuItemConstructorOptions[] { + const recentPaths = + typeof app.getRecentDocuments === 'function' ? app.getRecentDocuments() : [] + + const items: Electron.MenuItemConstructorOptions[] = recentPaths.map( + (filePath) => ({ + label: path.basename(filePath), + click: (_menuItem, window) => { + if (window instanceof BrowserWindow === false) { + return + } + + window.workspace.switch(filePath).catch((error) => { + log.error(error) + + sendToast(window.webContents, { + title: 'Failed to open workspace', + status: 'error', + }) + }) + }, + }) + ) + + if (items.length > 0) { + items.push({ type: 'separator' }) + } + + items.push({ + label: 'Clear Recently Opened', + click: () => { + app.clearRecentDocuments() + refreshApplicationMenu() + }, + }) + + return items +} + +function refreshApplicationMenu() { + const menu = Menu.buildFromTemplate(getMenuTemplate()) + + Menu.setApplicationMenu(menu) +} + // Custom application menu // https://www.electronjs.org/docs/latest/api/menu -const template: Electron.MenuItemConstructorOptions[] = [ - ...getAppMenu(), - { role: 'fileMenu' }, - { role: 'editMenu' }, - { - label: 'View', - submenu: [ - ...getDevToolsMenu(), - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - ], - }, - { role: 'windowMenu' }, - { - role: 'help', - submenu: [ - { - label: 'Documentation', - click: async () => { - await shell.openExternal('https://grafana.com/docs/k6-studio/') +function getMenuTemplate(): Electron.MenuItemConstructorOptions[] { + return [ + ...getAppMenu(), + { + role: 'fileMenu', + submenu: [ + { + label: 'New', + submenu: [ + { + label: 'Recording', + click: (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + browserWindow.webContents.send(AppHandler.DeepLink, '/recorder') + }, + }, + { + label: 'HTTP test', + click: (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + browserWindow.webContents.send( + AppHandler.DeepLink, + '/new/generator' + ) + }, + }, + { + label: 'Browser test', + click: (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + browserWindow.webContents.send( + AppHandler.DeepLink, + '/new/browser-test' + ) + }, + }, + ], }, - }, - { - label: 'Report an issue', - click: reportNewIssue, - }, - { - label: 'Application logs', - click: () => openLogFolder(), - }, - ], - }, -] + { type: 'separator' }, + { + label: 'Open workspace...', + click: async (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + const { + filePaths: [selectedPath], + } = await dialog.showOpenDialog(browserWindow, { + properties: ['openDirectory', 'createDirectory'], + title: 'Open workspace', + }) + + if (selectedPath === undefined) { + return + } + + browserWindow.workspace.switch(selectedPath).catch((error) => { + log.error(error) + + sendToast(browserWindow.webContents, { + title: 'Failed to open workspace', + status: 'error', + }) + }) + }, + }, + { + label: 'Open file...', + click: async (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + const { + filePaths: [selectedPath], + } = await dialog.showOpenDialog(browserWindow, { + properties: ['openFile'], + title: 'Open file', + }) + + if (selectedPath === undefined) { + return + } + + const route = `/file/${encodeURIComponent(selectedPath)}` + browserWindow.webContents.send(AppHandler.DeepLink, route) + }, + }, + { + label: 'Open Recent', + submenu: getOpenRecentSubmenu(), + }, + { type: 'separator' }, + { + label: 'Save', + accelerator: isMac ? 'Cmd+S' : 'Ctrl+S', + click: (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + browserWindow.webContents.send(AppHandler.SaveRequested) + }, + }, + { + label: 'Save As...', + accelerator: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S', + click: (_menuItem, browserWindow) => { + if (browserWindow instanceof BrowserWindow === false) { + return + } + + browserWindow.webContents.send(AppHandler.SaveRequested, { + saveAs: true, + }) + }, + }, + { type: 'separator' }, + { role: 'close' }, + ], + }, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + ...getDevToolsMenu(), + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Documentation', + click: async () => { + await shell.openExternal('https://grafana.com/docs/k6-studio/') + }, + }, + { + label: 'Report an issue', + click: reportNewIssue, + }, + { + label: 'Application logs', + click: () => openLogFolder(), + }, + ], + }, + ] +} function getAppMenu(): Electron.MenuItemConstructorOptions[] { return isMac ? [{ role: 'appMenu' }] : [] @@ -59,6 +236,11 @@ function getDevToolsMenu(): Electron.MenuItemConstructorOptions[] { } export function configureApplicationMenu() { - const menu = Menu.buildFromTemplate(template) - Menu.setApplicationMenu(menu) + refreshApplicationMenu() +} + +export function addToRecentDocuments(filePath: string) { + app.addRecentDocument(filePath) + + refreshApplicationMenu() } diff --git a/src/main/runner/instrumentation.ts b/src/main/runner/instrumentation.ts index a262bc99d..a1d28495c 100644 --- a/src/main/runner/instrumentation.ts +++ b/src/main/runner/instrumentation.ts @@ -4,11 +4,13 @@ import path from 'path' import { baseProps, NodeType } from '@/codegen/estree/nodes' import { traverse } from '@/codegen/estree/traverse' +import { makeRelativePath } from '@/utils/fs/path' import { readResource } from '@/utils/resources' interface InstrumentScriptOptions { entryScript: string replayScript: string + entryPath: string scriptPath: string } @@ -22,6 +24,7 @@ const parseScript = (input: string) => { export const instrumentScript = ({ entryScript, replayScript, + entryPath, scriptPath, }: InstrumentScriptOptions) => { const entryAst = parseScript(entryScript) @@ -30,9 +33,9 @@ export const instrumentScript = ({ throw new Error('Failed to parse entry script') } - // Use relative import path with ./ prefix for cross-platform compatibility - const scriptBasename = path.basename(scriptPath) - const relativePath = `./${scriptBasename}` + // Use relative import path from entry script's directory + const entryDir = path.dirname(entryPath) + const relativePath = makeRelativePath(entryDir, scriptPath) traverse(entryAst, { [NodeType.ImportDeclaration](node) { @@ -59,13 +62,17 @@ export const instrumentScript = ({ return generate(entryAst) } -export const instrumentScriptFromPath = async (scriptPath: string) => { +export const instrumentScriptFromPath = async ( + entryPath: string, + scriptPath: string +) => { const entryScript = await readResource('entrypoint-script') const replayScript = await readResource('replay-script') return instrumentScript({ entryScript, replayScript, + entryPath, scriptPath, }) } diff --git a/src/main/script.ts b/src/main/script.ts index 844baec9a..2f41ef637 100644 --- a/src/main/script.ts +++ b/src/main/script.ts @@ -4,7 +4,11 @@ import { writeFile, unlink } from 'fs/promises' import { ChildProcessWithoutNullStreams } from 'node:child_process' import path from 'path' -import { TEMP_K6_ARCHIVE_PATH, TEMP_SCRIPT_SUFFIX } from '@/constants/workspace' +import { + TEMP_K6_ARCHIVE_PATH, + TEMP_PATH, + TEMP_SCRIPT_SUFFIX, +} from '@/constants/workspace' import { ScriptHandler } from '@/handlers/script/types' import { getProxyArguments } from '@/main/proxy' import { ProxySettings } from '@/types/settings' @@ -45,23 +49,24 @@ export const runScript = async ({ proxySettings, browserWindow, }: RunScriptOptions) => { + const entryScriptName = getTempScriptName() + const entryScriptPath = path.join(TEMP_PATH, entryScriptName) + // 1. Get an instrumented version of the script content - const modifiedScript = await instrumentScriptFromPath(scriptPath) + const modifiedScript = await instrumentScriptFromPath( + entryScriptPath, + scriptPath + ) // 2. Save the enhanced script content to a temp file in the same directory as the original script // (k6 will look for modules/data files in the same directory as the script) - const dirname = path.dirname(scriptPath) - - const tempFileName = getTempScriptName() - const tempScriptPath = path.join(dirname, tempFileName) - - await writeFile(tempScriptPath, modifiedScript) + await writeFile(entryScriptPath, modifiedScript) // 3. Archive the script and its dependencies - const archivePath = await archiveScript(tempScriptPath, browserWindow) + const archivePath = await archiveScript(entryScriptPath, browserWindow) // 4. Delete the temp script file - await unlink(tempScriptPath) + await unlink(entryScriptPath) const proxyArgs = await getProxyArguments(proxySettings, { prefix: '', diff --git a/src/main/watcher.ts b/src/main/watcher.ts deleted file mode 100644 index aa1b6d651..000000000 --- a/src/main/watcher.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { watch } from 'chokidar' -import { BrowserWindow } from 'electron' - -import { - RECORDINGS_PATH, - GENERATORS_PATH, - SCRIPTS_PATH, - DATA_FILES_PATH, - TEMP_SCRIPT_SUFFIX, - BROWSER_TESTS_PATH, -} from '@/constants/workspace' -import { UIHandler } from '@/handlers/ui/types' - -import { getStudioFileFromPath } from './file' - -export function configureWatcher(browserWindow: BrowserWindow) { - k6StudioState.watcher = watch( - [ - RECORDINGS_PATH, - GENERATORS_PATH, - BROWSER_TESTS_PATH, - SCRIPTS_PATH, - DATA_FILES_PATH, - ], - { - ignoreInitial: true, - } - ) - - k6StudioState.watcher.on('add', (filePath) => { - const file = getStudioFileFromPath(filePath) - - if (!file || filePath.endsWith(TEMP_SCRIPT_SUFFIX)) { - return - } - - browserWindow.webContents.send(UIHandler.AddFile, file) - }) - - k6StudioState.watcher.on('unlink', (filePath) => { - const file = getStudioFileFromPath(filePath) - - if (!file || filePath.endsWith(TEMP_SCRIPT_SUFFIX)) { - return - } - - browserWindow.webContents.send(UIHandler.RemoveFile, file) - }) -} - -export async function closeWatcher() { - // stop watching files to avoid crash on exit - if (k6StudioState.watcher) { - await k6StudioState.watcher.close() - } -} diff --git a/src/preload.ts b/src/preload.ts index b5a9a5fa7..fe4717620 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -7,7 +7,7 @@ import * as browser from './handlers/browser/preload' import * as browserRemote from './handlers/browserRemote/preload' import * as browserTest from './handlers/browserTest/preload' import * as cloud from './handlers/cloud/preload' -import * as data from './handlers/dataFiles/preload' +import * as file from './handlers/file/preload' import * as generator from './handlers/generator/preload' import * as har from './handlers/har/preload' import * as log from './handlers/log/preload' @@ -15,6 +15,7 @@ import * as proxy from './handlers/proxy/preload' import * as script from './handlers/script/preload' import * as settings from './handlers/settings/preload' import * as ui from './handlers/ui/preload' +import * as workspace from './handlers/workspace/preload' import * as Sentry from './sentry' const studio = { @@ -22,7 +23,7 @@ const studio = { proxy, browser, script, - data, + file, har, ui, generator, @@ -33,6 +34,7 @@ const studio = { browserRemote, cloud, ai, + workspace, } as const contextBridge.exposeInMainWorld('studio', studio) diff --git a/src/routeMap.ts b/src/routeMap.ts index b6df1d8c1..0a48dcf5c 100644 --- a/src/routeMap.ts +++ b/src/routeMap.ts @@ -3,11 +3,10 @@ import { generatePath } from 'react-router-dom' const routes = { home: '/', recorder: '/recorder', - recordingPreviewer: '/recording-previewer/:fileName', - validator: '/validator/:fileName?', - generator: '/generator/:fileName', - browserTestEditor: '/editor/:fileName', - dataFilePreviewer: '/data-file/:fileName', + editorView: '/file/:path', + newGenerator: '/new/generator', + newBrowserTest: '/new/browser-test', + validator: '/validator/:path?', } export type RouteName = keyof typeof routes @@ -26,9 +25,8 @@ export function getRoutePath( export const routeMap = { home: getRoutePath('home'), recorder: getRoutePath('recorder'), - recordingPreviewer: getRoutePath('recordingPreviewer'), - generator: getRoutePath('generator'), - browserTestEditor: getRoutePath('browserTestEditor'), + editorView: getRoutePath('editorView'), + newGenerator: getRoutePath('newGenerator'), + newBrowserTest: getRoutePath('newBrowserTest'), validator: getRoutePath('validator'), - dataFilePreviewer: getRoutePath('dataFilePreviewer'), } diff --git a/src/rules/parameterization.ts b/src/rules/parameterization.ts index 8f95f092f..d14230f83 100644 --- a/src/rules/parameterization.ts +++ b/src/rules/parameterization.ts @@ -4,7 +4,10 @@ import { ParameterizationRuleInstance, ParameterizationState, } from '@/types/rules' -import { getFileNameWithoutExtension } from '@/utils/file' +import { + getDataFileDisplayName, + getFileNameWithoutExtension, +} from '@/utils/file' import { exhaustive } from '@/utils/typescript' import { replaceRequestValues } from './selectors' @@ -88,7 +91,9 @@ function getRuleValue(rule: ParameterizationRule, id: number) { return `\${VARS['${value.variableName}']}` case 'dataFileValue': { - const displayName = getFileNameWithoutExtension(value.fileName) + const displayName = getFileNameWithoutExtension( + getDataFileDisplayName(value.fileName) + ) return `\${getUniqueItem(FILES['${displayName}'])['${value.propertyName}']}` } diff --git a/src/schemas/generator/index.test.ts b/src/schemas/generator/index.test.ts index 0aac9a6ea..b16d08c38 100644 --- a/src/schemas/generator/index.test.ts +++ b/src/schemas/generator/index.test.ts @@ -45,7 +45,104 @@ describe('Generator migration', () => { } const migration = migrate(v0Generator) - expect(migration.version).toBe('2.0') + expect(migration.version).toBe('3.0') expect(migration.options.thresholds).toEqual([]) + expect(migration.recordingPath).toBe('../Recordings/test') + }) + + it('should migrate v2 recordingPath to relative path (v3)', () => { + const v2Generator = { + version: '2.0' as const, + recordingPath: 'my-recording.har', + options: { + loadProfile: { + executor: 'ramping-vus' as const, + stages: [], + }, + thinkTime: { + sleepType: 'groups' as const, + timing: { type: 'fixed' as const, value: 1 }, + }, + thresholds: [], + cloud: { + loadZones: { distribution: 'even' as const, zones: [] }, + }, + }, + testData: { variables: [], files: [] }, + rules: [], + allowlist: [], + includeStaticAssets: false, + scriptName: 'my-script.js', + } + + const migration = migrate(v2Generator) + expect(migration.version).toBe('3.0') + expect(migration.recordingPath).toBe('../Recordings/my-recording.har') + }) + + it('should keep empty recordingPath when migrating v2 to v3', () => { + const v2Generator = { + version: '2.0' as const, + recordingPath: '', + options: { + loadProfile: { + executor: 'ramping-vus' as const, + stages: [], + }, + thinkTime: { + sleepType: 'groups' as const, + timing: { type: 'fixed' as const, value: 1 }, + }, + thresholds: [], + cloud: { + loadZones: { distribution: 'even' as const, zones: [] }, + }, + }, + testData: { variables: [], files: [] }, + rules: [], + allowlist: [], + includeStaticAssets: false, + scriptName: 'my-script.js', + } + + const migration = migrate(v2Generator) + expect(migration.version).toBe('3.0') + expect(migration.recordingPath).toBe('') + }) + + it('should migrate v2 testData.files name to path (v3)', () => { + const v2Generator = { + version: '2.0' as const, + recordingPath: '', + options: { + loadProfile: { + executor: 'ramping-vus' as const, + stages: [], + }, + thinkTime: { + sleepType: 'groups' as const, + timing: { type: 'fixed' as const, value: 1 }, + }, + thresholds: [], + cloud: { + loadZones: { distribution: 'even' as const, zones: [] }, + }, + }, + testData: { + variables: [], + files: [{ name: 'users.json' }, { name: 'data/products.csv' }], + }, + rules: [], + allowlist: [], + includeStaticAssets: false, + scriptName: 'my-script.js', + } + + const migration = migrate(v2Generator) + expect(migration.version).toBe('3.0') + expect(migration.testData.files).toEqual([ + { path: '../Data/users.json' }, + { path: '../Data/products.csv' }, + ]) }) }) diff --git a/src/schemas/generator/index.ts b/src/schemas/generator/index.ts index 1c268cdfd..f2a8f5071 100644 --- a/src/schemas/generator/index.ts +++ b/src/schemas/generator/index.ts @@ -5,11 +5,13 @@ import { exhaustive } from '../../utils/typescript' import * as v0 from './v0' import * as v1 from './v1' import * as v2 from './v2' +import * as v3 from './v3' const AnyGeneratorSchema = z.discriminatedUnion('version', [ v0.GeneratorFileDataSchema, v1.GeneratorFileDataSchema, v2.GeneratorFileDataSchema, + v3.GeneratorFileDataSchema, ]) export function migrate(generator: z.infer) { @@ -19,6 +21,8 @@ export function migrate(generator: z.infer) { case '1.0': return migrate(v1.migrate(generator)) case '2.0': + return migrate(v2.migrate(generator)) + case '3.0': return generator default: return exhaustive(generator) @@ -28,6 +32,6 @@ export function migrate(generator: z.infer) { export const GeneratorFileDataSchema = AnyGeneratorSchema.transform(migrate) export * from './v2/rules' -export * from './v2/testData' +export * from './v3/testData' export * from './v2/testOptions' export * from './v2/thresholds' diff --git a/src/schemas/generator/v2/index.ts b/src/schemas/generator/v2/index.ts index 3e3199d12..474c332d6 100644 --- a/src/schemas/generator/v2/index.ts +++ b/src/schemas/generator/v2/index.ts @@ -1,5 +1,8 @@ +import path from 'path' import { z } from 'zod' +import * as v3 from '../v3' + import { TestRuleSchema } from './rules' import { TestDataSchema } from './testData' import { TestOptionsSchema } from './testOptions' @@ -17,7 +20,25 @@ export const GeneratorFileDataSchema = z.object({ export type GeneratorSchema = z.infer -// TODO: Migrate generator to the next version -export function migrate(generator: z.infer) { - return { ...generator } +export function migrate( + generator: z.infer +): v3.GeneratorSchema { + const relativeRecordingPath = + generator.recordingPath !== '' + ? `../Recordings/${path.basename(generator.recordingPath)}` + : '' + + const files = generator.testData.files.map((file) => ({ + path: `../Data/${path.basename(file.name)}`, + })) + + return { + ...generator, + version: '3.0', + recordingPath: relativeRecordingPath, + testData: { + ...generator.testData, + files, + }, + } } diff --git a/src/schemas/generator/v3/index.ts b/src/schemas/generator/v3/index.ts new file mode 100644 index 000000000..f6ec29055 --- /dev/null +++ b/src/schemas/generator/v3/index.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +import { TestRuleSchema } from './rules' +import { TestDataSchema } from './testData' +import { TestOptionsSchema } from './testOptions' + +export const GeneratorFileDataSchema = z.object({ + version: z.literal('3.0'), + /** Path to the recording file relative to the generator file (e.g. ../Recordings/file.har) */ + recordingPath: z.string(), + options: TestOptionsSchema, + testData: TestDataSchema, + rules: TestRuleSchema.array(), + allowlist: z.string().array(), + includeStaticAssets: z.boolean(), + scriptName: z.string().default('my-script.js'), +}) + +export type GeneratorSchema = z.infer + +export function migrate(generator: z.infer) { + return { ...generator } +} diff --git a/src/schemas/generator/v3/loadZone.ts b/src/schemas/generator/v3/loadZone.ts new file mode 100644 index 000000000..cc995a9fa --- /dev/null +++ b/src/schemas/generator/v3/loadZone.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +export const AvailableLoadZonesSchema = z.enum([ + 'amazon:us:columbus', + 'amazon:sa:cape town', + 'amazon:cn:hong kong', + 'amazon:in:mumbai', + 'amazon:jp:osaka', + 'amazon:kr:seoul', + 'amazon:sg:singapore', + 'amazon:au:sydney', + 'amazon:jp:tokyo', + 'amazon:ca:montreal', + 'amazon:de:frankfurt', + 'amazon:ie:dublin', + 'amazon:gb:london', + 'amazon:it:milan', + 'amazon:fr:paris', + 'amazon:se:stockholm', + 'amazon:bh:bahrain', + 'amazon:br:sao paulo', + 'amazon:us:palo alto', + 'amazon:us:portland', + 'amazon:us:ashburn', +]) + +export const LoadZoneItemSchema = z.object({ + id: z.string(), + loadZone: AvailableLoadZonesSchema, + percent: z + .number({ message: 'Invalid percentage' }) + .int() + .min(1, { message: 'Invalid percentage' }) + .max(100, { message: 'Invalid percentage' }), +}) + +export const LoadZoneSchema = z.object({ + distribution: z.enum(['even', 'manual']), + zones: z.array(LoadZoneItemSchema).refine( + (data) => { + if (data.length === 0) { + return true + } + + const totalPercentage = currentLoadZonePercentage(data) + return totalPercentage === 100 + }, + (data) => { + const totalPercentage = currentLoadZonePercentage(data) + return { + message: `Total percentage must be 100 (currently ${totalPercentage})`, + path: ['root'], + } + } + ), +}) + +function currentLoadZonePercentage(data: { percent: number }[]) { + return data.reduce((sum, { percent }) => sum + percent, 0) +} diff --git a/src/schemas/generator/v3/rules.ts b/src/schemas/generator/v3/rules.ts new file mode 100644 index 000000000..3d0e609d5 --- /dev/null +++ b/src/schemas/generator/v3/rules.ts @@ -0,0 +1,213 @@ +import { z } from 'zod' + +export const VariableValueSchema = z.object({ + type: z.literal('variable'), + variableName: z.string(), +}) + +export const DataFileValueSchema = z.object({ + type: z.literal('dataFileValue'), + fileName: z.string(), + propertyName: z.string(), +}) + +export const CustomCodeValueSchema = z.object({ + type: z.literal('customCode'), + code: z.string(), +}) + +export const RecordedValueSchema = z.object({ + type: z.literal('recordedValue'), +}) + +export const StringValueSchema = z.object({ + type: z.literal('string'), + value: z.coerce.string(), +}) + +export const NumberValueSchema = z.object({ + type: z.literal('number'), + number: z + .number({ message: 'Required' }) + .int() + .nonnegative({ message: 'Must be a positive number' }), +}) + +export const RegexValueSchema = z.object({ + type: z.literal('regex'), + regex: z.string().refine( + (value) => { + try { + new RegExp(value) + return true + } catch { + return false + } + }, + { message: 'Invalid regular expression' } + ), +}) + +export const FilterSchema = z.object({ + path: z.string(), +}) + +export const BeginEndSelectorSchema = z.object({ + type: z.literal('begin-end'), + from: z.enum(['headers', 'body', 'url']), + begin: z.string(), + end: z.string(), +}) + +export const HeaderNameSelectorSchema = z.object({ + type: z.literal('header-name'), + from: z.enum(['headers']), + name: z.string(), +}) + +export const RegexSelectorSchema = z.object({ + type: z.literal('regex'), + from: z.enum(['headers', 'body', 'url']), + regex: z.string().refine( + (value) => { + try { + new RegExp(value) + return true + } catch { + return false + } + }, + { message: 'Invalid regular expression' } + ), +}) + +export const JsonSelectorSchema = z.object({ + type: z.literal('json'), + from: z.literal('body'), + path: z.string(), +}) + +export const CustomCodeSelectorSchema = z.object({ + type: z.literal('custom-code'), + snippet: z.string(), +}) + +export const StatusCodeSelectorSchema = z.object({ + type: z.literal('status-code'), +}) + +export const TextSelectorSchema = z.object({ + type: z.literal('text'), + from: z.enum(['headers', 'body', 'url']), + value: z.string(), +}) + +export const ExtractorSelectorSchema = z.discriminatedUnion('type', [ + BeginEndSelectorSchema, + RegexSelectorSchema, + JsonSelectorSchema, + HeaderNameSelectorSchema, +]) + +export const ReplacerSelectorSchema = z.discriminatedUnion('type', [ + BeginEndSelectorSchema, + RegexSelectorSchema, + JsonSelectorSchema, + HeaderNameSelectorSchema, + TextSelectorSchema, +]) + +export const CorrelationExtractorSchema = z.object({ + filter: FilterSchema, + selector: ExtractorSelectorSchema, + variableName: z.string().optional(), + extractionMode: z.enum(['single', 'multiple']).default('single'), +}) + +export const CorrelationReplacerSchema = z.object({ + filter: FilterSchema, + selector: ReplacerSelectorSchema.optional(), +}) + +export const RuleBaseSchema = z.object({ + id: z.string(), + enabled: z.boolean().default(true), +}) + +export const ParameterizationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('parameterization'), + filter: FilterSchema, + selector: ReplacerSelectorSchema, + value: z.discriminatedUnion('type', [ + VariableValueSchema, + DataFileValueSchema, + CustomCodeValueSchema, + StringValueSchema, + ]), +}) + +export const CorrelationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('correlation'), + extractor: CorrelationExtractorSchema, + replacer: CorrelationReplacerSchema.optional(), +}) + +const VerificationOperator = z.enum([ + 'equals', + 'notEquals', + 'contains', + 'notContains', + // Regex only + 'matches', +]) + +const VerificationTarget = z.enum(['body', 'status']) + +export const BaseVerificationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('verification'), + filter: FilterSchema, +}) + +export const StatusVerificationRuleSchema = BaseVerificationRuleSchema.extend({ + target: z.literal(VerificationTarget.enum.status), + operator: z.enum([ + VerificationOperator.enum.equals, + VerificationOperator.enum.notEquals, + VerificationOperator.enum.matches, + ]), + value: z.discriminatedUnion('type', [ + RecordedValueSchema, + NumberValueSchema, + RegexValueSchema, + ]), +}) + +export const BodyVerificationRuleSchema = BaseVerificationRuleSchema.extend({ + target: z.literal(VerificationTarget.enum.body), + operator: VerificationOperator, + value: z.discriminatedUnion('type', [ + StringValueSchema, + RecordedValueSchema, + RegexValueSchema, + VariableValueSchema, + ]), +}) + +export const VerificationRuleSchema = z.discriminatedUnion('target', [ + StatusVerificationRuleSchema, + BodyVerificationRuleSchema, +]) + +export const CustomCodeRuleSchema = RuleBaseSchema.extend({ + type: z.literal('customCode'), + filter: FilterSchema, + placement: z.enum(['before', 'after']), + snippet: z.string(), +}) + +export const TestRuleSchema = z.union([ + ParameterizationRuleSchema, + CorrelationRuleSchema, + VerificationRuleSchema, + CustomCodeRuleSchema, +]) diff --git a/src/schemas/generator/v3/testData.ts b/src/schemas/generator/v3/testData.ts new file mode 100644 index 000000000..761267863 --- /dev/null +++ b/src/schemas/generator/v3/testData.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +export const VariableSchema = z.object({ + name: z + .string() + .min(1, { message: 'Required' }) + .regex(/^[a-zA-Z0-9_]*$/, { message: 'Invalid name' }) + // Don't allow native object properties, like __proto__, valueOf, etc. + .refine((val) => !(val in {}), { message: 'Invalid name' }), + value: z.string(), +}) + +export const DataFileSchema = z.object({ + /** Path to the data file relative to the generator file (e.g. ../Data/file.json) */ + path: z.string().min(1, { message: 'Required' }), +}) + +export const TestDataSchema = z.object({ + variables: VariableSchema.array().superRefine((variables, ctx) => { + const names = variables.map((variable) => variable.name) + + const duplicateIndex = variables.findIndex( + (item, index) => names.indexOf(item.name) !== index + ) + + if (duplicateIndex !== -1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Variable names must be unique', + path: [duplicateIndex, 'name'], + }) + } + }), + files: DataFileSchema.array().default([]), +}) diff --git a/src/schemas/generator/v3/testOptions.ts b/src/schemas/generator/v3/testOptions.ts new file mode 100644 index 000000000..6ad0c5431 --- /dev/null +++ b/src/schemas/generator/v3/testOptions.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' + +import { LoadZoneSchema } from './loadZone' +import { ThresholdSchema } from './thresholds' + +export const SleepTypeSchema = z.enum(['groups', 'requests', 'iterations']) + +export const FixedTimingSchema = z.object({ + type: z.literal('fixed'), + value: z.number().nonnegative().nullable(), +}) + +export const RangeTimingSchema = z.object({ + type: z.literal('range'), + value: z + .object({ + min: z.number().nonnegative(), + max: z.number().nonnegative(), + }) + .refine(({ min, max }) => max > min, { + message: 'Max must be greater than min', + path: ['max'], + }), +}) + +export const TimingSchema = z.discriminatedUnion('type', [ + FixedTimingSchema, + RangeTimingSchema, +]) + +export const ThinkTimeSchema = z.object({ + sleepType: SleepTypeSchema, + timing: TimingSchema, +}) + +export const CommonOptionsSchema = z.object({ + executor: z.enum(['shared-iterations', 'ramping-vus']), +}) + +export const SharedIterationsOptionsSchema = CommonOptionsSchema.extend({ + executor: z.literal('shared-iterations'), + vus: z.number().nonnegative().int().optional(), + iterations: z.number().nonnegative().int().optional(), +}) + +export const RampingStageSchema = z.object({ + id: z.string().optional(), + target: z.number().nonnegative().int(), + duration: z + .string() + .regex( + /^(\d+([hms]))$|^(\d+h)(\d+m)(\d+s)$|^(\d+h)(\d+m)$|^(\d+m)(\d+s)$/, + { + message: 'Must be in format 1m30s', + } + ), +}) + +export const RampingVUsOptionsSchema = CommonOptionsSchema.extend({ + executor: z.literal('ramping-vus'), + stages: RampingStageSchema.array(), +}) + +export const LoadProfileExecutorOptionsSchema = z.discriminatedUnion( + 'executor', + [SharedIterationsOptionsSchema, RampingVUsOptionsSchema] +) + +export const TestOptionsSchema = z.object({ + loadProfile: LoadProfileExecutorOptionsSchema, + thinkTime: ThinkTimeSchema, + thresholds: z.array(ThresholdSchema).default([]), + cloud: z + .object({ + loadZones: LoadZoneSchema, + }) + .default({ loadZones: { distribution: 'even', zones: [] } }), +}) diff --git a/src/schemas/generator/v3/thresholds.ts b/src/schemas/generator/v3/thresholds.ts new file mode 100644 index 000000000..84831c9c1 --- /dev/null +++ b/src/schemas/generator/v3/thresholds.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' + +export const ThresholdMetricSchema = z.enum([ + 'http_req_duration', + 'http_reqs', + 'http_req_failed', + 'http_req_connecting', + 'http_req_tls_handshaking', + 'data_sent', + 'data_received', + 'http_req_receiving', + 'http_req_blocked', + 'http_req_waiting', + 'iteration_duration', +]) + +export const ThresholdConditionSchema = z.enum([ + '<', + '<=', + '>', + '>=', + '===', + '!=', +]) + +export const ThresholdStatisticSchema = z.enum([ + 'count', + 'rate', + 'value', + 'p(99)', + 'p(95)', + 'p(90)', + 'p(50)', + 'avg', + 'max', + 'min', +]) + +export const ThresholdSchema = z.object({ + id: z.string(), + metric: ThresholdMetricSchema, + statistic: ThresholdStatisticSchema, + condition: ThresholdConditionSchema, + value: z + .number({ message: 'Invalid value' }) + .min(0, { message: 'Invalid value' }), + stopTest: z.boolean().default(false), +}) + +export const ThresholdDataSchema = z.object({ + thresholds: z.array(ThresholdSchema), +}) diff --git a/src/schemas/workspace/index.ts b/src/schemas/workspace/index.ts new file mode 100644 index 000000000..2e99f6aee --- /dev/null +++ b/src/schemas/workspace/index.ts @@ -0,0 +1,27 @@ +import { z } from 'zod/v4' + +type DeepRequired = + T extends Array + ? Array> + : T extends object + ? { [K in keyof T]-?: DeepRequired } + : T + +const WorkspaceConfigFileSchema = z.object({ + files: z + .object({ + include: z.array(z.string()).optional(), + exclude: z.array(z.string()).optional(), + }) + .optional(), + cloud: z + .object({ + project_id: z.number().nullable().optional(), + }) + .optional(), +}) + +export type WorkspaceConfigFile = z.infer +export type WorkspaceConfig = DeepRequired + +export { WorkspaceConfigFileSchema as WorkspaceConfigSchema } diff --git a/src/store/generator/selectors.ts b/src/store/generator/selectors.ts index 7cfe93f1c..617dc4d2c 100644 --- a/src/store/generator/selectors.ts +++ b/src/store/generator/selectors.ts @@ -54,7 +54,7 @@ export function selectGeneratorData(state: GeneratorStore): GeneratorFileData { } = state return { - version: '2.0', + version: '3.0', recordingPath, options: { loadProfile, diff --git a/src/store/generator/slices/recording.utils.ts b/src/store/generator/slices/recording.utils.ts index 5b4838bd3..c4ac30c4e 100644 --- a/src/store/generator/slices/recording.utils.ts +++ b/src/store/generator/slices/recording.utils.ts @@ -1,7 +1,7 @@ import { uniq } from 'lodash-es' -import { ProxyData } from '@/types' -import type { HarHeader } from '@/types/recording' +import { useFeaturesStore } from '@/store/features' +import { KeyValueTuple, ProxyData } from '@/types' export function extractUniqueHosts(requests: ProxyData[]) { return uniq(requests.map((request) => request.request.host).filter(Boolean)) @@ -127,16 +127,12 @@ function parseToJsonPaths(content: string): string[] { return paths } -/** - * Takes a set of headers and determines if its of json content type - */ -export function isJsonContentType(headerValues: HarHeader[]): boolean { - return headerValues.some((header) => { - const name = header.name - const value = header.value +function isJsonContentType(headerValues: KeyValueTuple[]): boolean { + return headerValues.some(([name, value]) => { if (!name || !value) { return false } + return ( name.toLowerCase() === 'content-type' && value.toLowerCase().includes('application/json') @@ -148,16 +144,42 @@ export function extractUniqueJsonPaths(requests: ProxyData[]): { requestJsonPaths: string[] responseJsonPaths: string[] } { - const requestJsonPaths = new Set() - const responseJsonPaths = new Set() + const isJsonPathsFeatureFlagTrue = + useFeaturesStore.getState().features['typeahead-json'] - for (const proxy of requests) { - proxy.request?.jsonPaths?.forEach((path) => requestJsonPaths.add(path)) - proxy.response?.jsonPaths?.forEach((path) => responseJsonPaths.add(path)) + if (!isJsonPathsFeatureFlagTrue) { + return { + requestJsonPaths: [], + responseJsonPaths: [], + } } + const requestJsonPaths = new Set( + requests.flatMap((proxy) => parseJsonPaths(proxy.request)) + ) + + const responseJsonPaths = new Set( + requests.flatMap((proxy) => + proxy.response ? parseJsonPaths(proxy.response) : [] + ) + ) + return { requestJsonPaths: Array.from(requestJsonPaths), responseJsonPaths: Array.from(responseJsonPaths), } } + +function parseJsonPaths({ + content, + headers, +}: { + content: string | null + headers: KeyValueTuple[] +}): string[] { + if (content === null || !isJsonContentType(headers)) { + return [] + } + + return generateJsonPaths(content) +} diff --git a/src/store/generator/useGeneratorStore.ts b/src/store/generator/useGeneratorStore.ts index 9dc842b64..b597d262f 100644 --- a/src/store/generator/useGeneratorStore.ts +++ b/src/store/generator/useGeneratorStore.ts @@ -19,7 +19,8 @@ import { import { createScriptDataSlice, ScriptDataStore } from './slices/script' export interface GeneratorStore - extends RecordingSliceStore, + extends + RecordingSliceStore, RulesSliceStore, TestDataStore, TestOptionsStore, @@ -86,11 +87,6 @@ export const useGeneratorStore = create()( state.rules = rules state.previewOriginalRequests = false - /** - * Store request level metadata. - * This uniqifies the json paths across all requests, since the json paths already are precomputed. - * Using a simple set based merge strategy - */ const { requestJsonPaths, responseJsonPaths } = extractUniqueJsonPaths( state.requests ) diff --git a/src/store/ui/useStudioUIStore.ts b/src/store/ui/useStudioUIStore.ts index 9c68e58d3..4d628a3ea 100644 --- a/src/store/ui/useStudioUIStore.ts +++ b/src/store/ui/useStudioUIStore.ts @@ -37,20 +37,25 @@ export const useStudioUIStore = create()( set((state) => { switch (file.type) { case 'recording': - state.recordings.set(file.fileName, file) + state.recordings.set(file.path, file) break case 'generator': - state.generators.set(file.fileName, file) + state.generators.set(file.path, file) break case 'browser-test': - state.browserTests.set(file.fileName, file) + state.browserTests.set(file.path, file) break case 'script': - state.scripts.set(file.fileName, file) + state.scripts.set(file.path, file) break - case 'data-file': - state.dataFiles.set(file.fileName, file) + case 'json': + case 'csv': + state.dataFiles.set(file.path, file) break + + case 'unsupported': + break + default: exhaustive(file.type) } @@ -59,20 +64,25 @@ export const useStudioUIStore = create()( set((state) => { switch (file.type) { case 'recording': - state.recordings.delete(file.fileName) + state.recordings.delete(file.path) break case 'generator': - state.generators.delete(file.fileName) + state.generators.delete(file.path) break case 'browser-test': - state.browserTests.delete(file.fileName) + state.browserTests.delete(file.path) break case 'script': - state.scripts.delete(file.fileName) + state.scripts.delete(file.path) break - case 'data-file': - state.dataFiles.delete(file.fileName) + case 'json': + case 'csv': + state.dataFiles.delete(file.path) break + + case 'unsupported': + break + default: exhaustive(file.type) } diff --git a/src/test/factories/generator.ts b/src/test/factories/generator.ts index c99afe5f3..738353506 100644 --- a/src/test/factories/generator.ts +++ b/src/test/factories/generator.ts @@ -36,7 +36,7 @@ export function createGeneratorData( variables: [], files: [], }, - version: '2.0', + version: '3.0', ...data, } } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b9f452a8c..3afb3ab73 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,7 +1,15 @@ +import { Workspace } from '@/utils/workspace' + declare global { export type EmptyObject = Record export type Falsy = T | false | 0 | '' | null | undefined + + namespace Electron { + interface BrowserWindow { + workspace: Workspace + } + } } export {} diff --git a/src/types/index.ts b/src/types/index.ts index 147f44425..790e78807 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,15 +18,7 @@ export type Header = KeyValueTuple export type Cookie = KeyValueTuple export type Query = KeyValueTuple -interface Metadata { - /** - * Metadata precomputed for json path rendering. - * Attached to each request/response to allow co-location, and efficient pre-computations when the data is loaded. - */ - jsonPaths?: string[] -} - -export interface Request extends Metadata { +export interface Request { headers: Header[] cookies: Cookie[] query: Query[] @@ -44,7 +36,7 @@ export interface Request extends Metadata { url: string } -export interface Response extends Metadata { +export interface Response { headers: Header[] cookies: Cookie[] reason: string @@ -88,12 +80,20 @@ export interface RequestSnippetSchema { } export interface StudioFile { - type: 'recording' | 'generator' | 'script' | 'data-file' | 'browser-test' + type: FileType + path: string displayName: string fileName: string } -export type StudioFileType = StudioFile['type'] +export type FileType = + | 'recording' + | 'generator' + | 'script' + | 'browser-test' + | 'json' + | 'csv' + | 'unsupported' export interface FolderContent { recordings: Map diff --git a/src/types/recordingData.ts b/src/types/recordingData.ts new file mode 100644 index 000000000..98e083d62 --- /dev/null +++ b/src/types/recordingData.ts @@ -0,0 +1,7 @@ +import { BrowserEvent } from '@/schemas/recording' +import { ProxyData } from '@/types' + +export interface RecordingData { + requests: ProxyData[] + browserEvents: BrowserEvent[] +} diff --git a/src/types/workspace.ts b/src/types/workspace.ts new file mode 100644 index 000000000..ef2f0e36d --- /dev/null +++ b/src/types/workspace.ts @@ -0,0 +1,6 @@ +import type { WorkspaceConfig } from '@/schemas/workspace' + +export interface Workspace { + path: string + config: WorkspaceConfig +} diff --git a/src/utils/file.ts b/src/utils/file.ts index 7c1356a55..97808831e 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,8 +1,14 @@ -import { StudioFileType } from '@/types' +import * as pathe from 'pathe' + +import { StudioFile } from '@/types' import { getRoutePath } from '../routeMap' -import { exhaustive } from './typescript' +/** Display name for a data file path (basename). Safe for UI in browser and Node. */ +export function getDataFileDisplayName(filePath: string) { + const base = filePath.replace(/^.*[/\\]/, '') + return base || filePath +} export function getFileNameWithoutExtension(fileName: string) { return fileName.replace(/\.[^/.]+$/, '') @@ -12,26 +18,52 @@ export function getFileExtension(fileName: string) { return fileName.split('.').pop() } -export function getViewPath(type: StudioFileType, fileName: string) { - const encodedFileName = encodeURIComponent(fileName) +export function getViewPath(path: string) { + const encodedPath = encodeURIComponent(path) + + return getRoutePath('editorView', { path: encodedPath }) +} + +export function inferFileTypeFromExtension( + filePath: string +): StudioFile['type'] { + const ext = pathe.extname(filePath).toLowerCase() + switch (ext) { + case '.har': + return 'recording' - switch (type) { - case 'recording': - return getRoutePath('recordingPreviewer', { fileName: encodedFileName }) + case '.k6g': + return 'generator' - case 'generator': - return getRoutePath('generator', { fileName: encodedFileName }) + case '.k6b': + return 'browser-test' - case 'browser-test': - return getRoutePath('browserTestEditor', { fileName: encodedFileName }) + case '.js': + case '.ts': + case '.mjs': + case '.cjs': + case '.mts': + case '.cts': + return 'script' - case 'script': - return getRoutePath('validator', { fileName: encodedFileName }) + case '.json': + return 'json' - case 'data-file': - return getRoutePath('dataFilePreviewer', { fileName: encodedFileName }) + case '.csv': + return 'csv' default: - return exhaustive(type) + return 'unsupported' + } +} + +export function createStudioFile(filePath: string): StudioFile { + const parsed = pathe.parse(filePath) + + return { + type: inferFileTypeFromExtension(filePath), + path: filePath, + fileName: parsed.base, + displayName: parsed.name, } } diff --git a/src/utils/fs/path.ts b/src/utils/fs/path.ts new file mode 100644 index 000000000..1da15a71c --- /dev/null +++ b/src/utils/fs/path.ts @@ -0,0 +1,9 @@ +import * as pathe from 'pathe' + +export function makeRelativePath(from: string, to: string) { + const relativePath = pathe.relative(from, to) + + // If the file is in the same directory, then path.relative will return + // just the filename, so we need to add the ./ prefix. + return !relativePath.startsWith('.') ? `./${relativePath}` : relativePath +} diff --git a/src/utils/fs/scripts.ts b/src/utils/fs/scripts.ts new file mode 100644 index 000000000..803f03519 --- /dev/null +++ b/src/utils/fs/scripts.ts @@ -0,0 +1,38 @@ +import { writeFile, unlink } from 'fs/promises' +import { basename, extname, join } from 'path' + +import { TEMP_PATH } from '@/constants/workspace' +import { RawScript, Script } from '@/handlers/cloud/types' +import { getTempScriptName } from '@/main/script' + +async function createTempFile(script: RawScript) { + const tempFilePath = + script.path ?? join(TEMP_PATH, script.name || getTempScriptName()) + + await writeFile(tempFilePath, script.content) + + return { + name: basename(script.name, extname(script.name)), + path: tempFilePath, + + async dispose() { + try { + await unlink(tempFilePath) + } catch { + // We tried our best + } + }, + } +} + +export function toScriptFile(script: Script) { + if (script.type === 'raw') { + return createTempFile(script) + } + + return { + name: basename(script.path), + path: script.path, + dispose() {}, + } +} diff --git a/src/utils/generator.ts b/src/utils/generator.ts index 1795a5a15..39f86fbd4 100644 --- a/src/utils/generator.ts +++ b/src/utils/generator.ts @@ -3,9 +3,13 @@ import { RampingStage } from '@/types/testOptions' import { createEmptyRule } from './rules' -export function createNewGeneratorFile(recordingPath = ''): GeneratorFileData { +export function createNewGeneratorFile( + recordingPath = '', + isNew?: boolean +): GeneratorFileData & { isNew?: boolean } { return { - version: '2.0', + isNew, + version: '3.0', recordingPath, options: { loadProfile: { diff --git a/src/utils/harToProxyData.ts b/src/utils/harToProxyData.ts index d523066c0..bd990cfe7 100644 --- a/src/utils/harToProxyData.ts +++ b/src/utils/harToProxyData.ts @@ -1,12 +1,7 @@ import { DEFAULT_GROUP_NAME } from '@/constants' import { Recording } from '@/schemas/recording' -import { useFeaturesStore } from '@/store/features' -import { - generateJsonPaths, - isJsonContentType, -} from '@/store/generator/slices/recording.utils' import { Method, ProxyData, Request, Response } from '@/types' -import type { HarContent, HarEntry, HarHeader } from '@/types/recording' +import type { HarContent, HarEntry } from '@/types/recording' import { safeAtob } from './format' @@ -16,7 +11,7 @@ export function harToProxyData(har: Recording): ProxyData[] { const response = harEntryToResponse(entry) return { - id: self.crypto.randomUUID(), + id: crypto.randomUUID(), request, response, group: entry.pageref || DEFAULT_GROUP_NAME, @@ -40,7 +35,6 @@ function harEntryToRequest({ request, startedDateTime }: HarEntry): Request { ) } - const jsonPaths = parseJsonPaths(content, request.headers ?? []) const url = new URL(request.url) return { @@ -50,7 +44,6 @@ function harEntryToRequest({ request, startedDateTime }: HarEntry): Request { headers: (request.headers ?? []).map((h) => [h.name, h.value]), query: (request.queryString ?? []).map((q) => [q.name, q.value]), cookies: (request.cookies ?? []).map((c) => [c.name, c.value]), - jsonPaths, content, timestampStart: startedDateTime ? isoToUnixTimestamp(startedDateTime) : 0, timestampEnd: 0, @@ -67,7 +60,6 @@ function harEntryToResponse({ response }: HarEntry): Response | undefined { } const content = parseContent(response.content) - const jsonPaths = parseJsonPaths(content, response.headers) return { statusCode: response.status, @@ -75,7 +67,6 @@ function harEntryToResponse({ response }: HarEntry): Response | undefined { httpVersion: response.httpVersion, headers: response.headers.map((h) => [h.name, h.value]), cookies: response.cookies.map((c) => [c.name, c.value]), - jsonPaths, content, contentLength: response.content?.size ?? 0, timestampStart: 0, @@ -83,27 +74,18 @@ function harEntryToResponse({ response }: HarEntry): Response | undefined { } } -function parseJsonPaths(content: string, headers: HarHeader[]): string[] { - const isJsonPathsFeatureFlagTrue = - useFeaturesStore.getState().features['typeahead-json'] - const isJsonPathsEnabled = - isJsonPathsFeatureFlagTrue && isJsonContentType(headers) - - if (!isJsonPathsEnabled) { - return [] - } - return generateJsonPaths(content) -} - function isoToUnixTimestamp(isoString: string): number { return new Date(isoString).getTime() / 1000 } function parseContent(content: HarContent): string { - if (!content.text) return '' + if (!content.text) { + return '' + } if (content.encoding === 'base64') { return safeAtob(content.text) } + return content.text } diff --git a/src/utils/json.ts b/src/utils/json.ts index b954ac4a6..cda37ebbf 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -1,5 +1,15 @@ import { z } from 'zod' +export type JsonObject = { [key: string]: JsonValue } + +export type JsonValue = + | JsonObject + | JsonValue[] + | string + | number + | boolean + | null + export function safeJsonParse(value: string) { try { return JSON.parse(value) as T diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 000000000..7751d3ff4 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,35 @@ +import { platform, arch } from 'os' + +import { Arch, Platform } from '@/types/electron' + +export function getPlatform(): Platform { + switch (platform()) { + case 'aix': + case 'freebsd': + case 'linux': + case 'openbsd': + case 'android': + return 'linux' + case 'darwin': + case 'sunos': + return 'mac' + case 'win32': + return 'win' + default: + throw new Error('unsupported platform') + } +} + +// note: the os.arch() returns the architecture for which the node runtime got compiled for, this could +// be wrong for our use case since we want to fish binaries for specific architectures we are building for. +// TODO: validate behaviour +export function getArch(): Arch { + switch (arch()) { + case 'arm64': + return 'arm64' + case 'x64': + return 'x86_64' + default: + throw new Error('unsupported arch') + } +} diff --git a/src/utils/proxyDataToHar.ts b/src/utils/proxyDataToHar.ts index 6f6ac7e7c..7b500c891 100644 --- a/src/utils/proxyDataToHar.ts +++ b/src/utils/proxyDataToHar.ts @@ -1,3 +1,5 @@ +import { app } from 'electron' + import { BrowserEvent, Recording } from '@/schemas/recording' import { GroupedProxyData, ProxyData, Request, Response } from '@/types' import { HarEntry, HarPage } from '@/types/recording' @@ -24,7 +26,7 @@ function createLog( version: '1.2', creator: { name: 'k6-studio', - version: __APP_VERSION__, + version: app.getVersion(), }, pages, entries, diff --git a/src/utils/validateScript.ts b/src/utils/validateScript.ts index 0631a8ce1..3e6d179dc 100644 --- a/src/utils/validateScript.ts +++ b/src/utils/validateScript.ts @@ -1,3 +1,4 @@ +import { RawScript } from '@/handlers/cloud/types' import { ProxyData } from '@/types' import { processProxyData } from './proxyData' @@ -6,7 +7,7 @@ import { processProxyData } from './proxyData' * Validates a k6 script by running it and collecting proxy data */ export async function validateScript( - script: string, + script: RawScript, signal?: AbortSignal, shouldTrack = true ): Promise { @@ -52,11 +53,10 @@ export async function validateScript( }) // Run the script - window.studio.script - .runScriptFromGenerator(script, shouldTrack) - .catch((error) => { - cleanup() - reject(error) - }) + window.studio.script.runScript(script, shouldTrack).catch((error) => { + cleanup() + + reject(error) + }) }) } diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index fc017e63a..d85a977c9 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,7 +1,14 @@ +import { parse as parseTOML } from '@iarna/toml' +import { FSWatcher, watch } from 'chokidar' import { existsSync } from 'fs' -import { mkdir } from 'fs/promises' +import { mkdir, readFile } from 'fs/promises' +import { isMatch } from 'micromatch' import path from 'path' +// import { EventEmitter } from 'extension/src/utils/events' + +import { EventEmitter } from 'extension/src/utils/events' + import { DATA_FILES_PATH, PROJECT_PATH, @@ -11,6 +18,12 @@ import { TEMP_PATH, BROWSER_TESTS_PATH, } from '../constants/workspace' +import { addToRecentDocuments } from '../main/menu' +import { + WorkspaceConfigFile, + WorkspaceConfigSchema, + type WorkspaceConfig, +} from '../schemas/workspace' const REQUIRED_FOLDERS = [ PROJECT_PATH, @@ -22,6 +35,69 @@ const REQUIRED_FOLDERS = [ BROWSER_TESTS_PATH, ] +const K6_TOML = 'k6.toml' + +const DEFAULT_WORKSPACE_CONFIG: WorkspaceConfig = { + files: { include: [], exclude: [] }, + cloud: { + project_id: null, + }, +} + +function deepMergeConfig( + base: WorkspaceConfig, + override: WorkspaceConfigFile +): WorkspaceConfig { + const files = { ...base.files, ...override.files } + const cloud = { ...base.cloud, ...override.cloud } + + return { + files, + cloud, + } +} + +async function discoverConfigFiles(workspaceRoot: string) { + const configFiles: WorkspaceConfigFile[] = [] + + let dir = path.resolve(workspaceRoot) + + // eslint-disable-next-line no-constant-condition + while (true) { + const configPath = path.join(dir, K6_TOML) + + try { + const raw = await readFile(configPath, 'utf-8') + const parsed = parseTOML(raw) + + const validated = WorkspaceConfigSchema.parse(parsed) + + configFiles.push(validated) + } catch { + // File does not exist or isn't a valid TOML file, continue to the parent directory + } + + const parent = path.dirname(dir) + + // `path.dirname` will return the same path if the path is the root. + if (parent === dir) { + break + } + + dir = path.dirname(dir) + } + + return configFiles.reverse() +} + +async function loadWorkspaceConfig( + workspaceRoot: string +): Promise { + const configFiles = await discoverConfigFiles(workspaceRoot) + + return configFiles.reduce(deepMergeConfig, DEFAULT_WORKSPACE_CONFIG) +} + export const setupProjectStructure = async () => { for (const folder of REQUIRED_FOLDERS) { if (!existsSync(folder)) { @@ -30,6 +106,150 @@ export const setupProjectStructure = async () => { } } -export function isExternalScript(scriptPath: string) { - return path.dirname(scriptPath) !== path.normalize(SCRIPTS_PATH) +function normalizeRootPath(rootPath: string) { + const normalized = path.normalize(rootPath) + + // Strip trailing sep except for the filesystem root (`/` or `C:\`), where that + // would yield an empty or ambiguous path. + if (normalized.endsWith(path.sep) && normalized.length > path.sep.length) { + return normalized.slice(0, -1) + } + + return normalized +} + +interface WorkspaceEventMap { + 'file:add': { path: string } + 'file:remove': { path: string } + 'file:change': { path: string } + 'workspace:change': { path: string } +} + +export class Workspace extends EventEmitter { + static async create(rootPath: string) { + const config = await loadWorkspaceConfig(rootPath) + + return new Workspace(rootPath, config) + } + + #rootPath: string + #watcher: FSWatcher + #config: WorkspaceConfig + + constructor(rootPath: string, config: WorkspaceConfig) { + super() + + this.#rootPath = normalizeRootPath(rootPath) + this.#config = config + + this.#watcher = watch(this.#rootPath, { + ignoreInitial: true, + ignored: (targetPath) => { + return isMatch(targetPath, [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/target/**', + '**/tmp/**', + '**/temp/**', + '**/cache/**', + '**/logs/**', + ]) + }, + }) + + const emitFsEvent = ( + type: 'file:add' | 'file:remove' | 'file:change', + filePath: string + ) => { + const normalized = path.normalize(filePath) + const rootPrefix = this.#rootPath + path.sep + + if (normalized !== this.#rootPath && !normalized.startsWith(rootPrefix)) { + return + } + + this.emit(type, { path: normalized }) + } + + this.#watcher.on('add', (filePath) => { + emitFsEvent('file:add', filePath) + }) + + this.#watcher.on('addDir', (dirPath) => { + emitFsEvent('file:add', dirPath) + }) + + this.#watcher.on('unlink', (filePath) => { + emitFsEvent('file:remove', filePath) + }) + + this.#watcher.on('unlinkDir', (dirPath) => { + emitFsEvent('file:remove', dirPath) + }) + + this.#watcher.on('change', (filePath) => { + emitFsEvent('file:change', filePath) + }) + } + + async switch(newRootPath: string) { + this.#watcher.unwatch(this.#rootPath) + + this.#rootPath = normalizeRootPath(newRootPath) + + this.#config = await loadWorkspaceConfig(this.#rootPath) + this.#watcher.add(this.#rootPath) + + this.emit('workspace:change', { + path: this.#rootPath, + }) + + addToRecentDocuments(this.#rootPath) + } + + get path() { + return this.#rootPath + } + + get config() { + return this.#config + } + + /** + * @deprecated These hardcoded paths are deprecated and will be removed in the future. + */ + get paths() { + const rootPath = this.#rootPath + + return { + get recordings() { + return path.join(rootPath, 'Recordings') + }, + get generators() { + return path.join(rootPath, 'Generators') + }, + get browserTests() { + return path.join(rootPath, 'Browser') + }, + get scripts() { + return path.join(rootPath, 'Scripts') + }, + get dataFiles() { + return path.join(rootPath, 'Data') + }, + } + } + + isInside(candidatePath: string) { + const normalized = path.normalize(candidatePath) + const root = this.#rootPath + + return normalized === root || normalized.startsWith(root + path.sep) + } + + async close() { + await this.#watcher.close() + } } diff --git a/src/views/BrowserTestEditor/BrowserTestEditor.hooks.ts b/src/views/BrowserTestEditor/BrowserTestEditor.hooks.ts index 7c0181e96..584b9a860 100644 --- a/src/views/BrowserTestEditor/BrowserTestEditor.hooks.ts +++ b/src/views/BrowserTestEditor/BrowserTestEditor.hooks.ts @@ -1,8 +1,5 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import log from 'electron-log/renderer' +import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useParams } from 'react-router-dom' -import invariant from 'tiny-invariant' import { emitScript } from '@/codegen/browser' import { convertActionsToTest } from '@/codegen/browser/test' @@ -10,62 +7,30 @@ import { useDefaultLayout, usePanelCallbackRef, } from '@/components/primitives/ResizablePanel' +import { useCurrentFile } from '@/hooks/useFileNameParam' import { useStateWithUndo } from '@/hooks/useStateWithUndo' import { AnyBrowserAction } from '@/main/runner/schema' import { BrowserTestFile } from '@/schemas/browserTest/v1' -import { useToast } from '@/store/ui/useToast' import { StudioFile } from '@/types' -import { getFileNameWithoutExtension } from '@/utils/file' -import { queryClient } from '@/utils/query' import { exhaustive } from '@/utils/typescript' import { BrowserActionWithId } from './types' export function useBrowserTestFile(): StudioFile { - const { fileName } = useParams() - invariant(fileName, 'fileName is required') - - return { - fileName, - displayName: getFileNameWithoutExtension(fileName), - type: 'browser-test', - } + return useCurrentFile('browser-test') } export function useBrowserTest(fileName: string) { return useQuery({ queryKey: ['browserTest', fileName], - queryFn: () => { - return window.studio.browserTest.open(fileName) - }, - }) -} - -export function useSaveBrowserTest(fileName: string) { - const showToast = useToast() + queryFn: async () => { + const result = await window.studio.file.open(fileName) - return useMutation({ - mutationFn: async (data: BrowserTestFile) => { - await window.studio.browserTest.save(fileName, data) - await queryClient.invalidateQueries({ - queryKey: ['browserTest', fileName], - }) - }, - - onSuccess: () => { - showToast({ - title: 'Browser test saved', - status: 'success', - }) - }, + if (result.type !== 'browser-test') { + throw new Error('Expected browser-test content') + } - onError: (error) => { - showToast({ - title: 'Failed to save browser test', - status: 'error', - description: error.message, - }) - log.error(error) + return result.data }, }) } @@ -119,9 +84,13 @@ export function useBrowserScriptPreview(browserActions: AnyBrowserAction[]) { export function useBrowserTestState( browserTestFile: BrowserTestFile | undefined ) { - const { actions = [] } = browserTestFile ?? {} + const test = useMemo( + () => browserTestFile ?? { actions: [] }, + [browserTestFile] + ) + const { state, undo, redo, push } = useStateWithUndo( - actions.map((action) => ({ + test.actions.map((action) => ({ id: crypto.randomUUID(), action, })) @@ -150,10 +119,10 @@ export function useBrowserTestState( const isDirty = useMemo(() => { return ( - plainActions.length !== actions.length || - JSON.stringify(plainActions) !== JSON.stringify(actions) + plainActions.length !== test.actions.length || + JSON.stringify({ actions: plainActions }) !== JSON.stringify(test) ) - }, [plainActions, actions]) + }, [plainActions, test]) return { actions: state, diff --git a/src/views/BrowserTestEditor/BrowserTestEditor.tsx b/src/views/BrowserTestEditor/BrowserTestEditor.tsx index eefb3935c..54c48b02c 100644 --- a/src/views/BrowserTestEditor/BrowserTestEditor.tsx +++ b/src/views/BrowserTestEditor/BrowserTestEditor.tsx @@ -1,13 +1,13 @@ import { css } from '@emotion/react' import { Flex, Tabs } from '@radix-ui/themes' -import { useNavigate } from 'react-router-dom' import { FileNameHeader } from '@/components/FileNameHeader' import { View } from '@/components/Layout/View' import { ReadOnlyEditor } from '@/components/Monaco/ReadOnlyEditor' import { LogsSection } from '@/components/Validator/LogsSection' import { Group, Panel, Separator } from '@/components/primitives/ResizablePanel' -import { routeMap } from '@/routeMap' +import { FileContent } from '@/handlers/file/types' +import { useSaveRequested } from '@/hooks/useSaveRequested' import { BrowserTestFile } from '@/schemas/browserTest/v1' import { StudioFile } from '@/types' @@ -16,11 +16,8 @@ import { useDebugSession } from '../Validator/Validator.hooks' import { useBrowserScriptPreview, - useBrowserTest, useBrowserTestEditorLayout, - useBrowserTestFile, useBrowserTestState, - useSaveBrowserTest, } from './BrowserTestEditor.hooks' import { BrowserTestEditorControls } from './BrowserTestEditorControls' import { EditableBrowserActionList } from './EditableBrowserActionList' @@ -28,14 +25,17 @@ import { EditableBrowserActionList } from './EditableBrowserActionList' interface BrowserTestEditorViewProps { file: StudioFile data: BrowserTestFile + onSave: (content: FileContent, saveAs?: boolean) => void | Promise } -function BrowserTestEditorView({ file, data }: BrowserTestEditorViewProps) { +function BrowserTestEditorView({ + file, + data, + onSave, +}: BrowserTestEditorViewProps) { const { drawerLayout, mainLayout, setDrawer, onTabClick } = useBrowserTestEditorLayout() - const { mutateAsync: saveBrowserTest } = useSaveBrowserTest(file.fileName) - const test = useBrowserTestState(data) const preview = useBrowserScriptPreview(test.plainActions) @@ -45,16 +45,24 @@ function BrowserTestEditorView({ file, data }: BrowserTestEditorViewProps) { name: file.fileName, }) - const handleSave = () => { + const handleSave = (payload?: { saveAs?: boolean }) => { if (!test.isDirty || !data) { return } - const browserTestData = { ...data, actions: test.plainActions } + const browserTestData = { + ...data, + actions: test.plainActions, + } - void saveBrowserTest(browserTestData) + void onSave( + { type: 'browser-test', data: browserTestData }, + payload?.saveAs + ) } + useSaveRequested(handleSave) + return ( void | Promise +} - return +export function BrowserTestEditor({ + file, + data, + onSave, +}: BrowserTestEditorProps) { + return ( + + ) } diff --git a/src/views/BrowserTestEditor/BrowserTestEditorControls.tsx b/src/views/BrowserTestEditor/BrowserTestEditorControls.tsx index 757f69224..5ea6d049e 100644 --- a/src/views/BrowserTestEditor/BrowserTestEditorControls.tsx +++ b/src/views/BrowserTestEditor/BrowserTestEditorControls.tsx @@ -1,14 +1,15 @@ import { Button, DropdownMenu, Flex, IconButton } from '@radix-ui/themes' import { EllipsisVerticalIcon } from 'lucide-react' +import * as pathe from 'pathe' import { useState } from 'react' import { DeleteFileDialog } from '@/components/DeleteFileDialog' import { RunInCloudDialog } from '@/components/RunInCloudDialog/RunInCloudDialog' import { GrafanaIcon } from '@/components/icons/GrafanaIcon' import { useDeleteFile } from '@/hooks/useDeleteFile' +import { useToast } from '@/store/ui/useToast' import { StudioFile } from '@/types' -import { ExportScriptDialog } from '../Generator/ExportScriptDialog' import { DebugSession } from '../Validator/types' interface BrowserTestEditorControlsProps { @@ -28,16 +29,40 @@ export function BrowserTestEditorControls({ onStartDebugging, onSave, }: BrowserTestEditorControlsProps) { - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) const [isRunInCloudDialogOpen, setIsRunInCloudDialogOpen] = useState(false) + const showToast = useToast() const handleDelete = useDeleteFile({ file, navigateHomeOnDelete: true, }) - const handleExportScript = (scriptName: string) => { - void window.studio.script.saveScript(preview, scriptName) + const handleExportScript = async () => { + try { + const parsedPath = pathe.parse(file.path) + + const filePath = await window.studio.script.showSaveDialog( + pathe.join(parsedPath.dir, parsedPath.name + '.js') + ) + + if (filePath === null) { + return + } + + await window.studio.script.saveScript(preview, filePath) + + showToast({ + title: 'Script exported successfully', + status: 'success', + }) + } catch (error) { + console.error(error) + + showToast({ + title: 'Failed to export script', + status: 'error', + }) + } } return ( @@ -62,7 +87,7 @@ export function BrowserTestEditorControls({ - setIsExportDialogOpen(true)}> + Export script - + { + const result = await window.studio.file.save({ + content, + location: { type: 'new', hint: file.fileName }, + }) + + if (result === null) { + return + } + + navigate( + getRoutePath('editorView', { path: encodeURIComponent(result.path) }), + { replace: true } + ) + } + + return ( + + ) +} diff --git a/src/views/DataFile/DataFile.hooks.ts b/src/views/DataFile/DataFile.hooks.ts index 862685120..a5957a124 100644 --- a/src/views/DataFile/DataFile.hooks.ts +++ b/src/views/DataFile/DataFile.hooks.ts @@ -1,9 +1,17 @@ import { useQuery } from '@tanstack/react-query' -export function useDataFilePreview(fileName: string) { +export function useDataFilePreview(filePath: string) { return useQuery({ - queryKey: ['data-file', fileName], - queryFn: () => window.studio.data.loadPreview(fileName), - enabled: !!fileName, + queryKey: ['data-file', filePath], + queryFn: async () => { + const data = await window.studio.file.open(filePath) + + if (data.type !== 'json' && data.type !== 'csv') { + throw new Error('Unsupported file format') + } + + return data + }, + enabled: !!filePath, }) } diff --git a/src/views/DataFile/DataFile.tsx b/src/views/DataFile/DataFile.tsx index c038226f2..14d2abb32 100644 --- a/src/views/DataFile/DataFile.tsx +++ b/src/views/DataFile/DataFile.tsx @@ -1,56 +1,24 @@ import { Grid } from '@radix-ui/themes' -import { useEffect } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import invariant from 'tiny-invariant' import { FileNameHeader } from '@/components/FileNameHeader' import { View } from '@/components/Layout/View' -import { TableSkeleton } from '@/components/TableSkeleton' -import { getRoutePath } from '@/routeMap' -import { useToast } from '@/store/ui/useToast' -import { getFileNameWithoutExtension } from '@/utils/file' +import { CsvFileContent, JsonFileContent } from '@/handlers/file/types' +import { StudioFile } from '@/types' -import { useDataFilePreview } from './DataFile.hooks' import { DataFileControls } from './DataFileControls' import { DataFileTable } from './DataFileTable' -export function DataFile() { - const { fileName } = useParams() - const navigate = useNavigate() - const showToast = useToast() - invariant(fileName, 'fileName is required') - - const { data: preview, isLoading, isError } = useDataFilePreview(fileName) - - useEffect(() => { - if (isError) { - showToast({ - title: 'Failed to load data file', - status: 'error', - }) - navigate(getRoutePath('home')) - } - }, [isError, navigate, showToast]) - - if (!preview) { - return null - } +interface DataFileProps { + file: StudioFile + preview: JsonFileContent | CsvFileContent +} +export function DataFile({ file, preview }: DataFileProps) { return ( - } - actions={} - loading={isLoading} + subTitle={} + actions={} > - {isLoading ? ( - - ) : ( - - )} + ) diff --git a/src/views/DataFile/DataFileControls.tsx b/src/views/DataFile/DataFileControls.tsx index 5194aa1e4..8d68e8545 100644 --- a/src/views/DataFile/DataFileControls.tsx +++ b/src/views/DataFile/DataFileControls.tsx @@ -5,19 +5,12 @@ import { DeleteFileDialog } from '@/components/DeleteFileDialog' import { useDeleteFile } from '@/hooks/useDeleteFile' import { useOpenInDefaultApp } from '@/hooks/useOpenInDefaultApp' import { StudioFile } from '@/types' -import { getFileNameWithoutExtension } from '@/utils/file' interface DataFileControlsProps { - fileName: string + file: StudioFile } -export function DataFileControls({ fileName }: DataFileControlsProps) { - const file: StudioFile = { - type: 'data-file', - fileName, - displayName: getFileNameWithoutExtension(fileName), - } - +export function DataFileControls({ file }: DataFileControlsProps) { const handleOpenFolder = () => { window.studio.ui.openContainingFolder(file) } diff --git a/src/views/DataFile/DataFileTable.tsx b/src/views/DataFile/DataFileTable.tsx index da75efc47..0e02004d7 100644 --- a/src/views/DataFile/DataFileTable.tsx +++ b/src/views/DataFile/DataFileTable.tsx @@ -3,11 +3,11 @@ import { InfoIcon } from 'lucide-react' import { Table } from '@/components/Table' import { TableCellWithTooltip } from '@/components/TableCellWithTooltip' -import { DataFilePreview } from '@/types/testData' +import { CsvFileContent, JsonFileContent } from '@/handlers/file/types' import { renderDataFileValue } from '@/utils/dataFile' interface DataFileTableProps { - preview: DataFilePreview + preview: JsonFileContent | CsvFileContent isLoading: boolean } diff --git a/src/views/EditorView/EditorView.tsx b/src/views/EditorView/EditorView.tsx new file mode 100644 index 000000000..27adbeba1 --- /dev/null +++ b/src/views/EditorView/EditorView.tsx @@ -0,0 +1,196 @@ +import { Flex, Text } from '@radix-ui/themes' +import { useQuery } from '@tanstack/react-query' +import log from 'electron-log/renderer' +import * as pathe from 'pathe' +import { useCallback } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import invariant from 'tiny-invariant' + +import { View } from '@/components/Layout/View' +import { FileContent } from '@/handlers/file/types' +import { getRoutePath } from '@/routeMap' +import { useToast } from '@/store/ui/useToast' +import { StudioFile } from '@/types' +import { queryClient } from '@/utils/query' + +import { BrowserTestEditor } from '../BrowserTestEditor' +import { DataFile } from '../DataFile' +import { Generator } from '../Generator' +import { RecordingPreviewer } from '../RecordingPreviewer' +import { Validator } from '../Validator' + +function createStudioFile(path: string, type: StudioFile['type']): StudioFile { + const fileName = pathe.basename(path) + const displayName = pathe.basename(path, pathe.extname(path)) + return { + type, + path, + fileName, + displayName, + } +} + +export function EditorView() { + const { path: pathParam } = useParams() + const navigate = useNavigate() + const showToast = useToast() + + invariant(pathParam, 'path is required') + + const path = decodeURIComponent(pathParam) + + const handleSave = useCallback( + async (content: FileContent, saveAs?: boolean) => { + try { + const location = saveAs + ? { type: 'new' as const, hint: path } + : { type: 'path' as const, path } + + const result = await window.studio.file.save({ + content, + location, + }) + + if (result === null) { + return + } + + if (saveAs) { + navigate( + getRoutePath('editorView', { + path: encodeURIComponent(result.path), + }), + { replace: true } + ) + } + + await queryClient.invalidateQueries({ queryKey: ['file', result.path] }) + + showToast({ title: 'File saved', status: 'success' }) + } catch (error) { + showToast({ + title: 'Failed to save file', + status: 'error', + description: error instanceof Error ? error.message : String(error), + }) + + log.error(error) + } + }, + [path, navigate, showToast] + ) + + const { + data: result, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ['file', path], + queryFn: async () => { + const result = await window.studio.file.open(path) + + if (result.type === 'script') { + const options = await window.studio.script.analyzeScript({ + type: 'path', + path: path, + }) + + return { + ...result, + options, + } + } + + return result + }, + staleTime: 0, + gcTime: 0, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }) + + if (isLoading) { + return ( + } loading> + {null} + + ) + } + + if (isError) { + return ( + }> + + Failed to open file:{' '} + {error instanceof Error ? error.message : String(error)} + + + ) + } + + invariant(result, 'Expected result') + + if (result.type === 'recording') { + const file = createStudioFile(path, 'recording') + + return + } + + if (result.type === 'script') { + const file = createStudioFile(path, 'script') + + return ( + + ) + } + + if (result.type === 'json' || result.type === 'csv') { + const file = createStudioFile(path, result.type) + + return + } + + if (result.type === 'browser-test') { + const file = createStudioFile(path, 'browser-test') + + return ( + + ) + } + + if (result.type === 'generator') { + const file = createStudioFile(path, 'generator') + + return + } + + if (result.type === 'unsupported-format') { + return ( + }> + + This file type is not supported. + + + ) + } + + return ( + }> + Preview not available for this file type. + + ) +} diff --git a/src/views/EditorView/index.ts b/src/views/EditorView/index.ts new file mode 100644 index 000000000..fc6147aa5 --- /dev/null +++ b/src/views/EditorView/index.ts @@ -0,0 +1 @@ +export * from './EditorView' diff --git a/src/views/Generator/AutoCorrelation/useGenerateRules.ts b/src/views/Generator/AutoCorrelation/useGenerateRules.ts index 4ea122d91..6775fddda 100644 --- a/src/views/Generator/AutoCorrelation/useGenerateRules.ts +++ b/src/views/Generator/AutoCorrelation/useGenerateRules.ts @@ -2,6 +2,7 @@ import { useChat } from '@ai-sdk/react' import { useCallback, useEffect, useRef, useState } from 'react' import { TokenUsage } from '@/handlers/ai/types' +import { RawScript } from '@/handlers/cloud/types' import { applyRules } from '@/rules/rules' import { UsageEventName } from '@/services/usageTracking/types' import { useFeaturesStore } from '@/store/features' @@ -170,13 +171,23 @@ export const useGenerateRules = ({ async function runValidation() { clearValidation() - const script = await generateScriptPreview( - { - ...generator, - rules: [...generator.rules, ...suggestedRulesRef.current], - }, - recording - ) + + const newGenerator = { + ...generator, + rules: [...generator.rules, ...suggestedRulesRef.current], + } + + const tempPath = await window.studio.file.getTempPath({ + extension: '.js', + prefix: 'script', + }) + + const script: RawScript = { + type: 'raw', + name: 'script.js', + path: tempPath, + content: await generateScriptPreview(tempPath, newGenerator, recording), + } const validationResult = await validateScript( script, diff --git a/src/views/Generator/ExportScriptDialog/ExportScriptDialog.tsx b/src/views/Generator/ExportScriptDialog/ExportScriptDialog.tsx deleted file mode 100644 index 9e34138f7..000000000 --- a/src/views/Generator/ExportScriptDialog/ExportScriptDialog.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { css } from '@emotion/react' -import { zodResolver } from '@hookform/resolvers/zod' -import { AlertDialog, Flex } from '@radix-ui/themes' -import { FileCode2Icon } from 'lucide-react' -import { useEffect } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { useLocalStorage } from 'react-use' - -import { - ExportScriptDialogData, - ExportScriptDialogSchema, -} from '@/schemas/exportScript' -import { useStudioUIStore } from '@/store/ui' - -import { getScriptNameWithExtension } from './ExportScriptDialog.utils' -import { OverwriteFileWarning } from './OverwriteFileWarning' -import { ScriptNameForm } from './ScriptNameForm' - -interface ExportScriptDialogProps { - open: boolean - scriptName: string - onExport: (scriptName: string) => void - onOpenChange: (open: boolean) => void -} - -export function ExportScriptDialog({ - open, - scriptName, - onExport, - onOpenChange, -}: ExportScriptDialogProps) { - const scripts = useStudioUIStore((store) => store.scripts) - - const formMethods = useForm({ - resolver: zodResolver(ExportScriptDialogSchema), - defaultValues: { - scriptName, - }, - }) - - const [alwaysOverwriteScript, setAlwaysOverwriteScript] = useLocalStorage( - 'alwaysOverwriteScript', - false - ) - const { setValue } = formMethods - - useEffect(() => { - if (!open) { - return - } - - setValue('scriptName', scriptName) - setValue('overwriteFile', false) - }, [open, scriptName, setValue]) - - const onSubmit = (data: ExportScriptDialogData) => { - const { scriptName: userInput, overwriteFile } = data - const fileName = getScriptNameWithExtension(userInput) - const fileExists = Array.from(scripts.keys()).includes(fileName) - if (fileExists && !overwriteFile && !alwaysOverwriteScript) { - setValue('overwriteFile', true) - return - } - - onExport(fileName) - onOpenChange(false) - } - - function handleOpenChange(open: boolean) { - onOpenChange(open) - if (!open) { - setValue('overwriteFile', false) - } - } - - const { overwriteFile: showOverwriteWarning } = formMethods.watch() - - return ( - - { - event.preventDefault() - }} - > - - - - Export script - - - - -
    - {showOverwriteWarning ? ( - - ) : ( - - )} - -
    -
    -
    - ) -} diff --git a/src/views/Generator/ExportScriptDialog/ExportScriptDialog.utils.ts b/src/views/Generator/ExportScriptDialog/ExportScriptDialog.utils.ts deleted file mode 100644 index 050754c07..000000000 --- a/src/views/Generator/ExportScriptDialog/ExportScriptDialog.utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getScriptNameWithExtension(scriptName: string) { - return scriptName.endsWith('.js') ? scriptName : `${scriptName}.js` -} diff --git a/src/views/Generator/ExportScriptDialog/OverwriteFileWarning.tsx b/src/views/Generator/ExportScriptDialog/OverwriteFileWarning.tsx deleted file mode 100644 index a68d4be1f..000000000 --- a/src/views/Generator/ExportScriptDialog/OverwriteFileWarning.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Code, Flex, Button, AlertDialog } from '@radix-ui/themes' -import { useFormContext } from 'react-hook-form' - -import { getScriptNameWithExtension } from './ExportScriptDialog.utils' - -export function OverwriteFileWarning() { - const { getValues, setValue } = useFormContext() - - function handleCancelOverwrite() { - setValue('overwriteFile', false) - } - - // TODO: https://github.com/grafana/k6-studio/issues/277 - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const scriptName = getScriptNameWithExtension(getValues('scriptName')) - - return ( - <> - - A script named {scriptName} already exists. Do you want to - overwrite it? - - - - - - - - - ) -} diff --git a/src/views/Generator/ExportScriptDialog/ScriptNameForm.tsx b/src/views/Generator/ExportScriptDialog/ScriptNameForm.tsx deleted file mode 100644 index 3074e207b..000000000 --- a/src/views/Generator/ExportScriptDialog/ScriptNameForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - Box, - TextField, - Flex, - AlertDialog, - Button, - Checkbox, - Text, -} from '@radix-ui/themes' -import { Dispatch } from 'react' -import { useFormContext } from 'react-hook-form' - -import { FieldGroup } from '@/components/Form' - -type ScriptNameFormProps = { - setAlwaysOverwriteScript: Dispatch - alwaysOverwriteScript: boolean -} - -export function ScriptNameForm({ - setAlwaysOverwriteScript, - alwaysOverwriteScript, -}: ScriptNameFormProps) { - const { - register, - formState: { errors }, - } = useFormContext() - - return ( - <> - - Choose a descriptive filename to help you identify your work later. Once - you've named your script, click Export to save it. - - - - - - - - - - - setAlwaysOverwriteScript(!!checked)} - />{' '} - Automatically overwrite existing script - - - - - - - - - - - - ) -} diff --git a/src/views/Generator/ExportScriptDialog/index.ts b/src/views/Generator/ExportScriptDialog/index.ts deleted file mode 100644 index f09dfbcad..000000000 --- a/src/views/Generator/ExportScriptDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ExportScriptDialog' diff --git a/src/views/Generator/Generator.hooks.ts b/src/views/Generator/Generator.hooks.ts index 181873b0d..f18f7b747 100644 --- a/src/views/Generator/Generator.hooks.ts +++ b/src/views/Generator/Generator.hooks.ts @@ -1,89 +1,58 @@ import { useMutation, useQuery } from '@tanstack/react-query' import log from 'electron-log/renderer' -import { useCallback } from 'react' -import { useParams } from 'react-router-dom' -import invariant from 'tiny-invariant' - -import { selectGeneratorData, useGeneratorStore } from '@/store/generator' +import { debounce } from 'lodash-es' +import * as pathe from 'pathe' +import { useCallback, useEffect, useState } from 'react' + +import { + GeneratorStore, + selectFilteredRequests, + selectGeneratorData, + useGeneratorStore, +} from '@/store/generator' import { useToast } from '@/store/ui/useToast' import { GeneratorFileData } from '@/types/generator' -import { queryClient } from '@/utils/query' - -import { exportScript, loadGeneratorFile, loadHarFile } from './Generator.utils' -export function useGeneratorParams() { - const { fileName } = useParams() - invariant(fileName, 'fileName is required') - - return { - fileName, - } -} +import { + exportScript, + generateScriptPreview, + loadGeneratorFile, + loadRecording, +} from './Generator.utils' -export function useLoadHarFile(fileName?: string) { +export function useLoadRecording(filePath?: string) { return useQuery({ - queryKey: ['har', fileName], - enabled: !!fileName, - queryFn: () => loadHarFile(fileName!), + queryKey: ['har', filePath], + enabled: !!filePath, + queryFn: () => loadRecording(filePath!), }) } -export function useLoadGeneratorFile(fileName: string) { +export function useLoadGeneratorFile(filePath: string) { return useQuery({ - queryKey: ['generator', fileName], - queryFn: () => loadGeneratorFile(fileName), + queryKey: ['generator', filePath], + queryFn: () => loadGeneratorFile(filePath), + enabled: !!filePath, }) } -export function useUpdateValueInGeneratorFile(fileName: string) { +export function useUpdateValueInGeneratorFile(filePath: string) { return useMutation({ mutationFn: async ({ key, value }: { key: string; value: unknown }) => { - const generator = await loadGeneratorFile(fileName) - await window.studio.generator.saveGenerator( - { ...generator, [key]: value }, - fileName - ) - }, - }) -} - -export function useSaveGeneratorFile(fileName: string) { - const showToast = useToast() - - return useMutation({ - mutationFn: async (generator: GeneratorFileData) => { - await window.studio.generator.saveGenerator(generator, fileName) - await queryClient.invalidateQueries({ queryKey: ['generator', fileName] }) - }, - - onSuccess: () => { - showToast({ - title: 'Generator saved', - status: 'success', - }) - }, - - onError: (error) => { - console.error('Failed to save generator', error) - - showToast({ - title: 'Failed to save generator', - status: 'error', - description: error.message, - }) - log.error(error) + const generator = await loadGeneratorFile(filePath) + const updated = { ...generator, [key]: value } + await window.studio.generator.saveGenerator(updated, filePath) }, }) } -export function useIsGeneratorDirty(fileName: string) { +export function useIsGeneratorDirty(initialData: GeneratorFileData) { const generatorState = useGeneratorStore(selectGeneratorData) - const { data } = useLoadGeneratorFile(fileName) // Comparing data without `scriptName`, which is saved to disk in the background // and should not be considered as a change const { scriptName: _, ...generatorStateData } = generatorState - const { scriptName: __, ...generatorFileData } = data || {} + const { scriptName: __, ...generatorFileData } = initialData // Convert to JSON instead of doing deep equal to remove // `property: undefined` values @@ -92,38 +61,106 @@ export function useIsGeneratorDirty(fileName: string) { ) } -export function useScriptExport(generatorFileName: string) { +export function useScriptExport(generatorFilePath: string) { const showToast = useToast() + const setScriptName = useGeneratorStore((store) => store.setScriptName) + const { mutateAsync: updateGeneratorFile } = - useUpdateValueInGeneratorFile(generatorFileName) + useUpdateValueInGeneratorFile(generatorFilePath) - return useCallback( - async (scriptName: string) => { - setScriptName(scriptName) + return useCallback(async () => { + const parsedPath = pathe.parse(generatorFilePath) - try { - await exportScript(scriptName) - } catch (error) { - log.error(error) - - showToast({ - title: 'Failed to export script', - status: 'error', - }) + let filePath: string | null = pathe.join( + parsedPath.dir, + parsedPath.name + '.js' + ) + + try { + filePath = await window.studio.script.showSaveDialog(filePath) + + if (filePath === null) { + return } + await exportScript(filePath) + + showToast({ + title: 'Script exported successfully', + status: 'success', + }) + } catch (error) { + log.error(error) + + showToast({ + title: 'Failed to export script', + status: 'error', + }) + + return + } + + const scriptName = pathe.basename(filePath) + + setScriptName(scriptName) + + try { + await updateGeneratorFile({ key: 'scriptName', value: scriptName }) + } catch (error) { + log.error(error) + + showToast({ + title: 'Failed to update script name', + status: 'error', + }) + } + }, [generatorFilePath, showToast, setScriptName, updateGeneratorFile]) +} + +export function useScriptPreview(generatorFilePath: string) { + const [preview, setPreview] = useState('') + const [error, setError] = useState() + + // Connect to the store on mount, disconnect on unmount, regenerate preview on state change + useEffect(() => { + if (!generatorFilePath) { + return + } + + const updatePreview = debounce(async (state: GeneratorStore) => { try { - await updateGeneratorFile({ key: 'scriptName', value: scriptName }) - } catch (error) { - log.error(error) - - showToast({ - title: 'Failed to update script name', - status: 'error', - }) + setError(undefined) + const generator = selectGeneratorData(state) + const requests = selectFilteredRequests(state) + + const scriptPath = pathe.join( + pathe.dirname(generatorFilePath), + generator.scriptName || 'script.js' + ) + + const script = await generateScriptPreview( + scriptPath, + generator, + requests + ) + setPreview(script) + } catch (e) { + console.error(e) + setError(e as Error) } - }, - [showToast, setScriptName, updateGeneratorFile] - ) + }, 100) + + // Initial preview generation + // TODO: https://github.com/grafana/k6-studio/issues/277 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + updatePreview(useGeneratorStore.getState()) + + const unsubscribe = useGeneratorStore.subscribe((state) => + updatePreview(state) + ) + return unsubscribe + }, [generatorFilePath]) + + return { preview, error } } diff --git a/src/views/Generator/Generator.tsx b/src/views/Generator/Generator.tsx index a870c46ef..a99ccfc93 100644 --- a/src/views/Generator/Generator.tsx +++ b/src/views/Generator/Generator.tsx @@ -1,62 +1,52 @@ import { Allotment } from 'allotment' import log from 'electron-log/renderer' -import { useCallback, useEffect, useRef, useState } from 'react' -import { useBlocker, useNavigate } from 'react-router-dom' -import useKeyboardJs from 'react-use/lib/useKeyboardJs' +import { useCallback, useEffect, useState } from 'react' +import { useBlocker } from 'react-router-dom' import { FileNameHeader } from '@/components/FileNameHeader' import { View } from '@/components/Layout/View' import { HttpRequestDetails } from '@/components/WebLogView/HttpRequestDetails' -import { getRoutePath } from '@/routeMap' +import { FileContent } from '@/handlers/file/types' +import { useSaveRequested } from '@/hooks/useSaveRequested' import { useGeneratorStore, selectGeneratorData } from '@/store/generator' import { useToast } from '@/store/ui/useToast' -import { ProxyData } from '@/types' -import { getFileNameWithoutExtension } from '@/utils/file' +import { StudioFile, ProxyData } from '@/types' +import { GeneratorFileData } from '@/types/generator' import { - useGeneratorParams, useIsGeneratorDirty, - useLoadGeneratorFile, - useLoadHarFile, - useSaveGeneratorFile, + useScriptPreview, + useLoadRecording, } from './Generator.hooks' import { GeneratorControls } from './GeneratorControls' import { GeneratorTabs } from './GeneratorTabs' import { TestRuleContainer } from './TestRuleContainer' import { UnsavedChangesDialog } from './UnsavedChangesDialog' -export function Generator() { +interface GeneratorProps { + file: StudioFile + data: GeneratorFileData + onSave: (content: FileContent, saveAs?: boolean) => void | Promise +} + +export function Generator({ file, data, onSave }: GeneratorProps) { const setGeneratorFile = useGeneratorStore((store) => store.setGeneratorFile) const [selectedRequest, setSelectedRequest] = useState(null) const showToast = useToast() - const navigate = useNavigate() - - const { fileName } = useGeneratorParams() - - const { - data: generatorFileData, - isLoading: isLoadingGenerator, - error: generatorError, - } = useLoadGeneratorFile(fileName) const { data: recording, isLoading: isLoadingRecording, error: harError, - } = useLoadHarFile(generatorFileData?.recordingPath) + } = useLoadRecording(data.recordingPath) - const { mutateAsync: saveGenerator } = useSaveGeneratorFile(fileName) + const isLoading = isLoadingRecording - const isLoading = isLoadingGenerator || isLoadingRecording - - const isDirty = useIsGeneratorDirty(fileName) - const isDirtyRef = useRef(isDirty) + const isDirty = useIsGeneratorDirty(data) const [isAppClosing, setIsAppClosing] = useState(false) - const [, onSaveKeyPress] = useKeyboardJs(['command + s', 'ctrl + s']) - const blocker = useBlocker(({ historyAction }) => { // Don't block navigation when redirecting home from invalid generator // TODO(router): Action enum is not exported from react-router-dom @@ -64,21 +54,11 @@ export function Generator() { return isDirty && historyAction !== 'REPLACE' }) - useEffect(() => { - if (!generatorFileData) return - setGeneratorFile(generatorFileData, recording) - }, [setGeneratorFile, generatorFileData, recording]) + const { preview, error } = useScriptPreview(file.path) useEffect(() => { - if (generatorError) { - showToast({ - title: 'Failed to load generator', - status: 'error', - }) - log.error(generatorError) - navigate(getRoutePath('home'), { replace: true }) - } - }, [generatorError, showToast, navigate]) + setGeneratorFile(data, recording) + }, [setGeneratorFile, data, recording]) useEffect(() => { if (harError) { @@ -101,22 +81,16 @@ export function Generator() { }) }) - useEffect(() => { - isDirtyRef.current = isDirty - }, [isDirty]) + const handleSaveGenerator = useCallback( + (payload?: { saveAs?: boolean }) => { + const generator = selectGeneratorData(useGeneratorStore.getState()) - const handleSaveGenerator = useCallback(() => { - const generator = selectGeneratorData(useGeneratorStore.getState()) - return saveGenerator(generator) - }, [saveGenerator]) + return onSave({ type: 'generator', data: generator }, payload?.saveAs) + }, + [onSave] + ) - useEffect(() => { - ;(async () => { - if (onSaveKeyPress && isDirtyRef.current === true) { - await handleSaveGenerator() - } - })() - }, [handleSaveGenerator, onSaveKeyPress]) + useSaveRequested(handleSaveGenerator) const handleSaveGeneratorDialog = async () => { await handleSaveGenerator() @@ -143,19 +117,16 @@ export function Generator() { return ( } + actions={ + } - actions={ - - } loading={isLoading} > @@ -163,6 +134,8 @@ export function Generator() { diff --git a/src/views/Generator/Generator.utils.ts b/src/views/Generator/Generator.utils.ts index 91c12d53f..1ac76cf45 100644 --- a/src/views/Generator/Generator.utils.ts +++ b/src/views/Generator/Generator.utils.ts @@ -1,3 +1,5 @@ +import invariant from 'tiny-invariant' + import { generateScript } from '@/codegen' import { selectFilteredRequests, @@ -6,14 +8,15 @@ import { } from '@/store/generator' import { ProxyData } from '@/types' import { GeneratorFileData } from '@/types/generator' -import { harToProxyData } from '@/utils/harToProxyData' import { prettify } from '@/utils/prettify' export async function generateScriptPreview( + scriptPath: string, generator: GeneratorFileData, recording: ProxyData[] ) { const script = generateScript({ + scriptPath, generator, recording, }) @@ -21,21 +24,31 @@ export async function generateScriptPreview( return prettify(script) } -export async function exportScript(fileName: string) { +export async function exportScript(filePath: string) { const generator = selectGeneratorData(useGeneratorStore.getState()) const filteredRequests = selectFilteredRequests(useGeneratorStore.getState()) - const script = await generateScriptPreview(generator, filteredRequests) + const script = await generateScriptPreview( + filePath, + generator, + filteredRequests + ) - await window.studio.script.saveScript(script, fileName) + await window.studio.script.saveScript(script, filePath) } export const loadGeneratorFile = async (fileName: string) => { - const generator = await window.studio.generator.loadGenerator(fileName) - return generator + const result = await window.studio.file.open(fileName) + + invariant(result.type === 'generator', 'Expected generator content') + + return result.data } -export const loadHarFile = async (fileName: string) => { - const har = await window.studio.har.openFile(fileName) - return harToProxyData(har) +export const loadRecording = async (filePath: string) => { + const result = await window.studio.file.open(filePath) + + invariant(result.type === 'recording', 'Expected recording content') + + return result.data.requests } diff --git a/src/views/Generator/GeneratorControls/GeneratorControls.tsx b/src/views/Generator/GeneratorControls/GeneratorControls.tsx index ac8a4c347..09fb896a2 100644 --- a/src/views/Generator/GeneratorControls/GeneratorControls.tsx +++ b/src/views/Generator/GeneratorControls/GeneratorControls.tsx @@ -13,38 +13,33 @@ import { RunInCloudButton } from '@/components/RunInCloudDialog/RunInCloudButton import { RunInCloudDialog } from '@/components/RunInCloudDialog/RunInCloudDialog' import { useDeleteFile } from '@/hooks/useDeleteFile' import { useProxyStatus } from '@/hooks/useProxyStatus' -import { useScriptPreview } from '@/hooks/useScriptPreview' -import { useGeneratorStore } from '@/store/generator' -import { getFileNameWithoutExtension } from '@/utils/file' +import { StudioFile } from '@/types' -import { ExportScriptDialog } from '../ExportScriptDialog' -import { useGeneratorParams, useScriptExport } from '../Generator.hooks' +import { useScriptExport } from '../Generator.hooks' import { ValidatorDialog } from '../ValidatorDialog' interface GeneratorControlsProps { - onSave: () => void + file: StudioFile isDirty: boolean + preview: string + error: Error | undefined + onSave: () => void } -export function GeneratorControls({ onSave, isDirty }: GeneratorControlsProps) { - const scriptName = useGeneratorStore((store) => store.scriptName) - +export function GeneratorControls({ + file, + preview, + error, + isDirty, + onSave, +}: GeneratorControlsProps) { const [isValidatorDialogOpen, setIsValidatorDialogOpen] = useState(false) - const [isExportScriptDialogOpen, setIsExportScriptDialogOpen] = - useState(false) const [isRunInCloudDialogOpen, setIsRunInCloudDialogOpen] = useState(false) - const { fileName } = useGeneratorParams() - const { preview, hasError } = useScriptPreview() - const proxyStatus = useProxyStatus() - const isScriptExportable = !hasError && !!preview - const file = { - type: 'generator' as const, - fileName, - displayName: getFileNameWithoutExtension(fileName), - } + const proxyStatus = useProxyStatus() + const isScriptExportable = error === undefined && preview !== '' - const handleExportScript = useScriptExport(fileName) + const handleExportScript = useScriptExport(file.path) const handleDelete = useDeleteFile({ file, @@ -67,7 +62,7 @@ export function GeneratorControls({ onSave, isDirty }: GeneratorControlsProps) { setIsExportScriptDialogOpen(true)} + onClick={() => handleExportScript()} disabled={!isScriptExportable} variant="ghost" color="gray" @@ -124,7 +119,7 @@ export function GeneratorControls({ onSave, isDirty }: GeneratorControlsProps) { <> - )} diff --git a/src/views/Generator/GeneratorTabs/GeneratorTabs.tsx b/src/views/Generator/GeneratorTabs/GeneratorTabs.tsx index d898239d4..d08a51ffc 100644 --- a/src/views/Generator/GeneratorTabs/GeneratorTabs.tsx +++ b/src/views/Generator/GeneratorTabs/GeneratorTabs.tsx @@ -3,7 +3,6 @@ import { Box, Flex, Tabs } from '@radix-ui/themes' import { CircleXIcon } from 'lucide-react' import { useState } from 'react' -import { useScriptPreview } from '@/hooks/useScriptPreview' import { selectFilteredRequests, selectHasRecording, @@ -19,17 +18,20 @@ import { RequestList } from './RequestList' import { ScriptPreview } from './ScriptPreview' interface GeneratorTabsProps { + preview: string + error: Error | undefined selectedRequest: ProxyData | null onSelectRequest: (request: ProxyData | null) => void } export function GeneratorTabs({ + preview, + error, selectedRequest, onSelectRequest, }: GeneratorTabsProps) { const [tab, setTab] = useState('requests') const filteredRequests = useGeneratorStore(selectFilteredRequests) - const { hasError } = useScriptPreview() const hasRecording = useGeneratorStore(selectHasRecording) @@ -47,13 +49,13 @@ export function GeneratorTabs({ value="script" disabled={!hasRecording} css={ - hasError && + error !== undefined && css` color: var(--red-9); ` } > - {hasError && ( + {error !== undefined && ( - + diff --git a/src/views/Generator/GeneratorTabs/ScriptPreview.tsx b/src/views/Generator/GeneratorTabs/ScriptPreview.tsx index 04a3efb8d..1a8470b70 100644 --- a/src/views/Generator/GeneratorTabs/ScriptPreview.tsx +++ b/src/views/Generator/GeneratorTabs/ScriptPreview.tsx @@ -1,13 +1,16 @@ import { Flex } from '@radix-ui/themes' import { ReactMonacoEditor } from '@/components/Monaco/ReactMonacoEditor' -import { useScriptPreview } from '@/hooks/useScriptPreview' import { useTrackScriptCopy } from '@/hooks/useTrackScriptCopy' import { ScriptPreviewError } from './ScriptPreviewError' -export function ScriptPreview() { - const { preview, error } = useScriptPreview() +interface ScriptPreviewProps { + preview: string + error: Error | undefined +} + +export function ScriptPreview({ preview, error }: ScriptPreviewProps) { const handleCopy = useTrackScriptCopy(preview, 'generator') return ( diff --git a/src/views/Generator/NewGeneratorView.tsx b/src/views/Generator/NewGeneratorView.tsx new file mode 100644 index 000000000..4f204acdb --- /dev/null +++ b/src/views/Generator/NewGeneratorView.tsx @@ -0,0 +1,42 @@ +import * as pathe from 'pathe' +import { useNavigate, useParams } from 'react-router-dom' + +import { FileContent } from '@/handlers/file/types' +import { getRoutePath } from '@/routeMap' +import { StudioFile } from '@/types' +import { createNewGeneratorFile } from '@/utils/generator' + +import { Generator } from './Generator' + +export function NewGeneratorView() { + const { recordingPath } = useParams<{ recordingPath: string }>() + + const navigate = useNavigate() + + const generator = createNewGeneratorFile(recordingPath, true) + + const file: StudioFile = { + type: 'generator', + path: pathe.join('untitled://', 'Untitled.k6g'), + fileName: 'Untitled.k6g', + displayName: 'Untitled', + } + + const handleSave = async (content: FileContent) => { + const result = await window.studio.file.save({ + content, + location: { type: 'new', hint: file.fileName }, + }) + + if (result === null) { + return + } + + navigate( + getRoutePath('editorView', { path: encodeURIComponent(result.path) }), + { replace: true } + ) + } + + return +} diff --git a/src/views/Generator/RecordingSelector.tsx b/src/views/Generator/RecordingSelector.tsx index cc9397620..71c19f7ac 100644 --- a/src/views/Generator/RecordingSelector.tsx +++ b/src/views/Generator/RecordingSelector.tsx @@ -1,13 +1,18 @@ import { css } from '@emotion/react' import { Flex, IconButton, Select, Text, Tooltip } from '@radix-ui/themes' import log from 'electron-log/renderer' -import { AlertTriangleIcon, PlusIcon } from 'lucide-react' +import { AlertTriangleIcon, FolderOpenIcon } from 'lucide-react' +import * as pathe from 'pathe' +import { UsageEventName } from '@/services/usageTracking/types' import { useGeneratorStore } from '@/store/generator' import { useStudioUIStore } from '@/store/ui' import { useToast } from '@/store/ui/useToast' import { getFileNameWithoutExtension } from '@/utils/file' -import { harToProxyData } from '@/utils/harToProxyData' + +function displayNameFromPath(filePath: string) { + return getFileNameWithoutExtension(pathe.basename(filePath)) +} export function RecordingSelector({ compact = false, @@ -23,38 +28,51 @@ export function RecordingSelector({ const showToast = useToast() const selectedRecording = recordings.find( - (recording) => recording.fileName === recordingPath + (recording) => recording.path === recordingPath ) const isRecordingMissing = selectedRecording === undefined && recordingPath !== '' - const handleOpen = async (filePath: string) => { + const handleOpen = async (filePath: string): Promise => { try { - const har = await window.studio.har.openFile(filePath) + const result = await window.studio.file.open(filePath) - const proxyData = harToProxyData(har) - setRecording(proxyData, filePath) + if (result.type !== 'recording') { + throw new Error('Expected recording content') + } + + setRecording(result.data.requests, filePath) onChangeRecording?.() + return true } catch (error) { showToast({ title: 'Failed to open recording', status: 'error', }) log.error(error) + return false } } - const handleImport = async () => { + const handlePickFromDisk = async () => { try { - const filePath = await window.studio.har.importFile() + const filePath = await window.studio.file.pickOpenFile() + + if (filePath === null) { + return + } - if (!filePath) return + const opened = await handleOpen(filePath) - await handleOpen(filePath) + if (opened) { + window.studio.app.trackEvent({ + event: UsageEventName.RecordingImported, + }) + } } catch (error) { showToast({ - title: 'Failed to import recording', + title: 'Failed to open recording', status: 'error', }) log.error(error) @@ -88,29 +106,27 @@ export function RecordingSelector({ `} /> )} - {getFileNameWithoutExtension(recordingPath)} + {displayNameFromPath(recordingPath)} {isRecordingMissing && ( - {getFileNameWithoutExtension(recordingPath)} + {displayNameFromPath(recordingPath)} )} {recordings.map((recording) => ( - + {recording.displayName} ))} - {!compact && ( - - - - - - )} + + + + + ) } diff --git a/src/views/Generator/RuleEditor/ParameterizationEditor/FileSelect.tsx b/src/views/Generator/RuleEditor/ParameterizationEditor/FileSelect.tsx index 428f34b52..da55eff6d 100644 --- a/src/views/Generator/RuleEditor/ParameterizationEditor/FileSelect.tsx +++ b/src/views/Generator/RuleEditor/ParameterizationEditor/FileSelect.tsx @@ -6,6 +6,7 @@ import { useFormContext } from 'react-hook-form' import { ControlledSelect, FieldGroup } from '@/components/Form' import { useGeneratorStore } from '@/store/generator' import { ParameterizationRule } from '@/types/rules' +import { getDataFileDisplayName } from '@/utils/file' import { useDataFilePreview } from '@/views/DataFile/DataFile.hooks' export function FileSelect() { @@ -15,17 +16,17 @@ export function FileSelect() { formState: { errors }, } = useFormContext() - const fileName = watch('value.fileName') + const filePath = watch('value.fileName') const propertyName = watch('value.propertyName') const files = useGeneratorStore((store) => store.files) - const { data: preview, isLoading } = useDataFilePreview(fileName) + const { data: preview, isLoading } = useDataFilePreview(filePath) const fileOptions = useMemo(() => { return files.map((file) => ({ - value: file.name, + value: file.path, label: ( - {file.name} + {getDataFileDisplayName(file.path)} ), })) @@ -63,7 +64,7 @@ export function FileSelect() { selectProps={{ // Automatically open the select when switching to data file value type // in new parameterization rule - defaultOpen: !fileName, + defaultOpen: !filePath, }} contentProps={{ css: { maxWidth: 'var(--radix-select-trigger-width)' }, @@ -84,7 +85,7 @@ export function FileSelect() { disabled: isLoading, // Automatically open the select when selecting data file // in new parameterization rule - defaultOpen: !!fileName && !propertyName, + defaultOpen: !!filePath && !propertyName, }} contentProps={{ css: { maxWidth: 'var(--radix-select-trigger-width)' }, diff --git a/src/views/Generator/TestData/DataFiles.tsx b/src/views/Generator/TestData/DataFiles.tsx index 35d09c115..2be248c3b 100644 --- a/src/views/Generator/TestData/DataFiles.tsx +++ b/src/views/Generator/TestData/DataFiles.tsx @@ -7,27 +7,21 @@ import { Text, Tooltip, } from '@radix-ui/themes' -import { - FilePlusIcon, - InfoIcon, - PlusIcon, - Trash2Icon, - TriangleAlertIcon, -} from 'lucide-react' +import { InfoIcon, PlusIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react' import { PopoverTooltip } from '@/components/PopoverTooltip' import { Table } from '@/components/Table' -import { useImportDataFile } from '@/hooks/useImportDataFile' import { useGeneratorStore } from '@/store/generator' import { useStudioUIStore } from '@/store/ui' import { DataFile } from '@/types/testData' +import { getDataFileDisplayName } from '@/utils/file' export function DataFiles() { const selectedFiles = useGeneratorStore((store) => store.files) const setFiles = useGeneratorStore((store) => store.setFiles) - const handleRemove = (fileName: string) => { - setFiles(selectedFiles.filter((file) => file.name !== fileName)) + const handleRemove = (filePath: string) => { + setFiles(selectedFiles.filter((file) => file.path !== filePath)) } return ( @@ -54,9 +48,9 @@ export function DataFiles() { {selectedFiles.map((file) => ( handleRemove(file.name)} + onRemove={() => handleRemove(file.path)} /> ))} @@ -82,12 +76,12 @@ function DataFileRow({ file, onRemove }: DataFileRowProps) { (rule) => rule.type === 'parameterization' && rule.value.type === 'dataFileValue' && - rule.value.fileName === file.name + rule.value.fileName === file.path ) ) const isFileMissing = useStudioUIStore( - (store) => !store.dataFiles.has(file.name) + (store) => !store.dataFiles.has(file.path) ) return ( @@ -108,7 +102,7 @@ function DataFileRow({ file, onRemove }: DataFileRowProps) { /> )} - {file.name} + {getDataFileDisplayName(file.path)} Unique item per iteration @@ -136,23 +130,13 @@ function AddDataFileDropdown() { const selectedFiles = useGeneratorStore((store) => store.files) const options = [...availableFiles.values()].filter( - (file) => !selectedFiles.find((f) => f.name === file.fileName) + (file) => !selectedFiles.find((f) => f.path === file.path) ) - const handleAdd = (fileName: string) => { - if (selectedFiles.find((file) => file.name === fileName)) return - - setFiles([...selectedFiles, { name: fileName }]) - } - - const importDataFile = useImportDataFile() - - const handleImportDataFile = async () => { - const fileName = await importDataFile() + const handleAdd = (filePath: string) => { + if (selectedFiles.find((file) => file.path === filePath)) return - if (fileName) { - handleAdd(fileName) - } + setFiles([...selectedFiles, { path: filePath }]) } return ( @@ -165,17 +149,12 @@ function AddDataFileDropdown() { {options.map((file) => ( handleAdd(file.fileName)} + key={file.path} + onClick={() => handleAdd(file.path)} > - {file.fileName} + {getDataFileDisplayName(file.path)} ))} - {options.length > 0 && } - - - Import new data file - ) diff --git a/src/views/Generator/TestRuleContainer/TestRule/TestRuleSelector.tsx b/src/views/Generator/TestRuleContainer/TestRule/TestRuleSelector.tsx index 4cac41334..a8fe73c36 100644 --- a/src/views/Generator/TestRuleContainer/TestRule/TestRuleSelector.tsx +++ b/src/views/Generator/TestRuleContainer/TestRule/TestRuleSelector.tsx @@ -10,6 +10,7 @@ import { ReplacerSelector, TestRule, } from '@/types/rules' +import { getDataFileDisplayName } from '@/utils/file' import { exhaustive } from '@/utils/typescript' import { CodeSnippetPreview, CustomCodeContent } from './CustomCodeContent' @@ -130,7 +131,7 @@ function ParameterizationValue({ rule }: { rule: ParameterizationRule }) { return ( <> {rule.value.propertyName} from{' '} - {rule.value.fileName} + {getDataFileDisplayName(rule.value.fileName)} ) case 'customCode': diff --git a/src/views/Generator/ValidatorDialog.tsx b/src/views/Generator/ValidatorDialog.tsx index 998b78d1d..7d46a7e80 100644 --- a/src/views/Generator/ValidatorDialog.tsx +++ b/src/views/Generator/ValidatorDialog.tsx @@ -8,8 +8,15 @@ import { useListenProxyData } from '@/hooks/useListenProxyData' import { useProxyHealthCheck } from '@/hooks/useProxyHealthCheck' import { useRunChecks } from '@/hooks/useRunChecks' import { useRunLogs } from '@/hooks/useRunLogs' +import { + selectFilteredRequests, + selectGeneratorData, + useGeneratorStore, +} from '@/store/generator' import { ValidatorResult } from '@/views/Generator/ValidatorResult' +import { generateScriptPreview } from './Generator.utils' + interface ValidatorDialogProps { script: string open: boolean @@ -17,8 +24,8 @@ interface ValidatorDialogProps { } export function ValidatorDialog({ - script, open, + script, onOpenChange, }: ValidatorDialogProps) { const [isRunning, setIsRunning] = useState(false) @@ -51,8 +58,29 @@ export function ValidatorDialog({ resetState() setIsRunning(true) - await window.studio.script.runScriptFromGenerator(script) - }, [resetState, script]) + const tempPath = await window.studio.file.getTempPath({ + prefix: 'script', + extension: '.js', + }) + + const generator = selectGeneratorData(useGeneratorStore.getState()) + const filteredRequests = selectFilteredRequests( + useGeneratorStore.getState() + ) + + const script = await generateScriptPreview( + tempPath, + generator, + filteredRequests + ) + + await window.studio.script.runScript({ + type: 'raw', + name: 'script.js', + path: tempPath, + content: script, + }) + }, [resetState]) useEffect(() => { if (!open) return diff --git a/src/views/Home/Home.tsx b/src/views/Home/Home.tsx index dcd8bb59b..d946c9db0 100644 --- a/src/views/Home/Home.tsx +++ b/src/views/Home/Home.tsx @@ -22,7 +22,7 @@ export function Home() { return } - navigate(getRoutePath('validator', { fileName: encodeURIComponent(path) })) + navigate(getRoutePath('editorView', { path: encodeURIComponent(path) })) } return ( diff --git a/src/views/Recorder/Recorder.tsx b/src/views/Recorder/Recorder.tsx index 5f1c759f6..f3f9a95f3 100644 --- a/src/views/Recorder/Recorder.tsx +++ b/src/views/Recorder/Recorder.tsx @@ -1,8 +1,9 @@ import { Button } from '@radix-ui/themes' import log from 'electron-log/renderer' import { StopCircle } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useBlocker, useNavigate } from 'react-router-dom' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useBlocker, useLocation, useNavigate } from 'react-router-dom' +import { useLocalStorage } from 'react-use' import { View } from '@/components/Layout/View' import TextSpinner from '@/components/TextSpinner/TextSpinner' @@ -14,7 +15,6 @@ import { useSettings } from '@/hooks/useSettings' import { getRoutePath } from '@/routeMap' import { useToast } from '@/store/ui/useToast' import { Group, ProxyData } from '@/types' -import { proxyDataToHar } from '@/utils/proxyDataToHar' import { ConfirmNavigationDialog } from './ConfirmNavigationDialog' import { EmptyState } from './EmptyState' @@ -36,6 +36,11 @@ const INITIAL_GROUPS: Group[] = [ export function Recorder() { const { data: settings } = useSettings() + const location = useLocation() + const [captureBrowser = true] = useLocalStorage( + 'start-recording.capture.browser', + true + ) const [startUrl, setStartUrl] = useState() const [groups, setGroups] = useState(() => INITIAL_GROUPS) @@ -89,6 +94,47 @@ export function Recorder() { } }, [recorderState, proxyData.length]) + // Auto-start recording when navigated with startUrl in state (e.g. from sidebar) + const hasProcessedAutoStart = useRef(false) + useEffect(() => { + const startUrlFromState = (location.state as { startUrl?: string }) + ?.startUrl + + if (!startUrlFromState || typeof startUrlFromState !== 'string') { + hasProcessedAutoStart.current = false + + return + } + + if (recorderState !== 'idle' || hasProcessedAutoStart.current) { + return + } + + hasProcessedAutoStart.current = true + + handleStartRecording({ + url: startUrlFromState, + capture: { + browser: true, + }, + }).catch(() => { + showToast({ + title: 'Failed to start recording', + status: 'error', + }) + }) + + navigate(getRoutePath('recorder'), { replace: true, state: {} }) + }, [ + location.state, + recorderState, + settings?.recorder.browserRecording, + captureBrowser, + handleStartRecording, + navigate, + showToast, + ]) + useEffect(() => { return window.studio.app.onApplicationClose(() => { if (recorderState === 'recording' || recorderState === 'starting') { @@ -107,7 +153,6 @@ export function Recorder() { return null } - // Temporary solution to avoid having to update `proxyDataToHar`. const grouped = proxyData.map((data) => { const group = groups.find((g) => g.id === data.group) ?? { id: DEFAULT_GROUP_NAME, @@ -120,9 +165,11 @@ export function Recorder() { } }) - const har = proxyDataToHar(grouped, browserEvents) const prefix = getHostNameFromURL(startUrl) ?? 'Recording' - const fileName = await window.studio.har.saveFile(har, prefix) + const fileName = await window.studio.har.saveFile( + { requests: grouped, browserEvents }, + prefix + ) return fileName } finally { @@ -186,9 +233,9 @@ export function Recorder() { return } - const fileName = await validateAndSaveHarFile() + const filePath = await validateAndSaveHarFile() - if (fileName === null) { + if (filePath === null) { return } @@ -198,8 +245,8 @@ export function Recorder() { }) navigate( - getRoutePath('recordingPreviewer', { - fileName: encodeURIComponent(fileName), + getRoutePath('editorView', { + path: encodeURIComponent(filePath), }), { state: { discardable: true }, diff --git a/src/views/RecordingPreviewer/RecordingPreviewer.tsx b/src/views/RecordingPreviewer/RecordingPreviewer.tsx index 409a8b09c..99f1d8710 100644 --- a/src/views/RecordingPreviewer/RecordingPreviewer.tsx +++ b/src/views/RecordingPreviewer/RecordingPreviewer.tsx @@ -1,80 +1,44 @@ -import { useEffect, useState } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import invariant from 'tiny-invariant' - import { FileNameHeader } from '@/components/FileNameHeader' import { View } from '@/components/Layout/View' import { useProxyDataGroups } from '@/hooks/useProxyDataGroups' import { useSettings } from '@/hooks/useSettings' -import { BrowserEvent } from '@/schemas/recording' -import { ProxyData, StudioFile } from '@/types' -import { getFileNameWithoutExtension } from '@/utils/file' -import { harToProxyData } from '@/utils/harToProxyData' +import { StudioFile } from '@/types' +import { RecordingData } from '@/types/recordingData' import { RecordingInspector } from '../Recorder/RecordingInspector' import { RequestLog } from '../Recorder/RequestLog' import { RecordingPreviewControls } from './RecordingPreviewerControls' -export function RecordingPreviewer() { - const { data: settings } = useSettings() +interface RecordingPreviewerProps { + file: StudioFile + data: RecordingData +} - const [proxyData, setProxyData] = useState([]) - const [browserEvents, setBrowserEvents] = useState([]) +export function RecordingPreviewer({ file, data }: RecordingPreviewerProps) { + const { data: settings } = useSettings() - const [isLoading, setIsLoading] = useState(true) - const { fileName } = useParams() - const navigate = useNavigate() + const { requests: proxyData, browserEvents } = data + const groups = useProxyDataGroups(proxyData) const browserRecorderSetting = settings?.recorder.browserRecording ?? 'disabled' - invariant(fileName, 'fileName is required') - const file: StudioFile = { - fileName, - displayName: getFileNameWithoutExtension(fileName), - type: 'recording', - } - - useEffect(() => { - ;(async () => { - setIsLoading(true) - setProxyData([]) - const har = await window.studio.har.openFile(fileName) - setIsLoading(false) - - invariant(har, 'Failed to open file') - - setProxyData(harToProxyData(har)) - setBrowserEvents(har.log._browserEvents?.events ?? []) - })() - - return () => { - setProxyData([]) - setBrowserEvents([]) - } - }, [fileName, navigate]) - - const groups = useProxyDataGroups(proxyData) - return ( } - loading={isLoading} actions={ } > - {!isLoading && browserRecorderSetting !== 'disabled' && ( + {browserRecorderSetting !== 'disabled' ? ( - )} - - {!isLoading && browserRecorderSetting === 'disabled' && ( + ) : ( )} diff --git a/src/views/RecordingPreviewer/RecordingPreviewerControls.tsx b/src/views/RecordingPreviewer/RecordingPreviewerControls.tsx index 55182969d..ee8760bfb 100644 --- a/src/views/RecordingPreviewer/RecordingPreviewerControls.tsx +++ b/src/views/RecordingPreviewer/RecordingPreviewerControls.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/react' import { Button, DropdownMenu, Flex, IconButton, Text } from '@radix-ui/themes' import { ChevronDownIcon, EllipsisVerticalIcon } from 'lucide-react' -import { useState } from 'react' -import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' +import * as pathe from 'pathe' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { emitScript } from '@/codegen/browser' import { convertEventsToTest } from '@/codegen/browser/test' @@ -14,8 +14,6 @@ import { BrowserEvent } from '@/schemas/recording' import { useToast } from '@/store/ui/useToast' import { StudioFile } from '@/types' -import { ExportScriptDialog } from '../Generator/ExportScriptDialog' - interface RecordingPreviewControlsProps { file: StudioFile browserEvents: BrowserEvent[] @@ -25,11 +23,9 @@ export function RecordingPreviewControls({ file, browserEvents, }: RecordingPreviewControlsProps) { - const [showExportDialog, setShowExportDialog] = useState(false) const showToast = useToast() const navigate = useNavigate() const createTestGenerator = useCreateGenerator() - const { fileName } = useParams() // TODO: https://github.com/grafana/k6-studio/issues/277 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -38,7 +34,7 @@ export function RecordingPreviewControls({ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isDiscardable = Boolean(state?.discardable) - const handleCreateGenerator = () => createTestGenerator(fileName) + const handleCreateGenerator = () => createTestGenerator(file.path) const handleDelete = useDeleteFile({ file, @@ -55,28 +51,44 @@ export function RecordingPreviewControls({ navigate(getRoutePath('home')) } - const handleExportBrowserScript = (fileName: string) => { + const handleExportBrowserScript = async () => { const test = convertEventsToTest({ browserEvents, }) - emitScript(test) - .then((script) => window.studio.script.saveScript(script, fileName)) - .then(() => { - navigate( - getRoutePath('validator', { - fileName: encodeURIComponent(fileName), - }) - ) + try { + const parsedPath = pathe.parse(file.path) + + const scriptPath = await window.studio.script.showSaveDialog( + pathe.join(parsedPath.dir, parsedPath.name + '.js') + ) + + if (scriptPath === null) { + return + } + + const script = await emitScript(test) + + await window.studio.script.saveScript(script, scriptPath) + + showToast({ + title: 'Script exported successfully', + status: 'success', }) - .catch((err) => { - console.error(err) - showToast({ - title: 'Failed to export browser script.', - status: 'error', + navigate( + getRoutePath('editorView', { + path: encodeURIComponent(scriptPath), }) + ) + } catch (error) { + console.error(error) + + showToast({ + title: 'Failed to export browser script', + status: 'error', }) + } } return ( @@ -114,7 +126,7 @@ export function RecordingPreviewControls({ label="Browser test" description="Export a k6 script simulating browser interactions" disabled={browserEvents.length === 0} - onClick={() => setShowExportDialog(true)} + onClick={handleExportBrowserScript} /> @@ -139,12 +151,6 @@ export function RecordingPreviewControls({ /> - ) } diff --git a/src/views/Validator/Browser/BrowserActionList.tsx b/src/views/Validator/Browser/BrowserActionList.tsx index 893d72e89..2056cb89b 100644 --- a/src/views/Validator/Browser/BrowserActionList.tsx +++ b/src/views/Validator/Browser/BrowserActionList.tsx @@ -79,12 +79,19 @@ interface BrowserActionListProps { onHighlight?: (selector: NodeSelector | null) => void } -export function BrowserActionList({ actions, onHighlight }: BrowserActionListProps) { +export function BrowserActionList({ + actions, + onHighlight, +}: BrowserActionListProps) { return (
      {actions.map((action) => ( - + ))}
    diff --git a/src/views/Validator/Browser/BrowserActionText.tsx b/src/views/Validator/Browser/BrowserActionText.tsx index 25060063f..ee8efb1bc 100644 --- a/src/views/Validator/Browser/BrowserActionText.tsx +++ b/src/views/Validator/Browser/BrowserActionText.tsx @@ -51,14 +51,22 @@ export function BrowserActionText({ case 'locator.check': return ( <> - Check + Check{' '} + ) case 'locator.clear': return ( <> - Clear + Clear{' '} + ) @@ -66,7 +74,10 @@ export function BrowserActionText({ return ( <> on{' '} - + ) @@ -74,29 +85,44 @@ export function BrowserActionText({ return ( <> on{' '} - + ) case 'locator.fill': return ( <> - Fill with text{' '} - {`"${action.value}"`} + Fill{' '} + {' '} + with text {`"${action.value}"`} ) case 'locator.focus': return ( <> - Focus on + Focus on{' '} + ) case 'locator.hover': return ( <> - Hover over + Hover over{' '} + ) @@ -104,7 +130,10 @@ export function BrowserActionText({ return ( <> Press key {`"${action.key}"`} on{' '} - + ) @@ -112,7 +141,10 @@ export function BrowserActionText({ return ( <> Select options on{' '} - + ) @@ -120,14 +152,21 @@ export function BrowserActionText({ return ( <> Set {'"checked"'} to {action.checked.toString()} on{' '} - + ) case 'locator.tap': return ( <> - Tap on + Tap on{' '} + ) @@ -135,21 +174,32 @@ export function BrowserActionText({ return ( <> Type {`"${action.text}"`} into{' '} - + ) case 'locator.uncheck': return ( <> - Uncheck + Uncheck{' '} + ) case 'locator.waitFor': return ( <> - Wait for element + Wait for element{' '} + ) @@ -157,7 +207,10 @@ export function BrowserActionText({ return ( <> Call {action.name} on{' '} - + ) diff --git a/src/views/Validator/Browser/BrowserActionsPanel.tsx b/src/views/Validator/Browser/BrowserActionsPanel.tsx index 5e143c716..821b63429 100644 --- a/src/views/Validator/Browser/BrowserActionsPanel.tsx +++ b/src/views/Validator/Browser/BrowserActionsPanel.tsx @@ -72,7 +72,10 @@ export function BrowserActionsPanel({ )} {session.state !== 'pending' && ( - + )} diff --git a/src/views/Validator/Validator.hooks.ts b/src/views/Validator/Validator.hooks.ts index 884c3be94..fbb52a43e 100644 --- a/src/views/Validator/Validator.hooks.ts +++ b/src/views/Validator/Validator.hooks.ts @@ -1,8 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { nanoid } from 'nanoid' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useParams } from 'react-router-dom' -import invariant from 'tiny-invariant' import { Script } from '@/handlers/cloud/types' import { useBrowserActions } from '@/hooks/useBrowserActions' @@ -13,19 +11,26 @@ import { useRunLogs } from '@/hooks/useRunLogs' import { DebuggerState } from './types' -export function useScriptPath() { - const { fileName } = useParams() +export function useScript(filePath: string) { + return useQuery({ + queryKey: ['script', filePath], + queryFn: async () => { + const result = await window.studio.file.open(filePath) - invariant(fileName, 'fileName param is required') + if (result.type !== 'script') { + throw new Error('Expected script content') + } - return fileName -} + const options = await window.studio.script.analyzeScript({ + type: 'path', + path: filePath, + }) -export function useScript(fileName: string) { - return useQuery({ - queryKey: ['script', fileName], - queryFn: async () => { - return window.studio.script.openScript(fileName) + return { + script: result.content, + options, + isExternal: result.isExternal, + } }, refetchOnMount: false, refetchOnReconnect: false, @@ -75,17 +80,10 @@ export function useDebugSession(script: Script) { resetSession() - if (script.type === 'raw') { - await window.studio.script.runScriptFromGenerator(input).catch(() => { - setState('stopped') - }) - return - } - - await window.studio.script.runScript(input).catch(() => { + await window.studio.script.runScript(script).catch(() => { setState('stopped') }) - }, [resetSession, script.type, input]) + }, [script, resetSession]) const stopDebugging = useCallback(() => { window.studio.script.stopScript() diff --git a/src/views/Validator/Validator.tsx b/src/views/Validator/Validator.tsx index 416d09d8a..61f7a859a 100644 --- a/src/views/Validator/Validator.tsx +++ b/src/views/Validator/Validator.tsx @@ -8,19 +8,24 @@ import { RunInCloudDialog } from '@/components/RunInCloudDialog/RunInCloudDialog import { getRoutePath } from '@/routeMap' import { useToast } from '@/store/ui/useToast' import { StudioFile } from '@/types' -import { getFileNameWithoutExtension } from '@/utils/file' +import { K6TestOptions } from '@/utils/k6/schema' import { Debugger } from './Debugger' -import { useDebugSession, useScript, useScriptPath } from './Validator.hooks' +import { useDebugSession } from './Validator.hooks' import { ValidatorControls } from './ValidatorControls' -interface ValidatorProps { - scriptPath: string +export interface ValidatorScriptData { + script: string + options: K6TestOptions + isExternal: boolean } -function Content({ scriptPath }: ValidatorProps) { - const { data, isLoading } = useScript(scriptPath) +interface ValidatorProps { + file: StudioFile + scriptData: ValidatorScriptData +} +export function Validator({ file, scriptData }: ValidatorProps) { const [showRunInCloudDialog, setShowRunInCloudDialog] = useState(false) const navigate = useNavigate() @@ -28,17 +33,11 @@ function Content({ scriptPath }: ValidatorProps) { const { session, startDebugging, stopDebugging } = useDebugSession({ type: 'file', - path: scriptPath, + path: file.path, }) const isRunning = session?.state === 'running' - const file: StudioFile = { - type: 'script', - fileName: scriptPath, - displayName: getFileNameWithoutExtension(scriptPath), - } - const handleSelectExternalScript = useCallback(async () => { const newScriptPath = await window.studio.script.showScriptSelectDialog() @@ -47,14 +46,14 @@ function Content({ scriptPath }: ValidatorProps) { } navigate( - getRoutePath('validator', { - fileName: encodeURIComponent(newScriptPath), + getRoutePath('editorView', { + path: encodeURIComponent(newScriptPath), }) ) }, [navigate]) async function handleDebugScript() { - if (!scriptPath) { + if (!file.path) { return } @@ -96,34 +95,35 @@ function Content({ scriptPath }: ValidatorProps) { return ( } + subTitle={ + + } actions={ } - loading={isLoading} > - {scriptPath !== undefined && ( + {file.path !== undefined && ( @@ -131,9 +131,3 @@ function Content({ scriptPath }: ValidatorProps) { ) } - -export function Validator() { - const scriptPath = useScriptPath() - - return -} diff --git a/vite.preload.config.ts b/vite.preload.config.ts index 382e654fe..856df1921 100644 --- a/vite.preload.config.ts +++ b/vite.preload.config.ts @@ -16,7 +16,9 @@ export default defineConfig((env) => { const config: UserConfig = { build: { rollupOptions: { - external: getExternal(env.command), + external: getExternal(env.command).filter( + (dep) => dep !== 'tiny-invariant' + ), // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. input: forgeConfigSelf.entry, output: {