Compare commits

..

5 Commits

Author SHA1 Message Date
sanish chirayath
068900866c fix: update preferences saving method in preferences utility (#5617)
* fix: update preferences saving method in preferences utility

* fix: make markAsLaunched asynchronous and improve error handling in onboarding process

* fix: lint errors

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-25 18:40:36 +05:30
Pragadesh-45
fa5ac0d460 Merge pull request #5613 from Pragadesh-45/main 2025-09-25 18:40:29 +05:30
Pooja
c8da13bd9b fix: Add null safety checks in GlobalSearchModal (#5625)
* fix: Add null safety checks in GlobalSearchModal
2025-09-25 18:40:19 +05:30
Pragadesh-45
86727c8525 fix: add Linux support for xdg-portal version in Electron app (#5618) 2025-09-25 18:40:13 +05:30
John Vester
901b6daaea Merge pull request #5582 from johnjvester/5579_correct_spelling
5579 - correct spelling error and introduce constant to avoid duplication
2025-09-25 18:40:05 +05:30
401 changed files with 2426 additions and 16997 deletions

View File

@@ -25,7 +25,7 @@ jobs:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -44,7 +44,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -73,7 +73,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps

View File

@@ -13,7 +13,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
@@ -34,8 +34,6 @@ jobs:
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
# tests
- name: Test Package bruno-js
@@ -66,7 +64,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
@@ -108,7 +106,7 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v5
with:
node-version: v22.11.x

View File

@@ -1 +0,0 @@
npx nano-staged

View File

@@ -37,37 +37,13 @@ Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
[下载 Bruno](https://www.usebruno.com/downloads)
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](../../assets/images/landing-2.png) <br /><br />
## 商业版本 ✨
### 安装
我们的大多数功能都是免费且开源的
我们致力于在 [开源与可持续性发展](https://github.com/usebruno/bruno/discussions/269) 之间取得和谐的平衡
欢迎使用我们的 [付费版本](https://www.usebruno.com/pricing) ,看看附加的功能是否对您或团队有所帮助! <br/>
## 目录
- [安装](#安装)
- [特性](#特性)
- [跨平台使用 🖥️](#跨平台使用-)
- [通过Git协作 👩‍💻🧑‍💻](#通过git协作-)
- [重要链接 📌](#重要链接-)
- [展示 🎥](#展示-)
- [分享评价 📣](#分享评价-)
- [发布到新的包管理器](#发布到新的包管理器)
- [联系方式 🌐](#联系方式-)
- [商标](#商标)
- [贡献 👩‍💻🧑‍💻](#贡献-)
- [作者](#作者)
- [许可证 📄](#许可证-)
## 安装
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) 适用于Mac、Windows 和 Linux 的可执行文件。
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) Mac、Windows 和 Linux 的可执行文件
您也可以通过包管理器如 Homebrew、Chocolatey、Scoop、Snap 和 Apt 安装 Bruno。
@@ -82,15 +58,9 @@ choco install bruno
scoop bucket add extras
scoop install bruno
# 在 Windows 上用 winget 安装
winget install Bruno.Bruno
# 在 Linux 上用 Snap 安装
snap install bruno
# 在 Linux 上用 Flatpak 安装
flatpak install com.usebruno.Bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg curl
@@ -103,50 +73,67 @@ echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebr
sudo apt update && sudo apt install bruno
```
## 特性
### 跨平台使用 🖥️
### 在 Mac 上通过 Homebrew 安装 🖥️
![bruno](../../assets/images/run-anywhere.png) <br /><br />
### 通过Git协作 👩‍💻🧑‍💻
### Collaborate 安装 👩‍💻🧑‍💻
或者任何您选择的版本控制系统
![bruno](../../assets/images/version-control.png) <br /><br />
## 重要链接 📌
### 重要链接 📌
- [我们的愿景](https://github.com/usebruno/bruno/discussions/269)
- [路线图](https://www.usebruno.com/roadmap)
- [路线图](https://github.com/usebruno/bruno/discussions/384)
- [文档](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [网站](https://www.usebruno.com)
- [价格](https://www.usebruno.com/pricing)
- [下载](https://www.usebruno.com/downloads)
- [GitHub 赞助](https://github.com/sponsors/helloanoop).
## 展示 🎥
### 展示 🎥
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
## 分享评价 📣
### 支持 ❤️
如果您喜欢 Bruno 并想支持我们的开源工作,请考虑通过 [GitHub Sponsors](https://github.com/sponsors/helloanoop) 来赞助我们。
### 分享评价 📣
如果 Bruno 在您的工作和团队中帮助了您,请不要忘记在我们的 GitHub 讨论上分享您的 [评价](https://github.com/usebruno/bruno/discussions/343)
## 发布到新的包管理器
### 发布到新的包管理器
如需了解更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
有关更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
## 联系方式 🌐
### 贡献 👩‍💻🧑‍💻
我很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
### 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### 联系方式 🌐
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
[LinkedIn](https://www.linkedin.com/company/usebruno)
## 商标
### 商标
**名称**
@@ -156,20 +143,6 @@ sudo apt update && sudo apt install bruno
Logo 源自 [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
## 贡献 👩‍💻🧑‍💻
很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
## 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
## 许可证 📄
### 许可证 📄
[MIT](../../license.md)

View File

@@ -1,70 +1,8 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
const { fixupPluginRules } = require('@eslint/compat');
const eslintPluginDiff = require('eslint-plugin-diff');
let stylistic;
const runESMImports = async () => {
stylistic = await import('@stylistic/eslint-plugin').then(d => d.default);
};
module.exports = runESMImports().then(() => defineConfig([
{
plugins: {
'diff': fixupPluginRules(eslintPluginDiff),
'@stylistic': stylistic,
},
languageOptions: {
parser: require('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
files: [
'./eslint.config.js',
'tests/**/*.{ts,js}',
'packages/bruno-app/**/*.{js,jsx,ts}',
'packages/bruno-app/src/test-utils/mocks/codemirror.js',
'packages/bruno-cli/**/*.js',
'packages/bruno-common/**/*.ts',
'packages/bruno-converters/**/*.js',
'packages/bruno-electron/**/*.js',
'packages/bruno-filestore/**/*.ts',
'packages/bruno-js/**/*.js',
'packages/bruno-lang/**/*.js',
'packages/bruno-requests/**/*.ts',
'packages/bruno-requests/**/*.js',
],
processor: 'diff/diff',
rules: {
...stylistic.configs.customize({
indent: 2,
quotes: 'single',
semi: true,
jsx: true,
}).rules,
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/curly-newline': ['error', {
multiline: true,
minElements: 2,
consistent: true,
}],
'@stylistic/function-paren-newline': ['error', 'never'],
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/multiline-ternary': ['off'],
'@stylistic/padding-line-between-statements': ['off'],
'@stylistic/semi-style': ['error', 'last'],
'@stylistic/max-len': ['off'],
'@stylistic/jsx-one-expression-per-line': ['off']
},
},
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js", "**/public/**/*"],
@@ -259,4 +197,4 @@ module.exports = runESMImports().then(() => defineConfig([
"no-undef": "error",
},
},
]));
]);

351
package-lock.json generated
View File

@@ -22,25 +22,20 @@
"packages/bruno-filestore"
],
"devDependencies": {
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
@@ -3658,9 +3653,9 @@
}
},
"node_modules/@electron/get/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3788,24 +3783,6 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/compat": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.2.tgz",
"integrity": "sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": "^8.40 || 9"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/config-array": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
@@ -7834,64 +7811,6 @@
"node": ">=16.0.0"
}
},
"node_modules/@stylistic/eslint-plugin": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.3.1.tgz",
"integrity": "sha512-Ykums1VYonM0TgkD0VteVq9mrlO2FhF48MDJnPyv3MktIB2ydtuhlO0AfWm7xnW1kyf5bjOqA6xc7JjviuVTxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/types": "^8.41.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.44.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.0.tgz",
"integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -9221,9 +9140,9 @@
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -10017,16 +9936,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -12893,9 +12802,9 @@
}
},
"node_modules/electron": {
"version": "37.6.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.6.1.tgz",
"integrity": "sha512-aHtJVNjqf0lk7dlPoc1X+fMBpZtLn+XGvP6IYc3gooTwsD1D/Ic2SBRC9SnIk6LkWTsDaSF9jgH1d9Q7eABy/Q==",
"version": "37.2.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.2.6.tgz",
"integrity": "sha512-Ns6xyxE+hIK5UlujtRlw7w4e2Ju/ImCWXf1Q/PoOhc0N3/6SN6YW7+ujCarsHbxWnolbW+1RlkHtdklUJpjbPA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -13355,19 +13264,6 @@
}
}
},
"node_modules/eslint-plugin-diff": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-diff/-/eslint-plugin-diff-2.0.3.tgz",
"integrity": "sha512-pkSwOTjPxqtplq+Ea+9i75QMdmJrItu6oxIG6EWY8bJ5veBNMUrp6Oti+5L6SW20FfjX+CmlZz+fyiWGG1POaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"eslint": ">=6.7.0"
}
},
"node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -13575,15 +13471,15 @@
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn": "^8.14.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14169,9 +14065,9 @@
}
},
"node_modules/extract-zip/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14387,13 +14283,6 @@
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -15114,9 +15003,9 @@
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"optional": true,
@@ -15457,18 +15346,6 @@
"he": "bin/he"
}
},
"node_modules/hexy": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.3.5.tgz",
"integrity": "sha512-UCP7TIZPXz5kxYJnNOym+9xaenxCLor/JyhKieo8y8/bJWunGh9xbhy3YrgYJUQ87WwfXGm05X330DszOfINZw==",
"license": "MIT",
"bin": {
"hexy": "bin/hexy_cmd.js"
},
"engines": {
"node": ">=10.4"
}
},
"node_modules/hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
@@ -15712,9 +15589,9 @@
"license": "MIT"
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
"dev": true,
"license": "BSD-2-Clause"
},
@@ -15915,22 +15792,6 @@
"node": ">=10.17.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/i18next": {
"version": "24.1.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.1.2.tgz",
@@ -18682,28 +18543,6 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/macos-export-certificate-and-key": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/macos-export-certificate-and-key/-/macos-export-certificate-and-key-1.2.4.tgz",
"integrity": "sha512-y5QZEywlBNKd+EhPZ1Hz1FmDbbeQKtuVHJaTlawdl7vXw9bi/4tJB2xSMwX4sMVcddy3gbQ8K0IqXAi2TpDo2g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^4.3.0"
}
},
"node_modules/macos-export-certificate-and-key/node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT",
"optional": true
},
"node_modules/magic-string": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz",
@@ -19383,22 +19222,6 @@
"license": "MIT",
"optional": true
},
"node_modules/nano-staged": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/nano-staged/-/nano-staged-0.8.0.tgz",
"integrity": "sha512-QSEqPGTCJbkHU2yLvfY6huqYPjdBrOaTMKatO1F8nCSrkQGXeKwtCiCnsdxnuMhbg3DTVywKaeWLGCE5oJpq0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0"
},
"bin": {
"nano-staged": "lib/bin.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/nanoclone": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz",
@@ -20297,18 +20120,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidusage": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
"integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
@@ -24881,9 +24692,9 @@
}
},
"node_modules/sumchecker/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -25017,16 +24828,6 @@
"jscat": "bundle.js"
}
},
"node_modules/system-ca": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/system-ca/-/system-ca-2.0.1.tgz",
"integrity": "sha512-9ZDV9yl8ph6Op67wDGPr4LykX86usE9x3le+XZSHfVMiiVJ5IRgmCWjLgxyz35ju9H3GDIJJZm4ogAeIfN5cQQ==",
"license": "Apache-2.0",
"optionalDependencies": {
"macos-export-certificate-and-key": "^1.2.0",
"win-export-certificate-and-key": "^2.1.0"
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@@ -26503,28 +26304,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/win-export-certificate-and-key": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/win-export-certificate-and-key/-/win-export-certificate-and-key-2.1.0.tgz",
"integrity": "sha512-WeMLa/2uNZcS/HWGKU2G1Gzeh3vHpV/UFvwLhJLKxPHYFAbubxxVcJbqmPXaqySWK1Ymymh16zKK5WYIJ3zgzA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^3.1.0"
}
},
"node_modules/win-export-certificate-and-key/node_modules/node-addon-api": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==",
"license": "MIT",
"optional": true
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -30262,7 +30041,6 @@
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
"hexy": "^0.3.5",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
@@ -30271,7 +30049,6 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"pidusage": "^4.0.1",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
@@ -30279,7 +30056,7 @@
"yup": "^0.32.11"
},
"devDependencies": {
"electron": "~37.6.1",
"electron": "~37.2.6",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^4.0.0"
},
@@ -31986,7 +31763,6 @@
"cheerio": "^1.0.0",
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.8",
@@ -32073,12 +31849,9 @@
"@grpc/proto-loader": "^0.7.15",
"@types/qs": "^6.9.18",
"axios": "^1.9.0",
"debug": "^4.4.3",
"grpc-reflection-js": "^0.3.0",
"is-ip": "^5.0.1",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",
"ws": "^8.18.3"
"tough-cookie": "^6.0.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",
@@ -32126,29 +31899,6 @@
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-requests/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"packages/bruno-requests/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"packages/bruno-requests/node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
@@ -32160,27 +31910,6 @@
"node": ">=16"
}
},
"packages/bruno-requests/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"packages/bruno-schema": {
"name": "@usebruno/schema",
"version": "0.7.0",
@@ -32225,8 +31954,7 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",
"ws": "^8.18.3"
"multer": "^1.4.5-lts.1"
}
},
"packages/bruno-tests/node_modules/axios": {
@@ -32346,27 +32074,6 @@
],
"license": "MIT"
},
"packages/bruno-tests/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"packages/bruno-toml": {
"name": "@usebruno/toml",
"version": "0.1.0",

View File

@@ -19,25 +19,20 @@
],
"homepage": "https://usebruno.com",
"devDependencies": {
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
@@ -73,14 +68,7 @@
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
"prepare": "husky"
},
"nano-staged": {
"*.{js,ts,jsx}": [
"npm run lint:fix"
]
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
},
"overrides": {
"rollup": "3.29.5",

View File

@@ -109,17 +109,6 @@ const StyledWrapper = styled.div`
text-decoration:unset;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}
.cm-search-current {
background: rgba(255, 193, 7, 0.4);
}
`;
export default StyledWrapper;

View File

@@ -14,7 +14,6 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -38,10 +37,6 @@ export default class CodeEditor extends React.Component {
expr: true,
asi: true
};
this.state = {
searchBarVisible: false
};
}
componentDidMount() {
@@ -50,7 +45,7 @@ export default class CodeEditor extends React.Component {
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: this.props.enableLineWrapping ?? true,
lineWrapping: true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
@@ -88,14 +83,24 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Ctrl-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -124,11 +129,6 @@ export default class CodeEditor extends React.Component {
} else {
this.editor.toggleComment();
}
},
'Esc': () => {
if (this.state.searchBarVisible) {
this.setState({ searchBarVisible: false });
}
}
},
foldOptions: {
@@ -237,14 +237,6 @@ export default class CodeEditor extends React.Component {
this.editor.scrollTo(null, this.props.initialScroll);
}
if (this.props.enableLineWrapping !== prevProps.enableLineWrapping) {
this.editor.setOption('lineWrapping', this.props.enableLineWrapping);
}
if (this.props.mode !== prevProps.mode) {
this.editor.setOption('mode', this.props.mode);
}
this.ignoreChangeEvent = false;
}
@@ -254,6 +246,11 @@ export default class CodeEditor extends React.Component {
this.editor.off('scroll', this.onScroll);
this.editor = null;
}
this._unbindSearchHandler();
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
render() {
@@ -266,18 +263,10 @@ export default class CodeEditor extends React.Component {
aria-label="Code Editor"
font={this.props.font}
fontSize={this.props.fontSize}
>
<CodeMirrorSearch
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}
/>
<div
className={`editor-container${this.state.searchBarVisible ? ' search-bar-visible' : ''}`}
ref={(node) => { this._node = node; }}
style={{ height: '100%', width: '100%' }}
/>
</StyledWrapper>
ref={(node) => {
this._node = node;
}}
/>
);
}
@@ -301,4 +290,67 @@ export default class CodeEditor extends React.Component {
}
}
};
_isSearchOpen = () => {
return document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
};
/**
* Bind handler to search input to count number of search results
*/
_bindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.addEventListener('input', this._countSearchResults);
}
};
/**
* Unbind handler to search input to count number of search results
*/
_unbindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.removeEventListener('input', this._countSearchResults);
}
};
/**
* Append search results count to search dialog
*/
_appendSearchResultsCount = () => {
const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
if (dialog) {
const searchResultsCount = document.createElement('span');
searchResultsCount.id = this.searchResultsCountElementId;
dialog.appendChild(searchResultsCount);
this._countSearchResults();
}
};
/**
* Count search results and update state
*/
_countSearchResults = () => {
let count = 0;
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput && searchInput.value.length > 0) {
// Escape special characters in search input to prevent RegExp crashes. Fixes #3051
const text = new RegExp(escapeRegExp(searchInput.value), 'gi');
const matches = this.editor.getValue().match(text);
count = matches ? matches.length : 0;
}
const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`);
if (searchResultsCountElement) {
searchResultsCountElement.innerText = `${count} results`;
}
};
}

View File

@@ -1,99 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-search-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
flex-wrap: nowrap;
padding: 0 2px;
min-height: 36px;
background: ${(props) => props.theme.sidebar.search.bg} !important;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.sidebar.search.bg} !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
width: auto;
min-width: 180px;
max-width: 320px;
}
.bruno-search-bar input {
min-width: 80px;
background: transparent;
color: inherit;
border: none;
outline: none;
padding: 1px 2px;
font-size: 13px;
margin: 0 1px;
height: 28px;
}
.searchbar-icon-btn {
background: none;
border: none;
padding: 0 1px;
margin: 0 1px;
cursor: pointer;
color: #aaa;
border-radius: 3px;
height: 18px;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.searchbar-result-count {
min-width: 28px;
text-align: center;
font-size: 11px;
color: #aaa;
margin: 0 8px 0 1px;
white-space: nowrap;
}
.bruno-search-bar.compact {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.codemirror.text || props.theme.text};
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border-radius: 4px;
padding: 1px 3px;
min-height: 22px;
display: flex;
align-items: center;
gap: 0;
}
.bruno-search-bar input {
background: transparent;
color: inherit;
border: none;
outline: none;
font-size: 13px;
padding: 1px 2px;
min-width: 80px;
}
.searchbar-icon-btn:focus {
outline: 1px solid ${(props) => props.theme.codemirror.border};
}
.bruno-search-bar, .bruno-search-bar input {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.searchbar-icon-btn.active {
color: #f39c12 !important;
}
`;
export default StyledWrapper;

View File

@@ -1,201 +0,0 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import useDebounce from 'hooks/useDebounce';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [matchIndex, setMatchIndex] = useState(0);
const [matchCount, setMatchCount] = useState(0);
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
if (!debouncedSearchText) {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
return;
}
try {
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
searchLineHighlight.current = null;
}
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
if (searchLineHighlight.current !== null && editor) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
searchMatches.current = [];
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
setTimeout(() => editor.focus(), 0);
}
}, [editor, onClose]);
const handleSearchTextChange = (text) => {
setSearchText(text);
setMatchIndex(0);
};
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};
if (!visible) return null;
return (
<StyledWrapper>
<div className="bruno-search-bar compact">
<input
autoFocus
type="text"
value={searchText}
onChange={(e) => handleSearchTextChange(e.target.value)}
placeholder="Search..."
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) handleNext();
if (e.key === 'Enter' && e.shiftKey) handlePrev();
if (e.key === 'Escape') handleSearchBarClose();
}}
/>
<span className="searchbar-result-count">{matchCount > 0 ? `${matchIndex + 1} / ${matchCount}` : '0 results'}</span>
<ToolHint text="Regex search" toolhintId="searchbar-regex-toolhint" place="top">
<button className={`searchbar-icon-btn ${regex ? 'active' : ''}`} onClick={handleToggleRegex}><IconRegex size={16} /></button>
</ToolHint>
<ToolHint text="Case sensitive" toolhintId="searchbar-case-toolhint" place="top">
<button className={`searchbar-icon-btn ${caseSensitive ? 'active' : ''}`} onClick={handleToggleCase}><IconLetterCase size={14} /></button>
</ToolHint>
<ToolHint text="Whole word" toolhintId="searchbar-wholeword-toolhint" place="top">
<button className={`searchbar-icon-btn ${wholeWord ? 'active' : ''}`} onClick={handleToggleWholeWord}><IconLetterW size={14} /></button>
</ToolHint>
<button className="searchbar-icon-btn" title="Previous" onClick={handlePrev}><IconArrowUp size={14} /></button>
<button className="searchbar-icon-btn" title="Next" onClick={handleNext}><IconArrowDown size={14} /></button>
<button className="searchbar-icon-btn" title="Close" onClick={handleSearchBarClose}><IconX size={14} /></button>
</div>
</StyledWrapper>
);
};
export default CodeMirrorSearch;

View File

@@ -10,4 +10,4 @@ const StyledWrapper = styled.div`
}
`;
export default StyledWrapper;
export default StyledWrapper;

View File

@@ -0,0 +1,263 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import { existsSync, resolvePath } from '../../../utils/filesystem';
const GrpcSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { grpc: grpcConfig = {} }
} = collection;
const fileInputRef = useRef(null);
const [protoFileValidity, setProtoFileValidity] = useState({});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
protoFiles: grpcConfig.protoFiles || []
},
onSubmit: (newGrpcConfig) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.grpc = newGrpcConfig;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('gRPC settings updated');
}
});
// Get file path using the ipcRenderer
const getProtoFile = (event) => {
const files = event?.files;
if (files && files.length > 0) {
const newProtoFiles = [...formik.values.protoFiles];
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const protoFileObj = {
path: relativePath,
type: 'file'
};
// Check if this path already exists
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
if (!exists) {
newProtoFiles.push(protoFileObj);
}
}
}
formik.setFieldValue('protoFiles', newProtoFiles);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Handler for removing a proto file
const handleRemoveProtoFile = (index) => {
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles.splice(index, 1);
formik.setFieldValue('protoFiles', updatedProtoFiles);
};
// Handle the browse button click
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Check if a proto file path is valid
const isProtoFileValid = async (protoFile) => {
try {
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
return await existsSync(absolutePath);
} catch (error) {
return false;
}
};
// Validate all proto files and update state
useEffect(() => {
const validateProtoFiles = async () => {
const validityMap = {};
for (const file of formik.values.protoFiles) {
validityMap[file.path] = await isProtoFileValid(file);
}
setProtoFileValidity(validityMap);
};
validateProtoFiles();
}, [formik.values.protoFiles, collection.pathname]);
// Handle replacing an invalid proto file
const handleReplaceProtoFile = (index) => {
if (fileInputRef.current) {
fileInputRef.current.click();
// Store the index to replace after file selection
fileInputRef.current.dataset.replaceIndex = index;
}
};
// Handle file input change
const handleFileInputChange = (e) => {
const replaceIndex = e.target.dataset.replaceIndex;
if (replaceIndex !== undefined) {
// Handle replacement
const files = e.target.files;
if (files && files.length > 0) {
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles[replaceIndex] = {
path: relativePath,
type: 'file'
};
formik.setFieldValue('protoFiles', updatedProtoFiles);
}
}
delete e.target.dataset.replaceIndex;
} else {
getProtoFile(e.target);
}
};
return (
<StyledWrapper className="h-full w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3">
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
Add Proto Files
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
<div className="flex flex-col">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
<div className="flex flex-col gap-3">
{/* File selection options */}
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<button
type="button"
className="btn btn-sm btn-secondary flex items-center"
onClick={handleBrowseClick}
>
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
Browse for proto files
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-600 my-2"></div>
{/* List of added proto files */}
<div>
<div className="text-sm font-semibold mb-2 flex items-center">
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
Added Proto Files ({formik.values.protoFiles.length})
</div>
{formik.values.protoFiles.length === 0 ? (
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
) : (
<>
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
</div>
)}
<ul className="mt-4">
{formik.values.protoFiles.map((file, index) => {
const isValid = protoFileValidity[file.path];
return (
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
<div
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
title={file.path}
>
{getBasename(file.path)}
<span className="text-xs text-neutral-500 ml-2">
{getDirPath(file.path)}
</span>
</div>
</div>
<div className="flex w-full items-center justify-end">
{!isValid && (
<div className="flex items-center mr-2">
<IconAlertCircle
size={16}
className="text-red-500"
title="Proto file not found. Click to replace."
/>
<button
type="button"
className="text-xs text-red-500 ml-1 hover:underline"
onClick={() => handleReplaceProtoFile(index)}
>
Replace
</button>
</div>
)}
<button
type="button"
className="remove-certificate ml-2"
onClick={() => handleRemoveProtoFile(index)}
title="Remove file"
>
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default GrpcSettings;

View File

@@ -5,9 +5,11 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const {
brunoConfig: { presets: presets = {} }
} = collection;
@@ -15,10 +17,15 @@ const PresetsSettings = ({ collection }) => {
const formik = useFormik({
enableReinitialize: true,
initialValues: {
requestType: presets.requestType || 'http',
requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http',
requestUrl: presets.requestUrl || ''
},
onSubmit: (newPresets) => {
// If gRPC is disabled but the preset is set to grpc, change it to http
if (!isGrpcEnabled && newPresets.requestType === 'grpc') {
newPresets.requestType = 'http';
}
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.presets = newPresets;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
@@ -63,18 +70,22 @@ const PresetsSettings = ({ collection }) => {
GraphQL
</label>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
{isGrpcEnabled && (
<>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</>
)}
</div>
</div>
<div className="mb-3 flex items-center">

View File

@@ -1,336 +0,0 @@
import React, { useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import {
IconTrash,
IconFile,
IconFileImport,
IconAlertCircle,
IconFolder
} from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
const ProtobufSettings = ({ collection }) => {
const {
protoFiles,
importPaths,
addProtoFileToCollection,
addImportPathToCollection,
toggleImportPath,
browseForProtoFile,
browseForImportDirectory,
removeProtoFileFromCollection,
removeImportPathFromCollection,
replaceImportPathInCollection,
replaceProtoFileInCollection
} = useProtoFileManagement(collection);
const fileInputRef = useRef(null);
// Get file path using the ipcRenderer
const getProtoFile = async (event) => {
const files = event?.files;
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
await addProtoFileToCollection(filePath);
}
}
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveProtoFile = async (index) => {
await removeProtoFileFromCollection(index);
};
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleReplaceProtoFile = async (index) => {
const result = await browseForProtoFile();
if (result.success) {
await replaceProtoFileInCollection(index, result.filePath);
}
};
const handleReplaceImportPath = async (index) => {
const result = await browseForImportDirectory();
if (result.success) {
await replaceImportPathInCollection(index, result.directoryPath);
}
};
const handleFileInputChange = (e) => {
getProtoFile(e.target);
};
const getImportPath = async () => {
const result = await browseForImportDirectory();
if (result.success) {
await addImportPathToCollection(result.directoryPath);
}
};
const handleRemoveImportPath = async (index) => {
await removeImportPathFromCollection(index);
};
const handleToggleImportPath = async (index) => {
await toggleImportPath(index);
};
const handleBrowseImportPathClick = () => {
getImportPath();
};
return (
<StyledWrapper className="h-full w-full">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
{/* Proto Files Section */}
<div className="mb-6" data-testid="protobuf-proto-files-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="protoFiles">
Proto Files (
{protoFiles.length}
)
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
</div>
</div>
<div>
{protoFiles.some((file) => !file.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-files-message">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found. Use the replace option to update their locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-proto-files-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
File
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{protoFiles.length === 0 ? (
<tr>
<td colSpan="3" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFile size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No proto files added</span>
</div>
</td>
</tr>
) : (
protoFiles.map((file, index) => {
const isValid = file.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFile size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, file.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{file.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceProtoFile(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace file"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveProtoFile(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove file"
data-testid="protobuf-remove-file-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseClick} data-testid="protobuf-add-file-button">
+ Add Proto File
</button>
</div>
</div>
{/* Import Paths Section */}
<div className="mb-6" data-testid="protobuf-import-paths-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="importPaths">
Import Paths (
{importPaths.length}
)
<span id="import-paths-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="import-paths-tooltip"
className="tooltip-mod font-normal"
html="Add directories that contain proto files to be imported. These paths help resolve import statements in your proto files."
/>
</label>
</div>
</div>
<div>
{importPaths.some((path) => !path.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-import-paths-message">
<IconAlertCircle size={14} className="mr-1" />
Some import paths cannot be found at their specified locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-import-paths-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Directory
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{importPaths.length === 0 ? (
<tr>
<td colSpan="4" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFolder size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No import paths added</span>
</div>
</td>
</tr>
) : (
importPaths.map((importPath, index) => {
const isValid = importPath.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<input
type="checkbox"
checked={importPath.enabled}
onChange={() => handleToggleImportPath(index)}
className="h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300 dark:border-gray-600 rounded"
title={importPath.enabled ? 'Disable this import path' : 'Enable this import path'}
data-testid="protobuf-import-path-checkbox"
/>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFolder size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, importPath.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{importPath.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceImportPath(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace directory"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveImportPath(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove import path"
data-testid="protobuf-remove-import-path-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseImportPathClick} data-testid="protobuf-add-import-path-button">
+ Add Import Path
</button>
</div>
</div>
</StyledWrapper>
);
};
export default ProtobufSettings;

View File

@@ -13,14 +13,16 @@ import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Protobuf from './Protobuf';
import Grpc from './Grpc';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const tab = collection.settingsSelectedTab;
const setTab = (tab) => {
dispatch(
@@ -44,13 +46,9 @@ const CollectionSettings = ({ collection }) => {
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const presets = get(collection, 'brunoConfig.presets', []);
const hasPresets = presets && presets.requestUrl !== "";
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
@@ -127,8 +125,8 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'protobuf': {
return <Protobuf collection={collection} />;
case 'grpc': {
return <Grpc collection={collection} />;
}
}
};
@@ -167,20 +165,21 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
{isGrpcEnabled && (
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
gRPC
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
</div>
)}
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>

View File

@@ -12,8 +12,7 @@ import {
IconCode,
IconChevronDown,
IconTerminal2,
IconNetwork,
IconDashboard,
IconNetwork
} from '@tabler/icons';
import {
closeConsole,
@@ -25,12 +24,10 @@ import {
updateNetworkFilter,
toggleAllNetworkFilters
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
import RequestDetailsPanel from './RequestDetailsPanel';
// import DebugTab from './DebugTab';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import Performance from '../Performance';
import StyledWrapper from './StyledWrapper';
const LogIcon = ({ type }) => {
@@ -387,8 +384,6 @@ const Console = () => {
);
case 'network':
return <NetworkTab />;
case 'performance':
return <Performance />;
// case 'debug':
// return <DebugTab />;
default:
@@ -489,14 +484,6 @@ const Console = () => {
<span>Network</span>
</button>
<button
className={`console-tab ${activeTab === 'performance' ? 'active' : ''}`}
onClick={() => handleTabChange('performance')}
>
<IconDashboard size={16} strokeWidth={1.5} />
<span>Performance</span>
</button>
{/* <button
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
onClick={() => handleTabChange('debug')}

View File

@@ -1,120 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
background: ${props => props.theme.console.bg};
}
.tab-content-area {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.overview-container {
max-width: 1200px;
margin: 0 auto;
}
.overview-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
}
.section-header {
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid ${props => props.theme.console.border};
h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
}
p {
margin: 0;
font-size: 13px;
color: ${props => props.theme.console.textMuted};
}
}
.system-resources {
margin-bottom: 16px;
h2 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
}
}
.resource-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.resource-card {
background: ${props => props.theme.console.headerBg};
border: 1px solid ${props => props.theme.console.border};
border-radius: 4px;
padding: 8px;
}
.resource-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: ${props => props.theme.console.titleColor};
}
.resource-title {
font-size: 12px;
font-weight: 500;
}
.resource-value {
font-size: 18px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
margin-bottom: 2px;
}
.resource-subtitle {
font-size: 11px;
color: ${props => props.theme.console.buttonColor};
}
.resource-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
margin-top: 8px;
&.up {
color: #10b981;
}
&.down {
color: #e81123;
}
&.stable {
color: ${props => props.theme.console.buttonColor};
}
}
`;
export default StyledWrapper;

View File

@@ -1,100 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import {
IconCpu,
IconDatabase,
IconClock,
IconServer,
IconChartLine,
} from '@tabler/icons';
const Performance = () => {
const { systemResources } = useSelector(state => state.performance);
const formatBytes = bytes => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatUptime = seconds => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
};
const SystemResourceCard = ({ icon: Icon, title, value, subtitle, color = 'default', trend }) => (
<div className={`resource-card ${color}`}>
<div className="resource-header">
<Icon size={20} strokeWidth={1.5} />
<span className="resource-title">{title}</span>
</div>
<div className="resource-value">{value}</div>
{subtitle && <div className="resource-subtitle">{subtitle}</div>}
{trend && (
<div className={`resource-trend ${trend > 0 ? 'up' : trend < 0 ? 'down' : 'stable'}`}>
<IconChartLine size={12} strokeWidth={1.5} />
<span>
{trend > 0 ? '+' : ''}
{trend.toFixed(1)}
%
</span>
</div>
)}
</div>
);
return (
<StyledWrapper>
<div className="tab-content">
<div className="tab-content-area">
<div className="system-resources">
<h2>System Resources</h2>
<div className="resource-cards">
<SystemResourceCard
icon={IconCpu}
title="CPU Usage"
value={`${systemResources.cpu.toFixed(1)}%`}
subtitle="Current process"
color={systemResources.cpu > 80 ? 'danger' : systemResources.cpu > 60 ? 'warning' : 'success'}
/>
<SystemResourceCard
icon={IconDatabase}
title="Memory Usage"
value={formatBytes(systemResources.memory)}
subtitle="Current process"
color={systemResources.memory > 500 * 1024 * 1024 ? 'danger' : 'default'}
/>
<SystemResourceCard
icon={IconClock}
title="Uptime"
value={formatUptime(systemResources.uptime)}
subtitle="Process runtime"
color="info"
/>
<SystemResourceCard
icon={IconServer}
title="Process ID"
value={systemResources.pid || 'N/A'}
subtitle="Current PID"
color="default"
/>
</div>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default Performance;

View File

@@ -2,12 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: 'parent' }
: { ...props, trigger: 'click', interactive: true, appendTo: 'parent' };
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
@@ -16,7 +11,10 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, .
animation={false}
arrow={false}
onCreate={onCreate}
{...tippyProps}
interactive={true}
trigger="click"
appendTo="parent"
{...props}
>
{icon}
</Tippy>

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
const EnvironmentListContent = ({
environments,
@@ -19,30 +18,17 @@ const EnvironmentListContent = ({
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<ToolHint
anchorSelect="[data-tooltip-content]"
place="right"
positionStrategy="fixed"
tooltipStyle={{
maxWidth: '200px',
wordWrap: 'break-word'
}}
delayShow={1000}
>
<div>
{environments.map((env) => (
<div
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}
</div>
</ToolHint>
<div>
{environments.map((env) => (
<div
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
>
<span className="max-w-32 truncate no-wrap">{env.name}</span>
</div>
))}
</div>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env">
<IconSettings size={16} strokeWidth={1.5} />

View File

@@ -23,7 +23,6 @@ const Wrapper = styled.div`
.env-text {
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: 0.875rem;
display: block;
}
.env-separator {
@@ -46,10 +45,8 @@ const Wrapper = styled.div`
}
.tippy-box {
width: ${(props) => props.width}px;
min-width: 12rem;
max-width: 650px !important;
min-height: 15.5rem;
min-width: 11.875rem;
min-height: 15.0625rem;
max-height: 75vh;
font-size: 0.8125rem;
position: relative;

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useRef, forwardRef } from 'react';
import React, { useState, useRef, forwardRef } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
@@ -14,7 +14,6 @@ import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
import ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
const EnvironmentSelector = ({ collection }) => {
@@ -114,18 +113,6 @@ const EnvironmentSelector = ({ collection }) => {
dispatch(updateEnvironmentSettingsModalVisibility(false));
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
return maxCharLength * 8;
}, [environments, globalEnvironments]);
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
@@ -136,15 +123,7 @@ const EnvironmentSelector = ({ collection }) => {
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
<span className="env-text max-w-24 truncate no-wrap">{activeCollectionEnvironment.name}</span>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
@@ -152,15 +131,7 @@ const EnvironmentSelector = ({ collection }) => {
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeGlobalEnvironment.name}
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeGlobalEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
</ToolHint>
<span className="env-text max-w-24 truncate no-wrap">{activeGlobalEnvironment.name}</span>
</div>
)}
</>
@@ -183,11 +154,11 @@ const EnvironmentSelector = ({ collection }) => {
});
return (
<StyledWrapper width={dropdownWidth}>
<StyledWrapper>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{/* Tab Headers */}
<div className="tab-header flex p-[0.75rem]">
<div className="tab-header flex justify-center p-[0.75rem]">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -222,12 +193,7 @@ const EnvironmentSelector = ({ collection }) => {
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{showGlobalSettings && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
onClose={handleCloseSettings}
/>
<GlobalEnvironmentSettings globalEnvironments={globalEnvironments} collection={collection} onClose={handleCloseSettings} />
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}

View File

@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -48,7 +48,15 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -61,7 +69,7 @@ const Auth = ({ collection, folder }) => {
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder

View File

@@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
@@ -41,7 +41,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
value: Yup.string().trim().nullable()
})
),
onSubmit: (values) => {
@@ -136,7 +136,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
/>
</td>
<td>
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
@@ -153,31 +153,16 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.name}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.name}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
<input

View File

@@ -39,7 +39,7 @@ const DefaultTab = ({ setTab }) => {
);
};
const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => {
const EnvironmentSettings = ({ globalEnvironments, collection, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
@@ -64,7 +64,6 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}

View File

@@ -90,4 +90,4 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);
);

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { IconChevronDown, IconX } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import Dropdown from 'components/Dropdown';
const InheritableSettingsInput = ({
id,
label,
value,
description,
onKeyDown,
isInherited,
onDropdownSelect,
onValueChange,
onCustomValueReset
}) => {
const { theme } = useTheme();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="flex items-center justify-end">
{isInherited ? (
<Dropdown
icon={(
<button
type="button"
className="px-2 py-1 text-xs rounded-sm outline-none transition-colors duration-100 w-24 h-8 flex items-center justify-between"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
>
<span>Inherit</span>
<IconChevronDown size={12} />
</button>
)}
>
<div className="dropdown-item" onClick={() => onDropdownSelect('inherit')}>
Inherit
</div>
<div className="dropdown-item" onClick={() => onDropdownSelect('custom')}>
Custom
</div>
</Dropdown>
) : (
<div className="relative">
<input
id={id}
type="text"
className="block px-2 py-1 pr-6 rounded-sm outline-none transition-colors duration-100 w-24 h-8"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={value}
onChange={onValueChange}
onKeyDown={onKeyDown}
/>
<button
type="button"
onClick={onCustomValueReset}
className="absolute right-1 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
title="Reset to inherit"
>
<IconX size={14} />
</button>
</div>
)}
</div>
</div>
);
};
export default InheritableSettingsInput;

View File

@@ -78,14 +78,6 @@ const StyledMarkdownBodyWrapper = styled.div`
background-color: ${(props) => props.theme.bg};
}
}
p {
white-space: pre-wrap;
}
div {
white-space: pre-wrap;
}
}
`;

View File

@@ -6,9 +6,6 @@ import { isValidUrl } from 'utils/url/index';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
html: true,
breaks: true,
linkify: true,
replaceLink: function (link, env) {
return link.replace(/^\./, collectionPath);
}

View File

@@ -9,8 +9,7 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header">
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel && !hideClose ? (
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
×
</div>
) : null}

View File

@@ -6,19 +6,6 @@ const StyledWrapper = styled.div`
max-height: 200px;
overflow: auto;
&.read-only {
.CodeMirror .CodeMirror-lines {
cursor: not-allowed !important;
user-select: none !important;
-webkit-user-select: none !important;
-ms-user-select: none !important;
}
.CodeMirror-line {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
.CodeMirror {
background: transparent;
height: fit-content;

View File

@@ -35,7 +35,6 @@ class MultiLineEditor extends Component {
brunoVarInfo: {
variables
},
readOnly: this.props.readOnly ? 'nocursor' : false,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
@@ -127,9 +126,6 @@ class MultiLineEditor extends Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
@@ -186,10 +182,9 @@ class MultiLineEditor extends Component {
};
render() {
const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className={wrapperClass} />
<StyledWrapper ref={this.editorRef} className="multi-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>
);

View File

@@ -10,6 +10,11 @@ import get from 'lodash/get';
// Beta features configuration
const BETA_FEATURES = [
{
id: 'grpc',
label: 'gRPC Support',
description: 'Enable gRPC request support for making gRPC calls to services'
},
{
id: 'nodevm',
label: 'Node VM Runtime',
@@ -98,6 +103,16 @@ const Beta = ({ close }) => {
<label className="block ml-2 select-none font-medium" htmlFor={feature.id}>
{feature.label}
</label>
{feature.id === 'grpc' && (
<a
href="https://github.com/usebruno/bruno/discussions/5447"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-blue-500 hover:text-blue-600 underline"
>
Share feedback
</a>
)}
</div>
<div className="beta-feature-description ml-6 text-xs text-gray-500 dark:text-gray-400">
{feature.description}

View File

@@ -3,7 +3,6 @@ import get from 'lodash/get';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
@@ -36,8 +35,7 @@ const General = ({ close }) => {
})
.test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => {
return value === undefined || Number(value) >= 0;
}),
defaultCollectionLocation: Yup.string().max(1024)
})
});
const formik = useFormik({
@@ -52,8 +50,7 @@ const General = ({ close }) => {
},
timeout: preferences.request.timeout,
storeCookies: get(preferences, 'request.storeCookies', true),
sendCookies: get(preferences, 'request.sendCookies', true),
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
sendCookies: get(preferences, 'request.sendCookies', true)
},
validationSchema: preferencesSchema,
onSubmit: async (values) => {
@@ -82,9 +79,6 @@ const General = ({ close }) => {
timeout: newPreferences.timeout,
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
},
general: {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.then(() => {
@@ -105,19 +99,6 @@ const General = ({ close }) => {
formik.setFieldValue('customCaCertificate.filePath', null);
};
const browseDefaultLocation = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('defaultCollectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('defaultCollectionLocation', '');
console.error(error);
});
};
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
@@ -250,35 +231,6 @@ const General = ({ close }) => {
{formik.touched.timeout && formik.errors.timeout ? (
<div className="text-red-500">{formik.errors.timeout}</div>
) : null}
<div className="flex flex-col mt-6">
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection Location
</label>
<input
type="text"
name="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.defaultCollectionLocation || ''}
onClick={browseDefaultLocation}
placeholder="Click to browse for default location"
/>
<div className="mt-1">
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse"
onClick={browseDefaultLocation}
>
Browse
</span>
</div>
</div>
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
) : null}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save

View File

@@ -9,7 +9,6 @@ import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -28,7 +27,6 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
};
const Auth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
@@ -39,7 +37,7 @@ const Auth = ({ item, collection }) => {
// Save function for request level
const save = () => {
return dispatch(saveRequest(item.uid, collection.uid));
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {

View File

@@ -17,218 +17,229 @@ import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
}
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(
methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
}
);
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
dispatch(updateRequestBody({
content: currentMessages,
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(collection.pathname, protoPath);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
});
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed
? <IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
: <IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? 'Send gRPC message' : 'Connection not active'} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ?
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" /> :
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? "Send gRPC message" : "Connection not active"} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "h-80"} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode='application/ld+json'
enableVariableHighlighting={true}
/>
</div>
)}
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};
)
}
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -237,10 +248,10 @@ const GrpcBody = ({ item, collection, handleRun }) => {
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
@@ -248,7 +259,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
container.scrollTop = container.scrollHeight;
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages(prev => {
if (prev.includes(index)) {
@@ -258,31 +269,34 @@ const GrpcBody = ({ item, collection, handleRun }) => {
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
@@ -294,21 +308,21 @@ const GrpcBody = ({ item, collection, handleRun }) => {
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'}`}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "overflow-y-auto"} ${canClientSendMultipleMessages && "pb-16"}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
<SingleGrpcMessage
key={index}
message={message}
item={item}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
@@ -317,13 +331,13 @@ const GrpcBody = ({ item, collection, handleRun }) => {
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
@@ -337,4 +351,4 @@ const GrpcBody = ({ item, collection, handleRun }) => {
);
};
export default GrpcBody;
export default GrpcBody;

View File

@@ -1,64 +0,0 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconCheck, IconCopy } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import Modal from 'components/Modal/index';
import CodeEditor from 'components/CodeEditor';
const GrpcurlModal = ({ isOpen, onClose, command }) => {
const { displayedTheme } = useTheme();
const [copied, setCopied] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
toast.success('Command copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast.error('Failed to copy command');
}
};
return (
<Modal
isOpen={isOpen}
handleCancel={onClose}
title={(
<div className="flex items-center gap-2">
<span>Generate gRPCurl Command</span>
</div>
)}
size="lg"
hideFooter={true}
>
<div>
<div className="flex w-full min-h-[400px]">
<div className="flex-grow relative">
<div className="absolute top-2 right-2 z-10">
<button
onClick={handleCopy}
className="btn btn-sm btn-secondary flex items-center gap-2"
>
{copied ? <IconCheck size={20} /> : <IconCopy size={20} />}
</button>
</div>
<CodeEditor
value={command}
theme={displayedTheme}
readOnly={true}
mode="shell"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
</div>
</div>
</div>
</Modal>
);
};
export default GrpcurlModal;

View File

@@ -1,131 +0,0 @@
import React, { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown/index';
import {
IconGrpcUnary,
IconGrpcClientStreaming,
IconGrpcServerStreaming,
IconGrpcBidiStreaming
} from 'components/Icons/Grpc';
const MethodDropdown = ({
grpcMethods,
selectedGrpcMethod,
onMethodSelect,
onMethodDropdownCreate
}) => {
const groupMethodsByService = (methods) => {
if (!methods || !methods.length) return {};
const groupedMethods = {};
methods.forEach((method) => {
const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path;
const parts = pathWithoutLeadingSlash.split('/');
const serviceName = parts[0] || 'Default';
const methodName = parts[1] || method.path;
const enhancedMethod = {
...method,
serviceName,
methodName
};
if (!groupedMethods[serviceName]) {
groupedMethods[serviceName] = [];
}
groupedMethods[serviceName].push(enhancedMethod);
});
return groupedMethods;
};
const getIconForMethodType = (type) => {
switch (type) {
case 'unary':
return <IconGrpcUnary size={20} strokeWidth={2} />;
case 'client-streaming':
return <IconGrpcClientStreaming size={20} strokeWidth={2} />;
case 'server-streaming':
return <IconGrpcServerStreaming size={20} strokeWidth={2} />;
case 'bidi-streaming':
return <IconGrpcBidiStreaming size={20} strokeWidth={2} />;
default:
return <IconGrpcUnary size={20} strokeWidth={2} />;
}
};
const MethodsDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center ml-2 cursor-pointer select-none">
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
<span className="text-xs">
{selectedGrpcMethod ? (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">
{selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
</span>
) : (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">Select Method </span>
)}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
const handleGrpcMethodSelect = (method) => {
const methodType = method.type;
onMethodSelect({ path: method.path, type: methodType });
};
if (!grpcMethods || grpcMethods.length === 0) {
return null;
}
return (
<div className="flex items-center h-full mr-2" data-testid="grpc-methods-dropdown">
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }}>
<div className="max-h-96 overflow-y-auto max-w-96 min-w-60" data-testid="grpc-methods-list">
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
<div key={serviceIndex} className="service-group mb-2">
<div className="service-header px-3 py-1 bg-neutral-100 dark:bg-neutral-800 text-sm font-medium truncate sticky top-0 z-10">
{serviceName || 'Default Service'}
</div>
<div className="service-methods">
{methods.map((method, methodIndex) => (
<div
key={`${serviceIndex}-${methodIndex}`}
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
selectedGrpcMethod && selectedGrpcMethod.path === method.path
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`}
onClick={() => handleGrpcMethodSelect(method)}
data-testid="grpc-method-item"
>
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
{getIconForMethodType(method.type)}
</div>
<div className="flex flex-col flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{method.methodName}
</div>
<div className="text-xs text-gray-500">
{method.type}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</Dropdown>
</div>
);
};
export default MethodDropdown;

View File

@@ -1,217 +0,0 @@
import React, { forwardRef, useState } from 'react';
import { IconFile, IconChevronDown } from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { updateRequestProtoPath } from 'providers/ReduxStore/slices/collections';
import { openCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown/index';
import ToggleSwitch from 'components/ToggleSwitch/index';
import { TabNavigation, ProtoFilesTab, ImportPathsTab } from '../Tabs';
import useProtoFileManagement from 'hooks/useProtoFileManagement/index';
const ProtoFileDropdown = ({
collection,
item,
isReflectionMode,
protoFilePath,
showProtoDropdown,
setShowProtoDropdown,
onProtoDropdownCreate,
onReflectionModeToggle,
onProtoFileLoad
}) => {
const { theme } = useTheme();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('protofiles'); // 'protofiles' or 'importpaths'
const protoFileManagement = useProtoFileManagement(collection, protoFilePath);
const invalidProtoFiles = protoFileManagement.protoFiles.filter((file) => !file.exists);
const invalidImportPaths = protoFileManagement.importPaths.filter((path) => !path.exists);
const handleSelectProtoFile = async (e) => {
e.stopPropagation();
const { success, filePath, error } = await protoFileManagement.browseForProtoFile();
if (!success) {
if (error) {
toast.error(`Failed to browse for proto file: ${error.message}`);
}
return;
}
const { success: addSuccess, relativePath, alreadyExists, error: addError } = await protoFileManagement.addProtoFileToCollection(filePath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add proto file: ${addError.message}`);
}
return;
}
if (alreadyExists) {
toast.error('Proto file already exists in collection settings');
} else {
toast.success('Added proto file to collection');
}
dispatch(updateRequestProtoPath({
protoPath: relativePath,
itemUid: item.uid,
collectionUid: collection.uid
}));
setShowProtoDropdown(false);
onProtoFileLoad(relativePath);
};
const handleSelectCollectionProtoFile = (protoFile) => {
if (!protoFile || !protoFile.exists) {
toast.error('Proto file not found');
return;
}
setShowProtoDropdown(false);
dispatch(updateRequestProtoPath({
protoPath: protoFile.path,
itemUid: item.uid,
collectionUid: collection.uid
}));
onProtoFileLoad(protoFile.path);
};
const handleBrowseImportPath = async (e) => {
e.stopPropagation();
const { success, directoryPath, error } = await protoFileManagement.browseForImportDirectory();
if (!success) {
if (error) {
toast.error(`Failed to browse for import directory: ${error.message}`);
}
return;
}
const { success: addSuccess, error: addError } = await protoFileManagement.addImportPathToCollection(directoryPath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add import path: ${addError.message}`);
}
return;
}
toast.success('Added import path to collection');
};
const handleToggleImportPath = async (index) => {
const { success, enabled, error } = await protoFileManagement.toggleImportPath(index);
if (!success) {
if (error) {
toast.error(`Failed to toggle import path: ${error.message}`);
}
return;
}
toast.success(`Import path ${enabled ? 'enabled' : 'disabled'}`);
};
const handleOpenCollectionProtobufSettings = (e) => {
e.stopPropagation();
dispatch(openCollectionSettings(collection.uid, 'protobuf'));
};
const ProtoFileDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center cursor-pointer select-none" onClick={() => setShowProtoDropdown((prev) => !prev)} data-testid="grpc-proto-file-dropdown-icon">
{isReflectionMode ? (<></>
) : (
<IconFile size={20} strokeWidth={1.5} className="mr-1 text-neutral-400" />
)}
<span className="text-xs dark:text-neutral-300 text-neutral-700 text-nowrap">
{isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(collection.pathname, protoFilePath) : 'Select Proto File')}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
return (
<div className="proto-file-dropdown">
<Dropdown
onCreate={onProtoDropdownCreate}
icon={<ProtoFileDropdownIcon />}
placement="bottom-end"
visible={showProtoDropdown}
onClickOutside={() => setShowProtoDropdown(false)}
data-testid="grpc-proto-file-dropdown"
>
<div className="max-h-fit overflow-y-auto w-[30rem]">
<div className="px-3 py-2 border-b border-neutral-200 dark:border-neutral-700" data-testid="grpc-mode-toggle">
<div className="flex items-center justify-between">
<span className="text-sm">Mode</span>
<div className="flex items-center gap-2">
<span className={`text-xs ${!isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: !isReflectionMode ? theme.colors.text.yellow : undefined }}>
Proto File
</span>
<ToggleSwitch
isOn={isReflectionMode}
handleToggle={onReflectionModeToggle}
size="2xs"
activeColor={theme.colors.text.yellow}
/>
<span className={`text-xs ${isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: isReflectionMode ? theme.colors.text.yellow : undefined }}>
Reflection
</span>
</div>
</div>
</div>
{!isReflectionMode && (
<TabNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
collectionProtoFiles={protoFileManagement.protoFiles}
collectionImportPaths={protoFileManagement.importPaths}
/>
)}
{!isReflectionMode && (
<>
{activeTab === 'protofiles' && (
<ProtoFilesTab
collectionProtoFiles={protoFileManagement.protoFiles}
invalidProtoFiles={invalidProtoFiles}
protoFilePath={protoFilePath}
collection={collection}
onSelectCollectionProtoFile={handleSelectCollectionProtoFile}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onSelectProtoFile={handleSelectProtoFile}
setShowProtoDropdown={setShowProtoDropdown}
/>
)}
{activeTab === 'importpaths' && (
<ImportPathsTab
collectionImportPaths={protoFileManagement.importPaths}
invalidImportPaths={invalidImportPaths}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onBrowseImportPath={handleBrowseImportPath}
onToggleImportPath={handleToggleImportPath}
/>
)}
</>
)}
{isReflectionMode && (
<div className="px-3 py-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
Using server reflection to discover gRPC methods.
</div>
</div>
)}
</div>
</Dropdown>
</div>
);
};
export default ProtoFileDropdown;

View File

@@ -1,154 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.content-wrapper {
padding: 0.5rem 0.75rem;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.header-text {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.header.text};
}
.settings-button {
color: ${(props) => props.theme.grpc.importPaths.header.button.color};
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.header.button.hoverColor};
}
}
.error-wrapper {
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: ${(props) => props.theme.grpc.importPaths.error.bg};
border-radius: 0.25rem;
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.error.text};
}
.error-text {
display: flex;
align-items: center;
margin: 0;
}
.error-link {
color: ${(props) => props.theme.grpc.importPaths.error.link.color};
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
font-size: inherit;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.error.link.hoverColor};
}
}
.items-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 15rem;
overflow: auto;
max-width: 30rem;
}
.item-wrapper {
padding: 0.5rem 0.75rem;
opacity: ${(props) => props.theme.grpc.importPaths.item.invalid.opacity};
&.valid {
opacity: 1;
}
}
.item-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-left {
display: flex;
align-items: center;
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-right: 0.75rem;
}
.checkbox {
margin-right: 0.5rem;
cursor: pointer;
color: ${(props) => props.theme.grpc.importPaths.item.checkbox.color};
}
.item-text {
display: flex;
align-items: center;
font-size: 0.75rem;
white-space: nowrap;
}
.invalid-icon {
color: ${(props) => props.theme.grpc.importPaths.item.invalid.text};
font-size: 0.75rem;
display: flex;
align-items: center;
}
.empty-wrapper {
padding: 0.5rem 0.75rem;
}
.empty-text {
color: ${(props) => props.theme.grpc.importPaths.empty.text};
font-size: 0.875rem;
font-style: italic;
text-align: center;
padding: 0.5rem 0;
}
.button-wrapper {
padding: 0.5rem 0.75rem;
}
.browse-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${(props) => props.theme.grpc.importPaths.button.bg};
color: ${(props) => props.theme.grpc.importPaths.button.color};
border: 1px solid ${(props) => props.theme.grpc.importPaths.button.border};
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: ${(props) => props.theme.grpc.importPaths.button.hoverBorder};
}
}
`;
export default Wrapper;

View File

@@ -1,102 +0,0 @@
import React from 'react';
import { IconFolder, IconSettings, IconAlertCircle, IconFileImport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ImportPathsTab = ({
collectionImportPaths,
invalidImportPaths,
onOpenCollectionProtobufSettings,
onBrowseImportPath,
onToggleImportPath
}) => {
return (
<StyledWrapper>
{collectionImportPaths && collectionImportPaths.length > 0 && (
<div className="content-wrapper">
<div className="header-wrapper">
<div className="header-text">From Collection Settings</div>
<button
onClick={onOpenCollectionProtobufSettings}
className="settings-button"
>
<IconSettings size={16} strokeWidth={1.5} />
</button>
</div>
{invalidImportPaths.length > 0 && (
<div className="error-wrapper">
<p className="error-text">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Some import paths could not be found.
{' '}
<button
onClick={onOpenCollectionProtobufSettings}
className="error-link"
>
Manage import paths
</button>
</p>
</div>
)}
<div className="items-container">
{collectionImportPaths.map((importPath, index) => {
const isInvalid = !importPath.exists;
return (
<div
key={`collection-import-${index}`}
className={`item-wrapper ${!isInvalid ? 'valid' : ''}`}
>
<div className="item-content">
<div className="item-left">
<div className="checkbox-wrapper">
<input
type="checkbox"
checked={importPath.enabled}
disabled={isInvalid}
onChange={() => onToggleImportPath(index)}
className="checkbox"
title={importPath.enabled ? 'Import path enabled' : 'Import path disabled'}
/>
</div>
<IconFolder size={20} strokeWidth={1.5} style={{ marginRight: '0.5rem', color: 'inherit' }} />
<div className="item-text">
{importPath.path}
{isInvalid && (
<span className="invalid-icon">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ margin: '0 0.25rem' }} />
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(!collectionImportPaths || collectionImportPaths.length === 0) && (
<div className="empty-wrapper">
<div className="empty-text">
No import paths configured in collection settings
</div>
</div>
)}
<div className="button-wrapper">
<button
className="browse-button"
onClick={onBrowseImportPath}
>
<IconFileImport size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Browse for Import Path
</button>
</div>
</StyledWrapper>
);
};
export default ImportPathsTab;

View File

@@ -1,172 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.content-wrapper {
padding: 0.5rem 0.75rem;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.header-text {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.header.text};
}
.settings-button {
color: ${(props) => props.theme.grpc.protoFiles.header.button.color};
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease;
&:hover {
color: ${(props) => props.theme.grpc.protoFiles.header.button.hoverColor};
}
}
.error-wrapper {
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: ${(props) => props.theme.grpc.protoFiles.error.bg};
border-radius: 0.25rem;
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.error.text};
}
.error-text {
display: flex;
align-items: center;
margin: 0;
}
.error-link {
color: ${(props) => props.theme.grpc.protoFiles.error.link.color};
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
font-size: inherit;
&:hover {
color: ${(props) => props.theme.grpc.protoFiles.error.link.hoverColor};
}
}
.items-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 15rem;
overflow-y: auto;
}
.item-wrapper {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-left: 2px solid transparent;
background-color: ${(props) => props.theme.grpc.protoFiles.item.bg};
transition: all 0.2s ease;
opacity: ${(props) => props.theme.grpc.protoFiles.item.invalid.opacity};
&.valid {
opacity: 1;
}
&.selected {
border-left-color: ${(props) => props.theme.grpc.protoFiles.item.selected.border};
background-color: ${(props) => props.theme.grpc.protoFiles.item.selected.bg};
}
&:hover {
background-color: ${(props) => props.theme.grpc.protoFiles.item.hoverBg};
&.selected {
background-color: ${(props) => props.theme.grpc.protoFiles.item.selected.bg};
}
}
}
.item-content {
display: flex;
align-items: center;
}
.item-icon {
margin-right: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.item.icon};
}
.item-details {
display: flex;
flex-direction: column;
}
.item-title {
font-size: 0.875rem;
display: flex;
align-items: center;
color: ${(props) => props.theme.grpc.protoFiles.item.text};
}
.item-path {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.item.secondaryText};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12.5rem;
}
.invalid-icon {
color: ${(props) => props.theme.grpc.protoFiles.item.invalid.text};
font-size: 0.75rem;
display: flex;
align-items: center;
margin-left: 0.5rem;
}
.empty-wrapper {
padding: 0.5rem 0.75rem;
}
.empty-text {
color: ${(props) => props.theme.grpc.protoFiles.empty.text};
font-size: 0.875rem;
font-style: italic;
text-align: center;
padding: 0.5rem 0;
}
.button-wrapper {
padding: 0.5rem 0.75rem;
}
.browse-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${(props) => props.theme.grpc.protoFiles.button.bg};
color: ${(props) => props.theme.grpc.protoFiles.button.color};
border: 1px solid ${(props) => props.theme.grpc.protoFiles.button.border};
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: ${(props) => props.theme.grpc.protoFiles.button.hoverBorder};
}
}
`;
export default Wrapper;

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { IconFile, IconSettings, IconAlertCircle } from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import StyledWrapper from './StyledWrapper';
const ProtoFilesTab = ({
collectionProtoFiles,
invalidProtoFiles,
protoFilePath,
collection,
onSelectCollectionProtoFile,
onOpenCollectionProtobufSettings,
onSelectProtoFile
}) => {
return (
<StyledWrapper>
{collectionProtoFiles && collectionProtoFiles.length > 0 && (
<div className="content-wrapper">
<div className="header-wrapper">
<div className="header-text">From Collection Settings</div>
<button
onClick={onOpenCollectionProtobufSettings}
className="settings-button"
>
<IconSettings size={16} strokeWidth={1.5} />
</button>
</div>
{invalidProtoFiles.length > 0 && (
<div className="error-wrapper">
<p className="error-text">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Some proto files could not be found.
{' '}
<button
onClick={onOpenCollectionProtobufSettings}
className="error-link"
>
Manage proto files
</button>
</p>
</div>
)}
<div className="items-container">
{collectionProtoFiles.map((protoFile, index) => {
const isSelected = protoFilePath === protoFile.path;
const isInvalid = !protoFile.exists;
return (
<div
key={`collection-proto-${index}`}
className={`item-wrapper ${!isInvalid ? 'valid' : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => {
if (!isInvalid) {
onSelectCollectionProtoFile(protoFile);
}
}}
>
<div className="item-content">
<div className="item-icon">
<IconFile size={20} strokeWidth={1.5} />
</div>
<div className="item-details">
<div className="item-title">
{getBasename(collection.pathname, protoFile.path)}
{isInvalid && (
<span className="invalid-icon">
<IconAlertCircle size={14} strokeWidth={1.5} />
</span>
)}
</div>
<div className="item-path">
{protoFile.path}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(!collectionProtoFiles || collectionProtoFiles.length === 0) && (
<div className="empty-wrapper">
<div className="empty-text">
No proto files configured in collection settings
</div>
</div>
)}
<div className="button-wrapper">
<button
className="browse-button"
onClick={onSelectProtoFile}
>
<IconFile size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Browse for Proto File
</button>
</div>
</StyledWrapper>
);
};
export default ProtoFilesTab;

View File

@@ -1,23 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.tab-container {
background-color: ${(props) => props.theme.grpc.tabNav.container.bg};
}
.tab-button {
background-color: ${(props) => props.theme.grpc.tabNav.button.inactive.bg};
color: ${(props) => props.theme.grpc.tabNav.button.inactive.color};
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
background-color: ${(props) => props.theme.grpc.tabNav.button.inactive.hover};
}
&.active {
background-color: ${(props) => props.theme.grpc.tabNav.button.active.bg};
color: ${(props) => props.theme.grpc.tabNav.button.active.color};
}
}
`;
export default Wrapper;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const TabNavigation = ({ activeTab, onTabChange, collectionProtoFiles, collectionImportPaths }) => {
return (
<StyledWrapper className="px-3 py-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="tab-container flex space-x-1 rounded-lg p-1">
<button
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors tab-button ${activeTab === 'protofiles' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onTabChange('protofiles');
}}
>
Proto Files (
{collectionProtoFiles?.length || 0}
)
</button>
<button
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors tab-button ${activeTab === 'importpaths' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onTabChange('importpaths');
}}
>
Import Paths (
{collectionImportPaths?.length || 0}
)
</button>
</div>
</StyledWrapper>
);
};
export default TabNavigation;

View File

@@ -1,3 +0,0 @@
export { default as TabNavigation } from './TabNavigation';
export { default as ProtoFilesTab } from './ProtoFilesTab';
export { default as ImportPathsTab } from './ImportPathsTab';

View File

@@ -8,7 +8,7 @@ import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
import { toastError } from 'utils/common/error';
import { prettifyJSON } from 'utils/common';
import { format, applyEdits } from 'jsonc-parser';
import xmlFormat from 'xml-formatter';
const RequestBodyMode = ({ item, collection }) => {
@@ -39,7 +39,8 @@ const RequestBodyMode = ({ item, collection }) => {
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
try {
const prettyBodyJson = prettifyJSON(body.json);
const edits = format(body.json, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(body.json, edits);
dispatch(
updateRequestBody({
content: prettyBodyJson,

View File

@@ -6,8 +6,7 @@ const ToggleSelector = ({
label,
description,
disabled = false,
size = 'small', // 'small', 'medium', 'large'
'data-testid': dataTestId
size = 'small' // 'small', 'medium', 'large'
}) => {
const sizeClasses = {
small: {
@@ -30,24 +29,13 @@ const ToggleSelector = ({
const currentSize = sizeClasses[size];
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onChange}
disabled={disabled}
data-testid={dataTestId}
className={`
relative inline-flex ${currentSize.container} flex-shrink-0 items-center rounded-full transition-colors
relative inline-flex ${currentSize.container} mx-1 items-center rounded-full transition-colors
focus:outline-none focus:ring-1 focus:ring-offset-1
${disabled
? 'opacity-50 cursor-not-allowed'
@@ -69,6 +57,16 @@ const ToggleSelector = ({
`}
/>
</button>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
);
};

View File

@@ -1,156 +1,47 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconTag } from '@tabler/icons';
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
import SettingsInput from 'components/SettingsInput';
import InheritableSettingsInput from 'components/InheritableSettingsInput';
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import Tags from './Tags/index';
// Default settings configuration
const DEFAULT_SETTINGS = {
encodeUrl: false,
followRedirects: true,
maxRedirects: 5,
timeout: 'inherit'
};
const Settings = ({ item, collection }) => {
const dispatch = useDispatch();
// Get current settings with defaults applied
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
const getPropertyFromDraftOrRequest = (propertyKey) =>
item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
const rawSettings = getPropertyFromDraftOrRequest('settings');
const settings = { ...DEFAULT_SETTINGS, ...rawSettings };
const { encodeUrl, followRedirects, maxRedirects, timeout } = settings;
const { encodeUrl } = getPropertyFromDraftOrRequest('settings');
// Reusable function to update settings
const updateSetting = useCallback((settingUpdate) => {
const updatedSettings = { ...settings, ...settingUpdate };
const onToggleUrlEncoding = useCallback(() => {
dispatch(updateItemSettings({
collectionUid: collection.uid,
itemUid: item.uid,
settings: updatedSettings
settings: { encodeUrl: !encodeUrl }
}));
}, [dispatch, collection.uid, item.uid, settings]);
// Setting change handlers
const onToggleUrlEncoding = useCallback(() =>
updateSetting({ encodeUrl: !encodeUrl }), [encodeUrl, updateSetting]);
const onToggleFollowRedirects = useCallback(() =>
updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]);
const onMaxRedirectsChange = useCallback((e) => {
const value = e.target.value;
// Only allow empty string or digits
if (value === '' || /^\d+$/.test(value)) {
const numericValue = value === '' ? 0 : parseInt(value, 10);
updateSetting({ maxRedirects: numericValue });
}
}, [updateSetting]);
const onTimeoutChange = useCallback((e) => {
const value = e.target.value;
// Only allow empty string or digits
if (value === '' || /^\d+$/.test(value)) {
const numericValue = value === '' ? 0 : parseInt(value, 10);
updateSetting({ timeout: numericValue });
}
}, [updateSetting]);
// Check if timeout is inherited
const isTimeoutInherited = timeout === 'inherit' || timeout === undefined || timeout === null;
const handleTimeoutDropdownSelect = useCallback((option) => {
if (option === 'inherit') {
updateSetting({ timeout: 'inherit' });
} else if (option === 'custom') {
// Switch to custom value - start with 0
updateSetting({ timeout: 0 });
}
}, [updateSetting]);
// Keyboard shortcut handlers
const onSave = useCallback(() => {
dispatch(saveRequest(item.uid, collection.uid));
}, [dispatch, item.uid, collection.uid]);
const onRun = useCallback(() => {
dispatch(sendRequest(item, collection.uid));
}, [dispatch, item, collection.uid]);
// Keyboard shortcut handler for input fields
const handleKeyDown = useCallback((e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
onRun();
}
}, [onSave, onRun]);
}, [encodeUrl, dispatch, collection.uid, item.uid]);
return (
<div className="h-full w-full">
<div className="text-xs mb-4 text-muted">Configure request settings for this item.</div>
<div className="bruno-form">
<div className="mb-6">
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1 mb-4">
<IconTag size={16} />
Tags
</h3>
<div className="w-full h-full flex flex-col gap-10">
<div className='flex flex-col gap-2 max-w-[400px]'>
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
<IconTag size={16} />
Tags
</h3>
<div label="Tags">
<Tags item={item} collection={collection} />
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<ToggleSelector
checked={encodeUrl}
onChange={onToggleUrlEncoding}
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
/>
</div>
<div className="flex flex-col gap-4">
<ToggleSelector
checked={followRedirects}
onChange={onToggleFollowRedirects}
label="Automatically Follow Redirects"
description="Follow HTTP redirects automatically"
size="medium"
data-testid="follow-redirects-toggle"
/>
</div>
<SettingsInput
id="maxRedirects"
label="Max Redirects"
value={maxRedirects}
onChange={onMaxRedirectsChange}
description="Set a limit for the number of redirects to follow"
onKeyDown={handleKeyDown}
/>
<InheritableSettingsInput
id="timeout"
label="Timeout (ms)"
value={timeout}
description="Set maximum time to wait before aborting the request"
onKeyDown={handleKeyDown}
isInherited={isTimeoutInherited}
onDropdownSelect={handleTimeoutDropdownSelect}
onValueChange={(e) => !isTimeoutInherited && onTimeoutChange(e)}
onCustomValueReset={() => updateSetting({ timeout: 'inherit' })}
/>
</div>
</div>
<div className='flex flex-col gap-4'>
<ToggleSelector
checked={encodeUrl}
onChange={onToggleUrlEncoding}
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
/>
</div>
</div>
);

View File

@@ -1,34 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text}
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,9 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -1,85 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const AUTH_MODES = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const WSAuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)}
{' '}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
}));
};
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{AUTH_MODES.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);
};
export default WSAuthMode;

View File

@@ -1,129 +0,0 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import WSAuthMode from './WSAuthMode';
import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
const WSAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
const save = () => {
return saveRequest(item.uid, collection.uid);
};
// Reset to 'none' if current auth mode is not supported
useEffect(() => {
if (authMode && !supportedAuthModes.includes(authMode)) {
dispatch(updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: 'none'
}));
}
}, [authMode, collection.uid, dispatch, item.uid]);
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'oauth2': {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>
OAuth 2 not <strong>yet</strong> supported by WebSockets. Using no auth instead.
</div>
</div>
</>
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
// Only show inherited auth if it's one of the supported types
if (source && supportedAuthModes.includes(source.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div> Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Inherited auth not supported by WebSockets. Using no auth instead.</div>
</div>
</>
);
}
}
default: {
return null;
}
}
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default WSAuth;

View File

@@ -1,120 +0,0 @@
import classnames from 'classnames';
import Documentation from 'components/Documentation/index';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import StatusDot from 'components/StatusDot/index';
import { find } from 'lodash';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
import WsBody from '../WsBody/index';
import StyledWrapper from './StyledWrapper';
import WSAuth from './WSAuth';
import WSSettingsPane from '../WSSettingsPane/index';
const WSRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const selectTab = (tab) => {
dispatch(updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
}));
};
const getTabPanel = (tab) => {
switch (tab) {
case 'body': {
return (
<WsBody
item={item}
collection={collection}
hideModeSelector={true}
hidePrettifyButton={true}
handleRun={handleRun}
/>
);
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} addHeaderText="Add Headers" />;
}
case 'settings': {
return <WSSettingsPane item={item} collection={collection} />;
}
case 'auth': {
return <WSAuth item={item} collection={collection} />;
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
const activeHeadersLength = headers.filter((header) => header.enabled).length;
useEffect(() => {
if (!focusedTab?.requestPaneTab) {
selectTab('body');
}
}, []);
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<section
className={classnames('flex w-full flex-1 h-full', {
'mt-2': !isMultipleContentTab
})}
>
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</StyledWrapper>
);
};
export default WSRequestPane;

View File

@@ -1,29 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
&.error{
border-color: ${(props) => props.theme.colors.text.danger};
}
}
.tooltip-mod {
font-size: 11px !important;
width: 150px !important;
& ul {
padding-left: 4px;
}
& ul > li {
list-style: circle;
}
}
`;
export default StyledWrapper;

View File

@@ -1,135 +0,0 @@
import cn from 'classnames';
import InfoTip from 'components/InfoTip/index';
import SingleLineEditor from 'components/SingleLineEditor';
import ToolHint from 'components/ToolHint/index';
import { useFormik } from 'formik';
import get from 'lodash/get';
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import StyledWrapper from './StyledWrapper';
/**
* @param {string} propertyKey
* @param {{draft?:Record<string,unknown>}} item
* @returns
*/
const getPropertyFromDraftOrRequest = (propertyKey, item) =>
item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
const ERRORS = {
timeout: {
invalid: `Timeout needs to be a valid number`
},
keepAliveInterval: {
invalid: `Timeout needs to be a valid number`
}
};
const WSSettingsPane = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const requestPreferences = useSelector((state) => state.app.preferences.request);
const { timeout: _connectionTimeout, keepAliveInterval = 0 } = getPropertyFromDraftOrRequest('settings', item);
const connectionTimeout = _connectionTimeout ?? requestPreferences.timeout;
const updateSetting = (key, value) => {
dispatch(updateItemSettings({
collectionUid: collection.uid,
itemUid: item.uid,
settings: {
[key]: value
}
}));
};
const formErrors = {
timeout: isNaN(Number(connectionTimeout)) && ERRORS.timeout.invalid,
keepAliveInterval: isNaN(Number(keepAliveInterval)) && ERRORS.keepAliveInterval.invalid
};
return (
<StyledWrapper className="flex flex-col mt-4 gap-4 w-full">
<section className="grid gap-4 items-center grid-cols-2">
<div>
<label className="font-medium mb-2">Timeout</label>
<InfoTip
infotipId="setting-connection-timeout"
className="tooltip-mod max-w-lg"
content={(
<div>
<p>
<span>Timeout in milliseconds</span>
</p>
</div>
)}
/>
</div>
<div>
<div className={cn('single-line-editor-wrapper', {
error: formErrors.timeout
})}
>
<ToolHint
key="timeout"
toolhintId="ws-settings-timeout"
place="top"
text={formErrors.timeout ? formErrors.timeout : ''}
>
<SingleLineEditor
value={connectionTimeout}
theme={storedTheme}
onChange={(newValue) => updateSetting('timeout', newValue)}
collection={collection}
/>
</ToolHint>
</div>
</div>
<div>
<label className="font-medium mb-2">Keep Alive Interval</label>
<InfoTip
infotipId="setting-keep-alive"
className="tooltip-mod max-w-lg"
content={(
<div>
<p>
<span>
Keep the websocket alive by sending ping requests to the server at every interval (in millseconds)
</span>
</p>
<p className="mt-2">0 (zero) = off</p>
</div>
)}
/>
</div>
<div>
<div className={cn('single-line-editor-wrapper', {
error: formErrors.keepAliveInterval
})}
>
<ToolHint
key="timeout"
toolhintId="ws-settings-keepAliveInterval"
place="top"
text={formErrors.keepAliveInterval ? formErrors.keepAliveInterval : ''}
>
<SingleLineEditor
value={keepAliveInterval}
theme={storedTheme}
onChange={(newValue) => updateSetting('keepAliveInterval', newValue)}
collection={collection}
/>
</ToolHint>
</div>
</div>
</section>
</StyledWrapper>
);
};
export default WSSettingsPane;

View File

@@ -1,30 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.8125rem;
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@@ -1,58 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const RAW_MODES = [
{
label: 'JSON',
key: 'json'
},
{
label: 'XML',
key: 'xml'
},
{
label: 'TEXT',
key: 'text'
}
];
const WSRequestBodyMode = ({ mode, onModeChange }) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(mode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Raw</div>
{RAW_MODES.map((d) => (
<div
className="dropdown-item"
key={d.key}
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange(d.key);
}}
>
{d.label}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);
};
export default WSRequestBodyMode;

View File

@@ -1,203 +0,0 @@
import { IconChevronDown, IconChevronUp, IconTrash, IconWand } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor/index';
import ToolHint from 'components/ToolHint/index';
import { get } from 'lodash';
import invert from 'lodash/invert';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { autoDetectLang } from 'utils/codemirror/lang-detect';
import { toastError } from 'utils/common/error';
import { prettifyJSON } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
import WSRequestBodyMode from '../BodyMode/index';
export const TYPE_BY_DECODER = {
base64: 'binary',
json: 'json',
xml: 'xml'
};
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
export const SingleWSMessage = ({
message,
item,
collection,
index,
methodType,
isCollapsed,
onToggleCollapse,
handleRun,
canClientSendMultipleMessages
}) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const { name, content, type } = message;
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
const onUpdateMessageType = (type) => {
setMessageFormat(type);
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
type: DECODER_BY_TYPE[type]
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onEdit = (value) => {
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
type: DECODER_BY_TYPE[messageFormat],
content: value
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onDeleteMessage = () => {
const currentMessages = [...(body.ws || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const getContainerHeight
= canClientSendMultipleMessages && body.ws.length > 1 ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
let codeType = messageFormat;
if (TYPE_BY_DECODER[type]) {
codeType = TYPE_BY_DECODER[type];
}
const codemirrorMode = {
text: 'application/text',
xml: 'application/xml',
json: 'application/ld+json'
};
const onPrettify = () => {
if (codeType === 'json') {
try {
const prettyBodyJson = prettifyJSON(content);
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
}
if (codeType === 'xml') {
try {
const prettyBodyXML = xmlFormat(content, { collapseContent: true });
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
name: name ? name : `message ${index + 1}`,
content: prettyBodyXML
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid XML format.'));
}
}
};
return (
<div
className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}
>
<div
className="ws-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ? (
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
) : (
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
)}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
<ToolHint text="Prettify" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[codeType] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};

View File

@@ -1,53 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
position: relative;
.ws-message-header {
.font-medium {
color: ${(props) => props.theme.text};
}
button {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
.add-message-btn-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: 8px;
background: ${(props) => props.theme.bg || '#fff'};
z-index: 15;
border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
.add-message-btn {
width: 100%;
}
}
.CodeMirror {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
`;
export default Wrapper;

View File

@@ -1,116 +0,0 @@
import { get } from 'lodash';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { IconPlus } from '@tabler/icons';
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ToolHint from 'components/ToolHint/index';
import StyledWrapper from './StyledWrapper';
import { SingleWSMessage } from './SingleWSMessage/index';
const WSBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const dispatch = useDispatch();
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = false;
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.ws?.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [body?.ws?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages((prev) => {
if (prev.includes(index)) {
return prev.filter((i) => i !== index);
} else {
return [...prev, index];
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
if (!body?.ws || !Array.isArray(body.ws)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No WebSocket messages available</p>
<ToolHint text="Add the first message to your WebSocket request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
ref={messagesContainerRef}
id="ws-messages-container"
className={`flex-1 ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'
}`}
>
{body.ws
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleWSMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new WebSocket message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
</div>
)}
</StyledWrapper>
);
};
export default WSBody;

View File

@@ -1,103 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.3rem;
position: relative;
.input-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
.method-ws {
color: ${(props) => props.theme.request.ws};
}
.connection-status-strip {
animation: pulse 1.5s ease-in-out infinite;
background-color: ${(props) => props.theme.colors.text.green};
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
.infotip {
position: relative;
display: inline-block;
cursor: pointer;
}
.infotip:hover .infotip-text {
visibility: visible;
opacity: 1;
}
.infotip-text {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
padding: 4px 8px;
position: absolute;
z-index: 1;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
.infotip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
}
.shortcut {
font-size: 0.625rem;
}
.connection-controls {
.infotip {
&:hover {
background-color: ${(props) => props.theme.requestTabPanel.url.errorHoverBg};
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,170 +0,0 @@
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import { IconWebSocket } from 'components/Icons/Grpc';
import classnames from 'classnames';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
import { wsConnectOnly, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { getPropertyFromDraftOrRequest } from 'utils/collections';
import { isMacOS } from 'utils/common/platform';
import { closeWsConnection, isWsConnectionActive } from 'utils/network/index';
import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
const WsQueryUrl = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const { theme, displayedTheme } = useTheme();
const [isConnectionActive, setIsConnectionActive] = useState(false);
// TODO: reaper, better state for connecting
const [isConnecting, setIsConnecting] = useState(false);
const url = getPropertyFromDraftOrRequest(item, 'request.url');
const response = item.draft ? get(item, 'draft.response', {}) : get(item, 'response', {});
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
const showConnectingPulse = isConnecting && response.status !== 'CLOSED';
// Check connection status
useEffect(() => {
const checkConnectionStatus = async () => {
try {
const result = await isWsConnectionActive(item.uid);
const active = Boolean(result.isActive);
setIsConnectionActive(active);
setIsConnecting(false);
} catch (error) {
setIsConnectionActive(false);
setIsConnecting(false);
}
};
checkConnectionStatus();
const interval = setInterval(checkConnectionStatus, 2000);
return () => clearInterval(interval);
}, [item.uid]);
const onUrlChange = (value) => {
closeWsConnection(item.uid);
dispatch(requestUrlChanged({
url: value,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const handleCloseConnection = (e) => {
e.stopPropagation();
closeWsConnection(item.uid)
.then(() => {
toast.success('WebSocket connection closed');
setIsConnectionActive(false);
setIsConnecting(false);
})
.catch((err) => {
console.error('Failed to close WebSocket connection:', err);
toast.error('Failed to close WebSocket connection');
});
};
const handleRunClick = async (e) => {
e.stopPropagation();
if (!url) {
toast.error('Please enter a valid WebSocket URL');
return;
}
handleRun(e);
};
const handleConnect = (e) => {
setIsConnecting(true);
dispatch(wsConnectOnly(item, collection.uid));
};
const onSave = (finalValue) => {
dispatch(saveRequest(item.uid, collection.uid));
};
return (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 w-full input-container pr-2 h-full relative">
<div className="flex items-center justify-center w-16">
<span className="text-xs font-bold method-ws">WS</span>
</div>
<SingleLineEditor
value={url}
onSave={(finalValue) => onSave(finalValue)}
onChange={onUrlChange}
placeholder="ws://localhost:8080 or wss://example.com"
className="w-full"
theme={displayedTheme}
onRun={handleRun}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleCloseConnection}>
<IconPlugConnectedX
color={theme.colors.text.danger}
strokeWidth={1.5}
size={22}
className="cursor-pointer"
/>
<span className="infotip-text text-xs">Close Connection</span>
</div>
</div>
)}
{!isConnectionActive && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleConnect}>
<IconPlugConnected
className={
classnames('cursor-pointer', {
'animate-pulse': showConnectingPulse
})
}
color={theme.colors.text.green}
strokeWidth={1.5}
size={22}
/>
<span className="infotip-text text-xs">Connect</span>
</div>
</div>
)}
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
</div>
</div>
</div>
</div>
{isConnectionActive && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};
export default WsQueryUrl;

View File

@@ -29,9 +29,6 @@ import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import FolderNotFound from './FolderNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -121,7 +118,7 @@ const RequestTabPanel = () => {
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
@@ -188,7 +185,6 @@ const RequestTabPanel = () => {
const item = findItemInCollection(collection, activeTabUid);
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
if (focusedTab.type === 'collection-runner') {
return <RunnerResults collection={collection} />;
@@ -233,7 +229,6 @@ const RequestTabPanel = () => {
const handleRun = async () => {
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
@@ -246,11 +241,6 @@ const RequestTabPanel = () => {
return;
}
if (isWsRequest && !request.url) {
toast.error('Please enter a valid WebSocket URL');
return;
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
@@ -258,21 +248,14 @@ const RequestTabPanel = () => {
);
};
// TODO: reaper, improve selection of panes
return (
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="pt-4 pb-3 px-4">
{
isGrpcRequest
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
: isWsRequest
? <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />
: <QueryUrl item={item} collection={collection} handleRun={handleRun} />
}
{isGrpcRequest ? (
<GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
) : (
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
)}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
@@ -282,7 +265,6 @@ const RequestTabPanel = () => {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
@@ -304,10 +286,6 @@ const RequestTabPanel = () => {
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
{isWsRequest ? (
<WSRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
</div>
</section>
@@ -320,12 +298,7 @@ const RequestTabPanel = () => {
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : item.type === 'ws-request' ? (
<WSResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : (

View File

@@ -1,47 +0,0 @@
import React from 'react';
import { useDrag, useDrop } from 'react-dnd';
const DraggableTab = ({ id, onMoveTab, index, children, className, onClick }) => {
const ref = React.useRef(null);
const [{ handlerId, isOver }, drop] = useDrop({
accept: 'tab',
hover(item, monitor) {
onMoveTab(item.id, id);
},
collect: (monitor) => ({
handlerId: monitor.getHandlerId(),
isOver: monitor.isOver()
})
});
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: () => {
return { id, index };
},
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
options: {
dropEffect: 'move'
}
});
drag(drop(ref));
return (
<li
className={className}
ref={ref}
role="tab"
style={{ opacity: isDragging || isOver ? 0 : 1 }}
onClick={onClick}
data-handler-id={handlerId}
>
{children}
</li>
);
};
export default DraggableTab;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, Fragment } from 'react';
import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -18,7 +18,6 @@ import NewRequest from 'components/Sidebar/NewRequest/index';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
@@ -67,13 +66,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const getMethodColor = (method = '') => {
const colorMap = {
...theme.request.methods,
...theme.request
};
return colorMap[method.toLocaleLowerCase()];
return theme.request.methods[method.toLocaleLowerCase()];
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
@@ -94,21 +90,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const item = findItemInCollection(collection, tab.uid);
const getMethodText = useCallback((item) => {
if (!item) return;
switch (item.type) {
case 'grpc-request':
return 'gRPC';
case 'ws-request':
return 'WS';
case 'graphql-request':
return 'GQL';
default:
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
}
}, [item]);
if (!item) {
return (
<StyledWrapper
@@ -127,8 +108,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
const isWS = item.type === 'ws-request';
const method = getMethodText(item);
const isGrpc = item.type === 'grpc-request';
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
@@ -137,7 +118,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
item={item}
onCancel={() => setShowConfirmClose(false)}
onCloseWithoutSave={() => {
isWS && closeWsConnection(item.uid);
dispatch(
deleteRequestDraft({
itemUid: item.uid,
@@ -181,8 +161,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
<span className="tab-method uppercase" style={{ color: getMethodColor(method), fontSize: 12 }}>
{method}
<span className="tab-method uppercase" style={{ color: isGrpc ? theme.request.grpc : getMethodColor(method), fontSize: 12 }}>
{isGrpc ? 'gRPC' : method}
</span>
<span className="ml-1 tab-name" title={item.name}>
{item.name}
@@ -200,10 +180,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!item.draft) {
isWS && closeWsConnection(item.uid);
return handleCloseClick(e);
};
if (!item.draft) return handleCloseClick(e);
e.stopPropagation();
e.preventDefault();
@@ -251,28 +228,6 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
} catch (err) {}
}
function handleRevertChanges(event) {
event.stopPropagation();
dropdownTippyRef.current.hide();
if (!currentTabUid) {
return;
}
try {
const item = findItemInCollection(collection, currentTabUid);
if (item.draft) {
dispatch(
deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
} catch (err) {}
}
function handleCloseOtherTabs(event) {
dropdownTippyRef.current.hide();
@@ -340,13 +295,6 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
>
Clone Request
</button>
<button
className="dropdown-item w-full"
onClick={handleRevertChanges}
disabled={!currentTabItem?.draft}
>
Revert Changes
</button>
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
Close
</button>

View File

@@ -4,12 +4,11 @@ import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -107,17 +106,10 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
<DraggableTab
<li
key={tab.uid}
id={tab.uid}
index={index}
onMoveTab={(source, target) => {
dispatch(reorderTabs({
sourceUid: source,
targetUid: target
}));
}}
className={getTabClassname(tab, index)}
role="tab"
onClick={() => handleClick(tab)}
>
<RequestTab
@@ -128,7 +120,7 @@ const RequestTabs = () => {
collection={activeCollection}
folderUid={tab.folderUid}
/>
</DraggableTab>
</li>
);
})
: null}

View File

@@ -4,7 +4,7 @@ import { get } from 'lodash';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
@@ -76,8 +76,6 @@ const QueryResultPreview = ({
dispatch(sendRequest(item, collection.uid));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onScroll = (event) => {
dispatch(
updateResponsePaneScrollPosition({
@@ -129,7 +127,6 @@ const QueryResultPreview = ({
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={mode}

View File

@@ -11,7 +11,7 @@ const ResponseSave = ({ item }) => {
const saveResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');

View File

@@ -76,39 +76,35 @@ describe('ResponseSize', () => {
});
it('should handle exactly 1024 bytes as size', () => {
const size = 1024;
renderWithTheme(<ResponseSize size={size} />);
renderWithTheme(<ResponseSize size={1024} />);
const element = screen.getByText(/1024B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^1024B$/);
expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`);
expect(element).toHaveAttribute('title', '1,024B');
});
it('should render kilobytes when size is greater than 1024', () => {
const size = 1500;
renderWithTheme(<ResponseSize size={size} />);
renderWithTheme(<ResponseSize size={1500} />);
const element = screen.getByText(/1\.46KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`);
expect(element).toHaveAttribute('title', '1,500B');
});
it('should handle large size numbers', () => {
const size = 10240;
renderWithTheme(<ResponseSize size={size} />);
renderWithTheme(<ResponseSize size={10240} />);
const element = screen.getByText(/10\.0KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`);
expect(element).toHaveAttribute('title', '10,240B');
});
it('should handle decimal size numbers', () => {
const size = 1126.5;
renderWithTheme(<ResponseSize size={size} />);
renderWithTheme(<ResponseSize size={1126.5} />);
const element = screen.getByText(/1\.10KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`);
expect(element).toHaveAttribute('title', '1,126.5B');
});
});
});

View File

@@ -45,7 +45,7 @@ const getEffectiveAuthSource = (collection, item) => {
const Timeline = ({ collection, item }) => {
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request';
const isGrpcRequest = item.type === 'grpc-request';
// Filter timeline entries based on new rules
const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => {

View File

@@ -1,58 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.stream-status {
display: inline-flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
`;
export default StyledWrapper;

View File

@@ -1,44 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
overflow-y: auto;
.ws-message.new {
background-color: ${({ theme }) => theme.table.striped};
}
.ws-message:not(:last-child) {
border-bottom: 1px solid ${({ theme }) => theme.table.border};
}
.ws-message:not(:last-child).open {
border-bottom-width: 0px;
}
.ws-incoming {
background: ${(props) => props.theme.bg};
border-color: ${(props) => props.theme.table.border};
}
.ws-outgoing {
background: ${(props) => props.theme.bg};
border-color: ${(props) => props.theme.table.border};
}
.CodeMirror {
border-radius: 0.25rem;
}
.CodeMirror-foldgutter, .CodeMirror-linenumbers, .CodeMirror-lint-markers {
background: ${({ theme }) => theme.bg};
}
div[role='tablist'] {
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default StyledWrapper;

View File

@@ -1,204 +0,0 @@
import React from 'react';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor/index';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useSelector } from 'react-redux';
import _ from 'lodash';
import { useRef } from 'react';
import { useEffect } from 'react';
const getContentMeta = (content) => {
if (typeof content === 'object') {
return {
isJSON: true,
content: JSON.stringify(content, null, 0)
};
}
try {
return {
isJSON: true,
content: JSON.stringify(JSON.parse(content), null, 0)
};
} catch {
return {
isJSON: false,
content: content
};
}
};
const parseContent = (content) => {
let contentMeta = getContentMeta(content);
return {
type: contentMeta.isJSON ? 'application/json' : 'text/plain',
content: contentMeta.isJSON ? JSON.stringify(JSON.parse(contentMeta.content), null, 2) : contentMeta.content
};
};
const getDataTypeText = (type) => {
const textMap = {
'text/plain': 'RAW',
'application/json': 'JSON'
};
return textMap[type] ?? 'RAW';
};
/**
*
* @param {"incoming"|"outgoing"|"info"} type
*/
const TypeIcon = ({ type }) => {
const commonProps = {
size: 18
};
return {
incoming: <IconArrowDownLeft {...commonProps} />,
outgoing: <IconArrowUpRight {...commonProps} />,
info: <IconInfoCircle {...commonProps} />,
error: <IconExclamationCircle {...commonProps} />
}[type];
};
const WSMessageItem = ({ message, inFocus }) => {
const [isOpen, setIsOpen] = useState(false);
const [showHex, setShowHex] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const { displayedTheme } = useTheme();
const [isNew, setIsNew] = useState(false);
const notified = useRef(false);
const isIncoming = message.type === 'incoming';
const isInfo = message.type === 'info';
const isError = message.type === 'error';
const isOutgoing = message.type === 'outgoing';
let contentHexdump = message.messageHexdump;
let parsedContent = parseContent(message.message);
const dataType = getDataTypeText(parsedContent.type);
useEffect(() => {
if (notified.current === true) return;
const dateDiff = Date.now() - new Date(message.timestamp).getTime();
if (dateDiff < 1000 * 10) {
setIsNew(true);
setTimeout(() => {
notified.current = true;
setIsNew(false);
}, 2500);
}
}, [message]);
const canOpenMessage = !isInfo && !isError;
return (
<div
ref={(node) => {
if (!node) return;
if (inFocus) node.scrollIntoView();
}}
className={classnames('ws-message flex flex-col p-2', {
'ws-incoming': isIncoming,
'ws-outgoing': !isIncoming,
'open': isOpen,
'new': isNew
})}
>
<div
className={classnames('flex items-center justify-between', {
'cursor-pointer': !isInfo,
'cursor-not-allowed': isInfo
})}
onClick={(e) => {
if (!canOpenMessage) return;
setIsOpen(!isOpen);
}}
>
<div className="flex min-w-0 shrink">
<span
className={classnames('font-semibold flex items-center gap-1',
{
'text-green-700': isIncoming,
'text-yellow-700': isOutgoing,
'text-blue-700': isInfo,
'text-red-700': isError,
'text-red-700': isError
})}
>
<TypeIcon type={message.type} />
</span>
<span className="ml-3 text-ellipsis max-w-full overflow-hidden text-nowrap">{parsedContent.content}</span>
</div>
<div className="flex shrink-0 gap-2">
{message.timestamp && (
<span className="text-xs text-gray-400">{new Date(message.timestamp).toISOString()}</span>
)}
{canOpenMessage
? (
<span className="text-gray-600">
{isOpen ? (
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
) : (
<IconChevronRight size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
)}
</span>
)
: <span class="w-4"></span>}
</div>
</div>
{isOpen && (
<>
<div className="mt-2 flex justify-end gap-2 text-xs ws-message-toolbar" role="tablist">
<div
className={classnames('select-none capitalize', {
'active': showHex,
'cursor-pointer': !showHex
})}
role="tab"
onClick={() => setShowHex(true)}
>
hexdump
</div>
<div
className={classnames('select-none capitalize', {
'active': !showHex,
'cursor-pointer': showHex
})}
role="tab"
onClick={() => setShowHex(false)}
>
{dataType.toLowerCase()}
</div>
</div>
<div className="mt-1 h-[300px] w-full">
<CodeEditor
mode={showHex ? 'text/plain' : parsedContent.type}
theme={displayedTheme}
enableLineWrapping={showHex ? false : true}
font={preferences.codeFont || 'default'}
value={showHex ? contentHexdump : parsedContent.content}
/>
</div>
</>
)}
</div>
);
};
const WSMessagesList = ({ order = -1, messages = [] }) => {
if (!messages.length) {
return <div className="p-4 text-gray-500">No messages yet.</div>;
}
const ordered = order === -1 ? messages : messages.slice().reverse();
return (
<StyledWrapper className="ws-messages-list mt-1 flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem inFocus={inFocus} id={idx} message={msg} />;
})}
</StyledWrapper>
);
};
export default WSMessagesList;

View File

@@ -1,31 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
thead {
color: #777777;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
td {
padding: 6px 10px;
&.value {
word-break: break-all;
}
}
tbody {
tr:nth-child(odd) {
background-color: ${(props) => props.theme.table.striped};
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,43 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const WSResponseHeaders = ({ response }) => {
const formatHeaders = (headers) => {
if (!headers) return [];
if (Array.isArray(headers)) return headers;
return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
};
const headersArray = formatHeaders(response.headers);
return (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{headersArray && headersArray.length ? (
headersArray.map((header, index) => (
<tr key={index}>
<td className="key">{header.name}</td>
<td className="value">{header.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No headers received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default WSResponseHeaders;

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { IconSortDescending2, IconSortAscending2 } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { wsUpdateResponseSortOrder } from 'providers/ReduxStore/slices/collections/index';
const WSResponseSortOrder = ({ collection, item }) => {
const dispatch = useDispatch();
const order = item.response?.sortOrder ?? -1;
const toggleSortOrder = () => {
dispatch(wsUpdateResponseSortOrder({
itemUid: item.uid,
collectionUid: collection.uid
}));
};
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={toggleSortOrder} title={order === -1 ? 'Latest Last' : 'Latest First'}>
{ order === -1
? <IconSortDescending2 size={16} strokeWidth={1.5} />
: <IconSortAscending2 size={16} strokeWidth={1.5} />}
</button>
</StyledWrapper>
);
};
export default WSResponseSortOrder;

View File

@@ -1,22 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
&.text-ok {
color: ${(props) => props.theme.requestTabPanel.responseOk};
}
&.text-pending {
color: ${(props) => props.theme.requestTabPanel.responsePending};
}
&.text-error {
color: ${(props) => props.theme.requestTabPanel.responseError};
}
`;
export default Wrapper;

View File

@@ -1,20 +0,0 @@
const wsStatusCodePhraseMap = {
1000: 'NORMAL_CLOSURE',
1001: 'GOING_AWAY',
1002: 'PROTOCOL_ERROR',
1003: 'UNSUPPORTED_DATA',
1004: 'RESERVED',
1005: 'NO_STATUS_RECEIVED',
1006: 'ABNORMAL_CLOSURE',
1007: 'INVALID_FRAME_PAYLOAD_DATA',
1008: 'POLICY_VIOLATION',
1009: 'MESSAGE_TOO_BIG',
1010: 'MANDATORY_EXTENSION',
1011: 'INTERNAL_ERROR',
1012: 'SERVICE_RESTART',
1013: 'TRY_AGAIN_LATER',
1014: 'BAD_GATEWAY',
1015: 'TLS_HANDSHAKE'
};
export default wsStatusCodePhraseMap;

View File

@@ -1,25 +0,0 @@
import React from 'react';
import classnames from 'classnames';
import wsStatusCodePhraseMap from './get-ws-status-code-phrase';
import StyledWrapper from './StyledWrapper';
const WSStatusCode = ({ status, text }) => {
const getTabClassname = (status) => {
return classnames('ml-2', {
// ok if normal connect and normal closure
'text-ok': parseInt(status) === 0 || parseInt(status) === 1000,
'text-error': parseInt(status) !== 1000 && parseInt(status) !== 0
});
};
const statusText = text || wsStatusCodePhraseMap[status];
return (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) && status != 0 ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
</StyledWrapper>
);
};
export default WSStatusCode;

View File

@@ -1,153 +0,0 @@
import React from 'react';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Overlay from '../Overlay';
import Placeholder from '../Placeholder';
import WSStatusCode from './WSStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseLayoutToggle from '../ResponseLayoutToggle';
import Tab from 'components/Tab';
import WSMessagesList from './WSMessagesList';
import WSResponseSortOrder from './WSResponseSortOrder';
import WSResponseHeaders from './WSResponseHeaders';
const WSResult = ({ response }) => {
return <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
};
const WSResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
if (obj.itemUid === item.uid) return true;
});
const selectTab = (tab) => {
dispatch(updateResponsePaneTab({
uid: item.uid,
responsePaneTab: tab
}));
};
const response = item.response || {};
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
return <WSResult response={response} />;
}
case 'headers': {
return <WSResponseHeaders response={response} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
}
default: {
return <div>404 | Not found</div>;
}
}
};
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex flex-col h-full relative">
<Overlay item={item} collection={collection} />
</StyledWrapper>
);
}
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
</StyledWrapper>
);
}
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const tabConfig = [
{
name: 'response',
label: 'Messages',
count: Array.isArray(response.responses) ? response.responses.length : 0
},
{
name: 'headers',
label: 'Headers',
count: response.headers ? Object.keys(response.headers).length : 0
},
{
name: 'timeline',
label: 'Timeline'
}
];
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSResponseSortOrder item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
</div>
<section
className={`flex flex-col flex-grow pl-3 pr-4 h-0 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</section>
</StyledWrapper>
);
};
export default WSResponsePane;

View File

@@ -1,47 +0,0 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const SettingsInput = ({
id,
label,
value,
onChange,
className = '',
description = '',
onKeyDown
}) => {
const { theme } = useTheme();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<input
id={id}
type="text"
className={`block px-2 py-1 rounded-sm outline-none transition-colors duration-100 w-24 h-8 ${className}`}
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
export default SettingsInput;

View File

@@ -14,15 +14,9 @@ const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = areItemsLoading(collection);
const hasNonExportableRequestTypes = useMemo(() => {
let types = new Set();
const hasGrpcRequests = useMemo(() => {
const checkItem = (item) => {
if (item.type === 'grpc-request') {
types.add('gRPC');
return true;
}
if (item.type === 'ws-request') {
types.add('WebSocket');
return true;
}
if (item.items) {
@@ -30,10 +24,7 @@ const ShareCollection = ({ onClose, collectionUid }) => {
}
return false;
};
return {
has: collection?.items?.filter(checkItem).length || false,
types: [...types]
};
return collection?.items?.some(checkItem) || false;
}, [collection]);
const handleExportBrunoCollection = () => {
@@ -84,15 +75,10 @@ const ShareCollection = ({ onClose, collectionUid }) => {
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
{hasNonExportableRequestTypes.has && (
{hasGrpcRequests && (
<div className="px-3 py-2 bg-yellow-50 w-full dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 text-xs border-b border-yellow-100 dark:border-yellow-800/20 flex items-center">
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
<span>
Note:
{hasNonExportableRequestTypes.types.join(', ')}
{' '}
requests in this collection will not be exported
</span>
<span>Note: gRPC requests in this collection will not be exported</span>
</div>
)}
<div className="flex items-center p-3 w-full">

View File

@@ -12,15 +12,12 @@ import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
import get from 'lodash/get';
const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
const { name } = collection;
const formik = useFormik({
@@ -28,7 +25,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
initialValues: {
collectionName: `${name} copy`,
collectionFolderName: `${sanitizeName(name)} copy`,
collectionLocation: defaultLocation
collectionLocation: ''
},
validationSchema: Yup.object({
collectionName: Yup.string()

View File

@@ -10,6 +10,7 @@ import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/co
import { cloneDeep } from 'lodash';
import { useMemo } from 'react';
import { generateSnippet } from '../utils/snippet-generator';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
@@ -32,12 +33,7 @@ const CodeView = ({ language, item }) => {
}, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]);
const snippet = useMemo(() => {
return generateSnippet({
language,
item,
collection,
shouldInterpolate: generateCodePrefs.shouldInterpolate
});
return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate });
}, [language, item, collection, generateCodePrefs.shouldInterpolate]);
return (

View File

@@ -37,12 +37,6 @@ const Wrapper = styled.div`
.method-grpc {
color: ${(props) => props.theme.request.grpc};
}
.method-ws {
color: ${(props) => props.theme.request.ws};
}
.method-graphql {
color: ${(props) => props.theme.request.gql};
}
`;
export default Wrapper;

View File

@@ -1,52 +1,33 @@
import classnames from 'classnames';
import React from 'react';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const getMethodFlags = (item) => ({
isGrpc: item.type === 'grpc-request',
isWS: item.type === 'ws-request',
isGraphQL: item.type === 'graphql-request'
});
const getMethodText = (item, { isGrpc, isWS, isGraphQL }) => {
if (isGrpc) return 'grpc';
if (isWS) return 'ws';
if (isGraphQL) return 'gql';
return item.request.method.length > 5
? item.request.method.substring(0, 3)
: item.request.method;
};
const getClassname = (method = '', { isGrpc, isWS, isGraphQL }) => {
method = method.toLocaleLowerCase();
return classnames('mr-1', {
'method-get': method === 'get',
'method-post': method === 'post',
'method-put': method === 'put',
'method-delete': method === 'delete',
'method-patch': method === 'patch',
'method-head': method === 'head',
'method-options': method === 'options',
'method-grpc': isGrpc,
'method-ws': isWS,
'method-graphql': isGraphQL
});
};
const RequestMethod = ({ item }) => {
if (!['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
if (!['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
return null;
}
const flags = getMethodFlags(item);
const methodText = getMethodText(item, flags);
const className = getClassname(item.request.method, flags);
const isGrpc = item.type === 'grpc-request';
const getClassname = (method = '') => {
method = method.toLocaleLowerCase();
return classnames('mr-1', {
'method-get': method === 'get',
'method-post': method === 'post',
'method-put': method === 'put',
'method-delete': method === 'delete',
'method-patch': method === 'patch',
'method-head': method === 'head',
'method-options': method === 'options',
'method-grpc': isGrpc,
});
};
return (
<StyledWrapper>
<div className={className}>
<div className={getClassname(item.request.method)}>
<span className="uppercase">
{methodText}
{isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
</span>
</div>
</StyledWrapper>

View File

@@ -6,10 +6,9 @@ import filter from 'lodash/filter';
import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
@@ -93,7 +92,6 @@ const Collection = ({ collection, searchText }) => {
}
if(!isChevronClick) {
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
dispatch(
addTab({
uid: collection.uid,
@@ -132,10 +130,6 @@ const Collection = ({ collection, searchText }) => {
}
};
const handleCollapseFullCollection = () => {
dispatch(collapseFullCollection({ collectionUid: collection.uid }));
};
const viewCollectionSettings = () => {
dispatch(
addTab({
@@ -215,7 +209,7 @@ const Collection = ({ collection, searchText }) => {
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
<StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
@@ -256,7 +250,7 @@ const Collection = ({ collection, searchText }) => {
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions" data-testid="collection-actions">
<div className="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
@@ -278,7 +272,6 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
data-testid="clone-collection"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowCloneCollectionModalOpen(true);
@@ -315,15 +308,6 @@ const Collection = ({ collection, searchText }) => {
>
Share
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
handleCollapseFullCollection();
}}
>
Collapse
</div>
<div
className="dropdown-item"
onClick={(_e) => {

View File

@@ -19,10 +19,7 @@ const CreateOrOpenCollection = () => {
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
(err) => {
console.log(err);
toast.error('An error occurred while opening the collection');
}
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
);
};
const CreateLink = () => (

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -14,21 +14,18 @@ import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import get from 'lodash/get';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionName: '',
collectionFolderName: '',
collectionLocation: defaultLocation
collectionLocation: ''
},
validationSchema: Yup.object({
collectionName: Yup.string()

View File

@@ -1,43 +0,0 @@
import { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">{loadingMessage}</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
export default FullscreenLoader;

View File

@@ -1,14 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconFileImport } from '@tabler/icons';
import { IconLoader2, IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import jsyaml from 'js-yaml';
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec, convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import ImportSettings from 'components/Sidebar/ImportSettings';
import FullscreenLoader from './FullscreenLoader/index';
const convertFileToObject = async (file) => {
const text = await file.text();
@@ -28,22 +26,60 @@ const convertFileToObject = async (file) => {
}
};
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [showImportSettings, setShowImportSettings] = useState(false);
const [openApiData, setOpenApiData] = useState(null);
const [groupingType, setGroupingType] = useState('tags');
const fileInputRef = useRef(null);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
@@ -51,43 +87,30 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
}
};
const handleImportSettings = () => {
try {
const collection = convertOpenapiToBruno(openApiData, { groupBy: groupingType });
handleSubmit({ collection });
} catch (err) {
console.error(err);
toastError(err, 'Failed to process OpenAPI specification');
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
// Check if it's an OpenAPI spec and show settings
if (isOpenApiSpec(data)) {
setOpenApiData(data);
setIsLoading(false);
setShowImportSettings(true);
return;
}
let collection;
if (isPostmanCollection(data)) {
collection = await postmanToBruno(data);
} else if (isInsomniaCollection(data)) {
}
else if (isInsomniaCollection(data)) {
collection = convertInsomniaToBruno(data);
} else {
}
else if (isOpenApiSpec(data)) {
collection = convertOpenapiToBruno(data);
}
else {
collection = await processBrunoCollection(data);
}
handleSubmit({ collection });
} catch (err) {
toastError(err, 'Import collection failed');
@@ -100,7 +123,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
@@ -120,23 +143,19 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
return <FullscreenLoader isLoading={isLoading} />;
}
const acceptedFileTypes = ['.json', '.yaml', '.yml', 'application/json', 'application/yaml', 'application/x-yaml'];
if (showImportSettings) {
return (
<ImportSettings
groupingType={groupingType}
setGroupingType={setGroupingType}
onClose={onClose}
onConfirm={handleImportSettings}
/>
);
}
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'application/json',
'application/yaml',
'application/x-yaml'
]
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<div className="mb-4">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
@@ -145,13 +164,16 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}

View File

@@ -1,22 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.sidebar.badge.border || 'transparent'};
}
.current-group:hover {
background-color: ${(props) => props.theme.sidebar.badge.hoverBg || props.theme.sidebar.badge.bg};
}
/* Fix dropdown positioning */
[data-tippy-root] {
left: 0 !important;
}
`;
export default Wrapper;

View File

@@ -1,84 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import StyledWrapper from './StyledWrapper';
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
const ImportSettings = ({
groupingType,
setGroupingType,
onClose,
onConfirm
}) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div
ref={ref}
className="flex items-center justify-between w-full current-group"
data-testid="grouping-dropdown"
>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
return (
<Portal>
<Modal
size="sm"
title="OpenAPI Import Settings"
handleCancel={onClose}
handleConfirm={onConfirm}
confirmText="Import"
dataTestId="import-settings-modal"
>
<StyledWrapper>
<div className="flex items-center">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Folder arrangement</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</StyledWrapper>
</Modal>
</Portal>
);
};
export default ImportSettings;

View File

@@ -7,7 +7,7 @@ import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { newHttpRequest, newGrpcRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
@@ -21,12 +21,14 @@ import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useTheme } from 'styled-components';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const storedTheme = useTheme();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const {
@@ -90,13 +92,13 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
}
if (collectionPresets.requestType === 'grpc') {
// If gRPC is disabled in beta features, fall back to http-request
if (!isGrpcEnabled) {
return 'http-request';
}
return 'grpc-request';
}
if (collectionPresets.requestType === 'ws') {
return 'ws-request';
}
return 'http-request';
};
@@ -144,7 +146,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
}),
onSubmit: (values) => {
const isGrpcRequest = values.requestType === 'grpc-request';
const isWsRequest = values.requestType === 'ws-request';
if (isGrpcRequest) {
dispatch(
@@ -164,21 +165,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
// will need to handle import from grpcurl command when we support it, now it is just for creating new requests
} else if (isWsRequest) {
dispatch(newWsRequest({
requestName: values.requestName,
requestMethod: values.requestMethod,
filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
collectionUid: collection.uid,
itemUid: item ? item.uid : null
}))
.then(() => {
toast.success('New request created!');
onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (isEphemeral) {
const uid = uuid();
dispatch(
@@ -323,81 +309,69 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
Type
</label>
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="radio"
id="http-request"
name="requestType"
value="http-request"
checked={formik.values.requestType === 'http-request'}
onChange={formik.handleChange}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP
</label>
</div>
<div className="flex items-center gap-2">
<input
type="radio"
id="graphql-request"
name="requestType"
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
onChange={formik.handleChange}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
</div>
</div>
<div className="flex items-center mt-2">
<input
id="http-request"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="http-request"
checked={formik.values.requestType === 'http-request'}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP
</label>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
id="graphql-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
{isGrpcEnabled && (
<>
<input
type="radio"
id="grpc-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="grpc-request"
checked={formik.values.requestType === 'grpc-request'}
onChange={formik.handleChange}
/>
<label htmlFor="grpc-request" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="radio"
id="ws-request"
name="requestType"
value="ws-request"
checked={formik.values.requestType === 'ws-request'}
onChange={formik.handleChange}
/>
<label htmlFor="ws-request" className="ml-1 cursor-pointer select-none">
WebSocket
</label>
</div>
</div>
<input
id="from-curl"
className="cursor-pointer ml-auto"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="radio"
id="from-curl"
name="requestType"
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
onChange={formik.handleChange}
/>
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
</div>
</div>
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
</div>
</div>
<div className="mt-4">
@@ -488,7 +462,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
URL
</label>
<div className="flex items-center mt-2 ">
{!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
{formik.values.requestType !== 'grpc-request' ? (
<div className="flex items-center h-full method-selector-container w-1/5">
<HttpMethodSelector
method={formik.values.requestMethod}

View File

@@ -55,10 +55,7 @@ const TitleBar = () => {
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
(err) => {
console.log(err);
toast.error('An error occurred while opening the collection');
}
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
);
};

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