From d5d2a64c32440d975002910f7d575675ddf49c4d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 9 Sep 2026 08:50:26 -0700 Subject: [PATCH 001/155] fix(cli): focus the agent skill description on when to use it (#625) --- crates-cli/yaak-cli/skills/use-yaak/SKILL.md | 21 +++++++------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md index 030c7a99d..007713e77 100644 --- a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md +++ b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md @@ -1,19 +1,12 @@ --- name: use-yaak description: > - Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces, - folders, environments and variables, author HTTP requests, configure - authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4), - send them individually or a whole folder/workspace at once, chain one - request's response into the next, and import existing APIs from OpenAPI, - Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a - Yaak workspace, or the `yaak` command, and also when they ask to try, hit, - call, exercise, or smoke test an HTTP or REST endpoint, to save or organize - API requests for reuse, to set up API requests for manual testing, to add auth - to a saved request, to turn an OpenAPI or Postman collection into runnable - requests, or to run a saved request suite against staging versus production. - Prefer this over one-off `curl` commands whenever the requests should be - saved, reused, shared, or run as a set. + Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or + asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API + requests for reuse or manual testing, configure auth on saved requests, import + an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run + saved requests across environments. Prefer over one-off `curl` when requests + should be saved, reused, shared, or run as a set. allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*) --- @@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry. Two consequences worth holding onto. Requests you create are permanent user data in an app they use, not scratch files, so name them the way the user would and clean up anything created just to test. And because the app is right there, the -CLI is usually the wrong place to *read* a response in detail; it is the right +CLI is usually the wrong place to _read_ a response in detail; it is the right place to build, organize, and run requests. ## The CLI describes itself From 89ce84cefe4c5b8bdfa8a81e6e56518ea4a508ee Mon Sep 17 00:00:00 2001 From: gschier Date: Thu, 10 Sep 2026 15:37:06 +0000 Subject: [PATCH 002/155] =?UTF-8?q?Deploying=20to=20main=20from=20@=20moun?= =?UTF-8?q?tain-loop/yaak@d5d2a64c32440d975002910f7d575675ddf49c4d=20?= =?UTF-8?q?=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df97f9612..c415d86d7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar:    + User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar: rauchg  User avatar:   

User avatar: seanwash  User avatar: jerath  User avatar: itsa-sh  User avatar: dmmulroy  User avatar: timcole  User avatar: VLZH  User avatar: terasaka2k  User avatar: andriyor  User avatar: majudhu  User avatar: axelrindle  User avatar: jirizverina  User avatar: chip-well  User avatar: GRAYAH  User avatar: flashblaze  User avatar: Frostist  User avatar: PurplProto   From 096e83a7bd1189c198cd1aa6d65fe0329be21d1d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 10 Sep 2026 14:34:00 -0700 Subject: [PATCH 003/155] chore(deps): patch open security advisories across npm deps (#630) --- .github/dependabot.yml | 34 + package-lock.json | 1387 +++++++++++------ package.json | 2 + plugins-external/faker/package.json | 2 +- .../tests/__snapshots__/init.test.ts.snap | 1 + plugins-external/mcp-server/package.json | 2 +- plugins/filter-xpath/package.json | 2 +- plugins/template-function-xml/package.json | 2 +- 8 files changed, 950 insertions(+), 482 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..8c05579a0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + npm-production: + dependency-type: production + update-types: + - minor + - patch + npm-development: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + cargo: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/package-lock.json b/package-lock.json index f288fd6b7..155dd7183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -362,9 +362,9 @@ "license": "MIT" }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", "dev": true, "license": "MIT", "engines": { @@ -873,9 +873,9 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", - "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" @@ -906,9 +906,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", - "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -917,18 +917,18 @@ } }, "node_modules/@codemirror/state": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", - "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.4.tgz", + "integrity": "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.43.9", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", - "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", + "version": "6.43.11", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.11.tgz", + "integrity": "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -1424,6 +1424,22 @@ "node": ">=18" } }, + "node_modules/@faker-js/faker": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz", + "integrity": "sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@gilbarbara/deep-equal": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", @@ -1503,9 +1519,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1606,9 +1622,9 @@ } }, "node_modules/@lezer/java": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", - "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.4.tgz", + "integrity": "sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -1658,9 +1674,9 @@ } }, "node_modules/@lezer/php": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", - "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz", + "integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -1702,9 +1718,9 @@ } }, "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", "license": "MIT" }, "node_modules/@mjackson/headers": { @@ -1879,6 +1895,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1899,6 +1918,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1919,6 +1941,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1939,6 +1964,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1959,6 +1987,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2020,6 +2051,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2214,6 +2248,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2231,6 +2268,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2248,6 +2288,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2265,6 +2308,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2282,6 +2328,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2299,6 +2348,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2316,6 +2368,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2333,6 +2388,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2621,6 +2679,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2638,6 +2699,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2655,6 +2719,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2672,6 +2739,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2689,6 +2759,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2706,6 +2779,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2723,6 +2799,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2740,6 +2819,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2902,13 +2984,13 @@ } }, "node_modules/@rolldown/plugin-babel": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.3.tgz", - "integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.4.tgz", + "integrity": "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^4.0.4" + "picomatch": "^4.0.7" }, "engines": { "node": ">=22.12.0 || ^24.0.0" @@ -2970,9 +3052,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", - "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", "cpu": [ "arm" ], @@ -2984,9 +3066,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", - "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", "cpu": [ "arm64" ], @@ -2998,9 +3080,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", - "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", "cpu": [ "arm64" ], @@ -3012,9 +3094,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", - "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", "cpu": [ "x64" ], @@ -3026,9 +3108,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", - "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", "cpu": [ "arm64" ], @@ -3040,9 +3122,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", - "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", "cpu": [ "x64" ], @@ -3054,13 +3136,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", - "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3068,13 +3153,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", - "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3082,13 +3170,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", - "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3096,13 +3187,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", - "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3110,13 +3204,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", - "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3124,13 +3221,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", - "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3138,13 +3238,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", - "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3152,13 +3255,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", - "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3166,13 +3272,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", - "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3180,13 +3289,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3194,13 +3306,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3208,13 +3323,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3222,13 +3340,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3236,9 +3357,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", "cpu": [ "x64" ], @@ -3250,9 +3371,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", "cpu": [ "arm64" ], @@ -3264,9 +3385,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", "cpu": [ "arm64" ], @@ -3278,9 +3399,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", "cpu": [ "ia32" ], @@ -3292,9 +3413,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", "cpu": [ "x64" ], @@ -3306,9 +3427,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", "cpu": [ "x64" ], @@ -3536,62 +3657,6 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@svgr/core/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@svgr/core/node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@svgr/core/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -3766,6 +3831,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3783,6 +3851,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3800,6 +3871,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3817,6 +3891,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3905,9 +3982,9 @@ } }, "node_modules/@tanstack/history": { - "version": "1.162.1", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.1.tgz", - "integrity": "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==", + "version": "1.162.3", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.3.tgz", + "integrity": "sha512-zZTDxZxdDxZudHXGp42d221R/sEKoQPbZsW2ezpdWknHpOmJVLyiljW21pdWtKXfMnLq+Kg5VJpVBwOe18+ZzQ==", "license": "MIT", "engines": { "node": ">=20.19" @@ -3918,9 +3995,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", "license": "MIT", "funding": { "type": "github", @@ -3928,12 +4005,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/query-core": "5.102.8" }, "funding": { "type": "github", @@ -3944,14 +4021,14 @@ } }, "node_modules/@tanstack/react-router": { - "version": "1.170.29", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.29.tgz", - "integrity": "sha512-xQwakR0ReaUGu3xeXEl2CqzlIXk4i9vxaf/zqdzoELMY0mDZgMOr+xobiA5cQdn8cnovqZgqqUxcwXhsruC+NA==", + "version": "1.170.34", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.34.tgz", + "integrity": "sha512-zYy1SNjIIAEMfU9mqmB5171PeELs/k3SR+U6QxDKSqyMLw4Aq6R+eC57Sa8iB6X91q1ouES30khauyYww4JMcQ==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.162.1", + "@tanstack/history": "1.162.3", "@tanstack/react-store": "^0.9.3", - "@tanstack/router-core": "1.171.24", + "@tanstack/router-core": "1.171.29", "isbot": "^5.1.22" }, "engines": { @@ -3985,12 +4062,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.14.9", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", - "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "version": "3.14.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.11.tgz", + "integrity": "sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.17.7" + "@tanstack/virtual-core": "3.17.9" }, "funding": { "type": "github", @@ -4002,12 +4079,12 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.171.24", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.24.tgz", - "integrity": "sha512-+j+8NmV4QOS+ILNLdXFlLTdKPPbBQIy6mXDZ0QWEKYZ0xQOdlnOCXVHPflyGanD2SWSxjqGkw8CsW0CXXP7fLg==", + "version": "1.171.29", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.29.tgz", + "integrity": "sha512-tBXJrU4rDl+UsfTvP/WM1pcQ+fXk4rb2uuK/FMw29JNHEkFdEbptA0yvk8vkNjooTF7DAiTUrZMf2H4X7eGt4g==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.162.1", + "@tanstack/history": "1.162.3", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" @@ -4021,20 +4098,20 @@ } }, "node_modules/@tanstack/router-generator": { - "version": "1.167.30", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.30.tgz", - "integrity": "sha512-Tf9TJfdpP0yL3k1D1ke6hR1dvPCgKShTik3HcBRDeTK5eNrp9/X0Q0YApN8thE6pVcXL6a78O3ZE7XN7s7KwNw==", + "version": "1.167.35", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.35.tgz", + "integrity": "sha512-1Bfvhon3+4khnB1E1+p9ub5diTzSZ782FiHipGX5wyAPOKdR5dQ1L2XGuZ2BCJQCynhQNYSW2CVJ5DvRHVixlw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5", - "@tanstack/router-core": "1.171.24", - "@tanstack/router-utils": "1.162.2", + "@babel/types": "^7.29.8", + "@tanstack/router-core": "1.171.29", + "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", - "prettier": "^3.5.0", - "zod": "^4.4.3" + "prettier": "^3.9.6", + "zod": "^4.5.4" }, "engines": { "node": ">=20.19" @@ -4045,9 +4122,9 @@ } }, "node_modules/@tanstack/router-generator/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", "dev": true, "license": "MIT", "funding": { @@ -4055,21 +4132,21 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.168.32", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.32.tgz", - "integrity": "sha512-P+qYIY9b/K+ZGUPNiyLqsd20vN/1abgmSNG0Ch9KTKldag3icR7TlrJgnM2km86yuRdXa4nJ5YT7+JLV5rJRZg==", + "version": "1.168.37", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.37.tgz", + "integrity": "sha512-LO+HAUnFGuC/s2zz6I7ZlSiFroq5obhNNJrkvn7+ALakXfMdo8sOvSqhdHgFh+3Em5rTpd+HVBgbSqLWsgXnTw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "@tanstack/router-core": "1.171.24", - "@tanstack/router-generator": "1.167.30", - "@tanstack/router-utils": "1.162.2", + "@babel/core": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "@tanstack/router-core": "1.171.29", + "@tanstack/router-generator": "1.167.35", + "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", - "unplugin": "^3.0.0", - "zod": "^4.4.3" + "unplugin": "^3.3.0", + "zod": "^4.5.4" }, "engines": { "node": ">=20.19" @@ -4080,7 +4157,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", - "@tanstack/react-router": "^1.170.29", + "@tanstack/react-router": "^1.170.34", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" @@ -4104,9 +4181,9 @@ } }, "node_modules/@tanstack/router-plugin/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", "dev": true, "license": "MIT", "funding": { @@ -4114,20 +4191,20 @@ } }, "node_modules/@tanstack/router-utils": { - "version": "1.162.2", - "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", - "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "version": "1.162.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.3.tgz", + "integrity": "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/generator": "^7.28.5", - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "ansis": "^4.1.0", + "@babel/generator": "^7.29.8", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", - "diff": "^8.0.2", + "diff": "^8.0.4", "pathe": "^2.0.3", - "tinyglobby": "^0.2.15" + "tinyglobby": "^0.2.17" }, "engines": { "node": ">=20.19" @@ -4148,9 +4225,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.17.7", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", - "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "version": "3.17.9", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.9.tgz", + "integrity": "sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg==", "license": "MIT", "funding": { "type": "github", @@ -4271,6 +4348,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4288,6 +4368,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4305,6 +4388,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4322,6 +4408,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4339,6 +4428,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4400,45 +4492,45 @@ } }, "node_modules/@tauri-apps/plugin-clipboard-manager": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", - "integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.3.tgz", + "integrity": "sha512-KnyoTs9gj1yEgDkSPUNjOIOHjJTr5wk8IWcYMOWxYTIJCip6QwlyPW8u2X+6bd6kHM4fAdZNpxoal0gy/TwJbg==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.8.0" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", - "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz", + "integrity": "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", - "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.2.tgz", + "integrity": "sha512-XXvMSnFiob+G1H+YHCDf+bzWVumseQuEIhzpbOJzevUfL4k0U+sTApZWJHpoLiamggnVPJm5dJ5sDsniRyWxlg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-log": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz", - "integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.1.tgz", + "integrity": "sha512-8dYNEQOgZcIEqeFtHAsOIGLoptm+j94270Jf2MrGS/zbsYNd4C8DKyOdeYggAyE3ugwr12mTefIfC8lVVEhdlg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", - "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz", + "integrity": "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" @@ -4454,12 +4546,12 @@ } }, "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.6.tgz", + "integrity": "sha512-C2AG+QmBJPK0E3gccLXfRdjTfGBC9DgwzGi/PfYa61ieAZbYvhvOss4OdSGfn/D28N3THGhJZEhf3lozOh/Uqw==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@testing-library/dom": { @@ -4483,9 +4575,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.4", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz", - "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -4655,9 +4747,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", - "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "version": "25.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz", + "integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==", "dev": true, "license": "MIT", "dependencies": { @@ -4688,9 +4780,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", "dependencies": { @@ -4698,13 +4790,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -4748,15 +4840,15 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", - "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -4768,6 +4860,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -4776,6 +4869,9 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, @@ -4839,13 +4935,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4865,6 +4961,16 @@ } } }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/pretty-format": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", @@ -5177,6 +5283,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5198,6 +5307,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5219,6 +5331,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5240,6 +5355,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5337,6 +5455,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5354,6 +5475,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5371,6 +5495,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5388,6 +5515,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5432,9 +5562,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.11.tgz", - "integrity": "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "license": "MIT", "engines": { "node": ">=14.6" @@ -5850,6 +5980,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -5863,6 +5996,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -5876,6 +6012,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -5889,6 +6028,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -5902,6 +6044,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -5915,6 +6060,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -5993,6 +6141,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -6006,6 +6157,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -6019,6 +6173,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -6032,6 +6189,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -6045,6 +6205,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -6058,6 +6221,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -6427,9 +6593,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", - "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6514,9 +6680,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -6534,11 +6700,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -6657,9 +6823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6881,17 +7047,17 @@ } }, "node_modules/cm6-graphql": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/cm6-graphql/-/cm6-graphql-0.2.1.tgz", - "integrity": "sha512-FIAFHn6qyiXChTz3Pml0NgTM8LyyXs8QfP2iPG7MLA8Xi83WuVlkGG5PDs+DDeEVabHkLIZmcyNngQlxLXKk6A==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cm6-graphql/-/cm6-graphql-0.2.2.tgz", + "integrity": "sha512-z9DQonlhuCQZotqSxKUMdZpZKZO8xaxx9BQNf2EBr0auunWaS9FJmUoeiBECOn24aZq2UZqcPIa9OwYZolk8Tg==", "license": "MIT", "dependencies": { - "graphql-language-service": "^5.3.0" + "graphql-language-service": "^5.7.0" }, "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", + "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0", @@ -7087,6 +7253,62 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cosmiconfig/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cp-file": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-10.0.0.tgz", @@ -7532,9 +7754,9 @@ } }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7685,9 +7907,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.407", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", - "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", + "version": "1.5.426", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.426.tgz", + "integrity": "sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==", "dev": true, "license": "ISC" }, @@ -7873,9 +8095,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -8130,9 +8352,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", - "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -8180,9 +8402,9 @@ "license": "MIT" }, "node_modules/fast-copy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", - "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.1.0.tgz", + "integrity": "sha512-IuCOCDFM1F/s4LtG7Bjr3yUUwPHybyhqN74xk2cMYoP51vVeErMy8+6HGFchi/+45lW8uzhs6lBeEqks2GWt6A==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -8214,9 +8436,9 @@ "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -8236,9 +8458,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -8759,9 +8981,9 @@ } }, "node_modules/graphql-language-service": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-5.5.2.tgz", - "integrity": "sha512-NJhgEKTArkyNPcy4NRUFdbpNs5/F99LcvXbNtmGzNGwwruN8tBE3YPMjpYmp8KpBQtOx3uSuvXJlOOE3Vy2KRQ==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-5.7.0.tgz", + "integrity": "sha512-5mwDb9HyHxMcO9RCLLieg6rC1u9XKJKWhFuvaZFJi3broQZDz1fcFNlm96UCMrIgI6MSsb9RwwqKKp2sZOZ9Gw==", "license": "MIT", "dependencies": { "debounce-promise": "^3.1.2", @@ -8994,9 +9216,9 @@ } }, "node_modules/hono": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", - "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -9149,9 +9371,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -9243,9 +9465,9 @@ } }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "license": "MIT", "engines": { "node": ">= 12" @@ -9830,9 +10052,9 @@ "license": "MIT" }, "node_modules/isbot": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", - "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", "license": "Unlicense", "engines": { "node": ">=18" @@ -9855,18 +10077,18 @@ } }, "node_modules/jose": { - "version": "6.2.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", - "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/jotai": { - "version": "2.20.2", - "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.20.2.tgz", - "integrity": "sha512-aHB4CNb9qRcyf0mwSB6EO5bCGAjx8cTwFgOFCE2leOnTzqACbnSWG8XoWB3LxCT1Qoj03I1OWAHszDmN4uHb/w==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.20.3.tgz", + "integrity": "sha512-N8L9FUbeGDP+0qNJw1FebOxt+5hk+jTOwUA2LlRE4C081jUcY6F1T/FSETp4DT2/INMtiSRZyNm2D+yZdzrSgA==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -9929,9 +10151,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -10008,18 +10230,6 @@ "valid-url": "^1.0.9" } }, - "node_modules/json-schema-library/node_modules/smtp-address-parser": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", - "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", - "license": "MIT", - "dependencies": { - "nearley": "^2.20.1" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -10290,6 +10500,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10311,6 +10524,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10332,6 +10548,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10353,6 +10572,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11848,12 +12070,32 @@ } }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/nested-error-stacks": { @@ -11902,9 +12144,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -12144,9 +12386,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -12381,9 +12623,9 @@ } }, "node_modules/p-map": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", - "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", "dev": true, "license": "MIT", "engines": { @@ -12414,9 +12656,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/papaparse": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.6.0.tgz", - "integrity": "sha512-N2vuNQAYGK1/4vs6HJX86+VYU6OkiSTgdJz3JQfTk1y51cFCO/U8gnaeTF4iNE4r57Tt0sV47dUua1/19pxO6Q==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", "license": "MIT" }, "node_modules/parent-module": { @@ -12594,9 +12836,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -12658,9 +12900,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -12678,7 +12920,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -12763,9 +13005,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -12847,18 +13089,18 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-colorful": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", - "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz", + "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0", @@ -12866,15 +13108,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-is": { @@ -12912,9 +13154,9 @@ } }, "node_modules/react-pdf": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", - "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.5.0.tgz", + "integrity": "sha512-zGKb6Ph5AYn2OfO4TS8Gg0Go3eW5K/TKy0+0FTm8EwFNbpgbuaWBZoJdYkJt+cRBCkizE9dYvqtMtHRj522Q4w==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -13268,9 +13510,9 @@ } }, "node_modules/rollup": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", - "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "dev": true, "license": "MIT", "dependencies": { @@ -13285,31 +13527,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.62.4", - "@rollup/rollup-android-arm64": "4.62.4", - "@rollup/rollup-darwin-arm64": "4.62.4", - "@rollup/rollup-darwin-x64": "4.62.4", - "@rollup/rollup-freebsd-arm64": "4.62.4", - "@rollup/rollup-freebsd-x64": "4.62.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", - "@rollup/rollup-linux-arm-musleabihf": "4.62.4", - "@rollup/rollup-linux-arm64-gnu": "4.62.4", - "@rollup/rollup-linux-arm64-musl": "4.62.4", - "@rollup/rollup-linux-loong64-gnu": "4.62.4", - "@rollup/rollup-linux-loong64-musl": "4.62.4", - "@rollup/rollup-linux-ppc64-gnu": "4.62.4", - "@rollup/rollup-linux-ppc64-musl": "4.62.4", - "@rollup/rollup-linux-riscv64-gnu": "4.62.4", - "@rollup/rollup-linux-riscv64-musl": "4.62.4", - "@rollup/rollup-linux-s390x-gnu": "4.62.4", - "@rollup/rollup-linux-x64-gnu": "4.62.4", - "@rollup/rollup-linux-x64-musl": "4.62.4", - "@rollup/rollup-openbsd-x64": "4.62.4", - "@rollup/rollup-openharmony-arm64": "4.62.4", - "@rollup/rollup-win32-arm64-msvc": "4.62.4", - "@rollup/rollup-win32-ia32-msvc": "4.62.4", - "@rollup/rollup-win32-x64-gnu": "4.62.4", - "@rollup/rollup-win32-x64-msvc": "4.62.4", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" } }, @@ -13453,9 +13695,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/screenfull": { @@ -13532,18 +13774,18 @@ } }, "node_modules/seroval": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.2.tgz", - "integrity": "sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.7.tgz", + "integrity": "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.2.tgz", - "integrity": "sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.7.tgz", + "integrity": "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==", "license": "MIT", "engines": { "node": ">=10" @@ -13796,6 +14038,18 @@ "node": ">=8.0.0" } }, + "node_modules/smtp-address-parser": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", + "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", + "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -14175,11 +14429,14 @@ } }, "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/tabbable": { "version": "6.5.0", @@ -14276,9 +14533,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -14409,9 +14666,9 @@ "license": "0BSD" }, "node_modules/type-fest": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", - "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -14591,9 +14848,9 @@ } }, "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici-types": { @@ -14755,9 +15012,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -14786,9 +15043,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -15111,6 +15368,96 @@ } } }, + "node_modules/vite-plus/node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vite/node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -15254,6 +15601,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15275,6 +15625,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15296,6 +15649,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15317,6 +15673,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15373,19 +15732,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15413,12 +15772,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -15462,6 +15821,92 @@ } } }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vkbeautify": { "version": "0.99.3", "resolved": "https://registry.npmjs.org/vkbeautify/-/vkbeautify-0.99.3.tgz", @@ -15469,9 +15914,9 @@ "license": "MIT" }, "node_modules/vscode-languageserver-types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", "license": "MIT" }, "node_modules/w3c-keyname": { @@ -15883,27 +16328,13 @@ "name": "@yaak/faker", "version": "1.1.1", "dependencies": { - "@faker-js/faker": "^10.1.0" + "@faker-js/faker": "^10.5.0" }, "devDependencies": { "@types/node": "^25.0.3", "typescript": "^5.9.3" } }, - "plugins-external/faker/node_modules/@faker-js/faker": { - "version": "10.3.0", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" - } - }, "plugins-external/httpsnippet": { "name": "@yaak/httpsnippet", "version": "1.0.3", @@ -15935,7 +16366,7 @@ "@hono/mcp": "^0.2.3", "@hono/node-server": "^2.0.10", "@modelcontextprotocol/sdk": "^1.26.0", - "hono": "^4.12.34", + "hono": "^4.13.5", "zod": "^3.25.76" }, "devDependencies": { @@ -16025,7 +16456,7 @@ "name": "@yaak/filter-xpath", "version": "0.1.0", "dependencies": { - "@xmldom/xmldom": "^0.9.10", + "@xmldom/xmldom": "^0.9.12", "xpath": "^0.0.34" } }, @@ -16158,7 +16589,7 @@ "name": "@yaak/template-function-xml", "version": "0.1.0", "dependencies": { - "@xmldom/xmldom": "^0.9.10", + "@xmldom/xmldom": "^0.9.12", "xpath": "^0.0.34" } }, diff --git a/package.json b/package.json index 3f7c267f4..dc7ca1a61 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,9 @@ "yauzl": "^3.4.0" }, "overrides": { + "@vitest/mocker": "^4.1.11", "js-yaml": "^4.3.1", + "underscore": "^1.13.8", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9" }, "packageManager": "npm@11.11.1" diff --git a/plugins-external/faker/package.json b/plugins-external/faker/package.json index 87630eedf..57cf6f23d 100755 --- a/plugins-external/faker/package.json +++ b/plugins-external/faker/package.json @@ -15,7 +15,7 @@ "test": "vp test --run tests" }, "dependencies": { - "@faker-js/faker": "^10.1.0" + "@faker-js/faker": "^10.5.0" }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/plugins-external/faker/tests/__snapshots__/init.test.ts.snap b/plugins-external/faker/tests/__snapshots__/init.test.ts.snap index adc26a67a..45030bada 100644 --- a/plugins-external/faker/tests/__snapshots__/init.test.ts.snap +++ b/plugins-external/faker/tests/__snapshots__/init.test.ts.snap @@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] = "faker.location.longitude", "faker.location.nearbyGPSCoordinate", "faker.location.ordinalDirection", + "faker.location.postalAddress", "faker.location.secondaryAddress", "faker.location.state", "faker.location.street", diff --git a/plugins-external/mcp-server/package.json b/plugins-external/mcp-server/package.json index beef33693..06afd8f5d 100644 --- a/plugins-external/mcp-server/package.json +++ b/plugins-external/mcp-server/package.json @@ -17,7 +17,7 @@ "@hono/mcp": "^0.2.3", "@hono/node-server": "^2.0.10", "@modelcontextprotocol/sdk": "^1.26.0", - "hono": "^4.12.34", + "hono": "^4.13.5", "zod": "^3.25.76" }, "devDependencies": { diff --git a/plugins/filter-xpath/package.json b/plugins/filter-xpath/package.json index 94fa642cc..f468b1ad4 100644 --- a/plugins/filter-xpath/package.json +++ b/plugins/filter-xpath/package.json @@ -9,7 +9,7 @@ "dev": "yaakcli dev" }, "dependencies": { - "@xmldom/xmldom": "^0.9.10", + "@xmldom/xmldom": "^0.9.12", "xpath": "^0.0.34" } } diff --git a/plugins/template-function-xml/package.json b/plugins/template-function-xml/package.json index 4d564f565..c54a09279 100644 --- a/plugins/template-function-xml/package.json +++ b/plugins/template-function-xml/package.json @@ -11,7 +11,7 @@ "dev": "yaakcli dev" }, "dependencies": { - "@xmldom/xmldom": "^0.9.10", + "@xmldom/xmldom": "^0.9.12", "xpath": "^0.0.34" } } From 7f8b7bf567ab2dba39b93bbdb470a0fa9a092a0b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 10 Sep 2026 15:26:43 -0700 Subject: [PATCH 004/155] feat(import): remember the user's import selection (#619) Co-authored-by: Claude Opus 5 --- Cargo.lock | 1 + .../components/ImportDataDialog.tsx | 299 +++-- .../components/core/CheckboxTree.tsx | 6 +- .../components/core/SegmentedControl.tsx | 12 +- .../yaak-cli/src/commands/import_export.rs | 4 + .../yaak-cli/tests/import_export_commands.rs | 58 + .../yaak-rpc-schema/bindings/gen_models.ts | 11 - .../yaak-rpc-schema/bindings/gen_util.ts | 105 +- crates/yaak-grpc/bindings/gen_grpc.ts | 5 + crates/yaak-models/bindings/gen_models.ts | 10 +- crates/yaak-models/bindings/gen_util.ts | 105 +- ...60902000000_import-source-content-hash.sql | 24 + crates/yaak-models/src/models.rs | 10 +- .../src/queries/import_source_resources.rs | 8 +- crates/yaak-models/src/util.rs | 45 + crates/yaak-plugins/bindings/gen_models.ts | 13 + crates/yaak/Cargo.toml | 1 + crates/yaak/src/import.rs | 1075 +++++++++++++++-- 18 files changed, 1494 insertions(+), 298 deletions(-) create mode 100644 crates/yaak-grpc/bindings/gen_grpc.ts create mode 100644 crates/yaak-models/migrations/20260902000000_import-source-content-hash.sql diff --git a/Cargo.lock b/Cargo.lock index 4adf00f72..c81eef004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11228,6 +11228,7 @@ dependencies = [ "md5 0.8.0", "rusqlite", "serde_json", + "sha2", "tempfile", "thiserror 2.0.17", "tokio", diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 951613017..d706b0315 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -6,7 +6,7 @@ import { type ImportSource, type Workspace, } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui"; import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; import { formatDistanceToNowStrict } from "date-fns"; @@ -260,14 +260,17 @@ function LoadedImportDataDialog({ const itemTree = useMemo(() => buildItemTree(items), [items]); - // A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and - // updates toggle together, while removals only ever cascade beneath a removed folder. - const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { - const targets = new Set( - collectItems(node) - .filter((i) => togglesWith(node.data, i)) - .map((i) => i.modelId), - ); + // A folder row's checkbox carries everything beneath it, deletions included — the row labels + // say which of those are destructive. Checking anything also brings back the folders it needs + // to live in. + const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { + const targets = new Set(togglableItems(node).map((i) => i.modelId)); + if (checked && node.data.kind === "item") { + const byId = new Map(items.map((i) => [i.modelId, i])); + for (const ancestor of ancestorsOf(node.data.item, byId)) { + if (isMissingFolder(ancestor)) targets.add(ancestor.modelId); + } + } setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i))); }; @@ -277,25 +280,16 @@ function LoadedImportDataDialog({ ); }; - // A row the user can't meaningfully toggle on its own: a planned resource inside a deselected - // new folder can't exist, and a removed folder takes its contents with it. + // Deleting a folder takes its contents with it, so those rows have nothing left to decide. const disabledIds = useMemo(() => { const disabled = new Set(); const byId = new Map(items.map((i) => [i.modelId, i])); for (const item of items) { - const seen = new Set(); - let parentId = item.parentId; - while (parentId != null && !seen.has(parentId)) { - seen.add(parentId); - const parent = byId.get(parentId); - if (parent == null || parent.model !== "folder") break; - if (parent.action === "create" && !parent.selected && item.action !== "delete") { + if (item.action !== "delete") continue; + for (const parent of ancestorsOf(item, byId)) { + if (parent.action === "delete" && parent.selected) { disabled.add(item.modelId); } - if (parent.action === "delete" && parent.selected && item.action === "delete") { - disabled.add(item.modelId); - } - parentId = parent.parentId; } } return disabled; @@ -314,18 +308,26 @@ function LoadedImportDataDialog({ return item.selected; }).length; - const destinationLabel = (() => { - if (plan.destination.type === "new_workspace") return "New workspace"; + // The row's label carries what kind of destination it is, so the value can just be its name + const [destinationLabel, destinationValue] = ((): [string, string] => { + if (plan.destination.type === "new_workspace") { + const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== ""); + if (names.length === 0) return ["New workspace", "Untitled"]; + return [pluralize("New workspace", names.length), names.join(", ")]; + } const { workspaceId, folderId } = plan.destination; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; - return folderId != null && folderId === selectedFolder?.id - ? `${name} / ${selectedFolder.name}` - : name; + return [ + "Destination", + folderId != null && folderId === selectedFolder?.id + ? `${name} / ${selectedFolder.name}` + : name, + ]; })(); // The destination workspace roots the tree. It is not a plan item — commit always applies // it — so its checkbox only aggregates the subtree. - const workspaceRoot: CheckboxTreeNode = (() => { + const workspaceRoot: CheckboxTreeNode = (() => { const planned = plan.resources.workspaces[0]; const planDestination = plan.destination; const existing = @@ -335,11 +337,9 @@ function LoadedImportDataDialog({ return { key: existing?.id ?? planned?.id ?? "workspace", data: { - action: plan.destination.type === "new_workspace" ? "create" : "unchanged", - model: "workspace", - modelId: existing?.id ?? planned?.id ?? "workspace", - name: existing?.name ?? planned?.name ?? "New workspace", - selected: true, + kind: "destination", + label: existing?.name ?? planned?.name ?? "New workspace", + isNew: planDestination.type === "new_workspace", }, children: itemTree, }; @@ -349,40 +349,42 @@ function LoadedImportDataDialog({

- +
+ {plan.warnings.map((warning) => ( + + +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} +
disabledIds.has(n.key)} - isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"} - renderRow={(n) => } + isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"} + isRelevant={(n) => + n.data.kind === "destination" || + (n.data.kind === "item" && n.data.item.action !== "unchanged") + } + renderRow={(n) => } />
- {plan.warnings.length > 0 && ( -
-
Import details
-
- {plan.warnings.map((warning) => ( -
- -
-
{warning.title}
-
{warning.detail}
-
-
- ))} -
-
- )} - {footerNote !== "" &&
{footerNote}
}
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({ } function ImportTreeRow({ - item, + row, onResolveConflict, }: { - item: ImportPlanItem; + row: TreeRow; onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void; }) { + if (row.kind !== "item") { + return ( + <> + +
{row.label}
+ {row.kind === "destination" && row.isNew && ( + + )} + + ); + } + + const { item } = row; + const label = actionLabel(item); return ( <> - {item.model === "workspace" || item.model === "folder" || item.model === "environment" ? ( - + {item.model === "folder" || item.model === "environment" ? ( + ) : ( )}
{item.name}
{item.action === "conflict" ? ( -
+
onResolveConflict(item.modelId, v)} options={[ @@ -577,29 +590,49 @@ function ImportTreeRow({ { value: "take_source", label: "Take source" }, ]} /> -
) : ( - actionLabel(item) && ( - - {actionLabel(item)} - - + /> ) )} ); } +function ActionChip({ + label, + help, + className, +}: { + label: string; + help: string | null; + className?: string; +}) { + return ( + + {label} + {help != null && } + + ); +} + function actionLabel(item: ImportPlanItem): string | null { switch (item.action) { case "create": @@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null { return "removed"; case "keep_local": return "edited"; + case "ignored": + return "ignored"; default: return null; } } function actionHelp(item: ImportPlanItem): string | null { + const help = (text: string) => + item.changedFields.length > 0 + ? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}` + : text; switch (item.action) { case "create": - return "Added since the last import"; + return "Not in this workspace yet"; case "update": - return "Changed since the last import"; + return item.reason === "moved_into_ignored_folder" + ? "Moved into a folder that isn't imported. Importing that folder brings it along" + : help("Changed in the source since the last import"); case "delete": - return "Deleted since the last import"; + return "Gone from the source since the last import. Checking it deletes it here"; case "keep_local": - return "Local edits made since the last import. Importing will revert them if checked"; + return help("Changed here since the last import. Checking it reverts to the source"); case "conflict": - return "Changed both here and in the file since the last import"; + return help("Changed here and in the source since the last import"); + case "ignored": + return "Not in this workspace. Imports leave it alone until you check it"; default: return null; } } -function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { +function fieldLabel(field: string): string { + return field.replace(/([A-Z])/g, " $1").toLowerCase(); +} + +/** Every plan item above `item`, nearest first. */ +function ancestorsOf(item: ImportPlanItem, byId: Map): ImportPlanItem[] { + const ancestors: ImportPlanItem[] = []; + const seen = new Set(); + let parentId = item.parentId; + while (parentId != null && !seen.has(parentId)) { + seen.add(parentId); + const parent = byId.get(parentId); + if (parent == null) break; + ancestors.push(parent); + parentId = parent.parentId; + } + return ancestors; +} + +/** + * A row of the preview tree. Most are plan items, but the destination workspace and the group the + * workspace's environments sit in are headings: they aggregate their children and decide nothing + * themselves. + */ +type TreeRow = + | { kind: "destination"; label: string; isNew: boolean } + | { kind: "group"; label: string; icon: IconProps["icon"] } + | { kind: "item"; item: ImportPlanItem }; + +function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { const byId = new Map(items.map((i) => [i.modelId, i])); const childrenOf = new Map(); const roots: ImportPlanItem[] = []; @@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode [ + const byKind = (list: ImportPlanItem[]) => [ + ...list.filter((i) => i.model === "environment"), ...list.filter((i) => i.model === "folder"), - ...list.filter((i) => i.model !== "folder"), + ...list.filter((i) => i.model !== "environment" && i.model !== "folder"), ]; - const toNode = (item: ImportPlanItem, seen: Set): CheckboxTreeNode => ({ + const toNode = (item: ImportPlanItem, seen: Set): CheckboxTreeNode => ({ key: item.modelId, - data: item, + data: { kind: "item", item }, children: seen.has(item.modelId) ? [] - : foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) => + : byKind(childrenOf.get(item.modelId) ?? []).map((c) => toNode(c, new Set([...seen, item.modelId])), ), }); - return foldersFirst(roots).map((r) => toNode(r, new Set())); + // The workspace's environments have nothing to sit under — a sub-environment is a sibling of + // the base one, not its child — so a heading groups them into one thing to turn on and off. + const environments = roots.filter((i) => i.model === "environment"); + const others = byKind(roots.filter((i) => i.model !== "environment")); + const nodes = others.map((r) => toNode(r, new Set())); + if (environments.length === 0) return nodes; + return [ + { + key: "group:environments", + data: { kind: "group", label: "Variables", icon: "variable" }, + children: environments.map((e) => toNode(e, new Set())), + }, + ...nodes, + ]; } -function collectItems(node: CheckboxTreeNode): ImportPlanItem[] { - return [node.data, ...node.children.flatMap(collectItems)]; +function collectRows(node: CheckboxTreeNode): TreeRow[] { + return [node.data, ...node.children.flatMap(collectRows)]; +} + +/** A folder that isn't there yet, so anything inside it needs it brought in first. */ +function isMissingFolder(item: ImportPlanItem): boolean { + return item.model === "folder" && (item.action === "create" || item.action === "ignored"); } /** - * Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions - * (deletions, reverting local edits) never ride along with a parent toggle. + * The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to + * decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides + * along with a parent's. */ -function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean { - if (item.model === "workspace") return false; - if (root.action === "delete") return item.action === "delete"; - if (item.action === "keep_local") { - return root.modelId === item.modelId && item.model !== "folder"; - } - return item.action === "create" || item.action === "update"; +function togglableItem(row: TreeRow): ImportPlanItem | null { + if (row.kind !== "item") return null; + const { item } = row; + return item.action === "unchanged" || item.action === "conflict" ? null : item; } -function nodeCheckedStatus( - node: CheckboxTreeNode, -): boolean | "indeterminate" | "hidden" { - const covered = collectItems(node).filter((i) => togglesWith(node.data, i)); +function togglableItems(node: CheckboxTreeNode): ImportPlanItem[] { + return collectRows(node) + .map(togglableItem) + .filter((i) => i != null); +} + +function nodeCheckedStatus(node: CheckboxTreeNode): boolean | "indeterminate" | "hidden" { + const covered = togglableItems(node); if (covered.length === 0) return "hidden"; const selected = covered.filter((i) => i.selected).length; if (selected === covered.length) return true; diff --git a/apps/yaak-client/components/core/CheckboxTree.tsx b/apps/yaak-client/components/core/CheckboxTree.tsx index 0f714e353..d6d4423da 100644 --- a/apps/yaak-client/components/core/CheckboxTree.tsx +++ b/apps/yaak-client/components/core/CheckboxTree.tsx @@ -21,6 +21,8 @@ interface Props { isCheckboxDisabled?: (node: CheckboxTreeNode) => boolean; /** An irrelevant row is hidden unless one of its descendants is relevant */ isRelevant: (node: CheckboxTreeNode) => boolean; + /** A node that starts collapsed, so a large subtree doesn't crowd out the rest */ + isCollapsedByDefault?: (node: CheckboxTreeNode) => boolean; renderRow: (node: CheckboxTreeNode) => ReactNode; onSelectRow?: (node: CheckboxTreeNode) => void; canSelectRow?: (node: CheckboxTreeNode) => boolean; @@ -29,7 +31,9 @@ interface Props { export function CheckboxTree(props: Props) { const { node, depth = 0 } = props; - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState( + () => props.isCollapsedByDefault?.(node) ?? false, + ); if (!hasRelevantNode(node, props.isRelevant)) return null; const checked = props.checked(node); diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx index 4b6022f52..aa9aca85e 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/apps/yaak-client/components/core/SegmentedControl.tsx @@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { generateId } from "../../lib/generateId"; import { Button } from "./Button"; import { IconButton, type IconButtonProps } from "./IconButton"; +import { IconTooltip } from "./IconTooltip"; import { Label } from "./Label"; interface Props { @@ -36,11 +37,15 @@ export function SegmentedControl({ const containerRef = useRef(null); const id = useRef(`input-${generateId()}`); + // A visually hidden label has nowhere to show the help, so the last option carries it + const inlineHelp = + hideLabel && help ? : null; + return (
- Supports OpenAPI, Swagger, Postman, Insomnia, and curl + Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
@@ -460,7 +460,7 @@ function LoadedImportDataDialog({ onChange={setSource} /> - + - - + {destinationKind === "other" && ( setOtherWorkspaceId(id === "" ? null : id)} - filterable - options={[ - { value: "", label: "Select a workspace" }, - ...workspaces - .filter((w) => w.id !== currentWorkspace?.id) - .map((w) => ({ value: w.id, label: w.name })), - ]} - /> + )} + + ); + + return ( + <> + + + + + + {sourceError != null && ( + + {errorMessage(sourceError)} + )} - {lastImported != null && destinationWorkspaceId != null ? ( + + {linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
- Last imported from {lastImported.originLabel} ·{" "} - {formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })} -
- ) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? ( -
- This file was last imported into{" "} - +
- ) : null} - {canTargetSelectedFolder && ( - )}
- - - - - -
+ + ); } @@ -556,10 +526,12 @@ function ImportTreeRow({ if (row.kind !== "item") { return ( <> - + {row.kind === "destination" && ( + + )}
{row.label}
{row.kind === "destination" && row.isNew && ( - + )} ); @@ -569,11 +541,6 @@ function ImportTreeRow({ const label = actionLabel(item); return ( <> - {item.model === "folder" || item.model === "environment" ? ( - - ) : ( - - )}
{item.name}
{item.action === "conflict" ? (
@@ -593,17 +560,7 @@ function ImportTreeRow({
) : ( label != null && ( - + ) )} @@ -613,26 +570,37 @@ function ImportTreeRow({ function ActionChip({ label, help, - className, + color, }: { label: string; help: string | null; - className?: string; + color: ComponentProps["color"]; }) { return ( - + {label} {help != null && } - + ); } +function actionColor(item: ImportPlanItem): ComponentProps["color"] { + switch (item.action) { + case "create": + return "success"; + case "update": + return "info"; + case "delete": + return "danger"; + case "keep_local": + return item.selected ? "warning" : "default"; + case "unchanged": + case "conflict": + case "ignored": + return "default"; + } +} + function actionLabel(item: ImportPlanItem): string | null { switch (item.action) { case "create": @@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map): I */ type TreeRow = | { kind: "destination"; label: string; isNew: boolean } - | { kind: "group"; label: string; icon: IconProps["icon"] } + | { kind: "group"; label: string } | { kind: "item"; item: ImportPlanItem }; function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { @@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { return [ { key: "group:environments", - data: { kind: "group", label: "Variables", icon: "variable" }, + data: { kind: "group", label: "Variables" }, children: environments.map((e) => toNode(e, new Set())), }, ...nodes, diff --git a/apps/yaak-client/components/ImportSourceList.tsx b/apps/yaak-client/components/ImportSourceList.tsx new file mode 100644 index 000000000..9e8b7f02d --- /dev/null +++ b/apps/yaak-client/components/ImportSourceList.tsx @@ -0,0 +1,271 @@ +import { platform } from "@yaakapp-internal/platform"; +import { Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; +import { Button } from "./core/Button"; +import { IconButton } from "./core/IconButton"; +import { PathList, splitPath } from "./core/PathList"; +import { PlainInput } from "./core/PlainInput"; + +export interface ImportSourcePath { + path: string; + kind?: "file" | "folder" | "url"; +} + +export type ImportSourceDetection = + | { status: "loading" } + | { status: "ok"; importer: string } + | { status: "error"; message: string }; + +interface Props { + sources: ImportSourcePath[]; + detections: Record; + disabled?: boolean; + onAdd: (sources: ImportSourcePath[]) => void; + onRemove: (path: string) => void; + onError: (err: unknown) => void; +} + +/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */ +export function isImportFilePath(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("~/") || + value.startsWith("\\\\") || + /^[a-zA-Z]:[\\/]/.test(value) + ); +} + +/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */ +export function toSourcePath(path: string): ImportSourcePath { + return { path, kind: isImportFilePath(path) ? undefined : "url" }; +} + +export function ImportSourceList({ + sources, + detections, + disabled, + onAdd, + onRemove, + onError, +}: Props) { + const ref = useRef(null); + const [isHovering, setIsHovering] = useState(false); + const [pathInput, setPathInput] = useState(""); + const [showPathInput, setShowPathInput] = useState(false); + const trimmedPath = pathInput.trim(); + + const closePathInput = () => { + setPathInput(""); + setShowPathInput(false); + }; + + const addPath = () => { + if (!trimmedPath || disabled) return; + onAdd([toSourcePath(trimmedPath)]); + closePathInput(); + }; + + useEffect(() => { + if (disabled) return; + return platform.window.onDragDrop((event) => { + if (event.type === "leave") { + setIsHovering(false); + return; + } + const rect = ref.current?.getBoundingClientRect(); + const { x, y } = event.position; + const isOver = + rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + if (event.type === "drop") { + if (isOver) onAdd(event.paths.map((path) => ({ path }))); + setIsHovering(false); + } else { + setIsHovering(isOver); + } + }); + }, [disabled, onAdd]); + + const pick = async (directory: boolean) => { + try { + const selected = await platform.dialog.open({ + title: directory ? "Select Import Folder" : "Select Import Files", + multiple: true, + directory, + }); + if (selected == null) return; + onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" }))); + } catch (err) { + onError(err); + } + }; + + const handlePathKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + addPath(); + } else if (event.key === "Escape") { + event.stopPropagation(); + closePathInput(); + } + }; + + const pathInputEl = ( + + {trimmedPath !== "" && ( + + )} + +
+ } + /> + ); + + if (sources.length === 0) { + return ( +
+
+ + {isHovering ? ( + Drop to add + ) : ( + + Choose{" "} + pick(false)}> + files + + , a{" "} + pick(true)}> + folder + + , or a{" "} + setShowPathInput(true)} + > + URL + + , or drag them here + + )} + + Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports + +
+ {showPathInput && pathInputEl} +
+ ); + } + + return ( + pick(false) }, + { label: "Folder", icon: "folder", onSelect: () => pick(true) }, + { + label: "URL or file path", + icon: "globe", + disabled: showPathInput, + onSelect: () => setShowPathInput(true), + }, + ]} + items={sources.map((source) => { + const detection = detections[source.path]; + return { + path: source.path, + icon: sourceIcon(source), + failed: detection?.status === "error", + subtitle: , + }; + })} + > + {showPathInput && pathInputEl} + + ); +} + +function DetectionSubtitle({ + path, + detection, +}: { + path: string; + detection: ImportSourceDetection | undefined; +}) { + const { parent } = splitPath(path); + if (detection?.status === "loading") { + return ( + <> + + Detecting format + + ); + } + if (detection?.status === "error") return detection.message; + return ( + <> + {detection?.status === "ok" && {detection.importer}} + {detection?.status === "ok" && parent !== "" && ·} + {parent !== "" && {parent}} + + ); +} + +function sourceIcon({ kind }: ImportSourcePath) { + switch (kind) { + case "url": + return "globe"; + case "folder": + return "folder"; + case "file": + return "file"; + case undefined: + return "import"; + } +} + +export function InlineButton({ + children, + disabled, + onClick, +}: { + children: ReactNode; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/yaak-client/components/Onboarding.tsx b/apps/yaak-client/components/Onboarding.tsx index c5574dc31..63e753af6 100644 --- a/apps/yaak-client/components/Onboarding.tsx +++ b/apps/yaak-client/components/Onboarding.tsx @@ -70,7 +70,7 @@ export function Onboarding() { color="primary" icon="folder_input" title="Migrate from another tool" - description="Postman, Insomnia, OpenAPI, or curl" + description="Postman, Insomnia, Bruno, OpenAPI, or curl" busy={busy === "import"} disabled={busy != null} onClick={choose("import", () => importData.mutateAsync())} diff --git a/apps/yaak-client/components/SwitchWorkspaceDialog.tsx b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx index 2852b8e15..45e9f4946 100644 --- a/apps/yaak-client/components/SwitchWorkspaceDialog.tsx +++ b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx @@ -1,11 +1,11 @@ import type { Workspace } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useState } from "react"; import { switchWorkspace } from "../commands/switchWorkspace"; -import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; +import { DialogFooter } from "./core/Dialog"; interface Props { hide: () => void; @@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) { const settings = useAtomValue(settingsAtom); const [remember, setRemember] = useState(false); + const open = async (inNewWindow: boolean) => { + hide(); + switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow }); + if (remember) { + await patchModel(settings, { openWorkspaceNewWindow: inNewWindow }); + } + }; + return (

Where would you like to open {workspace.name}?

- - - - {settings && ( - - - + )} + , + onClick: () => open(true), + }, + { label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) }, + ]} + />
); } diff --git a/apps/yaak-client/components/TemplateFunctionDialog.tsx b/apps/yaak-client/components/TemplateFunctionDialog.tsx index 92e217720..bb65886fd 100644 --- a/apps/yaak-client/components/TemplateFunctionDialog.tsx +++ b/apps/yaak-client/components/TemplateFunctionDialog.tsx @@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates"; import { parseTemplate } from "@yaakapp-internal/templates"; import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig"; @@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog"; import { convertTemplateToInsecure } from "../lib/encryption"; import { jotaiStore } from "../lib/jotai"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; -import { Button } from "./core/Button"; +import { DialogFooter } from "./core/Dialog"; import { collectArgumentValues } from "./core/Editor/twig/util"; import { IconButton } from "./core/IconButton"; import { PlainInput } from "./core/PlainInput"; @@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({ const previewType = ogPreviewType == null ? "live" : ogPreviewType; const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false); const [argValues, setArgValues] = useState>(initialArgValues); + const formId = useId(); const tokens: Tokens = useMemo(() => { const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({ @@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({ return (
{ e.preventDefault(); handleDone(); }} > -
+
{name === "secure" ? ( )}
-
- {previewType !== "none" ? ( -
- - - Rendered Preview - {rendered.isLoading && } - + {previewType !== "none" ? ( +
+ + + Rendered Preview + {rendered.isLoading && } + + + +
+ + {rendered.error || tagText.error ? ( + + {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")} + + ) : dataContainsSecrets && !showSecretsInPreview ? ( + + ------ sensitive values hidden ------ + + ) : tooLarge ? ( + "too large to preview" + ) : ( + rendered.data || <>  + )} + +
{ + setRenderKey(new Date().toISOString()); + }} /> - -
- - {rendered.error || tagText.error ? ( - - {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")} - - ) : dataContainsSecrets && !showSecretsInPreview ? ( - - ------ sensitive values hidden ------ - - ) : tooLarge ? ( - "too large to preview" - ) : ( - rendered.data || <>  - )} - -
- { - setRenderKey(new Date().toISOString()); - }} - /> -
- ) : ( - - )} -
- {templateFunction.data.name === "secure" && ( - - )} -
-
+ ) : ( + + )} + ); } @@ -272,7 +270,6 @@ TemplateFunctionDialog.show = ( id: `template-function-${Math.random()}`, // Allow multiple at once size: "md", className: "h-240", - noPadding: true, title: {fn.name}(…), description: fn.description, render: ({ hide }) => { diff --git a/apps/yaak-client/components/core/Chip.tsx b/apps/yaak-client/components/core/Chip.tsx new file mode 100644 index 000000000..2b347e50d --- /dev/null +++ b/apps/yaak-client/components/core/Chip.tsx @@ -0,0 +1,32 @@ +import type { Color } from "@yaakapp-internal/plugins"; +import classNames from "classnames"; +import type { ReactNode } from "react"; + +interface Props { + color?: Color | "default"; + className?: string; + children: ReactNode; +} + +/** A small tinted label, like a Gmail or Trello label */ +export function Chip({ color = "default", className, children }: Props) { + return ( + + {children} + + ); +} diff --git a/apps/yaak-client/components/core/Confirm.tsx b/apps/yaak-client/components/core/Confirm.tsx index dac8dc9b3..a1bd12b39 100644 --- a/apps/yaak-client/components/core/Confirm.tsx +++ b/apps/yaak-client/components/core/Confirm.tsx @@ -1,9 +1,8 @@ import type { Color } from "@yaakapp-internal/plugins"; -import { HStack } from "@yaakapp-internal/ui"; import type { FormEvent } from "react"; -import { useState } from "react"; +import { useId, useState } from "react"; import { CopyIconButton } from "../CopyIconButton"; -import { Button } from "./Button"; +import { DialogFooter } from "./Dialog"; import { PlainInput } from "./PlainInput"; export interface ConfirmProps { @@ -22,6 +21,7 @@ export function Confirm({ color = "primary", }: ConfirmProps) { const [confirm, setConfirm] = useState(""); + const formId = useId(); const handleHide = () => { onResult(false); onHide(); @@ -38,7 +38,7 @@ export function Confirm({ }; return ( -
+ {requireTyping && ( )} - - - - + ); } diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx index c7962a1e0..00f546921 100644 --- a/apps/yaak-client/components/core/Dialog.tsx +++ b/apps/yaak-client/components/core/Dialog.tsx @@ -1,9 +1,11 @@ import type { DialogSize } from "@yaakapp-internal/plugins"; -import { Heading, Overlay } from "@yaakapp-internal/ui"; +import { Heading, HStack, Overlay } from "@yaakapp-internal/ui"; import classNames from "classnames"; import * as m from "motion/react-m"; import type { ReactNode } from "react"; -import { useMemo } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button, type ButtonProps } from "./Button"; import { IconButton } from "./IconButton"; export interface DialogProps { @@ -38,6 +40,7 @@ export function Dialog({ vAlign = "center", }: DialogProps) { const titleId = useMemo(() => Math.random().toString(36).slice(2), []); + const [footerEl, setFooterEl] = useState(null); const descriptionId = useMemo( () => (description ? Math.random().toString(36).slice(2) : undefined), [description], @@ -71,7 +74,7 @@ export function Dialog({ animate={{ top: 0, scale: 1 }} className={classNames( className, - "grid grid-rows-[auto_auto_minmax(0,1fr)]", + "grid grid-rows-[auto_auto_minmax(0,1fr)_auto]", "grid-cols-1", // must be here for inline code blocks to correctly break words "relative bg-surface pointer-events-auto", "rounded-lg", @@ -108,9 +111,11 @@ export function Dialog({ !noScroll && "overflow-y-auto overflow-x-hidden", )} > - {children} + {children}
+
+ {/*Put close at the end so that it's the last thing to be tabbed to*/} {!disableClose && !hideX && (
@@ -129,3 +134,67 @@ export function Dialog({ ); } + +const DialogFooterSlot = createContext(null); + +export interface DialogFooterAction { + label: string; + onClick?: () => void; + /** Submit the form with this id, which can be anywhere in the dialog body */ + form?: string; + color?: ButtonProps["color"]; + variant?: ButtonProps["variant"]; + disabled?: boolean; + isLoading?: boolean; + autoFocus?: boolean; + leftSlot?: ReactNode; + rightSlot?: ReactNode; +} + +/** + * The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is + * placed below the scrolling body, outside its padding. `inline` drops the divider for small + * dialogs whose body never scrolls. + */ +export function DialogFooter({ + actions, + leftSlot, + inline, +}: { + actions: DialogFooterAction[]; + leftSlot?: ReactNode; + inline?: boolean; +}) { + const slot = useContext(DialogFooterSlot); + const footer = ( +
+ {leftSlot != null &&
{leftSlot}
} + + {actions.map((action, i) => ( + + ))} + +
+ ); + return slot == null ? footer : createPortal(footer, slot); +} diff --git a/apps/yaak-client/components/core/PathList.tsx b/apps/yaak-client/components/core/PathList.tsx new file mode 100644 index 000000000..64451b2b5 --- /dev/null +++ b/apps/yaak-client/components/core/PathList.tsx @@ -0,0 +1,123 @@ +import { Icon, type IconProps } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { forwardRef, type ReactNode } from "react"; +import { Dropdown, type DropdownItem } from "./Dropdown"; +import { IconButton } from "./IconButton"; + +export interface PathListItem { + path: string; + icon: IconProps["icon"]; + /** Replaces the parent-path line under the name */ + subtitle?: ReactNode; + failed?: boolean; +} + +interface Props { + items: PathListItem[]; + disabled?: boolean; + onRemove: (path: string) => void; + addItems: DropdownItem[]; + addLabel: string; + addHint?: string; + hovering?: boolean; + /** Rows rendered between the items and the add row, such as an inline input */ + children?: ReactNode; +} + +/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */ +export const PathList = forwardRef(function PathList( + { items, disabled, onRemove, addItems, addLabel, addHint, hovering, children }, + ref, +) { + return ( +
+ {items.map((item) => { + const { name, parent } = splitPath(item.path); + return ( +
+ +
+
{name}
+
+ {item.subtitle ?? + (parent !== "" && {parent})} +
+
+ onRemove(item.path)} + /> +
+ ); + })} + {children} + + + +
+ ); +}); + +/** The last segment as the name and everything before it, shortened, as the parent. */ +export function splitPath(path: string): { name: string; parent: string } { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) { + try { + const url = new URL(path); + const segments = url.pathname.split("/").filter(Boolean); + const name = segments.at(-1) ?? url.host; + const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : ""; + return { name, parent: parent.replace(/\/$/, "") }; + } catch { + return { name: path, parent: "" }; + } + } + const parts = path.split(/[/\\]/).filter(Boolean); + const name = parts.at(-1) ?? path; + const dirs = parts.slice(0, -1); + const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/"); + return { name, parent }; +} diff --git a/apps/yaak-client/components/core/Prompt.tsx b/apps/yaak-client/components/core/Prompt.tsx index 0c0d12201..e507e2f29 100644 --- a/apps/yaak-client/components/core/Prompt.tsx +++ b/apps/yaak-client/components/core/Prompt.tsx @@ -1,10 +1,9 @@ import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins"; -import { HStack } from "@yaakapp-internal/ui"; import type { FormEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { generateId } from "../../lib/generateId"; import { DynamicForm } from "../DynamicForm"; -import { Button } from "./Button"; +import { DialogFooter } from "./Dialog"; export interface PromptProps { inputs: FormInput[]; @@ -48,19 +47,15 @@ export function Prompt({ const id = `prompt.form.${useRef(generateId()).current}`; return ( -
+ - - - - + ); } diff --git a/apps/yaak-client/components/git/BranchSelectionDialog.tsx b/apps/yaak-client/components/git/BranchSelectionDialog.tsx deleted file mode 100644 index 0a3be09c9..000000000 --- a/apps/yaak-client/components/git/BranchSelectionDialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { HStack, VStack } from "@yaakapp-internal/ui"; -import { useState } from "react"; -import { Button } from "../core/Button"; -import { Select } from "../core/Select"; - -interface Props { - branches: string[]; - onCancel: () => void; - onSelect: (branch: string) => void; - selectText: string; -} - -export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) { - const [branch, setBranch] = useState("__NONE__"); - return ( - { - e.preventDefault(); - onSelect(branch); - }} - > - - -
- } - rightSlot={ -
- -
- } - /> -
, - ); - } else { - nodes.push( - , - ); - } - - return nodes; - }, [ - canFilter, - footerActions, - filter, - filterResult?.isPending, - resultError, - isSearching, - language, - handleFilterKeyDown, - ]); - - const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); - if (formattedBody == null) { - return null; - } - - let body: string; - if (appliedFilter) { - if (resultError) { - body = ""; - } else { - body = filterResult?.data != null ? filterResult.data : ""; - } - } else { - body = formattedBody; - } - - // Decode unicode sequences in the text to readable characters - if (language === "json" && pretty) { - body = decodeUnicodeLiterals(body); - body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks - } +export const textViewerExtensions = [hyperlink]; +export function TextViewer( + props: Omit, +) { return ( -
- - {showBreadcrumbs && ( -
- - filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count)) - } - /> -
- )} -
+ ); } -/** Convert \uXXXX to actual Unicode characters */ -function decodeUnicodeLiterals(text: string): string { - return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { - const charCode = Number.parseInt(hex, 16); - return String.fromCharCode(charCode); - }); +export async function formatText( + text: string, + language: CodeEditorProps["language"], +): Promise { + if (language === "json") { + return tryFormatJson(text); + } + if (language === "xml" || language === "html") { + return tryFormatXml(text); + } + return text; } diff --git a/apps/yaak-client/hooks/useAuthTab.tsx b/apps/yaak-client/hooks/useAuthTab.tsx index e094b6e52..871a4d915 100644 --- a/apps/yaak-client/hooks/useAuthTab.tsx +++ b/apps/yaak-client/hooks/useAuthTab.tsx @@ -1,10 +1,9 @@ import type { Folder } from "@yaakapp-internal/models"; import { modelTypeLabel, patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, IconTooltip } from "@yaakapp-internal/ui"; import { useMemo } from "react"; import { openFolderSettings } from "../commands/openFolderSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; -import { IconTooltip } from "../components/core/IconTooltip"; import type { RadioDropdownProps } from "../components/core/RadioDropdown"; import type { TabItem } from "../components/core/Tabs/Tabs"; import { capitalize } from "../lib/capitalize"; diff --git a/apps/yaak-client/hooks/useCreateDropdownItems.tsx b/apps/yaak-client/hooks/useCreateDropdownItems.tsx index 795432c0d..f8c0cc0e8 100644 --- a/apps/yaak-client/hooks/useCreateDropdownItems.tsx +++ b/apps/yaak-client/hooks/useCreateDropdownItems.tsx @@ -6,10 +6,10 @@ import { createFolder } from "../commands/commands"; import type { DropdownItem } from "../components/core/Dropdown"; import { Icon } from "@yaakapp-internal/ui"; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; -import { generateId } from "../lib/generateId"; import { BODY_TYPE_GRAPHQL } from "../lib/model_util"; import { activeRequestAtom } from "./useActiveRequest"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function useCreateDropdownItems({ hideFolder, diff --git a/apps/yaak-client/hooks/useDeleteSendHistory.tsx b/apps/yaak-client/hooks/useDeleteSendHistory.tsx index d64f886b3..c1d88457d 100644 --- a/apps/yaak-client/hooks/useDeleteSendHistory.tsx +++ b/apps/yaak-client/hooks/useDeleteSendHistory.tsx @@ -7,10 +7,10 @@ import { useAtomValue } from "jotai"; import { showAlert } from "../lib/alert"; import { showConfirmDelete } from "../lib/confirm"; import { jotaiStore } from "../lib/jotai"; -import { pluralizeCount } from "../lib/pluralize"; import { rpc } from "../lib/rpc"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; import { useFastMutation } from "./useFastMutation"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export function useDeleteSendHistory() { const httpResponses = useAtomValue(httpResponsesAtom); diff --git a/apps/yaak-client/hooks/useHeadersTab.tsx b/apps/yaak-client/hooks/useHeadersTab.tsx index 70849f0a1..c7f495c40 100644 --- a/apps/yaak-client/hooks/useHeadersTab.tsx +++ b/apps/yaak-client/hooks/useHeadersTab.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; -import { CountBadge } from "../components/core/CountBadge"; import type { TabItem } from "../components/core/Tabs/Tabs"; import type { HeaderModel } from "./useInheritedHeaders"; import { useInheritedHeaders } from "./useInheritedHeaders"; +import { CountBadge } from "@yaakapp-internal/ui"; export function useHeadersTab( tabValue: T, diff --git a/apps/yaak-client/hooks/useRandomKey.ts b/apps/yaak-client/hooks/useRandomKey.ts index a615088cb..9a73cf832 100644 --- a/apps/yaak-client/hooks/useRandomKey.ts +++ b/apps/yaak-client/hooks/useRandomKey.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { generateId } from "../lib/generateId"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function useRandomKey(initialValue?: string) { const [value, setValue] = useState(initialValue ?? generateId()); diff --git a/apps/yaak-client/hooks/useRequestUpdateKey.ts b/apps/yaak-client/hooks/useRequestUpdateKey.ts index dd570125b..aef282561 100644 --- a/apps/yaak-client/hooks/useRequestUpdateKey.ts +++ b/apps/yaak-client/hooks/useRequestUpdateKey.ts @@ -1,8 +1,8 @@ import { platform } from "@yaakapp-internal/platform"; import type { ModelPayload } from "@yaakapp-internal/models"; import { atom, useAtomValue } from "jotai"; -import { generateId } from "../lib/generateId"; import { jotaiStore } from "../lib/jotai"; +import { generateId } from "@yaakapp-internal/lib/generateId"; const requestUpdateKeyAtom = atom>({}); diff --git a/apps/yaak-client/hooks/useResponseFilter.ts b/apps/yaak-client/hooks/useResponseFilter.ts deleted file mode 100644 index bc6cd004c..000000000 --- a/apps/yaak-client/hooks/useResponseFilter.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { useCallback, useState } from "react"; -import { createGlobalState } from "react-use"; -import type { RecentFilter } from "./useRecentFilters"; -import { useRecentFilters } from "./useRecentFilters"; - -/** What's typed in the filter box. `null` means the filter box is closed */ -const useFilterTextMap = createGlobalState>({}); - -/** What's actually applied to the response. Only changes on an explicit apply */ -const useAppliedFilterMap = createGlobalState>({}); - -export interface ResponseFilterApi { - stateKey: string | null; - /** Draft text in the filter box, or `null` when the box is closed */ - filterText: string | null; - /** The expression currently filtering the response */ - appliedFilter: string | null; - isSearching: boolean; - /** The box holds an expression that isn't the one currently applied */ - isDirty: boolean; - /** Bumped when the (uncontrolled) filter input must re-read its defaultValue */ - filterUpdateKey: number; - setFilterText: (value: string | null) => void; - /** Apply the expression to the response, recording it if the filter accepts it */ - applyFilter: (value: string) => void; - /** Like applyFilter, but also replaces what's shown in the filter box */ - replaceFilter: (value: string) => void; - toggleSearch: () => void; - recentFilters: RecentFilter[]; - removeRecentFilter: (value: string) => void; - togglePinRecentFilter: (value: string) => void; - clearRecentFilters: () => void; -} - -/** - * Draft/applied state and history for a response filter (JSONPath/XPath). - * - * History records at most one entry per apply gesture, and only after `runFilter` - * confirms the plugin accepts the expression — the plugin is the sole judge of - * validity. Because nothing but the gesture ever writes, refetches can't resurrect - * deleted entries and a gesture can't record into another request's history. - */ -export function useResponseFilter({ - stateKey, - runFilter, -}: { - stateKey: string | null; - /** Evaluate an expression, rejecting if the filter plugin reports an error */ - runFilter: (filter: string) => Promise; -}): ResponseFilterApi { - const [filterTextMap, setFilterTextMap] = useFilterTextMap(); - const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap(); - const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; - const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null; - - const setFilterText = useCallback( - (v: string | null) => { - if (!stateKey) return; - setFilterTextMap((m) => ({ ...m, [stateKey]: v })); - }, - [stateKey, setFilterTextMap], - ); - - const setAppliedFilter = useCallback( - (v: string | null) => { - if (!stateKey) return; - setAppliedFilterMap((m) => ({ ...m, [stateKey]: v })); - }, - [stateKey, setAppliedFilterMap], - ); - - const { - recentFilters, - addFilter, - removeFilter: removeRecentFilter, - togglePin: togglePinRecentFilter, - clearFilters: clearRecentFilters, - } = useRecentFilters(stateKey); - - const applyFilter = useCallback( - (value: string) => { - setFilterText(value); - const applied = value.trim() === "" ? null : value.trim(); - setAppliedFilter(applied); - if (applied == null) return; - runFilter(applied).then( - () => addFilter(applied), - () => {}, // Rejected by the filter plugin — don't record - ); - }, - [setFilterText, setAppliedFilter, runFilter, addFilter], - ); - - const [filterUpdateKey, setFilterUpdateKey] = useState(0); - const replaceFilter = useCallback( - (value: string) => { - applyFilter(value); - setFilterUpdateKey((k) => k + 1); - }, - [applyFilter], - ); - - const isSearching = filterText != null; - const toggleSearch = useCallback(() => { - if (isSearching) { - setFilterText(null); - setAppliedFilter(null); - } else { - setFilterText(""); - } - }, [isSearching, setFilterText, setAppliedFilter]); - - return { - stateKey, - filterText, - appliedFilter, - isSearching, - isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""), - filterUpdateKey, - setFilterText, - applyFilter, - replaceFilter, - toggleSearch, - recentFilters, - removeRecentFilter, - togglePinRecentFilter, - clearRecentFilters, - }; -} diff --git a/apps/yaak-client/lib/contentType.ts b/apps/yaak-client/lib/contentType.ts index 2c198282f..262d7fc98 100644 --- a/apps/yaak-client/lib/contentType.ts +++ b/apps/yaak-client/lib/contentType.ts @@ -1,59 +1,4 @@ import { MIMEType } from "whatwg-mimetype"; -import type { EditorProps } from "../components/core/Editor/Editor"; - -export function languageFromContentType( - contentType: string | null, - content: string | null = null, -): EditorProps["language"] { - const justContentType = contentType?.split(";")[0] ?? contentType ?? ""; - if (justContentType.includes("json")) { - return "json"; - } - if (justContentType.includes("xml")) { - return "xml"; - } - if (justContentType.includes("html")) { - const detected = languageFromContent(content); - if (detected === "xml") { - // If it's detected as XML, but is already HTML, don't change it - return "html"; - } - return detected; - } - if (justContentType.includes("javascript")) { - // Sometimes `application/javascript` returns JSON, so try detecting that - return languageFromContent(content, "javascript"); - } - if (justContentType.includes("markdown")) { - return "markdown"; - } - - return languageFromContent(content, "text"); -} - -export function languageFromContent( - content: string | null, - fallback?: EditorProps["language"], -): EditorProps["language"] { - if (content == null) return "text"; - - const firstBytes = content.slice(0, 20).trim(); - - if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) { - return "json"; - } - if ( - firstBytes.toLowerCase().startsWith("([]); diff --git a/apps/yaak-client/lib/generateId.ts b/packages/common-lib/generateId.ts similarity index 100% rename from apps/yaak-client/lib/generateId.ts rename to packages/common-lib/generateId.ts diff --git a/apps/yaak-client/lib/pluralize.ts b/packages/common-lib/pluralize.ts similarity index 100% rename from apps/yaak-client/lib/pluralize.ts rename to packages/common-lib/pluralize.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 283be298d..7018f7bfa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,6 +5,13 @@ "type": "module", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./editor": "./src/editor.ts" + }, + "sideEffects": [ + "*.css" + ], "dependencies": { "jotai-family": "^1.0.1" } diff --git a/packages/ui/src/components/CopyIconButton.tsx b/packages/ui/src/components/CopyIconButton.tsx new file mode 100644 index 000000000..54b37ce0f --- /dev/null +++ b/packages/ui/src/components/CopyIconButton.tsx @@ -0,0 +1,29 @@ +import { useTimedBoolean } from "../hooks/useTimedBoolean"; +import { IconButton, type IconButtonProps } from "./IconButton"; + +export interface CopyIconButtonProps extends Omit { + text: string | (() => Promise); + copy: (text: string) => void; + /** Called when `text` resolves to nothing */ + onCopyFailed?: () => void; +} + +export function CopyIconButton({ text, copy, onCopyFailed, ...props }: CopyIconButtonProps) { + const [copied, setCopied] = useTimedBoolean(); + return ( + { + const content = typeof text === "function" ? await text() : text; + if (content == null) { + onCopyFailed?.(); + } else { + copy(content); + setCopied(); + } + }} + /> + ); +} diff --git a/apps/yaak-client/components/core/CountBadge.tsx b/packages/ui/src/components/CountBadge.tsx similarity index 100% rename from apps/yaak-client/components/core/CountBadge.tsx rename to packages/ui/src/components/CountBadge.tsx diff --git a/packages/ui/src/components/DetailsBanner.tsx b/packages/ui/src/components/DetailsBanner.tsx new file mode 100644 index 000000000..ce89e69dc --- /dev/null +++ b/packages/ui/src/components/DetailsBanner.tsx @@ -0,0 +1,52 @@ +import classNames from "classnames"; +import type { HTMLAttributes, ReactNode } from "react"; +import { useInMemoryStoredValue, type UseStoredValue } from "../hooks/useStoredValue"; +import type { BannerProps } from "./Banner"; +import { Banner } from "./Banner"; + +export interface DetailsBannerProps extends HTMLAttributes { + summary: ReactNode; + color?: BannerProps["color"]; + defaultOpen?: boolean; + storageKey?: string; + /** Where the open state of a banner with a `storageKey` is kept */ + useStoredValue?: UseStoredValue; +} + +export function DetailsBanner({ + className, + color, + summary, + children, + defaultOpen, + storageKey, + useStoredValue = useInMemoryStoredValue, + ...extraProps +}: DetailsBannerProps) { + const [isOpen, setIsOpen] = useStoredValue(storageKey || null, defaultOpen ?? false); + + const handleToggle = (e: React.SyntheticEvent) => { + if (storageKey) { + void setIsOpen(e.currentTarget.open); + } + }; + + return ( + +
+ +
+ {summary} +
+
{children}
+
+
+ ); +} diff --git a/packages/ui/src/components/HeadersTable.tsx b/packages/ui/src/components/HeadersTable.tsx new file mode 100644 index 000000000..4edfe856d --- /dev/null +++ b/packages/ui/src/components/HeadersTable.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import type { UseStoredValue } from "../hooks/useStoredValue"; +import { CountBadge } from "./CountBadge"; +import { DetailsBanner } from "./DetailsBanner"; +import { KeyValueRow, type KeyValueRowProps, KeyValueRows } from "./KeyValueRow"; + +export interface HeadersTableProps { + title: ReactNode; + headers: { name: string; value: string }[]; + labelColor?: KeyValueRowProps["labelColor"]; + defaultOpen?: boolean; + storageKey?: string; + useStoredValue?: UseStoredValue; +} + +export function HeadersTable({ + title, + headers, + labelColor, + defaultOpen, + storageKey, + useStoredValue, +}: HeadersTableProps) { + const sortedHeaders = useMemo( + () => + [...headers].sort((a, b) => + a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), + ), + [headers], + ); + + return ( + + {title} + + } + > + {sortedHeaders.length === 0 ? ( + No Headers + ) : ( + + {sortedHeaders.map((h, i) => ( + // oxlint-disable-next-line react/no-array-index-key + + {h.value} + + ))} + + )} + + ); +} diff --git a/apps/yaak-client/components/core/IconTooltip.tsx b/packages/ui/src/components/IconTooltip.tsx similarity index 90% rename from apps/yaak-client/components/core/IconTooltip.tsx rename to packages/ui/src/components/IconTooltip.tsx index 3886048e6..554a8366a 100644 --- a/apps/yaak-client/components/core/IconTooltip.tsx +++ b/packages/ui/src/components/IconTooltip.tsx @@ -1,4 +1,4 @@ -import { Icon, type IconProps } from "@yaakapp-internal/ui"; +import { Icon, type IconProps } from "./Icon"; import type { TooltipProps } from "./Tooltip"; import { Tooltip } from "./Tooltip"; diff --git a/packages/ui/src/components/Input.tsx b/packages/ui/src/components/Input.tsx new file mode 100644 index 000000000..537a3749c --- /dev/null +++ b/packages/ui/src/components/Input.tsx @@ -0,0 +1,352 @@ +import type { EditorView } from "@codemirror/view"; +import { generateId } from "@yaakapp-internal/lib/generateId"; +import type { Color } from "@yaakapp-internal/plugins"; +import classNames from "classnames"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useStateWithDeps } from "../hooks/useStateWithDeps"; +import type { CodeEditorProps } from "./editor/CodeEditor"; +import { LazyCodeEditor } from "./editor/LazyCodeEditor"; +import { IconButton } from "./IconButton"; +import { Label } from "./Label"; +import { HStack } from "./Stacks"; + +export type InputProps = Pick< + CodeEditorProps, + | "language" + | "forceUpdateKey" + | "disabled" + | "autoFocus" + | "autoSelect" + | "extensions" + | "onKeyDown" + | "readOnly" +> & { + className?: string; + containerClassName?: string; + inputWrapperClassName?: string; + defaultValue?: string | null; + disableObscureToggle?: boolean; + fullHeight?: boolean; + hideLabel?: boolean; + help?: ReactNode; + label: ReactNode; + labelClassName?: string; + labelPosition?: "top" | "left"; + leftSlot?: ReactNode; + multiLine?: boolean; + name?: string; + onBlur?: () => void; + onChange?: (value: string) => void; + onFocus?: () => void; + onPaste?: (value: string) => void; + onPasteOverwrite?: CodeEditorProps["onPasteOverwrite"]; + placeholder?: string; + /** Renders the editor, for a host whose editor builds on CodeEditor */ + renderEditor?: (props: CodeEditorProps) => ReactNode; + /** Rendered beside the right slot, given a way to replace the whole value */ + renderOptionsPicker?: (insert: (value: string) => void) => ReactNode; + required?: boolean; + rightSlot?: ReactNode; + size?: "2xs" | "xs" | "sm" | "md" | "auto"; + stateKey: CodeEditorProps["stateKey"]; + tint?: Color; + type?: "text" | "password"; + validate?: boolean | ((v: string) => boolean); + wrapLines?: boolean; + setRef?: (h: InputHandle | null) => void; +}; + +export interface InputHandle { + focus: () => void; + isFocused: () => boolean; + value: () => string; + selectAll: () => void; + dispatch: EditorView["dispatch"]; +} + +function renderCodeEditor(props: CodeEditorProps) { + return ; +} + +export function Input({ + className, + containerClassName, + defaultValue, + disableObscureToggle, + disabled, + forceUpdateKey, + fullHeight, + help, + hideLabel, + inputWrapperClassName, + label, + labelClassName, + labelPosition = "top", + leftSlot, + multiLine, + onBlur, + onChange, + onFocus, + onPaste, + onPasteOverwrite, + placeholder, + readOnly, + required, + renderEditor, + renderOptionsPicker, + rightSlot, + size = "md", + stateKey, + tint, + type = "text", + validate, + wrapLines, + setRef, + ...props +}: InputProps) { + const [focused, setFocused] = useState(false); + const [obscured, setObscured] = useStateWithDeps(type === "password", [type]); + const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); + const editorRef = useRef(null); + const skipNextFocus = useRef(false); + + const handle = useMemo( + () => ({ + focus: () => { + if (editorRef.current == null) return; + const anchor = editorRef.current.state.doc.length; + skipNextFocus.current = true; + editorRef.current.focus(); + editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true }); + }, + isFocused: () => editorRef.current?.hasFocus ?? false, + value: () => editorRef.current?.state.doc.toString() ?? "", + dispatch: (...args) => { + // oxlint-disable-next-line no-explicit-any + editorRef.current?.dispatch(...(args as any)); + }, + selectAll() { + if (editorRef.current == null) return; + editorRef.current.focus(); + editorRef.current.dispatch({ + selection: { anchor: 0, head: editorRef.current.state.doc.length }, + }); + }, + }), + [], + ); + + // Replace the whole value with a picked option. Dispatching runs it through the editor's + // onChange, same as typing, so callers see one consistent change path. + const insertOption = useCallback((value: string) => { + const view = editorRef.current; + if (view == null) return; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); + view.focus(); + }, []); + + const setEditorRef = useCallback( + (h: EditorView | null) => { + editorRef.current = h; + setRef?.(handle); + }, + [handle, setRef], + ); + + useEffect(() => { + const fn = () => { + skipNextFocus.current = true; + }; + window.addEventListener("focus", fn); + return () => { + window.removeEventListener("focus", fn); + }; + }, []); + + const handleFocus = useCallback(() => { + if (readOnly) return; + + if (!skipNextFocus.current) { + editorRef.current?.dispatch({ + selection: { anchor: 0, head: editorRef.current.state.doc.length }, + }); + } + + setFocused(true); + onFocus?.(); + skipNextFocus.current = false; + }, [onFocus, readOnly]); + + const handleBlur = useCallback(async () => { + setFocused(false); + // Move selection to the end on blur + const anchor = editorRef.current?.state.doc.length ?? 0; + editorRef.current?.dispatch({ + selection: { anchor, head: anchor }, + }); + onBlur?.(); + }, [onBlur]); + + const id = useRef(`input-${generateId()}`); + const editorClassName = classNames( + className, + "bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder", + ); + + const isValid = useMemo(() => { + if (required && !validateRequire(defaultValue ?? "")) return false; + if (typeof validate === "boolean") return validate; + if (typeof validate === "function" && !validate(defaultValue ?? "")) return false; + return true; + }, [required, defaultValue, validate]); + + const handleChange = useCallback( + (value: string) => { + onChange?.(value); + setHasChanged(true); + }, + [onChange, setHasChanged], + ); + + const wrapperRef = useRef(null); + + // Submit the nearest form on Enter key press + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + + const form = wrapperRef.current?.closest("form"); + if (!isValid || form == null) return; + + form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); + }, + [isValid], + ); + + return ( +
+ + + {tint != null && ( +
+ )} + {leftSlot} + + {(renderEditor ?? renderCodeEditor)({ + setRef: setEditorRef, + id: id.current, + hideGutter: true, + singleLine: !multiLine, + containerOnly: true, + stateKey, + wrapLines, + heightMode: "auto", + onKeyDown: handleKeyDown, + type: type === "password" && !obscured ? "text" : type, + defaultValue, + forceUpdateKey, + placeholder, + onChange: handleChange, + onPaste, + onPasteOverwrite, + disabled, + className: classNames( + editorClassName, + multiLine && size === "md" && "py-1.5", + multiLine && size === "sm" && "py-1", + multiLine && (size === "xs" || size === "2xs") && "py-0.5", + ), + onFocus: handleFocus, + onBlur: handleBlur, + readOnly, + ...props, + })} + + {type === "password" && !disableObscureToggle && ( + setObscured((o) => !o)} + /> + )} + {renderOptionsPicker?.(insertOption)} + {rightSlot} + +
+ ); +} + +function validateRequire(v: string) { + return v.length > 0; +} diff --git a/packages/ui/src/components/KeyValueRow.tsx b/packages/ui/src/components/KeyValueRow.tsx new file mode 100644 index 000000000..3f2d486a9 --- /dev/null +++ b/packages/ui/src/components/KeyValueRow.tsx @@ -0,0 +1,110 @@ +import classNames from "classnames"; +import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { CopyIconButton } from "./CopyIconButton"; + +interface Props { + children: + | ReactElement> + | (ReactElement> | null)[]; + selectable?: boolean; +} + +export function KeyValueRows({ children, selectable }: Props) { + const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; + return ( + + + {childArray.map((child, i) => ( + // oxlint-disable-next-line react/no-array-index-key + {child} + ))} + +
+ ); +} + +export interface KeyValueRowProps { + label: ReactNode; + children: ReactNode; + rightSlot?: ReactNode; + leftSlot?: ReactNode; + align?: "top" | "middle"; + labelClassName?: string; + labelColor?: "secondary" | "primary" | "info"; + enableCopy?: boolean; + copyText?: string; + /** Copies a value for `enableCopy`, which shows no button without it */ + copy?: (text: string) => void; +} + +export function KeyValueRow({ + label, + children, + rightSlot, + leftSlot, + align = "top", + labelColor = "secondary", + labelClassName, + enableCopy, + copyText, + copy, +}: KeyValueRowProps) { + const textToCopy = + copyText ?? + (typeof children === "string" || typeof children === "number" ? `${children}` : null); + const copyTitle = + typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value"; + const resolvedRightSlot = + rightSlot ?? + (enableCopy && copy != null && textToCopy != null ? ( + + ) : null); + + return ( + <> + + {label} + + +
+ {leftSlot ?? } + {children} + {resolvedRightSlot ? ( +
{resolvedRightSlot}
+ ) : ( + + )} +
+ + + ); +} diff --git a/apps/yaak-client/components/core/Label.tsx b/packages/ui/src/components/Label.tsx similarity index 100% rename from apps/yaak-client/components/core/Label.tsx rename to packages/ui/src/components/Label.tsx diff --git a/apps/yaak-client/components/core/Tooltip.tsx b/packages/ui/src/components/Tooltip.tsx similarity index 98% rename from apps/yaak-client/components/core/Tooltip.tsx rename to packages/ui/src/components/Tooltip.tsx index e2274b295..d32624cc2 100644 --- a/apps/yaak-client/components/core/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip.tsx @@ -1,8 +1,8 @@ import classNames from "classnames"; import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; import { useRef, useState } from "react"; -import { generateId } from "../../lib/generateId"; -import { Portal } from "@yaakapp-internal/ui"; +import { generateId } from "@yaakapp-internal/lib/generateId"; +import { Portal } from "./Portal"; export interface TooltipProps { children: ReactNode; diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/packages/ui/src/components/editor/CodeEditor.css similarity index 99% rename from apps/yaak-client/components/core/Editor/Editor.css rename to packages/ui/src/components/editor/CodeEditor.css index bc473ab78..14b2957fb 100644 --- a/apps/yaak-client/components/core/Editor/Editor.css +++ b/packages/ui/src/components/editor/CodeEditor.css @@ -1,4 +1,5 @@ -@reference "../../../main.css"; +@reference "tailwindcss"; +@reference "../../../../tailwind-config/index.css"; .cm-wrapper { @apply h-full overflow-hidden; diff --git a/packages/ui/src/components/editor/CodeEditor.tsx b/packages/ui/src/components/editor/CodeEditor.tsx new file mode 100644 index 000000000..3341864b7 --- /dev/null +++ b/packages/ui/src/components/editor/CodeEditor.tsx @@ -0,0 +1,696 @@ +import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; +import { foldState, forceParsing } from "@codemirror/language"; +import type { EditorStateConfig, Extension } from "@codemirror/state"; +import { Compartment, EditorState } from "@codemirror/state"; +import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view"; +import { emacs } from "@replit/codemirror-emacs"; +import { vim } from "@replit/codemirror-vim"; + +import { vscodeKeymap } from "@replit/codemirror-vscode-keymap"; +import { debounce } from "@yaakapp-internal/lib"; +import type { EditorKeymap } from "@yaakapp-internal/models"; +import type { EditorLanguage } from "@yaakapp-internal/plugins"; +import classNames from "classnames"; +import type { ReactNode, RefObject } from "react"; +import { + Children, + cloneElement, + isValidElement, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import { docFingerprint } from "../../lib/docFingerprint"; +import { IconButton } from "../IconButton"; +import { HStack } from "../Stacks"; +import "./CodeEditor.css"; +import { + baseExtensions, + editableExtensions, + getLanguageExtension, + multiLineExtensions, + readonlyExtensions, +} from "./extensions"; +import { singleLineExtensions } from "./singleLine"; + +// VSCode's Tab actions mess with the single-line editor tab actions, so remove it. +const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab"); + +const keymapExtensions: Record = { + vim: vim(), + emacs: emacs(), + vscode: keymap.of(vsCodeWithoutTab), + default: [], +}; + +export interface CodeEditorProps { + actions?: ReactNode; + autoFocus?: boolean; + autoSelect?: boolean; + className?: string; + defaultValue?: string | null; + disableTabIndent?: boolean; + disabled?: boolean; + /** Added to the editable document's extensions, swapped out with read-only */ + editableExtensions?: Extension; + extensions?: Extension[] | Extension; + forceUpdateKey?: string | number; + format?: (v: string) => Promise; + heightMode?: "auto" | "full"; + hideGutter?: boolean; + id?: string; + /** Runs before `onKeyDown`; returning true consumes the key */ + keyDownHandler?: (e: KeyboardEvent, view: EditorView) => boolean; + keymap?: EditorKeymap; + language?: EditorLanguage | null; + /** Replaces the extension built from `language`, reconfigured whenever it changes */ + languageExtension?: Extension; + lintExtension?: Extension; + /** Added to the read-only document's extensions, swapped out with editable */ + readOnlyExtensions?: Extension; + onBlur?: () => void; + onChange?: (value: string) => void; + onFocus?: () => void; + onKeyDown?: (e: KeyboardEvent) => void; + onPaste?: (value: string) => void; + onPasteOverwrite?: (e: ClipboardEvent, value: string) => void; + placeholder?: string; + readOnly?: boolean; + singleLine?: boolean; + containerOnly?: boolean; + stateKey: string | null; + tooltipContainer?: HTMLElement; + type?: "text" | "password"; + wrapLines?: boolean; + setRef?: (view: EditorView | null) => void; +} + +const stateFields = { history: historyField, folds: foldState }; + +const emptyExtension: Extension = []; + +export function CodeEditor(props: CodeEditorProps) { + return ; +} + +function CodeEditorInner({ + actions, + autoFocus, + autoSelect, + className, + defaultValue, + disableTabIndent, + disabled, + editableExtensions: extraEditableExtensions, + extensions: extraExtensions, + forceUpdateKey, + format, + heightMode, + hideGutter, + keyDownHandler, + keymap: editorKeymap = "default", + language, + languageExtension, + lintExtension, + onBlur, + onChange, + onFocus, + onKeyDown, + onPaste, + onPasteOverwrite, + placeholder, + readOnly, + readOnlyExtensions: extraReadOnlyExtensions, + singleLine, + containerOnly, + stateKey, + type, + wrapLines, + setRef, +}: CodeEditorProps) { + if (disabled) { + readOnly = true; + } + + if (singleLine || language == null || language === "text") { + disableTabIndent = true; + } + + const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); + + // Use ref so we can update the handler without re-initializing the editor + const handleChange = useRef(onChange); + useEffect(() => { + handleChange.current = onChange; + }, [onChange]); + + // Use ref so we can update the handler without re-initializing the editor + const handlePaste = useRef(onPaste); + useEffect(() => { + handlePaste.current = onPaste; + }, [onPaste]); + + // Use ref so we can update the handler without re-initializing the editor + const handlePasteOverwrite = useRef(onPasteOverwrite); + useEffect(() => { + handlePasteOverwrite.current = onPasteOverwrite; + }, [onPasteOverwrite]); + + // Use ref so we can update the handler without re-initializing the editor + const handleFocus = useRef(onFocus); + useEffect(() => { + handleFocus.current = onFocus; + }, [onFocus]); + + // Use ref so we can update the handler without re-initializing the editor + const handleBlur = useRef(onBlur); + useEffect(() => { + handleBlur.current = onBlur; + }, [onBlur]); + + // Use ref so we can update the handler without re-initializing the editor + const handleKeyDown = useRef(onKeyDown); + useEffect(() => { + handleKeyDown.current = onKeyDown; + }, [onKeyDown]); + + // Use ref so we can update the handler without re-initializing the editor + const handleKeyDownFirst = useRef(keyDownHandler); + useEffect(() => { + handleKeyDownFirst.current = keyDownHandler; + }, [keyDownHandler]); + + // Update placeholder + const placeholderCompartment = useRef(new Compartment()); + useEffect( + function configurePlaceholder() { + if (cm.current === null) return; + const ext = placeholderExt(placeholderElFromText(placeholder)); + const effects = placeholderCompartment.current.reconfigure(ext); + cm.current?.view.dispatch({ effects }); + }, + [placeholder], + ); + + // Update vim + const keymapCompartment = useRef(new Compartment()); + useEffect( + function configureKeymap() { + if (cm.current === null) return; + const current = keymapCompartment.current.get(cm.current.view.state) ?? []; + // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary + if (editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do + if (editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do + if (editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do + if (editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do + + const ext = keymapExtensions[editorKeymap] ?? keymapExtensions.default; + const effects = keymapCompartment.current.reconfigure(ext); + cm.current.view.dispatch({ effects }); + }, + [editorKeymap], + ); + + // Update wrap lines + const wrapLinesCompartment = useRef(new Compartment()); + useEffect( + function configureWrapLines() { + if (cm.current === null) return; + const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension; + // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary + if (wrapLines && current !== emptyExtension) return; // Nothing to do + if (!wrapLines && current === emptyExtension) return; // Nothing to do + + const ext = wrapLines ? EditorView.lineWrapping : emptyExtension; + const effects = wrapLinesCompartment.current.reconfigure(ext); + cm.current?.view.dispatch({ effects }); + }, + [wrapLines], + ); + + // Update tab indent + const tabIndentCompartment = useRef(new Compartment()); + useEffect( + function configureTabIndent() { + if (cm.current === null) return; + const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension; + // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary + if (disableTabIndent && current !== emptyExtension) return; // Nothing to do + if (!disableTabIndent && current === emptyExtension) return; // Nothing to do + + const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension; + const effects = tabIndentCompartment.current.reconfigure(ext); + cm.current?.view.dispatch({ effects }); + }, + [disableTabIndent], + ); + + const modeExtensions = useMemo( + () => ({ + readOnly: extraReadOnlyExtensions + ? [readonlyExtensions, extraReadOnlyExtensions] + : readonlyExtensions, + editable: extraEditableExtensions + ? [editableExtensions, extraEditableExtensions] + : editableExtensions, + }), + [extraReadOnlyExtensions, extraEditableExtensions], + ); + + // Update read-only + const readOnlyCompartment = useRef(new Compartment()); + useEffect( + function configureReadOnly() { + if (cm.current === null) return; + const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension; + const next = readOnly ? modeExtensions.readOnly : modeExtensions.editable; + // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary + if (current === next) return; + + const effects = readOnlyCompartment.current.reconfigure(next); + cm.current?.view.dispatch({ effects }); + }, + [readOnly, modeExtensions], + ); + + const langExt = useMemo( + () => languageExtension ?? getLanguageExtension({ language, lintExtension, hideGutter }), + [languageExtension, language, lintExtension, hideGutter], + ); + + // Update the language extension when the language changes + useEffect(() => { + if (cm.current === null) return; + const { view, languageCompartment } = cm.current; + if (languageCompartment.get(view.state) === langExt) return; + view.dispatch({ effects: languageCompartment.reconfigure(langExt) }); + }, [langExt]); + + // Initialize the editor when ref mounts + // oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary + const initEditorRef = useCallback( + function initEditorRef(container: HTMLDivElement | null) { + if (container === null) { + flushCachedEditorState(stateKey); + cm.current?.view.destroy(); + cm.current = null; + return; + } + + try { + const languageCompartment = new Compartment(); + const extensions = [ + languageCompartment.of(langExt), + placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))), + wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension), + tabIndentCompartment.current.of( + !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension, + ), + keymapCompartment.current.of(keymapExtensions[editorKeymap] ?? keymapExtensions.default), + readOnlyCompartment.current.of( + readOnly ? modeExtensions.readOnly : modeExtensions.editable, + ), + ...getExtensions({ + container, + singleLine, + hideGutter, + stateKey, + onChange: handleChange, + onPaste: handlePaste, + onPasteOverwrite: handlePasteOverwrite, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + keyDownHandler: handleKeyDownFirst, + }), + ...(Array.isArray(extraExtensions) + ? extraExtensions + : extraExtensions + ? [extraExtensions] + : []), + ]; + + const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly); + + const doc = `${defaultValue ?? ""}`; + const config: EditorStateConfig = { extensions, doc }; + + const state = cachedJsonState + ? EditorState.fromJSON(cachedJsonState, config, stateFields) + : EditorState.create(config); + + // PERF: Built detached and attached in a microtask, rather than passing `parent` and + // letting the constructor attach it first. + // + // The constructor sets `contenteditable` on the content element and then reads + // `document.fonts.ready`. Both force the browser to flush style and layout when the + // element is already in the document, and whatever the previous editor attached has + // just dirtied it again — so mounting n editors in one commit costs n forced layouts. + // The environment editor mounts two per variable row (~60 of them), which is where its + // half-second open went. + // + // Building detached moves that work off the live document, and deferring the attach to + // a microtask means every editor in the commit is built before any of them is inserted: + // one forced layout for the batch instead of one each. The microtask still runs before + // paint, so nothing is visible in the meantime. + const view = new EditorView({ state }); + + // For large documents, the parser may parse the max number of lines and fail to add + // things like fold markers because of it. + // This forces it to parse more but keeps the timeout to the default of 100 ms. + forceParsing(view, 9e6, 100); + + cm.current = { view, languageCompartment }; + queueMicrotask(() => { + // The editor was torn down (or replaced) before we got here + if (cm.current?.view !== view) return; + + // Runs after the surrounding try/catch has returned, so it needs its own + try { + container.appendChild(view.dom); + // Everything the constructor measured, it measured detached + view.requestMeasure(); + + if (autoFocus) { + view.focus(); + } + if (autoSelect) { + view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); + } + } catch (e) { + console.log("Failed to attach Codemirror", e); + } + }); + setRef?.(view); + } catch (e) { + console.log("Failed to initialize Codemirror", e); + } + }, + [forceUpdateKey], + ); + + // For read-only mode, update content when `defaultValue` changes + useEffect( + function updateReadOnlyEditor() { + if (readOnly && cm.current?.view != null) { + updateContents(cm.current.view, defaultValue || ""); + } + }, + [defaultValue, readOnly], + ); + + // Force input to update when receiving change and not in focus + useLayoutEffect( + function updateNonFocusedEditor() { + const notFocused = !cm.current?.view.hasFocus; + if (notFocused && cm.current != null) { + updateContents(cm.current.view, defaultValue || ""); + } + }, + [defaultValue], + ); + + // Add bg classes to actions, so they appear over the text + const decoratedActions = useMemo(() => { + const results = []; + const actionClassName = classNames( + "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow", + ); + + if (format) { + results.push( + { + if (cm.current === null) return; + const { doc } = cm.current.view.state; + const formatted = await format(doc.toString()); + // Update editor and blur because the cursor will reset anyway + cm.current.view.dispatch({ + changes: { from: 0, to: doc.length, insert: formatted }, + }); + cm.current.view.contentDOM.blur(); + // Fire change event + onChange?.(formatted); + }} + />, + ); + } + results.push( + Children.map(actions, (existingChild) => { + if (!isValidElement<{ className?: string }>(existingChild)) return null; + const existingProps = existingChild.props; + + return cloneElement(existingChild, { + ...existingProps, + className: classNames(existingProps.className, actionClassName), + }); + }), + ); + return results; + }, [actions, format, onChange]); + + const cmContainer = ( +
+ ); + + if (singleLine || containerOnly) { + return cmContainer; + } + + return ( +
+ {cmContainer} + {decoratedActions && ( + + {decoratedActions} + + )} +
+ ); +} + +function getExtensions({ + stateKey, + container, + singleLine, + hideGutter, + onChange, + onPaste, + onPasteOverwrite, + onFocus, + onBlur, + onKeyDown, + keyDownHandler, +}: Pick & { + stateKey: CodeEditorProps["stateKey"]; + container: HTMLDivElement | null; + onChange: RefObject; + onPaste: RefObject; + onPasteOverwrite: RefObject; + onFocus: RefObject; + onBlur: RefObject; + onKeyDown: RefObject; + keyDownHandler: RefObject; +}) { + // TODO: Ensure tooltips render inside the dialog if we are in one. + const parent = + container?.closest('[role="dialog"]') ?? + document.querySelector("#cm-portal") ?? + undefined; + + return [ + ...baseExtensions, // Must be first + EditorView.contentAttributes.of({ + autocapitalize: "off", + autocorrect: "off", + // Keeps macOS Writing Tools from offering to write code for us + writingsuggestions: "false", + }), + EditorView.domEventHandlers({ + focus: () => { + onFocus.current?.(); + }, + blur: () => { + onBlur.current?.(); + }, + keydown: (e, view) => { + if (keyDownHandler.current?.(e, view)) { + return true; + } + onKeyDown.current?.(e); + }, + paste: (e, v) => { + const textData = e.clipboardData?.getData("text/plain") ?? ""; + onPaste.current?.(textData); + if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) { + onPasteOverwrite.current?.(e, textData); + } + }, + }), + tooltips({ parent }), + keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), + ...(singleLine ? [singleLineExtensions()] : []), + ...(!singleLine ? multiLineExtensions({ hideGutter }) : []), + + // ------------------------ // + // Things that must be last // + // ------------------------ // + + EditorView.updateListener.of((update) => { + if (update.startState === update.state) return; + + if (onChange && update.docChanged) { + onChange.current?.(update.state.doc.toString()); + } + + saveCachedEditorStateDebounced(stateKey, update.state); + }), + ]; +} + +const placeholderElFromText = (text: string | undefined) => { + const el = document.createElement("div"); + // Default to because codemirror needs it for sizing. I'm not sure why, but probably something + // to do with how Yaak "hacks" it with CSS for single line input. + el.innerHTML = text ? text.replaceAll("\n", "
") : " "; + return el; +}; + +// Caching the state is too expensive to do on every update (every keystroke and cursor move), +// so debounce it per state key and flush when the editor unmounts. +// +// The cost scales with the document, and every update pays it in full: +// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB +// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint` +// only samples read-only ones, so editing a large body still hashes all of it +// - `sessionStorage.setItem` is synchronous and blocks the main thread +// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one +// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger +// half once documents get into the megabytes. +const SAVE_STATE_DEBOUNCE_MS = 500; +const stateSavers = new Map>(); + +function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) { + if (!stateKey) return; + let saver = stateSavers.get(stateKey); + if (saver == null) { + saver = debounce( + (s: EditorState) => saveCachedEditorState(stateKey, s), + SAVE_STATE_DEBOUNCE_MS, + ); + stateSavers.set(stateKey, saver); + } + saver(state); +} + +// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map +// for every state key the session has ever shown. A pending saver holds the last EditorState, +// which holds the whole document. +function flushCachedEditorState(stateKey: string | null) { + if (!stateKey) return; + const saver = stateSavers.get(stateKey); + if (saver == null) return; + saver.flush(); + stateSavers.delete(stateKey); +} + +function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { + if (!stateKey || state == null) return; + const stateObj = state.toJSON(stateFields); + + // Save state in sessionStorage by removing doc and saving a fingerprint of it instead. + // This will be checked on restore and put back in if it matches. + // Editable documents get the exact hash, so a collision can never restore undo history + // belonging to other content + stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly }); + stateObj.doc = undefined; + + try { + sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj)); + } catch (err) { + console.log("Failed to save to editor state", stateKey, err); + } +} + +function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) { + if (stateKey == null) return; + + try { + const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey)); + if (stateStr == null) return null; + + const { docHash, ...state } = JSON.parse(stateStr); + + // Ensure the doc matches the one that was used to save the state + if (docHash !== docFingerprint(doc, { exact: !readOnly })) { + return null; + } + + state.doc = doc; + return state; + } catch (err) { + console.log("Failed to restore editor storage", stateKey, err); + } + + return null; +} + +function computeFullStateKey(stateKey: string): string { + return `editor.${stateKey}`; +} + +function updateContents(view: EditorView, text: string) { + // Replace codemirror contents + const currentDoc = view.state.doc.toString(); + + if (currentDoc === text) { + return; + } + + if (text.startsWith(currentDoc)) { + // If we're just appending, append only the changes. This preserves + // things like scroll position. + view.dispatch({ + changes: view.state.changes({ + from: currentDoc.length, + insert: text.slice(currentDoc.length), + }), + }); + } else { + // If we're replacing everything, reset the entire content + view.dispatch({ + changes: view.state.changes({ + from: 0, + to: currentDoc.length, + insert: text, + }), + }); + } +} diff --git a/packages/ui/src/components/editor/LazyCodeEditor.tsx b/packages/ui/src/components/editor/LazyCodeEditor.tsx new file mode 100644 index 000000000..dbbf53f10 --- /dev/null +++ b/packages/ui/src/components/editor/LazyCodeEditor.tsx @@ -0,0 +1,12 @@ +import { lazy, Suspense } from "react"; +import type { CodeEditorProps } from "./CodeEditor"; + +const CodeEditor_ = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor }))); + +export function LazyCodeEditor(props: CodeEditorProps) { + return ( + + + + ); +} diff --git a/packages/ui/src/components/editor/extensions.ts b/packages/ui/src/components/editor/extensions.ts new file mode 100644 index 000000000..07c09b507 --- /dev/null +++ b/packages/ui/src/components/editor/extensions.ts @@ -0,0 +1,274 @@ +import { + autocompletion, + closeBrackets, + closeBracketsKeymap, + completionKeymap, +} from "@codemirror/autocomplete"; +import { history, historyKeymap } from "@codemirror/commands"; +import { go } from "@codemirror/lang-go"; +import { java } from "@codemirror/lang-java"; +import { javascript } from "@codemirror/lang-javascript"; +import { markdown } from "@codemirror/lang-markdown"; +import { php } from "@codemirror/lang-php"; +import { python } from "@codemirror/lang-python"; +import { xml } from "@codemirror/lang-xml"; +import { + bracketMatching, + codeFolding, + foldGutter, + foldKeymap, + HighlightStyle, + indentOnInput, + LanguageSupport, + StreamLanguage, + syntaxHighlighting, +} from "@codemirror/language"; +import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike"; +import { clojure } from "@codemirror/legacy-modes/mode/clojure"; +import { http } from "@codemirror/legacy-modes/mode/http"; +import { oCaml } from "@codemirror/legacy-modes/mode/mllike"; +import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; +import { r } from "@codemirror/legacy-modes/mode/r"; +import { ruby } from "@codemirror/legacy-modes/mode/ruby"; +import { shell } from "@codemirror/legacy-modes/mode/shell"; +import { swift } from "@codemirror/legacy-modes/mode/swift"; +import { linter, lintGutter, lintKeymap } from "@codemirror/lint"; +import { search, searchKeymap } from "@codemirror/search"; +import type { Extension } from "@codemirror/state"; +import { EditorState } from "@codemirror/state"; +import { + crosshairCursor, + drawSelection, + dropCursor, + EditorView, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, + rectangularSelection, +} from "@codemirror/view"; +import { tags as t } from "@lezer/highlight"; +import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; +import type { EditorLanguage } from "@yaakapp-internal/plugins"; +import { jsonParseLinter } from "./json-lint"; +import { searchMatchCount } from "./searchMatchCount"; +import { text } from "./text/extension"; + +export const syntaxHighlightStyle = HighlightStyle.define([ + { + tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment], + color: "var(--textSubtlest)", + }, + { + tag: [t.emphasis], + textDecoration: "underline", + }, + { + tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation], + color: "var(--textSubtle)", + }, + { + tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number], + color: "var(--info)", + }, + { tag: [t.variableName], color: "var(--success)" }, + { tag: [t.bool], color: "var(--warning)" }, + { tag: [t.attributeName, t.propertyName], color: "var(--primary)" }, + { tag: [t.attributeValue], color: "var(--warning)" }, + { tag: [t.string], color: "var(--notice)" }, + { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" }, +]); + +const syntaxTheme = EditorView.theme({}, { dark: true }); + +export const closeBracketsExtensions: Extension = [ + closeBrackets(), + keymap.of([...closeBracketsKeymap]), +]; + +const legacyLang = (mode: Parameters[0]) => { + return () => new LanguageSupport(StreamLanguage.define(mode)); +}; + +export type LanguageSupports = Record LanguageSupport)>; + +export const syntaxExtensions: Record LanguageSupport)> = { + graphql: null, + json: jsonc, + javascript: javascript, + // HTML as XML because HTML is oddly slow + html: xml, + xml: xml, + text: text, + markdown: markdown, + c: legacyLang(c), + clojure: legacyLang(clojure), + csharp: legacyLang(csharp), + go: go, + http: legacyLang(http), + java: java, + kotlin: legacyLang(kotlin), + objective_c: legacyLang(objectiveC), + ocaml: legacyLang(oCaml), + php: php, + powershell: legacyLang(powerShell), + python: python, + r: legacyLang(r), + ruby: legacyLang(ruby), + shell: legacyLang(shell), + swift: legacyLang(swift), +}; + +const closeBracketsFor: string[] = ["json", "javascript", "graphql"]; + +export function getLanguageExtension({ + language = "text", + lintExtension, + hideGutter, + languages = syntaxExtensions, + extraExtensions: hostExtensions = [], + wrap, +}: { + language?: string | null; + lintExtension?: Extension; + hideGutter?: boolean; + /** Language supports by name, for a host with languages of its own */ + languages?: LanguageSupports; + extraExtensions?: Extension[]; + /** Wraps the base language, as a templating language does */ + wrap?: (base: LanguageSupport, extraExtensions: Extension[]) => Extension; +}): Extension { + const extraExtensions: Extension[] = [...hostExtensions]; + + // Only close brackets on languages that need it + if (language && closeBracketsFor.includes(language)) { + extraExtensions.push(closeBracketsExtensions); + } + + if (language === "json") { + extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); + extraExtensions.push( + jsoncLanguage.data.of({ + commentTokens: { line: "//", block: { open: "/*", close: "*/" } }, + }), + ); + if (!hideGutter) { + extraExtensions.push(lintGutter()); + } + } + + const maybeBase = language ? languages[language] : null; + const base = typeof maybeBase === "function" ? maybeBase() : null; + if (base == null) { + return []; + } + + return wrap ? wrap(base, extraExtensions) : [base, extraExtensions]; +} + +// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys. +// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings. +const filteredCompletionKeymap = completionKeymap.filter((binding) => { + const key = binding.key?.toLowerCase() ?? ""; + const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? ""; + // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i) + const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`"); + return !isStartTrigger; +}); + +export const baseExtensions = [ + highlightSpecialChars(), + history(), + dropCursor(), + drawSelection(), + autocompletion({ + tooltipClass: () => "x-theme-menu", + closeOnBlur: true, // Set to `false` for debugging in devtools without closing it + defaultKeymap: false, // We handle the trigger via configurable hotkeys + compareCompletions: (a, b) => { + // Don't sort completions at all, only on boost + return (a.boost ?? 0) - (b.boost ?? 0); + }, + }), + syntaxHighlighting(syntaxHighlightStyle), + syntaxTheme, + keymap.of([...historyKeymap, ...filteredCompletionKeymap]), +]; + +export const readonlyExtensions = [ + EditorState.readOnly.of(true), + EditorView.contentAttributes.of({ tabindex: "-1" }), +]; + +export const editableExtensions: Extension[] = []; + +export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ + search({ top: true }), + searchMatchCount(), + hideGutter + ? [] + : [ + lineNumbers(), + foldGutter({ + markerDOM: (open) => { + const el = document.createElement("div"); + el.classList.add("fold-gutter-icon"); + el.tabIndex = -1; + if (open) { + el.setAttribute("data-open", ""); + } + return el; + }, + }), + ], + codeFolding({ + placeholderDOM(_view, onclick, prepared) { + const el = document.createElement("span"); + el.onclick = onclick; + el.className = "cm-foldPlaceholder"; + el.innerText = prepared || "…"; + el.title = "unfold"; + el.ariaLabel = "folded code"; + return el; + }, + /** + * Show the number of items when code folded. NOTE: this doesn't get called when restoring + * a previous serialized editor state, which is a bummer + */ + preparePlaceholder(state, range) { + let count: number | undefined; + let startToken = "{"; + let endToken = "}"; + + const prevLine = state.doc.lineAt(range.from).text; + const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{"); + + if (isArray) { + startToken = "["; + endToken = "]"; + } + + const internal = state.sliceDoc(range.from, range.to); + const toParse = startToken + internal + endToken; + + try { + const parsed = JSON.parse(toParse); + count = Object.keys(parsed).length; + } catch { + /* empty */ + } + + if (count !== undefined) { + const label = isArray ? "item" : "key"; + return pluralizeCount(label, count); + } + }, + }), + indentOnInput(), + rectangularSelection(), + crosshairCursor(), + bracketMatching(), + highlightActiveLineGutter(), + keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]), +]; diff --git a/packages/ui/src/components/editor/json-lint.ts b/packages/ui/src/components/editor/json-lint.ts new file mode 100644 index 000000000..4f0cf9ab4 --- /dev/null +++ b/packages/ui/src/components/editor/json-lint.ts @@ -0,0 +1,58 @@ +import type { Diagnostic } from "@codemirror/lint"; +import type { EditorView } from "@codemirror/view"; +import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; + +// jsonc-parser reports error codes, so these are the words the editor shows for them +const MESSAGES: Record = { + InvalidSymbol: "Invalid symbol", + InvalidNumberFormat: "Invalid number format", + PropertyNameExpected: "Property name expected", + ValueExpected: "Value expected", + ColonExpected: "Colon expected", + CommaExpected: "Comma expected", + CloseBraceExpected: "Closing brace expected", + CloseBracketExpected: "Closing bracket expected", + EndOfFileExpected: "End of file expected", + InvalidCommentToken: "Comments are not allowed", + UnexpectedEndOfComment: "Unexpected end of comment", + UnexpectedEndOfString: "Unexpected end of string", + UnexpectedEndOfNumber: "Unexpected end of number", + InvalidUnicode: "Invalid unicode sequence", + InvalidEscapeCharacter: "Invalid escape character", + InvalidCharacter: "Invalid character", +}; + +export interface JsonLintOptions { + allowComments?: boolean; + allowTrailingCommas?: boolean; + /** Rewrites the document before parsing, keeping every offset where it was */ + escape?: (doc: string) => string; +} + +export function jsonParseLinter(options?: JsonLintOptions) { + return (view: EditorView): Diagnostic[] => { + const doc = view.state.doc.toString(); + if (doc.trim() === "") return []; + + const escapedDoc = options?.escape?.(doc) ?? doc; + + const errors: ParseError[] = []; + parse(escapedDoc, errors, { + allowTrailingComma: options?.allowTrailingCommas ?? false, + disallowComments: !(options?.allowComments ?? true), + }); + + // Later errors are mostly consequences of the first one, so only that one is shown + const error = errors[0]; + if (error == null) return []; + + return [ + { + from: error.offset, + to: error.offset + error.length, + severity: "error", + message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON", + }, + ]; + }; +} diff --git a/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts b/packages/ui/src/components/editor/json/breadcrumbExtension.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts rename to packages/ui/src/components/editor/json/breadcrumbExtension.ts diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts b/packages/ui/src/components/editor/json/jsonPath.test.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/jsonPath.test.ts rename to packages/ui/src/components/editor/json/jsonPath.test.ts diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.ts b/packages/ui/src/components/editor/json/jsonPath.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/jsonPath.ts rename to packages/ui/src/components/editor/json/jsonPath.ts diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts b/packages/ui/src/components/editor/searchMatchCount.test.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/searchMatchCount.test.ts rename to packages/ui/src/components/editor/searchMatchCount.test.ts diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.ts b/packages/ui/src/components/editor/searchMatchCount.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/searchMatchCount.ts rename to packages/ui/src/components/editor/searchMatchCount.ts diff --git a/apps/yaak-client/components/core/Editor/singleLine.ts b/packages/ui/src/components/editor/singleLine.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/singleLine.ts rename to packages/ui/src/components/editor/singleLine.ts diff --git a/apps/yaak-client/components/core/Editor/text/extension.ts b/packages/ui/src/components/editor/text/extension.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/extension.ts rename to packages/ui/src/components/editor/text/extension.ts diff --git a/apps/yaak-client/components/core/Editor/text/text.grammar b/packages/ui/src/components/editor/text/text.grammar similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.grammar rename to packages/ui/src/components/editor/text/text.grammar diff --git a/apps/yaak-client/components/core/Editor/text/text.terms.ts b/packages/ui/src/components/editor/text/text.terms.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.terms.ts rename to packages/ui/src/components/editor/text/text.terms.ts diff --git a/apps/yaak-client/components/core/Editor/text/text.ts b/packages/ui/src/components/editor/text/text.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.ts rename to packages/ui/src/components/editor/text/text.ts diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx new file mode 100644 index 000000000..046199855 --- /dev/null +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -0,0 +1,528 @@ +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import classNames from "classnames"; +import type { ComponentType, ReactNode, Ref } from "react"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { useInMemoryStoredValue, type UseStoredValue } from "../../hooks/useStoredValue"; +import { computeSideForDragMove } from "../../lib/dnd"; +import type { ButtonProps } from "../Button"; +import { Button } from "../Button"; +import { DropMarker } from "../DropMarker"; + +export interface TabButtonRenderProps { + buttonProps: Partial; + isActive: boolean; +} + +export type TabItem = + | { + value: string; + label: string; + hidden?: boolean; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + } + | { + value: string; + /** Renders the tab's button, for a tab that is more than a label */ + renderButton: (props: TabButtonRenderProps) => ReactNode; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + }; + +export interface TabsStorage { + order: string[]; + activeTabs: Record; +} + +export interface TabsRef { + /** Programmatically set the active tab */ + setActiveTab: (value: string) => void; +} + +export interface TabsProps { + label: string; + /** Default tab value. If not provided, defaults to first tab. */ + defaultValue?: string; + /** Called when active tab changes */ + onChangeValue?: (value: string) => void; + tabs: TabItem[]; + tabListClassName?: string; + className?: string; + children: ReactNode; + addBorders?: boolean; + layout?: "horizontal" | "vertical"; + /** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */ + storageKey?: string | string[]; + /** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */ + activeTabKey?: string; + /** Where the order and active tab under `storageKey` are kept */ + useStoredValue?: UseStoredValue; +} + +export const Tabs = forwardRef(function Tabs( + { + defaultValue, + onChangeValue: onChangeValueProp, + label, + children, + tabs: originalTabs, + className, + tabListClassName, + addBorders, + layout = "vertical", + storageKey, + activeTabKey, + useStoredValue = useInMemoryStoredValue, + }: TabsProps, + forwardedRef: Ref, +) { + const ref = useRef(null); + const reorderable = !!storageKey; + + // Use key-value storage for persistence if storageKey is provided + // Handle migration from old format (string[]) to new format (TabsStorage) + const [rawStorage, setStorage] = useStoredValue(storageKey ?? null, { + order: [], + activeTabs: {}, + }); + + // Migrate old format (string[]) to new format (TabsStorage) + const storage: TabsStorage = Array.isArray(rawStorage) + ? { order: rawStorage, activeTabs: {} } + : (rawStorage ?? { order: [], activeTabs: {} }); + + const savedOrder = storage.order; + + // Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab + const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined; + const [internalValue, setInternalValue] = useState(undefined); + const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value; + + // Helper to normalize storage (handle migration from old format) + const normalizeStorage = useCallback( + (s: TabsStorage | string[]): TabsStorage => + Array.isArray(s) ? { order: s, activeTabs: {} } : s, + [], + ); + + // Handle tab change - update internal state, storage if we have a key, and call prop callback + const onChangeValue = useCallback( + async (newValue: string) => { + setInternalValue(newValue); + if (storageKey && activeTabKey) { + await setStorage((s) => { + const normalized = normalizeStorage(s); + return { + ...normalized, + activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue }, + }; + }); + } + onChangeValueProp?.(newValue); + }, + [storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage], + ); + + // Expose imperative methods via ref + useImperativeHandle( + forwardedRef, + () => ({ + setActiveTab: (value: string) => { + onChangeValue(value).catch(console.error); + }, + }), + [onChangeValue], + ); + + // Helper to save order + const setSavedOrder = useCallback( + async (order: string[]) => { + await setStorage((s) => { + const normalized = normalizeStorage(s); + return { ...normalized, order }; + }); + }, + [setStorage, normalizeStorage], + ); + + // State for ordered tabs + const [orderedTabs, setOrderedTabs] = useState(originalTabs); + const [isDragging, setIsDragging] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + + // Reorder tabs based on saved order when tabs or savedOrder changes + useEffect(() => { + if (!storageKey || savedOrder == null || savedOrder.length === 0) { + setOrderedTabs(originalTabs); + return; + } + + // Create a map of tab values to tab items + const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab])); + + // Reorder based on saved order, adding any new tabs at the end + const reordered: TabItem[] = []; + const seenValues = new Set(); + + // Add tabs in saved order + for (const value of savedOrder) { + const tab = tabMap.get(value); + if (tab) { + reordered.push(tab); + seenValues.add(value); + } + } + + // Add any new tabs that weren't in the saved order + for (const tab of originalTabs) { + if (!seenValues.has(tab.value)) { + reordered.push(tab); + } + } + + setOrderedTabs(reordered); + }, [originalTabs, savedOrder, storageKey]); + + const tabs = storageKey ? orderedTabs : originalTabs; + + // Update tabs when value changes + useEffect(() => { + const tabs = ref.current?.querySelectorAll("[data-tab]"); + for (const tab of tabs ?? []) { + const v = tab.getAttribute("data-tab"); + const parent = tab.closest(".tabs-container"); + if (parent !== ref.current) { + // Tab is part of a nested tab container, so ignore it + } else if (v === value) { + tab.setAttribute("data-state", "active"); + tab.setAttribute("aria-hidden", "false"); + tab.style.display = "block"; + } else { + tab.setAttribute("data-state", "inactive"); + tab.setAttribute("aria-hidden", "true"); + tab.style.display = "none"; + } + } + }, [value]); + + // Drag and drop handlers + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + const onDragStart = useCallback( + (e: DragStartEvent) => { + const tab = tabs.find((t) => t.value === e.active.id); + setIsDragging(tab ?? null); + }, + [tabs], + ); + + const onDragMove = useCallback( + (e: DragMoveEvent) => { + const overId = e.over?.id as string | undefined; + if (!overId) return setHoveredIndex(null); + + const overTab = tabs.find((t) => t.value === overId); + if (overTab == null) return setHoveredIndex(null); + + // For vertical layout, tabs are arranged horizontally (side-by-side) + const orientation = layout === "vertical" ? "horizontal" : "vertical"; + const side = computeSideForDragMove(overTab.value, e, orientation); + + // If computeSideForDragMove returns null (shouldn't happen but be safe), default to null + if (side === null) return setHoveredIndex(null); + + const overIndex = tabs.findIndex((t) => t.value === overId); + const hoveredIndex = overIndex + (side === "before" ? 0 : 1); + + setHoveredIndex(hoveredIndex); + }, + [tabs, layout], + ); + + const onDragCancel = useCallback(() => { + setIsDragging(null); + setHoveredIndex(null); + }, []); + + const onDragEnd = useCallback( + (e: DragEndEvent) => { + setIsDragging(null); + setHoveredIndex(null); + + const activeId = e.active.id as string | undefined; + const overId = e.over?.id as string | undefined; + if (!activeId || !overId || activeId === overId) return; + + const from = tabs.findIndex((t) => t.value === activeId); + const baseTo = tabs.findIndex((t) => t.value === overId); + const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); + + if (from !== -1 && to !== -1 && from !== to) { + const newTabs = [...tabs]; + const [moved] = newTabs.splice(from, 1); + if (moved === undefined) return; + newTabs.splice(to > from ? to - 1 : to, 0, moved); + + setOrderedTabs(newTabs); + + // Save order to storage + setSavedOrder(newTabs.map((t) => t.value)).catch(console.error); + } + }, + [tabs, hoveredIndex, setSavedOrder], + ); + + const tabButtons = useMemo(() => { + const items: ReactNode[] = []; + tabs.forEach((t, i) => { + if ("hidden" in t && t.hidden) { + return; + } + + const isActive = t.value === value; + const showDropMarkerBefore = hoveredIndex === i; + + if (showDropMarkerBefore) { + items.push( +
+ +
, + ); + } + + items.push( + , + ); + }); + return items; + }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); + + const tabList = ( +
+
+ {tabButtons} + {hoveredIndex === tabs.length && ( +
+ +
+ )} +
+
+ ); + + return ( +
+ {reorderable ? ( + + {tabList} + + {isDragging && ( + + )} + + + ) : ( + tabList + )} + {children} +
+ ); +}); + +interface TabButtonProps { + tab: TabItem; + isActive: boolean; + addBorders?: boolean; + layout: "horizontal" | "vertical"; + reorderable: boolean; + isDragging: boolean; + onChangeValue?: (value: string) => void; + overlay?: boolean; +} + +function TabButton({ + tab, + isActive, + addBorders, + layout, + reorderable, + isDragging, + onChangeValue, + overlay = false, +}: TabButtonProps) { + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + } = useDraggable({ + id: tab.value, + disabled: !reorderable, + // The button inside handles focus + attributes: { tabIndex: -1 }, + }); + const { setNodeRef: setDroppableRef } = useDroppable({ + id: tab.value, + disabled: !reorderable, + }); + + const handleSetWrapperRef = useCallback( + (n: HTMLDivElement | null) => { + if (reorderable) { + setDraggableRef(n); + setDroppableRef(n); + } + }, + [reorderable, setDraggableRef, setDroppableRef], + ); + + const btnProps: Partial = { + color: "custom", + justify: layout === "horizontal" ? "start" : "center", + onClick: isActive + ? undefined + : (e: React.MouseEvent) => { + e.preventDefault(); // Prevent dropdown from opening on first click + onChangeValue?.(tab.value); + }, + className: classNames( + "flex items-center rounded-sm whitespace-nowrap", + "px-2! ml-px", + "outline-hidden", + "ring-none", + "focus-visible-or-class:outline-2", + addBorders && "border focus-visible:bg-surface-highlight", + isActive ? "text-text" : "text-text-subtle", + isActive && addBorders + ? "border-surface-active bg-surface-active" + : layout === "vertical" + ? "border-border-subtle" + : "border-transparent", + layout === "horizontal" && "min-w-40", + isDragging && "opacity-50", + overlay && "opacity-80", + ), + }; + + const buttonContent = + "renderButton" in tab ? ( + tab.renderButton({ buttonProps: btnProps, isActive }) + ) : ( + + ); + + // Apply drag handlers to wrapper, not button + const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; + + return ( +
+ {buttonContent} +
+ ); +} + +export interface TabContentProps { + value: string; + children: ReactNode; + className?: string; + /** Catches errors thrown while rendering the tab, so they stay inside it */ + ErrorBoundary?: ComponentType<{ name: string; children: ReactNode }>; +} + +export const TabContent = memo(function TabContent({ + value, + children, + className, + ErrorBoundary, +}: TabContentProps) { + const content = ( +
+ {children} +
+ ); + return ErrorBoundary ? {content} : content; +}); diff --git a/packages/ui/src/components/tags/HttpMethodTag.tsx b/packages/ui/src/components/tags/HttpMethodTag.tsx new file mode 100644 index 000000000..d504a288a --- /dev/null +++ b/packages/ui/src/components/tags/HttpMethodTag.tsx @@ -0,0 +1,88 @@ +import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; +import classNames from "classnames"; +import { memo } from "react"; + +interface Props { + request: HttpRequest | GrpcRequest | WebsocketRequest; + className?: string; + short?: boolean; + noAlias?: boolean; + colored?: boolean; +} + +const methodNames: Record = { + get: "GET", + put: "PUT", + post: "POST", + patch: "PTCH", + delete: "DELE", + options: "OPTN", + head: "HEAD", + query: "QURY", + graphql: "GQL", + grpc: "GRPC", + websocket: "WS", +}; + +export const HttpMethodTag = memo(function HttpMethodTag({ + request, + className, + short, + noAlias, + colored, +}: Props) { + const method = + request.model === "http_request" && request.bodyType === "graphql" && !noAlias + ? "graphql" + : request.model === "grpc_request" + ? "grpc" + : request.model === "websocket_request" + ? "websocket" + : request.method; + + return ; +}); + +export function HttpMethodTagRaw({ + className, + method, + short, + colored, +}: { + method: string; + className?: string; + short?: boolean; + colored?: boolean; +}) { + let label = method.toUpperCase(); + if (short) { + label = methodNames[method.toLowerCase()] ?? method.slice(0, 4); + label = label.padEnd(4, " "); + } + + const m = method.toUpperCase(); + + return ( + + {label} + + ); +} diff --git a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx similarity index 100% rename from apps/yaak-client/components/core/HttpResponseDurationTag.tsx rename to packages/ui/src/components/tags/HttpResponseDurationTag.tsx diff --git a/apps/yaak-client/components/core/HttpStatusTag.tsx b/packages/ui/src/components/tags/HttpStatusTag.tsx similarity index 100% rename from apps/yaak-client/components/core/HttpStatusTag.tsx rename to packages/ui/src/components/tags/HttpStatusTag.tsx diff --git a/apps/yaak-client/components/core/SizeTag.tsx b/packages/ui/src/components/tags/SizeTag.tsx similarity index 100% rename from apps/yaak-client/components/core/SizeTag.tsx rename to packages/ui/src/components/tags/SizeTag.tsx diff --git a/apps/yaak-client/components/responseViewers/AudioViewer.tsx b/packages/ui/src/components/viewers/AudioViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/AudioViewer.tsx rename to packages/ui/src/components/viewers/AudioViewer.tsx diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx b/packages/ui/src/components/viewers/CsvViewer.test.tsx similarity index 97% rename from apps/yaak-client/components/responseViewers/CsvViewer.test.tsx rename to packages/ui/src/components/viewers/CsvViewer.test.tsx index e3df58c4c..05dc52e0b 100644 --- a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx +++ b/packages/ui/src/components/viewers/CsvViewer.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test, vi } from "vite-plus/test"; import { CsvViewerInner } from "./CsvViewer"; -vi.mock("@yaakapp-internal/ui", () => ({ +vi.mock("../Table", () => ({ Table: ({ children }: { children: ReactNode }) => {children}
, TableBody: ({ children }: { children: ReactNode }) => {children}, TableCell: ({ children }: { children: ReactNode }) => {children}, diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.tsx b/packages/ui/src/components/viewers/CsvViewer.tsx similarity index 93% rename from apps/yaak-client/components/responseViewers/CsvViewer.tsx rename to packages/ui/src/components/viewers/CsvViewer.tsx index f318a4374..031c37315 100644 --- a/apps/yaak-client/components/responseViewers/CsvViewer.tsx +++ b/packages/ui/src/components/viewers/CsvViewer.tsx @@ -1,14 +1,7 @@ import classNames from "classnames"; import Papa from "papaparse"; import { useMemo } from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, -} from "@yaakapp-internal/ui"; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "../Table"; interface Props { text: string | null; diff --git a/apps/yaak-client/components/responseViewers/ImageViewer.tsx b/packages/ui/src/components/viewers/ImageViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/ImageViewer.tsx rename to packages/ui/src/components/viewers/ImageViewer.tsx diff --git a/apps/yaak-client/components/core/JsonAttributeTree.tsx b/packages/ui/src/components/viewers/JsonAttributeTree.tsx similarity index 99% rename from apps/yaak-client/components/core/JsonAttributeTree.tsx rename to packages/ui/src/components/viewers/JsonAttributeTree.tsx index 58e6a4d4e..274ffdb4e 100644 --- a/apps/yaak-client/components/core/JsonAttributeTree.tsx +++ b/packages/ui/src/components/viewers/JsonAttributeTree.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; -import { Icon } from "@yaakapp-internal/ui"; +import { Icon } from "../Icon"; interface Props { depth?: number; diff --git a/apps/yaak-client/components/responseViewers/JsonViewer.tsx b/packages/ui/src/components/viewers/JsonViewer.tsx similarity index 86% rename from apps/yaak-client/components/responseViewers/JsonViewer.tsx rename to packages/ui/src/components/viewers/JsonViewer.tsx index 3cd02837e..298ef01c6 100644 --- a/apps/yaak-client/components/responseViewers/JsonViewer.tsx +++ b/packages/ui/src/components/viewers/JsonViewer.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { JsonAttributeTree } from "../core/JsonAttributeTree"; +import { JsonAttributeTree } from "./JsonAttributeTree"; interface Props { text: string; diff --git a/packages/ui/src/components/viewers/MultipartViewer.tsx b/packages/ui/src/components/viewers/MultipartViewer.tsx new file mode 100644 index 000000000..50a176537 --- /dev/null +++ b/packages/ui/src/components/viewers/MultipartViewer.tsx @@ -0,0 +1,151 @@ +import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { Banner } from "../Banner"; +import { Icon } from "../Icon"; +import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs"; +import { AudioViewer } from "./AudioViewer"; +import { CsvViewer } from "./CsvViewer"; +import { ImageViewer } from "./ImageViewer"; +import { SvgViewer } from "./SvgViewer"; +import { TextViewer, type TextViewerProps } from "./TextViewer"; +import { VideoViewer } from "./VideoViewer"; +import { WebPageViewer } from "./WebPageViewer"; + +export interface MultipartViewerProps { + data: Uint8Array; + boundary: string; + idPrefix?: string; + /** Renders a PDF part, which shows as text without it */ + renderPdf?: (data: Uint8Array) => ReactNode; + /** Passed to the viewer of each text part */ + textViewerProps?: Pick; + ErrorBoundary?: TabContentProps["ErrorBoundary"]; +} + +export function MultipartViewer({ + data, + boundary, + idPrefix = "multipart", + renderPdf, + textViewerProps, + ErrorBoundary, +}: MultipartViewerProps) { + const parseResult = useMemo(() => { + try { + const maxFileSize = 1024 * 1024 * 10; // 10MB + const parsed = parseMultipart(data, { boundary, maxFileSize }); + const parts = Array.from(parsed); + return { parts, error: null }; + } catch (err) { + return { parts: [], error: err instanceof Error ? err.message : String(err) }; + } + }, [data, boundary]); + + const { parts, error } = parseResult; + + if (error) { + return ( + + Failed to parse multipart data: {error} + + ); + } + + if (parts.length === 0) { + return ( + + No multipart parts found + + ); + } + + return ( + ({ + label: part.name ?? "", + value: tabValue(part, i), + rightSlot: + part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? ( +
+ +
+ ) : part.filename ? ( + + ) : null, + }))} + > + {parts.map((part, i) => ( + + + + ))} +
+ ); +} + +function Part({ + part, + renderPdf, + textViewerProps, +}: { part: MultipartPart } & Pick) { + const mimeType = part.headers.contentType.mediaType ?? null; + const contentTypeHeader = part.headers.get("content-type"); + + const { uint8Array, content, detectedLanguage } = useMemo(() => { + const uint8Array = new Uint8Array(part.arrayBuffer); + const content = new TextDecoder().decode(part.arrayBuffer); + const detectedLanguage = languageFromContentType(contentTypeHeader, content); + return { uint8Array, content, detectedLanguage }; + }, [part, contentTypeHeader]); + + if (mimeType?.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/^image/i)) { + return ; + } + + if (mimeType?.match(/^audio/i)) { + return ; + } + + if (mimeType?.match(/^video/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + return ; + } + + if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") { + return ; + } + + if (mimeType?.match(/pdf/i) && renderPdf != null) { + return renderPdf(uint8Array); + } + + return ( + + ); +} + +function tabValue(part: MultipartPart, i: number) { + return `${part.name ?? ""}::${i}`; +} diff --git a/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx b/packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx similarity index 96% rename from apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx rename to packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx index ba03595f4..02840d2d0 100644 --- a/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx +++ b/packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx @@ -1,9 +1,9 @@ -import { Icon } from "@yaakapp-internal/ui"; +import { Icon } from "../Icon"; import classNames from "classnames"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { JsonPathSegment } from "../core/Editor/json/jsonPath"; -import { IconButton } from "../core/IconButton"; -import { Tooltip } from "../core/Tooltip"; +import type { JsonPathSegment } from "../editor/json/jsonPath"; +import { IconButton } from "../IconButton"; +import { Tooltip } from "../Tooltip"; interface Props { segments: JsonPathSegment[]; diff --git a/apps/yaak-client/components/responseViewers/SvgViewer.tsx b/packages/ui/src/components/viewers/SvgViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/SvgViewer.tsx rename to packages/ui/src/components/viewers/SvgViewer.tsx diff --git a/packages/ui/src/components/viewers/TextViewer.tsx b/packages/ui/src/components/viewers/TextViewer.tsx new file mode 100644 index 000000000..6e25defe9 --- /dev/null +++ b/packages/ui/src/components/viewers/TextViewer.tsx @@ -0,0 +1,262 @@ +import type { Extension } from "@codemirror/state"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { Children, useCallback, useMemo, useState } from "react"; +import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter"; +import { docFingerprint } from "../../lib/docFingerprint"; +import type { CodeEditorProps } from "../editor/CodeEditor"; +import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension"; +import type { JsonPathSegment } from "../editor/json/jsonPath"; +import { jsonPathToSegments, segmentsToJsonPath } from "../editor/json/jsonPath"; +import { LazyCodeEditor } from "../editor/LazyCodeEditor"; +import { IconButton } from "../IconButton"; +import { Input } from "../Input"; +import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar"; + +export interface TextViewerProps { + text: string; + language: CodeEditorProps["language"]; + stateKey: string | null; + pretty?: boolean; + className?: string; + footerActions?: ReactNode; + extensions?: Extension[]; + /** Pretty-prints the text when `pretty` is set */ + formatText?: (text: string, language: CodeEditorProps["language"]) => Promise; + /** Evaluates a JSONPath or XPath expression, rejecting when it's invalid. Enables the filter box */ + filterText?: (expression: string) => Promise; + /** Where the filter's state is kept, when it outlives the text (defaults to `stateKey`) */ + filterStateKey?: string | null; + /** Called once the filter has accepted an applied expression */ + onFilterApplied?: (expression: string) => void; + /** Rendered at the start of the filter box */ + filterLeftSlot?: (filter: TextFilterApi) => ReactNode; + /** Renders the editors, for a host whose editor builds on CodeEditor */ + renderEditor?: (props: CodeEditorProps) => ReactNode; +} + +export function TextViewer({ + language, + text, + stateKey, + pretty, + className, + footerActions, + extensions, + formatText, + filterText, + filterStateKey = stateKey, + onFilterApplied, + filterLeftSlot, + renderEditor = renderCodeEditor, +}: TextViewerProps) { + // Track the JSON path under the cursor to drive the breadcrumb bar. Selection + // works even in this read-only editor, so it updates as the user clicks around. + const [breadcrumbSegments, setBreadcrumbSegments] = useState([]); + const handleBreadcrumbUpdate = useCallback( + ({ segments }: { segments: JsonPathSegment[] | null }) => + setBreadcrumbSegments((prev) => { + const next = segments ?? []; + return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next; + }), + [], + ); + const extraExtensions = useMemo( + () => + language === "json" + ? [...(extensions ?? []), jsonBreadcrumbExtension(handleBreadcrumbUpdate)] + : (extensions ?? []), + [language, extensions, handleBreadcrumbUpdate], + ); + + const filter = useTextFilter({ + stateKey: filterStateKey, + runFilter: filterText ?? rejectFilter, + onApplied: onFilterApplied, + }); + const hasFilter = filterText != null; + + // Exact, so two same-length bodies never share a cached filter result + const textFingerprint = useMemo(() => docFingerprint(text, { exact: true }), [text]); + const filterResult = useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: [ + "text_viewer_filter", + filterStateKey, + stateKey, + textFingerprint, + filter.appliedFilter, + ], + queryFn: () => (filterText ?? rejectFilter)(filter.appliedFilter ?? ""), + enabled: hasFilter && filter.appliedFilter != null, + }); + + const canFilter = hasFilter && (language === "json" || language === "xml" || language === "html"); + const { isSearching, appliedFilter } = filter; + const resultError = !!filterResult.error; + + // Filter output is always an array of matches, so under a plain-path filter the + // cursor's leading index is that single match and the rest extends the filter. + const appliedSegments = + appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null; + const pathSegments = + appliedSegments != null + ? [...appliedSegments, ...breadcrumbSegments.slice(1)] + : breadcrumbSegments; + const showBreadcrumbs = + hasFilter && (appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0)); + + const handleFilterKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + filter.toggleSearch(); + } else if (e.key === "Enter" && filter.filterText != null) { + filter.applyFilter(filter.filterText); + } + }, + [filter], + ); + + const actions = useMemo(() => { + const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); + + if (!canFilter) return nodes; + + if (isSearching) { + nodes.push( +
+ + +
+ } + /> +
, + ); + } else { + nodes.push( + , + ); + } + + return nodes; + }, [ + canFilter, + footerActions, + filter, + filterResult.isPending, + filterResult.isFetching, + filterLeftSlot, + renderEditor, + resultError, + isSearching, + language, + handleFilterKeyDown, + ]); + + const formattedBody = useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ["text_viewer_format", text, language, pretty ?? false], + queryFn: async () => { + if (text === "" || !pretty || formatText == null) { + return text; + } + return formatText(text, language); + }, + }).data; + if (formattedBody == null) { + return null; + } + + let body: string; + if (appliedFilter) { + if (resultError) { + body = ""; + } else { + body = filterResult.data != null ? filterResult.data : ""; + } + } else { + body = formattedBody; + } + + // Decode unicode sequences in the text to readable characters + if (language === "json" && pretty) { + body = decodeUnicodeLiterals(body); + body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks + } + + return ( +
+ {renderEditor({ + readOnly: true, + className, + defaultValue: body, + language, + actions, + extensions: extraExtensions, + stateKey, + })} + {showBreadcrumbs && ( +
+ + filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count)) + } + /> +
+ )} +
+ ); +} + +function renderCodeEditor(props: CodeEditorProps) { + return ; +} + +function rejectFilter(): Promise { + return Promise.reject(new Error("No filter")); +} + +/** Convert \uXXXX to actual Unicode characters */ +function decodeUnicodeLiterals(text: string): string { + return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { + const charCode = Number.parseInt(hex, 16); + return String.fromCharCode(charCode); + }); +} diff --git a/apps/yaak-client/components/responseViewers/VideoViewer.tsx b/packages/ui/src/components/viewers/VideoViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/VideoViewer.tsx rename to packages/ui/src/components/viewers/VideoViewer.tsx diff --git a/apps/yaak-client/components/responseViewers/WebPageViewer.tsx b/packages/ui/src/components/viewers/WebPageViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/WebPageViewer.tsx rename to packages/ui/src/components/viewers/WebPageViewer.tsx diff --git a/packages/ui/src/editor.ts b/packages/ui/src/editor.ts new file mode 100644 index 000000000..d49dc84a9 --- /dev/null +++ b/packages/ui/src/editor.ts @@ -0,0 +1,15 @@ +export type { CodeEditorProps } from "./components/editor/CodeEditor"; +export { CodeEditor } from "./components/editor/CodeEditor"; +export type { LanguageSupports } from "./components/editor/extensions"; +export { + closeBracketsExtensions, + getLanguageExtension, + readonlyExtensions, + syntaxExtensions, + syntaxHighlightStyle, +} from "./components/editor/extensions"; +export type { JsonLintOptions } from "./components/editor/json-lint"; +export { jsonParseLinter } from "./components/editor/json-lint"; +export { searchMatchCount } from "./components/editor/searchMatchCount"; +export { singleLineExtensions } from "./components/editor/singleLine"; +export { text, textLanguage } from "./components/editor/text/extension"; diff --git a/apps/yaak-client/hooks/useStateWithDeps.ts b/packages/ui/src/hooks/useStateWithDeps.ts similarity index 100% rename from apps/yaak-client/hooks/useStateWithDeps.ts rename to packages/ui/src/hooks/useStateWithDeps.ts diff --git a/packages/ui/src/hooks/useStoredValue.ts b/packages/ui/src/hooks/useStoredValue.ts new file mode 100644 index 000000000..10906066b --- /dev/null +++ b/packages/ui/src/hooks/useStoredValue.ts @@ -0,0 +1,42 @@ +import { atom, useAtom } from "jotai"; +import { atomFamily } from "jotai-family"; +import { useCallback, useState } from "react"; + +export type StoredValue = object | boolean | number | string | null; + +export type SetStoredValue = (value: T | ((prev: T) => T)) => void | Promise; + +/** + * Reads and writes a value kept under `key`, falling back to `fallback` until one is written. A + * `null` key keeps the value for the component's lifetime only. Components that remember things + * take one of these, so each host decides where the memory lives. + */ +export type UseStoredValue = ( + key: string | string[] | null, + fallback: T, +) => readonly [T, SetStoredValue]; + +const memoryAtoms = atomFamily((_key: string) => atom<{ value: unknown } | null>(null)); + +/** The default {@link UseStoredValue}: kept in memory for the session */ +export function useInMemoryStoredValue( + key: string | string[] | null, + fallback: T, +): readonly [T, SetStoredValue] { + const memoryKey = key == null ? null : Array.isArray(key) ? key.join("::") : key; + const [local, setLocal] = useState(fallback); + const [stored, setStored] = useAtom(memoryAtoms(memoryKey ?? "")); + + const storedValue = stored == null ? fallback : (stored.value as T); + const setStoredValue = useCallback>( + (update) => + setStored((prev) => { + const prevValue = prev == null ? fallback : (prev.value as T); + const value = typeof update === "function" ? (update as (p: T) => T)(prevValue) : update; + return { value }; + }), + [fallback, setStored], + ); + + return memoryKey == null ? [local, setLocal] : [storedValue, setStoredValue]; +} diff --git a/packages/ui/src/hooks/useTextFilter.ts b/packages/ui/src/hooks/useTextFilter.ts new file mode 100644 index 000000000..e8cccc6ad --- /dev/null +++ b/packages/ui/src/hooks/useTextFilter.ts @@ -0,0 +1,114 @@ +import { atom, useAtom } from "jotai"; +import { atomFamily } from "jotai-family"; +import { useCallback, useState } from "react"; + +/** What's typed in the filter box. `null` means the filter box is closed */ +const filterTextAtoms = atomFamily((_stateKey: string) => atom(null)); + +/** What's actually applied to the text. Only changes on an explicit apply */ +const appliedFilterAtoms = atomFamily((_stateKey: string) => atom(null)); + +export interface TextFilterApi { + stateKey: string | null; + /** Draft text in the filter box, or `null` when the box is closed */ + filterText: string | null; + /** The expression currently filtering the text */ + appliedFilter: string | null; + isSearching: boolean; + /** The box holds an expression that isn't the one currently applied */ + isDirty: boolean; + /** Bumped when the (uncontrolled) filter input must re-read its defaultValue */ + filterUpdateKey: number; + setFilterText: (value: string | null) => void; + /** Apply the expression to the text, reporting it to `onApplied` if the filter accepts it */ + applyFilter: (value: string) => void; + /** Like applyFilter, but also replaces what's shown in the filter box */ + replaceFilter: (value: string) => void; + toggleSearch: () => void; +} + +/** + * Draft/applied state for a text filter (JSONPath/XPath). + * + * `onApplied` hears at most once per apply gesture, and only after `runFilter` + * confirms the filter accepts the expression — the filter is the sole judge of + * validity. Because nothing but the gesture reports, refetches can't resurrect + * deleted history entries and a gesture can't report into another state key. + */ +export function useTextFilter({ + stateKey, + runFilter, + onApplied, +}: { + stateKey: string | null; + /** Evaluate an expression, rejecting if the filter reports an error */ + runFilter: (filter: string) => Promise; + onApplied?: (filter: string) => void; +}): TextFilterApi { + const [storedFilterText, setStoredFilterText] = useAtom(filterTextAtoms(stateKey ?? "")); + const [storedAppliedFilter, setStoredAppliedFilter] = useAtom(appliedFilterAtoms(stateKey ?? "")); + const filterText = stateKey ? storedFilterText : null; + const appliedFilter = stateKey ? storedAppliedFilter : null; + + const setFilterText = useCallback( + (v: string | null) => { + if (!stateKey) return; + setStoredFilterText(v); + }, + [stateKey, setStoredFilterText], + ); + + const setAppliedFilter = useCallback( + (v: string | null) => { + if (!stateKey) return; + setStoredAppliedFilter(v); + }, + [stateKey, setStoredAppliedFilter], + ); + + const applyFilter = useCallback( + (value: string) => { + setFilterText(value); + const applied = value.trim() === "" ? null : value.trim(); + setAppliedFilter(applied); + if (applied == null) return; + runFilter(applied).then( + () => onApplied?.(applied), + () => {}, // Rejected by the filter — don't report + ); + }, + [setFilterText, setAppliedFilter, runFilter, onApplied], + ); + + const [filterUpdateKey, setFilterUpdateKey] = useState(0); + const replaceFilter = useCallback( + (value: string) => { + applyFilter(value); + setFilterUpdateKey((k) => k + 1); + }, + [applyFilter], + ); + + const isSearching = filterText != null; + const toggleSearch = useCallback(() => { + if (isSearching) { + setFilterText(null); + setAppliedFilter(null); + } else { + setFilterText(""); + } + }, [isSearching, setFilterText, setAppliedFilter]); + + return { + stateKey, + filterText, + appliedFilter, + isSearching, + isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""), + filterUpdateKey, + setFilterText, + applyFilter, + replaceFilter, + toggleSearch, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 338c4dce9..a8f460a9c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,15 +2,29 @@ export type { BannerProps } from "./components/Banner"; export { Banner } from "./components/Banner"; export type { ButtonProps } from "./components/Button"; export { Button } from "./components/Button"; +export type { CopyIconButtonProps } from "./components/CopyIconButton"; +export { CopyIconButton } from "./components/CopyIconButton"; +export { CountBadge } from "./components/CountBadge"; +export type { DetailsBannerProps } from "./components/DetailsBanner"; +export { DetailsBanner } from "./components/DetailsBanner"; export { DropMarker } from "./components/DropMarker"; +export { LazyCodeEditor } from "./components/editor/LazyCodeEditor"; +export { FormattedError } from "./components/FormattedError"; export { HeaderSize } from "./components/HeaderSize"; +export type { HeadersTableProps } from "./components/HeadersTable"; +export { HeadersTable } from "./components/HeadersTable"; export { Heading } from "./components/Heading"; export type { IconProps } from "./components/Icon"; export { Icon } from "./components/Icon"; export type { IconButtonProps } from "./components/IconButton"; export { IconButton } from "./components/IconButton"; -export { FormattedError } from "./components/FormattedError"; +export { IconTooltip } from "./components/IconTooltip"; export { InlineCode } from "./components/InlineCode"; +export type { InputHandle, InputProps } from "./components/Input"; +export { Input } from "./components/Input"; +export type { KeyValueRowProps } from "./components/KeyValueRow"; +export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow"; +export { Label } from "./components/Label"; export { LoadingIcon } from "./components/LoadingIcon"; export { Overlay } from "./components/Overlay"; export { Portal } from "./components/Portal"; @@ -30,19 +44,53 @@ export { TableRow, TruncatedWideTableCell, } from "./components/Table"; +export type { + TabButtonRenderProps, + TabContentProps, + TabItem, + TabsProps, + TabsRef, + TabsStorage, +} from "./components/tabs/Tabs"; +export { TabContent, Tabs } from "./components/tabs/Tabs"; +export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; +export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; +export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; +export { SizeTag } from "./components/tags/SizeTag"; +export type { TooltipProps } from "./components/Tooltip"; +export { Tooltip } from "./components/Tooltip"; export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms"; export type { TreeNode } from "./components/tree/common"; export type { TreeHandle, TreeProps } from "./components/tree/Tree"; export { Tree } from "./components/tree/Tree"; export type { TreeItemProps } from "./components/tree/TreeItem"; +export { AudioViewer } from "./components/viewers/AudioViewer"; +export { CsvViewer, CsvViewerInner } from "./components/viewers/CsvViewer"; +export { ImageViewer } from "./components/viewers/ImageViewer"; +export { JsonAttributeTree } from "./components/viewers/JsonAttributeTree"; +export { JsonViewer } from "./components/viewers/JsonViewer"; +export type { MultipartViewerProps } from "./components/viewers/MultipartViewer"; +export { MultipartViewer } from "./components/viewers/MultipartViewer"; +export { SvgViewer } from "./components/viewers/SvgViewer"; +export type { TextViewerProps } from "./components/viewers/TextViewer"; +export { TextViewer } from "./components/viewers/TextViewer"; +export { VideoViewer } from "./components/viewers/VideoViewer"; +export { WebPageViewer } from "./components/viewers/WebPageViewer"; export { WindowControls } from "./components/WindowControls"; export { useContainerSize } from "./hooks/useContainerSize"; export { useDebouncedState } from "./hooks/useDebouncedState"; export { useDebouncedValue } from "./hooks/useDebouncedValue"; export { useIsFullscreen } from "./hooks/useIsFullscreen"; export { usePortal } from "./hooks/usePortal"; +export { useStateWithDeps } from "./hooks/useStateWithDeps"; +export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue"; +export { useInMemoryStoredValue } from "./hooks/useStoredValue"; +export type { TextFilterApi } from "./hooks/useTextFilter"; +export { useTextFilter } from "./hooks/useTextFilter"; export { useTimedBoolean } from "./hooks/useTimedBoolean"; export { clamp } from "./lib/clamp"; export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; +export { languageFromContent, languageFromContentType } from "./lib/contentType"; export { computeSideForDragMove } from "./lib/dnd"; +export { docFingerprint } from "./lib/docFingerprint"; export { minPromiseMillis } from "./lib/minPromiseMillis"; diff --git a/packages/ui/src/lib/contentType.ts b/packages/ui/src/lib/contentType.ts new file mode 100644 index 000000000..6b01b0483 --- /dev/null +++ b/packages/ui/src/lib/contentType.ts @@ -0,0 +1,55 @@ +import type { EditorLanguage } from "@yaakapp-internal/plugins"; + +export function languageFromContentType( + contentType: string | null, + content: string | null = null, +): EditorLanguage | undefined { + const justContentType = contentType?.split(";")[0] ?? contentType ?? ""; + if (justContentType.includes("json")) { + return "json"; + } + if (justContentType.includes("xml")) { + return "xml"; + } + if (justContentType.includes("html")) { + const detected = languageFromContent(content); + if (detected === "xml") { + // If it's detected as XML, but is already HTML, don't change it + return "html"; + } + return detected; + } + if (justContentType.includes("javascript")) { + // Sometimes `application/javascript` returns JSON, so try detecting that + return languageFromContent(content, "javascript"); + } + if (justContentType.includes("markdown")) { + return "markdown"; + } + + return languageFromContent(content, "text"); +} + +export function languageFromContent( + content: string | null, + fallback?: EditorLanguage, +): EditorLanguage | undefined { + if (content == null) return "text"; + + const firstBytes = content.slice(0, 20).trim(); + + if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) { + return "json"; + } + if ( + firstBytes.toLowerCase().startsWith(" Date: Wed, 14 Oct 2026 21:51:40 -0700 Subject: [PATCH 106/155] Move the MCP server into Rust, hosted by the app and `yaak mcp` (#752) --- Cargo.lock | 121 ++++ Cargo.toml | 2 + .../components/Settings/Settings.tsx | 14 +- .../components/Settings/SettingsAgents.tsx | 198 ++++++ .../components/SettingsDropdown.tsx | 5 +- crates-cli/yaak-cli/Cargo.toml | 2 + crates-cli/yaak-cli/src/cli.rs | 3 + crates-cli/yaak-cli/src/commands/mcp.rs | 659 ++++++++++++++++++ crates-cli/yaak-cli/src/commands/mod.rs | 1 + crates-cli/yaak-cli/src/context.rs | 12 +- crates-cli/yaak-cli/src/main.rs | 14 + crates-cli/yaak-cli/src/plugin_events.rs | 24 +- crates-cli/yaak-cli/tests/mcp_command.rs | 224 ++++++ crates-tauri/yaak-app-client/Cargo.toml | 1 + crates-tauri/yaak-app-client/src/lib.rs | 2 + crates-tauri/yaak-app-client/src/mcp_ext.rs | 192 +++++ .../yaak-app-client/src/models_ext.rs | 10 +- .../yaak-app-client/src/plugin_events.rs | 120 ++-- .../yaak-rpc-schema/bindings/gen_models.ts | 1 + crates/yaak-mcp/Cargo.toml | 28 + crates/yaak-mcp/src/http.rs | 124 ++++ crates/yaak-mcp/src/lib.rs | 63 ++ crates/yaak-mcp/src/params.rs | 281 ++++++++ crates/yaak-mcp/src/server.rs | 338 +++++++++ crates/yaak-mcp/tests/http.rs | 98 +++ crates/yaak-models/bindings/gen_models.ts | 1 + .../20261014000000_mcp-server-enabled.sql | 3 + crates/yaak-models/src/models.rs | 4 + crates/yaak-models/src/queries/settings.rs | 1 + crates/yaak-plugins/bindings/gen_models.ts | 1 + packages/platform/src/tauri/index.ts | 1 + packages/platform/src/types.ts | 2 + packages/platform/src/web/index.ts | 2 + plugins-external/mcp-server/README.md | 3 + plugins-external/mcp-server/src/index.ts | 7 + 35 files changed, 2501 insertions(+), 61 deletions(-) create mode 100644 apps/yaak-client/components/Settings/SettingsAgents.tsx create mode 100644 crates-cli/yaak-cli/src/commands/mcp.rs create mode 100644 crates-cli/yaak-cli/tests/mcp_command.rs create mode 100644 crates-tauri/yaak-app-client/src/mcp_ext.rs create mode 100644 crates/yaak-mcp/Cargo.toml create mode 100644 crates/yaak-mcp/src/http.rs create mode 100644 crates/yaak-mcp/src/lib.rs create mode 100644 crates/yaak-mcp/src/params.rs create mode 100644 crates/yaak-mcp/src/server.rs create mode 100644 crates/yaak-mcp/tests/http.rs create mode 100644 crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql diff --git a/Cargo.lock b/Cargo.lock index d6a1f96d6..255c7cac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1682,6 +1682,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1708,6 +1718,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1730,6 +1753,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -5717,6 +5751,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-slash" version = "0.2.1" @@ -6781,6 +6821,51 @@ dependencies = [ "libc", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.0", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rolldown" version = "0.1.0" @@ -7452,6 +7537,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.1", @@ -8162,6 +8248,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -11148,6 +11247,7 @@ dependencies = [ "yaak-license", "yaak-lifecycle", "yaak-mac-window", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-rpc", @@ -11198,6 +11298,7 @@ dependencies = [ "predicates", "rand 0.8.7", "reqwest", + "rmcp", "rolldown", "schemars 0.8.22", "serde", @@ -11214,6 +11315,7 @@ dependencies = [ "yaak-crypto", "yaak-http", "yaak-lifecycle", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-templates", @@ -11419,6 +11521,25 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "yaak-mcp" +version = "0.1.0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "log 0.4.34", + "reqwest", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-util", + "yaak-models", + "yaak-plugins", +] + [[package]] name = "yaak-models" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d57dcb12..3780f5343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/yaak-grpc", "crates/yaak-http", "crates/yaak-lifecycle", + "crates/yaak-mcp", "crates/yaak-models", "crates/yaak-plugins", "crates/yaak-sse", @@ -89,6 +90,7 @@ yaak-git = { path = "crates/yaak-git" } yaak-grpc = { path = "crates/yaak-grpc" } yaak-http = { path = "crates/yaak-http" } yaak-lifecycle = { path = "crates/yaak-lifecycle" } +yaak-mcp = { path = "crates/yaak-mcp" } yaak-models = { path = "crates/yaak-models" } yaak-plugins = { path = "crates/yaak-plugins" } yaak-sse = { path = "crates/yaak-sse" } diff --git a/apps/yaak-client/components/Settings/Settings.tsx b/apps/yaak-client/components/Settings/Settings.tsx index a88e0b010..5c8c0fb68 100644 --- a/apps/yaak-client/components/Settings/Settings.tsx +++ b/apps/yaak-client/components/Settings/Settings.tsx @@ -8,6 +8,7 @@ import { useKeyPressEvent } from "react-use"; import { appInfo } from "../../lib/appInfo"; import { capitalize } from "../../lib/capitalize"; import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs"; +import { SettingsAgents } from "./SettingsAgents"; import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsGeneral } from "./SettingsGeneral"; import { SettingsHotkeys } from "./SettingsHotkeys"; @@ -30,6 +31,7 @@ const TAB_SHORTCUTS = "shortcuts"; const TAB_PROXY = "proxy"; const TAB_CERTIFICATES = "certificates"; const TAB_PLUGINS = "plugins"; +const TAB_AGENTS = "agents"; const TAB_LICENSE = "license"; const tabs = [ TAB_GENERAL, @@ -37,6 +39,7 @@ const tabs = [ TAB_INTERFACE, TAB_SHORTCUTS, TAB_PLUGINS, + TAB_AGENTS, TAB_CERTIFICATES, TAB_PROXY, TAB_LICENSE, @@ -92,7 +95,9 @@ export default function Settings({ tab, hide }: Props) { (value): TabItem => ({ value, label: capitalize(value), - hidden: !appInfo.featureLicense && value === TAB_LICENSE, + hidden: + (!appInfo.featureLicense && value === TAB_LICENSE) || + (!platform.capabilities.agents && value === TAB_AGENTS), leftSlot: value === TAB_GENERAL ? ( @@ -108,6 +113,8 @@ export default function Settings({ tab, hide }: Props) { ) : value === TAB_PLUGINS ? ( + ) : value === TAB_AGENTS ? ( + ) : value === TAB_LICENSE ? ( ) : null, @@ -116,6 +123,8 @@ export default function Settings({ tab, hide }: Props) { ) : value === TAB_PLUGINS ? ( p.source !== "bundled").length} /> + ) : value === TAB_AGENTS && settings.mcpServerEnabled ? ( + ) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? ( ) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? ( @@ -139,6 +148,9 @@ export default function Settings({ tab, hide }: Props) { + + + diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx new file mode 100644 index 000000000..84099f138 --- /dev/null +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -0,0 +1,198 @@ +import { useQuery } from "@tanstack/react-query"; +import { settingsAtom } from "@yaakapp-internal/models"; +import { platform } from "@yaakapp-internal/platform"; +import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import type { AppMetaData } from "@yaakapp-internal/rpc-schema"; +import { useAtomValue } from "jotai"; +import { useKeyValue } from "../../hooks/useKeyValue"; +import { appInfo } from "../../lib/appInfo"; +import { rpc } from "../../lib/rpc"; +import { CopyIconButton } from "../CopyIconButton"; +import { Button } from "../core/Button"; +import { IconButton } from "../core/IconButton"; +import { + ModelSettingRowBoolean, + SettingRow, + SettingValue, + SettingsList, + SettingsSection, +} from "../core/SettingRow"; + +const MCP_URL = "http://127.0.0.1:64343/mcp"; +const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli"; +const SKILL_INSTALL_COMMAND = "yaak agent install"; +const CLAUDE_CODE_COMMAND = "claude mcp add yaak -- yaak mcp"; + +export function SettingsAgents() { + const settings = useAtomValue(settingsAtom); + const { value: token } = useKeyValue({ + namespace: "no_sync", + key: "mcp_server_token", + fallback: null, + }); + const cliVersion = useQuery({ + queryKey: ["cli_version"], + queryFn: async () => (await rpc("cmd_metadata")).cliVersion, + initialData: appInfo.cliVersion, + }); + + return ( + +
+ Agents +

+ Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and + send your requests. +

+
+ + + + cliVersion.refetch()} + /> + {cliVersion.data == null && ( + + )} + + {cliVersion.data == null ? ( + + + + ) : ( + <> + + Teach the agents you have installed to drive the yaak{" "} + command. + + } + > + + + + + + + For other clients that read an mcpServers config, like + Cursor. + + } + snippet={mcpServersConfig("yaak", ["mcp"])} + /> + + )} + + + + + Serve Yaak's tools at {MCP_URL} while Yaak is open. + + } + /> + {settings.mcpServerEnabled && token != null && ( + <> + + + + + For clients that read an mcpServers config, like Claude + Desktop and Cursor. + + } + snippet={mcpServersConfig("npx", [ + "-y", + "mcp-remote", + MCP_URL, + "--header", + `Authorization: Bearer ${token}`, + ])} + /> + + )} + + +
+ ); +} + +function SnippetRow({ + title, + description, + snippet, +}: { + title: string; + description: React.ReactNode; + snippet: string; +}) { + return ( +
+
{title}
+
{description}
+
+
+          {snippet}
+        
+
+ +
+
+
+ ); +} + +function mcpServersConfig(command: string, args: string[]) { + return [ + "{", + ' "mcpServers": {', + ' "yaak": {', + ` "command": ${JSON.stringify(command)},`, + ` "args": [${args.map((a) => JSON.stringify(a)).join(", ")}]`, + " }", + " }", + "}", + ].join("\n"); +} diff --git a/apps/yaak-client/components/SettingsDropdown.tsx b/apps/yaak-client/components/SettingsDropdown.tsx index 2c9336bec..48a677f29 100644 --- a/apps/yaak-client/components/SettingsDropdown.tsx +++ b/apps/yaak-client/components/SettingsDropdown.tsx @@ -82,10 +82,9 @@ export function SettingsDropdown() { }, { label: "Install CLI", - hidden: appInfo.cliVersion != null, + hidden: appInfo.cliVersion != null || !platform.capabilities.agents, leftSlot: , - rightSlot: , - onSelect: () => platform.openUrl("https://yaak.app/docs/cli"), + onSelect: () => openSettings.mutate("agents"), }, { label: "Feedback", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index 50db83428..5378975dc 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -50,11 +50,13 @@ yaak-core = { workspace = true } yaak-crypto = { workspace = true } yaak-http = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-templates = { workspace = true } [dev-dependencies] assert_cmd = "2" +rmcp = { version = "3.5.1", default-features = false, features = ["client"] } predicates = "3" tempfile = "3" diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index 4589936fd..b27b98be9 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -92,6 +92,9 @@ pub enum Commands { /// Response commands Response(ResponseArgs), + + /// Serve Yaak's MCP tools over stdio, for AI agents like Claude Code + Mcp, } #[derive(Args)] diff --git a/crates-cli/yaak-cli/src/commands/mcp.rs b/crates-cli/yaak-cli/src/commands/mcp.rs new file mode 100644 index 000000000..b2361b5f6 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/mcp.rs @@ -0,0 +1,659 @@ +use crate::context::{CliContext, CliExecutionContext}; +use crate::plugin_events::SendContexts; +use std::path::PathBuf; +use std::sync::Arc; +use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; +use yaak_crypto::manager::EncryptionManager; +use yaak_http::manager::HttpConnectionManager; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{PluginContext, ShowToastRequest}; +use yaak_plugins::manager::PluginManager; + +pub async fn run(ctx: &CliContext, environment: Option<&str>, cookie_jar_id: Option<&str>) -> i32 { + let host = CliMcpHost { + query_manager: ctx.query_manager().clone(), + blob_manager: ctx.blob_manager().clone(), + plugin_manager: ctx.plugin_manager(), + encryption_manager: ctx.encryption_manager.clone(), + connection_manager: ctx.connection_manager().clone(), + response_dir: ctx.data_dir().join("responses"), + send_contexts: ctx.send_contexts().await, + environment_id: environment.map(str::to_string), + cookie_jar_id: cookie_jar_id.map(str::to_string), + }; + match yaak_mcp::serve_stdio(host).await { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +/// Every workspace in the database is "open", and the global `--environment` +/// and `--cookie-jar` flags stand in for a window's selection. +struct CliMcpHost { + query_manager: QueryManager, + blob_manager: BlobManager, + plugin_manager: Arc, + encryption_manager: Arc, + connection_manager: Arc, + response_dir: PathBuf, + send_contexts: SendContexts, + environment_id: Option, + cookie_jar_id: Option, +} + +/// Registers a send's execution context for as long as the send runs. +struct SendContextGuard<'a> { + send_contexts: &'a SendContexts, + id: String, +} + +impl<'a> SendContextGuard<'a> { + fn new(send_contexts: &'a SendContexts, id: &str, context: CliExecutionContext) -> Self { + send_contexts.lock().expect("send contexts lock").insert(id.to_string(), context); + Self { send_contexts, id: id.to_string() } + } +} + +impl Drop for SendContextGuard<'_> { + fn drop(&mut self) { + self.send_contexts.lock().expect("send contexts lock").remove(&self.id); + } +} + +impl McpHost for CliMcpHost { + fn list_workspaces(&self) -> HostResult> { + let workspaces = + self.query_manager.connect().list_workspaces().map_err(|e| e.to_string())?; + Ok(workspaces.into_iter().map(|w| WorkspaceInfo { id: w.id, name: w.name }).collect()) + } + + fn active_environment_id(&self, workspace_id: &str) -> HostResult> { + let Some(environment_id) = &self.environment_id else { + return Ok(None); + }; + Ok(self + .query_manager + .connect() + .get_environment_for_workspace(workspace_id, environment_id) + .ok() + .map(|e| e.id)) + } + + fn list_http_requests(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_http_requests(workspace_id).map_err(|e| e.to_string()) + } + + fn get_http_request(&self, id: &str) -> HostResult> { + Ok(self.query_manager.connect().get_http_request(id).ok()) + } + + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert HTTP request: {e}")) + } + + fn delete_http_request(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_http_request_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete HTTP request: {e}")) + } + + fn list_folders(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_folders(workspace_id).map_err(|e| e.to_string()) + } + + fn upsert_folder(&self, folder: &Folder) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_folder(folder, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert folder: {e}")) + } + + fn delete_folder(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_folder_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete folder: {e}")) + } + + async fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> HostResult { + let environment_id = match environment_id { + Some(environment_id) => { + if request.workspace_id != workspace_id { + return Err("HTTP request does not belong to the selected workspace".into()); + } + let environment = self + .query_manager + .connect() + .get_environment_for_workspace(&request.workspace_id, &environment_id) + .map_err(|e| e.to_string())?; + Some(environment.id) + } + None => self.active_environment_id(&request.workspace_id)?, + }; + let cookie_jar_id = match &self.cookie_jar_id { + Some(id) => Some(id.clone()), + None => self + .query_manager + .connect() + .list_cookie_jars(&request.workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))? + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id), + }; + let plugin_context = + PluginContext::new(Some("cli".to_string()), Some(request.workspace_id.clone())); + let _send_context = SendContextGuard::new( + &self.send_contexts, + &plugin_context.id, + CliExecutionContext { + request_id: Some(request.id.clone()), + workspace_id: Some(request.workspace_id.clone()), + environment_id: environment_id.clone(), + cookie_jar_id: cookie_jar_id.clone(), + }, + ); + + let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams { + query_manager: &self.query_manager, + blob_manager: &self.blob_manager, + request, + environment_id: environment_id.as_deref(), + update_source: UpdateSource::Sync, + cookie_jar_id, + response_dir: &self.response_dir, + emit_events_to: None, + emit_response_body_chunks_to: None, + existing_response: None, + plugin_manager: self.plugin_manager.clone(), + encryption_manager: self.encryption_manager.clone(), + plugin_context: &plugin_context, + cancelled_rx: None, + connection_manager: &self.connection_manager, + }) + .await + .map_err(|e| e.to_string())?; + Ok(result.response) + } + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()> { + // stdout carries the protocol + eprintln!("[mcp] {}", toast.message); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{CallToolRequestParams, CallToolResult}; + use rmcp::service::RunningService; + use rmcp::{RoleClient, ServiceExt}; + use serde_json::{Value, json}; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::sync::mpsc::UnboundedReceiver; + use yaak_mcp::YaakMcpServer; + use yaak_models::models::{Environment, EnvironmentVariable, HttpRequestHeader, Workspace}; + + struct Fixture { + client: RunningService, + query_manager: QueryManager, + plugin_manager: Arc, + workspace_id: String, + request_id: String, + environments: [String; 3], + received: UnboundedReceiver, + _dir: TempDir, + } + + impl Fixture { + async fn new(active_environment: Option<&str>) -> Self { + let dir = TempDir::new().unwrap(); + let (query_manager, blob_manager, _rx) = yaak_models::init_standalone( + dir.path().join("db.sqlite"), + dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (url, received) = echo_server().await; + let (workspace_id, request_id, environments) = seed(&query_manager, &url); + + let plugin_dir = dir.path().join("plugins"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let plugin_manager = Arc::new( + PluginManager::new( + plugin_dir.clone(), + plugin_dir, + PathBuf::from("node"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs", + ), + &query_manager, + &PluginContext::new_empty(), + false, + ) + .await + .unwrap(), + ); + let environment_id = active_environment.map(|name| match name { + "a" => environments[1].clone(), + other => other.to_string(), + }); + let host = CliMcpHost { + encryption_manager: Arc::new(EncryptionManager::new( + query_manager.clone(), + "yaak-test", + )), + query_manager: query_manager.clone(), + blob_manager, + plugin_manager: plugin_manager.clone(), + connection_manager: Arc::new(HttpConnectionManager::new()), + response_dir: dir.path().join("responses"), + send_contexts: SendContexts::default(), + environment_id, + cookie_jar_id: None, + }; + + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + let server = YaakMcpServer::new(host).serve(server_io).await.unwrap(); + let _ = server.waiting().await; + }); + let client = ().serve(client_io).await.unwrap(); + + Self { + client, + query_manager, + plugin_manager, + workspace_id, + request_id, + environments, + received, + _dir: dir, + } + } + + async fn call(&self, name: &'static str, arguments: Value) -> CallToolResult { + let Value::Object(arguments) = arguments else { + panic!("arguments must be an object") + }; + self.client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap() + } + + async fn ok(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_ne!(result.is_error, Some(true), "{name} failed: {}", text(&result)); + text(&result) + } + + async fn err(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_eq!(result.is_error, Some(true), "{name} should fail: {}", text(&result)); + text(&result) + } + + async fn json(&self, name: &'static str, arguments: Value) -> Value { + serde_json::from_str(&self.ok(name, arguments).await).unwrap() + } + + async fn next_marker(&mut self) -> String { + let request = self.received.recv().await.unwrap().to_lowercase(); + let line = request.lines().find(|l| l.starts_with("x-environment:")).unwrap(); + line["x-environment:".len()..].trim().to_string() + } + + async fn finish(self) { + assert!(self.received.is_empty(), "unexpected requests reached the server"); + self.client.cancel().await.unwrap(); + self.plugin_manager.terminate().await; + } + } + + fn text(result: &CallToolResult) -> String { + result.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect() + } + + async fn echo_server() -> (String, UnboundedReceiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/echo", listener.local_addr().unwrap()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let mut chunk = [0u8; 1024]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + tx.send(String::from_utf8(request).unwrap()).unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .unwrap(); + } + }); + (url, rx) + } + + /// A workspace with base environment "global", sub-environments "a" and + /// "b", and a request that echoes its environment's marker in a header. + fn seed(query_manager: &QueryManager, url: &str) -> (String, String, [String; 3]) { + query_manager + .with_tx(|db| { + let source = &UpdateSource::Background; + let workspace = db.upsert_workspace( + &Workspace { name: "Test".into(), ..Default::default() }, + source, + )?; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment(&workspace.id)?; + let base = db.upsert_environment( + &Environment { variables: vars("global"), ..base }, + source, + )?; + let sub = |name: &str| { + db.upsert_environment( + &Environment { + name: name.into(), + workspace_id: workspace.id.clone(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars(name), + ..Default::default() + }, + source, + ) + }; + let a = sub("a")?; + let b = sub("b")?; + let request = db.upsert_http_request( + &HttpRequest { + workspace_id: workspace.id.clone(), + name: "Echo".into(), + url: url.to_string(), + method: "GET".into(), + headers: vec![HttpRequestHeader { + enabled: true, + name: "X-Environment".into(), + value: "${[ marker ]}".into(), + ..Default::default() + }], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>(( + workspace.id, + request.id, + [base.id, a.id, b.id], + )) + }) + .unwrap() + } + + #[tokio::test] + async fn advertises_the_plugin_tools() { + let fx = Fixture::new(None).await; + let tools = fx.client.list_all_tools().await.unwrap(); + let mut names = tools.iter().map(|t| t.name.to_string()).collect::>(); + names.sort(); + assert_eq!( + names, + [ + "create_folder", + "create_http_request", + "delete_folder", + "delete_http_request", + "get_environment_id", + "get_folder", + "get_http_request", + "get_workspace_id", + "list_folders", + "list_http_requests", + "list_workspaces", + "send_http_request", + "show_toast", + "update_folder", + "update_http_request", + ] + ); + let send = tools.iter().find(|t| t.name == "send_http_request").unwrap(); + let properties = send.input_schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("environmentId"), "{properties:?}"); + assert_eq!(send.input_schema["required"], json!(["id"])); + fx.finish().await; + } + + #[tokio::test] + async fn send_uses_the_given_environment() { + let mut fx = Fixture::new(Some("a")).await; + let response = fx + .json( + "send_http_request", + json!({ "id": fx.request_id, "workspaceId": fx.workspace_id, "environmentId": fx.environments[2] }), + ) + .await; + assert_eq!(response["status"], 200); + assert!(response.get("bodyPath").is_none(), "{response}"); + assert_eq!(fx.next_marker().await, "b"); + fx.finish().await; + } + + #[tokio::test] + async fn send_without_an_environment_uses_the_active_one() { + let mut fx = Fixture::new(Some("a")).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "a"); + fx.finish().await; + + let mut fx = Fixture::new(None).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "global"); + fx.finish().await; + } + + #[tokio::test] + async fn send_does_not_send_a_missing_request() { + let fx = Fixture::new(None).await; + let error = fx.err("send_http_request", json!({ "id": "rq_missing" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + fx.finish().await; + } + + #[tokio::test] + async fn send_returns_host_environment_errors() { + let fx = Fixture::new(None).await; + let expected = fx + .query_manager + .connect() + .get_environment_for_workspace(&fx.workspace_id, "ev_missing") + .unwrap_err() + .to_string(); + let error = fx + .err("send_http_request", json!({ "id": fx.request_id, "environmentId": "ev_missing" })) + .await; + assert_eq!(error, expected); + fx.finish().await; + } + + #[tokio::test] + async fn concurrent_sends_keep_their_environments() { + let mut fx = Fixture::new(None).await; + let (a, b) = (fx.environments[1].clone(), fx.environments[2].clone()); + tokio::join!( + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": a })), + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": b })), + ); + let mut markers = vec![fx.next_marker().await, fx.next_marker().await]; + markers.sort(); + assert_eq!(markers, ["a", "b"]); + let responses = + fx.query_manager.connect().list_http_responses_for_request(&fx.request_id, None); + assert_eq!(responses.unwrap().len(), 2); + fx.finish().await; + } + + #[tokio::test] + async fn workspace_and_environment_tools() { + let fx = Fixture::new(Some("a")).await; + assert_eq!( + fx.json("list_workspaces", json!({})).await, + json!([{ "id": fx.workspace_id, "name": "Test" }]) + ); + assert_eq!(fx.ok("get_workspace_id", json!({})).await, fx.workspace_id); + assert_eq!(fx.ok("get_environment_id", json!({})).await, fx.environments[1]); + + let error = fx.err("list_http_requests", json!({ "workspaceId": "wk_missing" })).await; + assert!(error.starts_with("Workspace with ID \"wk_missing\" not found."), "{error}"); + + fx.query_manager + .with_tx(|db| { + db.upsert_workspace( + &Workspace { name: "Other".into(), ..Default::default() }, + &UpdateSource::Background, + ) + }) + .unwrap(); + let error = fx.err("get_workspace_id", json!({})).await; + assert!(error.starts_with("Multiple workspaces are open."), "{error}"); + let error = fx.err("list_http_requests", json!({})).await; + assert!(error.contains(&format!("Test (ID: {})", fx.workspace_id)), "{error}"); + let requests = + fx.json("list_http_requests", json!({ "workspaceId": fx.workspace_id })).await; + assert_eq!(requests.as_array().unwrap().len(), 1); + fx.finish().await; + + let fx = Fixture::new(None).await; + assert_eq!(fx.ok("get_environment_id", json!({})).await, "No environment selected"); + fx.finish().await; + } + + #[tokio::test] + async fn http_request_tools() { + let fx = Fixture::new(None).await; + let created = fx + .json( + "create_http_request", + json!({ + "name": "Created", + "url": "https://example.com/items", + "headers": [{ "name": "Accept", "value": "application/json" }], + "bodyType": "application/json", + "body": { "text": "{}" }, + }), + ) + .await; + let id = created["id"].as_str().unwrap().to_string(); + assert!(id.starts_with("rq_"), "{created}"); + assert_eq!(created["workspaceId"], fx.workspace_id); + assert_eq!(created["method"], "GET"); + assert_eq!(created["headers"][0]["enabled"], true); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, created); + + let updated = fx + .json( + "update_http_request", + json!({ "id": id, "url": "https://example.com/v2", "method": "POST" }), + ) + .await; + assert_eq!(updated["id"], id); + assert_eq!(updated["url"], "https://example.com/v2"); + assert_eq!(updated["method"], "POST"); + assert_eq!(updated["name"], "Created"); + assert_eq!(updated["body"], json!({ "text": "{}" })); + + let error = fx.err("update_http_request", json!({ "id": "rq_missing", "name": "x" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + + let listed = fx.json("list_http_requests", json!({})).await; + assert_eq!(listed.as_array().unwrap().len(), 2); + + assert_eq!( + fx.ok("delete_http_request", json!({ "id": id })).await, + format!("Deleted: Created ({id})") + ); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn folder_tools() { + let fx = Fixture::new(None).await; + let parent = fx.json("create_folder", json!({ "name": "Parent" })).await; + let parent_id = parent["id"].as_str().unwrap().to_string(); + assert!(parent_id.starts_with("fl_"), "{parent}"); + let child = fx + .json( + "create_folder", + json!({ + "name": "Child", + "folderId": parent_id, + "sortPriority": 2.5, + "authenticationType": "bearer", + "authentication": { "token": "abc" }, + }), + ) + .await; + let child_id = child["id"].as_str().unwrap().to_string(); + assert_eq!(child["folderId"], parent_id); + assert_eq!(child["authentication"]["token"], "abc"); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, child); + assert_eq!(fx.json("list_folders", json!({})).await.as_array().unwrap().len(), 2); + + let updated = + fx.json("update_folder", json!({ "id": child_id, "description": "Nested" })).await; + assert_eq!(updated["description"], "Nested"); + assert_eq!(updated["name"], "Child"); + assert_eq!(updated["folderId"], parent_id); + + let error = fx.err("update_folder", json!({ "id": "fl_missing" })).await; + assert_eq!(error, "Folder with ID fl_missing not found"); + + assert_eq!( + fx.ok("delete_folder", json!({ "id": child_id })).await, + format!("Deleted: Child ({child_id})") + ); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn show_toast_reports_the_message() { + let fx = Fixture::new(None).await; + assert_eq!( + fx.ok("show_toast", json!({ "message": "Hello", "icon": "info", "color": "success" })) + .await, + "✓ Toast shown: \"Hello\"" + ); + fx.finish().await; + } +} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index 901371f1a..6f4e467b6 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cookie_jar; pub mod environment; pub mod folder; pub mod import_export; +pub mod mcp; pub mod plugin; pub mod request; pub mod response; diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index 9822e0cb9..df276087f 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -1,4 +1,4 @@ -use crate::plugin_events::CliPluginEventBridge; +use crate::plugin_events::{CliPluginEventBridge, SendContexts}; use include_dir::{Dir, include_dir}; use std::fs; use std::path::{Path, PathBuf}; @@ -130,10 +130,18 @@ impl CliContext { &self.blob_manager } - pub fn connection_manager(&self) -> &HttpConnectionManager { + pub fn connection_manager(&self) -> &Arc { &self.connection_manager } + /// Where a send registers its execution context for the plugin calls it makes. + pub async fn send_contexts(&self) -> SendContexts { + match &*self.plugin_event_bridge.lock().await { + Some(bridge) => bridge.send_contexts(), + None => SendContexts::default(), + } + } + pub fn plugin_manager(&self) -> Arc { self.plugin_manager.clone().expect("Plugin manager was not initialized for this command") } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index de9dfdb1a..d911c1409 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -184,6 +184,20 @@ async fn main() { context.shutdown().await; exit_code } + Commands::Mcp => { + let mut context = CliContext::new(data_dir.clone(), app_id); + context + .init_plugins(CliExecutionContext { + environment_id: environment.clone(), + cookie_jar_id: cookie_jar.clone(), + ..CliExecutionContext::default() + }) + .await; + let exit_code = + commands::mcp::run(&context, environment.as_deref(), cookie_jar.as_deref()).await; + context.shutdown().await; + exit_code + } Commands::Environment(args) => { let context = CliContext::new(data_dir.clone(), app_id); let exit_code = commands::environment::run(&context, args); diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index 09d59d32f..b4f773a10 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -36,9 +36,14 @@ use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw}; +/// Execution contexts for sends in flight, by the id of the plugin context +/// each send was given, so plugin calls made during a send see its settings. +pub type SendContexts = Arc>>; + pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, + send_contexts: SendContexts, } struct CliHostContext { @@ -49,6 +54,14 @@ struct CliHostContext { connection_manager: Arc, response_dir: PathBuf, execution_context: CliExecutionContext, + send_contexts: SendContexts, +} + +impl CliHostContext { + fn execution_context(&self, context: &PluginContext) -> CliExecutionContext { + let send_contexts = self.send_contexts.lock().expect("send contexts lock"); + send_contexts.get(&context.id).cloned().unwrap_or_else(|| self.execution_context.clone()) + } } impl CliPluginEventBridge { @@ -64,6 +77,7 @@ impl CliPluginEventBridge { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); + let send_contexts = SendContexts::default(); let host_context = Arc::new(CliHostContext { query_manager, blob_manager, @@ -72,6 +86,7 @@ impl CliPluginEventBridge { connection_manager, response_dir: data_dir.join("responses"), execution_context, + send_contexts: send_contexts.clone(), }); let task = tokio::spawn(async move { @@ -113,7 +128,11 @@ impl CliPluginEventBridge { pm.unsubscribe(&rx_id_for_task).await; }); - Self { rx_id, task } + Self { rx_id, task, send_contexts } + } + + pub fn send_contexts(&self) -> SendContexts { + self.send_contexts.clone() } pub async fn shutdown(self, plugin_manager: &PluginManager) { @@ -128,7 +147,7 @@ async fn build_plugin_reply( event: &InternalEvent, plugin_name: &str, ) -> Option { - let execution_context = &host_context.execution_context; + let execution_context = &host_context.execution_context(&event.context); let shared_workspace_id = event.context.workspace_id.as_deref().or(execution_context.workspace_id.as_deref()); @@ -1161,6 +1180,7 @@ mod environment_tests { environment_id: Some(a.id.clone()), ..Default::default() }, + send_contexts: SendContexts::default(), }; std::fs::create_dir_all(&host.response_dir).unwrap(); let event = |environment_id: Option<&str>| -> InternalEvent { diff --git a/crates-cli/yaak-cli/tests/mcp_command.rs b/crates-cli/yaak-cli/tests/mcp_command.rs new file mode 100644 index 000000000..b9d7a0e86 --- /dev/null +++ b/crates-cli/yaak-cli/tests/mcp_command.rs @@ -0,0 +1,224 @@ +mod common; + +use common::http_server::TestHttpServer; +use common::{query_manager, seed_workspace}; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParams; +use serde_json::{Value, json}; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; +use yaak_models::models::HttpRequest; +use yaak_models::util::UpdateSource; + +#[tokio::test] +async fn mcp_serves_tools_over_stdio() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let server = TestHttpServer::spawn_ok("hello from mcp"); + let request = HttpRequest { + id: "rq_mcp".to_string(), + workspace_id: "wk_test".to_string(), + name: "MCP Send".to_string(), + method: "GET".to_string(), + url: server.url.clone(), + ..Default::default() + }; + query_manager(data_dir) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .expect("Failed to seed request"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let call = async |name: &'static str, arguments: Value| { + let Value::Object(arguments) = arguments else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + let text = result.content[0].as_text().unwrap().text.clone(); + serde_json::from_str::(&text).unwrap() + }; + + assert_eq!(client.list_all_tools().await.unwrap().len(), 15); + assert_eq!( + call("list_workspaces", json!({})).await, + json!([{ "id": "wk_test", "name": "Seed Workspace" }]) + ); + let response = call("send_http_request", json!({ "id": "rq_mcp" })).await; + assert_eq!(response["status"], 200); + assert_eq!(response["requestId"], "rq_mcp"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} + +/// Answers each request with its `X-Environment` header and reports the +/// headers it received. +fn marker_server() -> (String, std::sync::mpsc::Receiver) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let request = String::from_utf8_lossy(&request).to_lowercase(); + let marker = request + .lines() + .find_map(|l| l.strip_prefix("x-environment:")) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + let _ = tx.send(request); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{marker}", + marker.len() + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + (url, rx) +} + +#[tokio::test] +async fn mcp_send_settings_reach_template_functions() { + use yaak_models::models::{ + Cookie, CookieDomain, CookieExpires, CookieJar, Environment, EnvironmentVariable, + HttpRequestHeader, + }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let (url, received) = marker_server(); + let header = |name: &str, value: &str| HttpRequestHeader { + enabled: true, + name: name.into(), + value: value.into(), + ..Default::default() + }; + let override_id = query_manager(data_dir) + .with_tx(|db| { + let source = &UpdateSource::Sync; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment("wk_test")?; + let base = + db.upsert_environment(&Environment { variables: vars("global"), ..base }, source)?; + let chosen = db.upsert_environment( + &Environment { + name: "chosen".into(), + workspace_id: "wk_test".into(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars("chosen"), + ..Default::default() + }, + source, + )?; + db.upsert_cookie_jar( + &CookieJar { + workspace_id: "wk_test".into(), + name: "Default".into(), + cookies: vec![Cookie { + name: "session".into(), + value: "from-jar".into(), + domain: CookieDomain::HostOnly("127.0.0.1".into()), + expires: CookieExpires::SessionEnd, + path: "/".into(), + secure: false, + http_only: false, + same_site: None, + }], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_inner".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/inner"), + headers: vec![header("X-Environment", "${[ marker ]}")], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_outer".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/outer"), + headers: vec![ + header( + "X-Nested", + "${[ response.body.raw(request='rq_inner', behavior='always') ]}", + ), + header("X-Cookie", "${[ cookie.value(name='session') ]}"), + ], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>(chosen.id) + }) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let Value::Object(arguments) = json!({ "id": "rq_outer", "environmentId": override_id }) else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new("send_http_request").with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + + let outer = received.iter().find(|r| r.starts_with("get /outer")).unwrap(); + assert!(outer.contains("x-nested: chosen\r\n"), "{outer}"); + assert!(outer.contains("x-cookie: from-jar\r\n"), "{outer}"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index ebe58bfc1..dd3c2cd0e 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -90,6 +90,7 @@ yaak-http = { workspace = true } yaak-license = { workspace = true, optional = true } yaak-mac-window = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-sse = { workspace = true } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 0ba49907c..df5778b7f 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -62,6 +62,7 @@ mod grpc; mod history; mod http_request; mod import; +mod mcp_ext; mod models_ext; mod notifications; mod plugin_events; @@ -1360,6 +1361,7 @@ pub fn run() { app.app_handle().set_native_titlebar(settings.use_native_titlebar); monitor_plugin_events(&app.app_handle().clone()); + mcp_ext::init(app.app_handle()); Ok(()) }) diff --git a/crates-tauri/yaak-app-client/src/mcp_ext.rs b/crates-tauri/yaak-app-client/src/mcp_ext.rs new file mode 100644 index 000000000..6e7ed28ed --- /dev/null +++ b/crates-tauri/yaak-app-client/src/mcp_ext.rs @@ -0,0 +1,192 @@ +//! The built-in MCP server: `yaak_mcp`'s tools on 127.0.0.1:64343, served +//! while the `mcpServerEnabled` setting is on, behind a bearer token kept in +//! key_values. + +use crate::models_ext::QueryManagerExt; +use crate::plugin_events::send_http_request_in_window; +use crate::{environment_from_window, workspace_from_window}; +use log::{error, info}; +use rand::Rng; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; +use tokio::sync::Mutex; +use yaak_mcp::http::{DEFAULT_PORT, HttpServer}; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{Color, PluginContext, ShowToastRequest}; + +pub(crate) const TOKEN_NAMESPACE: &str = "no_sync"; +pub(crate) const TOKEN_KEY: &str = "mcp_server_token"; + +#[derive(Default)] +struct McpServerState(Mutex>); + +pub(crate) fn init(app_handle: &AppHandle) { + app_handle.manage(McpServerState::default()); + sync(app_handle); +} + +/// Start or stop the server to match the setting. +pub(crate) fn sync(app_handle: &AppHandle) { + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let Some(state) = app_handle.try_state::() else { + return; + }; + let mut server = state.0.lock().await; + let enabled = app_handle.db().get_settings().mcp_server_enabled; + match (enabled, server.is_some()) { + (true, false) => match start(&app_handle).await { + Ok(s) => { + info!("MCP server listening on http://{}{}", s.addr, yaak_mcp::http::PATH); + *server = Some(s); + } + Err(e) => { + error!("Failed to start MCP server: {e}"); + let _ = app_handle.emit( + "show_toast", + ShowToastRequest { + message: format!( + "Failed to start the MCP server on port {DEFAULT_PORT}: {e}" + ), + color: Some(Color::Danger), + timeout: Some(10000), + ..Default::default() + }, + ); + } + }, + (false, true) => { + if let Some(s) = server.take() { + s.stop().await; + info!("MCP server stopped"); + } + } + (true, true) | (false, false) => {} + } + }); +} + +async fn start(app_handle: &AppHandle) -> std::io::Result { + let host = Arc::new(AppMcpHost { app_handle: app_handle.clone() }); + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, DEFAULT_PORT)); + yaak_mcp::http::serve(host, addr, token(app_handle)).await +} + +/// The token, generated the first time the server starts. +fn token(app_handle: &AppHandle) -> String { + let existing = app_handle.db().get_key_value_str(TOKEN_NAMESPACE, TOKEN_KEY, ""); + if !existing.is_empty() { + return existing; + } + let mut rng = rand::rng(); + let token: String = (0..32).map(|_| format!("{:02x}", rng.random::())).collect(); + if let Err(e) = app_handle.with_tx(|tx| { + tx.set_key_value_str(TOKEN_NAMESPACE, TOKEN_KEY, &token, &UpdateSource::Background); + Ok(()) + }) { + error!("Failed to save MCP server token: {e}"); + } + token +} + +struct AppMcpHost { + app_handle: AppHandle, +} + +impl AppMcpHost { + fn window_for_workspace(&self, workspace_id: &str) -> Option> { + self.app_handle + .webview_windows() + .into_values() + .find(|w| workspace_from_window(w).is_some_and(|ws| ws.id == workspace_id)) + } +} + +impl McpHost for AppMcpHost { + fn list_workspaces(&self) -> HostResult> { + let mut workspaces: Vec = Vec::new(); + for window in self.app_handle.webview_windows().values() { + if let Some(w) = workspace_from_window(window) + && !workspaces.iter().any(|existing| existing.id == w.id) + { + workspaces.push(WorkspaceInfo { id: w.id, name: w.name }); + } + } + Ok(workspaces) + } + + fn active_environment_id(&self, workspace_id: &str) -> HostResult> { + Ok(self + .window_for_workspace(workspace_id) + .and_then(|w| environment_from_window(&w)) + .map(|e| e.id)) + } + + fn list_http_requests(&self, workspace_id: &str) -> HostResult> { + self.app_handle.db().list_http_requests(workspace_id).map_err(|e| e.to_string()) + } + + fn get_http_request(&self, id: &str) -> HostResult> { + Ok(self.app_handle.db().get_http_request(id).ok()) + } + + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult { + self.app_handle + .with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to upsert HTTP request: {e}")) + } + + fn delete_http_request(&self, id: &str) -> HostResult { + self.app_handle + .with_tx(|tx| tx.delete_http_request_by_id(id, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to delete HTTP request: {e}")) + } + + fn list_folders(&self, workspace_id: &str) -> HostResult> { + self.app_handle.db().list_folders(workspace_id).map_err(|e| e.to_string()) + } + + fn upsert_folder(&self, folder: &Folder) -> HostResult { + self.app_handle + .with_tx(|tx| tx.upsert_folder(folder, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to upsert folder: {e}")) + } + + fn delete_folder(&self, id: &str) -> HostResult { + self.app_handle + .with_tx(|tx| tx.delete_folder_by_id(id, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to delete folder: {e}")) + } + + async fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> HostResult { + let window = self + .window_for_workspace(workspace_id) + .ok_or_else(|| format!("No window is open for workspace {workspace_id}"))?; + let plugin_context = PluginContext { + label: Some(window.label().to_string()), + workspace_id: Some(workspace_id.to_string()), + ..PluginContext::new_empty() + }; + let sent = send_http_request_in_window( + &window, + request, + environment_id.as_deref(), + &plugin_context, + ) + .await + .map_err(|e| e.to_string())?; + Ok(sent.response) + } + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()> { + self.app_handle.emit("show_toast", toast).map_err(|e| e.to_string()) + } +} diff --git a/crates-tauri/yaak-app-client/src/models_ext.rs b/crates-tauri/yaak-app-client/src/models_ext.rs index 5584d14e9..f0a149a60 100644 --- a/crates-tauri/yaak-app-client/src/models_ext.rs +++ b/crates-tauri/yaak-app-client/src/models_ext.rs @@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use yaak_models::blob_manager::BlobManager; use yaak_models::client_db::{ClientDb, WriteDb}; use yaak_models::error::Result; +use yaak_models::models::AnyModel; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; @@ -168,19 +169,26 @@ pub fn init() -> TauriPlugin { // Fast path for local app writes initiated by frontend windows. This keeps the // current sync-model UX snappy, while DB polling handles external writers (CLI). + // A thread of its own: `recv` blocks, and a runtime worker parked in it strands + // anything spawned from the loop in that worker's unstealable LIFO slot. let app_handle_local = app_handle.clone(); - tauri::async_runtime::spawn(async move { + std::thread::spawn(move || { while let Ok(payload) = rx.recv() { let mut batch: Vec = Vec::new(); + let mut settings_changed = matches!(payload.model, AnyModel::Settings(_)); if matches!(payload.update_source, UpdateSource::Window { .. }) { batch.push(payload); } // Coalesce any writes already queued into the same emit while let Ok(next) = rx.try_recv() { + settings_changed |= matches!(next.model, AnyModel::Settings(_)); if matches!(next.update_source, UpdateSource::Window { .. }) { batch.push(next); } } + if settings_changed { + crate::mcp_ext::sync(&app_handle_local); + } if batch.is_empty() { continue; } diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 2f4d0d206..5a079a51c 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use crate::http_request::send_http_request_with_context; +use crate::http_request::{SentHttpRequest, send_http_request_with_context}; use crate::models_ext::BlobManagerExt; use crate::models_ext::QueryManagerExt; use crate::render::{render_grpc_request, render_http_request, render_json_value}; @@ -12,7 +12,7 @@ use base64::prelude::BASE64_STANDARD; use chrono::Utc; use log::error; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Listener, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_opener::OpenerExt; use yaak::plugin_events::{ @@ -21,7 +21,7 @@ use yaak::plugin_events::{ use yaak::response_body::FileResponseBodyStore; use yaak_crypto::manager::EncryptionManager; use yaak_http::cookies::get_cookie_value_from_jar; -use yaak_models::models::{HttpResponse, Plugin}; +use yaak_models::models::{HttpRequest, HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::UpdateSource; use yaak_plugins::error::Error::PluginErr; @@ -280,64 +280,20 @@ async fn handle_host_plugin_request( } HostRequest::SendHttpRequest(req) => { let window = get_window_from_plugin_context(app_handle, plugin_context)?; - let mut http_request = req.http_request.clone(); - let workspace = - workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); - let cookie_jar = cookie_jar_from_window(&window); - - if http_request.workspace_id.is_empty() { - http_request.workspace_id = workspace.id.clone(); - } - - let environment = - if let Some(environment_id) = req.environment_id.as_deref() { - if http_request.workspace_id != workspace.id { - return Err(crate::error::Error::GenericError( - "HTTP request does not belong to the selected workspace".to_string(), - )); - } - Some(window.db().get_environment_for_workspace( - &http_request.workspace_id, - environment_id, - )?) - } else { - environment_from_window(&window) - }; - - let http_response = if http_request.id.is_empty() { - HttpResponse::default() - } else { - let blobs = window.blob_manager(); - window.with_tx(|tx| { - tx.upsert_http_response( - &HttpResponse { - request_id: http_request.id.clone(), - workspace_id: http_request.workspace_id.clone(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - &blobs, - ) - })? - }; - - let http_response = send_http_request_with_context( + let sent = send_http_request_in_window( &window, - &http_request, - &http_response, - environment, - cookie_jar, - &mut tokio::sync::watch::channel(false).1, + req.http_request.clone(), + req.environment_id.as_deref(), plugin_context, ) .await?; // An ad-hoc request saves nothing, so the engine hands the body // back and this reply is the only place the plugin can get it. - let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b)); + let body = sent.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b)); Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse { - http_response: http_response.response, + http_response: sent.response, body, }))) } @@ -478,3 +434,63 @@ async fn handle_host_plugin_request( } } } + +/// Send on behalf of a plugin or MCP client, from `window`'s workspace and +/// with its selected environment unless `environment_id` overrides it. +pub(crate) async fn send_http_request_in_window( + window: &WebviewWindow, + mut http_request: HttpRequest, + environment_id: Option<&str>, + plugin_context: &yaak_plugins::events::PluginContext, +) -> Result { + let workspace = + workspace_from_window(window).expect("Failed to get workspace_id from window URL"); + let cookie_jar = cookie_jar_from_window(window); + + if http_request.workspace_id.is_empty() { + http_request.workspace_id = workspace.id.clone(); + } + + let environment = if let Some(environment_id) = environment_id { + if http_request.workspace_id != workspace.id { + return Err(crate::error::Error::GenericError( + "HTTP request does not belong to the selected workspace".to_string(), + )); + } + Some( + window + .db() + .get_environment_for_workspace(&http_request.workspace_id, environment_id)?, + ) + } else { + environment_from_window(window) + }; + + let http_response = if http_request.id.is_empty() { + HttpResponse::default() + } else { + let blobs = window.blob_manager(); + window.with_tx(|tx| { + tx.upsert_http_response( + &HttpResponse { + request_id: http_request.id.clone(), + workspace_id: http_request.workspace_id.clone(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + &blobs, + ) + })? + }; + + send_http_request_with_context( + window, + &http_request, + &http_response, + environment, + cookie_jar, + &tokio::sync::watch::channel(false).1, + plugin_context, + ) + .await +} diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index ec4a53a40..2a63036fa 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -398,6 +398,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-mcp/Cargo.toml b/crates/yaak-mcp/Cargo.toml new file mode 100644 index 000000000..f7aa25c68 --- /dev/null +++ b/crates/yaak-mcp/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "yaak-mcp" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +bytes = { version = "1" } +http-body-util = { version = "0.1" } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +log = { workspace = true } +rmcp = { version = "3.5.1", default-features = false, features = [ + "server", + "macros", + "transport-io", + "transport-streamable-http-server", +] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "rt", "macros"] } +tokio-util = { version = "0.7" } +yaak-models = { workspace = true } +yaak-plugins = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/crates/yaak-mcp/src/http.rs b/crates/yaak-mcp/src/http.rs new file mode 100644 index 000000000..9b0f7e007 --- /dev/null +++ b/crates/yaak-mcp/src/http.rs @@ -0,0 +1,124 @@ +use crate::{McpHost, YaakMcpServer}; +use bytes::Bytes; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use log::{debug, warn}; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +pub const DEFAULT_PORT: u16 = 64343; +pub const PATH: &str = "/mcp"; + +/// A running server. Dropping it does not stop it; call [`HttpServer::stop`]. +pub struct HttpServer { + pub addr: SocketAddr, + cancel: CancellationToken, + task: JoinHandle<()>, +} + +impl HttpServer { + pub async fn stop(self) { + self.cancel.cancel(); + let _ = self.task.await; + } +} + +/// Serve the tools on `addr` at [`PATH`], answering only requests that carry +/// `Authorization: Bearer `. +pub async fn serve( + host: Arc, + addr: SocketAddr, + token: String, +) -> std::io::Result { + let listener = TcpListener::bind(addr).await?; + let addr = listener.local_addr()?; + let cancel = CancellationToken::new(); + + let config = + StreamableHttpServerConfig::default().with_cancellation_token(cancel.child_token()); + let mcp = StreamableHttpService::new( + move || Ok(YaakMcpServer::from_arc(host.clone())), + Arc::new(LocalSessionManager::default()), + config, + ); + let expected = Arc::new(format!("Bearer {token}")); + + let accept_cancel = cancel.clone(); + let task = tokio::spawn(async move { + loop { + let stream = tokio::select! { + _ = accept_cancel.cancelled() => break, + accepted = listener.accept() => match accepted { + Ok((stream, _)) => stream, + Err(e) => { + warn!("MCP server failed to accept a connection: {e}"); + continue; + } + }, + }; + + let mcp = mcp.clone(); + let expected = expected.clone(); + let conn_cancel = accept_cancel.clone(); + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let mcp = mcp.clone(); + let expected = expected.clone(); + async move { Ok::<_, Infallible>(route(&mcp, &expected, req).await) } + }); + let conn = http1::Builder::new().serve_connection(TokioIo::new(stream), service); + tokio::select! { + _ = conn_cancel.cancelled() => {} + result = conn => if let Err(e) = result { + debug!("MCP connection ended: {e}"); + }, + } + }); + } + }); + + Ok(HttpServer { addr, cancel, task }) +} + +async fn route( + mcp: &StreamableHttpService, + expected: &str, + req: Request, +) -> Response> +where + S: rmcp::ServerHandler + Send + 'static, +{ + if req.uri().path() != PATH { + return plain(StatusCode::NOT_FOUND, "Not found"); + } + let given = req.headers().get(hyper::header::AUTHORIZATION).map(|v| v.as_bytes()); + if !given.is_some_and(|given| constant_time_eq(given, expected.as_bytes())) { + let mut response = plain(StatusCode::UNAUTHORIZED, "Missing or invalid bearer token"); + response + .headers_mut() + .insert(hyper::header::WWW_AUTHENTICATE, "Bearer".parse().expect("valid header")); + return response; + } + mcp.handle(req).await +} + +fn plain(status: StatusCode, message: &'static str) -> Response> { + let mut response = Response::new(Full::new(Bytes::from_static(message.as_bytes())).boxed()); + *response.status_mut() = status; + response +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} diff --git a/crates/yaak-mcp/src/lib.rs b/crates/yaak-mcp/src/lib.rs new file mode 100644 index 000000000..af40d7ec9 --- /dev/null +++ b/crates/yaak-mcp/src/lib.rs @@ -0,0 +1,63 @@ +//! Yaak's MCP server: the tools, written once against [`McpHost`], and the +//! transports the app (streamable HTTP) and the CLI (stdio) serve them on. + +pub mod http; +mod params; +mod server; + +use serde::Serialize; +use std::future::Future; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_plugins::events::ShowToastRequest; + +pub use server::YaakMcpServer; + +pub type HostResult = Result; + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceInfo { + pub id: String, + pub name: String, +} + +/// What the tools need from whichever process hosts them. Errors are shown to +/// the MCP client as is. +pub trait McpHost: Send + Sync + 'static { + /// The workspaces a tool may act on: open windows in the app, every + /// workspace in the CLI. + fn list_workspaces(&self) -> HostResult>; + + /// The environment a send uses when the caller names none. + fn active_environment_id(&self, workspace_id: &str) -> HostResult>; + + fn list_http_requests(&self, workspace_id: &str) -> HostResult>; + fn get_http_request(&self, id: &str) -> HostResult>; + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult; + fn delete_http_request(&self, id: &str) -> HostResult; + + fn list_folders(&self, workspace_id: &str) -> HostResult>; + fn upsert_folder(&self, folder: &Folder) -> HostResult; + fn delete_folder(&self, id: &str) -> HostResult; + + /// Send `request` from `workspace_id`, with `environment_id` overriding the + /// active environment for this send only. + fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> impl Future> + Send; + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()>; +} + +/// Serve the tools on stdin and stdout until the client disconnects. +pub async fn serve_stdio(host: H) -> Result<(), String> { + use rmcp::ServiceExt; + let service = YaakMcpServer::new(host) + .serve(rmcp::transport::stdio()) + .await + .map_err(|e| format!("Failed to start MCP server: {e}"))?; + service.waiting().await.map_err(|e| format!("MCP server stopped: {e}"))?; + Ok(()) +} diff --git a/crates/yaak-mcp/src/params.rs b/crates/yaak-mcp/src/params.rs new file mode 100644 index 000000000..cccdc0bd5 --- /dev/null +++ b/crates/yaak-mcp/src/params.rs @@ -0,0 +1,281 @@ +//! Tool inputs, mirroring the zod schemas of the `@yaak/mcp-server` plugin +//! they replace. Optional fields skip serializing so that a tool's input, +//! serialized, holds only what the caller gave, ready to lay over a model. + +use rmcp::schemars::{self, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +const WORKSPACE_ID: &str = "Workspace ID (required if multiple workspaces are open)"; + +const BODY_TYPE: &str = r#"Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")"#; + +const BODY: &str = r#"Body content object. Structure varies by bodyType: +- "binary": { filePath: "/path/to/file" } +- "graphql": { query: "{ users { name } }", variables: "{\"id\": \"123\"}" } +- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] } +- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] } +- text-based (application/json, etc.): { text: "raw body content" }"#; + +const AUTHENTICATION_TYPE: &str = r#"Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent."#; + +const AUTHENTICATION: &str = r#"Authentication configuration object. Structure varies by authenticationType: +- "basic": { username: "user", password: "pass" } +- "bearer": { token: "abc123", prefix: "Bearer" } +- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... } +- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." } +- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" } +- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." } +- "none": {}"#; + +fn enabled_default() -> bool { + true +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct Pair { + pub name: String, + pub value: String, + #[serde(default = "enabled_default")] + pub enabled: bool, +} + +/// The given fields of a tool input, by their model names. +pub fn given_fields(input: &T) -> Map { + match serde_json::to_value(input) { + Ok(Value::Object(fields)) => fields, + _ => Map::new(), + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceParams { + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetHttpRequestParams { + #[schemars(description = "The HTTP request ID")] + pub id: String, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SendHttpRequestParams { + #[schemars(description = "The HTTP request ID to send")] + pub id: String, + #[schemars( + description = "Environment ID for this send only; defaults to the active environment" + )] + pub environment_id: Option, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateHttpRequestParams { + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request name (empty string to auto-generate from URL)")] + pub name: Option, + #[schemars(description = "Request URL")] + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "HTTP method (defaults to GET)")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request headers")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "URL query parameters")] + pub url_parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY_TYPE)] + pub body_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY)] + pub body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateHttpRequestParams { + #[serde(skip_serializing)] + #[schemars(description = "HTTP request ID to update")] + pub id: String, + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request name")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request URL")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "HTTP method")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request headers")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "URL query parameters")] + pub url_parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY_TYPE)] + pub body_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY)] + pub body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteHttpRequestParams { + #[schemars(description = "HTTP request ID to delete")] + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetFolderParams { + #[schemars(description = "The folder ID")] + pub id: String, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateFolderParams { + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[schemars(description = "Folder name")] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID (for nested folders)")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Sort priority for ordering")] + pub sort_priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Default headers to apply to requests in this folder")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFolderParams { + #[serde(skip_serializing)] + #[schemars(description = "Folder ID to update")] + pub id: String, + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder name")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID (for nested folders)")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Sort priority for ordering")] + pub sort_priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Default headers to apply to requests in this folder")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteFolderParams { + #[schemars(description = "Folder ID to delete")] + pub id: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToastIcon { + AlertTriangle, + Check, + CheckCircle, + ChevronDown, + Copy, + Info, + Pin, + Search, + Trash, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToastColor { + Primary, + Secondary, + Info, + Success, + Notice, + Warning, + Danger, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShowToastParams { + #[schemars(description = "The message to display")] + pub message: String, + #[schemars(description = "Icon name")] + pub icon: Option, + #[schemars(description = "Toast color")] + pub color: Option, + #[schemars(description = "Timeout in milliseconds")] + pub timeout: Option, +} diff --git a/crates/yaak-mcp/src/server.rs b/crates/yaak-mcp/src/server.rs new file mode 100644 index 000000000..6501f89db --- /dev/null +++ b/crates/yaak-mcp/src/server.rs @@ -0,0 +1,338 @@ +use crate::params::*; +use crate::{HostResult, McpHost, WorkspaceInfo}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerConfig}; +use rmcp::{ServerHandler, tool, tool_handler, tool_router}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; +use std::sync::Arc; +use yaak_models::models::{Folder, HttpRequest}; +use yaak_plugins::events::{Color, Icon, ShowToastRequest}; + +type ToolResult = Result; + +pub struct YaakMcpServer { + host: Arc, + tool_router: ToolRouter, +} + +impl Clone for YaakMcpServer { + fn clone(&self) -> Self { + Self { host: self.host.clone(), tool_router: self.tool_router.clone() } + } +} + +impl YaakMcpServer { + pub fn new(host: H) -> Self { + Self::from_arc(Arc::new(host)) + } + + pub fn from_arc(host: Arc) -> Self { + Self { host, tool_router: Self::tool_router() } + } + + fn resolve_workspace(&self, workspace_id: Option<&str>) -> HostResult { + let workspaces = self.host.list_workspaces()?; + + let Some(workspace_id) = workspace_id else { + if workspaces.len() > 1 { + let list = workspaces + .iter() + .enumerate() + .map(|(i, w)| format!("{}. {} (ID: {})", i + 1, w.name, w.id)) + .collect::>() + .join("\n"); + return Err(format!( + "Multiple workspaces are open. Please specify which workspace to use.\n\n\ + Currently open workspaces:\n{list}\n\n\ + You can use the list_workspaces tool to get workspace IDs, then use other tools \ + with the workspace context. For example, ask the user which workspace they want \ + to work with by presenting them with the numbered list above." + )); + } + return workspaces.into_iter().next().ok_or_else(|| "No workspace is open".to_string()); + }; + + if let Some(workspace) = workspaces.iter().find(|w| w.id == workspace_id) { + return Ok(workspace.clone()); + } + let list = + workspaces.iter().map(|w| format!("- {} (ID: {})", w.name, w.id)).collect::>(); + Err(format!( + "Workspace with ID \"{workspace_id}\" not found.\n\nAvailable workspaces:\n{}", + list.join("\n") + )) + } + + fn find_folder(&self, workspace_id: &str, id: &str) -> HostResult> { + Ok(self.host.list_folders(workspace_id)?.into_iter().find(|f| f.id == id)) + } +} + +#[tool_router] +impl YaakMcpServer { + #[tool( + name = "list_workspaces", + title = "List Workspaces", + description = "List all open workspaces in Yaak" + )] + async fn list_workspaces(&self) -> ToolResult { + to_json(&self.host.list_workspaces()?) + } + + #[tool( + name = "get_workspace_id", + title = "Get Workspace ID", + description = "Get the current workspace ID" + )] + async fn get_workspace_id(&self) -> ToolResult { + Ok(self.resolve_workspace(None)?.id) + } + + #[tool( + name = "get_environment_id", + title = "Get Environment ID", + description = "Get the current environment ID" + )] + async fn get_environment_id(&self) -> ToolResult { + let workspace = self.resolve_workspace(None)?; + Ok(self + .host + .active_environment_id(&workspace.id)? + .unwrap_or_else(|| "No environment selected".to_string())) + } + + #[tool( + name = "list_http_requests", + title = "List HTTP Requests", + description = "List all HTTP requests in a workspace" + )] + async fn list_http_requests(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.list_http_requests(&workspace.id)?) + } + + #[tool( + name = "get_http_request", + title = "Get HTTP Request", + description = "Get details of a specific HTTP request by ID" + )] + async fn get_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.get_http_request(&p.id)?) + } + + #[tool( + name = "send_http_request", + title = "Send HTTP Request", + description = "Send an HTTP request and get the response" + )] + async fn send_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let request = self + .host + .get_http_request(&p.id)? + .ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?; + let response = + self.host.send_http_request(&workspace.id, request, p.environment_id).await?; + + // Plugins never saw where the host keeps the body, so neither do MCP clients + let mut response = serde_json::to_value(response).map_err(|e| e.to_string())?; + if let Value::Object(fields) = &mut response { + fields.remove("bodyPath"); + } + to_json(&response) + } + + #[tool( + name = "create_http_request", + title = "Create HTTP Request", + description = "Create a new HTTP request" + )] + async fn create_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let mut fields = Map::new(); + fields.insert("name".into(), json!("")); + fields.insert("method".into(), json!("GET")); + fields.insert("workspaceId".into(), json!(workspace.id)); + fields.extend(given_fields(&p)); + fields.insert("id".into(), json!("")); + let request: HttpRequest = from_fields(fields)?; + to_json(&self.host.upsert_http_request(&request)?) + } + + #[tool( + name = "update_http_request", + title = "Update HTTP Request", + description = "Update an existing HTTP request" + )] + async fn update_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + self.resolve_workspace(p.workspace_id.as_deref())?; + let existing = self + .host + .get_http_request(&p.id)? + .ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?; + let request: HttpRequest = merge(&existing, given_fields(&p))?; + to_json(&self.host.upsert_http_request(&request)?) + } + + #[tool( + name = "delete_http_request", + title = "Delete HTTP Request", + description = "Delete an HTTP request by ID" + )] + async fn delete_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let request = self.host.delete_http_request(&p.id)?; + Ok(format!("Deleted: {} ({})", request.name, request.id)) + } + + #[tool( + name = "list_folders", + title = "List Folders", + description = "List all folders in a workspace" + )] + async fn list_folders(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.list_folders(&workspace.id)?) + } + + #[tool( + name = "get_folder", + title = "Get Folder", + description = "Get details of a specific folder by ID" + )] + async fn get_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.find_folder(&workspace.id, &p.id)?) + } + + #[tool( + name = "create_folder", + title = "Create Folder", + description = "Create a new folder in a workspace" + )] + async fn create_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let mut fields = given_fields(&p); + fields.insert("workspaceId".into(), json!(workspace.id)); + fields.insert("id".into(), json!("")); + let folder: Folder = from_fields(fields)?; + to_json(&self.host.upsert_folder(&folder)?) + } + + #[tool( + name = "update_folder", + title = "Update Folder", + description = "Update an existing folder" + )] + async fn update_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let existing = self + .find_folder(&workspace.id, &p.id)? + .ok_or_else(|| format!("Folder with ID {} not found", p.id))?; + let folder: Folder = merge(&existing, given_fields(&p))?; + to_json(&self.host.upsert_folder(&folder)?) + } + + #[tool( + name = "delete_folder", + title = "Delete Folder", + description = "Delete a folder by ID" + )] + async fn delete_folder(&self, Parameters(p): Parameters) -> ToolResult { + let folder = self.host.delete_folder(&p.id)?; + Ok(format!("Deleted: {} ({})", folder.name, folder.id)) + } + + #[tool( + name = "show_toast", + title = "Show Toast", + description = "Show a toast notification in Yaak" + )] + async fn show_toast(&self, Parameters(p): Parameters) -> ToolResult { + self.host.show_toast(ShowToastRequest { + message: p.message.clone(), + icon: p.icon.map(icon), + color: p.color.map(color), + timeout: Some(p.timeout.map_or(5000, |t| t as i32)), + })?; + Ok(format!("✓ Toast shown: \"{}\"", p.message)) + } +} + +#[tool_handler] +impl ServerHandler for YaakMcpServer { + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + info.server_info = Implementation::new("yaak-mcp-server", env!("CARGO_PKG_VERSION")); + info + } +} + +fn to_json(value: &T) -> ToolResult { + serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize result: {e}")) +} + +fn from_fields(fields: Map) -> HostResult { + serde_json::from_value(Value::Object(fields)).map_err(|e| format!("Invalid input: {e}")) +} + +/// The given fields laid over `existing`, keeping its id. +fn merge( + existing: &T, + given: Map, +) -> HostResult { + let Value::Object(mut fields) = serde_json::to_value(existing).map_err(|e| e.to_string())? + else { + return Err("Model did not serialize to an object".to_string()); + }; + let id = fields.get("id").cloned(); + fields.extend(given); + if let Some(id) = id { + fields.insert("id".into(), id); + } + from_fields(fields) +} + +fn icon(icon: ToastIcon) -> Icon { + match icon { + ToastIcon::AlertTriangle => Icon::AlertTriangle, + ToastIcon::Check => Icon::Check, + ToastIcon::CheckCircle => Icon::CheckCircle, + ToastIcon::ChevronDown => Icon::ChevronDown, + ToastIcon::Copy => Icon::Copy, + ToastIcon::Info => Icon::Info, + ToastIcon::Pin => Icon::Pin, + ToastIcon::Search => Icon::Search, + ToastIcon::Trash => Icon::Trash, + } +} + +fn color(color: ToastColor) -> Color { + match color { + ToastColor::Primary => Color::Primary, + ToastColor::Secondary => Color::Secondary, + ToastColor::Info => Color::Info, + ToastColor::Success => Color::Success, + ToastColor::Notice => Color::Notice, + ToastColor::Warning => Color::Warning, + ToastColor::Danger => Color::Danger, + } +} diff --git a/crates/yaak-mcp/tests/http.rs b/crates/yaak-mcp/tests/http.rs new file mode 100644 index 000000000..0462176fb --- /dev/null +++ b/crates/yaak-mcp/tests/http.rs @@ -0,0 +1,98 @@ +use reqwest::StatusCode; +use std::sync::Arc; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_plugins::events::ShowToastRequest; + +struct EmptyHost; + +impl McpHost for EmptyHost { + fn list_workspaces(&self) -> HostResult> { + Ok(Vec::new()) + } + fn active_environment_id(&self, _: &str) -> HostResult> { + Ok(None) + } + fn list_http_requests(&self, _: &str) -> HostResult> { + Ok(Vec::new()) + } + fn get_http_request(&self, _: &str) -> HostResult> { + Ok(None) + } + fn upsert_http_request(&self, r: &HttpRequest) -> HostResult { + Ok(r.clone()) + } + fn delete_http_request(&self, _: &str) -> HostResult { + Err("missing".into()) + } + fn list_folders(&self, _: &str) -> HostResult> { + Ok(Vec::new()) + } + fn upsert_folder(&self, f: &Folder) -> HostResult { + Ok(f.clone()) + } + fn delete_folder(&self, _: &str) -> HostResult { + Err("missing".into()) + } + async fn send_http_request( + &self, + _: &str, + _: HttpRequest, + _: Option, + ) -> HostResult { + Err("missing".into()) + } + fn show_toast(&self, _: ShowToastRequest) -> HostResult<()> { + Ok(()) + } +} + +const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; + +#[tokio::test] +async fn requires_the_bearer_token() { + let server = yaak_mcp::http::serve( + Arc::new(EmptyHost), + "127.0.0.1:0".parse().unwrap(), + "secret".to_string(), + ) + .await + .unwrap(); + let url = format!("http://{}/mcp", server.addr); + let client = reqwest::Client::new(); + let post = |url: &str, auth: Option<&str>| { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INITIALIZE); + if let Some(auth) = auth { + request = request.header("Authorization", auth); + } + request.send() + }; + + for auth in [ + None, + Some("Bearer wrong"), + Some("Bearer secre"), + Some("secret"), + ] { + let response = post(&url, auth).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{auth:?}"); + assert_eq!(response.headers()["www-authenticate"], "Bearer"); + } + + let response = post(&url, Some("Bearer secret")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key("mcp-session-id")); + let body = response.text().await.unwrap(); + assert!(body.contains("yaak-mcp-server"), "{body}"); + + let other = format!("http://{}/other", server.addr); + assert_eq!(post(&other, Some("Bearer secret")).await.unwrap().status(), StatusCode::NOT_FOUND); + + let addr = server.addr; + server.stop().await; + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); +} diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index c4a4a1f4f..7bd40fce8 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -455,6 +455,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql b/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql new file mode 100644 index 000000000..47a98f12d --- /dev/null +++ b/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql @@ -0,0 +1,3 @@ +-- Add a setting to run the built-in MCP server +ALTER TABLE settings + ADD COLUMN mcp_server_enabled BOOLEAN DEFAULT FALSE NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 875012286..7c8279bc4 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -377,6 +377,7 @@ pub struct Settings { pub update_channel: String, pub hide_license_badge: bool, pub prompt_feedback: bool, + pub mcp_server_enabled: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, @@ -435,6 +436,7 @@ impl UpsertModelInfo for Settings { (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), (PromptFeedback, self.prompt_feedback.into()), + (McpServerEnabled, self.mcp_server_enabled.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), @@ -465,6 +467,7 @@ impl UpsertModelInfo for Settings { SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, SettingsIden::PromptFeedback, + SettingsIden::McpServerEnabled, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, @@ -506,6 +509,7 @@ impl UpsertModelInfo for Settings { auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, prompt_feedback: row.get("prompt_feedback")?, + mcp_server_enabled: row.get("mcp_server_enabled")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), diff --git a/crates/yaak-models/src/queries/settings.rs b/crates/yaak-models/src/queries/settings.rs index 12fd175be..4c3bef0d2 100644 --- a/crates/yaak-models/src/queries/settings.rs +++ b/crates/yaak-models/src/queries/settings.rs @@ -54,6 +54,7 @@ fn default_settings() -> Settings { colored_methods: false, hide_license_badge: false, prompt_feedback: true, + mcp_server_enabled: false, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(), diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index c03e2595a..af89495bd 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -398,6 +398,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index c4280394a..bc939fe93 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -67,6 +67,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = { clipboardRead: true, systemFonts: true, license: true, + agents: true, }; function createWindow(): PlatformWindow { diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index 52119da75..51d4e0b0f 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -313,6 +313,8 @@ export interface PlatformCapabilities { systemFonts: boolean; /** Commercial licence activation. */ license: boolean; + /** Connecting local AI agents: the Yaak CLI on this machine and the app's own MCP server. */ + agents: boolean; } export type CapabilityName = keyof PlatformCapabilities; diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 787a3a5b5..d8094174d 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -72,6 +72,8 @@ function capabilitiesFor(): PlatformCapabilities { clipboardRead: false, systemFonts: false, license: false, + // No local CLI to find and no app process to serve MCP from. + agents: false, }; } diff --git a/plugins-external/mcp-server/README.md b/plugins-external/mcp-server/README.md index ebd95982e..c2d9c84ba 100644 --- a/plugins-external/mcp-server/README.md +++ b/plugins-external/mcp-server/README.md @@ -2,6 +2,9 @@ Exposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). +**Deprecated.** Yaak now has a built-in MCP server with the same tools. Uninstall this plugin, +then turn the server on in Settings > Agents, or run `yaak mcp` from the Yaak CLI. + ## Setup Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): diff --git a/plugins-external/mcp-server/src/index.ts b/plugins-external/mcp-server/src/index.ts index 01c15a9f6..2da1a6b6f 100644 --- a/plugins-external/mcp-server/src/index.ts +++ b/plugins-external/mcp-server/src/index.ts @@ -11,6 +11,13 @@ export const plugin: PluginDefinition = { // like show the startup toast. console.log("Initializing MCP Server plugin"); setTimeout(async () => { + void ctx.toast.show({ + message: + "The MCP Server plugin is deprecated. Yaak now has a built-in MCP server: uninstall this plugin, then turn it on in Settings > Agents.", + icon: "alert_triangle", + color: "notice", + timeout: 30000, + }); try { mcpServer = createMcpServer({ yaak: ctx }, serverPort); } catch (err) { From 19908b190250defa235661692b0105458a0b22b3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 14 Oct 2026 22:16:55 -0700 Subject: [PATCH 107/155] Add a Connect an agent section with setup for Claude Code, Codex, Cursor, and VS Code --- .../components/Settings/SettingsAgents.tsx | 167 +++++++++--------- apps/yaak-client/lib/agentSetup.test.ts | 62 +++++++ apps/yaak-client/lib/agentSetup.ts | 104 +++++++++++ 3 files changed, 254 insertions(+), 79 deletions(-) create mode 100644 apps/yaak-client/lib/agentSetup.test.ts create mode 100644 apps/yaak-client/lib/agentSetup.ts diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx index 84099f138..102d53705 100644 --- a/apps/yaak-client/components/Settings/SettingsAgents.tsx +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -6,10 +6,19 @@ import type { AppMetaData } from "@yaakapp-internal/rpc-schema"; import { useAtomValue } from "jotai"; import { useKeyValue } from "../../hooks/useKeyValue"; import { appInfo } from "../../lib/appInfo"; +import { + AGENT_CLIENTS, + type AgentClient, + type AgentSetup, + appSetup, + cliSetup, +} from "../../lib/agentSetup"; import { rpc } from "../../lib/rpc"; import { CopyIconButton } from "../CopyIconButton"; import { Button } from "../core/Button"; +import { Editor } from "../core/Editor/LazyEditor"; import { IconButton } from "../core/IconButton"; +import { SegmentedControl } from "../core/SegmentedControl"; import { ModelSettingRowBoolean, SettingRow, @@ -21,7 +30,6 @@ import { const MCP_URL = "http://127.0.0.1:64343/mcp"; const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli"; const SKILL_INSTALL_COMMAND = "yaak agent install"; -const CLAUDE_CODE_COMMAND = "claude mcp add yaak -- yaak mcp"; export function SettingsAgents() { const settings = useAtomValue(settingsAtom); @@ -30,6 +38,11 @@ export function SettingsAgents() { key: "mcp_server_token", fallback: null, }); + const { value: storedClient, set: setClient } = useKeyValue({ + key: "agents_setup_client", + fallback: "claude_code", + }); + const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code"; const cliVersion = useQuery({ queryKey: ["cli_version"], queryFn: async () => (await rpc("cmd_metadata")).cliVersion, @@ -48,7 +61,7 @@ export function SettingsAgents() { ) : ( - <> - - Teach the agents you have installed to drive the yaak{" "} - command. - - } - > - - - - - - - For other clients that read an mcpServers config, like - Cursor. - - } - snippet={mcpServersConfig("yaak", ["mcp"])} - /> - + + Teach Claude Code, Cursor, Codex, and opencode to drive the{" "} + yaak command, wherever they're installed. + + } + > + + )} {settings.mcpServerEnabled && token != null && ( - <> - - - - - For clients that read an mcpServers config, like Claude - Desktop and Cursor. - - } - snippet={mcpServersConfig("npx", [ - "-y", - "mcp-remote", - MCP_URL, - "--header", - `Authorization: Bearer ${token}`, - ])} - /> - + + + + )} + + + +
+ +
+ + {settings.mcpServerEnabled && token != null ? ( + + ) : ( + + + )}
@@ -159,40 +178,30 @@ export function SettingsAgents() { ); } -function SnippetRow({ +function SetupRow({ title, description, - snippet, + setup, }: { title: string; - description: React.ReactNode; - snippet: string; + description: string; + setup: AgentSetup; }) { return (
{title}
{description}
-
-
-          {snippet}
-        
-
- -
-
+ } + />
); } - -function mcpServersConfig(command: string, args: string[]) { - return [ - "{", - ' "mcpServers": {', - ' "yaak": {', - ` "command": ${JSON.stringify(command)},`, - ` "args": [${args.map((a) => JSON.stringify(a)).join(", ")}]`, - " }", - " }", - "}", - ].join("\n"); -} diff --git a/apps/yaak-client/lib/agentSetup.test.ts b/apps/yaak-client/lib/agentSetup.test.ts new file mode 100644 index 000000000..847d88a2f --- /dev/null +++ b/apps/yaak-client/lib/agentSetup.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vite-plus/test"; +import { AGENT_CLIENTS, appSetup, cliSetup } from "./agentSetup"; + +const URL = "http://127.0.0.1:64343/mcp"; +const TOKEN = "abc123"; + +describe("cliSetup", () => { + test("uses each client's own add command", () => { + expect(cliSetup("claude_code").snippet).toBe("claude mcp add yaak -- yaak mcp"); + expect(cliSetup("codex").snippet).toBe("codex mcp add yaak -- yaak mcp"); + }); + + test("writes the config shape each client reads", () => { + expect(JSON.parse(cliSetup("cursor").snippet)).toEqual({ + mcpServers: { yaak: { command: "yaak", args: ["mcp"] } }, + }); + expect(JSON.parse(cliSetup("vscode").snippet)).toEqual({ + servers: { yaak: { type: "stdio", command: "yaak", args: ["mcp"] } }, + }); + expect(JSON.parse(cliSetup("other").snippet)).toEqual({ + mcpServers: { yaak: { command: "yaak", args: ["mcp"] } }, + }); + }); +}); + +describe("appSetup", () => { + test("passes the token as a bearer header", () => { + expect(appSetup("claude_code", URL, TOKEN).snippet).toBe( + `claude mcp add --transport http yaak ${URL} --header "Authorization: Bearer ${TOKEN}"`, + ); + expect(appSetup("codex", URL, TOKEN).snippet).toBe( + `[mcp_servers.yaak]\nurl = "${URL}"\nhttp_headers = { "Authorization" = "Bearer ${TOKEN}" }`, + ); + expect(JSON.parse(appSetup("cursor", URL, TOKEN).snippet)).toEqual({ + mcpServers: { yaak: { url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } }, + }); + expect(JSON.parse(appSetup("vscode", URL, TOKEN).snippet)).toEqual({ + servers: { yaak: { type: "http", url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } }, + }); + expect(JSON.parse(appSetup("other", URL, TOKEN).snippet)).toEqual({ + mcpServers: { + yaak: { + command: "npx", + args: ["-y", "mcp-remote", URL, "--header", `Authorization: Bearer ${TOKEN}`], + }, + }, + }); + }); + + test("keeps argument lists on one line", () => { + expect(appSetup("other", URL, TOKEN).snippet).toContain( + `"args": ["-y", "mcp-remote", "${URL}", "--header", "Authorization: Bearer ${TOKEN}"]`, + ); + }); + + test("covers every listed client", () => { + for (const { value } of AGENT_CLIENTS) { + expect(cliSetup(value).snippet).not.toBe(""); + expect(appSetup(value, URL, TOKEN).snippet).toContain(TOKEN); + } + }); +}); diff --git a/apps/yaak-client/lib/agentSetup.ts b/apps/yaak-client/lib/agentSetup.ts new file mode 100644 index 000000000..fd5e489e2 --- /dev/null +++ b/apps/yaak-client/lib/agentSetup.ts @@ -0,0 +1,104 @@ +export type AgentClient = "claude_code" | "codex" | "cursor" | "vscode" | "other"; + +export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [ + { label: "Claude Code", value: "claude_code" }, + { label: "Codex", value: "codex" }, + { label: "Cursor", value: "cursor" }, + { label: "VS Code", value: "vscode" }, + { label: "Other", value: "other" }, +]; + +export interface AgentSetup { + /** Where the snippet goes, as a sentence */ + where: string; + snippet: string; + language: "shell" | "json" | "text"; +} + +const RUN_IN_TERMINAL = "Run in a terminal."; + +/** Setup for `yaak mcp`, which the client launches over stdio. */ +export function cliSetup(client: AgentClient): AgentSetup { + switch (client) { + case "claude_code": + return { where: RUN_IN_TERMINAL, snippet: "claude mcp add yaak -- yaak mcp", language: "shell" }; + case "codex": + return { where: RUN_IN_TERMINAL, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" }; + case "cursor": + return { + where: "Add to ~/.cursor/mcp.json.", + snippet: json({ mcpServers: { yaak: { command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + case "vscode": + return { + where: "Add to your user mcp.json (MCP: Open User Configuration).", + snippet: json({ servers: { yaak: { type: "stdio", command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + case "other": + return { + where: "For clients that read an mcpServers config, like Claude Desktop and Windsurf.", + snippet: json({ mcpServers: { yaak: { command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + } +} + +/** Setup for the app's MCP server at `url`, which needs `token` as a bearer token. */ +export function appSetup(client: AgentClient, url: string, token: string): AgentSetup { + const authorization = `Bearer ${token}`; + switch (client) { + case "claude_code": + return { + where: RUN_IN_TERMINAL, + snippet: `claude mcp add --transport http yaak ${url} --header "Authorization: ${authorization}"`, + language: "shell", + }; + case "codex": + return { + where: "Add to ~/.codex/config.toml.", + snippet: [ + "[mcp_servers.yaak]", + `url = ${JSON.stringify(url)}`, + `http_headers = { "Authorization" = ${JSON.stringify(authorization)} }`, + ].join("\n"), + language: "text", + }; + case "cursor": + return { + where: "Add to ~/.cursor/mcp.json.", + snippet: json({ mcpServers: { yaak: { url, headers: { Authorization: authorization } } } }), + language: "json", + }; + case "vscode": + return { + where: "Add to your user mcp.json (MCP: Open User Configuration).", + snippet: json({ + servers: { yaak: { type: "http", url, headers: { Authorization: authorization } } }, + }), + language: "json", + }; + case "other": + return { + where: "For clients that read an mcpServers config, through the mcp-remote bridge.", + snippet: json({ + mcpServers: { + yaak: { + command: "npx", + args: ["-y", "mcp-remote", url, "--header", `Authorization: ${authorization}`], + }, + }, + }), + language: "json", + }; + } +} + +/** Pretty JSON with arrays of plain values kept on one line. */ +function json(value: unknown): string { + return JSON.stringify(value, null, 2).replace( + /\[\n\s*([^[\]{}]*?)\n\s*\]/g, + (_, items: string) => `[${items.split(/,\n\s*/).join(", ")}]`, + ); +} From 9b3715024d6484f3fb2291a48807b16da54e3d2a Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 15 Oct 2026 06:22:23 -0700 Subject: [PATCH 108/155] Move response filters into Rust (#755) --- Cargo.lock | 46 + Cargo.toml | 2 + crates-cli/yaak-cli/tests/plugin_filter.rs | 85 + crates-tauri/yaak-app-client/Cargo.toml | 1 + crates-tauri/yaak-app-client/src/lib.rs | 9 +- crates/yaak-filter/Cargo.toml | 14 + crates/yaak-filter/src/jsonpath/json.rs | 423 + crates/yaak-filter/src/jsonpath/mod.rs | 798 ++ crates/yaak-filter/src/jsonpath/path.rs | 213 + crates/yaak-filter/src/jsonpath/regex.rs | 367 + crates/yaak-filter/src/jsonpath/rfc.rs | 711 ++ crates/yaak-filter/src/jsonpath/script.rs | 803 ++ crates/yaak-filter/src/jsonpath/value.rs | 953 ++ crates/yaak-filter/src/lib.rs | 57 + crates/yaak-filter/src/xml.rs | 206 + .../tests/fixtures/generate-jsonpath-plus.mjs | 1433 +++ .../tests/fixtures/jsonpath_plus.json | 8710 +++++++++++++++++ crates/yaak-filter/tests/json_path.rs | 300 + crates/yaak-filter/tests/jsonpath_plus.rs | 77 + crates/yaak-filter/tests/xpath.rs | 105 + crates/yaak-plugins/bindings/gen_events.ts | 9 +- crates/yaak-plugins/src/events.rs | 21 + crates/yaak-plugins/src/manager.rs | 62 +- crates/yaak/Cargo.toml | 1 + crates/yaak/src/plugin_events.rs | 69 +- package-lock.json | 29 - package.json | 2 - packages/platform/src/web/commands.ts | 2 +- .../src/bindings/gen_events.ts | 9 +- .../src/plugins/Context.ts | 11 + packages/plugin-runtime/src/PluginInstance.ts | 14 + packages/plugin-runtime/tests/filter.test.ts | 71 + .../components/editor/json/jsonPath.test.ts | 23 + plugins/filter-jsonpath/README.md | 59 - plugins/filter-jsonpath/package.json | 24 - plugins/filter-jsonpath/screenshot.png | Bin 345846 -> 0 bytes plugins/filter-jsonpath/src/index.ts | 39 - .../filter-jsonpath/tests/breadcrumbs.test.ts | 130 - plugins/filter-jsonpath/tsconfig.json | 3 - plugins/filter-xpath/package.json | 15 - plugins/filter-xpath/src/index.ts | 28 - plugins/filter-xpath/tsconfig.json | 3 - 42 files changed, 15538 insertions(+), 399 deletions(-) create mode 100644 crates-cli/yaak-cli/tests/plugin_filter.rs create mode 100644 crates/yaak-filter/Cargo.toml create mode 100644 crates/yaak-filter/src/jsonpath/json.rs create mode 100644 crates/yaak-filter/src/jsonpath/mod.rs create mode 100644 crates/yaak-filter/src/jsonpath/path.rs create mode 100644 crates/yaak-filter/src/jsonpath/regex.rs create mode 100644 crates/yaak-filter/src/jsonpath/rfc.rs create mode 100644 crates/yaak-filter/src/jsonpath/script.rs create mode 100644 crates/yaak-filter/src/jsonpath/value.rs create mode 100644 crates/yaak-filter/src/lib.rs create mode 100644 crates/yaak-filter/src/xml.rs create mode 100644 crates/yaak-filter/tests/fixtures/generate-jsonpath-plus.mjs create mode 100644 crates/yaak-filter/tests/fixtures/jsonpath_plus.json create mode 100644 crates/yaak-filter/tests/json_path.rs create mode 100644 crates/yaak-filter/tests/jsonpath_plus.rs create mode 100644 crates/yaak-filter/tests/xpath.rs create mode 100644 packages/plugin-runtime/tests/filter.test.ts delete mode 100644 plugins/filter-jsonpath/README.md delete mode 100644 plugins/filter-jsonpath/package.json delete mode 100644 plugins/filter-jsonpath/screenshot.png delete mode 100644 plugins/filter-jsonpath/src/index.ts delete mode 100644 plugins/filter-jsonpath/tests/breadcrumbs.test.ts delete mode 100644 plugins/filter-jsonpath/tsconfig.json delete mode 100644 plugins/filter-xpath/package.json delete mode 100644 plugins/filter-xpath/src/index.ts delete mode 100644 plugins/filter-xpath/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 255c7cac3..e1bb2fcd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5795,6 +5795,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + [[package]] name = "pest" version = "0.3.3" @@ -8373,6 +8379,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error", + "sxd-document", +] + [[package]] name = "syn" version = "1.0.109" @@ -9730,6 +9757,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "typed-arena" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" + [[package]] name = "typedmap" version = "0.6.0" @@ -11173,6 +11206,7 @@ dependencies = [ "tokio", "yaak-core", "yaak-crypto", + "yaak-filter", "yaak-http", "yaak-models", "yaak-plugins", @@ -11240,6 +11274,7 @@ dependencies = [ "yaak-common", "yaak-core", "yaak-crypto", + "yaak-filter", "yaak-fonts", "yaak-git", "yaak-grpc", @@ -11390,6 +11425,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "yaak-filter" +version = "0.1.0" +dependencies = [ + "fancy-regex", + "indexmap 2.14.0", + "serde_json", + "sxd-document", + "sxd-xpath", +] + [[package]] name = "yaak-fonts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3780f5343..fbc8e874d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/yaak-core", "crates/yaak-common", "crates/yaak-crypto", + "crates/yaak-filter", "crates/yaak-git", "crates/yaak-grpc", "crates/yaak-http", @@ -86,6 +87,7 @@ yaak = { path = "crates/yaak" } yaak-commands = { path = "crates/yaak-commands" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } +yaak-filter = { path = "crates/yaak-filter" } yaak-git = { path = "crates/yaak-git" } yaak-grpc = { path = "crates/yaak-grpc" } yaak-http = { path = "crates/yaak-http" } diff --git a/crates-cli/yaak-cli/tests/plugin_filter.rs b/crates-cli/yaak-cli/tests/plugin_filter.rs new file mode 100644 index 000000000..077baf11c --- /dev/null +++ b/crates-cli/yaak-cli/tests/plugin_filter.rs @@ -0,0 +1,85 @@ +mod common; + +use common::http_server::TestHttpServer; +use common::{cli_cmd, query_manager, seed_workspace}; +use predicates::str::contains; +use std::fs; +use tempfile::TempDir; +use yaak_models::models::{HttpRequest, HttpRequestHeader}; +use yaak_models::util::UpdateSource; + +/// A template function that runs `ctx.filter` and returns the host's reply as JSON. +const PROBE_PLUGIN: &str = r#"exports.plugin = { + templateFunctions: [{ + name: "filter_probe", + args: [ + { type: "text", name: "language" }, + { type: "text", name: "text" }, + { type: "text", name: "query" }, + ], + async onRender(ctx, args) { + const { language, text, query } = args.values; + const result = language === "xpath" + ? await ctx.filter.xPath(text, query) + : await ctx.filter.jsonPath(text, query); + return JSON.stringify(result); + }, + }], +};"#; + +/// The plugin runtime, the CLI's plugin host, and yaak-filter, end to end: the +/// rendered header values are what the host replied to the plugin. +#[test] +fn plugins_filter_text_through_the_host() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + + // Bundled plugins are extracted next to this on launch; extras are kept. + let probe_dir = data_dir.join("vendored-plugins").join("filter-probe"); + fs::create_dir_all(probe_dir.join("build")).expect("Failed to create probe plugin dir"); + fs::write(probe_dir.join("package.json"), r#"{"name":"@yaak/filter-probe","version":"0.0.0"}"#) + .expect("Failed to write probe package.json"); + fs::write(probe_dir.join("build").join("index.js"), PROBE_PLUGIN) + .expect("Failed to write probe plugin"); + + seed_workspace(data_dir, "wk_test"); + let server = TestHttpServer::spawn_ok("ok"); + let header = |name: &str, value: &str| HttpRequestHeader { + enabled: true, + name: name.to_string(), + value: value.to_string(), + id: None, + }; + let request = HttpRequest { + id: "rq_filter".to_string(), + workspace_id: "wk_test".to_string(), + method: "GET".to_string(), + url: server.url.clone(), + headers: vec![ + header( + "x-json", + r#"${[ filter_probe(language='jsonpath', text='{"a":[1,2]}', query='$.a[1]') ]}"#, + ), + header( + "x-xml", + "${[ filter_probe(language='xpath', text='
x', query='//b') ]}", + ), + header( + "x-bad", + "${[ filter_probe(language='jsonpath', text='{}', query='$[?(@ ==)]') ]}", + ), + ], + ..Default::default() + }; + query_manager(data_dir) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .expect("Failed to seed request"); + + cli_cmd(data_dir) + .args(["--verbose", "request", "send", "rq_filter"]) + .assert() + .success() + .stdout(contains(r#"> x-json: {"content":"[\n 2\n]"}"#)) + .stdout(contains(r#"> x-xml: {"content":"x"}"#)) + .stdout(contains(r#"> x-bad: {"content":"","error":"Invalid filter: "#)); +} diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index dd3c2cd0e..ed7547c6b 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -83,6 +83,7 @@ yaak-core = { workspace = true } yaak = { workspace = true } yaak-commands = { workspace = true } yaak-crypto = { workspace = true } +yaak-filter = { workspace = true } yaak-fonts = { workspace = true } yaak-git = { workspace = true } yaak-grpc = { workspace = true } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index df5778b7f..e53668207 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -1004,10 +1004,11 @@ async fn cmd_http_response_body( .ok_or(GenericError("Failed to find response body".to_string()))?; match filter { - Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window) - .await? - .filter_data(&window.plugin_context(), filter, &body, content_type) - .await?), + Some(filter) if !filter.is_empty() => { + let language = yaak_filter::Language::for_content_type(content_type); + let result = yaak_filter::filter(language, &body, filter); + Ok(FilterResponse { content: result.content, error: result.error }) + } _ => Ok(FilterResponse { content: body, error: None }), } } diff --git a/crates/yaak-filter/Cargo.toml b/crates/yaak-filter/Cargo.toml new file mode 100644 index 000000000..052e35201 --- /dev/null +++ b/crates/yaak-filter/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yaak-filter" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +fancy-regex = "0.17" +indexmap = "2" +sxd-document = "0.3.2" +sxd-xpath = "0.4.2" + +[dev-dependencies] +serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/yaak-filter/src/jsonpath/json.rs b/crates/yaak-filter/src/jsonpath/json.rs new file mode 100644 index 000000000..5f6b0420f --- /dev/null +++ b/crates/yaak-filter/src/jsonpath/json.rs @@ -0,0 +1,423 @@ +//! The response document as `JSON.parse` builds it, and `JSON.stringify(_, null, 2)`. + +use super::value::JsValue; +use indexmap::IndexMap; + +const MAX_DEPTH: usize = 128; + +#[derive(Debug)] +pub enum Json { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(IndexMap), +} + +pub fn parse(text: &str) -> Result { + let mut parser = Parser { bytes: text.as_bytes(), text, pos: 0 }; + parser.skip_ws(); + let value = parser.value(0)?; + parser.skip_ws(); + if parser.pos < parser.bytes.len() { + return Err(parser.error("trailing characters")); + } + Ok(value) +} + +struct Parser<'t> { + bytes: &'t [u8], + text: &'t str, + pos: usize, +} + +impl Parser<'_> { + fn error(&self, message: &str) -> String { + let mut end = self.pos.min(self.text.len()); + while !self.text.is_char_boundary(end) { + end -= 1; + } + let before = &self.text[..end]; + let line = before.matches('\n').count() + 1; + let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1; + format!("{message} at line {line} column {column}") + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.peek() { + self.pos += 1; + } + } + + fn expect_word(&mut self, word: &str, value: Json) -> Result { + if self.text[self.pos..].starts_with(word) { + self.pos += word.len(); + Ok(value) + } else { + Err(self.error("expected value")) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(self.error("recursion limit exceeded")); + } + match self.peek() { + None => Err(self.error("EOF while parsing a value")), + Some(b'n') => self.expect_word("null", Json::Null), + Some(b't') => self.expect_word("true", Json::Bool(true)), + Some(b'f') => self.expect_word("false", Json::Bool(false)), + Some(b'"') => Ok(Json::String(self.string()?)), + Some(b'[') => self.array(depth), + Some(b'{') => self.object(depth), + Some(b'-' | b'0'..=b'9') => self.number(), + Some(_) => Err(self.error("expected value")), + } + } + + fn array(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Array(items)); + } + loop { + self.skip_ws(); + items.push(self.value(depth + 1)?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Json::Array(items)); + } + _ => return Err(self.error("expected `,` or `]`")), + } + } + } + + fn object(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut map = IndexMap::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Object(map)); + } + loop { + self.skip_ws(); + if self.peek() != Some(b'"') { + return Err(self.error("key must be a string")); + } + let key = self.string()?; + self.skip_ws(); + if self.peek() != Some(b':') { + return Err(self.error("expected `:`")); + } + self.pos += 1; + self.skip_ws(); + let value = self.value(depth + 1)?; + map.insert(key, value); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Json::Object(js_key_order(map))); + } + _ => return Err(self.error("expected `,` or `}`")), + } + } + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => self.digits(), + _ => return Err(self.error("invalid number")), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.error("invalid number")); + } + self.digits(); + } + if let Some(b'e' | b'E') = self.peek() { + self.pos += 1; + if let Some(b'+' | b'-') = self.peek() { + self.pos += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.error("invalid number")); + } + self.digits(); + } + let number = + self.text[start..self.pos].parse::().map_err(|e| self.error(&e.to_string()))?; + Ok(Json::Number(number)) + } + + fn digits(&mut self) { + while let Some(b'0'..=b'9') = self.peek() { + self.pos += 1; + } + } + + fn hex4(&mut self) -> Result { + let hex = + self.text.get(self.pos..self.pos + 4).ok_or_else(|| self.error("invalid escape"))?; + let unit = u16::from_str_radix(hex, 16).map_err(|_| self.error("invalid escape"))?; + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(self.error("invalid escape")); + } + self.pos += 4; + Ok(unit) + } + + fn string(&mut self) -> Result { + self.pos += 1; + let mut out = String::new(); + loop { + let run = self.pos; + while let Some(b) = self.peek() { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + out.push_str(&self.text[run..self.pos]); + match self.peek() { + None => return Err(self.error("EOF while parsing a string")), + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let escape = + self.peek().ok_or_else(|| self.error("EOF while parsing a string"))?; + self.pos += 1; + match escape { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let unit = self.hex4()?; + let mut units = vec![unit]; + if (0xD800..0xDC00).contains(&unit) + && self.text[self.pos..].starts_with("\\u") + { + let save = self.pos; + self.pos += 2; + let low = self.hex4()?; + if (0xDC00..0xE000).contains(&low) { + units.push(low); + } else { + self.pos = save; + } + } + out.push_str(&String::from_utf16_lossy(&units)); + } + _ => return Err(self.error("invalid escape")), + } + } + Some(_) => return Err(self.error("control character in string")), + } + } + } +} + +/// `JSON.parse` gives array-index keys first, ascending, then the rest in document order. +fn js_key_order(map: IndexMap) -> IndexMap { + if !map.keys().any(|k| array_index(k).is_some()) { + return map; + } + let (mut indexes, rest): (Vec<_>, Vec<_>) = + map.into_iter().partition(|(k, _)| array_index(k).is_some()); + indexes.sort_by_key(|(k, _)| array_index(k)); + indexes.into_iter().chain(rest).collect() +} + +/// The value of a canonical array-index string ("0", "17", never "07" or "-1"). +pub fn array_index(key: &str) -> Option { + if key.is_empty() + || (key.len() > 1 && key.starts_with('0')) + || !key.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + key.parse::().ok().filter(|i| *i < u32::MAX) +} + +/// `JSON.stringify(values, null, 2)`. +pub fn stringify_results(values: &[JsValue<'_>]) -> String { + let mut out = String::new(); + write_array(&mut out, values.iter(), values.is_empty(), 0); + out +} + +fn indent(out: &mut String, depth: usize) { + out.push('\n'); + for _ in 0..depth { + out.push_str(" "); + } +} + +fn write_array<'v, 'a: 'v>( + out: &mut String, + items: impl Iterator>, + empty: bool, + depth: usize, +) { + if empty { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + if !write_js(out, item, depth + 1) { + out.push_str("null"); + } + } + indent(out, depth); + out.push(']'); +} + +/// Writes the value and returns true, or false for what `JSON.stringify` drops. +fn write_js(out: &mut String, value: &JsValue<'_>, depth: usize) -> bool { + match value { + JsValue::Undefined | JsValue::Function(_) => return false, + JsValue::Null => out.push_str("null"), + JsValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + JsValue::Number(n) => write_number(out, *n), + JsValue::String(s) => write_string(out, s), + JsValue::Doc(json) => write_json(out, json, depth), + JsValue::Array(array) => { + write_array(out, array.items.iter(), array.items.is_empty(), depth) + } + JsValue::Regex(_) => out.push_str("{}"), + } + true +} + +fn write_json(out: &mut String, json: &Json, depth: usize) { + match json { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Number(n) => write_number(out, *n), + Json::String(s) => write_string(out, s), + Json::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + write_json(out, item, depth + 1); + } + indent(out, depth); + out.push(']'); + } + Json::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + out.push('{'); + for (i, (key, item)) in map.iter().enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + write_string(out, key); + out.push_str(": "); + write_json(out, item, depth + 1); + } + indent(out, depth); + out.push('}'); + } + } +} + +fn write_number(out: &mut String, n: f64) { + if n.is_finite() { + out.push_str(&number_to_string(n)); + } else { + out.push_str("null"); + } +} + +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// `Number.prototype.toString()` for radix 10. +pub fn number_to_string(n: f64) -> String { + if n.is_nan() { + return "NaN".to_string(); + } + if n == 0.0 { + return "0".to_string(); + } + if n.is_infinite() { + return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + let sign = if n < 0.0 { "-" } else { "" }; + let sci = format!("{:e}", n.abs()); + let (mantissa, exponent) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); + let k = digits.len() as i32; + let n_exp = exponent.parse::().unwrap_or(0) + 1; + let body = if k <= n_exp && n_exp <= 21 { + format!("{digits}{}", "0".repeat((n_exp - k) as usize)) + } else if 0 < n_exp && n_exp <= 21 { + format!("{}.{}", &digits[..n_exp as usize], &digits[n_exp as usize..]) + } else if -6 < n_exp && n_exp <= 0 { + format!("0.{}{digits}", "0".repeat((-n_exp) as usize)) + } else { + let e = n_exp - 1; + let e_sign = if e < 0 { "-" } else { "+" }; + let rest = if k > 1 { format!(".{}", &digits[1..]) } else { String::new() }; + format!("{}{rest}e{e_sign}{}", &digits[..1], e.abs()) + }; + format!("{sign}{body}") +} diff --git a/crates/yaak-filter/src/jsonpath/mod.rs b/crates/yaak-filter/src/jsonpath/mod.rs new file mode 100644 index 000000000..82f7b5736 --- /dev/null +++ b/crates/yaak-filter/src/jsonpath/mod.rs @@ -0,0 +1,798 @@ +//! A port of jsonpath-plus 11.0.0 (`JSONPath({ path, json })` with its default +//! options and safe evaluator), so expressions written for the JavaScript +//! filter plugin keep their results. Where it differs on purpose: quoted +//! bracket names are always literal keys, and some RFC 9535 syntax that +//! jsonpath-plus finds nothing for works (`[-1]`, `[::-1]`, `[?@.a > 1]`, +//! `length()`, `count()`, `match()`, `search()`, `value()`). + +mod json; +mod path; +mod regex; +mod rfc; +mod script; +mod value; + +use json::Json; +pub(crate) use json::number_to_string; +use path::Loc; +use regex::JsRegex; +use rfc::Query; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::LazyLock; +use value::{JsValue, own_property}; + +/// Nested `_trace` calls; a debug build spends about 8 KB of stack on each. +const MAX_DEPTH: usize = 180; +/// Steps a single evaluation may take; `..` after `..` grows exponentially with nesting. +const MAX_STEPS: usize = 20_000_000; + +pub fn evaluate(text: &str, query: &str) -> Result { + let root = json::parse(text).map_err(|e| format!("Invalid JSON: {e}"))?; + if let Some(keys) = path::literal_keys(query) { + let found = keys.iter().try_fold(JsValue::from_doc(&root), |v, k| own_property(&v, k)); + return Ok(json::stringify_results(found.as_slice())); + } + let engine = Engine::new(&root); + let values = + engine.run(query, &JsValue::from_doc(&root)).map_err(|e| format!("Invalid filter: {e}"))?; + Ok(json::stringify_results(&values)) +} + +#[derive(Clone, Debug)] +enum PathElem { + Str(Rc), + Num(f64), +} + +impl PathElem { + fn to_js(&self) -> JsValue<'static> { + match self { + PathElem::Str(s) => JsValue::string(s.to_string()), + PathElem::Num(n) => JsValue::Number(*n), + } + } + + fn text(&self) -> String { + match self { + PathElem::Str(s) => s.to_string(), + PathElem::Num(n) => number_to_string(*n), + } + } +} + +struct PathNode { + elem: PathElem, + prev: Path, +} + +/// The steps from the root, newest first and shared, as jsonpath-plus copies its arrays. +type Path = Option>; + +fn push(path: &Path, elem: PathElem) -> Path { + Some(Rc::new(PathNode { elem, prev: path.clone() })) +} + +/// `JSONPath.toPathString`. +fn path_string(path: &Path) -> String { + let mut elems = Vec::new(); + let mut node = path.as_ref(); + while let Some(n) = node { + elems.push(n.elem.text()); + node = n.prev.as_ref(); + } + elems.reverse(); + let mut out = "$".to_string(); + for elem in elems.iter().skip(1) { + let operator = + elem == "~" || elem == "^" || (elem.starts_with('@') && elem.ends_with("()")); + if operator { + continue; + } + if !elem.is_empty() && elem.bytes().all(|b| b.is_ascii_digit() || b == b'*') { + out.push_str(&format!("[{elem}]")); + } else { + out.push_str(&format!("['{elem}']")); + } + } + out +} + +struct Ret<'a> { + path: Path, + value: JsValue<'a>, + /// The steps after a `^`, to trace again from the parent. + parent_selector: Option>, +} + +enum Traced<'a> { + One(Ret<'a>), + Many(Vec>>), +} + +fn add<'a>(ret: &mut Vec>>, traced: Traced<'a>) { + match traced { + Traced::One(r) => ret.push(Some(r)), + Traced::Many(items) => ret.extend(items), + } +} + +enum FilterPlan { + /// jsonpath-plus spots `@.key[?(...)]` in a filter and keeps the items + /// whose `key` the inner filter selects anything from, ignoring the rest. + Nested { + key: String, + inner: Loc, + }, + Script(Rc + + + + diff --git a/apps/yaak-proxy/harness/FlowPanesHarness.tsx b/apps/yaak-proxy/harness/FlowPanesHarness.tsx new file mode 100644 index 000000000..ff15d61ba --- /dev/null +++ b/apps/yaak-proxy/harness/FlowPanesHarness.tsx @@ -0,0 +1,113 @@ +import { + HttpRequestPaneView, + HttpResponsePaneView, + type HttpBodyResult, + type ResponseViewMode, +} from "@yaakapp-internal/ui"; +import { useMemo, useState } from "react"; +import { ProxyEditorHost } from "../components/ProxyEditorHost"; +import { findHeader } from "../lib/body"; +import { + type FlowBodyData, + flowBodyFromRpc, + flowBodyLoader, + flowRequestToView, + flowResponseToView, +} from "../lib/flowViews"; +import { type FlowFixture, fixtures } from "./fixtures"; + +/** + * Dev only: renders captured flows through the shared request and response panes, read-only, + * side by side, the way the proxy's detail view will. Open `/harness.html?flow=0&theme=dark`. + */ +export function FlowPanesHarness() { + const params = new URLSearchParams(location.search); + const [index, setIndex] = useState(Number(params.get("flow") ?? 0)); + const fixture = fixtures[index] ?? fixtures[0]; + if (fixture == null) return null; + + return ( + +
+
+ {fixtures.map((f, i) => ( + + ))} +
+ +
+
+ ); +} + +function FlowPanes({ fixture }: { fixture: FlowFixture }) { + const { flow } = fixture; + const [viewMode, setViewMode] = useState("pretty"); + const requestBody = useMemo(() => flowBodyFromRpc(fixture.requestBody), [fixture]); + const responseBody = useMemo(() => flowBodyFromRpc(fixture.responseBody), [fixture]); + const request = useMemo(() => flowRequestToView(flow, requestBody), [flow, requestBody]); + const response = useMemo(() => flowResponseToView(flow, responseBody), [flow, responseBody]); + + const requestLoader = useMemo( + () => + flowBodyLoader({ + useBody: () => loaded(requestBody), + contentType: findHeader(flow.requestHeaders, "content-type"), + }), + [requestBody, flow], + ); + const responseLoader = useMemo( + () => + flowBodyLoader({ + useBody: () => loaded(responseBody), + contentType: findHeader(flow.responseHeaders, "content-type"), + }), + [responseBody, flow], + ); + + return ( +
+
+ +
+ Waiting for a response
} + viewMode={viewMode} + onViewModeChange={setViewMode} + tabsStorageKey="flow_response_tabs" + activeTabKey={flow.id} + onCopyAsCurl={() => undefined} + /> +
+ ); +} + +function loaded(data: FlowBodyData): HttpBodyResult { + return { data, isLoading: false, error: null }; +} + +/** The engine formats JSON over RPC, which a page outside the app can't reach */ +async function formatLocally(text: string, language: unknown): Promise { + if (language !== "json") return text; + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts new file mode 100644 index 000000000..bc6966b45 --- /dev/null +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -0,0 +1,141 @@ +import type { Flow, GetFlowBodyResponse } from "@yaakapp-internal/proxy-lib"; + +/** A flow fixture with its two bodies, as the engine's `get_flow_body` RPC returns them */ +export interface FlowFixture { + label: string; + flow: Flow; + requestBody: GetFlowBodyResponse; + responseBody: GetFlowBodyResponse; +} + +function body(data: string | Uint8Array): GetFlowBodyResponse { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return { data: btoa(binary), truncated: false, storedSize: bytes.length }; +} + +const base: Flow = { + id: "fl_harness", + createdAt: "2026-11-04T22:10:00", + updatedAt: "2026-11-04T22:10:01", + startedAt: "2026-11-04T22:10:00.000", + method: "GET", + url: "https://api.example.com/", + clientHttpVersion: "HTTP/2", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "93.184.216.34:443", + status: 200, + contentType: null, + requestHeaders: [], + responseHeaders: [], + requestSize: 0, + responseSize: 0, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 84, + elapsedMs: 112, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: 4211, + clientName: "curl", + clientBundleId: null, + clientPath: "/usr/bin/curl", +}; + +const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true }); +const jsonResponse = JSON.stringify({ + id: "pi_3Q8x", + object: "payment_intent", + amount: 4200, + currency: "usd", + status: "succeeded", + charges: { data: [{ id: "ch_1", paid: true }] }, +}); +const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const htmlResponse = + "

Signed in

You can close this window.

"; +// A 24x24 PNG gradient +const png = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAIAAABvFaqvAAADnUlEQVR4nA3MEQJEIQBAwc95nOdxHj2J8zjP4zyP8zjP47wD7M4B5vv4BJ/kU3yaz/BZPsfn+QJf5Et8ma/wVb7G1/kG3+RbfJvv8F2+x/d9CIGQCIXQCIOwCIfwiICIiITIiIKoiIboiIGYiIXYiIO4iIf4R1IgJVIhNdIgLdIhPTIgIzIhM7IgK7IhO3IgJ3IhN/IgL/Ih/5ESKIlSKI0yKItyKI8KqIhKqIwqqIpqqI4aqIlaqI06qIt6qH+kBVqiFVqjDdqiHdqjAzqiEzqjC7qiG7qjB3qiF3qjD/qiH/ofGYGRGIXRGIOxGIfxmICJmITJmIKpmIbpmIGZmIXZmIO5mIf5R1ZgJVZhNdZgLdZhPTZgIzZhM7ZgK7ZhO3ZgJ3ZhN/ZgL/Zh/5ETOIlTOI0zOItzOI8LuIhLuIwruIpruI4buIlbuI07uIt7uH/kBV7iFV7jDd7iHd7jAz7iEz7jC77iG77jB37iF37jD/7iH/4fBUGQBEXQBEOwBEfwhECIhETIhEKohEbohEGYhEXYhEO4hEf4R1EQJVERNdEQLdERPTEQIzERM7EQK7ERO3EQJ3ERN/EQL/ER/1ESJElSJE0yJEtyJE8KpEhKpEwqpEpqpE4apElapE06pEt6pH+UBVmSFVmTDdmSHdmTAzmSEzmTC7mSG7mTB3mSF3mTD/mSH/kfFUGRFEXRFEOxFEfxlECJlETJlEKplEbplEGZlEXZlEO5lEf5R1VQJVVRNdVQLdVRPTVQIzVRM7VQK7VRO3VQJ3VRN/VQL/VR/1ETNElTNE0zNEtzNE8LtEhLtEwrtEprtE4btElbtE07tEt7tH/UBV3SFV3TDd3SHd3TAz3SEz3TC73SG73TB33SF33TD/3SH/0fDcGQDMXQDMOwDMfwjMCIjMTIjMKojMbojMGYjMXYjMO4jMf4R1MwJVMxNdMwLdMxPTMwIzMxM7MwK7MxO3MwJ3MxN/MwL/Mx/9ESLMlSLM0yLMtyLM8KrMhKrMwqrMpqrM4arMlarM06rMt6rH+0BVuyFVuzDduyHduzAzuyEzuzC7uyG7uzB3uyF3uzD/uyH/sfHcGRHMXRHMOxHMfxnMCJnMTJnMKpnMbpnMGZnMXZnMO5nMf5R1dwJVdxNddwLddxPTdwIzdxM7dwK7dxO3dwJ3dxN/dwL/dx/9ETPMlTPM0zPMtzPM8LvMhLvMwrvMprvM4bvMlbvM07vMt7vB8/gdf/dWsIbgAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), +); + +export const fixtures: FlowFixture[] = [ + { + label: "JSON POST", + flow: { + ...base, + id: "fl_json", + method: "POST", + url: "https://api.stripe.com/v1/payment_intents?expand=charges&limit=1", + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Authorization", value: "Bearer sk_test_51Hx" }, + { name: "Cookie", value: "session=abc123; theme=dark" }, + { name: "User-Agent", value: "curl/8.7.1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json; charset=utf-8" }, + { name: "Set-Cookie", value: "session=def456; Path=/; HttpOnly; Secure" }, + { name: "Request-Id", value: "req_9Z" }, + ], + requestSize: jsonRequest.length, + responseSize: jsonResponse.length, + }, + requestBody: body(jsonRequest), + responseBody: body(jsonResponse), + }, + { + label: "Form POST, HTML response", + flow: { + ...base, + id: "fl_form", + method: "POST", + url: "https://auth.example.com/oauth/token", + contentType: "text/html", + requestHeaders: [ + { name: "Content-Type", value: "application/x-www-form-urlencoded" }, + { name: "Authorization", value: "Basic eWFhazpwcm94eQ==" }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/html; charset=utf-8" }], + requestSize: formRequest.length, + responseSize: htmlResponse.length, + }, + requestBody: body(formRequest), + responseBody: body(htmlResponse), + }, + { + label: "Image GET", + flow: { + ...base, + id: "fl_image", + url: "https://cdn.example.com/avatars/42.png?size=2", + contentType: "image/png", + requestHeaders: [{ name: "Accept", value: "image/*" }], + responseHeaders: [{ name: "Content-Type", value: "image/png" }], + responseSize: png.length, + }, + requestBody: body(""), + responseBody: body(png), + }, + { + label: "Failed", + flow: { + ...base, + id: "fl_failed", + url: "https://unreachable.example.com/health", + status: null, + elapsedHeadersMs: null, + elapsedMs: null, + error: "dns error: failed to lookup address information: nodename nor servname provided", + requestHeaders: [{ name: "Accept", value: "*/*" }], + }, + requestBody: body(""), + responseBody: body(""), + }, +]; diff --git a/apps/yaak-proxy/harness/main.tsx b/apps/yaak-proxy/harness/main.tsx new file mode 100644 index 000000000..f9e2d5c9c --- /dev/null +++ b/apps/yaak-proxy/harness/main.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; +import { createStore, Provider } from "jotai"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { FlowPanesHarness } from "./FlowPanesHarness"; +import "../main.css"; + +const dark = new URLSearchParams(location.search).get("theme") !== "light"; +applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme); + +createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + , +); diff --git a/apps/yaak-proxy/lib/flowViews.test.ts b/apps/yaak-proxy/lib/flowViews.test.ts new file mode 100644 index 000000000..0902ca2bf --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.test.ts @@ -0,0 +1,222 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, +} from "@yaakapp-internal/ui/httpTypes"; +import { describe, expect, test } from "vite-plus/test"; +import { + authFromHeaders, + type FlowBodyData, + flowBodyFromRpc, + flowRequestToView, + flowResponseToView, + queryToPairs, + utcTimestamp, +} from "./flowViews"; + +const flow: Flow = { + id: "fl_1", + createdAt: "2026-11-04T22:10:00Z", + updatedAt: "2026-11-04T22:10:01Z", + startedAt: "2026-11-04T22:10:00Z", + method: "POST", + url: "https://api.example.com/v1/items?limit=1&q=a+b#top", + clientHttpVersion: "HTTP/1.1", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "1.2.3.4:443", + status: 201, + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Cookie", value: "a=1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Set-Cookie", value: "b=2; Path=/" }, + ], + requestSize: 7, + responseSize: 12, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 40, + elapsedMs: 55, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: null, + clientName: null, + clientBundleId: null, + clientPath: null, +}; + +function bytes(text: string): FlowBodyData { + return { bytes: new TextEncoder().encode(text), truncated: false, storedSize: text.length }; +} + +function withContentType(contentType: string): Flow { + return { ...flow, requestHeaders: [{ name: "Content-Type", value: contentType }] }; +} + +describe("flowRequestToView", () => { + test("maps the request line, headers and query", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.method).toBe("POST"); + expect(view.url).toBe(flow.url); + expect(view.headers.map((h) => h.name)).toEqual(["Content-Type", "Cookie"]); + expect(view.urlParameters.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "limit", value: "1" }, + { name: "q", value: "a b" }, + ]); + expect(view.contentType).toBe("application/json"); + }); + + test("reads a JSON body as JSON, sent as written", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.bodyType).toBe(BODY_TYPE_JSON); + expect(view.body).toEqual({ text: '{"a":1}', sendJsonComments: true }); + }); + + test("reads a url-encoded body as form fields", () => { + const view = flowRequestToView( + withContentType("application/x-www-form-urlencoded; charset=utf-8"), + bytes("a=1&b=two+words&c=%26"), + ); + expect(view.bodyType).toBe(BODY_TYPE_FORM_URLENCODED); + expect(view.body.form?.map(({ name, value }) => [name, value])).toEqual([ + ["a", "1"], + ["b", "two words"], + ["c", "&"], + ]); + }); + + test("leaves multipart and binary bodies to the viewers", () => { + expect( + flowRequestToView(withContentType("multipart/form-data; boundary=x"), bytes("--x--")) + .bodyType, + ).toBe(BODY_TYPE_FORM_MULTIPART); + const binary: FlowBodyData = { + bytes: new Uint8Array([0xff, 0xfe, 0x00]), + truncated: false, + storedSize: 3, + }; + expect(flowRequestToView(withContentType("application/octet-stream"), binary).bodyType).toBe( + BODY_TYPE_BINARY, + ); + }); + + test("reads other text as text, and no body as none", () => { + expect(flowRequestToView(withContentType("text/plain"), bytes("hi")).bodyType).toBe( + BODY_TYPE_OTHER, + ); + expect(flowRequestToView(flow, bytes("")).bodyType).toBeNull(); + expect(flowRequestToView(flow, null).bodyType).toBeNull(); + }); + + test("shows authentication from the Authorization header", () => { + expect(authFromHeaders([{ name: "authorization", value: "Bearer abc" }])).toEqual({ + label: "Bearer", + values: [{ name: "Authorization", value: "Bearer abc" }], + }); + expect(authFromHeaders([{ name: "Authorization", value: "Custom x" }])?.label).toBe("Auth"); + expect(authFromHeaders([])).toBeNull(); + }); +}); + +describe("flowResponseToView", () => { + test("maps status, timing, sizes and cookies", () => { + const view = flowResponseToView(flow); + expect(view).toMatchObject({ + id: "fl_1", + ownerId: "fl_1", + state: "closed", + status: 201, + statusReason: "Created", + contentType: "application/json", + contentLength: 12, + requestContentLength: 7, + startedAt: "2026-11-04T22:10:00Z", + elapsedMs: 55, + elapsedHeadersMs: 40, + version: "HTTP/2", + remoteAddr: "1.2.3.4:443", + cookieHeaders: { sent: ["a=1"], received: ["b=2; Path=/"] }, + }); + }); + + test("shows a gzip response's decoded size, with its wire size as compressed", () => { + const gzipped: Flow = { + ...flow, + responseHeaders: [...flow.responseHeaders, { name: "Content-Encoding", value: "gzip" }], + responseSize: 40, + }; + const decoded: FlowBodyData = { + bytes: new Uint8Array(120), + truncated: false, + storedSize: 40, + }; + expect(flowResponseToView(gzipped, decoded)).toMatchObject({ + contentLength: 120, + contentLengthCompressed: 40, + }); + // Before the body loads, the wire size stands in, still marked compressed + expect(flowResponseToView(gzipped)).toMatchObject({ + contentLength: 40, + contentLengthCompressed: 40, + }); + }); + + test("keeps the wire size for an unencoded body cut at the capture limit", () => { + const cut: FlowBodyData = { bytes: new Uint8Array(5), truncated: true, storedSize: 5 }; + expect(flowResponseToView(flow, cut)).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + expect(flowResponseToView(flow, bytes("hello world!"))).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + }); + + test("reads the engine's zone-less timestamps as UTC", () => { + expect(flowResponseToView({ ...flow, startedAt: "2026-11-04T22:10:00.123" })?.startedAt).toBe( + "2026-11-04T22:10:00.123Z", + ); + expect(utcTimestamp("2026-11-04T22:10:00+02:00")).toBe("2026-11-04T22:10:00+02:00"); + }); + + test("is still connected while the body streams", () => { + expect(flowResponseToView({ ...flow, elapsedMs: null })?.state).toBe("connected"); + }); + + test("has no response while waiting on one", () => { + expect(flowResponseToView({ ...flow, status: null, elapsedMs: null })).toBeNull(); + }); + + test("carries a failure as the response's error", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, error: "refused" }); + expect(view).toMatchObject({ state: "closed", status: 0, error: "refused" }); + }); + + test("says when the client gave up before a response", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, cancelled: true }); + expect(view?.error).toBe("Cancelled by the client"); + }); +}); + +describe("flowBodyFromRpc", () => { + test("decodes the RPC's base64", () => { + const body = flowBodyFromRpc({ data: btoa("hello"), truncated: true, storedSize: 5 }); + expect(new TextDecoder().decode(body.bytes)).toBe("hello"); + expect(body.truncated).toBe(true); + }); +}); + +describe("queryToPairs", () => { + test("ignores the fragment and keeps repeats", () => { + expect(queryToPairs("https://x.test/?a=1&a=2#b=3").map((p) => p.value)).toEqual(["1", "2"]); + expect(queryToPairs("https://x.test/")).toEqual([]); + }); +}); diff --git a/apps/yaak-proxy/lib/flowViews.ts b/apps/yaak-proxy/lib/flowViews.ts new file mode 100644 index 000000000..f4945e04a --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.ts @@ -0,0 +1,235 @@ +import type { Flow, GetFlowBodyResponse, ProxyHeader } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, + type HttpAuthView, + type HttpBodyLoader, + type HttpBodyResult, + type HttpPairView, + type HttpRequestBodyView, + type HttpRequestView, + type HttpResponseView, +} from "@yaakapp-internal/ui/httpTypes"; +import { useEffect, useState } from "react"; +import { base64ToBytes, bytesToText, findHeader, mimeEssence } from "./body"; +import { statusReason } from "./statusReason"; + +/** A captured body as the engine's body RPC returns it, decoded */ +export interface FlowBodyData { + bytes: Uint8Array; + truncated: boolean; + storedSize: number; +} + +export function flowBodyFromRpc(res: GetFlowBodyResponse): FlowBodyData { + return { bytes: base64ToBytes(res.data), truncated: res.truncated, storedSize: res.storedSize }; +} + +/** A captured request as the shared request pane shows it, read-only */ +export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView { + const contentType = findHeader(flow.requestHeaders, "content-type"); + const text = body == null ? null : bytesToText(body.bytes, body.truncated); + const { bodyType, body: bodyView } = requestBodyView(contentType, body, text); + + return { + id: flow.id, + method: flow.method, + url: flow.url, + headers: headersToPairs(flow.requestHeaders), + urlParameters: queryToPairs(flow.url), + bodyType, + body: bodyView, + contentType, + auth: authFromHeaders(flow.requestHeaders), + }; +} + +/** The body type a captured body reads as, in the shape that body type's editor shows */ +function requestBodyView( + contentType: string | null, + body: FlowBodyData | null, + text: string | null, +): { bodyType: string | null; body: HttpRequestBodyView } { + if (body == null || body.bytes.length === 0) return { bodyType: null, body: {} }; + + const mime = mimeEssence(contentType) ?? ""; + if (mime.startsWith("multipart/")) return { bodyType: BODY_TYPE_FORM_MULTIPART, body: {} }; + if (text == null) return { bodyType: BODY_TYPE_BINARY, body: {} }; + if (mime === BODY_TYPE_FORM_URLENCODED) { + return { bodyType: BODY_TYPE_FORM_URLENCODED, body: { form: formToPairs(text) } }; + } + if (mime === "application/json" || mime.endsWith("+json")) { + // Captured as sent, so nothing was cleaned up before it went out + return { bodyType: BODY_TYPE_JSON, body: { text, sendJsonComments: true } }; + } + if (mime === "text/xml" || mime === "application/xml" || mime.endsWith("+xml")) { + return { bodyType: BODY_TYPE_XML, body: { text } }; + } + return { bodyType: BODY_TYPE_OTHER, body: { text } }; +} + +/** + * A captured response as the shared response pane shows it, or null when the request never + * got one and didn't fail either (it's still waiting). `body` is the response body from the + * engine, decoded; its size is the one the pane shows, and the wire size counts as compressed + * when the response had a Content-Encoding. Until it loads, the wire size stands in. + */ +export function flowResponseToView( + flow: Flow, + body: FlowBodyData | null = null, +): HttpResponseView | null { + const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled; + if (flow.status == null && !ended) return null; + + const encoding = findHeader(flow.responseHeaders, "content-encoding")?.trim().toLowerCase(); + const encoded = encoding != null && encoding !== "" && encoding !== "identity"; + + return { + id: flow.id, + ownerId: flow.id, + state: ended ? "closed" : "connected", + status: flow.status ?? 0, + statusReason: flow.status == null ? null : statusReason(flow.status), + url: flow.url, + headers: headersToPairs(flow.responseHeaders), + requestHeaders: headersToPairs(flow.requestHeaders), + contentType: findHeader(flow.responseHeaders, "content-type"), + // A body cut at the capture limit is short; unencoded, the wire size is the exact one + contentLength: + body == null || (body.truncated && !encoded) ? flow.responseSize : body.bytes.length, + contentLengthCompressed: encoded ? flow.responseSize : null, + requestContentLength: flow.requestSize, + startedAt: utcTimestamp(flow.startedAt), + elapsedMs: flow.elapsedMs ?? 0, + elapsedHeadersMs: flow.elapsedHeadersMs ?? 0, + elapsedDnsMs: 0, + error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the client" : null), + remoteAddr: flow.remoteAddr, + version: flow.upstreamHttpVersion ?? flow.clientHttpVersion, + cookieHeaders: { + sent: headerValues(flow.requestHeaders, "cookie"), + received: headerValues(flow.responseHeaders, "set-cookie"), + }, + }; +} + +/** + * Reads a captured body the ways the shared viewers ask for it. `useBody` is the hook that + * loads it, the engine's body RPC in the app or a fixture in tests. + */ +export function flowBodyLoader({ + useBody, + contentType, + filterText, +}: { + useBody: () => HttpBodyResult; + contentType: string | null; + filterText?: (expression: string) => Promise; +}): HttpBodyLoader { + return { + useText: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => bytesToText(b.bytes, b.truncated)) }; + }, + useBytes: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => b.bytes) }; + }, + useUrl: (enabled: boolean) => { + const body = useBody(); + const bytes = enabled ? (body.data?.bytes ?? null) : null; + const url = useObjectUrl(bytes, contentType); + // The URL follows the bytes by a render + const waiting = bytes != null && bytes.length > 0 && url == null; + return { ...body, isLoading: !enabled || body.isLoading || waiting, data: url }; + }, + filterText, + }; +} + +function mapData( + body: HttpBodyResult, + fn: (b: FlowBodyData) => T, +): T | null | undefined { + if (body.data === undefined) return undefined; + if (body.data == null || body.data.bytes.length === 0) return null; + return fn(body.data); +} + +/** A URL for bytes held in memory, revoked when they change */ +function useObjectUrl(bytes: Uint8Array | null, contentType: string | null) { + const [url, setUrl] = useState(null); + // Made and revoked by the same effect, so a remount can't revoke a URL still in use + useEffect(() => { + if (bytes == null || bytes.length === 0) { + setUrl(null); + return; + } + const next = URL.createObjectURL(new Blob([bytes], { type: mimeEssence(contentType) ?? "" })); + setUrl(next); + return () => URL.revokeObjectURL(next); + }, [bytes, contentType]); + return url; +} + +/** The engine stores UTC without a zone; JavaScript would read that as local time */ +export function utcTimestamp(timestamp: string): string { + return /(Z|[+-]\d\d:?\d\d)$/i.test(timestamp) ? timestamp : `${timestamp}Z`; +} + +function headersToPairs(headers: ProxyHeader[]): HttpPairView[] { + return headers.map((h, i) => ({ id: `${i}`, name: h.name, value: h.value, enabled: true })); +} + +function headerValues(headers: ProxyHeader[], name: string): string[] { + return headers.filter((h) => h.name.toLowerCase() === name).map((h) => h.value); +} + +/** A URL's query string as parameters, in order, repeats kept */ +export function queryToPairs(url: string): HttpPairView[] { + const query = url.split("#")[0]?.split("?").slice(1).join("?") ?? ""; + return formToPairs(query); +} + +/** `a=1&b=2` as pairs, decoded the way a form or query string is */ +export function formToPairs(text: string): HttpPairView[] { + if (text === "") return []; + return text.split("&").map((part, i) => { + const [name = "", ...rest] = part.split("="); + return { + id: `${i}`, + name: decodeFormPart(name), + value: decodeFormPart(rest.join("=")), + enabled: true, + }; + }); +} + +function decodeFormPart(s: string): string { + try { + return decodeURIComponent(s.replaceAll("+", " ")); + } catch { + return s; + } +} + +/** What the request authenticated with, named by its scheme, for display */ +export function authFromHeaders(headers: ProxyHeader[]): HttpAuthView | null { + const value = findHeader(headers, "authorization"); + if (value == null) return null; + const scheme = value.split(" ")[0]?.toLowerCase() ?? ""; + return { label: AUTH_SCHEMES[scheme] ?? "Auth", values: [{ name: "Authorization", value }] }; +} + +const AUTH_SCHEMES: Record = { + basic: "Basic", + bearer: "Bearer", + digest: "Digest", + ntlm: "NTLM", + negotiate: "Negotiate", + "aws4-hmac-sha256": "AWS", +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 849778086..92ae0f847 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,18 +3,21 @@ "version": "1.0.0", "private": true, "type": "module", + "sideEffects": [ + "*.css" + ], "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": "./src/index.ts", "./editor": "./src/editor.ts", + "./httpTypes": "./src/components/http/types.ts", + "./mimeType": "./src/lib/mimeType.ts", "./resolvedModelName": "./src/lib/resolvedModelName.ts", "./templateTags": "./src/lib/templateTags.ts", - "./toast": "./src/lib/toast.ts" + "./toast": "./src/lib/toast.ts", + "./useToggle": "./src/hooks/useToggle.ts" }, - "sideEffects": [ - "*.css" - ], "dependencies": { "hexy": "^0.4.0", "jotai-family": "^1.0.1" diff --git a/packages/ui/src/components/EditorHost.tsx b/packages/ui/src/components/EditorHost.tsx new file mode 100644 index 000000000..16ee8f30e --- /dev/null +++ b/packages/ui/src/components/EditorHost.tsx @@ -0,0 +1,194 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { Extension } from "@codemirror/state"; +import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; +import type { ComponentType, ReactNode } from "react"; +import { createContext, useContext, useMemo } from "react"; +import type { SetStoredValue, StoredValue, UseStoredValue } from "../hooks/useStoredValue"; +import { useInMemoryStoredValue } from "../hooks/useStoredValue"; +import type { HttpBodyLoader } from "./http/types"; +import type { MultipartViewerProps } from "./viewers/MultipartViewer"; +import { MultipartViewer } from "./viewers/MultipartViewer"; +import type { TextViewerProps } from "./viewers/TextViewer"; +import { TextViewer } from "./viewers/TextViewer"; +import type { ButtonProps } from "./Button"; +import type { DialogProps } from "./Dialog"; +import type { CodeEditorProps } from "./editor/CodeEditor"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { LazyCodeEditor } from "./editor/LazyCodeEditor"; +import type { InputProps } from "./Input"; +import { Input } from "./Input"; + +export type GenericCompletion = GenericCompletionOption & { + apply?: Completion["apply"]; +}; + +export interface GenericCompletionConfig { + minMatch?: number; + options: GenericCompletion[]; +} + +/** A template variable, as far as shared components need to know one */ +export interface TemplateVariableRef { + variable: { name: string }; +} + +/** + * What a host's editor adds on top of `CodeEditor`, like the client's template tags and + * environment variables. Shared components pass these through; a host without templating + * ignores them. + */ +export interface TemplatingProps { + autocomplete?: GenericCompletionConfig; + autocompleteFunctions?: boolean; + autocompleteVariables?: boolean | ((v: TemplateVariableRef) => boolean); + forcedEnvironmentId?: string; + extraExtensions?: Extension[] | Extension; +} + +/** `CodeEditor`'s languages, plus the host-only ones it may know (the client's URL and pairs) */ +export type HostEditorLanguage = CodeEditorProps["language"] | "pairs" | "url"; + +export type HostEditorProps = Omit & + TemplatingProps & { language?: HostEditorLanguage }; + +export type HostInputProps = Omit< + InputProps, + "language" | "extensions" | "renderEditor" | "renderOptionsPicker" +> & + TemplatingProps & { + language?: HostEditorLanguage; + /** Show a chevron that opens the `autocomplete` options as a menu */ + showOptionsPicker?: boolean; + }; + +export type SelectFileProps = Omit & { + onChange: (value: { filePath: string | null; contentType: string | null }) => void; + filePath: string | null; + nameOverride?: string | null; + inline?: boolean; +}; + +export interface HostPromptArgs { + id: string; + title: ReactNode; + label: string; + description?: ReactNode; + placeholder?: string; + defaultValue?: string; + confirmText?: string; + required?: boolean; +} + +export type HostDialogArgs = { + id: string; + render: ComponentType<{ hide: () => void }>; +} & Omit; + +export type StoredValueNamespace = "global" | "no_sync"; + +/** Like `UseStoredValue`, but told whether the value is a synced preference or local state */ +export type UseHostStoredValue = ( + key: string | string[] | null, + fallback: T, + namespace?: StoredValueNamespace, +) => readonly [T, SetStoredValue]; + +/** + * What shared editing components get from the app they run in. The client provides its + * templated editor, file picker, dialogs and key-value store; anything else (the proxy) gets + * plain `CodeEditor`s, in-memory state, and no dialogs. + */ +export interface EditorHost { + Editor: ComponentType; + Input: ComponentType; + SelectFile: ComponentType | null; + showDialog: ((args: HostDialogArgs) => void) | null; + showPrompt: ((args: HostPromptArgs) => Promise) | null; + basename: (path: string) => Promise; + useStoredValue: UseHostStoredValue; + /** Where a `DetailsBanner` with a `storageKey` keeps whether it's open */ + useDetailsBannerStoredValue: UseStoredValue; + ErrorBoundary: ComponentType<{ name: string; children: ReactNode }>; + /** The text viewer, with whatever the app adds to it (formatting, links) */ + TextViewer: ComponentType; + /** The multipart viewer, with whatever the app adds to it (a PDF viewer) */ + MultipartViewer: ComponentType; + /** Viewers only the app has, like the client's PDF viewer; null to use the shared ones */ + renderBodyViewer?: (args: HostBodyViewerArgs) => ReactNode | null; + /** Open a URL outside the app */ + openUrl: ((url: string) => void) | null; + /** Copy text to the clipboard, quietly, as rows of values do */ + copy: (text: string) => void; +} + +export interface HostBodyViewerArgs { + /** Which body: a response's, or the request body it was sent with */ + source: "response" | "request"; + /** The media type, without parameters */ + mimeType: string | null; + viewMode: "pretty" | "raw"; + body: HttpBodyLoader; + /** Whether the whole body has arrived */ + complete: boolean; +} + +const defaultEditorHost: EditorHost = { + ErrorBoundary, + useDetailsBannerStoredValue: useInMemoryStoredValue, + TextViewer, + MultipartViewer, + openUrl: null, + copy: (text) => { + navigator.clipboard.writeText(text).catch(console.error); + }, + Editor: PlainEditor, + Input: PlainInputEditor, + SelectFile: null, + showDialog: null, + showPrompt: null, + basename: async (path) => path.split(/[\\/]/).pop() ?? null, + useStoredValue: useInMemoryStoredValue, +}; + +const EditorHostContext = createContext(defaultEditorHost); + +export function EditorHostProvider({ + value, + children, +}: { + value: Partial; + children: ReactNode; +}) { + const parent = useContext(EditorHostContext); + const merged = useMemo(() => ({ ...parent, ...value }), [parent, value]); + return {children}; +} + +export function useEditorHost(): EditorHost { + return useContext(EditorHostContext); +} + +/** Drops what only a host's editor understands, leaving what `CodeEditor` does */ +function stripTemplating({ + autocomplete: _autocomplete, + autocompleteFunctions: _autocompleteFunctions, + autocompleteVariables: _autocompleteVariables, + forcedEnvironmentId: _forcedEnvironmentId, + extraExtensions, + language, + ...props +}: T) { + return { + ...props, + extensions: extraExtensions, + language: language === "pairs" || language === "url" ? null : language, + }; +} + +function PlainEditor(props: HostEditorProps) { + return ; +} + +function PlainInputEditor({ showOptionsPicker: _showOptionsPicker, ...props }: HostInputProps) { + return ; +} diff --git a/packages/ui/src/components/EmptyStateText.tsx b/packages/ui/src/components/EmptyStateText.tsx new file mode 100644 index 000000000..30f75bb40 --- /dev/null +++ b/packages/ui/src/components/EmptyStateText.tsx @@ -0,0 +1,24 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; + +interface Props { + children: ReactNode; + className?: string; + wrapperClassName?: string; +} + +export function EmptyStateText({ children, className, wrapperClassName }: Props) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/yaak-client/components/core/HotkeyLabel.tsx b/packages/ui/src/components/HotkeyLabel.tsx similarity index 73% rename from apps/yaak-client/components/core/HotkeyLabel.tsx rename to packages/ui/src/components/HotkeyLabel.tsx index ab2e7360d..5e7902b65 100644 --- a/apps/yaak-client/components/core/HotkeyLabel.tsx +++ b/packages/ui/src/components/HotkeyLabel.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { useHotkeyLabel } from "../../hooks/useHotKey"; +import type { HotkeyAction } from "../hooks/useHotKey"; +import { useHotkeyLabel } from "../hooks/useHotKey"; interface Props { action: HotkeyAction; diff --git a/apps/yaak-client/components/core/HotkeyList.test.tsx b/packages/ui/src/components/HotkeyList.test.tsx similarity index 93% rename from apps/yaak-client/components/core/HotkeyList.test.tsx rename to packages/ui/src/components/HotkeyList.test.tsx index 47d99f4bb..b4018bf41 100644 --- a/apps/yaak-client/components/core/HotkeyList.test.tsx +++ b/packages/ui/src/components/HotkeyList.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test, vi } from "vite-plus/test"; -import type { HotkeyAction } from "../../hooks/useHotKey"; +import type { HotkeyAction } from "../hooks/useHotKey"; import { HotkeyList } from "./HotkeyList"; vi.mock("./Hotkey", () => ({ diff --git a/packages/ui/src/components/HotkeyList.tsx b/packages/ui/src/components/HotkeyList.tsx new file mode 100644 index 000000000..0bcea69f5 --- /dev/null +++ b/packages/ui/src/components/HotkeyList.tsx @@ -0,0 +1,31 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; +import { Fragment } from "react"; +import type { HotkeyAction } from "../hooks/useHotKey"; +import { Hotkey } from "./Hotkey"; +import { HotkeyLabel } from "./HotkeyLabel"; + +interface Props { + hotkeys: HotkeyAction[]; + bottomSlot?: ReactNode; + className?: string; +} + +export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => { + return ( +
+
+ {hotkeys.map((hotkey) => ( + + + {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */} +
+ +
+
+ ))} + {bottomSlot} +
+
+ ); +}; diff --git a/packages/ui/src/components/PillButton.tsx b/packages/ui/src/components/PillButton.tsx new file mode 100644 index 000000000..ba2b64251 --- /dev/null +++ b/packages/ui/src/components/PillButton.tsx @@ -0,0 +1,14 @@ +import classNames from "classnames"; +import type { ButtonProps } from "./Button"; +import { Button } from "./Button"; + +export function PillButton({ className, ...props }: ButtonProps) { + return ( + + {onSave != null && ( + + )} + {isProbablyText && getText != null && renderCopyButton?.(getText)} + + + ); + } + + return <>{children}; +} diff --git a/apps/yaak-client/components/FormMultipartEditor.tsx b/packages/ui/src/components/http/FormMultipartEditor.tsx similarity index 57% rename from apps/yaak-client/components/FormMultipartEditor.tsx rename to packages/ui/src/components/http/FormMultipartEditor.tsx index 4a93f9f25..e69b0e970 100644 --- a/apps/yaak-client/components/FormMultipartEditor.tsx +++ b/packages/ui/src/components/http/FormMultipartEditor.tsx @@ -1,32 +1,42 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; import { useCallback, useMemo } from "react"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairEditor } from "./core/PairEditor"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairEditor } from "../pairs/PairEditor"; +import type { HttpFormFieldView, HttpRequestBodyView } from "./types"; type Props = { forceUpdateKey: string; - request: HttpRequest; - onChange: (body: HttpRequest["body"]) => void; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + form: HttpFormFieldView[] | undefined; + onChange?: (body: HttpRequestBodyView) => void; + /** Show the fields without a way to change them */ + readOnly?: boolean; }; -export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) { +export function FormMultipartEditor({ + requestId, + form, + forceUpdateKey, + onChange, + readOnly, +}: Props) { const pairs = useMemo( () => - (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ + (Array.isArray(form) ? form : []).map((p) => ({ enabled: p.enabled, name: p.name, - value: p.file ?? p.value, + value: (p.file ?? p.value) as string, contentType: p.contentType, filename: p.filename, isFile: !!p.file, id: p.id, })), - [request.body.form], + [form], ); const handleChange = useCallback( (pairs) => - onChange({ + onChange?.({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, @@ -50,8 +60,9 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props allowMultilineValues pairs={pairs} onChange={handleChange} + readOnly={readOnly} forceUpdateKey={forceUpdateKey} - stateKey={`multipart.${request.id}`} + stateKey={`multipart.${requestId}`} /> ); } diff --git a/packages/ui/src/components/http/FormUrlencodedEditor.tsx b/packages/ui/src/components/http/FormUrlencodedEditor.tsx new file mode 100644 index 000000000..6b5aef48b --- /dev/null +++ b/packages/ui/src/components/http/FormUrlencodedEditor.tsx @@ -0,0 +1,59 @@ +import { useCallback, useMemo } from "react"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import type { HttpFormFieldView, HttpRequestBodyView } from "./types"; + +type Props = { + forceUpdateKey: string; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + form: HttpFormFieldView[] | undefined; + onChange?: (body: HttpRequestBodyView) => void; + /** Show the fields without a way to change them */ + readOnly?: boolean; +}; + +export function FormUrlencodedEditor({ + requestId, + form, + forceUpdateKey, + onChange, + readOnly, +}: Props) { + const pairs = useMemo( + () => + (Array.isArray(form) ? form : []).map((p) => ({ + enabled: !!p.enabled, + name: p.name || "", + value: p.value || "", + id: p.id, + })), + [form], + ); + + const handleChange = useCallback( + (pairs) => + onChange?.({ + form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })), + }), + [onChange], + ); + + return ( + + ); +} diff --git a/packages/ui/src/components/http/HeadersEditor.tsx b/packages/ui/src/components/http/HeadersEditor.tsx new file mode 100644 index 000000000..88e7bf1ca --- /dev/null +++ b/packages/ui/src/components/http/HeadersEditor.tsx @@ -0,0 +1,189 @@ +import { acceptLanguages } from "../../lib/data/acceptLanguages"; +import { cacheControlDirectives } from "../../lib/data/cacheControl"; +import { charsets } from "../../lib/data/charsets"; +import { connections } from "../../lib/data/connections"; +import { encodings } from "../../lib/data/encodings"; +import type { HeaderValuePreset } from "../../lib/data/headerValuePresets"; +import { headerNames } from "../../lib/data/headerNames"; +import { mimeTypes } from "../../lib/data/mimetypes"; +import { userAgents } from "../../lib/data/userAgents"; +import { validateHttpHeader } from "../../lib/validateHttpHeader"; +import { CountBadge } from "../CountBadge"; +import { DetailsBanner } from "../DetailsBanner"; +import type { GenericCompletion, GenericCompletionConfig, HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairEditorRow } from "../pairs/PairEditor"; +import { ensurePairId } from "../pairs/PairEditor.util"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import { HStack } from "../Stacks"; +import type { HttpPairView } from "./types"; + +type Props = { + forceUpdateKey: string; + headers: HttpPairView[]; + inheritedHeaders?: HttpPairView[]; + inheritedHeadersLabel?: string; + stateKey: string; + onChange?: PairEditorProps["onChange"]; + /** Show the headers without a way to change them */ + readOnly?: boolean; + label?: string; +}; + +export function HeadersEditor({ + stateKey, + headers, + inheritedHeaders, + inheritedHeadersLabel = "Inherited", + onChange = ignoreChange, + readOnly, + forceUpdateKey, +}: Props) { + const { useDetailsBannerStoredValue } = useEditorHost(); + // Get header names defined at current level (case-insensitive) + const currentHeaderNames = new Set( + headers.filter((h) => h.name).map((h) => h.name.toLowerCase()), + ); + // Filter inherited headers: must be enabled, have content, and not be overridden by current level + const validInheritedHeaders = + inheritedHeaders?.filter( + (pair) => + pair.enabled && + (pair.name || pair.value) && + !currentHeaderNames.has(pair.name.toLowerCase()), + ) ?? []; + const hasInheritedHeaders = validInheritedHeaders.length > 0; + return ( +
+ {hasInheritedHeaders && ( + + {inheritedHeadersLabel} + + } + > +
+ {validInheritedHeaders?.map((pair, i) => ( + + ))} +
+
+ )} + +
+ ); +} + +const MIN_MATCH = 3; + +const headerOptionsMap: Record = { + "content-type": mimeTypes, + accept: ["*/*", ...mimeTypes], + "accept-encoding": encodings, + "content-encoding": encodings, + connection: connections, + "accept-charset": charsets, + "accept-language": acceptLanguages, + "cache-control": cacheControlDirectives, + "user-agent": userAgents, + pragma: ["no-cache"], + te: ["trailers", "compress", "deflate", "gzip"], + dnt: ["1", "0"], + "upgrade-insecure-requests": ["1"], + "x-requested-with": ["XMLHttpRequest"], +}; + +function ignoreChange() {} + +const valueType = (pair: Pair): HostInputProps["type"] => { + const name = pair.name.toLowerCase().trim(); + if ( + name.includes("authorization") || + name.includes("api-key") || + name.includes("access-token") || + name.includes("auth") || + name.includes("secret") || + name.includes("token") || + name === "cookie" || + name === "set-cookie" + ) { + return "password"; + } + return "text"; +}; + +const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { + const name = headerName.toLowerCase().trim(); + const options: GenericCompletion[] = + headerOptionsMap[name]?.map((o) => + typeof o === "string" + ? { + label: o, + type: "constant", + boost: 1, // Put above other completions + } + : { + // Show the short label but insert the full value (e.g. User-Agent) + label: o.label, + apply: o.value, + type: "constant", + boost: 1, + }, + ) ?? []; + return { minMatch: MIN_MATCH, options }; +}; + +const nameAutocomplete: PairEditorProps["nameAutocomplete"] = { + minMatch: MIN_MATCH, + options: headerNames.map((t) => + typeof t === "string" + ? { + label: t, + type: "constant", + boost: 1, // Put above other completions + } + : { + ...t, + boost: 1, // Put above other completions + }, + ), +}; diff --git a/packages/ui/src/components/http/HttpRequestPaneView.tsx b/packages/ui/src/components/http/HttpRequestPaneView.tsx new file mode 100644 index 000000000..3402eea16 --- /dev/null +++ b/packages/ui/src/components/http/HttpRequestPaneView.tsx @@ -0,0 +1,458 @@ +import classNames from "classnames"; +import type { CSSProperties, ReactNode, Ref } from "react"; +import { useMemo } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { CountBadge } from "../CountBadge"; +import { EmptyStateText } from "../EmptyStateText"; +import type { HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { KeyValueRows } from "../KeyValueRow"; +import type { HotkeyAction } from "../../hooks/useHotKey"; +import type { EditablePair, PairEditorHandle, PairEditorProps } from "../pairs/PairEditor"; +import type { TabItem, TabsRef } from "../tabs/Tabs"; +import { TabContent, Tabs } from "../tabs/Tabs"; +import { FormMultipartEditor } from "./FormMultipartEditor"; +import { FormUrlencodedEditor } from "./FormUrlencodedEditor"; +import { HeadersEditor } from "./HeadersEditor"; +import type { ExtraPaneTab } from "./HttpResponsePaneView"; +import { JsonBodyEditor } from "./JsonBodyEditor"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; +import { KeyValueRow } from "./ResponseHeaders"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, + type HttpBodyLoader, + type HttpRequestBodyView, + type HttpRequestView, +} from "./types"; +import { UrlBar } from "./UrlBar"; +import { UrlParametersEditor } from "./UrlParametersEditor"; + +export const REQUEST_TAB_BODY = "body"; +export const REQUEST_TAB_PARAMS = "params"; +export const REQUEST_TAB_HEADERS = "headers"; +export const REQUEST_TAB_AUTH = "auth"; + +export interface HttpRequestPaneViewProps { + request: HttpRequestView; + /** Lets the request be changed and sent; without it the pane only shows it */ + editable?: boolean; + style?: CSSProperties; + className?: string; + fullHeight: boolean; + /** Where the tab order and the active tab are remembered */ + tabsStorageKey?: string; + tabsRef?: Ref; + /** Re-seeds the editors when the request changes from elsewhere */ + forceUpdateKey: string; + + /** The URL bar's own reset key, when the app resets it apart from the rest */ + urlBarKey?: string; + /** Re-seeds the URL bar's text without remounting it */ + urlForceUpdateKey?: string; + urlAutocomplete?: HostInputProps["autocomplete"]; + urlPlaceholder?: string; + onUrlChange?: (url: string) => void; + onUrlPasteOverwrite?: HostInputProps["onPasteOverwrite"]; + onSend?: () => void; + onCancel?: () => void; + isSending?: boolean; + /** The action whose keys send the request */ + sendHotkeyAction?: HotkeyAction; + /** The action whose keys select the URL */ + focusHotkeyAction?: HotkeyAction; + /** Colour the method by what it does */ + methodColored?: boolean; + onMethodChange?: (method: string) => void | Promise; + /** Replaces the method shown at the start of the URL bar */ + methodSlot?: ReactNode; + + /** The body tab, when the app offers a menu of body types; otherwise a label names it */ + bodyTab?: TabItem; + /** Renders the body tab's content, given the shared editor for the body type */ + renderBody?: (sharedEditor: ReactNode) => ReactNode; + /** Reads a body that isn't text, like a captured binary or multipart one */ + bodyLoader?: HttpBodyLoader; + onBodyChange?: (body: HttpRequestBodyView) => void; + /** A text body changed; JSON writes at once, the rest may debounce */ + onBodyTextChange?: (text: string, bodyType: string | null) => void; + onSendJsonCommentsChange?: (sendJsonComments: boolean) => void; + /** Where dismissing the JSON comments notice is remembered */ + jsonNoticeStorageKey?: string; + + /** Parameters to edit, when the app derives more than the request's own (path placeholders) */ + urlParameterPairs?: EditablePair[]; + urlParametersForceUpdateKey?: string; + onUrlParametersChange?: PairEditorProps["onChange"]; + onUrlParametersRef?: (handle: PairEditorHandle) => void; + /** Overrides the parameters editor, like the client's one wired to its focus events */ + renderUrlParameters?: (pairs: EditablePair[]) => ReactNode; + + /** The headers tab, when the app counts inherited headers into it */ + headersTabs?: TabItem[]; + inheritedHeaders?: HeadersEditorInherited; + headersForceUpdateKey?: string; + onHeadersChange?: PairEditorProps["onChange"]; + + /** The auth tab and what it shows, when the app edits authentication itself */ + authTabs?: TabItem[]; + authContent?: ReactNode; + + /** Tabs only the app has, after the shared ones */ + extraTabs?: ExtraPaneTab[]; +} + +type HeadersEditorInherited = { name: string; value: string; enabled?: boolean; id?: string }[]; + +/** Yaak's request pane: the URL bar over Body, Params, Headers and Auth tabs */ +export function HttpRequestPaneView({ + request, + editable = false, + style, + className, + fullHeight, + tabsStorageKey = "http_request_tabs", + tabsRef, + forceUpdateKey, + urlBarKey, + urlForceUpdateKey, + urlAutocomplete, + urlPlaceholder = "https://example.com", + onUrlChange, + onUrlPasteOverwrite, + onSend, + onCancel, + isSending, + sendHotkeyAction, + focusHotkeyAction, + methodColored, + onMethodChange, + methodSlot, + bodyTab, + renderBody, + bodyLoader, + onBodyChange, + onBodyTextChange, + onSendJsonCommentsChange, + jsonNoticeStorageKey, + urlParameterPairs, + urlParametersForceUpdateKey, + onUrlParametersChange, + onUrlParametersRef, + renderUrlParameters, + headersTabs, + inheritedHeaders, + headersForceUpdateKey, + onHeadersChange, + authTabs, + authContent, + extraTabs, +}: HttpRequestPaneViewProps) { + const { ErrorBoundary } = useEditorHost(); + const readOnly = !editable; + const paramPairs = urlParameterPairs ?? request.urlParameters; + + let numParams = 0; + if ( + request.bodyType === BODY_TYPE_FORM_URLENCODED || + request.bodyType === BODY_TYPE_FORM_MULTIPART + ) { + numParams = Array.isArray(request.body?.form) + ? request.body.form.filter((p) => p.name).length + : 0; + } + + const tabs = useMemo( + () => [ + bodyTab ?? { + value: REQUEST_TAB_BODY, + label: bodyTypeLabel(request.bodyType, request.contentType), + rightSlot: numParams > 0 ? : null, + }, + { + value: REQUEST_TAB_PARAMS, + rightSlot: , + label: "Params", + }, + ...(headersTabs ?? [ + { + value: REQUEST_TAB_HEADERS, + label: "Headers", + rightSlot: ( + h.name && h.enabled !== false).length} + /> + ), + }, + ]), + ...(authTabs ?? + (request.auth == null + ? [] + : [ + { + value: REQUEST_TAB_AUTH, + label: request.auth.label, + }, + ])), + ...(extraTabs?.map((t) => t.tab) ?? []), + ], + [ + authTabs, + bodyTab, + extraTabs, + headersTabs, + numParams, + paramPairs.length, + request.auth, + request.bodyType, + request.contentType, + request.headers, + ], + ); + + const sharedBody = ( + + ); + + return ( +
+ + {methodSlot ?? ( + + )} +
+ } + forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey} + isLoading={isSending} + /> + + + {authContent ?? (request.auth != null && )} + + + + + + {renderUrlParameters?.(paramPairs) ?? ( + + )} + + + {renderBody ? renderBody(sharedBody) : sharedBody} + + {extraTabs?.map(({ tab, content }) => ( + + {content} + + ))} + +
+ ); +} + +interface RequestBodyEditorProps { + request: HttpRequestView; + readOnly: boolean; + fullHeight: boolean; + forceUpdateKey: string; + bodyLoader?: HttpBodyLoader; + onBodyChange?: (body: HttpRequestBodyView) => void; + onBodyTextChange?: (text: string, bodyType: string | null) => void; + onSendJsonCommentsChange?: (sendJsonComments: boolean) => void; + jsonNoticeStorageKey: string; +} + +/** The editor for a request body's type: JSON, XML, forms, or any other text */ +export function RequestBodyEditor({ + request, + readOnly, + fullHeight, + forceUpdateKey, + bodyLoader, + onBodyChange, + onBodyTextChange, + onSendJsonCommentsChange, + jsonNoticeStorageKey, +}: RequestBodyEditorProps) { + const { Editor } = useEditorHost(); + const { bodyType } = request; + const handleTextChange = (text: string) => onBodyTextChange?.(text, bodyType); + + // A captured body that isn't text shows in the viewer for its content type + if ( + bodyLoader != null && + (bodyType === BODY_TYPE_BINARY || bodyType === BODY_TYPE_FORM_MULTIPART) && + readOnly + ) { + return ( + + ); + } + + if (bodyType === BODY_TYPE_JSON) { + return ( + + ); + } + if (bodyType === BODY_TYPE_XML) { + return ( + + ); + } + if (bodyType === BODY_TYPE_FORM_URLENCODED) { + return ( + + ); + } + if (bodyType === BODY_TYPE_FORM_MULTIPART) { + return ( + + ); + } + if (typeof bodyType === "string" && bodyType !== BODY_TYPE_BINARY) { + return ( + + ); + } + return No Body; +} + +function AuthDisplay({ auth }: { auth: NonNullable }) { + return ( + + {auth.values.map((v) => ( + + {v.value} + + ))} + + ); +} + +/** What the body tab is called when there's no menu of body types to name it */ +export function bodyTypeLabel(bodyType: string | null, contentType: string | null): string { + switch (bodyType) { + case BODY_TYPE_JSON: + return "JSON"; + case BODY_TYPE_XML: + return "XML"; + case BODY_TYPE_GRAPHQL: + return "GraphQL"; + case BODY_TYPE_FORM_URLENCODED: + return "Url Encoded"; + case BODY_TYPE_FORM_MULTIPART: + return "Multi-Part"; + case BODY_TYPE_BINARY: + return "Binary"; + case BODY_TYPE_OTHER: + return languageFromContentType(contentType) === "markdown" ? "Markdown" : "Other"; + case null: + return "Body"; + default: + return "Body"; + } +} diff --git a/packages/ui/src/components/http/HttpResponsePaneView.tsx b/packages/ui/src/components/http/HttpResponsePaneView.tsx new file mode 100644 index 000000000..2549182df --- /dev/null +++ b/packages/ui/src/components/http/HttpResponsePaneView.tsx @@ -0,0 +1,350 @@ +import classNames from "classnames"; +import type { CSSProperties, ReactElement, ReactNode } from "react"; +import { Suspense, useMemo } from "react"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { Banner } from "../Banner"; +import { CountBadge } from "../CountBadge"; +import type { DropdownItem } from "../Dropdown"; +import { type EditorHost, useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import { LoadingIcon } from "../LoadingIcon"; +import { HStack } from "../Stacks"; +import type { TabItem } from "../tabs/Tabs"; +import { TabContent, Tabs } from "../tabs/Tabs"; +import { HttpResponseDurationTagRaw } from "../tags/HttpResponseDurationTag"; +import { HttpStatusTagRaw } from "../tags/HttpStatusTag"; +import { SizeTag } from "../tags/SizeTag"; +import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import type { ResponseTextViewerExtras, ResponseViewMode } from "./ResponseBodyViewer"; +import { ResponseBodyViewer } from "./ResponseBodyViewer"; +import { parseCookieHeaders, ResponseCookies } from "./ResponseCookies"; +import { ResponseHeaders } from "./ResponseHeaders"; +import type { HttpBodyLoader, HttpResponseView } from "./types"; + +export const RESPONSE_TAB_BODY = "body"; +export const RESPONSE_TAB_REQUEST = "request"; +export const RESPONSE_TAB_HEADERS = "headers"; +export const RESPONSE_TAB_COOKIES = "cookies"; + +/** A tab only one app has, like the client's timeline, with what it shows */ +export interface ExtraPaneTab { + tab: TabItem; + content: ReactNode; +} + +export interface HttpResponsePaneViewProps { + response: HttpResponseView | null; + /** Reads the response body */ + body: HttpBodyLoader | null; + /** Reads the request body the response answered */ + requestBody: HttpBodyLoader | null; + style?: CSSProperties; + className?: string; + /** Shown before there's a response */ + emptyState: ReactNode; + viewMode: ResponseViewMode; + onViewModeChange: (viewMode: ResponseViewMode) => void; + /** Where the tab order and the active tab are remembered */ + tabsStorageKey?: string; + /** Remembers the active tab per request or flow */ + activeTabKey: string; + /** Between the status and the right of the status line, like a warning */ + statusLineCenter?: ReactNode; + /** At the right of the status line, like a menu of earlier responses */ + statusLineRight?: ReactNode; + onSaveBody?: () => void; + onCopyBody?: () => void; + onCopyAsCurl?: () => void; + /** Stop a request that's still being sent */ + onCancel?: () => void; + /** Overrides the cookie counts the tab shows, when the app counts them its own way */ + cookieCounts?: { sent: number; received: number }; + /** Tabs only the app has, after the shared ones */ + extraTabs?: ExtraPaneTab[]; + textViewer?: ResponseTextViewerExtras; + /** Viewers only this app has for a response body, like the client's event stream viewer */ + renderBodyViewer?: EditorHost["renderBodyViewer"]; + /** More rows for the Headers tab's Info section */ + infoRows?: ReactElement[]; + /** The copy button for a body held back for its size */ + renderCopyButton?: (getText: () => Promise) => ReactNode; +} + +/** Yaak's response pane: a status line over Response, Request, Headers and Cookies tabs */ +export function HttpResponsePaneView({ + response, + body, + requestBody, + style, + className, + emptyState, + viewMode, + onViewModeChange, + tabsStorageKey = "http_response_tabs", + activeTabKey, + statusLineCenter, + statusLineRight, + onSaveBody, + onCopyBody, + onCopyAsCurl, + onCancel, + cookieCounts: cookieCountsOverride, + extraTabs, + textViewer, + renderBodyViewer, + infoRows, + renderCopyButton, +}: HttpResponsePaneViewProps) { + const { ErrorBoundary } = useEditorHost(); + const mimeType = + response?.contentType == null ? null : getMimeTypeFromContentType(response.contentType).essence; + + const cookieCounts = useMemo(() => { + if (cookieCountsOverride != null) return cookieCountsOverride; + if (response == null) return { sent: 0, received: 0 }; + const { sentCookies, receivedCookies } = parseCookieHeaders(response.cookieHeaders); + return { sent: sentCookies.length, received: receivedCookies.length }; + }, [cookieCountsOverride, response]); + + const tabs = useMemo(() => { + const bodyActionsDisabled = response?.state !== "closed" && (response?.status ?? 0) >= 100; + const bodyActionsHidden = response == null || !!response.error; + const itemsAfter: DropdownItem[] = [ + ...(onSaveBody != null + ? [ + { + label: "Save to File", + onSelect: onSaveBody, + leftSlot: , + hidden: bodyActionsHidden, + disabled: bodyActionsDisabled, + }, + ] + : []), + ...(onCopyBody != null + ? [ + { + label: "Copy Body", + onSelect: onCopyBody, + leftSlot: , + hidden: bodyActionsHidden, + disabled: bodyActionsDisabled, + }, + ] + : []), + ...(onCopyAsCurl != null + ? [{ label: "Copy as cURL", onSelect: onCopyAsCurl, leftSlot: }] + : []), + ]; + + return [ + { + value: RESPONSE_TAB_BODY, + options: { + value: viewMode, + onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"), + items: [ + { label: "Response", value: "pretty" }, + ...(mimeType?.startsWith("image") + ? [] + : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), + ], + itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined, + }, + }, + { + value: RESPONSE_TAB_REQUEST, + label: "Request", + rightSlot: (response?.requestContentLength ?? 0) > 0 ? : null, + }, + { + value: RESPONSE_TAB_HEADERS, + label: "Headers", + rightSlot: ( + + ), + }, + { + value: RESPONSE_TAB_COOKIES, + label: "Cookies", + rightSlot: + cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( + + ) : null, + }, + ...(extraTabs?.map((t) => t.tab) ?? []), + ]; + }, [ + cookieCounts, + extraTabs, + mimeType, + onCopyAsCurl, + onCopyBody, + onSaveBody, + onViewModeChange, + response, + viewMode, + ]); + + return ( +
+ {response == null ? ( + emptyState + ) : ( +
+ + + + +
+ {response.error && ( + + {response.error} + + )} + {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} + + + + + + {body != null && ( + + )} + + + + + + + {requestBody != null && ( + + )} + + + + + + + + + {extraTabs?.map(({ tab, content }) => ( + + {content} + + ))} + +
+
+ )} +
+ ); +} + +/** The status, time and size of a response, with room for the app's own pieces */ +export function ResponseStatusLine({ + response, + center, + right, +}: { + response: HttpResponseView; + center?: ReactNode; + right?: ReactNode; +}) { + return ( +
+ + {response.state !== "closed" && } + + • + + • + + + {center ?? } +
{right}
+
+ ); +} + +function findHeader(headers: { name: string; value: string }[], name: string): string | null { + return headers.find((h) => h.name.toLowerCase() === name)?.value ?? null; +} diff --git a/packages/ui/src/components/http/JsonBodyEditor.tsx b/packages/ui/src/components/http/JsonBodyEditor.tsx new file mode 100644 index 000000000..c369ae286 --- /dev/null +++ b/packages/ui/src/components/http/JsonBodyEditor.tsx @@ -0,0 +1,141 @@ +import { linter } from "@codemirror/lint"; +import { useCallback, useMemo } from "react"; +import { textLikelyContainsJsonComments } from "../../lib/jsonComments"; +import { replaceTemplateTags } from "../../lib/templateTags"; +import { Banner } from "../Banner"; +import type { DropdownItem } from "../Dropdown"; +import { Dropdown } from "../Dropdown"; +import type { HostEditorProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { jsonParseLinter } from "../editor/json-lint"; +import { Icon } from "../Icon"; +import { IconButton } from "../IconButton"; +import { IconTooltip } from "../IconTooltip"; + +interface Props { + forceUpdateKey: string; + heightMode: HostEditorProps["heightMode"]; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + text: string; + /** JSON is sent as written, comments and all, instead of cleaned up first */ + sendJsonComments: boolean; + onChange?: (text: string) => void; + onChangeSendJsonComments?: (sendJsonComments: boolean) => void; + /** Where dismissing the comments notice is remembered, like the workspace it applies to */ + noticeStorageKey: string; + /** Show the body without a way to change it */ + readOnly?: boolean; +} + +export function JsonBodyEditor({ + forceUpdateKey, + heightMode, + requestId, + text, + sendJsonComments, + onChange, + onChangeSendJsonComments, + noticeStorageKey, + readOnly, +}: Props) { + const { Editor, useStoredValue } = useEditorHost(); + const autoFix = sendJsonComments !== true; + + const lintExtension = useMemo( + () => + linter( + jsonParseLinter( + autoFix || readOnly + ? { allowComments: true, allowTrailingCommas: true, escape: escapeTemplateTags } + : { allowComments: false, allowTrailingCommas: false, escape: escapeTemplateTags }, + ), + ), + [autoFix, readOnly], + ); + + const hasComments = useMemo(() => textLikelyContainsJsonComments(text), [text]); + + const [bannerDismissed, setBannerDismissed] = useStoredValue( + ["json-fix-3", noticeStorageKey], + false, + "no_sync", + ); + + const handleToggleAutoFix = useCallback( + () => onChangeSendJsonComments?.(autoFix), + [autoFix, onChangeSendJsonComments], + ); + + const handleDropdownOpen = useCallback(() => { + if (!bannerDismissed) { + Promise.resolve(setBannerDismissed(true)).catch(console.error); + } + }, [bannerDismissed, setBannerDismissed]); + + const showBanner = hasComments && autoFix && !bannerDismissed; + + const stripMessage = "Automatically strip comments and trailing commas before sending"; + const actions = useMemo( + () => + readOnly + ? undefined + : [ + showBanner && ( + +

+ Auto-fix enabled + +

+
+ ), +
+ , + leftSlot: ( + + ), + }, + ] satisfies DropdownItem[] + } + > + + +
, + ], + [handleDropdownOpen, handleToggleAutoFix, autoFix, readOnly, showBanner], + ); + + return ( + + ); +} + +// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template +// syntax with repeating `1` characters, so it's valid JSON and the position is still correct. +function escapeTemplateTags(doc: string): string { + return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start)); +} diff --git a/packages/ui/src/components/http/RequestBodyViewer.tsx b/packages/ui/src/components/http/RequestBodyViewer.tsx new file mode 100644 index 000000000..b058d5012 --- /dev/null +++ b/packages/ui/src/components/http/RequestBodyViewer.tsx @@ -0,0 +1,102 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import { languageFromContentType } from "../../lib/contentType"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { EmptyStateText } from "../EmptyStateText"; +import { useEditorHost } from "../EditorHost"; +import { LoadingIcon } from "../LoadingIcon"; +import { AudioViewer } from "../viewers/AudioViewer"; +import { CsvViewer } from "../viewers/CsvViewer"; +import { ImageViewer } from "../viewers/ImageViewer"; +import { SvgViewer } from "../viewers/SvgViewer"; +import { VideoViewer } from "../viewers/VideoViewer"; +import { WebPageViewer } from "../viewers/WebPageViewer"; +import type { HttpBodyLoader } from "./types"; + +interface Props { + /** Identifies the body, for viewer state */ + id: string; + /** The Content-Type the request was sent with */ + contentType: string | null; + body: HttpBodyLoader; +} + +/** The body a request was sent with, in the viewer its content type calls for */ +export function RequestBodyViewer({ id, contentType, body }: Props) { + return ; +} + +function RequestBodyViewerInner({ id, contentType, body }: Props) { + const { MultipartViewer, TextViewer, renderBodyViewer } = useEditorHost(); + const text = body.useText(); + const bytes = body.useBytes(); + + if (text.isLoading || bytes.isLoading) { + return ( + + + + ); + } + + const error = text.error ?? bytes.error; + if (error) { + return Error loading request body: {error.message}; + } + + // Whether there's a body is the bytes' to say: a binary body has no text + const bodyBytes = bytes.data; + if (bodyBytes == null || bodyBytes.length === 0) { + return No request body; + } + + const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null; + + // Route to appropriate viewer based on content type + if (mimeType?.match(/^multipart/i)) { + const boundary = contentType?.split("boundary=")[1] ?? "unknown"; + // Create a copy because parseMultipart may detach the buffer + const bodyCopy = new Uint8Array(bodyBytes); + return ; + } + + if (mimeType?.match(/^image/i) && !mimeType.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/^audio/i)) { + return ; + } + + if (mimeType?.match(/^video/i)) { + return ; + } + + const appViewer = mimeType?.match(/pdf/i) + ? renderBodyViewer?.({ source: "request", mimeType, viewMode: "pretty", body, complete: true }) + : null; + + // The rest show text, which a body that isn't valid UTF-8 doesn't have + const bodyText = text.data; + if (bodyText == null || bodyText.length === 0) { + return ( + appViewer ?? Binary body, {formatSize(bodyBytes.length)} + ); + } + + if (mimeType?.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + return ; + } + + if (mimeType?.match(/^text\/html/i)) { + return ; + } + + if (appViewer != null) return appViewer; + + const language = languageFromContentType(contentType, bodyText); + return ; +} diff --git a/packages/ui/src/components/http/RequestMethodDropdown.tsx b/packages/ui/src/components/http/RequestMethodDropdown.tsx new file mode 100644 index 000000000..cd0b4e89f --- /dev/null +++ b/packages/ui/src/components/http/RequestMethodDropdown.tsx @@ -0,0 +1,87 @@ +import classNames from "classnames"; +import { memo, useCallback, useMemo } from "react"; +import { Button } from "../Button"; +import type { DropdownItem } from "../Dropdown"; +import { useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import type { RadioDropdownItem } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; +import { HttpMethodTagRaw } from "../tags/HttpMethodTag"; + +type Props = { + method: string; + /** Colour the method by what it does, as the client's setting chooses */ + colored?: boolean; + onChange?: (method: string) => void | Promise; + /** Show the method without a menu to change it */ + readOnly?: boolean; + className?: string; +}; + +const METHODS = ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTIONS", "QUERY", "HEAD"]; + +export const RequestMethodDropdown = memo(function RequestMethodDropdown({ + method, + colored, + onChange, + readOnly, + className, +}: Props) { + const { showPrompt } = useEditorHost(); + + const radioItems = useMemo[]>( + () => + METHODS.map((m) => ({ value: m, label: })), + [colored], + ); + + const handleChange = useCallback( + async (method: string) => { + await onChange?.(method); + }, + [onChange], + ); + + const itemsAfter = useMemo( + () => [ + { + key: "custom", + label: "CUSTOM", + leftSlot: , + hidden: showPrompt == null, + onSelect: async () => { + const newMethod = await showPrompt?.({ + id: "custom-method", + label: "Http Method", + title: "Custom Method", + confirmText: "Save", + description: "Enter a custom method name", + placeholder: "CUSTOM", + }); + if (newMethod == null) return; + await handleChange(newMethod); + }, + }, + ], + [handleChange, showPrompt], + ); + + const button = ( + + ); + + if (readOnly) return button; + + return ( + + {button} + + ); +}); diff --git a/packages/ui/src/components/http/ResponseBodyViewer.tsx b/packages/ui/src/components/http/ResponseBodyViewer.tsx new file mode 100644 index 000000000..a81f658fc --- /dev/null +++ b/packages/ui/src/components/http/ResponseBodyViewer.tsx @@ -0,0 +1,224 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { ComponentType } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { Banner } from "../Banner"; +import { Button } from "../Button"; +import { EmptyStateText } from "../EmptyStateText"; +import { type EditorHost, useEditorHost } from "../EditorHost"; +import { LoadingIcon } from "../LoadingIcon"; +import { HStack, VStack } from "../Stacks"; +import { AudioViewer } from "../viewers/AudioViewer"; +import { CsvViewer } from "../viewers/CsvViewer"; +import { ImageViewer } from "../viewers/ImageViewer"; +import { SvgViewer } from "../viewers/SvgViewer"; +import type { TextViewerProps } from "../viewers/TextViewer"; +import { VideoViewer } from "../viewers/VideoViewer"; +import { WebPageViewer } from "../viewers/WebPageViewer"; +import type { HttpBodyLoader, HttpResponseView } from "./types"; + +export type ResponseViewMode = "pretty" | "raw"; + +/** What the text viewer adds for the app, like its filter history and save and copy buttons */ +export type ResponseTextViewerExtras = Pick< + TextViewerProps, + "footerActions" | "filterStateKey" | "onFilterApplied" | "filterLeftSlot" +>; + +interface Props { + response: HttpResponseView; + body: HttpBodyLoader; + viewMode: ResponseViewMode; + textViewer?: ResponseTextViewerExtras; + /** Stop a request that's still being sent */ + onCancel?: () => void; + /** Viewers only this pane's app has, tried before the host's and the shared ones */ + renderBodyViewer?: EditorHost["renderBodyViewer"]; +} + +/** Picks the viewer for a response body from its content type, as the client's response pane does */ +export function ResponseBodyViewer({ + response, + body, + viewMode, + textViewer, + onCancel, + renderBodyViewer: renderPaneBodyViewer, +}: Props) { + const { renderBodyViewer: renderHostBodyViewer } = useEditorHost(); + const mimeType = + response.contentType == null ? null : getMimeTypeFromContentType(response.contentType).essence; + + if (response.state === "initialized") { + return ( + + + + + Sending Request + + {onCancel != null && ( + + )} + + + ); + } + + if (response.state === "closed" && (response.contentLength ?? 0) === 0) { + return Empty; + } + + const viewerArgs = { + source: "response" as const, + mimeType, + viewMode, + body, + complete: response.state === "closed", + }; + const appViewer = renderPaneBodyViewer?.(viewerArgs) ?? renderHostBodyViewer?.(viewerArgs); + if (appViewer != null) return appViewer; + + if (mimeType?.match(/^image\/svg/)) { + return ; + } + if (mimeType?.match(/^image/i)) { + return ; + } + if (mimeType?.match(/^audio/i)) { + return ; + } + if (mimeType?.match(/^video/i)) { + return ; + } + if (mimeType?.match(/^multipart/i) && viewMode === "pretty") { + return ; + } + if (mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty") { + return ; + } + return ( + + ); +} + +/** A body with no text is either empty or not valid UTF-8, which only its bytes can tell apart */ +function BinaryOrEmptyBody({ body }: { body: HttpBodyLoader }) { + const bytes = body.useBytes(); + if (bytes.isLoading) return null; + const length = bytes.data?.length ?? 0; + return ( + + {length > 0 ? `Binary body, ${formatSize(length)}` : "Empty response"} + + ); +} + +function EnsureCompleteResponse({ + response, + body, + Component, +}: { + response: HttpResponseView; + body: HttpBodyLoader; + Component: ComponentType<{ bodyUrl: string }>; +}) { + // Wait until the response has been fully-downloaded before asking for it + const complete = response.state === "closed"; + const bodyUrl = body.useUrl(complete); + + if (!complete || bodyUrl.isLoading) { + return ( + + + + ); + } + + if (bodyUrl.error) { + return {String(bodyUrl.error)}; + } + + if (bodyUrl.data == null) { + return
Empty response body
; + } + + return ; +} + +function BodySvgViewer({ body }: { body: HttpBodyLoader }) { + const text = body.useText(); + if (!text.data) return null; + return ; +} + +function BodyCsvViewer({ body, className }: { body: HttpBodyLoader; className?: string }) { + const text = body.useText(); + return ; +} + +function BodyMultipartViewer({ + response, + body, +}: { + response: HttpResponseView; + body: HttpBodyLoader; +}) { + const { MultipartViewer } = useEditorHost(); + const bytes = body.useBytes(); + if (bytes.data == null) return null; + + const boundary = response.contentType?.split("boundary=")[1] ?? "unknown"; + return ; +} + +interface HTMLOrTextViewerProps { + response: HttpResponseView; + body: HttpBodyLoader; + pretty: boolean; + textViewerClassName?: string; + textViewer?: ResponseTextViewerExtras; +} + +export function HTMLOrTextViewer({ + response, + body, + pretty, + textViewerClassName, + textViewer, +}: HTMLOrTextViewerProps) { + const { TextViewer } = useEditorHost(); + const rawTextBody = body.useText(); + const language = languageFromContentType(response.contentType, rawTextBody.data ?? ""); + + if (rawTextBody.isLoading || response.state === "initialized") { + return null; + } + + if (language === "html" && pretty) { + return ; + } + if (rawTextBody.data == null) { + return ; + } + return ( + + ); +} diff --git a/apps/yaak-client/components/ResponseCookies.tsx b/packages/ui/src/components/http/ResponseCookies.tsx similarity index 79% rename from apps/yaak-client/components/ResponseCookies.tsx rename to packages/ui/src/components/http/ResponseCookies.tsx index 4a9eb29ad..61649b941 100644 --- a/apps/yaak-client/components/ResponseCookies.tsx +++ b/packages/ui/src/components/http/ResponseCookies.tsx @@ -1,14 +1,15 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; import classNames from "classnames"; import { useMemo } from "react"; import type { JSX } from "react/jsx-runtime"; -import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; -import { DetailsBanner } from "./core/DetailsBanner"; -import { KeyValueRow } from "./core/KeyValueRow"; -import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui"; +import { CountBadge } from "../CountBadge"; +import { DetailsBanner } from "../DetailsBanner"; +import { useEditorHost } from "../EditorHost"; +import { KeyValueRows } from "../KeyValueRow"; +import { KeyValueRow } from "./ResponseHeaders"; +import type { HttpResponseView } from "./types"; interface Props { - response: HttpResponse; + response: HttpResponseView; } interface ParsedCookie { @@ -80,45 +81,40 @@ function parseSetCookieHeader(setCookieHeader: string): ParsedCookie { return cookie; } -export function ResponseCookies({ response }: Props) { - const { data: events } = useHttpResponseEvents(response); +/** The cookies a response's headers sent and received, the latest value of each name winning */ +export function parseCookieHeaders({ sent, received }: HttpResponseView["cookieHeaders"]) { + const sentMap = new Map(); + const receivedMap = new Map(); - const { sentCookies, receivedCookies } = useMemo(() => { - if (!events) return { sentCookies: [], receivedCookies: [] }; - - // Use Maps to deduplicate by cookie name (latest value wins) - const sentMap = new Map(); - const receivedMap = new Map(); - - for (const event of events) { - const e = event.event; - - // Cookie headers sent (header_up with name=cookie) - if (e.type === "header_up" && e.name.toLowerCase() === "cookie") { - const cookies = parseCookieHeader(e.value); - for (const cookie of cookies) { - sentMap.set(cookie.name, cookie); - } - } - - // Set-Cookie headers received (header_down with name=set-cookie) - if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") { - const cookie = parseSetCookieHeader(e.value); - receivedMap.set(cookie.name, cookie); - } + for (const header of sent) { + for (const cookie of parseCookieHeader(header)) { + sentMap.set(cookie.name, cookie); } + } + for (const header of received) { + const cookie = parseSetCookieHeader(header); + receivedMap.set(cookie.name, cookie); + } - return { - sentCookies: Array.from(sentMap.values()), - receivedCookies: Array.from(receivedMap.values()), - }; - }, [events]); + return { + sentCookies: Array.from(sentMap.values()), + receivedCookies: Array.from(receivedMap.values()), + }; +} + +export function ResponseCookies({ response }: Props) { + const { useDetailsBannerStoredValue } = useEditorHost(); + const { sentCookies, receivedCookies } = useMemo( + () => parseCookieHeaders(response.cookieHeaders), + [response.cookieHeaders], + ); return (
Sent Cookies @@ -141,7 +137,8 @@ export function ResponseCookies({ response }: Props) { Received Cookies diff --git a/packages/ui/src/components/http/ResponseHeaders.tsx b/packages/ui/src/components/http/ResponseHeaders.tsx new file mode 100644 index 000000000..3206152de --- /dev/null +++ b/packages/ui/src/components/http/ResponseHeaders.tsx @@ -0,0 +1,83 @@ +import { format, formatDistanceToNowStrict } from "date-fns"; +import type { ReactElement } from "react"; +import { DetailsBanner } from "../DetailsBanner"; +import { useEditorHost } from "../EditorHost"; +import { HeadersTable } from "../HeadersTable"; +import { IconButton } from "../IconButton"; +import type { KeyValueRowProps } from "../KeyValueRow"; +import { KeyValueRow as BaseKeyValueRow, KeyValueRows } from "../KeyValueRow"; +import type { HttpResponseView } from "./types"; + +interface Props { + response: HttpResponseView; + /** More rows for the Info section, after the standard ones, each keyed */ + infoRows?: ReactElement[]; +} + +export function ResponseHeaders({ response, infoRows }: Props) { + const { openUrl, useDetailsBannerStoredValue } = useEditorHost(); + return ( +
+ Info} + useStoredValue={useDetailsBannerStoredValue} + > + + {[ + + + , + +
+ {response.url} + {openUrl != null && ( + openUrl(response.url)} + title="Open in browser" + /> + )} +
+
, + + {response.remoteAddr ?? --} + , + + {response.version ?? --} + , + ...(infoRows ?? []), + ]} +
+
+ + +
+ ); +} + +/** A row that copies its value with the host's clipboard */ +export function KeyValueRow(props: Omit) { + const { copy } = useEditorHost(); + return ; +} diff --git a/packages/ui/src/components/http/UrlBar.tsx b/packages/ui/src/components/http/UrlBar.tsx new file mode 100644 index 000000000..41c16e9a7 --- /dev/null +++ b/packages/ui/src/components/http/UrlBar.tsx @@ -0,0 +1,146 @@ +import classNames from "classnames"; +import type { FormEvent, ReactNode } from "react"; +import { memo, useCallback, useRef, useState } from "react"; +import type { HotkeyAction } from "../../hooks/useHotKey"; +import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey"; +import type { HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import type { IconProps } from "../Icon"; +import { IconButton } from "../IconButton"; +import type { InputHandle } from "../Input"; +import { HStack } from "../Stacks"; + +export type UrlBarProps = { + url: string; + className?: string; + placeholder: string; + onSend?: () => void; + onUrlChange?: (url: string) => void; + onPaste?: (v: string) => void; + onPasteOverwrite?: HostInputProps["onPasteOverwrite"]; + onCancel?: () => void; + submitIcon?: IconProps["icon"] | null; + isLoading?: boolean; + forceUpdateKey: string; + rightSlot?: ReactNode; + leftSlot?: ReactNode; + autocomplete?: HostInputProps["autocomplete"]; + stateKey: HostInputProps["stateKey"]; + /** Show the URL without a way to change or send it */ + readOnly?: boolean; + /** The action whose keys send, shown on the send button */ + sendHotkeyAction?: HotkeyAction; + /** The action whose keys select the whole URL */ + focusHotkeyAction?: HotkeyAction; +}; + +export const UrlBar = memo(function UrlBar({ + forceUpdateKey, + onUrlChange, + url, + placeholder, + className, + onSend, + onCancel, + onPaste, + onPasteOverwrite, + submitIcon = "send_horizontal", + autocomplete, + leftSlot, + rightSlot, + isLoading = false, + stateKey, + readOnly, + sendHotkeyAction, + focusHotkeyAction, +}: UrlBarProps) { + const { Input } = useEditorHost(); + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const handleInitInputRef = useCallback((h: InputHandle | null) => { + inputRef.current = h; + }, []); + + useHotKey(focusHotkeyAction ?? null, () => { + inputRef.current?.selectAll(); + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (readOnly) return; + if (isLoading) onCancel?.(); + else onSend?.(); + }; + + return ( +
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onPaste={onPaste} + onPasteOverwrite={onPasteOverwrite} + onChange={onUrlChange} + readOnly={readOnly} + defaultValue={url} + placeholder={placeholder} + leftSlot={leftSlot} + rightSlot={ + + {rightSlot &&
{rightSlot}
} + {submitIcon !== null && !readOnly && ( +
+ +
+ )} +
+ } + /> +
+ ); +}); + +function SendButton({ + icon, + hotkeyAction, +}: { + icon: IconProps["icon"]; + hotkeyAction: HotkeyAction | null; +}) { + const buttonRef = useRef(null); + const hotkeyTrigger = useFormattedHotkey(hotkeyAction)?.join(""); + useHotKey(hotkeyAction, () => buttonRef.current?.click()); + + return ( + { + // Prevent the button from taking focus + e.preventDefault(); + }} + /> + ); +} diff --git a/packages/ui/src/components/http/UrlParametersEditor.tsx b/packages/ui/src/components/http/UrlParametersEditor.tsx new file mode 100644 index 000000000..1093aa773 --- /dev/null +++ b/packages/ui/src/components/http/UrlParametersEditor.tsx @@ -0,0 +1,45 @@ +import type { EditablePair, PairEditorHandle, PairEditorProps } from "../pairs/PairEditor"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import { VStack } from "../Stacks"; + +type Props = { + forceUpdateKey: string; + pairs: EditablePair[]; + stateKey: PairEditorProps["stateKey"]; + onChange?: PairEditorProps["onChange"]; + /** Show the parameters without a way to change them */ + readOnly?: boolean; + setRef?: (handle: PairEditorHandle) => void; +}; + +export function UrlParametersEditor({ + pairs, + forceUpdateKey, + onChange = ignoreChange, + readOnly, + setRef, + stateKey, +}: Props) { + return ( + + + + ); +} + +function ignoreChange() {} diff --git a/packages/ui/src/components/http/readOnly.test.tsx b/packages/ui/src/components/http/readOnly.test.tsx new file mode 100644 index 000000000..22f188a2b --- /dev/null +++ b/packages/ui/src/components/http/readOnly.test.tsx @@ -0,0 +1,247 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test } from "vite-plus/test"; +import type { HostEditorProps, HostInputProps, SelectFileProps } from "../EditorHost"; +import { EditorHostProvider } from "../EditorHost"; +import { PairEditorRow } from "../pairs/PairEditor"; +import { bodyTypeLabel } from "./HttpRequestPaneView"; +import { ResponseStatusLine } from "./HttpResponsePaneView"; +import { JsonBodyEditor } from "./JsonBodyEditor"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import { HTMLOrTextViewer } from "./ResponseBodyViewer"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; +import { parseCookieHeaders } from "./ResponseCookies"; +import { + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + type HttpBodyLoader, + type HttpResponseView, +} from "./types"; +import { UrlBar } from "./UrlBar"; + +// Stand-ins for the host's editors that show what they were given +function StubInput({ name, defaultValue, readOnly, rightSlot, leftSlot }: HostInputProps) { + return ( +
+ {leftSlot} + {defaultValue} + {rightSlot} +
+ ); +} + +function StubEditor({ defaultValue, readOnly, actions }: HostEditorProps) { + return ( +
+ {defaultValue} + {actions} +
+ ); +} + +function StubSelectFile({ filePath }: SelectFileProps) { + return ( + + ); +} + +function render(node: ReactNode) { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("read-only modes", () => { + test("a pair row shows its fields read-only, with no drag handle or row menu", () => { + const markup = render( + , + ); + expect(markup).toContain('data-input="name[0]" data-readonly="true"'); + expect(markup).toContain('data-input="value[0]" data-readonly="true"'); + expect(markup).not.toContain("More actions"); + expect(markup).toMatch(/]*disabled/); + }); + + test("a read-only file value shows as text, not a file picker", () => { + const pair = { + id: "p1", + enabled: true, + name: "avatar", + value: "/Users/me/avatar.png", + isFile: true, + }; + const host = { Input: StubInput, Editor: StubEditor, SelectFile: StubSelectFile }; + const readOnly = renderToStaticMarkup( + + + , + ); + expect(readOnly).not.toContain("data-picker"); + expect(readOnly).toContain("data-file-value"); + expect(readOnly).toContain("/Users/me/avatar.png"); + + const editable = renderToStaticMarkup( + + + , + ); + expect(editable).toContain("data-picker"); + }); + + test("an editable pair row keeps its row menu", () => { + const markup = render( + , + ); + expect(markup).toContain('data-input="name[0]" data-readonly="false"'); + expect(markup).toContain("More actions"); + }); + + test("the URL bar has no send button when read-only", () => { + const readOnly = render( + , + ); + expect(readOnly).toContain('data-input="url" data-readonly="true"'); + expect(readOnly).not.toContain("Send Request"); + + const editable = render( + , + ); + expect(editable).toContain("Send Request"); + }); + + test("the method shows without a menu when read-only", () => { + const readOnly = render(); + const editable = render( {}} />); + expect(readOnly).toContain("POST"); + expect(readOnly).not.toEqual(editable); + }); + + test("a read-only JSON body has no settings menu", () => { + const props = { + forceUpdateKey: "", + heightMode: "auto" as const, + requestId: "r1", + text: '{"a":1}', + sendJsonComments: false, + noticeStorageKey: "w1", + }; + const readOnly = render(); + expect(readOnly).toContain('data-readonly="true"'); + expect(readOnly).not.toContain("JSON Settings"); + + const editable = render( {}} />); + expect(editable).toContain("JSON Settings"); + }); +}); + +describe("RequestBodyViewer", () => { + // A captured body that isn't valid UTF-8 has bytes but no text + const binary: HttpBodyLoader = { + useText: () => ({ data: null, isLoading: false, error: null }), + useBytes: () => ({ data: new Uint8Array([0x89, 0x50, 0xff]), isLoading: false, error: null }), + useUrl: () => ({ data: null, isLoading: false, error: null }), + }; + + test("shows a binary body instead of calling it empty", () => { + const markup = render( + , + ); + expect(markup).not.toContain("No request body"); + expect(markup).toContain("Binary body, 3"); + }); + + test("hands a binary image to the image viewer", () => { + const markup = render(); + expect(markup).not.toContain("No request body"); + expect(markup).not.toContain("Binary body"); + }); +}); + +describe("HTMLOrTextViewer", () => { + const response = { + id: "rs_1", + ownerId: "rq_1", + state: "closed", + status: 200, + statusReason: "OK", + url: "https://yaak.app", + headers: [], + requestHeaders: [], + contentType: "application/octet-stream", + contentLength: 3, + contentLengthCompressed: null, + requestContentLength: 0, + startedAt: "2026-11-04T22:10:00Z", + elapsedMs: 1, + elapsedHeadersMs: 1, + elapsedDnsMs: 0, + error: null, + remoteAddr: null, + version: null, + cookieHeaders: { sent: [], received: [] }, + } satisfies HttpResponseView; + + function loader(bytes: Uint8Array): HttpBodyLoader { + return { + useText: () => ({ data: null, isLoading: false, error: null }), + useBytes: () => ({ data: bytes, isLoading: false, error: null }), + useUrl: () => ({ data: null, isLoading: false, error: null }), + }; + } + + test("tells a binary response from an empty one", () => { + const binary = render( + , + ); + expect(binary).toContain("Binary body, 3"); + + const empty = render( + , + ); + expect(empty).toContain("Empty response"); + }); + + test("the status line keeps its height without the client's history button", () => { + // The client's button sets the line's height; without it, the line would hug the card's top + const markup = render(); + expect(markup).toContain("min-h-[calc(var(--height-sm)+0.25rem)]"); + }); +}); + +describe("bodyTypeLabel", () => { + test("names a body by its type", () => { + expect(bodyTypeLabel(BODY_TYPE_JSON, "application/json")).toBe("JSON"); + expect(bodyTypeLabel(BODY_TYPE_FORM_URLENCODED, null)).toBe("Url Encoded"); + expect(bodyTypeLabel(BODY_TYPE_OTHER, "text/markdown")).toBe("Markdown"); + expect(bodyTypeLabel(null, null)).toBe("Body"); + }); +}); + +describe("parseCookieHeaders", () => { + test("splits sent cookies and parses received ones, the latest of a name winning", () => { + const { sentCookies, receivedCookies } = parseCookieHeaders({ + sent: ["a=1; b=2", "a=3"], + received: ["s=x; Path=/; HttpOnly", "gone=; Max-Age=0"], + }); + expect(sentCookies).toEqual([ + { name: "a", value: "3" }, + { name: "b", value: "2" }, + ]); + expect(receivedCookies[0]).toMatchObject({ name: "s", value: "x", path: "/", httpOnly: true }); + expect(receivedCookies[1]).toMatchObject({ name: "gone", isDeleted: true }); + }); +}); diff --git a/packages/ui/src/components/http/types.ts b/packages/ui/src/components/http/types.ts new file mode 100644 index 000000000..7b484f4e3 --- /dev/null +++ b/packages/ui/src/components/http/types.ts @@ -0,0 +1,127 @@ +/** + * What the shared request and response panes render. Each app maps its own models onto these + * (the client its `HttpRequest` and `HttpResponse`, the proxy a captured `Flow`), so the panes + * never import either app's model types. + */ + +export const BODY_TYPE_NONE = null; +export const BODY_TYPE_GRAPHQL = "graphql"; +export const BODY_TYPE_JSON = "application/json"; +export const BODY_TYPE_BINARY = "binary"; +export const BODY_TYPE_OTHER = "other"; +export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"; +export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data"; +export const BODY_TYPE_XML = "text/xml"; + +/** A header, URL parameter or form field */ +export interface HttpPairView { + id?: string; + name: string; + value: string; + enabled?: boolean; +} + +/** A form field. A multipart file field has the file's path in `file` instead of a `value` */ +export interface HttpFormFieldView { + id?: string; + name: string; + value?: string; + enabled?: boolean; + file?: string; + contentType?: string; + filename?: string; +} + +/** A request body, in the shape its body type edits it */ +export interface HttpRequestBodyView { + /** Text bodies: JSON, XML, GraphQL's query, anything else typed */ + text?: string; + /** Form bodies, url-encoded or multipart */ + form?: HttpFormFieldView[]; + /** A binary body read from a file when sent */ + filePath?: string; + /** JSON sent as written, comments and all, instead of cleaned up first */ + sendJsonComments?: boolean; +} + +/** Authentication, for display only; editing it stays with the app that knows its plugins */ +export interface HttpAuthView { + /** The scheme's name, like `Bearer` or `Basic` */ + label: string; + /** What it sends, like an `Authorization` header */ + values: HttpPairView[]; +} + +export interface HttpRequestView { + /** Identifies the request across renders, for remembered tabs and editor state */ + id: string; + method: string; + url: string; + headers: HttpPairView[]; + urlParameters: HttpPairView[]; + /** One of the `BODY_TYPE_*` values, or a content type the body was typed as */ + bodyType: string | null; + body: HttpRequestBodyView; + /** The request's Content-Type header */ + contentType: string | null; + auth: HttpAuthView | null; +} + +export type HttpResponseViewState = "initialized" | "connected" | "closed"; + +export interface HttpResponseView { + /** Identifies this response, for viewer state */ + id: string; + /** Identifies what it answers (the request or flow), for state kept across responses */ + ownerId: string; + state: HttpResponseViewState; + /** 0 before a status arrives or when it never did */ + status: number; + statusReason: string | null; + url: string; + headers: HttpPairView[]; + requestHeaders: HttpPairView[]; + /** The response's Content-Type header */ + contentType: string | null; + /** Decoded body bytes */ + contentLength: number | null; + /** Body bytes on the wire, when it was compressed */ + contentLengthCompressed: number | null; + /** Request body bytes sent */ + requestContentLength: number | null; + /** When the request started, as an ISO 8601 string with its zone */ + startedAt: string; + elapsedMs: number; + elapsedHeadersMs: number; + elapsedDnsMs: number; + error: string | null; + remoteAddr: string | null; + version: string | null; + /** Raw `Cookie` header values sent and `Set-Cookie` values received */ + cookieHeaders: { sent: string[]; received: string[] }; +} + +/** What a viewer gets back from loading part of a body */ +export interface HttpBodyResult { + data: T | undefined; + isLoading: boolean; + error: Error | null; +} + +/** + * How a pane reads a body, which each app stores its own way: the client in files read + * through its host, the proxy through the engine's body RPC. Each `use*` is a hook, called + * unconditionally by the viewer that needs that form of the body. + */ +export interface HttpBodyLoader { + /** The body as text, null when there's none */ + useText: () => HttpBodyResult; + /** The body's bytes, null when there's none */ + useBytes: () => HttpBodyResult; + /** A URL a media element can load the body from, null when there's none or not `enabled` */ + useUrl: (enabled: boolean) => HttpBodyResult; + /** Run a JSONPath or XPath filter over the body */ + filterText?: (expression: string) => Promise; + /** The body's text, for copying, without rendering it */ + getText?: () => Promise; +} diff --git a/apps/yaak-client/components/core/BulkPairEditor.test.ts b/packages/ui/src/components/pairs/BulkPairEditor.test.ts similarity index 100% rename from apps/yaak-client/components/core/BulkPairEditor.test.ts rename to packages/ui/src/components/pairs/BulkPairEditor.test.ts diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/packages/ui/src/components/pairs/BulkPairEditor.tsx similarity index 96% rename from apps/yaak-client/components/core/BulkPairEditor.tsx rename to packages/ui/src/components/pairs/BulkPairEditor.tsx index f15945d46..5553ae225 100644 --- a/apps/yaak-client/components/core/BulkPairEditor.tsx +++ b/packages/ui/src/components/pairs/BulkPairEditor.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo } from "react"; -import { Editor } from "./Editor/LazyEditor"; -import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; import { generateId } from "@yaakapp-internal/lib/generateId"; +import { useCallback, useMemo } from "react"; +import { useEditorHost } from "../EditorHost"; +import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; type Props = PairEditorProps; @@ -12,8 +12,10 @@ export function BulkPairEditor({ valuePlaceholder, forceUpdateKey, forcedEnvironmentId, + readOnly, stateKey, }: Props) { + const { Editor } = useEditorHost(); const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]); const handleChange = useCallback( @@ -33,6 +35,7 @@ export function BulkPairEditor({ placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`} defaultValue={pairsText} language="pairs" + readOnly={readOnly} onChange={handleChange} /> ); diff --git a/packages/ui/src/components/pairs/PairEditor.tsx b/packages/ui/src/components/pairs/PairEditor.tsx new file mode 100644 index 000000000..f1444e4f7 --- /dev/null +++ b/packages/ui/src/components/pairs/PairEditor.tsx @@ -0,0 +1,994 @@ +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import classNames from "classnames"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRandomKey } from "../../hooks/useRandomKey"; +import { useToggle } from "../../hooks/useToggle"; +import { computeSideForDragMove } from "../../lib/dnd"; +import { languageFromContentType } from "../../lib/contentType"; +import { Button } from "../Button"; +import { Checkbox } from "../Checkbox"; +import { DropMarker } from "../DropMarker"; +import type { DropdownItem } from "../Dropdown"; +import { Dropdown } from "../Dropdown"; +import type { GenericCompletionConfig, HostInputProps, TemplatingProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import { IconButton } from "../IconButton"; +import type { InputHandle } from "../Input"; +import type { RadioDropdownItem } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; +import { ensurePairId } from "./PairEditor.util"; + +export interface PairEditorHandle { + /** + * Focus a row's name field once it's able to take focus. Focus can't land immediately when the + * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming + * active — so this retries for up to ~1s. A newer focus request cancels a pending one. + */ + focusName(id: string): void; + /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */ + focusValue(id: string): void; +} + +/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */ +const MAX_FOCUS_ATTEMPTS = 60; + +export type PairEditorProps = { + allowFileValues?: boolean; + allowMultilineValues?: boolean; + className?: string; + forcedEnvironmentId?: string; + forceUpdateKey?: string; + nameAutocomplete?: GenericCompletionConfig; + nameAutocompleteFunctions?: boolean; + nameAutocompleteVariables?: boolean; + namePlaceholder?: string; + nameValidate?: HostInputProps["validate"]; + noScroll?: boolean; + onChange: (pairs: PairWithId[]) => void; + pairs: EditablePair[]; + /** Show the pairs without a way to change them: no new row, no drag, no row menu */ + readOnly?: boolean; + stateKey: HostInputProps["stateKey"]; + setRef?: (n: PairEditorHandle) => void; + valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; + valueAutocompleteFunctions?: boolean; + valueAutocompleteVariables?: boolean | "environment"; + valuePlaceholder?: string; + valueType?: HostInputProps["type"] | ((pair: Pair) => HostInputProps["type"]); + valueValidate?: HostInputProps["validate"]; +}; + +export type Pair = { + id?: string; + enabled?: boolean; + name: string; + value: string; + contentType?: string; + filename?: string; + isFile?: boolean; +}; + +export type PairWithId = Pair & { + id: string; +}; + +/** + * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain + * `Pair` stays the shape that gets written to models. + */ +export type EditablePair = Pair & { + /** + * When set, name edits are held until the field blurs and then committed through this, instead + * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts + * the field. For names that can't be written directly, like a URL path placeholder that lives + * in the URL itself. + */ + commitName?: (name: string) => boolean; +}; + +type EditablePairWithId = EditablePair & { + id: string; +}; + +/** Strip the editor-only fields, so they can never reach a model write */ +function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId { + return pair; +} + +/** Max number of pairs to show before prompting the user to reveal the rest */ +const MAX_INITIAL_PAIRS = 30; + +// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners +// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows +// fast path below would swap in the new data without rebuilding the row editors, leaving any +// still-focused input showing the old owner's text. +export function PairEditor(props: PairEditorProps) { + return ; +} + +function PairEditorInner({ + allowFileValues, + allowMultilineValues, + className, + forcedEnvironmentId, + forceUpdateKey, + nameAutocomplete, + nameAutocompleteFunctions, + nameAutocompleteVariables, + namePlaceholder, + nameValidate, + noScroll, + onChange, + pairs: originalPairs, + readOnly, + stateKey, + valueAutocomplete, + valueAutocompleteFunctions, + valueAutocompleteVariables, + valuePlaceholder, + valueType, + valueValidate, + setRef, +}: PairEditorProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [isDragging, setIsDragging] = useState(null); + const [pairs, setPairs] = useState([]); + const [showAll, toggleShowAll] = useToggle(false); + // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If + // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. + const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey(); + + const rowsRef = useRef>({}); + + const pendingFocusFrame = useRef(null); + useEffect( + () => () => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + }, + [], + ); + + const focusWhenReady = useCallback((id: string, field: "name" | "value") => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + + let attemptsLeft = MAX_FOCUS_ATTEMPTS; + const attempt = () => { + pendingFocusFrame.current = null; + const row = rowsRef.current[id]; + const landed = field === "name" ? row?.focusName() : row?.focusValue(); + if (landed || --attemptsLeft <= 0) return; + pendingFocusFrame.current = requestAnimationFrame(attempt); + }; + attempt(); + }, []); + + const handle = useMemo( + () => ({ + focusName(id: string) { + focusWhenReady(id, "name"); + }, + focusValue(id: string) { + focusWhenReady(id, "value"); + }, + }), + [focusWhenReady], + ); + + const initPairEditorRow = useCallback( + (id: string, n: RowHandle | null) => { + const isLast = id === pairs[pairs.length - 1]?.id; + if (isLast && !readOnly) return; // Never add the last pair + + rowsRef.current[id] = n; + const validHandles = Object.values(rowsRef.current).filter((v) => v != null); + + // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) + const ready = validHandles.length >= pairs.length - (readOnly ? 0 : 1); + if (ready) { + setRef?.(handle); + } + }, + [handle, pairs, readOnly, setRef], + ); + + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey + useEffect(() => { + // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) + const newPairs: EditablePairWithId[] = []; + for (let i = 0; i < originalPairs.length; i++) { + const p = originalPairs[i]; + if (!p) continue; // Make TS happy + if (isPairEmpty(p)) continue; + newPairs.push(ensurePairId(p)); + } + + // Nothing can be added, so there's no trailing row to start a new pair with + if (readOnly) { + setPairs(newPairs); + regenerateLocalForceUpdateKey(); + return; + } + + // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps + // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed + // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and + // selection — like tabbing from a placeholder's name into its value. + const trailingPair = pairs[pairs.length - 1]; + const sameRows = + trailingPair != null && + isPairEmpty(trailingPair) && + pairs.length === newPairs.length + 1 && + newPairs.every((p, i) => p.id === pairs[i]?.id); + if (sameRows) { + setPairs([...newPairs, trailingPair]); + return; + } + + // Add empty last pair if there is none + const lastPair = newPairs[newPairs.length - 1]; + if (lastPair == null || !isPairEmpty(lastPair)) { + newPairs.push(emptyPair()); + } + + setPairs(newPairs); + regenerateLocalForceUpdateKey(); + }, [forceUpdateKey]); + + const setPairsAndSave = useCallback( + (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => { + setPairs((oldPairs) => { + const pairs = fn(oldPairs); + onChange(pairs.map(toPairData)); + return pairs; + }); + }, + [onChange], + ); + + const handleChange = useCallback( + (pair: EditablePairWithId) => + setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), + [setPairsAndSave], + ); + + const handleDelete = useCallback( + (pair: Pair, focusPrevious: boolean) => { + if (focusPrevious) { + const index = pairs.findIndex((p) => p.id === pair.id); + const id = pairs[index - 1]?.id ?? null; + rowsRef.current[id ?? "n/a"]?.focusName(); + } + return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); + }, + [setPairsAndSave, pairs], + ); + + const handleFocusName = useCallback( + (pair: Pair) => { + const isLast = pair.id === pairs[pairs.length - 1]?.id; + if (isLast) setPairs([...pairs, emptyPair()]); + }, + [pairs], + ); + + const handleFocusValue = useCallback( + (pair: Pair) => { + const isLast = pair.id === pairs[pairs.length - 1]?.id; + if (isLast) setPairs([...pairs, emptyPair()]); + }, + [pairs], + ); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + // dnd-kit: show the “between rows” marker while hovering + const onDragMove = useCallback( + (e: DragMoveEvent) => { + const overId = e.over?.id as string | undefined; + if (!overId) return setHoveredIndex(null); + + const overPair = pairs.find((p) => p.id === overId); + if (overPair == null) return setHoveredIndex(null); + + const side = computeSideForDragMove(overPair.id, e); + const overIndex = pairs.findIndex((p) => p.id === overId); + const hoveredIndex = overIndex + (side === "before" ? 0 : 1); + + setHoveredIndex(hoveredIndex); + }, + [pairs], + ); + + const onDragStart = useCallback( + (e: DragStartEvent) => { + const pair = pairs.find((p) => p.id === e.active.id); + setIsDragging(pair ?? null); + }, + [pairs], + ); + + const onDragCancel = useCallback(() => setIsDragging(null), []); + + const onDragEnd = useCallback( + (e: DragEndEvent) => { + setIsDragging(null); + setHoveredIndex(null); + const activeId = e.active.id as string | undefined; + const overId = e.over?.id as string | undefined; + if (!activeId || !overId) return; + + const from = pairs.findIndex((p) => p.id === activeId); + const baseTo = pairs.findIndex((p) => p.id === overId); + const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); + + if (from !== -1 && to !== -1 && from !== to) { + setPairsAndSave((ps) => { + const next = [...ps]; + const [moved] = next.splice(from, 1); + if (moved === undefined) return ps; // Make TS happy + next.splice(to > from ? to - 1 : to, 0, moved); + return next; + }); + } + }, + [pairs, hoveredIndex, setPairsAndSave], + ); + + return ( +
+
+ + {pairs.map((p, i) => { + if (!showAll && i > MAX_INITIAL_PAIRS) return null; + + const isLast = !readOnly && i === pairs.length - 1; + return ( + + {hoveredIndex === i && } + + + ); + })} + {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( + + )} + + {isDragging && ( + + )} + + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + }} + /> +
+ ); +} + +type PairEditorRowProps = { + className?: string; + pair: EditablePairWithId; + forceFocusNamePairId?: string | null; + forceFocusValuePairId?: string | null; + onChange?: (pair: EditablePairWithId) => void; + onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void; + onFocusName?: (pair: EditablePairWithId) => void; + onFocusValue?: (pair: EditablePairWithId) => void; + onSubmit?: (pair: EditablePairWithId) => void; + isLast?: boolean; + disabled?: boolean; + disableDrag?: boolean; + readOnly?: boolean; + index: number; + isDraggingGlobal?: boolean; + setRef?: (id: string, n: RowHandle | null) => void; +} & Pick< + PairEditorProps, + | "allowFileValues" + | "allowMultilineValues" + | "forcedEnvironmentId" + | "forceUpdateKey" + | "nameAutocomplete" + | "nameAutocompleteVariables" + | "namePlaceholder" + | "nameValidate" + | "nameAutocompleteFunctions" + | "stateKey" + | "valueAutocomplete" + | "valueAutocompleteFunctions" + | "valueAutocompleteVariables" + | "valuePlaceholder" + | "valueType" + | "valueValidate" +>; + +interface RowHandle { + focusName(): boolean; + focusValue(): boolean; +} + +export function PairEditorRow({ + allowFileValues, + allowMultilineValues, + className, + disableDrag, + disabled, + forceUpdateKey, + forcedEnvironmentId, + index, + isLast, + nameAutocomplete, + nameAutocompleteFunctions, + nameAutocompleteVariables, + namePlaceholder, + nameValidate, + isDraggingGlobal, + onChange, + onDelete, + onFocusName, + onFocusValue, + pair, + readOnly, + stateKey, + valueAutocomplete, + valueAutocompleteFunctions, + valueAutocompleteVariables, + valuePlaceholder, + valueType, + valueValidate, + setRef, +}: PairEditorRowProps) { + const { Input, SelectFile, showDialog } = useEditorHost(); + const nameInputRef = useRef(null); + const valueInputRef = useRef(null); + const handle = useRef({ + focusName() { + nameInputRef.current?.focus(); + return nameInputRef.current?.isFocused() ?? false; + }, + focusValue() { + valueInputRef.current?.focus(); + return valueInputRef.current?.isFocused() ?? false; + }, + }); + + const initNameInputRef = useCallback( + (n: InputHandle | null) => { + nameInputRef.current = n; + if (nameInputRef.current && valueInputRef.current) { + setRef?.(pair.id, handle.current); + } + }, + [pair.id, setRef], + ); + + const initValueInputRef = useCallback( + (n: InputHandle | null) => { + valueInputRef.current = n; + if (nameInputRef.current && valueInputRef.current) { + setRef?.(pair.id, handle.current); + } + }, + [pair.id, setRef], + ); + + const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]); + const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]); + const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); + + const handleChangeEnabled = useMemo( + () => (enabled: boolean) => onChange?.({ ...pair, enabled }), + [onChange, pair], + ); + + // The name being typed into a deferred-commit field, before it's committed or reverted + const pendingName = useRef(null); + + const handleChangeName = useMemo( + () => (name: string) => { + // Keep the edit local until commit. Writing on every keystroke would reset the editor from + // beneath the cursor, since the pairs are derived from the name being edited. + if (pair.commitName != null) pendingName.current = name; + else onChange?.({ ...pair, name }); + }, + [onChange, pair], + ); + + const revertName = useCallback(() => { + const nameInput = nameInputRef.current; + if (nameInput != null) { + const changes = { from: 0, to: nameInput.value().length, insert: pair.name }; + nameInput.dispatch({ changes }); + } + pendingName.current = null; + }, [pair.name]); + + const handleBlurName = useCallback(() => { + if (pair.commitName == null) return; + + const name = pendingName.current; + pendingName.current = null; + if (name == null || name === pair.name) return; + if (!pair.commitName(name)) revertName(); + }, [pair, revertName]); + + const handleChangeValueText = useMemo( + () => (value: string) => onChange?.({ ...pair, value, isFile: false }), + [onChange, pair], + ); + + const handleChangeValueFile = useMemo( + () => + ({ filePath }: { filePath: string | null }) => + onChange?.({ ...pair, value: filePath ?? "", isFile: true }), + [onChange, pair], + ); + + const handleChangeValueContentType = useMemo( + () => (contentType: string) => onChange?.({ ...pair, contentType }), + [onChange, pair], + ); + + const handleChangeValueFilename = useMemo( + () => (filename: string) => onChange?.({ ...pair, filename }), + [onChange, pair], + ); + + const handleEditMultiLineValue = useCallback( + () => + showDialog?.({ + id: "pair-edit-multiline", + size: "dynamic", + title: <>Edit {pair.name}, + render: ({ hide }) => ( + + ), + }), + [handleChangeValueText, pair.contentType, pair.name, pair.value, showDialog], + ); + + const defaultItems = useMemo( + (): DropdownItem[] => [ + { + label: "Edit Multi-line", + onSelect: handleEditMultiLineValue, + hidden: !allowMultilineValues, + }, + { + label: "Delete", + onSelect: handleDelete, + color: "danger", + }, + ], + [allowMultilineValues, handleDelete, handleEditMultiLineValue], + ); + + const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id }); + const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id }); + + // Filter out the current pair name + const valueAutocompleteVariablesFiltered = useMemo< + TemplatingProps["autocompleteVariables"] + >(() => { + if (valueAutocompleteVariables === "environment") { + return (v): boolean => v.variable.name !== pair.name; + } + return valueAutocompleteVariables; + }, [pair.name, valueAutocompleteVariables]); + + const handleSetRef = useCallback( + (n: HTMLDivElement | null) => { + setDraggableRef(n); + setDroppableRef(n); + }, + [setDraggableRef, setDroppableRef], + ); + + // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would + // imply it's already a real row. `Input` handles the rest, including having no options to show. + const showOptionsPicker = !isLast; + + return ( +
+ + {!isLast && !disableDrag && !readOnly ? ( +
+ +
+ ) : ( + + )} +
+ +
+ {pair.isFile && readOnly ? ( + // A file value to read, not to pick: no picker and nothing to drop a file on + + {pair.filename || pair.value || "No file"} + + ) : pair.isFile && SelectFile != null ? ( + + ) : pair.value.includes("\n") ? ( + + ) : ( + + )} +
+
+ {readOnly ? null : allowFileValues ? ( + + ) : ( + + + + )} +
+ ); +} + +const fileItems: RadioDropdownItem[] = [ + { label: "Text", value: "text" }, + { label: "File", value: "file" }, +]; + +function FileActionsDropdown({ + pair, + onChangeFile, + onChangeText, + onChangeContentType, + onChangeFilename, + onDelete, + editMultiLine, +}: { + pair: Pair; + onChangeFile: ({ filePath }: { filePath: string | null }) => void; + onChangeText: (text: string) => void; + onChangeContentType: (contentType: string) => void; + onChangeFilename: (filename: string) => void; + onDelete: () => void; + editMultiLine: () => void; +}) { + const { basename, showPrompt } = useEditorHost(); + const onChange = useCallback( + (v: string) => { + if (v === "file") onChangeFile({ filePath: "" }); + else onChangeText(""); + }, + [onChangeFile, onChangeText], + ); + + const itemsAfter = useMemo( + () => [ + { + label: "Edit Multi-Line", + leftSlot: , + hidden: pair.isFile, + onSelect: editMultiLine, + }, + { + label: "Set Content-Type", + leftSlot: , + onSelect: async () => { + const contentType = await showPrompt?.({ + id: "content-type", + title: "Override Content-Type", + label: "Content-Type", + required: false, + placeholder: "text/plain", + defaultValue: pair.contentType ?? "", + confirmText: "Set", + description: "Leave blank to auto-detect", + }); + if (contentType == null) return; + onChangeContentType(contentType); + }, + }, + { + label: "Set File Name", + leftSlot: , + onSelect: async () => { + console.log("PAIR", pair); + const defaultFilename = await basename(pair.value ?? ""); + const filename = await showPrompt?.({ + id: "filename", + title: "Override Filename", + label: "Filename", + required: false, + placeholder: defaultFilename ?? "myfile.png", + defaultValue: pair.filename, + confirmText: "Set", + description: "Leave blank to use the name of the selected file", + }); + if (filename == null) return; + onChangeFilename(filename); + }, + }, + { + label: "Unset File", + leftSlot: , + hidden: pair.isFile, + onSelect: async () => { + onChangeFile({ filePath: null }); + }, + }, + { + label: "Delete", + onSelect: onDelete, + variant: "danger", + leftSlot: , + color: "danger", + }, + ], + [ + editMultiLine, + onChangeContentType, + onChangeFile, + onDelete, + pair.contentType, + pair.isFile, + onChangeFilename, + pair.filename, + pair, + basename, + showPrompt, + ], + ); + + return ( + + + + ); +} + +function emptyPair(): EditablePairWithId { + return ensurePairId({ enabled: true, name: "", value: "" }); +} + +function isPairEmpty(pair: Pair): boolean { + return !pair.name && !pair.value; +} + +function MultilineEditDialog({ + defaultValue, + contentType, + onChange, + hide, +}: { + defaultValue: string; + contentType: string | null; + onChange: (value: string) => void; + hide: () => void; +}) { + const { Editor } = useEditorHost(); + const [value, setValue] = useState(defaultValue); + const language = languageFromContentType(contentType, value); + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/ui/src/components/pairs/PairEditor.util.tsx b/packages/ui/src/components/pairs/PairEditor.util.tsx new file mode 100644 index 000000000..b43428292 --- /dev/null +++ b/packages/ui/src/components/pairs/PairEditor.util.tsx @@ -0,0 +1,10 @@ +import type { Pair, PairWithId } from "./PairEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; + +// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) +export function ensurePairId(p: T): T & PairWithId { + if (typeof p.id === "string") { + return p as T & PairWithId; + } + return { ...p, id: p.id ?? generateId() }; +} diff --git a/packages/ui/src/components/pairs/PairOrBulkEditor.tsx b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx new file mode 100644 index 000000000..22f4facc4 --- /dev/null +++ b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx @@ -0,0 +1,39 @@ +import classNames from "classnames"; +import { useEditorHost } from "../EditorHost"; +import { IconButton } from "../IconButton"; +import { BulkPairEditor } from "./BulkPairEditor"; +import type { PairEditorProps } from "./PairEditor"; +import { PairEditor } from "./PairEditor"; + +interface Props extends PairEditorProps { + preferenceName: string; + forcedEnvironmentId?: string; +} + +export function PairOrBulkEditor({ preferenceName, ...props }: Props) { + const { useStoredValue } = useEditorHost(); + const [useBulk, setUseBulk] = useStoredValue( + ["bulk_edit", preferenceName], + false, + "global", + ); + + return ( +
+ {useBulk ? : } +
+ setUseBulk((b) => !b)} + icon={useBulk ? "table" : "file_code"} + /> +
+
+ ); +} diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx index 046199855..8dd9a24bf 100644 --- a/packages/ui/src/components/tabs/Tabs.tsx +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -21,11 +21,15 @@ import { useRef, useState, } from "react"; -import { useInMemoryStoredValue, type UseStoredValue } from "../../hooks/useStoredValue"; +import type { UseStoredValue } from "../../hooks/useStoredValue"; +import { type UseHostStoredValue, useEditorHost } from "../EditorHost"; import { computeSideForDragMove } from "../../lib/dnd"; import type { ButtonProps } from "../Button"; import { Button } from "../Button"; import { DropMarker } from "../DropMarker"; +import { Icon } from "../Icon"; +import type { RadioDropdownProps } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; export interface TabButtonRenderProps { buttonProps: Partial; @@ -46,7 +50,16 @@ export type TabItem = renderButton: (props: TabButtonRenderProps) => ReactNode; leftSlot?: ReactNode; rightSlot?: ReactNode; - }; + } + | TabOptionsItem; + +/** A tab whose button is also a menu of choices, labelled with the chosen one */ +export interface TabOptionsItem { + value: string; + options: Omit; + leftSlot?: ReactNode; + rightSlot?: ReactNode; +} export interface TabsStorage { order: string[]; @@ -91,19 +104,23 @@ export const Tabs = forwardRef(function Tabs( layout = "vertical", storageKey, activeTabKey, - useStoredValue = useInMemoryStoredValue, + useStoredValue: useStoredValueProp, }: TabsProps, forwardedRef: Ref, ) { const ref = useRef(null); const reorderable = !!storageKey; + const host = useEditorHost(); - // Use key-value storage for persistence if storageKey is provided + // Use key-value storage for persistence if storageKey is provided. Without one passed in, the + // host's local storage keeps it. // Handle migration from old format (string[]) to new format (TabsStorage) - const [rawStorage, setStorage] = useStoredValue(storageKey ?? null, { - order: [], - activeTabs: {}, - }); + const useStoredValue: UseHostStoredValue = useStoredValueProp ?? host.useStoredValue; + const [rawStorage, setStorage] = useStoredValue( + storageKey ?? null, + { order: [], activeTabs: {} }, + "no_sync", + ); // Migrate old format (string[]) to new format (TabsStorage) const storage: TabsStorage = Array.isArray(rawStorage) @@ -481,6 +498,8 @@ function TabButton({ const buttonContent = "renderButton" in tab ? ( tab.renderButton({ buttonProps: btnProps, isActive }) + ) : "options" in tab ? ( + ) : (
+ } + {...buttonProps} + > + {option && "shortLabel" in option && option.shortLabel + ? option.shortLabel + : (option?.label ?? "Unknown")} + + + ); +} diff --git a/packages/ui/src/components/tags/HttpResponseDurationTag.tsx b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx index 93709aaeb..afd14f1be 100644 --- a/packages/ui/src/components/tags/HttpResponseDurationTag.tsx +++ b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx @@ -6,23 +6,51 @@ interface Props { } export function HttpResponseDurationTag({ response }: Props) { + return ( + + ); +} + +interface RawProps { + /** Whether the response has ended; until then the time counts up from `startedAt` */ + closed: boolean; + /** When the request started, as an ISO 8601 string with its zone */ + startedAt: string; + elapsedMs: number; + elapsedHeadersMs: number; + elapsedDnsMs: number; +} + +export function HttpResponseDurationTagRaw({ + closed, + startedAt, + elapsedMs, + elapsedHeadersMs, + elapsedDnsMs, +}: RawProps) { const [fallbackElapsed, setFallbackElapsed] = useState(0); const timeout = useRef>(undefined); // Calculate the duration of the response for use when the response hasn't finished yet useEffect(() => { clearInterval(timeout.current); - if (response.state === "closed") return; + if (closed) return; timeout.current = setInterval(() => { - setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime()); + setFallbackElapsed(Date.now() - new Date(startedAt).getTime()); }, 100); return () => clearInterval(timeout.current); - }, [response.createdAt, response.state]); + }, [startedAt, closed]); - const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : "--"; - const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`; + const dnsValue = elapsedDnsMs > 0 ? formatMillis(elapsedDnsMs) : "--"; + const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(elapsedHeadersMs)}\nTOTAL: ${formatMillis(elapsedMs)}`; - const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed; + const elapsed = closed ? elapsedMs : fallbackElapsed; return ( diff --git a/packages/ui/src/hooks/useHotKey.ts b/packages/ui/src/hooks/useHotKey.ts index 09f2896ee..7bf9ff760 100644 --- a/packages/ui/src/hooks/useHotKey.ts +++ b/packages/ui/src/hooks/useHotKey.ts @@ -21,6 +21,8 @@ export interface HotkeyContextValue { os: string; /** The key combos bound to an action, like `["Meta+Shift+s"]`, or null when it has none */ bindingFor: (action: HotkeyAction) => string[] | null; + /** What an action does, in words, for lists of shortcuts. Falls back to the action's name */ + labelFor?: (action: HotkeyAction) => string; /** Bind a callback to an action's keys, returning the unbind */ register: ( action: HotkeyAction, @@ -49,6 +51,11 @@ export function useHotKey( }, [action, callback, options, register]); } +export function useHotkeyLabel(action: HotkeyAction): string { + const { labelFor } = useContext(HotkeyContext); + return labelFor?.(action) ?? action; +} + export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { const { os, bindingFor } = useContext(HotkeyContext); const trigger = action == null ? null : (bindingFor(action)?.[0] ?? null); diff --git a/packages/ui/src/hooks/useToggle.ts b/packages/ui/src/hooks/useToggle.ts new file mode 100644 index 000000000..e611d3826 --- /dev/null +++ b/packages/ui/src/hooks/useToggle.ts @@ -0,0 +1,7 @@ +import { useCallback, useState } from "react"; + +export function useToggle(initialValue = false) { + const [value, setValue] = useState(initialValue); + const toggle = useCallback(() => setValue((v) => !v), []); + return [value, toggle, setValue] as const; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3c46e5edd..006b12733 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,6 +23,85 @@ export type { } from "./components/Dropdown"; export { ContextMenu, Dropdown } from "./components/Dropdown"; export { LazyCodeEditor } from "./components/editor/LazyCodeEditor"; +export type { + EditorHost, + GenericCompletion, + GenericCompletionConfig, + HostBodyViewerArgs, + HostDialogArgs, + HostEditorLanguage, + HostEditorProps, + HostInputProps, + HostPromptArgs, + SelectFileProps, + StoredValueNamespace, + TemplateVariableRef, + TemplatingProps, + UseHostStoredValue, +} from "./components/EditorHost"; +export { EditorHostProvider, useEditorHost } from "./components/EditorHost"; +export { EmptyStateText } from "./components/EmptyStateText"; +export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse"; +export { FormMultipartEditor } from "./components/http/FormMultipartEditor"; +export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor"; +export { HeadersEditor } from "./components/http/HeadersEditor"; +export type { HttpRequestPaneViewProps } from "./components/http/HttpRequestPaneView"; +export { + bodyTypeLabel, + HttpRequestPaneView, + REQUEST_TAB_AUTH, + REQUEST_TAB_BODY, + REQUEST_TAB_HEADERS, + REQUEST_TAB_PARAMS, + RequestBodyEditor, +} from "./components/http/HttpRequestPaneView"; +export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView"; +export { + HttpResponsePaneView, + RESPONSE_TAB_BODY, + RESPONSE_TAB_COOKIES, + RESPONSE_TAB_HEADERS, + RESPONSE_TAB_REQUEST, + ResponseStatusLine, +} from "./components/http/HttpResponsePaneView"; +export { JsonBodyEditor } from "./components/http/JsonBodyEditor"; +export { RequestBodyViewer } from "./components/http/RequestBodyViewer"; +export { RequestMethodDropdown } from "./components/http/RequestMethodDropdown"; +export type { + ResponseTextViewerExtras, + ResponseViewMode, +} from "./components/http/ResponseBodyViewer"; +export { HTMLOrTextViewer, ResponseBodyViewer } from "./components/http/ResponseBodyViewer"; +export { parseCookieHeaders, ResponseCookies } from "./components/http/ResponseCookies"; +export { ResponseHeaders } from "./components/http/ResponseHeaders"; +export * from "./components/http/types"; +export type { UrlBarProps } from "./components/http/UrlBar"; +export { UrlBar } from "./components/http/UrlBar"; +export { UrlParametersEditor } from "./components/http/UrlParametersEditor"; +export { HotkeyLabel } from "./components/HotkeyLabel"; +export { HotkeyList } from "./components/HotkeyList"; +export { + formatBulkPairLine, + formatBulkPairs, + parseBulkPairLine, + parseBulkPairs, +} from "./components/pairs/BulkPairEditor"; +export type { + EditablePair, + Pair, + PairEditorHandle, + PairEditorProps, + PairWithId, +} from "./components/pairs/PairEditor"; +export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor"; +export { ensurePairId } from "./components/pairs/PairEditor.util"; +export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor"; +export { PillButton } from "./components/PillButton"; +export type { + RadioDropdownItem, + RadioDropdownProps, +} from "./components/RadioDropdown"; +export { RadioDropdown } from "./components/RadioDropdown"; export type { ErrorBoundaryProps } from "./components/ErrorBoundary"; export { ErrorBoundary } from "./components/ErrorBoundary"; export { FormattedError } from "./components/FormattedError"; @@ -78,7 +157,11 @@ export type { ToastProps } from "./components/Toast"; export { Toast } from "./components/Toast"; export { Toasts } from "./components/Toasts"; export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; -export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; +export { + formatMillis, + HttpResponseDurationTag, + HttpResponseDurationTagRaw, +} from "./components/tags/HttpResponseDurationTag"; export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; export { SizeTag } from "./components/tags/SizeTag"; export type { TooltipProps } from "./components/Tooltip"; @@ -113,7 +196,13 @@ export type { HotkeyActionRegistry, HotkeyContextValue, } from "./hooks/useHotKey"; -export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey"; +export { + formatHotkey, + HotkeyProvider, + useFormattedHotkey, + useHotKey, + useHotkeyLabel, +} from "./hooks/useHotKey"; export { useIsFullscreen } from "./hooks/useIsFullscreen"; export { useRandomKey } from "./hooks/useRandomKey"; export { usePortal } from "./hooks/usePortal"; @@ -123,9 +212,13 @@ export { useInMemoryStoredValue } from "./hooks/useStoredValue"; export type { TextFilterApi } from "./hooks/useTextFilter"; export { useTextFilter } from "./hooks/useTextFilter"; export { useTimedBoolean } from "./hooks/useTimedBoolean"; +export { useToggle } from "./hooks/useToggle"; export { clamp } from "./lib/clamp"; export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; export { languageFromContent, languageFromContentType } from "./lib/contentType"; +export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType"; +export { textLikelyContainsJsonComments } from "./lib/jsonComments"; +export { validateHttpHeader } from "./lib/validateHttpHeader"; export { computeSideForDragMove } from "./lib/dnd"; export { docFingerprint } from "./lib/docFingerprint"; export { getNodeText } from "./lib/getNodeText"; diff --git a/apps/yaak-client/lib/data/acceptLanguages.ts b/packages/ui/src/lib/data/acceptLanguages.ts similarity index 100% rename from apps/yaak-client/lib/data/acceptLanguages.ts rename to packages/ui/src/lib/data/acceptLanguages.ts diff --git a/apps/yaak-client/lib/data/cacheControl.ts b/packages/ui/src/lib/data/cacheControl.ts similarity index 100% rename from apps/yaak-client/lib/data/cacheControl.ts rename to packages/ui/src/lib/data/cacheControl.ts diff --git a/apps/yaak-client/lib/data/charsets.ts b/packages/ui/src/lib/data/charsets.ts similarity index 100% rename from apps/yaak-client/lib/data/charsets.ts rename to packages/ui/src/lib/data/charsets.ts diff --git a/apps/yaak-client/lib/data/connections.ts b/packages/ui/src/lib/data/connections.ts similarity index 100% rename from apps/yaak-client/lib/data/connections.ts rename to packages/ui/src/lib/data/connections.ts diff --git a/apps/yaak-client/lib/data/encodings.ts b/packages/ui/src/lib/data/encodings.ts similarity index 100% rename from apps/yaak-client/lib/data/encodings.ts rename to packages/ui/src/lib/data/encodings.ts diff --git a/apps/yaak-client/lib/data/headerNames.ts b/packages/ui/src/lib/data/headerNames.ts similarity index 100% rename from apps/yaak-client/lib/data/headerNames.ts rename to packages/ui/src/lib/data/headerNames.ts diff --git a/apps/yaak-client/lib/data/headerValuePresets.ts b/packages/ui/src/lib/data/headerValuePresets.ts similarity index 100% rename from apps/yaak-client/lib/data/headerValuePresets.ts rename to packages/ui/src/lib/data/headerValuePresets.ts diff --git a/apps/yaak-client/lib/data/mimetypes.ts b/packages/ui/src/lib/data/mimetypes.ts similarity index 100% rename from apps/yaak-client/lib/data/mimetypes.ts rename to packages/ui/src/lib/data/mimetypes.ts diff --git a/apps/yaak-client/lib/data/userAgents.ts b/packages/ui/src/lib/data/userAgents.ts similarity index 100% rename from apps/yaak-client/lib/data/userAgents.ts rename to packages/ui/src/lib/data/userAgents.ts diff --git a/apps/yaak-client/lib/jsonComments.ts b/packages/ui/src/lib/jsonComments.ts similarity index 100% rename from apps/yaak-client/lib/jsonComments.ts rename to packages/ui/src/lib/jsonComments.ts diff --git a/packages/ui/src/lib/mimeType.ts b/packages/ui/src/lib/mimeType.ts new file mode 100644 index 000000000..262d7fc98 --- /dev/null +++ b/packages/ui/src/lib/mimeType.ts @@ -0,0 +1,44 @@ +import { MIMEType } from "whatwg-mimetype"; + +export function isJSON(content: string | null | undefined): boolean { + if (typeof content !== "string") return false; + + try { + JSON.parse(content); + return true; + } catch { + return false; + } +} + +export function isProbablyTextContentType(contentType: string | null): boolean { + if (contentType == null) return false; + + const mimeType = getMimeTypeFromContentType(contentType).essence; + const normalized = mimeType.toLowerCase(); + + // Check if it starts with "text/" + if (normalized.startsWith("text/")) { + return true; + } + + // Common text mimetypes and suffixes + return [ + "application/json", + "application/xml", + "application/javascript", + "application/yaml", + "+json", + "+xml", + "+yaml", + "+text", + ].some((textType) => normalized === textType || normalized.endsWith(textType)); +} + +export function getMimeTypeFromContentType(contentType: string): MIMEType { + try { + return new MIMEType(contentType); + } catch { + return new MIMEType("text/plain"); + } +} diff --git a/apps/yaak-client/lib/validateHttpHeader.test.ts b/packages/ui/src/lib/validateHttpHeader.test.ts similarity index 100% rename from apps/yaak-client/lib/validateHttpHeader.test.ts rename to packages/ui/src/lib/validateHttpHeader.test.ts diff --git a/apps/yaak-client/lib/validateHttpHeader.ts b/packages/ui/src/lib/validateHttpHeader.ts similarity index 100% rename from apps/yaak-client/lib/validateHttpHeader.ts rename to packages/ui/src/lib/validateHttpHeader.ts