Compare commits

...

48 Commits

Author SHA1 Message Date
Gregory Schier
6e9b1db196 Bump version 2025-10-16 14:42:02 -07:00
Gregory Schier
d83aabd2be Dynamic template function args and TTL option for request chaining (#266) 2025-10-16 14:39:30 -07:00
Gregory Schier
d46479cd22 Remove debug console log from TreeDragOverlay component 2025-10-15 14:08:21 -07:00
Gregory Schier
19cae33382 Fix crash when delete after drag 2025-10-15 14:07:55 -07:00
Gregory Schier
267cd079ad New sidebar and folder view (#263) 2025-10-15 13:46:57 -07:00
Gregory Schier
19c1efc73e Resolve 2025-10-11 08:28:07 -07:00
Gregory Schier
dfa9a22861 Merge remote-tracking branch 'origin/main' 2025-10-11 06:29:17 -07:00
Gregory Schier
533f9bacc4 Add AWS authentication 2025-10-11 06:29:06 -07:00
Zhizhen He
0358748729 Fix icon paths in package.json (#265) 2025-10-09 04:24:44 -07:00
Zhizhen He
1540d0a5a5 Fix typo (#264) 2025-10-08 19:54:18 -07:00
Gregory Schier
d177e164f1 Fix log 2025-10-08 04:25:06 -07:00
Gregory Schier
f1355c9d15 Fix non-release build 2025-10-08 04:25:00 -07:00
Gregory Schier
485a9ea47c Show toast on plugin event handling errors instead of crashing
Also set folder context on template render and fix timestamp function
2025-10-06 06:53:45 -07:00
Gregory Schier
dbc606fb53 Update README 2025-10-04 08:22:39 -07:00
Gregory Schier
a00b4ae232 Always open new window when clicking current workspace:
https://feedback.yaak.app/p/dont-trigger-workspace-change-when-clicking-same-workspace
2025-10-04 06:47:50 -07:00
Gregory Schier
998b5cf78a Add RenderOptions and RenderErrorBehavior to ensure auth UI still loads with missing variables 2025-10-04 06:29:29 -07:00
Gregory Schier
b4deae6e8d Batch insert environments last, to handle folder case 2025-10-04 05:48:08 -07:00
Gregory Schier
87fdf17010 Fix plugin refresh toasts having no timeout 2025-10-04 05:47:39 -07:00
Gregory Schier
c6975a9e8b Allow toast interaction when dialog is open 2025-10-04 05:47:16 -07:00
Gregory Schier
b44ac55bc2 Fix broken environment migration 2025-10-04 05:47:00 -07:00
Gregory Schier
9c65c95ba9 Fix batch import potentially creating a useless base environment 2025-10-04 05:46:42 -07:00
Gregory Schier
7beb9f4e69 Merge remote-tracking branch 'origin/main' 2025-10-04 05:46:13 -07:00
Gregory Schier
dbecd74f46 Allow selecting confirm text 2025-10-04 05:46:06 -07:00
Gregory Schier
6826ee1672 Revise README for clarity and updated features
Updated the README to enhance clarity and organization, including feature descriptions and removing outdated content.
2025-10-03 10:29:19 -07:00
Gregory Schier
a12ae7ef56 Update README 2025-10-03 09:53:40 -07:00
gschier
dbc100409d Deploying to main from @ mountain-loop/yaak@6b87cd9655 🚀 2025-10-03 16:52:47 +00:00
Gregory Schier
6b87cd9655 Merge remote-tracking branch 'origin/main' 2025-10-03 09:52:31 -07:00
Gregory Schier
7ce2cdc9cc Update tiers 2025-10-03 09:52:26 -07:00
gschier
1f4e38b7a7 Deploying to main from @ mountain-loop/yaak@0013a0797b 🚀 2025-10-03 16:50:09 +00:00
Gregory Schier
0013a0797b Merge remote-tracking branch 'origin/main' 2025-10-03 09:49:45 -07:00
Gregory Schier
5e9b14dc0b Update workflow 2025-10-03 09:49:39 -07:00
gschier
b7cfb0db13 Deploying to main from @ mountain-loop/yaak@8948bfbf45 🚀 2025-10-03 16:46:35 +00:00
Gregory Schier
8948bfbf45 Merge remote-tracking branch 'origin/main' 2025-10-03 09:46:19 -07:00
Gregory Schier
4218e90bf4 Set active-only to true in sponsors workflow 2025-10-03 09:44:56 -07:00
gschier
2172d7ac60 Deploying to main from @ mountain-loop/yaak@5e45cb4908 🚀 2025-10-03 16:42:45 +00:00
Gregory Schier
5e45cb4908 Update sponsors workflow to use SPONSORS_PAT secret 2025-10-03 09:42:21 -07:00
Gregory Schier
d662883fdd Update README 2025-10-03 09:41:17 -07:00
Gregory Schier
f83f3d4682 Refine Yaak description in README
Updated the description of Yaak in the README.
2025-10-03 09:36:22 -07:00
gschier
e03c745093 Deploying to main from @ mountain-loop/yaak@73b9d699ed 🚀 2025-10-03 16:35:10 +00:00
Gregory Schier
73b9d699ed Merge remote-tracking branch 'origin/main' 2025-10-03 09:34:35 -07:00
Gregory Schier
5a7b9aba2f Sponsors workflow 2025-10-03 09:34:22 -07:00
Gregory Schier
cf433b26a5 Revise sponsor section in README.md
Updated sponsor section in README.md to include new sponsor images and links.
2025-10-03 09:32:36 -07:00
gschier
573035b17d Deploying to main from @ mountain-loop/yaak@3844fec968 🚀 2025-10-03 16:31:42 +00:00
Gregory Schier
a267c0c53f Update README 2025-10-03 09:31:21 -07:00
gschier
328563f4e6 Deploying to main from @ mountain-loop/yaak@3844fec968 🚀 2025-10-03 16:30:09 +00:00
Gregory Schier
3844fec968 Merge remote-tracking branch 'origin/main' 2025-10-03 09:29:35 -07:00
Gregory Schier
8557a2477b Sponsors workflow 2025-10-03 09:29:31 -07:00
Gregory Schier
d02519ab74 Update README header 2025-10-03 09:19:17 -07:00
131 changed files with 4208 additions and 1812 deletions

View File

@@ -114,4 +114,4 @@ jobs:
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: true
args: '${{ matrix.args }} --config ./src-tauri/tauri.commercial.conf.json'
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'

43
.github/workflows/sponsors.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 30 15 * * 0-6
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md'
maximum: 1999
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false
include-private: true
marker: 'sponsors-base'
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md'
minimum: 2000
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false
include-private: true
marker: 'sponsors-premium'
# ⚠️ Note: You can use any deployment step here to automatically push the README
# changes back to your branch.
- name: Commit Changes
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: main
folder: '.'

View File

@@ -1,32 +1,66 @@
# Yaak API Client
<p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
</a>
</p>
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
<h1 align="center">
💫 Yaak ➟ Desktop API Client 💫
</h1>
<p align="center">
A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC built with Tauri, Rust, and React.
</p>
<p align="center">
Development is funded by community-purchased <a href="https://yaak.app/pricing">licenses</a>. You can also <a href="https://github.com/sponsors/gschier">become a sponsor</a> to have your logo appear below. 💖
</p>
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="80px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
### 🌐 Work with any API
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
- Filter and inspect responses with JSONPath or XPath.
### 🔐 Stay secure
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
- Secure sensitive values with encrypted secrets.
- Store secrets in your OS keychain.
### ☁️ Organize & collaborate
- Group requests into workspaces and nested folders.
- Use environment variables to switch between dev, staging, and prod.
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
### 🧩 Extend & customize
- Insert dynamic values like UUIDs or timestamps with template tags.
- Pick from built-in themes or build your own.
- Create plugins to extend authentication, template tags, or the UI.
## Contribution Policy
Yaak is open source, but only accepting contributions for bug fixes. To get started,
Yaak is open source but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Feature Overview
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
- 📤 Send requests via REST, GraphQL, Server Sent Events (SSE), WebSockets, or gRPC.<br/>
- 🔐 Automatically authorize requests with OAuth 2.0, JWT tokens, Basic Auth, and more.<br/>
- 🔎 Filter response bodies using JSONPath or XPath queries.<br/>
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
- 📂 Organize requests into workspaces and nested folders.<br/>
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
- 🛡️ Secure arbitrary text values with end-to-end encryption<br/>
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
- 🎨 Choose from many of the included themes, or make your own.<br/>
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
- 📜 View response history for each request.<br/>
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
- 🛜 Configure a proxy to access firewall-blocked APIs
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
@@ -34,4 +68,3 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

90
package-lock.json generated
View File

@@ -8,16 +8,17 @@
"name": "yaak-app",
"version": "0.0.0",
"workspaces": [
"packages/common-lib",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/common-lib",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/auth-apikey",
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-jwt",
"plugins/auth-oauth2",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-curl",
@@ -26,7 +27,6 @@
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-timestamp",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",
@@ -35,13 +35,14 @@
"plugins/template-function-regex",
"plugins/template-function-request",
"plugins/template-function-response",
"plugins/template-function-timestamp",
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-fonts",
"src-tauri/yaak-git",
"src-tauri/yaak-license",
"src-tauri/yaak-mac-window",
"src-tauri/yaak-models",
@@ -819,6 +820,45 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
@@ -3377,6 +3417,16 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/aws4": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@types/aws4/-/aws4-1.11.6.tgz",
"integrity": "sha512-5CnVUkHNyLGpD9AnOcK66YyP0qvIh6nhJJoeK8zSl5YKikUcUbdB7SlHevUYVqicgeh6j5AJa1qa/h08dSZHoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4112,6 +4162,10 @@
"resolved": "plugins/auth-apikey",
"link": true
},
"node_modules/@yaak/auth-aws": {
"resolved": "plugins/auth-aws",
"link": true
},
"node_modules/@yaak/auth-basic": {
"resolved": "plugins/auth-basic",
"link": true
@@ -4887,6 +4941,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"license": "MIT"
},
"node_modules/axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
@@ -14391,12 +14451,13 @@
}
}
},
"node_modules/react-dnd-html5-backend": {
"node_modules/react-dnd-touch-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz",
"integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"dnd-core": "^16.0.1"
}
},
@@ -18823,6 +18884,16 @@
"name": "@yaak/auth-apikey",
"version": "0.1.0"
},
"plugins/auth-aws": {
"name": "@yaak/auth-aws",
"version": "0.1.0",
"dependencies": {
"aws4": "^1.13.2"
},
"devDependencies": {
"@types/aws4": "^1.11.6"
}
},
"plugins/auth-basic": {
"name": "@yaak/auth-basic",
"version": "0.1.0"
@@ -19058,6 +19129,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.11.0",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.3.1",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
@@ -19098,7 +19170,7 @@
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dnd-touch-backend": "^16.0.1",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.0.1",

View File

@@ -7,16 +7,17 @@
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"workspaces": [
"packages/common-lib",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/common-lib",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/auth-apikey",
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-jwt",
"plugins/auth-oauth2",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-curl",
@@ -25,7 +26,6 @@
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-timestamp",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",
@@ -34,13 +34,14 @@
"plugins/template-function-regex",
"plugins/template-function-request",
"plugins/template-function-response",
"plugins/template-function-timestamp",
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-fonts",
"src-tauri/yaak-git",
"src-tauri/yaak-license",
"src-tauri/yaak-mac-window",
"src-tauri/yaak-models",
@@ -60,8 +61,8 @@
"build-plugins": "npm run --workspaces --if-present build",
"test": "npm run --workspaces --if-present test",
"icons": "run-p icons:*",
"icons:dev": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
"icons:release": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",
"icons:dev": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",
"icons:release": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
"bootstrap": "run-p bootstrap:* && npm run --workspaces --if-present bootstrap",
"bootstrap:vendor-node": "node scripts/vendor-node.cjs",
"bootstrap:vendor-plugins": "node scripts/vendor-plugins.cjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@yaakapp/api",
"version": "0.6.6",
"version": "0.7.0",
"keywords": [
"api-client",
"insomnia-alternative",

View File

@@ -361,7 +361,11 @@ export type GetKeyValueRequest = { key: string, };
export type GetKeyValueResponse = { value?: string, };
export type GetTemplateFunctionsResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };
export type GetTemplateFunctionConfigRequest = { contextId: string, name: string, values: { [key in string]?: JsonPrimitive }, };
export type GetTemplateFunctionConfigResponse = { function: TemplateFunction, pluginRefId: string, };
export type GetTemplateFunctionSummaryResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };
export type GetThemesRequest = Record<string, never>;
@@ -385,7 +389,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: PluginWindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;
@@ -437,7 +441,7 @@ export type SetKeyValueRequest = { key: string, value: string, };
export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, description?: string,
/**

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -1,12 +1,21 @@
import {
CallTemplateFunctionArgs,
FormInput,
GetHttpAuthenticationConfigRequest,
TemplateFunction,
} from "../bindings/gen_events";
import { Context } from "./Context";
TemplateFunctionArg,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
export type DynamicTemplateFunctionArg = FormInput & {
dynamic(
ctx: Context,
args: GetHttpAuthenticationConfigRequest,
): MaybePromise<Partial<FormInput> | undefined | null>;
};
export type TemplateFunctionPlugin = TemplateFunction & {
onRender(
ctx: Context,
args: CallTemplateFunctionArgs,
): Promise<string | null>;
args: (TemplateFunctionArg | DynamicTemplateFunctionArg)[];
onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;
};

View File

@@ -186,20 +186,55 @@ export class PluginInstance {
}
if (
payload.type === 'get_template_functions_request' &&
payload.type === 'get_template_function_summary_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const reply: TemplateFunction[] = this.#mod.templateFunctions.map((templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
});
const functions: TemplateFunction[] = this.#mod.templateFunctions.map(
(templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
},
);
const replyPayload: InternalEventPayload = {
type: 'get_template_functions_response',
type: 'get_template_function_summary_response',
pluginRefId: this.#workerData.pluginRefId,
functions: reply,
functions,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_function_config_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
let templateFunction = this.#mod.templateFunctions.find((f) => f.name === payload.name);
if (templateFunction == null) {
this.#sendEmpty(windowContext, replyId);
return;
}
templateFunction = migrateTemplateFunctionSelectOptions(templateFunction);
// @ts-ignore
delete templateFunction.onRender;
const resolvedArgs: TemplateFunctionArg[] = [];
for (const arg of templateFunction.args) {
if (arg && 'dynamic' in arg) {
const dynamicAttrs = await arg.dynamic(ctx, payload);
const { dynamic, ...other } = arg;
resolvedArgs.push({ ...other, ...dynamicAttrs } as TemplateFunctionArg);
} else if (arg) {
resolvedArgs.push(arg);
}
templateFunction.args = resolvedArgs;
}
const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId,
function: templateFunction,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
@@ -454,6 +489,8 @@ export class PluginInstance {
show: async (args) => {
await this.#sendAndWaitForReply(windowContext, {
type: 'show_toast_request',
// Handle default here because null/undefined both convert to None in Rust translation
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},

View File

@@ -1,6 +1,8 @@
import { TemplateFunction } from '@yaakapp/api';
import { TemplateFunctionPlugin } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
export function migrateTemplateFunctionSelectOptions(f: TemplateFunction): TemplateFunction {
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === 'select') {
a.options = a.options.map((o) => ({

View File

@@ -0,0 +1,49 @@
# AWS Signature Version 4 Auth
A plugin for authenticating AWS-compatible requests using the
[AWS Signature Version 4 signing process](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
This enables secure, signed requests to AWS services (or any S3-compatible APIs like
Cloudflare R2).
![Screenshot of AWS SigV4 UI](screenshot.png)
## Overview
This plugin provides AWS Signature authentication for API requests in Yaak. SigV4 is used
by nearly all AWS APIs to verify the authenticity and integrity of requests using
cryptographic signatures.
With this plugin, you can securely sign requests to AWS services such as S3, STS, Lambda,
API Gateway, DynamoDB, and more. You can also authenticate against S3-compatible services
like **Cloudflare R2**, **MinIO**, or **Wasabi**.
## How AWS Signature Version 4 Works
SigV4 signs requests by creating a hash of key request components (method, URL, headers,
and optionally the payload) using your AWS credentials. The resulting HMAC signature is
added in the `Authorization` header along with credential scope metadata.
Example header:
```
Authorization: AWS4-HMAC-SHA256 Credential=AKIA…/20251011/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abcdef123456…
```
Each request must include a timestamp (`X-Amz-Date`) and may include a session token if
using temporary credentials.
## Configuration
The plugin presents the following fields:
- **Access Key ID** Your AWS access key identifier
- **Secret Access Key** The secret associated with the access key
- **Session Token** *(optional)* Used for temporary or assumed-role credentials (treated as secret)
- **Region** AWS region (e.g., `us-east-1`)
- **Service** AWS service identifier (e.g., `sts`, `s3`, `execute-api`)
## Usage
1. Configure a request, folder, or workspace to use **AWS SigV4 Authentication**
2. Enter your AWS credentials and target service/region
3. The plugin will automatically sign outgoing requests with valid SigV4 headers

View File

@@ -0,0 +1,23 @@
{
"name": "@yaak/auth-aws",
"displayName": "AWS SigV4",
"description": "Authenticate requests using AWS SigV4 signing",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-aws"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"aws4": "^1.13.2"
},
"devDependencies": {
"@types/aws4": "^1.11.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

View File

@@ -0,0 +1,97 @@
import type { CallHttpAuthenticationResponse } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from '@yaakapp/api';
import aws4 from 'aws4';
import type { Request } from 'aws4';
import { URL } from 'node:url';
export const plugin: PluginDefinition = {
authentication: {
name: 'auth-aws-sig-v4',
label: 'AWS Signature',
shortLabel: 'AWS v4',
args: [
{ name: 'accessKeyId', label: 'Access Key ID', type: 'text', password: true },
{
name: 'secretAccessKey',
label: 'Secret Access Key',
type: 'text',
password: true,
},
{
name: 'service',
label: 'Service Name',
type: 'text',
defaultValue: 'sts',
placeholder: 'sts',
description: 'The service that is receiving the request (sts, s3, sqs, ...)',
},
{
name: 'region',
label: 'Region',
type: 'text',
placeholder: 'us-east-1',
description: 'The region that is receiving the request (defaults to us-east-1)',
optional: true,
},
{
name: 'sessionToken',
label: 'Session Token',
type: 'text',
password: true,
optional: true,
description: 'Only required if you are using temporary credentials',
},
],
onApply(_ctx, { values, ...args }): CallHttpAuthenticationResponse {
const accessKeyId = String(values.accessKeyId || '');
const secretAccessKey = String(values.secretAccessKey || '');
const sessionToken = String(values.sessionToken || '') || undefined;
const url = new URL(args.url);
const headers: NonNullable<Request['headers']> = {};
for (const headerName of ['content-type', 'host', 'x-amz-date', 'x-amz-security-token']) {
const v = args.headers.find((h) => h.name.toLowerCase() === headerName);
if (v != null) {
headers[headerName] = v.value;
}
}
// TODO: Support body signing here
headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD';
const signature = aws4.sign(
{
host: url.host,
method: args.method,
path: url.pathname + (url.search || '') || undefined,
service: String(values.service || 'sts') || undefined,
region: String(values.region || 'us-east-1') || undefined,
headers,
},
{
accessKeyId,
secretAccessKey,
sessionToken,
},
);
// After signing, aws4 will set:
// - opts.headers["Authorization"]
// - opts.headers["X-Amz-Date"]
// - optionally content sha256 header etc
console.log('ADDING STUFF', signature);
if (signature.headers == null) {
return {};
}
return {
setHeaders: Object.entries(signature.headers)
.filter(([name]) => name !== 'content-type') // Don't add this because we already have it
.map(([name, value]) => ({ name, value: String(value || '') })),
};
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -271,6 +271,12 @@ export const plugin: PluginDefinition = {
label: 'Advanced',
inputs: [
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{
type: 'text',
name: 'headerName',
label: 'Header Name',
defaultValue: 'Authorization',
},
{
type: 'text',
name: 'headerPrefix',
@@ -397,15 +403,9 @@ export const plugin: PluginDefinition = {
throw new Error('Invalid grant type ' + grantType);
}
const headerName = stringArg(values, 'headerName') || 'Authorization';
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
return {
setHeaders: [
{
name: 'Authorization',
value: headerValue,
},
],
};
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
};

View File

@@ -68,11 +68,11 @@ export function migrateImport(contents: string) {
// Migrate v4 to v5
for (const environment of parsed.resources.environments ?? []) {
if ('base' in environment && environment.base) {
if ('base' in environment && environment.base && environment.parentModel == null) {
environment.parentModel = 'workspace';
environment.parentId = null;
delete environment.base;
} else if ('base' in environment && !environment.base) {
} else if ('base' in environment && !environment.base && environment.parentModel == null) {
environment.parentModel = 'environment';
environment.parentId = null;
delete environment.base;

View File

@@ -1,4 +1,4 @@
import type { HttpUrlParameter } from '@yaakapp-internal/models';
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
@@ -96,5 +96,59 @@ export const plugin: PluginDefinition = {
return renderedValue;
},
},
{
name: 'request.name',
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
return resolvedModelName(httpRequest);
},
},
],
};
// TODO: Use a common function for this, but it fails to build on windows during CI if I try importing it here
export function resolvedModelName(r: AnyModel | null): string {
if (r == null) return '';
if (!('url' in r) || r.model === 'plugin') {
return 'name' in r ? r.name : '';
}
// Return name if it has one
if ('name' in r && r.name) {
return r.name;
}
// Replace variable syntax with variable name
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, '$1');
if (withoutVariables.trim() === '') {
return r.model === 'http_request'
? r.bodyType && r.bodyType === 'graphql'
? 'GraphQL Request'
: 'HTTP Request'
: r.model === 'websocket_request'
? 'WebSocket Request'
: 'gRPC Request';
}
// GRPC gets nice short names
if (r.model === 'grpc_request' && r.service != null && r.method != null) {
const shortService = r.service.split('.').pop();
return `${shortService}/${r.method}`;
}
// Strip unnecessary protocol
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, '');
return withoutProto;
}

View File

@@ -3,25 +3,44 @@ import type {
CallTemplateFunctionArgs,
Context,
FormInput,
GetHttpAuthenticationConfigRequest,
HttpResponse,
PluginDefinition,
RenderPurpose,
} from '@yaakapp/api';
import type { DynamicTemplateFunctionArg } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import { JSONPath } from 'jsonpath-plus';
import { readFileSync } from 'node:fs';
import xpath from 'xpath';
const BEHAVIOR_TTL = 'ttl';
const BEHAVIOR_ALWAYS = 'always';
const BEHAVIOR_SMART = 'smart';
const behaviorArg: FormInput = {
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
defaultValue: 'smart',
options: [
{ label: 'When no responses', value: 'smart' },
{ label: 'Always', value: 'always' },
{ label: 'When no responses', value: BEHAVIOR_SMART },
{ label: 'Always', value: BEHAVIOR_ALWAYS },
{ label: 'When expired', value: BEHAVIOR_TTL },
],
};
const ttlArg: DynamicTemplateFunctionArg = {
type: 'text',
name: 'ttl',
label: 'Expiration Time (seconds)',
placeholder: '0',
description: 'Resend the request when the latest response is older than this many seconds, or if there are no responses yet.',
dynamic(_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) {
const show = values.behavior === BEHAVIOR_TTL;
return { hidden: !show };
},
};
const requestArg: FormInput = {
type: 'http_request',
name: 'request',
@@ -42,6 +61,7 @@ export const plugin: PluginDefinition = {
placeholder: 'Content-Type',
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.header) return null;
@@ -50,6 +70,7 @@ export const plugin: PluginDefinition = {
requestId: String(args.values.request || ''),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
if (response == null) return null;
@@ -72,6 +93,7 @@ export const plugin: PluginDefinition = {
placeholder: '$.books[0].id or /books[0]/id',
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.path) return null;
@@ -80,6 +102,7 @@ export const plugin: PluginDefinition = {
requestId: String(args.values.request || ''),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
if (response == null) return null;
@@ -113,7 +136,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.raw',
description: 'Access the entire response body, as text',
aliases: ['response'],
args: [requestArg, behaviorArg],
args: [requestArg, behaviorArg, ttlArg],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request) return null;
@@ -121,6 +144,7 @@ export const plugin: PluginDefinition = {
requestId: String(args.values.request || ''),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
if (response == null) return null;
@@ -177,9 +201,11 @@ async function getResponse(
requestId,
behavior,
purpose,
ttl,
}: {
requestId: string;
behavior: string | null;
ttl: string | null;
purpose: RenderPurpose;
},
): Promise<HttpResponse | null> {
@@ -203,7 +229,11 @@ async function getResponse(
const finalBehavior = behavior === 'always' && purpose === 'preview' ? 'smart' : behavior;
// Send if no responses and "smart," or "always"
if ((finalBehavior === 'smart' && response == null) || finalBehavior === 'always') {
if (
(finalBehavior === 'smart' && response == null) ||
finalBehavior === 'always' ||
(finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl))
) {
// NOTE: Render inside this conditional, or we'll get infinite recursion (render->render->...)
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
@@ -211,3 +241,12 @@ async function getResponse(
return response;
}
function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {
if (response == null) return true;
const ttlSeconds = parseInt(ttl || '0');
if (isNaN(ttlSeconds)) throw new Error(`Invalid TTL "${ttl}"`);
const nowMillis = Date.now();
const respMillis = new Date(response.createdAt + 'Z').getTime();
return respMillis + ttlSeconds * 1000 < nowMillis;
}

View File

@@ -155,7 +155,7 @@ export function formatDatetime(args: {
format?: string;
in?: ContextFn<Date>;
}): string {
const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args;
const { date, format } = args;
const d = parseDateString(date ?? '');
return formatDate(d, String(format), { in: args.in });
return formatDate(d, String(format || 'yyyy-MM-dd HH:mm:ss'), { in: args.in });
}

1
src-tauri/Cargo.lock generated
View File

@@ -8102,6 +8102,7 @@ name = "yaak-templates"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"log",
"serde",
"serde-wasm-bindgen",
"serde_json",

View File

@@ -35,6 +35,9 @@ pub enum Error {
#[error(transparent)]
CommonError(#[from] yaak_common::error::Error),
#[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
#[error("Updater error: {0}")]
UpdaterError(#[from] tauri_plugin_updater::Error),

View File

@@ -32,6 +32,7 @@ use yaak_plugins::events::{
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>,
@@ -76,7 +77,11 @@ pub async fn send_http_request<R: Runtime>(
RenderPurpose::Send,
);
let request = match render_http_request(&resolved_request, environment_chain, &cb).await {
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let request = match render_http_request(&resolved_request, environment_chain, &cb, &opt).await {
Ok(r) => r,
Err(e) => {
return Ok(response_err(

View File

@@ -1,6 +1,7 @@
extern crate core;
use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
use crate::http_request::{resolve_http_request, send_http_request};
use crate::import::import_data;
@@ -37,19 +38,13 @@ use yaak_models::models::{
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{
CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs,
CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose, ShowToastRequest,
};
use yaak_plugins::events::{CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionSummaryResponse, GetTemplateFunctionConfigResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose, ShowToastRequest};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format::format_json;
use yaak_templates::{Tokens, transform_args};
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
mod commands;
mod encoding;
@@ -126,6 +121,9 @@ async fn cmd_render_template<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Preview,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
Ok(result)
@@ -167,6 +165,9 @@ async fn cmd_grpc_reflect<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
@@ -213,6 +214,9 @@ async fn cmd_grpc_go<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
@@ -335,6 +339,9 @@ async fn cmd_grpc_go<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await
.expect("Failed to render template")
@@ -404,6 +411,9 @@ async fn cmd_grpc_go<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
@@ -811,11 +821,36 @@ async fn cmd_grpc_request_actions<R: Runtime>(
}
#[tauri::command]
async fn cmd_template_functions<R: Runtime>(
async fn cmd_template_function_summaries<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetTemplateFunctionsResponse>> {
Ok(plugin_manager.get_template_functions(&window).await?)
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
let results = plugin_manager.get_template_function_summaries(&window).await?;
Ok(results)
}
#[tauri::command]
async fn cmd_template_function_config<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
function_name: &str,
values: HashMap<String, JsonPrimitive>,
model: AnyModel,
environment_id: Option<&str>,
) -> YaakResult<GetTemplateFunctionConfigResponse> {
let (workspace_id, folder_id) = match model.clone() {
AnyModel::HttpRequest(m) => (m.workspace_id, m.folder_id),
AnyModel::GrpcRequest(m) => (m.workspace_id, m.folder_id),
AnyModel::WebsocketRequest(m) => (m.workspace_id, m.folder_id),
AnyModel::Folder(m) => (m.workspace_id, m.folder_id),
AnyModel::Workspace(m) => (m.id, None),
m => {
return Err(GenericError(format!("Unsupported model to call template functions {m:?}")));
}
};
let environment_chain =
window.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
Ok(plugin_manager.get_template_function_config(&window, function_name, environment_chain, values, model.id()).await?)
}
#[tauri::command]
@@ -833,10 +868,10 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
plugin_manager: State<'_, PluginManager>,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
request: AnyModel,
model: AnyModel,
environment_id: Option<&str>,
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
let (workspace_id, folder_id) = match request.clone() {
let (workspace_id, folder_id) = match model.clone() {
AnyModel::HttpRequest(m) => (m.workspace_id, m.folder_id),
AnyModel::GrpcRequest(m) => (m.workspace_id, m.folder_id),
AnyModel::WebsocketRequest(m) => (m.workspace_id, m.folder_id),
@@ -851,7 +886,7 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
window.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
Ok(plugin_manager
.get_http_authentication_config(&window, environment_chain, auth_name, values, request.id())
.get_http_authentication_config(&window, environment_chain, auth_name, values, model.id())
.await?)
}
@@ -991,6 +1026,35 @@ async fn cmd_save_response<R: Runtime>(
Ok(())
}
#[tauri::command]
async fn cmd_send_folder<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
folder_id: &str,
) -> YaakResult<()> {
let requests = app_handle.db().list_http_requests_for_folder_recursive(folder_id)?;
for request in requests {
let app_handle = app_handle.clone();
let window = window.clone();
let environment_id = environment_id.clone();
let cookie_jar_id = cookie_jar_id.clone();
tokio::spawn(async move {
let _ = cmd_send_http_request(
app_handle,
window,
environment_id.as_deref(),
cookie_jar_id.as_deref(),
request,
)
.await;
});
}
Ok(())
}
#[tauri::command]
async fn cmd_send_http_request<R: Runtime>(
app_handle: AppHandle<R>,
@@ -1266,7 +1330,6 @@ pub fn run() {
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
@@ -1284,6 +1347,11 @@ pub fn run() {
builder = builder.plugin(yaak_license::init());
}
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::default().build());
}
builder
.setup(|app| {
{
@@ -1366,7 +1434,9 @@ pub fn run() {
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
cmd_template_functions,
cmd_send_folder,
cmd_template_function_config,
cmd_template_function_summaries,
cmd_template_tokens_to_string,
//
//
@@ -1484,7 +1554,30 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
// We might have recursive back-and-forth calls between app and plugin, so we don't
// want to block here
tauri::async_runtime::spawn(async move {
plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
let ev = plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
let ev = match ev {
Ok(Some(ev)) => ev,
Ok(None) => return,
Err(e) => {
warn!("Failed to handle plugin event: {e:?}");
let _ = app_handle.emit(
"show_toast",
InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: e.to_string(),
color: Some(Color::Danger),
icon: None,
timeout: Some(30000),
}),
);
return;
}
};
let plugin_manager: State<'_, PluginManager> = app_handle.state();
if let Err(e) = plugin_manager.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
});
}
plugin_manager.unsubscribe(rx_id.as_str()).await;
@@ -1519,11 +1612,16 @@ async fn call_frontend<R: Runtime>(
fn get_window_from_window_context<R: Runtime>(
app_handle: &AppHandle<R>,
window_context: &PluginWindowContext,
) -> Option<WebviewWindow<R>> {
) -> Result<WebviewWindow<R>> {
let label = match window_context {
PluginWindowContext::Label { label, .. } => label,
PluginWindowContext::None => {
return app_handle.webview_windows().iter().next().map(|(_, w)| w.to_owned());
return app_handle
.webview_windows()
.iter()
.next()
.map(|(_, w)| w.to_owned())
.ok_or(GenericError("No windows open".to_string()));
}
};
@@ -1536,7 +1634,7 @@ fn get_window_from_window_context<R: Runtime>(
error!("Failed to find window by {window_context:?}");
}
window
Ok(window.ok_or(GenericError(format!("Failed to find window for {}", label)))?)
}
fn workspace_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Workspace> {

View File

@@ -1,3 +1,4 @@
use crate::error::Result;
use crate::http_request::send_http_request;
use crate::render::{render_grpc_request, render_http_request, render_json_value};
use crate::window::{CreateWindowConfig, create_window};
@@ -7,10 +8,12 @@ use crate::{
};
use chrono::Utc;
use cookie::Cookie;
use log::{error, warn};
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use log::error;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use yaak_common::window::WorkspaceWindowTrait;
use yaak_models::models::{HttpResponse, Plugin};
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
@@ -20,108 +23,119 @@ use yaak_plugins::events::{
RenderHttpRequestResponse, SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest,
TemplateRenderResponse, WindowNavigateEvent,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
pub(crate) async fn handle_plugin_event<R: Runtime>(
app_handle: &AppHandle<R>,
event: &InternalEvent,
plugin_handle: &PluginHandle,
) {
) -> Result<Option<InternalEventPayload>> {
// debug!("Got event to app {event:?}");
let window_context = event.window_context.to_owned();
let response_event: Option<InternalEventPayload> = match event.clone().payload {
match event.clone().payload {
InternalEventPayload::CopyTextRequest(req) => {
app_handle
.clipboard()
.write_text(req.text.as_str())
.expect("Failed to write text to clipboard");
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
app_handle.clipboard().write_text(req.text.as_str())?;
Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})))
}
InternalEventPayload::ShowToastRequest(req) => {
match window_context {
PluginWindowContext::Label { label, .. } => app_handle
.emit_to(label, "show_toast", req)
.expect("Failed to emit show_toast to window"),
_ => app_handle.emit("show_toast", req).expect("Failed to emit show_toast"),
PluginWindowContext::Label { label, .. } => {
app_handle.emit_to(label, "show_toast", req)?
}
_ => app_handle.emit("show_toast", req)?,
};
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
Ok(Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})))
}
InternalEventPayload::PromptTextRequest(_) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render");
call_frontend(&window, event).await
let window = get_window_from_window_context(app_handle, &window_context)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle
.db()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
.unwrap_or_default();
Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
Ok(Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
http_responses,
}))
})))
}
InternalEventPayload::GetHttpRequestByIdRequest(req) => {
let http_request = app_handle.db().get_http_request(&req.id).ok();
Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
Ok(Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
http_request,
}))
})))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render grpc request");
let window = get_window_from_window_context(app_handle, &window_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let environment_chain = window
.db()
.resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to resolve environments");
let environment_chain = window.db().resolve_environments(
&workspace.id,
req.grpc_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let grpc_request = render_grpc_request(&req.grpc_request, environment_chain, &cb)
.await
.expect("Failed to render grpc request");
Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let grpc_request =
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
grpc_request,
}))
})))
}
InternalEventPayload::RenderHttpRequestRequest(req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render http request");
let window = get_window_from_window_context(app_handle, &window_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let environment_chain = window
.db()
.resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to resolve environments");
let environment_chain = window.db().resolve_environments(
&workspace.id,
req.http_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let http_request = render_http_request(&req.http_request, environment_chain, &cb)
.await
.expect("Failed to render http request");
Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
let opt = &RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let http_request =
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
http_request,
}))
})))
}
InternalEventPayload::TemplateRenderRequest(req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render");
let window = get_window_from_window_context(app_handle, &window_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let environment_chain = window
.db()
.resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to resolve environments");
let folder_id = if let Some(id) = window.request_id() {
match window.db().get_any_request(&id) {
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
Err(_) => None,
}
} else {
None
};
let environment_chain = window.db().resolve_environments(
&workspace.id,
folder_id.as_deref(),
environment_id.as_deref(),
)?;
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let data = render_json_value(req.data, environment_chain, &cb)
.await
.expect("Failed to render template");
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
}
InternalEventPayload::ErrorResponse(resp) => {
error!("Plugin error: {}: {:?}", resp.error, resp);
@@ -134,16 +148,15 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
resp.error
),
color: Some(Color::Danger),
timeout: None,
timeout: Some(30000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await;
None
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
}
InternalEventPayload::ReloadResponse(req) => {
let plugins = app_handle.db().list_plugins().unwrap();
let plugins = app_handle.db().list_plugins()?;
for plugin in plugins {
if plugin.directory != plugin_handle.dir {
continue;
@@ -153,7 +166,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
updated_at: Utc::now().naive_utc(), // TODO: Add reloaded_at field to use instead
..plugin
};
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin).unwrap();
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
}
if !req.silent {
@@ -163,17 +176,18 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!("Reloaded plugin {}@{}", info.name, info.version),
icon: Some(Icon::Info),
timeout: Some(3000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await;
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
} else {
Ok(None)
}
None
}
InternalEventPayload::SendHttpRequestRequest(req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for sending HTTP request");
let window = get_window_from_window_context(app_handle, &window_context)?;
let mut http_request = req.http_request;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
@@ -187,20 +201,17 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let http_response = if http_request.id.is_empty() {
HttpResponse::default()
} else {
window
.db()
.upsert_http_response(
&HttpResponse {
request_id: http_request.id.clone(),
workspace_id: http_request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::Plugin,
)
.unwrap()
window.db().upsert_http_response(
&HttpResponse {
request_id: http_request.id.clone(),
workspace_id: http_request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::Plugin,
)?
};
let result = send_http_request(
let http_response = send_http_request(
&window,
&http_request,
&http_response,
@@ -208,16 +219,11 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
cookie_jar,
&mut tokio::sync::watch::channel(false).1, // No-op cancel channel
)
.await;
.await?;
let http_response = match result {
Ok(r) => r,
Err(_e) => return,
};
Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response,
}))
})))
}
InternalEventPayload::OpenWindowRequest(req) => {
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
@@ -240,8 +246,8 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle)).await;
return;
return Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle))
.await;
}
{
@@ -277,32 +283,33 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
});
}
None
Ok(None)
}
InternalEventPayload::CloseWindowRequest(req) => {
if let Some(window) = app_handle.webview_windows().get(&req.label) {
window.close().expect("Failed to close window");
window.close()?;
}
None
Ok(None)
}
InternalEventPayload::SetKeyValueRequest(req) => {
let name = plugin_handle.info().name;
app_handle.db().set_plugin_key_value(&name, &req.key, &req.value);
Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {}))
Ok(Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {})))
}
InternalEventPayload::GetKeyValueRequest(req) => {
let name = plugin_handle.info().name;
let value = app_handle.db().get_plugin_key_value(&name, &req.key).map(|v| v.value);
Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value }))
Ok(Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })))
}
InternalEventPayload::DeleteKeyValueRequest(req) => {
let name = plugin_handle.info().name;
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key).unwrap();
Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted }))
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key)?;
Ok(Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse {
deleted,
})))
}
InternalEventPayload::ListCookieNamesRequest(_req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for listing cookies");
let window = get_window_from_window_context(app_handle, &window_context)?;
let names = match cookie_jar_from_window(&window) {
None => Vec::new(),
Some(j) => j
@@ -311,11 +318,12 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
.collect(),
};
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { names }))
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
names,
})))
}
InternalEventPayload::GetCookieValueRequest(req) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for listing cookies");
let window = get_window_from_window_context(app_handle, &window_context)?;
let value = match cookie_jar_from_window(&window) {
None => None,
Some(j) => j.cookies.into_iter().find_map(|c| match Cookie::parse(c.raw_cookie) {
@@ -325,15 +333,8 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
_ => None,
}),
};
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))
}
_ => None,
};
if let Some(e) = response_event {
let plugin_manager: State<'_, PluginManager> = app_handle.state();
if let Err(e) = plugin_manager.reply(&event, &e).await {
warn!("Failed to reply to plugin manager: {:?}", e)
Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })))
}
_ => Ok(None),
}
}

View File

@@ -1,34 +1,37 @@
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
use yaak_http::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{TemplateCallback, parse_and_render, render_json_value_raw};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_template<T: TemplateCallback>(
template: &str,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<String> {
let vars = &make_vars_hashmap(environment_chain);
render(template, vars, cb).await
parse_and_render(template, vars, cb, &opt).await
}
pub async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb).await
render_json_value_raw(value, vars, cb, opt).await
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
@@ -36,18 +39,18 @@ pub async fn render_grpc_request<T: TemplateCallback>(
for p in r.metadata.clone() {
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: render(p.name.as_str(), vars, cb).await?,
value: render(p.value.as_str(), vars, cb).await?,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
let mut authentication = BTreeMap::new();
for (k, v) in r.authentication.clone() {
authentication.insert(k, render_json_value_raw(v, vars, cb).await?);
authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
let url = render(r.url.as_str(), vars, cb).await?;
let url = parse_and_render(r.url.as_str(), vars, cb, &opt).await?;
Ok(GrpcRequest {
url,
@@ -61,6 +64,7 @@ pub async fn render_http_request<T: TemplateCallback>(
r: &HttpRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
@@ -68,8 +72,8 @@ pub async fn render_http_request<T: TemplateCallback>(
for p in r.url_parameters.clone() {
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: render(p.name.as_str(), vars, cb).await?,
value: render(p.value.as_str(), vars, cb).await?,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
@@ -78,23 +82,23 @@ pub async fn render_http_request<T: TemplateCallback>(
for p in r.headers.clone() {
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: render(p.name.as_str(), vars, cb).await?,
value: render(p.value.as_str(), vars, cb).await?,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(k, render_json_value_raw(v, vars, cb).await?);
body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
let mut authentication = BTreeMap::new();
for (k, v) in r.authentication.clone() {
authentication.insert(k, render_json_value_raw(v, vars, cb).await?);
authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
let url = render(r.url.clone().as_str(), vars, cb).await?;
let url = parse_and_render(r.url.clone().as_str(), vars, cb, &opt).await?;
// This doesn't fit perfectly with the concept of "rendering" but it kind of does
let (url, url_parameters) = apply_path_placeholders(&url, url_parameters);
@@ -108,11 +112,3 @@ pub async fn render_http_request<T: TemplateCallback>(
..r.to_owned()
})
}
pub async fn render<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
) -> yaak_templates::error::Result<String> {
parse_and_render(template, vars, cb).await
}

View File

@@ -1,3 +1,4 @@
use crate::error::Result;
use crate::window_menu::app_menu;
use log::{info, warn};
use rand::random;
@@ -6,7 +7,6 @@ use tauri::{
};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc;
use crate::error::Result;
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
@@ -49,7 +49,6 @@ pub(crate) fn create_window<R: Runtime>(
.resizable(true)
.visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme
.fullscreen(false)
.disable_drag_drop_handler() // Required for frontend Dnd on windows
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
if let Some(key) = config.data_dir_key {
@@ -216,10 +215,10 @@ pub(crate) fn create_child_window(
) -> Result<WebviewWindow> {
let app_handle = parent_window.app_handle();
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
let scale_factor = parent_window.scale_factor().unwrap();
let scale_factor = parent_window.scale_factor()?;
let current_pos = parent_window.inner_position().unwrap().to_logical::<f64>(scale_factor);
let current_size = parent_window.inner_size().unwrap().to_logical::<f64>(scale_factor);
let current_pos = parent_window.inner_position()?.to_logical::<f64>(scale_factor);
let current_size = parent_window.inner_size()?.to_logical::<f64>(scale_factor);
// Position the new window in the middle of the parent
let position = (

View File

@@ -5,6 +5,7 @@ pub trait WorkspaceWindowTrait {
fn workspace_id(&self) -> Option<String>;
fn cookie_jar_id(&self) -> Option<String>;
fn environment_id(&self) -> Option<String>;
fn request_id(&self) -> Option<String>;
}
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
@@ -28,4 +29,10 @@ impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
}
fn request_id(&self) -> Option<String> {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
}
}

View File

@@ -81,11 +81,12 @@ export function getAnyModel(id: string): AnyModel | null {
}
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
modelType: M | M[],
modelType: M | ReadonlyArray<M>,
id: string,
): T | null {
let data = mustStore().get(modelStoreDataAtom);
for (const t of Array.isArray(modelType) ? modelType : [modelType]) {
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
for (const t of types) {
let v = data[t][id];
if (v?.model === t) return v as T;
}
@@ -139,7 +140,7 @@ export async function deleteModel<M extends AnyModel['model'], T extends Extract
export function duplicateModelById<
M extends AnyModel['model'],
T extends ExtractModel<AnyModel, M>,
>(modelType: M | M[], id: string) {
>(modelType: M | ReadonlyArray<M>, id: string) {
let model = getModel<M, T>(modelType, id);
return duplicateModel(model);
}
@@ -150,6 +151,8 @@ export function duplicateModel<M extends AnyModel['model'], T extends ExtractMod
if (model == null) {
throw new Error('Failed to delete null model');
}
if ('sortPriority' in model) model.sortPriority = model.sortPriority + 0.0001;
return invoke<string>('plugin:yaak-models|duplicate', { model });
}

View File

@@ -0,0 +1,23 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
GrpcRequest, HttpRequest, WebsocketRequest,
};
pub enum AnyRequest {
HttpRequest(HttpRequest),
GrpcRequest(GrpcRequest),
WebsocketRequest(WebsocketRequest),
}
impl<'a> DbContext<'a> {
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
if let Ok(http_request) = self.get_http_request(id) {
Ok(AnyRequest::HttpRequest(http_request))
} else if let Ok(grpc_request) = self.get_grpc_request(id) {
Ok(AnyRequest::GrpcRequest(grpc_request))
} else {
Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?))
}
}
}

View File

@@ -49,15 +49,7 @@ impl<'a> DbContext<'a> {
info!("Upserted {} websocket_requests", imported_resources.websocket_requests.len());
}
if environments.len() > 0 {
for x in environments {
let x = self.upsert_environment(&x, source)?;
imported_resources.environments.push(x.clone());
}
info!("Upserted {} environments", imported_resources.environments.len());
}
// Do folders last so it doesn't cause the UI to render empty folders before populating
// Do folders after their children so the UI doesn't render empty folders before populating
// immediately after.
if folders.len() > 0 {
for v in folders {
@@ -67,6 +59,15 @@ impl<'a> DbContext<'a> {
info!("Upserted {} folders", imported_resources.folders.len());
}
// Do environments last because they can depend on many models (requests, folders, etc)
if environments.len() > 0 {
for x in environments {
let x = self.upsert_environment(&x, source)?;
imported_resources.environments.push(x.clone());
}
info!("Upserted {} environments", imported_resources.environments.len());
}
Ok(imported_resources)
}
}

View File

@@ -37,8 +37,7 @@ impl<'a> DbContext<'a> {
/// Lists environments and will create a base environment if one doesn't exist
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
let mut environments =
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
let mut environments = self.list_environments_dangerous(workspace_id)?;
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
@@ -59,6 +58,11 @@ impl<'a> DbContext<'a> {
Ok(environments)
}
/// List environments for a workspace. Prefer list_environments_ensure_base()
fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
}
pub fn delete_environment(
&self,
environment: &Environment,
@@ -93,7 +97,7 @@ impl<'a> DbContext<'a> {
return Vec::new();
}
self.list_environments_ensure_base(&environment.workspace_id)
self.list_environments_dangerous(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
@@ -131,8 +135,9 @@ impl<'a> DbContext<'a> {
let mut name = environment.name.clone();
match (environment.parent_model.as_str(), environment.parent_id.as_deref()) {
("folder", Some(folder_id)) => {
let folder = self.get_folder(folder_id)?;
name = format!("{} Environment", folder.name);
if let Ok(folder) = self.get_folder(folder_id) {
name = format!("{} Environment", folder.name);
}
}
_ => {}
}

View File

@@ -1,6 +1,6 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -89,4 +89,18 @@ impl<'a> DbContext<'a> {
Ok(headers)
}
pub fn list_http_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<HttpRequest>> {
let mut children = Vec::new();
for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);
}
for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {
children.push(m);
}
Ok(children)
}
}

View File

@@ -1,3 +1,4 @@
pub mod any_request;
mod batch;
mod cookie_jars;
mod environments;

View File

@@ -361,7 +361,11 @@ export type GetKeyValueRequest = { key: string, };
export type GetKeyValueResponse = { value?: string, };
export type GetTemplateFunctionsResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };
export type GetTemplateFunctionConfigRequest = { contextId: string, name: string, values: { [key in string]?: JsonPrimitive }, };
export type GetTemplateFunctionConfigResponse = { function: TemplateFunction, pluginRefId: string, };
export type GetTemplateFunctionSummaryResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };
export type GetThemesRequest = Record<string, never>;
@@ -385,7 +389,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: PluginWindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;

View File

@@ -99,8 +99,10 @@ pub enum InternalEventPayload {
CallGrpcRequestActionRequest(CallGrpcRequestActionRequest),
// Template Functions
GetTemplateFunctionsRequest,
GetTemplateFunctionsResponse(GetTemplateFunctionsResponse),
GetTemplateFunctionSummaryRequest(EmptyPayload),
GetTemplateFunctionSummaryResponse(GetTemplateFunctionSummaryResponse),
GetTemplateFunctionConfigRequest(GetTemplateFunctionConfigRequest),
GetTemplateFunctionConfigResponse(GetTemplateFunctionConfigResponse),
CallTemplateFunctionRequest(CallTemplateFunctionRequest),
CallTemplateFunctionResponse(CallTemplateFunctionResponse),
@@ -123,6 +125,9 @@ pub enum InternalEventPayload {
RenderGrpcRequestRequest(RenderGrpcRequestRequest),
RenderGrpcRequestResponse(RenderGrpcRequestResponse),
TemplateRenderRequest(TemplateRenderRequest),
TemplateRenderResponse(TemplateRenderResponse),
GetKeyValueRequest(GetKeyValueRequest),
GetKeyValueResponse(GetKeyValueResponse),
SetKeyValueRequest(SetKeyValueRequest),
@@ -135,9 +140,6 @@ pub enum InternalEventPayload {
WindowCloseEvent,
CloseWindowRequest(CloseWindowRequest),
TemplateRenderRequest(TemplateRenderRequest),
TemplateRenderResponse(TemplateRenderResponse),
ShowToastRequest(ShowToastRequest),
ShowToastResponse(EmptyPayload),
@@ -673,11 +675,28 @@ pub struct CallHttpAuthenticationResponse {
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetTemplateFunctionsResponse {
pub struct GetTemplateFunctionSummaryResponse {
pub functions: Vec<TemplateFunction>,
pub plugin_ref_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetTemplateFunctionConfigRequest {
pub context_id: String,
pub name: String,
pub values: HashMap<String, JsonPrimitive>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetTemplateFunctionConfigResponse {
pub function: TemplateFunction,
pub plugin_ref_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]

View File

@@ -10,7 +10,8 @@ use crate::events::{
FilterRequest, FilterResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionsResponse, GetThemesRequest, GetThemesResponse, ImportRequest,
GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse, ImportRequest,
ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext,
RenderPurpose,
};
@@ -39,7 +40,7 @@ use yaak_models::render::make_vars_hashmap;
use yaak_models::util::generate_id;
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
use yaak_templates::render_json_value_raw;
use yaak_templates::{RenderErrorBehavior, RenderOptions, render_json_value_raw};
#[derive(Clone)]
pub struct PluginManager {
@@ -489,35 +490,59 @@ impl PluginManager {
Ok(all_actions)
}
pub async fn get_template_functions<R: Runtime>(
pub async fn get_template_function_config<R: Runtime>(
&self,
window: &WebviewWindow<R>,
) -> Result<Vec<GetTemplateFunctionsResponse>> {
self.get_template_functions_with_context(&PluginWindowContext::new(&window)).await
}
fn_name: &str,
environment_chain: Vec<Environment>,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> Result<GetTemplateFunctionConfigResponse> {
let results = self.get_template_function_summaries(window).await?;
let r = results
.iter()
.find(|r| r.functions.iter().any(|f| f.name == fn_name))
.ok_or_else(|| PluginNotFoundErr(fn_name.into()))?;
let plugin = self
.get_plugin_by_ref_id(&r.plugin_ref_id)
.await
.ok_or_else(|| PluginNotFoundErr(r.plugin_ref_id.clone()))?;
pub async fn get_template_functions_with_context(
&self,
window_context: &PluginWindowContext,
) -> Result<Vec<GetTemplateFunctionsResponse>> {
let reply_events = self
.send_and_wait(window_context, &InternalEventPayload::GetTemplateFunctionsRequest)
let window_context = &PluginWindowContext::new(&window);
let vars = &make_vars_hashmap(environment_chain);
let cb = PluginTemplateCallback::new(
window.app_handle(),
&window_context,
RenderPurpose::Preview,
);
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions {
error_behavior: RenderErrorBehavior::ReturnEmpty,
};
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let event = self
.send_to_plugin_and_wait(
&PluginWindowContext::new(window),
&plugin,
&InternalEventPayload::GetTemplateFunctionConfigRequest(
GetTemplateFunctionConfigRequest {
values: serde_json::from_value(rendered_values)?,
name: fn_name.to_string(),
context_id,
},
),
)
.await?;
let mut result = Vec::new();
for event in reply_events {
if let InternalEventPayload::GetTemplateFunctionsResponse(resp) = event.payload {
result.push(resp.clone());
match event.payload {
InternalEventPayload::GetTemplateFunctionConfigResponse(resp) => Ok(resp),
InternalEventPayload::EmptyResponse(_) => {
Err(PluginErr("Template function plugin returned empty".to_string()))
}
InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),
e => Err(PluginErr(format!("Template function plugin returned invalid event {:?}", e))),
}
// Add Rust-based functions
result.push(GetTemplateFunctionsResponse {
plugin_ref_id: "__NATIVE__".to_string(), // Meh
functions: vec![template_function_secure(), template_function_keyring()],
});
Ok(result)
}
pub async fn call_http_request_action<R: Runtime>(
@@ -587,7 +612,7 @@ impl PluginManager {
environment_chain: Vec<Environment>,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
request_id: &str,
model_id: &str,
) -> Result<GetHttpAuthenticationConfigResponse> {
let results = self.get_http_authentication_summaries(window).await?;
let plugin = results
@@ -601,8 +626,12 @@ impl PluginManager {
&PluginWindowContext::new(&window),
RenderPurpose::Preview,
);
let rendered_values = render_json_value_raw(json!(values), vars, &cb).await?;
let context_id = format!("{:x}", md5::compute(request_id.to_string()));
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions {
error_behavior: RenderErrorBehavior::ReturnEmpty,
};
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let event = self
.send_to_plugin_and_wait(
&PluginWindowContext::new(window),
@@ -643,6 +672,9 @@ impl PluginManager {
&PluginWindowContext::new(&window),
RenderPurpose::Preview,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
let results = self.get_http_authentication_summaries(window).await?;
@@ -713,6 +745,34 @@ impl PluginManager {
}
}
pub async fn get_template_function_summaries<R: Runtime>(
&self,
window: &WebviewWindow<R>,
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
let window_context = PluginWindowContext::new(window);
let reply_events = self
.send_and_wait(
&window_context,
&InternalEventPayload::GetTemplateFunctionSummaryRequest(EmptyPayload {}),
)
.await?;
let mut results = Vec::new();
for event in reply_events {
if let InternalEventPayload::GetTemplateFunctionSummaryResponse(resp) = event.payload {
results.push(resp.clone());
}
}
// Add Rust-based functions
results.push(GetTemplateFunctionSummaryResponse {
plugin_ref_id: "__NATIVE__".to_string(), // Meh
functions: vec![template_function_secure(), template_function_keyring()],
});
Ok(results)
}
pub async fn call_template_function(
&self,
window_context: &PluginWindowContext,

View File

@@ -1,7 +1,7 @@
use crate::error::Error::UnknownModel;
use crate::error::Result;
use chrono::NaiveDateTime;
use log::warn;
use log::{debug, warn};
use serde::{Deserialize, Deserializer, Serialize};
use serde_yaml::{Mapping, Value};
use sha1::{Digest, Sha1};
@@ -84,8 +84,9 @@ impl<'de> Deserialize<'de> for SyncModel {
}
fn migrate_environment(obj: &mut Mapping) {
match obj.get("base") {
Some(Value::Bool(base)) => {
match (obj.get("base"), obj.get("parentModel")) {
(Some(Value::Bool(base)), None) => {
debug!("Migrating legacy environment {:?}", obj.get("id"));
if *base {
obj.insert("parentModel".into(), "workspace".into());
} else {
@@ -220,7 +221,7 @@ impl TryFrom<AnyModel> for SyncModel {
}
#[cfg(test)]
mod placeholder_tests {
mod migration_tests {
use crate::error::Result;
use crate::models::SyncModel;
@@ -271,6 +272,30 @@ color: null
_ => panic!("expected sub environment"),
}
let raw = r#"
type: environment
model: environment
id: ev_fAUS49FUN2
parentId: fld_123
parentModel: folder
workspaceId: wk_kfSI3JDHd7
createdAt: 2025-01-11T17:02:58.012792
updatedAt: 2025-07-23T20:00:46.049649
name: Folder Environment
public: true
base: false
variables: []
color: null
"#;
let m: SyncModel = serde_yaml::from_str(raw)?;
match m {
SyncModel::Environment(env) => {
assert_eq!(env.parent_model, "folder".to_string());
assert_eq!(env.parent_id, Some("fld_123".to_string()));
}
_ => panic!("expected folder environment"),
}
Ok(())
}
}

View File

@@ -19,3 +19,4 @@ tokio = { workspace = true, features = ["macros", "rt"] }
ts-rs = { workspace = true }
wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] }
serde-wasm-bindgen = "0.6.5"
log = "0.4.27"

View File

@@ -1,7 +1,15 @@
export * from './bindings/parser';
import { Tokens } from './bindings/parser';
import { parse_template } from './pkg';
import { escape_template, parse_template, unescape_template } from './pkg';
export function parseTemplate(template: string) {
return parse_template(template) as Tokens;
}
export function escapeTemplate(template: string) {
return escape_template(template) as string;
}
export function unescapeTemplate(template: string) {
return unescape_template(template) as string;
}

View File

@@ -0,0 +1,166 @@
pub fn escape_template(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check if we're at "${["
if i + 2 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' && chars[i + 2] == '[' {
// Count preceding backslashes
let mut backslash_count = 0;
let mut j = i;
while j > 0 && chars[j - 1] == '\\' {
backslash_count += 1;
j -= 1;
}
// If odd number of backslashes, the $ is escaped
// If even number (including 0), the $ is not escaped
let already_escaped = backslash_count % 2 == 1;
if already_escaped {
// Already escaped, just add the current character
result.push(chars[i]);
} else {
// Not escaped, add backslash before $
result.push('\\');
result.push(chars[i]);
}
} else {
result.push(chars[i]);
}
i += 1;
}
result
}
pub fn unescape_template(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check if we're at "\${["
if i + 3 < chars.len()
&& chars[i] == '\\'
&& chars[i + 1] == '$'
&& chars[i + 2] == '{'
&& chars[i + 3] == '['
{
// Count preceding backslashes (before the current backslash)
let mut backslash_count = 0;
let mut j = i;
while j > 0 && chars[j - 1] == '\\' {
backslash_count += 1;
j -= 1;
}
// If even number of preceding backslashes, this backslash escapes the $
// If odd number, this backslash is itself escaped
let escapes_dollar = backslash_count % 2 == 0;
if escapes_dollar {
// Skip the backslash, just add the $
result.push(chars[i + 1]);
i += 1; // Skip the backslash
} else {
// This backslash is escaped itself, keep it
result.push(chars[i]);
}
} else {
result.push(chars[i]);
}
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use crate::escape::{escape_template, unescape_template};
#[test]
fn test_escape_simple() {
let input = r#"${[foo]}"#;
let expected = r#"\${[foo]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_already_escaped() {
let input = r#"\${[bar]}"#;
let expected = r#"\${[bar]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_double_backslash() {
let input = r#"\\${[bar]}"#;
let expected = r#"\\\${[bar]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_escape_with_surrounding_text() {
let input = r#"text ${[var]} more"#;
let expected = r#"text \${[var]} more"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_preserve_already_escaped() {
let input = r#"already \${[escaped]}"#;
let expected = r#"already \${[escaped]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_multiple_occurrences() {
let input = r#"${[one]} and ${[two]}"#;
let expected = r#"\${[one]} and \${[two]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_mixed_escaped_and_unescaped() {
let input = r#"mixed \${[esc]} and ${[unesc]}"#;
let expected = r#"mixed \${[esc]} and \${[unesc]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_unescape_simple() {
let input = r#"\${[foo]}"#;
let expected = r#"${[foo]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_with_text() {
let input = r#"text \${[var]} more"#;
let expected = r#"text ${[var]} more"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_multiple() {
let input = r#"\${[one]} and \${[two]}"#;
let expected = r#"${[one]} and ${[two]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_double_backslash() {
let input = r#"\\\${[bar]}"#;
let expected = r#"\\${[bar]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_plain_text() {
let input = r#"${[foo]}"#;
let expected = r#"${[foo]}"#;
assert_eq!(unescape_template(input), expected);
}
}

View File

@@ -1,7 +1,8 @@
pub mod error;
pub mod escape;
pub mod format;
pub mod parser;
pub mod renderer;
pub mod error;
pub mod wasm;
pub use parser::*;

View File

@@ -170,7 +170,13 @@ impl Parser {
let start_pos = self.pos;
while self.pos < self.chars.len() {
if self.match_str("${[") {
if self.match_str(r#"\\"#) {
// Skip double-escapes so we don't trigger our own escapes in the next case
self.curr_text += r#"\\"#;
} else if self.match_str(r#"\${["#) {
// Unescaped template syntax so we treat it as a string
self.curr_text += "${[";
} else if self.match_str("${[") {
let start_curr = self.pos;
if let Some(t) = self.parse_tag()? {
self.push_token(t);
@@ -490,6 +496,39 @@ mod tests {
use crate::error::Result;
use crate::*;
#[test]
fn escaped() -> Result<()> {
let mut p = Parser::new(r#"\${[ foo ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Raw {
text: "${[ foo ]}".to_string()
},
Token::Eof
]
);
Ok(())
}
#[test]
fn escaped_tricky() -> Result<()> {
let mut p = Parser::new(r#"\\${[ foo ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Raw {
text: r#"\\"#.to_string()
},
Token::Tag {
val: Val::Var { name: "foo".into() }
},
Token::Eof
]
);
Ok(())
}
#[test]
fn var_simple() -> Result<()> {
let mut p = Parser::new("${[ foo ]}");

View File

@@ -1,6 +1,7 @@
use crate::error::Error::{RenderStackExceededError, VariableNotFound};
use crate::error::Result;
use crate::{Parser, Token, Tokens, Val};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
use std::future::Future;
@@ -21,21 +22,22 @@ pub async fn render_json_value_raw<T: TemplateCallback>(
v: serde_json::Value,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
) -> Result<serde_json::Value> {
let v = match v {
serde_json::Value::String(s) => json!(parse_and_render(&s, vars, cb).await?),
serde_json::Value::String(s) => json!(parse_and_render(&s, vars, cb, opt).await?),
serde_json::Value::Array(a) => {
let mut new_a = Vec::new();
for v in a {
new_a.push(Box::pin(render_json_value_raw(v, vars, cb)).await?)
new_a.push(Box::pin(render_json_value_raw(v, vars, cb, opt)).await?)
}
json!(new_a)
}
serde_json::Value::Object(o) => {
let mut new_o = serde_json::Map::new();
for (k, v) in o {
let key = Box::pin(parse_and_render(&k, vars, cb)).await?;
let value = Box::pin(render_json_value_raw(v, vars, cb)).await?;
let key = Box::pin(parse_and_render(&k, vars, cb, opt)).await?;
let value = Box::pin(render_json_value_raw(v, vars, cb, opt)).await?;
new_o.insert(key, value);
}
json!(new_o)
@@ -49,30 +51,55 @@ async fn parse_and_render_at_depth<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
depth: usize,
) -> Result<String> {
let mut p = Parser::new(template);
let tokens = p.parse()?;
render(tokens, vars, cb, depth + 1).await
render(tokens, vars, cb, opt, depth + 1).await
}
pub async fn parse_and_render<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
) -> Result<String> {
parse_and_render_at_depth(template, vars, cb, 1).await
parse_and_render_at_depth(template, vars, cb, opt, 1).await
}
pub enum RenderErrorBehavior {
Throw,
ReturnEmpty,
}
pub struct RenderOptions {
pub error_behavior: RenderErrorBehavior,
}
impl RenderErrorBehavior {
pub fn handle(&self, r: Result<String>) -> Result<String> {
match (self, r) {
(_, Ok(v)) => Ok(v),
(RenderErrorBehavior::Throw, Err(e)) => Err(e),
(RenderErrorBehavior::ReturnEmpty, Err(e)) => {
warn!("Error rendering string: {}", e);
Ok("".to_string())
}
}
}
}
pub async fn render<T: TemplateCallback>(
tokens: Tokens,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
mut depth: usize,
) -> Result<String> {
depth += 1;
if depth > MAX_DEPTH {
return Err(RenderStackExceededError);
return opt.error_behavior.handle(Err(RenderStackExceededError));
}
let mut doc_str: Vec<String> = Vec::new();
@@ -80,7 +107,10 @@ pub async fn render<T: TemplateCallback>(
for t in tokens.tokens {
match t {
Token::Raw { text } => doc_str.push(text),
Token::Tag { val } => doc_str.push(render_value(val, &vars, cb, depth).await?),
Token::Tag { val } => {
let val = render_value(val, &vars, cb, opt, depth).await;
doc_str.push(opt.error_behavior.handle(val)?)
}
Token::Eof => {}
}
}
@@ -92,16 +122,17 @@ async fn render_value<T: TemplateCallback>(
val: Val,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
depth: usize,
) -> Result<String> {
let v = match val {
Val::Str { text } => {
let r = Box::pin(parse_and_render_at_depth(&text, vars, cb, depth)).await?;
let r = Box::pin(parse_and_render_at_depth(&text, vars, cb, opt, depth)).await?;
r.to_string()
}
Val::Var { name } => match vars.get(name.as_str()) {
Some(v) => {
let r = Box::pin(parse_and_render_at_depth(v, vars, cb, depth)).await?;
let r = Box::pin(parse_and_render_at_depth(v, vars, cb, opt, depth)).await?;
r.to_string()
}
None => return Err(VariableNotFound(name)),
@@ -113,13 +144,13 @@ async fn render_value<T: TemplateCallback>(
Val::Bool { value } => serde_json::Value::Bool(value),
Val::Null => serde_json::Value::Null,
_ => serde_json::Value::String(
Box::pin(render_value(a.value, vars, cb, depth)).await?,
Box::pin(render_value(a.value, vars, cb, opt, depth)).await?,
),
};
resolved_args.insert(a.name, v);
}
let result = cb.run(name.as_str(), resolved_args.clone()).await?;
Box::pin(parse_and_render_at_depth(&result, vars, cb, depth)).await?
Box::pin(parse_and_render_at_depth(&result, vars, cb, opt, depth)).await?
}
Val::Bool { value } => value.to_string(),
Val::Null => "".into(),
@@ -163,7 +194,10 @@ mod parse_and_render_tests {
let template = "";
let vars = HashMap::new();
let result = "";
assert_eq!(parse_and_render(template, &vars, &empty_cb).await?, result.to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
@@ -173,7 +207,10 @@ mod parse_and_render_tests {
let template = "Hello World!";
let vars = HashMap::new();
let result = "Hello World!";
assert_eq!(parse_and_render(template, &vars, &empty_cb).await?, result.to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
@@ -183,7 +220,10 @@ mod parse_and_render_tests {
let template = "${[ foo ]}";
let vars = HashMap::from([("foo".to_string(), "bar".to_string())]);
let result = "bar";
assert_eq!(parse_and_render(template, &vars, &empty_cb).await?, result.to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
@@ -197,7 +237,10 @@ mod parse_and_render_tests {
vars.insert("baz".to_string(), "baz".to_string());
let result = "foo: bar: baz";
assert_eq!(parse_and_render(template, &vars, &empty_cb).await?, result.to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
@@ -206,9 +249,11 @@ mod parse_and_render_tests {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let vars = HashMap::new();
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(
parse_and_render(template, &vars, &empty_cb).await,
parse_and_render(template, &vars, &empty_cb, &opt).await,
Err(VariableNotFound("foo".to_string()))
);
Ok(())
@@ -220,9 +265,11 @@ mod parse_and_render_tests {
let template = "${[ foo ]}";
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "${[ foo ]}".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(
parse_and_render(template, &vars, &empty_cb).await,
parse_and_render(template, &vars, &empty_cb, &opt).await,
Err(RenderStackExceededError)
);
Ok(())
@@ -234,7 +281,10 @@ mod parse_and_render_tests {
let template = "hello ${[ word ]} world!";
let vars = HashMap::from([("word".to_string(), "cruel".to_string())]);
let result = "hello cruel world!";
assert_eq!(parse_and_render(template, &vars, &empty_cb).await?, result.to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
@@ -243,6 +293,9 @@ mod parse_and_render_tests {
let vars = HashMap::new();
let template = r#"${[ say_hello(a='John', b='Kate') ]}"#;
let result = r#"say_hello: 2, Some(String("John")) Some(String("Kate"))"#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
@@ -263,7 +316,7 @@ mod parse_and_render_tests {
Ok(arg_value.to_string())
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result);
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result);
Ok(())
}
@@ -272,6 +325,9 @@ mod parse_and_render_tests {
let vars = HashMap::new();
let template = r#"${[ upper(foo='bar') ]}"#;
let result = r#""BAR""#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(
@@ -296,7 +352,7 @@ mod parse_and_render_tests {
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
@@ -306,9 +362,16 @@ mod parse_and_render_tests {
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo=b64'Zm9vICdiYXInIGJheg') ]}"#;
let result = r#""FOO 'BAR' BAZ""#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
@@ -325,7 +388,7 @@ mod parse_and_render_tests {
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
@@ -335,9 +398,17 @@ mod parse_and_render_tests {
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo='${[ foo ]}') ]}"#;
let result = r#""BAR""#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
@@ -355,7 +426,7 @@ mod parse_and_render_tests {
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
@@ -365,9 +436,17 @@ mod parse_and_render_tests {
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ no_op(inner='${[ foo ]}') ]}"#;
let result = r#""bar""#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"no_op" => args["inner"].to_string(),
_ => "".to_string(),
@@ -384,7 +463,7 @@ mod parse_and_render_tests {
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
@@ -393,9 +472,17 @@ mod parse_and_render_tests {
let vars = HashMap::new();
let template = r#"${[ upper(foo=secret()) ]}"#;
let result = r#""ABC""#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
@@ -412,8 +499,7 @@ mod parse_and_render_tests {
Ok(arg_value.to_string())
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
@@ -421,10 +507,17 @@ mod parse_and_render_tests {
async fn render_fn_err() -> Result<()> {
let vars = HashMap::new();
let template = r#"hello ${[ error() ]}"#;
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, _fn_name: &str, _args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
_fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Err(RenderError("Failed to do it!".to_string()))
}
@@ -439,7 +532,7 @@ mod parse_and_render_tests {
}
assert_eq!(
parse_and_render(template, &vars, &CB {}).await,
parse_and_render(template, &vars, &CB {}, &opt).await,
Err(RenderError("Failed to do it!".to_string()))
);
Ok(())
@@ -449,14 +542,21 @@ mod parse_and_render_tests {
#[cfg(test)]
mod render_json_value_raw_tests {
use crate::error::Result;
use crate::{TemplateCallback, render_json_value_raw};
use crate::{
RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,
render_json_value_raw,
};
use serde_json::json;
use std::collections::HashMap;
struct EmptyCB {}
impl TemplateCallback for EmptyCB {
async fn run(&self, _fn_name: &str, _args: HashMap<String, serde_json::Value>) -> Result<String> {
async fn run(
&self,
_fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> Result<String> {
todo!()
}
@@ -475,8 +575,11 @@ mod render_json_value_raw_tests {
let v = json!("${[a]}");
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(render_json_value_raw(v, &vars, &EmptyCB {}).await?, json!("aaa"));
assert_eq!(render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?, json!("aaa"));
Ok(())
}
@@ -485,8 +588,11 @@ mod render_json_value_raw_tests {
let v = json!(["${[a]}", "${[a]}"]);
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let result = render_json_value_raw(v, &vars, &EmptyCB {}).await?;
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, json!(["aaa", "aaa"]));
Ok(())
@@ -497,8 +603,11 @@ mod render_json_value_raw_tests {
let v = json!({"${[a]}": "${[a]}"});
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let result = render_json_value_raw(v, &vars, &EmptyCB {}).await?;
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, json!({"aaa": "aaa"}));
Ok(())
@@ -516,8 +625,11 @@ mod render_json_value_raw_tests {
]);
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
let result = render_json_value_raw(v, &vars, &EmptyCB {}).await?;
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(
result,
json!([
@@ -532,4 +644,17 @@ mod render_json_value_raw_tests {
Ok(())
}
#[tokio::test]
async fn render_opt_return_empty() -> Result<()> {
let vars = HashMap::new();
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::ReturnEmpty,
};
let result = parse_and_render("DNE: ${[hello]}", &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, "DNE: ".to_string());
Ok(())
}
}

View File

@@ -1,5 +1,5 @@
use crate::error::Result;
use crate::Parser;
use crate::{escape, Parser};
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;
@@ -7,4 +7,16 @@ use wasm_bindgen::JsValue;
pub fn parse_template(template: &str) -> Result<JsValue> {
let tokens = Parser::new(template).parse()?;
Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())
}
}
#[wasm_bindgen]
pub fn escape_template(template: &str) -> Result<JsValue> {
let escaped = escape::escape_template(template);
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
}
#[wasm_bindgen]
pub fn unescape_template(template: &str) -> Result<JsValue> {
let escaped = escape::unescape_template(template);
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
}

View File

@@ -22,6 +22,7 @@ use yaak_plugins::events::{
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
#[tauri::command]
pub(crate) async fn upsert_request<R: Runtime>(
@@ -126,6 +127,9 @@ pub(crate) async fn send<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;
@@ -202,6 +206,9 @@ pub(crate) async fn connect<R: Runtime>(
&PluginWindowContext::new(&window),
RenderPurpose::Send,
),
&RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
},
)
.await?;

View File

@@ -2,12 +2,13 @@ use crate::error::Result;
use std::collections::BTreeMap;
use yaak_models::models::{Environment, HttpRequestHeader, WebsocketRequest};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback};
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions, TemplateCallback};
pub async fn render_websocket_request<T: TemplateCallback>(
r: &WebsocketRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> Result<WebsocketRequest> {
let vars = &make_vars_hashmap(environment_chain);
@@ -15,20 +16,20 @@ pub async fn render_websocket_request<T: TemplateCallback>(
for p in r.headers.clone() {
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(&p.name, vars, cb).await?,
value: parse_and_render(&p.value, vars, cb).await?,
name: parse_and_render(&p.name, vars, cb, opt).await?,
value: parse_and_render(&p.value, vars, cb, opt).await?,
id: p.id,
})
}
let mut authentication = BTreeMap::new();
for (k, v) in r.authentication.clone() {
authentication.insert(k, render_json_value_raw(v, vars, cb).await?);
authentication.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
let url = parse_and_render(r.url.as_str(), vars, cb).await?;
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
let message = parse_and_render(&r.message.clone(), vars, cb).await?;
let message = parse_and_render(&r.message.clone(), vars, cb, opt).await?;
Ok(WebsocketRequest {
url,

View File

@@ -5,7 +5,7 @@ import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createEnvironmentAndActivate = createFastMutation<
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
@@ -46,7 +46,6 @@ export const createEnvironmentAndActivate = createFastMutation<
return; // Was not created
}
console.log('NAVIGATING', jotaiStore.get(activeWorkspaceIdAtom), environmentId);
setWorkspaceSearchParams({ environment_id: environmentId });
},
});

View File

@@ -0,0 +1,28 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import React from 'react';
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
export const moveToWorkspace = createFastMutation({
mutationKey: ['move_workspace'],
mutationFn: async (request: HttpRequest | GrpcRequest | WebsocketRequest) => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return;
showDialog({
id: 'change-workspace',
title: 'Move Workspace',
size: 'sm',
render: ({ hide }) => (
<MoveToWorkspaceDialog
onDone={hide}
request={request}
activeWorkspaceId={activeWorkspaceId}
/>
),
});
},
});

View File

@@ -1,11 +1,21 @@
import { getModel } from '@yaakapp-internal/models';
import { Icon } from '../components/core/Icon';
import { HStack } from '../components/core/Stacks';
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
const folder = getModel('folder', folderId);
showDialog({
id: 'folder-settings',
title: 'Folder Settings',
title: (
<HStack space={2} alignItems="center">
<Icon icon="folder_cog" size="xl" color="secondary" />
{resolvedModelName(folder)}
</HStack>
),
size: 'lg',
className: 'h-[50rem]',
noPadding: true,

View File

@@ -5,7 +5,7 @@ import { useAtomValue } from 'jotai';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFolder } from '../commands/commands';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { openSettings } from '../commands/openSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
@@ -16,8 +16,8 @@ import { useAllRequests } from '../hooks/useAllRequests';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
@@ -61,6 +61,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const activeEnvironment = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions();
const grpcRequestActions = useGrpcRequestActions();
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const workspaces = useAtomValue(workspacesAtom);
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
@@ -90,7 +91,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
onSelect: createWorkspace,
},
{
key: 'http_request.create',
key: 'model.create',
label: 'Create HTTP Request',
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
},
@@ -130,7 +131,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: 'environment.create',
label: 'Create Environment',
onSelect: () => createEnvironmentAndActivate.mutate(baseEnvironment),
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
},
{
key: 'sidebar.toggle',
@@ -142,8 +143,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
if (activeRequest?.model === 'http_request') {
commands.push({
key: 'http_request.send',
action: 'http_request.send',
key: 'request.send',
action: 'request.send',
label: 'Send Request',
onSelect: () => sendRequest(activeRequest.id),
});
@@ -157,6 +158,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}
}
if (activeRequest?.model === 'grpc_request') {
for (let i = 0; i < grpcRequestActions.length; i++) {
const a = grpcRequestActions[i]!;
commands.push({
key: `grpc_request_action.${i}`,
label: a.label,
onSelect: () => a.call(activeRequest),
});
}
}
if (activeRequest != null) {
commands.push({
key: 'http_request.rename',
@@ -182,6 +194,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
activeRequest,
baseEnvironment,
createWorkspace,
grpcRequestActions,
httpRequestActions,
sendRequest,
setSidebarHidden,
@@ -369,7 +382,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
setSelectedItemKey(next?.key ?? null);
@@ -417,9 +429,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
rightSlot={
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
}
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
>
{v.label}
</CommandPaletteItem>
@@ -465,13 +475,6 @@ function CommandPaletteItem({
);
}
function CommandPaletteAction({
action,
onAction,
}: {
action: HotkeyAction;
onAction: () => void;
}) {
useHotKey(action, onAction);
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
return <HotKey className="ml-auto" action={action} />;
}

View File

@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useState } from 'react';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { isBaseEnvironment } from '../lib/model_util';
@@ -42,7 +42,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createEnvironmentAndActivate.mutateAsync(baseEnvironment);
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
if (id != null) setSelectedEnvironmentId(id);
};

View File

@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2">
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>

View File

@@ -0,0 +1,192 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { foldersAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import { useCallback, useMemo } from 'react';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
import { router } from '../lib/router';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { HttpResponsePane } from './HttpResponsePane';
interface Props {
folder: Folder;
style: CSSProperties;
}
export function FolderLayout({ folder, style }: Props) {
const folders = useAtomValue(foldersAtom);
const requests = useAtomValue(allRequestsAtom);
const children = useMemo(() => {
return [
...folders.filter((f) => f.folderId === folder.id),
...requests.filter((r) => r.folderId === folder.id),
];
}, [folder.id, folders, requests]);
return (
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
<HStack space={2} alignItems="center">
<Icon icon="folder" size="xl" color="secondary" />
<Heading level={1}>{resolvedModelName(folder)}</Heading>
<HStack className="ml-auto" alignItems="center">
<Button rightSlot={<Icon icon="send_horizontal" />} color="secondary" size="sm" variant="border">Send All</Button>
</HStack>
</HStack>
<Separator className="mt-3 mb-8" />
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
{children.map((child) => (
<ChildCard key={child.id} child={child} />
))}
</div>
</div>
);
}
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
let card;
if (child.model === 'folder') {
card = <FolderCard folder={child} />;
} else if (child.model === 'http_request') {
card = <HttpRequestCard request={child} />;
} else if (child.model === 'grpc_request') {
card = <RequestCard request={child} />;
} else if (child.model === 'websocket_request') {
card = <RequestCard request={child} />;
} else {
card = <div>Unknown model {child['model']}</div>;
}
const navigate = useCallback(async () => {
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: child.workspaceId },
search: (prev) => ({ ...prev, request_id: child.id }),
});
}, [child.id, child.workspaceId]);
return (
<div
className={classNames(
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border',
'flex flex-col gap-3',
)}
>
<HStack space={2}>
{child.model === 'folder' && <Icon icon="folder" size="lg" />}
<Heading className="truncate" level={2}>
{resolvedModelName(child)}
</Heading>
<HStack space={0.5} className="ml-auto -mr-1.5">
<IconButton
color="custom"
title="Send Request"
size="sm"
icon="external_link"
className="opacity-70 hover:opacity-100"
onClick={navigate}
/>
<IconButton
color="custom"
title="Send Request"
size="sm"
icon="send_horizontal"
className="opacity-70 hover:opacity-100"
onClick={() => {
sendAnyHttpRequest.mutate(child.id);
}}
/>
</HStack>
</HStack>
<div className="text-text-subtle">{card}</div>
</div>
);
}
function FolderCard({ folder }: { folder: Folder }) {
return (
<div>
<Button
color="primary"
onClick={async () => {
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: folder.workspaceId },
search: (prev) => {
return { ...prev, request_id: null, folder_id: folder.id };
},
});
}}
>
Open
</Button>
</div>
);
}
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
return <div>TODO {request.id}</div>;
}
function HttpRequestCard({ request }: { request: HttpRequest }) {
const latestResponse = useLatestHttpResponse(request.id);
return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url}
</code>
{latestResponse ? (
<button
className="block mr-auto"
type="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
showDialog({
id: 'response-preview',
title: 'Response Preview',
size: 'md',
className: 'h-full',
render: () => {
return <HttpResponsePane activeRequestId={request.id} />;
},
});
}}
>
<HStack
space={2}
alignItems="center"
className={classNames(
'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
'font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full',
)}
>
{latestResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={latestResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={latestResponse} />
<span>&bull;</span>
<SizeTag contentLength={latestResponse.contentLength ?? 0} />
</HStack>
</button>
) : (
<div>No Responses</div>
)}
</div>
);
}

View File

@@ -68,14 +68,15 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
value={activeTab}
onChangeValue={setActiveTab}
label="Folder Settings"
className="px-1.5 pb-2"
className="pt-2 pb-2 pl-3 pr-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<VStack space={3} className="pb-3 h-full">
<Input
label="Folder Name"
@@ -93,7 +94,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
/>
</VStack>
</TabContent>
<TabContent value={TAB_HEADERS} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
@@ -102,7 +103,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>

View File

@@ -1,5 +1,6 @@
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
import { useSubscribeHotKeys } from '../hooks/useHotKey';
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
@@ -18,6 +19,7 @@ export function GlobalHooks() {
// Other useful things
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
return null;
}

View File

@@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : grpcEvents.length >= 0 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'url_bar.focus']} />
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
)}
</div>
)

View File

@@ -240,7 +240,7 @@ export function GrpcRequestPane({
size="sm"
variant="border"
title={isStreaming ? 'Connect' : 'Send'}
hotkeyAction="grpc_request.send"
hotkeyAction="request.send"
onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
/>
@@ -250,7 +250,7 @@ export function GrpcRequestPane({
size="sm"
variant="border"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction="grpc_request.send"
hotkeyAction="request.send"
onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={

View File

@@ -74,7 +74,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
firstSlot={() =>
activeConnection == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">

View File

@@ -67,7 +67,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
</EmptyStateText>
);
} else {
return <EmptyStateText>Authentication not configured</EmptyStateText>;
return <EmptyStateText>No authentication</EmptyStateText>;
}
}

View File

@@ -160,7 +160,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'Other', value: BODY_TYPE_OTHER },
{
label: 'Other',
value: BODY_TYPE_OTHER,
shortLabel: nameOfContentTypeOr(contentType, 'Other'),
},
{ type: 'separator', label: 'Other' },
{ label: 'Binary File', value: BODY_TYPE_BINARY },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
@@ -229,6 +233,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[
activeRequest,
authTab,
contentType,
handleContentTypeChange,
headersTab,
numParams,
@@ -351,7 +356,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
tabListClassName="mt-1 mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
@@ -471,3 +476,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
</div>
);
}
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
const language = languageFromContentType(contentType);
if (language === 'markdown') {
return 'Markdown';
}
return fallback;
}

View File

@@ -107,7 +107,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
>
{activeResponse == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">

View File

@@ -14,7 +14,6 @@ export function LocalImage({ src: srcPath, className }: Props) {
queryKey: ['local-image', srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
console.log("LOADING SRC", srcPath, p)
return convertFileSrc(p);
},
});

View File

@@ -0,0 +1,450 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import {
duplicateModel,
foldersAtom,
getModel,
grpcConnectionsAtom,
httpResponsesAtom,
patchModel,
websocketConnectionsAtom,
workspacesAtom,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment';
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions';
import { useHotKey } from '../hooks/useHotKey';
import { getHttpRequestActions } from '../hooks/useHttpRequestActions';
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { deepEqualAtom } from '../lib/atoms';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
import { invokeCmd } from '../lib/tauri';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { LoadingIcon } from './core/LoadingIcon';
import { isSelectedFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
import type { TreeItemProps } from './core/tree/TreeItem';
import { GitDropdown } from './GitDropdown';
type Model = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
const opacitySubtle = 'opacity-80';
function getItemKey(item: Model) {
const responses = jotaiStore.get(httpResponsesAtom);
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
const url = 'url' in item ? item.url : 'n/a';
const method = 'method' in item ? item.method : 'n/a';
return [
item.id,
item.name,
url,
method,
latestResponse?.elapsed,
latestResponse?.id ?? 'n/a',
].join('::');
}
function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
if (item.model === 'folder') {
return <Icon icon="folder" />;
} else if (item.model === 'workspace') {
return null;
} else {
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
return (
<HttpMethodTag
short
className={classNames('text-xs', !isSelected && opacitySubtle)}
request={item}
/>
);
}
}
function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
const response = useAtomValue(
useMemo(
() =>
selectAtom(
atom((get) => [
...get(grpcConnectionsAtom),
...get(httpResponsesAtom),
...get(websocketConnectionsAtom),
]),
(responses) => responses.find((r) => r.requestId === item.id),
(a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
),
[item.id],
),
);
return (
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
<div className="truncate">{resolvedModelName(item)}</div>
{response != null && (
<div className="ml-auto">
{response.state !== 'closed' ? (
<LoadingIcon size="sm" className="text-text-subtlest" />
) : response.model === 'http_response' ? (
<HttpStatusTag short className="text-xs" response={response} />
) : null}
</div>
)}
</div>
);
}
function NewSidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden();
const tree = useAtomValue(sidebarTreeAtom);
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const focusActiveItem = useCallback(() => {
treeRef.current?.focus();
}, []);
useHotKey('sidebar.focus', async function focusHotkey() {
// Hide the sidebar if it's already focused
if (!hidden && isSidebarFocused()) {
await setHidden(true);
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await setHidden(false);
}
// Select the 0th index on focus if none selected
focusActiveItem();
});
const handleDragEnd = useCallback(async function handleDragEnd({
items,
parent,
children,
insertAt,
}: {
items: Model[];
parent: Model;
children: Model[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<Model, Workspace>;
const next = children[insertAt] as Exclude<Model, Workspace>;
const folderId = parent.model === 'folder' ? parent.id : null;
const beforePriority = prev?.sortPriority ?? 0;
const afterPriority = next?.sortPriority ?? 0;
const shouldUpdateAll = afterPriority - beforePriority < 1;
try {
if (shouldUpdateAll) {
// Add items to children at insertAt
children.splice(insertAt, 0, ...items);
await Promise.all(
children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
);
} else {
const range = afterPriority - beforePriority;
const increment = range / (items.length + 2);
await Promise.all(
items.map((m, i) =>
// Spread item sortPriority out over before/after range
patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }),
),
);
}
} catch (e) {
console.error(e);
}
}, []);
const handleTreeRefInit = useCallback((n: TreeHandle) => {
treeRef.current = n;
if (n == null) return;
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
n.selectItem(activeId);
}, []);
useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => {
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
treeRef.current?.selectItem(activeId);
});
}, []);
if (tree == null || hidden) {
return null;
}
return (
<aside
ref={wrapperRef}
aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
>
<Tree
ref={handleTreeRefInit}
root={tree}
treeId={treeId}
hotkeys={hotkeys}
getItemKey={getItemKey}
ItemInner={SidebarInnerItem}
ItemLeftSlot={SidebarLeftSlot}
getContextMenu={getContextMenu}
onActivate={handleActivate}
getEditOptions={getEditOptions}
className="pl-2 pr-3 pt-2 pb-2"
onDragEnd={handleDragEnd}
/>
<GitDropdown />
</aside>
);
}
export default NewSidebar;
const activeIdAtom = atom<string | null>((get) => {
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
});
function getEditOptions(
item: Model,
): ReturnType<NonNullable<TreeItemProps<Model>['getEditOptions']>> {
return {
onChange: handleSubmitEdit,
defaultValue: resolvedModelName(item),
placeholder: item.name,
};
}
async function handleSubmitEdit(item: Model, text: string) {
await patchModel(item, { name: text });
}
function handleActivate(item: Model) {
// TODO: Add folder layout support
if (item.model !== 'folder' && item.model !== 'workspace') {
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
}
}
const allPotentialChildrenAtom = atom<Model[]>((get) => {
const requests = get(allRequestsAtom);
const folders = get(foldersAtom);
return [...requests, ...folders];
});
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
const sidebarTreeAtom = atom((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const childrenMap: Record<string, Exclude<Model, Workspace>[]> = {};
for (const item of allModels) {
if ('folderId' in item && item.folderId == null) {
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
childrenMap[item.workspaceId]!.push(item);
} else if ('folderId' in item && item.folderId != null) {
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
childrenMap[item.folderId]!.push(item);
}
}
const treeParentMap: Record<string, TreeNode<Model>> = {};
if (activeWorkspace == null) {
return null;
}
// Put requests and folders into a tree structure
const next = (node: TreeNode<Model>): TreeNode<Model> => {
const childItems = childrenMap[node.item.id] ?? [];
// Recurse to children
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
if (node.item.model === 'folder' || node.item.model === 'workspace') {
node.children = node.children ?? [];
for (const item of childItems) {
treeParentMap[item.id] = node;
node.children.push(next({ item, parent: node }));
}
}
return node;
};
return next({
item: activeWorkspace,
children: [],
parent: null,
});
});
const actions = {
'sidebar.delete_selected_item': async function (items: Model[]) {
await deleteModelWithConfirm(items);
},
'model.duplicate': async function (items: Model[]) {
if (items.length === 1) {
const item = items[0]!;
const newId = await duplicateModel(item);
navigateToRequestOrFolderOrWorkspace(newId, item.model);
} else {
await Promise.all(items.map(duplicateModel));
}
},
'request.send': async function (items: Model[]) {
await Promise.all(
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
);
},
} as const;
const hotkeys: TreeProps<Model>['hotkeys'] = {
priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused
actions,
enable: () => isSidebarFocused(),
};
async function getContextMenu(items: Model[]): Promise<DropdownItem[]> {
const child = items[0];
if (child == null) return [];
const workspaces = jotaiStore.get(workspacesAtom);
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
const initialItems: ContextMenuProps['items'] = [
{
label: 'Folder Settings',
hidden: !(items.length === 1 && child.model === 'folder'),
leftSlot: <Icon icon="folder_cog" />,
onSelect: () => openFolderSettings(child.id),
},
{
label: 'Send All',
hidden: !(items.length === 1 && child.model === 'folder'),
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => {
const environment = jotaiStore.get(activeEnvironmentAtom);
const cookieJar = jotaiStore.get(activeCookieJarAtom);
invokeCmd('cmd_send_folder', {
folderId: child.id,
environmentId: environment?.id,
cookieJarId: cookieJar?.id,
});
},
},
{
label: 'Send',
hotKeyAction: 'request.send',
hotKeyLabelOnly: true,
hidden: !onlyHttpRequests,
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => actions['request.send'](items),
},
...(items.length === 1 && child.model === 'http_request'
? await getHttpRequestActions()
: []
).map((a) => ({
label: a.label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
onSelect: async () => {
const request = getModel('http_request', child.id);
if (request != null) await a.call(request);
},
})),
...(items.length === 1 && child.model === 'grpc_request'
? await getGrpcRequestActions()
: []
).map((a) => ({
label: a.label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
onSelect: async () => {
const request = getModel('grpc_request', child.id);
if (request != null) await a.call(request);
},
})),
];
const menuItems: ContextMenuProps['items'] = [
...initialItems,
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: items.length > 1,
onSelect: async () => {
const request = getModel(
['folder', 'http_request', 'grpc_request', 'websocket_request'],
child.id,
);
await renameModelWithPrompt(request);
},
},
{
label: 'Duplicate',
hotKeyAction: 'model.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => actions['model.duplicate'](items),
},
{
label: 'Move',
leftSlot: <Icon icon="arrow_right_circle" />,
hidden:
workspaces.length <= 1 ||
items.length > 1 ||
child.model === 'folder' ||
child.model === 'workspace',
onSelect: () => {
if (child.model === 'folder' || child.model === 'workspace') return;
moveToWorkspace.mutate(child);
},
},
{
color: 'danger',
label: 'Delete',
hotKeyAction: 'sidebar.delete_selected_item',
hotKeyLabelOnly: true,
leftSlot: <Icon icon="trash" />,
onSelect: () => actions['sidebar.delete_selected_item'](items),
},
];
return menuItems;
}

View File

@@ -1,8 +1,8 @@
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { FocusTrap } from 'focus-trap-react';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import React from 'react';
import React, { useRef } from 'react';
import { Portal } from './Portal';
interface Props {
@@ -32,6 +32,8 @@ export function Overlay({
noBackdrop,
children,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
if (noBackdrop) {
return (
<Portal name={portalName}>
@@ -44,11 +46,33 @@ export function Overlay({
</Portal>
);
}
return (
<Portal name={portalName}>
{open && (
<FocusTrap>
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true, // So we can still click toasts and things
delayInitialFocus: true,
fallbackFocus: () => containerRef.current!, // always have a target
initialFocus: () =>
// Doing this explicitly seems to work better than the default behavior for some reason
containerRef.current?.querySelector<HTMLElement>(
[
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? undefined,
}}
>
<m.div
ref={containerRef}
tabIndex={-1}
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}

View File

@@ -32,7 +32,7 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
console.log("Redirecting to workspace", params, search);
console.log('Redirecting to workspace', params, search);
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
})();
}, [recentWorkspaces, workspaces, workspaces.length]);

View File

@@ -25,9 +25,8 @@ export function ResizeHandle({
return (
<div
aria-hidden
draggable
style={style}
onDragStart={onResizeStart}
onPointerDown={onResizeStart}
onDoubleClick={onReset}
className={classNames(
className,

View File

@@ -1,7 +1,9 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { open } from '@tauri-apps/plugin-dialog';
import classNames from 'classnames';
import mime from 'mime';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
@@ -51,9 +53,43 @@ export function SelectFile({
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
const [isHovering, setIsHovering] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Listen for dropped files on the element
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
// as browser drag-n-drop.
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
const webview = getCurrentWebviewWindow();
unlisten = await webview.onDragDropEvent((event) => {
if (event.payload.type === 'over') {
const p = event.payload.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
console.log('IS OVER', isOver);
setIsHovering(isOver);
} else if (event.payload.type === 'drop' && isHovering) {
console.log('User dropped', event.payload.paths);
const p = event.payload.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
console.log('File drop cancelled');
setIsHovering(false);
}
});
};
setup().catch(console.error);
return () => {
if (unlisten) unlisten();
};
}, [isHovering, onChange]);
return (
<div>
<div ref={ref} className="w-full">
{label && (
<Label htmlFor={null} help={help}>
{label}
@@ -66,8 +102,9 @@ export function SelectFile({
'rtl mr-1.5',
inline && 'w-full',
filePath && inline && 'font-mono text-xs',
isHovering && '!border-notice',
)}
color="secondary"
color={isHovering ? 'primary' : 'secondary'}
onClick={handleClick}
size={size}
{...props}

View File

@@ -1,10 +1,10 @@
import { useMemo } from 'react';
import { useFloatingSidebarHidden } from '../../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
import { IconButton } from '../core/IconButton';
import { HStack } from '../core/Stacks';
import { CreateDropdown } from '../CreateDropdown';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
export function SidebarActions() {
const floating = useShouldFloatSidebar();
@@ -31,7 +31,7 @@ export function SidebarActions() {
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
iconColor="secondary"
/>
<CreateDropdown hotKeyAction="http_request.create">
<CreateDropdown hotKeyAction="model.create">
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
</CreateDropdown>
</HStack>

View File

@@ -1,9 +1,17 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateFunctionConfig } from '../hooks/useTemplateFunctionConfig';
import {
templateTokensToString,
useTemplateTokensToString,
@@ -24,6 +32,7 @@ interface Props {
initialTokens: Tokens;
hide: () => void;
onChange: (insert: string) => void;
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
}
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
@@ -84,14 +93,15 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
}
function InitializedTemplateFunctionDialog({
templateFunction,
hide,
templateFunction: { name },
initialArgValues,
hide,
onChange,
model,
}: Omit<Props, 'initialTokens'> & {
initialArgValues: Record<string, string | boolean>;
}) {
const enablePreview = templateFunction.name !== 'secure';
const enablePreview = name !== 'secure';
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
@@ -112,15 +122,16 @@ function InitializedTemplateFunctionDialog({
type: 'tag',
val: {
type: 'fn',
name: templateFunction.name,
name,
args: argTokens,
},
},
],
};
}, [argValues, templateFunction.name]);
}, [argValues, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model).data;
const handleDone = () => {
if (tagText.data) {
@@ -134,7 +145,7 @@ function InitializedTemplateFunctionDialog({
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) {
const arg = templateFunction.args.find((a) => 'name' in a && a.name === name);
const arg = templateFunction?.args.find((a) => 'name' in a && a.name === name);
const isTextPassword = arg?.type === 'text' && arg.password;
if (isTextPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
return true;
@@ -145,6 +156,8 @@ function InitializedTemplateFunctionDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rendered.data]);
if (templateFunction == null) return null;
return (
<VStack
as="form"
@@ -155,7 +168,7 @@ function InitializedTemplateFunctionDialog({
handleDone();
}}
>
{templateFunction.name === 'secure' ? (
{name === 'secure' ? (
<PlainInput
required
label="Value"

View File

@@ -98,7 +98,7 @@ export const UrlBar = memo(function UrlBar({
className="w-8 mr-0.5 !h-full"
iconColor="secondary"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
hotkeyAction="request.send"
onMouseDown={(e) => {
// Prevent the button from taking focus
e.preventDefault();

View File

@@ -72,7 +72,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
firstSlot={() =>
activeConnection == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">

View File

@@ -12,6 +12,8 @@ import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from '../hooks/useActiveEnvironment';
import { activeFolderAtom } from '../hooks/useActiveFolder';
import { useSubscribeActiveFolderId } from '../hooks/useActiveFolderId';
import { activeRequestAtom } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
@@ -26,7 +28,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate';
import { importData } from '../lib/importData';
import { jotaiStore } from '../lib/jotai';
import { Banner } from './core/Banner';
@@ -36,13 +38,14 @@ import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { ErrorBoundary } from './ErrorBoundary';
import { FolderLayout } from './FolderLayout';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HeaderSize } from './HeaderSize';
import { HttpRequestLayout } from './HttpRequestLayout';
import NewSidebar from './NewSidebar';
import { Overlay } from './Overlay';
import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions';
import { SidebarActions } from './SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
@@ -161,7 +164,7 @@ export function Workspace() {
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
<NewSidebar />
</ErrorBoundary>
</m.div>
</Overlay>
@@ -169,7 +172,7 @@ export function Workspace() {
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
<NewSidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
@@ -193,7 +196,7 @@ export function Workspace() {
style={environmentBgStyle}
className="absolute inset-0 opacity-5"
/>
<div // Add subtle border bottom
<div // Add a subtle border bottom
style={environmentBgStyle}
className="absolute left-0 right-0 bottom-0 h-[0.5px] opacity-20"
/>
@@ -209,6 +212,7 @@ export function Workspace() {
function WorkspaceBody() {
const activeRequest = useAtomValue(activeRequestAtom);
const activeFolder = useAtomValue(activeFolderAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
if (activeWorkspace == null) {
@@ -228,39 +232,40 @@ function WorkspaceBody() {
);
}
if (activeRequest == null) {
return (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
);
if (activeRequest?.model === 'grpc_request') {
return <GrpcConnectionLayout style={body} />;
} else if (activeRequest?.model === 'websocket_request') {
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
} else if (activeRequest?.model === 'http_request') {
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
} else if (activeFolder != null) {
return <FolderLayout folder={activeFolder} style={body} />;
}
if (activeRequest.model === 'grpc_request') {
return <GrpcConnectionLayout style={body} />;
} else if (activeRequest.model === 'websocket_request') {
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
} else {
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}
return (
<HotKeyList
hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
);
}
function useGlobalWorkspaceHooks() {
useEnsureActiveCookieJar();
useSubscribeActiveRequestId();
useSubscribeActiveFolderId();
useSubscribeActiveEnvironmentId();
useSubscribeActiveCookieJarId();
@@ -274,7 +279,7 @@ function useGlobalWorkspaceHooks() {
const toggleCommandPalette = useToggleCommandPalette();
useHotKey('command_palette.toggle', toggleCommandPalette);
useHotKey('http_request.duplicate', () =>
duplicateRequestAndNavigate(jotaiStore.get(activeRequestAtom)),
useHotKey('model.duplicate', () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);
}

View File

@@ -7,7 +7,11 @@ import { memo, useCallback, useMemo } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import {
activeWorkspaceAtom,
activeWorkspaceIdAtom,
activeWorkspaceMetaAtom,
} from '../hooks/useActiveWorkspace';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteSendHistory } from '../hooks/useDeleteSendHistory';
import { showDialog } from '../lib/dialog';
@@ -95,7 +99,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
if (workspaceId == null) return;
const settings = jotaiStore.get(settingsAtom);
if (typeof settings.openWorkspaceNewWindow === 'boolean') {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId === activeWorkspaceId) {
// Always open a new window if the selected one is already active
switchWorkspace.mutate({ workspaceId, inNewWindow: true });
return;
} else if (typeof settings.openWorkspaceNewWindow === 'boolean') {
switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
return;
}

View File

@@ -16,7 +16,7 @@ import { ImportCurlButton } from './ImportCurlButton';
import { LicenseBadge } from './LicenseBadge';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './sidebar/SidebarActions';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {

View File

@@ -44,7 +44,7 @@ export function Confirm({
onChange={setConfirm}
label={
<>
Type <strong>{requireTyping}</strong> to confirm
Type <strong className="select-auto cursor-text">{requireTyping}</strong> to confirm
</>
}
/>

View File

@@ -221,7 +221,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
);
});
interface ContextMenuProps {
export interface ContextMenuProps {
triggerPosition: { x: number; y: number } | null;
className?: string;
items: DropdownProps['items'];

View File

@@ -24,16 +24,20 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../../hooks/useRandomKey';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog';
import { editEnvironment } from '../../../lib/editEnvironment';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
@@ -114,7 +118,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
@@ -145,6 +149,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
? allEnvironmentVariables.filter(autocompleteVariables)
: allEnvironmentVariables;
}, [allEnvironmentVariables, autocompleteVariables]);
// Track a local key for updates. If the default value is changed when the input is not in focus,
// regenerate this to force the field to update.
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
if (settings && wrapLines === undefined) {
wrapLines = settings.editorSoftWrap;
@@ -286,18 +294,22 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
size: 'md',
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
render: ({ hide }) => {
const model = jotaiStore.get(activeWorkspaceAtom)!;
return (
<TemplateFunctionDialog
templateFunction={fn}
model={model}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
);
},
});
if (fn.name === 'secure') {
@@ -340,6 +352,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[],
);
// Force input to update when receiving change and not in focus
useLayoutEffect(() => {
const currDoc = cm.current?.view.state.doc.toString() || '';
const nextDoc = defaultValue || '';
const notFocused = !cm.current?.view.hasFocus;
const hasChanged = currDoc !== nextDoc;
if (notFocused && hasChanged) {
regenerateFocusedUpdateKey();
}
}, [defaultValue, regenerateFocusedUpdateKey]);
const [, { focusParamValue }] = useRequestEditor();
const onClickPathParameter = useCallback(
async (name: string) => {

View File

@@ -1,5 +1,5 @@
import { settingsAtom } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
@@ -18,42 +18,69 @@ const methodNames: Record<string, string> = {
options: 'OPTN',
head: 'HEAD',
query: 'QURY',
graphql: 'GQL',
grpc: 'GRPC',
websocket: 'WS',
};
export function HttpMethodTag({ request, className, short }: Props) {
const settings = useAtomValue(settingsAtom);
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'GQL'
? 'graphql'
: request.model === 'grpc_request'
? 'GRPC'
? 'grpc'
: request.model === 'websocket_request'
? 'WS'
? 'websocket'
: request.method;
let label = method.toUpperCase();
return (
<HttpMethodTagRaw
method={method}
colored={settings.coloredMethods}
className={className}
short={short}
/>
);
}
export function HttpMethodTagRaw({
className,
method,
colored,
short,
}: {
method: string;
className?: string;
colored: boolean;
short?: boolean;
}) {
let label = method.toUpperCase();
if (short) {
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
label = label.padStart(4, ' ');
}
const m = method.toUpperCase();
return (
<span
className={classNames(
className,
!settings.coloredMethods && 'text-text-subtle',
settings.coloredMethods && method === 'GQL' && 'text-info',
settings.coloredMethods && method === 'WS' && 'text-info',
settings.coloredMethods && method === 'GRPC' && 'text-info',
settings.coloredMethods && method === 'OPTIONS' && 'text-info',
settings.coloredMethods && method === 'HEAD' && 'text-info',
settings.coloredMethods && method === 'GET' && 'text-primary',
settings.coloredMethods && method === 'PUT' && 'text-warning',
settings.coloredMethods && method === 'PATCH' && 'text-notice',
settings.coloredMethods && method === 'POST' && 'text-success',
settings.coloredMethods && method === 'DELETE' && 'text-danger',
!colored && 'text-text-subtle',
colored && m === 'GRAPHQL' && 'text-info',
colored && m === 'WEBSOCKET' && 'text-info',
colored && m === 'GRPC' && 'text-info',
colored && m === 'QUERY' && 'text-secondary',
colored && m === 'OPTIONS' && 'text-info',
colored && m === 'HEAD' && 'text-secondary',
colored && m === 'GET' && 'text-primary',
colored && m === 'PUT' && 'text-warning',
colored && m === 'PATCH' && 'text-notice',
colored && m === 'POST' && 'text-success',
colored && m === 'DELETE' && 'text-danger',
'font-mono flex-shrink-0 whitespace-pre',
'pt-[0.25em]', // Fix for monospace font not vertically centering
'pt-[0.15em]', // Fix for monospace font not vertically centering
)}
>
{label}

View File

@@ -12,6 +12,7 @@ export function HttpResponseDurationTag({ response }: Props) {
// Calculate the duration of the response for use when the response hasn't finished yet
useEffect(() => {
clearInterval(timeout.current);
if (response.state === 'closed') return;
timeout.current = setInterval(() => {
setFallbackElapsed(Date.now() - new Date(response.createdAt + 'Z').getTime());
}, 100);

View File

@@ -33,7 +33,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
}
return (
<span className={classNames(className, 'font-mono', colorClass)}>
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
</span>
);

View File

@@ -39,6 +39,7 @@ const icons = {
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
command: lucide.CommandIcon,
corner_right_up: lucide.CornerRightUpIcon,
credit_card: lucide.CreditCardIcon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
@@ -54,6 +55,7 @@ const icons = {
flame: lucide.FlameIcon,
flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon,
folder_cog: lucide.FolderCogIcon,
folder_code: lucide.FolderCodeIcon,
folder_git: lucide.FolderGitIcon,
folder_input: lucide.FolderInputIcon,
@@ -61,6 +63,7 @@ const icons = {
folder_output: lucide.FolderOutputIcon,
folder_symlink: lucide.FolderSymlinkIcon,
folder_sync: lucide.FolderSyncIcon,
folder_up: lucide.FolderUpIcon,
git_branch: lucide.GitBranchIcon,
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon,
@@ -118,7 +121,7 @@ const icons = {
x: lucide.XIcon,
_unknown: lucide.ShieldAlertIcon,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
};
export interface IconProps {

View File

@@ -1,3 +1,4 @@
import { EditorSelection } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
@@ -164,7 +165,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
setFocused(false);
// Move selection to the end on blur
editorRef.current?.dispatch({
selection: { anchor: editorRef.current.state.doc.length },
selection: EditorSelection.single(editorRef.current.state.doc.length ),
});
onBlur?.();
}, [onBlur]);

View File

@@ -1,6 +1,14 @@
import classNames from 'classnames';
import type { FocusEvent, HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
@@ -22,7 +30,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
className,
containerClassName,
defaultValue,
forceUpdateKey,
forceUpdateKey: forceUpdateKeyFromAbove,
help,
hideLabel,
hideObscureToggle,
@@ -47,15 +55,21 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
},
ref,
) {
// Track a local key for updates. If the default value is changed when the input is not in focus,
// regenerate this to force the field to update.
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
ref,
() => inputRef.current,
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
@@ -75,6 +89,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
onBlur?.();
}, [onBlur]);
// Force input to update when receiving change and not in focus
useLayoutEffect(() => {
if (!focused) {
regenerateFocusedUpdateKey();
}
}, [focused, regenerateFocusedUpdateKey, defaultValue]);
const id = `input-${name}`;
const commonClassName = classNames(
className,
@@ -152,9 +173,9 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
)}
>
<input
id={id}
ref={inputRef}
key={forceUpdateKey}
id={id}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue ?? undefined}
autoComplete="off"

View File

@@ -33,7 +33,15 @@ export function RadioDropdown<T = string | null>({
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() => [
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
...((itemsBefore
? [
...itemsBefore,
{
type: 'separator',
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
},
]
: []) as DropdownItem[]),
...items.map((item) => {
if (item.type === 'separator') {
return item;
@@ -47,7 +55,9 @@ export function RadioDropdown<T = string | null>({
} as DropdownItem;
}
}),
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
...((itemsAfter
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
: []) as DropdownItem[]),
],
[itemsBefore, items, itemsAfter, value, onChange],
);

View File

@@ -88,15 +88,15 @@ export function SplitLayout({
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
document.documentElement.removeEventListener('pointermove', moveState.current.move);
document.documentElement.removeEventListener('pointerup', moveState.current.up);
}
};
const handleReset = useCallback(
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
[vertical, setHeight, defaultRatio, setWidth],
);
const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
@@ -112,6 +112,7 @@ export function SplitLayout({
moveState.current = {
move: (e: MouseEvent) => {
setIsResizing(true); // Set this here so we don't block double-clicks
e.preventDefault(); // Prevent text selection and things
if (vertical) {
const maxHeightPx = containerRect.height - minHeightPx;
@@ -137,9 +138,8 @@ export function SplitLayout({
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
document.documentElement.addEventListener('pointermove', moveState.current.move);
document.documentElement.addEventListener('pointerup', moveState.current.up);
},
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);

View File

@@ -83,12 +83,13 @@ export function Tabs({
aria-label={label}
className={classNames(
tabListClassName,
addBorders && '!-ml-1',
'flex items-center hide-scrollbars mb-2',
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
addBorders && layout === 'vertical' && 'ml-0 mb-2',
'flex items-center hide-scrollbars',
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
)}
>
<div
@@ -124,6 +125,8 @@ export function Tabs({
<RadioDropdown
key={t.value}
items={t.options.items}
itemsAfter={t.options.itemsAfter}
itemsBefore={t.options.itemsBefore}
value={t.options.value}
onChange={t.options.onChange}
>

View File

@@ -0,0 +1,63 @@
// AutoScrollWhileDragging.tsx
import { useEffect, useRef } from 'react';
import { useDragLayer } from 'react-dnd';
type Props = {
container: HTMLElement | null | undefined;
edgeDistance?: number;
maxSpeedPerFrame?: number;
};
export function AutoScrollWhileDragging({
container,
edgeDistance = 30,
maxSpeedPerFrame = 6,
}: Props) {
const rafId = useRef<number | null>(null);
const { isDragging, pointer } = useDragLayer((monitor) => ({
isDragging: monitor.isDragging(),
pointer: monitor.getClientOffset(), // { x, y } | null
}));
useEffect(() => {
if (!container || !isDragging) {
if (rafId.current != null) cancelAnimationFrame(rafId.current);
rafId.current = null;
return;
}
const tick = () => {
if (!container || !isDragging || !pointer) return;
const rect = container.getBoundingClientRect();
const y = pointer.y;
// Compute vertical speed based on proximity to edges
let dy = 0;
if (y < rect.top + edgeDistance) {
const t = (rect.top + edgeDistance - y) / edgeDistance; // 0..1
dy = -Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
} else if (y > rect.bottom - edgeDistance) {
const t = (y - (rect.bottom - edgeDistance)) / edgeDistance; // 0..1
dy = Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
}
if (dy !== 0) {
// Only scroll if theres more content in that direction
const prev = container.scrollTop;
container.scrollTop = prev + dy;
}
rafId.current = requestAnimationFrame(tick);
};
rafId.current = requestAnimationFrame(tick);
return () => {
if (rafId.current != null) cancelAnimationFrame(rafId.current);
rafId.current = null;
};
}, [container, isDragging, pointer, edgeDistance, maxSpeedPerFrame]);
return null;
}

View File

@@ -0,0 +1,557 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps } from '../Dropdown';
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList';
export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
className?: string;
onActivate?: (item: T) => void;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
hotkeys?: { actions: Partial<Record<HotkeyAction, (items: T[]) => void>> } & HotKeyOptions;
getEditOptions?: (item: T) => {
defaultValue: string;
placeholder?: string;
onChange: (item: T, text: string) => void;
};
}
export interface TreeHandle {
focus: () => void;
selectItem: (id: string) => void;
}
function TreeInner<T extends { id: string }>(
{
className,
getContextMenu,
getEditOptions,
getItemKey,
hotkeys,
onActivate,
onDragEnd,
ItemInner,
ItemLeftSlot,
root,
treeId,
}: TreeProps<T>,
ref: Ref<TreeHandle>,
) {
const treeRef = useRef<HTMLDivElement>(null);
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
const [isFocused, setIsFocused] = useState<boolean>(false);
const tryFocus = useCallback(() => {
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
}, []);
const setSelected = useCallback(
function setSelected(ids: string[], focus: boolean) {
jotaiStore.set(selectedIdsFamily(treeId), ids);
// TODO: Figure out a better way than timeout
if (focus) setTimeout(tryFocus, 50);
},
[treeId, tryFocus],
);
useImperativeHandle(
ref,
(): TreeHandle => ({
focus: tryFocus,
selectItem(id) {
setSelected([id], false);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
},
}),
[setSelected, treeId, tryFocus],
);
const handleGetContextMenu = useMemo(() => {
if (getContextMenu == null) return;
return (item: T) => {
const items = getSelectedItems(treeId, selectableItems);
const isSelected = items.find((i) => i.id === item.id);
if (isSelected) {
// If right-clicked an item that was in the multiple-selection, use the entire selection
return getContextMenu(items);
} else {
// If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId);
const selectedIds = jotaiStore.get(selectedIdsAtom);
// Mark item as the last one selected
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) {
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
const currIndex = selectableItems.findIndex((v) => v.node.item.id === item.id);
// Nothing was selected yet, so just select this item
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
return;
}
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
);
} else if (currIndex < anchorIndex) {
// Selecting up
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
);
} else {
setSelected([item.id], true);
}
} else if (type() === 'macos' ? metaKey : ctrlKey) {
const withoutCurr = selectedIds.filter((id) => id !== item.id);
if (withoutCurr.length === selectedIds.length) {
// It wasn't in there, so add it
setSelected([...selectedIds, item.id], true);
} else {
// It was in there, so remove it
setSelected(withoutCurr, true);
}
} else {
// Select single
setSelected([item.id], true);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
}
},
[selectableItems, setSelected, treeId],
);
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e);
} else {
handleSelect(item, e);
onActivate?.(item);
}
},
[handleSelect, onActivate],
);
useKey(
'ArrowUp',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
e.preventDefault();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index - 1];
if (item != null) handleSelect(item.node.item, e);
},
undefined,
[selectableItems, handleSelect],
);
useKey(
'ArrowDown',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
e.preventDefault();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index + 1];
if (item != null) handleSelect(item.node.item, e);
},
undefined,
[selectableItems, handleSelect],
);
useKeyPressEvent('Escape', async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
if (lastSelectedId == null) return;
setSelected([lastSelectedId], false);
});
const handleDragMove = useCallback(
function handleDragMove(e: DragMoveEvent) {
const over = e.over;
if (!over) {
// Clear the drop indicator when hovering outside the tree
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
return;
}
// Not sure when or if this happens
if (e.active.rect.current.initial == null) {
return;
}
// Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) {
jotaiStore.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
index: root.children?.length ?? 0,
});
return;
}
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
if (node == null) {
return;
}
const side = computeSideForDragMove(node, e);
const item = node.item;
let hoveredParent = treeParentMap[item.id] ?? null;
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
// Move into the folder if it's open and we're moving below it
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
hoveredIndex = 0;
}
jotaiStore.set(hoveredParentFamily(treeId), {
parentId: hoveredParent?.item.id ?? null,
index: hoveredIndex,
});
},
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
);
const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) {
const item = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item ?? null;
if (item == null) return;
const selectedItems = getSelectedItems(treeId, selectableItems);
const isDraggingSelectedItem = selectedItems.find((i) => i.id === item.id);
if (isDraggingSelectedItem) {
jotaiStore.set(
draggingIdsFamily(treeId),
selectedItems.map((i) => i.id),
);
} else {
jotaiStore.set(draggingIdsFamily(treeId), [item.id]);
// Also update selection to just be this one
handleSelect(item, { shiftKey: false, metaKey: false, ctrlKey: false });
}
},
[handleSelect, selectableItems, treeId],
);
const clearDragState = useCallback(() => {
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
jotaiStore.set(draggingIdsFamily(treeId), []);
}, [treeId]);
const handleDragEnd = useCallback(
function handleDragEnd(e: DragEndEvent) {
// Get this from the store so our callback doesn't change all the time
const hovered = jotaiStore.get(hoveredParentFamily(treeId));
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
clearDragState();
// Dropped outside the tree?
if (e.over == null) return;
const hoveredParent =
hovered.parentId == root.item.id
? root
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
// Optional tiny guard: don't drop into itself
if (draggingItems.some((id) => id === hovered.parentId)) return;
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
const draggedNodes: TreeNode<T>[] = draggingItems
.map((id) => {
const parent = treeParentMap[id];
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
return idx >= 0 ? parent!.children![idx]! : null;
})
.filter((n) => n != null)
// Filter out invalid drags (dragging into descendant)
.filter((n) => !hasAncestor(hoveredParent, n.item.id));
// Work on a local copy of target children
const nextChildren = [...(hoveredParent.children ?? [])];
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
let insertAt = hovered.index;
for (const node of draggedNodes) {
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
if (i !== -1) {
nextChildren.splice(i, 1);
if (i < insertAt) insertAt -= 1; // account for removed-before
}
}
// Batch callback
onDragEnd?.({
items: draggedNodes.map((n) => n.item),
parent: hoveredParent.item,
children: nextChildren.map((c) => c.item),
insertAt,
});
},
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap],
);
const treeItemListProps: Omit<
TreeItemListProps<T>,
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
> = {
depth: 0,
getItemKey,
getContextMenu: handleGetContextMenu,
onClick: handleClick,
getEditOptions,
ItemInner,
ItemLeftSlot,
};
const handleFocus = useCallback(function handleFocus() {
setIsFocused(true);
}, []);
const handleBlur = useCallback(function handleBlur() {
setIsFocused(false);
}, []);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
onDragMove={handleDragMove}
autoScroll
>
<div
ref={treeRef}
onFocus={handleFocus}
onBlur={handleBlur}
className={classNames(
className,
'outline-none h-full',
'overflow-y-auto overflow-x-hidden',
'grid grid-rows-[auto_1fr]',
' [&_.tree-item.selected]:text-text',
isFocused
? '[&_.tree-item.selected]:bg-surface-active'
: '[&_.tree-item.selected]:bg-surface-highlight',
)}
>
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
{/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList id={root.item.id} />
<TreeDragOverlay
treeId={treeId}
root={root}
selectableItems={selectableItems}
ItemInner={ItemInner}
getItemKey={getItemKey}
/>
</div>
</DndContext>
</>
);
}
// 1) Preserve generics through forwardRef:
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
props: TreeProps<T> & RefAttributes<TreeHandle>,
) => ReactElement | null;
export const Tree = memo(
Tree_,
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
return false;
}
}
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
},
) as typeof Tree_;
function DropRegionAfterList({ id }: { id: string }) {
const { setNodeRef } = useDroppable({ id });
return <div ref={setNodeRef} />;
}
function useTreeParentMap<T extends { id: string }>(
root: TreeNode<T>,
getItemKey: (item: T) => string,
) {
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
return compute(root, collapsedMap);
});
const prevRoot = useRef<TreeNode<T> | null>(null);
useEffect(() => {
const shouldRecompute =
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
if (!shouldRecompute) return;
setData(compute(root, collapsedMap));
prevRoot.current = root;
}, [collapsedMap, getItemKey, root]);
return { treeParentMap, selectableItems };
}
function compute<T extends { id: string }>(
root: TreeNode<T>,
collapsedMap: Record<string, boolean>,
) {
const treeParentMap: Record<string, TreeNode<T>> = {};
const selectableItems: SelectableTreeNode<T>[] = [];
// Put requests and folders into a tree structure
const next = (node: TreeNode<T>, depth: number = 0) => {
const isCollapsed = collapsedMap[node.item.id] === true;
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
if (node.children == null) {
return;
}
// Recurse to children
let selectableIndex = 0;
for (const child of node.children) {
treeParentMap[child.item.id] = node;
if (!isCollapsed) {
selectableItems.push({
node: child,
index: selectableIndex++,
depth,
});
}
next(child, depth + 1);
}
};
next(root);
return { treeParentMap, selectableItems };
}
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
action: HotkeyAction;
selectableItems: SelectableTreeNode<T>[];
treeId: string;
onDone: (items: T[]) => void;
}
function TreeHotKey<T extends { id: string }>({
treeId,
action,
onDone,
selectableItems,
...options
}: TreeHotKeyProps<T>) {
useHotKey(
action,
() => {
onDone(getSelectedItems(treeId, selectableItems));
},
options,
);
return null;
}
function TreeHotKeys<T extends { id: string }>({
treeId,
hotkeys,
selectableItems,
}: {
treeId: string;
hotkeys: TreeProps<T>['hotkeys'];
selectableItems: SelectableTreeNode<T>[];
}) {
if (hotkeys == null) return null;
return (
<>
{Object.entries(hotkeys.actions).map(([hotkey, onDone]) => (
<TreeHotKey
key={hotkey}
action={hotkey as HotkeyAction}
priority={hotkeys.priority}
enable={hotkeys.enable}
treeId={treeId}
onDone={onDone}
selectableItems={selectableItems}
/>
))}
</>
);
}

View File

@@ -0,0 +1,44 @@
import { DragOverlay } from '@dnd-kit/core';
import { useAtomValue } from 'jotai';
import { draggingIdsFamily } from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import type { TreeProps } from './Tree';
import { TreeItemList } from './TreeItemList';
export function TreeDragOverlay<T extends { id: string }>({
treeId,
root,
selectableItems,
getItemKey,
ItemInner,
ItemLeftSlot,
}: {
treeId: string;
root: TreeNode<T>;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>
<TreeItemList
treeId={treeId + '.dragging'}
node={{
item: { ...root.item, id: `${root.item.id}_dragging` },
parent: null,
children: draggingItems
.map((id) => {
const child = selectableItems.find((i2) => {
return i2.node.item.id === id;
})?.node;
return child == null ? null : { ...child, children: undefined };
})
.filter((c) => c != null),
}}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
depth={0}
/>
</DragOverlay>
);
}

View File

@@ -0,0 +1,273 @@
import type { DragMoveEvent } from '@dnd-kit/core';
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { MouseEvent, PointerEvent } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon';
import {
isCollapsedFamily,
isLastFocusedFamily,
isParentHoveredFamily,
isSelectedFamily,
} from './atoms';
import type { TreeNode } from './common';
import { computeSideForDragMove } from './common';
import type { TreeProps } from './Tree';
interface OnClickEvent {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: OnClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
};
const HOVER_CLOSED_FOLDER_DELAY = 800;
export function TreeItem<T extends { id: string }>({
treeId,
node,
ItemInner,
ItemLeftSlot,
getContextMenu,
onClick,
getEditOptions,
className,
}: TreeItemProps<T>) {
const ref = useRef<HTMLDivElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null);
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState<boolean>(false);
const [isDropHover, setIsDropHover] = useState<boolean>(false);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[];
x: number;
y: number;
} | null>(null);
useEffect(
function scrollIntoViewWhenSelected() {
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
ref.current?.scrollIntoView({ block: 'nearest' });
});
},
[node.item.id, treeId],
);
const handleClick = useCallback(
function handleClick(e: MouseEvent<HTMLButtonElement>) {
onClick?.(node.item, e);
},
[node, onClick],
);
const toggleCollapsed = useCallback(
function toggleCollapsed() {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
},
[node.item.id, treeId],
);
const handleSubmitNameEdit = useCallback(
async function submitNameEdit(el: HTMLInputElement) {
getEditOptions?.(node.item).onChange(node.item, el.value);
// Slight delay for the model to propagate to the local store
setTimeout(() => setEditing(false), 200);
},
[getEditOptions, node.item],
);
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
el?.focus();
el?.select();
}, []);
const handleEditBlur = useCallback(
async function editBlur(e: React.FocusEvent<HTMLInputElement>) {
await handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleEditKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
const handleDoubleClick = useCallback(() => {
const isFolder = node.children != null;
if (isFolder) {
toggleCollapsed();
} else if (getEditOptions != null) {
setEditing(true);
}
}, [getEditOptions, node.children, toggleCollapsed]);
const clearHoverTimer = () => {
if (startedHoverTimeout.current) {
setIsDropHover(false); // NEW
clearTimeout(startedHoverTimeout.current); // NEW
startedHoverTimeout.current = undefined; // NEW
}
};
// Toggle auto-expand of folders when hovering over them
useDndMonitor({
onDragMove(e: DragMoveEvent) {
const side = computeSideForDragMove(node, e);
const isFolderWithChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
if (isCollapsed && isFolderWithChildren && side === 'below') {
setIsDropHover(true);
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
setIsDropHover(false);
}, HOVER_CLOSED_FOLDER_DELAY);
} else {
clearHoverTimer();
}
},
});
const handleContextMenu = useCallback(
async (e: MouseEvent<HTMLDivElement>) => {
if (getContextMenu == null) return;
e.preventDefault();
e.stopPropagation();
const items = await getContextMenu(node.item);
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
},
[getContextMenu, node.item],
);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback(
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
if (!handleByTree) {
listeners?.onPointerDown?.(e);
}
},
[listeners],
);
const handleSetDraggableRef = useCallback(
(node: HTMLButtonElement | null) => {
draggableRef.current = node;
setDraggableRef(node);
setDroppableRef(node);
},
[setDraggableRef, setDroppableRef],
);
return (
<div
ref={ref}
onContextMenu={handleContextMenu}
className={classNames(
className,
'tree-item',
isSelected && 'selected',
'text-text-subtle',
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
editing && 'ring-1 focus-within:ring-focus',
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
)}
>
{showContextMenu && (
<ContextMenu
items={showContextMenu.items}
triggerPosition={showContextMenu}
onClose={handleCloseContextMenu}
/>
)}
{node.children != null ? (
<button
tabIndex={-1}
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
onClick={toggleCollapsed}
>
<Icon
icon="chevron_right"
className={classNames(
'transition-transform text-text-subtlest',
'ml-auto !h-[1rem] !w-[1rem]',
node.children.length == 0 && 'opacity-0',
!isCollapsed && 'rotate-90',
isHoveredAsParent && '!text-text',
)}
/>
</button>
) : (
<span />
)}
<button
ref={handleSetDraggableRef}
onPointerDown={handlePointerDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
{...attributes}
{...listeners}
tabIndex={isLastSelected ? 0 : -1}
>
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
return (
<input
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown}
/>
);
})()
) : (
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import { Fragment, memo } from 'react';
import { DropMarker } from '../../DropMarker';
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
import type { TreeNode } from './common';
import { equalSubtree } from './common';
import type { TreeProps } from './Tree';
import type { TreeItemProps } from './TreeItem';
import { TreeItem } from './TreeItem';
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
> &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
node: TreeNode<T>;
depth: number;
style?: CSSProperties;
className?: string;
};
function TreeItemList_<T extends { id: string }>({
className,
depth,
getContextMenu,
getEditOptions,
getItemKey,
node,
onClick,
ItemInner,
ItemLeftSlot,
style,
treeId,
}: TreeItemListProps<T>) {
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
const childList = !isCollapsed && node.children != null && (
<ul
style={style}
className={classNames(
className,
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
)}
>
{node.children.map(function mapChild(child, i) {
return (
<Fragment key={getItemKey(child.item)}>
<TreeDropMarker treeId={treeId} parent={node} index={i} />
<TreeItemList
treeId={treeId}
node={child}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
depth={depth + 1}
getItemKey={getItemKey}
getContextMenu={getContextMenu}
/>
</Fragment>
);
})}
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
</ul>
);
if (depth === 0) {
return childList;
}
return (
<li>
<TreeItem
treeId={treeId}
node={node}
getContextMenu={getContextMenu}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
/>
{childList}
</li>
);
}
export const TreeItemList = memo(
TreeItemList_,
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
const nonEqualKeys = [];
for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
nonEqualKeys.push(key);
}
}
if (nonEqualKeys.length > 0) {
// console.log('TreeItemList: ', nonEqualKeys);
return false;
}
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
},
) as typeof TreeItemList_;
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className,
treeId,
parent,
index,
}: {
treeId: string;
parent: TreeNode<T> | null;
index: number;
className?: string;
}) {
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
const isLastItem = parent?.children?.length === index;
const isLastItemHovered = useAtomValue(
isItemHoveredFamily({
treeId,
parentId: parent?.item.id,
index: parent?.children?.length ?? 0,
}),
);
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
return <DropMarker className={classNames(className)} />;
});

View File

@@ -0,0 +1,89 @@
import { atom } from 'jotai';
import { atomFamily, selectAtom } from 'jotai/utils';
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const selectedIdsFamily = atomFamily((_treeId: string) => {
return atom<string[]>([]);
});
export const isSelectedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) => {
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
},
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const focusIdsFamily = atomFamily((_treeId: string) => {
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
});
export const isLastFocusedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) =>
selectAtom(focusIdsFamily(treeId), (v) => v.lastId == itemId, Object.is),
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const draggingIdsFamily = atomFamily((_treeId: string) => {
return atom<string[]>([]);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const hoveredParentFamily = atomFamily((_treeId: string) => {
return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null });
});
export const isParentHoveredFamily = atomFamily(
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
);
export const isItemHoveredFamily = atomFamily(
({
treeId,
parentId,
index,
}: {
treeId: string;
parentId: string | null | undefined;
index: number | null;
}) =>
selectAtom(
hoveredParentFamily(treeId),
(v) => v.parentId === parentId && v.index === index,
Object.is,
),
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
);
function kvKey(workspaceId: string | null) {
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
}
export const collapsedFamily = atomFamily((workspaceId: string) => {
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
});
export const isCollapsedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) =>
atom(
// --- getter ---
(get) => !!get(collapsedFamily(treeId))[itemId],
// --- setter ---
(get, set, next: boolean | ((prev: boolean) => boolean)) => {
const a = collapsedFamily(treeId);
const prevMap = get(a);
const prevValue = !!prevMap[itemId];
const value = typeof next === 'function' ? next(prevValue) : next;
if (value === prevValue) return; // no-op
set(a, { ...prevMap, [itemId]: value });
},
),
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);

View File

@@ -0,0 +1,70 @@
import type { DragMoveEvent } from '@dnd-kit/core';
import { jotaiStore } from '../../../lib/jotai';
import { selectedIdsFamily } from './atoms';
export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[];
item: T;
parent: TreeNode<T> | null;
}
export interface SelectableTreeNode<T extends { id: string }> {
node: TreeNode<T>;
depth: number;
index: number;
}
export function getSelectedItems<T extends { id: string }>(
treeId: string,
selectableItems: SelectableTreeNode<T>[],
) {
const selectedItemIds = jotaiStore.get(selectedIdsFamily(treeId));
return selectableItems
.filter((i) => selectedItemIds.includes(i.node.item.id))
.map((i) => i.node.item);
}
export function equalSubtree<T extends { id: string }>(
a: TreeNode<T>,
b: TreeNode<T>,
getKey: (t: T) => string,
): boolean {
if (getKey(a.item) !== getKey(b.item)) return false;
const ak = a.children ?? [];
const bk = b.children ?? [];
if (ak.length !== bk.length) return false;
for (let i = 0; i < ak.length; i++) {
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
}
return true;
}
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
// Check parents recursively
if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true;
return hasAncestor(node.parent, ancestorId);
}
export function computeSideForDragMove<T extends { id: string }>(
node: TreeNode<T>,
e: DragMoveEvent,
): 'above' | 'below' | null {
if (e.over == null || e.over.id !== node.item.id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? 'above' : 'below';
}

View File

@@ -1,9 +1,8 @@
export enum ItemTypes {
REQUEST = 'request',
SIDEBAR = 'sidebar',
TREE_ITEM = 'tree.item',
TREE = 'tree',
}
export type DragItem = {
id: string;
itemName: string;
};

View File

@@ -210,7 +210,7 @@ function GraphQLExplorerHeader({
/>
</div>
<div className="ml-auto flex gap-1 [&>*]:text-text-subtle">
<IconButton icon="x" size="sm" title="Close documenation explorer" onClick={onClose} />
<IconButton icon="x" size="sm" title="Close documentation explorer" onClick={onClose} />
</div>
</nav>
);

View File

@@ -25,7 +25,7 @@ export function WebPageViewer({ response }: Props) {
srcDoc={contentForIframe}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full rounded border border-border-subtle"
className="h-full w-full rounded-lg border border-border-subtle"
/>
</div>
);

View File

@@ -1,369 +0,0 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import { getAnyModel, patchModelById } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
import React, { useCallback, useRef, useState } from 'react';
import { useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
import { activeRequestIdAtom } from '../../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
import { useHotKey } from '../../hooks/useHotKey';
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
import { getSidebarCollapsedMap } from '../../hooks/useSidebarItemCollapsed';
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
import { jotaiStore } from '../../lib/jotai';
import { router } from '../../lib/router';
import { setWorkspaceSearchParams } from '../../lib/setWorkspaceSearchParams';
import { ContextMenu } from '../core/Dropdown';
import { GitDropdown } from '../GitDropdown';
import type { DragItem } from './dnd';
import { ItemTypes } from './dnd';
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
import type { SidebarItemProps } from './SidebarItem';
import { SidebarItems } from './SidebarItems';
interface Props {
className?: string;
}
export type SidebarModel = Folder | GrpcRequest | HttpRequest | WebsocketRequest | Workspace;
export interface SidebarTreeNode {
id: string;
name: string;
model: SidebarModel['model'];
sortPriority?: number;
workspaceId?: string;
folderId?: string | null;
children: SidebarTreeNode[];
depth: number;
}
export function Sidebar({ className }: Props) {
const [hidden, setHidden] = useSidebarHidden();
const sidebarRef = useRef<HTMLElement>(null);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom);
const [selectedTree, setSelectedTree] = useState<SidebarTreeNode | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<SidebarTreeNode | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom);
const focusActiveRequest = useCallback(
(
args: {
forced?: {
id: string;
tree: SidebarTreeNode;
};
noFocusSidebar?: boolean;
} = {},
) => {
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const id = forced?.id ?? children.find((m) => m.id === activeRequestId)?.id ?? null;
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
if (id == null) {
return;
}
if (!noFocusSidebar) {
sidebarRef.current?.focus();
}
},
[setHasFocus, setSelectedId, treeParentMap],
);
const handleSelect = useCallback(
async (id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.id === id) ?? null;
if (node == null || tree == null || node.model === 'workspace') {
return;
}
// NOTE: I'm not sure why, but TS thinks workspaceId is (string | undefined) here
if (node.model !== 'folder' && node.workspaceId) {
const workspaceId = node.workspaceId;
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId },
search: (prev) => ({ ...prev, request_id: node.id }),
});
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
}
},
[treeParentMap, setSelectedId],
);
const handleClearSelected = useCallback(() => {
setSelectedId(null);
setSelectedTree(null);
}, [setSelectedId]);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest({ noFocusSidebar: true });
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]);
useHotKey(
'sidebar.delete_selected_item',
async () => {
const request = getAnyModel(selectedId ?? 'n/a');
if (request != null) {
await deleteModelWithConfirm(request);
}
},
{ enable: hasFocus },
);
useHotKey('sidebar.focus', async () => {
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
await setHidden(true);
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await setHidden(false);
}
// Select 0th index on focus if none selected
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
});
useKeyPressEvent('Enter', async (e) => {
if (!hasFocus) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || activeWorkspace == null) {
return;
}
e.preventDefault();
setWorkspaceSearchParams({ request_id: selected.id });
});
useKey(
'ArrowUp',
(e) => {
if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newI = i <= 0 ? selectableRequests.length - 1 : i - 1;
const newSelectable = selectableRequests[newI];
if (newSelectable == null) {
return;
}
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
useKey(
'ArrowDown',
(e) => {
if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newI = i >= selectableRequests.length - 1 ? 0 : i + 1;
const newSelectable = selectableRequests[newI];
if (newSelectable == null) {
return;
}
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
const handleMoveToSidebarEnd = useCallback(() => {
setHoveredTree(tree);
// Put at the end of the top tree
setHoveredIndex(tree?.children?.length ?? 0);
}, [tree]);
const handleMove = useCallback<SidebarItemProps['onMove']>(
(id, side) => {
let hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.id === id) ?? -99;
const hoveredItem = hoveredTree?.children[dragIndex] ?? null;
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
const collapsedMap = getSidebarCollapsedMap();
const isHoveredItemCollapsed = hoveredItem != null ? collapsedMap[hoveredItem.id] : false;
if (hoveredItem?.model === 'folder' && side === 'below' && !isHoveredItemCollapsed) {
// Move into the folder if it's open and we're moving below it
hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null;
hoveredIndex = 0;
}
setHoveredTree(hoveredTree);
setHoveredIndex(hoveredIndex);
},
[treeParentMap],
);
const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id);
}, []);
const handleEnd = useCallback<SidebarItemProps['onEnd']>(
async (itemId) => {
setHoveredTree(null);
setDraggingId(null);
handleClearSelected();
if (hoveredTree == null || hoveredIndex == null) {
return;
}
// Block dragging folder into itself
if (hoveredTree.id === itemId) {
return;
}
const parentTree = treeParentMap[itemId] ?? null;
const index = parentTree?.children.findIndex((n) => n.id === itemId) ?? -1;
const child = parentTree?.children[index ?? -1];
if (child == null || parentTree == null) return;
const movedToDifferentTree = hoveredTree.id !== parentTree.id;
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
const newChildren = hoveredTree.children.filter((c) => c.id !== itemId);
if (movedToDifferentTree || movedUpInSameTree) {
// Moving up or into a new tree is simply inserting before the hovered item
newChildren.splice(hoveredIndex, 0, child);
} else {
// Moving down has to account for the fact that the original item will be removed
newChildren.splice(hoveredIndex - 1, 0, child);
}
const insertedIndex = newChildren.findIndex((c) => c.id === child.id);
const prev = newChildren[insertedIndex - 1];
const next = newChildren[insertedIndex + 1];
const beforePriority = prev?.sortPriority ?? 0;
const afterPriority = next?.sortPriority ?? 0;
const folderId = hoveredTree.model === 'folder' ? hoveredTree.id : null;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
await Promise.all(
newChildren.map((child, i) => {
const sortPriority = i * 1000;
return patchModelById(child.model, child.id, { sortPriority, folderId });
}),
);
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
await patchModelById(child.model, child.id, { sortPriority, folderId });
}
},
[handleClearSelected, hoveredTree, hoveredIndex, treeParentMap],
);
const [showMainContextMenu, setShowMainContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const mainContextMenuItems = useCreateDropdownItems({ folderId: null });
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (sidebarRef.current == null) return;
if (!monitor.isOver({ shallow: true })) return;
handleMoveToSidebarEnd();
},
},
[handleMoveToSidebarEnd],
);
connectDrop(sidebarRef);
// Not ready to render yet
if (tree == null) {
return null;
}
return (
<aside
aria-hidden={hidden ?? undefined}
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
onContextMenu={handleMainContextMenu}
data-focused={hasFocus}
className={classNames(
className,
// Style item selection color here, because it's very hard to do in an efficient
// way in the item itself (selection ID makes it hard)
hasFocus && '[&_[data-selected=true]]:bg-surface-active',
'h-full grid grid-rows-[minmax(0,1fr)_auto]',
)}
>
<div className="pb-3 overflow-x-visible overflow-y-scroll pt-2 pr-0.5">
<ContextMenu
triggerPosition={showMainContextMenu}
items={mainContextMenuItems}
onClose={() => setShowMainContextMenu(null)}
/>
<SidebarItems
treeParentMap={treeParentMap}
selectedTree={selectedTree}
tree={tree}
draggingId={draggingId}
onSelect={handleSelect}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
</div>
<GitDropdown />
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show More