Compare commits

..

58 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
Pooja
87d8c5ccb7 fix: env name overflow (#5598) 2025-09-19 19:36:37 +05:30
Pragadesh-45
17d5629627 refactor: Replace SingleLineEditor with MultiLineEditor in EnvironmentVariables components and add masking functionality (#5576)
* refactor: Replace SingleLineEditor with MultiLineEditor in EnvironmentVariables components and add masking functionality
- Adjusted related components to support the new editor and its features, including toggling visibility for secret values.

---------

Co-authored-by: lohit-bruno <lohit@usebruno.com>
2025-09-18 22:31:09 +05:30
Sanjai Kumar
4321846dbd feat: Add end-to-end tests for collection run reports (#5562)
* feat: Add end-to-end tests for collection run reports
2025-09-18 15:20:28 +05:30
Pooja
f3d4ac84d8 fix: environment list scroll (#5585) 2025-09-18 11:23:49 +05:30
Sanjai Kumar
de52ceea48 refactor: moved sample collection JSON to resources folder inside bruno-electron and updated electron-builder-config (#5581) 2025-09-17 20:45:33 +05:30
Pooja
65e69e77b3 revamp: collection and global env selector dropdown (#5542)
* revamp: collection and global env selector dropdown

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Pragadesh-45 <54320162+Pragadesh-45@users.noreply.github.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: sanish-bruno <sanish@usebruno.com>
Co-authored-by: bernborgess <bernborgesse@outlook.com>
Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com>
Co-authored-by: jayakrishnancn <jayakrishnancn@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-17 19:35:00 +05:30
Sanjai Kumar
fb2ca8937e feat: add environment variable DISABLE_SAMPLE_COLLECTION_IMPORT to control sample collection import behavior (#5567)
* feat: add environment variable DISABLE_SAMPLE_COLLECTION_IMPORT to control sample collection import behavior
2025-09-17 13:57:10 +05:30
Anoop M D
e2da072e8b Merge pull request #5566 from lohit-bruno/crypto_safe_mode_support
fix crypto-js in safe mode
2025-09-16 16:11:05 +05:30
lohit-bruno
90492d6e79 fix crypto-js in safe mode 2025-09-16 15:39:06 +05:30
Sanjai Kumar
5393e3b496 feat: Add default sample collection on first app launch (#5536)
* feat: Add Default Sample Collection On First Launch
* feat(initial-load): add  attribute for app readiness

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-15 15:00:39 +05:30
lohit
9fc885839f ca certs function updates (#5555) 2025-09-12 21:49:49 +05:30
Pragadesh-45
dbfbde43cf refactor: Replace MultiLineEditor with SingleLineEditor in EnvironmentVariables components (#5554) 2025-09-12 21:49:33 +05:30
lohit
1aa4e27ab5 use node:tls library for ca certs (#5552) 2025-09-12 20:29:28 +05:30
Siddharth Gelera (reaper)
2b6da56c3c fix(electron): avoid double encoding urls params. Fixes #5380. (#5507)
* fix(common): avoid double encoding urls params
2025-09-12 19:08:53 +05:30
lohit
c08827b0c0 ca certs updates and fixes (#5549) 2025-09-12 16:03:27 +05:30
Anoop M D
841d977725 Merge pull request #5547 from lohit-bruno/ca_certs_fixes_system_ca
use system-ca library for ca certs
2025-09-12 01:11:25 +05:30
Anoop M D
56629663dc Remove flaky header size test from getSize
Removed test for header size from getSize tests.
2025-09-12 01:05:24 +05:30
lohit-bruno
27cbb194bf use system-ca library for ca certs 2025-09-12 00:33:22 +05:30
Anoop M D
cfec4a9e1b Merge pull request #5531 from helloanoop/chore/update-digest-tests
Update digest authentication test cases with new URLs and credentials
2025-09-08 22:58:57 +05:30
Anoop M D
a7f6d669af Update digest authentication test cases with new URLs and credentials 2025-09-08 22:50:11 +05:30
Anoop M D
03abbc585f Remove body size test from getSize tests 2025-09-08 22:36:22 +05:30
Anoop M D
be730a8c4f Merge pull request #5529 from usebruno/dependabot/github_actions/actions/setup-node-5
chore(deps): bump actions/setup-node from 4 to 5
2025-09-08 22:29:25 +05:30
dependabot[bot]
194d904284 chore(deps): bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 16:21:53 +00:00
Anoop M D
86b3c65dcd Merge pull request #5525 from sanish-bruno/feat/moving-requests-cross-collection
Feature: moving requests cross collection
2025-09-08 20:05:04 +05:30
sanish chirayath
c9fe9813db Merge pull request #5526 from sanish-bruno/fix/tags-removed-while-moving-request
Fix: tags removed while moving request
2025-09-08 20:03:36 +05:30
sanish-bruno
70d65d87c5 move: test cases to new folder 2025-09-08 16:32:13 +05:30
jayakrishnancn
0bce203851 feat: Move requests between collections #3320
test: update e2e test case for moving request from one collection to another

test: updated the tests

test: added more test cases

test: e2e test updated

test: fixed test case

test: fixed test cause for folder

fix: add grpc-request to clone-folder

fix: removed handleCrossCollectionItemMove method

test: updated e2e test cases

fix: removed cross-collection gurard statement

format: revert format

fix: UX changes for collection drag and drop
2025-09-08 16:29:46 +05:30
lohit
5b716cbe60 node vm fixes (#5519) 2025-09-08 06:39:18 +05:30
lohit
a6b0b6c117 node vm support (#5518)
Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com>
2025-09-08 06:09:25 +05:30
lohit
3c656270b3 ca certs fixes and tests (#5429)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-09-07 23:06:44 +05:30
Anoop M D
1bc7a1f655 Modify body size test to check for > 1MB 2025-09-07 21:49:11 +05:30
Anoop M D
5a10322608 Merge pull request #5490 from bernborgess/bugfix/global-shortcut-unfocused
Bugfix/global shortcut unfocused
2025-09-07 21:24:29 +05:30
Anoop M D
2864ddaa72 Merge pull request #5262 from sanish-bruno/add/playwright-testing-guide
Add Playwright testing guide for Bruno application
2025-09-07 04:59:23 +05:30
Anoop M D
c2f3d8e7da Merge pull request #5238 from helloanoop/feat/loc-count-script
feat: script to calculate locs of repo
2025-09-07 04:48:52 +05:30
Anoop M D
1fd61f0601 Merge pull request #5517 from helloanoop/refactor-tests
Refactor tests
2025-09-07 03:23:01 +05:30
Anoop M D
033c5cc0f7 Refactor tests 2025-09-07 03:05:11 +05:30
Pragadesh-45
db35e7059c Merge pull request #5438 from Pragadesh-45/feat/multiline-values-for-env-vars
Feat/ Add Multiline Support for Enviroment Variables
2025-09-06 17:17:51 +05:30
bernborgess
cd80332de9 fix: Make globalShortcut only active when app is focused 2025-09-03 14:02:01 -03:00
Anoop M D
1902329226 Merge pull request #5491 from naman-bruno/add/vscode-image
add: vscode image
2025-09-03 22:09:25 +05:30
naman-bruno
b25569d29a add: vscode image 2025-09-03 22:08:24 +05:30
Pooja
de4674dcc4 add: playwright test for import collection modal (#5487)
* add: playwright test for import collection modal
2025-09-03 19:14:08 +05:30
naman-bruno
457a2f83e7 fix: Bruno GUI hangs on 308 redirect (#5445)
* fix: 308 redirect
2025-09-03 17:52:12 +05:30
Pragadesh-45
ae3d5a5515 fix(apt): ensure Bruno repo key is world-readable on Debian 12+ (#5474)
- Added `chmod 644 /etc/apt/keyrings/bruno.gpg` so `_apt` user can read the key
- Keeps key in binary format with `gpg --dearmor`
- Prevents NO_PUBKEY errors and repository being ignored on Debian 12+
2025-09-03 17:43:58 +05:30
Bijin A B
3b74e0da86 fix(curl-parser): curl commands with url without protocol (#5453) 2025-09-03 16:05:19 +05:30
Pooja
985b5ed20c add: global search modal (#5400)
* add: global search modal
2025-09-03 15:32:18 +05:30
Anoop M D
188a2e63e3 Add HttpMethodSelector component tests (#5481) 2025-09-03 10:33:54 +05:30
Anoop M D
01839c8e5f Merge pull request #5435 from notKvS/theme-fix
fix: graphQL documentation theme
2025-09-03 01:18:11 +05:30
Sanjai Kumar
648581ded5 feat: custom HTTP method (#4841) 2025-09-02 23:09:48 +05:30
sreelakshmi-bruno
bf38cc0f51 adding metadata to report (#5360) 2025-09-02 15:11:23 +05:30
sanish chirayath
abddc98767 feat: add WSSE authentication support to gRPC requests (#5455)
* feat: add WSSE authentication support to gRPC requests

- Introduced WSSE authentication mode in GrpcAuth component.
- Updated supported authentication modes to include WSSE.
- Refactored gRPC event handlers to streamline authentication header setting.
- Added notes regarding limitations of complex auth modes in gRPC.

* fix: update authentication header retrieval in setAuthHeaders function

- Refactored the setAuthHeaders function to correctly retrieve WSSE and OAuth2 refresh token URLs from the request object instead of the collectionAuth object.
- Added comprehensive tests for various authentication modes, ensuring proper inheritance and request-level overrides for AWS v4, basic, bearer, digest, NTLM, WSSE, API key, and OAuth2 authentication methods.

* chore: remove outdated comments on gRPC authentication limitations
2025-09-01 19:19:14 +05:30
notKvS
5dd90e1386 fix: graphQL documentation theme 2025-08-27 20:47:16 +05:30
sanish-bruno
da2f2519ec feat: add Playwright testing guide for Bruno application 2025-08-05 17:17:38 +05:30
Anoop M D
7efaa427ca feat: script to calculate locs of repo 2025-08-01 13:50:13 +05:30
299 changed files with 18352 additions and 2121 deletions

View File

@@ -0,0 +1,26 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
shell: bash
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - Linux'
description: 'Run basic SSL CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - Linux'
description: 'Run custom CA certs CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,19 @@
name: 'Run SSL E2E Tests - Linux'
description: 'Run SSL E2E tests on Linux'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-linux
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - Linux'
description: 'Setup CA certificates and start test server for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,15 @@
name: 'Setup Custom CA Certs Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb libxml2-utils
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - macOS'
description: 'Run basic SSL CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - macOS'
description: 'Run custom CA certs CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - macOS'
description: 'Run SSL E2E tests on macOS'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-macos
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - macOS'
description: 'Setup CA certificates and start test server for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,9 @@
name: 'Setup Custom CA Certs Feature Dependencies - macOS'
description: 'Setup feature-specific dependencies for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
brew install libxml2

View File

@@ -0,0 +1,50 @@
name: 'Run Basic SSL CLI Tests - Windows'
description: 'Run basic SSL CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to basic SSL test collection directory
Set-Location tests\ssl\basic-ssl\collections\badssl
Write-Host "basic ssl success"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "with default/system ca certs"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
# navigate to self-signed SSL test collection directory
Set-Location ..\self-signed-badssl
Write-Host "self-signed ssl with validation disabled"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "self-signed ssl with default/system ca certs"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,47 @@
name: 'Run Custom CA Certs CLI Tests - Windows'
description: 'Run custom CA certs CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to CA certificates test collection directory
Set-Location tests\ssl\custom-ca-certs\collection
Write-Host "custom valid ca cert"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --cacert ..\server\certs\ca-cert.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "custom valid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --cacert ..\server\certs\ca-cert.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --cacert ..\server\certs\ca-key.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --cacert ..\server\certs\ca-key.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - Windows'
description: 'Run SSL E2E tests on Windows'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: pwsh
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-windows
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,25 @@
name: 'Setup CA Certificates - Windows'
description: 'Setup CA certificates and start test server for custom CA certs tests on Windows'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "starting server in background"
Start-Process -FilePath "node" -ArgumentList "index.js" -PassThru -WindowStyle Hidden

View File

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

91
.github/workflows/ssl-tests.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: SSL Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests-for-linux:
name: SSL Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
tests-for-macos:
name: SSL Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/macos/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
tests-for-windows:
name: SSL Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/windows/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests

View File

@@ -14,7 +14,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -65,7 +65,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -83,6 +83,11 @@ jobs:
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
@@ -102,7 +107,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: v22.11.x
- name: Install dependencies

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

View File

@@ -0,0 +1,470 @@
# Playwright Testing Guide for Bruno
This guide explains how to create and run Playwright test cases for the Bruno application using the UI.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Creating Tests Using Codegen](#creating-tests-using-codegen)
- [Manual Test Creation](#manual-test-creation)
- [Test Structure and Organization](#test-structure-and-organization)
- [Available Test Fixtures](#available-test-fixtures)
- [Running Tests](#running-tests)
- [Best Practices](#best-practices)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)
## Overview
Bruno uses Playwright for end-to-end testing of its Electron application. The testing setup includes custom fixtures for Electron app testing and utilities for managing test data.
## Prerequisites
- Node.js installed
- All dependencies installed (`npm install`)
- Electron app can be built and run
## Creating Tests Using Codegen
The easiest way to create tests is using Playwright's codegen feature, which records your UI interactions and generates test code.
### Using the Built-in Codegen Script
```bash
# Generate a test with a specific name
npm run test:codegen my-new-test
# Generate a test without specifying a name (will prompt for input)
npm run test:codegen
```
### What Happens During Codegen
1. The Electron app launches automatically
2. Playwright Inspector opens in a separate window
3. You interact with the Bruno UI
4. Actions are recorded and converted to test code
5. The generated test file is saved in `e2e-tests/`
### Codegen Workflow
1. **Start Recording**: Run the codegen command
2. **Interact with UI**: Perform the actions you want to test
3. **Add Assertions**: Use the inspector to add assertions
4. **Save Test**: The test file is automatically generated
5. **Review and Refine**: Edit the generated test as needed
## Manual Test Creation
You can also create tests manually by following the established patterns.
### Basic Test Structure
```typescript
import { test, expect } from '../../playwright';
test('Test description', async ({ page }) => {
// Test steps here
await page.getByLabel('Some Label').click();
// Assertions
await expect(page.getByText('Expected Text')).toBeVisible();
});
```
### Test with Temporary Data
```typescript
import { test, expect } from '../../playwright';
test('Test with temporary data', async ({ page, createTmpDir }) => {
// Create temporary directory for test data
const testDir = await createTmpDir('test-collection');
// Test steps
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Location').fill(testDir);
// Assertions
await expect(page.getByText('test-collection')).toBeVisible();
});
```
## Test Structure and Organization
### Directory Structure
```
e2e-tests/
├── 001-sanity-tests/ # Basic functionality tests
│ ├── 001-home-screen.spec.ts
│ └── 002-create-new-collection-and-new-request.spec.ts
├── 002-feature-tests/ # Specific feature tests
├── 003-integration-tests/ # Complex workflow tests
└── bruno-testbench/ # Test utilities and helpers
```
### Naming Conventions
- **Files**: Use descriptive names with `.spec.ts` extension
- **Tests**: Use clear, descriptive test names
- **Folders**: Use numbered prefixes for ordering
### Test File Template
```typescript
import { test, expect } from '../../playwright';
test.describe('Feature Name', () => {
test('should perform specific action', async ({ page }) => {
// Arrange
// Act
// Assert
});
test('should handle error case', async ({ page }) => {
// Test error scenarios
});
});
```
## Available Test Fixtures
The Bruno Playwright setup provides several custom fixtures:
### Core Fixtures
- `page`: Main page for testing
- `context`: Browser context
- `electronApp`: Electron application instance
### Utility Fixtures
- `createTmpDir`: Creates temporary directories for test data
- `newPage`: Creates a new page instance
- `pageWithUserData`: Page with custom user data
- `launchElectronApp`: Launches a new Electron app instance
- `reuseOrLaunchElectronApp`: Reuses existing app or launches new one
### Using Fixtures
```typescript
test('Test with multiple fixtures', async ({ page, createTmpDir, electronApp }) => {
const testDir = await createTmpDir('test-data');
// Your test logic here
});
```
## Running Tests
### Basic Commands
```bash
# Run all tests
npm run test:e2e
# Run specific test file
npx playwright test e2e-tests/001-sanity-tests/001-home-screen.spec.ts
# Run tests in a specific folder
npx playwright test e2e-tests/001-sanity-tests/
```
### Advanced Options
```bash
# Run with UI mode (for debugging)
npx playwright test --ui
# Run in headed mode (see browser)
npx playwright test --headed
# Run with specific browser
npx playwright test --project="Bruno Electron App"
# Run with debugging
npx playwright test --debug
# Run with trace recording
npx playwright test --trace on
```
### CI/CD Integration
```bash
# Install browsers for CI
npx playwright install
# Run tests in CI mode
npm run test:e2e
```
## Best Practices
### 1. Use Semantic Selectors
**Preferred:**
```typescript
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Collection Name').fill('test');
await page.getByText('Success message').toBeVisible();
```
**Avoid:**
```typescript
await page.locator('.btn-primary').click();
await page.locator('#collection-name').fill('test');
```
### 2. Create Isolated Tests
Each test should be independent and not rely on other tests:
```typescript
test('should create collection', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('collection-test');
// Test creates its own data
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Location').fill(testDir);
// Clean up happens automatically via createTmpDir
});
```
### 3. Add Meaningful Assertions
Always verify the expected outcomes:
```typescript
test('should save request successfully', async ({ page }) => {
// Arrange
await page.getByLabel('Create Collection').click();
// Act
await page.getByRole('button', { name: 'Save' }).click();
// Assert
await expect(page.getByText('Request saved successfully')).toBeVisible();
await expect(page.getByRole('tab', { name: 'GET request' })).toBeVisible();
});
```
### 4. Handle Async Operations
```typescript
test('should wait for network requests', async ({ page }) => {
// Wait for specific network request
await page.waitForResponse((response) => response.url().includes('/api/endpoint'));
// Or wait for element to be stable
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
});
```
### 5. Use Test Data Management
```typescript
test('should work with test data', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('test-data');
// Create test files
await fs.writeFile(path.join(testDir, 'test.bru'), testContent);
// Use in test
await page.getByLabel('Open Collection').click();
await page.getByText(testDir).click();
});
```
## Examples
### Example 1: Basic Collection Creation
```typescript
import { test, expect } from '../../playwright';
test('should create a new collection', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('new-collection');
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('My Test Collection');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('My Test Collection')).toBeVisible();
});
```
### Example 2: Request Creation and Execution
```typescript
import { test, expect } from '../../playwright';
test('should create and execute HTTP request', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('request-test');
// Create collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('Request Test');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
// Create request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('Test Request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081/ping');
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');
});
```
### Example 3: Environment Management
```typescript
import { test, expect } from '../../playwright';
test('should create and use environment variables', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('env-test');
// Setup collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('Environment Test');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
// Create environment
await page.getByRole('button', { name: 'Environments' }).click();
await page.getByRole('button', { name: 'Add Environment' }).click();
await page.getByLabel('Environment Name').fill('Development');
await page.getByRole('button', { name: 'Create' }).click();
// Add variable
await page.getByRole('button', { name: 'Add Variable' }).click();
await page.getByLabel('Variable Name').fill('API_URL');
await page.getByLabel('Variable Value').fill('http://localhost:3000');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('API_URL')).toBeVisible();
});
```
## Troubleshooting
### Common Issues
1. **Electron App Not Starting**
```bash
# Ensure dependencies are installed
npm install
# Try running the app manually first
npm run dev:electron
```
2. **Tests Timing Out**
```typescript
// Increase timeout for specific test
test('slow test', async ({ page }) => {
test.setTimeout(60000); // 60 seconds
// Test steps
});
```
3. **Element Not Found**
```typescript
// Wait for element to be present
await page.waitForSelector('[data-testid="element"]');
// Or use more specific selectors
await page.getByRole('button', { name: 'Exact Button Text' }).click();
```
4. **Flaky Tests**
```typescript
// Use stable selectors
await page.getByTestId('stable-id').click();
// Wait for state changes
await page.waitForLoadState('networkidle');
```
### Debug Mode
```bash
# Run with debug mode
npx playwright test --debug
# Run specific test in debug mode
npx playwright test --debug e2e-tests/001-sanity-tests/001-home-screen.spec.ts
```
### Trace Analysis
```bash
# Run with trace recording
npx playwright test --trace on
# View trace in browser
npx playwright show-trace test-results/trace-*.zip
```
## Configuration
The Playwright configuration is in `playwright.config.ts`:
```typescript
export default defineConfig({
testDir: './e2e-tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? undefined : 1,
projects: [
{
name: 'Bruno Electron App'
}
],
webServer: [
{
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
},
{
command: 'npm start --workspace=packages/bruno-tests',
url: 'http://localhost:8081/ping',
reuseExistingServer: !process.env.CI
}
]
});
```
## Additional Resources
- [Playwright Documentation](https://playwright.dev/)
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
- [Electron Testing with Playwright](https://playwright.dev/docs/api/class-electronapplication)
- [Bruno Project Structure](../readme.md)
---
For questions or issues with testing, please refer to the project's contributing guidelines or create an issue in the repository.

View File

@@ -74,10 +74,13 @@ flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -59,10 +59,13 @@ snap install bruno
# Apt এর মাধ্যমে লিনাক্সে
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -78,10 +78,13 @@ flatpak install com.usebruno.Bruno
# Auf Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -75,10 +75,13 @@ flatpak install com.usebruno.Bruno
# En Linux con Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -75,12 +75,14 @@ flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
@@ -148,4 +150,3 @@ Scriptmania
लाइसेंस 📄
MIT

View File

@@ -59,10 +59,13 @@ snap install bruno
# Su Linux tramite Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -78,10 +78,13 @@ flatpak install com.usebruno.Bruno
# LinuxでAptを使ってインストール
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -77,12 +77,14 @@ flatpak install com.usebruno.Bruno
# Linux-ზე Apt-ის საშუალებით
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### პლატფორმებს შორის მუშაობა 🖥️

View File

@@ -59,10 +59,13 @@ snap install bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -61,12 +61,14 @@ flatpak install com.usebruno.Bruno
# Op Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Draai op meerdere platformen 🖥️

View File

@@ -69,10 +69,13 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -76,10 +76,13 @@ flatpak install com.usebruno.Bruno
# No Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -59,10 +59,13 @@ snap install bruno
# Pe Linux cu Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# Apt aracılığıyla Linux'ta
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# 在 Linux 上使用 Apt 安裝
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -1,33 +0,0 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('ping', { exact: true }).click();
await page.getByText('No Environment').click();
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -1,40 +0,0 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByText('ping2', { exact: true }).click();
await page.getByText('Env', { exact: true }).click();
await page.getByText('Stage', { exact: true }).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping2', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByText('Stage').click();
await newPage
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -1,4 +0,0 @@
vars {
host: https://testbench-sanity.usebruno.com
persistent-env-test: persistent-env-test-value
}

View File

@@ -1,6 +0,0 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
]
}

753
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,8 @@
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"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)"
},

View File

@@ -16,6 +16,7 @@
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
"@tabler/icons": "^1.46.0",
"@testing-library/user-event": "^14.6.1",
"@tippyjs/react": "^4.2.6",
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
@@ -92,7 +93,7 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
const EnvironmentListContent = ({
environments,
activeEnvironmentUid,
description,
onEnvironmentSelect,
onSettingsClick,
onCreateClick,
onImportClick
}) => {
return (
<div>
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<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} />
<span>Configure</span>
</button>
</div>
</div>
</>
) : (
<div className="empty-state">
<h3>Ready to get started?</h3>
<p>{description}</p>
<div className="space-y-2">
<button onClick={onCreateClick} id="create-env">
<IconPlus size={16} strokeWidth={1.5} />
Create
</button>
<button onClick={onImportClick} id="import-env">
<IconDownload size={16} strokeWidth={1.5} />
Import
</button>
</div>
</div>
)}
</div>
);
};
export default EnvironmentListContent;

View File

@@ -2,14 +2,227 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 15px;
border-radius: 0.9375rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
user-select: none;
background-color: transparent;
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
line-height: 1rem;
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.dropdown.selectedColor};
}
.env-text {
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: 0.875rem;
}
.env-separator {
color: #8c8c8c;
margin: 0 0.25rem;
opacity: 0.7;
}
.env-text-inactive {
color: ${(props) => props.theme.dropdown.color};
font-size: 0.875rem;
opacity: 0.7;
}
&.no-environments {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border: 1px solid transparent;
color: ${(props) => props.theme.dropdown.secondaryText};
}
}
.tippy-box {
min-width: 11.875rem;
min-height: 15.0625rem;
max-height: 75vh;
font-size: 0.8125rem;
position: relative;
overflow: hidden;
}
.tippy-box .tippy-content {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
.dropdown-item {
display: flex;
align-items: center;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.8125rem;
color: ${(props) => props.theme.dropdown.primaryText};
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
background-color: ${(props) => props.theme.dropdown.selectedBg};
color: ${(props) => props.theme.dropdown.selectedColor};
}
&.no-environment {
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
.configure-button {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: ${(props) => props.theme.dropdown.bg};
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
z-index: 10;
margin: 0;
&:hover {
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
}
button {
color: ${(props) => props.theme.dropdown.primaryText};
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 0.5rem;
}
}
.tab-button {
color: var(--color-tab-inactive);
font-size: 0.8125rem;
.tab-content-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.125rem;
}
&.active {
color: ${(props) => props.theme.tabs.active.color};
border-bottom-color: ${(props) => props.theme.tabs.active.border};
}
&.inactive {
border-bottom-color: transparent;
}
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.environment-list {
flex: 1;
overflow-y: auto;
max-height: calc(75vh - 8rem);
padding-bottom: 2.625rem;
}
.dropdown-item-list {
max-height: 75vh;
overflow-y: scroll;
}
.empty-state {
max-width: 20rem;
margin: 0 auto;
padding: 0.35rem 0.6rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 12.5rem;
h3 {
color: ${(props) => props.theme.dropdown.primaryText};
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
line-height: 1.4;
}
p {
color: ${(props) => props.theme.dropdown.primaryText};
opacity: 0.75;
font-size: 0.6875rem;
line-height: 1.5;
margin-bottom: 1rem;
max-width: 11.875rem;
margin: 0 auto;
margin-bottom: 1rem;
}
.space-y-2 {
width: 100%;
align-self: stretch;
}
.space-y-2 > button {
border: 0.0625rem solid ${(props) => props.theme.dropdown.primaryText};
background: transparent;
color: ${(props) => props.theme.dropdown.primaryText};
padding: 0.5rem 1rem;
border-radius: 0.375rem;
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:last-child {
margin-bottom: 0;
}
}
}
.no-collection-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: ${(props) => props.theme.dropdown.primaryText};
font-size: 0.8125rem;
line-height: 1.5;
text-align: center;
opacity: 0.75;
svg {
margin: 0 auto 1rem auto;
color: ${(props) => props.theme.dropdown.primaryText};
opacity: 0.5;
}
}
`;

View File

@@ -1,95 +1,240 @@
import React, { useRef, forwardRef, useState } from 'react';
import React, { useState, useRef, forwardRef } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import EnvironmentListContent from './EnvironmentListContent/index';
import EnvironmentSettings from '../EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
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 StyledWrapper from './StyledWrapper';
const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const { environments, activeEnvironmentUid } = collection;
const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
const [activeTab, setActiveTab] = useState('collection');
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const activeGlobalEnvironment = activeGlobalEnvironmentUid
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
: null;
const environments = collection?.environments || [];
const activeEnvironmentUid = collection?.activeEnvironmentUid;
const activeCollectionEnvironment = activeEnvironmentUid
? find(environments, (e) => e.uid === activeEnvironmentUid)
: null;
const tabs = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
// Get description based on active tab
const description =
activeTab === 'collection'
? 'Create your first environment to begin working with your collection.'
: 'Create your first global environment to begin working across collections.';
// Environment selection handler
const handleEnvironmentSelect = (environment) => {
const action =
activeTab === 'collection'
? selectEnvironment(environment ? environment.uid : null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
dispatch(action)
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success('No Environments are active now');
}
dropdownTippyRef.current.hide();
})
.catch((err) => {
toast.error('An error occurred while selecting the environment');
});
};
// Settings handler
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
} else {
setShowGlobalSettings(true);
}
dropdownTippyRef.current.hide();
};
// Create handler
const handleCreateClick = () => {
if (activeTab === 'collection') {
setShowCreateCollectionModal(true);
} else {
setShowCreateGlobalModal(true);
}
dropdownTippyRef.current.hide();
};
// Import handler
const handleImportClick = () => {
if (activeTab === 'collection') {
setShowImportCollectionModal(true);
} else {
setShowImportGlobalModal(true);
}
dropdownTippyRef.current.hide();
};
// Modal handlers
const handleCloseSettings = () => {
setShowGlobalSettings(false);
setShowCollectionSettings(false);
dispatch(updateEnvironmentSettingsModalVisibility(false));
};
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
const displayContent = hasAnyEnv ? (
<>
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<span className="env-text max-w-24 truncate no-wrap">{activeCollectionEnvironment.name}</span>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
)}
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<span className="env-text max-w-24 truncate no-wrap">{activeGlobalEnvironment.name}</span>
</div>
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
);
return (
<div ref={ref} className="current-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
<p className="text-nowrap truncate max-w-32">{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
<div
ref={ref}
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
!hasAnyEnv ? 'no-environments' : ''
}`}
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
dispatch(updateEnvironmentSettingsModalVisibility(true));
};
const handleModalClose = () => {
setOpenSettingsModal(false);
dispatch(updateEnvironmentSettingsModalVisibility(false));
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector">
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Collection Environments</div>
{environments && environments.length
? environments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
{/* Tab Headers */}
<div className="tab-header flex justify-center p-[0.75rem]">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
activeTab === tab.id ? 'active' : 'inactive'
}`}
onClick={() => setActiveTab(tab.id)}
data-testid={`env-tab-${tab.id}`}
>
<span className="tab-content-wrapper">
{tab.icon}
{tab.label}
</span>
</button>
))}
</div>
<div className="dropdown-item border-top" onClick={() => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
{/* Tab Content */}
<div className="tab-content">
<EnvironmentListContent
environments={activeTab === 'collection' ? environments : globalEnvironments}
activeEnvironmentUid={activeTab === 'collection' ? activeEnvironmentUid : activeGlobalEnvironmentUid}
description={description}
onEnvironmentSelect={handleEnvironmentSelect}
onSettingsClick={handleSettingsClick}
onCreateClick={handleCreateClick}
onImportClick={handleImportClick}
/>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings collection={collection} onClose={handleModalClose} />}
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{showGlobalSettings && (
<GlobalEnvironmentSettings globalEnvironments={globalEnvironments} collection={collection} onClose={handleCloseSettings} />
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
}}
/>
)}
{showImportGlobalModal && (
<ImportGlobalEnvironment
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
}}
/>
)}
{showCreateCollectionModal && (
<CreateEnvironment
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
}}
/>
)}
{showImportCollectionModal && (
<ImportEnvironment
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
}}
/>
)}
</StyledWrapper>
);
};

View File

@@ -8,7 +8,7 @@ import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => {
const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const inputRef = useRef();
@@ -37,6 +37,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
.then(() => {
toast.success('Environment created in collection');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}

View File

@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -173,7 +173,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table>
<table className="environment-variables">
<thead>
<tr>
<td className="text-center">Enabled</td>
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
@@ -253,6 +253,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
id="add-variable"
data-testid="add-variable"
>
+ Add Variable
</button>
@@ -260,15 +262,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</div>
<div className="flex items-center">
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit}>
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset}>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
Reset
</button>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate}>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
Activate
</button>

View File

@@ -8,7 +8,7 @@ import { importEnvironment } from 'providers/ReduxStore/slices/collections/actio
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
const ImportEnvironment = ({ collection, onClose }) => {
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -36,17 +36,22 @@ const ImportEnvironment = ({ collection, onClose }) => {
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>

View File

@@ -56,9 +56,8 @@ const EnvironmentSettings = ({ collection, onClose }) => {
) : tab === 'import' ? (
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
) : (
<></>
<DefaultTab setTab={setTab} />
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);

View File

@@ -1,18 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
}
.environment-active {
padding: 0.3rem 0.4rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
.environment-selector {
.active: {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default Wrapper;

View File

@@ -1,100 +0,0 @@
import React, { useRef, forwardRef, useState } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import ToolHint from 'components/ToolHint/index';
const EnvironmentSelector = () => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
<IconWorld className="globe" size={16} strokeWidth={1.5} />
{
activeEnvironment ? <div className='text-nowrap truncate max-w-32'>{activeEnvironment?.name}</div> : null
}
</ToolHint>
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
};
const handleModalClose = () => {
setOpenSettingsModal(false);
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector mr-3">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
<div className="label-item font-medium">Global Environments</div>
{globalEnvironments && globalEnvironments.length
? globalEnvironments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
</div>
<div className="dropdown-item border-top" onClick={() => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
</StyledWrapper>
);
};
export default EnvironmentSelector;

View File

@@ -8,7 +8,7 @@ import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose }) => {
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
@@ -39,6 +39,10 @@ const CreateEnvironment = ({ onClose }) => {
.then(() => {
toast.success('Global environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}

View File

@@ -2,8 +2,8 @@ import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -12,11 +12,18 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector(state => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -93,7 +100,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
@@ -145,10 +152,11 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
@@ -179,6 +187,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
data-testid="add-variable"
>
+ Add Variable
</button>
@@ -186,10 +195,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>

View File

@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, setIsModified }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -37,7 +37,7 @@ const EnvironmentDetails = ({ environment, setIsModified }) => {
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</div>
);

View File

@@ -10,7 +10,7 @@ import ImportEnvironment from '../ImportEnvironment';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
@@ -143,6 +143,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
</div>
</StyledWrapper>

View File

@@ -9,7 +9,7 @@ import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose }) => {
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -37,17 +37,22 @@ const ImportEnvironment = ({ onClose }) => {
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-global-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>

View File

@@ -39,7 +39,7 @@ const DefaultTab = ({ setTab }) => {
);
};
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
const EnvironmentSettings = ({ globalEnvironments, collection, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
@@ -53,9 +53,8 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<></>
<DefaultTab setTab={setTab} />
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);
@@ -65,11 +64,11 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
/>
</Modal>
);

View File

@@ -0,0 +1,361 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.command-k-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
z-index: 20;
background-color: transparent;
&:before {
content: '';
height: 100%;
width: 100%;
left: 0;
opacity: ${(props) => props.theme.modal.backdrop.opacity};
top: 0;
background: black;
position: fixed;
}
animation: fade-in 0.1s forwards cubic-bezier(0.19, 1, 0.22, 1);
}
.command-k-modal {
background: ${(props) => props.theme.modal.body.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 80px auto;
animation: fade-and-slide-in-from-top 0.3s forwards cubic-bezier(0.19, 1, 0.22, 1);
will-change: opacity, transform;
}
.command-k-header {
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
background: ${(props) => props.theme.modal.title.bg};
}
.search-input-container {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 6px;
background: ${(props) => props.theme.modal.input.bg};
transition: all 0.2s ease;
&:focus-within {
border-color: ${(props) => props.theme.colors.text.muted};
box-shadow: 0 0 0 1px ${(props) => props.theme.colors.text.muted}40;
}
.search-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
margin-right: 8px;
flex-shrink: 0;
}
.clear-button {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
margin-left: 8px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'};
}
}
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
width: 100%;
padding: 0;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
}
}
.command-k-results {
flex: 1;
overflow-y: auto;
max-height: 400px;
background: ${(props) => props.theme.modal.body.bg};
scrollbar-width: thin;
padding: 4px;
scroll-behavior: smooth;
/* Webkit scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'};
border-radius: 4px;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'};
}
}
}
.result-item {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 8px;
cursor: pointer;
border-left: 2px solid transparent;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
}
&.selected {
background: ${(props) => `${props.theme.colors.text.yellow}15`};
border-left: 2px solid ${(props) => props.theme.colors.text.yellow};
}
}
.result-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.result-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.result-info {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.result-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.result-name {
font-size: 13px;
margin-bottom: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: ${(props) => props.theme.text};
letter-spacing: 0.2px;
}
.result-path {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.1px;
}
.method-badge {
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
min-width: 55px;
text-align: center;
&.get {
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
&.post {
color: #3498db;
background: rgba(52, 152, 219, 0.1);
}
&.put {
color: #e67e22;
background: rgba(230, 126, 34, 0.1);
}
&.delete {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
&.patch {
color: #9b59b6;
background: rgba(155, 89, 182, 0.1);
}
&.head {
color: #2980b9;
background: rgba(41, 128, 185, 0.1);
}
&.options {
color: #f1c40f;
background: rgba(241, 196, 15, 0.1);
}
&.unary {
color: #27ae60;
background: rgba(39, 174, 96, 0.12);
font-weight: 600;
}
&.client-streaming {
color: #2980b9;
background: rgba(41, 128, 185, 0.12);
font-weight: 600;
}
&.server-streaming {
color: #f39c12;
background: rgba(243, 156, 18, 0.12);
font-weight: 600;
}
&.bidirectional-streaming,
&.bidi-streaming {
color: #8e44ad;
background: rgba(142, 68, 173, 0.12);
font-weight: 600;
}
}
.result-type {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.3px;
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'};
opacity: 0.8;
flex-shrink: 0;
}
.result-item[data-type="documentation"] {
.result-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.result-path {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.1px;
opacity: 0.8;
}
&:hover:not(.selected) {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
}
}
.no-results,
.empty-state {
padding: 24px 16px;
text-align: center;
color: ${(props) => props.theme.colors.text.muted};
font-size: 13px;
}
.command-k-footer {
padding: 8px 12px;
border-top: 1px solid ${(props) => props.theme.modal.input.border};
background: ${(props) => props.theme.colors.surface};
}
.keyboard-hints {
display: flex;
justify-content: center;
gap: 24px;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
letter-spacing: 0.2px;
span {
display: flex;
align-items: center;
gap: 6px;
.hint-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.hint-icon + .hint-icon {
margin-left: -8px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 4px;
background: ${(props) =>
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
font-size: 11px;
font-weight: 500;
font-family: inherit;
line-height: 1;
color: ${(props) => props.theme.text};
}
}
}
.highlight {
background: ${(props) => `${props.theme.colors.text.yellow}30`};
border-radius: 2px;
padding: 0 2px;
margin: 0 -1px;
font-weight: 500;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-and-slide-in-from-top {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,32 @@
export const SEARCH_TYPES = {
DOCUMENTATION: 'documentation',
COLLECTION: 'collection',
FOLDER: 'folder',
REQUEST: 'request'
};
export const MATCH_TYPES = {
COLLECTION: 'collection',
FOLDER: 'folder',
REQUEST: 'request',
URL: 'url',
PATH: 'path',
DOCUMENTATION: 'documentation'
};
export const SEARCH_CONFIG = {
MAX_DEPTH: 20,
FOCUS_DELAY: 100,
SCROLL_BEHAVIOR: 'smooth',
SCROLL_BLOCK: 'nearest',
DEBOUNCE_DELAY: 300
};
export const DOCUMENTATION_RESULT = {
type: SEARCH_TYPES.DOCUMENTATION,
item: { id: 'docs', name: 'Bruno Documentation' },
name: 'Bruno Documentation',
path: '/',
description: 'Browse the official Bruno documentation',
matchType: MATCH_TYPES.DOCUMENTATION
};

View File

@@ -0,0 +1,516 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconSearch,
IconX,
IconFolder,
IconBox,
IconFileText,
IconBook
} from '@tabler/icons';
import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
import StyledWrapper from './StyledWrapper';
const GlobalSearchModal = ({ isOpen, onClose }) => {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [results, setResults] = useState([]);
const inputRef = useRef(null);
const resultsRef = useRef(null);
const debounceTimeoutRef = useRef(null);
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const createCollectionResults = () => {
const collectionResults = collections.map(collection => ({
type: SEARCH_TYPES.COLLECTION,
item: collection,
name: collection.name,
path: collection.name,
matchType: MATCH_TYPES.COLLECTION,
collectionUid: collection.uid
}));
collectionResults.sort((a, b) => a.name.localeCompare(b.name));
return [DOCUMENTATION_RESULT, ...collectionResults];
};
const searchInCollections = (searchTerms, enablePathMatch) => {
const results = [];
// Check for documentation match
const queryLower = searchTerms.join(' ');
if (['documentation', 'docs', 'bruno docs'].some(term => term.includes(queryLower))) {
results.push(DOCUMENTATION_RESULT);
}
collections.forEach(collection => {
// Search collection name
if (searchTerms.every(term => collection.name.toLowerCase().includes(term))) {
results.push({
type: SEARCH_TYPES.COLLECTION,
item: collection,
name: collection.name,
path: collection.name,
matchType: MATCH_TYPES.COLLECTION,
collectionUid: collection.uid
});
}
// Search collection items
const flattenedItems = flattenItems(collection.items);
flattenedItems.forEach(item => {
const itemPath = getItemPath(item, collection, findParentItemInCollection);
const itemPathLower = itemPath.toLowerCase();
if (isItemARequest(item)) {
// add an optional check for the item name to prevent a crash if it doesnt exist.
const nameMatch = searchTerms.every(term => (item.name || '').toLowerCase().includes(term));
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
if (nameMatch || urlMatch || pathMatch) {
// Check if this is a gRPC request and get the method type
const isGrpcRequest = item.request?.type === 'grpc';
let method = item.request?.method || '';
if (isGrpcRequest) {
// For gRPC requests, use the methodType
const methodType = item.request?.methodType || 'UNARY';
method = methodType.toLowerCase().replace(/[_]/g, '-');
}
results.push({
type: SEARCH_TYPES.REQUEST,
item,
name: item.name,
path: itemPath,
matchType: nameMatch ? MATCH_TYPES.REQUEST : urlMatch ? MATCH_TYPES.URL : MATCH_TYPES.PATH,
method,
collectionUid: collection.uid
});
}
} else if (isItemAFolder(item)) {
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
if (nameMatch || pathMatch) {
results.push({
type: SEARCH_TYPES.FOLDER,
item,
name: item.name,
path: itemPath,
matchType: nameMatch ? MATCH_TYPES.FOLDER : MATCH_TYPES.PATH,
collectionUid: collection.uid
});
}
}
});
});
return results;
};
const performSearch = (searchQuery) => {
const normalizedQuery = normalizeQuery(searchQuery);
if (!normalizedQuery) {
setResults(createCollectionResults());
return;
}
if (!isValidQuery(normalizedQuery)) {
setResults([]);
return;
}
const searchTerms = normalizedQuery.toLowerCase().split(/[\s\/]+/).filter(Boolean);
if (!searchTerms.length) {
setResults([]);
return;
}
const enablePathMatch = normalizedQuery.includes('/');
const searchResults = searchInCollections(searchTerms, enablePathMatch);
const sortedResults = sortResults(searchResults);
setResults(sortedResults);
setSelectedIndex(0);
};
const debouncedSearch = useCallback((searchQuery) => {
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Set new timeout
debounceTimeoutRef.current = setTimeout(() => {
performSearch(searchQuery);
}, SEARCH_CONFIG.DEBOUNCE_DELAY);
}, [collections]); // Depend on collections to recreate when they change
const expandItemPath = (result) => {
const collection = collections.find(c => c.uid === result.collectionUid);
if (!collection) return;
ensureCollectionIsMounted(collection);
if (collection.collapsed) {
dispatch(toggleCollection(collection.uid));
}
let currentItem = result.type === SEARCH_TYPES.FOLDER
? result.item
: findParentItemInCollection(collection, result.item.uid);
while (currentItem?.type === 'folder') {
if (currentItem.collapsed) {
dispatch(toggleCollectionItem({ collectionUid: collection.uid, itemUid: currentItem.uid }));
}
currentItem = findParentItemInCollection(collection, currentItem.uid);
}
};
const ensureCollectionIsMounted = (collection) => {
if (!collection || collection.mountStatus === 'mounted') return;
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
};
const handleKeyNavigation = (e) => {
const handlers = {
ArrowDown: () => {
e.preventDefault();
setSelectedIndex(prev => prev < results.length - 1 ? prev + 1 : 0);
},
ArrowUp: () => {
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : results.length - 1);
},
Enter: () => {
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelection(results[selectedIndex]);
}
},
Escape: () => {
e.preventDefault();
onClose();
},
PageDown: () => {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 5, results.length - 1));
},
PageUp: () => {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 5, 0));
},
Home: () => {
e.preventDefault();
setSelectedIndex(0);
},
End: () => {
e.preventDefault();
setSelectedIndex(results.length - 1);
}
};
const handler = handlers[e.key];
if (handler) handler();
};
const handleResultSelection = (result) => {
const targetCollection = collections.find(c => c.uid === result.collectionUid);
ensureCollectionIsMounted(targetCollection);
if (result.type === SEARCH_TYPES.DOCUMENTATION) {
window.open('https://docs.usebruno.com/', '_blank');
onClose();
return;
}
expandItemPath(result);
if (result.type === SEARCH_TYPES.REQUEST) {
dispatch(hideHomePage());
const existingTab = tabs.find(tab => tab.uid === result.item.uid);
if (existingTab) {
dispatch(focusTab({ uid: result.item.uid }));
} else {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: 'request',
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings',
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'collection-settings',
}));
}
onClose();
};
const handleQueryChange = (e) => {
const newQuery = e.target.value;
setQuery(newQuery);
if (newQuery.trim()) {
debouncedSearch(newQuery);
} else {
// For empty queries, search immediately to show collections
performSearch(newQuery);
}
};
const clearSearch = () => {
// Clear any pending debounced search
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
setQuery('');
setResults([]);
};
// Initialize modal when opened
useEffect(() => {
if (isOpen) {
const timeoutId = setTimeout(() => inputRef.current?.focus(), SEARCH_CONFIG.FOCUS_DELAY);
setQuery('');
performSearch('');
setSelectedIndex(0);
return () => clearTimeout(timeoutId);
} else {
// Clear any pending debounced search when modal closes
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
}
}, [isOpen]);
// Auto-scroll selected item into view
useEffect(() => {
if (resultsRef.current && results.length > 0) {
const selectedElement = resultsRef.current.children[selectedIndex];
selectedElement?.scrollIntoView({
behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR,
block: SEARCH_CONFIG.SCROLL_BLOCK
});
}
}, [selectedIndex, results]);
// Cleanup debounce timeout on unmount or modal close
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
const getResultIcon = (type) => {
const iconMap = {
[SEARCH_TYPES.DOCUMENTATION]: IconBook,
[SEARCH_TYPES.COLLECTION]: IconBox,
[SEARCH_TYPES.FOLDER]: IconFolder,
[SEARCH_TYPES.REQUEST]: IconFileText
};
const IconComponent = iconMap[type] || IconFileText;
return <IconComponent size={18} stroke={1.5} />;
};
if (!isOpen) return null;
return (
<StyledWrapper>
<div
className="command-k-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="search-modal-title"
aria-describedby="search-modal-description"
>
<div className="command-k-modal" onClick={(e) => e.stopPropagation()}>
<h1 id="search-modal-title" className="sr-only">Global Search</h1>
<p id="search-modal-description" className="sr-only">
Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select.
</p>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{results.length > 0 && query
? `${results.length} result${results.length === 1 ? '' : 's'} found`
: query && results.length === 0
? 'No results found'
: ''
}
</div>
<div className="command-k-header">
<div className="search-input-container">
<IconSearch size={20} className="search-icon" aria-hidden="true" />
<input
ref={inputRef}
type="text"
placeholder="Search collections, requests, or documentation..."
value={query}
onChange={handleQueryChange}
onKeyDown={handleKeyNavigation}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
aria-label="Search collections, requests, or documentation"
aria-expanded={results.length > 0}
aria-controls="search-results"
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
role="combobox"
aria-autocomplete="list"
/>
{query && (
<button
onClick={clearSearch}
className="clear-button"
aria-label="Clear search query"
type="button"
>
<IconX size={16} aria-hidden="true" />
</button>
)}
</div>
</div>
<div
className="command-k-results"
ref={resultsRef}
id="search-results"
role="listbox"
aria-label="Search results"
>
{results.length === 0 && query ? (
<div className="no-results">
<p>
No results found for "{query}".
<br />
<span className="block mt-2">
The item might not exist yet, or its collection isnt mounted. Press <strong>Enter</strong> here (or open it from the sidebar) to mount the collection automatically.
</span>
</p>
</div>
) : results.length === 0 ? (
<div className="empty-state">
<p>
No collections are currently mounted or visible.
<br />
<span className="block mt-2">
Mount a collection via the sidebar or this search modal, then try again.
</span>
</p>
</div>
) : (
results.map((result, index) => {
const isSelected = index === selectedIndex;
const typeLabel = getTypeLabel(result.type);
return (
<div
key={`${result.type}-${result.item.id || result.item.uid}-${index}`}
id={`search-result-${index}`}
className={`result-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleResultSelection(result)}
data-selected={isSelected}
data-type={result.type}
role="option"
aria-selected={isSelected}
aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`}
tabIndex={-1}
>
<div className="result-icon">
{getResultIcon(result.type)}
</div>
<div className="result-content">
<div className="result-info">
<div className="result-name">
{highlightText(result.name, query)}
</div>
<div className="result-path">
{result.type === SEARCH_TYPES.DOCUMENTATION
? result.description
: result.type === SEARCH_TYPES.REQUEST
? highlightText(result.item.request?.url || '', query)
: highlightText(result.path, query)}
</div>
</div>
<div className="result-badges">
{result.type === SEARCH_TYPES.REQUEST && result.method && (
<span
className={`method-badge ${result.method.toLowerCase()}`}
aria-label={`HTTP method ${result.method.toUpperCase().replace(/-/g, ' ')}`}
>
{result.method.toUpperCase().replace(/-/g, ' ')}
</span>
)}
{typeLabel && (
<div className="result-type" aria-label={`Item type ${typeLabel}`}>
{typeLabel}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="command-k-footer">
<div className="keyboard-hints" role="region" aria-label="Keyboard shortcuts">
<span aria-label="Use up and down arrows to navigate">
<span className="keycap" aria-hidden="true"></span>
<span className="keycap" aria-hidden="true"></span>
<span className="hint-label">to navigate</span>
</span>
<span aria-label="Press Enter to select">
<span className="keycap" aria-hidden="true"></span>
<span className="hint-label">to select</span>
</span>
<span aria-label="Press Escape to close">
<span className="keycap" aria-hidden="true">esc</span>
<span className="hint-label">to close</span>
</span>
</div>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default GlobalSearchModal;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants';
export const normalizeQuery = (searchQuery) => {
return searchQuery.trim().replace(/\/+/g, '/');
};
export const isValidQuery = (normalizedQuery) => {
return normalizedQuery &&
normalizedQuery !== '/' &&
!(normalizedQuery.length === 1 && !normalizedQuery.match(/[a-zA-Z0-9]/));
};
export const highlightText = (text, searchQuery) => {
if (!searchQuery) return text;
try {
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return text.split(regex).map((part, i) =>
regex.test(part) ? (
<span key={i} className="highlight">{part}</span>
) : part
);
} catch {
return text;
}
};
export const sortResults = (results) => {
return results.sort((a, b) => {
// Documentation always first
if (a.type === SEARCH_TYPES.DOCUMENTATION) return -1;
if (b.type === SEARCH_TYPES.DOCUMENTATION) return 1;
// Sort by match type priority
const matchTypeOrder = {
[MATCH_TYPES.COLLECTION]: 0,
[MATCH_TYPES.FOLDER]: 1,
[MATCH_TYPES.REQUEST]: 2,
[MATCH_TYPES.URL]: 3,
[MATCH_TYPES.PATH]: 4
};
const aMatchType = matchTypeOrder[a.matchType] ?? 5;
const bMatchType = matchTypeOrder[b.matchType] ?? 5;
if (aMatchType !== bMatchType) return aMatchType - bMatchType;
// Sort by type priority
const typeOrder = {
[SEARCH_TYPES.COLLECTION]: 0,
[SEARCH_TYPES.FOLDER]: 1,
[SEARCH_TYPES.REQUEST]: 2
};
const aType = typeOrder[a.type] ?? 3;
const bType = typeOrder[b.type] ?? 3;
if (aType !== bType) return aType - bType;
// Finally sort alphabetically
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
};
export const getTypeLabel = (type) => {
const baseLabels = {
[SEARCH_TYPES.DOCUMENTATION]: 'Documentation',
[SEARCH_TYPES.COLLECTION]: 'Collection',
[SEARCH_TYPES.FOLDER]: 'Folder'
};
return baseLabels[type] || '';
};
export const getItemPath = (item, collection, findParentItemInCollection) => {
const pathParts = [];
let currentItem = item;
let depth = 0;
const maxDepth = SEARCH_CONFIG.MAX_DEPTH;
while (currentItem && depth < maxDepth) {
pathParts.unshift(currentItem.name);
const parent = findParentItemInCollection(collection, currentItem.uid);
if (parent) {
currentItem = parent;
depth++;
} else {
break;
}
}
pathParts.unshift(collection.name);
return pathParts.join('/');
};

View File

@@ -9,7 +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 ? (
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
×
</div>
) : null}
@@ -71,7 +71,8 @@ const Modal = ({
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500
closeModalFadeTimeout = 500,
dataTestId
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -120,6 +121,7 @@ const Modal = ({
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
data-testid={dataTestId}
>
<ModalHeader
title={title}

View File

@@ -11,7 +11,9 @@ const StyledWrapper = styled.div`
height: fit-content;
font-size: 14px;
line-height: 30px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 200px;
pre.CodeMirror-placeholder {
color: ${(props) => props.theme.text};
@@ -19,18 +21,10 @@ const StyledWrapper = styled.div`
opacity: 0.5;
}
.CodeMirror-scroll {
overflow: visible !important;
position: relative;
display: block;
margin: 0px;
padding: 0px;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none;
display: none !important;
}
.CodeMirror-lines {

View File

@@ -3,7 +3,9 @@ import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -16,6 +18,10 @@ class MultiLineEditor extends Component {
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
@@ -23,22 +29,14 @@ class MultiLineEditor extends Component {
const variables = getAllVariables(this.props.collection, this.props.item);
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
variables
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
@@ -49,14 +47,6 @@ class MultiLineEditor extends Component {
this.props.onRun();
}
},
'Alt-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Shift-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
@@ -94,6 +84,10 @@ class MultiLineEditor extends Component {
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
// Initialize masking if this is a secret field
this.setState({ maskInput: this.props.isSecret });
this._enableMaskedEditor(this.props.isSecret);
}
_onEdit = () => {
@@ -105,6 +99,19 @@ class MultiLineEditor extends Component {
}
};
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
this.maskedEditor?.disable();
this.maskedEditor = null;
}
};
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
@@ -123,8 +130,11 @@ class MultiLineEditor extends Component {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
}
if (this.editorRef?.current) {
this.editorRef.current.scrollTo(0, 10000);
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
this.ignoreChangeEvent = false;
}
@@ -133,6 +143,10 @@ class MultiLineEditor extends Component {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
this.editor.getWrapperElement().remove();
}
@@ -142,8 +156,38 @@ class MultiLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables');
};
/**
* @brief Toggle the visibility of the secret value
*/
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
this._enableMaskedEditor(isVisible);
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className="multi-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>
);
}
}
export default MultiLineEditor;

View File

@@ -14,6 +14,11 @@ 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',
description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
}
];
@@ -68,7 +73,7 @@ const Beta = ({ close }) => {
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
};
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
const hasAnyBetaFeatures = BETA_FEATURES.length > 0;
return (
<StyledWrapper>

View File

@@ -176,11 +176,11 @@ const General = ({ close }) => {
name="keepDefaultCaCertificates.enabled"
checked={formik.values.keepDefaultCaCertificates.enabled}
onChange={formik.handleChange}
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
disabled={formik.values.customCaCertificate.enabled ? false : true}
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
disabled={formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? false : true}
/>
<label
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
htmlFor="keepDefaultCaCertificatesEnabled"
>
Keep Default CA Certificates

View File

@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'WSSE Auth',
mode: 'wsse'
},
{
name: 'Inherit',
mode: 'inherit'

View File

@@ -6,6 +6,7 @@ import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import OAuth2 from '../../Auth/OAuth2/index';
import WsseAuth from '../../Auth/WsseAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
@@ -13,7 +14,10 @@ import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/c
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
// List of auth modes supported by gRPC
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
// Note: Only header-based auth modes work with gRPC
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
// and cannot be supported in gRPC requests as of now
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
const GrpcAuth = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -83,6 +87,9 @@ const GrpcAuth = ({ item, collection }) => {
case 'oauth2': {
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@@ -9,7 +9,6 @@ const Wrapper = styled.div`
.method-selector {
border-radius: 3px;
min-width: 90px;
.tippy-box {
max-width: 150px !important;
@@ -21,6 +20,28 @@ const Wrapper = styled.div`
}
}
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
outline: none;
box-shadow: none;
text-align: left;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
.method-span {
width: 70px;
min-width: 70px;
max-width: 90px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: inline-block;
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);

View File

@@ -1,52 +1,142 @@
import React, { useRef, forwardRef } from 'react';
import React, { useState, useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const HttpMethodSelector = ({ method, onMethodSelect }) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const STANDARD_METHODS = Object.freeze(['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD','TRACE','CONNECT']);
const Icon = forwardRef((props, ref) => {
const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
const DEFAULT_METHOD = 'GET';
function Verb({ verb, onSelect }) {
return (
<div className="dropdown-item" onClick={() => onSelect(verb)}>
{verb}
</div>
);
}
const Icon = forwardRef(function IconComponent(
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
ref
) {
if (isCustomMode) {
return (
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
<div className="flex-grow font-medium" id="create-new-request-method">
{method}
</div>
<div>
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
</div>
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="font-medium px-2 w-full focus:bg-transparent"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={inputValue}
autoFocus
/>
</div>
);
});
}
const handleMethodSelect = (verb) => onMethodSelect(verb);
const Verb = ({ verb }) => {
return (
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleMethodSelect(verb);
}}
return (
<div ref={ref} className="flex pr-4 select-none">
<button
type="button"
className="cursor-pointer flex items-center text-left w-full"
>
{verb}
</div>
);
<span
className="font-medium px-2 truncate method-span"
id="create-new-request-method"
title={inputValue}
>
{inputValue}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
</div>
);
});
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
const [isCustomMode, setIsCustomMode] = useState(false);
const dropdownTippyRef = useRef();
const inputRef = useRef();
const blurInput = () => inputRef.current?.blur();
const handleInputChange = (e) => {
const val = e.target.value.toUpperCase();
onMethodSelect(val);
};
const handleDropdownSelect = (verb) => {
onMethodSelect(verb);
setIsCustomMode(false);
dropdownTippyRef.current?.hide();
blurInput();
};
const handleBlur = () => {
setIsCustomMode(false);
};
const handleAddCustomMethod = () => {
setIsCustomMode(true);
onMethodSelect('');
dropdownTippyRef.current?.hide();
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
};
const handleKeyDown = (e) => {
switch (e.key) {
case KEY.ESCAPE:
setIsCustomMode(false);
blurInput();
e.preventDefault();
e.stopPropagation();
return;
case KEY.ENTER:
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
setIsCustomMode(false);
blurInput();
return;
default:
return;
}
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer method-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-start">
<Verb verb="GET" />
<Verb verb="POST" />
<Verb verb="PUT" />
<Verb verb="DELETE" />
<Verb verb="PATCH" />
<Verb verb="OPTIONS" />
<Verb verb="HEAD" />
<div className="flex method-selector">
<Dropdown
onCreate={onDropdownCreate}
icon={
<Icon
isCustomMode={isCustomMode}
inputValue={method}
handleInputChange={handleInputChange}
handleBlur={handleBlur}
handleKeyDown={handleKeyDown}
inputRef={inputRef}
/>
}
placement="bottom-start"
>
<div>
{STANDARD_METHODS.map((verb) => (
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
))}
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
<span className="text-link">+ Add Custom</span>
</div>
</div>
</Dropdown>
</div>
</StyledWrapper>

View File

@@ -0,0 +1,248 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import HttpMethodSelector from './index';
import themes from 'themes/index';
import userEvent from '@testing-library/user-event';
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={themes.dark}>
{component}
</ThemeProvider>
);
};
describe('HttpMethodSelector', () => {
const mockOnMethodSelect = jest.fn();
beforeEach(() => {
mockOnMethodSelect.mockClear();
});
describe('Initial Render', () => {
it('should render with default GET method when no method prop is provided', () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const methodSpan = screen.getByText('GET');
expect(methodSpan).toBeInTheDocument();
expect(methodSpan).toHaveClass('method-span');
expect(methodSpan).toHaveAttribute('title', 'GET');
});
it('should render with a standard method when method prop is provided', () => {
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
const methodSpan = screen.getByText('POST');
expect(methodSpan).toBeInTheDocument();
expect(methodSpan).toHaveAttribute('title', 'POST');
});
it('should render with a custom method when method prop is provided', () => {
renderWithTheme(<HttpMethodSelector method="CUSTOM" onMethodSelect={mockOnMethodSelect} />);
const methodSpan = screen.getByText('CUSTOM');
expect(methodSpan).toBeInTheDocument();
expect(methodSpan).toHaveAttribute('title', 'CUSTOM');
});
});
describe('Dropdown Interaction', () => {
it('should display all standard HTTP methods in dropdown when clicked', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const standardMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
const dropdownItems = screen.getAllByText((content, element) => {
return element?.classList.contains('dropdown-item');
});
const renderedMethods = dropdownItems.map(item => item.textContent);
standardMethods.forEach(method => {
expect(renderedMethods).toContain(method);
});
});
});
it('should display "Add Custom" option in dropdown', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustomSpan = screen.getByText('+ Add Custom');
expect(addCustomSpan).toBeInTheDocument();
expect(addCustomSpan).toHaveClass('text-link');
});
});
it('should call onMethodSelect when a standard method is clicked', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const postMethod = screen.getByText('POST');
fireEvent.click(postMethod);
});
expect(mockOnMethodSelect).toHaveBeenCalledWith('POST');
});
});
describe('Custom Method Mode', () => {
it('should enter custom mode when "Add Custom" is clicked', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
// Should show input field
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveFocus();
});
});
it('should call onMethodSelect with uppercase value when typing in custom input', async () => {
const user = userEvent.setup();
// Create a wrapper component that manages the method state
const TestWrapper = () => {
const [method, setMethod] = React.useState('GET');
const handleMethodSelect = (newMethod) => {
mockOnMethodSelect(newMethod);
setMethod(newMethod);
};
return (
<HttpMethodSelector
method={method}
onMethodSelect={handleMethodSelect}
/>
);
};
renderWithTheme(<TestWrapper />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
const input = await screen.findByRole('textbox');
await user.type(input, 'custom');
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
expect(mockOnMethodSelect).toHaveBeenCalledWith('C');
expect(mockOnMethodSelect).toHaveBeenCalledWith('CU');
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUS');
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUST');
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTO');
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
expect(mockOnMethodSelect).toHaveBeenCalledTimes(7);
});
it('should exit custom mode and set method on Enter key', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
const input = await screen.findByRole('textbox');
fireEvent.change(input, { target: { value: 'CUSTOM' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
// Should exit custom mode
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
});
it('should set default method on Enter key with empty input', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
const input = await screen.findByRole('textbox');
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnMethodSelect).toHaveBeenCalledWith('GET');
});
it('should exit custom mode on Escape key and keep the custom method', async () => {
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
const input = await screen.findByRole('textbox');
fireEvent.change(input, { target: { value: 'CUSTOM' } });
fireEvent.keyDown(input, { key: 'Escape' });
// Should exit custom mode and onMethodSelect should be called with custom method
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
});
});
it('should exit custom mode on blur and keep the custom method', async () => {
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
const addCustom = screen.getByText('+ Add Custom');
fireEvent.click(addCustom);
});
const input = await screen.findByRole('textbox');
fireEvent.change(input, { target: { value: 'CUSTOM' } });
fireEvent.blur(input);
// Should exit custom mode and onMethodSelect should be called with custom method
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
});
});
});
});

View File

@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper className="flex items-center">
<div className="flex items-center h-full method-selector-container">
<div className="flex flex-1 items-center h-full method-selector-container">
{isGrpc ? (
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
@@ -112,6 +112,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip mr-3"
onClick={(e) => {
handleGenerateCode(e);
@@ -128,6 +129,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</span>
</div>
<div
title="Save Request"
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
@@ -145,7 +147,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
</div>
</div>
{generateCodeItemModalOpen && (

View File

@@ -63,7 +63,8 @@ const StyledWrapper = styled.div`
}
div.graphql-docs-explorer-container {
background: white;
background: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
outline: none;
box-shadow: rgb(0 0 0 / 15%) 0px 0px 8px;
position: absolute;
@@ -72,6 +73,14 @@ const StyledWrapper = styled.div`
width: 350px;
height: 100%;
.doc-explorer-contents,
.doc-explorer,
.search-box > input,
.search-box-clear {
background-color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
}
div.doc-explorer-title {
text-align: left;
}

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
@@ -69,9 +68,8 @@ const CollectionToolBar = ({ collection }) => {
</ToolHint>
</span>
<span>
<GlobalEnvironmentSelector />
<EnvironmentSelector collection={collection} />
</span>
<EnvironmentSelector collection={collection} />
</div>
</div>
</StyledWrapper>

View File

@@ -16,7 +16,7 @@ const StatusCode = ({ status }) => {
};
return (
<StyledWrapper className={getTabClassname(status)}>
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
{status} {statusCodePhraseMap[status]}
</StyledWrapper>
);

View File

@@ -60,8 +60,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
type: `collection-item-${collectionUid}`,
item,
type: 'collection-item',
item: { ...item, sourceCollectionUid: collectionUid },
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
@@ -92,10 +92,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem;
if (draggedItemUid === targetItemUid) return false;
// For cross-collection moves, we allow the drop
if (sourceCollectionUid !== collectionUid) {
return true;
}
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;
@@ -105,7 +110,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
accept: 'collection-item',
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;

View File

@@ -7,6 +7,7 @@ const Wrapper = styled.div`
user-select: none;
padding-left: 8px;
font-weight: 600;
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
.rotate-90 {
transform: rotateZ(90deg);
@@ -66,6 +67,7 @@ const Wrapper = styled.div`
}
&.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
@@ -95,15 +97,6 @@ const Wrapper = styled.div`
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -34,6 +34,7 @@ const Collection = ({ collection, searchText }) => {
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [dropType, setDropType] = useState(null);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
@@ -42,7 +43,7 @@ const Collection = ({ collection, searchText }) => {
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
const MenuIcon = forwardRef((_props, ref) => {
return (
<div ref={ref} className="pr-2">
<IconDots size={22} />
@@ -101,7 +102,7 @@ const Collection = ({ collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
const handleDoubleClick = (_event) => {
dispatch(makeTabPermanent({ uid: collection.uid }))
};
@@ -118,7 +119,7 @@ const Collection = ({ collection, searchText }) => {
e.preventDefault();
};
const handleRightClick = (event) => {
const handleRightClick = (_event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
let menuDropdownBehavior = 'show';
@@ -140,7 +141,7 @@ const Collection = ({ collection, searchText }) => {
};
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
return itemType === 'collection-item';
};
const [{ isDragging }, drag, dragPreview] = useDrag({
@@ -155,7 +156,17 @@ const Collection = ({ collection, searchText }) => {
});
const [{ isOver }, drop] = useDrop({
accept: ["collection", `collection-item-${collection.uid}`],
accept: ["collection", "collection-item"],
hover: (_draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
// For collection items, always show full highlight (inside drop)
setDropType('inside');
} else {
// For collections, show line indicator (adjacent drop)
setDropType('adjacent');
}
},
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
@@ -163,6 +174,7 @@ const Collection = ({ collection, searchText }) => {
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
setDropType(null);
},
canDrop: (draggedItem) => {
return draggedItem.uid !== collection.uid;
@@ -183,7 +195,8 @@ const Collection = ({ collection, searchText }) => {
}
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
'item-hovered': isOver,
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
'collection-focused-in-tab': isCollectionFocused
});
@@ -232,7 +245,7 @@ const Collection = ({ collection, searchText }) => {
onClick={handleCollectionCollapse}
onDoubleClick={handleCollectionDoubleClick}
/>
<div className="ml-1 w-full" id="sidebar-collection-name">
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
@@ -241,7 +254,7 @@ const Collection = ({ collection, searchText }) => {
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowNewRequestModal(true);
}}
@@ -250,7 +263,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowNewFolderModal(true);
}}
@@ -259,7 +272,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowCloneCollectionModalOpen(true);
}}
@@ -268,7 +281,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
handleRun();
@@ -278,7 +291,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowRenameCollectionModal(true);
}}
@@ -287,7 +300,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
setShowShareCollectionModal(true);
@@ -297,7 +310,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowRemoveCollectionModal(true);
}}
@@ -306,7 +319,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
onClick={(e) => {
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
viewCollectionSettings();
}}

View File

@@ -153,7 +153,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
]
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>

View File

@@ -48,7 +48,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
const onSubmit = () => formik.handleSubmit();
return (
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose}>
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose} dataTestId="import-collection-location-modal">
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="collectionName" className="block font-semibold">

View File

@@ -463,7 +463,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
</label>
<div className="flex items-center mt-2 ">
{formik.values.requestType !== 'grpc-request' ? (
<div className="flex items-center h-full method-selector-container">
<div className="flex items-center h-full method-selector-container w-1/5">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}

View File

@@ -78,7 +78,7 @@ const TitleBar = () => {
) : null}
<div className="flex items-center">
<button className="flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
<button className="bruno-logo flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
<span aria-hidden>
<Bruno width={30} />
</span>

View File

@@ -1,7 +1,8 @@
import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
@@ -159,6 +160,10 @@ class SingleLineEditor extends Component {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
}
addOverlay = (variables) => {

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconSettings, IconCookie, IconTool } from '@tabler/icons';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import { IconSettings, IconCookie, IconTool, IconSearch } from '@tabler/icons';
import Mousetrap from 'mousetrap';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import ToolHint from 'components/ToolHint';
import Preferences from 'components/Preferences';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import Cookies from 'components/Cookies';
import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
@@ -26,6 +28,13 @@ const StatusBar = () => {
dispatch(openConsole());
};
const openGlobalSearch = () => {
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
bindings.forEach((binding) => {
Mousetrap.trigger(binding);
});
};
return (
<StyledWrapper>
{preferencesOpen && (
@@ -93,6 +102,19 @@ const StatusBar = () => {
<div className="status-bar-section">
<div className="flex items-center gap-3">
<button
className="status-bar-button"
data-trigger="search"
onClick={openGlobalSearch}
tabIndex={0}
aria-label="Global Search"
>
<div className="console-button-content">
<IconSearch size={16} strokeWidth={1.5} aria-hidden="true" />
<span className="console-label">Search</span>
</div>
</button>
<button
className="status-bar-button"
data-trigger="cookies"

View File

@@ -24,6 +24,22 @@ const StyledWrapper = styled.div`
}
}
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 6px;
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 4px;
background: ${(props) =>
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
font-size: 0.75rem;
font-weight: 500;
font-family: inherit;
line-height: 1;
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -138,6 +138,12 @@ const Welcome = () => {
<span className="label ml-2">{t('COMMON.GITHUB')}</span>
</a>
</div>
<div className="mt-10 select-none">
{t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} <span className="keycap"></span>{' '}<span className="keycap">K</span>{' '}
{t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} <span className="keycap">Ctrl</span>{' '}<span className="keycap">K</span>{' '}
{t('WELCOME.GLOBAL_SEARCH_TIP_PART3')}
</div>
</div>
</StyledWrapper>
);

View File

@@ -15,6 +15,9 @@
"IMPORT_COLLECTION": "Import Collection",
"COLLECTION_IMPORT_SUCCESS": "Collection imported successfully",
"COLLECTION_IMPORT_ERROR": "An error occurred while importing the collection. Check the logs for more information.",
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection"
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection",
"GLOBAL_SEARCH_TIP_PART1": "Press",
"GLOBAL_SEARCH_TIP_PART2": "(mac) or",
"GLOBAL_SEARCH_TIP_PART3": "(windows) anytime to quickly search collections, folders, and requests"
}
}

View File

@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper';
import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
@@ -56,12 +57,31 @@ export default function Main() {
'is-dragging': isDragging
});
useEffect(() => {
if (!isElectron()) {
return;
}
const { ipcRenderer } = window;
const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => {
if (mainSectionRef.current) {
mainSectionRef.current.setAttribute('data-app-state', 'loaded');
}
});
return () => {
removeAppLoadedListener();
};
}, []);
return (
// <ErrorCapture>
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
data-app-state="loading"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
@@ -80,7 +100,7 @@ export default function Main() {
</section>
</StyledWrapper>
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
</div>

View File

@@ -6,6 +6,7 @@ import { useSelector, useDispatch } from 'react-redux';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import GlobalSearchModal from 'components/GlobalSearchModal';
import {
sendRequest,
saveRequest,
@@ -27,6 +28,7 @@ export const HotkeysProvider = (props) => {
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
const getCurrentCollection = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
@@ -149,6 +151,19 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// global search (ctrl/cmd + k)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
setShowGlobalSearchModal(true);
return false; // stop bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
};
}, []);
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
@@ -247,6 +262,9 @@ export const HotkeysProvider = (props) => {
{showNewRequestModal && (
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}
{showGlobalSearchModal && (
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>
);

View File

@@ -3,6 +3,7 @@ const KeyMapping = {
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
closeBruno: {

View File

@@ -726,11 +726,16 @@ export const handleCollectionItemDrop =
(dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
// if its withincollection set the source to current collection,
// if its cross collection set the source to the source collection
const sourceCollectionUid = draggedItem.sourceCollectionUid
const isCrossCollectionMove = sourceCollectionUid && collectionUid !== sourceCollectionUid;
const sourceCollection = isCrossCollectionMove ? findCollectionByUid(state.collections.collections, sourceCollectionUid) : collection;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectory = findParentItemInCollection(sourceCollection, draggedItemUid) || sourceCollection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const handleMoveToNewLocation = async ({

View File

@@ -2440,6 +2440,9 @@ export const collectionsSlice = createSlice({
if (type === 'testrun-ended') {
const info = collection.runnerResult.info;
info.status = 'ended';
if (action.payload.runCompletionTime) {
info.runCompletionTime = action.payload.runCompletionTime;
}
if (action.payload.statusText) {
info.statusText = action.payload.statusText;
}

View File

@@ -94,6 +94,7 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid: uid })))
.then(resolve)
.catch(reject);
});

View File

@@ -79,10 +79,16 @@ const darkTheme = {
color: 'rgb(204, 204, 204)',
iconColor: 'rgb(204, 204, 204)',
bg: 'rgb(48, 48, 49)',
hoverBg: '#185387',
hoverBg: '#6A6A6A29',
shadow: 'rgb(0 0 0 / 36%) 0px 2px 8px',
separator: '#444',
labelBg: '#4a4949'
labelBg: '#4a4949',
selectedBg: '#F59E0B14',
selectedColor: '#F59E0B',
mutedText: '#9B9B9B',
primaryText: '#D4D4D4',
secondaryText: '#9CA3AF',
headingText: '#FFFFFF'
},
request: {
@@ -94,7 +100,7 @@ const darkTheme = {
// customize these colors if needed
patch: '#d69956',
options: '#d69956',
head: '#d69956',
head: '#d69956'
},
grpc: '#6366f1'
},
@@ -134,6 +140,10 @@ const darkTheme = {
color: '#ccc'
}
}
},
graphqlDocsExplorer: {
bg: '#1e1e1e',
color: '#d4d4d4'
}
},
@@ -222,8 +232,8 @@ const darkTheme = {
tabs: {
active: {
color: '#ccc',
border: '#569cd6'
color: '#CCCCCC',
border: '#F59E0B'
}
},

View File

@@ -79,10 +79,16 @@ const lightTheme = {
color: 'rgb(48 48 48)',
iconColor: 'rgb(75, 85, 99)',
bg: '#fff',
hoverBg: '#e9e9e9',
hoverBg: '#e9ecef',
shadow: 'rgb(50 50 93 / 25%) 0px 6px 12px -2px, rgb(0 0 0 / 30%) 0px 3px 7px -3px',
separator: '#e7e7e7',
labelBg: '#f3f3f3'
labelBg: '#f3f3f3',
selectedBg: '#D977060F',
selectedColor: '#D97706',
mutedText: '#9B9B9B',
primaryText: '#343434',
secondaryText: '#6B7280',
headingText: '#343434'
},
request: {
@@ -131,6 +137,10 @@ const lightTheme = {
color: 'rgb(75 85 99)'
}
}
},
graphqlDocsExplorer: {
bg: '#fff',
color: 'rgb(52, 52, 52)'
}
},
@@ -223,8 +233,8 @@ const lightTheme = {
tabs: {
active: {
color: 'rgb(50, 46, 44)',
border: '#546de5'
color: '#343434',
border: '#D97706'
}
},

View File

@@ -5,7 +5,8 @@ import { useSelector } from 'react-redux';
* Contains all available beta feature keys
*/
export const BETA_FEATURES = Object.freeze({
GRPC: 'grpc'
GRPC: 'grpc',
NODE_VM: 'nodevm'
});
/**

View File

@@ -8,80 +8,6 @@ const pathFoundInVariables = (path, obj) => {
return value !== undefined;
};
/**
* Changes the render behaviour for a given CodeMirror editor.
* Replaces all **rendered** characters, not the actual value, with the provided character.
*/
export class MaskedEditor {
/**
* @param {import('codemirror').Editor} editor CodeMirror editor instance
* @param {string} maskChar Target character being applied to all content
*/
constructor(editor, maskChar) {
this.editor = editor;
this.maskChar = maskChar;
this.enabled = false;
}
/**
* Set and apply new masking character
*/
enable = () => {
this.enabled = true;
this.editor.setValue(this.editor.getValue());
this.editor.on('inputRead', this.maskContent);
this.update();
};
/** Disables masking of the editor field. */
disable = () => {
this.enabled = false;
this.editor.off('inputRead', this.maskContent);
this.editor.setValue(this.editor.getValue());
};
/** Updates the rendered content if enabled. */
update = () => {
if (this.enabled) this.maskContent();
};
/** Replaces all rendered characters, with the provided character. */
maskContent = () => {
const content = this.editor.getValue();
const lineCount = this.editor.lineCount();
if (lineCount === 0) return;
this.editor.operation(() => {
// Clear previous masked text
this.editor.getAllMarks().forEach((mark) => mark.clear());
// Apply new masked text
if (content.length <= 500) {
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
);
}
}
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}
});
};
}
/**
* Defines a custom CodeMirror mode for Bruno variables highlighting.
* This function creates a specialized mode that can highlight both Bruno template

View File

@@ -0,0 +1,444 @@
/**
* MaskedEditor - A robust, multiline-capable masking system for CodeMirror editors
*
* OVERVIEW:
* This implementation provides flawless masking of sensitive content with proper
* multiline support, error handling, and memory management. It replaces visible
* characters with mask characters while preserving the actual content.
*
* KEY FEATURES:
* - Zero race conditions with proper state management
* - Perfect performance for any content size (small or large)
* - Proper event handling with cleanup
* - Memory leak prevention with comprehensive cleanup
* - Cursor position preservation across multiline edits
* - Copy/paste compatibility with masked content
* - Full multiline support (JSON, XML, certificates, etc.)
* - State consistency across all operations
* - Error handling for problematic content
* - Performance optimization strategies
*
* MULTILINE SUPPORT:
* The MaskedEditor automatically handles multiline content efficiently:
* - Small content (< 1000 chars): Character-by-character masking
* - Large content (>= 1000 chars): Line-by-line masking for performance
* - Preserves line breaks and cursor position across line boundaries
* - Handles empty lines gracefully
*
* USAGE PATTERNS:
* 1. Create: new MaskedEditor(editor, maskChar)
* 2. Enable: maskedEditor.enable() - Start masking
* 3. Disable: maskedEditor.disable() - Show real content
* 4. Cleanup: maskedEditor.destroy() - CRITICAL for memory management
*
* MEMORY MANAGEMENT:
* Always call destroy() when done to prevent memory leaks:
* - Removes all event listeners
* - Clears all DOM marks and references
* - Cancels pending timeouts
* - Nullifies object references
*
* API METHODS:
* - enable(): Start masking the editor content
* - disable(): Stop masking and show real content
* - update(): Refresh masking (called automatically)
* - destroy(): Clean up all resources (CRITICAL!)
* - isEnabled(): Check if masking is currently active
* - getMaskChar(): Get current mask character
* - setMaskChar(char): Change mask character
*
* PERFORMANCE:
* - Uses debounced updates (10ms) to prevent excessive re-renders
* - Character-by-character masking for precise control on small content
* - Line-by-line masking for efficiency on large content
* - Efficient mark cleanup and reuse
* - Bounds checking to prevent errors
*
* ERROR HANDLING:
* - Try-catch blocks for problematic content
* - Bounds checking for cursor positions
* - Graceful degradation when marks fail
* - Memory cleanup even on errors
*/
export class MaskedEditor {
constructor(editor, maskChar = '*') {
this.editor = editor;
this.maskChar = maskChar;
this.enabled = false;
this.isProcessing = false;
this.marks = new Set();
this.originalCursor = null;
this.originalSelection = null;
// Bind methods to preserve context
this.handleInputRead = this.handleInputRead.bind(this);
this.handleBeforeChange = this.handleBeforeChange.bind(this);
this.handleCursorActivity = this.handleCursorActivity.bind(this);
this.handleSelectionChange = this.handleSelectionChange.bind(this);
}
/**
* Enable masking with perfect state management
*/
enable() {
if (this.enabled || this.isProcessing) return;
this.enabled = true;
this.isProcessing = true;
try {
// Store current cursor and selection
this.storeCursorState();
// Add event listeners with proper cleanup
this.editor.on('inputRead', this.handleInputRead);
this.editor.on('beforeChange', this.handleBeforeChange);
this.editor.on('cursorActivity', this.handleCursorActivity);
this.editor.on('selectionChange', this.handleSelectionChange);
// Apply masking
this.applyMasking();
// Restore cursor state
this.restoreCursorState();
} finally {
this.isProcessing = false;
}
}
/**
* Disable masking with complete cleanup
*/
disable() {
if (!this.enabled || this.isProcessing) return;
this.enabled = false;
this.isProcessing = true;
try {
// Store current state
this.storeCursorState();
// Remove event listeners
this.editor.off('inputRead', this.handleInputRead);
this.editor.off('beforeChange', this.handleBeforeChange);
this.editor.off('cursorActivity', this.handleCursorActivity);
this.editor.off('selectionChange', this.handleSelectionChange);
// Clear all marks
this.clearAllMarks();
// Refresh editor to show real content
this.editor.refresh();
// Restore cursor state
this.restoreCursorState();
} finally {
this.isProcessing = false;
}
}
/**
* Update masking (called when content changes)
*/
update() {
if (!this.enabled || this.isProcessing) return;
this.isProcessing = true;
try {
this.storeCursorState();
this.applyMasking();
this.restoreCursorState();
} finally {
this.isProcessing = false;
}
}
/**
* Handle multiline content changes efficiently
*/
handleMultilineChange() {
if (!this.enabled || this.isProcessing) return;
this.isProcessing = true;
try {
const content = this.editor.getValue();
const lineCount = this.editor.lineCount();
// For multiline content, use more efficient line-based masking
if (lineCount > 1) {
this.editor.operation(() => {
this.clearAllMarks();
this.applyLineMasking(lineCount);
});
} else {
this.update();
}
} finally {
this.isProcessing = false;
}
}
/**
* Store current cursor and selection state
*/
storeCursorState() {
this.originalCursor = this.editor.getCursor();
this.originalSelection = this.editor.getSelection();
}
/**
* Restore cursor and selection state
*/
restoreCursorState() {
if (this.originalCursor) {
// Ensure cursor position is within editor bounds
const lineCount = this.editor.lineCount();
const clampedLine = Math.min(this.originalCursor.line, Math.max(0, lineCount - 1));
const lineLength = this.editor.getLine(clampedLine).length;
const clampedCh = Math.min(this.originalCursor.ch, Math.max(0, lineLength));
this.editor.setCursor({ line: clampedLine, ch: clampedCh });
}
if (this.originalSelection) {
// For selection, just set cursor position to avoid selection issues with masked content
this.editor.setSelection(this.editor.getCursor(), this.editor.getCursor());
}
}
/**
* Handle input read events
*/
handleInputRead() {
if (!this.enabled || this.isProcessing) return;
// Debounce masking to prevent excessive updates
clearTimeout(this.maskTimeout);
this.maskTimeout = setTimeout(() => {
this.update();
}, 10);
}
/**
* Handle before change events to preserve cursor
*/
handleBeforeChange(cm, changeObj) {
if (!this.enabled || this.isProcessing) return;
// Store cursor position before change
this.storeCursorState();
}
/**
* Handle cursor activity
*/
handleCursorActivity() {
if (!this.enabled || this.isProcessing) return;
// Update cursor state
this.storeCursorState();
}
/**
* Handle selection changes
*/
handleSelectionChange() {
if (!this.enabled || this.isProcessing) return;
// Update selection state
this.storeCursorState();
}
/**
* Apply masking with perfect performance
*/
applyMasking() {
const content = this.editor.getValue();
const lineCount = this.editor.lineCount();
if (lineCount === 0) return;
this.editor.operation(() => {
// Clear existing marks
this.clearAllMarks();
// Apply new masking based on content size
if (content.length <= 1000) {
this.applyCharacterMasking(content);
} else {
this.applyLineMasking(lineCount);
}
});
}
/**
* Apply character-by-character masking for small content
*/
applyCharacterMasking(content) {
let currentLine = 0;
let currentCh = 0;
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (char === '\n') {
currentLine++;
currentCh = 0;
} else {
// Create masked node
const maskedNode = document.createTextNode(this.maskChar);
// Create mark with proper bounds checking
const fromPos = { line: currentLine, ch: currentCh };
const toPos = { line: currentLine, ch: currentCh + 1 };
// Ensure positions are within editor bounds
const lineCount = this.editor.lineCount();
if (currentLine < lineCount) {
const lineLength = this.editor.getLine(currentLine).length;
if (currentCh < lineLength) {
const mark = this.editor.markText(fromPos, toPos, {
replacedWith: maskedNode,
handleMouseEvents: true,
className: 'masked-character'
});
// Store mark for cleanup
this.marks.add(mark);
}
}
currentCh++;
}
}
}
/**
* Apply line-by-line masking for large content
*/
applyLineMasking(lineCount) {
for (let line = 0; line < lineCount; line++) {
try {
const lineLength = this.editor.getLine(line).length;
if (lineLength > 0) {
// Create masked node for entire line
const maskedNode = document.createTextNode(this.maskChar.repeat(lineLength));
// Create mark with proper bounds checking
const mark = this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{
replacedWith: maskedNode,
handleMouseEvents: false,
className: 'masked-line'
}
);
// Store mark for cleanup
this.marks.add(mark);
}
} catch (error) {
// Skip problematic lines to prevent crashes
console.warn(`Failed to mask line ${line}:`, error);
}
}
}
/**
* Clear all marks with proper cleanup
*/
clearAllMarks() {
this.marks.forEach(mark => {
try {
mark.clear();
} catch (e) {
// Ignore errors when clearing marks
}
});
this.marks.clear();
// Also clear any marks that might have been created outside our control
this.editor.getAllMarks().forEach(mark => {
try {
mark.clear();
} catch (e) {
// Ignore errors
}
});
}
/**
* Check if masking is enabled
*/
isEnabled() {
return this.enabled;
}
/**
* Get current mask character
*/
getMaskChar() {
return this.maskChar;
}
/**
* Set new mask character
*/
setMaskChar(newMaskChar) {
if (typeof newMaskChar !== 'string' || newMaskChar.length !== 1) {
throw new Error('Mask character must be a single character string');
}
this.maskChar = newMaskChar;
if (this.enabled) {
this.update();
}
}
/**
* Destroy the masked editor instance
*
* CRITICAL: Always call this method when done with the MaskedEditor
* to prevent memory leaks. This method:
* 1. Disables masking and removes event listeners
* 2. Clears all DOM marks and references
* 3. Cancels any pending timeouts
* 4. Nullifies all object references
*/
destroy() {
this.disable();
this.marks.clear();
this.originalCursor = null;
this.originalSelection = null;
if (this.maskTimeout) {
clearTimeout(this.maskTimeout);
this.maskTimeout = null;
}
}
}
/**
* Factory function to create a perfect masked editor
*/
export function createMaskedEditor(editor, maskChar = '*') {
return new MaskedEditor(editor, maskChar);
}
/**
* Utility function to check if an editor supports masking
*/
export function supportsMasking(editor) {
return editor &&
typeof editor.getValue === 'function' &&
typeof editor.markText === 'function' &&
typeof editor.operation === 'function';
}

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