Compare commits

..

8 Commits

Author SHA1 Message Date
Pragadesh-45
8d77d05b34 Revert "Feat/ Add Global Shortcuts for Zoom, Minimize, and Close on Windows (fixes: #4108) (#4110)" (#5504)
This reverts commit ce0fc08500.
2025-09-04 17:57:18 +05:30
lohit
518b1ed441 include oauth2 additional parameters in bruno collection exports (#5422) 2025-08-28 20:10:27 +05:30
Sanjai Kumar
c1aa682c03 fix: environment persistence and UI (#5404) 2025-08-28 20:10:19 +05:30
Martin Braconi
01275acc89 Update: readme.md installation instructions via Apt (#5411) 2025-08-28 20:10:11 +05:30
naman-bruno
c8f223a000 fix: large response 2025-08-28 20:09:52 +05:30
lohit
4202b48edd chore: eslint updates and fixes (#5402) 2025-08-28 20:09:25 +05:30
lohit
69891c0bc7 electron builder updates (#5425) 2025-08-28 20:09:16 +05:30
Bijin A B
76729519c6 Update contributing.md (#5407) 2025-08-28 20:09:08 +05:30
601 changed files with 4669 additions and 35575 deletions

View File

@@ -1,26 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,50 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,25 +0,0 @@
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

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

View File

@@ -1,91 +0,0 @@
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@v5
- 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@v5
- 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@v5
- 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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

View File

@@ -1,470 +0,0 @@
# 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,13 +74,10 @@ flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -59,13 +59,10 @@ snap install bruno
# Apt এর মাধ্যমে লিনাক্সে
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

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

View File

@@ -78,13 +78,10 @@ flatpak install com.usebruno.Bruno
# Auf Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -75,13 +75,10 @@ flatpak install com.usebruno.Bruno
# En Linux con Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -63,13 +63,10 @@ snap install bruno
# Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -75,14 +75,12 @@ flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
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
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
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
@@ -150,3 +148,4 @@ Scriptmania
लाइसेंस 📄
MIT

View File

@@ -59,13 +59,10 @@ snap install bruno
# Su Linux tramite Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -78,13 +78,10 @@ flatpak install com.usebruno.Bruno
# LinuxでAptを使ってインストール
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -77,14 +77,12 @@ flatpak install com.usebruno.Bruno
# Linux-ზე Apt-ის საშუალებით
sudo mkdir -p /etc/apt/keyrings
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
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
```
### პლატფორმებს შორის მუშაობა 🖥️

View File

@@ -59,13 +59,10 @@ snap install bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -61,14 +61,12 @@ flatpak install com.usebruno.Bruno
# Op Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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
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
```
### Draai op meerdere platformen 🖥️

View File

@@ -69,13 +69,10 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -76,13 +76,10 @@ flatpak install com.usebruno.Bruno
# No Linux via Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -59,13 +59,10 @@ snap install bruno
# Pe Linux cu Apt
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -63,13 +63,10 @@ snap install bruno
# Apt aracılığıyla Linux'ta
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -63,13 +63,10 @@ snap install bruno
# 在 Linux 上使用 Apt 安裝
sudo mkdir -p /etc/apt/keyrings
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 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 bruno
```

View File

@@ -0,0 +1,31 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -1,6 +1,6 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Collection Run', () => {
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);

View File

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,40 @@
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

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

View File

@@ -1,4 +1,3 @@
vars {
host: https://testbench-sanity.usebruno.com
token: secret
}
}

View File

@@ -1,5 +1,5 @@
meta {
name: api-setEnvVar-without-persist
name: ping2
type: http
seq: 1
}
@@ -11,5 +11,5 @@ get {
}
script:pre-request {
bru.setEnvVar("token", "secret");
bru.setEnvVar("persistent-env-test", "persistent-env-test-value");
}

View File

@@ -1,5 +1,5 @@
meta {
name: api-setEnvVar-with-persist
name: ping
type: http
seq: 1
}
@@ -11,5 +11,5 @@ get {
}
script:pre-request {
bru.setEnvVar("token", "secret", { persist: true });
bru.setEnvVar("persistent-env-test", "persistent-env-test-value", { persist: true });
}

View File

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

View File

@@ -1,13 +1,13 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Go to Support tab
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
// Verify all support links with correct URL
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
@@ -22,4 +22,6 @@ test('Should verify all support links with correct URL in preference > Support t
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

View File

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

1103
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,7 +16,6 @@
"@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",
@@ -93,7 +92,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.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,10 +67,10 @@ const RequestTab = ({ request, response }) => {
)}
</div>
{request?.data && (
{request?.body && (
<div className="section">
<h4>Request Body</h4>
<pre className="code-block">{formatBody(request.data)}</pre>
<pre className="code-block">{formatBody(request.body)}</pre>
</div>
)}
</div>
@@ -239,4 +239,4 @@ const RequestDetailsPanel = () => {
);
};
export default RequestDetailsPanel;
export default RequestDetailsPanel;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
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>
<ToolHint
anchorSelect="[data-tooltip-content]"
place="right"
positionStrategy="fixed"
tooltipStyle={{
maxWidth: '200px',
wordWrap: 'break-word'
}}
delayShow={1000}
>
<div>
{environments.map((env) => (
<div
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}
</div>
</ToolHint>
<div 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,230 +2,14 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
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;
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 15px;
.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;
display: block;
}
.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 {
width: ${(props) => props.width}px;
min-width: 12rem;
max-width: 650px !important;
min-height: 15.5rem;
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,274 +1,95 @@
import React, { useMemo, useState, useRef, forwardRef } from 'react';
import React, { useRef, forwardRef, useState } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
import EnvironmentListContent from './EnvironmentListContent/index';
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
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 ToolHint from 'components/ToolHint';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
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 [openSettingsModal, setOpenSettingsModal] = useState(false);
const { environments, activeEnvironmentUid } = collection;
const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
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));
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
return maxCharLength * 8;
}, [environments, globalEnvironments]);
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
const displayContent = hasAnyEnv ? (
<>
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
)}
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeGlobalEnvironment.name}
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeGlobalEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
</ToolHint>
</div>
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
);
return (
<div
ref={ref}
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
!hasAnyEnv ? 'no-environments' : ''
}`}
data-testid="environment-selector-trigger"
>
{displayContent}
<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>
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
});
return (
<StyledWrapper width={dropdownWidth}>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{/* Tab Headers */}
<div className="tab-header flex p-[0.75rem]">
{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>
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
dispatch(updateEnvironmentSettingsModalVisibility(true));
};
{/* 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}
/>
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">
<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>
</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>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{showGlobalSettings && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
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);
}}
/>
)}
{openSettingsModal && <EnvironmentSettings collection={collection} onClose={handleModalClose} />}
</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, onEnvironmentCreated }) => {
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
@@ -37,10 +37,6 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
.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 MultiLineEditor from 'components/MultiLineEditor/index';
import SingleLineEditor from 'components/SingleLineEditor';
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 className="environment-variables">
<table>
<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">
<MultiLineEditor
<SingleLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
@@ -253,8 +253,6 @@ 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>
@@ -262,15 +260,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} data-testid="save-env">
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit}>
<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} data-testid="reset-env">
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset}>
<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} data-testid="activate-env">
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate}>
<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, onEnvironmentCreated }) => {
const ImportEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -36,22 +36,17 @@ const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
})
.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} dataTestId="import-environment-modal">
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<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,8 +56,9 @@ const EnvironmentSettings = ({ collection, onClose }) => {
) : tab === 'import' ? (
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
<></>
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);

View File

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

View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,100 @@
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, onEnvironmentCreated }) => {
const CreateEnvironment = ({ onClose }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
@@ -39,10 +39,6 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
.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

@@ -1,9 +1,9 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -12,18 +12,11 @@ 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, collection }) => {
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
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,
@@ -41,7 +34,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
value: Yup.string().trim().nullable()
})
),
onSubmit: (values) => {
@@ -100,7 +93,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' });
}
@@ -136,7 +129,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
/>
</td>
<td>
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
@@ -152,32 +145,16 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<MultiLineEditor
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.name}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.name}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
<input
@@ -202,7 +179,6 @@ 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>
@@ -210,10 +186,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
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, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -37,7 +37,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
</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, collection }) => {
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
@@ -143,7 +143,6 @@ 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, onEnvironmentCreated }) => {
const ImportEnvironment = ({ onClose }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -37,22 +37,17 @@ const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
})
.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} dataTestId="import-global-environment-modal">
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<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, collection, activeGlobalEnvironmentUid, onClose }) => {
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
@@ -53,8 +53,9 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
<></>
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);
@@ -69,7 +70,6 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
/>
</Modal>
);

View File

@@ -1,361 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,516 +0,0 @@
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

@@ -1,94 +0,0 @@
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

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

View File

@@ -1,28 +0,0 @@
import React from 'react';
const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar ${className}`}
{...rest}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M9 4l0 16" />
{!collapsed && (
<rect x="4.6" y="4.6" width="4.8" height="14.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconSidebarToggle;

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,7 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header">
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel && !hideClose ? (
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
×
</div>
) : null}
@@ -72,8 +71,7 @@ const Modal = ({
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500,
dataTestId
closeModalFadeTimeout = 500
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -122,7 +120,6 @@ const Modal = ({
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
data-testid={dataTestId}
>
<ModalHeader
title={title}

View File

@@ -6,27 +6,12 @@ const StyledWrapper = styled.div`
max-height: 200px;
overflow: auto;
&.read-only {
.CodeMirror .CodeMirror-lines {
cursor: not-allowed !important;
user-select: none !important;
-webkit-user-select: none !important;
-ms-user-select: none !important;
}
.CodeMirror-line {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
.CodeMirror {
background: transparent;
height: fit-content;
font-size: 14px;
line-height: 30px;
display: flex;
flex-direction: column;
max-height: 200px;
overflow: hidden;
pre.CodeMirror-placeholder {
color: ${(props) => props.theme.text};
@@ -34,10 +19,18 @@ 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 !important;
display: none;
}
.CodeMirror-lines {

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